diff --git a/app/routes/dashboard/-layout.tsx b/app/routes/dashboard/-layout.tsx index 7af49d4..e73abe7 100644 --- a/app/routes/dashboard/-layout.tsx +++ b/app/routes/dashboard/-layout.tsx @@ -32,15 +32,21 @@ import { prewarmTeam } from "./-team.data"; import { useVideoUploadManager } from "./-useVideoUploadManager"; import { DashboardUploadProvider } from "@/lib/dashboardUploadContext"; -const VIDEO_FILE_EXTENSIONS = /\.(mp4|mov|m4v|webm|avi|mkv)$/i; +const SUPPORTED_FILE_EXTENSIONS = /\.(mp4|mov|m4v|webm|avi|mkv|docx|wav|aup3)$/i; -function isVideoFile(file: File) { - return file.type.startsWith("video/") || VIDEO_FILE_EXTENSIONS.test(file.name); +function isSupportedFile(file: File) { + return ( + file.type.startsWith("video/") || + file.type.startsWith("audio/") || + file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || + file.type === "application/octet-stream" || + SUPPORTED_FILE_EXTENSIONS.test(file.name) + ); } -function getVideoFiles(files: FileList | null) { +function getSupportedFiles(files: FileList | null) { if (!files) return []; - return Array.from(files).filter(isVideoFile); + return Array.from(files).filter(isSupportedFile); } function dragEventHasFiles(event: DragEvent) { @@ -90,7 +96,7 @@ export default function DashboardLayout() { const requestUpload = useCallback( (inputFiles: File[], preferredProjectId?: Id<"projects">) => { - const files = inputFiles.filter(isVideoFile); + const files = inputFiles.filter(isSupportedFile); if (files.length === 0) return; if (preferredProjectId) { @@ -170,7 +176,7 @@ export default function DashboardLayout() { dragDepthRef.current = 0; setIsGlobalDragActive(false); - const files = getVideoFiles(event.dataTransfer?.files ?? null); + const files = getSupportedFiles(event.dataTransfer?.files ?? null); if (files.length === 0) return; requestUpload(files); }; @@ -249,7 +255,7 @@ export default function DashboardLayout() {

- Drop videos to upload + Drop files to upload

@@ -278,7 +284,7 @@ export default function DashboardLayout() { Choose a project - {pendingFiles?.length ? `Upload ${pendingFiles.length} video${pendingFiles.length > 1 ? "s" : ""} to:` : "Pick a project to start uploading."} + {pendingFiles?.length ? `Upload ${pendingFiles.length} file${pendingFiles.length > 1 ? "s" : ""} to:` : "Pick a project to start uploading."} {uploadTargets === undefined ? ( diff --git a/app/routes/dashboard/-useVideoUploadManager.ts b/app/routes/dashboard/-useVideoUploadManager.ts index cfb760d..4e5dfdf 100644 --- a/app/routes/dashboard/-useVideoUploadManager.ts +++ b/app/routes/dashboard/-useVideoUploadManager.ts @@ -57,7 +57,7 @@ export function useVideoUploadManager() { projectId, title, fileSize: file.size, - contentType: file.type || "video/mp4", + contentType: file.type || "application/octet-stream", }); setUploads((prev) => @@ -72,7 +72,7 @@ export function useVideoUploadManager() { videoId: createdVideoId, filename: file.name, fileSize: file.size, - contentType: file.type || "video/mp4", + contentType: file.type || "application/octet-stream", }); await new Promise((resolve, reject) => { @@ -100,7 +100,7 @@ export function useVideoUploadManager() { const avgSpeed = recentSpeeds.length > 0 ? recentSpeeds.reduce((sum, speed) => sum + speed, 0) / - recentSpeeds.length + recentSpeeds.length : 0; const remaining = event.total - event.loaded; const eta = avgSpeed > 0 ? Math.ceil(remaining / avgSpeed) : null; @@ -109,11 +109,11 @@ export function useVideoUploadManager() { prev.map((upload) => upload.id === uploadId ? { - ...upload, - progress: percentage, - bytesPerSecond: avgSpeed, - estimatedSecondsRemaining: eta, - } + ...upload, + progress: percentage, + bytesPerSecond: avgSpeed, + estimatedSecondsRemaining: eta, + } : upload, ), ); @@ -140,7 +140,7 @@ export function useVideoUploadManager() { }); xhr.open("PUT", url); - xhr.setRequestHeader("Content-Type", file.type || "video/mp4"); + xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream"); xhr.send(file); }); diff --git a/convex/videoActions.ts b/convex/videoActions.ts index 43fde9d..9becada 100644 --- a/convex/videoActions.ts +++ b/convex/videoActions.ts @@ -23,10 +23,18 @@ import { BUCKET_NAME, getS3Client } from "./s3"; const GIBIBYTE = 1024 ** 3; const MAX_PRESIGNED_PUT_FILE_SIZE_BYTES = 5 * GIBIBYTE; const ALLOWED_UPLOAD_CONTENT_TYPES = new Set([ + // Video "video/mp4", "video/quicktime", "video/webm", "video/x-matroska", + // Documents + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx + // Audio + "audio/wav", + "audio/x-wav", + // Binary / generic (covers .aup3 — Audacity project files) + "application/octet-stream", ]); function getExtensionFromKey(key: string, fallback = "mp4") { @@ -115,18 +123,24 @@ function isAllowedUploadContentType(contentType: string): boolean { return ALLOWED_UPLOAD_CONTENT_TYPES.has(contentType); } +function isVideoContentType(contentType: string): boolean { + return contentType.startsWith("video/"); +} + function validateUploadRequestOrThrow(args: { fileSize: number; contentType: string }) { if (!Number.isFinite(args.fileSize) || args.fileSize <= 0) { - throw new Error("Video file size must be greater than zero."); + throw new Error("File size must be greater than zero."); } if (args.fileSize > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) { - throw new Error("Video file is too large for direct upload."); + throw new Error("File is too large for direct upload."); } const normalizedContentType = normalizeContentType(args.contentType); if (!isAllowedUploadContentType(normalizedContentType)) { - throw new Error("Unsupported video format. Allowed: mp4, mov, webm, mkv."); + throw new Error( + "Unsupported file format. Allowed: mp4, mov, webm, mkv, docx, wav, aup3.", + ); } return normalizedContentType; @@ -138,9 +152,9 @@ function shouldDeleteUploadedObjectOnFailure(error: unknown): boolean { } return ( - error.message.includes("Unsupported video format") || - error.message.includes("Video file is too large") || - error.message.includes("Uploaded video file not found") || + error.message.includes("Unsupported file format") || + error.message.includes("File is too large") || + error.message.includes("Uploaded file not found") || error.message.includes("Storage limit reached") ); } @@ -272,18 +286,20 @@ export const markUploadComplete = action({ !Number.isFinite(contentLengthRaw) || contentLengthRaw <= 0 ) { - throw new Error("Uploaded video file not found or empty."); + throw new Error("Uploaded file not found or empty."); } const contentLength = contentLengthRaw; if (contentLength > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) { - throw new Error("Video file is too large for direct upload."); + throw new Error("File is too large for direct upload."); } const normalizedContentType = normalizeContentType( head.ContentType ?? video.contentType, ); if (!isAllowedUploadContentType(normalizedContentType)) { - throw new Error("Unsupported video format. Allowed: mp4, mov, webm, mkv."); + throw new Error( + "Unsupported file format. Allowed: mp4, mov, webm, mkv, docx, wav, aup3.", + ); } await ctx.runMutation(internal.videos.reconcileUploadedObjectMetadata, { @@ -292,18 +308,26 @@ export const markUploadComplete = action({ contentType: normalizedContentType, }); - await ctx.runMutation(internal.videos.markAsProcessing, { - videoId: args.videoId, - }); + if (isVideoContentType(normalizedContentType)) { + // ── Video path: send to Mux for transcoding ── + await ctx.runMutation(internal.videos.markAsProcessing, { + videoId: args.videoId, + }); - const ingestUrl = await buildSignedBucketObjectUrl(video.s3Key, { - expiresIn: 60 * 60 * 24, - }); - const asset = await createMuxAssetFromInputUrl(args.videoId, ingestUrl); - if (asset.id) { - await ctx.runMutation(internal.videos.setMuxAssetReference, { + const ingestUrl = await buildSignedBucketObjectUrl(video.s3Key, { + expiresIn: 60 * 60 * 24, + }); + const asset = await createMuxAssetFromInputUrl(args.videoId, ingestUrl); + if (asset.id) { + await ctx.runMutation(internal.videos.setMuxAssetReference, { + videoId: args.videoId, + muxAssetId: asset.id, + }); + } + } else { + // ── Non-video path: skip Mux, mark ready immediately ── + await ctx.runMutation(internal.videos.markFileAsReady, { videoId: args.videoId, - muxAssetId: asset.id, }); } } catch (error) { @@ -325,7 +349,7 @@ export const markUploadComplete = action({ const uploadError = shouldDeleteObject && error instanceof Error ? error.message - : "Mux ingest failed after upload."; + : "Upload processing failed."; await ctx.runMutation(internal.videos.markAsFailed, { videoId: args.videoId, uploadError, diff --git a/convex/videos.ts b/convex/videos.ts index dae50eb..c360f50 100644 --- a/convex/videos.ts +++ b/convex/videos.ts @@ -363,6 +363,19 @@ export const markAsReady = internalMutation({ }, }); +/** Mark a non-video file as ready (no Mux processing required). */ +export const markFileAsReady = internalMutation({ + args: { + videoId: v.id("videos"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.videoId, { + uploadError: undefined, + status: "ready", + }); + }, +}); + export const markAsFailed = internalMutation({ args: { videoId: v.id("videos"), diff --git a/src/components/upload/DropZone.tsx b/src/components/upload/DropZone.tsx index c298591..950a601 100644 --- a/src/components/upload/DropZone.tsx +++ b/src/components/upload/DropZone.tsx @@ -31,9 +31,7 @@ export function DropZone({ onFilesSelected, disabled, className }: DropZoneProps if (disabled) return; - const files = Array.from(e.dataTransfer.files).filter((file) => - file.type.startsWith("video/") - ); + const files = Array.from(e.dataTransfer.files); if (files.length > 0) { onFilesSelected(files); @@ -71,7 +69,7 @@ export function DropZone({ onFilesSelected, disabled, className }: DropZoneProps >

- {isDragActive ? "Drop to upload" : "Drop videos or click to upload"} + {isDragActive ? "Drop to upload" : "Drop files or click to upload"}

- MP4, MOV, WebM supported + Video, audio, and documents supported

diff --git a/src/components/upload/UploadButton.tsx b/src/components/upload/UploadButton.tsx index 960881f..0752fb4 100644 --- a/src/components/upload/UploadButton.tsx +++ b/src/components/upload/UploadButton.tsx @@ -34,7 +34,7 @@ export function UploadButton({