From 34e9efdb73aa409c2e46bb9ef7ff6c958cec6c59 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sun, 16 Nov 2025 16:02:21 -0700 Subject: [PATCH] export working --- dist-electron/main.js | 21 +- dist-electron/preload.mjs | 3 + electron/electron-env.d.ts | 1 + electron/ipc/handlers.ts | 23 +- electron/preload.ts | 5 +- package-lock.json | 34 ++ package.json | 2 + src/components/video-editor/ExportDialog.tsx | 153 ++++++ src/components/video-editor/SettingsPanel.tsx | 4 +- src/components/video-editor/VideoEditor.tsx | 106 ++++ src/components/video-editor/VideoPlayback.tsx | 3 + src/lib/exporter/frameRenderer.ts | 503 ++++++++++++++++++ src/lib/exporter/index.ts | 5 + src/lib/exporter/muxer.ts | 94 ++++ src/lib/exporter/types.ts | 26 + src/lib/exporter/videoDecoder.ts | 91 ++++ src/lib/exporter/videoExporter.ts | 260 +++++++++ src/vite-env.d.ts | 6 + 18 files changed, 1336 insertions(+), 4 deletions(-) create mode 100644 src/components/video-editor/ExportDialog.tsx create mode 100644 src/lib/exporter/frameRenderer.ts create mode 100644 src/lib/exporter/index.ts create mode 100644 src/lib/exporter/muxer.ts create mode 100644 src/lib/exporter/types.ts create mode 100644 src/lib/exporter/videoDecoder.ts create mode 100644 src/lib/exporter/videoExporter.ts diff --git a/dist-electron/main.js b/dist-electron/main.js index 47c50f2..c12676b 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -119,7 +119,7 @@ function startMouseTracking() { isHookStarted = true; return { success: true, message: "Mouse tracking started", startTime: recordingStartTime }; } catch (error) { - console.error("Failed to start mouse tracking:", error); + console.error("Failed to start mouse tracking:", error); isMouseTrackingActive = false; return { success: false, message: "Failed to start hook", error }; } @@ -329,6 +329,25 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g return { success: false, error: String(error) }; } }); + ipcMain.handle("save-exported-video", async (_, videoData, fileName) => { + try { + const downloadsPath = app.getPath("downloads"); + const videoPath = path.join(downloadsPath, fileName); + await fs.writeFile(videoPath, Buffer.from(videoData)); + return { + success: true, + path: videoPath, + message: "Video exported successfully" + }; + } catch (error) { + console.error("Failed to save exported video:", error); + return { + success: false, + message: "Failed to save exported video", + error: String(error) + }; + } + }); } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index 768f2e9..ded2edb 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -41,5 +41,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { }, openExternalUrl: (url) => { return electron.ipcRenderer.invoke("open-external-url", url); + }, + saveExportedVideo: (videoData, fileName) => { + return electron.ipcRenderer.invoke("save-exported-video", videoData, fileName); } }); diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c985c47..0853fc1 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -37,6 +37,7 @@ interface Window { setRecordingState: (recording: boolean) => Promise onStopRecordingFromTray: (callback: () => void) => () => void openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> + saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }> } } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index bb4fe68..337d0b1 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain, desktopCapturer, BrowserWindow, shell } from 'electron' +import { ipcMain, desktopCapturer, BrowserWindow, shell, app } from 'electron' import { startMouseTracking, stopMouseTracking, getTrackingData } from './mouseTracking' import fs from 'node:fs/promises' import path from 'node:path' @@ -144,4 +144,25 @@ export function registerIpcHandlers( return { success: false, error: String(error) } } }) + + ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => { + try { + const downloadsPath = app.getPath('downloads') + const videoPath = path.join(downloadsPath, fileName) + await fs.writeFile(videoPath, Buffer.from(videoData)) + + return { + success: true, + path: videoPath, + message: 'Video exported successfully' + } + } catch (error) { + console.error('Failed to save exported video:', error) + return { + success: false, + message: 'Failed to save exported video', + error: String(error) + } + } + }) } diff --git a/electron/preload.ts b/electron/preload.ts index 62ecbe5..854c7e3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -41,5 +41,8 @@ contextBridge.exposeInMainWorld('electronAPI', { }, openExternalUrl: (url: string) => { return ipcRenderer.invoke('open-external-url', url) - } + }, + saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { + return ipcRenderer.invoke('save-exported-video', videoData, fileName) + }, }) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4ffcd49..e876651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "clsx": "^2.1.1", "dnd-timeline": "^2.2.0", "lucide-react": "^0.545.0", + "mp4-muxer": "^5.2.2", + "mp4box": "^2.2.0", "pixi.js": "^8.14.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -2830,6 +2832,12 @@ "@types/ms": "*" } }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.17.tgz", + "integrity": "sha512-IwKW5uKL0Zrv5ccUJpjIlqf7ppk2v29l/ZLQxLlwHxljBfnDD9Gxm+hzMkGM0AOAL/21H0pp7cTUYLiiVUGchA==", + "license": "MIT" + }, "node_modules/@types/earcut": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", @@ -2945,6 +2953,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/wicg-file-system-access": { + "version": "2020.9.8", + "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz", + "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -7821,6 +7835,26 @@ "node": ">=10" } }, + "node_modules/mp4-muxer": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz", + "integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==", + "deprecated": "This library is superseded by Mediabunny. Please migrate to it.", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "^0.1.6", + "@types/wicg-file-system-access": "^2020.9.5" + } + }, + "node_modules/mp4box": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.2.0.tgz", + "integrity": "sha512-tE+L7wdhSuwBKZGjUzj03Qzj4lWyOw8pHSPyLnvHTKx92NJGkJls0pcEusUHWEh5gWVBlhdu79STJh4Bubz9mQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.8.1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index ba8898f..104251a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "clsx": "^2.1.1", "dnd-timeline": "^2.2.0", "lucide-react": "^0.545.0", + "mp4-muxer": "^5.2.2", + "mp4box": "^2.2.0", "pixi.js": "^8.14.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/components/video-editor/ExportDialog.tsx b/src/components/video-editor/ExportDialog.tsx new file mode 100644 index 0000000..1f19767 --- /dev/null +++ b/src/components/video-editor/ExportDialog.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from 'react'; +import { X, Download, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import type { ExportProgress } from '@/lib/exporter'; + +interface ExportDialogProps { + isOpen: boolean; + onClose: () => void; + progress: ExportProgress | null; + isExporting: boolean; + error: string | null; + onCancel?: () => void; +} + +function formatTime(seconds: number): string { + if (!isFinite(seconds) || seconds < 0) return '0:00'; + + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +export function ExportDialog({ + isOpen, + onClose, + progress, + isExporting, + error, + onCancel, +}: ExportDialogProps) { + const [showSuccess, setShowSuccess] = useState(false); + + useEffect(() => { + if (!isExporting && progress && progress.percentage >= 100 && !error) { + setShowSuccess(true); + const timer = setTimeout(() => { + setShowSuccess(false); + onClose(); + }, 2000); + return () => clearTimeout(timer); + } + }, [isExporting, progress, error, onClose]); + + if (!isOpen) return null; + + return ( + <> +
+
+
+
+ {showSuccess ? ( + <> +
+ +
+ Export Complete! + + ) : ( + <> + {isExporting ? ( + + ) : ( + + )} + + {error ? 'Export Failed' : isExporting ? 'Exporting Video' : 'Export Video'} + + + )} +
+ {!isExporting && ( + + )} +
+ + {error && ( +
+
+

{error}

+
+
+ )} + + {isExporting && progress && ( +
+
+
+ Progress + {progress.percentage.toFixed(1)}% +
+
+
+
+
+ +
+
+
Frame
+
+ {progress.currentFrame} / {progress.totalFrames} +
+
+
+
Time Remaining
+
+ {formatTime(progress.estimatedTimeRemaining)} +
+
+
+ + {onCancel && ( +
+ +
+ )} +
+ )} + + {showSuccess && ( +
+

Video saved successfully!

+
+ )} + + {!isExporting && !error && !showSuccess && ( +
+

Ready to export your video

+
+ )} +
+ + ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 14fcc4d..6836a97 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -40,6 +40,7 @@ interface SettingsPanelProps { cropRegion?: CropRegion; onCropChange?: (region: CropRegion) => void; videoElement?: HTMLVideoElement | null; + onExport?: () => void; } const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ @@ -50,7 +51,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 5, label: "3.5×" }, ]; -export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement }: SettingsPanelProps) { +export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement, onExport }: SettingsPanelProps) { const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 }); const [gradient, setGradient] = useState(GRADIENTS[0]); const [showCropDropdown, setShowCropDropdown] = useState(false); @@ -241,6 +242,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,