diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 217f14e08..214db366a 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 f6e923697..58fead53b 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,26 +217,41 @@ impl UploadProgressUpdater { } async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { + debug!("\tUploadProgressUpdater::send_api_update({video_id}, {uploaded}, {total})"); + 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; 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: {}", resp.status()), - Err(err) => error!("Failed to send progress update: {err}"), + Ok(resp) => { + error!( + "Failed to send progress update: {} - {}/{} bytes", + resp.status(), + uploaded, + total + ); + if let Ok(body) = resp.text().await { + error!("Response body: {}", body); + } + } + Err(err) => error!( + "Failed to send progress update: {err} - {}/{} bytes", + uploaded, total + ), } } } @@ -259,9 +307,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(); } @@ -760,6 +813,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) @@ -857,6 +921,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()); @@ -866,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}"))?; @@ -925,6 +995,7 @@ impl InstantMultipartUpload { ); } + // TODO: Shouldn't this be inferable? let file_size = tokio::fs::metadata(file_path) .await .map(|m| m.len()) @@ -963,8 +1034,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 @@ -1068,10 +1137,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/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); 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 90041d38a..ba95eac72 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 +

+
+ ) : ( +
+ +
+ )}
- )} -
- )} +
+ ) : ( + + )} +
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); @@ -299,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 b993fb7ad..14555f865 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", 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";