mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-03 22:43:12 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6d1a3eca6 | ||
|
|
c5aa622898 | ||
|
|
1345c8109c | ||
|
|
d91ed78fc2 | ||
|
|
5d7b817586 | ||
|
|
fecb9a9b22 | ||
|
|
cbdef41667 | ||
|
|
f34bd19183 | ||
|
|
7a7db0b277 | ||
|
|
391938049b |
+181
-309
@@ -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 n, 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;
|
||||
n.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: s } = r, c = 500, w = 100, y = Math.floor(s.x + (s.width - c) / 2), h = Math.floor(s.y + s.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: s } = 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((s - 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, s, c, w, y) {
|
||||
n.handle("get-sources", async (e, a) => (await V.getSources(a)).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
|
||||
}))), n.handle("select-source", (e, a) => {
|
||||
v = a;
|
||||
const i = w();
|
||||
return i && i.close(), v;
|
||||
}), n.handle("get-selected-source", () => v), n.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) => {
|
||||
s();
|
||||
}), n.handle("switch-to-editor", () => {
|
||||
const e = c();
|
||||
e && e.close(), r();
|
||||
}), n.handle("store-recorded-video", async (e, a, i) => {
|
||||
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, i);
|
||||
return await P.writeFile(t, Buffer.from(a)), 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 () => {
|
||||
}), n.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 P.readdir(p)).filter((j) => j.endsWith(".webm"));
|
||||
if (a.length === 0)
|
||||
return { success: !1, message: "No recorded video found" };
|
||||
const i = a.sort().reverse()[0];
|
||||
return { success: !0, path: o.join(p, i) };
|
||||
} 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) => {
|
||||
}), n.handle("set-recording-state", (e, a) => {
|
||||
y && y(a, (v || { name: "Screen" }).name);
|
||||
}), n.handle("open-external-url", async (e, 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 O.openExternal(a), { success: !0 };
|
||||
} catch (i) {
|
||||
return console.error("Failed to open URL:", i), { success: !1, error: String(i) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("get-asset-base-path", () => {
|
||||
}), n.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) => {
|
||||
}), n.handle("save-exported-video", async (e, a, i) => {
|
||||
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"), i),
|
||||
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(a)), {
|
||||
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 () => {
|
||||
}), n.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;
|
||||
n.handle("set-current-video-path", (e, a) => (h = a, { success: !0 })), n.handle("get-current-video-path", () => h ? { success: !0, path: h } : { success: !1 }), n.handle("clear-current-video-path", () => (h = null, { success: !0 })), n.handle("get-platform", () => process.platform);
|
||||
}
|
||||
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 s = W.createFromPath(r);
|
||||
s = s.resize({ width: 24, height: 24, quality: "best" }), u = new k(s), 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}`);
|
||||
], s = L.buildFromTemplate(r);
|
||||
u.setContextMenu(s), 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,
|
||||
(s, c) => {
|
||||
x = c, s ? (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 +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"),getPlatform:()=>e.ipcRenderer.invoke("get-platform")});
|
||||
|
||||
Vendored
+1
@@ -39,6 +39,7 @@ interface Window {
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>
|
||||
getPlatform: () => Promise<string>
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
}
|
||||
|
||||
@@ -208,4 +208,8 @@ export function registerIpcHandlers(
|
||||
currentVideoPath = null;
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('get-platform', () => {
|
||||
return process.platform;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,4 +60,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke('clear-current-video-path')
|
||||
},
|
||||
getPlatform: () => {
|
||||
return ipcRenderer.invoke('get-platform')
|
||||
},
|
||||
})
|
||||
Generated
+26
-105
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openscreen",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
@@ -21,11 +21,12 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@uiw/color-convert": "^2.9.2",
|
||||
"@uiw/react-color-colorful": "^2.9.0",
|
||||
"@uiw/react-color-block": "^2.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"gsap": "^3.13.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
@@ -3915,14 +3916,15 @@
|
||||
"@babel/runtime": ">=7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-color-alpha": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-color-alpha/-/react-color-alpha-2.9.0.tgz",
|
||||
"integrity": "sha512-tP9zs2KUPzIGvdtTBTNlDEdwHWcNJ3Ju/nnpPTsKERRJF2vzucb3v18JzihX0ACiOXU9+JVqgpAVgvoZXMeITA==",
|
||||
"node_modules/@uiw/react-color-block": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-color-block/-/react-color-block-2.9.2.tgz",
|
||||
"integrity": "sha512-0EIZTELA5pnxyMlBOFo3hrpy73db+Qeq6E+QptNfD/8izor8OvY1Uquj2VqD6gDz+iVHMELIoKxpaQ8sZR7NOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uiw/color-convert": "2.9.0",
|
||||
"@uiw/react-drag-event-interactive": "2.9.0"
|
||||
"@uiw/color-convert": "2.9.2",
|
||||
"@uiw/react-color-editable-input": "2.9.2",
|
||||
"@uiw/react-color-swatch": "2.9.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
@@ -3933,58 +3935,27 @@
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-color-alpha/node_modules/@uiw/color-convert": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
|
||||
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
|
||||
"node_modules/@uiw/react-color-editable-input": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input/-/react-color-editable-input-2.9.2.tgz",
|
||||
"integrity": "sha512-DY7pu12+LDRn6cxDMvsy1/quaPTxicAPz/kfODV7KBf8+Hq4rFWeJ4KS6m22IKIbQxrBQgmQG0WFJLaPeY7cPw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-color-colorful": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-color-colorful/-/react-color-colorful-2.9.0.tgz",
|
||||
"integrity": "sha512-qw7zhgX9J4aydK0hKKLroXMYp0tw3ASNuZM1fQNwXXIpOkEW4zO+PHTkzUBpbII68wGS8tAxsZessTWNpAVi0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uiw/color-convert": "2.9.0",
|
||||
"@uiw/react-color-alpha": "2.9.0",
|
||||
"@uiw/react-color-hue": "2.9.0",
|
||||
"@uiw/react-color-saturation": "2.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0",
|
||||
"react": ">=16.9.0",
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-color-colorful/node_modules/@uiw/color-convert": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
|
||||
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-color-hue": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-color-hue/-/react-color-hue-2.9.0.tgz",
|
||||
"integrity": "sha512-2Ix027ref3ppiVq+OQkbyKr6O8fLTH+n0wEW4xhHfa3kV8mBa2bEu8jAjrkSwO6uElaE6OJcuqYe2llyKRlIFg==",
|
||||
"node_modules/@uiw/react-color-swatch": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-color-swatch/-/react-color-swatch-2.9.2.tgz",
|
||||
"integrity": "sha512-6zBy+E9NzZR672M2wPsbbNRqKy9Wi9jOuuxxyzov1CEZp+pPX7UwMlCX6RUhKdO0PzTSPCVQmbz5bplu5vsW0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uiw/color-convert": "2.9.0",
|
||||
"@uiw/react-color-alpha": "2.9.0"
|
||||
"@uiw/color-convert": "2.9.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
@@ -3995,62 +3966,6 @@
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-color-hue/node_modules/@uiw/color-convert": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
|
||||
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-color-saturation": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-color-saturation/-/react-color-saturation-2.9.0.tgz",
|
||||
"integrity": "sha512-tBARqsW0ap+gwByGKSid1nYaGJd6dw2MTMwCABF9rebPQHtYbN/YgBjl9dNnp6XDGd+VBhH4RbpM38WV7J1F4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uiw/color-convert": "2.9.0",
|
||||
"@uiw/react-drag-event-interactive": "2.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0",
|
||||
"react": ">=16.9.0",
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-color-saturation/node_modules/@uiw/color-convert": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
|
||||
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-drag-event-interactive": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.9.0.tgz",
|
||||
"integrity": "sha512-oBqrClsSuYA3IjkSHp4uHOBLgPkR98KeKeheqCRltF8w/a1CPaFCl43pyKyxwXaTV6qM6cb3VkT3JRCHMNqTzA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0",
|
||||
"react": ">=16.9.0",
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||
@@ -7085,6 +7000,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/fix-webm-duration": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fix-webm-duration/-/fix-webm-duration-1.0.6.tgz",
|
||||
"integrity": "sha512-zVAqi4gE+8ywxJuAyV/rlJVX6CMtvyapEbQx6jyoeX9TMjdqAlt/FdG5d7rXSSkDVzTvS0H7CtwzHcH/vh4FPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flairup": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -25,11 +25,12 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@uiw/color-convert": "^2.9.2",
|
||||
"@uiw/react-color-colorful": "^2.9.0",
|
||||
"@uiw/react-color-block": "^2.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"gsap": "^3.13.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
@@ -70,4 +71,4 @@
|
||||
"vite-plugin-electron-renderer": "^0.14.5"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-8053
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
- lzma-native
|
||||
- phantomjs-prebuilt
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useRef } from "react";
|
||||
import {useRef } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import Colorful from '@uiw/react-color-colorful';
|
||||
import { hsvaToHex, hexToHsva } from '@uiw/color-convert';
|
||||
import Block from '@uiw/react-color-block';
|
||||
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -44,11 +43,24 @@ export function AnnotationSettingsPanel({
|
||||
onDelete,
|
||||
}: AnnotationSettingsPanelProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [textColorHsva, setTextColorHsva] = useState(hexToHsva(annotation.style.color));
|
||||
const [bgColorHsva, setBgColorHsva] = useState(hexToHsva(annotation.style.backgroundColor || '#00000000'));
|
||||
const [figureColorHsva, setFigureColorHsva] = useState(
|
||||
hexToHsva(annotation.figureData?.color || '#34B27B')
|
||||
);
|
||||
const colorPalette = [
|
||||
'#FF0000', // Red
|
||||
'#FFD700', // Yellow/Gold
|
||||
'#00FF00', // Green
|
||||
'#FFFFFF', // White
|
||||
'#0000FF', // Blue
|
||||
'#FF6B00', // Orange
|
||||
'#9B59B6', // Purple
|
||||
'#E91E63', // Pink
|
||||
'#00BCD4', // Cyan
|
||||
'#FF5722', // Deep Orange
|
||||
'#8BC34A', // Light Green
|
||||
'#FFC107', // Amber
|
||||
'#34B27B', // Brand Green
|
||||
'#000000', // Black
|
||||
'#607D8B', // Blue Grey
|
||||
'#795548', // Brown
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -252,18 +264,17 @@ export function AnnotationSettingsPanel({
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 border-none bg-transparent shadow-xl">
|
||||
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
|
||||
<Colorful
|
||||
color={textColorHsva}
|
||||
disableAlpha={true}
|
||||
onChange={(color) => {
|
||||
setTextColorHsva(color.hsva);
|
||||
onStyleChange({ color: hsvaToHex(color.hsva) });
|
||||
}}
|
||||
style={{ width: '100%', borderRadius: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={annotation.style.color}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({ color: color.hex });
|
||||
}}
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -290,28 +301,27 @@ export function AnnotationSettingsPanel({
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 border-none bg-transparent shadow-xl">
|
||||
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
|
||||
<Colorful
|
||||
color={bgColorHsva}
|
||||
onChange={(color) => {
|
||||
setBgColorHsva(color.hsva);
|
||||
onStyleChange({ backgroundColor: hsvaToHex(color.hsva) });
|
||||
}}
|
||||
style={{ width: '100%', borderRadius: '8px' }}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
|
||||
onClick={() => {
|
||||
onStyleChange({ backgroundColor: 'transparent' });
|
||||
setBgColorHsva({ h: 0, s: 0, v: 0, a: 0 });
|
||||
}}
|
||||
>
|
||||
Clear Background
|
||||
</Button>
|
||||
</div>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={annotation.style.backgroundColor === 'transparent' ? '#000000' : annotation.style.backgroundColor}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({ backgroundColor: color.hex });
|
||||
}}
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
|
||||
onClick={() => {
|
||||
onStyleChange({ backgroundColor: 'transparent' });
|
||||
}}
|
||||
>
|
||||
Clear Background
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -428,22 +438,21 @@ export function AnnotationSettingsPanel({
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 border-none bg-transparent shadow-xl">
|
||||
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
|
||||
<Colorful
|
||||
color={figureColorHsva}
|
||||
disableAlpha={true}
|
||||
onChange={(color) => {
|
||||
setFigureColorHsva(color.hsva);
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
color: hsvaToHex(color.hsva),
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
style={{ width: '100%', borderRadius: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={annotation.figureData?.color || '#34B27B'}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
color: color.hex,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const [shortcuts, setShortcuts] = useState({
|
||||
delete: 'Ctrl + D',
|
||||
pan: 'Shift + Ctrl + Scroll',
|
||||
zoom: 'Ctrl + Scroll'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
formatShortcut(['mod', 'D']),
|
||||
formatShortcut(['shift', 'mod', 'Scroll']),
|
||||
formatShortcut(['mod', 'Scroll'])
|
||||
]).then(([deleteKey, panKey, zoomKey]) => {
|
||||
setShortcuts({
|
||||
delete: deleteKey,
|
||||
pan: panKey,
|
||||
zoom: zoomKey
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
||||
@@ -25,15 +47,15 @@ export function KeyboardShortcutsHelp() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Delete Selected</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + D</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.delete}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pan Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⇧ + ⌘ + Scroll</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.pan}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Zoom Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + Scroll</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.zoom}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pause/Play</span>
|
||||
|
||||
@@ -6,8 +6,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import Colorful from '@uiw/react-color-colorful';
|
||||
import { hsvaToHex } from '@uiw/color-convert';
|
||||
import Block from '@uiw/react-color-block';
|
||||
import { Trash2, Download, Crop, X, Bug, Upload } from "lucide-react";
|
||||
import { GiHearts } from "react-icons/gi";
|
||||
import { toast } from "sonner";
|
||||
@@ -54,6 +53,8 @@ interface SettingsPanelProps {
|
||||
onZoomDepthChange?: (depth: ZoomDepth) => void;
|
||||
selectedZoomId?: string | null;
|
||||
onZoomDelete?: (id: string) => void;
|
||||
selectedTrimId?: string | null;
|
||||
onTrimDelete?: (id: string) => void;
|
||||
shadowIntensity?: number;
|
||||
onShadowChange?: (intensity: number) => void;
|
||||
showBlur?: boolean;
|
||||
@@ -98,6 +99,8 @@ export function SettingsPanel({
|
||||
onZoomDepthChange,
|
||||
selectedZoomId,
|
||||
onZoomDelete,
|
||||
selectedTrimId,
|
||||
onTrimDelete,
|
||||
shadowIntensity = 0,
|
||||
onShadowChange,
|
||||
showBlur,
|
||||
@@ -139,11 +142,18 @@ export function SettingsPanel({
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [])
|
||||
const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 });
|
||||
const colorPalette = [
|
||||
'#FF0000', '#FFD700', '#00FF00', '#FFFFFF', '#0000FF', '#FF6B00',
|
||||
'#9B59B6', '#E91E63', '#00BCD4', '#FF5722', '#8BC34A', '#FFC107',
|
||||
'#34B27B', '#000000', '#607D8B', '#795548',
|
||||
];
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#ADADAD');
|
||||
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
|
||||
const [showCropDropdown, setShowCropDropdown] = useState(false);
|
||||
|
||||
const zoomEnabled = Boolean(selectedZoomDepth);
|
||||
const trimEnabled = Boolean(selectedTrimId);
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (selectedZoomId && onZoomDelete) {
|
||||
@@ -151,6 +161,12 @@ export function SettingsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrimDeleteClick = () => {
|
||||
if (selectedTrimId && onTrimDelete) {
|
||||
onTrimDelete(selectedTrimId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -270,6 +286,21 @@ export function SettingsPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trim Delete Section */}
|
||||
<div className="mb-6">
|
||||
{trimEnabled && (
|
||||
<Button
|
||||
onClick={handleTrimDeleteClick}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Trim Region
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Motion Blur Switch */}
|
||||
@@ -482,14 +513,17 @@ export function SettingsPanel({
|
||||
|
||||
<TabsContent value="color" className="mt-0 px-2">
|
||||
<div className="p-1">
|
||||
<Colorful
|
||||
color={hsva}
|
||||
disableAlpha={true}
|
||||
<Block
|
||||
color={selectedColor}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
setHsva(color.hsva);
|
||||
onWallpaperChange(hsvaToHex(color.hsva));
|
||||
setSelectedColor(color.hex);
|
||||
onWallpaperChange(color.hex);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
style={{ width: '100%', borderRadius: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -529,7 +563,7 @@ export function SettingsPanel({
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Medium
|
||||
Low
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('good')}
|
||||
@@ -540,7 +574,7 @@ export function SettingsPanel({
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Good
|
||||
Medium
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('source')}
|
||||
@@ -551,7 +585,7 @@ export function SettingsPanel({
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Source
|
||||
High
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "./types";
|
||||
import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter";
|
||||
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
|
||||
@@ -104,6 +105,23 @@ export default function VideoEditor() {
|
||||
loadVideo();
|
||||
}, []);
|
||||
|
||||
// Initialize default wallpaper with resolved asset path
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const resolvedPath = await getAssetPath('wallpapers/wallpaper1.jpg');
|
||||
if (mounted) {
|
||||
setWallpaper(resolvedPath);
|
||||
}
|
||||
} catch (err) {
|
||||
// If resolution fails, keep the fallback
|
||||
console.warn('Failed to resolve default wallpaper path:', err);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false };
|
||||
}, []);
|
||||
|
||||
function togglePlayPause() {
|
||||
const playback = videoPlaybackRef.current;
|
||||
const video = playback?.video;
|
||||
@@ -735,6 +753,8 @@ export default function VideoEditor() {
|
||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
shadowIntensity={shadowIntensity}
|
||||
onShadowChange={setShadowIntensity}
|
||||
showBlur={showBlur}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
@@ -520,6 +521,18 @@ export default function TimelineEditor({
|
||||
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
|
||||
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
||||
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
||||
const [shortcuts, setShortcuts] = useState({
|
||||
pan: 'Shift + Ctrl + Scroll',
|
||||
zoom: 'Ctrl + Scroll'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
|
||||
formatShortcut(['mod', 'Scroll']).then(zoom => {
|
||||
setShortcuts({ pan, zoom });
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Add keyframe at current playhead position
|
||||
const addKeyframe = useCallback(() => {
|
||||
@@ -898,11 +911,11 @@ export default function TimelineEditor({
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⇧ + ⌘ + Scroll</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.pan}</kbd>
|
||||
<span>Pan</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⌘ + Scroll</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.zoom}</kbd>
|
||||
<span>Zoom</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
let cachedPlatform: string | null = null;
|
||||
|
||||
/**
|
||||
* Gets the current platform from Electron
|
||||
*/
|
||||
const getPlatform = async (): Promise<string> => {
|
||||
if (cachedPlatform) return cachedPlatform;
|
||||
|
||||
try {
|
||||
const platform = await window.electronAPI.getPlatform();
|
||||
cachedPlatform = platform;
|
||||
return platform;
|
||||
} catch (error) {
|
||||
console.warn('Failed to get platform from Electron, falling back to navigator:', error);
|
||||
// Fallback for development/testing
|
||||
let fallbackPlatform = 'win32';
|
||||
if (typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform)) {
|
||||
fallbackPlatform = 'darwin';
|
||||
}
|
||||
cachedPlatform = fallbackPlatform;
|
||||
return fallbackPlatform;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects if the current platform is macOS
|
||||
*/
|
||||
export const isMac = async (): Promise<boolean> => {
|
||||
const platform = await getPlatform();
|
||||
return platform === 'darwin';
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the modifier key symbol based on the platform
|
||||
*/
|
||||
export const getModifierKey = async (): Promise<string> => {
|
||||
return (await isMac()) ? '⌘' : 'Ctrl';
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the shift key symbol based on the platform
|
||||
*/
|
||||
export const getShiftKey = async (): Promise<string> => {
|
||||
return (await isMac()) ? '⇧' : 'Shift';
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a keyboard shortcut for display based on the platform
|
||||
* @param keys Array of key combinations (e.g., ['mod', 'D'] or ['shift', 'mod', 'Scroll'])
|
||||
*/
|
||||
export const formatShortcut = async (keys: string[]): Promise<string> => {
|
||||
const isMacPlatform = await isMac();
|
||||
return keys
|
||||
.map(key => {
|
||||
if (key.toLowerCase() === 'mod') return isMacPlatform ? '⌘' : 'Ctrl';
|
||||
if (key.toLowerCase() === 'shift') return isMacPlatform ? '⇧' : 'Shift';
|
||||
return key;
|
||||
})
|
||||
.join(' + ');
|
||||
};
|
||||
Reference in New Issue
Block a user