Merge remote-tracking branch 'origin/main' into feature/undo-redo
# Conflicts: # src/components/video-editor/VideoEditor.tsx
This commit is contained in:
@@ -9,6 +9,7 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
|
||||
+410
-333
@@ -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 +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)});
|
||||
|
||||
Vendored
+11
-5
@@ -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
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ export function LaunchWindow() {
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
|
||||
if (result.cancelled) {
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -86,6 +86,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[];
|
||||
@@ -148,6 +150,8 @@ export function SettingsPanel({
|
||||
gifSizePreset = 'medium',
|
||||
onGifSizePresetChange,
|
||||
gifOutputDimensions = { width: 1280, height: 720 },
|
||||
onSaveProject,
|
||||
onLoadProject,
|
||||
onExport,
|
||||
selectedAnnotationId,
|
||||
annotationRegions = [],
|
||||
@@ -748,6 +752,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"
|
||||
|
||||
@@ -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,14 @@ import PlaybackControls from "./PlaybackControls";
|
||||
import TimelineEditor from "./timeline/TimelineEditor";
|
||||
import { SettingsPanel } from "./SettingsPanel";
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
import {
|
||||
createProjectData,
|
||||
deriveNextId,
|
||||
fromFileUrl,
|
||||
normalizeProjectEditor,
|
||||
toFileUrl,
|
||||
validateProjectData,
|
||||
} from "./projectPersistence";
|
||||
|
||||
import type { Span } from "dnd-timeline";
|
||||
import {
|
||||
@@ -32,14 +40,10 @@ import {
|
||||
} from "./types";
|
||||
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
|
||||
import { getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { useEditorHistory, INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
|
||||
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 { state: editorState, pushState, updateState, commitState, undo, redo } =
|
||||
useEditorHistory(INITIAL_EDITOR_STATE);
|
||||
@@ -52,6 +56,8 @@ export default function VideoEditor() {
|
||||
|
||||
// ── Non-undoable state
|
||||
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);
|
||||
@@ -71,6 +77,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);
|
||||
@@ -82,43 +89,289 @@ export default function VideoEditor() {
|
||||
const nextAnnotationZIndexRef = useRef(1);
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
|
||||
const toFileUrl = (filePath: string): string => {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
return normalized.match(/^[a-zA-Z]:/) ? `file:///${normalized}` : `file://${normalized}`;
|
||||
};
|
||||
|
||||
const fromFileUrl = (fileUrl: string): string => {
|
||||
if (!fileUrl.startsWith('file://')) {
|
||||
return fileUrl;
|
||||
const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => {
|
||||
if (!validateProjectData(candidate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
pushState({
|
||||
wallpaper: normalizedEditor.wallpaper,
|
||||
shadowIntensity: normalizedEditor.shadowIntensity,
|
||||
showBlur: normalizedEditor.showBlur,
|
||||
motionBlurEnabled: normalizedEditor.motionBlurEnabled,
|
||||
borderRadius: normalizedEditor.borderRadius,
|
||||
padding: normalizedEditor.padding,
|
||||
cropRegion: normalizedEditor.cropRegion,
|
||||
zoomRegions: normalizedEditor.zoomRegions,
|
||||
trimRegions: normalizedEditor.trimRegions,
|
||||
speedRegions: normalizedEditor.speedRegions,
|
||||
annotationRegions: normalizedEditor.annotationRegions,
|
||||
aspectRatio: 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;
|
||||
}, [pushState]);
|
||||
|
||||
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;
|
||||
@@ -151,15 +404,6 @@ export default function VideoEditor() {
|
||||
};
|
||||
}, [videoPath]);
|
||||
|
||||
// Initialize default wallpaper with resolved asset path
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
getAssetPath('wallpapers/wallpaper1.jpg')
|
||||
.then((path) => { if (mounted) updateState({ wallpaper: path }); })
|
||||
.catch((err) => console.warn('Failed to resolve default wallpaper path:', err));
|
||||
return () => { mounted = false; };
|
||||
}, [updateState]);
|
||||
|
||||
function togglePlayPause() {
|
||||
const playback = videoPlaybackRef.current;
|
||||
const video = playback?.video;
|
||||
@@ -581,8 +825,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 {
|
||||
@@ -707,8 +951,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 {
|
||||
@@ -778,7 +1022,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);
|
||||
@@ -796,7 +1040,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>
|
||||
);
|
||||
}
|
||||
@@ -822,6 +1075,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 || ''}
|
||||
@@ -965,6 +1219,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,
|
||||
};
|
||||
}
|
||||
Vendored
+37
-11
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user