revert exporter

This commit is contained in:
Siddharth
2025-12-04 10:22:20 -07:00
parent 3a4ec9c470
commit 7a7db0b277
3 changed files with 273 additions and 457 deletions
+181 -309
View File
@@ -1,397 +1,269 @@
import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron"; 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 } from "node:url"; import { fileURLToPath as E } from "node:url";
import path from "node:path"; import o from "node:path";
import fs from "node:fs/promises"; import P from "node:fs/promises";
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url)); const _ = o.dirname(E(import.meta.url)), U = o.join(_, ".."), m = process.env.VITE_DEV_SERVER_URL, T = o.join(U, "dist");
const APP_ROOT = path.join(__dirname$1, ".."); let f = null;
const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"]; i.on("hud-overlay-hide", () => {
const RENDERER_DIST$1 = path.join(APP_ROOT, "dist"); f && !f.isDestroyed() && f.minimize();
let hudOverlayWindow = null;
ipcMain.on("hud-overlay-hide", () => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
}
}); });
function createHudOverlayWindow() { function C() {
const primaryDisplay = screen.getPrimaryDisplay(); 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({
const { workArea } = primaryDisplay; width: c,
const windowWidth = 500; height: w,
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,
minWidth: 500, minWidth: 500,
maxWidth: 500, maxWidth: 500,
minHeight: 100, minHeight: 100,
maxHeight: 100, maxHeight: 100,
x, x: y,
y, y: h,
frame: false, frame: !1,
transparent: true, transparent: !0,
resizable: false, resizable: !1,
alwaysOnTop: true, alwaysOnTop: !0,
skipTaskbar: true, skipTaskbar: !0,
hasShadow: false, hasShadow: !1,
webPreferences: { webPreferences: {
preload: path.join(__dirname$1, "preload.mjs"), preload: o.join(_, "preload.mjs"),
nodeIntegration: false, nodeIntegration: !1,
contextIsolation: true, contextIsolation: !0,
backgroundThrottling: false backgroundThrottling: !1
} }
}); });
win.webContents.on("did-finish-load", () => { return e.webContents.on("did-finish-load", () => {
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString()); e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
}); }), f = e, e.on("closed", () => {
hudOverlayWindow = win; f === e && (f = null);
win.on("closed", () => { }), m ? e.loadURL(m + "?windowType=hud-overlay") : e.loadFile(o.join(T, "index.html"), {
if (hudOverlayWindow === win) { query: { windowType: "hud-overlay" }
hudOverlayWindow = null; }), e;
}
});
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;
} }
function createEditorWindow() { function M() {
const win = new BrowserWindow({ const r = new R({
width: 1200, width: 1200,
height: 800, height: 800,
minWidth: 800, minWidth: 800,
minHeight: 600, minHeight: 600,
titleBarStyle: "hiddenInset", titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 }, trafficLightPosition: { x: 12, y: 12 },
transparent: false, transparent: !1,
resizable: true, resizable: !0,
alwaysOnTop: false, alwaysOnTop: !1,
skipTaskbar: false, skipTaskbar: !1,
title: "OpenScreen", title: "OpenScreen",
backgroundColor: "#000000", backgroundColor: "#000000",
webPreferences: { webPreferences: {
preload: path.join(__dirname$1, "preload.mjs"), preload: o.join(_, "preload.mjs"),
nodeIntegration: false, nodeIntegration: !1,
contextIsolation: true, contextIsolation: !0,
webSecurity: false, webSecurity: !1,
backgroundThrottling: false backgroundThrottling: !1
} }
}); });
win.maximize(); return r.maximize(), r.webContents.on("did-finish-load", () => {
win.webContents.on("did-finish-load", () => { r == null || r.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
win == null ? void 0 : win.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" }
if (VITE_DEV_SERVER_URL$1) { }), r;
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=editor");
} else {
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
query: { windowType: "editor" }
});
}
return win;
} }
function createSourceSelectorWindow() { function A() {
const { width, height } = screen.getPrimaryDisplay().workAreaSize; const { width: r, height: n } = b.getPrimaryDisplay().workAreaSize, c = new R({
const win = new BrowserWindow({
width: 620, width: 620,
height: 420, height: 420,
minHeight: 350, minHeight: 350,
maxHeight: 500, maxHeight: 500,
x: Math.round((width - 620) / 2), x: Math.round((r - 620) / 2),
y: Math.round((height - 420) / 2), y: Math.round((n - 420) / 2),
frame: false, frame: !1,
resizable: false, resizable: !1,
alwaysOnTop: true, alwaysOnTop: !0,
transparent: true, transparent: !0,
backgroundColor: "#00000000", backgroundColor: "#00000000",
webPreferences: { webPreferences: {
preload: path.join(__dirname$1, "preload.mjs"), preload: o.join(_, "preload.mjs"),
nodeIntegration: false, nodeIntegration: !1,
contextIsolation: true contextIsolation: !0
} }
}); });
if (VITE_DEV_SERVER_URL$1) { return m ? c.loadURL(m + "?windowType=source-selector") : c.loadFile(o.join(T, "index.html"), {
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=source-selector"); query: { windowType: "source-selector" }
} else { }), c;
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
query: { windowType: "source-selector" }
});
}
return win;
} }
let selectedSource = null; let v = null;
function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) { function H(r, n, c, w, y) {
ipcMain.handle("get-sources", async (_, opts) => { i.handle("get-sources", async (e, s) => (await V.getSources(s)).map((t) => ({
const sources = await desktopCapturer.getSources(opts); id: t.id,
return sources.map((source) => ({ name: t.name,
id: source.id, display_id: t.display_id,
name: source.name, thumbnail: t.thumbnail ? t.thumbnail.toDataURL() : null,
display_id: source.display_id, appIcon: t.appIcon ? t.appIcon.toDataURL() : null
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, }))), i.handle("select-source", (e, s) => {
appIcon: source.appIcon ? source.appIcon.toDataURL() : null v = s;
})); const a = w();
}); return a && a.close(), v;
ipcMain.handle("select-source", (_, source) => { }), i.handle("get-selected-source", () => v), i.handle("open-source-selector", () => {
selectedSource = source; const e = w();
const sourceSelectorWin = getSourceSelectorWindow(); if (e) {
if (sourceSelectorWin) { e.focus();
sourceSelectorWin.close();
}
return selectedSource;
});
ipcMain.handle("get-selected-source", () => {
return selectedSource;
});
ipcMain.handle("open-source-selector", () => {
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.focus();
return; return;
} }
createSourceSelectorWindow2(); n();
}); }), i.handle("switch-to-editor", () => {
ipcMain.handle("switch-to-editor", () => { const e = c();
const mainWin = getMainWindow(); e && e.close(), r();
if (mainWin) { }), i.handle("store-recorded-video", async (e, s, a) => {
mainWin.close();
}
createEditorWindow2();
});
ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => {
try { try {
const videoPath = path.join(RECORDINGS_DIR, fileName); const t = o.join(p, a);
await fs.writeFile(videoPath, Buffer.from(videoData)); return await P.writeFile(t, Buffer.from(s)), h = t, {
currentVideoPath = videoPath; success: !0,
return { path: t,
success: true,
path: videoPath,
message: "Video stored successfully" message: "Video stored successfully"
}; };
} catch (error) { } catch (t) {
console.error("Failed to store video:", error); return console.error("Failed to store video:", t), {
return { success: !1,
success: false,
message: "Failed to store video", message: "Failed to store video",
error: String(error) error: String(t)
}; };
} }
}); }), i.handle("get-recorded-video-path", async () => {
ipcMain.handle("get-recorded-video-path", async () => {
try { try {
const files = await fs.readdir(RECORDINGS_DIR); const s = (await P.readdir(p)).filter((j) => j.endsWith(".webm"));
const videoFiles = files.filter((file) => file.endsWith(".webm")); if (s.length === 0)
if (videoFiles.length === 0) { return { success: !1, message: "No recorded video found" };
return { success: false, message: "No recorded video found" }; const a = s.sort().reverse()[0];
} return { success: !0, path: o.join(p, a) };
const latestVideo = videoFiles.sort().reverse()[0]; } catch (e) {
const videoPath = path.join(RECORDINGS_DIR, latestVideo); return console.error("Failed to get video path:", e), { success: !1, message: "Failed to get video path", error: String(e) };
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) };
} }
}); }), i.handle("set-recording-state", (e, s) => {
ipcMain.handle("set-recording-state", (_, recording) => { y && y(s, (v || { name: "Screen" }).name);
const source = selectedSource || { name: "Screen" }; }), i.handle("open-external-url", async (e, s) => {
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
}
});
ipcMain.handle("open-external-url", async (_, url) => {
try { try {
await shell.openExternal(url); return await O.openExternal(s), { success: !0 };
return { success: true }; } catch (a) {
} catch (error) { return console.error("Failed to open URL:", a), { success: !1, error: String(a) };
console.error("Failed to open URL:", error);
return { success: false, error: String(error) };
} }
}); }), i.handle("get-asset-base-path", () => {
ipcMain.handle("get-asset-base-path", () => {
try { try {
if (app.isPackaged) { return d.isPackaged ? o.join(process.resourcesPath, "assets") : o.join(d.getAppPath(), "public", "assets");
return path.join(process.resourcesPath, "assets"); } catch (e) {
} return console.error("Failed to resolve asset base path:", e), null;
return path.join(app.getAppPath(), "public", "assets");
} catch (err) {
console.error("Failed to resolve asset base path:", err);
return null;
} }
}); }), i.handle("save-exported-video", async (e, s, a) => {
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
try { try {
const result = await dialog.showSaveDialog({ const t = await S.showSaveDialog({
title: "Save Exported Video", title: "Save Exported Video",
defaultPath: path.join(app.getPath("downloads"), fileName), defaultPath: o.join(d.getPath("downloads"), a),
filters: [ filters: [
{ name: "MP4 Video", extensions: ["mp4"] } { name: "MP4 Video", extensions: ["mp4"] }
], ],
properties: ["createDirectory", "showOverwriteConfirmation"] properties: ["createDirectory", "showOverwriteConfirmation"]
}); });
if (result.canceled || !result.filePath) { return t.canceled || !t.filePath ? {
return { success: !1,
success: false, cancelled: !0,
cancelled: true, message: "Export cancelled"
message: "Export cancelled" } : (await P.writeFile(t.filePath, Buffer.from(s)), {
}; success: !0,
} path: t.filePath,
await fs.writeFile(result.filePath, Buffer.from(videoData));
return {
success: true,
path: result.filePath,
message: "Video exported successfully" message: "Video exported successfully"
}; });
} catch (error) { } catch (t) {
console.error("Failed to save exported video:", error); return console.error("Failed to save exported video:", t), {
return { success: !1,
success: false,
message: "Failed to save exported video", message: "Failed to save exported video",
error: String(error) error: String(t)
}; };
} }
}); }), i.handle("open-video-file-picker", async () => {
ipcMain.handle("open-video-file-picker", async () => {
try { try {
const result = await dialog.showOpenDialog({ const e = await S.showOpenDialog({
title: "Select Video File", title: "Select Video File",
defaultPath: RECORDINGS_DIR, defaultPath: p,
filters: [ filters: [
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] }, { name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
{ name: "All Files", extensions: ["*"] } { name: "All Files", extensions: ["*"] }
], ],
properties: ["openFile"] properties: ["openFile"]
}); });
if (result.canceled || result.filePaths.length === 0) { return e.canceled || e.filePaths.length === 0 ? { success: !1, cancelled: !0 } : {
return { success: false, cancelled: true }; success: !0,
} path: e.filePaths[0]
return {
success: true,
path: result.filePaths[0]
}; };
} catch (error) { } catch (e) {
console.error("Failed to open file picker:", error); return console.error("Failed to open file picker:", e), {
return { success: !1,
success: false,
message: "Failed to open file picker", message: "Failed to open file picker",
error: String(error) error: String(e)
}; };
} }
}); });
let currentVideoPath = null; let h = null;
ipcMain.handle("set-current-video-path", (_, path2) => { 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 }));
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 };
});
} }
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const z = o.dirname(E(import.meta.url)), p = o.join(d.getPath("userData"), "recordings");
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); async function N() {
async function ensureRecordingsDir() {
try { try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true }); await P.mkdir(p, { recursive: !0 }), console.log("RECORDINGS_DIR:", p), console.log("User Data Path:", d.getPath("userData"));
console.log("RECORDINGS_DIR:", RECORDINGS_DIR); } catch (r) {
console.log("User Data Path:", app.getPath("userData")); console.error("Failed to create recordings directory:", r);
} catch (error) {
console.error("Failed to create recordings directory:", error);
} }
} }
process.env.APP_ROOT = path.join(__dirname, ".."); process.env.APP_ROOT = o.join(z, "..");
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; 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");
const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron"); process.env.VITE_PUBLIC = B ? o.join(process.env.APP_ROOT, "public") : D;
const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist"); let l = null, g = null, u = null, x = "";
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST; function I() {
let mainWindow = null; l = C();
let sourceSelectorWindow = null;
let tray = null;
let selectedSourceName = "";
function createWindow() {
mainWindow = createHudOverlayWindow();
} }
function createTray() { function q() {
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png"); const r = o.join(process.env.VITE_PUBLIC || D, "rec-button.png");
let icon = nativeImage.createFromPath(iconPath); let n = W.createFromPath(r);
icon = icon.resize({ width: 24, height: 24, quality: "best" }); n = n.resize({ width: 24, height: 24, quality: "best" }), u = new k(n), F();
tray = new Tray(icon);
updateTrayMenu();
} }
function updateTrayMenu() { function F() {
if (!tray) return; if (!u) return;
const menuTemplate = [ const r = [
{ {
label: "Stop Recording", label: "Stop Recording",
click: () => { click: () => {
if (mainWindow && !mainWindow.isDestroyed()) { l && !l.isDestroyed() && l.webContents.send("stop-recording-from-tray");
mainWindow.webContents.send("stop-recording-from-tray");
}
} }
} }
]; ], n = L.buildFromTemplate(r);
const contextMenu = Menu.buildFromTemplate(menuTemplate); u.setContextMenu(n), u.setToolTip(`Recording: ${x}`);
tray.setContextMenu(contextMenu);
tray.setToolTip(`Recording: ${selectedSourceName}`);
} }
function createEditorWindowWrapper() { function $() {
if (mainWindow) { l && (l.close(), l = null), l = M();
mainWindow.close();
mainWindow = null;
}
mainWindow = createEditorWindow();
} }
function createSourceSelectorWindowWrapper() { function G() {
sourceSelectorWindow = createSourceSelectorWindow(); return g = A(), g.on("closed", () => {
sourceSelectorWindow.on("closed", () => { g = null;
sourceSelectorWindow = null; }), g;
});
return sourceSelectorWindow;
} }
app.on("window-all-closed", () => { d.on("window-all-closed", () => {
}); });
app.on("activate", () => { d.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { R.getAllWindows().length === 0 && I();
createWindow();
}
}); });
app.whenReady().then(async () => { d.whenReady().then(async () => {
const { ipcMain: ipcMain2 } = await import("electron"); const { ipcMain: r } = await import("electron");
ipcMain2.on("hud-overlay-close", () => { r.on("hud-overlay-close", () => {
if (process.platform === "darwin") { process.platform === "darwin" && d.quit();
app.quit(); }), await N(), H(
$,
G,
() => l,
() => g,
(n, c) => {
x = c, n ? (u || q(), F()) : (u && (u.destroy(), u = null), l && l.restore());
} }
}); ), I();
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();
}); });
export { export {
MAIN_DIST, Y as MAIN_DIST,
RECORDINGS_DIR, p as RECORDINGS_DIR,
RENDERER_DIST, D as RENDERER_DIST,
VITE_DEV_SERVER_URL B as VITE_DEV_SERVER_URL
}; };
+1 -60
View File
@@ -1,60 +1 @@
"use strict"; "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")});
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");
}
});
+80 -77
View File
@@ -31,9 +31,11 @@ export class VideoExporter {
private muxer: VideoMuxer | null = null; private muxer: VideoMuxer | null = null;
private cancelled = false; private cancelled = false;
private encodeQueue = 0; private encodeQueue = 0;
// Increased queue size for better throughput with hardware encoding
private readonly MAX_ENCODE_QUEUE = 120; private readonly MAX_ENCODE_QUEUE = 120;
private videoDescription: Uint8Array | undefined; private videoDescription: Uint8Array | undefined;
private videoColorSpace: VideoColorSpaceInit | undefined; private videoColorSpace: VideoColorSpaceInit | undefined;
// Track muxing promises for parallel processing
private muxingPromises: Promise<void>[] = []; private muxingPromises: Promise<void>[] = [];
private chunkCount = 0; private chunkCount = 0;
@@ -76,11 +78,11 @@ export class VideoExporter {
this.cleanup(); this.cleanup();
this.cancelled = false; this.cancelled = false;
const exportStartTime = performance.now(); // Initialize decoder and load video
this.decoder = new VideoFileDecoder(); this.decoder = new VideoFileDecoder();
const videoInfo = await this.decoder.loadVideo(this.config.videoUrl); const videoInfo = await this.decoder.loadVideo(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({ this.renderer = new FrameRenderer({
width: this.config.width, width: this.config.width,
height: this.config.height, height: this.config.height,
@@ -101,82 +103,74 @@ export class VideoExporter {
}); });
await this.renderer.initialize(); await this.renderer.initialize();
// Initialize video encoder
await this.initializeEncoder(); await this.initializeEncoder();
// Initialize muxer
this.muxer = new VideoMuxer(this.config, false); this.muxer = new VideoMuxer(this.config, false);
await this.muxer.initialize(); await this.muxer.initialize();
// Get the video element for frame extraction
const videoElement = this.decoder.getVideoElement(); const videoElement = this.decoder.getVideoElement();
if (!videoElement) { if (!videoElement) {
throw new Error('Video element not available'); 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 effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); 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; const timeStep = 1 / this.config.frameRate;
videoElement.muted = true; while (frameIndex < totalFrames && !this.cancelled) {
if (videoElement.readyState < 2) { const i = frameIndex;
await new Promise<void>(r => { const timestamp = i * frameDuration;
videoElement.addEventListener('loadeddata', () => r(), { once: true });
});
}
// Pipeline: Decode 10 frames ahead to overlap decode/render/encode operations // Map effective time to source time (accounting for trim regions)
const DECODE_AHEAD = 10; const effectiveTimeMs = (i * timeStep) * 1000;
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;
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs); const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
const videoTime = sourceTimeMs / 1000; 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; 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<void>(resolve => {
videoElement.addEventListener('seeked', () => resolve(), { once: true });
});
videoElement.currentTime = videoTime; videoElement.currentTime = videoTime;
await new Promise<void>(r => { await seekedPromise;
videoElement.addEventListener('seeked', () => r(), { once: true }); } else if (i === 0) {
// Only for the very first frame, wait for it to be ready
await new Promise<void>(resolve => {
videoElement.requestVideoFrameCallback(() => resolve());
}); });
} }
// Create VideoFrame from current video element position // Create a VideoFrame from the video element (on GPU!)
const videoFrame = new VideoFrame(videoElement, { timestamp }); const videoFrame = new VideoFrame(videoElement, {
frameQueue.push({ frame: videoFrame, timestamp, sourceTimestamp }); timestamp,
}; });
// Pre-decode first batch of frames // Render the frame with all effects using source timestamp
for (let i = 0; i < Math.min(DECODE_AHEAD, totalFrames); i++) { const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
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()!;
// Render frame with effects using PixiJS
await this.renderer!.renderFrame(videoFrame, sourceTimestamp); await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
videoFrame.close(); videoFrame.close();
// Create VideoFrame directly from canvas (GPU-level)
const canvas = this.renderer!.getCanvas(); 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, { const exportFrame = new VideoFrame(canvas, {
timestamp, timestamp,
duration: frameDuration, 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) { 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') { if (this.encoder && this.encoder.state === 'configured') {
this.encodeQueue++; 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(); exportFrame.close();
frameIndex++; frameIndex++;
// Decode next frame in parallel while we process current frame // Update progress
if (decodeIndex < totalFrames) {
decodeFrame(decodeIndex++).catch(e => console.error('[VideoExporter] Decode error:', e));
}
if (this.config.onProgress) { if (this.config.onProgress) {
this.config.onProgress({ this.config.onProgress({
currentFrame: frameIndex, currentFrame: frameIndex,
@@ -222,18 +213,20 @@ export class VideoExporter {
return { success: false, error: 'Export cancelled' }; return { success: false, error: 'Export cancelled' };
} }
// Finalize encoding
if (this.encoder && this.encoder.state === 'configured') { if (this.encoder && this.encoder.state === 'configured') {
await this.encoder.flush(); await this.encoder.flush();
} }
await Promise.all(this.muxingPromises);
const blob = await this.muxer!.finalize();
const totalTime = performance.now() - exportStartTime; // Wait for all muxing operations to complete
console.log(`[VideoExporter] Export complete in ${(totalTime/1000).toFixed(2)}s (${totalFrames} frames)`); await Promise.all(this.muxingPromises);
// Finalize muxer and get output blob
const blob = await this.muxer!.finalize();
return { success: true, blob }; return { success: true, blob };
} catch (error) { } catch (error) {
console.error('[VideoExporter] Export error:', error); console.error('Export error:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@@ -247,26 +240,29 @@ export class VideoExporter {
this.encodeQueue = 0; this.encodeQueue = 0;
this.muxingPromises = []; this.muxingPromises = [];
this.chunkCount = 0; this.chunkCount = 0;
let videoDescription: Uint8Array | undefined;
// Create VideoEncoder with hardware acceleration
this.encoder = new VideoEncoder({ this.encoder = new VideoEncoder({
output: (chunk, meta) => { output: (chunk, meta) => {
// Capture codec description and color space from first chunk // Capture decoder config metadata from encoder output
if (meta?.decoderConfig?.description && !this.videoDescription) { if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description; 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) { if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
this.videoColorSpace = meta.decoderConfig.colorSpace; this.videoColorSpace = meta.decoderConfig.colorSpace;
} }
// Stream chunk to muxer immediately (parallel processing)
const isFirstChunk = this.chunkCount === 0; const isFirstChunk = this.chunkCount === 0;
this.chunkCount++; this.chunkCount++;
// Send encoded chunk to muxer
const muxingPromise = (async () => { const muxingPromise = (async () => {
try { try {
if (isFirstChunk && this.videoDescription) { if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || { const colorSpace = this.videoColorSpace || {
primaries: 'bt709', primaries: 'bt709',
transfer: 'iec61966-2-1', transfer: 'iec61966-2-1',
@@ -289,7 +285,7 @@ export class VideoExporter {
await this.muxer!.addVideoChunk(chunk, meta); await this.muxer!.addVideoChunk(chunk, meta);
} }
} catch (error) { } catch (error) {
console.error('[VideoExporter] Muxing error:', error); console.error('Muxing error:', error);
} }
})(); })();
@@ -298,12 +294,13 @@ export class VideoExporter {
}, },
error: (error) => { error: (error) => {
console.error('[VideoExporter] Encoder error:', error); console.error('[VideoExporter] Encoder error:', error);
// Stop export encoding failed
this.cancelled = true; this.cancelled = true;
}, },
}); });
// Configure encoder with hardware acceleration
const codec = this.config.codec || 'avc1.640033'; const codec = this.config.codec || 'avc1.640033';
const encoderConfig: VideoEncoderConfig = { const encoderConfig: VideoEncoderConfig = {
codec, codec,
width: this.config.width, width: this.config.width,
@@ -315,17 +312,23 @@ export class VideoExporter {
hardwareAcceleration: 'prefer-hardware', hardwareAcceleration: 'prefer-hardware',
}; };
const support = await VideoEncoder.isConfigSupported(encoderConfig); // Check hardware support first
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (support.supported) { if (hardwareSupport.supported) {
// Use hardware encoding
console.log('[VideoExporter] Using hardware acceleration');
this.encoder.configure(encoderConfig); this.encoder.configure(encoderConfig);
} else { } else {
// Fallback to software encoding // Fall back to software encoding
console.log('[VideoExporter] Hardware not supported, using software encoding');
encoderConfig.hardwareAcceleration = 'prefer-software'; encoderConfig.hardwareAcceleration = 'prefer-software';
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig); const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (!softwareSupport.supported) { if (!softwareSupport.supported) {
throw new Error('Video encoding not supported'); throw new Error('Video encoding not supported on this system');
} }
this.encoder.configure(encoderConfig); this.encoder.configure(encoderConfig);
} }
} }