From f7f28baf8141ced7fe04bc4625c8ef2ae8d6ac0e Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 25 Sep 2025 21:02:48 +1000 Subject: [PATCH 1/6] disable it for `UploadCapButton` --- apps/web/actions/video/upload.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 2bfa2f541c..464f390710 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,6 +174,8 @@ 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(); @@ -237,9 +240,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" From 913b990dfead4d6f839b6738171f7e82596ec5b3 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 25 Sep 2025 21:47:37 +1000 Subject: [PATCH 2/6] bruhhhhhh --- apps/desktop/src-tauri/src/web_api.rs | 2 +- apps/web/actions/video/upload.ts | 7 ++----- apps/web/app/api/desktop/[...route]/video.ts | 3 +-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-tauri/src/web_api.rs index 2970d903fb..b87b4721fc 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); diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 464f390710..6ac5ce1f7c 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -179,14 +179,11 @@ export async function createVideoAndGetUploadUrl({ }) { 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() diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 29a0074223..2a74f5230a 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()); From dbdfed3f60f1922896943ae87bffb3f2210e2280 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Fri, 26 Sep 2025 09:38:39 +1000 Subject: [PATCH 3/6] drop header as it's set by `authed_api_request` --- apps/desktop/src-tauri/src/upload.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index f6e9236972..91b442dc69 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -186,15 +186,12 @@ 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() - })) + client.post(url).json(&json!({ + "videoId": video_id, + "uploaded": uploaded, + "total": total, + "updatedAt": chrono::Utc::now().to_rfc3339() + })) }) .await; From 3a6537c718c99490000239d01cf4196e65c66d74 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Fri, 26 Sep 2025 11:46:14 +1000 Subject: [PATCH 4/6] bad-wip --- apps/desktop/src-tauri/src/recording.rs | 27 ++-- apps/desktop/src-tauri/src/upload.rs | 135 ++++++++++++++++--- apps/web/app/api/desktop/[...route]/video.ts | 53 +++++++- apps/web/package.json | 2 +- 4 files changed, 181 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 217f14e084..214db366a3 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -250,7 +250,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, @@ -261,18 +261,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-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 91b442dc69..68c304f40b 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -135,12 +135,37 @@ impl UploadProgressUpdater { } pub fn update(&mut self, uploaded: u64, total: u64) { + // Safety checks for edge cases + if total == 0 && uploaded == 0 { + debug!("Skipping progress update with both uploaded and total as 0"); + return; + } + + let clamped_uploaded = uploaded.min(total); + + debug!( + "Progress update: {}/{} bytes ({:.1}%)", + clamped_uploaded, + total, + if total > 0 { + (clamped_uploaded as f64 / total as f64) * 100.0 + } else { + 0.0 + } + ); + let should_send_immediately = { - let state = self.video_state.get_or_insert_with(|| VideoProgressState { - uploaded, - total, - pending_task: None, - last_update_time: Instant::now(), + let state = self.video_state.get_or_insert_with(|| { + debug!( + "Initializing progress state with {}/{} bytes", + clamped_uploaded, total + ); + VideoProgressState { + uploaded: clamped_uploaded, + total, + pending_task: None, + last_update_time: Instant::now(), + } }); // Cancel any pending task @@ -148,12 +173,20 @@ impl UploadProgressUpdater { handle.abort(); } - state.uploaded = uploaded; + // Only update if we have meaningful progress or completion + let has_meaningful_change = clamped_uploaded != state.uploaded || total != state.total; + + if !has_meaningful_change { + debug!("No meaningful progress change, skipping update"); + return; + } + + state.uploaded = clamped_uploaded; state.total = total; state.last_update_time = Instant::now(); // Send immediately if upload is complete - uploaded >= total + clamped_uploaded >= total && total > 0 }; let app = self.app.clone(); @@ -161,19 +194,19 @@ impl UploadProgressUpdater { tokio::spawn({ let video_id = self.video_id.clone(); async move { - Self::send_api_update(&app, video_id, uploaded, total).await; + Self::send_api_update(&app, video_id, clamped_uploaded, total).await; } }); // Clear state since upload is complete self.video_state = None; - } else { - // Schedule delayed update + } else if total > 0 { + // Only schedule delayed update if we have a valid total 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; + Self::send_api_update(&app, video_id, clamped_uploaded, total).await; }) }; @@ -184,6 +217,11 @@ impl UploadProgressUpdater { } async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { + debug!( + "Sending progress update to API: {}/{} bytes for video {}", + uploaded, total, video_id + ); + let response = app .authed_api_request("/api/desktop/video/progress", |client, url| { client.post(url).json(&json!({ @@ -197,10 +235,26 @@ impl UploadProgressUpdater { match response { Ok(resp) if resp.status().is_success() => { - trace!("Progress update sent successfully"); + trace!( + "Progress update sent successfully: {}/{} bytes", + uploaded, total + ); + } + Ok(resp) => { + error!( + "Failed to send progress update: {} - {}/{} bytes", + resp.status(), + uploaded, + total + ); + if let Ok(body) = resp.text().await { + error!("Response body: {}", body); + } } - Ok(resp) => error!("Failed to send progress update: {}", resp.status()), - Err(err) => error!("Failed to send progress update: {err}"), + Err(err) => error!( + "Failed to send progress update: {err} - {}/{} bytes", + uploaded, total + ), } } } @@ -256,9 +310,14 @@ pub async fn upload_video( if bytes_uploaded > 0 { if let Some(channel) = &channel { + let progress_value = if total_size > 0 { + bytes_uploaded as f64 / total_size as f64 + } else { + 0.0 + }; channel .send(UploadProgress { - progress: bytes_uploaded as f64 / total_size as f64, + progress: progress_value, }) .ok(); } @@ -666,6 +725,19 @@ impl InstantMultipartUpload { let mut last_uploaded_position: u64 = 0; let mut progress = UploadProgressUpdater::new(app.clone(), pre_created_video.id.clone()); + // Initialize progress with proper values + if let Ok(metadata) = tokio::fs::metadata(&file_path).await { + let initial_file_size = metadata.len(); + debug!("Initial file size: {} bytes", initial_file_size); + if initial_file_size > 0 { + progress.update(0, initial_file_size); + } else { + debug!("Initial file size is 0, will wait for file to grow"); + } + } else { + warn!("Could not get initial file metadata for progress tracking"); + } + // -------------------------------------------- // initiate the multipart upload // -------------------------------------------- @@ -757,6 +829,17 @@ impl InstantMultipartUpload { } }; + // Skip if file size is 0 + if file_size == 0 { + if realtime_is_done.unwrap_or(false) { + error!("File size is 0 after recording completed"); + return Err("File size is 0 after recording completed".to_string()); + } + debug!("File size is still 0, waiting for recording to write data..."); + sleep(Duration::from_millis(500)).await; + continue; + } + let new_data_size = file_size - last_uploaded_position; if ((new_data_size >= CHUNK_SIZE) @@ -854,6 +937,11 @@ impl InstantMultipartUpload { Err(e) => return Err(format!("Failed to get file metadata: {e}")), }; + // Check if file size is 0 + if file_size == 0 { + return Err("File size is 0, cannot upload".to_string()); + } + // 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()); @@ -960,8 +1048,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 @@ -1065,10 +1151,19 @@ impl InstantMultipartUpload { // Advance the global progress *last_uploaded_position += total_read as u64; - println!( - "After upload: new last_uploaded_position is {} ({}% of file)", - *last_uploaded_position, + + // Update progress after successful upload + progress.update(*last_uploaded_position, file_size); + + let progress_percent = if file_size > 0 { (*last_uploaded_position as f64 / file_size as f64 * 100.0) as u32 + } else { + 0 + }; + + println!( + "After upload: new last_uploaded_position is {} ({}% of file, {}/{} bytes)", + *last_uploaded_position, progress_percent, *last_uploaded_position, file_size ); let part = UploadedPart { diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 2a74f5230a..7a3428128f 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -270,6 +270,38 @@ app.post( const user = c.get("user"); const videoId = Video.VideoId.make(videoIdRaw); + console.log("Progress update request:", { + videoId: videoIdRaw, + uploaded: uploadedRaw, + total, + userId: user.id, + updatedAt, + }); + + // Validate inputs + if (total < 0) { + console.error("Invalid total size:", total); + return c.json( + { error: true, message: "Invalid total size" }, + { status: 400 }, + ); + } + + if (uploadedRaw < 0) { + console.error("Invalid uploaded size:", uploadedRaw); + return c.json( + { error: true, message: "Invalid uploaded size" }, + { status: 400 }, + ); + } + + if (total === 0 && uploadedRaw > 0) { + console.warn("Total is 0 but uploaded is greater than 0:", { + uploaded: uploadedRaw, + total, + }); + } + // Prevent it maths breaking const uploaded = Math.min(uploadedRaw, total); @@ -298,18 +330,35 @@ app.post( ), ); - if (result.rowsAffected === 0) + if (result.rowsAffected === 0) { + console.log("No existing progress record, inserting new one:", { + videoId: videoIdRaw, + uploaded, + total, + }); await db().insert(videoUploads).values({ videoId, uploaded, total, updatedAt, }); + } else { + console.log("Updated existing progress record:", { + videoId: videoIdRaw, + uploaded, + total, + rowsAffected: result.rowsAffected, + }); + } - if (uploaded === total) + if (uploaded === total) { + console.log("Upload completed, cleaning up progress record:", { + videoId: videoIdRaw, + }); await db() .delete(videoUploads) .where(eq(videoUploads.videoId, videoId)); + } return c.json(true); } catch (error) { diff --git a/apps/web/package.json b/apps/web/package.json index b993fb7ada..14555f8650 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.3.1", "private": true, "scripts": { - "dev": "dotenv -e ../../.env -- next dev --turbopack", + "dev": "dotenv -e ../../.env -- next dev", "build": "next build --turbopack", "build:web": "next build --turbopack", "build:web:docker": "cd ../.. && docker build -t cap-web-docker . --no-cache --progress=plain", From 1ac9eec05e2b855fcd4ebe94660d1bb20a39e14b Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sat, 27 Sep 2025 10:23:03 +1000 Subject: [PATCH 5/6] Make `CapCard` actually good with progress --- .../caps/components/CapCard/CapCard.tsx | 95 ++++++++++--------- packages/web-backend/src/Videos/VideosRepo.ts | 10 +- 2 files changed, 57 insertions(+), 48 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 90041d38a4..ba95eac729 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 ? ( @@ -419,7 +419,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" > @@ -501,53 +501,54 @@ 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 ? ( +
+
+ {uploadProgress.status === "failed" ? ( +
+
+ +
+

+ Upload failed +

+
+ ) : ( +
+ +
+ )}
- )} -
- )} +
+ ) : ( + + )} +
()("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* () { From f2aa0c056ffddf5da9715031f0186ba1bbde87a7 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sat, 27 Sep 2025 10:28:12 +1000 Subject: [PATCH 6/6] nit --- apps/desktop/src-tauri/src/upload.rs | 20 +++----------------- packages/web-backend/src/Videos/index.ts | 1 - 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 68c304f40b..58fead53b7 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -217,10 +217,7 @@ impl UploadProgressUpdater { } async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { - debug!( - "Sending progress update to API: {}/{} bytes for video {}", - uploaded, total, video_id - ); + debug!("\tUploadProgressUpdater::send_api_update({video_id}, {uploaded}, {total})"); let response = app .authed_api_request("/api/desktop/video/progress", |client, url| { @@ -725,19 +722,6 @@ impl InstantMultipartUpload { let mut last_uploaded_position: u64 = 0; let mut progress = UploadProgressUpdater::new(app.clone(), pre_created_video.id.clone()); - // Initialize progress with proper values - if let Ok(metadata) = tokio::fs::metadata(&file_path).await { - let initial_file_size = metadata.len(); - debug!("Initial file size: {} bytes", initial_file_size); - if initial_file_size > 0 { - progress.update(0, initial_file_size); - } else { - debug!("Initial file size is 0, will wait for file to grow"); - } - } else { - warn!("Could not get initial file metadata for progress tracking"); - } - // -------------------------------------------- // initiate the multipart upload // -------------------------------------------- @@ -951,6 +935,7 @@ impl InstantMultipartUpload { let remaining = file_size - *last_uploaded_position; let bytes_to_read = std::cmp::min(chunk_size, remaining); + // TODO: Surely we can reuse this let mut file = tokio::fs::File::open(file_path) .await .map_err(|e| format!("Failed to open file: {e}"))?; @@ -1010,6 +995,7 @@ impl InstantMultipartUpload { ); } + // TODO: Shouldn't this be inferable? let file_size = tokio::fs::metadata(file_path) .await .map(|m| m.len()) diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index fc77017804..bce182983f 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";