From f964446121100408abbcfaabc23b5489470b7fb0 Mon Sep 17 00:00:00 2001 From: Gabbana Date: Wed, 17 Sep 2025 07:16:37 +0200 Subject: [PATCH 01/18] Add image/video mode toggle and video support UI Introduces a mode toggle allowing users to switch between image and video processing. Updates the UI to support video uploads, including a collapsible dropzone, video-specific icons and instructions, and layout adjustments for video previews. Adds a 'Remove Selected' button for bulk deletion and persists the selected mode in localStorage. --- src/app/index.tsx | 313 +++++++++++++++++++++++++++++++++------------- 1 file changed, 228 insertions(+), 85 deletions(-) diff --git a/src/app/index.tsx b/src/app/index.tsx index f50667c..1a39f47 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -188,6 +188,20 @@ export function App() { preset: "Custom", }); + // Toggle between Image and Video mode + const [mode, setMode] = useState<"image" | "video">(() => { + const saved = localStorage.getItem("pulp-mode"); + return saved === "video" || saved === "image" ? saved : "image"; + }); + + const handleModeToggle = (newMode: "image" | "video") => { + setMode(newMode); + localStorage.setItem("pulp-mode", newMode); + }; + + // Collapsible dropzone state for videos + const [isDropzoneCollapsed, setIsDropzoneCollapsed] = useState(false); + const onDrop = useCallback( async (acceptedFiles: File[]) => { startProcessing(); @@ -595,13 +609,33 @@ export function App() { setSelectedIds([]); }; + const removeSelected = () => { + // Revoke URLs for selected images before removing + compressedImages.forEach((img) => { + if (selectedIds.includes(img.id) && img.thumbnail) { + URL.revokeObjectURL(img.thumbnail); + } + }); + setCompressedImages((prev) => + prev.filter((image) => !selectedIds.includes(image.id)) + ); + setSelectedIds([]); + }; + return (
{/* Settings Panel */}
-
+ {/* Format dropdown - only show on image side with transition */} +
@@ -920,89 +954,140 @@ export function App() { )}
+ {/* Collapse/Expand Button for Videos */} + {mode === "video" && compressedImages.length > 0 && ( +
+ +
+ )} + {/* Drop Zone */}
{ - const rootProps = getRootProps(); - const setRefs = (node: HTMLDivElement | null) => { - const maybeFn = ( - rootProps as unknown as { - ref?: (n: HTMLDivElement | null) => void; - } - ).ref; - if (typeof maybeFn === "function") { - maybeFn(node); - } - dropAreaRef.current = node; - }; - return { ...rootProps, ref: setRefs } as typeof rootProps & { - ref: typeof setRefs; - }; - })()} - className={`drag-area ${isDragActive ? "drag-over" : ""} ${ - isProcessing ? "loading" : "" - } mb-12`} + className={`transition-all duration-300 ease-in-out mb-12 ${ + mode === "video" && + compressedImages.length > 0 && + isDropzoneCollapsed + ? "max-h-0 overflow-hidden" + : "max-h-96" + }`} > - -
- {isProcessing ? ( -
- - - - -

- Processing images... -

-
- ) : ( - <> - - - - {isDragActive ? ( -

- Drop the images here... +

{ + const rootProps = getRootProps(); + const setRefs = (node: HTMLDivElement | null) => { + const maybeFn = ( + rootProps as unknown as { + ref?: (n: HTMLDivElement | null) => void; + } + ).ref; + if (typeof maybeFn === "function") { + maybeFn(node); + } + dropAreaRef.current = node; + }; + return { ...rootProps, ref: setRefs } as typeof rootProps & { + ref: typeof setRefs; + }; + })()} + className={`drag-area ${isDragActive ? "drag-over" : ""} ${ + isProcessing ? "loading" : "" + }`} + > + +
+ {isProcessing ? ( +
+ + + + +

+ Processing {mode === "image" ? "images" : "videos"}...

- ) : ( -
-

- Drag & drop images here, or click to select -

-

- Supports JPEG, PNG, WebP, GIF, and BMP +

+ ) : ( + <> + + {mode === "image" ? ( + + ) : ( + + )} + + {isDragActive ? ( +

+ Drop the {mode === "image" ? "images" : "videos"} here...

-
- )} - - )} + ) : ( +
+

+ Drag & drop {mode === "image" ? "images" : "videos"}{" "} + here, or click to select +

+

+ {mode === "image" + ? "Supports JPEG, PNG, WebP, GIF, and BMP" + : "Supports MP4, MOV, AVI, MKV, and WebM"} +

+
+ )} + + )} +
@@ -1010,7 +1095,7 @@ export function App() { {/* Results */} {compressedImages.length > 0 && ( -
+
{/* Left: bulk actions and toggles */}
@@ -1031,8 +1116,19 @@ export function App() {
- {/* Center: Save Selected */} -
+ {/* Center: Save Selected and Remove Selected */} +
+
-
+
{compressedImages.map((image, idx) => ( -
+
{ saveImage(image); }} @@ -1252,6 +1363,38 @@ export function App() {
)}
+ + {/* Mode Toggle Slider - Bottom of entire app */} +
+
+ {/* Sliding pill */} +
+ + {/* Image button */} + + + {/* Video button */} + +
+
); } From dd2e21b689ac1f87d9fcd62a05f6f11c73f51007 Mon Sep 17 00:00:00 2001 From: Gabbana Date: Wed, 17 Sep 2025 08:12:12 +0200 Subject: [PATCH 02/18] Maintain aspect ratio when calculating image dimensions Added logic to automatically calculate width and height based on the selected preset's aspect ratio if only one dimension is provided. This ensures that images are resized proportionally according to the preset, improving output consistency. --- src/app/index.tsx | 93 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 3 deletions(-) diff --git a/src/app/index.tsx b/src/app/index.tsx index 1a39f47..8cc9503 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -245,12 +245,55 @@ export function App() { ) ); - // Optimize buffers with current settings + // Calculate dimensions based on aspect ratio if needed + let processedSettings = { ...settings }; + const selectedPreset = PRESET_OPTIONS.find( + (p) => p.label === settings.preset + ); + + if (selectedPreset?.aspectRatio && (settings.width || settings.height)) { + const aspectRatio = selectedPreset.aspectRatio; + const maxWidth = settings.width; + const maxHeight = settings.height; + + let calculatedWidth: number | undefined; + let calculatedHeight: number | undefined; + + if (maxWidth && maxHeight) { + // Both dimensions specified, use the smaller one to maintain aspect ratio + const widthBasedHeight = maxWidth / aspectRatio; + const heightBasedWidth = maxHeight * aspectRatio; + + if (widthBasedHeight <= maxHeight) { + calculatedWidth = maxWidth; + calculatedHeight = Math.round(widthBasedHeight); + } else { + calculatedWidth = Math.round(heightBasedWidth); + calculatedHeight = maxHeight; + } + } else if (maxWidth) { + // Only max width specified + calculatedWidth = maxWidth; + calculatedHeight = Math.round(maxWidth / aspectRatio); + } else if (maxHeight) { + // Only max height specified + calculatedWidth = Math.round(maxHeight * aspectRatio); + calculatedHeight = maxHeight; + } + + processedSettings = { + ...settings, + width: calculatedWidth, + height: calculatedHeight, + }; + } + + // Optimize buffers with processed settings let bufferResults: BufferOptimizeResult[] = []; try { bufferResults = await window.electronAPI.optimizeImagesBuffers({ files: filesForBuffer, - settings: settings as any, + settings: processedSettings as any, }); } catch (e) { console.error("optimizeImagesBuffers error:", e); @@ -416,10 +459,54 @@ export function App() { }) ) ); + + // Calculate dimensions based on aspect ratio if needed + let processedSettings = { ...settings }; + const selectedPreset = PRESET_OPTIONS.find( + (p) => p.label === settings.preset + ); + + if (selectedPreset?.aspectRatio && (settings.width || settings.height)) { + const aspectRatio = selectedPreset.aspectRatio; + const maxWidth = settings.width; + const maxHeight = settings.height; + + let calculatedWidth: number | undefined; + let calculatedHeight: number | undefined; + + if (maxWidth && maxHeight) { + // Both dimensions specified, use the smaller one to maintain aspect ratio + const widthBasedHeight = maxWidth / aspectRatio; + const heightBasedWidth = maxHeight * aspectRatio; + + if (widthBasedHeight <= maxHeight) { + calculatedWidth = maxWidth; + calculatedHeight = Math.round(widthBasedHeight); + } else { + calculatedWidth = Math.round(heightBasedWidth); + calculatedHeight = maxHeight; + } + } else if (maxWidth) { + // Only max width specified + calculatedWidth = maxWidth; + calculatedHeight = Math.round(maxWidth / aspectRatio); + } else if (maxHeight) { + // Only max height specified + calculatedWidth = Math.round(maxHeight * aspectRatio); + calculatedHeight = maxHeight; + } + + processedSettings = { + ...settings, + width: calculatedWidth, + height: calculatedHeight, + }; + } + try { const out = await window.electronAPI.optimizeImagesBuffers({ files, - settings, + settings: processedSettings, }); setCompressedImages((prev) => { const byId = new Map(prev.map((p) => [p.id, p] as const)); From fb31b7317a68fd196c35d50dfcb95d10f151a332 Mon Sep 17 00:00:00 2001 From: Gabbana Date: Thu, 18 Sep 2025 14:26:42 +0200 Subject: [PATCH 03/18] Add dynamic preview overlays for image cropping Introduces a preview overlay feature that visually indicates the crop area based on current settings and aspect ratio presets. Previews are updated when settings change and are generated for new images on drop. Proper cleanup of preview URLs is implemented when images are removed or cleared. --- src/app/index.tsx | 207 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 203 insertions(+), 4 deletions(-) diff --git a/src/app/index.tsx b/src/app/index.tsx index 8cc9503..ca6698c 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -132,6 +132,9 @@ export function App() { [] ); const [isProcessing, setIsProcessing] = useState(false); + const [previewUrls, setPreviewUrls] = useState>( + new Map() + ); // Ensure loading UI is visible for at least a minimum duration const processingCountRef = useRef(0); const processingStartRef = useRef(0); @@ -202,6 +205,169 @@ export function App() { // Collapsible dropzone state for videos const [isDropzoneCollapsed, setIsDropzoneCollapsed] = useState(false); + // Function to update all previews when settings change + const updateAllPreviews = useCallback(async () => { + const newPreviewUrls = new Map(); + + for (const image of compressedImages) { + if (image.originalFile) { + try { + const previewUrl = await processImageForPreview( + image.originalFile, + settings + ); + newPreviewUrls.set(image.id, previewUrl); + } catch (error) { + console.error("Error generating preview:", error); + } + } + } + + setPreviewUrls(newPreviewUrls); + }, [compressedImages, settings]); + + // Update previews when settings change + useEffect(() => { + if (compressedImages.length > 0) { + updateAllPreviews(); + } + }, [ + settings.quality, + settings.width, + settings.height, + settings.preset, + settings.format, + updateAllPreviews, + ]); + + // Function to process image for preview + const processImageForPreview = async ( + file: File, + settings: CompressionSettings + ): Promise => { + return new Promise((resolve) => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const img = new Image(); + + img.onload = () => { + // Calculate dimensions based on aspect ratio if needed + let targetWidth = settings.width; + let targetHeight = settings.height; + + const selectedPreset = PRESET_OPTIONS.find( + (p) => p.label === settings.preset + ); + if ( + selectedPreset?.aspectRatio && + (settings.width || settings.height) + ) { + const aspectRatio = selectedPreset.aspectRatio; + const maxWidth = settings.width; + const maxHeight = settings.height; + + if (maxWidth && maxHeight) { + const widthBasedHeight = maxWidth / aspectRatio; + const heightBasedWidth = maxHeight * aspectRatio; + + if (widthBasedHeight <= maxHeight) { + targetWidth = maxWidth; + targetHeight = Math.round(widthBasedHeight); + } else { + targetWidth = Math.round(heightBasedWidth); + targetHeight = maxHeight; + } + } else if (maxWidth) { + targetWidth = maxWidth; + targetHeight = Math.round(maxWidth / aspectRatio); + } else if (maxHeight) { + targetWidth = Math.round(maxHeight * aspectRatio); + targetHeight = maxHeight; + } + } + + // Set canvas size to original image size for overlay effect + canvas.width = img.width; + canvas.height = img.height; + + if (!ctx) { + resolve(URL.createObjectURL(file)); + return; + } + + // Draw original image + ctx.drawImage(img, 0, 0); + + // Calculate crop area for aspect ratio + let cropX = 0; + let cropY = 0; + let cropWidth = img.width; + let cropHeight = img.height; + + if (selectedPreset?.aspectRatio && targetWidth && targetHeight) { + const targetAspectRatio = targetWidth / targetHeight; + const currentAspectRatio = img.width / img.height; + + if (currentAspectRatio > targetAspectRatio) { + // Image is wider than target, crop sides + cropWidth = img.height * targetAspectRatio; + cropX = (img.width - cropWidth) / 2; + } else { + // Image is taller than target, crop top/bottom + cropHeight = img.width / targetAspectRatio; + cropY = (img.height - cropHeight) / 2; + } + } + + // Create overlay canvas for crop preview + const overlayCanvas = document.createElement("canvas"); + const overlayCtx = overlayCanvas.getContext("2d"); + overlayCanvas.width = img.width; + overlayCanvas.height = img.height; + + if (overlayCtx) { + // Fill with black overlay + overlayCtx.fillStyle = "rgba(0, 0, 0, 0.4)"; + overlayCtx.fillRect(0, 0, img.width, img.height); + + // Clear the crop area + overlayCtx.clearRect(cropX, cropY, cropWidth, cropHeight); + + // Draw the cropped area from original image + overlayCtx.drawImage( + img, + cropX, + cropY, + cropWidth, + cropHeight, + cropX, + cropY, + cropWidth, + cropHeight + ); + } + + // Draw overlay on main canvas + ctx.drawImage(overlayCanvas, 0, 0); + + // Convert to blob with quality + canvas.toBlob( + (blob) => { + if (blob) { + resolve(URL.createObjectURL(blob)); + } else { + resolve(URL.createObjectURL(file)); + } + }, + `image/${settings.format}`, + settings.quality / 100 + ); + }; + + img.src = URL.createObjectURL(file); + }); + }; + const onDrop = useCallback( async (acceptedFiles: File[]) => { startProcessing(); @@ -329,6 +495,24 @@ export function App() { return updated; }); + // Generate previews for new images + const newPreviewUrls = new Map(); + for (let ri = 0; ri < staged.length; ri += 1) { + const item = staged[ri]; + if (item.originalFile) { + try { + const previewUrl = await processImageForPreview( + item.originalFile, + processedSettings + ); + newPreviewUrls.set(item.id, previewUrl); + } catch (error) { + console.error("Error generating preview:", error); + } + } + } + setPreviewUrls((prev) => new Map([...prev, ...newPreviewUrls])); + finishProcessing(); }, [settings, startProcessing, finishProcessing] @@ -692,21 +876,36 @@ export function App() { URL.revokeObjectURL(img.thumbnail); } }); + // Clean up all preview URLs + previewUrls.forEach((url) => URL.revokeObjectURL(url)); setCompressedImages([]); setSelectedIds([]); + setPreviewUrls(new Map()); }; const removeSelected = () => { // Revoke URLs for selected images before removing compressedImages.forEach((img) => { - if (selectedIds.includes(img.id) && img.thumbnail) { - URL.revokeObjectURL(img.thumbnail); + if (selectedIds.includes(img.id)) { + if (img.thumbnail) { + URL.revokeObjectURL(img.thumbnail); + } + const previewUrl = previewUrls.get(img.id); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + } } }); setCompressedImages((prev) => prev.filter((image) => !selectedIds.includes(image.id)) ); setSelectedIds([]); + // Clean up preview URLs + setPreviewUrls((prev) => { + const newMap = new Map(prev); + selectedIds.forEach((id) => newMap.delete(id)); + return newMap; + }); }; return ( @@ -1264,9 +1463,9 @@ export function App() { saveImage(image); }} > - {image.thumbnail ? ( + {previewUrls.get(image.id) || image.thumbnail ? ( {image.originalFile.name} From 840e25ff20b76a8c8017748d4ec3e9c2c6263c8e Mon Sep 17 00:00:00 2001 From: chrlnd Date: Sat, 27 Sep 2025 11:45:07 +0200 Subject: [PATCH 04/18] Add video compression support and refactor UI Introduces video compression using ffmpeg-static and fluent-ffmpeg, updates dependencies, and refactors the App to support both image and video modes. Adds new hooks and components for modularity, updates the settings panel for video options, and improves file handling for both images and videos. --- forge.config.ts | 7 +- package-lock.json | 265 ++++- package.json | 4 + src/app/App.tsx | 290 +++++ src/app/index.tsx | 283 ++++- src/app/index.tsx.backup | 1861 ++++++++++++++++++++++++++++++ src/components/Dropzone.tsx | 89 ++ src/components/ImageGrid.tsx | 102 ++ src/components/Input.tsx | 47 + src/components/Select.tsx | 80 ++ src/components/SettingsPanel.tsx | 164 +++ src/components/index.ts | 5 + src/hooks/index.ts | 3 + src/hooks/useFileHandling.ts | 32 + src/hooks/useImageProcessing.ts | 133 +++ src/hooks/useVideoProcessing.ts | 47 + src/main.ts | 170 ++- src/preload.ts | 31 + src/types/index.ts | 35 + webpack.main.config.ts | 2 + 20 files changed, 3575 insertions(+), 75 deletions(-) create mode 100644 src/app/App.tsx create mode 100644 src/app/index.tsx.backup create mode 100644 src/components/Dropzone.tsx create mode 100644 src/components/ImageGrid.tsx create mode 100644 src/components/Input.tsx create mode 100644 src/components/Select.tsx create mode 100644 src/components/SettingsPanel.tsx create mode 100644 src/components/index.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useFileHandling.ts create mode 100644 src/hooks/useImageProcessing.ts create mode 100644 src/hooks/useVideoProcessing.ts create mode 100644 src/types/index.ts diff --git a/forge.config.ts b/forge.config.ts index 05a7db4..e06168e 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -17,7 +17,7 @@ import { rendererConfig } from "./webpack.renderer.config"; const config: ForgeConfig = { packagerConfig: { asar: { - unpack: "**/node_modules/{sharp,@img}/**/*", + unpack: "**/node_modules/{sharp,@img,ffmpeg-static,@ffprobe-installer}/**/*", }, // Use icons from assets/icons/pulp.(ico|icns|png) depending on platform icon: path.resolve(__dirname, "assets/icons/pulp"), @@ -55,7 +55,7 @@ const config: ForgeConfig = { new WebpackPlugin({ mainConfig, devContentSecurityPolicy: - "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws:;", + "default-src 'self' blob:; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; connect-src 'self' ws:;", renderer: { config: rendererConfig, entryPoints: [ @@ -70,9 +70,8 @@ const config: ForgeConfig = { ], }, }), - // @ts-expect-error ForgeExternalsPlugin is not typed new ForgeExternalsPlugin({ - externals: ["sharp"], + externals: ["sharp", "ffmpeg-static", "@ffprobe-installer/ffprobe"], includeDeps: true, }), diff --git a/package-lock.json b/package-lock.json index b7a7ef8..02db140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,10 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@ffprobe-installer/ffprobe": "^2.1.2", "electron-squirrel-startup": "^1.0.1", + "ffmpeg-static": "^5.2.0", + "fluent-ffmpeg": "^2.1.3", "react": "^19.1.0", "react-dom": "^19.1.0", "react-dropzone": "^14.3.8", @@ -28,6 +31,7 @@ "@electron/fuses": "^1.8.0", "@tailwindcss/postcss": "^4.1.10", "@timfish/forge-externals-plugin": "^0.2.1", + "@types/fluent-ffmpeg": "^2.1.27", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -124,6 +128,21 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@electron-forge/cli": { "version": "7.8.1", "resolved": "https://registry.npmmirror.com/@electron-forge/cli/-/cli-7.8.1.tgz", @@ -1088,6 +1107,135 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@ffprobe-installer/darwin-arm64": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-arm64/-/darwin-arm64-5.0.1.tgz", + "integrity": "sha512-vwNCNjokH8hfkbl6m95zICHwkSzhEvDC3GVBcUp5HX8+4wsX10SP3B+bGur7XUzTIZ4cQpgJmEIAx6TUwRepMg==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "LGPL-2.1", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffprobe-installer/darwin-x64": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-x64/-/darwin-x64-5.1.0.tgz", + "integrity": "sha512-J+YGscZMpQclFg31O4cfVRGmDpkVsQ2fZujoUdMAAYcP0NtqpC49Hs3SWJpBdsGB4VeqOt5TTm1vSZQzs1NkhA==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffprobe-installer/ffprobe": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/ffprobe/-/ffprobe-2.1.2.tgz", + "integrity": "sha512-ZNvwk4f2magF42Zji2Ese16SMj9BS7Fui4kRjg6gTYTxY3gWZNpg85n4MIfQyI9nimHg4x/gT6FVkp/bBDuBwg==", + "license": "LGPL-2.1", + "engines": { + "node": ">=14.21.2" + }, + "optionalDependencies": { + "@ffprobe-installer/darwin-arm64": "5.0.1", + "@ffprobe-installer/darwin-x64": "5.1.0", + "@ffprobe-installer/linux-arm": "5.2.0", + "@ffprobe-installer/linux-arm64": "5.2.0", + "@ffprobe-installer/linux-ia32": "5.2.0", + "@ffprobe-installer/linux-x64": "5.2.0", + "@ffprobe-installer/win32-ia32": "5.1.0", + "@ffprobe-installer/win32-x64": "5.1.0" + } + }, + "node_modules/@ffprobe-installer/linux-arm": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm/-/linux-arm-5.2.0.tgz", + "integrity": "sha512-PF5HqEhCY7WTWHtLDYbA/+rLS+rhslWvyBlAG1Fk8VzVlnRdl93o6hy7DE2kJgxWQbFaR3ZktPQGEzfkrmQHvQ==", + "cpu": [ + "arm" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/linux-arm64": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm64/-/linux-arm64-5.2.0.tgz", + "integrity": "sha512-X1VvWtlLs6ScP73biVLuHD5ohKJKsMTa0vafCESOen4mOoNeLAYbxOVxDWAdFz9cpZgRiloFj5QD6nDj8E28yQ==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/linux-ia32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-ia32/-/linux-ia32-5.2.0.tgz", + "integrity": "sha512-TFVK5sasXyXhbIG7LtPRDmtkrkOsInwKcL43iEvEw+D9vCS2rc//mn9/0Q+BR0UoJEiMK4+ApYr/3LLVUBPOCQ==", + "cpu": [ + "ia32" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/linux-x64": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-x64/-/linux-x64-5.2.0.tgz", + "integrity": "sha512-D3UeqTLYPNs7pBWPLUYGehPdRVqU8eACox4OZy3pZUZatxye2YKlvBwEfaLdL1v2Z4FOAlLUhms0kY8m8kqSRA==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/win32-ia32": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-ia32/-/win32-ia32-5.1.0.tgz", + "integrity": "sha512-5O3vOoNRxmut0/Nu9vSazTdSHasrr+zPT2B3Hm7kjmO3QVFcIfVImS6ReQnZeSy8JPJOqXts5kX5x/3KOX54XQ==", + "cpu": [ + "ia32" + ], + "license": "GPL-3.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ffprobe-installer/win32-x64": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-x64/-/win32-x64-5.1.0.tgz", + "integrity": "sha512-jMGYeAgkrdn4e2vvYt/qakgHRE3CPju4bn5TmdPfoAm1BlX1mY9cyMd8gf5vSzI8gH8Zq5WQAyAkmekX/8TSTg==", + "cpu": [ + "x64" + ], + "license": "GPL-3.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz", @@ -2330,6 +2478,16 @@ "@types/send": "*" } }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.27.tgz", + "integrity": "sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -3011,7 +3169,6 @@ "version": "6.0.2", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "4" @@ -3846,7 +4003,6 @@ "version": "1.1.2", "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/bytes": { @@ -4054,6 +4210,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -4435,6 +4597,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/connect-history-api-fallback": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", @@ -4706,7 +4883,6 @@ "version": "4.4.1", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5783,7 +5959,6 @@ "version": "2.2.1", "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6585,6 +6760,22 @@ "pend": "~1.2.0" } }, + "node_modules/ffmpeg-static": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.2.0.tgz", + "integrity": "sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==", + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -6750,6 +6941,37 @@ "node": ">= 12" } }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fluent-ffmpeg/node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, + "node_modules/fluent-ffmpeg/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/fmix": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/fmix/-/fmix-0.1.0.tgz", @@ -7711,6 +7933,21 @@ "dev": true, "license": "MIT" }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -7729,7 +7966,6 @@ "version": "5.0.1", "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", @@ -7901,7 +8137,6 @@ "version": "2.0.4", "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -8550,7 +8785,6 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -9608,7 +9842,6 @@ "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/multicast-dns": { @@ -10316,6 +10549,11 @@ "node": ">=0.10.0" } }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "node_modules/parse-color": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/parse-color/-/parse-color-1.0.0.tgz", @@ -10771,7 +11009,6 @@ "version": "2.0.3", "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -11108,7 +11345,6 @@ "version": "3.6.2", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -11449,7 +11685,6 @@ "version": "5.2.1", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -12264,7 +12499,6 @@ "version": "1.3.0", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -13093,6 +13327,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "4.5.5", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-4.5.5.tgz", @@ -13377,7 +13617,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utila": { diff --git a/package.json b/package.json index 8d13a31..3e95c64 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,10 @@ "start": "electron-forge start" }, "dependencies": { + "@ffprobe-installer/ffprobe": "^2.1.2", "electron-squirrel-startup": "^1.0.1", + "ffmpeg-static": "^5.2.0", + "fluent-ffmpeg": "^2.1.3", "react": "^19.1.0", "react-dom": "^19.1.0", "react-dropzone": "^14.3.8", @@ -36,6 +39,7 @@ "@electron/fuses": "^1.8.0", "@tailwindcss/postcss": "^4.1.10", "@timfish/forge-externals-plugin": "^0.2.1", + "@types/fluent-ffmpeg": "^2.1.27", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^5.62.0", diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..f3291a1 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,290 @@ +import React, { useState, useCallback } from 'react'; +import { SettingsPanel, Dropzone, ImageGrid } from '../components'; +import { useImageProcessing, useFileHandling, useVideoProcessing } from '../hooks'; +import { CompressionSettings } from '../types'; + +const PRESET_OPTIONS = [ + { value: "Custom", label: "Custom" }, + { value: "Instagram", label: "Instagram", width: 1080, height: 1080, category: "Social Media" }, + { value: "Instagram Story", label: "Instagram Story", width: 1080, height: 1920, category: "Social Media" }, + { value: "Facebook", label: "Facebook", width: 1200, height: 630, category: "Social Media" }, + { value: "Twitter", label: "Twitter", width: 1200, height: 675, category: "Social Media" }, + { value: "LinkedIn", label: "LinkedIn", width: 1200, height: 627, category: "Social Media" }, + { value: "YouTube Thumbnail", label: "YouTube Thumbnail", width: 1280, height: 720, category: "Video" }, + { value: "Banner", label: "Banner", width: 1200, height: 300, category: "Web" }, + { value: "Card", label: "Card", width: 400, height: 300, category: "Web" }, + { value: "Profile", label: "Profile", width: 300, height: 300, category: "Profile" }, + { value: "Small", label: "Small", width: 200, height: 200, category: "Preset Sizes" }, + { value: "Medium", label: "Medium", width: 400, height: 400, category: "Preset Sizes" }, + { value: "Large", label: "Large", width: 800, height: 600, category: "Preset Sizes" }, +]; + +export const App: React.FC = () => { + const [mode, setMode] = useState<'image' | 'video'>('image'); + const [isDropzoneCollapsed, setIsDropzoneCollapsed] = useState(false); + + const [settings, setSettings] = useState({ + format: "jpeg", + quality: 80, + preset: "Custom", + videoFormat: "mp4", + videoCodec: "libx264", + videoQuality: 60, + videoPreset: "medium", + }); + + const { + compressedImages, + setCompressedImages, + isProcessing, + previewUrls, + setPreviewUrls, + generateId, + processImageForPreview, + startProcessing, + finishProcessing, + clearImages, + saveImage, + } = useImageProcessing(); + + const { writeTempFile } = useFileHandling(); + const { processVideos } = useVideoProcessing(); + + const formatFileSize = useCallback((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 parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, []); + + const getCompressionRatio = useCallback((original: number, compressed: number): string => { + const ratio = ((original - compressed) / original * 100).toFixed(1); + return `${ratio}% smaller`; + }, []); + + const handleImageClick = useCallback((image: any) => { + if (image.originalFile.type.startsWith("video/")) { + if (image.success && image.outputPath) { + alert(`Video processed successfully!\n\nOriginal: ${formatFileSize(image.originalSize)}\nCompressed: ${formatFileSize(image.compressedSize || 0)}\nSaved: ${getCompressionRatio(image.originalSize, image.compressedSize || 0)}\n\nOutput: ${image.outputPath}`); + } else if (image.error) { + alert(`Video processing failed: ${image.error}`); + } else { + alert("Video processing in progress..."); + } + } else { + saveImage(image); + } + }, [formatFileSize, getCompressionRatio, saveImage]); + + const handleDrop = useCallback(async (acceptedFiles: File[]) => { + startProcessing(); + + const imageFiles = acceptedFiles.filter((file) => file.type.startsWith("image/")); + const videoFiles = acceptedFiles.filter((file) => file.type.startsWith("video/")); + + // Process images + if (imageFiles.length > 0) { + const staged = imageFiles.map((file) => ({ + id: generateId(), + originalFile: file, + originalSize: file.size, + format: settings.format, + filename: file.name.replace(/\.[^/.]+$/, "") + "." + settings.format, + thumbnail: URL.createObjectURL(file), + success: false, + })); + setCompressedImages((prev) => [...prev, ...staged]); + + // Process images with Sharp + const results = await window.electronAPI.optimizeImages({ + filePaths: imageFiles.map(f => f.path || URL.createObjectURL(f)), + settings: { + format: settings.format, + quality: settings.quality, + width: settings.width, + height: settings.height, + }, + }); + + setCompressedImages((prev) => { + const updated = [...prev]; + const startIdx = updated.length - staged.length; + for (let idx = 0; idx < results.length; idx += 1) { + const r = results[idx]; + const item = updated[startIdx + idx]; + if (!item) continue; + updated[startIdx + idx] = { + ...item, + outputPath: r.outputPath, + sourcePath: r.filePath, + originalSize: r.originalBytes ?? item.originalSize, + compressedSize: r.outputBytes ?? item.compressedSize, + success: r.success, + error: r.error, + }; + } + return updated; + }); + + // Generate previews + const newPreviewUrls = new Map(); + for (let ri = 0; ri < staged.length; ri += 1) { + const item = staged[ri]; + if (item.originalFile && item.originalFile.type.startsWith("image/")) { + try { + const previewUrl = await processImageForPreview(item.originalFile, settings); + newPreviewUrls.set(item.id, previewUrl); + } catch (error) { + console.error("Error generating preview:", error); + } + } + } + setPreviewUrls((prev) => new Map([...prev, ...newPreviewUrls])); + } + + // Process videos + if (videoFiles.length > 0) { + const videoFormat = settings.videoFormat || "mp4"; + const staged = videoFiles.map((file) => ({ + id: generateId(), + originalFile: file, + originalSize: file.size, + format: videoFormat, + filename: file.name.replace(/\.[^/.]+$/, "") + "." + videoFormat, + thumbnail: URL.createObjectURL(file), + success: false, + })); + setCompressedImages((prev) => [...prev, ...staged]); + + // Get file paths or write to temp files + const withFsPath = videoFiles.filter(f => f.path).map(f => f.path!); + const tempPaths: string[] = []; + + for (const file of videoFiles.filter(f => !f.path)) { + try { + const tempPath = await writeTempFile(file); + tempPaths.push(tempPath); + } catch (error) { + console.error("Error writing temp file:", error); + } + } + + const allPaths = [...withFsPath, ...tempPaths]; + if (allPaths.length > 0) { + try { + const results = await processVideos(allPaths, settings); + + setCompressedImages((prev) => { + const updated = [...prev]; + const startIdx = updated.length - staged.length; + for (let idx = 0; idx < results.length; idx += 1) { + const r = results[idx]; + const item = updated[startIdx + idx]; + if (!item) continue; + updated[startIdx + idx] = { + ...item, + outputPath: r.outputPath, + sourcePath: r.filePath, + originalSize: r.originalBytes ?? item.originalSize, + compressedSize: r.outputBytes ?? item.compressedSize, + success: r.success, + error: r.error, + }; + } + return updated; + }); + } catch (e) { + console.error("Video processing error:", e); + alert("Video engine not ready. Please restart the app and try again."); + } + } + } + + finishProcessing(); + }, [settings, startProcessing, finishProcessing, generateId, processImageForPreview, writeTempFile, processVideos]); + + return ( +
+
+ {/* Header */} +
+

Pulp

+

+ {mode === 'image' ? 'Image' : 'Video'} compression made simple +

+
+ + {/* Mode Toggle */} +
+
+ + +
+
+ + {/* Settings Panel */} + + + {/* Dropzone */} + 0} + /> + + {/* Results */} + {compressedImages.length > 0 && ( +
+
+

+ {mode === 'image' ? 'Compressed Images' : 'Processed Videos'} ({compressedImages.length}) +

+ +
+ + +
+ )} +
+
+ ); +}; diff --git a/src/app/index.tsx b/src/app/index.tsx index ca6698c..a066f17 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -42,6 +42,11 @@ interface CompressionSettings { width?: number; height?: number; preset?: string; + // Video settings + videoFormat?: "mp4" | "webm" | "avi" | "mov" | "mkv"; + videoCodec?: "libx264" | "libvpx-vp9" | "libx265" | "libvpx"; + videoQuality?: number; + videoPreset?: "ultrafast" | "superfast" | "veryfast" | "faster" | "fast" | "medium" | "slow" | "slower" | "veryslow"; } interface PresetOption { @@ -189,8 +194,21 @@ export function App() { format: "jpeg", quality: 80, preset: "Custom", + // Video settings + videoFormat: "mp4", + videoCodec: "libx264", + videoQuality: 60, // Good quality on 1-100 scale + videoPreset: "medium", }); + // Map 1-100 scale to CRF 0-51 (inverted: higher quality = lower CRF) + const mapQualityToCRF = (quality: number): number => { + // Map 1-100 to 51-0 (inverted) + // 1 = worst quality = CRF 51 + // 100 = best quality = CRF 0 + return Math.round(51 - (quality - 1) * (51 / 99)); + }; + // Toggle between Image and Video mode const [mode, setMode] = useState<"image" | "video">(() => { const saved = localStorage.getItem("pulp-mode"); @@ -210,7 +228,7 @@ export function App() { const newPreviewUrls = new Map(); for (const image of compressedImages) { - if (image.originalFile) { + if (image.originalFile && image.originalFile.type.startsWith("image/")) { try { const previewUrl = await processImageForPreview( image.originalFile, @@ -221,6 +239,7 @@ export function App() { console.error("Error generating preview:", error); } } + // For videos, we don't need to generate preview URLs - we'll use the original file directly } setPreviewUrls(newPreviewUrls); @@ -371,6 +390,103 @@ export function App() { const onDrop = useCallback( async (acceptedFiles: File[]) => { startProcessing(); + if (mode === "video") { + const videoFiles = acceptedFiles.filter((file) => + file.type.startsWith("video/") + ); + + // Stage entries for UI + const videoFormat = settings.videoFormat || "mp4"; + const staged = videoFiles.map((file) => ({ + id: generateId(), + originalFile: file, + originalSize: file.size, + format: videoFormat, + filename: file.name.replace(/\.[^/.]+$/, "") + "." + videoFormat, + thumbnail: URL.createObjectURL(file), + success: false, + })); + setCompressedImages((prev) => [...prev, ...staged]); + + // Try to use filesystem paths from Electron's File objects + const withFsPath: string[] = []; + const missingPath: { name: string; file: File }[] = []; + for (const f of videoFiles) { + const p = (f as unknown as { path?: string }).path; + if (p && typeof p === "string") withFsPath.push(p); + else missingPath.push({ name: f.name, file: f }); + } + + let tempPaths: string[] = []; + if (missingPath.length > 0) { + // Fallback: persist videos to temp files (may be slower for large files) + const filesForBuffer = await Promise.all( + missingPath.map( + ({ name, file }) => + new Promise<{ name: string; dataBase64: string }>((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + const res = reader.result as string; // data URL + resolve({ name, dataBase64: res.split(",")[1] || "" }); + }; + reader.readAsDataURL(file); + }) + ) + ); + try { + const writeRes = await window.electronAPI.writeTempFiles(filesForBuffer); + tempPaths = writeRes.filePaths || []; + } catch (e) { + console.error("writeTempFiles error:", e); + } + } + + const allPaths = [...withFsPath, ...tempPaths]; + console.log("Video processing paths:", allPaths); + if (allPaths.length > 0) { + try { + console.log("Starting video transcoding..."); + const results = await window.electronAPI.transcodeVideos({ + filePaths: allPaths, + settings: { + container: settings.videoFormat || "mp4", + videoCodec: settings.videoCodec || "libx264", + crf: mapQualityToCRF(settings.videoQuality || 60), + preset: settings.videoPreset || "medium", + }, + }); + console.log("Video transcoding results:", results); + + // Update state with results + setCompressedImages((prev) => { + const updated = [...prev]; + const startIdx = updated.length - staged.length; + for (let i = 0; i < results.length; i += 1) { + const r = results[i]; + const idx = startIdx + i; + const item = updated[idx]; + if (!item) continue; + updated[idx] = { + ...item, + outputPath: r.outputPath, + sourcePath: r.filePath, + originalSize: r.originalBytes ?? item.originalSize, + compressedSize: r.outputBytes ?? item.compressedSize, + success: r.success, + error: r.error, + }; + } + return updated; + }); + } catch (e) { + console.error("transcodeVideos error:", e); + alert("Video engine not ready. Please restart the app and try again."); + } + } + + finishProcessing(); + return; + } const imageFiles = acceptedFiles.filter((file) => file.type.startsWith("image/") @@ -495,11 +611,11 @@ export function App() { return updated; }); - // Generate previews for new images + // Generate previews for new images only (not videos) const newPreviewUrls = new Map(); for (let ri = 0; ri < staged.length; ri += 1) { const item = staged[ri]; - if (item.originalFile) { + if (item.originalFile && item.originalFile.type.startsWith("image/")) { try { const previewUrl = await processImageForPreview( item.originalFile, @@ -523,9 +639,10 @@ export function App() { const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, - accept: { - "image/*": [".jpeg", ".jpg", ".png", ".webp", ".gif", ".bmp"], - }, + accept: + mode === "image" + ? { "image/*": [".jpeg", ".jpg", ".png", ".webp", ".gif", ".bmp"] } + : { "video/*": [".mp4", ".mov", ".avi", ".mkv", ".webm"] }, multiple: true, }); @@ -914,61 +1031,94 @@ export function App() { {/* Settings Panel */}
- {/* Format dropdown - only show on image side with transition */} -
+ {/* Format dropdown - show different formats for image vs video */} +
- + {mode === "image" ? ( + + ) : ( + + )}
- - setSettings((prev) => ({ - ...prev, - quality: parseInt(e.target.value), - })) - } - className="w-full h-12 px-4 bg-white bg-opacity-10 leading-none text-white text-opacity-70 rounded-md focus:ring-primary-500 focus:border-primary-500 transition-colors hover:bg-white/15" - /> +
+ + setSettings((prev) => ({ + ...prev, + ...(mode === "image" + ? { quality: parseInt(e.target.value) } + : { videoQuality: parseInt(e.target.value) } + ), + })) + } + min={1} + max={100} + className="w-full h-12 px-4 pr-8 bg-white bg-opacity-10 leading-none text-white text-opacity-70 rounded-md focus:ring-primary-500 focus:border-primary-500 transition-colors hover:bg-white/15" + /> + + % + +