diff --git a/app/page.tsx b/app/page.tsx index db622893f..ac1b3e540 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -14,7 +14,9 @@ import { FileImage, FileJson, Table, - Type + Type, + Video, + Music } from "lucide-react"; import toolsData from '@/data/tools.json'; @@ -63,6 +65,14 @@ const iconMap: { [key: string]: any } = { 'csv-combiner': Table, 'json-to-csv': FileJson, 'character-counter': Type, + 'mkv-to-mp4': Video, + 'mkv-to-webm': Video, + 'mkv-to-avi': Video, + 'mkv-to-mov': Video, + 'mkv-to-gif': Image, + 'mkv-to-mp3': Music, + 'mkv-to-wav': Music, + 'mkv-to-ogg': Music, }; // Process tools from JSON data diff --git a/app/tools/mkv-to-avi/page.tsx b/app/tools/mkv-to-avi/page.tsx new file mode 100644 index 000000000..a2466cd52 --- /dev/null +++ b/app/tools/mkv-to-avi/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import HeroConverter from "@/components/HeroConverter"; + +export default function Page() { + return ( + + ); +} \ No newline at end of file diff --git a/app/tools/mkv-to-gif/page.tsx b/app/tools/mkv-to-gif/page.tsx new file mode 100644 index 000000000..2d064dbb4 --- /dev/null +++ b/app/tools/mkv-to-gif/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import HeroConverter from "@/components/HeroConverter"; + +export default function Page() { + return ( + + ); +} \ No newline at end of file diff --git a/app/tools/mkv-to-mov/page.tsx b/app/tools/mkv-to-mov/page.tsx new file mode 100644 index 000000000..ffa2e76b8 --- /dev/null +++ b/app/tools/mkv-to-mov/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import HeroConverter from "@/components/HeroConverter"; + +export default function Page() { + return ( + + ); +} \ No newline at end of file diff --git a/app/tools/mkv-to-mp3/page.tsx b/app/tools/mkv-to-mp3/page.tsx new file mode 100644 index 000000000..edb46d2d4 --- /dev/null +++ b/app/tools/mkv-to-mp3/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import HeroConverter from "@/components/HeroConverter"; + +export default function Page() { + return ( + + ); +} \ No newline at end of file diff --git a/app/tools/mkv-to-mp4/page.tsx b/app/tools/mkv-to-mp4/page.tsx new file mode 100644 index 000000000..692d150d0 --- /dev/null +++ b/app/tools/mkv-to-mp4/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import HeroConverter from "@/components/HeroConverter"; + +export default function Page() { + return ( + + ); +} \ No newline at end of file diff --git a/app/tools/mkv-to-ogg/page.tsx b/app/tools/mkv-to-ogg/page.tsx new file mode 100644 index 000000000..984206b3b --- /dev/null +++ b/app/tools/mkv-to-ogg/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import HeroConverter from "@/components/HeroConverter"; + +export default function Page() { + return ( + + ); +} \ No newline at end of file diff --git a/app/tools/mkv-to-wav/page.tsx b/app/tools/mkv-to-wav/page.tsx new file mode 100644 index 000000000..42a03e993 --- /dev/null +++ b/app/tools/mkv-to-wav/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import HeroConverter from "@/components/HeroConverter"; + +export default function Page() { + return ( + + ); +} \ No newline at end of file diff --git a/app/tools/mkv-to-webm/page.tsx b/app/tools/mkv-to-webm/page.tsx new file mode 100644 index 000000000..17d707019 --- /dev/null +++ b/app/tools/mkv-to-webm/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import HeroConverter from "@/components/HeroConverter"; + +export default function Page() { + return ( + + ); +} \ No newline at end of file diff --git a/components/HeroConverter.tsx b/components/HeroConverter.tsx index 67a4cf095..52faa328d 100644 --- a/components/HeroConverter.tsx +++ b/components/HeroConverter.tsx @@ -2,7 +2,10 @@ import { useRef, useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { saveBlob } from "@/components/saveAs"; // you already added this earlier +import { saveBlob } from "@/components/saveAs"; +import { VideoProgress } from "@/components/VideoProgress"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertTriangle } from "lucide-react"; type Props = { title: string; // e.g., "PDF to JPG" @@ -25,6 +28,12 @@ export default function HeroConverter({ const [busy, setBusy] = useState(false); const [hint, setHint] = useState("or drop files here"); const [dropEffect, setDropEffect] = useState(""); + const [currentFile, setCurrentFile] = useState<{ + name: string; + progress: number; + status: 'loading' | 'processing' | 'completed' | 'error'; + message?: string; + } | null>(null); // Generate stable color based on tool properties const colors = [ "#ef4444", // red-500 @@ -65,11 +74,46 @@ export default function HeroConverter({ if (!files || !files.length) return; const w = ensureWorker(); setBusy(true); + for (const file of Array.from(files)) { + // Check file size for video files + const isVideo = ["mkv", "mp4", "webm", "avi", "mov"].includes(from); + const sizeMB = file.size / (1024 * 1024); + + if (isVideo && sizeMB > 100) { + console.warn(`Large file warning: ${file.name} is ${sizeMB.toFixed(1)}MB`); + } + + setCurrentFile({ + name: file.name, + progress: 0, + status: 'loading', + message: 'Loading FFmpeg...' + }); + const buf = await file.arrayBuffer(); await new Promise((resolve, reject) => { w.onmessage = (ev: MessageEvent) => { - if (!ev.data?.ok) return reject(new Error(ev.data?.error || "Convert failed")); + // Handle progress messages + if (ev.data?.type === 'progress') { + setCurrentFile({ + name: file.name, + progress: ev.data.progress || 0, + status: ev.data.status || 'processing', + message: ev.data.status === 'loading' ? 'Loading FFmpeg...' : undefined + }); + return; + } + + if (!ev.data?.ok) { + setCurrentFile({ + name: file.name, + progress: 0, + status: 'error', + message: ev.data?.error || "Convert failed" + }); + return reject(new Error(ev.data?.error || "Convert failed")); + } // Handle PDF pages (returns multiple blobs) if (ev.data.blobs) { @@ -80,17 +124,53 @@ export default function HeroConverter({ saveBlob(blob, name); }); } else { - // Handle single image conversion - const blob = new Blob([ev.data.blob], { type: to === "png" ? "image/png" : "image/jpeg" }); + // Handle single file conversion (image/video/audio) + let mimeType = "application/octet-stream"; + if (to === "png") mimeType = "image/png"; + else if (to === "jpg" || to === "jpeg") mimeType = "image/jpeg"; + else if (to === "webp") mimeType = "image/webp"; + else if (to === "gif") mimeType = "image/gif"; + else if (to === "mp4") mimeType = "video/mp4"; + else if (to === "webm") mimeType = "video/webm"; + else if (to === "avi") mimeType = "video/x-msvideo"; + else if (to === "mov") mimeType = "video/quicktime"; + else if (to === "mp3") mimeType = "audio/mpeg"; + else if (to === "wav") mimeType = "audio/wav"; + else if (to === "ogg") mimeType = "audio/ogg"; + + // Ensure we have valid data + if (!ev.data.blob || ev.data.blob.byteLength === 0) { + console.error('Received empty blob data'); + setCurrentFile({ + name: file.name, + progress: 0, + status: 'error', + message: 'Conversion produced empty file' + }); + return reject(new Error('Empty output')); + } + + const blob = new Blob([ev.data.blob], { type: mimeType }); const name = file.name.replace(/\.[^.]+$/, "") + "." + to; + console.log(`Saving ${name}, size: ${blob.size} bytes, type: ${mimeType}`); saveBlob(blob, name); + + setCurrentFile({ + name: file.name, + progress: 100, + status: 'completed', + message: 'Conversion complete!' + }); } resolve(); }; - const op = from === "pdf" ? "pdf-pages" : "raster"; - w.postMessage(op === "raster" - ? { op, from, to, buf } - : { op, to, buf }, // pdf -> jpg/png pages + const isVideo = ["mkv", "mp4", "webm", "avi", "mov"].includes(from) || + ["mp4", "webm", "avi", "mov", "gif", "mp3", "wav", "ogg"].includes(to); + const op = from === "pdf" ? "pdf-pages" : isVideo ? "video" : "raster"; + w.postMessage( + op === "pdf-pages" ? { op, to, buf } : + op === "video" ? { op, from, to, buf } : + { op, from, to, buf }, [buf]); }); } @@ -142,11 +222,41 @@ export default function HeroConverter({ (from === "pdf" ? ".pdf" : from === "jpg" ? ".jpg,.jpeg" : from === "jpeg" ? ".jpeg,.jpg" + : from === "mkv" ? ".mkv" + : from === "mp4" ? ".mp4" + : from === "webm" ? ".webm" + : from === "avi" ? ".avi" + : from === "mov" ? ".mov" : `.${from}`); + const isVideoTool = ["mkv", "mp4", "webm", "avi", "mov"].includes(from) || + ["mp4", "webm", "avi", "mov", "gif", "mp3", "wav", "ogg"].includes(to); + return (
+ {/* Show warning for video tools */} + {isVideoTool && !busy && ( + + + + Note: Video conversion runs in your browser using WebAssembly. + Large files (>50MB) may take several minutes. For MKV→MP4/MOV, we use fast remuxing when possible. + + + )} + + {/* Show progress when converting */} + {currentFile && busy && ( +
+ +
+ )}
{ + switch (status) { + case 'loading': + return ; + case 'processing': + return ; + case 'completed': + return ; + case 'error': + return ; + } + }; + + const getStatusText = () => { + switch (status) { + case 'loading': + return 'Loading FFmpeg...'; + case 'processing': + return message || `Processing ${Math.round(progress)}%`; + case 'completed': + return 'Conversion complete!'; + case 'error': + return message || 'Conversion failed'; + } + }; + + const getProgressColor = () => { + switch (status) { + case 'error': + return 'bg-red-500'; + case 'completed': + return 'bg-green-500'; + default: + return 'bg-blue-500'; + } + }; + + return ( + +
+
+
+ {getStatusIcon()} + + {fileName} + +
+ + {getStatusText()} + +
+ + + + {status === 'processing' && ( +
+ Processing video... + {Math.round(progress)}% +
+ )} +
+
+ ); +} + +interface MultiFileProgressProps { + files: Array<{ + id: string; + name: string; + progress: number; + status: 'loading' | 'processing' | 'completed' | 'error'; + message?: string; + }>; +} + +export function MultiFileProgress({ files }: MultiFileProgressProps) { + if (files.length === 0) return null; + + const totalProgress = files.reduce((acc, file) => acc + file.progress, 0) / files.length; + const completedCount = files.filter(f => f.status === 'completed').length; + const errorCount = files.filter(f => f.status === 'error').length; + const processingCount = files.filter(f => f.status === 'processing' || f.status === 'loading').length; + + return ( +
+ {/* Overall Progress */} + +
+
+

Overall Progress

+
+ {completedCount > 0 && ( + ✓ {completedCount} completed + )} + {processingCount > 0 && ( + ⟳ {processingCount} processing + )} + {errorCount > 0 && ( + ✗ {errorCount} failed + )} +
+
+ +
+ {Math.round(totalProgress)}% complete +
+
+
+ + {/* Individual File Progress */} +
+ {files.map((file) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 000000000..fc218ce3a --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,58 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } \ No newline at end of file diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx index b86e0279f..5df3c8fbc 100644 --- a/components/ui/progress.tsx +++ b/components/ui/progress.tsx @@ -5,10 +5,14 @@ import * as ProgressPrimitive from "@radix-ui/react-progress" import { cn } from "@/lib/utils" +interface ProgressProps extends React.ComponentPropsWithoutRef { + indicatorClassName?: string; +} + const Progress = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, value, ...props }, ref) => ( + ProgressProps +>(({ className, value, indicatorClassName, ...props }, ref) => ( diff --git a/data/tools.json b/data/tools.json index bd04fc685..6a132b638 100644 --- a/data/tools.json +++ b/data/tools.json @@ -3302,5 +3302,101 @@ } ] } + }, + { + "id": "mkv-to-mp4", + "name": "MKV to MP4", + "description": "Convert MKV video files to MP4 format", + "operation": "convert", + "route": "/tools/mkv-to-mp4", + "from": "mkv", + "to": "mp4", + "isActive": true, + "tags": ["mkv", "mp4", "video", "convert", "movie"], + "priority": 7 + }, + { + "id": "mkv-to-webm", + "name": "MKV to WebM", + "description": "Convert MKV video files to WebM format", + "operation": "convert", + "route": "/tools/mkv-to-webm", + "from": "mkv", + "to": "webm", + "isActive": true, + "tags": ["mkv", "webm", "video", "convert", "web"], + "priority": 6 + }, + { + "id": "mkv-to-avi", + "name": "MKV to AVI", + "description": "Convert MKV video files to AVI format", + "operation": "convert", + "route": "/tools/mkv-to-avi", + "from": "mkv", + "to": "avi", + "isActive": true, + "tags": ["mkv", "avi", "video", "convert", "legacy"], + "priority": 5 + }, + { + "id": "mkv-to-mov", + "name": "MKV to MOV", + "description": "Convert MKV video files to MOV format", + "operation": "convert", + "route": "/tools/mkv-to-mov", + "from": "mkv", + "to": "mov", + "isActive": true, + "tags": ["mkv", "mov", "video", "convert", "quicktime"], + "priority": 5 + }, + { + "id": "mkv-to-gif", + "name": "MKV to GIF", + "description": "Convert MKV video to animated GIF", + "operation": "convert", + "route": "/tools/mkv-to-gif", + "from": "mkv", + "to": "gif", + "isActive": true, + "tags": ["mkv", "gif", "video", "animation", "convert"], + "priority": 6 + }, + { + "id": "mkv-to-mp3", + "name": "MKV to MP3", + "description": "Extract audio from MKV to MP3", + "operation": "convert", + "route": "/tools/mkv-to-mp3", + "from": "mkv", + "to": "mp3", + "isActive": true, + "tags": ["mkv", "mp3", "audio", "extract", "convert"], + "priority": 7 + }, + { + "id": "mkv-to-wav", + "name": "MKV to WAV", + "description": "Extract audio from MKV to WAV", + "operation": "convert", + "route": "/tools/mkv-to-wav", + "from": "mkv", + "to": "wav", + "isActive": true, + "tags": ["mkv", "wav", "audio", "extract", "uncompressed"], + "priority": 5 + }, + { + "id": "mkv-to-ogg", + "name": "MKV to OGG", + "description": "Extract audio from MKV to OGG", + "operation": "convert", + "route": "/tools/mkv-to-ogg", + "from": "mkv", + "to": "ogg", + "isActive": true, + "tags": ["mkv", "ogg", "audio", "extract", "vorbis"], + "priority": 4 } ] \ No newline at end of file diff --git a/lib/convert/video.ts b/lib/convert/video.ts new file mode 100644 index 000000000..b3e9312b1 --- /dev/null +++ b/lib/convert/video.ts @@ -0,0 +1,154 @@ +// Load FFmpeg.wasm for video conversion +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; + +let ffmpeg: FFmpeg | null = null; +let loaded = false; + +async function loadFFmpeg(): Promise { + if (ffmpeg && loaded) return ffmpeg; + + if (!ffmpeg) { + ffmpeg = new FFmpeg(); + + // Load FFmpeg with CDN URLs + const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'; + + ffmpeg.on('log', ({ message }) => { + console.log('[FFmpeg]', message); + }); + + await ffmpeg.load({ + coreURL: `${baseURL}/ffmpeg-core.js`, + wasmURL: `${baseURL}/ffmpeg-core.wasm`, + }); + + loaded = true; + } + + return ffmpeg; +} + +export async function convertVideo( + inputBuffer: ArrayBuffer, + fromFormat: string, + toFormat: string, + options: { + quality?: number; + audioOnly?: boolean; + onProgress?: (progress: { ratio: number; time: number }) => void; + } = {} +): Promise { + const ff = await loadFFmpeg(); + + // Set up progress callback + if (options.onProgress) { + ff.on('progress', (event) => { + options.onProgress?.(event); + }); + } + + const inputName = `input.${fromFormat}`; + const outputName = `output.${toFormat}`; + + // Write input file + await ff.writeFile(inputName, new Uint8Array(inputBuffer)); + + // Build FFmpeg command based on output format + let args: string[] = ['-i', inputName]; + + // For MKV to MOV/MP4 with H.264, we can use copy codec (super fast) + const canUseCopyCodec = + fromFormat === 'mkv' && + ['mov', 'mp4'].includes(toFormat); + + if (canUseCopyCodec) { + // Just copy streams without re-encoding (FAST - like your 2 second example) + args.push('-c', 'copy'); + if (toFormat === 'mp4') { + args.push('-movflags', '+faststart'); + } + } + // Audio extraction (mp3, wav, ogg) + else if (['mp3', 'wav', 'ogg'].includes(toFormat)) { + if (toFormat === 'mp3') { + args.push('-acodec', 'libmp3lame', '-b:a', '192k'); + } else if (toFormat === 'wav') { + args.push('-acodec', 'pcm_s16le'); + } else if (toFormat === 'ogg') { + args.push('-acodec', 'libvorbis', '-q:a', '5'); + } + args.push('-vn'); // No video + } + // Video conversions - optimized for speed + else if (toFormat === 'mp4') { + // Use ultrafast preset for speed, higher CRF for smaller file + args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '28'); + args.push('-c:a', 'aac', '-b:a', '128k'); + args.push('-movflags', '+faststart'); + // Limit resolution for faster processing + args.push('-vf', 'scale=\'min(1280,iw)\':\'min(720,ih)\':force_original_aspect_ratio=decrease'); + } else if (toFormat === 'webm') { + // Use faster VP8 instead of VP9 + args.push('-c:v', 'libvpx', '-crf', '30', '-b:v', '1M'); + args.push('-c:a', 'libvorbis', '-b:a', '128k'); + args.push('-vf', 'scale=\'min(1280,iw)\':\'min(720,ih)\':force_original_aspect_ratio=decrease'); + } else if (toFormat === 'avi') { + args.push('-c:v', 'mpeg4', '-vtag', 'xvid', '-qscale:v', '8'); + args.push('-c:a', 'libmp3lame', '-b:a', '128k'); + args.push('-vf', 'scale=\'min(1280,iw)\':\'min(720,ih)\':force_original_aspect_ratio=decrease'); + } else if (toFormat === 'mov') { + args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '28'); + args.push('-c:a', 'aac', '-b:a', '128k'); + args.push('-movflags', '+faststart'); + args.push('-vf', 'scale=\'min(1280,iw)\':\'min(720,ih)\':force_original_aspect_ratio=decrease'); + } else if (toFormat === 'gif') { + // Generate palette for better quality + const paletteName = 'palette.png'; + await ff.exec([ + '-i', inputName, + '-vf', 'fps=15,scale=480:-1:flags=lanczos,palettegen', + paletteName + ]); + + // Use palette to create GIF + args = [ + '-i', inputName, + '-i', paletteName, + '-lavfi', 'fps=15,scale=480:-1:flags=lanczos[x];[x][1:v]paletteuse', + '-loop', '0' + ]; + } + + args.push(outputName); + + // Execute conversion + await ff.exec(args); + + // Read output file + const data = await ff.readFile(outputName); + + console.log(`Output file size: ${data.length} bytes`); + + // Cleanup + try { + await ff.deleteFile(inputName); + await ff.deleteFile(outputName); + if (toFormat === 'gif') { + await ff.deleteFile('palette.png'); + } + } catch (cleanupErr) { + console.warn('Cleanup error:', cleanupErr); + } + + // Return the Uint8Array buffer + return data instanceof Uint8Array ? data.buffer : data; +} + +export async function cleanupFFmpeg() { + if (ffmpeg) { + ffmpeg.terminate(); + ffmpeg = null; + loaded = false; + } +} \ No newline at end of file diff --git a/package.json b/package.json index 5f9726b2e..f09228b86 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "prepare": "husky install" }, "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.10", + "@ffmpeg/util": "^0.12.1", "@next/third-parties": "^15.4.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-progress": "^1.0.3", @@ -39,6 +41,7 @@ "autoprefixer": "^10.4.20", "eslint": "^9", "eslint-config-next": "15.1.0", + "husky": "^9.1.7", "jsdom": "^26.1.0", "next-sitemap": "^4.2.3", "postcss": "^8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 286ed3019..3da37c0f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@ffmpeg/ffmpeg': + specifier: ^0.12.10 + version: 0.12.15 + '@ffmpeg/util': + specifier: ^0.12.1 + version: 0.12.2 '@next/third-parties': specifier: ^15.4.6 version: 15.4.6(next@15.1.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1) @@ -78,6 +84,9 @@ importers: eslint-config-next: specifier: 15.1.0 version: 15.1.0(eslint@9.32.0(jiti@1.21.7))(typescript@5.9.2) + husky: + specifier: ^9.1.7 + version: 9.1.7 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -181,6 +190,18 @@ packages: resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ffmpeg/ffmpeg@0.12.15': + resolution: {integrity: sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==} + engines: {node: '>=18.x'} + + '@ffmpeg/types@0.12.4': + resolution: {integrity: sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==} + engines: {node: '>=16.x'} + + '@ffmpeg/util@0.12.2': + resolution: {integrity: sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==} + engines: {node: '>=18.x'} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1494,6 +1515,11 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2480,6 +2506,14 @@ snapshots: '@eslint/core': 0.15.1 levn: 0.4.1 + '@ffmpeg/ffmpeg@0.12.15': + dependencies: + '@ffmpeg/types': 0.12.4 + + '@ffmpeg/types@0.12.4': {} + + '@ffmpeg/util@0.12.2': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -3862,6 +3896,8 @@ snapshots: transitivePeerDependencies: - supports-color + husky@9.1.7: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 diff --git a/workers/convert.worker.ts b/workers/convert.worker.ts index 0a7ae1a1a..83edc5b7a 100644 --- a/workers/convert.worker.ts +++ b/workers/convert.worker.ts @@ -4,7 +4,8 @@ import { encodeFromRGBA } from "../lib/convert/encode"; type RasterJob = { op: "raster"; from: string; to: string; quality?: number; buf: ArrayBuffer }; type PdfJob = { op: "pdf-pages"; page?: number; to?: string; buf: ArrayBuffer }; -type Job = RasterJob | PdfJob; +type VideoJob = { op: "video"; from: string; to: string; quality?: number; buf: ArrayBuffer }; +type Job = RasterJob | PdfJob | VideoJob; declare const self: DedicatedWorkerGlobalScope; @@ -28,6 +29,36 @@ self.onmessage = async (e: MessageEvent) => { return; } + if (job.op === "video") { + try { + const { convertVideo } = await import("../lib/convert/video"); + + // Send loading status + self.postMessage({ type: 'progress', status: 'loading', progress: 0 }); + + const outputBuffer = await convertVideo(job.buf, job.from, job.to, { + quality: job.quality, + onProgress: (event) => { + // FFmpeg progress events have ratio (0-1) and time + const percent = Math.round((event.ratio || 0) * 100); + self.postMessage({ + type: 'progress', + status: 'processing', + progress: percent, + time: event.time + }); + } + }); + + // Don't transfer the buffer, just send it normally + self.postMessage({ ok: true, blob: outputBuffer }); + } catch (videoErr: any) { + console.error('Video conversion error:', videoErr); + self.postMessage({ ok: false, error: `Video conversion failed: ${videoErr?.message || videoErr}` }); + } + return; + } + self.postMessage({ ok: false, error: "Unknown op" }); } catch (err: any) { self.postMessage({ ok: false, error: err?.message || String(err) });