From 9c743e24403b94c55023a314324f1e1f5f2776b9 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sat, 27 Sep 2025 11:56:10 +1000 Subject: [PATCH 01/47] cleanup `upload_video` arguments --- apps/desktop/src-tauri/src/lib.rs | 6 +- apps/desktop/src-tauri/src/recording.rs | 41 +++++++------ apps/desktop/src-tauri/src/upload.rs | 82 +++---------------------- 3 files changed, 34 insertions(+), 95 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9b3936ca8..b13fd120d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1111,9 +1111,9 @@ async fn upload_exported_video( &app, upload_id.clone(), output_path, - Some(s3_config), - Some(meta.project_path.join("screenshots/display.jpg")), - Some(metadata), + meta.project_path.join("screenshots/display.jpg"), + s3_config, + metadata, Some(channel.clone()), ) .await diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 217f14e08..c553bd6a6 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -803,26 +803,29 @@ async fn handle_recording_finish( } } } else { - let meta = build_video_meta(&output_path).ok(); - // The upload_video function handles screenshot upload, so we can pass it along - match upload_video( - &app, - video_upload_info.id.clone(), - output_path, - Some(video_upload_info.config.clone()), - Some(display_screenshot.clone()), - meta, - None, - ) - .await + if let Ok(meta) = build_video_meta(&output_path) + .map_err(|err| error!("Error getting video metdata: {}", err)) { - Ok(_) => { - info!( - "Final video upload with screenshot completed successfully" - ) - } - Err(e) => { - error!("Error in final upload with screenshot: {}", e) + // The upload_video function handles screenshot upload, so we can pass it along + match upload_video( + &app, + video_upload_info.id.clone(), + output_path, + display_screenshot.clone(), + video_upload_info.config.clone(), + meta, + None, + ) + .await + { + Ok(_) => { + info!( + "Final video upload with screenshot completed successfully" + ) + } + Err(e) => { + error!("Error in final upload with screenshot: {}", e) + } } } } diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index f6e923697..ee28a3108 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -34,53 +34,10 @@ pub struct CreateErrorResponse { error: String, } -// fn deserialize_empty_object_as_string<'de, D>(deserializer: D) -> Result -// where -// D: Deserializer<'de>, -// { -// struct StringOrObject; - -// impl<'de> de::Visitor<'de> for StringOrObject { -// type Value = String; - -// fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { -// formatter.write_str("string or empty object") -// } - -// fn visit_str(self, value: &str) -> Result -// where -// E: de::Error, -// { -// Ok(value.to_string()) -// } - -// fn visit_string(self, value: String) -> Result -// where -// E: de::Error, -// { -// Ok(value) -// } - -// fn visit_map(self, _map: M) -> Result -// where -// M: de::MapAccess<'de>, -// { -// // Return empty string for empty objects -// Ok(String::new()) -// } -// } - -// deserializer.deserialize_any(StringOrObject) -// } - impl S3UploadMeta { pub fn id(&self) -> &str { &self.id } - - // pub fn new(id: String) -> Self { - // Self { id } - // } } #[derive(Serialize, Debug, Clone)] @@ -106,12 +63,6 @@ pub struct UploadedImage { pub id: String, } -// pub struct UploadedAudio { -// pub link: String, -// pub id: String, -// pub config: S3UploadMeta, -// } - pub struct UploadProgressUpdater { video_state: Option, app: AppHandle, @@ -212,18 +163,14 @@ pub async fn upload_video( app: &AppHandle, video_id: String, file_path: PathBuf, - existing_config: Option, - screenshot_path: Option, - meta: Option, + screenshot_path: PathBuf, + s3_config: S3UploadMeta, + meta: S3VideoMeta, channel: Option>, ) -> Result { - println!("Uploading video {video_id}..."); + info!("Uploading video {video_id}..."); let client = reqwest::Client::new(); - let s3_config = match existing_config { - Some(config) => config, - None => create_or_get_video(app, false, Some(video_id.clone()), None, meta).await?, - }; let presigned_put = presigned_s3_put( app, @@ -231,7 +178,7 @@ pub async fn upload_video( video_id: video_id.clone(), subpath: "result.mp4".to_string(), method: PresignedS3PutRequestMethod::Put, - meta: Some(build_video_meta(&file_path)?), + meta: Some(meta), }, ) .await?; @@ -270,12 +217,7 @@ pub async fn upload_video( } }); - let screenshot_upload = match screenshot_path { - Some(screenshot_path) if screenshot_path.exists() => { - Some(prepare_screenshot_upload(app, &s3_config, screenshot_path)) - } - _ => None, - }; + let screenshot_upload = prepare_screenshot_upload(app, &s3_config, screenshot_path); let video_upload = client .put(presigned_put) @@ -284,21 +226,15 @@ pub async fn upload_video( let (video_upload, screenshot_result): ( Result, - Option>, - ) = tokio::join!(video_upload.send(), async { - if let Some(screenshot_req) = screenshot_upload { - Some(screenshot_req.await) - } else { - None - } - }); + Result, + ) = tokio::join!(video_upload.send(), screenshot_upload); let response = video_upload.map_err(|e| format!("Failed to send upload file request: {e}"))?; if response.status().is_success() { println!("Video uploaded successfully"); - if let Some(Ok(screenshot_response)) = screenshot_result { + if let Ok(screenshot_response) = screenshot_result { if screenshot_response.status().is_success() { println!("Screenshot uploaded successfully"); } else { From a0d7028ab4604fda663e37dec45636752cdddeef Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 28 Sep 2025 08:29:59 +1000 Subject: [PATCH 02/47] wip --- Cargo.lock | 1 + apps/desktop/src-tauri/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6f7390457..69f8faab6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1218,6 +1218,7 @@ dependencies = [ "tauri-plugin-window-state", "tauri-specta", "tempfile", + "thiserror 1.0.69", "tokio", "tokio-stream", "tokio-util", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 2b53cdb32..13562cd21 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -103,6 +103,7 @@ wgpu.workspace = true bytemuck = "1.23.1" kameo = "0.17.2" tauri-plugin-sentry = "0.5.0" +thiserror.workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-graphics = "0.24.0" From 8a1d081016aff47c71b8fff54aa9ea77a84ee661 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 28 Sep 2025 12:09:40 +1000 Subject: [PATCH 03/47] wip --- Cargo.lock | 1 + apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/recording.rs | 46 ++-- apps/desktop/src-tauri/src/upload.rs | 317 +++++++++++------------- 4 files changed, 175 insertions(+), 190 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69f8faab6..29be7a43c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1146,6 +1146,7 @@ dependencies = [ "axum", "base64 0.22.1", "bytemuck", + "bytes", "cap-audio", "cap-camera", "cap-editor", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 13562cd21..8b7e31f59 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -104,6 +104,7 @@ bytemuck = "1.23.1" kameo = "0.17.2" tauri-plugin-sentry = "0.5.0" thiserror.workspace = true +bytes = "1.10.1" [target.'cfg(target_os = "macos")'.dependencies] core-graphics = "0.24.0" diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index c553bd6a6..e8b15e57c 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -30,10 +30,7 @@ use crate::{ general_settings::{GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour}, open_external_link, presets::PresetsStore, - upload::{ - InstantMultipartUpload, build_video_meta, create_or_get_video, prepare_screenshot_upload, - upload_video, - }, + upload::{InstantMultipartUpload, build_video_meta, create_or_get_video, upload_video}, web_api::ManagerExt, windows::{CapWindowId, ShowCapWindow}, }; @@ -782,26 +779,27 @@ async fn handle_recording_finish( let _ = screenshot_task.await; if video_upload_succeeded { - let resp = prepare_screenshot_upload( - &app, - &video_upload_info.config.clone(), - display_screenshot, - ) - .await; - - match resp { - Ok(r) - if r.status().as_u16() >= 200 && r.status().as_u16() < 300 => - { - info!("Screenshot uploaded successfully"); - } - Ok(r) => { - error!("Failed to upload screenshot: {}", r.status()); - } - Err(e) => { - error!("Failed to upload screenshot: {e}"); - } - } + // let resp = prepare_screenshot_upload( + // &app, + // &video_upload_info.config.clone(), + // display_screenshot, + // ) + // .await; + + // match resp { + // Ok(r) + // if r.status().as_u16() >= 200 && r.status().as_u16() < 300 => + // { + // info!("Screenshot uploaded successfully"); + // } + // Ok(r) => { + // error!("Failed to upload screenshot: {}", r.status()); + // } + // Err(e) => { + // error!("Failed to upload screenshot: {e}"); + // } + // } + todo!(); } else { if let Ok(meta) = build_video_meta(&output_path) .map_err(|err| error!("Error getting video metdata: {}", err)) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index ee28a3108..802330653 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -2,10 +2,12 @@ use crate::web_api::ManagerExt; use crate::{UploadProgress, VideoUploadInfo}; +use axum::body::Body; +use bytes::Bytes; use cap_utils::spawn_actor; use ffmpeg::ffi::AV_TIME_BASE; use flume::Receiver; -use futures::StreamExt; +use futures::{Stream, StreamExt, stream}; use image::ImageReader; use image::codecs::jpeg::JpegEncoder; use reqwest::StatusCode; @@ -13,15 +15,21 @@ use reqwest::header::CONTENT_LENGTH; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; +use std::error::Error; +use std::io; +use std::path::Path; use std::{ path::PathBuf, time::{Duration, Instant}, }; use tauri::{AppHandle, ipc::Channel}; use tauri_plugin_clipboard_manager::ClipboardExt; +use tokio::fs::File; use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tokio::sync::watch; use tokio::task::{self, JoinHandle}; use tokio::time::sleep; +use tokio_util::io::ReaderStream; use tracing::{debug, error, info, trace, warn}; #[derive(Deserialize, Serialize, Clone, Type, Debug)] @@ -159,6 +167,24 @@ impl UploadProgressUpdater { } } +#[derive(Default, Debug)] +pub enum UploadPartProgress { + #[default] + Presigning, + Uploading { + uploaded: i64, + total: i64, + }, + Done, + Error(String), +} + +#[derive(Default, Debug)] +pub struct UploadVideoProgress { + video: UploadPartProgress, + thumbnail: UploadPartProgress, +} + pub async fn upload_video( app: &AppHandle, video_id: String, @@ -166,24 +192,81 @@ pub async fn upload_video( screenshot_path: PathBuf, s3_config: S3UploadMeta, meta: S3VideoMeta, + // TODO: Hook this back up? channel: Option>, ) -> Result { - info!("Uploading video {video_id}..."); + let (tx, mut rx) = watch::channel(UploadVideoProgress::default()); - let client = reqwest::Client::new(); + // TODO: Hook this up properly + tokio::spawn(async move { + loop { + println!("STATUS: {:?}", *rx.borrow_and_update()); + if rx.changed().await.is_err() { + break; + } + } + }); + + info!("Uploading video {video_id}..."); - let presigned_put = presigned_s3_put( + let (stream, total_size) = file_reader_stream(file_path).await?; + let video_upload_fut = do_presigned_upload( app, + stream, + total_size, PresignedS3PutRequest { video_id: video_id.clone(), subpath: "result.mp4".to_string(), method: PresignedS3PutRequestMethod::Put, meta: Some(meta), }, - ) - .await?; + { + let tx = tx.clone(); + move |p| tx.send_modify(|v| v.video = p) + }, + ); + + let (stream, total_size) = bytes_into_stream(compress_image(screenshot_path).await?); + let thumbnail_upload_fut = do_presigned_upload( + app, + stream, + total_size, + PresignedS3PutRequest { + video_id: s3_config.id.clone(), + subpath: "screenshot/screen-capture.jpg".to_string(), + method: PresignedS3PutRequestMethod::Put, + meta: None, + }, + { + let tx = tx.clone(); + move |p| tx.send_modify(|v| v.thumbnail = p) + }, + ); + + let (video_result, thumbnail_result): (Result<(), String>, Result<(), String>) = + tokio::join!(video_upload_fut, thumbnail_upload_fut); + + if let Some(err) = video_result.err() { + error!("Failed to upload video for {video_id}: {err}"); + tx.send_modify(|v| v.video = UploadPartProgress::Error(err.clone())); + return Err(err); // TODO: Maybe don't do this + } + if let Some(err) = thumbnail_result.err() { + error!("Failed to upload thumbnail for video {video_id}: {err}"); + tx.send_modify(|v| v.thumbnail = UploadPartProgress::Error(err.clone())); + return Err(err); // TODO: Maybe don't do this + } + + Ok(UploadedVideo { + link: app.make_app_url(format!("/s/{}", &s3_config.id)).await, + id: s3_config.id.clone(), + config: s3_config, + }) +} - let file = tokio::fs::File::open(&file_path) +/// Open a file and construct a stream to it. +async fn file_reader_stream(path: impl AsRef) -> Result<(ReaderStream, u64), String> { + let file = File::open(path) .await .map_err(|e| format!("Failed to open file: {e}"))?; @@ -192,79 +275,52 @@ pub async fn upload_video( .await .map_err(|e| format!("Failed to get file metadata: {e}"))?; - let total_size = metadata.len(); - - let reader_stream = tokio_util::io::ReaderStream::new(file); + Ok((ReaderStream::new(file), metadata.len())) +} - let mut bytes_uploaded = 0u64; - let mut progress = UploadProgressUpdater::new(app.clone(), video_id); +async fn do_presigned_upload( + app: &AppHandle, + stream: impl Stream> + Send + 'static, + total_size: u64, + request: PresignedS3PutRequest, + mut set_progress: impl FnMut(UploadPartProgress) + Send + 'static, +) -> Result<(), String> { + set_progress(UploadPartProgress::Presigning); + let client = reqwest::Client::new(); + let presigned_url = presigned_s3_put(app, request).await?; - let progress_stream = reader_stream.inspect(move |chunk| { + set_progress(UploadPartProgress::Uploading { + uploaded: 0, + total: 0, + }); + let mut uploaded = 0i64; + let total = total_size as i64; + let stream = stream.inspect(move |chunk| { if let Ok(chunk) = chunk { - bytes_uploaded += chunk.len() as u64; - } - - if bytes_uploaded > 0 { - if let Some(channel) = &channel { - channel - .send(UploadProgress { - progress: bytes_uploaded as f64 / total_size as f64, - }) - .ok(); - } - - progress.update(bytes_uploaded, total_size); + uploaded += chunk.len() as i64; + set_progress(UploadPartProgress::Uploading { uploaded, total }); } }); - let screenshot_upload = prepare_screenshot_upload(app, &s3_config, screenshot_path); - - let video_upload = client - .put(presigned_put) - .body(reqwest::Body::wrap_stream(progress_stream)) - .header(CONTENT_LENGTH, metadata.len()); - - let (video_upload, screenshot_result): ( - Result, - Result, - ) = tokio::join!(video_upload.send(), screenshot_upload); - - let response = video_upload.map_err(|e| format!("Failed to send upload file request: {e}"))?; - - if response.status().is_success() { - println!("Video uploaded successfully"); - - if let Ok(screenshot_response) = screenshot_result { - if screenshot_response.status().is_success() { - println!("Screenshot uploaded successfully"); - } else { - println!( - "Failed to upload screenshot: {}", - screenshot_response.status() - ); - } - } + let response = client + .put(presigned_url) + .body(reqwest::Body::wrap_stream(stream)) + .send() + .await + .map_err(|e| format!("Failed to upload file: {e}"))?; - return Ok(UploadedVideo { - link: app.make_app_url(format!("/s/{}", &s3_config.id)).await, - id: s3_config.id.clone(), - config: s3_config, - }); + if !response.status().is_success() { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!( + "Failed to upload file. Status: {status}. Body: {error_body}" + )); } - let status = response.status(); - let error_body = response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - tracing::error!( - "Failed to upload file. Status: {}. Body: {}", - status, - error_body - ); - Err(format!( - "Failed to upload file. Status: {status}. Body: {error_body}" - )) + Ok(()) } pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result { @@ -274,53 +330,29 @@ pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result".to_string()); - tracing::error!( - "Failed to upload file. Status: {}. Body: {}", - status, - error_body - ); - Err(format!( - "Failed to upload file. Status: {status}. Body: {error_body}" - )) + Ok(UploadedImage { + link: app.make_app_url(format!("/s/{}", &s3_config.id)).await, + id: s3_config.id, + }) } pub async fn create_or_get_video( @@ -461,60 +493,6 @@ pub fn build_video_meta(path: &PathBuf) -> Result { }) } -// fn build_audio_upload_body( -// path: &PathBuf, -// base: S3UploadBody, -// ) -> Result { -// let input = -// ffmpeg::format::input(path).map_err(|e| format!("Failed to read input file: {e}"))?; -// let stream = input -// .streams() -// .best(ffmpeg::media::Type::Audio) -// .ok_or_else(|| "Failed to find appropriate audio stream in file".to_string())?; - -// let duration_millis = input.duration() as f64 / 1000.; - -// let codec = ffmpeg::codec::context::Context::from_parameters(stream.parameters()) -// .map_err(|e| format!("Unable to read audio codec information: {e}"))?; -// let codec_name = codec.id(); - -// let is_mp3 = path.extension().is_some_and(|ext| ext == "mp3"); - -// Ok(S3AudioUploadBody { -// base, -// duration: duration_millis.to_string(), -// audio_codec: format!("{codec_name:?}").replace("Id::", "").to_lowercase(), -// is_mp3, -// }) -// } - -pub async fn prepare_screenshot_upload( - app: &AppHandle, - s3_config: &S3UploadMeta, - screenshot_path: PathBuf, -) -> Result { - let presigned_put = presigned_s3_put( - app, - PresignedS3PutRequest { - video_id: s3_config.id.clone(), - subpath: "screenshot/screen-capture.jpg".to_string(), - method: PresignedS3PutRequestMethod::Put, - meta: None, - }, - ) - .await?; - - let compressed_image = compress_image(screenshot_path).await?; - - reqwest::Client::new() - .put(presigned_put) - .header(CONTENT_LENGTH, compressed_image.len()) - .body(compressed_image) - .send() - .await - .map_err(|e| format!("Error uploading screenshot: {e}")) -} - async fn compress_image(path: PathBuf) -> Result, String> { task::spawn_blocking(move || { let img = ImageReader::open(&path) @@ -522,18 +500,19 @@ async fn compress_image(path: PathBuf) -> Result, String> { .decode() .map_err(|e| format!("Failed to decode image: {e}"))?; - let new_width = img.width() / 2; - let new_height = img.height() / 2; - - let resized_img = img.resize(new_width, new_height, image::imageops::FilterType::Nearest); + let resized_img = img.resize( + img.width() / 2, + img.height() / 2, + image::imageops::FilterType::Nearest, + ); let mut buffer = Vec::new(); let mut encoder = JpegEncoder::new_with_quality(&mut buffer, 30); encoder .encode( resized_img.as_bytes(), - new_width, - new_height, + resized_img.width(), + resized_img.height(), resized_img.color().into(), ) .map_err(|e| format!("Failed to compress image: {e}"))?; @@ -544,6 +523,12 @@ async fn compress_image(path: PathBuf) -> Result, String> { .map_err(|e| format!("Failed to compress image: {e}"))? } +fn bytes_into_stream(bytes: Vec) -> (impl Stream>, u64) { + let total_size = bytes.len(); + let stream = stream::once(async move { Ok::<_, std::io::Error>(bytes::Bytes::from(bytes)) }); + (stream, total_size as u64) +} + // a typical recommended chunk size is 5MB (AWS min part size). const CHUNK_SIZE: u64 = 5 * 1024 * 1024; // 5MB // const MIN_PART_SIZE: u64 = 5 * 1024 * 1024; // For non-final parts From 9d14a0da8a785dfdcc546237c1267bcbcf4bd6be Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 28 Sep 2025 12:20:03 +1000 Subject: [PATCH 04/47] make `progress_upload` required for instant mode --- apps/desktop/src-tauri/src/recording.rs | 155 ++++++++++++------------ 1 file changed, 76 insertions(+), 79 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index e8b15e57c..8333c449a 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -39,7 +39,7 @@ pub enum InProgressRecording { Instant { target_name: String, handle: instant_recording::ActorHandle, - progressive_upload: Option, + progressive_upload: InstantMultipartUpload, video_upload_info: VideoUploadInfo, inputs: StartRecordingInputs, recording_dir: PathBuf, @@ -132,7 +132,7 @@ pub enum CompletedRecording { Instant { recording: instant_recording::CompletedRecording, target_name: String, - progressive_upload: Option, + progressive_upload: InstantMultipartUpload, video_upload_info: VideoUploadInfo, }, Studio { @@ -339,22 +339,11 @@ pub async fn start_recording( } let (finish_upload_tx, finish_upload_rx) = flume::bounded(1); - let progressive_upload = video_upload_info - .as_ref() - .filter(|_| matches!(inputs.mode, RecordingMode::Instant)) - .map(|video_upload_info| { - InstantMultipartUpload::spawn( - app.clone(), - id.clone(), - recording_dir.join("content/output.mp4"), - video_upload_info.clone(), - Some(finish_upload_rx), - ) - }); debug!("spawning start_recording actor"); // done in spawn to catch panics just in case + let app_handle = app.clone(); let spawn_actor_res = async { spawn_actor({ let state_mtx = Arc::clone(&state_mtx); @@ -417,6 +406,14 @@ pub async fn start_recording( return Err("Video upload info not found".to_string()); }; + let progressive_upload = InstantMultipartUpload::spawn( + app_handle, + id.clone(), + recording_dir.join("content/output.mp4"), + video_upload_info.clone(), + Some(finish_upload_rx), + ); + let mut builder = instant_recording::Actor::builder( recording_dir.clone(), inputs.capture_target.clone(), @@ -757,77 +754,77 @@ async fn handle_recording_finish( let video_upload_info = video_upload_info.clone(); async move { - if let Some(progressive_upload) = progressive_upload { - let video_upload_succeeded = match progressive_upload - .handle - .await - .map_err(|e| e.to_string()) - .and_then(|r| r) + // if let Some(progressive_upload) = progressive_upload { + let video_upload_succeeded = match progressive_upload + .handle + .await + .map_err(|e| e.to_string()) + .and_then(|r| r) + { + Ok(()) => { + info!( + "Not attempting instant recording upload as progressive upload succeeded" + ); + true + } + Err(e) => { + error!("Progressive upload failed: {}", e); + false + } + }; + + let _ = screenshot_task.await; + + if video_upload_succeeded { + // let resp = prepare_screenshot_upload( + // &app, + // &video_upload_info.config.clone(), + // display_screenshot, + // ) + // .await; + + // match resp { + // Ok(r) + // if r.status().as_u16() >= 200 && r.status().as_u16() < 300 => + // { + // info!("Screenshot uploaded successfully"); + // } + // Ok(r) => { + // error!("Failed to upload screenshot: {}", r.status()); + // } + // Err(e) => { + // error!("Failed to upload screenshot: {e}"); + // } + // } + todo!(); + } else { + if let Ok(meta) = build_video_meta(&output_path) + .map_err(|err| error!("Error getting video metdata: {}", err)) { - Ok(()) => { - info!( - "Not attempting instant recording upload as progressive upload succeeded" - ); - true - } - Err(e) => { - error!("Progressive upload failed: {}", e); - false - } - }; - - let _ = screenshot_task.await; - - if video_upload_succeeded { - // let resp = prepare_screenshot_upload( - // &app, - // &video_upload_info.config.clone(), - // display_screenshot, - // ) - // .await; - - // match resp { - // Ok(r) - // if r.status().as_u16() >= 200 && r.status().as_u16() < 300 => - // { - // info!("Screenshot uploaded successfully"); - // } - // Ok(r) => { - // error!("Failed to upload screenshot: {}", r.status()); - // } - // Err(e) => { - // error!("Failed to upload screenshot: {e}"); - // } - // } - todo!(); - } else { - if let Ok(meta) = build_video_meta(&output_path) - .map_err(|err| error!("Error getting video metdata: {}", err)) + // The upload_video function handles screenshot upload, so we can pass it along + match upload_video( + &app, + video_upload_info.id.clone(), + output_path, + display_screenshot.clone(), + video_upload_info.config.clone(), + meta, + None, + ) + .await { - // The upload_video function handles screenshot upload, so we can pass it along - match upload_video( - &app, - video_upload_info.id.clone(), - output_path, - display_screenshot.clone(), - video_upload_info.config.clone(), - meta, - None, - ) - .await - { - Ok(_) => { - info!( - "Final video upload with screenshot completed successfully" - ) - } - Err(e) => { - error!("Error in final upload with screenshot: {}", e) - } + Ok(_) => { + info!( + "Final video upload with screenshot completed successfully" + ) + } + Err(e) => { + error!("Error in final upload with screenshot: {}", e) } } } } + // } } }); From ea2bd209c46237aff2f4a7a4ce16a7dc3f637252 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 28 Sep 2025 12:28:12 +1000 Subject: [PATCH 05/47] fix uploading of thumbnails --- apps/desktop/src-tauri/src/recording.rs | 53 ++++++++++++++----------- apps/desktop/src-tauri/src/upload.rs | 16 ++++---- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 8333c449a..009ada021 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -30,7 +30,11 @@ use crate::{ general_settings::{GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour}, open_external_link, presets::PresetsStore, - upload::{InstantMultipartUpload, build_video_meta, create_or_get_video, upload_video}, + upload::{ + InstantMultipartUpload, PresignedS3PutRequest, PresignedS3PutRequestMethod, + build_video_meta, bytes_into_stream, compress_image, create_or_get_video, + do_presigned_upload, upload_video, + }, web_api::ManagerExt, windows::{CapWindowId, ShowCapWindow}, }; @@ -754,7 +758,6 @@ async fn handle_recording_finish( let video_upload_info = video_upload_info.clone(); async move { - // if let Some(progressive_upload) = progressive_upload { let video_upload_succeeded = match progressive_upload .handle .await @@ -776,27 +779,30 @@ async fn handle_recording_finish( let _ = screenshot_task.await; if video_upload_succeeded { - // let resp = prepare_screenshot_upload( - // &app, - // &video_upload_info.config.clone(), - // display_screenshot, - // ) - // .await; - - // match resp { - // Ok(r) - // if r.status().as_u16() >= 200 && r.status().as_u16() < 300 => - // { - // info!("Screenshot uploaded successfully"); - // } - // Ok(r) => { - // error!("Failed to upload screenshot: {}", r.status()); - // } - // Err(e) => { - // error!("Failed to upload screenshot: {e}"); - // } - // } - todo!(); + if let Ok(result) = + compress_image(display_screenshot).await + .map_err(|err| + error!("Error compressing thumbnail for instant mode progressive upload: {err}") + ) { + let (stream, total_size) = bytes_into_stream(result); + do_presigned_upload( + &app, + stream, + total_size, + crate::upload::PresignedS3PutRequest { + video_id: video_upload_info.id.clone(), + subpath: "screenshot/screen-capture.jpg".to_string(), + method: PresignedS3PutRequestMethod::Put, + meta: None, + }, + |p| {} // TODO: Progress reporting + ) + .await + .map_err(|err| { + error!("Error updating thumbnail for instant mode progressive upload: {err}") + }) + .ok(); + } } else { if let Ok(meta) = build_video_meta(&output_path) .map_err(|err| error!("Error getting video metdata: {}", err)) @@ -824,7 +830,6 @@ async fn handle_recording_finish( } } } - // } } }); diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 802330653..af90ab18b 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -278,7 +278,7 @@ async fn file_reader_stream(path: impl AsRef) -> Result<(ReaderStream> + Send + 'static, total_size: u64, @@ -422,11 +422,11 @@ pub async fn create_or_get_video( #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PresignedS3PutRequest { - video_id: String, - subpath: String, - method: PresignedS3PutRequestMethod, + pub video_id: String, + pub subpath: String, + pub method: PresignedS3PutRequestMethod, #[serde(flatten)] - meta: Option, + pub meta: Option, } #[derive(Serialize)] @@ -493,7 +493,7 @@ pub fn build_video_meta(path: &PathBuf) -> Result { }) } -async fn compress_image(path: PathBuf) -> Result, String> { +pub async fn compress_image(path: PathBuf) -> Result, String> { task::spawn_blocking(move || { let img = ImageReader::open(&path) .map_err(|e| format!("Failed to open image: {e}"))? @@ -523,7 +523,9 @@ async fn compress_image(path: PathBuf) -> Result, String> { .map_err(|e| format!("Failed to compress image: {e}"))? } -fn bytes_into_stream(bytes: Vec) -> (impl Stream>, u64) { +pub fn bytes_into_stream( + bytes: Vec, +) -> (impl Stream>, u64) { let total_size = bytes.len(); let stream = stream::once(async move { Ok::<_, std::io::Error>(bytes::Bytes::from(bytes)) }); (stream, total_size as u64) From 313d7f1764f67665c5a665d0188cef34418c92c1 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 28 Sep 2025 13:47:39 +1000 Subject: [PATCH 06/47] absolute mess --- apps/desktop/src-tauri/src/lib.rs | 20 +++++++++-- apps/desktop/src-tauri/src/recording.rs | 36 +++++++++++-------- .../(window-chrome)/settings/recordings.tsx | 4 ++- apps/desktop/src/utils/tauri.ts | 4 +-- crates/project/src/meta.rs | 17 +++++++-- 5 files changed, 58 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b13fd120d..14d0ef2aa 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -910,6 +910,7 @@ async fn get_video_metadata(path: PathBuf) -> Result todo!(), }; let duration = display_paths @@ -1389,19 +1390,31 @@ async fn save_file_dialog( } } +// #[derive(Serialize, specta::Type)] +// #[serde(tag = "status")] +// pub enum RecordingStatus { +// Recording, +// Failed { error: String }, +// Complete { mode: RecordingMode }, +// } +// +// #[serde(flatten)] +// pub status: RecordingStatus, + #[derive(Serialize, specta::Type)] pub struct RecordingMetaWithMode { #[serde(flatten)] pub inner: RecordingMeta, - pub mode: RecordingMode, + pub mode: Option, } impl RecordingMetaWithMode { fn new(inner: RecordingMeta) -> Self { Self { mode: match &inner.inner { - RecordingMetaInner::Studio(_) => RecordingMode::Studio, - RecordingMetaInner::Instant(_) => RecordingMode::Instant, + RecordingMetaInner::Studio(_) => Some(RecordingMode::Studio), + RecordingMetaInner::Instant(_) => Some(RecordingMode::Instant), + RecordingMetaInner::InProgress { .. } => None, }, inner, } @@ -2489,6 +2502,7 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { } } } + RecordingMetaInner::InProgress { recording } => todo!(), } Ok(()) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 009ada021..1930a135d 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -285,6 +285,24 @@ pub async fn start_recording( RecordingMode::Studio => None, }; + let date_time = if cfg!(windows) { + // Windows doesn't support colon in file paths + chrono::Local::now().format("%Y-%m-%d %H.%M.%S") + } else { + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + }; + + let meta = RecordingMeta { + platform: Some(Platform::default()), + project_path: recording_dir.clone(), + sharing: None, // TODO: Is this gonna be problematic as it was previously always set + pretty_name: format!("{target_name} {date_time}"), + inner: RecordingMetaInner::InProgress { recording: true }, + }; + + meta.save_for_project() + .map_err(|e| format!("Failed to save recording meta: {e}"))?; + match &inputs.capture_target { ScreenCaptureTarget::Window { id: _id } => { if let Some(show) = inputs @@ -843,21 +861,9 @@ async fn handle_recording_finish( } }; - let date_time = if cfg!(windows) { - // Windows doesn't support colon in file paths - chrono::Local::now().format("%Y-%m-%d %H.%M.%S") - } else { - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - }; - - let meta = RecordingMeta { - platform: Some(Platform::default()), - project_path: recording_dir.clone(), - sharing, - pretty_name: format!("{target_name} {date_time}"), - inner: meta_inner, - }; - + // TODO: Can we avoid reloading it from disk by parsing as arg? + let mut meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); + meta.inner = meta_inner; meta.save_for_project() .map_err(|e| format!("Failed to save recording meta: {e}"))?; diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index e26e056e0..9c17336fc 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -187,7 +187,7 @@ function RecordingItem(props: { onCopyVideoToClipboard: () => void; }) { const [imageExists, setImageExists] = createSignal(true); - const mode = () => props.recording.meta.mode; + const mode = () => props.recording.meta.mode || "other"; // TODO: Fix this const firstLetterUpperCase = () => mode().charAt(0).toUpperCase() + mode().slice(1); @@ -210,6 +210,8 @@ function RecordingItem(props: { />
+ {"recording" in props.recording.meta ? "TRUE" : "FALSE"} + {props.recording.prettyName}
, #[serde(flatten)] pub inner: RecordingMetaInner, + // #[serde(default)] + // pub upload: UploadState, // TODO: Put in `StudioRecordingMeta`, etc } -impl specta::Flatten for RecordingMetaInner {} +// impl specta::Flatten for RecordingMetaInner {} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub enum UploadState { + Uploading, + Failed(String), + Complete, +} #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(untagged, rename_all = "camelCase")] pub enum RecordingMetaInner { Studio(StudioRecordingMeta), Instant(InstantRecordingMeta), + // This is set while the recording is still active. + InProgress { recording: bool }, + // Failed { error: String }, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -91,6 +102,7 @@ impl RecordingMeta { pub fn path(&self, relative: &RelativePathBuf) -> PathBuf { relative.to_path(&self.project_path) } + pub fn load_for_project(project_path: &Path) -> Result> { let meta_path = project_path.join("recording-meta.json"); let mut meta: Self = serde_json::from_str(&std::fs::read_to_string(&meta_path)?)?; @@ -135,6 +147,7 @@ impl RecordingMeta { match &self.inner { RecordingMetaInner::Instant(_) => self.project_path.join("content/output.mp4"), RecordingMetaInner::Studio(_) => self.project_path.join("output").join("result.mp4"), + RecordingMetaInner::InProgress { recording } => todo!(), } } From c813c07aa9690ffcf5ced42e930e1d60781e9568 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 28 Sep 2025 14:55:12 +1000 Subject: [PATCH 07/47] store recordings into project meta? --- apps/desktop/src-tauri/src/lib.rs | 20 ++- apps/desktop/src-tauri/src/recording.rs | 147 ++++++++++++---------- apps/desktop/src-tauri/src/upload.rs | 4 - apps/desktop/src/utils/tauri.ts | 5 +- crates/export/src/lib.rs | 5 +- crates/project/src/meta.rs | 22 ++-- crates/recording/src/instant_recording.rs | 6 + 7 files changed, 120 insertions(+), 89 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 14d0ef2aa..26486525f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -910,7 +910,12 @@ async fn get_video_metadata(path: PathBuf) -> Result todo!(), + RecordingMetaInner::InProgress { .. } => { + return Err(format!("Unable to get metadata on in-progress recording")); + } + RecordingMetaInner::Failed { .. } => { + return Err(format!("Unable to get metadata on failed recording")); + } }; let duration = display_paths @@ -1063,7 +1068,11 @@ async fn upload_exported_video( let mut meta = RecordingMeta::load_for_project(&path).map_err(|v| v.to_string())?; - let output_path = meta.output_path(); + let Some(output_path) = meta.output_path() else { + notifications::send_notification(&app, notifications::NotificationType::UploadFailed); + return Err("Failed to upload video: Recording failed to complete".to_string()); + }; + if !output_path.exists() { notifications::send_notification(&app, notifications::NotificationType::UploadFailed); return Err("Failed to upload video: Rendered video not found".to_string()); @@ -1304,6 +1313,7 @@ async fn take_screenshot(app: AppHandle, _state: MutableState<'_, App>) -> Resul cursor: None, }, }), + upload: None, } .save_for_project() .unwrap(); @@ -1415,6 +1425,7 @@ impl RecordingMetaWithMode { RecordingMetaInner::Studio(_) => Some(RecordingMode::Studio), RecordingMetaInner::Instant(_) => Some(RecordingMode::Instant), RecordingMetaInner::InProgress { .. } => None, + RecordingMetaInner::Failed { .. } => None, }, inner, } @@ -2502,7 +2513,10 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { } } } - RecordingMetaInner::InProgress { recording } => todo!(), + RecordingMetaInner::InProgress { .. } => return Err(format!("Recording in progress")), + RecordingMetaInner::Failed { .. } => { + return Err(format!("Unable to open failed recording")); + } } Ok(()) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 1930a135d..2381099b9 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -298,6 +298,7 @@ pub async fn start_recording( sharing: None, // TODO: Is this gonna be problematic as it was previously always set pretty_name: format!("{target_name} {date_time}"), inner: RecordingMetaInner::InProgress { recording: true }, + upload: None, }; meta.save_for_project() @@ -370,6 +371,7 @@ pub async fn start_recording( spawn_actor({ let state_mtx = Arc::clone(&state_mtx); let general_settings = general_settings.cloned(); + let recording_dir = recording_dir.clone(); async move { fail!("recording::spawn_actor"); let mut state = state_mtx.write().await; @@ -477,13 +479,13 @@ pub async fn start_recording( let actor_done_rx = match spawn_actor_res { Ok(rx) => rx, - Err(e) => { - let _ = RecordingEvent::Failed { error: e.clone() }.emit(&app); + Err(err) => { + let _ = RecordingEvent::Failed { error: err.clone() }.emit(&app); let mut dialog = MessageDialogBuilder::new( app.dialog().clone(), "An error occurred".to_string(), - e.clone(), + err.clone(), ) .kind(tauri_plugin_dialog::MessageDialogKind::Error); @@ -494,9 +496,9 @@ pub async fn start_recording( dialog.blocking_show(); let mut state = state_mtx.write().await; - let _ = handle_recording_end(app, None, &mut state).await; + let _ = handle_recording_end(app, Err(err.clone()), &mut state, recording_dir).await; - return Err(e); + return Err(err); } }; @@ -522,7 +524,7 @@ pub async fn start_recording( let mut dialog = MessageDialogBuilder::new( app.dialog().clone(), "An error occurred".to_string(), - e, + e.clone(), ) .kind(tauri_plugin_dialog::MessageDialogKind::Error); @@ -533,7 +535,9 @@ pub async fn start_recording( dialog.blocking_show(); // this clears the current recording for us - handle_recording_end(app, None, &mut state).await.ok(); + handle_recording_end(app, Err(e), &mut state, recording_dir) + .await + .ok(); } // Actor hasn't errored, it's just finished v => { @@ -581,8 +585,9 @@ pub async fn stop_recording(app: AppHandle, state: MutableState<'_, App>) -> Res }; let completed_recording = current_recording.stop().await.map_err(|e| e.to_string())?; + let recording_dir = completed_recording.project_path().clone(); - handle_recording_end(app, Some(completed_recording), &mut state).await?; + handle_recording_end(app, Ok(completed_recording), &mut state, recording_dir).await?; Ok(()) } @@ -669,17 +674,24 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R // runs when a recording ends, whether from success or failure async fn handle_recording_end( handle: AppHandle, - recording: Option, + recording: Result, app: &mut App, + recording_dir: PathBuf, ) -> Result<(), String> { // Clear current recording, just in case :) app.clear_current_recording(); - let res = if let Some(recording) = recording { + let res = match recording { // we delay reporting errors here so that everything else happens first - Some(handle_recording_finish(&handle, recording).await) - } else { - None + Ok(recording) => Some(handle_recording_finish(&handle, recording).await), + Err(error) => { + // TODO: Error handling + let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); + project_meta.inner = RecordingMetaInner::Failed { error }; + project_meta.save_for_project().unwrap(); + + None + } }; let _ = RecordingStopped.emit(&handle); @@ -743,8 +755,6 @@ async fn handle_recording_finish( None, )); - let target_name = completed_recording.target_name().clone(); - let (meta_inner, sharing) = match completed_recording { CompletedRecording::Studio { recording, .. } => { let recordings = ProjectRecordingsMeta::new(&recording_dir, &recording.meta)?; @@ -766,7 +776,6 @@ async fn handle_recording_finish( video_upload_info, .. } => { - // shareable_link = Some(video_upload_info.link.clone()); let app = app.clone(); let output_path = recording_dir.join("content/output.mp4"); @@ -796,58 +805,58 @@ async fn handle_recording_finish( let _ = screenshot_task.await; - if video_upload_succeeded { - if let Ok(result) = - compress_image(display_screenshot).await - .map_err(|err| - error!("Error compressing thumbnail for instant mode progressive upload: {err}") - ) { - let (stream, total_size) = bytes_into_stream(result); - do_presigned_upload( - &app, - stream, - total_size, - crate::upload::PresignedS3PutRequest { - video_id: video_upload_info.id.clone(), - subpath: "screenshot/screen-capture.jpg".to_string(), - method: PresignedS3PutRequestMethod::Put, - meta: None, - }, - |p| {} // TODO: Progress reporting - ) - .await - .map_err(|err| { - error!("Error updating thumbnail for instant mode progressive upload: {err}") - }) - .ok(); - } - } else { - if let Ok(meta) = build_video_meta(&output_path) - .map_err(|err| error!("Error getting video metdata: {}", err)) - { - // The upload_video function handles screenshot upload, so we can pass it along - match upload_video( - &app, - video_upload_info.id.clone(), - output_path, - display_screenshot.clone(), - video_upload_info.config.clone(), - meta, - None, - ) - .await - { - Ok(_) => { - info!( - "Final video upload with screenshot completed successfully" - ) - } - Err(e) => { - error!("Error in final upload with screenshot: {}", e) - } - } - } - } + // if video_upload_succeeded { + // if let Ok(result) = + // compress_image(display_screenshot).await + // .map_err(|err| + // error!("Error compressing thumbnail for instant mode progressive upload: {err}") + // ) { + // let (stream, total_size) = bytes_into_stream(result); + // do_presigned_upload( + // &app, + // stream, + // total_size, + // crate::upload::PresignedS3PutRequest { + // video_id: video_upload_info.id.clone(), + // subpath: "screenshot/screen-capture.jpg".to_string(), + // method: PresignedS3PutRequestMethod::Put, + // meta: None, + // }, + // |p| {} // TODO: Progress reporting + // ) + // .await + // .map_err(|err| { + // error!("Error updating thumbnail for instant mode progressive upload: {err}") + // }) + // .ok(); + // } + // } else { + // if let Ok(meta) = build_video_meta(&output_path) + // .map_err(|err| error!("Error getting video metdata: {}", err)) + // { + // // The upload_video function handles screenshot upload, so we can pass it along + // match upload_video( + // &app, + // video_upload_info.id.clone(), + // output_path, + // display_screenshot.clone(), + // video_upload_info.config.clone(), + // meta, + // None, + // ) + // .await + // { + // Ok(_) => { + // info!( + // "Final video upload with screenshot completed successfully" + // ) + // } + // Err(e) => { + // error!("Error in final upload with screenshot: {}", e) + // } + // } + // } + // } } }); @@ -864,6 +873,7 @@ async fn handle_recording_finish( // TODO: Can we avoid reloading it from disk by parsing as arg? let mut meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); meta.inner = meta_inner; + meta.sharing = sharing; meta.save_for_project() .map_err(|e| format!("Failed to save recording meta: {e}"))?; @@ -973,6 +983,7 @@ pub fn generate_zoom_segments_from_clicks( pretty_name: String::new(), sharing: None, inner: RecordingMetaInner::Studio(recording.meta.clone()), + upload: None, }; generate_zoom_segments_for_project(&recording_meta, recordings) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index af90ab18b..10db4a489 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -577,10 +577,6 @@ impl InstantMultipartUpload { pre_created_video: VideoUploadInfo, realtime_video_done: Option>, ) -> Result<(), String> { - use std::time::Duration; - - use tokio::time::sleep; - // -------------------------------------------- // basic constants and info for chunk approach // -------------------------------------------- diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 408f8cc52..67ce07d4a 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -420,8 +420,8 @@ export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingDeleted = { path: string } export type RecordingEvent = { variant: "Countdown"; value: number } | { variant: "Started" } | { variant: "Stopped" } | { variant: "Failed"; error: string } -export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta | { recording: boolean }) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null } -export type RecordingMetaWithMode = ((StudioRecordingMeta | InstantRecordingMeta | { recording: boolean }) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null }) & { mode: RecordingMode | null } +export type RecordingMeta = ({ recording: boolean } | { error: string } | StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null } +export type RecordingMetaWithMode = (({ recording: boolean } | { error: string } | StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null }) & { mode: RecordingMode | null } export type RecordingMode = "studio" | "instant" export type RecordingOptionsChanged = null export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean } @@ -452,6 +452,7 @@ export type TimelineSegment = { recordingSegment?: number; timescale: number; st export type UploadMode = { Initial: { pre_created_video: VideoUploadInfo | null } } | "Reupload" export type UploadProgress = { progress: number } export type UploadResult = { Success: string } | "NotAuthenticated" | "PlanCheckFailed" | "UpgradeRequired" +export type UploadState = "Uploading" | { Failed: string } | "Complete" export type Video = { duration: number; width: number; height: number; fps: number; start_time: number } export type VideoMeta = { path: string; fps?: number; /** diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index b94f5302e..7218144aa 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -39,6 +39,8 @@ pub enum ExporterBuildError { MetaLoad(#[source] Box), #[error("Recording is not a studio recording")] NotStudioRecording, + #[error("Unable to export a failed recording")] + RecordingFailed, #[error("Failed to load recordings meta: {0}")] RecordingsMeta(String), #[error("Failed to setup renderer: {0}")] @@ -97,7 +99,8 @@ impl ExporterBuilder { let output_path = self .output_path - .unwrap_or_else(|| recording_meta.output_path()); + .or_else(|| recording_meta.output_path()) + .ok_or(Error::RecordingFailed)?; if let Some(parent) = output_path.parent() { std::fs::create_dir_all(parent) diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 37cfa3934..a8ba51617 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -69,12 +69,10 @@ pub struct RecordingMeta { pub sharing: Option, #[serde(flatten)] pub inner: RecordingMetaInner, - // #[serde(default)] - // pub upload: UploadState, // TODO: Put in `StudioRecordingMeta`, etc + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upload: Option, } -// impl specta::Flatten for RecordingMetaInner {} - #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub enum UploadState { Uploading, @@ -85,11 +83,10 @@ pub enum UploadState { #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(untagged, rename_all = "camelCase")] pub enum RecordingMetaInner { + InProgress { recording: bool }, + Failed { error: String }, Studio(StudioRecordingMeta), Instant(InstantRecordingMeta), - // This is set while the recording is still active. - InProgress { recording: bool }, - // Failed { error: String }, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -143,11 +140,14 @@ impl RecordingMeta { config } - pub fn output_path(&self) -> PathBuf { + pub fn output_path(&self) -> Option { match &self.inner { - RecordingMetaInner::Instant(_) => self.project_path.join("content/output.mp4"), - RecordingMetaInner::Studio(_) => self.project_path.join("output").join("result.mp4"), - RecordingMetaInner::InProgress { recording } => todo!(), + RecordingMetaInner::Instant(_) => Some(self.project_path.join("content/output.mp4")), + RecordingMetaInner::Studio(_) => { + Some(self.project_path.join("output").join("result.mp4")) + } + RecordingMetaInner::InProgress { recording } => None, + RecordingMetaInner::Failed { error } => None, } } diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index 187546257..fa5b93e70 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -209,6 +209,12 @@ pub async fn spawn_instant_recording_actor( ), RecordingError, > { + // TODO: Remove + return Err(RecordingError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Bruh"), + ))); + ensure_dir(&recording_dir)?; let start_time = SystemTime::now(); From a65d8712b8aa68778be450eedb72461c4b9660d6 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 28 Sep 2025 15:28:21 +1000 Subject: [PATCH 08/47] cursed realtime upload progress --- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/src/recording.rs | 107 +++++++++--------- apps/desktop/src-tauri/src/upload.rs | 35 +++++- .../(window-chrome)/settings/recordings.tsx | 16 +++ apps/desktop/src/utils/tauri.ts | 9 +- crates/project/src/meta.rs | 8 +- crates/recording/src/instant_recording.rs | 8 +- 7 files changed, 118 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 26486525f..ef5ea8360 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1969,6 +1969,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) { RecordingDeleted, target_select_overlay::TargetUnderCursor, hotkeys::OnEscapePress, + upload::UploadProgressEvent, ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) .typ::() diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 2381099b9..8a48a4d62 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -436,6 +436,7 @@ pub async fn start_recording( recording_dir.join("content/output.mp4"), video_upload_info.clone(), Some(finish_upload_rx), + recording_dir.clone(), ); let mut builder = instant_recording::Actor::builder( @@ -685,7 +686,7 @@ async fn handle_recording_end( // we delay reporting errors here so that everything else happens first Ok(recording) => Some(handle_recording_finish(&handle, recording).await), Err(error) => { - // TODO: Error handling + // TODO: Error handling -> Can we reuse `RecordingMeta` too? let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); project_meta.inner = RecordingMetaInner::Failed { error }; project_meta.save_for_project().unwrap(); @@ -805,58 +806,58 @@ async fn handle_recording_finish( let _ = screenshot_task.await; - // if video_upload_succeeded { - // if let Ok(result) = - // compress_image(display_screenshot).await - // .map_err(|err| - // error!("Error compressing thumbnail for instant mode progressive upload: {err}") - // ) { - // let (stream, total_size) = bytes_into_stream(result); - // do_presigned_upload( - // &app, - // stream, - // total_size, - // crate::upload::PresignedS3PutRequest { - // video_id: video_upload_info.id.clone(), - // subpath: "screenshot/screen-capture.jpg".to_string(), - // method: PresignedS3PutRequestMethod::Put, - // meta: None, - // }, - // |p| {} // TODO: Progress reporting - // ) - // .await - // .map_err(|err| { - // error!("Error updating thumbnail for instant mode progressive upload: {err}") - // }) - // .ok(); - // } - // } else { - // if let Ok(meta) = build_video_meta(&output_path) - // .map_err(|err| error!("Error getting video metdata: {}", err)) - // { - // // The upload_video function handles screenshot upload, so we can pass it along - // match upload_video( - // &app, - // video_upload_info.id.clone(), - // output_path, - // display_screenshot.clone(), - // video_upload_info.config.clone(), - // meta, - // None, - // ) - // .await - // { - // Ok(_) => { - // info!( - // "Final video upload with screenshot completed successfully" - // ) - // } - // Err(e) => { - // error!("Error in final upload with screenshot: {}", e) - // } - // } - // } - // } + if video_upload_succeeded { + if let Ok(result) = + compress_image(display_screenshot).await + .map_err(|err| + error!("Error compressing thumbnail for instant mode progressive upload: {err}") + ) { + let (stream, total_size) = bytes_into_stream(result); + do_presigned_upload( + &app, + stream, + total_size, + crate::upload::PresignedS3PutRequest { + video_id: video_upload_info.id.clone(), + subpath: "screenshot/screen-capture.jpg".to_string(), + method: PresignedS3PutRequestMethod::Put, + meta: None, + }, + |p| {} // TODO: Progress reporting + ) + .await + .map_err(|err| { + error!("Error updating thumbnail for instant mode progressive upload: {err}") + }) + .ok(); + } + } else { + if let Ok(meta) = build_video_meta(&output_path) + .map_err(|err| error!("Error getting video metdata: {}", err)) + { + // The upload_video function handles screenshot upload, so we can pass it along + match upload_video( + &app, + video_upload_info.id.clone(), + output_path, + display_screenshot.clone(), + video_upload_info.config.clone(), + meta, + None, + ) + .await + { + Ok(_) => { + info!( + "Final video upload with screenshot completed successfully" + ) + } + Err(e) => { + error!("Error in final upload with screenshot: {}", e) + } + } + } + } } }); diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 10db4a489..fa98d63a7 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -4,6 +4,7 @@ use crate::web_api::ManagerExt; use crate::{UploadProgress, VideoUploadInfo}; use axum::body::Body; use bytes::Bytes; +use cap_project::{RecordingMeta, RecordingMetaInner, UploadState}; use cap_utils::spawn_actor; use ffmpeg::ffi::AV_TIME_BASE; use flume::Receiver; @@ -24,6 +25,7 @@ use std::{ }; use tauri::{AppHandle, ipc::Channel}; use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_specta::Event; use tokio::fs::File; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio::sync::watch; @@ -531,9 +533,16 @@ pub fn bytes_into_stream( (stream, total_size as u64) } +#[derive(Clone, Serialize, Type, tauri_specta::Event)] +pub struct UploadProgressEvent { + video_id: String, + // TODO: Account for different states -> Eg. uploading video vs thumbnail + uploaded: String, + total: String, +} + // a typical recommended chunk size is 5MB (AWS min part size). const CHUNK_SIZE: u64 = 5 * 1024 * 1024; // 5MB -// const MIN_PART_SIZE: u64 = 5 * 1024 * 1024; // For non-final parts #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -558,6 +567,7 @@ impl InstantMultipartUpload { file_path: PathBuf, pre_created_video: VideoUploadInfo, realtime_upload_done: Option>, + recording_dir: PathBuf, ) -> Self { Self { handle: spawn_actor(Self::run( @@ -566,6 +576,7 @@ impl InstantMultipartUpload { file_path, pre_created_video, realtime_upload_done, + recording_dir, )), } } @@ -576,7 +587,13 @@ impl InstantMultipartUpload { file_path: PathBuf, pre_created_video: VideoUploadInfo, realtime_video_done: Option>, + recording_dir: PathBuf, ) -> Result<(), String> { + // TODO: Reuse this + error handling + let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); + project_meta.upload = Some(UploadState::MultipartUpload); + project_meta.save_for_project().unwrap(); + // -------------------------------------------- // basic constants and info for chunk approach // -------------------------------------------- @@ -751,6 +768,11 @@ impl InstantMultipartUpload { } } + // TODO: Reuse this + error handling + let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); + project_meta.upload = Some(UploadState::Complete); + project_meta.save_for_project().unwrap(); + // Copy link to clipboard early let _ = app.clipboard().write_text(pre_created_video.link.clone()); @@ -882,8 +904,6 @@ impl InstantMultipartUpload { } }; - progress.update(expected_pos, file_size); - if !presign_response.status().is_success() { let status = presign_response.status(); let error_body = presign_response @@ -993,6 +1013,15 @@ impl InstantMultipartUpload { (*last_uploaded_position as f64 / file_size as f64 * 100.0) as u32 ); + progress.update(expected_pos, file_size); + UploadProgressEvent { + video_id: video_id.to_string(), + uploaded: last_uploaded_position.to_string(), + total: file_size.to_string(), + } + .emit(app) + .ok(); + let part = UploadedPart { part_number: *part_number, etag, diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 9c17336fc..0f8c827dd 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -20,6 +20,7 @@ import { type ParentProps, Show, } from "solid-js"; +import { createStore } from "solid-js/store"; import { trackEvent } from "~/utils/analytics"; import { createTauriEventListener } from "~/utils/createEventListener"; import { @@ -117,6 +118,17 @@ export default function Recordings() { }); }; + const [uploadProgress, setUploadProgress] = createStore< + Record + >({}); + // TODO: Cleanup subscription + events.uploadProgressEvent.listen((e) => { + setUploadProgress( + e.payload.video_id, + Number(e.payload.uploaded) / Number(e.payload.total), + ); + }); + return (
@@ -133,6 +145,8 @@ export default function Recordings() {

} > +
{JSON.stringify(uploadProgress)}
+
{(tab) => ( @@ -211,6 +225,8 @@ function RecordingItem(props: {
{"recording" in props.recording.meta ? "TRUE" : "FALSE"} + {"error" in props.recording.meta ? props.recording.meta.error : ""} + {JSON.stringify(props.recording.meta?.upload || {})} {props.recording.prettyName}
({ audioInputLevelChange: "audio-input-level-change", authenticationInvalid: "authentication-invalid", @@ -312,7 +313,8 @@ requestNewScreenshot: "request-new-screenshot", requestOpenRecordingPicker: "request-open-recording-picker", requestOpenSettings: "request-open-settings", requestStartRecording: "request-start-recording", -targetUnderCursor: "target-under-cursor" +targetUnderCursor: "target-under-cursor", +uploadProgressEvent: "upload-progress-event" }) /** user-defined constants **/ @@ -451,8 +453,9 @@ export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number } export type UploadMode = { Initial: { pre_created_video: VideoUploadInfo | null } } | "Reupload" export type UploadProgress = { progress: number } +export type UploadProgressEvent = { video_id: string; uploaded: string; total: string } export type UploadResult = { Success: string } | "NotAuthenticated" | "PlanCheckFailed" | "UpgradeRequired" -export type UploadState = "Uploading" | { Failed: string } | "Complete" +export type UploadState = "MultipartUpload" | "SinglePartUpload" | { Failed: string } | "Complete" export type Video = { duration: number; width: number; height: number; fps: number; start_time: number } export type VideoMeta = { path: string; fps?: number; /** diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index a8ba51617..d9f626b1e 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -75,7 +75,9 @@ pub struct RecordingMeta { #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub enum UploadState { - Uploading, + // TODO: Do we care about what sort of upload it is??? + MultipartUpload, + SinglePartUpload, Failed(String), Complete, } @@ -146,8 +148,8 @@ impl RecordingMeta { RecordingMetaInner::Studio(_) => { Some(self.project_path.join("output").join("result.mp4")) } - RecordingMetaInner::InProgress { recording } => None, - RecordingMetaInner::Failed { error } => None, + RecordingMetaInner::InProgress { .. } => None, + RecordingMetaInner::Failed { .. } => None, } } diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index fa5b93e70..f67fb8fbf 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -210,10 +210,10 @@ pub async fn spawn_instant_recording_actor( RecordingError, > { // TODO: Remove - return Err(RecordingError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Bruh"), - ))); + // return Err(RecordingError::Io(std::io::Error::new( + // std::io::ErrorKind::Other, + // format!("Bruh"), + // ))); ensure_dir(&recording_dir)?; From 7a8bf5883d2a62a4f66bef9ddcde81eebb5c518e Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 28 Sep 2025 19:40:04 +1000 Subject: [PATCH 09/47] break out API definitions + use tracing for logging in `upload.rs` --- Cargo.lock | 7 + apps/desktop/src-tauri/src/api.rs | 160 ++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/src/upload.rs | 240 +++++---------------------- crates/api/Cargo.toml | 11 ++ crates/api/src/lib.rs | 34 ++++ 6 files changed, 257 insertions(+), 196 deletions(-) create mode 100644 apps/desktop/src-tauri/src/api.rs create mode 100644 crates/api/Cargo.toml create mode 100644 crates/api/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 29be7a43c..61fbbb176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,6 +1019,13 @@ dependencies = [ "uuid", ] +[[package]] +name = "cap-api" +version = "0.0.0" +dependencies = [ + "reqwest", +] + [[package]] name = "cap-audio" version = "0.1.0" diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs new file mode 100644 index 000000000..47aed5e73 --- /dev/null +++ b/apps/desktop/src-tauri/src/api.rs @@ -0,0 +1,160 @@ +//! TODO: We should investigate generating this with OpenAPI. +//! This will come part of the EffectTS rewrite work. + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +use crate::web_api::ManagerExt; + +// TODO: Adding retry and backoff logic to everything! + +pub async fn upload_multipart_initiate(app: &AppHandle, video_id: &str) -> Result { + #[derive(Deserialize)] + pub struct Response { + upload_id: String, + } + + let resp = app + .authed_api_request("/api/upload/multipart/initiate", |c, url| { + c.post(url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "videoId": video_id, + "contentType": "video/mp4" + })) + }) + .await + .map_err(|err| format!("api/upload_multipart_initiate/request: {err}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!( + "api/upload_multipart_initiate/{status}: {error_body}" + )); + } + + resp.json::() + .await + .map_err(|err| format!("api/upload_multipart_initiate/response: {err}")) + .map(|data| data.upload_id) +} + +pub async fn upload_multipart_presign_part( + app: &AppHandle, + video_id: &str, + upload_id: &str, + part_number: u32, + md5_sum: &str, +) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Response { + presigned_url: String, + } + + let resp = app + .authed_api_request("/api/upload/multipart/presign-part", |c, url| { + c.post(url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "videoId": video_id, + "uploadId": upload_id, + "partNumber": part_number, + "md5Sum": md5_sum + })) + }) + .await + .map_err(|err| format!("api/upload_multipart_presign_part/request: {err}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!( + "api/upload_multipart_presign_part/{status}: {error_body}" + )); + } + + resp.json::() + .await + .map_err(|err| format!("api/upload_multipart_presign_part/response: {err}")) + .map(|data| data.presigned_url) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadedPart { + pub part_number: u32, + pub etag: String, + pub size: usize, +} + +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct S3VideoMeta { + #[serde(rename = "durationInSecs")] + pub duration_in_secs: f64, + pub width: u32, + pub height: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub fps: Option, +} + +pub async fn upload_multipart_complete( + app: &AppHandle, + video_id: &str, + upload_id: &str, + parts: &[UploadedPart], + meta: Option, +) -> Result, String> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct MultipartCompleteRequest<'a> { + video_id: &'a str, + upload_id: &'a str, + parts: &'a [UploadedPart], + #[serde(flatten)] + meta: Option, + } + + #[derive(Deserialize)] + pub struct Response { + location: Option, + } + + let resp = app + .authed_api_request("/api/upload/multipart/complete", |c, url| { + c.post(url) + .header("Content-Type", "application/json") + .json(&MultipartCompleteRequest { + video_id, + upload_id, + parts, + meta, + }) + }) + .await + .map_err(|err| format!("api/upload_multipart_complete/request: {err}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!( + "api/upload_multipart_complete/{status}: {error_body}" + )); + } + + resp.json::() + .await + .map_err(|err| format!("api/upload_multipart_complete/response: {err}")) + .map(|data| data.location) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ef5ea8360..b7e0ea4d7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod api; mod audio; mod audio_meter; mod auth; diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index fa98d63a7..0ea09a8e9 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -1,7 +1,8 @@ // credit @filleduchaos +use crate::api::{S3VideoMeta, UploadedPart}; use crate::web_api::ManagerExt; -use crate::{UploadProgress, VideoUploadInfo}; +use crate::{UploadProgress, VideoUploadInfo, api}; use axum::body::Body; use bytes::Bytes; use cap_project::{RecordingMeta, RecordingMetaInner, UploadState}; @@ -50,17 +51,6 @@ impl S3UploadMeta { } } -#[derive(Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct S3VideoMeta { - #[serde(rename = "durationInSecs")] - pub duration_in_secs: f64, - pub width: u32, - pub height: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub fps: Option, -} - pub struct UploadedVideo { pub link: String, pub id: String, @@ -73,6 +63,15 @@ pub struct UploadedImage { pub id: String, } +pub fn upload_v2(app: AppHandle) { + // TODO: Progress reporting + // TODO: Multipart or regular upload automatically sorted out + // TODO: Allow either FS derived or Rust progress derived multipart upload source + // TODO: Support screenshots, or videos + + todo!(); +} + pub struct UploadProgressUpdater { video_state: Option, app: AppHandle, @@ -544,16 +543,6 @@ pub struct UploadProgressEvent { // a typical recommended chunk size is 5MB (AWS min part size). const CHUNK_SIZE: u64 = 5 * 1024 * 1024; // 5MB -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MultipartCompleteResponse<'a> { - video_id: &'a str, - upload_id: &'a str, - parts: &'a [UploadedPart], - #[serde(flatten)] - meta: Option, -} - pub struct InstantMultipartUpload { pub handle: tokio::task::JoinHandle>, } @@ -589,6 +578,8 @@ impl InstantMultipartUpload { realtime_video_done: Option>, recording_dir: PathBuf, ) -> Result<(), String> { + debug!("Initiating multipart upload for {video_id}..."); + // TODO: Reuse this + error handling let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); project_meta.upload = Some(UploadState::MultipartUpload); @@ -605,57 +596,8 @@ impl InstantMultipartUpload { let mut last_uploaded_position: u64 = 0; let mut progress = UploadProgressUpdater::new(app.clone(), pre_created_video.id.clone()); - // -------------------------------------------- - // initiate the multipart upload - // -------------------------------------------- - debug!("Initiating multipart upload for {video_id}..."); - let initiate_response = match app - .authed_api_request("/api/upload/multipart/initiate", |c, url| { - c.post(url) - .header("Content-Type", "application/json") - .json(&serde_json::json!({ - "videoId": s3_config.id(), - "contentType": "video/mp4" - })) - }) - .await - { - Ok(r) => r, - Err(e) => { - return Err(format!("Failed to initiate multipart upload: {e}")); - } - }; - - if !initiate_response.status().is_success() { - let status = initiate_response.status(); - let error_body = initiate_response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Err(format!( - "Failed to initiate multipart upload. Status: {status}. Body: {error_body}" - )); - } - - let initiate_data = match initiate_response.json::().await { - Ok(d) => d, - Err(e) => { - return Err(format!("Failed to parse initiate response: {e}")); - } - }; - - let upload_id = match initiate_data.get("uploadId") { - Some(val) => val.as_str().unwrap_or("").to_string(), - None => { - return Err("No uploadId returned from initiate endpoint".to_string()); - } - }; - - if upload_id.is_empty() { - return Err("Empty uploadId returned from initiate endpoint".to_string()); - } - - println!("Multipart upload initiated with ID: {upload_id}"); + let upload_id = api::upload_multipart_initiate(&app, s3_config.id()).await?; + debug!("Multipart upload initiated with ID: {upload_id}"); let mut realtime_is_done = realtime_video_done.as_ref().map(|_| false); @@ -683,14 +625,14 @@ impl InstantMultipartUpload { // Check the file's current size if !file_path.exists() { - println!("File no longer exists, aborting upload"); + debug!("File no longer exists, aborting upload"); return Err("File no longer exists".to_string()); } let file_size = match tokio::fs::metadata(&file_path).await { Ok(md) => md.len(), Err(e) => { - println!("Failed to get file metadata: {e}"); + debug!("Failed to get file metadata: {e}"); sleep(Duration::from_millis(500)).await; continue; } @@ -720,7 +662,7 @@ impl InstantMultipartUpload { uploaded_parts.push(part); } Err(e) => { - println!( + debug!( "Error uploading chunk (part {part_number}): {e}. Retrying in 1s..." ); sleep(Duration::from_secs(1)).await; @@ -745,11 +687,11 @@ impl InstantMultipartUpload { .map_err(|err| format!("Failed to re-upload first chunk: {err}"))?; uploaded_parts[0] = part; - println!("Successfully re-uploaded first chunk",); + debug!("Successfully re-uploaded first chunk",); } // All leftover chunks are now uploaded. We finalize. - println!( + debug!( "Completing multipart upload with {} parts", uploaded_parts.len() ); @@ -788,7 +730,7 @@ impl InstantMultipartUpload { file_path: &PathBuf, video_id: &str, upload_id: &str, - part_number: &mut i32, + part_number: &mut u32, last_uploaded_position: &mut u64, chunk_size: u64, progress: &mut UploadProgressUpdater, @@ -812,7 +754,7 @@ impl InstantMultipartUpload { .map_err(|e| format!("Failed to open file: {e}"))?; // Log before seeking - println!( + debug!( "Seeking to offset {} for part {} (file size: {}, remaining: {})", *last_uploaded_position, *part_number, file_size, remaining ); @@ -834,7 +776,7 @@ impl InstantMultipartUpload { Ok(0) => break, // EOF Ok(n) => { total_read += n; - println!("Read {n} bytes, total so far: {total_read}/{bytes_to_read}"); + debug!("Read {n} bytes, total so far: {total_read}/{bytes_to_read}"); } Err(e) => return Err(format!("Failed to read chunk from file: {e}")), } @@ -861,7 +803,7 @@ impl InstantMultipartUpload { let expected_pos = *last_uploaded_position + total_read as u64; if pos_after_read != expected_pos { - println!( + warn!( "WARNING: File position after read ({pos_after_read}) doesn't match expected position ({expected_pos})" ); } @@ -872,64 +814,18 @@ impl InstantMultipartUpload { .unwrap_or(0); let remaining = file_size - *last_uploaded_position; - println!( + debug!( "File size: {}, Last uploaded: {}, Remaining: {}, chunk_size: {}, part: {}", file_size, *last_uploaded_position, remaining, chunk_size, *part_number ); - println!( + debug!( "Uploading part {} ({} bytes), MD5: {}", *part_number, total_read, md5_sum ); - // Request presigned URL for this part - let presign_response = match app - .authed_api_request("/api/upload/multipart/presign-part", |c, url| { - c.post(url) - .header("Content-Type", "application/json") - .json(&serde_json::json!({ - "videoId": video_id, - "uploadId": upload_id, - "partNumber": *part_number, - "md5Sum": &md5_sum - })) - }) - .await - { - Ok(r) => r, - Err(e) => { - return Err(format!( - "Failed to request presigned URL for part {}: {}", - *part_number, e - )); - } - }; - - if !presign_response.status().is_success() { - let status = presign_response.status(); - let error_body = presign_response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Err(format!( - "Presign-part failed for part {}: status={}, body={}", - *part_number, status, error_body - )); - } - - let presign_data = match presign_response.json::().await { - Ok(d) => d, - Err(e) => return Err(format!("Failed to parse presigned URL response: {e}")), - }; - - let presigned_url = presign_data - .get("presignedUrl") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - if presigned_url.is_empty() { - return Err(format!("Empty presignedUrl for part {}", *part_number)); - } + let presigned_url = + api::upload_multipart_presign_part(app, video_id, upload_id, *part_number, &md5_sum) + .await?; // Upload the chunk with retry let mut retry_count = 0; @@ -937,7 +833,7 @@ impl InstantMultipartUpload { let mut etag: Option = None; while retry_count < max_retries && etag.is_none() { - println!( + debug!( "Sending part {} (attempt {}/{}): {} bytes", *part_number, retry_count + 1, @@ -961,28 +857,28 @@ impl InstantMultipartUpload { .unwrap_or("") .trim_matches('"') .to_string(); - println!("Received ETag {} for part {}", e, *part_number); + debug!("Received ETag {} for part {}", e, *part_number); etag = Some(e); } else { - println!("No ETag in response for part {}", *part_number); + error!("No ETag in response for part {}", *part_number); retry_count += 1; sleep(Duration::from_secs(2)).await; } } else { - println!( + error!( "Failed part {} (status {}). Will retry if possible.", *part_number, upload_response.status() ); if let Ok(body) = upload_response.text().await { - println!("Error response: {body}"); + error!("Error response: {body}"); } retry_count += 1; sleep(Duration::from_secs(2)).await; } } Err(e) => { - println!( + debug!( "Part {} upload error (attempt {}/{}): {}", *part_number, retry_count + 1, @@ -1007,7 +903,7 @@ impl InstantMultipartUpload { // Advance the global progress *last_uploaded_position += total_read as u64; - println!( + debug!( "After upload: new last_uploaded_position is {} ({}% of file)", *last_uploaded_position, (*last_uploaded_position as f64 / file_size as f64 * 100.0) as u32 @@ -1040,7 +936,7 @@ impl InstantMultipartUpload { upload_id: &str, uploaded_parts: &[UploadedPart], ) -> Result<(), String> { - println!( + debug!( "Completing multipart upload with {} parts", uploaded_parts.len() ); @@ -1055,7 +951,7 @@ impl InstantMultipartUpload { let size = part.size; let etag = &part.etag; total_bytes_in_parts += part.size; - println!("Part {pn}: {size} bytes (ETag: {etag})"); + debug!("Part {pn}: {size} bytes (ETag: {etag})"); } let file_final_size = tokio::fs::metadata(file_path) @@ -1063,66 +959,18 @@ impl InstantMultipartUpload { .map(|md| md.len()) .unwrap_or(0); - println!("Sum of all parts: {total_bytes_in_parts} bytes"); - println!("File size on disk: {file_final_size} bytes"); - println!("Proceeding with multipart upload completion..."); + debug!("Sum of all parts: {total_bytes_in_parts} bytes"); + debug!("File size on disk: {file_final_size} bytes"); + debug!("Proceeding with multipart upload completion..."); let metadata = build_video_meta(file_path) .map_err(|e| error!("Failed to get video metadata: {e}")) .ok(); - let complete_response = match app - .authed_api_request("/api/upload/multipart/complete", |c, url| { - c.post(url).header("Content-Type", "application/json").json( - &MultipartCompleteResponse { - video_id, - upload_id, - parts: uploaded_parts, - meta: metadata, - }, - ) - }) - .await - { - Ok(response) => response, - Err(e) => { - return Err(format!("Failed to complete multipart upload: {e}")); - } - }; - - if !complete_response.status().is_success() { - let status = complete_response.status(); - let error_body = complete_response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Err(format!( - "Failed to complete multipart upload. Status: {status}. Body: {error_body}" - )); - } - - let complete_data = match complete_response.json::().await { - Ok(d) => d, - Err(e) => { - return Err(format!("Failed to parse completion response: {e}")); - } - }; - - if let Some(location) = complete_data.get("location") { - println!("Multipart upload complete. Final S3 location: {location}"); - } else { - println!("Multipart upload complete. No 'location' in response."); - } + api::upload_multipart_complete(&app, &video_id, &upload_id, &uploaded_parts, metadata) + .await?; - println!("Multipart upload complete for {video_id}."); + info!("Multipart upload complete for {video_id}."); Ok(()) } } - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UploadedPart { - part_number: i32, - etag: String, - size: usize, -} diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml new file mode 100644 index 000000000..531e155be --- /dev/null +++ b/crates/api/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cap-api" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +reqwest = "0.12.23" + +[lints] +workspace = true diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs new file mode 100644 index 000000000..9c0bd4235 --- /dev/null +++ b/crates/api/src/lib.rs @@ -0,0 +1,34 @@ +///! Types and implements for the Cap web API endpoints. + +pub struct UploadMultipartInitiate { + video_id: String, +} + +// impl UploadMultipartInitiate { +// pub fn as_request(self) -> reqwest::Request { +// let mut request = reqwest::Request::new( +// reqwest::Method::POST, +// "https://api.example.com/upload_multipart_initiate" +// .parse() +// .unwrap(), +// ); +// request.header("Authorization", "Bearer YOUR_TOKEN"); +// request.json(&self).unwrap(); +// request +// } +// } + +// #[derive(Default)] +// pub struct Api { +// client: reqwest::Client, +// bearer: Option, // TODO: Hook this up +// } + +// impl Api { +// pub fn upload_multipart_initiate(&self) { +// // self.client +// todo!(); +// } +// } + +// TODO: Helper for retries, exponential backoff From ead7d0f74a44ba758894f85d84f943c8a4cd1234 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 28 Sep 2025 21:07:50 +1000 Subject: [PATCH 10/47] working stream-based uploader --- Cargo.lock | 23 ++ apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/upload.rs | 535 ++++++++++----------------- 3 files changed, 213 insertions(+), 346 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61fbbb176..c804cee74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,28 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "async-task" version = "4.7.1" @@ -1150,6 +1172,7 @@ name = "cap-desktop" version = "0.3.72" dependencies = [ "anyhow", + "async-stream", "axum", "base64 0.22.1", "bytemuck", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8b7e31f59..d0b983650 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -105,6 +105,7 @@ kameo = "0.17.2" tauri-plugin-sentry = "0.5.0" thiserror.workspace = true bytes = "1.10.1" +async-stream = "0.3.6" [target.'cfg(target_os = "macos")'.dependencies] core-graphics = "0.24.0" diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 0ea09a8e9..aa0c7a283 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -3,13 +3,14 @@ use crate::api::{S3VideoMeta, UploadedPart}; use crate::web_api::ManagerExt; use crate::{UploadProgress, VideoUploadInfo, api}; +use async_stream::{stream, try_stream}; use axum::body::Body; use bytes::Bytes; use cap_project::{RecordingMeta, RecordingMetaInner, UploadState}; use cap_utils::spawn_actor; use ffmpeg::ffi::AV_TIME_BASE; use flume::Receiver; -use futures::{Stream, StreamExt, stream}; +use futures::{Stream, StreamExt, TryStreamExt, stream}; use image::ImageReader; use image::codecs::jpeg::JpegEncoder; use reqwest::StatusCode; @@ -20,6 +21,7 @@ use specta::Type; use std::error::Error; use std::io; use std::path::Path; +use std::pin::pin; use std::{ path::PathBuf, time::{Duration, Instant}, @@ -305,6 +307,7 @@ pub async fn do_presigned_upload( let response = client .put(presigned_url) + .header("Content-Length", total_size) .body(reqwest::Body::wrap_stream(stream)) .send() .await @@ -585,392 +588,232 @@ impl InstantMultipartUpload { project_meta.upload = Some(UploadState::MultipartUpload); project_meta.save_for_project().unwrap(); - // -------------------------------------------- - // basic constants and info for chunk approach - // -------------------------------------------- - let client = reqwest::Client::new(); - let s3_config = pre_created_video.config; + // TODO: Allow injecting this for Studio mode upload + // let file = File::open(path).await.unwrap(); // TODO: Error handling + // ReaderStream::new(file) // TODO: Map into part numbers - let mut uploaded_parts = Vec::new(); - let mut part_number = 1; - let mut last_uploaded_position: u64 = 0; - let mut progress = UploadProgressUpdater::new(app.clone(), pre_created_video.id.clone()); + let upload_id = api::upload_multipart_initiate(&app, &video_id).await?; - let upload_id = api::upload_multipart_initiate(&app, s3_config.id()).await?; - debug!("Multipart upload initiated with ID: {upload_id}"); + // TODO: Will it be a problem that `ReaderStream` doesn't have a fixed chunk size??? We should fix that!!!! + let parts = progress(uploader( + app.clone(), + video_id.clone(), + upload_id.clone(), + from_pending_file(file_path.clone(), realtime_video_done), + )) + .try_collect::>() + .await?; - let mut realtime_is_done = realtime_video_done.as_ref().map(|_| false); - - // -------------------------------------------- - // Main loop while upload not complete: - // - If we have >= CHUNK_SIZE new data, upload. - // - If recording hasn't stopped, keep waiting. - // - If recording stopped, do leftover final(s). - // -------------------------------------------- - loop { - if !realtime_is_done.unwrap_or(true) - && let Some(realtime_video_done) = &realtime_video_done - { - match realtime_video_done.try_recv() { - Ok(_) => { - realtime_is_done = Some(true); - } - Err(flume::TryRecvError::Empty) => {} - _ => { - warn!("cancelling upload as realtime generation failed"); - return Err("cancelling upload as realtime generation failed".to_string()); - } - } - } - - // Check the file's current size - if !file_path.exists() { - debug!("File no longer exists, aborting upload"); - return Err("File no longer exists".to_string()); - } - - let file_size = match tokio::fs::metadata(&file_path).await { - Ok(md) => md.len(), - Err(e) => { - debug!("Failed to get file metadata: {e}"); - sleep(Duration::from_millis(500)).await; - continue; - } - }; - - let new_data_size = file_size - last_uploaded_position; - - if ((new_data_size >= CHUNK_SIZE) - || new_data_size > 0 && realtime_is_done.unwrap_or(false)) - || (realtime_is_done.is_none() && new_data_size > 0) - { - // We have a full chunk to send - match Self::upload_chunk( - &app, - &client, - &file_path, - s3_config.id(), - &upload_id, - &mut part_number, - &mut last_uploaded_position, - new_data_size.min(CHUNK_SIZE), - &mut progress, - ) - .await - { - Ok(part) => { - uploaded_parts.push(part); - } - Err(e) => { - debug!( - "Error uploading chunk (part {part_number}): {e}. Retrying in 1s..." - ); - sleep(Duration::from_secs(1)).await; - } - } - } else if new_data_size == 0 && realtime_is_done.unwrap_or(true) { - if realtime_is_done.unwrap_or(false) { - info!("realtime video done, uploading header chunk"); - - let part = Self::upload_chunk( - &app, - &client, - &file_path, - s3_config.id(), - &upload_id, - &mut 1, - &mut 0, - uploaded_parts[0].size as u64, - &mut progress, - ) - .await - .map_err(|err| format!("Failed to re-upload first chunk: {err}"))?; - - uploaded_parts[0] = part; - debug!("Successfully re-uploaded first chunk",); - } - - // All leftover chunks are now uploaded. We finalize. - debug!( - "Completing multipart upload with {} parts", - uploaded_parts.len() - ); - Self::finalize_upload( - &app, - &file_path, - s3_config.id(), - &upload_id, - &uploaded_parts, - ) - .await?; + let metadata = build_video_meta(&file_path) + .map_err(|e| error!("Failed to get video metadata: {e}")) + .ok(); - break; - } else { - tokio::time::sleep(Duration::from_secs(1)).await; - } - } + api::upload_multipart_complete(&app, &video_id, &upload_id, &parts, metadata).await?; + info!("Multipart upload complete for {video_id}."); // TODO: Reuse this + error handling let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); project_meta.upload = Some(UploadState::Complete); project_meta.save_for_project().unwrap(); - // Copy link to clipboard early let _ = app.clipboard().write_text(pre_created_video.link.clone()); Ok(()) } +} - /// Upload a single chunk from the file at `last_uploaded_position` for `chunk_size` bytes. - /// Advances `last_uploaded_position` accordingly. Returns JSON { PartNumber, ETag, Size }. - #[allow(clippy::too_many_arguments)] - async fn upload_chunk( - app: &AppHandle, - client: &reqwest::Client, - file_path: &PathBuf, - video_id: &str, - upload_id: &str, - part_number: &mut u32, - last_uploaded_position: &mut u64, - chunk_size: u64, - progress: &mut UploadProgressUpdater, - ) -> Result { - let file_size = match tokio::fs::metadata(file_path).await { - Ok(metadata) => metadata.len(), - Err(e) => return Err(format!("Failed to get file metadata: {e}")), - }; - - // Check if we're at the end of the file - if *last_uploaded_position >= file_size { - return Err("No more data to read - already at end of file".to_string()); - } +/// Creates a stream that reads chunks from a potentially growing file, +/// yielding (part_number, chunk_data) pairs. The first chunk is yielded last +/// to allow for header rewriting after recording completion. +pub fn from_pending_file( + path: PathBuf, + realtime_upload_done: Option>, +) -> impl Stream> { + try_stream! { + let mut part_number = 2; // Start at 2 since part 1 will be yielded last + let mut last_read_position: u64 = 0; + let mut realtime_is_done = realtime_upload_done.as_ref().map(|_| false); + let mut first_chunk_size: Option = None; - // Calculate how much we can actually read - let remaining = file_size - *last_uploaded_position; - let bytes_to_read = std::cmp::min(chunk_size, remaining); + loop { + // Check if realtime recording is done + if !realtime_is_done.unwrap_or(true) { + if let Some(ref realtime_receiver) = realtime_upload_done { + match realtime_receiver.try_recv() { + Ok(_) => realtime_is_done = Some(true), + Err(flume::TryRecvError::Empty) => {}, + Err(_) => { + todo!(); // TODO + // return Err(std::io::Error::new( + // std::io::ErrorKind::Interrupted, + // "Realtime generation failed" + // )); + } + } + } + } - let mut file = tokio::fs::File::open(file_path) - .await - .map_err(|e| format!("Failed to open file: {e}"))?; + // Check file existence and size + if !path.exists() { + todo!(); + // return Err(std::io::Error::new( + // std::io::ErrorKind::NotFound, + // "File no longer exists" + // )); + } - // Log before seeking - debug!( - "Seeking to offset {} for part {} (file size: {}, remaining: {})", - *last_uploaded_position, *part_number, file_size, remaining - ); + let file_size = match tokio::fs::metadata(&path).await { + Ok(metadata) => metadata.len(), + Err(e) => { + // Retry on metadata errors (file might be temporarily locked) + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + }; - // Seek to the position we left off - if let Err(e) = file - .seek(std::io::SeekFrom::Start(*last_uploaded_position)) - .await - { - return Err(format!("Failed to seek in file: {e}")); - } + let new_data_size = file_size.saturating_sub(last_read_position); - // Read exactly bytes_to_read - let mut chunk = vec![0u8; bytes_to_read as usize]; - let mut total_read = 0; + // Determine if we should read a chunk + let should_read_chunk = (new_data_size >= CHUNK_SIZE) + || (new_data_size > 0 && realtime_is_done.unwrap_or(false)) + || (realtime_is_done.is_none() && new_data_size > 0); - while total_read < bytes_to_read as usize { - match file.read(&mut chunk[total_read..]).await { - Ok(0) => break, // EOF - Ok(n) => { - total_read += n; - debug!("Read {n} bytes, total so far: {total_read}/{bytes_to_read}"); - } - Err(e) => return Err(format!("Failed to read chunk from file: {e}")), - } - } + if should_read_chunk { + let chunk_size = std::cmp::min(new_data_size, CHUNK_SIZE); - if total_read == 0 { - return Err("No data to upload for this part.".to_string()); - } + let mut file = tokio::fs::File::open(&path).await?; + file.seek(std::io::SeekFrom::Start(last_read_position)).await?; - // Truncate the buffer to the actual bytes read - chunk.truncate(total_read); + let mut chunk = vec![0u8; chunk_size as usize]; + let mut total_read = 0; - // Basic content‑MD5 for data integrity - let md5_sum = { - let digest = md5::compute(&chunk); - base64::encode(digest.0) - }; + while total_read < chunk_size as usize { + match file.read(&mut chunk[total_read..]).await { + Ok(0) => break, // EOF + Ok(n) => total_read += n, + Err(e) => todo!(), // TODO: return Err(e), + } + } - // Verify file position to ensure we're not experiencing file handle issues - let pos_after_read = file - .seek(std::io::SeekFrom::Current(0)) - .await - .map_err(|e| format!("Failed to get current file position: {e}"))?; + if total_read > 0 { + chunk.truncate(total_read); - let expected_pos = *last_uploaded_position + total_read as u64; - if pos_after_read != expected_pos { - warn!( - "WARNING: File position after read ({pos_after_read}) doesn't match expected position ({expected_pos})" - ); - } + if last_read_position == 0 { + // This is the first chunk - remember its size but don't yield yet + first_chunk_size = Some(total_read as u64); + } else { + // Yield non-first chunks immediately + yield (part_number, Bytes::from(chunk)); + part_number += 1; + } - let file_size = tokio::fs::metadata(file_path) - .await - .map(|m| m.len()) - .unwrap_or(0); - let remaining = file_size - *last_uploaded_position; + last_read_position += total_read as u64; + } + } else if new_data_size == 0 && realtime_is_done.unwrap_or(true) { + // Recording is done and no new data - now yield the first chunk + if let Some(first_size) = first_chunk_size { + let mut file = tokio::fs::File::open(&path).await?; + file.seek(std::io::SeekFrom::Start(0)).await?; + + let mut first_chunk = vec![0u8; first_size as usize]; + let mut total_read = 0; + + while total_read < first_size as usize { + match file.read(&mut first_chunk[total_read..]).await { + Ok(0) => break, + Ok(n) => total_read += n, + Err(e) => todo!(), // TODO: return Err(e), + } + } - debug!( - "File size: {}, Last uploaded: {}, Remaining: {}, chunk_size: {}, part: {}", - file_size, *last_uploaded_position, remaining, chunk_size, *part_number - ); - debug!( - "Uploading part {} ({} bytes), MD5: {}", - *part_number, total_read, md5_sum - ); + if total_read > 0 { + first_chunk.truncate(total_read); + yield (1, Bytes::from(first_chunk)); + } + } + break; + } else { + // Wait for more data + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } +} - let presigned_url = - api::upload_multipart_presign_part(app, video_id, upload_id, *part_number, &md5_sum) - .await?; - - // Upload the chunk with retry - let mut retry_count = 0; - let max_retries = 3; - let mut etag: Option = None; - - while retry_count < max_retries && etag.is_none() { - debug!( - "Sending part {} (attempt {}/{}): {} bytes", - *part_number, - retry_count + 1, - max_retries, - total_read - ); - - match client +/// Takes an incoming stream of bytes and individually uploads them to S3. +/// +/// Note: It's on the caller to ensure the chunks are sized correctly within S3 limits. +fn uploader( + app: AppHandle, + video_id: String, + upload_id: String, + stream: impl Stream>, +) -> impl Stream> { + let client = reqwest::Client::default(); + + try_stream! { + let mut stream = pin!(stream); + let mut prev_part_number = None; + while let Some(item) = stream.next().await { + let (part_number, chunk) = item.map_err(|err| format!("uploader/part/{:?}/fs: {err:?}", prev_part_number.map(|p| p + 1)))?; + prev_part_number = Some(part_number); + let md5_sum = base64::encode(md5::compute(&chunk).0); + let size = chunk.len(); + + let presigned_url = + api::upload_multipart_presign_part(&app, &video_id, &upload_id, part_number, &md5_sum) + .await?; + + // TODO: Retries + let resp = client .put(&presigned_url) .header("Content-MD5", &md5_sum) + .header("Content-Length", chunk.len()) .timeout(Duration::from_secs(120)) - .body(chunk.clone()) + .body(chunk) .send() .await - { - Ok(upload_response) => { - if upload_response.status().is_success() { - if let Some(etag_val) = upload_response.headers().get("ETag") { - let e = etag_val - .to_str() - .unwrap_or("") - .trim_matches('"') - .to_string(); - debug!("Received ETag {} for part {}", e, *part_number); - etag = Some(e); - } else { - error!("No ETag in response for part {}", *part_number); - retry_count += 1; - sleep(Duration::from_secs(2)).await; - } - } else { - error!( - "Failed part {} (status {}). Will retry if possible.", - *part_number, - upload_response.status() - ); - if let Ok(body) = upload_response.text().await { - error!("Error response: {body}"); - } - retry_count += 1; - sleep(Duration::from_secs(2)).await; - } - } - Err(e) => { - debug!( - "Part {} upload error (attempt {}/{}): {}", - *part_number, - retry_count + 1, - max_retries, - e - ); - retry_count += 1; - sleep(Duration::from_secs(2)).await; - } - } - } + .map_err(|err| format!("uploader/part/{part_number}/error: {err:?}"))?; - let etag = match etag { - Some(e) => e, - None => { - return Err(format!( - "Failed to upload part {} after {} attempts", - *part_number, max_retries - )); - } - }; + let etag = resp.headers().get("ETag").as_ref().and_then(|etag| etag.to_str().ok()).map(|v| v.trim_matches('"').to_string()); - // Advance the global progress - *last_uploaded_position += total_read as u64; - debug!( - "After upload: new last_uploaded_position is {} ({}% of file)", - *last_uploaded_position, - (*last_uploaded_position as f64 / file_size as f64 * 100.0) as u32 - ); + match !resp.status().is_success() { + true => Err(format!("uploader/part/{part_number}/error: {}", resp.text().await.unwrap_or_default())), + false => Ok(()), + }?; - progress.update(expected_pos, file_size); - UploadProgressEvent { - video_id: video_id.to_string(), - uploaded: last_uploaded_position.to_string(), - total: file_size.to_string(), + yield UploadedPart { + etag: etag.ok_or_else(|| format!("uploader/part/{part_number}/error: ETag header not found"))?, + part_number, + size, + }; } - .emit(app) - .ok(); - - let part = UploadedPart { - part_number: *part_number, - etag, - size: total_read, - }; - *part_number += 1; - Ok(part) } +} - /// Completes the multipart upload with the stored parts. - /// Logs a final location if the complete call is successful. - async fn finalize_upload( - app: &AppHandle, - file_path: &PathBuf, - video_id: &str, - upload_id: &str, - uploaded_parts: &[UploadedPart], - ) -> Result<(), String> { - debug!( - "Completing multipart upload with {} parts", - uploaded_parts.len() - ); - - if uploaded_parts.is_empty() { - return Err("No parts uploaded before finalizing.".to_string()); - } +/// Monitor the stream to report the upload progress +fn progress( + stream: impl Stream>, +) -> impl Stream> { + // TODO: Reenable progress reporting to the backend but build it on streams directly here. + // let mut progress = UploadProgressUpdater::new(app.clone(), pre_created_video.id.clone()); + + stream! { + let mut stream = pin!(stream); + + while let Some(part) = stream.next().await { + if let Ok(part) = &part { + // progress.update(expected_pos, file_size); + // UploadProgressEvent { + // video_id: video_id.to_string(), + // uploaded: last_uploaded_position.to_string(), + // total: file_size.to_string(), + // } + // .emit(app) + // .ok(); + } - let mut total_bytes_in_parts = 0; - for part in uploaded_parts { - let pn = part.part_number; - let size = part.size; - let etag = &part.etag; - total_bytes_in_parts += part.size; - debug!("Part {pn}: {size} bytes (ETag: {etag})"); + yield part; } - - let file_final_size = tokio::fs::metadata(file_path) - .await - .map(|md| md.len()) - .unwrap_or(0); - - debug!("Sum of all parts: {total_bytes_in_parts} bytes"); - debug!("File size on disk: {file_final_size} bytes"); - debug!("Proceeding with multipart upload completion..."); - - let metadata = build_video_meta(file_path) - .map_err(|e| error!("Failed to get video metadata: {e}")) - .ok(); - - api::upload_multipart_complete(&app, &video_id, &upload_id, &uploaded_parts, metadata) - .await?; - - info!("Multipart upload complete for {video_id}."); - Ok(()) } } From b288365e12fca37238e275500200d4209ad0d826 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 09:38:53 +1000 Subject: [PATCH 11/47] bring back progress tracking to upload --- apps/desktop/src-tauri/src/api.rs | 2 + apps/desktop/src-tauri/src/upload.rs | 66 ++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index 47aed5e73..2d2220fb8 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -93,6 +93,8 @@ pub struct UploadedPart { pub part_number: u32, pub etag: String, pub size: usize, + #[serde(skip)] + pub total_size: u64, } #[derive(Serialize, Debug, Clone)] diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index aa0c7a283..4846cea50 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -595,12 +595,16 @@ impl InstantMultipartUpload { let upload_id = api::upload_multipart_initiate(&app, &video_id).await?; // TODO: Will it be a problem that `ReaderStream` doesn't have a fixed chunk size??? We should fix that!!!! - let parts = progress(uploader( + let parts = progress( app.clone(), video_id.clone(), - upload_id.clone(), - from_pending_file(file_path.clone(), realtime_video_done), - )) + uploader( + app.clone(), + video_id.clone(), + upload_id.clone(), + from_pending_file(file_path.clone(), realtime_video_done), + ), + ) .try_collect::>() .await?; @@ -622,13 +626,23 @@ impl InstantMultipartUpload { } } +struct Chunk { + /// The total size of the file to be uploaded. + /// This can change as the recording grows. + total_size: u64, + /// The part number. `FILE_OFFSET = PART_NUMBER * CHUNK_SIZE`. + part_number: u32, + /// Actual data bytes of this chunk + chunk: Bytes, +} + /// Creates a stream that reads chunks from a potentially growing file, /// yielding (part_number, chunk_data) pairs. The first chunk is yielded last /// to allow for header rewriting after recording completion. pub fn from_pending_file( path: PathBuf, realtime_upload_done: Option>, -) -> impl Stream> { +) -> impl Stream> { try_stream! { let mut part_number = 2; // Start at 2 since part 1 will be yielded last let mut last_read_position: u64 = 0; @@ -703,7 +717,11 @@ pub fn from_pending_file( first_chunk_size = Some(total_read as u64); } else { // Yield non-first chunks immediately - yield (part_number, Bytes::from(chunk)); + yield Chunk { + total_size: file_size, + part_number, + chunk: Bytes::from(chunk), + }; part_number += 1; } @@ -728,7 +746,11 @@ pub fn from_pending_file( if total_read > 0 { first_chunk.truncate(total_read); - yield (1, Bytes::from(first_chunk)); + yield Chunk { + total_size: file_size, + part_number: 1, + chunk: Bytes::from(first_chunk), + }; } } break; @@ -747,7 +769,7 @@ fn uploader( app: AppHandle, video_id: String, upload_id: String, - stream: impl Stream>, + stream: impl Stream>, ) -> impl Stream> { let client = reqwest::Client::default(); @@ -755,7 +777,7 @@ fn uploader( let mut stream = pin!(stream); let mut prev_part_number = None; while let Some(item) = stream.next().await { - let (part_number, chunk) = item.map_err(|err| format!("uploader/part/{:?}/fs: {err:?}", prev_part_number.map(|p| p + 1)))?; + let Chunk { total_size, part_number, chunk } = item.map_err(|err| format!("uploader/part/{:?}/fs: {err:?}", prev_part_number.map(|p| p + 1)))?; prev_part_number = Some(part_number); let md5_sum = base64::encode(md5::compute(&chunk).0); let size = chunk.len(); @@ -786,6 +808,7 @@ fn uploader( etag: etag.ok_or_else(|| format!("uploader/part/{part_number}/error: ETag header not found"))?, part_number, size, + total_size }; } } @@ -793,24 +816,29 @@ fn uploader( /// Monitor the stream to report the upload progress fn progress( + app: AppHandle, + video_id: String, stream: impl Stream>, ) -> impl Stream> { - // TODO: Reenable progress reporting to the backend but build it on streams directly here. - // let mut progress = UploadProgressUpdater::new(app.clone(), pre_created_video.id.clone()); + // TODO: Flatten this implementation into here + let mut progress = UploadProgressUpdater::new(app.clone(), video_id.clone()); + let mut uploaded = 0; stream! { let mut stream = pin!(stream); while let Some(part) = stream.next().await { if let Ok(part) = &part { - // progress.update(expected_pos, file_size); - // UploadProgressEvent { - // video_id: video_id.to_string(), - // uploaded: last_uploaded_position.to_string(), - // total: file_size.to_string(), - // } - // .emit(app) - // .ok(); + uploaded += part.size as u64; + + progress.update(uploaded, part.total_size); + UploadProgressEvent { + video_id: video_id.to_string(), + uploaded: uploaded.to_string(), + total: part.total_size.to_string(), + } + .emit(&app) + .ok(); } yield part; From 7a4d4288adf6cb6231d92bc438ee98c5bdebb7a8 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 12:19:26 +1000 Subject: [PATCH 12/47] implement `from_file` abstraction --- apps/desktop/src-tauri/src/upload.rs | 32 +++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 4846cea50..6a87094d2 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -30,7 +30,7 @@ use tauri::{AppHandle, ipc::Channel}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_specta::Event; use tokio::fs::File; -use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, BufReader}; use tokio::sync::watch; use tokio::task::{self, JoinHandle}; use tokio::time::sleep; @@ -626,7 +626,7 @@ impl InstantMultipartUpload { } } -struct Chunk { +pub struct Chunk { /// The total size of the file to be uploaded. /// This can change as the recording grows. total_size: u64, @@ -636,9 +636,31 @@ struct Chunk { chunk: Bytes, } -/// Creates a stream that reads chunks from a potentially growing file, -/// yielding (part_number, chunk_data) pairs. The first chunk is yielded last -/// to allow for header rewriting after recording completion. +/// Creates a stream that reads chunks from a file, yielding [Chunk]'s. +pub fn from_file(path: PathBuf) -> impl Stream> { + try_stream! { + let file = File::open(path).await?; + let total_size = file.metadata().await?.len(); + let mut file = BufReader::new(file); + + let mut buf = vec![0u8; CHUNK_SIZE as usize]; + let mut part_number = 0; + loop { + part_number += 1; + let n = file.read(&mut buf).await?; + if n == 0 { break; } + yield Chunk { + total_size, + part_number, + chunk: Bytes::copy_from_slice(&buf[..n]), + }; + } + } +} + +/// Creates a stream that reads chunks from a potentially growing file, yielding [Chunk]'s. +/// The first chunk of the file is yielded last to allow for header rewriting after recording completion. +/// This uploader will continually poll the filesystem and wait for the file to stop uploading before flushing the rest. pub fn from_pending_file( path: PathBuf, realtime_upload_done: Option>, From ecddfb7ac9ec321ae100164cbc7c8a4145b9b24e Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 12:36:19 +1000 Subject: [PATCH 13/47] abstract another endpoint into `api` module --- apps/desktop/src-tauri/src/api.rs | 52 ++++++++++++++++ apps/desktop/src-tauri/src/recording.rs | 8 +-- apps/desktop/src-tauri/src/upload.rs | 81 +++++-------------------- 3 files changed, 70 insertions(+), 71 deletions(-) diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index 2d2220fb8..64e052002 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -160,3 +160,55 @@ pub async fn upload_multipart_complete( .map_err(|err| format!("api/upload_multipart_complete/response: {err}")) .map(|data| data.location) } + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PresignedS3PutRequestMethod { + #[allow(unused)] + Post, + Put, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PresignedS3PutRequest { + pub video_id: String, + pub subpath: String, + pub method: PresignedS3PutRequestMethod, + #[serde(flatten)] + pub meta: Option, +} + +pub async fn upload_signed(app: &AppHandle, body: PresignedS3PutRequest) -> Result { + #[derive(Deserialize)] + struct Data { + url: String, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Response { + presigned_put_data: Data, + } + + let resp = app + .authed_api_request("/api/upload/signed", |client, url| { + client.post(url).json(&body) + }) + .await + .map_err(|err| format!("api/upload_signed/request: {err}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!("api/upload_signed/{status}: {error_body}")); + } + + resp.json::() + .await + .map_err(|err| format!("api/upload_signed/response: {err}")) + .map(|data| data.presigned_put_data.url) +} diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 8a48a4d62..8b1da1217 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -24,6 +24,7 @@ use tracing::{debug, error, info}; use crate::{ App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState, RecordingStopped, VideoUploadInfo, + api::{PresignedS3PutRequest, PresignedS3PutRequestMethod}, audio::AppSounds, auth::AuthStore, create_screenshot, @@ -31,9 +32,8 @@ use crate::{ open_external_link, presets::PresetsStore, upload::{ - InstantMultipartUpload, PresignedS3PutRequest, PresignedS3PutRequestMethod, - build_video_meta, bytes_into_stream, compress_image, create_or_get_video, - do_presigned_upload, upload_video, + InstantMultipartUpload, build_video_meta, bytes_into_stream, compress_image, + create_or_get_video, do_presigned_upload, upload_video, }, web_api::ManagerExt, windows::{CapWindowId, ShowCapWindow}, @@ -817,7 +817,7 @@ async fn handle_recording_finish( &app, stream, total_size, - crate::upload::PresignedS3PutRequest { + crate::api::PresignedS3PutRequest { video_id: video_upload_info.id.clone(), subpath: "screenshot/screen-capture.jpg".to_string(), method: PresignedS3PutRequestMethod::Put, diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 6a87094d2..4e46ab3b0 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -1,6 +1,6 @@ // credit @filleduchaos -use crate::api::{S3VideoMeta, UploadedPart}; +use crate::api::{PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}; use crate::web_api::ManagerExt; use crate::{UploadProgress, VideoUploadInfo, api}; use async_stream::{stream, try_stream}; @@ -290,7 +290,7 @@ pub async fn do_presigned_upload( ) -> Result<(), String> { set_progress(UploadPartProgress::Presigning); let client = reqwest::Client::new(); - let presigned_url = presigned_s3_put(app, request).await?; + let presigned_url = api::upload_signed(app, request).await?; set_progress(UploadPartProgress::Uploading { uploaded: 0, @@ -423,55 +423,6 @@ pub async fn create_or_get_video( Ok(config) } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PresignedS3PutRequest { - pub video_id: String, - pub subpath: String, - pub method: PresignedS3PutRequestMethod, - #[serde(flatten)] - pub meta: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "lowercase")] -pub enum PresignedS3PutRequestMethod { - #[allow(unused)] - Post, - Put, -} - -async fn presigned_s3_put(app: &AppHandle, body: PresignedS3PutRequest) -> Result { - #[derive(Deserialize, Debug)] - struct Data { - url: String, - } - - #[derive(Deserialize, Debug)] - #[serde(rename_all = "camelCase")] - struct Wrapper { - presigned_put_data: Data, - } - - let response = app - .authed_api_request("/api/upload/signed", |client, url| { - client.post(url).json(&body) - }) - .await - .map_err(|e| format!("Failed to send request to Next.js handler: {e}"))?; - - if response.status() == StatusCode::UNAUTHORIZED { - return Err("Failed to authenticate request; please log in again".into()); - } - - let Wrapper { presigned_put_data } = response - .json::() - .await - .map_err(|e| format!("Failed to deserialize server response: {e}"))?; - - Ok(presigned_put_data.url) -} - pub fn build_video_meta(path: &PathBuf) -> Result { let input = ffmpeg::format::input(path).map_err(|e| format!("Failed to read input file: {e}"))?; @@ -678,29 +629,25 @@ pub fn from_pending_file( match realtime_receiver.try_recv() { Ok(_) => realtime_is_done = Some(true), Err(flume::TryRecvError::Empty) => {}, - Err(_) => { - todo!(); // TODO - // return Err(std::io::Error::new( - // std::io::ErrorKind::Interrupted, - // "Realtime generation failed" - // )); - } - } + Err(_) => yield Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Realtime generation failed" + ))?, + }; } } // Check file existence and size if !path.exists() { - todo!(); - // return Err(std::io::Error::new( - // std::io::ErrorKind::NotFound, - // "File no longer exists" - // )); + yield Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "File no longer exists" + ))?; } let file_size = match tokio::fs::metadata(&path).await { Ok(metadata) => metadata.len(), - Err(e) => { + Err(_) => { // Retry on metadata errors (file might be temporarily locked) tokio::time::sleep(Duration::from_millis(500)).await; continue; @@ -727,7 +674,7 @@ pub fn from_pending_file( match file.read(&mut chunk[total_read..]).await { Ok(0) => break, // EOF Ok(n) => total_read += n, - Err(e) => todo!(), // TODO: return Err(e), + Err(e) => yield Err(e)?, } } @@ -762,7 +709,7 @@ pub fn from_pending_file( match file.read(&mut first_chunk[total_read..]).await { Ok(0) => break, Ok(n) => total_read += n, - Err(e) => todo!(), // TODO: return Err(e), + Err(e) => yield Err(e)?, } } From c5c8e84d1d7b5a5ba681277032e38f1b96408b3f Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 13:06:49 +1000 Subject: [PATCH 14/47] abstract more API + retry on S3 upload request --- apps/desktop/src-tauri/src/api.rs | 33 +++++++++- apps/desktop/src-tauri/src/upload.rs | 91 ++++++++++----------------- apps/desktop/src-tauri/src/web_api.rs | 2 +- 3 files changed, 64 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index 64e052002..710e8cd47 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -2,12 +2,11 @@ //! This will come part of the EffectTS rewrite work. use serde::{Deserialize, Serialize}; +use serde_json::json; use tauri::AppHandle; use crate::web_api::ManagerExt; -// TODO: Adding retry and backoff logic to everything! - pub async fn upload_multipart_initiate(app: &AppHandle, video_id: &str) -> Result { #[derive(Deserialize)] pub struct Response { @@ -212,3 +211,33 @@ pub async fn upload_signed(app: &AppHandle, body: PresignedS3PutRequest) -> Resu .map_err(|err| format!("api/upload_signed/response: {err}")) .map(|data| data.presigned_put_data.url) } + +pub async fn desktop_video_progress( + app: &AppHandle, + video_id: &str, + uploaded: u64, + total: u64, +) -> Result<(), String> { + let resp = app + .authed_api_request("/api/desktop/video/progress", |client, url| { + client.post(url).json(&json!({ + "videoId": video_id, + "uploaded": uploaded, + "total": total, + "updatedAt": chrono::Utc::now().to_rfc3339() + })) + }) + .await + .map_err(|err| format!("api/desktop_video_progress/request: {err}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!("api/desktop_video_progress/{status}: {error_body}")); + } + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 4e46ab3b0..efe908c58 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -1,41 +1,40 @@ // credit @filleduchaos -use crate::api::{PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}; -use crate::web_api::ManagerExt; -use crate::{UploadProgress, VideoUploadInfo, api}; +use crate::{ + UploadProgress, VideoUploadInfo, api, + api::{PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}, + web_api::ManagerExt, +}; use async_stream::{stream, try_stream}; -use axum::body::Body; +use axum::http::{self, Uri}; use bytes::Bytes; -use cap_project::{RecordingMeta, RecordingMetaInner, UploadState}; +use cap_project::{RecordingMeta, UploadState}; use cap_utils::spawn_actor; use ffmpeg::ffi::AV_TIME_BASE; use flume::Receiver; use futures::{Stream, StreamExt, TryStreamExt, stream}; -use image::ImageReader; -use image::codecs::jpeg::JpegEncoder; +use image::{ImageReader, codecs::jpeg::JpegEncoder}; use reqwest::StatusCode; -use reqwest::header::CONTENT_LENGTH; use serde::{Deserialize, Serialize}; -use serde_json::json; use specta::Type; -use std::error::Error; -use std::io; -use std::path::Path; -use std::pin::pin; use std::{ - path::PathBuf, + io, + path::{Path, PathBuf}, + pin::pin, + str::FromStr, time::{Duration, Instant}, }; use tauri::{AppHandle, ipc::Channel}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_specta::Event; -use tokio::fs::File; -use tokio::io::{AsyncReadExt, AsyncSeekExt, BufReader}; -use tokio::sync::watch; -use tokio::task::{self, JoinHandle}; -use tokio::time::sleep; +use tokio::{ + fs::File, + io::{AsyncReadExt, AsyncSeekExt, BufReader}, + sync::watch, + task::{self, JoinHandle}, +}; use tokio_util::io::ReaderStream; -use tracing::{debug, error, info, trace, warn}; +use tracing::{debug, error, info, trace}; #[derive(Deserialize, Serialize, Clone, Type, Debug)] pub struct S3UploadMeta { @@ -65,15 +64,6 @@ pub struct UploadedImage { pub id: String, } -pub fn upload_v2(app: AppHandle) { - // TODO: Progress reporting - // TODO: Multipart or regular upload automatically sorted out - // TODO: Allow either FS derived or Rust progress derived multipart upload source - // TODO: Support screenshots, or videos - - todo!(); -} - pub struct UploadProgressUpdater { video_state: Option, app: AppHandle, @@ -123,7 +113,7 @@ impl UploadProgressUpdater { tokio::spawn({ let video_id = self.video_id.clone(); async move { - Self::send_api_update(&app, video_id, uploaded, total).await; + api::desktop_video_progress(&app, &video_id, uploaded, total).await; } }); @@ -135,7 +125,7 @@ impl UploadProgressUpdater { let video_id = self.video_id.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(2)).await; - Self::send_api_update(&app, video_id, uploaded, total).await; + api::desktop_video_progress(&app, &video_id, uploaded, total).await; }) }; @@ -144,30 +134,6 @@ impl UploadProgressUpdater { } } } - - async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { - let response = app - .authed_api_request("/api/desktop/video/progress", |client, url| { - client - .post(url) - .header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")) - .json(&json!({ - "videoId": video_id, - "uploaded": uploaded, - "total": total, - "updatedAt": chrono::Utc::now().to_rfc3339() - })) - }) - .await; - - match response { - Ok(resp) if resp.status().is_success() => { - trace!("Progress update sent successfully"); - } - Ok(resp) => error!("Failed to send progress update: {}", resp.status()), - Err(err) => error!("Failed to send progress update: {err}"), - } - } } #[derive(Default, Debug)] @@ -740,8 +706,6 @@ fn uploader( upload_id: String, stream: impl Stream>, ) -> impl Stream> { - let client = reqwest::Client::default(); - try_stream! { let mut stream = pin!(stream); let mut prev_part_number = None; @@ -755,8 +719,17 @@ fn uploader( api::upload_multipart_presign_part(&app, &video_id, &upload_id, part_number, &md5_sum) .await?; - // TODO: Retries - let resp = client + let url = Uri::from_str(&presigned_url).map_err(|err| format!("uploader/part/{part_number}/invalid_url: {err:?}"))?; + let resp = reqwest::Client::builder() + .retry(reqwest::retry::for_host(url.host().unwrap_or("").to_string()).classify_fn(|req_rep| { + if req_rep.status().map_or(false, |s| s.is_server_error()) { + req_rep.retryable() + } else { + req_rep.success() + } + })) + .build() + .map_err(|err| format!("uploader/part/{part_number}/client: {err:?}"))? .put(&presigned_url) .header("Content-MD5", &md5_sum) .header("Content-Length", chunk.len()) diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-tauri/src/web_api.rs index 2970d903f..b87b4721f 100644 --- a/apps/desktop/src-tauri/src/web_api.rs +++ b/apps/desktop/src-tauri/src/web_api.rs @@ -26,7 +26,7 @@ async fn do_authed_request( } ), ) - .header("X-Desktop-Version", env!("CARGO_PKG_VERSION")); + .header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")); if let Some(s) = std::option_env!("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") { req = req.header("x-vercel-protection-bypass", s); From 476a649708988a7cdefc86b113bff377dbbe57b1 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 15:07:16 +1000 Subject: [PATCH 15/47] finish overhauling upload code --- apps/desktop/src-tauri/src/lib.rs | 4 +- apps/desktop/src-tauri/src/recording.rs | 24 +- apps/desktop/src-tauri/src/upload.rs | 421 ++++++++++-------------- 3 files changed, 185 insertions(+), 264 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b7e0ea4d7..91d961591 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1116,7 +1116,7 @@ async fn upload_exported_video( } .await?; - let upload_id = s3_config.id().to_string(); + let upload_id = s3_config.id.to_string(); match upload_video( &app, @@ -1125,7 +1125,7 @@ async fn upload_exported_video( meta.project_path.join("screenshots/display.jpg"), s3_config, metadata, - Some(channel.clone()), + // Some(channel.clone()), ) .await { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 8b1da1217..7713e5e1f 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -13,6 +13,7 @@ use cap_recording::{ }; use cap_rendering::ProjectRecordingsMeta; use cap_utils::{ensure_dir, spawn_actor}; +use futures::stream; use serde::Deserialize; use specta::Type; use std::{path::PathBuf, str::FromStr, sync::Arc, time::Duration}; @@ -24,7 +25,7 @@ use tracing::{debug, error, info}; use crate::{ App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState, RecordingStopped, VideoUploadInfo, - api::{PresignedS3PutRequest, PresignedS3PutRequestMethod}, + api::PresignedS3PutRequestMethod, audio::AppSounds, auth::AuthStore, create_screenshot, @@ -32,8 +33,7 @@ use crate::{ open_external_link, presets::PresetsStore, upload::{ - InstantMultipartUpload, build_video_meta, bytes_into_stream, compress_image, - create_or_get_video, do_presigned_upload, upload_video, + InstantMultipartUpload, build_video_meta, compress_image, create_or_get_video, upload_video, }, web_api::ManagerExt, windows::{CapWindowId, ShowCapWindow}, @@ -263,11 +263,11 @@ pub async fn start_recording( ) .await { - let link = app.make_app_url(format!("/s/{}", s3_config.id())).await; + let link = app.make_app_url(format!("/s/{}", s3_config.id)).await; info!("Pre-created shareable link: {}", link); Some(VideoUploadInfo { - id: s3_config.id().to_string(), + id: s3_config.id.to_string(), link: link.clone(), config: s3_config, }) @@ -807,23 +807,22 @@ async fn handle_recording_finish( let _ = screenshot_task.await; if video_upload_succeeded { - if let Ok(result) = + if let Ok(bytes) = compress_image(display_screenshot).await .map_err(|err| error!("Error compressing thumbnail for instant mode progressive upload: {err}") ) { - let (stream, total_size) = bytes_into_stream(result); - do_presigned_upload( - &app, - stream, - total_size, + crate::upload::singlepart_uploader( + app.clone(), crate::api::PresignedS3PutRequest { video_id: video_upload_info.id.clone(), subpath: "screenshot/screen-capture.jpg".to_string(), method: PresignedS3PutRequestMethod::Put, meta: None, }, - |p| {} // TODO: Progress reporting + bytes.len() as u64, + stream::once(async move { Ok::<_, std::io::Error>(bytes::Bytes::from(bytes)) }), + ) .await .map_err(|err| { @@ -843,7 +842,6 @@ async fn handle_recording_finish( display_screenshot.clone(), video_upload_info.config.clone(), meta, - None, ) .await { diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index efe908c58..2accfc190 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -1,12 +1,12 @@ // credit @filleduchaos use crate::{ - UploadProgress, VideoUploadInfo, api, + VideoUploadInfo, api, api::{PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}, web_api::ManagerExt, }; use async_stream::{stream, try_stream}; -use axum::http::{self, Uri}; +use axum::http::Uri; use bytes::Bytes; use cap_project::{RecordingMeta, UploadState}; use cap_utils::spawn_actor; @@ -22,137 +22,41 @@ use std::{ path::{Path, PathBuf}, pin::pin, str::FromStr, - time::{Duration, Instant}, + time::Duration, }; -use tauri::{AppHandle, ipc::Channel}; +use tauri::AppHandle; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_specta::Event; use tokio::{ fs::File, io::{AsyncReadExt, AsyncSeekExt, BufReader}, - sync::watch, task::{self, JoinHandle}, }; use tokio_util::io::ReaderStream; -use tracing::{debug, error, info, trace}; +use tracing::{debug, error, info}; #[derive(Deserialize, Serialize, Clone, Type, Debug)] pub struct S3UploadMeta { - id: String, -} - -#[derive(Deserialize, Clone, Debug)] -pub struct CreateErrorResponse { - error: String, -} - -impl S3UploadMeta { - pub fn id(&self) -> &str { - &self.id - } -} - -pub struct UploadedVideo { - pub link: String, pub id: String, - #[allow(unused)] - pub config: S3UploadMeta, } -pub struct UploadedImage { +pub struct UploadedItem { pub link: String, pub id: String, + // #[allow(unused)] + // pub config: S3UploadMeta, } -pub struct UploadProgressUpdater { - video_state: Option, - app: AppHandle, +#[derive(Clone, Serialize, Type, tauri_specta::Event)] +pub struct UploadProgressEvent { video_id: String, + // TODO: Account for different states -> Eg. uploading video vs thumbnail + uploaded: String, + total: String, } -struct VideoProgressState { - uploaded: u64, - total: u64, - pending_task: Option>, - last_update_time: Instant, -} - -impl UploadProgressUpdater { - pub fn new(app: AppHandle, video_id: String) -> Self { - Self { - video_state: None, - app, - video_id, - } - } - - pub fn update(&mut self, uploaded: u64, total: u64) { - let should_send_immediately = { - let state = self.video_state.get_or_insert_with(|| VideoProgressState { - uploaded, - total, - pending_task: None, - last_update_time: Instant::now(), - }); - - // Cancel any pending task - if let Some(handle) = state.pending_task.take() { - handle.abort(); - } - - state.uploaded = uploaded; - state.total = total; - state.last_update_time = Instant::now(); - - // Send immediately if upload is complete - uploaded >= total - }; - - let app = self.app.clone(); - if should_send_immediately { - tokio::spawn({ - let video_id = self.video_id.clone(); - async move { - api::desktop_video_progress(&app, &video_id, uploaded, total).await; - } - }); - - // Clear state since upload is complete - self.video_state = None; - } else { - // Schedule delayed update - let handle = { - let video_id = self.video_id.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(2)).await; - api::desktop_video_progress(&app, &video_id, uploaded, total).await; - }) - }; - - if let Some(state) = &mut self.video_state { - state.pending_task = Some(handle); - } - } - } -} - -#[derive(Default, Debug)] -pub enum UploadPartProgress { - #[default] - Presigning, - Uploading { - uploaded: i64, - total: i64, - }, - Done, - Error(String), -} - -#[derive(Default, Debug)] -pub struct UploadVideoProgress { - video: UploadPartProgress, - thumbnail: UploadPartProgress, -} +// a typical recommended chunk size is 5MB (AWS min part size). +const CHUNK_SIZE: u64 = 5 * 1024 * 1024; // 5MB pub async fn upload_video( app: &AppHandle, @@ -161,75 +65,50 @@ pub async fn upload_video( screenshot_path: PathBuf, s3_config: S3UploadMeta, meta: S3VideoMeta, - // TODO: Hook this back up? - channel: Option>, -) -> Result { - let (tx, mut rx) = watch::channel(UploadVideoProgress::default()); - - // TODO: Hook this up properly - tokio::spawn(async move { - loop { - println!("STATUS: {:?}", *rx.borrow_and_update()); - if rx.changed().await.is_err() { - break; - } - } - }); - +) -> Result { info!("Uploading video {video_id}..."); let (stream, total_size) = file_reader_stream(file_path).await?; - let video_upload_fut = do_presigned_upload( - app, - stream, - total_size, + let video_fut = singlepart_uploader( + app.clone(), PresignedS3PutRequest { video_id: video_id.clone(), subpath: "result.mp4".to_string(), method: PresignedS3PutRequestMethod::Put, meta: Some(meta), }, - { - let tx = tx.clone(); - move |p| tx.send_modify(|v| v.video = p) - }, + total_size, + progress( + app.clone(), + video_id, + stream.map(move |v| v.map(move |v| (total_size, v))), + ) + .and_then(|(_, c)| async move { Ok(c) }), ); - let (stream, total_size) = bytes_into_stream(compress_image(screenshot_path).await?); - let thumbnail_upload_fut = do_presigned_upload( - app, - stream, - total_size, + // TODO: We don't report progress on image upload + let bytes = compress_image(screenshot_path).await?; + let thumbnail_fut = singlepart_uploader( + app.clone(), PresignedS3PutRequest { video_id: s3_config.id.clone(), subpath: "screenshot/screen-capture.jpg".to_string(), method: PresignedS3PutRequestMethod::Put, meta: None, }, - { - let tx = tx.clone(); - move |p| tx.send_modify(|v| v.thumbnail = p) - }, + bytes.len() as u64, + stream::once(async move { Ok::<_, std::io::Error>(bytes::Bytes::from(bytes)) }), ); - let (video_result, thumbnail_result): (Result<(), String>, Result<(), String>) = - tokio::join!(video_upload_fut, thumbnail_upload_fut); + let (video_result, thumbnail_result): (Result<_, String>, Result<_, String>) = + tokio::join!(video_fut, thumbnail_fut); - if let Some(err) = video_result.err() { - error!("Failed to upload video for {video_id}: {err}"); - tx.send_modify(|v| v.video = UploadPartProgress::Error(err.clone())); - return Err(err); // TODO: Maybe don't do this - } - if let Some(err) = thumbnail_result.err() { - error!("Failed to upload thumbnail for video {video_id}: {err}"); - tx.send_modify(|v| v.thumbnail = UploadPartProgress::Error(err.clone())); - return Err(err); // TODO: Maybe don't do this - } + // TODO: Reporting errors to the frontend??? + let _ = (video_result?, thumbnail_result?); - Ok(UploadedVideo { + Ok(UploadedItem { link: app.make_app_url(format!("/s/{}", &s3_config.id)).await, id: s3_config.id.clone(), - config: s3_config, }) } @@ -247,53 +126,7 @@ async fn file_reader_stream(path: impl AsRef) -> Result<(ReaderStream> + Send + 'static, - total_size: u64, - request: PresignedS3PutRequest, - mut set_progress: impl FnMut(UploadPartProgress) + Send + 'static, -) -> Result<(), String> { - set_progress(UploadPartProgress::Presigning); - let client = reqwest::Client::new(); - let presigned_url = api::upload_signed(app, request).await?; - - set_progress(UploadPartProgress::Uploading { - uploaded: 0, - total: 0, - }); - let mut uploaded = 0i64; - let total = total_size as i64; - let stream = stream.inspect(move |chunk| { - if let Ok(chunk) = chunk { - uploaded += chunk.len() as i64; - set_progress(UploadPartProgress::Uploading { uploaded, total }); - } - }); - - let response = client - .put(presigned_url) - .header("Content-Length", total_size) - .body(reqwest::Body::wrap_stream(stream)) - .send() - .await - .map_err(|e| format!("Failed to upload file: {e}"))?; - - if !response.status().is_success() { - let status = response.status(); - let error_body = response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Err(format!( - "Failed to upload file. Status: {status}. Body: {error_body}" - )); - } - - Ok(()) -} - -pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result { +pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result { let file_name = file_path .file_name() .and_then(|name| name.to_str()) @@ -303,23 +136,20 @@ pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result().await { if error.error == "upgrade_required" { return Err( @@ -444,25 +279,6 @@ pub async fn compress_image(path: PathBuf) -> Result, String> { .map_err(|e| format!("Failed to compress image: {e}"))? } -pub fn bytes_into_stream( - bytes: Vec, -) -> (impl Stream>, u64) { - let total_size = bytes.len(); - let stream = stream::once(async move { Ok::<_, std::io::Error>(bytes::Bytes::from(bytes)) }); - (stream, total_size as u64) -} - -#[derive(Clone, Serialize, Type, tauri_specta::Event)] -pub struct UploadProgressEvent { - video_id: String, - // TODO: Account for different states -> Eg. uploading video vs thumbnail - uploaded: String, - total: String, -} - -// a typical recommended chunk size is 5MB (AWS min part size). -const CHUNK_SIZE: u64 = 5 * 1024 * 1024; // 5MB - pub struct InstantMultipartUpload { pub handle: tokio::task::JoinHandle>, } @@ -515,11 +331,11 @@ impl InstantMultipartUpload { let parts = progress( app.clone(), video_id.clone(), - uploader( + multipart_uploader( app.clone(), video_id.clone(), upload_id.clone(), - from_pending_file(file_path.clone(), realtime_video_done), + from_pending_file_to_chunks(file_path.clone(), realtime_video_done), ), ) .try_collect::>() @@ -554,7 +370,8 @@ pub struct Chunk { } /// Creates a stream that reads chunks from a file, yielding [Chunk]'s. -pub fn from_file(path: PathBuf) -> impl Stream> { +#[allow(unused)] +pub fn from_file_to_chunks(path: PathBuf) -> impl Stream> { try_stream! { let file = File::open(path).await?; let total_size = file.metadata().await?.len(); @@ -578,7 +395,7 @@ pub fn from_file(path: PathBuf) -> impl Stream> { /// Creates a stream that reads chunks from a potentially growing file, yielding [Chunk]'s. /// The first chunk of the file is yielded last to allow for header rewriting after recording completion. /// This uploader will continually poll the filesystem and wait for the file to stop uploading before flushing the rest. -pub fn from_pending_file( +pub fn from_pending_file_to_chunks( path: PathBuf, realtime_upload_done: Option>, ) -> impl Stream> { @@ -700,7 +517,7 @@ pub fn from_pending_file( /// Takes an incoming stream of bytes and individually uploads them to S3. /// /// Note: It's on the caller to ensure the chunks are sized correctly within S3 limits. -fn uploader( +fn multipart_uploader( app: AppHandle, video_id: String, upload_id: String, @@ -756,34 +573,140 @@ fn uploader( } } +/// Takes an incoming stream of bytes and streams them to an S3 object. +pub async fn singlepart_uploader( + app: AppHandle, + request: PresignedS3PutRequest, + total_size: u64, + stream: impl Stream> + Send + 'static, +) -> Result<(), String> { + let presigned_url = api::upload_signed(&app, request).await?; + + let url = Uri::from_str(&presigned_url) + .map_err(|err| format!("singlepart_uploader/invalid_url: {err:?}"))?; + let resp = reqwest::Client::builder() + .retry( + reqwest::retry::for_host(url.host().unwrap_or("").to_string()).classify_fn( + |req_rep| { + if req_rep.status().map_or(false, |s| s.is_server_error()) { + req_rep.retryable() + } else { + req_rep.success() + } + }, + ), + ) + .build() + .map_err(|err| format!("singlepart_uploader/client: {err:?}"))? + .put(&presigned_url) + .header("Content-Length", total_size) + .timeout(Duration::from_secs(120)) + .body(reqwest::Body::wrap_stream(stream)) + .send() + .await + .map_err(|err| format!("singlepart_uploader/error: {err:?}"))?; + + match !resp.status().is_success() { + true => Err(format!( + "singlepart_uploader/error: {}", + resp.text().await.unwrap_or_default() + )), + false => Ok(()), + }?; + + Ok(()) +} + +pub trait UploadedChunk { + /// total size of the file + fn total(&self) -> u64; + + /// size of the current chunk + fn size(&self) -> u64; +} + +impl UploadedChunk for UploadedPart { + fn total(&self) -> u64 { + self.total_size + } + + fn size(&self) -> u64 { + self.size as u64 + } +} + +impl UploadedChunk for Chunk { + fn total(&self) -> u64 { + self.total_size + } + + fn size(&self) -> u64 { + self.chunk.len() as u64 + } +} + +impl UploadedChunk for (u64, Bytes) { + fn total(&self) -> u64 { + self.0 + } + + fn size(&self) -> u64 { + self.1.len() as u64 + } +} + /// Monitor the stream to report the upload progress -fn progress( +fn progress( app: AppHandle, video_id: String, - stream: impl Stream>, -) -> impl Stream> { - // TODO: Flatten this implementation into here - let mut progress = UploadProgressUpdater::new(app.clone(), video_id.clone()); - let mut uploaded = 0; + stream: impl Stream>, +) -> impl Stream> { + let mut uploaded = 0u64; + let mut pending_task: Option> = None; stream! { let mut stream = pin!(stream); - while let Some(part) = stream.next().await { - if let Ok(part) = &part { - uploaded += part.size as u64; + while let Some(chunk) = stream.next().await { + if let Ok(chunk) = &chunk { + uploaded += chunk.size(); + let total = chunk.total(); + + // Cancel any pending task + if let Some(handle) = pending_task.take() { + handle.abort(); + } + + let should_send_immediately = uploaded >= total; + + if should_send_immediately { + // Send immediately if upload is complete + let app_clone = app.clone(); + let video_id_clone = video_id.clone(); + tokio::spawn(async move { + api::desktop_video_progress(&app_clone, &video_id_clone, uploaded, total).await.ok(); + }); + } else { + // Schedule delayed update + let app_clone = app.clone(); + let video_id_clone = video_id.clone(); + pending_task = Some(tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + api::desktop_video_progress(&app_clone, &video_id_clone, uploaded, total).await.ok(); + })); + } - progress.update(uploaded, part.total_size); + // Emit progress event for the app frontend UploadProgressEvent { - video_id: video_id.to_string(), + video_id: video_id.clone(), uploaded: uploaded.to_string(), - total: part.total_size.to_string(), + total: total.to_string(), } .emit(&app) .ok(); } - yield part; + yield chunk; } } } From c411b0229801fe4844829df8a882e499be58a242 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 16:19:03 +1000 Subject: [PATCH 16/47] restructure project file again --- apps/desktop/src-tauri/src/lib.rs | 68 ++++++++++------------- apps/desktop/src-tauri/src/recording.rs | 32 +++++++++-- apps/desktop/src/utils/tauri.ts | 9 +-- crates/export/src/lib.rs | 3 +- crates/project/src/meta.rs | 64 ++++++++++++++------- crates/recording/src/instant_recording.rs | 2 +- crates/recording/src/studio_recording.rs | 1 + 7 files changed, 107 insertions(+), 72 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 91d961591..8e99b17ac 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -30,8 +30,8 @@ use auth::{AuthStore, AuthenticationInvalid, Plan}; use camera::CameraPreviewState; use cap_editor::{EditorInstance, EditorState}; use cap_project::{ - ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, XY, - ZoomSegment, + ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, + StudioRecordingStatus, XY, ZoomSegment, }; use cap_recording::{ RecordingMode, @@ -901,21 +901,24 @@ async fn get_video_metadata(path: PathBuf) -> Result { vec![path.join("content/output.mp4")] } - RecordingMetaInner::Studio(meta) => match meta { - StudioRecordingMeta::SingleSegment { segment } => { - vec![recording_meta.path(&segment.display.path)] + RecordingMetaInner::Studio(meta) => { + let status = meta.status(); + if let StudioRecordingStatus::Failed { .. } = status { + return Err(format!("Unable to get metadata on failed recording")); + } else if let StudioRecordingStatus::InProgress = status { + return Err(format!("Unable to get metadata on in-progress recording")); + } + + match meta { + StudioRecordingMeta::SingleSegment { segment } => { + vec![recording_meta.path(&segment.display.path)] + } + StudioRecordingMeta::MultipleSegments { inner } => inner + .segments + .iter() + .map(|s| recording_meta.path(&s.display.path)) + .collect(), } - StudioRecordingMeta::MultipleSegments { inner, .. } => inner - .segments - .iter() - .map(|s| recording_meta.path(&s.display.path)) - .collect(), - }, - RecordingMetaInner::InProgress { .. } => { - return Err(format!("Unable to get metadata on in-progress recording")); - } - RecordingMetaInner::Failed { .. } => { - return Err(format!("Unable to get metadata on failed recording")); } }; @@ -1069,11 +1072,7 @@ async fn upload_exported_video( let mut meta = RecordingMeta::load_for_project(&path).map_err(|v| v.to_string())?; - let Some(output_path) = meta.output_path() else { - notifications::send_notification(&app, notifications::NotificationType::UploadFailed); - return Err("Failed to upload video: Recording failed to complete".to_string()); - }; - + let output_path = meta.output_path(); if !output_path.exists() { notifications::send_notification(&app, notifications::NotificationType::UploadFailed); return Err("Failed to upload video: Rendered video not found".to_string()); @@ -1401,17 +1400,6 @@ async fn save_file_dialog( } } -// #[derive(Serialize, specta::Type)] -// #[serde(tag = "status")] -// pub enum RecordingStatus { -// Recording, -// Failed { error: String }, -// Complete { mode: RecordingMode }, -// } -// -// #[serde(flatten)] -// pub status: RecordingStatus, - #[derive(Serialize, specta::Type)] pub struct RecordingMetaWithMode { #[serde(flatten)] @@ -1425,8 +1413,6 @@ impl RecordingMetaWithMode { mode: match &inner.inner { RecordingMetaInner::Studio(_) => Some(RecordingMode::Studio), RecordingMetaInner::Instant(_) => Some(RecordingMode::Instant), - RecordingMetaInner::InProgress { .. } => None, - RecordingMetaInner::Failed { .. } => None, }, inner, } @@ -2498,9 +2484,15 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { let meta = RecordingMeta::load_for_project(path).map_err(|v| v.to_string())?; match &meta.inner { - RecordingMetaInner::Studio(_) => { - let project_path = path.to_path_buf(); + RecordingMetaInner::Studio(meta) => { + let status = meta.status(); + if let StudioRecordingStatus::Failed { .. } = status { + return Err(format!("Unable to open failed recording")); + } else if let StudioRecordingStatus::InProgress = status { + return Err(format!("Recording in progress")); + } + let project_path = path.to_path_buf(); tokio::spawn(async move { ShowCapWindow::Editor { project_path }.show(&app).await }); } RecordingMetaInner::Instant(_) => { @@ -2515,10 +2507,6 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { } } } - RecordingMetaInner::InProgress { .. } => return Err(format!("Recording in progress")), - RecordingMetaInner::Failed { .. } => { - return Err(format!("Unable to open failed recording")); - } } Ok(()) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 7713e5e1f..79244aeba 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1,8 +1,8 @@ use cap_fail::fail; use cap_project::{ - CursorClickEvent, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, - SharingMeta, StudioRecordingMeta, TimelineConfiguration, TimelineSegment, ZoomMode, - ZoomSegment, cursor::CursorEvents, + CursorClickEvent, InstantRecordingMeta, MultipleSegments, Platform, ProjectConfiguration, + RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, StudioRecordingStatus, + TimelineConfiguration, TimelineSegment, ZoomMode, ZoomSegment, cursor::CursorEvents, }; use cap_recording::{ RecordingError, RecordingMode, @@ -297,7 +297,20 @@ pub async fn start_recording( project_path: recording_dir.clone(), sharing: None, // TODO: Is this gonna be problematic as it was previously always set pretty_name: format!("{target_name} {date_time}"), - inner: RecordingMetaInner::InProgress { recording: true }, + inner: match inputs.mode { + RecordingMode::Studio => { + RecordingMetaInner::Studio(StudioRecordingMeta::MultipleSegments { + inner: MultipleSegments { + segments: Default::default(), + cursors: Default::default(), + status: Some(StudioRecordingStatus::InProgress), + }, + }) + } + RecordingMode::Instant => { + RecordingMetaInner::Instant(InstantRecordingMeta::InProgress { recording: true }) + } + }, upload: None, }; @@ -688,7 +701,16 @@ async fn handle_recording_end( Err(error) => { // TODO: Error handling -> Can we reuse `RecordingMeta` too? let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); - project_meta.inner = RecordingMetaInner::Failed { error }; + match &mut project_meta.inner { + RecordingMetaInner::Studio(meta) => { + if let StudioRecordingMeta::MultipleSegments { inner } = meta { + inner.status = Some(StudioRecordingStatus::Failed { error }); + } + } + RecordingMetaInner::Instant(meta) => { + *meta = InstantRecordingMeta::Failed { error }; + } + } project_meta.save_for_project().unwrap(); None diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 7e0d6f060..89f1c92fe 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -394,7 +394,7 @@ export type Hotkey = { code: string; meta: boolean; ctrl: boolean; alt: boolean; export type HotkeyAction = "startStudioRecording" | "startInstantRecording" | "stopRecording" | "restartRecording" | "openRecordingPicker" | "openRecordingPickerDisplay" | "openRecordingPickerWindow" | "openRecordingPickerArea" | "other" export type HotkeysConfiguration = { show: boolean } export type HotkeysStore = { hotkeys: { [key in HotkeyAction]: Hotkey } } -export type InstantRecordingMeta = { fps: number; sample_rate: number | null } +export type InstantRecordingMeta = { recording: boolean } | { error: string } | { fps: number; sample_rate: number | null } export type JsonValue = [T] export type LogicalBounds = { position: LogicalPosition; size: LogicalSize } export type LogicalPosition = { x: number; y: number } @@ -403,7 +403,7 @@ export type MainWindowRecordingStartBehaviour = "close" | "minimise" export type ModelIDType = string export type Mp4ExportSettings = { fps: number; resolution_base: XY; compression: ExportCompression } export type MultipleSegment = { display: VideoMeta; camera?: VideoMeta | null; mic?: AudioMeta | null; system_audio?: AudioMeta | null; cursor?: string | null } -export type MultipleSegments = { segments: MultipleSegment[]; cursors: Cursors } +export type MultipleSegments = { segments: MultipleSegment[]; cursors: Cursors; status?: StudioRecordingStatus | null } export type NewNotification = { title: string; body: string; is_error: boolean } export type NewScreenshotAdded = { path: string } export type NewStudioRecordingAdded = { path: string } @@ -422,8 +422,8 @@ export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingDeleted = { path: string } export type RecordingEvent = { variant: "Countdown"; value: number } | { variant: "Started" } | { variant: "Stopped" } | { variant: "Failed"; error: string } -export type RecordingMeta = ({ recording: boolean } | { error: string } | StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null } -export type RecordingMetaWithMode = (({ recording: boolean } | { error: string } | StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null }) & { mode: RecordingMode | null } +export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null } +export type RecordingMetaWithMode = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null }) & { mode: RecordingMode | null } export type RecordingMode = "studio" | "instant" export type RecordingOptionsChanged = null export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean } @@ -448,6 +448,7 @@ export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; aud export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode } export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } +export type StudioRecordingStatus = "inProgress" | { failed: { error: string } } | "completed" export type TargetUnderCursor = { display_id: DisplayId | null; window: WindowUnderCursor | null } export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[] } export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 7218144aa..8a02a6708 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -99,8 +99,7 @@ impl ExporterBuilder { let output_path = self .output_path - .or_else(|| recording_meta.output_path()) - .ok_or(Error::RecordingFailed)?; + .unwrap_or_else(|| recording_meta.output_path()); if let Some(parent) = output_path.parent() { std::fs::create_dir_all(parent) diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index d9f626b1e..00eddf64c 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -85,16 +85,26 @@ pub enum UploadState { #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(untagged, rename_all = "camelCase")] pub enum RecordingMetaInner { - InProgress { recording: bool }, - Failed { error: String }, Studio(StudioRecordingMeta), Instant(InstantRecordingMeta), } +impl specta::Flatten for RecordingMetaInner {} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct InstantRecordingMeta { - pub fps: u32, - pub sample_rate: Option, +#[serde(untagged, rename_all = "camelCase")] +pub enum InstantRecordingMeta { + InProgress { + // This field means nothing and is just because this enum is untagged. + recording: bool, + }, + Failed { + error: String, + }, + Complete { + fps: u32, + sample_rate: Option, + }, } impl RecordingMeta { @@ -142,14 +152,10 @@ impl RecordingMeta { config } - pub fn output_path(&self) -> Option { + pub fn output_path(&self) -> PathBuf { match &self.inner { - RecordingMetaInner::Instant(_) => Some(self.project_path.join("content/output.mp4")), - RecordingMetaInner::Studio(_) => { - Some(self.project_path.join("output").join("result.mp4")) - } - RecordingMetaInner::InProgress { .. } => None, - RecordingMetaInner::Failed { .. } => None, + RecordingMetaInner::Instant(_) => self.project_path.join("content/output.mp4"), + RecordingMetaInner::Studio(_) => self.project_path.join("output").join("result.mp4"), } } @@ -177,12 +183,20 @@ pub enum StudioRecordingMeta { } impl StudioRecordingMeta { + pub fn status(&self) -> StudioRecordingStatus { + match self { + StudioRecordingMeta::SingleSegment { .. } => StudioRecordingStatus::Completed, + StudioRecordingMeta::MultipleSegments { inner } => inner + .status + .clone() + .unwrap_or(StudioRecordingStatus::Completed), + } + } + pub fn camera_path(&self) -> Option { match self { - StudioRecordingMeta::SingleSegment { segment } => { - segment.camera.as_ref().map(|c| c.path.clone()) - } - StudioRecordingMeta::MultipleSegments { inner, .. } => inner + Self::SingleSegment { segment } => segment.camera.as_ref().map(|c| c.path.clone()), + Self::MultipleSegments { inner, .. } => inner .segments .first() .and_then(|s| s.camera.as_ref().map(|c| c.path.clone())), @@ -191,8 +205,8 @@ impl StudioRecordingMeta { pub fn min_fps(&self) -> u32 { match self { - StudioRecordingMeta::SingleSegment { segment } => segment.display.fps, - StudioRecordingMeta::MultipleSegments { inner, .. } => { + Self::SingleSegment { segment } => segment.display.fps, + Self::MultipleSegments { inner, .. } => { inner.segments.iter().map(|s| s.display.fps).min().unwrap() } } @@ -200,8 +214,8 @@ impl StudioRecordingMeta { pub fn max_fps(&self) -> u32 { match self { - StudioRecordingMeta::SingleSegment { segment } => segment.display.fps, - StudioRecordingMeta::MultipleSegments { inner, .. } => { + Self::SingleSegment { segment } => segment.display.fps, + Self::MultipleSegments { inner, .. } => { inner.segments.iter().map(|s| s.display.fps).max().unwrap() } } @@ -227,6 +241,16 @@ pub struct MultipleSegments { pub segments: Vec, #[serde(default, skip_serializing_if = "Cursors::is_empty")] pub cursors: Cursors, + #[serde(default)] + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum StudioRecordingStatus { + InProgress, + Failed { error: String }, + Completed, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index f67fb8fbf..fb226ff7d 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -480,7 +480,7 @@ async fn stop_recording(actor: Actor) -> CompletedRecording { CompletedRecording { project_path: actor.recording_dir.clone(), - meta: InstantRecordingMeta { + meta: InstantRecordingMeta::Complete { fps: actor.video_info.fps(), sample_rate: None, }, diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 3aefa361c..c7d02f8ec 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -603,6 +603,7 @@ async fn stop_recording( }) .collect(), ), + status: Some(StudioRecordingStatus::Completed), }, }; From cc1d0de410d8fc14f38d934c5e39defe04de03ac Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 16:44:40 +1000 Subject: [PATCH 17/47] UI for uploading state + errors --- .../(window-chrome)/settings/recordings.tsx | 146 ++++++++++++------ 1 file changed, 98 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 0f8c827dd..1090b1ae8 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -119,10 +119,15 @@ export default function Recordings() { }; const [uploadProgress, setUploadProgress] = createStore< - Record + Record >({}); // TODO: Cleanup subscription events.uploadProgressEvent.listen((e) => { + // TODO: Make this cleanup + // if (e.payload.uploaded === e.payload.total) { + // setUploadProgress(e.payload.video_id, undefined); + // } + setUploadProgress( e.payload.video_id, Number(e.payload.uploaded) / Number(e.payload.total), @@ -145,8 +150,6 @@ export default function Recordings() {

} > -
{JSON.stringify(uploadProgress)}
-
{(tab) => ( @@ -183,6 +186,11 @@ export default function Recordings() { onCopyVideoToClipboard={() => handleCopyVideoToClipboard(recording.path) } + uploadProgress={ + recording.meta.sharing?.id + ? uploadProgress[recording.meta.sharing.id] + : undefined + } /> )} @@ -199,6 +207,7 @@ function RecordingItem(props: { onOpenFolder: () => void; onOpenEditor: () => void; onCopyVideoToClipboard: () => void; + uploadProgress: number | undefined; }) { const [imageExists, setImageExists] = createSignal(true); const mode = () => props.recording.meta.mode || "other"; // TODO: Fix this @@ -224,23 +233,45 @@ function RecordingItem(props: { />
- {"recording" in props.recording.meta ? "TRUE" : "FALSE"} - {"error" in props.recording.meta ? props.recording.meta.error : ""} - {JSON.stringify(props.recording.meta?.upload || {})} - {props.recording.prettyName} -
- {mode() === "instant" ? ( - - ) : ( - - )} -

{firstLetterUpperCase()}

+
+
+ {mode() === "instant" ? ( + + ) : ( + + )} +

{firstLetterUpperCase()}

+
+ + + {(error) => ( +
+ {/* TODO: Get a proper icon here */} + {/* TODO: Show the error in a tooltip */} + +

FAILED: {error()}

+
+ )} +
+ + {/* TODO: Show a badge for when recording fails */}
@@ -248,12 +279,33 @@ function RecordingItem(props: { {(sharing) => ( - shell.open(sharing().link)} - > - - + <> + {/* // TODO: Add something here like instant mode */} + {/* reupload.mutate()} + > + + + } + > + + */} + + shell.open(sharing().link)} + > + + + )} {(_) => { - const [progress, setProgress] = createSignal(0); const reupload = createMutation(() => ({ - mutationFn: async () => { - setProgress(0); - return await commands.uploadExportedVideo( + mutationFn: () => + commands.uploadExportedVideo( props.recording.path, "Reupload", - new Channel((progress) => - setProgress(Math.round(progress.progress * 100)), - ), - ); - }, - onSettled: () => setProgress(0), + new Channel((progress) => {}), + ), })); return ( {(sharing) => ( <> - reupload.mutate()} + reupload.mutate()} + > + + + } > - {reupload.isPending ? ( - - ) : ( - - )} - + + + shell.open(sharing().link)} From 8356ec089a632e9853ff1521ae83f2a7f8a623e2 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 16:48:09 +1000 Subject: [PATCH 18/47] show recording and pending status --- .../(window-chrome)/settings/recordings.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 1090b1ae8..44d9c6047 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -234,7 +234,7 @@ function RecordingItem(props: {
{props.recording.prettyName} -
+
{firstLetterUpperCase()}

+ +
+ {/* TODO: Get a proper icon here */} + +

RECORDING

+
+
+ + {/* TODO: Account for studio mode vs instant mode error */} (
{/* TODO: Get a proper icon here */} From 2da924c800d6b9c1200574f41656497a6d04093f Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 17:07:36 +1000 Subject: [PATCH 19/47] merge in changes from #1077 --- apps/desktop/src-tauri/src/api.rs | 1 + apps/desktop/src-tauri/src/recording.rs | 27 ++++---- .../(window-chrome)/settings/recordings.tsx | 10 ++- apps/web/actions/video/upload.ts | 17 ++--- .../caps/components/CapCard/CapCard.tsx | 69 +++++++++++++------ apps/web/app/api/desktop/[...route]/video.ts | 3 +- packages/web-backend/src/Videos/VideosRepo.ts | 10 ++- packages/web-backend/src/Videos/index.ts | 1 - 8 files changed, 89 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index 710e8cd47..92a2ba203 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -9,6 +9,7 @@ use crate::web_api::ManagerExt; pub async fn upload_multipart_initiate(app: &AppHandle, video_id: &str) -> Result { #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] pub struct Response { upload_id: String, } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 79244aeba..de4ebe3fb 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -251,7 +251,7 @@ pub async fn start_recording( match AuthStore::get(&app).ok().flatten() { Some(_) => { // Pre-create the video and get the shareable link - if let Ok(s3_config) = create_or_get_video( + let s3_config = create_or_get_video( &app, false, None, @@ -262,18 +262,19 @@ pub async fn start_recording( None, ) .await - { - let link = app.make_app_url(format!("/s/{}", s3_config.id)).await; - info!("Pre-created shareable link: {}", link); - - Some(VideoUploadInfo { - id: s3_config.id.to_string(), - link: link.clone(), - config: s3_config, - }) - } else { - None - } + .map_err(|err| { + error!("Error creating instant mode video: {err}"); + err + })?; + + let link = app.make_app_url(format!("/s/{}", s3_config.id)).await; + info!("Pre-created shareable link: {}", link); + + Some(VideoUploadInfo { + id: s3_config.id.to_string(), + link: link.clone(), + config: s3_config, + }) } // Allow the recording to proceed without error for any signed-in user _ => { diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 44d9c6047..ed0455b84 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -13,6 +13,7 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener"; import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; import { + createEffect, createMemo, createSignal, For, @@ -133,6 +134,7 @@ export default function Recordings() { Number(e.payload.uploaded) / Number(e.payload.total), ); }); + createEffect(() => console.log({ ...uploadProgress })); return (
@@ -187,9 +189,11 @@ export default function Recordings() { handleCopyVideoToClipboard(recording.path) } uploadProgress={ - recording.meta.sharing?.id - ? uploadProgress[recording.meta.sharing.id] - : undefined + // TODO: Fix this + Object.values(uploadProgress)[0] + // recording.meta.sharing?.id + // ? uploadProgress[recording.meta.sharing.id] + // : undefined } /> )} diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 2bfa2f541..6ac5ce1f7 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -164,6 +164,7 @@ export async function createVideoAndGetUploadUrl({ isScreenshot = false, isUpload = false, folderId, + supportsUploadProgress = false, }: { videoId?: Video.VideoId; duration?: number; @@ -173,17 +174,16 @@ export async function createVideoAndGetUploadUrl({ isScreenshot?: boolean; isUpload?: boolean; folderId?: Folder.FolderId; + // TODO: Remove this once we are happy with it's stability + supportsUploadProgress?: boolean; }) { const user = await getCurrentUser(); - if (!user) { - throw new Error("Unauthorized"); - } + if (!user) throw new Error("Unauthorized"); try { - if (!userIsPro(user) && duration && duration > 300) { + if (!userIsPro(user) && duration && duration > 300) throw new Error("upgrade_required"); - } const [customBucket] = await db() .select() @@ -237,9 +237,10 @@ export async function createVideoAndGetUploadUrl({ await db().insert(videos).values(videoData); - await db().insert(videoUploads).values({ - videoId: idToUse, - }); + if (supportsUploadProgress) + await db().insert(videoUploads).values({ + videoId: idToUse, + }); const fileKey = `${user.id}/${idToUse}/${ isScreenshot ? "screenshot/screen-capture.jpg" : "result.mp4" diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index 07392d593..c3bc2f74b 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -292,7 +292,7 @@ export const CapCard = ({ onDragStart={handleDragStart} onDragEnd={handleDragEnd} className={clsx( - "flex relative overflow-hidden transition-colors duration-200 flex-col gap-4 w-full h-full rounded-xl cursor-default bg-gray-1 border border-gray-3 group", + "flex relative overflow-hidden transition-colors duration-200 flex-col gap-4 w-full h-full rounded-xl cursor-default bg-gray-1 border border-gray-3 group z-10", isSelected ? "!border-blue-10" : anyCapSelected @@ -314,7 +314,7 @@ export const CapCard = ({ : isDropdownOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100", - "top-2 right-2 flex-col gap-2 z-[20]", + "top-2 right-2 flex-col gap-2 z-20", )} > { return downloadMutation.isPending ? ( @@ -421,7 +421,7 @@ export const CapCard = ({ error: "Failed to duplicate cap", }); }} - disabled={duplicateMutation.isPending} + disabled={duplicateMutation.isPending || cap.hasActiveUpload} className="flex gap-2 items-center rounded-lg" > @@ -503,26 +503,53 @@ export const CapCard = ({ anyCapSelected && "cursor-pointer pointer-events-none", )} onClick={(e) => { - if (isDeleting) { - e.preventDefault(); - } + if (isDeleting) e.preventDefault(); }} href={`/s/${cap.id}`} > - + {uploadProgress ? ( +
+
+ {uploadProgress.status === "failed" ? ( +
+
+ +
+

+ Upload failed +

+
+ ) : ( +
+ +
+ )} +
+
+ ) : ( + + )} {uploadProgress && (
diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 29a007422..2a74f5230 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -85,7 +85,7 @@ app.get( .from(videos) .where(eq(videos.id, Video.VideoId.make(videoId))); - if (video) { + if (video) return c.json({ id: video.id, // All deprecated @@ -93,7 +93,6 @@ app.get( aws_region: "n/a", aws_bucket: "n/a", }); - } } const idToUse = Video.VideoId.make(nanoId()); diff --git a/packages/web-backend/src/Videos/VideosRepo.ts b/packages/web-backend/src/Videos/VideosRepo.ts index daa597ff6..0e18cc8f4 100644 --- a/packages/web-backend/src/Videos/VideosRepo.ts +++ b/packages/web-backend/src/Videos/VideosRepo.ts @@ -45,7 +45,15 @@ export class VideosRepo extends Effect.Service()("VideosRepo", { }); const delete_ = (id: Video.VideoId) => - db.execute((db) => db.delete(Db.videos).where(Dz.eq(Db.videos.id, id))); + db.execute( + async (db) => + await Promise.all([ + db.delete(Db.videos).where(Dz.eq(Db.videos.id, id)), + db + .delete(Db.videoUploads) + .where(Dz.eq(Db.videoUploads.videoId, id)), + ]), + ); const create = (data: CreateVideoInput) => Effect.gen(function* () { diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index fc7701780..bce182983 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -5,7 +5,6 @@ import { Array, Effect, Option, pipe } from "effect"; import { Database } from "../Database.ts"; import { S3Buckets } from "../S3Buckets/index.ts"; -import { S3BucketAccess } from "../S3Buckets/S3BucketAccess.ts"; import { VideosPolicy } from "./VideosPolicy.ts"; import { VideosRepo } from "./VideosRepo.ts"; From 19c495c03705fae3988bc4c70a5f5319706bb210 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 17:55:29 +1000 Subject: [PATCH 20/47] upload progress working in sync pogggg --- apps/desktop/src-tauri/src/lib.rs | 6 +--- apps/desktop/src-tauri/src/recording.rs | 4 +-- apps/desktop/src-tauri/src/upload.rs | 36 +++++++++++++------ .../(window-chrome)/settings/recordings.tsx | 17 +++++---- apps/desktop/src/utils/tauri.ts | 2 +- crates/project/src/meta.rs | 7 ++-- 6 files changed, 43 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8e99b17ac..1016595b1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1115,16 +1115,12 @@ async fn upload_exported_video( } .await?; - let upload_id = s3_config.id.to_string(); - match upload_video( &app, - upload_id.clone(), + s3_config.id.clone(), output_path, meta.project_path.join("screenshots/display.jpg"), - s3_config, metadata, - // Some(channel.clone()), ) .await { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index de4ebe3fb..2a8a31806 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -296,7 +296,6 @@ pub async fn start_recording( let meta = RecordingMeta { platform: Some(Platform::default()), project_path: recording_dir.clone(), - sharing: None, // TODO: Is this gonna be problematic as it was previously always set pretty_name: format!("{target_name} {date_time}"), inner: match inputs.mode { RecordingMode::Studio => { @@ -312,6 +311,7 @@ pub async fn start_recording( RecordingMetaInner::Instant(InstantRecordingMeta::InProgress { recording: true }) } }, + sharing: None, upload: None, }; @@ -446,7 +446,6 @@ pub async fn start_recording( let progressive_upload = InstantMultipartUpload::spawn( app_handle, - id.clone(), recording_dir.join("content/output.mp4"), video_upload_info.clone(), Some(finish_upload_rx), @@ -863,7 +862,6 @@ async fn handle_recording_finish( video_upload_info.id.clone(), output_path, display_screenshot.clone(), - video_upload_info.config.clone(), meta, ) .await diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 2accfc190..102132c57 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -50,7 +50,6 @@ pub struct UploadedItem { #[derive(Clone, Serialize, Type, tauri_specta::Event)] pub struct UploadProgressEvent { video_id: String, - // TODO: Account for different states -> Eg. uploading video vs thumbnail uploaded: String, total: String, } @@ -63,7 +62,6 @@ pub async fn upload_video( video_id: String, file_path: PathBuf, screenshot_path: PathBuf, - s3_config: S3UploadMeta, meta: S3VideoMeta, ) -> Result { info!("Uploading video {video_id}..."); @@ -80,7 +78,7 @@ pub async fn upload_video( total_size, progress( app.clone(), - video_id, + video_id.clone(), stream.map(move |v| v.map(move |v| (total_size, v))), ) .and_then(|(_, c)| async move { Ok(c) }), @@ -91,7 +89,7 @@ pub async fn upload_video( let thumbnail_fut = singlepart_uploader( app.clone(), PresignedS3PutRequest { - video_id: s3_config.id.clone(), + video_id: video_id.clone(), subpath: "screenshot/screen-capture.jpg".to_string(), method: PresignedS3PutRequestMethod::Put, meta: None, @@ -107,8 +105,8 @@ pub async fn upload_video( let _ = (video_result?, thumbnail_result?); Ok(UploadedItem { - link: app.make_app_url(format!("/s/{}", &s3_config.id)).await, - id: s3_config.id.clone(), + link: app.make_app_url(format!("/s/{video_id}")).await, + id: video_id, }) } @@ -288,7 +286,6 @@ impl InstantMultipartUpload { /// and the file has stabilized (no additional data is being written). pub fn spawn( app: AppHandle, - video_id: String, file_path: PathBuf, pre_created_video: VideoUploadInfo, realtime_upload_done: Option>, @@ -297,7 +294,6 @@ impl InstantMultipartUpload { Self { handle: spawn_actor(Self::run( app, - video_id, file_path, pre_created_video, realtime_upload_done, @@ -308,17 +304,19 @@ impl InstantMultipartUpload { pub async fn run( app: AppHandle, - video_id: String, file_path: PathBuf, pre_created_video: VideoUploadInfo, realtime_video_done: Option>, recording_dir: PathBuf, ) -> Result<(), String> { + let video_id = pre_created_video.id.clone(); debug!("Initiating multipart upload for {video_id}..."); // TODO: Reuse this + error handling let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); - project_meta.upload = Some(UploadState::MultipartUpload); + project_meta.upload = Some(UploadState::MultipartUpload { + cap_id: video_id.clone(), + }); project_meta.save_for_project().unwrap(); // TODO: Allow injecting this for Studio mode upload @@ -523,11 +521,14 @@ fn multipart_uploader( upload_id: String, stream: impl Stream>, ) -> impl Stream> { + debug!("Initializing multipart uploader for video {video_id:?}"); + try_stream! { let mut stream = pin!(stream); let mut prev_part_number = None; while let Some(item) = stream.next().await { let Chunk { total_size, part_number, chunk } = item.map_err(|err| format!("uploader/part/{:?}/fs: {err:?}", prev_part_number.map(|p| p + 1)))?; + debug!("Uploading chunk {part_number} for video {video_id:?}"); prev_part_number = Some(part_number); let md5_sum = base64::encode(md5::compute(&chunk).0); let size = chunk.len(); @@ -663,6 +664,7 @@ fn progress( ) -> impl Stream> { let mut uploaded = 0u64; let mut pending_task: Option> = None; + let (video_id2, app_handle) = (video_id.clone(), app.clone()); stream! { let mut stream = pin!(stream); @@ -709,4 +711,18 @@ fn progress( yield chunk; } } + .map(Some) + .chain(stream::once(async move { + // This will trigger the frontend to remove the event from the SolidJS store. + UploadProgressEvent { + video_id: video_id2, + uploaded: "0".into(), + total: "0".into(), + } + .emit(&app_handle) + .ok(); + + None + })) + .filter_map(|item| async move { item }) } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index ed0455b84..7cbfef53f 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -131,7 +131,7 @@ export default function Recordings() { setUploadProgress( e.payload.video_id, - Number(e.payload.uploaded) / Number(e.payload.total), + (Number(e.payload.uploaded) / Number(e.payload.total)) * 100, ); }); createEffect(() => console.log({ ...uploadProgress })); @@ -189,11 +189,11 @@ export default function Recordings() { handleCopyVideoToClipboard(recording.path) } uploadProgress={ - // TODO: Fix this - Object.values(uploadProgress)[0] - // recording.meta.sharing?.id - // ? uploadProgress[recording.meta.sharing.id] - // : undefined + recording.meta.upload && + (recording.meta.upload.state === "MultipartUpload" || + recording.meta.upload.state === "SinglePartUpload") + ? uploadProgress[recording.meta.upload.cap_id] + : undefined } /> )} @@ -298,6 +298,10 @@ function RecordingItem(props: {
+ {/*TODO*/} + {/*{JSON.stringify(props.recording.meta.upload || "none")} + {JSON.stringify(props.uploadProgress || "none")}*/} + {(sharing) => ( @@ -369,7 +373,6 @@ function RecordingItem(props: { size="sm" /> - shell.open(sharing().link)} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 89f1c92fe..d38a1cf66 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -456,7 +456,7 @@ export type UploadMode = { Initial: { pre_created_video: VideoUploadInfo | null export type UploadProgress = { progress: number } export type UploadProgressEvent = { video_id: string; uploaded: string; total: string } export type UploadResult = { Success: string } | "NotAuthenticated" | "PlanCheckFailed" | "UpgradeRequired" -export type UploadState = "MultipartUpload" | "SinglePartUpload" | { Failed: string } | "Complete" +export type UploadState = { state: "MultipartUpload"; cap_id: string } | { state: "SinglePartUpload"; cap_id: string } | { state: "Failed"; error: string } | { state: "Complete" } export type Video = { duration: number; width: number; height: number; fps: number; start_time: number } export type VideoMeta = { path: string; fps?: number; /** diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 00eddf64c..b3e04aa4e 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -74,11 +74,12 @@ pub struct RecordingMeta { } #[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(tag = "state")] pub enum UploadState { // TODO: Do we care about what sort of upload it is??? - MultipartUpload, - SinglePartUpload, - Failed(String), + MultipartUpload { cap_id: String }, + SinglePartUpload { cap_id: String }, + Failed { error: String }, Complete, } From 49b9dc57eecf2fe6527e67b3daa074ff7ba21ceb Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 18:05:59 +1000 Subject: [PATCH 21/47] wip --- .../(window-chrome)/settings/recordings.tsx | 72 ++++++++++++------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 7cbfef53f..2a5b362ac 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -353,35 +353,55 @@ function RecordingItem(props: { })); return ( - - {(sharing) => ( - <> - reupload.mutate()} - > - - - } - > - - + <> + shell.open(sharing().link)} + tooltipText="Reupload" + onClick={() => reupload.mutate()} > - + - - )} - + } + > + + + + + {(sharing) => ( + <> + {/* reupload.mutate()} + > + + + } + > + + */} + shell.open(sharing().link)} + > + + + + )} + + ); }} From c48c57d4471424960750970fd0bea018de89d705 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 20:15:48 +1000 Subject: [PATCH 22/47] polish off todo's in `recordings.tsx` --- apps/desktop/src-tauri/src/lib.rs | 45 ++++-- .../(window-chrome)/settings/recordings.tsx | 137 ++++++------------ apps/desktop/src/utils/createEventListener.ts | 3 + apps/desktop/src/utils/tauri.ts | 8 +- crates/project/src/meta.rs | 8 +- crates/recording/src/studio_recording.rs | 2 +- packages/ui-solid/src/auto-imports.d.ts | 2 + 7 files changed, 94 insertions(+), 111 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1016595b1..9e3cbb8f3 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -30,8 +30,8 @@ use auth::{AuthStore, AuthenticationInvalid, Plan}; use camera::CameraPreviewState; use cap_editor::{EditorInstance, EditorState}; use cap_project::{ - ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, - StudioRecordingStatus, XY, ZoomSegment, + InstantRecordingMeta, ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, + StudioRecordingMeta, StudioRecordingStatus, XY, ZoomSegment, }; use cap_recording::{ RecordingMode, @@ -1397,18 +1397,43 @@ async fn save_file_dialog( } #[derive(Serialize, specta::Type)] -pub struct RecordingMetaWithMode { +pub struct RecordingMetaWithMetadata { #[serde(flatten)] pub inner: RecordingMeta, - pub mode: Option, + // Easier accessors for within webview + // THESE MUST COME AFTER `inner` to override flattened fields with the same name + pub mode: RecordingMode, + pub status: StudioRecordingStatus, } -impl RecordingMetaWithMode { +impl RecordingMetaWithMetadata { fn new(inner: RecordingMeta) -> Self { Self { mode: match &inner.inner { - RecordingMetaInner::Studio(_) => Some(RecordingMode::Studio), - RecordingMetaInner::Instant(_) => Some(RecordingMode::Instant), + RecordingMetaInner::Studio(_) => RecordingMode::Studio, + RecordingMetaInner::Instant(_) => RecordingMode::Instant, + }, + status: match &inner.inner { + RecordingMetaInner::Studio(StudioRecordingMeta::MultipleSegments { inner }) => { + inner + .status + .clone() + .unwrap_or(StudioRecordingStatus::Complete) + } + RecordingMetaInner::Studio(StudioRecordingMeta::SingleSegment { .. }) => { + StudioRecordingStatus::Complete + } + RecordingMetaInner::Instant(InstantRecordingMeta::InProgress { .. }) => { + StudioRecordingStatus::InProgress + } + RecordingMetaInner::Instant(InstantRecordingMeta::Failed { error }) => { + StudioRecordingStatus::Failed { + error: error.clone(), + } + } + RecordingMetaInner::Instant(InstantRecordingMeta::Complete { .. }) => { + StudioRecordingStatus::Complete + } }, inner, } @@ -1427,15 +1452,15 @@ pub enum FileType { fn get_recording_meta( path: PathBuf, _file_type: FileType, -) -> Result { +) -> Result { RecordingMeta::load_for_project(&path) - .map(RecordingMetaWithMode::new) + .map(RecordingMetaWithMetadata::new) .map_err(|e| format!("Failed to load recording meta: {e}")) } #[tauri::command] #[specta::specta] -fn list_recordings(app: AppHandle) -> Result, String> { +fn list_recordings(app: AppHandle) -> Result, String> { let recordings_dir = recordings_path(&app); if !recordings_dir.exists() { diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 2a5b362ac..114f37974 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -13,26 +13,27 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener"; import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; import { - createEffect, createMemo, createSignal, For, type JSX, + onCleanup, type ParentProps, Show, } from "solid-js"; -import { createStore } from "solid-js/store"; +import { createStore, produce } from "solid-js/store"; import { trackEvent } from "~/utils/analytics"; import { createTauriEventListener } from "~/utils/createEventListener"; import { commands, events, - type RecordingMetaWithMode, + type RecordingMetaWithMetadata, type UploadProgress, } from "~/utils/tauri"; +import CapTooltip from "~/components/Tooltip"; type Recording = { - meta: RecordingMetaWithMode; + meta: RecordingMetaWithMetadata; path: string; prettyName: string; thumbnailPath: string; @@ -81,8 +82,21 @@ export default function Recordings() { const [activeTab, setActiveTab] = createSignal<(typeof Tabs)[number]["id"]>( Tabs[0].id, ); + const [uploadProgress, setUploadProgress] = createStore< + Record + >({}); const recordings = createQuery(() => recordingsQuery); + createTauriEventListener(events.uploadProgressEvent, (e) => { + setUploadProgress(e.video_id, (Number(e.uploaded) / Number(e.total)) * 100); + if (e.uploaded === e.total) + setUploadProgress( + produce((s) => { + delete s[e.video_id]; + }), + ); + }); + createTauriEventListener(events.recordingDeleted, () => recordings.refetch()); const filteredRecordings = createMemo(() => { @@ -119,23 +133,6 @@ export default function Recordings() { }); }; - const [uploadProgress, setUploadProgress] = createStore< - Record - >({}); - // TODO: Cleanup subscription - events.uploadProgressEvent.listen((e) => { - // TODO: Make this cleanup - // if (e.payload.uploaded === e.payload.total) { - // setUploadProgress(e.payload.video_id, undefined); - // } - - setUploadProgress( - e.payload.video_id, - (Number(e.payload.uploaded) / Number(e.payload.total)) * 100, - ); - }); - createEffect(() => console.log({ ...uploadProgress })); - return (
@@ -214,7 +211,7 @@ function RecordingItem(props: { uploadProgress: number | undefined; }) { const [imageExists, setImageExists] = createSignal(true); - const mode = () => props.recording.meta.mode || "other"; // TODO: Fix this + const mode = () => props.recording.meta.mode; const firstLetterUpperCase = () => mode().charAt(0).toUpperCase() + mode().slice(1); @@ -253,77 +250,52 @@ function RecordingItem(props: {

{firstLetterUpperCase()}

- +
- {/* TODO: Get a proper icon here */} - -

RECORDING

+ +

Recording in progress

- {/* TODO: Account for studio mode vs instant mode error */} - - {(error) => ( + + + {props.recording.meta.status.status === "Failed" + ? props.recording.meta.status.error + : ""} + + } + >
- {/* TODO: Get a proper icon here */} - {/* TODO: Show the error in a tooltip */} - -

FAILED: {error()}

+ +

Recording failed

- )} +
- - {/* TODO: Show a badge for when recording fails */}
- {/*TODO*/} - {/*{JSON.stringify(props.recording.meta.upload || "none")} - {JSON.stringify(props.uploadProgress || "none")}*/} - {(sharing) => ( <> - {/* // TODO: Add something here like instant mode */} - {/* reupload.mutate()} - > - - - } - > + - */} + {(sharing) => ( - <> - {/* reupload.mutate()} - > - - - } - > - - */} - shell.open(sharing().link)} - > - - - + shell.open(sharing().link)} + > + + )} diff --git a/apps/desktop/src/utils/createEventListener.ts b/apps/desktop/src/utils/createEventListener.ts index a59c336f2..e3ecd2bc7 100644 --- a/apps/desktop/src/utils/createEventListener.ts +++ b/apps/desktop/src/utils/createEventListener.ts @@ -31,11 +31,14 @@ export function createTauriEventListener( eventListener: EventListener, callback: (payload: T) => void, ): void { + let aborted = false; const unlisten = eventListener.listen((event) => { + if (aborted) return; callback(event.payload); }); onCleanup(() => { + aborted = true; unlisten.then((cleanup) => cleanup()); }); } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index d38a1cf66..5397b5e96 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -119,13 +119,13 @@ async uploadExportedVideo(path: string, mode: UploadMode, channel: TAURI_CHANNEL async uploadScreenshot(screenshotPath: string) : Promise { return await TAURI_INVOKE("upload_screenshot", { screenshotPath }); }, -async getRecordingMeta(path: string, fileType: FileType) : Promise { +async getRecordingMeta(path: string, fileType: FileType) : Promise { return await TAURI_INVOKE("get_recording_meta", { path, fileType }); }, async saveFileDialog(fileName: string, fileType: string) : Promise { return await TAURI_INVOKE("save_file_dialog", { fileName, fileType }); }, -async listRecordings() : Promise<([string, RecordingMetaWithMode])[]> { +async listRecordings() : Promise<([string, RecordingMetaWithMetadata])[]> { return await TAURI_INVOKE("list_recordings"); }, async listScreenshots() : Promise<([string, RecordingMeta])[]> { @@ -423,7 +423,7 @@ export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingDeleted = { path: string } export type RecordingEvent = { variant: "Countdown"; value: number } | { variant: "Started" } | { variant: "Stopped" } | { variant: "Failed"; error: string } export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null } -export type RecordingMetaWithMode = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null }) & { mode: RecordingMode | null } +export type RecordingMetaWithMetadata = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null }) & { mode: RecordingMode; status: StudioRecordingStatus } export type RecordingMode = "studio" | "instant" export type RecordingOptionsChanged = null export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean } @@ -448,7 +448,7 @@ export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; aud export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode } export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } -export type StudioRecordingStatus = "inProgress" | { failed: { error: string } } | "completed" +export type StudioRecordingStatus = { status: "InProgress" } | { status: "Failed"; error: string } | { status: "Complete" } export type TargetUnderCursor = { display_id: DisplayId | null; window: WindowUnderCursor | null } export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[] } export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number } diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index b3e04aa4e..7d2541239 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -186,11 +186,11 @@ pub enum StudioRecordingMeta { impl StudioRecordingMeta { pub fn status(&self) -> StudioRecordingStatus { match self { - StudioRecordingMeta::SingleSegment { .. } => StudioRecordingStatus::Completed, + StudioRecordingMeta::SingleSegment { .. } => StudioRecordingStatus::Complete, StudioRecordingMeta::MultipleSegments { inner } => inner .status .clone() - .unwrap_or(StudioRecordingStatus::Completed), + .unwrap_or(StudioRecordingStatus::Complete), } } @@ -247,11 +247,11 @@ pub struct MultipleSegments { } #[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] +#[serde(tag = "status")] pub enum StudioRecordingStatus { InProgress, Failed { error: String }, - Completed, + Complete, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index c7d02f8ec..b7174077b 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -603,7 +603,7 @@ async fn stop_recording( }) .collect(), ), - status: Some(StudioRecordingStatus::Completed), + status: Some(StudioRecordingStatus::Complete), }, }; diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 2dad1b502..51b18cfd4 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -90,4 +90,6 @@ declare global { const IconMdiLoading: typeof import("~icons/mdi/loading.jsx")["default"] const IconMdiMonitor: typeof import('~icons/mdi/monitor.jsx')['default'] const IconPhMonitorBold: typeof import('~icons/ph/monitor-bold.jsx')['default'] + const IconPhRecordFill: typeof import('~icons/ph/record-fill.jsx')['default'] + const IconPhWarningBold: typeof import('~icons/ph/warning-bold.jsx')['default'] } From a101f6dd704f9b5a124661fa33218f84592f03f6 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 29 Sep 2025 21:21:39 +1000 Subject: [PATCH 23/47] a bunch of random fixes --- apps/desktop/src-tauri/src/lib.rs | 22 +++++-- apps/desktop/src-tauri/src/recording.rs | 1 + apps/desktop/src-tauri/src/upload.rs | 61 ++++++++++++++----- .../(window-chrome)/settings/recordings.tsx | 19 +++--- apps/desktop/src/utils/tauri.ts | 6 +- crates/project/src/meta.rs | 23 +++++-- 6 files changed, 95 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9e3cbb8f3..165e7d734 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -31,7 +31,7 @@ use camera::CameraPreviewState; use cap_editor::{EditorInstance, EditorState}; use cap_project::{ InstantRecordingMeta, ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, - StudioRecordingMeta, StudioRecordingStatus, XY, ZoomSegment, + StudioRecordingMeta, StudioRecordingStatus, UploadMeta, XY, ZoomSegment, }; use cap_recording::{ RecordingMode, @@ -1072,13 +1072,13 @@ async fn upload_exported_video( let mut meta = RecordingMeta::load_for_project(&path).map_err(|v| v.to_string())?; - let output_path = meta.output_path(); - if !output_path.exists() { + let file_path = meta.output_path(); + if !file_path.exists() { notifications::send_notification(&app, notifications::NotificationType::UploadFailed); return Err("Failed to upload video: Rendered video not found".to_string()); } - let metadata = build_video_meta(&output_path) + let metadata = build_video_meta(&file_path) .map_err(|err| format!("Error getting output video meta: {err}"))?; if !auth.is_upgraded() && metadata.duration_in_secs > 300.0 { @@ -1115,18 +1115,28 @@ async fn upload_exported_video( } .await?; + let screenshot_path = meta.project_path.join("screenshots/display.jpg"); + meta.upload = Some(UploadMeta::SinglePartUpload { + video_id: s3_config.id.clone(), + file_path: file_path.clone(), + screenshot_path: screenshot_path.clone(), + }); + meta.save_for_project().ok(); + match upload_video( &app, s3_config.id.clone(), - output_path, - meta.project_path.join("screenshots/display.jpg"), + file_path, + screenshot_path, metadata, + Some(channel.clone()), ) .await { Ok(uploaded_video) => { channel.send(UploadProgress { progress: 1.0 }).ok(); + meta.upload = Some(UploadMeta::Complete); meta.sharing = Some(SharingMeta { link: uploaded_video.link.clone(), id: uploaded_video.id.clone(), diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 2a8a31806..98b511cbc 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -863,6 +863,7 @@ async fn handle_recording_finish( output_path, display_screenshot.clone(), meta, + None, ) .await { diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 102132c57..20b0c3b9c 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -1,14 +1,14 @@ // credit @filleduchaos use crate::{ - VideoUploadInfo, api, - api::{PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}, + UploadProgress, VideoUploadInfo, + api::{self, PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}, web_api::ManagerExt, }; use async_stream::{stream, try_stream}; use axum::http::Uri; use bytes::Bytes; -use cap_project::{RecordingMeta, UploadState}; +use cap_project::{RecordingMeta, UploadMeta}; use cap_utils::spawn_actor; use ffmpeg::ffi::AV_TIME_BASE; use flume::Receiver; @@ -24,7 +24,7 @@ use std::{ str::FromStr, time::Duration, }; -use tauri::AppHandle; +use tauri::{AppHandle, ipc::Channel}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_specta::Event; use tokio::{ @@ -63,10 +63,23 @@ pub async fn upload_video( file_path: PathBuf, screenshot_path: PathBuf, meta: S3VideoMeta, + channel: Option>, ) -> Result { info!("Uploading video {video_id}..."); let (stream, total_size) = file_reader_stream(file_path).await?; + let stream = progress( + app.clone(), + video_id.clone(), + stream.map(move |v| v.map(move |v| (total_size, v))), + ); + + let stream = if let Some(channel) = channel { + tauri_progress(channel, stream).boxed() + } else { + stream.boxed() + }; + let video_fut = singlepart_uploader( app.clone(), PresignedS3PutRequest { @@ -76,12 +89,7 @@ pub async fn upload_video( meta: Some(meta), }, total_size, - progress( - app.clone(), - video_id.clone(), - stream.map(move |v| v.map(move |v| (total_size, v))), - ) - .and_then(|(_, c)| async move { Ok(c) }), + stream.and_then(|(_, c)| async move { Ok(c) }), ); // TODO: We don't report progress on image upload @@ -314,10 +322,10 @@ impl InstantMultipartUpload { // TODO: Reuse this + error handling let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); - project_meta.upload = Some(UploadState::MultipartUpload { - cap_id: video_id.clone(), + project_meta.upload = Some(UploadMeta::MultipartUpload { + video_id: video_id.clone(), }); - project_meta.save_for_project().unwrap(); + project_meta.save_for_project().ok(); // TODO: Allow injecting this for Studio mode upload // let file = File::open(path).await.unwrap(); // TODO: Error handling @@ -348,7 +356,7 @@ impl InstantMultipartUpload { // TODO: Reuse this + error handling let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); - project_meta.upload = Some(UploadState::Complete); + project_meta.upload = Some(UploadMeta::Complete); project_meta.save_for_project().unwrap(); let _ = app.clipboard().write_text(pre_created_video.link.clone()); @@ -726,3 +734,28 @@ fn progress( })) .filter_map(|item| async move { item }) } + +/// Track the upload progress into a Tauri channel +fn tauri_progress( + channel: Channel, + stream: impl Stream>, +) -> impl Stream> { + let mut uploaded = 0u64; + + stream! { + let mut stream = pin!(stream); + + while let Some(chunk) = stream.next().await { + if let Ok(chunk) = &chunk { + uploaded += chunk.size(); + + channel.send(UploadProgress { + progress: uploaded as f64 / chunk.total() as f64 + }) + .ok(); + } + + yield chunk; + } + } +} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 114f37974..88ce7dcf6 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -253,7 +253,7 @@ function RecordingItem(props: {
@@ -273,7 +273,7 @@ function RecordingItem(props: { >
@@ -290,11 +290,13 @@ function RecordingItem(props: { {(sharing) => ( <> - + + + props.onOpenEditor()} + disabled={props.recording.meta.status.status !== "Complete"} > @@ -396,7 +399,7 @@ function TooltipIconButton( props.onClick(); }} disabled={props.disabled} - class="p-2.5 opacity-70 will-change-transform hover:opacity-100 rounded-full transition-all duration-200 hover:bg-gray-3 dark:hover:bg-gray-5" + class="p-2.5 opacity-70 will-change-transform hover:opacity-100 rounded-full transition-all duration-200 hover:bg-gray-3 dark:hover:bg-gray-5 disabled:pointer-events-none disabled:opacity-45 disabled:hover:opacity-45" > {props.children} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 5397b5e96..fe9323c33 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -422,8 +422,8 @@ export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingDeleted = { path: string } export type RecordingEvent = { variant: "Countdown"; value: number } | { variant: "Started" } | { variant: "Stopped" } | { variant: "Failed"; error: string } -export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null } -export type RecordingMetaWithMetadata = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadState | null }) & { mode: RecordingMode; status: StudioRecordingStatus } +export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadMeta | null } +export type RecordingMetaWithMetadata = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadMeta | null }) & { mode: RecordingMode; status: StudioRecordingStatus } export type RecordingMode = "studio" | "instant" export type RecordingOptionsChanged = null export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean } @@ -452,11 +452,11 @@ export type StudioRecordingStatus = { status: "InProgress" } | { status: "Failed export type TargetUnderCursor = { display_id: DisplayId | null; window: WindowUnderCursor | null } export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[] } export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number } +export type UploadMeta = { state: "MultipartUpload"; video_id: string } | { state: "SinglePartUpload"; video_id: string; file_path: string; screenshot_path: string } | { state: "Failed"; error: string } | { state: "Complete" } export type UploadMode = { Initial: { pre_created_video: VideoUploadInfo | null } } | "Reupload" export type UploadProgress = { progress: number } export type UploadProgressEvent = { video_id: string; uploaded: string; total: string } export type UploadResult = { Success: string } | "NotAuthenticated" | "PlanCheckFailed" | "UpgradeRequired" -export type UploadState = { state: "MultipartUpload"; cap_id: string } | { state: "SinglePartUpload"; cap_id: string } | { state: "Failed"; error: string } | { state: "Complete" } export type Video = { duration: number; width: number; height: number; fps: number; start_time: number } export type VideoMeta = { path: string; fps?: number; /** diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 7d2541239..aaf7d1076 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -70,16 +70,27 @@ pub struct RecordingMeta { #[serde(flatten)] pub inner: RecordingMetaInner, #[serde(default, skip_serializing_if = "Option::is_none")] - pub upload: Option, + pub upload: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(tag = "state")] -pub enum UploadState { - // TODO: Do we care about what sort of upload it is??? - MultipartUpload { cap_id: String }, - SinglePartUpload { cap_id: String }, - Failed { error: String }, +pub enum UploadMeta { + MultipartUpload { + // Cap web identifier + video_id: String, + // TODO + }, + SinglePartUpload { + // Cap web identifier + video_id: String, + // Path to video and screenshot files for resuming + file_path: PathBuf, + screenshot_path: PathBuf, + }, + Failed { + error: String, + }, Complete, } From 7ea039cf72c92e53080e21f446339016f2ff8d4e Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 13:21:58 +1000 Subject: [PATCH 24/47] fixes --- apps/desktop/src-tauri/src/lib.rs | 59 ++++++++++++++++++- apps/desktop/src-tauri/src/recording.rs | 30 ++++++---- apps/desktop/src-tauri/src/upload.rs | 5 +- .../(window-chrome)/settings/recordings.tsx | 39 ++++++------ 4 files changed, 97 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 165e7d734..6be959c4d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -81,7 +81,7 @@ use tauri_plugin_opener::OpenerExt; use tauri_plugin_shell::ShellExt; use tauri_specta::Event; use tokio::sync::{RwLock, oneshot}; -use tracing::{error, trace}; +use tracing::{error, trace, warn}; use upload::{S3UploadMeta, create_or_get_video, upload_image, upload_video}; use web_api::ManagerExt as WebManagerExt; use windows::{CapWindowId, EditorWindowIds, ShowCapWindow, set_window_transparent}; @@ -1121,7 +1121,9 @@ async fn upload_exported_video( file_path: file_path.clone(), screenshot_path: screenshot_path.clone(), }); - meta.save_for_project().ok(); + meta.save_for_project() + .map_err(|e| error!("Failed to save recording meta: {e}")) + .ok(); match upload_video( &app, @@ -1141,7 +1143,9 @@ async fn upload_exported_video( link: uploaded_video.link.clone(), id: uploaded_video.id.clone(), }); - meta.save_for_project().ok(); + meta.save_for_project() + .map_err(|e| error!("Failed to save recording meta: {e}")) + .ok(); let _ = app .state::>() @@ -1156,6 +1160,12 @@ async fn upload_exported_video( error!("Failed to upload video: {e}"); NotificationType::UploadFailed.send(&app); + + meta.upload = Some(UploadMeta::Failed { error: e.clone() }); + meta.save_for_project() + .map_err(|e| error!("Failed to save recording meta: {e}")) + .ok(); + Err(e) } } @@ -2147,6 +2157,16 @@ pub async fn run(recording_logging_handle: LoggingHandle) { }); } + tokio::spawn({ + let app = app.clone(); + async move { + resume_uploads(app) + .await + .map_err(|err| warn!("Error resuming uploads: {err}")) + .ok(); + } + }); + { app.manage(Arc::new(RwLock::new(App { camera_ws_port, @@ -2414,6 +2434,39 @@ pub async fn run(recording_logging_handle: LoggingHandle) { }); } +async fn resume_uploads(app: AppHandle) -> Result<(), String> { + let recordings_dir = recordings_path(&app); + (!recordings_dir.exists()).then(|| format!("Recording directory missing"))?; + + let entries = std::fs::read_dir(&recordings_dir) + .map_err(|e| format!("Failed to read recordings directory: {}", e))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path.extension().and_then(|s| s.to_str()) == Some("cap") { + if let Some(upload_meta) = RecordingMeta::load_for_project(&path) + .ok() + .and_then(|v| v.upload) + { + match upload_meta { + UploadMeta::MultipartUpload { video_id } => { + // TODO: Resume `MultipartUpload` + } + UploadMeta::SinglePartUpload { + video_id, + file_path, + screenshot_path, + } => { + // TODO: Resume `SinglePartUpload` + } + UploadMeta::Failed { .. } | UploadMeta::Complete => {} + } + } + } + } + + Ok(()) +} + async fn create_editor_instance_impl( app: &AppHandle, path: PathBuf, diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 98b511cbc..a0089c1c1 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2,7 +2,8 @@ use cap_fail::fail; use cap_project::{ CursorClickEvent, InstantRecordingMeta, MultipleSegments, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, StudioRecordingStatus, - TimelineConfiguration, TimelineSegment, ZoomMode, ZoomSegment, cursor::CursorEvents, + TimelineConfiguration, TimelineSegment, UploadMeta, ZoomMode, ZoomSegment, + cursor::CursorEvents, }; use cap_recording::{ RecordingError, RecordingMode, @@ -806,6 +807,7 @@ async fn handle_recording_finish( spawn_actor({ let video_upload_info = video_upload_info.clone(); + let recording_dir = recording_dir.clone(); async move { let video_upload_succeeded = match progressive_upload @@ -857,7 +859,7 @@ async fn handle_recording_finish( .map_err(|err| error!("Error getting video metdata: {}", err)) { // The upload_video function handles screenshot upload, so we can pass it along - match upload_video( + upload_video( &app, video_upload_info.id.clone(), output_path, @@ -866,16 +868,20 @@ async fn handle_recording_finish( None, ) .await - { - Ok(_) => { - info!( - "Final video upload with screenshot completed successfully" - ) - } - Err(e) => { - error!("Error in final upload with screenshot: {}", e) - } - } + .map(|_| { + info!("Final video upload with screenshot completed successfully") + }) + .map_err(|error| { + error!("Error in upload_video: {error}"); + + let mut meta = + RecordingMeta::load_for_project(&recording_dir).unwrap(); + meta.upload = Some(UploadMeta::Failed { error }); + meta.save_for_project() + .map_err(|e| format!("Failed to save recording meta: {e}")) + .ok(); + }) + .ok(); } } } diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 20b0c3b9c..c1ec08aed 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -325,7 +325,10 @@ impl InstantMultipartUpload { project_meta.upload = Some(UploadMeta::MultipartUpload { video_id: video_id.clone(), }); - project_meta.save_for_project().ok(); + project_meta + .save_for_project() + .map_err(|e| error!("Failed to save recording meta: {e}")) + .ok(); // TODO: Allow injecting this for Studio mode upload // let file = File::open(path).await.unwrap(); // TODO: Error handling diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 88ce7dcf6..135a9cb3a 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -13,11 +13,11 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener"; import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; import { + createEffect, createMemo, createSignal, For, type JSX, - onCleanup, type ParentProps, Show, } from "solid-js"; @@ -76,6 +76,8 @@ const recordingsQuery = queryOptions({ ); return recordings; }, + // This will ensure any changes to the upload status in the project meta are reflected. + refetchInterval: 2000, }); export default function Recordings() { @@ -189,7 +191,7 @@ export default function Recordings() { recording.meta.upload && (recording.meta.upload.state === "MultipartUpload" || recording.meta.upload.state === "SinglePartUpload") - ? uploadProgress[recording.meta.upload.cap_id] + ? uploadProgress[recording.meta.upload.video_id] : undefined } /> @@ -286,26 +288,23 @@ function RecordingItem(props: {
+ + + + + {(sharing) => ( - <> - - - - - - - shell.open(sharing().link)} - > - - - + shell.open(sharing().link)} + > + + )} Date: Thu, 2 Oct 2025 13:27:34 +1000 Subject: [PATCH 25/47] fix --- apps/desktop/src-tauri/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6be959c4d..156c48412 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2436,7 +2436,9 @@ pub async fn run(recording_logging_handle: LoggingHandle) { async fn resume_uploads(app: AppHandle) -> Result<(), String> { let recordings_dir = recordings_path(&app); - (!recordings_dir.exists()).then(|| format!("Recording directory missing"))?; + if !recordings_dir.exists() { + return Err("Recording directory missing".to_string()); + } let entries = std::fs::read_dir(&recordings_dir) .map_err(|e| format!("Failed to read recordings directory: {}", e))?; From cc85380ea96074f047887633815791cb86d4a956 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 15:00:16 +1000 Subject: [PATCH 26/47] resumable system --- apps/desktop/src-tauri/src/lib.rs | 127 +- apps/desktop/src-tauri/src/recording.rs | 4 +- apps/desktop/src-tauri/src/upload.rs | 29 +- apps/desktop/src-tauri/src/upload_legacy.rs | 1179 +++++++++++++++++++ apps/desktop/src/utils/tauri.ts | 2 +- crates/project/src/meta.rs | 19 +- 6 files changed, 1317 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src-tauri/src/upload_legacy.rs diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 29f6bdfa2..d17a82d41 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ mod recording_settings; mod target_select_overlay; mod tray; mod upload; +mod upload_legacy; mod web_api; mod windows; @@ -31,7 +32,7 @@ use camera::CameraPreviewState; use cap_editor::{EditorInstance, EditorState}; use cap_project::{ InstantRecordingMeta, ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, - StudioRecordingMeta, StudioRecordingStatus, UploadMeta, XY, ZoomSegment, + StudioRecordingMeta, StudioRecordingStatus, UploadMeta, VideoUploadInfo, XY, ZoomSegment, }; use cap_recording::{ RecordingMode, @@ -82,13 +83,14 @@ use tauri_plugin_shell::ShellExt; use tauri_specta::Event; use tokio::sync::{RwLock, oneshot}; use tracing::{error, trace, warn}; -use upload::{S3UploadMeta, create_or_get_video, upload_image, upload_video}; +use upload::{create_or_get_video, upload_image, upload_video}; use web_api::ManagerExt as WebManagerExt; use windows::{CapWindowId, EditorWindowIds, ShowCapWindow, set_window_transparent}; use crate::{ camera::CameraPreviewManager, recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + upload::InstantMultipartUpload, }; use crate::{recording::start_recording, upload::build_video_meta}; @@ -144,13 +146,6 @@ pub struct VideoRecordingMetadata { pub size: f64, } -#[derive(Clone, Serialize, Deserialize, specta::Type, Debug)] -pub struct VideoUploadInfo { - id: String, - link: String, - config: S3UploadMeta, -} - impl App { pub fn set_pending_recording(&mut self, mode: RecordingMode, target: ScreenCaptureTarget) { self.recording_state = RecordingState::Pending { mode, target }; @@ -1120,6 +1115,7 @@ async fn upload_exported_video( video_id: s3_config.id.clone(), file_path: file_path.clone(), screenshot_path: screenshot_path.clone(), + recording_dir: path.clone(), }); meta.save_for_project() .map_err(|e| error!("Failed to save recording meta: {e}")) @@ -2448,22 +2444,107 @@ async fn resume_uploads(app: AppHandle) -> Result<(), String> { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() && path.extension().and_then(|s| s.to_str()) == Some("cap") { - if let Some(upload_meta) = RecordingMeta::load_for_project(&path) - .ok() - .and_then(|v| v.upload) - { - match upload_meta { - UploadMeta::MultipartUpload { video_id } => { - // TODO: Resume `MultipartUpload` + // Load recording meta to check for in-progress recordings + if let Ok(mut meta) = RecordingMeta::load_for_project(&path) { + let mut needs_save = false; + + // Check if recording is still marked as in-progress and if so mark as failed + // This should only happen if the application crashes while recording + match &mut meta.inner { + RecordingMetaInner::Studio(StudioRecordingMeta::MultipleSegments { inner }) => { + if let Some(StudioRecordingStatus::InProgress) = &inner.status { + inner.status = Some(StudioRecordingStatus::Failed { + error: "Recording crashed".to_string(), + }); + needs_save = true; + } + } + RecordingMetaInner::Instant(InstantRecordingMeta::InProgress { .. }) => { + meta.inner = RecordingMetaInner::Instant(InstantRecordingMeta::Failed { + error: "Recording crashed".to_string(), + }); + needs_save = true; + } + _ => {} + } + + // Save the updated meta if we made changes + if needs_save { + if let Err(err) = meta.save_for_project() { + error!("Failed to save recording meta for {path:?}: {err}"); } - UploadMeta::SinglePartUpload { - video_id, - file_path, - screenshot_path, - } => { - // TODO: Resume `SinglePartUpload` + } + + // Handle upload resumption + if let Some(upload_meta) = meta.upload { + match upload_meta { + UploadMeta::MultipartUpload { + video_id, + file_path, + pre_created_video, + recording_dir, + } => { + InstantMultipartUpload::spawn( + app.clone(), + file_path, + pre_created_video, + None, + recording_dir, + ); + } + UploadMeta::SinglePartUpload { + video_id, + file_path, + screenshot_path, + recording_dir, + } => { + let app = app.clone(); + tokio::spawn(async move { + if let Ok(meta) = build_video_meta(&file_path) + .map_err(|err| error!("Failed to resume video upload. error getting video metadata: {}", err)) + { + if let Ok(uploaded_video) = upload_video( + &app, + video_id, + file_path, + screenshot_path, + meta, + None, + ) + .await + .map_err(|error| { + error!("Error completing resumed upload for video: {error}"); + + if let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir).map_err(|err| error!("Error loading project metadata: {err}")) { + meta.upload = Some(UploadMeta::Failed { error }); + meta.save_for_project().map_err(|err| error!("Error saving project metadata: {err}")).ok(); + } + }) + { + if let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir).map_err(|err| error!("Error loading project metadata: {err}")) { + meta.upload = Some(UploadMeta::Complete); + meta.sharing = Some(SharingMeta { + link: uploaded_video.link.clone(), + id: uploaded_video.id.clone(), + }); + meta.save_for_project() + .map_err(|e| error!("Failed to save recording meta: {e}")) + .ok(); + } + + let _ = app + .state::>() + .write() + .await + .set_text(uploaded_video.link.clone()); + NotificationType::ShareableLinkCopied.send(&app); + } + + } + }); + } + UploadMeta::Failed { .. } | UploadMeta::Complete => {} } - UploadMeta::Failed { .. } | UploadMeta::Complete => {} } } } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 31a431438..eb41b07b5 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -268,7 +268,7 @@ fn normalize_thumbnail_dimensions(image: &image::RgbaImage) -> image::RgbaImage async fn capture_thumbnail_from_filter(filter: &cidre::sc::ContentFilter) -> Option { use cidre::{cv, sc}; use image::{ImageEncoder, RgbaImage, codecs::png::PngEncoder}; - use std::{io::Cursor, slice}; + use std::io::Cursor; let mut config = sc::StreamCfg::new(); config.set_width(THUMBNAIL_WIDTH as usize); @@ -1683,7 +1683,7 @@ async fn handle_recording_finish( } } else { if let Ok(meta) = build_video_meta(&output_path) - .map_err(|err| error!("Error getting video metdata: {}", err)) + .map_err(|err| error!("Error getting video metadata: {}", err)) { // The upload_video function handles screenshot upload, so we can pass it along upload_video( diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index c1ec08aed..e0acfa809 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -8,7 +8,7 @@ use crate::{ use async_stream::{stream, try_stream}; use axum::http::Uri; use bytes::Bytes; -use cap_project::{RecordingMeta, UploadMeta}; +use cap_project::{RecordingMeta, S3UploadMeta, UploadMeta}; use cap_utils::spawn_actor; use ffmpeg::ffi::AV_TIME_BASE; use flume::Receiver; @@ -35,11 +35,6 @@ use tokio::{ use tokio_util::io::ReaderStream; use tracing::{debug, error, info}; -#[derive(Deserialize, Serialize, Clone, Type, Debug)] -pub struct S3UploadMeta { - pub id: String, -} - pub struct UploadedItem { pub link: String, pub id: String, @@ -320,23 +315,22 @@ impl InstantMultipartUpload { let video_id = pre_created_video.id.clone(); debug!("Initiating multipart upload for {video_id}..."); - // TODO: Reuse this + error handling - let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); + let mut project_meta = RecordingMeta::load_for_project(&recording_dir).map_err(|err| { + format!("Error reading project meta from {recording_dir:?} for upload init: {err}") + })?; project_meta.upload = Some(UploadMeta::MultipartUpload { video_id: video_id.clone(), + file_path: file_path.clone(), + pre_created_video: pre_created_video.clone(), + recording_dir: recording_dir.clone(), }); project_meta .save_for_project() .map_err(|e| error!("Failed to save recording meta: {e}")) .ok(); - // TODO: Allow injecting this for Studio mode upload - // let file = File::open(path).await.unwrap(); // TODO: Error handling - // ReaderStream::new(file) // TODO: Map into part numbers - let upload_id = api::upload_multipart_initiate(&app, &video_id).await?; - // TODO: Will it be a problem that `ReaderStream` doesn't have a fixed chunk size??? We should fix that!!!! let parts = progress( app.clone(), video_id.clone(), @@ -357,10 +351,13 @@ impl InstantMultipartUpload { api::upload_multipart_complete(&app, &video_id, &upload_id, &parts, metadata).await?; info!("Multipart upload complete for {video_id}."); - // TODO: Reuse this + error handling - let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); + let mut project_meta = RecordingMeta::load_for_project(&recording_dir).map_err(|err| { + format!("Error reading project meta from {recording_dir:?} for upload complete: {err}") + })?; project_meta.upload = Some(UploadMeta::Complete); - project_meta.save_for_project().unwrap(); + project_meta + .save_for_project() + .map_err(|err| format!("Error reading project meta from {recording_dir:?}: {err}"))?; let _ = app.clipboard().write_text(pre_created_video.link.clone()); diff --git a/apps/desktop/src-tauri/src/upload_legacy.rs b/apps/desktop/src-tauri/src/upload_legacy.rs new file mode 100644 index 000000000..a8d05b654 --- /dev/null +++ b/apps/desktop/src-tauri/src/upload_legacy.rs @@ -0,0 +1,1179 @@ +//! This is the legacy uploading module. +//! We are keeping it for now as an easy fallback. +//! +//! You should avoid making changes to it, make changes to the new upload module instead. + +// credit @filleduchaos + +use crate::web_api::ManagerExt; +use crate::{UploadProgress, VideoUploadInfo}; +use cap_utils::spawn_actor; +use ffmpeg::ffi::AV_TIME_BASE; +use flume::Receiver; +use futures::StreamExt; +use image::ImageReader; +use image::codecs::jpeg::JpegEncoder; +use reqwest::StatusCode; +use reqwest::header::CONTENT_LENGTH; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use specta::Type; +use std::{ + path::PathBuf, + time::{Duration, Instant}, +}; +use tauri::{AppHandle, ipc::Channel}; +use tauri_plugin_clipboard_manager::ClipboardExt; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tokio::task::{self, JoinHandle}; +use tokio::time::sleep; +use tracing::{debug, error, info, trace, warn}; + +#[derive(Deserialize, Serialize, Clone, Type, Debug)] +pub struct S3UploadMeta { + id: String, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct CreateErrorResponse { + error: String, +} + +// fn deserialize_empty_object_as_string<'de, D>(deserializer: D) -> Result +// where +// D: Deserializer<'de>, +// { +// struct StringOrObject; + +// impl<'de> de::Visitor<'de> for StringOrObject { +// type Value = String; + +// fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { +// formatter.write_str("string or empty object") +// } + +// fn visit_str(self, value: &str) -> Result +// where +// E: de::Error, +// { +// Ok(value.to_string()) +// } + +// fn visit_string(self, value: String) -> Result +// where +// E: de::Error, +// { +// Ok(value) +// } + +// fn visit_map(self, _map: M) -> Result +// where +// M: de::MapAccess<'de>, +// { +// // Return empty string for empty objects +// Ok(String::new()) +// } +// } + +// deserializer.deserialize_any(StringOrObject) +// } + +impl S3UploadMeta { + pub fn id(&self) -> &str { + &self.id + } + + // pub fn new(id: String) -> Self { + // Self { id } + // } +} + +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct S3VideoMeta { + #[serde(rename = "durationInSecs")] + pub duration_in_secs: f64, + pub width: u32, + pub height: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub fps: Option, +} + +pub struct UploadedVideo { + pub link: String, + pub id: String, + #[allow(unused)] + pub config: S3UploadMeta, +} + +pub struct UploadedImage { + pub link: String, + pub id: String, +} + +// pub struct UploadedAudio { +// pub link: String, +// pub id: String, +// pub config: S3UploadMeta, +// } + +pub struct UploadProgressUpdater { + video_state: Option, + app: AppHandle, + video_id: String, +} + +struct VideoProgressState { + uploaded: u64, + total: u64, + pending_task: Option>, + last_update_time: Instant, +} + +impl UploadProgressUpdater { + pub fn new(app: AppHandle, video_id: String) -> Self { + Self { + video_state: None, + app, + video_id, + } + } + + pub fn update(&mut self, uploaded: u64, total: u64) { + let should_send_immediately = { + let state = self.video_state.get_or_insert_with(|| VideoProgressState { + uploaded, + total, + pending_task: None, + last_update_time: Instant::now(), + }); + + // Cancel any pending task + if let Some(handle) = state.pending_task.take() { + handle.abort(); + } + + state.uploaded = uploaded; + state.total = total; + state.last_update_time = Instant::now(); + + // Send immediately if upload is complete + uploaded >= total + }; + + let app = self.app.clone(); + if should_send_immediately { + tokio::spawn({ + let video_id = self.video_id.clone(); + async move { + Self::send_api_update(&app, video_id, uploaded, total).await; + } + }); + + // Clear state since upload is complete + self.video_state = None; + } else { + // Schedule delayed update + let handle = { + let video_id = self.video_id.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + Self::send_api_update(&app, video_id, uploaded, total).await; + }) + }; + + if let Some(state) = &mut self.video_state { + state.pending_task = Some(handle); + } + } + } + + async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { + let response = app + .authed_api_request("/api/desktop/video/progress", |client, url| { + client + .post(url) + .header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")) + .json(&json!({ + "videoId": video_id, + "uploaded": uploaded, + "total": total, + "updatedAt": chrono::Utc::now().to_rfc3339() + })) + }) + .await; + + match response { + Ok(resp) if resp.status().is_success() => { + trace!("Progress update sent successfully"); + } + Ok(resp) => error!("Failed to send progress update: {}", resp.status()), + Err(err) => error!("Failed to send progress update: {err}"), + } + } +} + +pub async fn upload_video( + app: &AppHandle, + video_id: String, + file_path: PathBuf, + existing_config: Option, + screenshot_path: Option, + meta: Option, + channel: Option>, +) -> Result { + println!("Uploading video {video_id}..."); + + let client = reqwest::Client::new(); + let s3_config = match existing_config { + Some(config) => config, + None => create_or_get_video(app, false, Some(video_id.clone()), None, meta).await?, + }; + + let presigned_put = presigned_s3_put( + app, + PresignedS3PutRequest { + video_id: video_id.clone(), + subpath: "result.mp4".to_string(), + method: PresignedS3PutRequestMethod::Put, + meta: Some(build_video_meta(&file_path)?), + }, + ) + .await?; + + let file = tokio::fs::File::open(&file_path) + .await + .map_err(|e| format!("Failed to open file: {e}"))?; + + let metadata = file + .metadata() + .await + .map_err(|e| format!("Failed to get file metadata: {e}"))?; + + let total_size = metadata.len(); + + let reader_stream = tokio_util::io::ReaderStream::new(file); + + let mut bytes_uploaded = 0u64; + let mut progress = UploadProgressUpdater::new(app.clone(), video_id); + + let progress_stream = reader_stream.inspect(move |chunk| { + if let Ok(chunk) = chunk { + bytes_uploaded += chunk.len() as u64; + } + + if bytes_uploaded > 0 { + if let Some(channel) = &channel { + channel + .send(UploadProgress { + progress: bytes_uploaded as f64 / total_size as f64, + }) + .ok(); + } + + progress.update(bytes_uploaded, total_size); + } + }); + + let screenshot_upload = match screenshot_path { + Some(screenshot_path) if screenshot_path.exists() => { + Some(prepare_screenshot_upload(app, &s3_config, screenshot_path)) + } + _ => None, + }; + + let video_upload = client + .put(presigned_put) + .body(reqwest::Body::wrap_stream(progress_stream)) + .header(CONTENT_LENGTH, metadata.len()); + + let (video_upload, screenshot_result): ( + Result, + Option>, + ) = tokio::join!(video_upload.send(), async { + if let Some(screenshot_req) = screenshot_upload { + Some(screenshot_req.await) + } else { + None + } + }); + + let response = video_upload.map_err(|e| format!("Failed to send upload file request: {e}"))?; + + if response.status().is_success() { + println!("Video uploaded successfully"); + + if let Some(Ok(screenshot_response)) = screenshot_result { + if screenshot_response.status().is_success() { + println!("Screenshot uploaded successfully"); + } else { + println!( + "Failed to upload screenshot: {}", + screenshot_response.status() + ); + } + } + + return Ok(UploadedVideo { + link: app.make_app_url(format!("/s/{}", &s3_config.id)).await, + id: s3_config.id.clone(), + config: s3_config, + }); + } + + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + tracing::error!( + "Failed to upload file. Status: {}. Body: {}", + status, + error_body + ); + Err(format!( + "Failed to upload file. Status: {status}. Body: {error_body}" + )) +} + +pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result { + let file_name = file_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or("Invalid file path")? + .to_string(); + + let client = reqwest::Client::new(); + let s3_config = create_or_get_video(app, true, None, None, None).await?; + + let presigned_put = presigned_s3_put( + app, + PresignedS3PutRequest { + video_id: s3_config.id.clone(), + subpath: file_name, + method: PresignedS3PutRequestMethod::Put, + meta: None, + }, + ) + .await?; + + let file_content = tokio::fs::read(&file_path) + .await + .map_err(|e| format!("Failed to read file: {e}"))?; + + let response = client + .put(presigned_put) + .header(CONTENT_LENGTH, file_content.len()) + .body(file_content) + .send() + .await + .map_err(|e| format!("Failed to send upload file request: {e}"))?; + + if response.status().is_success() { + println!("File uploaded successfully"); + return Ok(UploadedImage { + link: app.make_app_url(format!("/s/{}", &s3_config.id)).await, + id: s3_config.id, + }); + } + + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + tracing::error!( + "Failed to upload file. Status: {}. Body: {}", + status, + error_body + ); + Err(format!( + "Failed to upload file. Status: {status}. Body: {error_body}" + )) +} + +pub async fn create_or_get_video( + app: &AppHandle, + is_screenshot: bool, + video_id: Option, + name: Option, + meta: Option, +) -> Result { + let mut s3_config_url = if let Some(id) = video_id { + format!("/api/desktop/video/create?recordingMode=desktopMP4&videoId={id}") + } else if is_screenshot { + "/api/desktop/video/create?recordingMode=desktopMP4&isScreenshot=true".to_string() + } else { + "/api/desktop/video/create?recordingMode=desktopMP4".to_string() + }; + + if let Some(name) = name { + s3_config_url.push_str(&format!("&name={name}")); + } + + if let Some(meta) = meta { + s3_config_url.push_str(&format!("&durationInSecs={}", meta.duration_in_secs)); + s3_config_url.push_str(&format!("&width={}", meta.width)); + s3_config_url.push_str(&format!("&height={}", meta.height)); + if let Some(fps) = meta.fps { + s3_config_url.push_str(&format!("&fps={}", fps)); + } + } + + let response = app + .authed_api_request(s3_config_url, |client, url| client.get(url)) + .await + .map_err(|e| format!("Failed to send request to Next.js handler: {e}"))?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err("Failed to authenticate request; please log in again".into()); + } + + if response.status() != StatusCode::OK { + if let Ok(error) = response.json::().await { + if error.error == "upgrade_required" { + return Err( + "You must upgrade to Cap Pro to upload recordings over 5 minutes in length" + .into(), + ); + } + + return Err(format!("server error: {}", error.error)); + } + + return Err("Unknown error uploading video".into()); + } + + let response_text = response + .text() + .await + .map_err(|e| format!("Failed to read response body: {e}"))?; + + let config = serde_json::from_str::(&response_text).map_err(|e| { + format!("Failed to deserialize response: {e}. Response body: {response_text}") + })?; + + Ok(config) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PresignedS3PutRequest { + video_id: String, + subpath: String, + method: PresignedS3PutRequestMethod, + #[serde(flatten)] + meta: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PresignedS3PutRequestMethod { + #[allow(unused)] + Post, + Put, +} + +async fn presigned_s3_put(app: &AppHandle, body: PresignedS3PutRequest) -> Result { + #[derive(Deserialize, Debug)] + struct Data { + url: String, + } + + #[derive(Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + struct Wrapper { + presigned_put_data: Data, + } + + let response = app + .authed_api_request("/api/upload/signed", |client, url| { + client.post(url).json(&body) + }) + .await + .map_err(|e| format!("Failed to send request to Next.js handler: {e}"))?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err("Failed to authenticate request; please log in again".into()); + } + + let Wrapper { presigned_put_data } = response + .json::() + .await + .map_err(|e| format!("Failed to deserialize server response: {e}"))?; + + Ok(presigned_put_data.url) +} + +pub fn build_video_meta(path: &PathBuf) -> Result { + let input = + ffmpeg::format::input(path).map_err(|e| format!("Failed to read input file: {e}"))?; + let video_stream = input + .streams() + .best(ffmpeg::media::Type::Video) + .ok_or_else(|| "Failed to find appropriate video stream in file".to_string())?; + + let video_codec = ffmpeg::codec::context::Context::from_parameters(video_stream.parameters()) + .map_err(|e| format!("Unable to read video codec information: {e}"))?; + let video = video_codec + .decoder() + .video() + .map_err(|e| format!("Unable to get video decoder: {e}"))?; + + Ok(S3VideoMeta { + duration_in_secs: input.duration() as f64 / AV_TIME_BASE as f64, + width: video.width(), + height: video.height(), + fps: video + .frame_rate() + .map(|v| (v.numerator() as f32 / v.denominator() as f32)), + }) +} + +// fn build_audio_upload_body( +// path: &PathBuf, +// base: S3UploadBody, +// ) -> Result { +// let input = +// ffmpeg::format::input(path).map_err(|e| format!("Failed to read input file: {e}"))?; +// let stream = input +// .streams() +// .best(ffmpeg::media::Type::Audio) +// .ok_or_else(|| "Failed to find appropriate audio stream in file".to_string())?; + +// let duration_millis = input.duration() as f64 / 1000.; + +// let codec = ffmpeg::codec::context::Context::from_parameters(stream.parameters()) +// .map_err(|e| format!("Unable to read audio codec information: {e}"))?; +// let codec_name = codec.id(); + +// let is_mp3 = path.extension().is_some_and(|ext| ext == "mp3"); + +// Ok(S3AudioUploadBody { +// base, +// duration: duration_millis.to_string(), +// audio_codec: format!("{codec_name:?}").replace("Id::", "").to_lowercase(), +// is_mp3, +// }) +// } + +pub async fn prepare_screenshot_upload( + app: &AppHandle, + s3_config: &S3UploadMeta, + screenshot_path: PathBuf, +) -> Result { + let presigned_put = presigned_s3_put( + app, + PresignedS3PutRequest { + video_id: s3_config.id.clone(), + subpath: "screenshot/screen-capture.jpg".to_string(), + method: PresignedS3PutRequestMethod::Put, + meta: None, + }, + ) + .await?; + + let compressed_image = compress_image(screenshot_path).await?; + + reqwest::Client::new() + .put(presigned_put) + .header(CONTENT_LENGTH, compressed_image.len()) + .body(compressed_image) + .send() + .await + .map_err(|e| format!("Error uploading screenshot: {e}")) +} + +async fn compress_image(path: PathBuf) -> Result, String> { + task::spawn_blocking(move || { + let img = ImageReader::open(&path) + .map_err(|e| format!("Failed to open image: {e}"))? + .decode() + .map_err(|e| format!("Failed to decode image: {e}"))?; + + let new_width = img.width() / 2; + let new_height = img.height() / 2; + + let resized_img = img.resize(new_width, new_height, image::imageops::FilterType::Nearest); + + let mut buffer = Vec::new(); + let mut encoder = JpegEncoder::new_with_quality(&mut buffer, 30); + encoder + .encode( + resized_img.as_bytes(), + new_width, + new_height, + resized_img.color().into(), + ) + .map_err(|e| format!("Failed to compress image: {e}"))?; + + Ok(buffer) + }) + .await + .map_err(|e| format!("Failed to compress image: {e}"))? +} + +// a typical recommended chunk size is 5MB (AWS min part size). +const CHUNK_SIZE: u64 = 5 * 1024 * 1024; // 5MB +// const MIN_PART_SIZE: u64 = 5 * 1024 * 1024; // For non-final parts + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MultipartCompleteResponse<'a> { + video_id: &'a str, + upload_id: &'a str, + parts: &'a [UploadedPart], + #[serde(flatten)] + meta: Option, +} + +pub struct InstantMultipartUpload { + pub handle: tokio::task::JoinHandle>, +} + +impl InstantMultipartUpload { + /// starts a progressive (multipart) upload that runs until recording stops + /// and the file has stabilized (no additional data is being written). + pub fn spawn( + app: AppHandle, + video_id: String, + file_path: PathBuf, + pre_created_video: VideoUploadInfo, + realtime_upload_done: Option>, + ) -> Self { + Self { + handle: spawn_actor(Self::run( + app, + video_id, + file_path, + pre_created_video, + realtime_upload_done, + )), + } + } + + pub async fn run( + app: AppHandle, + video_id: String, + file_path: PathBuf, + pre_created_video: VideoUploadInfo, + realtime_video_done: Option>, + ) -> Result<(), String> { + use std::time::Duration; + + use tokio::time::sleep; + + // -------------------------------------------- + // basic constants and info for chunk approach + // -------------------------------------------- + let client = reqwest::Client::new(); + let s3_config = pre_created_video.config; + + let mut uploaded_parts = Vec::new(); + let mut part_number = 1; + let mut last_uploaded_position: u64 = 0; + let mut progress = UploadProgressUpdater::new(app.clone(), pre_created_video.id.clone()); + + // -------------------------------------------- + // initiate the multipart upload + // -------------------------------------------- + debug!("Initiating multipart upload for {video_id}..."); + let initiate_response = match app + .authed_api_request("/api/upload/multipart/initiate", |c, url| { + c.post(url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "videoId": s3_config.id, + "contentType": "video/mp4" + })) + }) + .await + { + Ok(r) => r, + Err(e) => { + return Err(format!("Failed to initiate multipart upload: {e}")); + } + }; + + if !initiate_response.status().is_success() { + let status = initiate_response.status(); + let error_body = initiate_response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!( + "Failed to initiate multipart upload. Status: {status}. Body: {error_body}" + )); + } + + let initiate_data = match initiate_response.json::().await { + Ok(d) => d, + Err(e) => { + return Err(format!("Failed to parse initiate response: {e}")); + } + }; + + let upload_id = match initiate_data.get("uploadId") { + Some(val) => val.as_str().unwrap_or("").to_string(), + None => { + return Err("No uploadId returned from initiate endpoint".to_string()); + } + }; + + if upload_id.is_empty() { + return Err("Empty uploadId returned from initiate endpoint".to_string()); + } + + println!("Multipart upload initiated with ID: {upload_id}"); + + let mut realtime_is_done = realtime_video_done.as_ref().map(|_| false); + + // -------------------------------------------- + // Main loop while upload not complete: + // - If we have >= CHUNK_SIZE new data, upload. + // - If recording hasn't stopped, keep waiting. + // - If recording stopped, do leftover final(s). + // -------------------------------------------- + loop { + if !realtime_is_done.unwrap_or(true) + && let Some(realtime_video_done) = &realtime_video_done + { + match realtime_video_done.try_recv() { + Ok(_) => { + realtime_is_done = Some(true); + } + Err(flume::TryRecvError::Empty) => {} + _ => { + warn!("cancelling upload as realtime generation failed"); + return Err("cancelling upload as realtime generation failed".to_string()); + } + } + } + + // Check the file's current size + if !file_path.exists() { + println!("File no longer exists, aborting upload"); + return Err("File no longer exists".to_string()); + } + + let file_size = match tokio::fs::metadata(&file_path).await { + Ok(md) => md.len(), + Err(e) => { + println!("Failed to get file metadata: {e}"); + sleep(Duration::from_millis(500)).await; + continue; + } + }; + + let new_data_size = file_size - last_uploaded_position; + + if ((new_data_size >= CHUNK_SIZE) + || new_data_size > 0 && realtime_is_done.unwrap_or(false)) + || (realtime_is_done.is_none() && new_data_size > 0) + { + // We have a full chunk to send + match Self::upload_chunk( + &app, + &client, + &file_path, + &s3_config.id, + &upload_id, + &mut part_number, + &mut last_uploaded_position, + new_data_size.min(CHUNK_SIZE), + &mut progress, + ) + .await + { + Ok(part) => { + uploaded_parts.push(part); + } + Err(e) => { + println!( + "Error uploading chunk (part {part_number}): {e}. Retrying in 1s..." + ); + sleep(Duration::from_secs(1)).await; + } + } + } else if new_data_size == 0 && realtime_is_done.unwrap_or(true) { + if realtime_is_done.unwrap_or(false) { + info!("realtime video done, uploading header chunk"); + + let part = Self::upload_chunk( + &app, + &client, + &file_path, + &s3_config.id, + &upload_id, + &mut 1, + &mut 0, + uploaded_parts[0].size as u64, + &mut progress, + ) + .await + .map_err(|err| format!("Failed to re-upload first chunk: {err}"))?; + + uploaded_parts[0] = part; + println!("Successfully re-uploaded first chunk",); + } + + // All leftover chunks are now uploaded. We finalize. + println!( + "Completing multipart upload with {} parts", + uploaded_parts.len() + ); + Self::finalize_upload(&app, &file_path, &s3_config.id, &upload_id, &uploaded_parts) + .await?; + + break; + } else { + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + + // Copy link to clipboard early + let _ = app.clipboard().write_text(pre_created_video.link.clone()); + + Ok(()) + } + + /// Upload a single chunk from the file at `last_uploaded_position` for `chunk_size` bytes. + /// Advances `last_uploaded_position` accordingly. Returns JSON { PartNumber, ETag, Size }. + #[allow(clippy::too_many_arguments)] + async fn upload_chunk( + app: &AppHandle, + client: &reqwest::Client, + file_path: &PathBuf, + video_id: &str, + upload_id: &str, + part_number: &mut i32, + last_uploaded_position: &mut u64, + chunk_size: u64, + progress: &mut UploadProgressUpdater, + ) -> Result { + let file_size = match tokio::fs::metadata(file_path).await { + Ok(metadata) => metadata.len(), + Err(e) => return Err(format!("Failed to get file metadata: {e}")), + }; + + // Check if we're at the end of the file + if *last_uploaded_position >= file_size { + return Err("No more data to read - already at end of file".to_string()); + } + + // Calculate how much we can actually read + let remaining = file_size - *last_uploaded_position; + let bytes_to_read = std::cmp::min(chunk_size, remaining); + + let mut file = tokio::fs::File::open(file_path) + .await + .map_err(|e| format!("Failed to open file: {e}"))?; + + // Log before seeking + println!( + "Seeking to offset {} for part {} (file size: {}, remaining: {})", + *last_uploaded_position, *part_number, file_size, remaining + ); + + // Seek to the position we left off + if let Err(e) = file + .seek(std::io::SeekFrom::Start(*last_uploaded_position)) + .await + { + return Err(format!("Failed to seek in file: {e}")); + } + + // Read exactly bytes_to_read + let mut chunk = vec![0u8; bytes_to_read as usize]; + let mut total_read = 0; + + while total_read < bytes_to_read as usize { + match file.read(&mut chunk[total_read..]).await { + Ok(0) => break, // EOF + Ok(n) => { + total_read += n; + println!("Read {n} bytes, total so far: {total_read}/{bytes_to_read}"); + } + Err(e) => return Err(format!("Failed to read chunk from file: {e}")), + } + } + + if total_read == 0 { + return Err("No data to upload for this part.".to_string()); + } + + // Truncate the buffer to the actual bytes read + chunk.truncate(total_read); + + // Basic content‑MD5 for data integrity + let md5_sum = { + let digest = md5::compute(&chunk); + base64::encode(digest.0) + }; + + // Verify file position to ensure we're not experiencing file handle issues + let pos_after_read = file + .seek(std::io::SeekFrom::Current(0)) + .await + .map_err(|e| format!("Failed to get current file position: {e}"))?; + + let expected_pos = *last_uploaded_position + total_read as u64; + if pos_after_read != expected_pos { + println!( + "WARNING: File position after read ({pos_after_read}) doesn't match expected position ({expected_pos})" + ); + } + + let file_size = tokio::fs::metadata(file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + let remaining = file_size - *last_uploaded_position; + + println!( + "File size: {}, Last uploaded: {}, Remaining: {}, chunk_size: {}, part: {}", + file_size, *last_uploaded_position, remaining, chunk_size, *part_number + ); + println!( + "Uploading part {} ({} bytes), MD5: {}", + *part_number, total_read, md5_sum + ); + + // Request presigned URL for this part + let presign_response = match app + .authed_api_request("/api/upload/multipart/presign-part", |c, url| { + c.post(url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "videoId": video_id, + "uploadId": upload_id, + "partNumber": *part_number, + "md5Sum": &md5_sum + })) + }) + .await + { + Ok(r) => r, + Err(e) => { + return Err(format!( + "Failed to request presigned URL for part {}: {}", + *part_number, e + )); + } + }; + + progress.update(expected_pos, file_size); + + if !presign_response.status().is_success() { + let status = presign_response.status(); + let error_body = presign_response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!( + "Presign-part failed for part {}: status={}, body={}", + *part_number, status, error_body + )); + } + + let presign_data = match presign_response.json::().await { + Ok(d) => d, + Err(e) => return Err(format!("Failed to parse presigned URL response: {e}")), + }; + + let presigned_url = presign_data + .get("presignedUrl") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if presigned_url.is_empty() { + return Err(format!("Empty presignedUrl for part {}", *part_number)); + } + + // Upload the chunk with retry + let mut retry_count = 0; + let max_retries = 3; + let mut etag: Option = None; + + while retry_count < max_retries && etag.is_none() { + println!( + "Sending part {} (attempt {}/{}): {} bytes", + *part_number, + retry_count + 1, + max_retries, + total_read + ); + + match client + .put(&presigned_url) + .header("Content-MD5", &md5_sum) + .timeout(Duration::from_secs(120)) + .body(chunk.clone()) + .send() + .await + { + Ok(upload_response) => { + if upload_response.status().is_success() { + if let Some(etag_val) = upload_response.headers().get("ETag") { + let e = etag_val + .to_str() + .unwrap_or("") + .trim_matches('"') + .to_string(); + println!("Received ETag {} for part {}", e, *part_number); + etag = Some(e); + } else { + println!("No ETag in response for part {}", *part_number); + retry_count += 1; + sleep(Duration::from_secs(2)).await; + } + } else { + println!( + "Failed part {} (status {}). Will retry if possible.", + *part_number, + upload_response.status() + ); + if let Ok(body) = upload_response.text().await { + println!("Error response: {body}"); + } + retry_count += 1; + sleep(Duration::from_secs(2)).await; + } + } + Err(e) => { + println!( + "Part {} upload error (attempt {}/{}): {}", + *part_number, + retry_count + 1, + max_retries, + e + ); + retry_count += 1; + sleep(Duration::from_secs(2)).await; + } + } + } + + let etag = match etag { + Some(e) => e, + None => { + return Err(format!( + "Failed to upload part {} after {} attempts", + *part_number, max_retries + )); + } + }; + + // Advance the global progress + *last_uploaded_position += total_read as u64; + println!( + "After upload: new last_uploaded_position is {} ({}% of file)", + *last_uploaded_position, + (*last_uploaded_position as f64 / file_size as f64 * 100.0) as u32 + ); + + let part = UploadedPart { + part_number: *part_number, + etag, + size: total_read, + }; + *part_number += 1; + Ok(part) + } + + /// Completes the multipart upload with the stored parts. + /// Logs a final location if the complete call is successful. + async fn finalize_upload( + app: &AppHandle, + file_path: &PathBuf, + video_id: &str, + upload_id: &str, + uploaded_parts: &[UploadedPart], + ) -> Result<(), String> { + println!( + "Completing multipart upload with {} parts", + uploaded_parts.len() + ); + + if uploaded_parts.is_empty() { + return Err("No parts uploaded before finalizing.".to_string()); + } + + let mut total_bytes_in_parts = 0; + for part in uploaded_parts { + let pn = part.part_number; + let size = part.size; + let etag = &part.etag; + total_bytes_in_parts += part.size; + println!("Part {pn}: {size} bytes (ETag: {etag})"); + } + + let file_final_size = tokio::fs::metadata(file_path) + .await + .map(|md| md.len()) + .unwrap_or(0); + + println!("Sum of all parts: {total_bytes_in_parts} bytes"); + println!("File size on disk: {file_final_size} bytes"); + println!("Proceeding with multipart upload completion..."); + + let metadata = build_video_meta(file_path) + .map_err(|e| error!("Failed to get video metadata: {e}")) + .ok(); + + let complete_response = match app + .authed_api_request("/api/upload/multipart/complete", |c, url| { + c.post(url).header("Content-Type", "application/json").json( + &MultipartCompleteResponse { + video_id, + upload_id, + parts: uploaded_parts, + meta: metadata, + }, + ) + }) + .await + { + Ok(response) => response, + Err(e) => { + return Err(format!("Failed to complete multipart upload: {e}")); + } + }; + + if !complete_response.status().is_success() { + let status = complete_response.status(); + let error_body = complete_response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!( + "Failed to complete multipart upload. Status: {status}. Body: {error_body}" + )); + } + + let complete_data = match complete_response.json::().await { + Ok(d) => d, + Err(e) => { + return Err(format!("Failed to parse completion response: {e}")); + } + }; + + if let Some(location) = complete_data.get("location") { + println!("Multipart upload complete. Final S3 location: {location}"); + } else { + println!("Multipart upload complete. No 'location' in response."); + } + + println!("Multipart upload complete for {video_id}."); + Ok(()) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UploadedPart { + part_number: i32, + etag: String, + size: usize, +} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 52c31c84a..19ba7a9bd 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -463,7 +463,7 @@ export type StudioRecordingStatus = { status: "InProgress" } | { status: "Failed export type TargetUnderCursor = { display_id: DisplayId | null; window: WindowUnderCursor | null } export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[] } export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number } -export type UploadMeta = { state: "MultipartUpload"; video_id: string } | { state: "SinglePartUpload"; video_id: string; file_path: string; screenshot_path: string } | { state: "Failed"; error: string } | { state: "Complete" } +export type UploadMeta = { state: "MultipartUpload"; video_id: string; file_path: string; pre_created_video: VideoUploadInfo; recording_dir: string } | { state: "SinglePartUpload"; video_id: string; recording_dir: string; file_path: string; screenshot_path: string } | { state: "Failed"; error: string } | { state: "Complete" } export type UploadMode = { Initial: { pre_created_video: VideoUploadInfo | null } } | "Reupload" export type UploadProgress = { progress: number } export type UploadProgressEvent = { video_id: string; uploaded: string; total: string } diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 22c9a8e98..496ac6d5d 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -76,17 +76,34 @@ pub struct RecordingMeta { pub upload: Option, } +#[derive(Deserialize, Serialize, Clone, Type, Debug)] +pub struct S3UploadMeta { + pub id: String, +} + +#[derive(Clone, Serialize, Deserialize, specta::Type, Debug)] +pub struct VideoUploadInfo { + pub id: String, + pub link: String, + pub config: S3UploadMeta, +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(tag = "state")] pub enum UploadMeta { MultipartUpload { // Cap web identifier video_id: String, - // TODO + // Data for resuming + file_path: PathBuf, + pre_created_video: VideoUploadInfo, + recording_dir: PathBuf, }, SinglePartUpload { // Cap web identifier video_id: String, + // Path of the Cap file + recording_dir: PathBuf, // Path to video and screenshot files for resuming file_path: PathBuf, screenshot_path: PathBuf, From 1bb11a9f927ea55f4378580edcce44dba0909c79 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 15:00:56 +1000 Subject: [PATCH 27/47] format --- apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 135a9cb3a..16f03892a 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -22,6 +22,7 @@ import { Show, } from "solid-js"; import { createStore, produce } from "solid-js/store"; +import CapTooltip from "~/components/Tooltip"; import { trackEvent } from "~/utils/analytics"; import { createTauriEventListener } from "~/utils/createEventListener"; import { @@ -30,7 +31,6 @@ import { type RecordingMetaWithMetadata, type UploadProgress, } from "~/utils/tauri"; -import CapTooltip from "~/components/Tooltip"; type Recording = { meta: RecordingMetaWithMetadata; From 9c4f064fc274240669e8725c969216ba7d19f75c Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 15:16:12 +1000 Subject: [PATCH 28/47] feature flag the new uploader --- .../desktop/src-tauri/src/general_settings.rs | 7 +++ apps/desktop/src-tauri/src/lib.rs | 19 +++++-- apps/desktop/src-tauri/src/upload.rs | 56 +++++++++++++++++++ apps/desktop/src-tauri/src/upload_legacy.rs | 12 +--- .../(window-chrome)/settings/experimental.tsx | 14 +++++ apps/desktop/src/utils/tauri.ts | 2 +- 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index f8e6b4725..771ae56a8 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -97,6 +97,8 @@ pub struct GeneralSettingsStore { pub enable_new_recording_flow: bool, #[serde(default)] pub post_deletion_behaviour: PostDeletionBehaviour, + #[serde(default = "default_enable_new_uploader", skip_serializing_if = "no")] + pub enable_new_uploader: bool, } fn default_enable_native_camera_preview() -> bool { @@ -108,6 +110,10 @@ fn default_enable_new_recording_flow() -> bool { cfg!(debug_assertions) } +fn default_enable_new_uploader() -> bool { + cfg!(debug_assertions) +} + fn no(_: &bool) -> bool { false } @@ -155,6 +161,7 @@ impl Default for GeneralSettingsStore { auto_zoom_on_clicks: false, enable_new_recording_flow: default_enable_new_recording_flow(), post_deletion_behaviour: PostDeletionBehaviour::DoNothing, + enable_new_uploader: default_enable_new_uploader(), } } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d17a82d41..156734d40 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2159,10 +2159,21 @@ pub async fn run(recording_logging_handle: LoggingHandle) { tokio::spawn({ let app = app.clone(); async move { - resume_uploads(app) - .await - .map_err(|err| warn!("Error resuming uploads: {err}")) - .ok(); + let is_new_uploader_enabled = GeneralSettingsStore::get(&app) + .map_err(|err| { + error!( + "Error checking status of new uploader flow from settings: {err}" + ) + }) + .ok() + .and_then(|v| v.map(|v| v.enable_new_uploader)) + .unwrap_or(false); + if is_new_uploader_enabled { + resume_uploads(app) + .await + .map_err(|err| warn!("Error resuming uploads: {err}")) + .ok(); + } } }); diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index e0acfa809..79552dfaf 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -3,6 +3,8 @@ use crate::{ UploadProgress, VideoUploadInfo, api::{self, PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}, + general_settings::GeneralSettingsStore, + upload_legacy, web_api::ManagerExt, }; use async_stream::{stream, try_stream}; @@ -60,6 +62,28 @@ pub async fn upload_video( meta: S3VideoMeta, channel: Option>, ) -> Result { + let is_new_uploader_enabled = GeneralSettingsStore::get(&app) + .map_err(|err| error!("Error checking status of new uploader flow from settings: {err}")) + .ok() + .and_then(|v| v.map(|v| v.enable_new_uploader)) + .unwrap_or(false); + if !is_new_uploader_enabled { + return upload_legacy::upload_video( + app, + video_id, + file_path, + None, + Some(screenshot_path), + Some(meta), + channel, + ) + .await + .map(|v| UploadedItem { + link: v.link, + id: v.id, + }); + } + info!("Uploading video {video_id}..."); let (stream, total_size) = file_reader_stream(file_path).await?; @@ -128,6 +152,20 @@ async fn file_reader_stream(path: impl AsRef) -> Result<(ReaderStream Result { + let is_new_uploader_enabled = GeneralSettingsStore::get(&app) + .map_err(|err| error!("Error checking status of new uploader flow from settings: {err}")) + .ok() + .and_then(|v| v.map(|v| v.enable_new_uploader)) + .unwrap_or(false); + if !is_new_uploader_enabled { + return upload_legacy::upload_image(app, file_path) + .await + .map(|v| UploadedItem { + link: v.link, + id: v.id, + }); + } + let file_name = file_path .file_name() .and_then(|name| name.to_str()) @@ -312,6 +350,24 @@ impl InstantMultipartUpload { realtime_video_done: Option>, recording_dir: PathBuf, ) -> Result<(), String> { + let is_new_uploader_enabled = GeneralSettingsStore::get(&app) + .map_err(|err| { + error!("Error checking status of new uploader flow from settings: {err}") + }) + .ok() + .and_then(|v| v.map(|v| v.enable_new_uploader)) + .unwrap_or(false); + if !is_new_uploader_enabled { + return upload_legacy::InstantMultipartUpload::run( + app, + pre_created_video.id.clone(), + file_path, + pre_created_video, + realtime_video_done, + ) + .await; + } + let video_id = pre_created_video.id.clone(); debug!("Initiating multipart upload for {video_id}..."); diff --git a/apps/desktop/src-tauri/src/upload_legacy.rs b/apps/desktop/src-tauri/src/upload_legacy.rs index a8d05b654..20c398b1e 100644 --- a/apps/desktop/src-tauri/src/upload_legacy.rs +++ b/apps/desktop/src-tauri/src/upload_legacy.rs @@ -5,6 +5,7 @@ // credit @filleduchaos +use crate::api::S3VideoMeta; use crate::web_api::ManagerExt; use crate::{UploadProgress, VideoUploadInfo}; use cap_utils::spawn_actor; @@ -88,17 +89,6 @@ impl S3UploadMeta { // } } -#[derive(Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct S3VideoMeta { - #[serde(rename = "durationInSecs")] - pub duration_in_secs: f64, - pub width: u32, - pub height: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub fps: Option, -} - pub struct UploadedVideo { pub link: String, pub id: String, diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 1dc37e8f8..114453a98 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -26,6 +26,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { enableNewRecordingFlow: false, autoZoomOnClicks: false, custom_cursor_capture2: true, + enableNewUploader: false, }, ); @@ -96,6 +97,19 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { ); }} /> + { + handleChange("enableNewUploader", value); + // This is bad code, but I just want the UI to not jank and can't seem to find the issue. + setTimeout( + () => window.scrollTo({ top: 0, behavior: "instant" }), + 5, + ); + }} + />
diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 19ba7a9bd..56d229c9d 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -388,7 +388,7 @@ export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format export type FileType = "recording" | "screenshot" export type Flags = { captions: boolean } export type FramesRendered = { renderedCount: number; totalFrames: number; type: "FramesRendered" } -export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; hapticsEnabled?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; enableNewRecordingFlow: boolean; postDeletionBehaviour?: PostDeletionBehaviour } +export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; hapticsEnabled?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; enableNewRecordingFlow: boolean; postDeletionBehaviour?: PostDeletionBehaviour; enableNewUploader: boolean } export type GifExportSettings = { fps: number; resolution_base: XY; quality: GifQuality | null } export type GifQuality = { /** From 529cc5cbcbd45b8c29d890728b0b9cf99eb26008 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 15:48:41 +1000 Subject: [PATCH 29/47] fixes to some remaining issues --- apps/desktop/src-tauri/src/upload.rs | 48 ++++++++++++++++++--- apps/desktop/src-tauri/src/upload_legacy.rs | 38 ++++++++-------- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 79552dfaf..769501dde 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -33,6 +33,7 @@ use tokio::{ fs::File, io::{AsyncReadExt, AsyncSeekExt, BufReader}, task::{self, JoinHandle}, + time, }; use tokio_util::io::ReaderStream; use tracing::{debug, error, info}; @@ -501,10 +502,14 @@ pub fn from_pending_file_to_chunks( let new_data_size = file_size.saturating_sub(last_read_position); - // Determine if we should read a chunk - let should_read_chunk = (new_data_size >= CHUNK_SIZE) - || (new_data_size > 0 && realtime_is_done.unwrap_or(false)) - || (realtime_is_done.is_none() && new_data_size > 0); + // Read chunk if we have enough data OR if recording is done with any data + let should_read_chunk = if let Some(is_done) = realtime_is_done { + // We have a realtime receiver - check if recording is done or we have enough data + (new_data_size >= CHUNK_SIZE) || (is_done && new_data_size > 0) + } else { + // No realtime receiver - read any available data + new_data_size > 0 + }; if should_read_chunk { let chunk_size = std::cmp::min(new_data_size, CHUNK_SIZE); @@ -569,8 +574,7 @@ pub fn from_pending_file_to_chunks( } break; } else { - // Wait for more data - tokio::time::sleep(Duration::from_secs(1)).await; + tokio::time::sleep(Duration::from_millis(500)).await; } } } @@ -728,6 +732,7 @@ fn progress( ) -> impl Stream> { let mut uploaded = 0u64; let mut pending_task: Option> = None; + let mut reemit_task: Option> = None; let (video_id2, app_handle) = (video_id.clone(), app.clone()); stream! { @@ -743,6 +748,11 @@ fn progress( handle.abort(); } + // Cancel any existing reemit task + if let Some(handle) = reemit_task.take() { + handle.abort(); + } + let should_send_immediately = uploaded >= total; if should_send_immediately { @@ -760,6 +770,27 @@ fn progress( tokio::time::sleep(Duration::from_secs(2)).await; api::desktop_video_progress(&app_clone, &video_id_clone, uploaded, total).await.ok(); })); + + // Start reemit task for continuous progress updates every 700ms + let app_reemit = app.clone(); + let video_id_reemit = video_id.clone(); + let uploaded_reemit = uploaded; + let total_reemit = total; + reemit_task = Some(tokio::spawn(async move { + let mut interval = time::interval(Duration::from_millis(700)); + interval.tick().await; // Skip first immediate tick + + loop { + interval.tick().await; + UploadProgressEvent { + video_id: video_id_reemit.clone(), + uploaded: uploaded_reemit.to_string(), + total: total_reemit.to_string(), + } + .emit(&app_reemit) + .ok(); + } + })); } // Emit progress event for the app frontend @@ -774,6 +805,11 @@ fn progress( yield chunk; } + + // Clean up reemit task when stream ends + if let Some(handle) = reemit_task.take() { + handle.abort(); + } } .map(Some) .chain(stream::once(async move { diff --git a/apps/desktop/src-tauri/src/upload_legacy.rs b/apps/desktop/src-tauri/src/upload_legacy.rs index 20c398b1e..17433905f 100644 --- a/apps/desktop/src-tauri/src/upload_legacy.rs +++ b/apps/desktop/src-tauri/src/upload_legacy.rs @@ -622,25 +622,25 @@ pub struct InstantMultipartUpload { } impl InstantMultipartUpload { - /// starts a progressive (multipart) upload that runs until recording stops - /// and the file has stabilized (no additional data is being written). - pub fn spawn( - app: AppHandle, - video_id: String, - file_path: PathBuf, - pre_created_video: VideoUploadInfo, - realtime_upload_done: Option>, - ) -> Self { - Self { - handle: spawn_actor(Self::run( - app, - video_id, - file_path, - pre_created_video, - realtime_upload_done, - )), - } - } + // /// starts a progressive (multipart) upload that runs until recording stops + // /// and the file has stabilized (no additional data is being written). + // pub fn spawn( + // app: AppHandle, + // video_id: String, + // file_path: PathBuf, + // pre_created_video: VideoUploadInfo, + // realtime_upload_done: Option>, + // ) -> Self { + // Self { + // handle: spawn_actor(Self::run( + // app, + // video_id, + // file_path, + // pre_created_video, + // realtime_upload_done, + // )), + // } + // } pub async fn run( app: AppHandle, From b7c38c7e526fcecdc73ee88985caa84f88e23ab7 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 15:51:12 +1000 Subject: [PATCH 30/47] fix button visibility on `CapCard` --- .../app/(org)/dashboard/caps/components/CapCard/CapCard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index c3bc2f74b..b8442fbf5 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -314,7 +314,7 @@ export const CapCard = ({ : isDropdownOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100", - "top-2 right-2 flex-col gap-2 z-20", + "top-2 right-2 flex-col gap-2 z-[51]", )} > {uploadProgress ? ( -
-
+
+
{uploadProgress.status === "failed" ? (
From a782308a82fade29b8e89400d9ae9d3153013776 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 16:00:56 +1000 Subject: [PATCH 31/47] wip --- crates/api/src/lib.rs | 35 ++--------------------------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 9c0bd4235..b8aa47d34 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,34 +1,3 @@ -///! Types and implements for the Cap web API endpoints. +//! Types and implements for the Cap web API endpoints. -pub struct UploadMultipartInitiate { - video_id: String, -} - -// impl UploadMultipartInitiate { -// pub fn as_request(self) -> reqwest::Request { -// let mut request = reqwest::Request::new( -// reqwest::Method::POST, -// "https://api.example.com/upload_multipart_initiate" -// .parse() -// .unwrap(), -// ); -// request.header("Authorization", "Bearer YOUR_TOKEN"); -// request.json(&self).unwrap(); -// request -// } -// } - -// #[derive(Default)] -// pub struct Api { -// client: reqwest::Client, -// bearer: Option, // TODO: Hook this up -// } - -// impl Api { -// pub fn upload_multipart_initiate(&self) { -// // self.client -// todo!(); -// } -// } - -// TODO: Helper for retries, exponential backoff +// TODO: Migrate `apps/desktop/src-tauri/upload.rs` here once we figure out how auth will work with that. From 6bccc9d18cf667449cdfd7f1f50b03090bbda88f Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 16:06:59 +1000 Subject: [PATCH 32/47] Clippy improvements --- apps/desktop/src-tauri/src/lib.rs | 23 +++---- apps/desktop/src-tauri/src/main.rs | 2 - apps/desktop/src-tauri/src/recording.rs | 69 ++++++++++----------- apps/desktop/src-tauri/src/upload.rs | 26 ++++---- apps/desktop/src-tauri/src/upload_legacy.rs | 41 +++--------- 5 files changed, 62 insertions(+), 99 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 156734d40..1164fdcf9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -899,9 +899,9 @@ async fn get_video_metadata(path: PathBuf) -> Result { let status = meta.status(); if let StudioRecordingStatus::Failed { .. } = status { - return Err(format!("Unable to get metadata on failed recording")); + return Err("Unable to get metadata on failed recording".to_string()); } else if let StudioRecordingStatus::InProgress = status { - return Err(format!("Unable to get metadata on in-progress recording")); + return Err("Unable to get metadata on in-progress recording".to_string()); } match meta { @@ -1754,7 +1754,7 @@ async fn editor_delete_project( let _ = tokio::fs::remove_dir_all(&path).await; - RecordingDeleted { path }.emit(&app); + RecordingDeleted { path }.emit(&app).ok(); Ok(()) } @@ -2480,17 +2480,15 @@ async fn resume_uploads(app: AppHandle) -> Result<(), String> { } // Save the updated meta if we made changes - if needs_save { - if let Err(err) = meta.save_for_project() { - error!("Failed to save recording meta for {path:?}: {err}"); - } + if needs_save && let Err(err) = meta.save_for_project() { + error!("Failed to save recording meta for {path:?}: {err}"); } // Handle upload resumption if let Some(upload_meta) = meta.upload { match upload_meta { UploadMeta::MultipartUpload { - video_id, + video_id: _, file_path, pre_created_video, recording_dir, @@ -2513,8 +2511,7 @@ async fn resume_uploads(app: AppHandle) -> Result<(), String> { tokio::spawn(async move { if let Ok(meta) = build_video_meta(&file_path) .map_err(|err| error!("Failed to resume video upload. error getting video metadata: {}", err)) - { - if let Ok(uploaded_video) = upload_video( + && let Ok(uploaded_video) = upload_video( &app, video_id, file_path, @@ -2550,8 +2547,6 @@ async fn resume_uploads(app: AppHandle) -> Result<(), String> { .set_text(uploaded_video.link.clone()); NotificationType::ShareableLinkCopied.send(&app); } - - } }); } UploadMeta::Failed { .. } | UploadMeta::Complete => {} @@ -2668,9 +2663,9 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { RecordingMetaInner::Studio(meta) => { let status = meta.status(); if let StudioRecordingStatus::Failed { .. } = status { - return Err(format!("Unable to open failed recording")); + return Err("Unable to open failed recording".to_string()); } else if let StudioRecordingStatus::InProgress = status { - return Err(format!("Recording in progress")); + return Err("Recording in progress".to_string()); } let project_path = path.to_path_buf(); diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c99138f74..9dcaab06d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -4,8 +4,6 @@ use std::sync::Arc; use cap_desktop_lib::DynLoggingLayer; -use dirs; -use tracing_appender; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; fn main() { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index eb41b07b5..94e091f84 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -520,7 +520,7 @@ fn ycbcr_to_rgb(y: u8, cb: u8, cr: u8, range: Nv12Range) -> (u8, u8, u8) { #[cfg(target_os = "macos")] fn clamp_channel(value: f32) -> u8 { - value.max(0.0).min(255.0) as u8 + value.clamp(0.0, 255.0) as u8 } #[cfg(target_os = "macos")] @@ -929,7 +929,7 @@ async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option Result, String> { - tokio::task::spawn_blocking(|| collect_displays_with_thumbnails()) + tokio::task::spawn_blocking(collect_displays_with_thumbnails) .await .map_err(|err| err.to_string())? } @@ -937,7 +937,7 @@ pub async fn list_displays_with_thumbnails() -> Result Result, String> { - tokio::task::spawn_blocking(|| collect_windows_with_thumbnails()) + tokio::task::spawn_blocking(collect_windows_with_thumbnails) .await .map_err(|err| err.to_string())? } @@ -1681,35 +1681,30 @@ async fn handle_recording_finish( }) .ok(); } - } else { - if let Ok(meta) = build_video_meta(&output_path) - .map_err(|err| error!("Error getting video metadata: {}", err)) - { - // The upload_video function handles screenshot upload, so we can pass it along - upload_video( - &app, - video_upload_info.id.clone(), - output_path, - display_screenshot.clone(), - meta, - None, - ) - .await - .map(|_| { - info!("Final video upload with screenshot completed successfully") - }) - .map_err(|error| { - error!("Error in upload_video: {error}"); - - let mut meta = - RecordingMeta::load_for_project(&recording_dir).unwrap(); - meta.upload = Some(UploadMeta::Failed { error }); - meta.save_for_project() - .map_err(|e| format!("Failed to save recording meta: {e}")) - .ok(); - }) - .ok(); - } + } else if let Ok(meta) = build_video_meta(&output_path) + .map_err(|err| error!("Error getting video metadata: {}", err)) + { + // The upload_video function handles screenshot upload, so we can pass it along + upload_video( + &app, + video_upload_info.id.clone(), + output_path, + display_screenshot.clone(), + meta, + None, + ) + .await + .map(|_| info!("Final video upload with screenshot completed successfully")) + .map_err(|error| { + error!("Error in upload_video: {error}"); + + let mut meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); + meta.upload = Some(UploadMeta::Failed { error }); + meta.save_for_project() + .map_err(|e| format!("Failed to save recording meta: {e}")) + .ok(); + }) + .ok(); } } }); @@ -1902,11 +1897,11 @@ fn generate_zoom_segments_from_clicks_impl( let mut merged: Vec<(f64, f64)> = Vec::new(); for interval in intervals { - if let Some(last) = merged.last_mut() { - if interval.0 <= last.1 + MERGE_GAP_THRESHOLD { - last.1 = last.1.max(interval.1); - continue; - } + if let Some(last) = merged.last_mut() + && interval.0 <= last.1 + MERGE_GAP_THRESHOLD + { + last.1 = last.1.max(interval.1); + continue; } merged.push(interval); } diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 769501dde..7803ab1d4 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -153,7 +153,7 @@ async fn file_reader_stream(path: impl AsRef) -> Result<(ReaderStream Result { - let is_new_uploader_enabled = GeneralSettingsStore::get(&app) + let is_new_uploader_enabled = GeneralSettingsStore::get(app) .map_err(|err| error!("Error checking status of new uploader flow from settings: {err}")) .ok() .and_then(|v| v.map(|v| v.enable_new_uploader)) @@ -470,17 +470,15 @@ pub fn from_pending_file_to_chunks( loop { // Check if realtime recording is done - if !realtime_is_done.unwrap_or(true) { - if let Some(ref realtime_receiver) = realtime_upload_done { - match realtime_receiver.try_recv() { - Ok(_) => realtime_is_done = Some(true), - Err(flume::TryRecvError::Empty) => {}, - Err(_) => yield Err(std::io::Error::new( - std::io::ErrorKind::Interrupted, - "Realtime generation failed" - ))?, - }; - } + if !realtime_is_done.unwrap_or(true) && let Some(ref realtime_receiver) = realtime_upload_done { + match realtime_receiver.try_recv() { + Ok(_) => realtime_is_done = Some(true), + Err(flume::TryRecvError::Empty) => {}, + Err(_) => yield Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Realtime generation failed" + ))?, + }; } // Check file existence and size @@ -608,7 +606,7 @@ fn multipart_uploader( let url = Uri::from_str(&presigned_url).map_err(|err| format!("uploader/part/{part_number}/invalid_url: {err:?}"))?; let resp = reqwest::Client::builder() .retry(reqwest::retry::for_host(url.host().unwrap_or("").to_string()).classify_fn(|req_rep| { - if req_rep.status().map_or(false, |s| s.is_server_error()) { + if req_rep.status().is_some_and(|s| s.is_server_error()) { req_rep.retryable() } else { req_rep.success() @@ -657,7 +655,7 @@ pub async fn singlepart_uploader( .retry( reqwest::retry::for_host(url.host().unwrap_or("").to_string()).classify_fn( |req_rep| { - if req_rep.status().map_or(false, |s| s.is_server_error()) { + if req_rep.status().is_some_and(|s| s.is_server_error()) { req_rep.retryable() } else { req_rep.success() diff --git a/apps/desktop/src-tauri/src/upload_legacy.rs b/apps/desktop/src-tauri/src/upload_legacy.rs index 17433905f..ee069f329 100644 --- a/apps/desktop/src-tauri/src/upload_legacy.rs +++ b/apps/desktop/src-tauri/src/upload_legacy.rs @@ -8,7 +8,6 @@ use crate::api::S3VideoMeta; use crate::web_api::ManagerExt; use crate::{UploadProgress, VideoUploadInfo}; -use cap_utils::spawn_actor; use ffmpeg::ffi::AV_TIME_BASE; use flume::Receiver; use futures::StreamExt; @@ -79,15 +78,15 @@ pub struct CreateErrorResponse { // deserializer.deserialize_any(StringOrObject) // } -impl S3UploadMeta { - pub fn id(&self) -> &str { - &self.id - } +// impl S3UploadMeta { +// pub fn id(&self) -> &str { +// &self.id +// } - // pub fn new(id: String) -> Self { - // Self { id } - // } -} +// // pub fn new(id: String) -> Self { +// // Self { id } +// // } +// } pub struct UploadedVideo { pub link: String, @@ -617,31 +616,9 @@ pub struct MultipartCompleteResponse<'a> { meta: Option, } -pub struct InstantMultipartUpload { - pub handle: tokio::task::JoinHandle>, -} +pub struct InstantMultipartUpload {} impl InstantMultipartUpload { - // /// starts a progressive (multipart) upload that runs until recording stops - // /// and the file has stabilized (no additional data is being written). - // pub fn spawn( - // app: AppHandle, - // video_id: String, - // file_path: PathBuf, - // pre_created_video: VideoUploadInfo, - // realtime_upload_done: Option>, - // ) -> Self { - // Self { - // handle: spawn_actor(Self::run( - // app, - // video_id, - // file_path, - // pre_created_video, - // realtime_upload_done, - // )), - // } - // } - pub async fn run( app: AppHandle, video_id: String, From 1f88951e7b0bf9e4a4506476991bb1966eb1a94a Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 16:18:24 +1000 Subject: [PATCH 33/47] cleanup --- apps/desktop/src-tauri/src/recording.rs | 40 +++++++++++++---------- apps/desktop/src-tauri/src/upload.rs | 4 ++- crates/export/src/lib.rs | 2 -- crates/recording/src/instant_recording.rs | 6 ---- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 94e091f84..8fdd76342 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1527,19 +1527,23 @@ async fn handle_recording_end( // we delay reporting errors here so that everything else happens first Ok(recording) => Some(handle_recording_finish(&handle, recording).await), Err(error) => { - // TODO: Error handling -> Can we reuse `RecordingMeta` too? - let mut project_meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); - match &mut project_meta.inner { - RecordingMetaInner::Studio(meta) => { - if let StudioRecordingMeta::MultipleSegments { inner } = meta { - inner.status = Some(StudioRecordingStatus::Failed { error }); + if let Ok(mut project_meta) = + RecordingMeta::load_for_project(&recording_dir).map_err(|err| { + error!("Error loading recording meta while finishing recording: {err}") + }) + { + match &mut project_meta.inner { + RecordingMetaInner::Studio(meta) => { + if let StudioRecordingMeta::MultipleSegments { inner } = meta { + inner.status = Some(StudioRecordingStatus::Failed { error }); + } + } + RecordingMetaInner::Instant(meta) => { + *meta = InstantRecordingMeta::Failed { error }; } } - RecordingMetaInner::Instant(meta) => { - *meta = InstantRecordingMeta::Failed { error }; - } + project_meta.save_for_project().unwrap(); } - project_meta.save_for_project().unwrap(); None } @@ -1719,14 +1723,16 @@ async fn handle_recording_finish( } }; - // TODO: Can we avoid reloading it from disk by parsing as arg? - let mut meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); - meta.inner = meta_inner; - meta.sharing = sharing; - meta.save_for_project() - .map_err(|e| format!("Failed to save recording meta: {e}"))?; + if let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir).map_err(|err| { + error!("Failed to load recording meta while saving finished recording: {err}") + }) { + meta.inner = meta_inner.clone(); + meta.sharing = sharing; + meta.save_for_project() + .map_err(|e| format!("Failed to save recording meta: {e}"))?; + } - if let RecordingMetaInner::Studio(_) = meta.inner { + if let RecordingMetaInner::Studio(_) = meta_inner { match GeneralSettingsStore::get(app) .ok() .flatten() diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 7803ab1d4..a239a3dc2 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -68,6 +68,7 @@ pub async fn upload_video( .ok() .and_then(|v| v.map(|v| v.enable_new_uploader)) .unwrap_or(false); + info!("uploader_video: is new uploader enabled? {is_new_uploader_enabled}"); if !is_new_uploader_enabled { return upload_legacy::upload_video( app, @@ -129,7 +130,6 @@ pub async fn upload_video( let (video_result, thumbnail_result): (Result<_, String>, Result<_, String>) = tokio::join!(video_fut, thumbnail_fut); - // TODO: Reporting errors to the frontend??? let _ = (video_result?, thumbnail_result?); Ok(UploadedItem { @@ -158,6 +158,7 @@ pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result), #[error("Recording is not a studio recording")] NotStudioRecording, - #[error("Unable to export a failed recording")] - RecordingFailed, #[error("Failed to load recordings meta: {0}")] RecordingsMeta(String), #[error("Failed to setup renderer: {0}")] diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index fb226ff7d..3d56238f0 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -209,12 +209,6 @@ pub async fn spawn_instant_recording_actor( ), RecordingError, > { - // TODO: Remove - // return Err(RecordingError::Io(std::io::Error::new( - // std::io::ErrorKind::Other, - // format!("Bruh"), - // ))); - ensure_dir(&recording_dir)?; let start_time = SystemTime::now(); From 02edbdf709fbb4e4ef9e39d1f20883d6c6dd05c6 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 16:19:50 +1000 Subject: [PATCH 34/47] fix CI --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8226689de..827c80e27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.settings.target }} + components: clippy - name: Rust cache uses: swatinem/rust-cache@v2 From a5e332e9d2617203721089a2545c5ed308681ca1 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 16:23:47 +1000 Subject: [PATCH 35/47] fix Typescript --- apps/desktop/src/routes/(window-chrome)/settings/general.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 8b6b1f1e4..853973d0e 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -118,6 +118,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { enableNewRecordingFlow: false, autoZoomOnClicks: false, custom_cursor_capture2: true, + enableNewUploader: false, }, ); From d4b9b0c34d06cf8b6c9c93d9c6982e69219c3be7 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 16:46:57 +1000 Subject: [PATCH 36/47] fixes --- apps/desktop/src-tauri/src/lib.rs | 9 ++++++++- apps/desktop/src-tauri/src/recording.rs | 7 ++++++- apps/desktop/src-tauri/src/upload.rs | 3 ++- packages/web-backend/src/Videos/VideosRepo.ts | 7 ++++--- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1164fdcf9..cef248175 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2510,7 +2510,14 @@ async fn resume_uploads(app: AppHandle) -> Result<(), String> { let app = app.clone(); tokio::spawn(async move { if let Ok(meta) = build_video_meta(&file_path) - .map_err(|err| error!("Failed to resume video upload. error getting video metadata: {}", err)) + .map_err(|error| { + error!("Failed to resume video upload. error getting video metadata: {error}"); + + if let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir).map_err(|err| error!("Error loading project metadata: {err}")) { + meta.upload = Some(UploadMeta::Failed { error }); + meta.save_for_project().map_err(|err| error!("Error saving project metadata: {err}")).ok(); + } + }) && let Ok(uploaded_video) = upload_video( &app, video_id, diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 8fdd76342..bbe5343d7 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1542,7 +1542,12 @@ async fn handle_recording_end( *meta = InstantRecordingMeta::Failed { error }; } } - project_meta.save_for_project().unwrap(); + project_meta + .save_for_project() + .map_err(|err| { + error!("Error saving recording meta while finishing recording: {err}") + }) + .ok(); } None diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index a239a3dc2..cd162f3aa 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -390,7 +390,7 @@ impl InstantMultipartUpload { let upload_id = api::upload_multipart_initiate(&app, &video_id).await?; - let parts = progress( + let mut parts = progress( app.clone(), video_id.clone(), multipart_uploader( @@ -402,6 +402,7 @@ impl InstantMultipartUpload { ) .try_collect::>() .await?; + parts.sort_by_key(|part| part.part_number); let metadata = build_video_meta(&file_path) .map_err(|e| error!("Failed to get video metadata: {e}")) diff --git a/packages/web-backend/src/Videos/VideosRepo.ts b/packages/web-backend/src/Videos/VideosRepo.ts index 0e18cc8f4..d4d2e3d3f 100644 --- a/packages/web-backend/src/Videos/VideosRepo.ts +++ b/packages/web-backend/src/Videos/VideosRepo.ts @@ -45,14 +45,15 @@ export class VideosRepo extends Effect.Service()("VideosRepo", { }); const delete_ = (id: Video.VideoId) => - db.execute( - async (db) => - await Promise.all([ + db.execute(async (db) => + db.transaction((db) => + Promise.all([ db.delete(Db.videos).where(Dz.eq(Db.videos.id, id)), db .delete(Db.videoUploads) .where(Dz.eq(Db.videoUploads.videoId, id)), ]), + ), ); const create = (data: CreateVideoInput) => From 35ebc8df8011edce9e3bb5737f5f620984718e85 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 17:47:47 +1000 Subject: [PATCH 37/47] make `CapCard` more conservative with upload status --- .../caps/components/CapCard/CapCard.tsx | 123 ++++++++---------- .../spaces/[spaceId]/components/VideoCard.tsx | 12 +- apps/web/components/VideoThumbnail.tsx | 10 +- 3 files changed, 72 insertions(+), 73 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index b8442fbf5..f47e6ddff 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -32,7 +32,10 @@ import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import ProgressCircle, { useUploadProgress, } from "@/app/s/[videoId]/_components/ProgressCircle"; -import { VideoThumbnail } from "@/components/VideoThumbnail"; +import { + ImageLoadingStatus, + VideoThumbnail, +} from "@/components/VideoThumbnail"; import { useEffectMutation } from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; import { PasswordDialog } from "../PasswordDialog"; @@ -172,6 +175,7 @@ export const CapCard = ({ cap.id, cap.hasActiveUpload || false, ); + const [imageStatus, setImageStatus] = useState("loading"); // Helper function to create a drag preview element const createDragPreview = (text: string): HTMLElement => { @@ -496,10 +500,12 @@ export const CapCard = ({
)} +
{ @@ -507,76 +513,59 @@ export const CapCard = ({ }} href={`/s/${cap.id}`} > - {uploadProgress ? ( -
-
- {uploadProgress.status === "failed" ? ( -
-
- +
+
+ {uploadProgress.status === "failed" ? ( +
+
+ +
+

+ Upload failed +

+
+ ) : ( +
+
-

- Upload failed -

-
- ) : ( -
- -
- )} -
-
- ) : ( - - )} - - {uploadProgress && ( -
- {uploadProgress.status === "failed" ? ( -
-
- + )}
-

- Upload failed -

-
- ) : ( -
-
+
+ ) : null} + + - )} + containerClass={clsx( + imageStatus !== "success" && uploadProgress + ? "display-none" + : "", + "absolute inset-0", + )} + videoId={cap.id} + alt={`${cap.name} Thumbnail`} + imageStatus={imageStatus} + setImageStatus={setImageStatus} + /> +
= memo( ? new Date(video.metadata.customCreatedAt) : video.createdAt; + const [imageStatus, setImageStatus] = + useState("loading"); + return (
= memo( alt={`${video.name} Thumbnail`} objectFit="cover" containerClass="!h-full !rounded-lg !border-b-0" + imageStatus={imageStatus} + setImageStatus={setImageStatus} />
diff --git a/apps/web/components/VideoThumbnail.tsx b/apps/web/components/VideoThumbnail.tsx index 965e6ae52..6d2477d86 100644 --- a/apps/web/components/VideoThumbnail.tsx +++ b/apps/web/components/VideoThumbnail.tsx @@ -5,6 +5,8 @@ import moment from "moment"; import Image from "next/image"; import { memo, useEffect, useRef, useState } from "react"; +export type ImageLoadingStatus = "loading" | "success" | "error"; + interface VideoThumbnailProps { videoId: string; alt: string; @@ -12,6 +14,8 @@ interface VideoThumbnailProps { objectFit?: string; containerClass?: string; videoDuration?: number; + imageStatus: ImageLoadingStatus; + setImageStatus: (status: ImageLoadingStatus) => void; } const formatDuration = (durationSecs: number) => { @@ -61,16 +65,14 @@ export const VideoThumbnail: React.FC = memo( objectFit = "cover", containerClass, videoDuration, + imageStatus, + setImageStatus, }) => { const imageUrl = useQuery(imageUrlQuery(videoId)); const imageRef = useRef(null); const randomGradient = `linear-gradient(to right, ${generateRandomGrayScaleColor()}, ${generateRandomGrayScaleColor()})`; - const [imageStatus, setImageStatus] = useState< - "loading" | "error" | "success" - >("loading"); - useEffect(() => { if (imageRef.current?.complete && imageRef.current.naturalWidth !== 0) { setImageStatus("success"); From f05f2e48681acebfb6d45f42af0eb969adccfee8 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 23:19:39 +1000 Subject: [PATCH 38/47] CodeRabbit fixes --- apps/desktop/src-tauri/src/recording.rs | 11 ++++++----- .../dashboard/caps/components/CapCard/CapCard.tsx | 4 +--- apps/web/components/VideoThumbnail.tsx | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index bbe5343d7..5e433c072 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1707,11 +1707,12 @@ async fn handle_recording_finish( .map_err(|error| { error!("Error in upload_video: {error}"); - let mut meta = RecordingMeta::load_for_project(&recording_dir).unwrap(); - meta.upload = Some(UploadMeta::Failed { error }); - meta.save_for_project() - .map_err(|e| format!("Failed to save recording meta: {e}")) - .ok(); + if let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir) { + meta.upload = Some(UploadMeta::Failed { error }); + meta.save_for_project() + .map_err(|e| format!("Failed to save recording meta: {e}")) + .ok(); + } }) .ok(); } diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index f47e6ddff..3e7506f43 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -555,9 +555,7 @@ export const CapCard = ({ "transition-opacity duration-200", )} containerClass={clsx( - imageStatus !== "success" && uploadProgress - ? "display-none" - : "", + imageStatus !== "success" && uploadProgress ? "hidden" : "", "absolute inset-0", )} videoId={cap.id} diff --git a/apps/web/components/VideoThumbnail.tsx b/apps/web/components/VideoThumbnail.tsx index 6d2477d86..605da0e46 100644 --- a/apps/web/components/VideoThumbnail.tsx +++ b/apps/web/components/VideoThumbnail.tsx @@ -3,7 +3,7 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import clsx from "clsx"; import moment from "moment"; import Image from "next/image"; -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef } from "react"; export type ImageLoadingStatus = "loading" | "success" | "error"; From df98f25606602b1dd42e5f27dc13523ccee0a2cc Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 23:47:46 +1000 Subject: [PATCH 39/47] fix some `CapCard` states --- .../dashboard/caps/components/CapCard/CapCard.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index 3e7506f43..79b57a491 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -43,6 +43,7 @@ import { SharingDialog } from "../SharingDialog"; import { CapCardAnalytics } from "./CapCardAnalytics"; import { CapCardButton } from "./CapCardButton"; import { CapCardContent } from "./CapCardContent"; +import { useFeatureFlag } from "@/app/Layout/features"; export interface CapCardProps extends PropsWithChildren { cap: { @@ -175,6 +176,7 @@ export const CapCard = ({ cap.id, cap.hasActiveUpload || false, ); + const enableBetaUploadProgress = useFeatureFlag("enableUploadProgress"); const [imageStatus, setImageStatus] = useState("loading"); // Helper function to create a drag preview element @@ -367,7 +369,10 @@ export const CapCard = ({ e.stopPropagation(); handleDownload(); }} - disabled={downloadMutation.isPending || cap.hasActiveUpload} + disabled={ + downloadMutation.isPending || + (enableBetaUploadProgress && cap.hasActiveUpload) + } className="delay-25" icon={() => { return downloadMutation.isPending ? ( @@ -425,7 +430,10 @@ export const CapCard = ({ error: "Failed to duplicate cap", }); }} - disabled={duplicateMutation.isPending || cap.hasActiveUpload} + disabled={ + duplicateMutation.isPending || + (enableBetaUploadProgress && cap.hasActiveUpload) + } className="flex gap-2 items-center rounded-lg" > @@ -514,7 +522,7 @@ export const CapCard = ({ href={`/s/${cap.id}`} > {imageStatus !== "success" && uploadProgress ? ( -
+
{uploadProgress.status === "failed" ? ( From 2cae777668877fffc495ec1664138eda2decdfb2 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 2 Oct 2025 23:58:09 +1000 Subject: [PATCH 40/47] fix retry policy --- apps/desktop/src-tauri/src/upload.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index cd162f3aa..e374f6d4b 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -656,15 +656,16 @@ pub async fn singlepart_uploader( .map_err(|err| format!("singlepart_uploader/invalid_url: {err:?}"))?; let resp = reqwest::Client::builder() .retry( - reqwest::retry::for_host(url.host().unwrap_or("").to_string()).classify_fn( - |req_rep| { + reqwest::retry::for_host(url.host().unwrap_or("").to_string()) + .classify_fn(|req_rep| { if req_rep.status().is_some_and(|s| s.is_server_error()) { req_rep.retryable() } else { req_rep.success() } - }, - ), + }) + .max_retries_per_request(5) + .max_extra_load(5.0), ) .build() .map_err(|err| format!("singlepart_uploader/client: {err:?}"))? From 46b9f163b786e9e30145b8627f50468dfb9e099c Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Fri, 3 Oct 2025 00:16:40 +1000 Subject: [PATCH 41/47] wip --- .../app/(org)/dashboard/caps/components/CapCard/CapCard.tsx | 4 ++-- .../(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index 79b57a491..65b27fdd2 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -29,11 +29,12 @@ import { type PropsWithChildren, useState } from "react"; import { toast } from "sonner"; import { ConfirmationDialog } from "@/app/(org)/dashboard/_components/ConfirmationDialog"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; +import { useFeatureFlag } from "@/app/Layout/features"; import ProgressCircle, { useUploadProgress, } from "@/app/s/[videoId]/_components/ProgressCircle"; import { - ImageLoadingStatus, + type ImageLoadingStatus, VideoThumbnail, } from "@/components/VideoThumbnail"; import { useEffectMutation } from "@/lib/EffectRuntime"; @@ -43,7 +44,6 @@ import { SharingDialog } from "../SharingDialog"; import { CapCardAnalytics } from "./CapCardAnalytics"; import { CapCardButton } from "./CapCardButton"; import { CapCardContent } from "./CapCardContent"; -import { useFeatureFlag } from "@/app/Layout/features"; export interface CapCardProps extends PropsWithChildren { cap: { diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx index 53f9d0f5e..590f1f75d 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx @@ -6,7 +6,7 @@ import type React from "react"; import { memo, useState } from "react"; import { Tooltip } from "@/components/Tooltip"; import { - ImageLoadingStatus, + type ImageLoadingStatus, VideoThumbnail, } from "@/components/VideoThumbnail"; import type { VideoData } from "./AddVideosDialogBase"; From ef47a9d5ece22957b082114fe252f93a52a23daa Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 6 Oct 2025 13:21:55 +1000 Subject: [PATCH 42/47] fixes --- apps/desktop/src-tauri/src/recording.rs | 7 ++++--- crates/recording/src/instant_recording.rs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index e619f865a..aedb3f164 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -7,6 +7,7 @@ use cap_project::{ TimelineConfiguration, TimelineSegment, UploadMeta, ZoomMode, ZoomSegment, cursor::CursorEvents, }; +use cap_recording::PipelineDoneError; use cap_recording::{ RecordingError, RecordingMode, feeds::{camera, microphone}, @@ -547,7 +548,7 @@ pub async fn start_recording( } .await; - let actor_done_rx = match spawn_actor_res { + let actor_done_fut = match spawn_actor_res { Ok(rx) => rx, Err(err) => { let _ = RecordingEvent::Failed { error: err.clone() }.emit(&app); @@ -608,7 +609,7 @@ pub async fn start_recording( dialog.blocking_show(); // this clears the current recording for us - handle_recording_end(app, Err(e), &mut state, recording_dir) + handle_recording_end(app, Err(e.to_string()), &mut state, recording_dir) .await .ok(); } @@ -766,7 +767,7 @@ async fn handle_recording_end( } } RecordingMetaInner::Instant(meta) => { - *meta = InstantRecordingMeta::Failed { error }; + *meta = InstantRecordingMeta::Failed { error: error }; } } project_meta diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index ecf9d1f22..0a1ddc57c 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -108,7 +108,7 @@ impl Message for Actor { Ok(CompletedRecording { project_path: self.recording_dir.clone(), - meta: InstantRecordingMeta { + meta: InstantRecordingMeta::Complete { fps: self.video_info.fps(), sample_rate: None, }, From 8d6cb13d93688cdeab00ae6d051876e1c4f5a791 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 6 Oct 2025 14:07:13 +1000 Subject: [PATCH 43/47] emit first chunk multiple times --- apps/desktop/src-tauri/src/upload.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index e374f6d4b..0c91bd783 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -20,6 +20,7 @@ use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use specta::Type; use std::{ + collections::{HashMap, HashSet}, io, path::{Path, PathBuf}, pin::pin, @@ -402,6 +403,13 @@ impl InstantMultipartUpload { ) .try_collect::>() .await?; + + // Deduplicate parts - keep the last occurrence of each part number + let mut deduplicated_parts = HashMap::new(); + for part in parts { + deduplicated_parts.insert(part.part_number, part); + } + parts = deduplicated_parts.into_values().collect::>(); parts.sort_by_key(|part| part.part_number); let metadata = build_video_meta(&file_path) @@ -466,7 +474,7 @@ pub fn from_pending_file_to_chunks( realtime_upload_done: Option>, ) -> impl Stream> { try_stream! { - let mut part_number = 2; // Start at 2 since part 1 will be yielded last + let mut part_number = 1; let mut last_read_position: u64 = 0; let mut realtime_is_done = realtime_upload_done.as_ref().map(|_| false); let mut first_chunk_size: Option = None; @@ -533,18 +541,17 @@ pub fn from_pending_file_to_chunks( chunk.truncate(total_read); if last_read_position == 0 { - // This is the first chunk - remember its size but don't yield yet + // This is the first chunk - remember its size so we can reemit it. first_chunk_size = Some(total_read as u64); - } else { - // Yield non-first chunks immediately - yield Chunk { - total_size: file_size, - part_number, - chunk: Bytes::from(chunk), - }; - part_number += 1; } + yield Chunk { + total_size: file_size, + part_number, + chunk: Bytes::from(chunk), + }; + part_number += 1; + last_read_position += total_read as u64; } } else if new_data_size == 0 && realtime_is_done.unwrap_or(true) { From f0435a4a5df115d53417e17129fe8a9cda88385d Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 6 Oct 2025 14:20:50 +1000 Subject: [PATCH 44/47] potentially fix the missing video toolbar sometimes --- apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx | 7 ++++++- apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 7422d5912..957413a85 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -464,7 +464,12 @@ export function CapVideoPlayer({ return `https://placeholder.pics/svg/224x128/dc2626/ffffff/Error`; }, []); - const uploadProgress = useUploadProgress(videoId, hasActiveUpload || false); + const uploadProgressRaw = useUploadProgress( + videoId, + hasActiveUpload || false, + ); + // if the video comes back from S3, just ignore the upload progress. + const uploadProgress = videoLoaded ? null : uploadProgressRaw; const isUploading = uploadProgress?.status === "uploading"; const isUploadFailed = uploadProgress?.status === "failed"; diff --git a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx index a8dc133be..f0f8ecec8 100644 --- a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx @@ -280,7 +280,12 @@ export function HLSVideoPlayer({ }; }, [captionsSrc]); - const uploadProgress = useUploadProgress(videoId, hasActiveUpload || false); + const uploadProgressRaw = useUploadProgress( + videoId, + hasActiveUpload || false, + ); + // if the video comes back from S3, just ignore the upload progress. + const uploadProgress = videoLoaded ? null : uploadProgressRaw; const isUploading = uploadProgress?.status === "uploading"; const isUploadFailed = uploadProgress?.status === "failed"; From 58dede19ad5f34dd7df1904ad9599bb55ef16cc1 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 6 Oct 2025 14:36:37 +1000 Subject: [PATCH 45/47] improve chunk emit logic --- apps/desktop/src-tauri/src/upload.rs | 73 ++++++++++++---------------- 1 file changed, 31 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 0c91bd783..a6d92dc79 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -474,63 +474,55 @@ pub fn from_pending_file_to_chunks( realtime_upload_done: Option>, ) -> impl Stream> { try_stream! { + let mut file = tokio::fs::File::open(&path).await?; let mut part_number = 1; let mut last_read_position: u64 = 0; let mut realtime_is_done = realtime_upload_done.as_ref().map(|_| false); let mut first_chunk_size: Option = None; + let mut chunk_buffer = vec![0u8; CHUNK_SIZE as usize]; loop { // Check if realtime recording is done - if !realtime_is_done.unwrap_or(true) && let Some(ref realtime_receiver) = realtime_upload_done { - match realtime_receiver.try_recv() { - Ok(_) => realtime_is_done = Some(true), - Err(flume::TryRecvError::Empty) => {}, - Err(_) => yield Err(std::io::Error::new( - std::io::ErrorKind::Interrupted, - "Realtime generation failed" - ))?, - }; - } - - // Check file existence and size - if !path.exists() { - yield Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "File no longer exists" - ))?; + if !realtime_is_done.unwrap_or(true) { + if let Some(ref realtime_receiver) = realtime_upload_done { + match realtime_receiver.try_recv() { + Ok(_) => realtime_is_done = Some(true), + Err(flume::TryRecvError::Empty) => {}, + Err(_) => yield Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Realtime generation failed" + ))?, + } + } } - let file_size = match tokio::fs::metadata(&path).await { + // Get current file size - reuse file handle for metadata + let file_size = match file.metadata().await { Ok(metadata) => metadata.len(), Err(_) => { - // Retry on metadata errors (file might be temporarily locked) - tokio::time::sleep(Duration::from_millis(500)).await; + // File might be temporarily locked, retry with shorter delay + tokio::time::sleep(Duration::from_millis(100)).await; continue; } }; let new_data_size = file_size.saturating_sub(last_read_position); - // Read chunk if we have enough data OR if recording is done with any data + // Determine if we should read a chunk let should_read_chunk = if let Some(is_done) = realtime_is_done { - // We have a realtime receiver - check if recording is done or we have enough data (new_data_size >= CHUNK_SIZE) || (is_done && new_data_size > 0) } else { - // No realtime receiver - read any available data new_data_size > 0 }; if should_read_chunk { - let chunk_size = std::cmp::min(new_data_size, CHUNK_SIZE); + let chunk_size = std::cmp::min(new_data_size, CHUNK_SIZE) as usize; - let mut file = tokio::fs::File::open(&path).await?; file.seek(std::io::SeekFrom::Start(last_read_position)).await?; - let mut chunk = vec![0u8; chunk_size as usize]; let mut total_read = 0; - - while total_read < chunk_size as usize { - match file.read(&mut chunk[total_read..]).await { + while total_read < chunk_size { + match file.read(&mut chunk_buffer[total_read..chunk_size]).await { Ok(0) => break, // EOF Ok(n) => total_read += n, Err(e) => yield Err(e)?, @@ -538,33 +530,29 @@ pub fn from_pending_file_to_chunks( } if total_read > 0 { - chunk.truncate(total_read); - + // Remember first chunk size for later re-emission with updated header if last_read_position == 0 { - // This is the first chunk - remember its size so we can reemit it. first_chunk_size = Some(total_read as u64); } yield Chunk { total_size: file_size, part_number, - chunk: Bytes::from(chunk), + chunk: Bytes::copy_from_slice(&chunk_buffer[..total_read]), }; part_number += 1; - last_read_position += total_read as u64; } } else if new_data_size == 0 && realtime_is_done.unwrap_or(true) { - // Recording is done and no new data - now yield the first chunk + // Recording is done and no new data - re-emit first chunk with corrected MP4 header if let Some(first_size) = first_chunk_size { - let mut file = tokio::fs::File::open(&path).await?; file.seek(std::io::SeekFrom::Start(0)).await?; - let mut first_chunk = vec![0u8; first_size as usize]; + let chunk_size = first_size as usize; let mut total_read = 0; - while total_read < first_size as usize { - match file.read(&mut first_chunk[total_read..]).await { + while total_read < chunk_size { + match file.read(&mut chunk_buffer[total_read..chunk_size]).await { Ok(0) => break, Ok(n) => total_read += n, Err(e) => yield Err(e)?, @@ -572,17 +560,18 @@ pub fn from_pending_file_to_chunks( } if total_read > 0 { - first_chunk.truncate(total_read); + // Re-emit first chunk with part_number 1 to fix MP4 header yield Chunk { total_size: file_size, part_number: 1, - chunk: Bytes::from(first_chunk), + chunk: Bytes::copy_from_slice(&chunk_buffer[..total_read]), }; } } break; } else { - tokio::time::sleep(Duration::from_millis(500)).await; + // Reduced polling interval for better responsiveness + tokio::time::sleep(Duration::from_millis(100)).await; } } } From 30d4726c349f935fb56140505e2cf68485b224d8 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 6 Oct 2025 14:51:04 +1000 Subject: [PATCH 46/47] fix retry policy for s3 upload --- apps/desktop/src-tauri/src/upload.rs | 45 +++++++++++++--------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index a6d92dc79..bb503b108 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -496,7 +496,6 @@ pub fn from_pending_file_to_chunks( } } - // Get current file size - reuse file handle for metadata let file_size = match file.metadata().await { Ok(metadata) => metadata.len(), Err(_) => { @@ -560,7 +559,6 @@ pub fn from_pending_file_to_chunks( } if total_read > 0 { - // Re-emit first chunk with part_number 1 to fix MP4 header yield Chunk { total_size: file_size, part_number: 1, @@ -570,13 +568,31 @@ pub fn from_pending_file_to_chunks( } break; } else { - // Reduced polling interval for better responsiveness tokio::time::sleep(Duration::from_millis(100)).await; } } } } +fn retryable_client(host: &str) -> reqwest::ClientBuilder { + reqwest::Client::builder().retry( + reqwest::retry::for_host(host) + .classify_fn(|req_rep| { + match req_rep.status() { + // Server errors + Some(s) if s.is_server_error() || s == StatusCode::TOO_MANY_REQUESTS => { + req_rep.retryable() + } + // Network errors + None => req_rep.retryable(), + _ => req_rep.success(), + } + }) + .max_retries_per_request(5) + .max_extra_load(5.0), + ) +} + /// Takes an incoming stream of bytes and individually uploads them to S3. /// /// Note: It's on the caller to ensure the chunks are sized correctly within S3 limits. @@ -603,14 +619,7 @@ fn multipart_uploader( .await?; let url = Uri::from_str(&presigned_url).map_err(|err| format!("uploader/part/{part_number}/invalid_url: {err:?}"))?; - let resp = reqwest::Client::builder() - .retry(reqwest::retry::for_host(url.host().unwrap_or("").to_string()).classify_fn(|req_rep| { - if req_rep.status().is_some_and(|s| s.is_server_error()) { - req_rep.retryable() - } else { - req_rep.success() - } - })) + let resp = retryable_client(&url.host().unwrap_or("").to_string()) .build() .map_err(|err| format!("uploader/part/{part_number}/client: {err:?}"))? .put(&presigned_url) @@ -650,19 +659,7 @@ pub async fn singlepart_uploader( let url = Uri::from_str(&presigned_url) .map_err(|err| format!("singlepart_uploader/invalid_url: {err:?}"))?; - let resp = reqwest::Client::builder() - .retry( - reqwest::retry::for_host(url.host().unwrap_or("").to_string()) - .classify_fn(|req_rep| { - if req_rep.status().is_some_and(|s| s.is_server_error()) { - req_rep.retryable() - } else { - req_rep.success() - } - }) - .max_retries_per_request(5) - .max_extra_load(5.0), - ) + let resp = retryable_client(&url.host().unwrap_or("").to_string()) .build() .map_err(|err| format!("singlepart_uploader/client: {err:?}"))? .put(&presigned_url) From 13c71cf46ce9cef228518bbc0261efafbbc883e3 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 6 Oct 2025 14:56:27 +1000 Subject: [PATCH 47/47] stttttrrrriiiinnnnggg --- apps/desktop/src-tauri/src/upload.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index bb503b108..d0bb1a535 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -574,7 +574,7 @@ pub fn from_pending_file_to_chunks( } } -fn retryable_client(host: &str) -> reqwest::ClientBuilder { +fn retryable_client(host: String) -> reqwest::ClientBuilder { reqwest::Client::builder().retry( reqwest::retry::for_host(host) .classify_fn(|req_rep| { @@ -619,7 +619,7 @@ fn multipart_uploader( .await?; let url = Uri::from_str(&presigned_url).map_err(|err| format!("uploader/part/{part_number}/invalid_url: {err:?}"))?; - let resp = retryable_client(&url.host().unwrap_or("").to_string()) + let resp = retryable_client(url.host().unwrap_or("").to_string()) .build() .map_err(|err| format!("uploader/part/{part_number}/client: {err:?}"))? .put(&presigned_url) @@ -659,7 +659,7 @@ pub async fn singlepart_uploader( let url = Uri::from_str(&presigned_url) .map_err(|err| format!("singlepart_uploader/invalid_url: {err:?}"))?; - let resp = retryable_client(&url.host().unwrap_or("").to_string()) + let resp = retryable_client(url.host().unwrap_or("").to_string()) .build() .map_err(|err| format!("singlepart_uploader/client: {err:?}"))? .put(&presigned_url)