From fbc8cdeebe345d8406efe9be3a34678de87de1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Wed, 25 Feb 2026 10:06:50 +0100 Subject: [PATCH] feat: add media converter app and fix compilation errors --- app/(app)/media/page.tsx | 17 + app/(app)/pastel/batch/page.tsx | 4 +- app/(app)/pastel/colorblind/page.tsx | 4 +- app/(app)/pastel/contrast/page.tsx | 2 +- app/(app)/pastel/distinct/page.tsx | 4 +- app/(app)/pastel/gradient/page.tsx | 6 +- app/(app)/pastel/harmony/page.tsx | 6 +- app/(app)/pastel/names/page.tsx | 2 +- app/(app)/pastel/page.tsx | 8 +- app/(app)/pastel/textcolor/page.tsx | 4 +- app/(app)/units/page.tsx | 2 +- app/globals.css | 4 + components/ToolsGrid.tsx | 15 +- components/layout/AppSidebar.tsx | 12 + components/media/ConversionHistory.tsx | 173 ++++++ components/media/ConversionOptions.tsx | 456 ++++++++++++++ components/media/ConversionPreview.tsx | 294 +++++++++ components/media/FileConverter.tsx | 587 ++++++++++++++++++ components/media/FileInfo.tsx | 210 +++++++ components/media/FileUpload.tsx | 178 ++++++ components/media/FormatPresets.tsx | 67 ++ components/media/FormatSelector.tsx | 136 ++++ .../pastel/{color => }/ColorDisplay.tsx | 0 components/pastel/{color => }/ColorInfo.tsx | 0 components/pastel/{color => }/ColorPicker.tsx | 0 components/pastel/{color => }/ColorSwatch.tsx | 0 components/pastel/{tools => }/ExportMenu.tsx | 0 .../pastel/{tools => }/ManipulationPanel.tsx | 0 components/pastel/{color => }/PaletteGrid.tsx | 0 components/pastel/layout/Navbar.tsx | 76 --- components/pastel/layout/ThemeToggle.tsx | 28 - components/pastel/providers/Providers.tsx | 29 - components/pastel/providers/ThemeProvider.tsx | 78 --- components/ui/KeyboardShortcutsModal.tsx | 61 ++ components/ui/progress.tsx | 31 + .../units/{converter => }/MainConverter.tsx | 7 +- .../units/{converter => }/SearchUnits.tsx | 0 .../{converter => }/VisualComparison.tsx | 0 components/units/providers/ThemeProvider.tsx | 77 --- components/units/ui/CommandPalette.tsx | 174 ------ lib/media/converters/ffmpegService.ts | 224 +++++++ lib/media/converters/imagemagickService.ts | 248 ++++++++ lib/media/hooks/useKeyboardShortcuts.ts | 65 ++ lib/media/storage/history.ts | 88 +++ lib/media/storage/settings.ts | 73 +++ lib/media/utils/fileUtils.ts | 114 ++++ lib/media/utils/formatMappings.ts | 272 ++++++++ lib/media/utils/formatPresets.ts | 188 ++++++ lib/media/wasm/wasmLoader.ts | 157 +++++ package.json | 4 + pnpm-lock.yaml | 109 ++++ types/{units => }/convert-units.d.ts | 0 types/media.ts | 121 ++++ 53 files changed, 3925 insertions(+), 490 deletions(-) create mode 100644 app/(app)/media/page.tsx create mode 100644 components/media/ConversionHistory.tsx create mode 100644 components/media/ConversionOptions.tsx create mode 100644 components/media/ConversionPreview.tsx create mode 100644 components/media/FileConverter.tsx create mode 100644 components/media/FileInfo.tsx create mode 100644 components/media/FileUpload.tsx create mode 100644 components/media/FormatPresets.tsx create mode 100644 components/media/FormatSelector.tsx rename components/pastel/{color => }/ColorDisplay.tsx (100%) rename components/pastel/{color => }/ColorInfo.tsx (100%) rename components/pastel/{color => }/ColorPicker.tsx (100%) rename components/pastel/{color => }/ColorSwatch.tsx (100%) rename components/pastel/{tools => }/ExportMenu.tsx (100%) rename components/pastel/{tools => }/ManipulationPanel.tsx (100%) rename components/pastel/{color => }/PaletteGrid.tsx (100%) delete mode 100644 components/pastel/layout/Navbar.tsx delete mode 100644 components/pastel/layout/ThemeToggle.tsx delete mode 100644 components/pastel/providers/Providers.tsx delete mode 100644 components/pastel/providers/ThemeProvider.tsx create mode 100644 components/ui/KeyboardShortcutsModal.tsx create mode 100644 components/ui/progress.tsx rename components/units/{converter => }/MainConverter.tsx (98%) rename components/units/{converter => }/SearchUnits.tsx (100%) rename components/units/{converter => }/VisualComparison.tsx (100%) delete mode 100644 components/units/providers/ThemeProvider.tsx delete mode 100644 components/units/ui/CommandPalette.tsx create mode 100644 lib/media/converters/ffmpegService.ts create mode 100644 lib/media/converters/imagemagickService.ts create mode 100644 lib/media/hooks/useKeyboardShortcuts.ts create mode 100644 lib/media/storage/history.ts create mode 100644 lib/media/storage/settings.ts create mode 100644 lib/media/utils/fileUtils.ts create mode 100644 lib/media/utils/formatMappings.ts create mode 100644 lib/media/utils/formatPresets.ts create mode 100644 lib/media/wasm/wasmLoader.ts rename types/{units => }/convert-units.d.ts (100%) create mode 100644 types/media.ts diff --git a/app/(app)/media/page.tsx b/app/(app)/media/page.tsx new file mode 100644 index 0000000..067f529 --- /dev/null +++ b/app/(app)/media/page.tsx @@ -0,0 +1,17 @@ +import { FileConverter } from '@/components/media/FileConverter'; + +export default function MediaPage() { + return ( +
+
+
+

Media Converter

+

+ Professional browser-based media conversion for video, audio, and images +

+
+ +
+
+ ); +} diff --git a/app/(app)/pastel/batch/page.tsx b/app/(app)/pastel/batch/page.tsx index 455f78a..6e55142 100644 --- a/app/(app)/pastel/batch/page.tsx +++ b/app/(app)/pastel/batch/page.tsx @@ -10,8 +10,8 @@ import { SelectValue, } from '@/components/ui/select'; import { Input } from '@/components/ui/input'; -import { PaletteGrid } from '@/components/pastel/color/PaletteGrid'; -import { ExportMenu } from '@/components/pastel/tools/ExportMenu'; +import { PaletteGrid } from '@/components/pastel/PaletteGrid'; +import { ExportMenu } from '@/components/pastel/ExportMenu'; import { useLighten, useDarken, useSaturate, useDesaturate, useRotate } from '@/lib/pastel/api/queries'; import { Loader2, Upload, Download } from 'lucide-react'; import { toast } from 'sonner'; diff --git a/app/(app)/pastel/colorblind/page.tsx b/app/(app)/pastel/colorblind/page.tsx index 7417ba9..926da38 100644 --- a/app/(app)/pastel/colorblind/page.tsx +++ b/app/(app)/pastel/colorblind/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { useState } from 'react'; -import { ColorPicker } from '@/components/pastel/color/ColorPicker'; -import { ColorDisplay } from '@/components/pastel/color/ColorDisplay'; +import { ColorPicker } from '@/components/pastel/ColorPicker'; +import { ColorDisplay } from '@/components/pastel/ColorDisplay'; import { Button } from '@/components/ui/button'; import { Select, diff --git a/app/(app)/pastel/contrast/page.tsx b/app/(app)/pastel/contrast/page.tsx index 166bd5e..40f3e3c 100644 --- a/app/(app)/pastel/contrast/page.tsx +++ b/app/(app)/pastel/contrast/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { ColorPicker } from '@/components/pastel/color/ColorPicker'; +import { ColorPicker } from '@/components/pastel/ColorPicker'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { getContrastRatio, hexToRgb, checkWCAGCompliance } from '@/lib/pastel/utils/color'; diff --git a/app/(app)/pastel/distinct/page.tsx b/app/(app)/pastel/distinct/page.tsx index 5708a9d..ac10c35 100644 --- a/app/(app)/pastel/distinct/page.tsx +++ b/app/(app)/pastel/distinct/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { useState } from 'react'; -import { PaletteGrid } from '@/components/pastel/color/PaletteGrid'; -import { ExportMenu } from '@/components/pastel/tools/ExportMenu'; +import { PaletteGrid } from '@/components/pastel/PaletteGrid'; +import { ExportMenu } from '@/components/pastel/ExportMenu'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { diff --git a/app/(app)/pastel/gradient/page.tsx b/app/(app)/pastel/gradient/page.tsx index 395455f..a23b29a 100644 --- a/app/(app)/pastel/gradient/page.tsx +++ b/app/(app)/pastel/gradient/page.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState } from 'react'; -import { ColorPicker } from '@/components/pastel/color/ColorPicker'; -import { PaletteGrid } from '@/components/pastel/color/PaletteGrid'; -import { ExportMenu } from '@/components/pastel/tools/ExportMenu'; +import { ColorPicker } from '@/components/pastel/ColorPicker'; +import { PaletteGrid } from '@/components/pastel/PaletteGrid'; +import { ExportMenu } from '@/components/pastel/ExportMenu'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useGenerateGradient } from '@/lib/pastel/api/queries'; diff --git a/app/(app)/pastel/harmony/page.tsx b/app/(app)/pastel/harmony/page.tsx index 5c2fcfb..6a3ccb3 100644 --- a/app/(app)/pastel/harmony/page.tsx +++ b/app/(app)/pastel/harmony/page.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState } from 'react'; -import { ColorPicker } from '@/components/pastel/color/ColorPicker'; -import { PaletteGrid } from '@/components/pastel/color/PaletteGrid'; -import { ExportMenu } from '@/components/pastel/tools/ExportMenu'; +import { ColorPicker } from '@/components/pastel/ColorPicker'; +import { PaletteGrid } from '@/components/pastel/PaletteGrid'; +import { ExportMenu } from '@/components/pastel/ExportMenu'; import { Button } from '@/components/ui/button'; import { Select, diff --git a/app/(app)/pastel/names/page.tsx b/app/(app)/pastel/names/page.tsx index 497c4e5..d62064e 100644 --- a/app/(app)/pastel/names/page.tsx +++ b/app/(app)/pastel/names/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useMemo } from 'react'; -import { ColorSwatch } from '@/components/pastel/color/ColorSwatch'; +import { ColorSwatch } from '@/components/pastel/ColorSwatch'; import { Input } from '@/components/ui/input'; import { Select, diff --git a/app/(app)/pastel/page.tsx b/app/(app)/pastel/page.tsx index c976ba1..b4b941f 100644 --- a/app/(app)/pastel/page.tsx +++ b/app/(app)/pastel/page.tsx @@ -2,10 +2,10 @@ import { useState, useEffect, Suspense } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; -import { ColorPicker } from '@/components/pastel/color/ColorPicker'; -import { ColorDisplay } from '@/components/pastel/color/ColorDisplay'; -import { ColorInfo } from '@/components/pastel/color/ColorInfo'; -import { ManipulationPanel } from '@/components/pastel/tools/ManipulationPanel'; +import { ColorPicker } from '@/components/pastel/ColorPicker'; +import { ColorDisplay } from '@/components/pastel/ColorDisplay'; +import { ColorInfo } from '@/components/pastel/ColorInfo'; +import { ManipulationPanel } from '@/components/pastel/ManipulationPanel'; import { useColorInfo } from '@/lib/pastel/api/queries'; import { useColorHistory } from '@/lib/pastel/stores/historyStore'; import { Loader2, Share2, History, X } from 'lucide-react'; diff --git a/app/(app)/pastel/textcolor/page.tsx b/app/(app)/pastel/textcolor/page.tsx index 1a5e202..b4d139e 100644 --- a/app/(app)/pastel/textcolor/page.tsx +++ b/app/(app)/pastel/textcolor/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { useState } from 'react'; -import { ColorPicker } from '@/components/pastel/color/ColorPicker'; -import { ColorDisplay } from '@/components/pastel/color/ColorDisplay'; +import { ColorPicker } from '@/components/pastel/ColorPicker'; +import { ColorDisplay } from '@/components/pastel/ColorDisplay'; import { Button } from '@/components/ui/button'; import { useTextColor } from '@/lib/pastel/api/queries'; import { Loader2, Palette, Plus, X, CheckCircle2, XCircle } from 'lucide-react'; diff --git a/app/(app)/units/page.tsx b/app/(app)/units/page.tsx index 179f5ce..1b83e88 100644 --- a/app/(app)/units/page.tsx +++ b/app/(app)/units/page.tsx @@ -1,4 +1,4 @@ -import MainConverter from '@/components/units/converter/MainConverter'; +import MainConverter from '@/components/units/MainConverter'; export default function UnitsPage() { return ( diff --git a/app/globals.css b/app/globals.css index ba3ca3c..cbddfd8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -219,6 +219,10 @@ html { background: linear-gradient(135deg, #eab308 0%, #f59e0b 100%); } +@utility gradient-green-teal { + background: linear-gradient(135deg, #10b981 0%, #06b6d4 100%); +} + @utility gradient-brand { background: linear-gradient(to right, #a78bfa, #f472b6, #22d3ee); } diff --git a/components/ToolsGrid.tsx b/components/ToolsGrid.tsx index 5afa19a..8c98570 100644 --- a/components/ToolsGrid.tsx +++ b/components/ToolsGrid.tsx @@ -50,6 +50,19 @@ const tools = [ ), }, + { + title: 'Media', + description: 'Modern browser-based file converter powered by WebAssembly. Convert videos, images, and audio locally without server uploads. Privacy-first with no file size limits.', + url: '/media', + gradient: 'gradient-green-teal', + accentColor: '#10b981', + badges: ['Open Source', 'Converter', 'Free'], + icon: ( + + + + ), + }, ]; export default function ToolsGrid() { @@ -73,7 +86,7 @@ export default function ToolsGrid() { {/* Tools grid */} -
+
{tools.map((tool, index) => ( ( ); + +const MediaIcon = (props: any) => ( + + + +); + const navigation: NavGroup[] = [ { label: 'Toolkit', @@ -84,6 +91,11 @@ const navigation: NavGroup[] = [ { title: 'Batch Operations', href: '/pastel/batch' }, ] }, + { + title: 'Media Converter', + href: '/media', + icon: + }, ] } ]; diff --git a/components/media/ConversionHistory.tsx b/components/media/ConversionHistory.tsx new file mode 100644 index 0000000..df63247 --- /dev/null +++ b/components/media/ConversionHistory.tsx @@ -0,0 +1,173 @@ +'use client'; + +import * as React from 'react'; +import { History, Trash2, ArrowRight, Clock } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { formatFileSize } from '@/lib/media/utils/fileUtils'; +import { getHistory, clearHistory, removeHistoryItem } from '@/lib/media/storage/history'; +import type { ConversionHistoryItem } from '@/types/media'; + +export function ConversionHistory() { + const [history, setHistory] = React.useState([]); + + // Load history on mount and listen for updates + React.useEffect(() => { + const loadHistory = () => { + const items = getHistory(); + setHistory(items); + }; + + loadHistory(); + + // Listen for storage changes (e.g., from other tabs) + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'convert-ui-history') { + loadHistory(); + } + }; + + // Listen for custom event (same-page updates) + const handleHistoryUpdate = () => { + loadHistory(); + }; + + window.addEventListener('storage', handleStorageChange); + window.addEventListener('conversionHistoryUpdated', handleHistoryUpdate); + + return () => { + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener('conversionHistoryUpdated', handleHistoryUpdate); + }; + }, []); + + const handleClearHistory = () => { + if (confirm('Are you sure you want to clear all conversion history?')) { + clearHistory(); + setHistory([]); + } + }; + + const handleRemoveItem = (id: string) => { + removeHistoryItem(id); + setHistory((prev) => prev.filter((item) => item.id !== id)); + }; + + const formatTimestamp = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + + return date.toLocaleDateString(); + }; + + if (history.length === 0) { + return ( +
+ + + + + Conversion History + + Your recent conversions will appear here + + +
+ +

No conversion history yet

+

Convert some files to see them here

+
+
+
+
+ ); + } + + return ( +
+ + +
+
+ + + Conversion History + + + Recent conversions ({history.length} item{history.length > 1 ? 's' : ''}) + +
+ +
+
+ +
+ {history.map((item) => ( +
+
+
+ {/* File conversion info */} +
+ + {item.inputFileName} + + + + {item.outputFileName} + +
+ + {/* Format conversion */} +
+ + {item.inputFormat} + + + + {item.outputFormat} + + + {formatFileSize(item.fileSize)} +
+ + {/* Timestamp */} +
+ + {formatTimestamp(item.timestamp)} +
+
+ + {/* Remove button */} + +
+
+ ))} +
+
+
+
+ ); +} diff --git a/components/media/ConversionOptions.tsx b/components/media/ConversionOptions.tsx new file mode 100644 index 0000000..e1725bf --- /dev/null +++ b/components/media/ConversionOptions.tsx @@ -0,0 +1,456 @@ +'use client'; + +import * as React from 'react'; +import { ChevronDown, ChevronUp, Sparkles } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Slider } from '@/components/ui/slider'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { ConversionOptions, ConversionFormat } from '@/types/media'; + +interface ConversionOptionsProps { + inputFormat: ConversionFormat; + outputFormat: ConversionFormat; + options: ConversionOptions; + onOptionsChange: (options: ConversionOptions) => void; + disabled?: boolean; +} + +interface QualityPreset { + id: string; + name: string; + description: string; + icon: string; + options: ConversionOptions; +} + +export function ConversionOptionsPanel({ + inputFormat, + outputFormat, + options, + onOptionsChange, + disabled = false, +}: ConversionOptionsProps) { + const [isExpanded, setIsExpanded] = React.useState(false); + const [selectedPreset, setSelectedPreset] = React.useState(null); + + // Quality presets based on output format category + const getPresets = (): QualityPreset[] => { + const category = outputFormat.category; + + if (category === 'video') { + return [ + { + id: 'high-quality', + name: 'High Quality', + description: 'Best quality, larger file size', + icon: '⭐', + options: { + videoBitrate: '5M', + videoCodec: outputFormat.extension === 'webm' ? 'libvpx' : 'libx264', + audioBitrate: '192k', + audioCodec: outputFormat.extension === 'webm' ? 'libvorbis' : 'aac', + }, + }, + { + id: 'balanced', + name: 'Balanced', + description: 'Good quality, moderate size', + icon: '⚖️', + options: { + videoBitrate: '2M', + videoCodec: outputFormat.extension === 'webm' ? 'libvpx' : 'libx264', + audioBitrate: '128k', + audioCodec: outputFormat.extension === 'webm' ? 'libvorbis' : 'aac', + }, + }, + { + id: 'small-file', + name: 'Small File', + description: 'Smaller size, lower quality', + icon: '📦', + options: { + videoBitrate: '1M', + videoCodec: outputFormat.extension === 'webm' ? 'libvpx' : 'libx264', + audioBitrate: '96k', + audioCodec: outputFormat.extension === 'webm' ? 'libvorbis' : 'aac', + }, + }, + { + id: 'web-optimized', + name: 'Web Optimized', + description: 'Fast loading for web', + icon: '🌐', + options: { + videoBitrate: '1.5M', + videoCodec: 'libvpx', + audioBitrate: '128k', + audioCodec: 'libvorbis', + videoResolution: '720x-1', + }, + }, + ]; + } else if (category === 'audio') { + return [ + { + id: 'high-quality', + name: 'High Quality', + description: 'Best audio quality', + icon: '⭐', + options: { + audioBitrate: '320k', + audioCodec: outputFormat.extension === 'mp3' ? 'libmp3lame' : 'default', + }, + }, + { + id: 'balanced', + name: 'Balanced', + description: 'Good quality, smaller size', + icon: '⚖️', + options: { + audioBitrate: '192k', + audioCodec: outputFormat.extension === 'mp3' ? 'libmp3lame' : 'default', + }, + }, + { + id: 'small-file', + name: 'Small File', + description: 'Minimum file size', + icon: '📦', + options: { + audioBitrate: '128k', + audioCodec: outputFormat.extension === 'mp3' ? 'libmp3lame' : 'default', + }, + }, + ]; + } else if (category === 'image') { + return [ + { + id: 'high-quality', + name: 'High Quality', + description: 'Best image quality', + icon: '⭐', + options: { + imageQuality: 95, + }, + }, + { + id: 'balanced', + name: 'Balanced', + description: 'Good quality', + icon: '⚖️', + options: { + imageQuality: 85, + }, + }, + { + id: 'web-optimized', + name: 'Web Optimized', + description: 'Optimized for web', + icon: '🌐', + options: { + imageQuality: 75, + }, + }, + ]; + } + + return []; + }; + + const presets = getPresets(); + + const handlePresetClick = (preset: QualityPreset) => { + setSelectedPreset(preset.id); + onOptionsChange({ ...options, ...preset.options }); + }; + + const handleOptionChange = (key: string, value: any) => { + setSelectedPreset(null); // Clear preset when manual change + onOptionsChange({ ...options, [key]: value }); + }; + + const renderVideoOptions = () => ( +
+ {/* Video Codec */} +
+ + +
+ + {/* Video Bitrate */} +
+
+ + {options.videoBitrate || '2M'} +
+ handleOptionChange('videoBitrate', `${vals[0]}M`)} + disabled={disabled} + /> +

Higher bitrate = better quality, larger file

+
+ + {/* Resolution */} +
+ + +
+ + {/* FPS */} +
+ + +
+ + {/* Audio Bitrate */} +
+
+ + {options.audioBitrate || '128k'} +
+ handleOptionChange('audioBitrate', `${vals[0]}k`)} + disabled={disabled} + /> +
+
+ ); + + const renderAudioOptions = () => ( +
+ {/* Audio Codec */} +
+ + +
+ + {/* Bitrate */} +
+
+ + {options.audioBitrate || '192k'} +
+ handleOptionChange('audioBitrate', `${vals[0]}k`)} + disabled={disabled} + /> +
+ + {/* Sample Rate */} +
+ + +
+ + {/* Channels */} +
+ + +
+
+ ); + + const renderImageOptions = () => ( +
+ {/* Quality */} +
+
+ + {options.imageQuality || 85}% +
+ handleOptionChange('imageQuality', vals[0])} + disabled={disabled} + /> +
+ + {/* Width */} +
+ + handleOptionChange('imageWidth', e.target.value ? parseInt(e.target.value) : undefined)} + placeholder="Original" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + disabled={disabled} + /> +

