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 { 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
};
+1 -60
View File
@@ -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")});
+91 -88
View File
@@ -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<void>[] = [];
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<void>(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<void>(resolve => {
videoElement.addEventListener('seeked', () => resolve(), { once: true });
});
videoElement.currentTime = videoTime;
await new Promise<void>(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<void>(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);
}
}