@@ -1,43 +0,0 @@
|
||||
name: Build Electron App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
|
||||
- name: Build macOS app
|
||||
run: npm run build:mac
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload macOS build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-installer
|
||||
path: release/**/*.dmg
|
||||
retention-days: 30
|
||||
+223
-363
@@ -1,483 +1,343 @@
|
||||
import { BrowserWindow, screen, ipcMain, desktopCapturer, shell, app, nativeImage, Tray, Menu } from "electron";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { uIOhook } from "uiohook-napi";
|
||||
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");
|
||||
function createHudOverlayWindow() {
|
||||
const win = new BrowserWindow({
|
||||
import { BrowserWindow as P, screen as O, ipcMain as c, desktopCapturer as M, shell as W, app as p, nativeImage as L, Tray as V, Menu as U } from "electron";
|
||||
import { fileURLToPath as E } from "node:url";
|
||||
import t from "node:path";
|
||||
import m from "node:fs/promises";
|
||||
import { uIOhook as w } from "uiohook-napi";
|
||||
const S = t.dirname(E(import.meta.url)), A = t.join(S, ".."), y = process.env.VITE_DEV_SERVER_URL, x = t.join(A, "dist");
|
||||
function C() {
|
||||
const e = new P({
|
||||
width: 250,
|
||||
height: 80,
|
||||
minWidth: 250,
|
||||
maxWidth: 250,
|
||||
minHeight: 80,
|
||||
maxHeight: 80,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
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: t.join(S, "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());
|
||||
});
|
||||
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());
|
||||
}), y ? e.loadURL(y + "?windowType=hud-overlay") : e.loadFile(t.join(x, "index.html"), {
|
||||
query: { windowType: "hud-overlay" }
|
||||
}), e;
|
||||
}
|
||||
function createEditorWindow() {
|
||||
const win = new BrowserWindow({
|
||||
function N() {
|
||||
const e = new P({
|
||||
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
|
||||
preload: t.join(S, "preload.mjs"),
|
||||
nodeIntegration: !1,
|
||||
contextIsolation: !0,
|
||||
webSecurity: !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 e.maximize(), e.webContents.on("did-finish-load", () => {
|
||||
e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
|
||||
}), y ? e.loadURL(y + "?windowType=editor") : e.loadFile(t.join(x, "index.html"), {
|
||||
query: { windowType: "editor" }
|
||||
}), e;
|
||||
}
|
||||
function createSourceSelectorWindow() {
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
const win = new BrowserWindow({
|
||||
function H() {
|
||||
const { width: e, height: n } = O.getPrimaryDisplay().workAreaSize, i = new P({
|
||||
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((e - 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: t.join(S, "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 y ? i.loadURL(y + "?windowType=source-selector") : i.loadFile(t.join(x, "index.html"), {
|
||||
query: { windowType: "source-selector" }
|
||||
}), i;
|
||||
}
|
||||
let isMouseTrackingActive = false;
|
||||
let isHookStarted = false;
|
||||
let recordingStartTime = 0;
|
||||
let mouseEventData = [];
|
||||
function startMouseTracking() {
|
||||
if (isMouseTrackingActive) {
|
||||
return { success: false, message: "Already tracking" };
|
||||
}
|
||||
isMouseTrackingActive = true;
|
||||
recordingStartTime = performance.now();
|
||||
mouseEventData = [];
|
||||
if (!isHookStarted) {
|
||||
setupMouseEventListeners();
|
||||
try {
|
||||
uIOhook.start();
|
||||
isHookStarted = true;
|
||||
return { success: true, message: "Mouse tracking started", startTime: recordingStartTime };
|
||||
} catch (error) {
|
||||
console.error("Failed to start mouse tracking:", error);
|
||||
isMouseTrackingActive = false;
|
||||
return { success: false, message: "Failed to start hook", error };
|
||||
}
|
||||
} else {
|
||||
return { success: true, message: "Mouse tracking resumed", startTime: recordingStartTime };
|
||||
let u = !1, _ = !1, d = 0, f = [];
|
||||
function z() {
|
||||
if (u)
|
||||
return { success: !1, message: "Already tracking" };
|
||||
if (u = !0, d = performance.now(), f = [], _)
|
||||
return { success: !0, message: "Mouse tracking resumed", startTime: d };
|
||||
q();
|
||||
try {
|
||||
return w.start(), _ = !0, { success: !0, message: "Mouse tracking started", startTime: d };
|
||||
} catch (e) {
|
||||
return console.error("Failed to start mouse tracking:", e), u = !1, { success: !1, message: "Failed to start hook", error: e };
|
||||
}
|
||||
}
|
||||
function stopMouseTracking() {
|
||||
if (!isMouseTrackingActive) {
|
||||
return { success: false, message: "Not currently tracking" };
|
||||
}
|
||||
isMouseTrackingActive = false;
|
||||
const duration = performance.now() - recordingStartTime;
|
||||
const session = {
|
||||
startTime: recordingStartTime,
|
||||
events: mouseEventData,
|
||||
duration
|
||||
};
|
||||
function B() {
|
||||
if (!u)
|
||||
return { success: !1, message: "Not currently tracking" };
|
||||
u = !1;
|
||||
const e = performance.now() - d;
|
||||
return {
|
||||
success: true,
|
||||
success: !0,
|
||||
message: "Mouse tracking stopped",
|
||||
data: session
|
||||
data: {
|
||||
startTime: d,
|
||||
events: f,
|
||||
duration: e
|
||||
}
|
||||
};
|
||||
}
|
||||
function setupMouseEventListeners() {
|
||||
uIOhook.on("mousemove", (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime;
|
||||
const event = {
|
||||
function q() {
|
||||
w.on("mousemove", (e) => {
|
||||
if (u) {
|
||||
const i = {
|
||||
type: "move",
|
||||
timestamp,
|
||||
timestamp: performance.now() - d,
|
||||
x: e.x,
|
||||
y: e.y
|
||||
};
|
||||
mouseEventData.push(event);
|
||||
f.push(i);
|
||||
}
|
||||
});
|
||||
uIOhook.on("mousedown", (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime;
|
||||
const event = {
|
||||
}), w.on("mousedown", (e) => {
|
||||
if (u) {
|
||||
const i = {
|
||||
type: "down",
|
||||
timestamp,
|
||||
timestamp: performance.now() - d,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
button: e.button,
|
||||
clicks: e.clicks
|
||||
};
|
||||
mouseEventData.push(event);
|
||||
f.push(i);
|
||||
}
|
||||
});
|
||||
uIOhook.on("mouseup", (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime;
|
||||
const event = {
|
||||
}), w.on("mouseup", (e) => {
|
||||
if (u) {
|
||||
const i = {
|
||||
type: "up",
|
||||
timestamp,
|
||||
timestamp: performance.now() - d,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
button: e.button
|
||||
};
|
||||
mouseEventData.push(event);
|
||||
f.push(i);
|
||||
}
|
||||
});
|
||||
uIOhook.on("click", (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime;
|
||||
const event = {
|
||||
}), w.on("click", (e) => {
|
||||
if (u) {
|
||||
const i = {
|
||||
type: "click",
|
||||
timestamp,
|
||||
timestamp: performance.now() - d,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
button: e.button,
|
||||
clicks: e.clicks
|
||||
};
|
||||
mouseEventData.push(event);
|
||||
f.push(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
function getTrackingData() {
|
||||
return [...mouseEventData];
|
||||
function $() {
|
||||
return [...f];
|
||||
}
|
||||
function cleanupMouseTracking() {
|
||||
if (isHookStarted) {
|
||||
function G() {
|
||||
if (_)
|
||||
try {
|
||||
uIOhook.stop();
|
||||
isHookStarted = false;
|
||||
isMouseTrackingActive = false;
|
||||
mouseEventData = [];
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up mouse tracking:", error);
|
||||
w.stop(), _ = !1, u = !1, f = [];
|
||||
} catch (e) {
|
||||
console.error("Error cleaning up mouse tracking:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 b = null;
|
||||
function J(e, n, i, v, T) {
|
||||
c.handle("get-sources", async (o, a) => (await M.getSources(a)).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
display_id: r.display_id,
|
||||
thumbnail: r.thumbnail ? r.thumbnail.toDataURL() : null,
|
||||
appIcon: r.appIcon ? r.appIcon.toDataURL() : null
|
||||
}))), c.handle("select-source", (o, a) => {
|
||||
b = a;
|
||||
const s = v();
|
||||
return s && s.close(), b;
|
||||
}), c.handle("get-selected-source", () => b), c.handle("open-source-selector", () => {
|
||||
const o = v();
|
||||
if (o) {
|
||||
o.focus();
|
||||
return;
|
||||
}
|
||||
createSourceSelectorWindow2();
|
||||
});
|
||||
ipcMain.handle("switch-to-editor", () => {
|
||||
const mainWin = getMainWindow();
|
||||
if (mainWin) {
|
||||
mainWin.close();
|
||||
}
|
||||
createEditorWindow2();
|
||||
});
|
||||
ipcMain.handle("start-mouse-tracking", () => {
|
||||
return startMouseTracking();
|
||||
});
|
||||
ipcMain.handle("stop-mouse-tracking", () => {
|
||||
return stopMouseTracking();
|
||||
});
|
||||
ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => {
|
||||
n();
|
||||
}), c.handle("switch-to-editor", () => {
|
||||
const o = i();
|
||||
o && o.close(), e();
|
||||
}), c.handle("start-mouse-tracking", () => z()), c.handle("stop-mouse-tracking", () => B()), c.handle("store-recorded-video", async (o, a, s) => {
|
||||
try {
|
||||
const videoPath = path.join(RECORDINGS_DIR, fileName);
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData));
|
||||
return {
|
||||
success: true,
|
||||
path: videoPath,
|
||||
const r = t.join(h, s);
|
||||
return await m.writeFile(r, Buffer.from(a)), {
|
||||
success: !0,
|
||||
path: r,
|
||||
message: "Video stored successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to store video:", error);
|
||||
return {
|
||||
success: false,
|
||||
} catch (r) {
|
||||
return console.error("Failed to store video:", r), {
|
||||
success: !1,
|
||||
message: "Failed to store video",
|
||||
error: String(error)
|
||||
error: String(r)
|
||||
};
|
||||
}
|
||||
});
|
||||
ipcMain.handle("store-mouse-tracking-data", async (_, fileName) => {
|
||||
}), c.handle("store-mouse-tracking-data", async (o, a) => {
|
||||
try {
|
||||
const data = getTrackingData();
|
||||
if (data.length === 0) {
|
||||
return { success: false, message: "No tracking data to save" };
|
||||
}
|
||||
const trackingPath = path.join(RECORDINGS_DIR, fileName);
|
||||
await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
return {
|
||||
success: true,
|
||||
path: trackingPath,
|
||||
eventCount: data.length,
|
||||
const s = $();
|
||||
if (s.length === 0)
|
||||
return { success: !1, message: "No tracking data to save" };
|
||||
const r = t.join(h, a);
|
||||
return await m.writeFile(r, JSON.stringify(s, null, 2), "utf-8"), {
|
||||
success: !0,
|
||||
path: r,
|
||||
eventCount: s.length,
|
||||
message: "Mouse tracking data stored successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to store mouse tracking data:", error);
|
||||
return {
|
||||
success: false,
|
||||
} catch (s) {
|
||||
return console.error("Failed to store mouse tracking data:", s), {
|
||||
success: !1,
|
||||
message: "Failed to store mouse tracking data",
|
||||
error: String(error)
|
||||
error: String(s)
|
||||
};
|
||||
}
|
||||
});
|
||||
ipcMain.handle("get-recorded-video-path", async () => {
|
||||
}), c.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 a = (await m.readdir(h)).filter((R) => R.endsWith(".webm"));
|
||||
if (a.length === 0)
|
||||
return { success: !1, message: "No recorded video found" };
|
||||
const s = a.sort().reverse()[0];
|
||||
return { success: !0, path: t.join(h, s) };
|
||||
} catch (o) {
|
||||
return console.error("Failed to get video path:", o), { success: !1, message: "Failed to get video path", error: String(o) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("set-recording-state", (_, recording) => {
|
||||
const source = selectedSource || { name: "Screen" };
|
||||
if (onRecordingStateChange) {
|
||||
onRecordingStateChange(recording, source.name);
|
||||
}
|
||||
});
|
||||
ipcMain.handle("open-external-url", async (_, url) => {
|
||||
}), c.handle("set-recording-state", (o, a) => {
|
||||
T && T(a, (b || { name: "Screen" }).name);
|
||||
}), c.handle("open-external-url", async (o, a) => {
|
||||
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 W.openExternal(a), { success: !0 };
|
||||
} catch (s) {
|
||||
return console.error("Failed to open URL:", s), { success: !1, error: String(s) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("get-asset-base-path", () => {
|
||||
}), c.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 p.isPackaged ? t.join(process.resourcesPath, "assets") : t.join(p.getAppPath(), "public", "assets");
|
||||
} catch (o) {
|
||||
return console.error("Failed to resolve asset base path:", o), null;
|
||||
}
|
||||
});
|
||||
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
|
||||
}), c.handle("save-exported-video", async (o, a, s) => {
|
||||
try {
|
||||
const downloadsPath = app.getPath("downloads");
|
||||
const videoPath = path.join(downloadsPath, fileName);
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData));
|
||||
return {
|
||||
success: true,
|
||||
path: videoPath,
|
||||
const r = p.getPath("downloads"), R = t.join(r, s);
|
||||
return await m.writeFile(R, Buffer.from(a)), {
|
||||
success: !0,
|
||||
path: R,
|
||||
message: "Video exported successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to save exported video:", error);
|
||||
return {
|
||||
success: false,
|
||||
} catch (r) {
|
||||
return console.error("Failed to save exported video:", r), {
|
||||
success: !1,
|
||||
message: "Failed to save exported video",
|
||||
error: String(error)
|
||||
error: String(r)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
||||
async function cleanupOldRecordings() {
|
||||
const K = t.dirname(E(import.meta.url)), h = t.join(p.getPath("userData"), "recordings");
|
||||
async function Q() {
|
||||
try {
|
||||
const files = await fs.readdir(RECORDINGS_DIR);
|
||||
const now = Date.now();
|
||||
const maxAge = 1 * 24 * 60 * 60 * 1e3;
|
||||
for (const file of files) {
|
||||
const filePath = path.join(RECORDINGS_DIR, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
if (now - stats.mtimeMs > maxAge) {
|
||||
await fs.unlink(filePath);
|
||||
console.log(`Deleted old recording: ${file}`);
|
||||
}
|
||||
const e = await m.readdir(h), n = Date.now(), i = 1 * 24 * 60 * 60 * 1e3;
|
||||
for (const v of e) {
|
||||
const T = t.join(h, v), o = await m.stat(T);
|
||||
n - o.mtimeMs > i && (await m.unlink(T), console.log(`Deleted old recording: ${v}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup old recordings:", error);
|
||||
} catch (e) {
|
||||
console.error("Failed to cleanup old recordings:", e);
|
||||
}
|
||||
}
|
||||
async function ensureRecordingsDir() {
|
||||
async function X() {
|
||||
try {
|
||||
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
|
||||
console.log("Recordings directory ready:", RECORDINGS_DIR);
|
||||
} catch (error) {
|
||||
console.error("Failed to create recordings directory:", error);
|
||||
await m.mkdir(h, { recursive: !0 }), console.log("Recordings directory ready:", h);
|
||||
} catch (e) {
|
||||
console.error("Failed to create recordings directory:", e);
|
||||
}
|
||||
}
|
||||
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 = t.join(K, "..");
|
||||
const Y = process.env.VITE_DEV_SERVER_URL, ie = t.join(process.env.APP_ROOT, "dist-electron"), I = t.join(process.env.APP_ROOT, "dist");
|
||||
process.env.VITE_PUBLIC = Y ? t.join(process.env.APP_ROOT, "public") : I;
|
||||
let l = null, k = null, g = null, j = "";
|
||||
function D() {
|
||||
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 Z() {
|
||||
const e = t.join(process.env.VITE_PUBLIC || I, "rec-button.png");
|
||||
let n = L.createFromPath(e);
|
||||
n = n.resize({ width: 24, height: 24, quality: "best" }), g = new V(n), F();
|
||||
}
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
const menuTemplate = [
|
||||
function F() {
|
||||
if (!g) return;
|
||||
const e = [
|
||||
{
|
||||
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 = U.buildFromTemplate(e);
|
||||
g.setContextMenu(n), g.setToolTip(`Recording: ${j}`);
|
||||
}
|
||||
function createEditorWindowWrapper() {
|
||||
if (mainWindow) {
|
||||
mainWindow.close();
|
||||
mainWindow = null;
|
||||
}
|
||||
mainWindow = createEditorWindow();
|
||||
function ee() {
|
||||
l && (l.close(), l = null), l = N();
|
||||
}
|
||||
function createSourceSelectorWindowWrapper() {
|
||||
sourceSelectorWindow = createSourceSelectorWindow();
|
||||
sourceSelectorWindow.on("closed", () => {
|
||||
sourceSelectorWindow = null;
|
||||
});
|
||||
return sourceSelectorWindow;
|
||||
function te() {
|
||||
return k = H(), k.on("closed", () => {
|
||||
k = null;
|
||||
}), k;
|
||||
}
|
||||
app.on("window-all-closed", () => {
|
||||
p.on("window-all-closed", () => {
|
||||
});
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
p.on("activate", () => {
|
||||
P.getAllWindows().length === 0 && D();
|
||||
});
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault();
|
||||
cleanupMouseTracking();
|
||||
await cleanupOldRecordings();
|
||||
app.exit(0);
|
||||
p.on("before-quit", async (e) => {
|
||||
e.preventDefault(), G(), await Q(), p.exit(0);
|
||||
});
|
||||
app.whenReady().then(async () => {
|
||||
await ensureRecordingsDir();
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
createSourceSelectorWindowWrapper,
|
||||
() => mainWindow,
|
||||
() => sourceSelectorWindow,
|
||||
(recording, sourceName) => {
|
||||
selectedSourceName = sourceName;
|
||||
if (recording) {
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu();
|
||||
if (mainWindow) mainWindow.minimize();
|
||||
} else {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
if (mainWindow) mainWindow.restore();
|
||||
}
|
||||
p.whenReady().then(async () => {
|
||||
await X(), J(
|
||||
ee,
|
||||
te,
|
||||
() => l,
|
||||
() => k,
|
||||
(e, n) => {
|
||||
j = n, e ? (g || Z(), F(), l && l.minimize()) : (g && (g.destroy(), g = null), l && l.restore());
|
||||
}
|
||||
);
|
||||
createWindow();
|
||||
), D();
|
||||
});
|
||||
export {
|
||||
MAIN_DIST,
|
||||
RECORDINGS_DIR,
|
||||
RENDERER_DIST,
|
||||
VITE_DEV_SERVER_URL
|
||||
ie as MAIN_DIST,
|
||||
h as RECORDINGS_DIR,
|
||||
I as RENDERER_DIST,
|
||||
Y as VITE_DEV_SERVER_URL
|
||||
};
|
||||
|
||||
@@ -1,51 +1 @@
|
||||
"use strict";
|
||||
const electron = require("electron");
|
||||
electron.contextBridge.exposeInMainWorld("electronAPI", {
|
||||
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");
|
||||
},
|
||||
startMouseTracking: () => {
|
||||
return electron.ipcRenderer.invoke("start-mouse-tracking");
|
||||
},
|
||||
stopMouseTracking: () => {
|
||||
return electron.ipcRenderer.invoke("stop-mouse-tracking");
|
||||
},
|
||||
storeRecordedVideo: (videoData, fileName) => {
|
||||
return electron.ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
storeMouseTrackingData: (fileName) => {
|
||||
return electron.ipcRenderer.invoke("store-mouse-tracking-data", 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);
|
||||
}
|
||||
});
|
||||
"use strict";const e=require("electron");e.contextBridge.exposeInMainWorld("electronAPI",{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"),startMouseTracking:()=>e.ipcRenderer.invoke("start-mouse-tracking"),stopMouseTracking:()=>e.ipcRenderer.invoke("stop-mouse-tracking"),storeRecordedVideo:(r,t)=>e.ipcRenderer.invoke("store-recorded-video",r,t),storeMouseTrackingData:r=>e.ipcRenderer.invoke("store-mouse-tracking-data",r),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)});
|
||||
|
||||
@@ -184,15 +184,31 @@ export default function VideoEditor() {
|
||||
videoPlaybackRef.current?.pause();
|
||||
}
|
||||
|
||||
const width = 1920;
|
||||
const height = 1080;
|
||||
// Get actual video dimensions to match recording resolution
|
||||
const video = videoPlaybackRef.current?.video;
|
||||
if (!video) {
|
||||
toast.error('Video not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const width = video.videoWidth || 1920;
|
||||
const height = video.videoHeight || 1080;
|
||||
|
||||
// Calculate visually lossless bitrate matching screen recording optimization
|
||||
const totalPixels = width * height;
|
||||
let bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
bitrate = 50_000_000;
|
||||
} else if (totalPixels > 2560 * 1440) {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: videoPath,
|
||||
width,
|
||||
height,
|
||||
frameRate: 60,
|
||||
bitrate: 15_000_000,
|
||||
bitrate,
|
||||
codec: 'avc1.640033',
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
|
||||
@@ -55,12 +55,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
await window.electronAPI.startMouseTracking();
|
||||
// Capture screen at source resolution without constraints
|
||||
const mediaStream = await (navigator.mediaDevices as any).getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
frameRate: { ideal: 60, max: 60 }
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -69,16 +71,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
throw new Error("Media stream is not available.");
|
||||
}
|
||||
const videoTrack = stream.current.getVideoTracks()[0];
|
||||
const { width = 1920, height = 1080 } = videoTrack.getSettings();
|
||||
let { width = 1920, height = 1080 } = videoTrack.getSettings();
|
||||
|
||||
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
|
||||
width = Math.floor(width / 2) * 2;
|
||||
height = Math.floor(height / 2) * 2;
|
||||
|
||||
console.log(`Recording at ${width}x${height}`);
|
||||
|
||||
const totalPixels = width * height;
|
||||
let bitrate = 150_000_000;
|
||||
// Use visually lossless bitrates optimized for quality and file size balance
|
||||
let bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
bitrate = 250_000_000;
|
||||
bitrate = 50_000_000;
|
||||
} else if (totalPixels > 2560 * 1440) {
|
||||
bitrate = 400_000_000;
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
chunks.current = [];
|
||||
const mimeType = "video/webm;codecs=vp9";
|
||||
// Prefer AV1 codec for better compression, fallback to VP9 then VP8
|
||||
const supportedCodecs = [
|
||||
'video/webm;codecs=av1',
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8'
|
||||
];
|
||||
const mimeType = supportedCodecs.find(codec => MediaRecorder.isTypeSupported(codec)) || 'video/webm;codecs=vp9';
|
||||
const recorder = new MediaRecorder(stream.current, { mimeType, videoBitsPerSecond: bitrate });
|
||||
mediaRecorder.current = recorder;
|
||||
recorder.ondataavailable = e => {
|
||||
@@ -89,6 +105,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (chunks.current.length === 0) return;
|
||||
const duration = Date.now() - startTime.current;
|
||||
const buggyBlob = new Blob(chunks.current, { type: mimeType });
|
||||
// Clear chunks early to free memory immediately after blob creation
|
||||
chunks.current = [];
|
||||
const timestamp = Date.now();
|
||||
const videoFileName = `recording-${timestamp}.webm`;
|
||||
const trackingFileName = `recording-${timestamp}_tracking.json`;
|
||||
@@ -110,7 +128,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
};
|
||||
recorder.onerror = () => setRecording(false);
|
||||
recorder.start(1000);
|
||||
// Use larger timeslice to reduce recording overhead and improve smoothness
|
||||
recorder.start(5000);
|
||||
startTime.current = Date.now();
|
||||
setRecording(true);
|
||||
window.electronAPI?.setRecordingState(true);
|
||||
|
||||
@@ -53,7 +53,7 @@ export class FrameRenderer {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Create offscreen canvas with sRGB color space for fidelity
|
||||
// Create canvas for rendering
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = this.config.width;
|
||||
canvas.height = this.config.height;
|
||||
@@ -69,15 +69,15 @@ export class FrameRenderer {
|
||||
console.warn('[FrameRenderer] colorSpace not supported on this platform:', error);
|
||||
}
|
||||
|
||||
// Initialize PixiJS app with transparent background (background rendered separately)
|
||||
// Initialize PixiJS with optimized settings for export performance
|
||||
this.app = new PIXI.Application();
|
||||
await this.app.init({
|
||||
canvas,
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
resolution: 2,
|
||||
antialias: false,
|
||||
resolution: 1,
|
||||
autoDensity: true,
|
||||
});
|
||||
|
||||
@@ -255,9 +255,11 @@ export class FrameRenderer {
|
||||
this.videoSprite = new PIXI.Sprite(texture);
|
||||
this.videoContainer.addChild(this.videoSprite);
|
||||
} else {
|
||||
// Update texture with new frame
|
||||
const texture = PIXI.Texture.from(videoFrame as any);
|
||||
this.videoSprite.texture = texture;
|
||||
// Destroy old texture to avoid memory leaks, then create new one
|
||||
const oldTexture = this.videoSprite.texture;
|
||||
const newTexture = PIXI.Texture.from(videoFrame as any);
|
||||
this.videoSprite.texture = newTexture;
|
||||
oldTexture.destroy(true);
|
||||
}
|
||||
|
||||
// Apply layout
|
||||
@@ -442,7 +444,7 @@ export class FrameRenderer {
|
||||
console.warn('[FrameRenderer] No background sprite found during compositing!');
|
||||
}
|
||||
|
||||
// Step 2: Draw video layer with shadows on top of background
|
||||
// Draw video layer with shadows on top of background
|
||||
if (this.config.showShadow && this.shadowCanvas && this.shadowCtx) {
|
||||
const shadowCtx = this.shadowCtx;
|
||||
shadowCtx.clearRect(0, 0, w, h);
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface DecodedVideoInfo {
|
||||
}
|
||||
|
||||
export class VideoFileDecoder {
|
||||
private decoder: VideoDecoder | null = null;
|
||||
private info: DecodedVideoInfo | null = null;
|
||||
private videoElement: HTMLVideoElement | null = null;
|
||||
|
||||
@@ -44,27 +43,6 @@ export class VideoFileDecoder {
|
||||
return this.videoElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a specific time and wait for the frame to be ready
|
||||
*/
|
||||
async seekToTime(timeInSeconds: number): Promise<void> {
|
||||
if (!this.videoElement) {
|
||||
throw new Error('Video not loaded');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const video = this.videoElement!;
|
||||
|
||||
const onSeeked = () => {
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
resolve();
|
||||
};
|
||||
|
||||
video.addEventListener('seeked', onSeeked);
|
||||
video.currentTime = timeInSeconds;
|
||||
});
|
||||
}
|
||||
|
||||
getInfo(): DecodedVideoInfo | null {
|
||||
return this.info;
|
||||
}
|
||||
@@ -75,12 +53,5 @@ export class VideoFileDecoder {
|
||||
this.videoElement.src = '';
|
||||
this.videoElement = null;
|
||||
}
|
||||
|
||||
if (this.decoder) {
|
||||
if (this.decoder.state !== 'closed') {
|
||||
this.decoder.close();
|
||||
}
|
||||
this.decoder = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,10 @@ export class VideoExporter {
|
||||
private cancelled = false;
|
||||
private encodedChunks: EncodedVideoChunk[] = [];
|
||||
private encodeQueue = 0;
|
||||
private readonly MAX_ENCODE_QUEUE = 60;
|
||||
// Increased queue size for better throughput with hardware encoding
|
||||
private readonly MAX_ENCODE_QUEUE = 120;
|
||||
private videoDescription: Uint8Array | undefined;
|
||||
private videoColorSpace: VideoColorSpaceInit | undefined;
|
||||
|
||||
constructor(config: VideoExporterConfig) {
|
||||
this.config = config;
|
||||
@@ -71,75 +73,68 @@ export class VideoExporter {
|
||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||
let frameIndex = 0;
|
||||
const timeStep = 1 / this.config.frameRate;
|
||||
|
||||
// Pre-load first frame
|
||||
videoElement.currentTime = 0;
|
||||
await new Promise(resolve => {
|
||||
const onSeeked = () => {
|
||||
videoElement.removeEventListener('seeked', onSeeked);
|
||||
resolve(null);
|
||||
};
|
||||
videoElement.addEventListener('seeked', onSeeked);
|
||||
});
|
||||
const BATCH_SIZE = 5; // Process frames in batches for better throughput
|
||||
|
||||
while (frameIndex < totalFrames && !this.cancelled) {
|
||||
const timestamp = frameIndex * frameDuration;
|
||||
const videoTime = frameIndex * timeStep;
|
||||
// Seek to frame (only seek if not already there)
|
||||
if (Math.abs(videoElement.currentTime - videoTime) > 0.001) {
|
||||
videoElement.currentTime = videoTime;
|
||||
await Promise.race([
|
||||
new Promise(resolve => {
|
||||
const onSeeked = () => {
|
||||
videoElement.removeEventListener('seeked', onSeeked);
|
||||
// Wait for video to render the frame
|
||||
videoElement.requestVideoFrameCallback(() => resolve(null));
|
||||
};
|
||||
videoElement.addEventListener('seeked', onSeeked, { once: true });
|
||||
}),
|
||||
new Promise(resolve => setTimeout(resolve, 200)) // higher this number, slower the export, but better capture/ no frame drops
|
||||
]);
|
||||
}
|
||||
|
||||
// Create a VideoFrame from the video element (on GPU!)
|
||||
const videoFrame = new VideoFrame(videoElement, {
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Render the frame with all effects
|
||||
await this.renderer!.renderFrame(videoFrame, timestamp);
|
||||
// Process a batch of frames
|
||||
const batchEnd = Math.min(frameIndex + BATCH_SIZE, totalFrames);
|
||||
|
||||
videoFrame.close();
|
||||
for (let i = frameIndex; i < batchEnd && !this.cancelled; i++) {
|
||||
const timestamp = i * frameDuration;
|
||||
const videoTime = i * timeStep;
|
||||
|
||||
// Seek if needed or wait for first frame to be ready
|
||||
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
|
||||
if (needsSeek || i === 0) {
|
||||
if (needsSeek) {
|
||||
videoElement.currentTime = videoTime;
|
||||
}
|
||||
// Wait for video frame to be ready
|
||||
await new Promise<void>(resolve => {
|
||||
videoElement.requestVideoFrameCallback(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// Create a VideoFrame from the video element (on GPU!)
|
||||
const videoFrame = new VideoFrame(videoElement, {
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Render the frame with all effects
|
||||
await this.renderer!.renderFrame(videoFrame, timestamp);
|
||||
|
||||
videoFrame.close();
|
||||
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
|
||||
// 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,
|
||||
colorSpace: {
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
this.encodeQueue++;
|
||||
this.encoder.encode(exportFrame, { keyFrame: i % 150 === 0 });
|
||||
}
|
||||
exportFrame.close();
|
||||
}
|
||||
|
||||
// Wait for encoder queue once per batch
|
||||
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
if (this.cancelled) break;
|
||||
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
|
||||
|
||||
// @ts-ignore - TypeScript definitions may not include all VideoFrameInit properties
|
||||
const exportFrame = new VideoFrame(canvas, {
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
colorSpace: {
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
this.encodeQueue++;
|
||||
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
|
||||
}
|
||||
exportFrame.close();
|
||||
|
||||
frameIndex++;
|
||||
frameIndex = batchEnd;
|
||||
|
||||
// Batch progress updates to reduce callback overhead
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: frameIndex,
|
||||
@@ -166,11 +161,20 @@ export class VideoExporter {
|
||||
|
||||
// Add decoder config for the first chunk
|
||||
if (i === 0 && this.videoDescription) {
|
||||
// Use captured colorSpace from encoder or fallback to default sRGB colorspace
|
||||
const colorSpace = this.videoColorSpace || {
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
};
|
||||
|
||||
meta.decoderConfig = {
|
||||
codec: this.config.codec || 'avc1.640033',
|
||||
codedWidth: this.config.width,
|
||||
codedHeight: this.config.height,
|
||||
description: this.videoDescription,
|
||||
colorSpace,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,11 +203,16 @@ export class VideoExporter {
|
||||
|
||||
this.encoder = new VideoEncoder({
|
||||
output: (chunk, meta) => {
|
||||
// Capture decoder config metadata from encoder output
|
||||
if (meta?.decoderConfig?.description && !videoDescription) {
|
||||
const desc = meta.decoderConfig.description;
|
||||
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;
|
||||
}
|
||||
this.encodedChunks.push(chunk);
|
||||
this.encodeQueue--;
|
||||
},
|
||||
@@ -265,5 +274,6 @@ export class VideoExporter {
|
||||
this.encodedChunks = [];
|
||||
this.encodeQueue = 0;
|
||||
this.videoDescription = undefined;
|
||||
this.videoColorSpace = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user