Leave empty to keep original

+
+ + {/* Height */} +
+ + handleOptionChange('imageHeight', e.target.value ? parseInt(e.target.value) : undefined)} + placeholder="Original" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + disabled={disabled} + /> +

Leave empty to maintain aspect ratio

+
+
+ ); + + return ( + + {/* Presets Section */} + {presets.length > 0 && ( +
+
+ +

Quality Presets

+
+
+ {presets.map((preset) => ( + + ))} +
+
+ )} + + {/* Advanced Options Toggle */} + + + {/* Advanced Options Panel */} + {isExpanded && ( +
+ {outputFormat.category === 'video' && renderVideoOptions()} + {outputFormat.category === 'audio' && renderAudioOptions()} + {outputFormat.category === 'image' && renderImageOptions()} +
+ )} +
+ ); +} diff --git a/components/media/ConversionPreview.tsx b/components/media/ConversionPreview.tsx new file mode 100644 index 0000000..89588f8 --- /dev/null +++ b/components/media/ConversionPreview.tsx @@ -0,0 +1,294 @@ +'use client'; + +import * as React from 'react'; +import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, FileCheck2, ArrowRight, RefreshCw } from 'lucide-react'; +import { cn } from '@/lib/utils/cn'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/media/utils/fileUtils'; +import type { ConversionJob } from '@/types/media'; + +export interface ConversionPreviewProps { + job: ConversionJob; + onDownload?: () => void; + onRetry?: () => void; +} + +export function ConversionPreview({ job, onDownload, onRetry }: ConversionPreviewProps) { + const [previewUrl, setPreviewUrl] = React.useState(null); + const [elapsedTime, setElapsedTime] = React.useState(0); + const [estimatedTimeRemaining, setEstimatedTimeRemaining] = React.useState(null); + + // Timer for elapsed time and estimation + React.useEffect(() => { + if (job.status === 'processing' || job.status === 'loading') { + const interval = setInterval(() => { + if (job.startTime) { + const elapsed = Date.now() - job.startTime; + setElapsedTime(elapsed); + + // Estimate time remaining based on progress + if (job.progress > 5 && job.progress < 100) { + const progressRate = job.progress / elapsed; + const remainingProgress = 100 - job.progress; + const estimated = remainingProgress / progressRate; + setEstimatedTimeRemaining(estimated); + } + } + }, 100); + + return () => clearInterval(interval); + } else { + setEstimatedTimeRemaining(null); + } + }, [job.status, job.startTime, job.progress]); + + // Create preview URL for result + React.useEffect(() => { + if (job.result && job.status === 'completed') { + console.log('[Preview] Creating object URL for blob'); + const url = URL.createObjectURL(job.result); + setPreviewUrl(url); + console.log('[Preview] Object URL created:', url); + + return () => { + console.log('[Preview] Revoking object URL:', url); + URL.revokeObjectURL(url); + }; + } else { + setPreviewUrl(null); + } + }, [job.result, job.status]); + + const handleDownload = () => { + if (job.result) { + const filename = generateOutputFilename(job.inputFile.name, job.outputFormat.extension); + downloadBlob(job.result, filename); + onDownload?.(); + } + }; + + const renderPreview = () => { + if (!previewUrl || !job.result) return null; + + const category = job.outputFormat.category; + + // Log blob details for debugging + console.log('[Preview] Blob details:', { + size: job.result.size, + type: job.result.type, + previewUrl, + outputFormat: job.outputFormat.extension, + }); + + switch (category) { + case 'image': + return ( +
+ Converted image preview { + console.error('[Preview] Image failed to load:', { + src: previewUrl, + blobSize: job.result?.size, + blobType: job.result?.type, + error: e, + }); + }} + onLoad={() => { + console.log('[Preview] Image loaded successfully'); + }} + /> +
+ ); + + case 'video': + return ( +
+ +
+ ); + + case 'audio': + return ( +
+ +
+ ); + + default: + return null; + } + }; + + const formatTime = (ms: number) => { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + }; + + const renderStatus = () => { + switch (job.status) { + case 'loading': + return ( +
+
+ + Loading WASM converter... +
+
+ + Elapsed: {formatTime(elapsedTime)} +
+
+ ); + + case 'processing': + return ( +
+
+
+ + Converting... +
+ {job.progress}% +
+ +
+
+ + Elapsed: {formatTime(elapsedTime)} +
+ {estimatedTimeRemaining && ( +
+ + ~{formatTime(estimatedTimeRemaining)} remaining +
+ )} +
+
+ ); + + case 'completed': + const inputSize = job.inputFile.size; + const outputSize = job.result?.size || 0; + const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0; + + return ( +
+
+ + Conversion complete! +
+ + {/* File size comparison */} +
+
+
+ + Input: +
+ {formatFileSize(inputSize)} +
+ +
+ +
+ +
+
+ + Output: +
+
+ {formatFileSize(outputSize)} + {Math.abs(sizeReduction) > 1 && ( + 0 + ? "bg-success/10 text-success" + : "bg-info/10 text-info" + )}> + {sizeReduction > 0 ? '-' : '+'}{Math.abs(sizeReduction).toFixed(0)}% + + )} +
+
+
+
+ ); + + case 'error': + return ( +
+ + Conversion failed +
+ ); + + default: + return null; + } + }; + + if (job.status === 'pending') { + return null; + } + + return ( + + + Conversion Status + + +
+ {/* Status */} + {renderStatus()} + + {/* Error message */} + {job.error && ( +
+

{job.error}

+
+ )} + + {/* Retry button */} + {job.status === 'error' && onRetry && ( + + )} + + {/* Preview */} + {job.status === 'completed' && renderPreview()} + + {/* Download button */} + {job.status === 'completed' && job.result && ( + + )} + + {/* Duration */} + {job.status === 'completed' && job.startTime && job.endTime && ( +

+ Completed in {((job.endTime - job.startTime) / 1000).toFixed(2)}s +

+ )} +
+
+
+ ); +} diff --git a/components/media/FileConverter.tsx b/components/media/FileConverter.tsx new file mode 100644 index 0000000..27d5ee8 --- /dev/null +++ b/components/media/FileConverter.tsx @@ -0,0 +1,587 @@ +'use client'; + +import * as React from 'react'; +import { ArrowRight, ArrowDown, Keyboard } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { FileUpload } from './FileUpload'; +import { FormatSelector } from './FormatSelector'; +import { ConversionPreview } from './ConversionPreview'; +import { ConversionOptionsPanel } from './ConversionOptions'; +import { FileInfo } from './FileInfo'; +import { FormatPresets } from './FormatPresets'; +import { toast } from 'sonner'; +import { + SUPPORTED_FORMATS, + getFormatByExtension, + getFormatByMimeType, + getCompatibleFormats, +} from '@/lib/media/utils/formatMappings'; +import { convertWithFFmpeg } from '@/lib/media/converters/ffmpegService'; +import { convertWithImageMagick } from '@/lib/media/converters/imagemagickService'; +import { addToHistory } from '@/lib/media/storage/history'; +import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/media/utils/fileUtils'; +import { getPresetById, type FormatPreset } from '@/lib/media/utils/formatPresets'; +import { useKeyboardShortcuts, type KeyboardShortcut } from '@/lib/media/hooks/useKeyboardShortcuts'; +import { KeyboardShortcutsModal } from '@/components/ui/KeyboardShortcutsModal'; +import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/media'; + +export function FileConverter() { + const [selectedFiles, setSelectedFiles] = React.useState([]); + const [inputFormat, setInputFormat] = React.useState(); + const [outputFormat, setOutputFormat] = React.useState(); + const [compatibleFormats, setCompatibleFormats] = React.useState([]); + const [conversionJobs, setConversionJobs] = React.useState([]); + const [conversionOptions, setConversionOptions] = React.useState({}); + const [showShortcutsModal, setShowShortcutsModal] = React.useState(false); + + const fileInputRef = React.useRef(null); + + // Detect input format when files are selected + React.useEffect(() => { + if (selectedFiles.length === 0) { + setInputFormat(undefined); + setOutputFormat(undefined); + setCompatibleFormats([]); + setConversionJobs([]); + return; + } + + // Use first file to detect format (assume all files same format for batch) + const firstFile = selectedFiles[0]; + + // Try to detect format from extension + const ext = firstFile.name.split('.').pop()?.toLowerCase(); + let format = ext ? getFormatByExtension(ext) : undefined; + + // Fallback to MIME type + if (!format) { + format = getFormatByMimeType(firstFile.type); + } + + if (format) { + setInputFormat(format); + const compatible = getCompatibleFormats(format); + setCompatibleFormats(compatible); + + // Auto-select first compatible format + if (compatible.length > 0 && !outputFormat) { + setOutputFormat(compatible[0]); + } + + toast.success(`Detected format: ${format.name} (${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''})`); + } else { + toast.error('Could not detect file format'); + setInputFormat(undefined); + setCompatibleFormats([]); + } + }, [selectedFiles]); + + const handleConvert = async () => { + if (selectedFiles.length === 0 || !inputFormat || !outputFormat) { + toast.error('Please select files and output format'); + return; + } + + // Create conversion jobs for all files + const jobs: ConversionJob[] = selectedFiles.map((file) => ({ + id: Math.random().toString(36).substring(7), + inputFile: file, + inputFormat, + outputFormat, + options: conversionOptions, + status: 'pending', + progress: 0, + startTime: Date.now(), + })); + + setConversionJobs(jobs); + + // Track success/failure counts + let successCount = 0; + let failureCount = 0; + + // Convert files sequentially + for (let i = 0; i < jobs.length; i++) { + const job = jobs[i]; + + try { + // Update job to loading + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { ...j, status: 'loading' as const } : j) + ); + + // Update job to processing + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { ...j, status: 'processing' as const, progress: 10 } : j) + ); + + // Call appropriate converter + let result; + + if (!outputFormat) throw new Error('Output format not selected'); + + switch (outputFormat.converter) { + case 'ffmpeg': + result = await convertWithFFmpeg(job.inputFile, outputFormat.extension, conversionOptions, (progress) => { + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { ...j, progress } : j) + ); + }); + break; + + case 'imagemagick': + result = await convertWithImageMagick( + job.inputFile, + outputFormat.extension, + conversionOptions, + (progress) => { + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { ...j, progress } : j) + ); + } + ); + break; + + default: + throw new Error(`Unknown converter: ${outputFormat.converter}`); + } + + // Update job with result + if (result.success && result.blob) { + successCount++; + + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { + ...j, + status: 'completed' as const, + progress: 100, + result: result.blob, + endTime: Date.now(), + } : j) + ); + + // Add to history + addToHistory({ + inputFileName: job.inputFile.name, + inputFormat: inputFormat.name, + outputFormat: outputFormat.name, + outputFileName: `output.${outputFormat.extension}`, + fileSize: result.blob.size, + result: result.blob, + }); + } else { + failureCount++; + + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { + ...j, + status: 'error' as const, + error: result.error || 'Unknown error', + endTime: Date.now(), + } : j) + ); + } + } catch (error) { + failureCount++; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { + ...j, + status: 'error' as const, + error: errorMessage, + endTime: Date.now(), + } : j) + ); + } + } + + // Show completion message + if (successCount === jobs.length) { + toast.success(`All ${jobs.length} files converted successfully!`); + } else if (successCount > 0) { + toast.info(`${successCount}/${jobs.length} files converted successfully`); + } else { + toast.error('All conversions failed'); + } + }; + + const handleReset = () => { + setSelectedFiles([]); + setInputFormat(undefined); + setOutputFormat(undefined); + setCompatibleFormats([]); + setConversionJobs([]); + setConversionOptions({}); + }; + + const handleFileSelect = (files: File[]) => { + setSelectedFiles((prev) => [...prev, ...files]); + }; + + const handleFileRemove = (index: number) => { + setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + const handlePresetSelect = (preset: FormatPreset) => { + // Find the output format that matches the preset + const format = compatibleFormats.find(f => f.extension === preset.outputFormat); + + if (format) { + setOutputFormat(format); + setConversionOptions(preset.options); + toast.success(`Applied ${preset.name} preset`); + } + }; + + const handleDownloadAll = async () => { + if (!outputFormat) return; + + const completedJobs = conversionJobs.filter(job => job.status === 'completed' && job.result); + + if (completedJobs.length === 0) { + toast.error('No files to download'); + return; + } + + if (completedJobs.length === 1) { + // Just download the single file + const job = completedJobs[0]; + const filename = generateOutputFilename(job.inputFile.name, outputFormat.extension); + const url = URL.createObjectURL(job.result!); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + return; + } + + // Download multiple files as ZIP + const files = completedJobs.map(job => ({ + blob: job.result!, + filename: generateOutputFilename(job.inputFile.name, outputFormat.extension), + })); + + await downloadBlobsAsZip(files, `converted-files.zip`); + toast.success(`Downloaded ${files.length} files as ZIP`); + }; + + const handleRetry = async (jobId: string) => { + const jobIndex = conversionJobs.findIndex(j => j.id === jobId); + if (jobIndex === -1 || !outputFormat) return; + + const job = conversionJobs[jobIndex]; + + try { + // Reset job to loading + setConversionJobs((prev) => + prev.map((j, idx) => idx === jobIndex ? { + ...j, + status: 'loading' as const, + progress: 0, + error: undefined, + startTime: Date.now(), + } : j) + ); + + // Update to processing + setConversionJobs((prev) => + prev.map((j, idx) => idx === jobIndex ? { ...j, status: 'processing' as const, progress: 10 } : j) + ); + + // Call appropriate converter + let result; + + switch (outputFormat.converter) { + case 'ffmpeg': + result = await convertWithFFmpeg(job.inputFile, outputFormat.extension, conversionOptions, (progress) => { + setConversionJobs((prev) => + prev.map((j, idx) => idx === jobIndex ? { ...j, progress } : j) + ); + }); + break; + + case 'imagemagick': + result = await convertWithImageMagick( + job.inputFile, + outputFormat.extension, + conversionOptions, + (progress) => { + setConversionJobs((prev) => + prev.map((j, idx) => idx === jobIndex ? { ...j, progress } : j) + ); + } + ); + break; + + default: + throw new Error(`Unknown converter: ${outputFormat.converter}`); + } + + // Update job with result + if (result.success && result.blob) { + setConversionJobs((prev) => + prev.map((j, idx) => idx === jobIndex ? { + ...j, + status: 'completed' as const, + progress: 100, + result: result.blob, + endTime: Date.now(), + } : j) + ); + + toast.success('Conversion completed successfully!'); + + // Add to history + addToHistory({ + inputFileName: job.inputFile.name, + inputFormat: job.inputFormat.name, + outputFormat: outputFormat.name, + outputFileName: `output.${outputFormat.extension}`, + fileSize: result.blob.size, + result: result.blob, + }); + } else { + setConversionJobs((prev) => + prev.map((j, idx) => idx === jobIndex ? { + ...j, + status: 'error' as const, + error: result.error || 'Unknown error', + endTime: Date.now(), + } : j) + ); + + toast.error(result.error || 'Retry failed'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + setConversionJobs((prev) => + prev.map((j, idx) => idx === jobIndex ? { + ...j, + status: 'error' as const, + error: errorMessage, + endTime: Date.now(), + } : j) + ); + + toast.error(`Retry failed: ${errorMessage}`); + } + }; + + const isConverting = conversionJobs.some(job => job.status === 'loading' || job.status === 'processing'); + const isConvertDisabled = selectedFiles.length === 0 || !outputFormat || isConverting; + const completedCount = conversionJobs.filter(job => job.status === 'completed').length; + + // Define keyboard shortcuts + const shortcuts: KeyboardShortcut[] = [ + { + key: 'o', + ctrl: true, + description: 'Open file dialog', + action: () => { + if (!isConverting) { + fileInputRef.current?.click(); + } + }, + }, + { + key: 'Enter', + ctrl: true, + description: 'Start conversion', + action: () => { + if (!isConvertDisabled) { + handleConvert(); + } + }, + }, + { + key: 's', + ctrl: true, + description: 'Download results', + action: () => { + if (completedCount > 0) { + handleDownloadAll(); + } + }, + }, + { + key: 'r', + ctrl: true, + description: 'Reset converter', + action: () => { + if (!isConverting) { + handleReset(); + } + }, + }, + { + key: '/', + ctrl: true, + description: 'Show keyboard shortcuts', + action: () => setShowShortcutsModal(true), + }, + { + key: 'Escape', + description: 'Close shortcuts modal', + action: () => setShowShortcutsModal(false), + }, + { + key: '?', + description: 'Show keyboard shortcuts', + action: () => setShowShortcutsModal(true), + }, + ]; + + // Enable keyboard shortcuts + useKeyboardShortcuts(shortcuts, !showShortcutsModal); + + return ( +
+ {/* Header */} + + + File Converter + + Convert videos, audio, and images directly in your browser using WebAssembly + + + + {/* File upload */} + + + {/* File Info - show first file */} + {selectedFiles.length > 0 && inputFormat && ( + + )} + + {/* Format Presets */} + {inputFormat && ( + + )} + + {/* Format selection */} + {inputFormat && compatibleFormats.length > 0 && ( +
+ {/* Input format */} +
+ + +

{inputFormat.name}

+

{inputFormat.description}

+
+
+ + {/* Arrow - horizontal on desktop, vertical on mobile */} +
+ +
+
+ +
+ + {/* Output format */} + +
+ )} + + {/* Conversion Options */} + {inputFormat && outputFormat && ( + + )} + + {/* Convert button */} + {inputFormat && outputFormat && ( +
+ + +
+ )} +
+
+ + {/* Download All Button */} + {completedCount > 0 && ( + + + + + + )} + + {/* Conversion previews */} + {conversionJobs.length > 0 && ( +
+ {conversionJobs.map((job) => ( + handleRetry(job.id)} + /> + ))} +
+ )} + + {/* Keyboard Shortcuts Button */} + + + {/* Keyboard Shortcuts Modal */} + setShowShortcutsModal(false)} + /> +
+ ); +} diff --git a/components/media/FileInfo.tsx b/components/media/FileInfo.tsx new file mode 100644 index 0000000..5c3bf1d --- /dev/null +++ b/components/media/FileInfo.tsx @@ -0,0 +1,210 @@ +'use client'; + +import * as React from 'react'; +import { File, FileVideo, FileAudio, FileImage, Clock, HardDrive, Film, Music } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import type { ConversionFormat } from '@/types/media'; + +interface FileInfoProps { + file: File; + format: ConversionFormat; +} + +interface FileMetadata { + name: string; + size: string; + type: string; + category: string; + duration?: string; + dimensions?: string; +} + +export function FileInfo({ file, format }: FileInfoProps) { + const [metadata, setMetadata] = React.useState(null); + + React.useEffect(() => { + extractMetadata(file, format); + }, [file, format]); + + const extractMetadata = async (file: File, format: ConversionFormat) => { + const sizeInMB = (file.size / (1024 * 1024)).toFixed(2); + const baseMetadata: FileMetadata = { + name: file.name, + size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(2)} KB` : `${sizeInMB} MB`, + type: format.name, + category: format.category, + }; + + // Try to extract media-specific metadata + if (format.category === 'video' && file.type.startsWith('video/')) { + try { + const video = document.createElement('video'); + video.preload = 'metadata'; + + const promise = new Promise((resolve) => { + video.onloadedmetadata = () => { + const duration = video.duration; + const minutes = Math.floor(duration / 60); + const seconds = Math.floor(duration % 60); + const durationStr = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + resolve({ + ...baseMetadata, + duration: durationStr, + dimensions: `${video.videoWidth} × ${video.videoHeight}`, + }); + + URL.revokeObjectURL(video.src); + }; + + video.onerror = () => { + resolve(baseMetadata); + URL.revokeObjectURL(video.src); + }; + }); + + video.src = URL.createObjectURL(file); + const result = await promise; + setMetadata(result); + return; + } catch (error) { + console.error('Failed to extract video metadata:', error); + } + } else if (format.category === 'audio' && file.type.startsWith('audio/')) { + try { + const audio = document.createElement('audio'); + audio.preload = 'metadata'; + + const promise = new Promise((resolve) => { + audio.onloadedmetadata = () => { + const duration = audio.duration; + const minutes = Math.floor(duration / 60); + const seconds = Math.floor(duration % 60); + const durationStr = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + resolve({ + ...baseMetadata, + duration: durationStr, + }); + + URL.revokeObjectURL(audio.src); + }; + + audio.onerror = () => { + resolve(baseMetadata); + URL.revokeObjectURL(audio.src); + }; + }); + + audio.src = URL.createObjectURL(file); + const result = await promise; + setMetadata(result); + return; + } catch (error) { + console.error('Failed to extract audio metadata:', error); + } + } else if (format.category === 'image' && file.type.startsWith('image/')) { + try { + const img = new Image(); + + const promise = new Promise((resolve) => { + img.onload = () => { + resolve({ + ...baseMetadata, + dimensions: `${img.width} × ${img.height}`, + }); + + URL.revokeObjectURL(img.src); + }; + + img.onerror = () => { + resolve(baseMetadata); + URL.revokeObjectURL(img.src); + }; + }); + + img.src = URL.createObjectURL(file); + const result = await promise; + setMetadata(result); + return; + } catch (error) { + console.error('Failed to extract image metadata:', error); + } + } + + setMetadata(baseMetadata); + }; + + const getCategoryIcon = () => { + switch (format.category) { + case 'video': + return ; + case 'audio': + return ; + case 'image': + return ; + default: + return ; + } + }; + + if (!metadata) { + return ( + +
+
+
+
+
+
+
+
+ ); + } + + return ( + +
+
{getCategoryIcon()}
+
+

+ {metadata.name} +

+
+ {/* File Size */} +
+ + {metadata.size} +
+ + {/* Type */} +
+ + {metadata.type} +
+ + {/* Duration (for video/audio) */} + {metadata.duration && ( +
+ + {metadata.duration} +
+ )} + + {/* Dimensions */} + {metadata.dimensions && ( +
+ {format.category === 'video' ? ( + + ) : ( + + )} + {metadata.dimensions} +
+ )} +
+
+
+
+ ); +} diff --git a/components/media/FileUpload.tsx b/components/media/FileUpload.tsx new file mode 100644 index 0000000..fb54d99 --- /dev/null +++ b/components/media/FileUpload.tsx @@ -0,0 +1,178 @@ +'use client'; + +import * as React from 'react'; +import { Upload, X } from 'lucide-react'; +import { cn } from '@/lib/utils/cn'; +import { formatFileSize } from '@/lib/media/utils/fileUtils'; +import { Button } from '@/components/ui/button'; + +export interface FileUploadProps { + onFileSelect: (files: File[]) => void; + onFileRemove: (index: number) => void; + selectedFiles?: File[]; + accept?: string; + maxSizeMB?: number; + disabled?: boolean; + inputRef?: React.RefObject; +} + +export function FileUpload({ + onFileSelect, + onFileRemove, + selectedFiles = [], + accept, + maxSizeMB = 500, + disabled = false, + inputRef, +}: FileUploadProps) { + const [isDragging, setIsDragging] = React.useState(false); + const fileInputRef = inputRef || React.useRef(null); + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragging(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + if (disabled) return; + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + handleFiles(files); + } + }; + + const handleFileInput = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + handleFiles(files); + } + }; + + const handleFiles = (files: File[]) => { + // Check file sizes + const maxBytes = maxSizeMB * 1024 * 1024; + const validFiles = files.filter(file => { + if (file.size > maxBytes) { + alert(`${file.name} exceeds ${maxSizeMB}MB limit and will be skipped.`); + return false; + } + return true; + }); + + if (validFiles.length > 0) { + onFileSelect(validFiles); + } + + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleClick = () => { + if (!disabled) { + fileInputRef.current?.click(); + } + }; + + const handleRemove = (index: number) => (e: React.MouseEvent) => { + e.stopPropagation(); + onFileRemove(index); + }; + + return ( +
+ + + {selectedFiles.length > 0 ? ( +
+ {selectedFiles.map((file, index) => ( +
+
+
+

{file.name}

+

+ {formatFileSize(file.size)} +

+
+ +
+
+ ))} + + {/* Add more files button */} + +
+ ) : ( +
+ +

+ Drop your files here or click to browse +

+

+ Maximum file size: {maxSizeMB}MB per file +

+
+ )} +
+ ); +} diff --git a/components/media/FormatPresets.tsx b/components/media/FormatPresets.tsx new file mode 100644 index 0000000..e89f52a --- /dev/null +++ b/components/media/FormatPresets.tsx @@ -0,0 +1,67 @@ +'use client'; + +import * as React from 'react'; +import { Sparkles } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils/cn'; +import { getPresetsByCategory, type FormatPreset } from '@/lib/media/utils/formatPresets'; +import type { ConversionFormat } from '@/types/media'; + +interface FormatPresetsProps { + inputFormat: ConversionFormat; + onPresetSelect: (preset: FormatPreset) => void; + disabled?: boolean; +} + +export function FormatPresets({ inputFormat, onPresetSelect, disabled = false }: FormatPresetsProps) { + const [selectedPresetId, setSelectedPresetId] = React.useState(null); + + // Get presets for the input format's category + const presets = getPresetsByCategory(inputFormat.category); + + if (presets.length === 0) { + return null; + } + + const handlePresetClick = (preset: FormatPreset) => { + setSelectedPresetId(preset.id); + onPresetSelect(preset); + }; + + return ( + +
+ +

Quick Presets

+
+ +
+ {presets.map((preset) => ( + + ))} +
+ +
+ Select a preset to automatically configure optimal settings for your use case +
+
+ ); +} diff --git a/components/media/FormatSelector.tsx b/components/media/FormatSelector.tsx new file mode 100644 index 0000000..1417d10 --- /dev/null +++ b/components/media/FormatSelector.tsx @@ -0,0 +1,136 @@ +'use client'; + +import * as React from 'react'; +import Fuse from 'fuse.js'; +import { Search } from 'lucide-react'; +import { cn } from '@/lib/utils/cn'; +import { Input } from '@/components/ui/input'; +import { Card } from '@/components/ui/card'; +import type { ConversionFormat } from '@/types/media'; + +export interface FormatSelectorProps { + formats: ConversionFormat[]; + selectedFormat?: ConversionFormat; + onFormatSelect: (format: ConversionFormat) => void; + label?: string; + disabled?: boolean; +} + +export function FormatSelector({ + formats, + selectedFormat, + onFormatSelect, + label = 'Select format', + disabled = false, +}: FormatSelectorProps) { + const [searchQuery, setSearchQuery] = React.useState(''); + const [filteredFormats, setFilteredFormats] = React.useState(formats); + + // Set up Fuse.js for fuzzy search + const fuse = React.useMemo(() => { + return new Fuse(formats, { + keys: ['name', 'extension', 'description'], + threshold: 0.3, + includeScore: true, + }); + }, [formats]); + + // Filter formats based on search query + React.useEffect(() => { + if (!searchQuery.trim()) { + setFilteredFormats(formats); + return; + } + + const results = fuse.search(searchQuery); + setFilteredFormats(results.map((result) => result.item)); + }, [searchQuery, formats, fuse]); + + // Group formats by category + const groupedFormats = React.useMemo(() => { + const groups: Record = {}; + + filteredFormats.forEach((format) => { + if (!groups[format.category]) { + groups[format.category] = []; + } + groups[format.category].push(format); + }); + + return groups; + }, [filteredFormats]); + + return ( +
+ + + {/* Search input */} +
+ + setSearchQuery(e.target.value)} + disabled={disabled} + className="pl-10" + /> +
+ + {/* Format list */} + + {Object.entries(groupedFormats).length === 0 ? ( +
+ No formats found matching "{searchQuery}" +
+ ) : ( +
+ {Object.entries(groupedFormats).map(([category, categoryFormats]) => ( +
+

+ {category} +

+
+ {categoryFormats.map((format) => ( + + ))} +
+
+ ))} +
+ )} +
+ + {/* Selected format display */} + {selectedFormat && ( +
+ Selected: {selectedFormat.name} (. + {selectedFormat.extension}) +
+ )} +
+ ); +} diff --git a/components/pastel/color/ColorDisplay.tsx b/components/pastel/ColorDisplay.tsx similarity index 100% rename from components/pastel/color/ColorDisplay.tsx rename to components/pastel/ColorDisplay.tsx diff --git a/components/pastel/color/ColorInfo.tsx b/components/pastel/ColorInfo.tsx similarity index 100% rename from components/pastel/color/ColorInfo.tsx rename to components/pastel/ColorInfo.tsx diff --git a/components/pastel/color/ColorPicker.tsx b/components/pastel/ColorPicker.tsx similarity index 100% rename from components/pastel/color/ColorPicker.tsx rename to components/pastel/ColorPicker.tsx diff --git a/components/pastel/color/ColorSwatch.tsx b/components/pastel/ColorSwatch.tsx similarity index 100% rename from components/pastel/color/ColorSwatch.tsx rename to components/pastel/ColorSwatch.tsx diff --git a/components/pastel/tools/ExportMenu.tsx b/components/pastel/ExportMenu.tsx similarity index 100% rename from components/pastel/tools/ExportMenu.tsx rename to components/pastel/ExportMenu.tsx diff --git a/components/pastel/tools/ManipulationPanel.tsx b/components/pastel/ManipulationPanel.tsx similarity index 100% rename from components/pastel/tools/ManipulationPanel.tsx rename to components/pastel/ManipulationPanel.tsx diff --git a/components/pastel/color/PaletteGrid.tsx b/components/pastel/PaletteGrid.tsx similarity index 100% rename from components/pastel/color/PaletteGrid.tsx rename to components/pastel/PaletteGrid.tsx diff --git a/components/pastel/layout/Navbar.tsx b/components/pastel/layout/Navbar.tsx deleted file mode 100644 index 7da8632..0000000 --- a/components/pastel/layout/Navbar.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { ThemeToggle } from './ThemeToggle'; -import { cn } from '@/lib/utils/cn'; -import { Palette } from 'lucide-react'; - -const navigation = [ - { name: 'Playground', href: '/pastel' }, - { name: 'Harmony', href: '/pastel/harmony' }, - { name: 'Contrast', href: '/pastel/contrast' }, - { name: 'Names', href: '/pastel/names' }, - { name: 'Batch', href: '/pastel/batch' }, -]; - -export function Navbar() { - const pathname = usePathname(); - - return ( - - ); -} diff --git a/components/pastel/layout/ThemeToggle.tsx b/components/pastel/layout/ThemeToggle.tsx deleted file mode 100644 index df20157..0000000 --- a/components/pastel/layout/ThemeToggle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import { Moon, Sun } from 'lucide-react'; -import { useTheme } from '@/components/providers/ThemeProvider'; -import { Button } from '@/components/ui/button'; - -export function ThemeToggle() { - const { theme, setTheme, resolvedTheme } = useTheme(); - - const toggleTheme = () => { - setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); - }; - - return ( - - ); -} diff --git a/components/pastel/providers/Providers.tsx b/components/pastel/providers/Providers.tsx deleted file mode 100644 index 217d0b9..0000000 --- a/components/pastel/providers/Providers.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Toaster } from 'sonner'; -import { useState } from 'react'; -import { ThemeProvider } from './ThemeProvider'; - -export function Providers({ children }: { children: React.ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 60 * 1000, // 1 minute - refetchOnWindowFocus: false, - }, - }, - }) - ); - - return ( - - - {children} - - - - ); -} diff --git a/components/pastel/providers/ThemeProvider.tsx b/components/pastel/providers/ThemeProvider.tsx deleted file mode 100644 index 490c787..0000000 --- a/components/pastel/providers/ThemeProvider.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import { createContext, useContext, useEffect, useState } from 'react'; - -type Theme = 'light' | 'dark' | 'system'; - -interface ThemeContextType { - theme: Theme; - setTheme: (theme: Theme) => void; - resolvedTheme: 'light' | 'dark'; -} - -const ThemeContext = createContext(undefined); - -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setTheme] = useState('system'); - const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light'); - - useEffect(() => { - // Load theme from localStorage - const stored = localStorage.getItem('theme') as Theme | null; - if (stored) { - setTheme(stored); - } - }, []); - - useEffect(() => { - const root = window.document.documentElement; - - // Remove previous theme classes - root.classList.remove('light', 'dark'); - - if (theme === 'system') { - const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light'; - root.classList.add(systemTheme); - setResolvedTheme(systemTheme); - } else { - root.classList.add(theme); - setResolvedTheme(theme); - } - - // Save to localStorage - localStorage.setItem('theme', theme); - }, [theme]); - - // Listen for system theme changes - useEffect(() => { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - - const handleChange = () => { - if (theme === 'system') { - const systemTheme = mediaQuery.matches ? 'dark' : 'light'; - setResolvedTheme(systemTheme); - window.document.documentElement.classList.remove('light', 'dark'); - window.document.documentElement.classList.add(systemTheme); - } - }; - - mediaQuery.addEventListener('change', handleChange); - return () => mediaQuery.removeEventListener('change', handleChange); - }, [theme]); - - return ( - - {children} - - ); -} - -export function useTheme() { - const context = useContext(ThemeContext); - if (context === undefined) { - throw new Error('useTheme must be used within a ThemeProvider'); - } - return context; -} diff --git a/components/ui/KeyboardShortcutsModal.tsx b/components/ui/KeyboardShortcutsModal.tsx new file mode 100644 index 0000000..e0cac94 --- /dev/null +++ b/components/ui/KeyboardShortcutsModal.tsx @@ -0,0 +1,61 @@ +'use client'; + +import * as React from 'react'; +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { formatShortcut, type KeyboardShortcut } from '@/lib/media/hooks/useKeyboardShortcuts'; +import { cn } from '@/lib/utils'; + +interface KeyboardShortcutsModalProps { + shortcuts: KeyboardShortcut[]; + isOpen: boolean; + onClose: () => void; +} + +export function KeyboardShortcutsModal({ shortcuts, isOpen, onClose }: KeyboardShortcutsModalProps) { + React.useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
+
+ + + Keyboard Shortcuts + + + +
+ {shortcuts.map((shortcut, index) => ( +
+ {shortcut.description} + + {formatShortcut(shortcut)} + +
+ ))} +
+
+ +
+
+
+
+ ); +} diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx new file mode 100644 index 0000000..9995b32 --- /dev/null +++ b/components/ui/progress.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils/index" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/components/units/converter/MainConverter.tsx b/components/units/MainConverter.tsx similarity index 98% rename from components/units/converter/MainConverter.tsx rename to components/units/MainConverter.tsx index 13e3c69..ea715b1 100644 --- a/components/units/converter/MainConverter.tsx +++ b/components/units/MainConverter.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/select'; import SearchUnits from './SearchUnits'; import VisualComparison from './VisualComparison'; -import CommandPalette from '@/components/units/ui/CommandPalette'; + import { getAllMeasures, getUnitsForMeasure, @@ -118,11 +118,6 @@ export default function MainConverter() { return (
- {/* Command Palette */} - {/* Quick Access Row */}
diff --git a/components/units/converter/SearchUnits.tsx b/components/units/SearchUnits.tsx similarity index 100% rename from components/units/converter/SearchUnits.tsx rename to components/units/SearchUnits.tsx diff --git a/components/units/converter/VisualComparison.tsx b/components/units/VisualComparison.tsx similarity index 100% rename from components/units/converter/VisualComparison.tsx rename to components/units/VisualComparison.tsx diff --git a/components/units/providers/ThemeProvider.tsx b/components/units/providers/ThemeProvider.tsx deleted file mode 100644 index 3187c57..0000000 --- a/components/units/providers/ThemeProvider.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'use client'; - -import { createContext, useContext, useEffect, useState } from 'react'; - -type Theme = 'dark' | 'light' | 'system'; - -interface ThemeProviderProps { - children: React.ReactNode; - defaultTheme?: Theme; - storageKey?: string; -} - -interface ThemeProviderState { - theme: Theme; - setTheme: (theme: Theme) => void; -} - -const ThemeProviderContext = createContext( - undefined -); - -export function ThemeProvider({ - children, - defaultTheme = 'system', - storageKey = 'units-ui-theme', -}: ThemeProviderProps) { - const [theme, setTheme] = useState(defaultTheme); - - useEffect(() => { - // Load theme from localStorage - const stored = localStorage.getItem(storageKey) as Theme | null; - if (stored) { - setTheme(stored); - } - }, [storageKey]); - - useEffect(() => { - const root = window.document.documentElement; - - root.classList.remove('light', 'dark'); - - if (theme === 'system') { - const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') - .matches - ? 'dark' - : 'light'; - - root.classList.add(systemTheme); - return; - } - - root.classList.add(theme); - }, [theme]); - - const value = { - theme, - setTheme: (theme: Theme) => { - localStorage.setItem(storageKey, theme); - setTheme(theme); - }, - }; - - return ( - - {children} - - ); -} - -export const useTheme = () => { - const context = useContext(ThemeProviderContext); - - if (context === undefined) - throw new Error('useTheme must be used within a ThemeProvider'); - - return context; -}; diff --git a/components/units/ui/CommandPalette.tsx b/components/units/ui/CommandPalette.tsx deleted file mode 100644 index ded3caa..0000000 --- a/components/units/ui/CommandPalette.tsx +++ /dev/null @@ -1,174 +0,0 @@ -'use client'; - -import { useState, useEffect, useMemo, useRef } from 'react'; -import { Command, Hash, Star, Moon, Sun } from 'lucide-react'; -import { useTheme } from '@/components/providers/ThemeProvider'; -import { - getAllMeasures, - formatMeasureName, - getCategoryColor, - getCategoryColorHex, - type Measure, -} from '@/lib/units/units'; -import { getFavorites } from '@/lib/units/storage'; -import { cn } from '@/lib/utils'; - -interface CommandPaletteProps { - onSelectMeasure: (measure: Measure) => void; - onSelectUnit: (unit: string, measure: Measure) => void; -} - -export default function CommandPalette({ - onSelectMeasure, - onSelectUnit, -}: CommandPaletteProps) { - const [isOpen, setIsOpen] = useState(false); - const [query, setQuery] = useState(''); - const [selectedIndex, setSelectedIndex] = useState(0); - const { theme, setTheme } = useTheme(); - const inputRef = useRef(null); - - // Commands - const commands: Array<{ - id: string; - label: string; - icon: any; - action: () => void; - keywords: string[]; - color?: string; - }> = [ - { - id: 'theme-light', - label: 'Switch to Light Mode', - icon: Sun, - action: () => setTheme('light'), - keywords: ['theme', 'light', 'mode'], - }, - { - id: 'theme-dark', - label: 'Switch to Dark Mode', - icon: Moon, - action: () => setTheme('dark'), - keywords: ['theme', 'dark', 'mode'], - }, - { - id: 'theme-system', - label: 'Use System Theme', - icon: Command, - action: () => setTheme('system'), - keywords: ['theme', 'system', 'auto'], - }, - ]; - - // Add measure commands - const measures = getAllMeasures(); - const measureCommands = measures.map(measure => ({ - id: `measure-${measure}`, - label: `Convert ${formatMeasureName(measure)}`, - icon: Hash, - action: () => onSelectMeasure(measure), - keywords: ['convert', measure, formatMeasureName(measure).toLowerCase()], - color: getCategoryColorHex(measure), - })); - - const allCommands = [...commands, ...measureCommands]; - - // Filter commands - const filteredCommands = query - ? allCommands.filter(cmd => - cmd.keywords.some(kw => kw.toLowerCase().includes(query.toLowerCase())) || - cmd.label.toLowerCase().includes(query.toLowerCase()) - ) - : allCommands; - - // Focus input when opened - useEffect(() => { - if (isOpen) { - inputRef.current?.focus(); - setQuery(''); - setSelectedIndex(0); - } - }, [isOpen]); - - // Reset selected index when query changes - useEffect(() => { - setSelectedIndex(0); - }, [query]); - - if (!isOpen) return null; - - return ( - <> - {/* Backdrop */} -
setIsOpen(false)} - /> - - {/* Command Palette */} -
-
- {/* Search Input */} -
- - setQuery(e.target.value)} - className="flex-1 bg-transparent py-4 px-4 outline-none placeholder:text-muted-foreground" - /> -
- - {/* Commands List */} -
- {filteredCommands.length === 0 ? ( -
- No commands found -
- ) : ( - filteredCommands.map((command, index) => { - const Icon = command.icon; - return ( - - ); - }) - )} -
- - {/* Footer */} -
- Navigate with arrows - Select with Enter - Close with click outside -
-
-
- - ); -} diff --git a/lib/media/converters/ffmpegService.ts b/lib/media/converters/ffmpegService.ts new file mode 100644 index 0000000..fbe9039 --- /dev/null +++ b/lib/media/converters/ffmpegService.ts @@ -0,0 +1,224 @@ +import type { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; +import { loadFFmpeg } from '@/lib/media/wasm/wasmLoader'; +import type { ConversionOptions, ProgressCallback, ConversionResult } from '@/types/media'; + +/** + * Convert video/audio using FFmpeg + */ +export async function convertWithFFmpeg( + file: File, + outputFormat: string, + options: ConversionOptions = {}, + onProgress?: ProgressCallback +): Promise { + const startTime = Date.now(); + + try { + // Load FFmpeg instance + const ffmpeg: FFmpeg = await loadFFmpeg(); + + // Set up progress tracking + if (onProgress) { + ffmpeg.on('progress', ({ progress }) => { + onProgress(Math.round(progress * 100)); + }); + } + + // Input filename + const inputName = file.name; + const outputName = `output.${outputFormat}`; + + // Write input file to FFmpeg virtual file system + await ffmpeg.writeFile(inputName, await fetchFile(file)); + + // Build FFmpeg command based on format and options + const args = buildFFmpegArgs(inputName, outputName, outputFormat, options); + + console.log('[FFmpeg] Running command:', args.join(' ')); + + // Execute FFmpeg command + await ffmpeg.exec(args); + + // Read output file + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data as BlobPart], { type: getMimeType(outputFormat) }); + + // Clean up virtual file system + await ffmpeg.deleteFile(inputName); + await ffmpeg.deleteFile(outputName); + + const duration = Date.now() - startTime; + + return { + success: true, + blob, + duration, + }; + } catch (error) { + console.error('[FFmpeg] Conversion error:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown conversion error', + duration: Date.now() - startTime, + }; + } +} + +/** + * Build FFmpeg command arguments + */ +function buildFFmpegArgs( + inputName: string, + outputName: string, + outputFormat: string, + options: ConversionOptions +): string[] { + const args = ['-i', inputName]; + + // Video codec + if (options.videoCodec) { + args.push('-c:v', options.videoCodec); + } + + // Video bitrate + if (options.videoBitrate) { + args.push('-b:v', options.videoBitrate); + } + + // Video resolution + if (options.videoResolution) { + args.push('-s', options.videoResolution); + } + + // Video FPS + if (options.videoFps) { + args.push('-r', options.videoFps.toString()); + } + + // Audio codec + if (options.audioCodec) { + args.push('-c:a', options.audioCodec); + } + + // Audio bitrate + if (options.audioBitrate) { + args.push('-b:a', options.audioBitrate); + } + + // Audio sample rate + if (options.audioSampleRate) { + args.push('-ar', options.audioSampleRate.toString()); + } + + // Audio channels + if (options.audioChannels) { + args.push('-ac', options.audioChannels.toString()); + } + + // Format-specific settings + switch (outputFormat) { + case 'webm': + // Use VP8 by default (less memory-intensive than VP9) + if (!options.videoCodec) args.push('-c:v', 'libvpx'); + if (!options.audioCodec) args.push('-c:a', 'libvorbis'); + // Optimize for faster encoding and lower memory usage + args.push('-deadline', 'realtime'); + args.push('-cpu-used', '8'); + // Set quality/bitrate if not specified + if (!options.videoBitrate) args.push('-b:v', '1M'); + if (!options.audioBitrate) args.push('-b:a', '128k'); + break; + case 'mp4': + if (!options.videoCodec) args.push('-c:v', 'libx264'); + if (!options.audioCodec) args.push('-c:a', 'aac'); + // Use faster preset for browser encoding + args.push('-preset', 'ultrafast'); + args.push('-tune', 'zerolatency'); + if (!options.videoBitrate) args.push('-b:v', '1M'); + if (!options.audioBitrate) args.push('-b:a', '128k'); + break; + case 'mp3': + if (!options.audioCodec) args.push('-c:a', 'libmp3lame'); + args.push('-vn'); // No video + break; + case 'wav': + if (!options.audioCodec) args.push('-c:a', 'pcm_s16le'); + args.push('-vn'); // No video + break; + case 'ogg': + if (!options.audioCodec) args.push('-c:a', 'libvorbis'); + args.push('-vn'); // No video + break; + case 'gif': + // For GIF, use filter to optimize + args.push('-vf', 'fps=15,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse'); + break; + } + + // Output file + args.push(outputName); + + return args; +} + +/** + * Get MIME type for output format + */ +function getMimeType(format: string): string { + const mimeTypes: Record = { + mp4: 'video/mp4', + webm: 'video/webm', + avi: 'video/x-msvideo', + mov: 'video/quicktime', + mkv: 'video/x-matroska', + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + aac: 'audio/aac', + flac: 'audio/flac', + gif: 'image/gif', + }; + + return mimeTypes[format] || 'application/octet-stream'; +} + +/** + * Extract audio from video + */ +export async function extractAudio( + file: File, + outputFormat: string = 'mp3', + onProgress?: ProgressCallback +): Promise { + return convertWithFFmpeg( + file, + outputFormat, + { + audioCodec: outputFormat === 'mp3' ? 'libmp3lame' : undefined, + audioBitrate: '192k', + }, + onProgress + ); +} + +/** + * Convert video to GIF + */ +export async function videoToGif( + file: File, + fps: number = 15, + width: number = 480, + onProgress?: ProgressCallback +): Promise { + return convertWithFFmpeg( + file, + 'gif', + { + videoFps: fps, + videoResolution: `${width}:-1`, + }, + onProgress + ); +} diff --git a/lib/media/converters/imagemagickService.ts b/lib/media/converters/imagemagickService.ts new file mode 100644 index 0000000..7b1e56b --- /dev/null +++ b/lib/media/converters/imagemagickService.ts @@ -0,0 +1,248 @@ +import { loadImageMagick } from '@/lib/media/wasm/wasmLoader'; +import type { ConversionOptions, ProgressCallback, ConversionResult } from '@/types/media'; + +/** + * Convert image using ImageMagick + */ +export async function convertWithImageMagick( + file: File, + outputFormat: string, + options: ConversionOptions = {}, + onProgress?: ProgressCallback +): Promise { + const startTime = Date.now(); + + try { + // Load ImageMagick instance + await loadImageMagick(); + + // Report initial progress + if (onProgress) onProgress(10); + + // Read input file as ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + const inputData = new Uint8Array(arrayBuffer); + + if (onProgress) onProgress(30); + + // Import ImageMagick functions (already initialized by loadImageMagick) + const { ImageMagick } = await import('@imagemagick/magick-wasm'); + + if (onProgress) onProgress(40); + + // Get output format enum + const outputFormatEnum = await getMagickFormatEnum(outputFormat); + + if (onProgress) onProgress(50); + + // Convert image using ImageMagick + let result: Uint8Array | undefined; + + await ImageMagick.read(inputData, (image) => { + // Apply quality setting if specified + if (options.imageQuality !== undefined) { + image.quality = options.imageQuality; + } + + // Apply resize if specified + if (options.imageWidth || options.imageHeight) { + const width = options.imageWidth || 0; + const height = options.imageHeight || 0; + + if (width > 0 && height > 0) { + // Both dimensions specified + image.resize(width, height); + } else if (width > 0) { + // Only width specified, maintain aspect ratio + const aspectRatio = image.height / image.width; + image.resize(width, Math.round(width * aspectRatio)); + } else if (height > 0) { + // Only height specified, maintain aspect ratio + const aspectRatio = image.width / image.height; + image.resize(Math.round(height * aspectRatio), height); + } + } + + if (onProgress) onProgress(70); + + // Write the image data with format + image.write(outputFormatEnum, (data) => { + result = data; + }); + + if (onProgress) onProgress(90); + }); + + // Verify we have a result + if (!result || result.length === 0) { + throw new Error('ImageMagick conversion produced empty result'); + } + + console.log('[ImageMagick] Conversion complete:', { + inputSize: inputData.length, + outputSize: result.length, + format: outputFormat, + quality: options.imageQuality, + }); + + // Verify the data looks like valid image data by checking magic bytes + const first4Bytes = Array.from(result.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(' '); + console.log('[ImageMagick] First 4 bytes:', first4Bytes); + + // Create blob from result + const mimeType = getMimeType(outputFormat); + const blob = new Blob([result as BlobPart], { type: mimeType }); + + console.log('[ImageMagick] Created blob:', { + size: blob.size, + type: blob.type, + }); + + // Verify blob can be read + try { + const testReader = new FileReader(); + const testPromise = new Promise((resolve) => { + testReader.onloadend = () => { + if (testReader.result instanceof ArrayBuffer) { + const testArr = new Uint8Array(testReader.result); + console.log('[ImageMagick] Blob verification - first 4 bytes:', + Array.from(testArr.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(' ')); + } + resolve(true); + }; + }); + testReader.readAsArrayBuffer(blob.slice(0, 4)); + await testPromise; + } catch (err) { + console.error('[ImageMagick] Blob verification failed:', err); + } + + if (onProgress) onProgress(100); + + const duration = Date.now() - startTime; + + return { + success: true, + blob, + duration, + }; + } catch (error) { + console.error('[ImageMagick] Conversion error:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown conversion error', + duration: Date.now() - startTime, + }; + } +} + +/** + * Get ImageMagick format enum + */ +async function getMagickFormatEnum(format: string): Promise { + const { MagickFormat } = await import('@imagemagick/magick-wasm'); + + const formatMap: Record = { + png: MagickFormat.Png, + jpg: MagickFormat.Jpg, + jpeg: MagickFormat.Jpg, + webp: MagickFormat.WebP, + gif: MagickFormat.Gif, + bmp: MagickFormat.Bmp, + tiff: MagickFormat.Tiff, + svg: MagickFormat.Svg, + }; + + return formatMap[format.toLowerCase()] || MagickFormat.Png; +} + +/** + * Get MIME type for output format + */ +function getMimeType(format: string): string { + const mimeTypes: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + gif: 'image/gif', + bmp: 'image/bmp', + tiff: 'image/tiff', + svg: 'image/svg+xml', + }; + + return mimeTypes[format.toLowerCase()] || 'application/octet-stream'; +} + +/** + * Resize image + */ +export async function resizeImage( + file: File, + width: number, + height: number, + outputFormat?: string, + onProgress?: ProgressCallback +): Promise { + const format = outputFormat || file.name.split('.').pop() || 'png'; + + return convertWithImageMagick( + file, + format, + { + imageWidth: width, + imageHeight: height, + }, + onProgress + ); +} + +/** + * Convert image to WebP + */ +export async function convertToWebP( + file: File, + quality: number = 85, + onProgress?: ProgressCallback +): Promise { + return convertWithImageMagick( + file, + 'webp', + { + imageQuality: quality, + }, + onProgress + ); +} + +/** + * Batch convert images + */ +export async function batchConvertImages( + files: File[], + outputFormat: string, + options: ConversionOptions = {}, + onProgress?: (fileIndex: number, progress: number) => void +): Promise { + const results: ConversionResult[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + const result = await convertWithImageMagick( + file, + outputFormat, + options, + (progress) => { + if (onProgress) { + onProgress(i, progress); + } + } + ); + + results.push(result); + } + + return results; +} diff --git a/lib/media/hooks/useKeyboardShortcuts.ts b/lib/media/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..611dc94 --- /dev/null +++ b/lib/media/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,65 @@ +import { useEffect } from 'react'; + +export interface KeyboardShortcut { + key: string; + ctrl?: boolean; + alt?: boolean; + shift?: boolean; + description: string; + action: () => void; +} + +/** + * Hook for managing keyboard shortcuts + */ +export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[], enabled: boolean = true) { + useEffect(() => { + if (!enabled) return; + + const handleKeyDown = (event: KeyboardEvent) => { + // Find matching shortcut + const shortcut = shortcuts.find((s) => { + const keyMatch = s.key.toLowerCase() === event.key.toLowerCase(); + const ctrlMatch = s.ctrl ? (event.ctrlKey || event.metaKey) : !event.ctrlKey && !event.metaKey; + const altMatch = s.alt ? event.altKey : !event.altKey; + const shiftMatch = s.shift ? event.shiftKey : !event.shiftKey; + + return keyMatch && ctrlMatch && altMatch && shiftMatch; + }); + + if (shortcut) { + event.preventDefault(); + shortcut.action(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [shortcuts, enabled]); +} + +/** + * Format shortcut key combination for display + */ +export function formatShortcut(shortcut: KeyboardShortcut): string { + const parts: string[] = []; + + // Use Cmd on Mac, Ctrl on others + const isMac = typeof window !== 'undefined' && /Mac|iPhone|iPod|iPad/.test(navigator.platform); + + if (shortcut.ctrl) { + parts.push(isMac ? '⌘' : 'Ctrl'); + } + + if (shortcut.alt) { + parts.push(isMac ? '⌥' : 'Alt'); + } + + if (shortcut.shift) { + parts.push(isMac ? '⇧' : 'Shift'); + } + + parts.push(shortcut.key.toUpperCase()); + + return parts.join(' + '); +} diff --git a/lib/media/storage/history.ts b/lib/media/storage/history.ts new file mode 100644 index 0000000..54509e4 --- /dev/null +++ b/lib/media/storage/history.ts @@ -0,0 +1,88 @@ +import type { ConversionHistoryItem } from '@/types/media'; + +const HISTORY_KEY = 'convert-ui-history'; +const MAX_HISTORY_ITEMS = 10; + +/** + * Get conversion history from localStorage + */ +export function getHistory(): ConversionHistoryItem[] { + if (typeof window === 'undefined') return []; + + try { + const stored = localStorage.getItem(HISTORY_KEY); + if (!stored) return []; + + const history = JSON.parse(stored); + return Array.isArray(history) ? history : []; + } catch (error) { + console.error('Failed to load history:', error); + return []; + } +} + +/** + * Add item to conversion history + */ +export function addToHistory(item: Omit): void { + if (typeof window === 'undefined') return; + + try { + const history = getHistory(); + + const newItem: ConversionHistoryItem = { + ...item, + id: Math.random().toString(36).substring(7), + timestamp: Date.now(), + }; + + // Add to beginning of array + history.unshift(newItem); + + // Keep only the latest MAX_HISTORY_ITEMS + const trimmed = history.slice(0, MAX_HISTORY_ITEMS); + + localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed)); + + // Dispatch custom event for same-page updates + window.dispatchEvent(new CustomEvent('conversionHistoryUpdated')); + } catch (error) { + console.error('Failed to save history:', error); + } +} + +/** + * Clear all conversion history + */ +export function clearHistory(): void { + if (typeof window === 'undefined') return; + + try { + localStorage.removeItem(HISTORY_KEY); + } catch (error) { + console.error('Failed to clear history:', error); + } +} + +/** + * Remove single item from history + */ +export function removeHistoryItem(id: string): void { + if (typeof window === 'undefined') return; + + try { + const history = getHistory(); + const filtered = history.filter((item) => item.id !== id); + localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered)); + } catch (error) { + console.error('Failed to remove history item:', error); + } +} + +/** + * Get history item by ID + */ +export function getHistoryItem(id: string): ConversionHistoryItem | undefined { + const history = getHistory(); + return history.find((item) => item.id === id); +} diff --git a/lib/media/storage/settings.ts b/lib/media/storage/settings.ts new file mode 100644 index 0000000..6d84ca6 --- /dev/null +++ b/lib/media/storage/settings.ts @@ -0,0 +1,73 @@ +export interface UserSettings { + // Quality preferences + defaultQualityPreset: 'high-quality' | 'balanced' | 'small-file' | 'web-optimized'; + + // Behavior preferences + autoStartConversion: boolean; + showConversionHistory: boolean; + clearHistoryOnReset: boolean; + + // Default formats (optional) + defaultVideoFormat?: string; + defaultAudioFormat?: string; + defaultImageFormat?: string; +} + +const SETTINGS_KEY = 'convert-ui-settings'; + +const DEFAULT_SETTINGS: UserSettings = { + defaultQualityPreset: 'balanced', + autoStartConversion: false, + showConversionHistory: true, + clearHistoryOnReset: false, +}; + +/** + * Get user settings from localStorage + */ +export function getSettings(): UserSettings { + if (typeof window === 'undefined') return DEFAULT_SETTINGS; + + try { + const stored = localStorage.getItem(SETTINGS_KEY); + if (!stored) return DEFAULT_SETTINGS; + + const settings = JSON.parse(stored); + return { ...DEFAULT_SETTINGS, ...settings }; + } catch (error) { + console.error('Failed to load settings:', error); + return DEFAULT_SETTINGS; + } +} + +/** + * Save user settings to localStorage + */ +export function saveSettings(settings: Partial): void { + if (typeof window === 'undefined') return; + + try { + const current = getSettings(); + const updated = { ...current, ...settings }; + localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated)); + + // Dispatch custom event for settings updates + window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: updated })); + } catch (error) { + console.error('Failed to save settings:', error); + } +} + +/** + * Reset settings to defaults + */ +export function resetSettings(): void { + if (typeof window === 'undefined') return; + + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(DEFAULT_SETTINGS)); + window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: DEFAULT_SETTINGS })); + } catch (error) { + console.error('Failed to reset settings:', error); + } +} diff --git a/lib/media/utils/fileUtils.ts b/lib/media/utils/fileUtils.ts new file mode 100644 index 0000000..75826aa --- /dev/null +++ b/lib/media/utils/fileUtils.ts @@ -0,0 +1,114 @@ +/** + * Format file size in human-readable format + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${Math.round(bytes / Math.pow(k, i) * 100) / 100} ${sizes[i]}`; +} + +/** + * Validate file size (max 500MB for browser processing) + */ +export function validateFileSize(file: File, maxSizeMB: number = 500): boolean { + const maxBytes = maxSizeMB * 1024 * 1024; + return file.size <= maxBytes; +} + +/** + * Get file extension from filename + */ +export function getFileExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + return lastDot === -1 ? '' : filename.substring(lastDot + 1).toLowerCase(); +} + +/** + * Get filename without extension + */ +export function getFilenameWithoutExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + return lastDot === -1 ? filename : filename.substring(0, lastDot); +} + +/** + * Generate output filename + */ +export function generateOutputFilename(inputFilename: string, outputExtension: string): string { + const basename = getFilenameWithoutExtension(inputFilename); + return `${basename}.${outputExtension}`; +} + +/** + * Download blob as file + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Read file as ArrayBuffer + */ +export async function readFileAsArrayBuffer(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(file); + }); +} + +/** + * Read file as Data URL + */ +export async function readFileAsDataURL(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +/** + * Validate file type against allowed MIME types + */ +export function validateFileType(file: File, allowedTypes: string[]): boolean { + return allowedTypes.some((type) => { + if (type.endsWith('/*')) { + const category = type.split('/')[0]; + return file.type.startsWith(`${category}/`); + } + return file.type === type; + }); +} + +/** + * Download multiple blobs as a ZIP file + */ +export async function downloadBlobsAsZip(files: Array<{ blob: Blob; filename: string }>, zipFilename: string): Promise { + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + // Add all files to ZIP + files.forEach(({ blob, filename }) => { + zip.file(filename, blob); + }); + + // Generate ZIP blob + const zipBlob = await zip.generateAsync({ type: 'blob' }); + + // Download ZIP + downloadBlob(zipBlob, zipFilename); +} diff --git a/lib/media/utils/formatMappings.ts b/lib/media/utils/formatMappings.ts new file mode 100644 index 0000000..de045b9 --- /dev/null +++ b/lib/media/utils/formatMappings.ts @@ -0,0 +1,272 @@ +import type { ConversionFormat, FormatPreset } from '@/types/media'; + +/** + * All supported conversion formats + */ +export const SUPPORTED_FORMATS: ConversionFormat[] = [ + // Video formats (FFmpeg) + { + id: 'mp4', + name: 'MP4', + extension: 'mp4', + mimeType: 'video/mp4', + category: 'video', + converter: 'ffmpeg', + description: 'MPEG-4 video format', + }, + { + id: 'webm', + name: 'WebM', + extension: 'webm', + mimeType: 'video/webm', + category: 'video', + converter: 'ffmpeg', + description: 'WebM video format', + }, + { + id: 'avi', + name: 'AVI', + extension: 'avi', + mimeType: 'video/x-msvideo', + category: 'video', + converter: 'ffmpeg', + description: 'Audio Video Interleave', + }, + { + id: 'mov', + name: 'MOV', + extension: 'mov', + mimeType: 'video/quicktime', + category: 'video', + converter: 'ffmpeg', + description: 'QuickTime movie', + }, + { + id: 'mkv', + name: 'MKV', + extension: 'mkv', + mimeType: 'video/x-matroska', + category: 'video', + converter: 'ffmpeg', + description: 'Matroska video', + }, + + // Audio formats (FFmpeg) + { + id: 'mp3', + name: 'MP3', + extension: 'mp3', + mimeType: 'audio/mpeg', + category: 'audio', + converter: 'ffmpeg', + description: 'MPEG audio layer 3', + }, + { + id: 'wav', + name: 'WAV', + extension: 'wav', + mimeType: 'audio/wav', + category: 'audio', + converter: 'ffmpeg', + description: 'Waveform audio', + }, + { + id: 'ogg', + name: 'OGG', + extension: 'ogg', + mimeType: 'audio/ogg', + category: 'audio', + converter: 'ffmpeg', + description: 'Ogg Vorbis audio', + }, + { + id: 'aac', + name: 'AAC', + extension: 'aac', + mimeType: 'audio/aac', + category: 'audio', + converter: 'ffmpeg', + description: 'Advanced Audio Coding', + }, + { + id: 'flac', + name: 'FLAC', + extension: 'flac', + mimeType: 'audio/flac', + category: 'audio', + converter: 'ffmpeg', + description: 'Free Lossless Audio Codec', + }, + + // Image formats (ImageMagick) + { + id: 'png', + name: 'PNG', + extension: 'png', + mimeType: 'image/png', + category: 'image', + converter: 'imagemagick', + description: 'Portable Network Graphics', + }, + { + id: 'jpg', + name: 'JPG', + extension: 'jpg', + mimeType: 'image/jpeg', + category: 'image', + converter: 'imagemagick', + description: 'JPEG image', + }, + { + id: 'webp', + name: 'WebP', + extension: 'webp', + mimeType: 'image/webp', + category: 'image', + converter: 'imagemagick', + description: 'WebP image format', + }, + { + id: 'gif', + name: 'GIF', + extension: 'gif', + mimeType: 'image/gif', + category: 'image', + converter: 'imagemagick', + description: 'Graphics Interchange Format', + }, + { + id: 'bmp', + name: 'BMP', + extension: 'bmp', + mimeType: 'image/bmp', + category: 'image', + converter: 'imagemagick', + description: 'Bitmap image', + }, + { + id: 'tiff', + name: 'TIFF', + extension: 'tiff', + mimeType: 'image/tiff', + category: 'image', + converter: 'imagemagick', + description: 'Tagged Image File Format', + }, + { + id: 'svg', + name: 'SVG', + extension: 'svg', + mimeType: 'image/svg+xml', + category: 'image', + converter: 'imagemagick', + description: 'Scalable Vector Graphics', + }, +]; + +/** + * Format presets for common conversions + */ +export const FORMAT_PRESETS: FormatPreset[] = [ + { + id: 'web-video', + name: 'Web Video', + description: 'Optimize video for web playback (VP8 for better compatibility)', + category: 'video', + sourceFormats: ['mp4', 'avi', 'mov', 'mkv'], + targetFormat: 'webm', + options: { + videoCodec: 'libvpx', + videoBitrate: '1M', + audioCodec: 'libvorbis', + audioBitrate: '128k', + }, + }, + { + id: 'web-image', + name: 'Web Image', + description: 'Optimize image for web', + category: 'image', + sourceFormats: ['png', 'jpg', 'bmp', 'tiff'], + targetFormat: 'webp', + options: { + imageQuality: 85, + }, + }, + { + id: 'audio-compress', + name: 'Compress Audio', + description: 'Reduce audio file size', + category: 'audio', + sourceFormats: ['wav', 'flac'], + targetFormat: 'mp3', + options: { + audioBitrate: '192k', + audioCodec: 'libmp3lame', + }, + }, + { + id: 'video-gif', + name: 'Video to GIF', + description: 'Convert video to animated GIF', + category: 'video', + sourceFormats: ['mp4', 'webm', 'avi', 'mov'], + targetFormat: 'gif', + options: { + videoFps: 15, + videoResolution: '480x-1', + }, + }, +]; + +/** + * Get format by ID + */ +export function getFormatById(id: string): ConversionFormat | undefined { + return SUPPORTED_FORMATS.find((f) => f.id === id); +} + +/** + * Get format by extension + */ +export function getFormatByExtension(extension: string): ConversionFormat | undefined { + return SUPPORTED_FORMATS.find((f) => f.extension === extension.toLowerCase()); +} + +/** + * Get format by MIME type + */ +export function getFormatByMimeType(mimeType: string): ConversionFormat | undefined { + return SUPPORTED_FORMATS.find((f) => f.mimeType === mimeType); +} + +/** + * Get all formats by category + */ +export function getFormatsByCategory(category: string): ConversionFormat[] { + return SUPPORTED_FORMATS.filter((f) => f.category === category); +} + +/** + * Get compatible output formats for input format + */ +export function getCompatibleFormats(inputFormat: ConversionFormat): ConversionFormat[] { + // Same category and same converter + return SUPPORTED_FORMATS.filter( + (f) => f.category === inputFormat.category && f.converter === inputFormat.converter && f.id !== inputFormat.id + ); +} + +/** + * Check if conversion is supported + */ +export function isConversionSupported( + inputFormat: ConversionFormat, + outputFormat: ConversionFormat +): boolean { + return ( + inputFormat.category === outputFormat.category && + inputFormat.converter === outputFormat.converter && + inputFormat.id !== outputFormat.id + ); +} diff --git a/lib/media/utils/formatPresets.ts b/lib/media/utils/formatPresets.ts new file mode 100644 index 0000000..9afd517 --- /dev/null +++ b/lib/media/utils/formatPresets.ts @@ -0,0 +1,188 @@ +import type { ConversionOptions } from '@/types/media'; + +export interface FormatPreset { + id: string; + name: string; + description: string; + icon: string; + category: 'video' | 'audio' | 'image'; + outputFormat: string; + options: ConversionOptions; +} + +/** + * Predefined format presets for common use cases + */ +export const FORMAT_PRESETS: FormatPreset[] = [ + // Video Presets + { + id: 'youtube-video', + name: 'YouTube Video', + description: '1080p MP4, optimized for YouTube', + icon: '🎬', + category: 'video', + outputFormat: 'mp4', + options: { + videoCodec: 'libx264', + videoBitrate: '5M', + videoResolution: '1920x-1', + videoFps: 30, + audioCodec: 'aac', + audioBitrate: '192k', + }, + }, + { + id: 'instagram-video', + name: 'Instagram Video', + description: 'Square 1:1 format for Instagram', + icon: '📸', + category: 'video', + outputFormat: 'mp4', + options: { + videoCodec: 'libx264', + videoBitrate: '3M', + videoResolution: '1080x-1', // Will be cropped to square + videoFps: 30, + audioCodec: 'aac', + audioBitrate: '128k', + }, + }, + { + id: 'twitter-video', + name: 'Twitter Video', + description: '720p, optimized for Twitter', + icon: '🐦', + category: 'video', + outputFormat: 'mp4', + options: { + videoCodec: 'libx264', + videoBitrate: '2M', + videoResolution: '1280x-1', + videoFps: 30, + audioCodec: 'aac', + audioBitrate: '128k', + }, + }, + { + id: 'web-video', + name: 'Web Optimized', + description: 'Small file size for web streaming', + icon: '🌐', + category: 'video', + outputFormat: 'mp4', + options: { + videoCodec: 'libx264', + videoBitrate: '1.5M', + videoResolution: '854x-1', + videoFps: 24, + audioCodec: 'aac', + audioBitrate: '96k', + }, + }, + + // Audio Presets + { + id: 'podcast-audio', + name: 'Podcast', + description: 'MP3, optimized for voice', + icon: '🎙️', + category: 'audio', + outputFormat: 'mp3', + options: { + audioCodec: 'libmp3lame', + audioBitrate: '128k', + audioSampleRate: 44100, + audioChannels: 2, + }, + }, + { + id: 'music-high-quality', + name: 'High Quality Music', + description: 'MP3, 320kbps for music', + icon: '🎵', + category: 'audio', + outputFormat: 'mp3', + options: { + audioCodec: 'libmp3lame', + audioBitrate: '320k', + audioSampleRate: 48000, + audioChannels: 2, + }, + }, + { + id: 'audiobook', + name: 'Audiobook', + description: 'Mono, small file size', + icon: '📚', + category: 'audio', + outputFormat: 'mp3', + options: { + audioCodec: 'libmp3lame', + audioBitrate: '64k', + audioSampleRate: 22050, + audioChannels: 1, + }, + }, + + // Image Presets + { + id: 'web-thumbnail', + name: 'Web Thumbnail', + description: 'JPG, 800px width, optimized', + icon: '🖼️', + category: 'image', + outputFormat: 'jpg', + options: { + imageQuality: 85, + imageWidth: 800, + }, + }, + { + id: 'hd-image', + name: 'HD Image', + description: 'PNG, high quality, lossless', + icon: '🎨', + category: 'image', + outputFormat: 'png', + options: { + imageQuality: 100, + }, + }, + { + id: 'social-media-image', + name: 'Social Media', + description: 'JPG, 1200px, optimized', + icon: '📱', + category: 'image', + outputFormat: 'jpg', + options: { + imageQuality: 90, + imageWidth: 1200, + }, + }, + { + id: 'web-optimized-image', + name: 'Web Optimized', + description: 'WebP, small file size', + icon: '⚡', + category: 'image', + outputFormat: 'webp', + options: { + imageQuality: 80, + }, + }, +]; + +/** + * Get presets by category + */ +export function getPresetsByCategory(category: 'video' | 'audio' | 'image'): FormatPreset[] { + return FORMAT_PRESETS.filter(preset => preset.category === category); +} + +/** + * Get preset by ID + */ +export function getPresetById(id: string): FormatPreset | undefined { + return FORMAT_PRESETS.find(preset => preset.id === id); +} diff --git a/lib/media/wasm/wasmLoader.ts b/lib/media/wasm/wasmLoader.ts new file mode 100644 index 0000000..ed09758 --- /dev/null +++ b/lib/media/wasm/wasmLoader.ts @@ -0,0 +1,157 @@ +import type { FFmpeg } from '@ffmpeg/ffmpeg'; +import type { ConverterEngine, WASMModuleState } from '@/types/media'; + +/** + * WASM module loading state + */ +const moduleState: WASMModuleState = { + ffmpeg: false, + imagemagick: false, +}; + +/** + * Cached WASM instances + */ +let ffmpegInstance: FFmpeg | null = null; +let imagemagickInstance: any = null; + +/** + * Load FFmpeg WASM module + */ +export async function loadFFmpeg(): Promise { + if (ffmpegInstance && moduleState.ffmpeg) { + return ffmpegInstance; + } + + try { + const { FFmpeg } = await import('@ffmpeg/ffmpeg'); + const { toBlobURL } = await import('@ffmpeg/util'); + + ffmpegInstance = new FFmpeg(); + + // Load core and dependencies + const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'; + + ffmpegInstance.on('log', ({ message }) => { + console.log('[FFmpeg]', message); + }); + + await ffmpegInstance.load({ + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), + wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), + }); + + moduleState.ffmpeg = true; + console.log('FFmpeg loaded successfully'); + + return ffmpegInstance; + } catch (error) { + console.error('Failed to load FFmpeg:', error); + throw new Error('Failed to load FFmpeg WASM module'); + } +} + +/** + * Load ImageMagick WASM module + */ +export async function loadImageMagick(): Promise { + if (imagemagickInstance && moduleState.imagemagick) { + return imagemagickInstance; + } + + try { + const { initializeImageMagick } = await import('@imagemagick/magick-wasm'); + + // Initialize ImageMagick with WASM file from public directory + // In production (static export), this will be served from /wasm/magick.wasm + const wasmUrl = '/wasm/magick.wasm'; + + console.log('[ImageMagick] Attempting to load WASM from:', wasmUrl); + + // Test fetch the WASM file first to debug + try { + const response = await fetch(wasmUrl); + console.log('[ImageMagick] WASM fetch response:', { + ok: response.ok, + status: response.status, + contentType: response.headers.get('content-type'), + contentLength: response.headers.get('content-length'), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch WASM file: ${response.status} ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + console.log('[ImageMagick] WASM file size:', arrayBuffer.byteLength, 'bytes'); + + if (arrayBuffer.byteLength === 0) { + throw new Error('WASM file is empty'); + } + + // Now initialize with the buffer directly + await initializeImageMagick(arrayBuffer); + } catch (fetchError) { + console.error('[ImageMagick] Failed to fetch WASM:', fetchError); + throw fetchError; + } + + const ImageMagick = await import('@imagemagick/magick-wasm'); + + imagemagickInstance = ImageMagick; + moduleState.imagemagick = true; + console.log('[ImageMagick] Loaded and initialized successfully'); + + return imagemagickInstance; + } catch (error) { + console.error('[ImageMagick] Failed to load:', error); + throw new Error(`Failed to load ImageMagick WASM module: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Get loaded module state + */ +export function getModuleState(): WASMModuleState { + return { ...moduleState }; +} + +/** + * Check if a specific module is loaded + */ +export function isModuleLoaded(engine: ConverterEngine): boolean { + return moduleState[engine]; +} + +/** + * Load appropriate WASM module for converter engine + */ +export async function loadModule(engine: ConverterEngine): Promise { + switch (engine) { + case 'ffmpeg': + return loadFFmpeg(); + case 'imagemagick': + return loadImageMagick(); + default: + throw new Error(`Unknown converter engine: ${engine}`); + } +} + +/** + * Unload all WASM modules and free memory + */ +export function unloadAll(): void { + if (ffmpegInstance) { + // FFmpeg doesn't have an explicit unload method + // Just null the instance + ffmpegInstance = null; + moduleState.ffmpeg = false; + } + + if (imagemagickInstance) { + imagemagickInstance = null; + moduleState.imagemagick = false; + } + + console.log('All WASM modules unloaded'); +} diff --git a/package.json b/package.json index aaff97c..be5d50d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "lint": "eslint ." }, "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", + "@imagemagick/magick-wasm": "^0.0.38", "@tanstack/react-query": "^5.90.21", "@valknarthing/pastel-wasm": "^0.1.0", "class-variance-authority": "^0.7.1", @@ -19,6 +22,7 @@ "framer-motion": "^12.34.3", "fuse.js": "^7.1.0", "html-to-image": "^1.11.13", + "jszip": "^3.10.1", "lucide-react": "^0.575.0", "next": "^16.1.6", "radix-ui": "^1.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67a1dbc..352bbd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@ffmpeg/ffmpeg': + specifier: ^0.12.15 + version: 0.12.15 + '@ffmpeg/util': + specifier: ^0.12.2 + version: 0.12.2 + '@imagemagick/magick-wasm': + specifier: ^0.0.38 + version: 0.0.38 '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) @@ -38,6 +47,9 @@ importers: html-to-image: specifier: ^1.11.13 version: 1.11.13 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lucide-react: specifier: ^0.575.0 version: 0.575.0(react@19.2.4) @@ -302,6 +314,18 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} 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'} + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -339,6 +363,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@imagemagick/magick-wasm@0.0.38': + resolution: {integrity: sha512-xg3q6ZMqUADyyy0h/1IndT9DUWUXY5lRhevF2WB+AYvphJ5yraH+R0IGO7H7DFnLSMrJ6zsxEbXfOnhNfark9w==} + '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} @@ -1911,6 +1938,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -2527,6 +2557,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2716,6 +2749,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2783,6 +2819,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2805,6 +2844,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.31.1: resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} @@ -3180,6 +3222,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3263,6 +3308,9 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -3354,6 +3402,9 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -3417,6 +3468,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3460,6 +3514,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3568,6 +3625,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-object@5.0.0: resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} engines: {node: '>=14.16'} @@ -4163,6 +4223,14 @@ snapshots: '@eslint/core': 0.17.0 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': {} + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -4195,6 +4263,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@imagemagick/magick-wasm@0.0.38': {} + '@img/colour@1.0.0': optional: true @@ -5748,6 +5818,8 @@ snapshots: cookie@1.1.1: {} + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -6504,6 +6576,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -6671,6 +6745,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -6729,6 +6805,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6748,6 +6831,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.31.1: optional: true @@ -7150,6 +7237,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7214,6 +7303,8 @@ snapshots: dependencies: parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -7351,6 +7442,16 @@ snapshots: react@19.2.4: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -7435,6 +7536,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -7501,6 +7604,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} shadcn@3.8.5(@types/node@25.3.0)(typescript@5.9.3): @@ -7703,6 +7808,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-object@5.0.0: dependencies: get-own-enumerable-keys: 1.0.0 diff --git a/types/units/convert-units.d.ts b/types/convert-units.d.ts similarity index 100% rename from types/units/convert-units.d.ts rename to types/convert-units.d.ts diff --git a/types/media.ts b/types/media.ts new file mode 100644 index 0000000..45d928a --- /dev/null +++ b/types/media.ts @@ -0,0 +1,121 @@ +/** + * Supported converter engines + */ +export type ConverterEngine = 'ffmpeg' | 'imagemagick'; + +/** + * File category based on type + */ +export type FileCategory = 'video' | 'audio' | 'image'; + +/** + * Conversion status + */ +export type ConversionStatus = 'pending' | 'loading' | 'processing' | 'completed' | 'error'; + +/** + * Supported conversion format + */ +export interface ConversionFormat { + id: string; + name: string; + extension: string; + mimeType: string; + category: FileCategory; + converter: ConverterEngine; + description?: string; +} + +/** + * Conversion job configuration + */ +export interface ConversionJob { + id: string; + inputFile: File; + inputFormat: ConversionFormat; + outputFormat: ConversionFormat; + options: ConversionOptions; + status: ConversionStatus; + progress: number; + result?: Blob; + error?: string; + startTime?: number; + endTime?: number; +} + +/** + * Generic conversion options + */ +export interface ConversionOptions { + // Video options + videoBitrate?: string; + videoCodec?: string; + videoResolution?: string; + videoFps?: number; + + // Audio options + audioBitrate?: string; + audioCodec?: string; + audioSampleRate?: number; + audioChannels?: number; + + // Image options + imageQuality?: number; + imageWidth?: number; + imageHeight?: number; + imageFormat?: string; + + // Generic options + [key: string]: string | number | boolean | undefined; +} + +/** + * History item for conversion history + */ +export interface ConversionHistoryItem { + id: string; + inputFileName: string; + inputFormat: string; + outputFormat: string; + outputFileName: string; + timestamp: number; + fileSize: number; + result?: Blob; +} + +/** + * Format preset for common conversions + */ +export interface FormatPreset { + id: string; + name: string; + description: string; + category: FileCategory; + sourceFormats: string[]; + targetFormat: string; + options: ConversionOptions; + icon?: string; +} + +/** + * WASM module loading state + */ +export interface WASMModuleState { + ffmpeg: boolean; + imagemagick: boolean; +} + +/** + * Progress callback for conversion + */ +export type ProgressCallback = (progress: number) => void; + +/** + * Conversion result + */ +export interface ConversionResult { + success: boolean; + blob?: Blob; + error?: string; + duration?: number; +}