diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index 6062c4526..d94a0427e 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -9,7 +9,10 @@ import { useRef, useState } from "react"; import { toast } from "sonner"; import { createVideoAndGetUploadUrl } from "@/actions/video/upload"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; -import { useUploadingContext } from "@/app/(org)/dashboard/caps/UploadingContext"; +import { + type UploadStatus, + useUploadingContext, +} from "@/app/(org)/dashboard/caps/UploadingContext"; import { UpgradeModal } from "@/components/UpgradeModal"; export const UploadCapButton = ({ @@ -43,468 +46,479 @@ export const UploadCapButton = ({ const file = e.target.files?.[0]; if (!file || !user) return; - const parser = await import("@remotion/media-parser"); - const webcodecs = await import("@remotion/webcodecs"); - - try { - setUploadStatus({ status: "parsing" }); - const metadata = await parser.parseMedia({ - src: file, - fields: { - durationInSeconds: true, - dimensions: true, - fps: true, - numberOfAudioChannels: true, - sampleRate: true, - }, - }); - - const duration = metadata.durationInSeconds - ? Math.round(metadata.durationInSeconds) - : undefined; - - setUploadStatus({ status: "creating" }); - const videoData = await createVideoAndGetUploadUrl({ - duration, - resolution: metadata.dimensions - ? `${metadata.dimensions.width}x${metadata.dimensions.height}` - : undefined, - videoCodec: "h264", - audioCodec: "aac", - isScreenshot: false, - isUpload: true, - folderId, - }); - - const uploadId = videoData.id; - - setUploadStatus({ status: "converting", capId: uploadId, progress: 0 }); - - let optimizedBlob: Blob; - - try { - const calculateResizeOptions = () => { - if (!metadata.dimensions) return undefined; + const ok = await legacyUploadCap(file, folderId, setUploadStatus); + if (ok) router.refresh(); + if (inputRef.current) inputRef.current.value = ""; + }; - const { width, height } = metadata.dimensions; - const maxWidth = 1920; - const maxHeight = 1080; + const isUploading = !!uploadStatus; - if (width <= maxWidth && height <= maxHeight) { - return undefined; - } + return ( + <> + + + + + ); +}; - const widthScale = maxWidth / width; - const heightScale = maxHeight / height; - const scale = Math.min(widthScale, heightScale); +async function legacyUploadCap( + file: File, + folderId: string | undefined, + setUploadStatus: (state: UploadStatus | undefined) => void, +) { + const parser = await import("@remotion/media-parser"); + const webcodecs = await import("@remotion/webcodecs"); + + try { + setUploadStatus({ status: "parsing" }); + const metadata = await parser.parseMedia({ + src: file, + fields: { + durationInSeconds: true, + dimensions: true, + fps: true, + numberOfAudioChannels: true, + sampleRate: true, + }, + }); + + const duration = metadata.durationInSeconds + ? Math.round(metadata.durationInSeconds) + : undefined; + + setUploadStatus({ status: "creating" }); + const videoData = await createVideoAndGetUploadUrl({ + duration, + resolution: metadata.dimensions + ? `${metadata.dimensions.width}x${metadata.dimensions.height}` + : undefined, + videoCodec: "h264", + audioCodec: "aac", + isScreenshot: false, + isUpload: true, + folderId, + }); + + const uploadId = videoData.id; + + setUploadStatus({ status: "converting", capId: uploadId, progress: 0 }); + + let optimizedBlob: Blob; - return { mode: "scale" as const, scale }; - }; + try { + const calculateResizeOptions = () => { + if (!metadata.dimensions) return undefined; - const resizeOptions = calculateResizeOptions(); - - const convertResult = await webcodecs.convertMedia({ - src: file, - container: "mp4", - videoCodec: "h264", - audioCodec: "aac", - ...(resizeOptions && { resize: resizeOptions }), - onProgress: ({ overallProgress }) => { - if (overallProgress !== null) { - const progressValue = overallProgress * 100; - setUploadStatus({ - status: "converting", - capId: uploadId, - progress: progressValue, - }); - } - }, - }); - optimizedBlob = await convertResult.save(); + const { width, height } = metadata.dimensions; + const maxWidth = 1920; + const maxHeight = 1080; - if (optimizedBlob.size === 0) { - throw new Error("Conversion produced empty file"); + if (width <= maxWidth && height <= maxHeight) { + return undefined; } - const isValidVideo = await new Promise((resolve) => { - const testVideo = document.createElement("video"); - testVideo.muted = true; - testVideo.playsInline = true; - testVideo.preload = "metadata"; - - const timeout = setTimeout(() => { - console.warn("Video validation timed out"); - URL.revokeObjectURL(testVideo.src); - resolve(false); - }, 15000); - - let metadataLoaded = false; - - const validateVideo = () => { - if (metadataLoaded) return; - metadataLoaded = true; - const hasValidDuration = - testVideo.duration > 0 && - !isNaN(testVideo.duration) && - isFinite(testVideo.duration); + const widthScale = maxWidth / width; + const heightScale = maxHeight / height; + const scale = Math.min(widthScale, heightScale); - const hasValidDimensions = - (testVideo.videoWidth > 0 && testVideo.videoHeight > 0) || - (metadata.dimensions && - metadata.dimensions.width > 0 && - metadata.dimensions.height > 0); - - if (hasValidDuration && hasValidDimensions) { - clearTimeout(timeout); - URL.revokeObjectURL(testVideo.src); - resolve(true); - } else { - console.warn( - `Invalid video properties - Duration: ${testVideo.duration}, Dimensions: ${testVideo.videoWidth}x${testVideo.videoHeight}, Original dimensions: ${metadata.dimensions?.width}x${metadata.dimensions?.height}`, - ); - clearTimeout(timeout); - URL.revokeObjectURL(testVideo.src); - resolve(false); - } - }; + return { mode: "scale" as const, scale }; + }; - testVideo.addEventListener("loadedmetadata", validateVideo); - testVideo.addEventListener("loadeddata", validateVideo); - testVideo.addEventListener("canplay", validateVideo); + const resizeOptions = calculateResizeOptions(); - testVideo.addEventListener("error", (e) => { - console.error("Video validation error:", e); + const convertResult = await webcodecs.convertMedia({ + src: file, + container: "mp4", + videoCodec: "h264", + audioCodec: "aac", + ...(resizeOptions && { resize: resizeOptions }), + onProgress: ({ overallProgress }) => { + if (overallProgress !== null) { + const progressValue = overallProgress * 100; + setUploadStatus({ + status: "converting", + capId: uploadId, + progress: progressValue, + }); + } + }, + }); + optimizedBlob = await convertResult.save(); + + if (optimizedBlob.size === 0) + throw new Error("Conversion produced empty file"); + const isValidVideo = await new Promise((resolve) => { + const testVideo = document.createElement("video"); + testVideo.muted = true; + testVideo.playsInline = true; + testVideo.preload = "metadata"; + + const timeout = setTimeout(() => { + console.warn("Video validation timed out"); + URL.revokeObjectURL(testVideo.src); + resolve(false); + }, 15000); + + let metadataLoaded = false; + + const validateVideo = () => { + if (metadataLoaded) return; + metadataLoaded = true; + + const hasValidDuration = + testVideo.duration > 0 && + !isNaN(testVideo.duration) && + isFinite(testVideo.duration); + + const hasValidDimensions = + (testVideo.videoWidth > 0 && testVideo.videoHeight > 0) || + (metadata.dimensions && + metadata.dimensions.width > 0 && + metadata.dimensions.height > 0); + + if (hasValidDuration && hasValidDimensions) { + clearTimeout(timeout); + URL.revokeObjectURL(testVideo.src); + resolve(true); + } else { + console.warn( + `Invalid video properties - Duration: ${testVideo.duration}, Dimensions: ${testVideo.videoWidth}x${testVideo.videoHeight}, Original dimensions: ${metadata.dimensions?.width}x${metadata.dimensions?.height}`, + ); clearTimeout(timeout); URL.revokeObjectURL(testVideo.src); resolve(false); - }); + } + }; - testVideo.addEventListener("loadstart", () => {}); + testVideo.addEventListener("loadedmetadata", validateVideo); + testVideo.addEventListener("loadeddata", validateVideo); + testVideo.addEventListener("canplay", validateVideo); - testVideo.src = URL.createObjectURL(optimizedBlob); + testVideo.addEventListener("error", (e) => { + console.error("Video validation error:", e); + clearTimeout(timeout); + URL.revokeObjectURL(testVideo.src); + resolve(false); }); - if (!isValidVideo) { - throw new Error("Converted video is not playable"); - } - } catch (conversionError) { - console.error("Video conversion failed:", conversionError); - toast.error( - "Failed to process video file. This format may not be supported for upload.", - ); - return; + testVideo.addEventListener("loadstart", () => {}); + + testVideo.src = URL.createObjectURL(optimizedBlob); + }); + + if (!isValidVideo) { + throw new Error("Converted video is not playable"); } + } catch (conversionError) { + console.error("Video conversion failed:", conversionError); + toast.error( + "Failed to process video file. This format may not be supported for upload.", + ); + setUploadStatus(undefined); + return false; + } - const captureThumbnail = (): Promise => { - return new Promise((resolve) => { - const video = document.createElement("video"); - video.src = URL.createObjectURL(optimizedBlob); - video.muted = true; - video.playsInline = true; - video.crossOrigin = "anonymous"; + const captureThumbnail = (): Promise => { + return new Promise((resolve) => { + const video = document.createElement("video"); + video.src = URL.createObjectURL(optimizedBlob); + video.muted = true; + video.playsInline = true; + video.crossOrigin = "anonymous"; - const cleanup = () => { - URL.revokeObjectURL(video.src); - }; + const cleanup = () => { + URL.revokeObjectURL(video.src); + }; - const timeout = setTimeout(() => { + const timeout = setTimeout(() => { + cleanup(); + console.warn( + "Thumbnail generation timed out, proceeding without thumbnail", + ); + resolve(null); + }, 10000); + + video.addEventListener("loadedmetadata", () => { + try { + const seekTime = Math.min(1, video.duration / 4); + video.currentTime = seekTime; + } catch (err) { + console.warn("Failed to seek video for thumbnail:", err); + clearTimeout(timeout); cleanup(); - console.warn( - "Thumbnail generation timed out, proceeding without thumbnail", - ); resolve(null); - }, 10000); - - video.addEventListener("loadedmetadata", () => { - try { - const seekTime = Math.min(1, video.duration / 4); - video.currentTime = seekTime; - } catch (err) { - console.warn("Failed to seek video for thumbnail:", err); + } + }); + + video.addEventListener("seeked", () => { + try { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth || 640; + canvas.height = video.videoHeight || 480; + const ctx = canvas.getContext("2d"); + if (!ctx) { + console.warn("Failed to get canvas context"); clearTimeout(timeout); cleanup(); resolve(null); + return; } - }); - - video.addEventListener("seeked", () => { - try { - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth || 640; - canvas.height = video.videoHeight || 480; - const ctx = canvas.getContext("2d"); - if (!ctx) { - console.warn("Failed to get canvas context"); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + canvas.toBlob( + (blob) => { clearTimeout(timeout); cleanup(); - resolve(null); - return; - } - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - canvas.toBlob( - (blob) => { - clearTimeout(timeout); - cleanup(); - if (blob) { - resolve(blob); - } else { - console.warn("Failed to create thumbnail blob"); - resolve(null); - } - }, - "image/jpeg", - 0.8, - ); - } catch (err) { - console.warn("Error during thumbnail capture:", err); - clearTimeout(timeout); - cleanup(); - resolve(null); - } - }); - - video.addEventListener("error", (err) => { - console.warn("Video loading error for thumbnail:", err); + if (blob) { + resolve(blob); + } else { + console.warn("Failed to create thumbnail blob"); + resolve(null); + } + }, + "image/jpeg", + 0.8, + ); + } catch (err) { + console.warn("Error during thumbnail capture:", err); clearTimeout(timeout); cleanup(); resolve(null); - }); + } + }); - video.addEventListener("loadstart", () => {}); + video.addEventListener("error", (err) => { + console.warn("Video loading error for thumbnail:", err); + clearTimeout(timeout); + cleanup(); + resolve(null); }); + + video.addEventListener("loadstart", () => {}); + }); + }; + + const thumbnailBlob = await captureThumbnail(); + const thumbnailUrl = thumbnailBlob + ? URL.createObjectURL(thumbnailBlob) + : undefined; + + const formData = new FormData(); + Object.entries(videoData.presignedPostData.fields).forEach( + ([key, value]) => { + formData.append(key, value as string); + }, + ); + formData.append("file", optimizedBlob); + + setUploadStatus({ + status: "uploadingVideo", + capId: uploadId, + progress: 0, + thumbnailUrl, + }); + + // Create progress tracking state outside React + const createProgressTracker = () => { + const uploadState = { + videoId: uploadId, + uploaded: 0, + total: 0, + pendingTask: undefined as ReturnType | undefined, + lastUpdateTime: Date.now(), }; - const thumbnailBlob = await captureThumbnail(); - const thumbnailUrl = thumbnailBlob - ? URL.createObjectURL(thumbnailBlob) - : undefined; + const scheduleProgressUpdate = (uploaded: number, total: number) => { + uploadState.uploaded = uploaded; + uploadState.total = total; + uploadState.lastUpdateTime = Date.now(); - const formData = new FormData(); - Object.entries(videoData.presignedPostData.fields).forEach( - ([key, value]) => { - formData.append(key, value as string); - }, - ); - formData.append("file", optimizedBlob); + // Clear any existing pending task + if (uploadState.pendingTask) { + clearTimeout(uploadState.pendingTask); + uploadState.pendingTask = undefined; + } - setUploadStatus({ - status: "uploadingVideo", - capId: uploadId, - progress: 0, - thumbnailUrl, - }); + const shouldSendImmediately = uploaded >= total; + + if (shouldSendImmediately) { + // Don't send completion update immediately - let xhr.onload handle it + // to avoid double progress updates + return; + } else { + // Schedule delayed update (after 2 seconds) + uploadState.pendingTask = setTimeout(() => { + if (uploadState.videoId) { + sendProgressUpdate( + uploadState.videoId, + uploadState.uploaded, + uploadState.total, + ); + } + uploadState.pendingTask = undefined; + }, 2000); + } + }; - // Create progress tracking state outside React - const createProgressTracker = () => { - const uploadState = { - videoId: uploadId, - uploaded: 0, - total: 0, - pendingTask: undefined as ReturnType | undefined, - lastUpdateTime: Date.now(), - }; + const cleanup = () => { + if (uploadState.pendingTask) { + clearTimeout(uploadState.pendingTask); + uploadState.pendingTask = undefined; + } + }; - const scheduleProgressUpdate = (uploaded: number, total: number) => { - uploadState.uploaded = uploaded; - uploadState.total = total; - uploadState.lastUpdateTime = Date.now(); + const getTotal = () => uploadState.total; - // Clear any existing pending task - if (uploadState.pendingTask) { - clearTimeout(uploadState.pendingTask); - uploadState.pendingTask = undefined; - } + return { scheduleProgressUpdate, cleanup, getTotal }; + }; - const shouldSendImmediately = uploaded >= total; + const progressTracker = createProgressTracker(); - if (shouldSendImmediately) { - // Don't send completion update immediately - let xhr.onload handle it - // to avoid double progress updates - return; - } else { - // Schedule delayed update (after 2 seconds) - uploadState.pendingTask = setTimeout(() => { - if (uploadState.videoId) { - sendProgressUpdate( - uploadState.videoId, - uploadState.uploaded, - uploadState.total, - ); - } - uploadState.pendingTask = undefined; - }, 2000); + try { + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", videoData.presignedPostData.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = (event.loaded / event.total) * 100; + setUploadStatus({ + status: "uploadingVideo", + capId: uploadId, + progress: percent, + thumbnailUrl, + }); + + progressTracker.scheduleProgressUpdate(event.loaded, event.total); } }; - const cleanup = () => { - if (uploadState.pendingTask) { - clearTimeout(uploadState.pendingTask); - uploadState.pendingTask = undefined; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + progressTracker.cleanup(); + // Guarantee final 100% progress update + const total = progressTracker.getTotal() || 1; + sendProgressUpdate(uploadId, total, total); + resolve(); + } else { + progressTracker.cleanup(); + reject(new Error(`Upload failed with status ${xhr.status}`)); } }; + xhr.onerror = () => { + progressTracker.cleanup(); + reject(new Error("Upload failed")); + }; - const getTotal = () => uploadState.total; - - return { scheduleProgressUpdate, cleanup, getTotal }; - }; - - const progressTracker = createProgressTracker(); - - try { - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", videoData.presignedPostData.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - setUploadStatus({ - status: "uploadingVideo", - capId: uploadId, - progress: percent, - thumbnailUrl, - }); - - progressTracker.scheduleProgressUpdate(event.loaded, event.total); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - progressTracker.cleanup(); - // Guarantee final 100% progress update - const total = progressTracker.getTotal() || 1; - sendProgressUpdate(uploadId, total, total); - resolve(); - } else { - progressTracker.cleanup(); - reject(new Error(`Upload failed with status ${xhr.status}`)); - } - }; - xhr.onerror = () => { - progressTracker.cleanup(); - reject(new Error("Upload failed")); - }; - - xhr.send(formData); - }); - } catch (uploadError) { - progressTracker.cleanup(); - throw uploadError; - } + xhr.send(formData); + }); + } catch (uploadError) { + progressTracker.cleanup(); + throw uploadError; + } - if (thumbnailBlob) { - const screenshotData = await createVideoAndGetUploadUrl({ - videoId: uploadId, - isScreenshot: true, - isUpload: true, - }); + if (thumbnailBlob) { + const screenshotData = await createVideoAndGetUploadUrl({ + videoId: uploadId, + isScreenshot: true, + isUpload: true, + }); - const screenshotFormData = new FormData(); - Object.entries(screenshotData.presignedPostData.fields).forEach( - ([key, value]) => { - screenshotFormData.append(key, value as string); - }, - ); - screenshotFormData.append("file", thumbnailBlob); - - setUploadStatus({ - status: "uploadingThumbnail", - capId: uploadId, - progress: 0, - }); - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", screenshotData.presignedPostData.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - const thumbnailProgress = 90 + percent * 0.1; - setUploadStatus({ - status: "uploadingThumbnail", - capId: uploadId, - progress: thumbnailProgress, - }); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject( - new Error(`Screenshot upload failed with status ${xhr.status}`), - ); - } - }; - xhr.onerror = () => reject(new Error("Screenshot upload failed")); + const screenshotFormData = new FormData(); + Object.entries(screenshotData.presignedPostData.fields).forEach( + ([key, value]) => { + screenshotFormData.append(key, value as string); + }, + ); + screenshotFormData.append("file", thumbnailBlob); - xhr.send(screenshotFormData); - }); - } + setUploadStatus({ + status: "uploadingThumbnail", + capId: uploadId, + progress: 0, + }); + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", screenshotData.presignedPostData.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = (event.loaded / event.total) * 100; + const thumbnailProgress = 90 + percent * 0.1; + setUploadStatus({ + status: "uploadingThumbnail", + capId: uploadId, + progress: thumbnailProgress, + }); + } + }; - router.refresh(); - } catch (err) { - console.error("Video upload failed", err); - } finally { - setUploadStatus(undefined); - if (inputRef.current) inputRef.current.value = ""; - } - }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject( + new Error(`Screenshot upload failed with status ${xhr.status}`), + ); + } + }; + xhr.onerror = () => reject(new Error("Screenshot upload failed")); - const sendProgressUpdate = async ( - videoId: string, - uploaded: number, - total: number, - ) => { - try { - const response = await fetch("/api/desktop/video/progress", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - videoId, - uploaded, - total, - updatedAt: new Date().toISOString(), - }), + xhr.send(screenshotFormData); }); - - if (!response.ok) - console.error("Failed to send progress update:", response.status); - } catch (err) { - console.error("Error sending progress update:", err); } - }; - - const isUploading = !!uploadStatus; - return ( - <> - - - - - ); + setUploadStatus(undefined); + return true; + } catch (err) { + console.error("Video upload failed", err); + } + + setUploadStatus(undefined); + return false; +} + +const sendProgressUpdate = async ( + videoId: string, + uploaded: number, + total: number, +) => { + try { + const response = await fetch("/api/desktop/video/progress", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + videoId, + uploaded, + total, + updatedAt: new Date().toISOString(), + }), + }); + + if (!response.ok) + console.error("Failed to send progress update:", response.status); + } catch (err) { + console.error("Error sending progress update:", err); + } };