From 8bca6f0c4ede8bcb5c2c5686f7c1c6c4ed47e97e Mon Sep 17 00:00:00 2001 From: devinschumacher Date: Thu, 14 Aug 2025 00:29:53 -0700 Subject: [PATCH 1/6] Add FFmpeg.wasm support for MKV video converters - Install @ffmpeg/ffmpeg and @ffmpeg/util dependencies - Create video.ts module for FFmpeg video/audio conversions - Update convert.worker.ts to handle video operations - Support MKV to MP4, WebM, AVI, MOV, GIF, MP3, WAV, OGG conversions --- lib/convert/video.ts | 125 ++++++++++++++++++++++++++++++++++++++ package.json | 3 + pnpm-lock.yaml | 36 +++++++++++ workers/convert.worker.ts | 10 ++- 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 lib/convert/video.ts diff --git a/lib/convert/video.ts b/lib/convert/video.ts new file mode 100644 index 000000000..8ff5d1016 --- /dev/null +++ b/lib/convert/video.ts @@ -0,0 +1,125 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { toBlobURL, fetchFile } from '@ffmpeg/util'; + +let ffmpeg: FFmpeg | null = null; +let loadingPromise: Promise | null = null; + +const CDN_BASE = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'; + +async function loadFFmpeg(): Promise { + if (ffmpeg) return ffmpeg; + + if (loadingPromise) { + await loadingPromise; + return ffmpeg!; + } + + loadingPromise = (async () => { + ffmpeg = new FFmpeg(); + + const coreURL = await toBlobURL(`${CDN_BASE}/ffmpeg-core.js`, 'text/javascript'); + const wasmURL = await toBlobURL(`${CDN_BASE}/ffmpeg-core.wasm`, 'application/wasm'); + + ffmpeg.on('log', ({ message }) => { + console.log('[FFmpeg]', message); + }); + + await ffmpeg.load({ + coreURL, + wasmURL, + }); + })(); + + await loadingPromise; + return ffmpeg!; +} + +export async function convertVideo( + inputBuffer: ArrayBuffer, + fromFormat: string, + toFormat: string, + options: { + quality?: number; + audioOnly?: boolean; + } = {} +): Promise { + const ff = await loadFFmpeg(); + + 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]; + + // Audio extraction (mp3, wav, ogg) + 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 + else if (toFormat === 'mp4') { + args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '23'); + args.push('-c:a', 'aac', '-b:a', '128k'); + args.push('-movflags', '+faststart'); + } else if (toFormat === 'webm') { + args.push('-c:v', 'libvpx-vp9', '-crf', '30', '-b:v', '0'); + args.push('-c:a', 'libopus', '-b:a', '128k'); + } else if (toFormat === 'avi') { + args.push('-c:v', 'mpeg4', '-vtag', 'xvid', '-qscale:v', '5'); + args.push('-c:a', 'libmp3lame', '-b:a', '192k'); + } else if (toFormat === 'mov') { + args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '23'); + args.push('-c:a', 'aac', '-b:a', '128k'); + args.push('-movflags', '+faststart'); + } 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); + + // Cleanup + await ff.deleteFile(inputName); + await ff.deleteFile(outputName); + if (toFormat === 'gif') { + await ff.deleteFile('palette.png'); + } + + return (data as Uint8Array).buffer; +} + +export async function cleanupFFmpeg() { + if (ffmpeg) { + ffmpeg.terminate(); + ffmpeg = null; + loadingPromise = null; + } +} \ 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..82f86702e 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,13 @@ self.onmessage = async (e: MessageEvent) => { return; } + if (job.op === "video") { + const { convertVideo } = await import("../lib/convert/video"); + const outputBuffer = await convertVideo(job.buf, job.from, job.to, { quality: job.quality }); + self.postMessage({ ok: true, blob: outputBuffer }, [outputBuffer]); + return; + } + self.postMessage({ ok: false, error: "Unknown op" }); } catch (err: any) { self.postMessage({ ok: false, error: err?.message || String(err) }); From 6ee53ee8496ca143735645a040249ca0a2f307df Mon Sep 17 00:00:00 2001 From: devinschumacher Date: Thu, 14 Aug 2025 00:36:19 -0700 Subject: [PATCH 2/6] Add 8 MKV video converter tool pages - MKV to MP4 converter - MKV to WebM converter - MKV to AVI converter - MKV to MOV converter - MKV to GIF converter - MKV to MP3 audio extractor - MKV to WAV audio extractor - MKV to OGG audio extractor All converters use FFmpeg.wasm for client-side processing --- app/tools/mkv-to-avi/page.tsx | 14 ++++++++++++++ app/tools/mkv-to-gif/page.tsx | 14 ++++++++++++++ app/tools/mkv-to-mov/page.tsx | 14 ++++++++++++++ app/tools/mkv-to-mp3/page.tsx | 14 ++++++++++++++ app/tools/mkv-to-mp4/page.tsx | 14 ++++++++++++++ app/tools/mkv-to-ogg/page.tsx | 14 ++++++++++++++ app/tools/mkv-to-wav/page.tsx | 14 ++++++++++++++ app/tools/mkv-to-webm/page.tsx | 14 ++++++++++++++ components/HeroConverter.tsx | 33 +++++++++++++++++++++++++++------ 9 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 app/tools/mkv-to-avi/page.tsx create mode 100644 app/tools/mkv-to-gif/page.tsx create mode 100644 app/tools/mkv-to-mov/page.tsx create mode 100644 app/tools/mkv-to-mp3/page.tsx create mode 100644 app/tools/mkv-to-mp4/page.tsx create mode 100644 app/tools/mkv-to-ogg/page.tsx create mode 100644 app/tools/mkv-to-wav/page.tsx create mode 100644 app/tools/mkv-to-webm/page.tsx 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..885726621 100644 --- a/components/HeroConverter.tsx +++ b/components/HeroConverter.tsx @@ -80,17 +80,33 @@ 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"; + + const blob = new Blob([ev.data.blob], { type: mimeType }); const name = file.name.replace(/\.[^.]+$/, "") + "." + to; saveBlob(blob, name); } 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,6 +158,11 @@ 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}`); return ( From d6e1a50217c5f180a8c0808e67d670f88bc136b2 Mon Sep 17 00:00:00 2001 From: devinschumacher Date: Thu, 14 Aug 2025 00:39:49 -0700 Subject: [PATCH 3/6] Add MKV converters to homepage - Added 8 MKV converter tools to tools.json - Added Video and Music icons for MKV converters - Tools now appear as cards on homepage --- app/page.tsx | 12 ++++++- data/tools.json | 96 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) 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/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 From 2a52fe39fe7048b52c2f41f2ccf28b888031296c Mon Sep 17 00:00:00 2001 From: devinschumacher Date: Thu, 14 Aug 2025 00:43:14 -0700 Subject: [PATCH 4/6] Fix FFmpeg.wasm loading issues - Use correct FFmpeg API for version 0.12 - Load from CDN with proper URLs - Fix data buffer return type - Add better error handling in worker --- lib/convert/video.ts | 35 +++++++++++++++-------------------- workers/convert.worker.ts | 11 ++++++++--- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/convert/video.ts b/lib/convert/video.ts index 8ff5d1016..2c47e83f3 100644 --- a/lib/convert/video.ts +++ b/lib/convert/video.ts @@ -1,37 +1,32 @@ +// Load FFmpeg.wasm for video conversion import { FFmpeg } from '@ffmpeg/ffmpeg'; -import { toBlobURL, fetchFile } from '@ffmpeg/util'; +import { fetchFile } from '@ffmpeg/util'; let ffmpeg: FFmpeg | null = null; -let loadingPromise: Promise | null = null; - -const CDN_BASE = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'; +let loaded = false; async function loadFFmpeg(): Promise { - if (ffmpeg) return ffmpeg; + if (ffmpeg && loaded) return ffmpeg; - if (loadingPromise) { - await loadingPromise; - return ffmpeg!; - } - - loadingPromise = (async () => { + if (!ffmpeg) { ffmpeg = new FFmpeg(); - const coreURL = await toBlobURL(`${CDN_BASE}/ffmpeg-core.js`, 'text/javascript'); - const wasmURL = await toBlobURL(`${CDN_BASE}/ffmpeg-core.wasm`, 'application/wasm'); + // 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, - wasmURL, + coreURL: `${baseURL}/ffmpeg-core.js`, + wasmURL: `${baseURL}/ffmpeg-core.wasm`, }); - })(); + + loaded = true; + } - await loadingPromise; - return ffmpeg!; + return ffmpeg; } export async function convertVideo( @@ -113,13 +108,13 @@ export async function convertVideo( await ff.deleteFile('palette.png'); } - return (data as Uint8Array).buffer; + return data.buffer; } export async function cleanupFFmpeg() { if (ffmpeg) { ffmpeg.terminate(); ffmpeg = null; - loadingPromise = null; + loaded = false; } } \ No newline at end of file diff --git a/workers/convert.worker.ts b/workers/convert.worker.ts index 82f86702e..d8360a9e4 100644 --- a/workers/convert.worker.ts +++ b/workers/convert.worker.ts @@ -30,9 +30,14 @@ self.onmessage = async (e: MessageEvent) => { } if (job.op === "video") { - const { convertVideo } = await import("../lib/convert/video"); - const outputBuffer = await convertVideo(job.buf, job.from, job.to, { quality: job.quality }); - self.postMessage({ ok: true, blob: outputBuffer }, [outputBuffer]); + try { + const { convertVideo } = await import("../lib/convert/video"); + const outputBuffer = await convertVideo(job.buf, job.from, job.to, { quality: job.quality }); + self.postMessage({ ok: true, blob: outputBuffer }, [outputBuffer]); + } catch (videoErr: any) { + console.error('Video conversion error:', videoErr); + self.postMessage({ ok: false, error: `Video conversion failed: ${videoErr?.message || videoErr}` }); + } return; } From c9ee49ec6dbb371392a3157d1882da9a5bab8dfa Mon Sep 17 00:00:00 2001 From: devinschumacher Date: Thu, 14 Aug 2025 00:58:41 -0700 Subject: [PATCH 5/6] Add progress bars and optimize video conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add VideoProgress component with real-time progress display - Show FFmpeg loading and conversion progress - Add file size warnings for large video files - Optimize for speed: use remuxing (copy codec) for MKV→MP4/MOV - Use faster encoding presets (ultrafast) when re-encoding needed - Add Alert component for user warnings - Explain remuxing vs converting to users Performance improvements: - MKV→MP4/MOV now uses -c copy (fast remux, no re-encoding) - Reduced resolution to 720p max for faster processing - Changed from VP9 to VP8 for WebM (faster) - Use ultrafast x264 preset instead of medium --- components/HeroConverter.tsx | 80 +++++++++++++++++++- components/VideoProgress.tsx | 142 +++++++++++++++++++++++++++++++++++ components/ui/alert.tsx | 58 ++++++++++++++ components/ui/progress.tsx | 10 ++- lib/convert/video.ts | 43 +++++++++-- workers/convert.worker.ts | 19 ++++- 6 files changed, 338 insertions(+), 14 deletions(-) create mode 100644 components/VideoProgress.tsx create mode 100644 components/ui/alert.tsx diff --git a/components/HeroConverter.tsx b/components/HeroConverter.tsx index 885726621..872f5cd55 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) { @@ -97,6 +141,13 @@ export default function HeroConverter({ const blob = new Blob([ev.data.blob], { type: mimeType }); const name = file.name.replace(/\.[^.]+$/, "") + "." + to; saveBlob(blob, name); + + setCurrentFile({ + name: file.name, + progress: 100, + status: 'completed', + message: 'Conversion complete!' + }); } resolve(); }; @@ -165,9 +216,34 @@ export default function HeroConverter({ : 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/lib/convert/video.ts b/lib/convert/video.ts index 2c47e83f3..0971fdfcc 100644 --- a/lib/convert/video.ts +++ b/lib/convert/video.ts @@ -36,10 +36,18 @@ export async function convertVideo( 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}`; @@ -49,8 +57,20 @@ export async function convertVideo( // 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) - if (['mp3', 'wav', 'ogg'].includes(toFormat)) { + else if (['mp3', 'wav', 'ogg'].includes(toFormat)) { if (toFormat === 'mp3') { args.push('-acodec', 'libmp3lame', '-b:a', '192k'); } else if (toFormat === 'wav') { @@ -60,21 +80,28 @@ export async function convertVideo( } args.push('-vn'); // No video } - // Video conversions + // Video conversions - optimized for speed else if (toFormat === 'mp4') { - args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '23'); + // 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') { - args.push('-c:v', 'libvpx-vp9', '-crf', '30', '-b:v', '0'); - args.push('-c:a', 'libopus', '-b:a', '128k'); + // 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', '5'); - args.push('-c:a', 'libmp3lame', '-b:a', '192k'); + 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', 'medium', '-crf', '23'); + 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'; diff --git a/workers/convert.worker.ts b/workers/convert.worker.ts index d8360a9e4..a5608a710 100644 --- a/workers/convert.worker.ts +++ b/workers/convert.worker.ts @@ -32,7 +32,24 @@ self.onmessage = async (e: MessageEvent) => { if (job.op === "video") { try { const { convertVideo } = await import("../lib/convert/video"); - const outputBuffer = await convertVideo(job.buf, job.from, job.to, { quality: job.quality }); + + // 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 + }); + } + }); + self.postMessage({ ok: true, blob: outputBuffer }, [outputBuffer]); } catch (videoErr: any) { console.error('Video conversion error:', videoErr); From 75774137bee702d3b623546b26bc9c9a906a52af Mon Sep 17 00:00:00 2001 From: devinschumacher Date: Thu, 14 Aug 2025 01:01:58 -0700 Subject: [PATCH 6/6] Fix video file download issues - Remove ArrayBuffer transfer that was causing empty blobs - Add validation for blob data before saving - Add logging to debug file sizes - Ensure proper Uint8Array to ArrayBuffer conversion - Add error handling for empty outputs --- components/HeroConverter.tsx | 13 +++++++++++++ lib/convert/video.ts | 17 ++++++++++++----- workers/convert.worker.ts | 3 ++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/components/HeroConverter.tsx b/components/HeroConverter.tsx index 872f5cd55..52faa328d 100644 --- a/components/HeroConverter.tsx +++ b/components/HeroConverter.tsx @@ -138,8 +138,21 @@ export default function HeroConverter({ 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({ diff --git a/lib/convert/video.ts b/lib/convert/video.ts index 0971fdfcc..b3e9312b1 100644 --- a/lib/convert/video.ts +++ b/lib/convert/video.ts @@ -128,14 +128,21 @@ export async function convertVideo( // Read output file const data = await ff.readFile(outputName); + console.log(`Output file size: ${data.length} bytes`); + // Cleanup - await ff.deleteFile(inputName); - await ff.deleteFile(outputName); - if (toFormat === 'gif') { - await ff.deleteFile('palette.png'); + 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 data.buffer; + // Return the Uint8Array buffer + return data instanceof Uint8Array ? data.buffer : data; } export async function cleanupFFmpeg() { diff --git a/workers/convert.worker.ts b/workers/convert.worker.ts index a5608a710..83edc5b7a 100644 --- a/workers/convert.worker.ts +++ b/workers/convert.worker.ts @@ -50,7 +50,8 @@ self.onmessage = async (e: MessageEvent) => { } }); - self.postMessage({ ok: true, blob: outputBuffer }, [outputBuffer]); + // 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}` });