Merge pull request #153 from yusufm/projectsave

Add project save/load files with File menu integration
This commit is contained in:
Sid
2026-03-02 18:36:10 -08:00
committed by GitHub
12 changed files with 1346 additions and 466 deletions
+1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.local
+410 -333
View File
@@ -1,440 +1,517 @@
import { ipcMain, screen, BrowserWindow, app, desktopCapturer, shell, dialog, nativeImage, Tray, Menu } from "electron";
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs/promises";
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.join(__dirname$1, "..");
const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"];
const RENDERER_DIST$1 = path.join(APP_ROOT, "dist");
let hudOverlayWindow = null;
ipcMain.on("hud-overlay-hide", () => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
}
import { ipcMain as i, screen as R, BrowserWindow as x, app as f, desktopCapturer as ee, shell as te, dialog as I, nativeImage as re, Tray as oe, Menu as V } from "electron";
import { fileURLToPath as B } from "node:url";
import a from "node:path";
import p from "node:fs/promises";
const N = a.dirname(B(import.meta.url)), se = a.join(N, ".."), T = process.env.VITE_DEV_SERVER_URL, W = a.join(se, "dist");
let O = null;
i.on("hud-overlay-hide", () => {
O && !O.isDestroyed() && O.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 ne() {
const o = R.getPrimaryDisplay(), { workArea: r } = o, c = 500, g = 100, y = Math.floor(r.x + (r.width - c) / 2), t = Math.floor(r.y + r.height - g - 5), e = new x({
width: c,
height: g,
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: t,
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: a.join(N, "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());
}), O = e, e.on("closed", () => {
O === e && (O = null);
}), T ? e.loadURL(T + "?windowType=hud-overlay") : e.loadFile(a.join(W, "index.html"), {
query: { windowType: "hud-overlay" }
}), e;
}
function createEditorWindow() {
const isMac = process.platform === "darwin";
const win = new BrowserWindow({
function ae() {
const o = process.platform === "darwin", r = new x({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
...isMac && {
...o && {
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: a.join(N, "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());
}), T ? r.loadURL(T + "?windowType=editor") : r.loadFile(a.join(W, "index.html"), {
query: { windowType: "editor" }
}), r;
}
function createSourceSelectorWindow() {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
const win = new BrowserWindow({
function ie() {
const { width: o, height: r } = R.getPrimaryDisplay().workAreaSize, c = new x({
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((o - 620) / 2),
y: Math.round((r - 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: a.join(N, "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 T ? c.loadURL(T + "?windowType=source-selector") : c.loadFile(a.join(W, "index.html"), {
query: { windowType: "source-selector" }
}), c;
}
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
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();
const D = "openscreen", U = a.join(f.getPath("userData"), "shortcuts.json");
let v = null, w = null, m = null;
function z(o) {
return a.resolve(o);
}
function le(o) {
return !o || !m ? !1 : z(o) === z(m);
}
const ce = 1, ue = 100, de = 60 * 60 * 10;
let C = null, G = 0, F = [], _ = [];
function M(o, r, c) {
return Math.min(c, Math.max(r, o));
}
function J() {
C && (clearInterval(C), C = null);
}
function $() {
const o = R.getCursorScreenPoint(), r = Number(v == null ? void 0 : v.display_id), y = ((Number.isFinite(r) ? R.getAllDisplays().find((l) => l.id === r) ?? null : null) ?? R.getDisplayNearestPoint(o)).bounds, t = Math.max(1, y.width), e = Math.max(1, y.height), n = M((o.x - y.x) / t, 0, 1), s = M((o.y - y.y) / e, 0, 1);
F.push({
timeMs: Math.max(0, Date.now() - G),
cx: n,
cy: s
}), F.length > de && F.shift();
}
function pe(o, r, c, g, y) {
i.handle("get-sources", async (t, e) => (await ee.getSources(e)).map((s) => ({
id: s.id,
name: s.name,
display_id: s.display_id,
thumbnail: s.thumbnail ? s.thumbnail.toDataURL() : null,
appIcon: s.appIcon ? s.appIcon.toDataURL() : null
}))), i.handle("select-source", (t, e) => {
v = e;
const n = g();
return n && n.close(), v;
}), i.handle("get-selected-source", () => v), i.handle("open-source-selector", () => {
const t = g();
if (t) {
t.focus();
return;
}
createSourceSelectorWindow2();
});
ipcMain.handle("switch-to-editor", () => {
const mainWin = getMainWindow();
if (mainWin) {
mainWin.close();
}
createEditorWindow2();
});
ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => {
r();
}), i.handle("switch-to-editor", () => {
const t = c();
t && t.close(), o();
}), i.handle("store-recorded-video", async (t, e, n) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
currentVideoPath = videoPath;
return {
success: true,
path: videoPath,
const s = a.join(S, n);
await p.writeFile(s, Buffer.from(e)), w = s, m = null;
const l = `${s}.cursor.json`;
return _.length > 0 && await p.writeFile(
l,
JSON.stringify({ version: ce, samples: _ }, null, 2),
"utf-8"
), _ = [], {
success: !0,
path: s,
message: "Video stored successfully"
};
} catch (error) {
console.error("Failed to store video:", error);
return {
success: false,
} catch (s) {
return console.error("Failed to store video:", s), {
success: !1,
message: "Failed to store video",
error: String(error)
error: String(s)
};
}
});
ipcMain.handle("get-recorded-video-path", async () => {
}), i.handle("get-recorded-video-path", async () => {
try {
const files = await fs.readdir(RECORDINGS_DIR);
const videoFiles = files.filter((file) => file.endsWith(".webm"));
if (videoFiles.length === 0) {
return { success: false, message: "No recorded video found" };
}
const latestVideo = videoFiles.sort().reverse()[0];
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
return { success: true, path: videoPath };
} catch (error) {
console.error("Failed to get video path:", error);
return { success: false, message: "Failed to get video path", error: String(error) };
const e = (await p.readdir(S)).filter((l) => l.endsWith(".webm"));
if (e.length === 0)
return { success: !1, message: "No recorded video found" };
const n = e.sort().reverse()[0];
return { success: !0, path: a.join(S, n) };
} catch (t) {
return console.error("Failed to get video path:", t), { success: !1, message: "Failed to get video path", error: String(t) };
}
});
ipcMain.handle("set-recording-state", (_, recording) => {
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
}
});
ipcMain.handle("open-external-url", async (_, url) => {
}), i.handle("set-recording-state", (t, e) => {
e ? (J(), F = [], _ = [], G = Date.now(), $(), C = setInterval($, ue)) : (J(), _ = [...F], F = []), y && y(e, (v || { name: "Screen" }).name);
}), i.handle("get-cursor-telemetry", async (t, e) => {
const n = e ?? w;
if (!n)
return { success: !0, samples: [] };
const s = `${n}.cursor.json`;
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error("Failed to open URL:", error);
return { success: false, error: String(error) };
const l = await p.readFile(s, "utf-8"), d = JSON.parse(l);
return { success: !0, samples: (Array.isArray(d) ? d : Array.isArray(d == null ? void 0 : d.samples) ? d.samples : []).filter((b) => !!(b && typeof b == "object")).map((b) => {
const h = b;
return {
timeMs: typeof h.timeMs == "number" && Number.isFinite(h.timeMs) ? Math.max(0, h.timeMs) : 0,
cx: typeof h.cx == "number" && Number.isFinite(h.cx) ? M(h.cx, 0, 1) : 0.5,
cy: typeof h.cy == "number" && Number.isFinite(h.cy) ? M(h.cy, 0, 1) : 0.5
};
}).sort((b, h) => b.timeMs - h.timeMs) };
} catch (l) {
return l.code === "ENOENT" ? { success: !0, samples: [] } : (console.error("Failed to load cursor telemetry:", l), { success: !1, message: "Failed to load cursor telemetry", error: String(l), samples: [] });
}
});
ipcMain.handle("get-asset-base-path", () => {
}), i.handle("open-external-url", async (t, e) => {
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 await te.openExternal(e), { success: !0 };
} catch (n) {
return console.error("Failed to open URL:", n), { success: !1, error: String(n) };
}
});
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
}), i.handle("get-asset-base-path", () => {
try {
const isGif = fileName.toLowerCase().endsWith(".gif");
const filters = isGif ? [{ name: "GIF Image", extensions: ["gif"] }] : [{ name: "MP4 Video", extensions: ["mp4"] }];
const result = await dialog.showSaveDialog({
title: isGif ? "Save Exported GIF" : "Save Exported Video",
defaultPath: path.join(app.getPath("downloads"), fileName),
filters,
return f.isPackaged ? a.join(process.resourcesPath, "assets") : a.join(f.getAppPath(), "public", "assets");
} catch (t) {
return console.error("Failed to resolve asset base path:", t), null;
}
}), i.handle("save-exported-video", async (t, e, n) => {
try {
const s = n.toLowerCase().endsWith(".gif"), l = s ? [{ name: "GIF Image", extensions: ["gif"] }] : [{ name: "MP4 Video", extensions: ["mp4"] }], d = await I.showSaveDialog({
title: s ? "Save Exported GIF" : "Save Exported Video",
defaultPath: a.join(f.getPath("downloads"), n),
filters: l,
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 d.canceled || !d.filePath ? {
success: !1,
canceled: !0,
message: "Export canceled"
} : (await p.writeFile(d.filePath, Buffer.from(e)), {
success: !0,
path: d.filePath,
message: "Video exported successfully"
};
} catch (error) {
console.error("Failed to save exported video:", error);
return {
success: false,
});
} catch (s) {
return console.error("Failed to save exported video:", s), {
success: !1,
message: "Failed to save exported video",
error: String(error)
error: String(s)
};
}
});
ipcMain.handle("open-video-file-picker", async () => {
}), i.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
const t = await I.showOpenDialog({
title: "Select Video File",
defaultPath: RECORDINGS_DIR,
defaultPath: S,
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]
};
} catch (error) {
console.error("Failed to open file picker:", error);
return {
success: false,
return t.canceled || t.filePaths.length === 0 ? { success: !1, canceled: !0 } : (m = null, {
success: !0,
path: t.filePaths[0]
});
} catch (t) {
return console.error("Failed to open file picker:", t), {
success: !1,
message: "Failed to open file picker",
error: String(error)
error: String(t)
};
}
});
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 };
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
ipcMain.handle("get-shortcuts", async () => {
}), i.handle("save-project-file", async (t, e, n, s) => {
try {
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
return JSON.parse(data);
const l = le(s) ? s : null;
if (l)
return await p.writeFile(l, JSON.stringify(e, null, 2), "utf-8"), m = l, {
success: !0,
path: l,
message: "Project saved successfully"
};
const d = (n || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, "_"), k = d.endsWith(`.${D}`) ? d : `${d}.${D}`, P = await I.showSaveDialog({
title: "Save OpenScreen Project",
defaultPath: a.join(S, k),
filters: [
{ name: "OpenScreen Project", extensions: [D] },
{ name: "JSON", extensions: ["json"] }
],
properties: ["createDirectory", "showOverwriteConfirmation"]
});
return P.canceled || !P.filePath ? {
success: !1,
canceled: !0,
message: "Save project canceled"
} : (await p.writeFile(P.filePath, JSON.stringify(e, null, 2), "utf-8"), m = P.filePath, {
success: !0,
path: P.filePath,
message: "Project saved successfully"
});
} catch (l) {
return console.error("Failed to save project file:", l), {
success: !1,
message: "Failed to save project file",
error: String(l)
};
}
}), i.handle("load-project-file", async () => {
try {
const t = await I.showOpenDialog({
title: "Open OpenScreen Project",
defaultPath: S,
filters: [
{ name: "OpenScreen Project", extensions: [D] },
{ name: "JSON", extensions: ["json"] },
{ name: "All Files", extensions: ["*"] }
],
properties: ["openFile"]
});
if (t.canceled || t.filePaths.length === 0)
return { success: !1, canceled: !0, message: "Open project canceled" };
const e = t.filePaths[0], n = await p.readFile(e, "utf-8"), s = JSON.parse(n);
return m = e, s && typeof s == "object" && typeof s.videoPath == "string" && (w = s.videoPath), {
success: !0,
path: e,
project: s
};
} catch (t) {
return console.error("Failed to load project file:", t), {
success: !1,
message: "Failed to load project file",
error: String(t)
};
}
}), i.handle("load-current-project-file", async () => {
try {
if (!m)
return { success: !1, message: "No active project" };
const t = await p.readFile(m, "utf-8"), e = JSON.parse(t);
return e && typeof e == "object" && typeof e.videoPath == "string" && (w = e.videoPath), {
success: !0,
path: m,
project: e
};
} catch (t) {
return console.error("Failed to load current project file:", t), {
success: !1,
message: "Failed to load current project file",
error: String(t)
};
}
}), i.handle("set-current-video-path", (t, e) => (w = e, m = null, { success: !0 })), i.handle("get-current-video-path", () => w ? { success: !0, path: w } : { success: !1 }), i.handle("clear-current-video-path", () => (w = null, { success: !0 })), i.handle("get-platform", () => process.platform), i.handle("get-shortcuts", async () => {
try {
const t = await p.readFile(U, "utf-8");
return JSON.parse(t);
} catch {
return null;
}
});
ipcMain.handle("save-shortcuts", async (_, shortcuts) => {
}), i.handle("save-shortcuts", async (t, e) => {
try {
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8");
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
return { success: false, error: String(error) };
return await p.writeFile(U, JSON.stringify(e, null, 2), "utf-8"), { success: !0 };
} catch (n) {
return console.error("Failed to save shortcuts:", n), { success: !1, error: String(n) };
}
});
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function ensureRecordingsDir() {
const fe = a.dirname(B(import.meta.url)), S = a.join(f.getPath("userData"), "recordings");
async function he() {
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(S, { recursive: !0 }), console.log("RECORDINGS_DIR:", S), console.log("User Data Path:", f.getPath("userData"));
} catch (o) {
console.error("Failed to create recordings directory:", o);
}
}
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 = "";
const defaultTrayIcon = getTrayIcon("openscreen.png");
const recordingTrayIcon = getTrayIcon("rec-button.png");
function createWindow() {
mainWindow = createHudOverlayWindow();
process.env.APP_ROOT = a.join(fe, "..");
const me = process.env.VITE_DEV_SERVER_URL, Oe = a.join(process.env.APP_ROOT, "dist-electron"), X = a.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = me ? a.join(process.env.APP_ROOT, "public") : X;
let u = null, E = null, j = null, Z = "";
const Q = Y("openscreen.png"), ye = Y("rec-button.png");
function L() {
u = ne();
}
function createTray() {
tray = new Tray(defaultTrayIcon);
function ge(o) {
return o.webContents.getURL().includes("windowType=editor");
}
function getTrayIcon(filename) {
return nativeImage.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)).resize({
function A(o) {
let r = x.getFocusedWindow() ?? u;
if (!r || r.isDestroyed() || !ge(r)) {
if (K(), r = u, !r || r.isDestroyed()) return;
r.webContents.once("did-finish-load", () => {
!r || r.isDestroyed() || r.webContents.send(o);
});
return;
}
r.webContents.send(o);
}
function we() {
const o = process.platform === "darwin", r = [];
o && r.push({
label: f.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" }
]
}), r.push(
{
label: "File",
submenu: [
{
label: "Load Project…",
accelerator: "CmdOrCtrl+O",
click: () => A("menu-load-project")
},
{
label: "Save Project…",
accelerator: "CmdOrCtrl+S",
click: () => A("menu-save-project")
},
{
label: "Save Project As…",
accelerator: "CmdOrCtrl+Shift+S",
click: () => A("menu-save-project-as")
},
...o ? [] : [{ type: "separator" }, { role: "quit" }]
]
},
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" }
]
},
{
label: "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" }
]
},
{
label: "Window",
submenu: o ? [
{ role: "minimize" },
{ role: "zoom" },
{ type: "separator" },
{ role: "front" }
] : [
{ role: "minimize" },
{ role: "close" }
]
}
);
const c = V.buildFromTemplate(r);
V.setApplicationMenu(c);
}
function H() {
j = new oe(Q);
}
function Y(o) {
return re.createFromPath(a.join(process.env.VITE_PUBLIC || X, o)).resize({
width: 24,
height: 24,
quality: "best"
});
}
function updateTrayMenu(recording = false) {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
const menuTemplate = recording ? [
function q(o = !1) {
if (!j) return;
const r = o ? ye : Q, c = o ? `Recording: ${Z}` : "OpenScreen", g = o ? [
{
label: "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
u && !u.isDestroyed() && u.webContents.send("stop-recording-from-tray");
}
}
] : [
{
label: "Open",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.isMinimized() && mainWindow.restore();
} else {
createWindow();
}
u && !u.isDestroyed() ? u.isMinimized() && u.restore() : L();
}
},
{
label: "Quit",
click: () => {
app.quit();
f.quit();
}
}
];
tray.setImage(trayIcon);
tray.setToolTip(trayToolTip);
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
j.setImage(r), j.setToolTip(c), j.setContextMenu(V.buildFromTemplate(g));
}
function createEditorWindowWrapper() {
if (mainWindow) {
mainWindow.close();
mainWindow = null;
}
mainWindow = createEditorWindow();
function K() {
u && (u.close(), u = null), u = ae();
}
function createSourceSelectorWindowWrapper() {
sourceSelectorWindow = createSourceSelectorWindow();
sourceSelectorWindow.on("closed", () => {
sourceSelectorWindow = null;
});
return sourceSelectorWindow;
function Se() {
return E = ie(), E.on("closed", () => {
E = null;
}), E;
}
app.on("window-all-closed", () => {
f.on("window-all-closed", () => {
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
f.on("activate", () => {
x.getAllWindows().length === 0 && L();
});
app.whenReady().then(async () => {
const { ipcMain: ipcMain2 } = await import("electron");
ipcMain2.on("hud-overlay-close", () => {
app.quit();
});
createTray();
updateTrayMenu();
await ensureRecordingsDir();
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording, sourceName) => {
selectedSourceName = sourceName;
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
if (mainWindow) mainWindow.restore();
}
f.whenReady().then(async () => {
const { ipcMain: o } = await import("electron");
o.on("hud-overlay-close", () => {
f.quit();
}), H(), q(), we(), await he(), pe(
K,
Se,
() => u,
() => E,
(r, c) => {
Z = c, j || H(), q(r), r || u && u.restore();
}
);
createWindow();
), L();
});
export {
MAIN_DIST,
RECORDINGS_DIR,
RENDERER_DIST,
VITE_DEV_SERVER_URL
Oe as MAIN_DIST,
S as RECORDINGS_DIR,
X as RENDERER_DIST,
me as VITE_DEV_SERVER_URL
};
+1 -69
View File
@@ -1,69 +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");
},
getPlatform: () => {
return electron.ipcRenderer.invoke("get-platform");
},
getShortcuts: () => {
return electron.ipcRenderer.invoke("get-shortcuts");
},
saveShortcuts: (shortcuts) => {
return electron.ipcRenderer.invoke("save-shortcuts", shortcuts);
}
});
"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),getCursorTelemetry:r=>e.ipcRenderer.invoke("get-cursor-telemetry",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"),saveProjectFile:(r,t,n)=>e.ipcRenderer.invoke("save-project-file",r,t,n),loadProjectFile:()=>e.ipcRenderer.invoke("load-project-file"),loadCurrentProjectFile:()=>e.ipcRenderer.invoke("load-current-project-file"),onMenuLoadProject:r=>{const t=()=>r();return e.ipcRenderer.on("menu-load-project",t),()=>e.ipcRenderer.removeListener("menu-load-project",t)},onMenuSaveProject:r=>{const t=()=>r();return e.ipcRenderer.on("menu-save-project",t),()=>e.ipcRenderer.removeListener("menu-save-project",t)},onMenuSaveProjectAs:r=>{const t=()=>r();return e.ipcRenderer.on("menu-save-project-as",t),()=>e.ipcRenderer.removeListener("menu-save-project-as",t)},getPlatform:()=>e.ipcRenderer.invoke("get-platform"),getShortcuts:()=>e.ipcRenderer.invoke("get-shortcuts"),saveShortcuts:r=>e.ipcRenderer.invoke("save-shortcuts",r)});
+11 -5
View File
@@ -35,11 +35,17 @@ interface Window {
getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; message?: string; error?: string }>
onStopRecordingFromTray: (callback: () => void) => () => void
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean }>
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; cancelled?: boolean }>
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean; error?: string }>
loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }>
loadCurrentProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }>
onMenuLoadProject: (callback: () => void) => () => void
onMenuSaveProject: (callback: () => void) => () => void
onMenuSaveProjectAs: (callback: () => void) => () => void
getPlatform: () => Promise<string>
getShortcuts: () => Promise<Record<string, unknown> | null>
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>
+148 -5
View File
@@ -4,10 +4,28 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import { RECORDINGS_DIR } from '../main'
const PROJECT_FILE_EXTENSION = 'openscreen'
const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json')
let selectedSource: any = null
type SelectedSource = {
name: string
[key: string]: unknown
}
let selectedSource: SelectedSource | null = null
let currentVideoPath: string | null = null
let currentProjectPath: string | null = null
function normalizePath(filePath: string) {
return path.resolve(filePath)
}
function isTrustedProjectPath(filePath?: string | null) {
if (!filePath || !currentProjectPath) {
return false
}
return normalizePath(filePath) === normalizePath(currentProjectPath)
}
const CURSOR_TELEMETRY_VERSION = 1
const CURSOR_SAMPLE_INTERVAL_MS = 100
@@ -78,7 +96,7 @@ export function registerIpcHandlers(
}))
})
ipcMain.handle('select-source', (_, source) => {
ipcMain.handle('select-source', (_, source: SelectedSource) => {
selectedSource = source
const sourceSelectorWin = getSourceSelectorWindow()
if (sourceSelectorWin) {
@@ -115,6 +133,7 @@ export function registerIpcHandlers(
const videoPath = path.join(RECORDINGS_DIR, fileName)
await fs.writeFile(videoPath, Buffer.from(videoData))
currentVideoPath = videoPath;
currentProjectPath = null
const telemetryPath = `${videoPath}.cursor.json`
if (pendingCursorSamples.length > 0) {
@@ -261,8 +280,8 @@ export function registerIpcHandlers(
if (result.canceled || !result.filePath) {
return {
success: false,
cancelled: true,
message: 'Export cancelled'
canceled: true,
message: 'Export canceled'
};
}
@@ -296,9 +315,10 @@ export function registerIpcHandlers(
});
if (result.canceled || result.filePaths.length === 0) {
return { success: false, cancelled: true };
return { success: false, canceled: true };
}
currentProjectPath = null
return {
success: true,
path: result.filePaths[0]
@@ -313,8 +333,131 @@ export function registerIpcHandlers(
}
});
ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
try {
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
? existingProjectPath
: null
if (trustedExistingProjectPath) {
await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8')
currentProjectPath = trustedExistingProjectPath
return {
success: true,
path: trustedExistingProjectPath,
message: 'Project saved successfully'
}
}
const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, '_')
const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`)
? safeName
: `${safeName}.${PROJECT_FILE_EXTENSION}`
const result = await dialog.showSaveDialog({
title: 'Save OpenScreen Project',
defaultPath: path.join(RECORDINGS_DIR, defaultName),
filters: [
{ name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] },
{ name: 'JSON', extensions: ['json'] }
],
properties: ['createDirectory', 'showOverwriteConfirmation']
})
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: 'Save project canceled'
}
}
await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8')
currentProjectPath = result.filePath
return {
success: true,
path: result.filePath,
message: 'Project saved successfully'
}
} catch (error) {
console.error('Failed to save project file:', error)
return {
success: false,
message: 'Failed to save project file',
error: String(error)
}
}
})
ipcMain.handle('load-project-file', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Open OpenScreen Project',
defaultPath: RECORDINGS_DIR,
filters: [
{ name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] },
{ name: 'JSON', extensions: ['json'] },
{ name: 'All Files', extensions: ['*'] }
],
properties: ['openFile']
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true, message: 'Open project canceled' }
}
const filePath = result.filePaths[0]
const content = await fs.readFile(filePath, 'utf-8')
const project = JSON.parse(content)
currentProjectPath = filePath
if (project && typeof project === 'object' && typeof project.videoPath === 'string') {
currentVideoPath = project.videoPath
}
return {
success: true,
path: filePath,
project
}
} catch (error) {
console.error('Failed to load project file:', error)
return {
success: false,
message: 'Failed to load project file',
error: String(error)
}
}
})
ipcMain.handle('load-current-project-file', async () => {
try {
if (!currentProjectPath) {
return { success: false, message: 'No active project' }
}
const content = await fs.readFile(currentProjectPath, 'utf-8')
const project = JSON.parse(content)
if (project && typeof project === 'object' && typeof project.videoPath === 'string') {
currentVideoPath = project.videoPath
}
return {
success: true,
path: currentProjectPath,
project,
}
} catch (error) {
console.error('Failed to load current project file:', error)
return {
success: false,
message: 'Failed to load current project file',
error: String(error),
}
}
})
ipcMain.handle('set-current-video-path', (_, path: string) => {
currentVideoPath = path;
currentProjectPath = null
return { success: true };
});
+112
View File
@@ -53,6 +53,117 @@ function createWindow() {
mainWindow = createHudOverlayWindow()
}
function isEditorWindow(window: BrowserWindow) {
return window.webContents.getURL().includes('windowType=editor')
}
function sendEditorMenuAction(channel: 'menu-load-project' | 'menu-save-project' | 'menu-save-project-as') {
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow
if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
createEditorWindowWrapper()
targetWindow = mainWindow
if (!targetWindow || targetWindow.isDestroyed()) return
targetWindow.webContents.once('did-finish-load', () => {
if (!targetWindow || targetWindow.isDestroyed()) return
targetWindow.webContents.send(channel)
})
return
}
targetWindow.webContents.send(channel)
}
function setupApplicationMenu() {
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = []
if (isMac) {
template.push({
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
})
}
template.push(
{
label: 'File',
submenu: [
{
label: 'Load Project…',
accelerator: 'CmdOrCtrl+O',
click: () => sendEditorMenuAction('menu-load-project'),
},
{
label: 'Save Project…',
accelerator: 'CmdOrCtrl+S',
click: () => sendEditorMenuAction('menu-save-project'),
},
{
label: 'Save Project As…',
accelerator: 'CmdOrCtrl+Shift+S',
click: () => sendEditorMenuAction('menu-save-project-as'),
},
...(isMac ? [] : [{ type: 'separator' as const }, { role: 'quit' as const }]),
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: isMac
? [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
]
: [
{ role: 'minimize' },
{ role: 'close' },
],
},
)
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}
function createTray() {
tray = new Tray(defaultTrayIcon);
}
@@ -145,6 +256,7 @@ app.whenReady().then(async () => {
});
createTray()
updateTrayMenu()
setupApplicationMenu()
// Ensure recordings directory exists
await ensureRecordingsDir()
+25 -1
View File
@@ -63,6 +63,30 @@ contextBridge.exposeInMainWorld('electronAPI', {
clearCurrentVideoPath: () => {
return ipcRenderer.invoke('clear-current-video-path')
},
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
return ipcRenderer.invoke('save-project-file', projectData, suggestedName, existingProjectPath)
},
loadProjectFile: () => {
return ipcRenderer.invoke('load-project-file')
},
loadCurrentProjectFile: () => {
return ipcRenderer.invoke('load-current-project-file')
},
onMenuLoadProject: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('menu-load-project', listener)
return () => ipcRenderer.removeListener('menu-load-project', listener)
},
onMenuSaveProject: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('menu-save-project', listener)
return () => ipcRenderer.removeListener('menu-save-project', listener)
},
onMenuSaveProjectAs: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('menu-save-project-as', listener)
return () => ipcRenderer.removeListener('menu-save-project-as', listener)
},
getPlatform: () => {
return ipcRenderer.invoke('get-platform')
},
@@ -72,4 +96,4 @@ contextBridge.exposeInMainWorld('electronAPI', {
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke('save-shortcuts', shortcuts)
},
})
})
+1 -1
View File
@@ -71,7 +71,7 @@ export function LaunchWindow() {
const openVideoFile = async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (result.cancelled) {
if (result.canceled) {
return;
}
+26 -1
View File
@@ -7,7 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import Block from '@uiw/react-color-block';
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette } from "lucide-react";
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen } from "lucide-react";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types";
import { SPEED_OPTIONS } from "./types";
@@ -83,6 +83,8 @@ interface SettingsPanelProps {
gifSizePreset?: GifSizePreset;
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onSaveProject?: () => void;
onLoadProject?: () => void;
onExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
@@ -142,6 +144,8 @@ export function SettingsPanel({
gifSizePreset = 'medium',
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
onSaveProject,
onLoadProject,
onExport,
selectedAnnotationId,
annotationRegions = [],
@@ -739,6 +743,27 @@ export function SettingsPanel({
</div>
)}
<div className="grid grid-cols-2 gap-2 mb-2">
<Button
type="button"
variant="outline"
onClick={onLoadProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<FolderOpen className="w-3.5 h-3.5" />
Load Project
</Button>
<Button
type="button"
variant="outline"
onClick={onSaveProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<Save className="w-3.5 h-3.5" />
Save Project
</Button>
</div>
<Button
type="button"
size="lg"
+294 -40
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
@@ -10,6 +10,15 @@ import PlaybackControls from "./PlaybackControls";
import TimelineEditor from "./timeline/TimelineEditor";
import { SettingsPanel } from "./SettingsPanel";
import { ExportDialog } from "./ExportDialog";
import {
WALLPAPER_PATHS,
createProjectData,
deriveNextId,
fromFileUrl,
normalizeProjectEditor,
toFileUrl,
validateProjectData,
} from "./projectPersistence";
import type { Span } from "dnd-timeline";
import {
@@ -38,11 +47,10 @@ import { getAssetPath } from "@/lib/assetPath";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
const WALLPAPER_COUNT = 18;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
export default function VideoEditor() {
const [videoPath, setVideoPath] = useState<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -74,6 +82,7 @@ export default function VideoEditor() {
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
const [gifLoop, setGifLoop] = useState(true);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>('medium');
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
@@ -85,54 +94,287 @@ export default function VideoEditor() {
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
const exporterRef = useRef<VideoExporter | null>(null);
// Helper to convert file path to proper file:// URL
const toFileUrl = (filePath: string): string => {
// Normalize path separators to forward slashes
const normalized = filePath.replace(/\\/g, '/');
// Check if it's a Windows absolute path (e.g., C:/Users/...)
if (normalized.match(/^[a-zA-Z]:/)) {
const fileUrl = `file:///${normalized}`;
return fileUrl;
const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => {
if (!validateProjectData(candidate)) {
return false;
}
// Unix-style absolute path
const fileUrl = `file://${normalized}`;
return fileUrl;
};
const fromFileUrl = (fileUrl: string): string => {
if (!fileUrl.startsWith('file://')) {
return fileUrl;
}
const project = candidate;
const sourcePath = project.videoPath;
const normalizedEditor = normalizeProjectEditor(project.editor);
try {
const url = new URL(fileUrl);
return decodeURIComponent(url.pathname);
videoPlaybackRef.current?.pause();
} catch {
return fileUrl.replace(/^file:\/\//, '');
// no-op
}
};
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setCurrentProjectPath(path ?? null);
setWallpaper(normalizedEditor.wallpaper);
setShadowIntensity(normalizedEditor.shadowIntensity);
setShowBlur(normalizedEditor.showBlur);
setMotionBlurEnabled(normalizedEditor.motionBlurEnabled);
setBorderRadius(normalizedEditor.borderRadius);
setPadding(normalizedEditor.padding);
setCropRegion(normalizedEditor.cropRegion);
setZoomRegions(normalizedEditor.zoomRegions);
setTrimRegions(normalizedEditor.trimRegions);
setSpeedRegions(normalizedEditor.speedRegions);
setAnnotationRegions(normalizedEditor.annotationRegions);
setAspectRatio(normalizedEditor.aspectRatio);
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
setGifFrameRate(normalizedEditor.gifFrameRate);
setGifLoop(normalizedEditor.gifLoop);
setGifSizePreset(normalizedEditor.gifSizePreset);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
nextZoomIdRef.current = deriveNextId("zoom", normalizedEditor.zoomRegions.map((region) => region.id));
nextTrimIdRef.current = deriveNextId("trim", normalizedEditor.trimRegions.map((region) => region.id));
nextSpeedIdRef.current = deriveNextId("speed", normalizedEditor.speedRegions.map((region) => region.id));
nextAnnotationIdRef.current = deriveNextId(
"annotation",
normalizedEditor.annotationRegions.map((region) => region.id),
);
nextAnnotationZIndexRef.current =
normalizedEditor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1;
setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor)));
return true;
}, []);
const currentProjectSnapshot = useMemo(() => {
const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!sourcePath) {
return null;
}
return JSON.stringify(
createProjectData(sourcePath, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
}),
);
}, [
videoPath,
videoSourcePath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
const hasUnsavedChanges = Boolean(
currentProjectPath &&
currentProjectSnapshot &&
lastSavedSnapshot &&
currentProjectSnapshot !== lastSavedSnapshot,
);
useEffect(() => {
async function loadVideo() {
async function loadInitialData() {
try {
const currentProjectResult = await window.electronAPI.loadCurrentProjectFile();
if (currentProjectResult.success && currentProjectResult.project) {
const restored = await applyLoadedProject(
currentProjectResult.project,
currentProjectResult.path ?? null,
);
if (restored) {
return;
}
}
const result = await window.electronAPI.getCurrentVideoPath();
if (result.success && result.path) {
const videoUrl = toFileUrl(result.path);
setVideoPath(videoUrl);
setVideoSourcePath(result.path);
setVideoPath(toFileUrl(result.path));
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
} else {
setError('No video to load. Please record or select a video.');
setError("No video to load. Please record or select a video.");
}
} catch (err) {
setError('Error loading video: ' + String(err));
setError("Error loading video: " + String(err));
} finally {
setLoading(false);
}
}
loadVideo();
}, []);
loadInitialData();
}, [applyLoadedProject]);
const saveProject = useCallback(async (forceSaveAs: boolean) => {
if (!videoPath) {
toast.error('No video loaded');
return;
}
const sourcePath = videoSourcePath ?? fromFileUrl(videoPath);
if (!sourcePath) {
toast.error('Unable to determine source video path');
return;
}
const projectData = createProjectData(sourcePath, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
});
const fileNameBase = sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || `project-${Date.now()}`;
const projectSnapshot = JSON.stringify(projectData);
const result = await window.electronAPI.saveProjectFile(
projectData,
fileNameBase,
forceSaveAs ? undefined : currentProjectPath ?? undefined,
);
if (result.canceled) {
toast.info("Project save canceled");
return;
}
if (!result.success) {
toast.error(result.message || 'Failed to save project');
return;
}
if (result.path) {
setCurrentProjectPath(result.path);
}
setLastSavedSnapshot(projectSnapshot);
toast.success(`Project saved to ${result.path}`);
}, [
videoPath,
videoSourcePath,
currentProjectPath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasUnsavedChanges) {
return;
}
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
const handleSaveProject = useCallback(async () => {
await saveProject(false);
}, [saveProject]);
const handleSaveProjectAs = useCallback(async () => {
await saveProject(true);
}, [saveProject]);
const handleLoadProject = useCallback(async () => {
const result = await window.electronAPI.loadProjectFile();
if (result.canceled) {
return;
}
if (!result.success) {
toast.error(result.message || 'Failed to load project');
return;
}
const restored = await applyLoadedProject(result.project, result.path ?? null);
if (!restored) {
toast.error('Invalid project file format');
return;
}
toast.success(`Project loaded from ${result.path}`);
}, [applyLoadedProject]);
useEffect(() => {
const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject);
const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs);
return () => {
removeLoadListener?.();
removeSaveListener?.();
removeSaveAsListener?.();
};
}, [handleLoadProject, handleSaveProject, handleSaveProjectAs]);
useEffect(() => {
let mounted = true;
@@ -640,8 +882,8 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.cancelled) {
toast.info('Export cancelled');
if (saveResult.canceled) {
toast.info('Export canceled');
} else if (saveResult.success) {
toast.success(`GIF exported successfully to ${saveResult.path}`);
} else {
@@ -766,8 +1008,8 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.cancelled) {
toast.info('Export cancelled');
if (saveResult.canceled) {
toast.info('Export canceled');
} else if (saveResult.success) {
toast.success(`Video exported successfully to ${saveResult.path}`);
} else {
@@ -837,7 +1079,7 @@ export default function VideoEditor() {
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
exporterRef.current.cancel();
toast.info('Export cancelled');
toast.info('Export canceled');
setShowExportDialog(false);
setIsExporting(false);
setExportProgress(null);
@@ -855,7 +1097,16 @@ export default function VideoEditor() {
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-destructive">{error}</div>
<div className="flex flex-col items-center gap-3">
<div className="text-destructive">{error}</div>
<button
type="button"
onClick={handleLoadProject}
className="px-3 py-1.5 rounded-md bg-[#34B27B] text-white text-sm hover:bg-[#34B27B]/90"
>
Load Project File
</button>
</div>
</div>
);
}
@@ -881,6 +1132,7 @@ export default function VideoEditor() {
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', margin: '6px 0 0' }}>
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: getAspectRatioValue(aspectRatio), maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
<VideoPlayback
key={videoPath || 'no-video'}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ''}
@@ -1020,6 +1272,8 @@ export default function VideoEditor() {
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
onSaveProject={handleSaveProject}
onLoadProject={handleLoadProject}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={selectedSpeedId ? speedRegions.find(r => r.id === selectedSpeedId)?.speed ?? null : null}
onSpeedChange={handleSpeedChange}
@@ -0,0 +1,280 @@
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import {
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_CROP_REGION,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_FIGURE_DATA,
DEFAULT_ZOOM_DEPTH,
type AnnotationRegion,
type CropRegion,
type SpeedRegion,
type TrimRegion,
type ZoomRegion,
} from "./types";
const WALLPAPER_COUNT = 18;
export const WALLPAPER_PATHS = Array.from(
{ length: WALLPAPER_COUNT },
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
);
export const PROJECT_VERSION = 1;
export interface ProjectEditorState {
wallpaper: string;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled: boolean;
borderRadius: number;
padding: number;
cropRegion: CropRegion;
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
}
export interface EditorProjectData {
version: number;
videoPath: string;
editor: ProjectEditorState;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function toFileUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
if (normalized.match(/^[a-zA-Z]:/)) {
return `file:///${normalized}`;
}
return `file://${normalized}`;
}
export function fromFileUrl(fileUrl: string): string {
if (!fileUrl.startsWith("file://")) {
return fileUrl;
}
try {
const url = new URL(fileUrl);
return decodeURIComponent(url.pathname);
} catch {
return fileUrl.replace(/^file:\/\//, "");
}
}
export function deriveNextId(prefix: string, ids: string[]): number {
const max = ids.reduce((acc, id) => {
const match = id.match(new RegExp(`^${prefix}-(\\d+)$`));
if (!match) return acc;
const value = Number(match[1]);
return Number.isFinite(value) ? Math.max(acc, value) : acc;
}, 0);
return max + 1;
}
export function validateProjectData(candidate: unknown): candidate is EditorProjectData {
if (!candidate || typeof candidate !== "object") return false;
const project = candidate as Partial<EditorProjectData>;
if (typeof project.version !== "number") return false;
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
if (!project.editor || typeof project.editor !== "object") return false;
return true;
}
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
? editor.zoomRegions
.filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string"))
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
id: region.id,
startMs,
endMs,
depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH,
focus: {
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
};
})
: [];
const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions)
? editor.trimRegions
.filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string"))
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
id: region.id,
startMs,
endMs,
};
})
: [];
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
? editor.speedRegions
.filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string"))
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const speed =
region.speed === 0.25 ||
region.speed === 0.5 ||
region.speed === 0.75 ||
region.speed === 1.25 ||
region.speed === 1.5 ||
region.speed === 1.75 ||
region.speed === 2
? region.speed
: DEFAULT_PLAYBACK_SPEED;
return {
id: region.id,
startMs,
endMs,
speed,
};
})
: [];
const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions)
? editor.annotationRegions
.filter((region): region is AnnotationRegion => Boolean(region && typeof region.id === "string"))
.map((region, index) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
id: region.id,
startMs,
endMs,
type: region.type === "image" || region.type === "figure" ? region.type : "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
position: {
x: clamp(
isFiniteNumber(region.position?.x) ? region.position.x : DEFAULT_ANNOTATION_POSITION.x,
0,
100,
),
y: clamp(
isFiniteNumber(region.position?.y) ? region.position.y : DEFAULT_ANNOTATION_POSITION.y,
0,
100,
),
},
size: {
width: clamp(
isFiniteNumber(region.size?.width) ? region.size.width : DEFAULT_ANNOTATION_SIZE.width,
1,
200,
),
height: clamp(
isFiniteNumber(region.size?.height) ? region.size.height : DEFAULT_ANNOTATION_SIZE.height,
1,
200,
),
},
style: {
...DEFAULT_ANNOTATION_STYLE,
...(region.style && typeof region.style === "object" ? region.style : {}),
},
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
figureData: region.figureData
? {
...DEFAULT_FIGURE_DATA,
...region.figureData,
}
: undefined,
};
})
: [];
const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x : DEFAULT_CROP_REGION.x;
const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y : DEFAULT_CROP_REGION.y;
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width : DEFAULT_CROP_REGION.width;
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
? editor.cropRegion.height
: DEFAULT_CROP_REGION.height;
const cropX = clamp(rawCropX, 0, 1);
const cropY = clamp(rawCropY, 0, 1);
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
motionBlurEnabled: typeof editor.motionBlurEnabled === "boolean" ? editor.motionBlurEnabled : false,
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
cropRegion: {
x: cropX,
y: cropY,
width: cropWidth,
height: cropHeight,
},
zoomRegions: normalizedZoomRegions,
trimRegions: normalizedTrimRegions,
speedRegions: normalizedSpeedRegions,
annotationRegions: normalizedAnnotationRegions,
aspectRatio: editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality : "good",
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
gifFrameRate:
editor.gifFrameRate === 15 ||
editor.gifFrameRate === 20 ||
editor.gifFrameRate === 25 ||
editor.gifFrameRate === 30
? editor.gifFrameRate
: 15,
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
gifSizePreset:
editor.gifSizePreset === "medium" || editor.gifSizePreset === "large" || editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
};
}
export function createProjectData(videoPath: string, editor: ProjectEditorState): EditorProjectData {
return {
version: PROJECT_VERSION,
videoPath,
editor,
};
}
+37 -11
View File
@@ -44,15 +44,41 @@ interface Window {
}>
onStopRecordingFromTray: (callback: () => void) => () => void
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
success: boolean
path?: string
message?: string
cancelled?: boolean
}>
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; cancelled?: boolean }>
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
success: boolean
path?: string
message?: string
canceled?: boolean
}>
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{
success: boolean
path?: string
message?: string
canceled?: boolean
error?: string
}>
loadProjectFile: () => Promise<{
success: boolean
path?: string
project?: unknown
message?: string
canceled?: boolean
error?: string
}>
loadCurrentProjectFile: () => Promise<{
success: boolean
path?: string
project?: unknown
message?: string
canceled?: boolean
error?: string
}>
onMenuLoadProject: (callback: () => void) => () => void
onMenuSaveProject: (callback: () => void) => () => void
onMenuSaveProjectAs: (callback: () => void) => () => void
}
}
}