From 7a7db0b2774d052d16be05c7ba2eef8ae07f876a Mon Sep 17 00:00:00 2001 From: Siddharth Date: Thu, 4 Dec 2025 10:22:20 -0700 Subject: [PATCH] revert exporter --- dist-electron/main.js | 490 +++++++++++------------------- dist-electron/preload.mjs | 61 +--- src/lib/exporter/videoExporter.ts | 179 +++++------ 3 files changed, 273 insertions(+), 457 deletions(-) diff --git a/dist-electron/main.js b/dist-electron/main.js index 84b48e9..fe1bfeb 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -1,397 +1,269 @@ -import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; -import fs from "node:fs/promises"; -const __dirname$1 = path.dirname(fileURLToPath(import.meta.url)); -const APP_ROOT = path.join(__dirname$1, ".."); -const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"]; -const RENDERER_DIST$1 = path.join(APP_ROOT, "dist"); -let hudOverlayWindow = null; -ipcMain.on("hud-overlay-hide", () => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.minimize(); - } +import { ipcMain as i, screen as b, BrowserWindow as R, desktopCapturer as V, shell as O, app as d, dialog as S, nativeImage as W, Tray as k, Menu as L } from "electron"; +import { fileURLToPath as E } from "node:url"; +import o from "node:path"; +import P from "node:fs/promises"; +const _ = o.dirname(E(import.meta.url)), U = o.join(_, ".."), m = process.env.VITE_DEV_SERVER_URL, T = o.join(U, "dist"); +let f = null; +i.on("hud-overlay-hide", () => { + f && !f.isDestroyed() && f.minimize(); }); -function createHudOverlayWindow() { - const primaryDisplay = screen.getPrimaryDisplay(); - const { workArea } = primaryDisplay; - const windowWidth = 500; - const windowHeight = 100; - const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); - const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); - const win = new BrowserWindow({ - width: windowWidth, - height: windowHeight, +function C() { + const r = b.getPrimaryDisplay(), { workArea: n } = r, c = 500, w = 100, y = Math.floor(n.x + (n.width - c) / 2), h = Math.floor(n.y + n.height - w - 5), e = new R({ + width: c, + height: w, minWidth: 500, maxWidth: 500, minHeight: 100, maxHeight: 100, - x, - y, - frame: false, - transparent: true, - resizable: false, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, + x: y, + y: h, + frame: !1, + transparent: !0, + resizable: !1, + alwaysOnTop: !0, + skipTaskbar: !0, + hasShadow: !1, webPreferences: { - preload: path.join(__dirname$1, "preload.mjs"), - nodeIntegration: false, - contextIsolation: true, - backgroundThrottling: false + preload: o.join(_, "preload.mjs"), + nodeIntegration: !1, + contextIsolation: !0, + backgroundThrottling: !1 } }); - win.webContents.on("did-finish-load", () => { - win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString()); - }); - hudOverlayWindow = win; - win.on("closed", () => { - if (hudOverlayWindow === win) { - hudOverlayWindow = null; - } - }); - if (VITE_DEV_SERVER_URL$1) { - win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=hud-overlay"); - } else { - win.loadFile(path.join(RENDERER_DIST$1, "index.html"), { - query: { windowType: "hud-overlay" } - }); - } - return win; + return e.webContents.on("did-finish-load", () => { + e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString()); + }), f = e, e.on("closed", () => { + f === e && (f = null); + }), m ? e.loadURL(m + "?windowType=hud-overlay") : e.loadFile(o.join(T, "index.html"), { + query: { windowType: "hud-overlay" } + }), e; } -function createEditorWindow() { - const win = new BrowserWindow({ +function M() { + const r = new R({ width: 1200, height: 800, minWidth: 800, minHeight: 600, titleBarStyle: "hiddenInset", trafficLightPosition: { x: 12, y: 12 }, - transparent: false, - resizable: true, - alwaysOnTop: false, - skipTaskbar: false, + transparent: !1, + resizable: !0, + alwaysOnTop: !1, + skipTaskbar: !1, title: "OpenScreen", backgroundColor: "#000000", webPreferences: { - preload: path.join(__dirname$1, "preload.mjs"), - nodeIntegration: false, - contextIsolation: true, - webSecurity: false, - backgroundThrottling: false + preload: o.join(_, "preload.mjs"), + nodeIntegration: !1, + contextIsolation: !0, + webSecurity: !1, + backgroundThrottling: !1 } }); - win.maximize(); - win.webContents.on("did-finish-load", () => { - win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString()); - }); - if (VITE_DEV_SERVER_URL$1) { - win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=editor"); - } else { - win.loadFile(path.join(RENDERER_DIST$1, "index.html"), { - query: { windowType: "editor" } - }); - } - return win; + return r.maximize(), r.webContents.on("did-finish-load", () => { + r == null || r.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString()); + }), m ? r.loadURL(m + "?windowType=editor") : r.loadFile(o.join(T, "index.html"), { + query: { windowType: "editor" } + }), r; } -function createSourceSelectorWindow() { - const { width, height } = screen.getPrimaryDisplay().workAreaSize; - const win = new BrowserWindow({ +function A() { + const { width: r, height: n } = b.getPrimaryDisplay().workAreaSize, c = new R({ width: 620, height: 420, minHeight: 350, maxHeight: 500, - x: Math.round((width - 620) / 2), - y: Math.round((height - 420) / 2), - frame: false, - resizable: false, - alwaysOnTop: true, - transparent: true, + x: Math.round((r - 620) / 2), + y: Math.round((n - 420) / 2), + frame: !1, + resizable: !1, + alwaysOnTop: !0, + transparent: !0, backgroundColor: "#00000000", webPreferences: { - preload: path.join(__dirname$1, "preload.mjs"), - nodeIntegration: false, - contextIsolation: true + preload: o.join(_, "preload.mjs"), + nodeIntegration: !1, + contextIsolation: !0 } }); - if (VITE_DEV_SERVER_URL$1) { - win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=source-selector"); - } else { - win.loadFile(path.join(RENDERER_DIST$1, "index.html"), { - query: { windowType: "source-selector" } - }); - } - return win; + return m ? c.loadURL(m + "?windowType=source-selector") : c.loadFile(o.join(T, "index.html"), { + query: { windowType: "source-selector" } + }), c; } -let selectedSource = null; -function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) { - ipcMain.handle("get-sources", async (_, opts) => { - const sources = await desktopCapturer.getSources(opts); - return sources.map((source) => ({ - id: source.id, - name: source.name, - display_id: source.display_id, - thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, - appIcon: source.appIcon ? source.appIcon.toDataURL() : null - })); - }); - ipcMain.handle("select-source", (_, source) => { - selectedSource = source; - const sourceSelectorWin = getSourceSelectorWindow(); - if (sourceSelectorWin) { - sourceSelectorWin.close(); - } - return selectedSource; - }); - ipcMain.handle("get-selected-source", () => { - return selectedSource; - }); - ipcMain.handle("open-source-selector", () => { - const sourceSelectorWin = getSourceSelectorWindow(); - if (sourceSelectorWin) { - sourceSelectorWin.focus(); +let v = null; +function H(r, n, c, w, y) { + i.handle("get-sources", async (e, s) => (await V.getSources(s)).map((t) => ({ + id: t.id, + name: t.name, + display_id: t.display_id, + thumbnail: t.thumbnail ? t.thumbnail.toDataURL() : null, + appIcon: t.appIcon ? t.appIcon.toDataURL() : null + }))), i.handle("select-source", (e, s) => { + v = s; + const a = w(); + return a && a.close(), v; + }), i.handle("get-selected-source", () => v), i.handle("open-source-selector", () => { + const e = w(); + if (e) { + e.focus(); return; } - createSourceSelectorWindow2(); - }); - ipcMain.handle("switch-to-editor", () => { - const mainWin = getMainWindow(); - if (mainWin) { - mainWin.close(); - } - createEditorWindow2(); - }); - ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => { + n(); + }), i.handle("switch-to-editor", () => { + const e = c(); + e && e.close(), r(); + }), i.handle("store-recorded-video", async (e, s, a) => { try { - const videoPath = path.join(RECORDINGS_DIR, fileName); - await fs.writeFile(videoPath, Buffer.from(videoData)); - currentVideoPath = videoPath; - return { - success: true, - path: videoPath, + const t = o.join(p, a); + return await P.writeFile(t, Buffer.from(s)), h = t, { + success: !0, + path: t, message: "Video stored successfully" }; - } catch (error) { - console.error("Failed to store video:", error); - return { - success: false, + } catch (t) { + return console.error("Failed to store video:", t), { + success: !1, message: "Failed to store video", - error: String(error) + error: String(t) }; } - }); - ipcMain.handle("get-recorded-video-path", async () => { + }), i.handle("get-recorded-video-path", async () => { try { - const files = await fs.readdir(RECORDINGS_DIR); - const videoFiles = files.filter((file) => file.endsWith(".webm")); - if (videoFiles.length === 0) { - return { success: false, message: "No recorded video found" }; - } - const latestVideo = videoFiles.sort().reverse()[0]; - const videoPath = path.join(RECORDINGS_DIR, latestVideo); - return { success: true, path: videoPath }; - } catch (error) { - console.error("Failed to get video path:", error); - return { success: false, message: "Failed to get video path", error: String(error) }; + const s = (await P.readdir(p)).filter((j) => j.endsWith(".webm")); + if (s.length === 0) + return { success: !1, message: "No recorded video found" }; + const a = s.sort().reverse()[0]; + return { success: !0, path: o.join(p, a) }; + } catch (e) { + return console.error("Failed to get video path:", e), { success: !1, message: "Failed to get video path", error: String(e) }; } - }); - ipcMain.handle("set-recording-state", (_, recording) => { - const source = selectedSource || { name: "Screen" }; - if (onRecordingStateChange) { - onRecordingStateChange(recording, source.name); - } - }); - ipcMain.handle("open-external-url", async (_, url) => { + }), i.handle("set-recording-state", (e, s) => { + y && y(s, (v || { name: "Screen" }).name); + }), i.handle("open-external-url", async (e, s) => { try { - await shell.openExternal(url); - return { success: true }; - } catch (error) { - console.error("Failed to open URL:", error); - return { success: false, error: String(error) }; + return await O.openExternal(s), { success: !0 }; + } catch (a) { + return console.error("Failed to open URL:", a), { success: !1, error: String(a) }; } - }); - ipcMain.handle("get-asset-base-path", () => { + }), i.handle("get-asset-base-path", () => { try { - if (app.isPackaged) { - return path.join(process.resourcesPath, "assets"); - } - return path.join(app.getAppPath(), "public", "assets"); - } catch (err) { - console.error("Failed to resolve asset base path:", err); - return null; + return d.isPackaged ? o.join(process.resourcesPath, "assets") : o.join(d.getAppPath(), "public", "assets"); + } catch (e) { + return console.error("Failed to resolve asset base path:", e), null; } - }); - ipcMain.handle("save-exported-video", async (_, videoData, fileName) => { + }), i.handle("save-exported-video", async (e, s, a) => { try { - const result = await dialog.showSaveDialog({ + const t = await S.showSaveDialog({ title: "Save Exported Video", - defaultPath: path.join(app.getPath("downloads"), fileName), + defaultPath: o.join(d.getPath("downloads"), a), filters: [ { name: "MP4 Video", extensions: ["mp4"] } ], properties: ["createDirectory", "showOverwriteConfirmation"] }); - if (result.canceled || !result.filePath) { - return { - success: false, - cancelled: true, - message: "Export cancelled" - }; - } - await fs.writeFile(result.filePath, Buffer.from(videoData)); - return { - success: true, - path: result.filePath, + return t.canceled || !t.filePath ? { + success: !1, + cancelled: !0, + message: "Export cancelled" + } : (await P.writeFile(t.filePath, Buffer.from(s)), { + success: !0, + path: t.filePath, message: "Video exported successfully" - }; - } catch (error) { - console.error("Failed to save exported video:", error); - return { - success: false, + }); + } catch (t) { + return console.error("Failed to save exported video:", t), { + success: !1, message: "Failed to save exported video", - error: String(error) + error: String(t) }; } - }); - ipcMain.handle("open-video-file-picker", async () => { + }), i.handle("open-video-file-picker", async () => { try { - const result = await dialog.showOpenDialog({ + const e = await S.showOpenDialog({ title: "Select Video File", - defaultPath: RECORDINGS_DIR, + defaultPath: p, filters: [ { name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] }, { name: "All Files", extensions: ["*"] } ], properties: ["openFile"] }); - if (result.canceled || result.filePaths.length === 0) { - return { success: false, cancelled: true }; - } - return { - success: true, - path: result.filePaths[0] + return e.canceled || e.filePaths.length === 0 ? { success: !1, cancelled: !0 } : { + success: !0, + path: e.filePaths[0] }; - } catch (error) { - console.error("Failed to open file picker:", error); - return { - success: false, + } catch (e) { + return console.error("Failed to open file picker:", e), { + success: !1, message: "Failed to open file picker", - error: String(error) + error: String(e) }; } }); - let currentVideoPath = null; - ipcMain.handle("set-current-video-path", (_, path2) => { - currentVideoPath = path2; - return { success: true }; - }); - ipcMain.handle("get-current-video-path", () => { - return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; - }); - ipcMain.handle("clear-current-video-path", () => { - currentVideoPath = null; - return { success: true }; - }); + let h = null; + i.handle("set-current-video-path", (e, s) => (h = s, { success: !0 })), i.handle("get-current-video-path", () => h ? { success: !0, path: h } : { success: !1 }), i.handle("clear-current-video-path", () => (h = null, { success: !0 })); } -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); -async function ensureRecordingsDir() { +const z = o.dirname(E(import.meta.url)), p = o.join(d.getPath("userData"), "recordings"); +async function N() { try { - await fs.mkdir(RECORDINGS_DIR, { recursive: true }); - console.log("RECORDINGS_DIR:", RECORDINGS_DIR); - console.log("User Data Path:", app.getPath("userData")); - } catch (error) { - console.error("Failed to create recordings directory:", error); + await P.mkdir(p, { recursive: !0 }), console.log("RECORDINGS_DIR:", p), console.log("User Data Path:", d.getPath("userData")); + } catch (r) { + console.error("Failed to create recordings directory:", r); } } -process.env.APP_ROOT = path.join(__dirname, ".."); -const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; -const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron"); -const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist"); -process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST; -let mainWindow = null; -let sourceSelectorWindow = null; -let tray = null; -let selectedSourceName = ""; -function createWindow() { - mainWindow = createHudOverlayWindow(); +process.env.APP_ROOT = o.join(z, ".."); +const B = process.env.VITE_DEV_SERVER_URL, Y = o.join(process.env.APP_ROOT, "dist-electron"), D = o.join(process.env.APP_ROOT, "dist"); +process.env.VITE_PUBLIC = B ? o.join(process.env.APP_ROOT, "public") : D; +let l = null, g = null, u = null, x = ""; +function I() { + l = C(); } -function createTray() { - const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png"); - let icon = nativeImage.createFromPath(iconPath); - icon = icon.resize({ width: 24, height: 24, quality: "best" }); - tray = new Tray(icon); - updateTrayMenu(); +function q() { + const r = o.join(process.env.VITE_PUBLIC || D, "rec-button.png"); + let n = W.createFromPath(r); + n = n.resize({ width: 24, height: 24, quality: "best" }), u = new k(n), F(); } -function updateTrayMenu() { - if (!tray) return; - const menuTemplate = [ +function F() { + if (!u) return; + const r = [ { label: "Stop Recording", click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("stop-recording-from-tray"); - } + l && !l.isDestroyed() && l.webContents.send("stop-recording-from-tray"); } } - ]; - const contextMenu = Menu.buildFromTemplate(menuTemplate); - tray.setContextMenu(contextMenu); - tray.setToolTip(`Recording: ${selectedSourceName}`); + ], n = L.buildFromTemplate(r); + u.setContextMenu(n), u.setToolTip(`Recording: ${x}`); } -function createEditorWindowWrapper() { - if (mainWindow) { - mainWindow.close(); - mainWindow = null; - } - mainWindow = createEditorWindow(); +function $() { + l && (l.close(), l = null), l = M(); } -function createSourceSelectorWindowWrapper() { - sourceSelectorWindow = createSourceSelectorWindow(); - sourceSelectorWindow.on("closed", () => { - sourceSelectorWindow = null; - }); - return sourceSelectorWindow; +function G() { + return g = A(), g.on("closed", () => { + g = null; + }), g; } -app.on("window-all-closed", () => { +d.on("window-all-closed", () => { }); -app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } +d.on("activate", () => { + R.getAllWindows().length === 0 && I(); }); -app.whenReady().then(async () => { - const { ipcMain: ipcMain2 } = await import("electron"); - ipcMain2.on("hud-overlay-close", () => { - if (process.platform === "darwin") { - app.quit(); +d.whenReady().then(async () => { + const { ipcMain: r } = await import("electron"); + r.on("hud-overlay-close", () => { + process.platform === "darwin" && d.quit(); + }), await N(), H( + $, + G, + () => l, + () => g, + (n, c) => { + x = c, n ? (u || q(), F()) : (u && (u.destroy(), u = null), l && l.restore()); } - }); - await ensureRecordingsDir(); - registerIpcHandlers( - createEditorWindowWrapper, - createSourceSelectorWindowWrapper, - () => mainWindow, - () => sourceSelectorWindow, - (recording, sourceName) => { - selectedSourceName = sourceName; - if (recording) { - if (!tray) createTray(); - updateTrayMenu(); - } else { - if (tray) { - tray.destroy(); - tray = null; - } - if (mainWindow) mainWindow.restore(); - } - } - ); - createWindow(); + ), I(); }); export { - MAIN_DIST, - RECORDINGS_DIR, - RENDERER_DIST, - VITE_DEV_SERVER_URL + Y as MAIN_DIST, + p as RECORDINGS_DIR, + D as RENDERER_DIST, + B as VITE_DEV_SERVER_URL }; diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index 42f5816..5b8cb40 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -1,60 +1 @@ -"use strict"; -const electron = require("electron"); -electron.contextBridge.exposeInMainWorld("electronAPI", { - hudOverlayHide: () => { - electron.ipcRenderer.send("hud-overlay-hide"); - }, - hudOverlayClose: () => { - electron.ipcRenderer.send("hud-overlay-close"); - }, - getAssetBasePath: async () => { - return await electron.ipcRenderer.invoke("get-asset-base-path"); - }, - getSources: async (opts) => { - return await electron.ipcRenderer.invoke("get-sources", opts); - }, - switchToEditor: () => { - return electron.ipcRenderer.invoke("switch-to-editor"); - }, - openSourceSelector: () => { - return electron.ipcRenderer.invoke("open-source-selector"); - }, - selectSource: (source) => { - return electron.ipcRenderer.invoke("select-source", source); - }, - getSelectedSource: () => { - return electron.ipcRenderer.invoke("get-selected-source"); - }, - storeRecordedVideo: (videoData, fileName) => { - return electron.ipcRenderer.invoke("store-recorded-video", videoData, fileName); - }, - getRecordedVideoPath: () => { - return electron.ipcRenderer.invoke("get-recorded-video-path"); - }, - setRecordingState: (recording) => { - return electron.ipcRenderer.invoke("set-recording-state", recording); - }, - onStopRecordingFromTray: (callback) => { - const listener = () => callback(); - electron.ipcRenderer.on("stop-recording-from-tray", listener); - return () => electron.ipcRenderer.removeListener("stop-recording-from-tray", listener); - }, - openExternalUrl: (url) => { - return electron.ipcRenderer.invoke("open-external-url", url); - }, - saveExportedVideo: (videoData, fileName) => { - return electron.ipcRenderer.invoke("save-exported-video", videoData, fileName); - }, - openVideoFilePicker: () => { - return electron.ipcRenderer.invoke("open-video-file-picker"); - }, - setCurrentVideoPath: (path) => { - return electron.ipcRenderer.invoke("set-current-video-path", path); - }, - getCurrentVideoPath: () => { - return electron.ipcRenderer.invoke("get-current-video-path"); - }, - clearCurrentVideoPath: () => { - return electron.ipcRenderer.invoke("clear-current-video-path"); - } -}); +"use strict";const e=require("electron");e.contextBridge.exposeInMainWorld("electronAPI",{hudOverlayHide:()=>{e.ipcRenderer.send("hud-overlay-hide")},hudOverlayClose:()=>{e.ipcRenderer.send("hud-overlay-close")},getAssetBasePath:async()=>await e.ipcRenderer.invoke("get-asset-base-path"),getSources:async r=>await e.ipcRenderer.invoke("get-sources",r),switchToEditor:()=>e.ipcRenderer.invoke("switch-to-editor"),openSourceSelector:()=>e.ipcRenderer.invoke("open-source-selector"),selectSource:r=>e.ipcRenderer.invoke("select-source",r),getSelectedSource:()=>e.ipcRenderer.invoke("get-selected-source"),storeRecordedVideo:(r,t)=>e.ipcRenderer.invoke("store-recorded-video",r,t),getRecordedVideoPath:()=>e.ipcRenderer.invoke("get-recorded-video-path"),setRecordingState:r=>e.ipcRenderer.invoke("set-recording-state",r),onStopRecordingFromTray:r=>{const t=()=>r();return e.ipcRenderer.on("stop-recording-from-tray",t),()=>e.ipcRenderer.removeListener("stop-recording-from-tray",t)},openExternalUrl:r=>e.ipcRenderer.invoke("open-external-url",r),saveExportedVideo:(r,t)=>e.ipcRenderer.invoke("save-exported-video",r,t),openVideoFilePicker:()=>e.ipcRenderer.invoke("open-video-file-picker"),setCurrentVideoPath:r=>e.ipcRenderer.invoke("set-current-video-path",r),getCurrentVideoPath:()=>e.ipcRenderer.invoke("get-current-video-path"),clearCurrentVideoPath:()=>e.ipcRenderer.invoke("clear-current-video-path")}); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 9536e9f..4d07414 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -31,9 +31,11 @@ export class VideoExporter { private muxer: VideoMuxer | null = null; private cancelled = false; private encodeQueue = 0; + // Increased queue size for better throughput with hardware encoding private readonly MAX_ENCODE_QUEUE = 120; private videoDescription: Uint8Array | undefined; private videoColorSpace: VideoColorSpaceInit | undefined; + // Track muxing promises for parallel processing private muxingPromises: Promise[] = []; private chunkCount = 0; @@ -54,20 +56,20 @@ export class VideoExporter { const trimRegions = this.config.trimRegions || []; // Sort trim regions by start time const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs); - + let sourceTimeMs = effectiveTimeMs; - + for (const trim of sortedTrims) { // If the source time hasn't reached this trim region yet, we're done if (sourceTimeMs < trim.startMs) { break; } - + // Add the duration of this trim region to the source time const trimDuration = trim.endMs - trim.startMs; sourceTimeMs += trimDuration; } - + return sourceTimeMs; } @@ -75,12 +77,12 @@ export class VideoExporter { try { this.cleanup(); this.cancelled = false; - - const exportStartTime = performance.now(); + // Initialize decoder and load video this.decoder = new VideoFileDecoder(); const videoInfo = await this.decoder.loadVideo(this.config.videoUrl); + // Initialize frame renderer this.renderer = new FrameRenderer({ width: this.config.width, height: this.config.height, @@ -101,82 +103,74 @@ export class VideoExporter { }); await this.renderer.initialize(); + // Initialize video encoder await this.initializeEncoder(); + + // Initialize muxer this.muxer = new VideoMuxer(this.config, false); await this.muxer.initialize(); + // Get the video element for frame extraction const videoElement = this.decoder.getVideoElement(); if (!videoElement) { throw new Error('Video element not available'); } - // Calculate frame count after trimming + // Calculate effective duration and frame count (excluding trim regions) const effectiveDuration = this.getEffectiveDuration(videoInfo.duration); const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); - const frameDuration = 1_000_000 / this.config.frameRate; + + console.log('[VideoExporter] Original duration:', videoInfo.duration, 's'); + console.log('[VideoExporter] Effective duration:', effectiveDuration, 's'); + console.log('[VideoExporter] Total frames to export:', totalFrames); + + // Process frames continuously without batching delays + const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds + let frameIndex = 0; const timeStep = 1 / this.config.frameRate; - videoElement.muted = true; - if (videoElement.readyState < 2) { - await new Promise(r => { - videoElement.addEventListener('loadeddata', () => r(), { once: true }); - }); - } + while (frameIndex < totalFrames && !this.cancelled) { + const i = frameIndex; + const timestamp = i * frameDuration; - // Pipeline: Decode 10 frames ahead to overlap decode/render/encode operations - const DECODE_AHEAD = 10; - const frameQueue: { frame: VideoFrame; timestamp: number; sourceTimestamp: number }[] = []; - - // Decode a single frame from source video - const decodeFrame = async (idx: number) => { - if (idx >= totalFrames) return; - - const timestamp = idx * frameDuration; - const effectiveTimeMs = (idx * timeStep) * 1000; + // Map effective time to source time (accounting for trim regions) + const effectiveTimeMs = (i * timeStep) * 1000; const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs); const videoTime = sourceTimeMs / 1000; - const sourceTimestamp = sourceTimeMs * 1000; - - // Seek to frame position + + // Seek if needed or wait for first frame to be ready const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001; - if (needsSeek || idx === 0) { + + if (needsSeek) { + // Attach listener BEFORE setting currentTime to avoid race condition + const seekedPromise = new Promise(resolve => { + videoElement.addEventListener('seeked', () => resolve(), { once: true }); + }); + videoElement.currentTime = videoTime; - await new Promise(r => { - videoElement.addEventListener('seeked', () => r(), { once: true }); + await seekedPromise; + } else if (i === 0) { + // Only for the very first frame, wait for it to be ready + await new Promise(resolve => { + videoElement.requestVideoFrameCallback(() => resolve()); }); } - // Create VideoFrame from current video element position - const videoFrame = new VideoFrame(videoElement, { timestamp }); - frameQueue.push({ frame: videoFrame, timestamp, sourceTimestamp }); - }; - - // Pre-decode first batch of frames - for (let i = 0; i < Math.min(DECODE_AHEAD, totalFrames); i++) { - await decodeFrame(i); - } - - let frameIndex = 0; - let decodeIndex = DECODE_AHEAD; - - // Main processing loop - while (frameIndex < totalFrames && !this.cancelled) { - // Wait for decoded frame to be available - while (frameQueue.length === 0 && frameIndex < totalFrames) { - await new Promise(r => setTimeout(r, 1)); - } - - if (frameQueue.length === 0) break; - - const { frame: videoFrame, timestamp, sourceTimestamp } = frameQueue.shift()!; + // Create a VideoFrame from the video element (on GPU!) + const videoFrame = new VideoFrame(videoElement, { + timestamp, + }); - // Render frame with effects using PixiJS + // Render the frame with all effects using source timestamp + const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds await this.renderer!.renderFrame(videoFrame, sourceTimestamp); + videoFrame.close(); - // Create VideoFrame directly from canvas (GPU-level) const canvas = this.renderer!.getCanvas(); - // @ts-ignore + + // Create VideoFrame from canvas on GPU without reading pixels + // @ts-ignore - colorSpace not in TypeScript definitions but works at runtime const exportFrame = new VideoFrame(canvas, { timestamp, duration: frameDuration, @@ -188,26 +182,23 @@ export class VideoExporter { }, }); - // Wait if encoder queue is full + // Check encoder queue before encoding to keep it full while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) { - await new Promise(r => setTimeout(r, 0)); + await new Promise(resolve => setTimeout(resolve, 0)); } - // Encode frame using hardware acceleration if (this.encoder && this.encoder.state === 'configured') { this.encodeQueue++; - this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 }); + this.encoder.encode(exportFrame, { keyFrame: i % 150 === 0 }); + } else { + console.warn(`[Frame ${i}] Encoder not ready! State: ${this.encoder?.state}`); } - + exportFrame.close(); + frameIndex++; - - // Decode next frame in parallel while we process current frame - if (decodeIndex < totalFrames) { - decodeFrame(decodeIndex++).catch(e => console.error('[VideoExporter] Decode error:', e)); - } - + // Update progress if (this.config.onProgress) { this.config.onProgress({ currentFrame: frameIndex, @@ -222,18 +213,20 @@ export class VideoExporter { return { success: false, error: 'Export cancelled' }; } + // Finalize encoding if (this.encoder && this.encoder.state === 'configured') { await this.encoder.flush(); } + + // Wait for all muxing operations to complete await Promise.all(this.muxingPromises); + + // Finalize muxer and get output blob const blob = await this.muxer!.finalize(); - - const totalTime = performance.now() - exportStartTime; - console.log(`[VideoExporter] Export complete in ${(totalTime/1000).toFixed(2)}s (${totalFrames} frames)`); return { success: true, blob }; } catch (error) { - console.error('[VideoExporter] Export error:', error); + console.error('Export error:', error); return { success: false, error: error instanceof Error ? error.message : String(error), @@ -247,33 +240,36 @@ export class VideoExporter { this.encodeQueue = 0; this.muxingPromises = []; this.chunkCount = 0; + let videoDescription: Uint8Array | undefined; - // Create VideoEncoder with hardware acceleration this.encoder = new VideoEncoder({ output: (chunk, meta) => { - // Capture codec description and color space from first chunk - if (meta?.decoderConfig?.description && !this.videoDescription) { + // Capture decoder config metadata from encoder output + if (meta?.decoderConfig?.description && !videoDescription) { const desc = meta.decoderConfig.description; - this.videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any)); + videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any)); + this.videoDescription = videoDescription; } + // Capture colorSpace from encoder metadata if provided if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) { this.videoColorSpace = meta.decoderConfig.colorSpace; } - + + // Stream chunk to muxer immediately (parallel processing) const isFirstChunk = this.chunkCount === 0; this.chunkCount++; - - // Send encoded chunk to muxer + const muxingPromise = (async () => { try { if (isFirstChunk && this.videoDescription) { + // Add decoder config for the first chunk const colorSpace = this.videoColorSpace || { primaries: 'bt709', transfer: 'iec61966-2-1', matrix: 'rgb', fullRange: true, }; - + const metadata: EncodedVideoChunkMetadata = { decoderConfig: { codec: this.config.codec || 'avc1.640033', @@ -283,27 +279,28 @@ export class VideoExporter { colorSpace, }, }; - + await this.muxer!.addVideoChunk(chunk, metadata); } else { await this.muxer!.addVideoChunk(chunk, meta); } } catch (error) { - console.error('[VideoExporter] Muxing error:', error); + console.error('Muxing error:', error); } })(); - + this.muxingPromises.push(muxingPromise); this.encodeQueue--; }, error: (error) => { console.error('[VideoExporter] Encoder error:', error); + // Stop export encoding failed this.cancelled = true; }, }); - // Configure encoder with hardware acceleration const codec = this.config.codec || 'avc1.640033'; + const encoderConfig: VideoEncoderConfig = { codec, width: this.config.width, @@ -315,17 +312,23 @@ export class VideoExporter { hardwareAcceleration: 'prefer-hardware', }; - const support = await VideoEncoder.isConfigSupported(encoderConfig); - - if (support.supported) { + // Check hardware support first + const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig); + + if (hardwareSupport.supported) { + // Use hardware encoding + console.log('[VideoExporter] Using hardware acceleration'); this.encoder.configure(encoderConfig); } else { - // Fallback to software encoding + // Fall back to software encoding + console.log('[VideoExporter] Hardware not supported, using software encoding'); encoderConfig.hardwareAcceleration = 'prefer-software'; + const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig); if (!softwareSupport.supported) { - throw new Error('Video encoding not supported'); + throw new Error('Video encoding not supported on this system'); } + this.encoder.configure(encoderConfig); } }