+ {/* Show warning for video tools */}
+ {isVideoTool && !busy && (
+
+ )}
+
+ {/* 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) });