diff --git a/.gitignore b/.gitignore index 3913db1..680ad3b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ lerna-debug.log* node_modules dist +dist-electron dist-ssr *.local diff --git a/dist-electron/main.js b/dist-electron/main.js index c9416de..03ef7ad 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -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 }; diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index 6355b7b..c06f432 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -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)}); diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a489699..dac1169 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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 getShortcuts: () => Promise | null> saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }> diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index cec3ed9..5432561 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 }; }); diff --git a/electron/main.ts b/electron/main.ts index e40e08b..9efbf73 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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() diff --git a/electron/preload.ts b/electron/preload.ts index a609e7b..c0efd9d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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) }, -}) \ No newline at end of file +}) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 322e17b..40809a0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -71,7 +71,7 @@ export function LaunchWindow() { const openVideoFile = async () => { const result = await window.electronAPI.openVideoFilePicker(); - if (result.cancelled) { + if (result.canceled) { return; } diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 3aa6222..7b63c18 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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({ )} +
+ + +
+ + ); } @@ -822,6 +1075,7 @@ export default function VideoEditor() {
r.id === selectedSpeedId)?.speed ?? null : null} onSpeedChange={handleSpeedChange} diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts new file mode 100644 index 0000000..32b406a --- /dev/null +++ b/src/components/video-editor/projectPersistence.ts @@ -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; + 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 { + const validAspectRatios = new Set(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, + }; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 08c5eb7..31bf196 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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 } -} \ No newline at end of file +}