Merge branch 'main' into feature/reveal-export-folder

This commit is contained in:
SaiVaraprasad Medapati
2026-03-04 19:33:39 +05:30
committed by GitHub
36 changed files with 16897 additions and 15846 deletions
-18
View File
@@ -1,18 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
+1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.local
+164
View File
@@ -0,0 +1,164 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": false },
"formatter": {
"enabled": true,
"indentStyle": "tab",
"formatWithErrors": true,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noBannedTypes": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error",
"noUselessThisAlias": "error",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInnerDeclarations": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedVariables": "error",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error",
"useIsNan": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"style": {
"noNamespace": "error",
"useArrayLiterals": "error",
"useAsConstAssertion": "error",
"useComponentExportOnlyModules": "warn"
},
"suspicious": {
"noAssignInExpressions": "error",
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noMisleadingInstantiator": "error",
"noNonNullAssertedOptionalChain": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noTsIgnore": "error",
"noUnsafeDeclarationMerging": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error"
}
},
"includes": ["**", "**/dist", "**/.eslintrc.cjs", "**", "**/dist", "**/.eslintrc.cjs"]
},
"javascript": { "formatter": { "quoteStyle": "double" } },
"overrides": [
{
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
},
{
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
}
],
"assist": {
"enabled": true,
"actions": { "source": { "organizeImports": "on" } }
}
}
+416 -321
View File
@@ -1,422 +1,517 @@
import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron";
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs/promises";
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.join(__dirname$1, "..");
const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"];
const RENDERER_DIST$1 = path.join(APP_ROOT, "dist");
let hudOverlayWindow = null;
ipcMain.on("hud-overlay-hide", () => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
}
import { ipcMain as 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;
}
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;
}), i.handle("save-project-file", async (t, e, n, s) => {
try {
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;
}
}), i.handle("save-shortcuts", async (t, e) => {
try {
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 -63
View File
@@ -1,63 +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");
}
});
"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)});
+17 -2
View File
@@ -32,15 +32,24 @@ interface Window {
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>
setRecordingState: (recording: boolean) => Promise<void>
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 }>
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>
revealInFolder: (filePath: string) => Promise<{ success: boolean; error?: string; message?: string }>,
getShortcuts: () => Promise<Record<string, unknown> | null>
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>
hudOverlayHide: () => void;
hudOverlayClose: () => void;
}
@@ -53,3 +62,9 @@ interface ProcessedDesktopSource {
thumbnail: string | null
appIcon: string | null
}
interface CursorTelemetryPoint {
timeMs: number
cx: number
cy: number
}
+282 -6
View File
@@ -1,10 +1,82 @@
import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog } from 'electron'
import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog, screen } from 'electron'
import fs from 'node:fs/promises'
import path from 'node:path'
import { RECORDINGS_DIR } from '../main'
let selectedSource: any = null
const PROJECT_FILE_EXTENSION = 'openscreen'
const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json')
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
const MAX_CURSOR_SAMPLES = 60 * 60 * 10 // 1 hour @ 10Hz
interface CursorTelemetryPoint {
timeMs: number
cx: number
cy: number
}
let cursorCaptureInterval: NodeJS.Timeout | null = null
let cursorCaptureStartTimeMs = 0
let activeCursorSamples: CursorTelemetryPoint[] = []
let pendingCursorSamples: CursorTelemetryPoint[] = []
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function stopCursorCapture() {
if (cursorCaptureInterval) {
clearInterval(cursorCaptureInterval)
cursorCaptureInterval = null
}
}
function sampleCursorPoint() {
const cursor = screen.getCursorScreenPoint()
const sourceDisplayId = Number(selectedSource?.display_id)
const sourceDisplay = Number.isFinite(sourceDisplayId)
? screen.getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null
: null
const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor)
const bounds = display.bounds
const width = Math.max(1, bounds.width)
const height = Math.max(1, bounds.height)
const cx = clamp((cursor.x - bounds.x) / width, 0, 1)
const cy = clamp((cursor.y - bounds.y) / height, 0, 1)
activeCursorSamples.push({
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
cx,
cy,
})
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
activeCursorSamples.shift()
}
}
export function registerIpcHandlers(
createEditorWindow: () => void,
@@ -24,7 +96,7 @@ export function registerIpcHandlers(
}))
})
ipcMain.handle('select-source', (_, source) => {
ipcMain.handle('select-source', (_, source: SelectedSource) => {
selectedSource = source
const sourceSelectorWin = getSourceSelectorWindow()
if (sourceSelectorWin) {
@@ -61,6 +133,18 @@ 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) {
await fs.writeFile(
telemetryPath,
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
'utf-8'
)
}
pendingCursorSamples = []
return {
success: true,
path: videoPath,
@@ -98,12 +182,62 @@ export function registerIpcHandlers(
})
ipcMain.handle('set-recording-state', (_, recording: boolean) => {
if (recording) {
stopCursorCapture()
activeCursorSamples = []
pendingCursorSamples = []
cursorCaptureStartTimeMs = Date.now()
sampleCursorPoint()
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS)
} else {
stopCursorCapture()
pendingCursorSamples = [...activeCursorSamples]
activeCursorSamples = []
}
const source = selectedSource || { name: 'Screen' }
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name)
}
})
ipcMain.handle('get-cursor-telemetry', async (_, videoPath?: string) => {
const targetVideoPath = videoPath ?? currentVideoPath
if (!targetVideoPath) {
return { success: true, samples: [] }
}
const telemetryPath = `${targetVideoPath}.cursor.json`
try {
const content = await fs.readFile(telemetryPath, 'utf-8')
const parsed = JSON.parse(content)
const rawSamples = Array.isArray(parsed)
? parsed
: (Array.isArray(parsed?.samples) ? parsed.samples : [])
const samples: CursorTelemetryPoint[] = rawSamples
.filter((sample: unknown) => Boolean(sample && typeof sample === 'object'))
.map((sample: unknown) => {
const point = sample as Partial<CursorTelemetryPoint>
return {
timeMs: typeof point.timeMs === 'number' && Number.isFinite(point.timeMs) ? Math.max(0, point.timeMs) : 0,
cx: typeof point.cx === 'number' && Number.isFinite(point.cx) ? clamp(point.cx, 0, 1) : 0.5,
cy: typeof point.cy === 'number' && Number.isFinite(point.cy) ? clamp(point.cy, 0, 1) : 0.5,
}
})
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs)
return { success: true, samples }
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code === 'ENOENT') {
return { success: true, samples: [] }
}
console.error('Failed to load cursor telemetry:', error)
return { success: false, message: 'Failed to load cursor telemetry', error: String(error), samples: [] }
}
})
ipcMain.handle('open-external-url', async (_, url: string) => {
try {
@@ -146,8 +280,8 @@ export function registerIpcHandlers(
if (result.canceled || !result.filePath) {
return {
success: false,
cancelled: true,
message: 'Export cancelled'
canceled: true,
message: 'Export canceled'
};
}
@@ -181,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]
@@ -223,9 +358,131 @@ export function registerIpcHandlers(
});
let currentVideoPath: string | null = null;
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 };
});
@@ -241,4 +498,23 @@ export function registerIpcHandlers(
ipcMain.handle('get-platform', () => {
return process.platform;
});
ipcMain.handle('get-shortcuts', async () => {
try {
const data = await fs.readFile(SHORTCUTS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
});
ipcMain.handle('save-shortcuts', async (_, shortcuts: unknown) => {
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) };
}
});
}
+112
View File
@@ -53,6 +53,117 @@ function createWindow() {
mainWindow = createHudOverlayWindow()
}
function isEditorWindow(window: BrowserWindow) {
return window.webContents.getURL().includes('windowType=editor')
}
function sendEditorMenuAction(channel: 'menu-load-project' | 'menu-save-project' | 'menu-save-project-as') {
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow
if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
createEditorWindowWrapper()
targetWindow = mainWindow
if (!targetWindow || targetWindow.isDestroyed()) return
targetWindow.webContents.once('did-finish-load', () => {
if (!targetWindow || targetWindow.isDestroyed()) return
targetWindow.webContents.send(channel)
})
return
}
targetWindow.webContents.send(channel)
}
function setupApplicationMenu() {
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = []
if (isMac) {
template.push({
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
})
}
template.push(
{
label: 'File',
submenu: [
{
label: 'Load Project…',
accelerator: 'CmdOrCtrl+O',
click: () => sendEditorMenuAction('menu-load-project'),
},
{
label: 'Save Project…',
accelerator: 'CmdOrCtrl+S',
click: () => sendEditorMenuAction('menu-save-project'),
},
{
label: 'Save Project As…',
accelerator: 'CmdOrCtrl+Shift+S',
click: () => sendEditorMenuAction('menu-save-project-as'),
},
...(isMac ? [] : [{ type: 'separator' as const }, { role: 'quit' as const }]),
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: isMac
? [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
]
: [
{ role: 'minimize' },
{ role: 'close' },
],
},
)
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}
function createTray() {
tray = new Tray(defaultTrayIcon);
}
@@ -145,6 +256,7 @@ app.whenReady().then(async () => {
});
createTray()
updateTrayMenu()
setupApplicationMenu()
// Ensure recordings directory exists
await ensureRecordingsDir()
+35 -1
View File
@@ -37,6 +37,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
setRecordingState: (recording: boolean) => {
return ipcRenderer.invoke('set-recording-state', recording)
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke('get-cursor-telemetry', videoPath)
},
onStopRecordingFromTray: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('stop-recording-from-tray', listener)
@@ -60,10 +63,41 @@ 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')
},
revealInFolder: (filePath: string) => {
return ipcRenderer.invoke('reveal-in-folder', filePath)
},
})
})
getShortcuts: () => {
return ipcRenderer.invoke('get-shortcuts')
},
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke('save-shortcuts', shortcuts)
},
})
+13922 -14645
View File
File diff suppressed because it is too large Load Diff
+80 -82
View File
@@ -1,84 +1,82 @@
{
"name": "openscreen",
"private": true,
"version": "1.1.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"test": "vitest --run",
"test:watch": "vitest"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gif.js": "^0.2.0",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"react-rnd": "^10.5.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
"electron-icon-builder": "^2.0.1",
"electron-rebuild": "^3.2.9",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"fast-check": "^4.5.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"terser": "^5.44.1",
"typescript": "^5.2.2",
"vite": "^5.1.6",
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.0.16"
},
"main": "dist-electron/main.js"
"name": "openscreen",
"private": true,
"version": "1.1.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"test": "vitest --run",
"test:watch": "vitest"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gif.js": "^0.2.0",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"react-rnd": "^10.5.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.3.13",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
"electron-icon-builder": "^2.0.1",
"electron-rebuild": "^3.2.9",
"fast-check": "^4.5.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"terser": "^5.44.1",
"typescript": "^5.2.2",
"vite": "^5.1.6",
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.0.16"
},
"main": "dist-electron/main.js"
}
+8 -1
View File
@@ -3,6 +3,8 @@ import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import VideoEditor from "./components/video-editor/VideoEditor";
import { loadAllCustomFonts } from "./lib/customFonts";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
export default function App() {
const [windowType, setWindowType] = useState('');
@@ -29,7 +31,12 @@ export default function App() {
case 'source-selector':
return <SourceSelector />;
case 'editor':
return <VideoEditor />;
return (
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
</ShortcutsProvider>
);
default:
return (
<div className="w-full h-full bg-background text-foreground">
+1 -1
View File
@@ -71,7 +71,7 @@ export function LaunchWindow() {
const openVideoFile = async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (result.cancelled) {
if (result.canceled) {
return;
}
@@ -1,65 +1,62 @@
import { HelpCircle } from "lucide-react";
import { HelpCircle, Settings2 } from "lucide-react";
import { useState, useEffect } from "react";
import { formatShortcut } from "@/utils/platformUtils";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
export function KeyboardShortcutsHelp() {
const [shortcuts, setShortcuts] = useState({
delete: 'Ctrl + D',
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
const { shortcuts, isMac, openConfig } = useShortcuts();
const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' });
useEffect(() => {
Promise.all([
formatShortcut(['mod', 'D']),
formatShortcut(['shift', 'mod', 'Scroll']),
formatShortcut(['mod', 'Scroll'])
]).then(([deleteKey, panKey, zoomKey]) => {
setShortcuts({
delete: deleteKey,
pan: panKey,
zoom: zoomKey
});
});
formatShortcut(['mod', 'Scroll']),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
<div className="text-xs font-semibold text-slate-200 mb-2">Keyboard Shortcuts</div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
<button
type="button"
onClick={openConfig}
title="Customize shortcuts"
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
>
<Settings2 className="w-3 h-3" />
Customize
</button>
</div>
<div className="space-y-1.5 text-[10px]">
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Zoom</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Z</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Annotation</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">A</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Keyframe</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">F</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Trim</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">T</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Delete Selected</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.delete}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.pan}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.zoom}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pause/Play</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Space</kbd>
{SHORTCUT_ACTIONS.map((action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
</div>
))}
<div className="pt-1 border-t border-white/5 mt-1">
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{scrollLabels.pan}</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{scrollLabels.zoom}</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Cycle Annotations</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Tab</kbd>
</div>
</div>
</div>
</div>
+84 -2
View File
@@ -7,9 +7,10 @@ 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 } from "./types";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types";
import { SPEED_OPTIONS } from "./types";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
@@ -82,6 +83,8 @@ interface SettingsPanelProps {
gifSizePreset?: GifSizePreset;
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onSaveProject?: () => void;
onLoadProject?: () => void;
onExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
@@ -90,6 +93,10 @@ interface SettingsPanelProps {
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion['style']>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
onAnnotationDelete?: (id: string) => void;
selectedSpeedId?: string | null;
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
onSpeedDelete?: (id: string) => void;
}
export default SettingsPanel;
@@ -137,6 +144,8 @@ export function SettingsPanel({
gifSizePreset = 'medium',
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
onSaveProject,
onLoadProject,
onExport,
selectedAnnotationId,
annotationRegions = [],
@@ -145,6 +154,10 @@ export function SettingsPanel({
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDelete,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
onSpeedDelete,
}: SettingsPanelProps) {
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
@@ -321,6 +334,54 @@ export function SettingsPanel({
</div>
)}
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-200">Playback Speed</span>
{selectedSpeedId && selectedSpeedValue && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
{SPEED_OPTIONS.find(o => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`}
</span>
)}
</div>
<div className="grid grid-cols-7 gap-1.5">
{SPEED_OPTIONS.map((option) => {
const isActive = selectedSpeedValue === option.speed;
return (
<Button
key={option.speed}
type="button"
disabled={!selectedSpeedId}
onClick={() => onSpeedChange?.(option.speed)}
className={cn(
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
"duration-200 ease-out",
selectedSpeedId ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
isActive
? "border-[#d97706] bg-[#d97706] text-white shadow-[#d97706]/20"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200"
)}
>
<span className="text-xs font-semibold">{option.label}</span>
</Button>
);
})}
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">Select a speed region to adjust</p>
)}
{selectedSpeedId && (
<Button
onClick={() => selectedSpeedId && onSpeedDelete?.(selectedSpeedId)}
variant="destructive"
size="sm"
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Speed Region
</Button>
)}
</div>
<Accordion type="multiple" defaultValue={["effects", "background"]} className="space-y-1">
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
<AccordionTrigger className="py-2.5 hover:no-underline">
@@ -682,6 +743,27 @@ export function SettingsPanel({
</div>
)}
<div className="grid grid-cols-2 gap-2 mb-2">
<Button
type="button"
variant="outline"
onClick={onLoadProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<FolderOpen className="w-3.5 h-3.5" />
Load Project
</Button>
<Button
type="button"
variant="outline"
onClick={onSaveProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<Save className="w-3.5 h-3.5" />
Save Project
</Button>
</div>
<Button
type="button"
size="lg"
@@ -0,0 +1,223 @@
import { useCallback, useEffect, useState } from 'react';
import { Keyboard, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
DEFAULT_SHORTCUTS,
FIXED_SHORTCUTS,
SHORTCUT_ACTIONS,
SHORTCUT_LABELS,
findConflict,
formatBinding,
type ShortcutAction,
type ShortcutBinding,
type ShortcutConflict,
type ShortcutsConfig,
} from '@/lib/shortcuts';
import { useShortcuts } from '@/contexts/ShortcutsContext';
const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']);
export function ShortcutsConfigDialog() {
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
useShortcuts();
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
const [conflict, setConflict] = useState<{ forAction: ShortcutAction; pending: ShortcutBinding; conflictWith: ShortcutConflict } | null>(null);
useEffect(() => {
if (isConfigOpen) {
setDraft(shortcuts);
setCaptureFor(null);
setConflict(null);
}
}, [isConfigOpen, shortcuts]);
useEffect(() => {
if (!captureFor) return;
const handleCapture = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
setCaptureFor(null);
return;
}
if (MODIFIER_KEYS.has(e.key)) return;
const binding: ShortcutBinding = {
key: e.key.toLowerCase(),
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
...(e.shiftKey ? { shift: true } : {}),
...(e.altKey ? { alt: true } : {}),
};
const found = findConflict(binding, captureFor, draft);
setCaptureFor(null);
if (found?.type === 'fixed') {
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
return;
}
if (found?.type === 'configurable') {
setConflict({ forAction: captureFor, pending: binding, conflictWith: found });
return;
}
setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
};
window.addEventListener('keydown', handleCapture, { capture: true });
return () => window.removeEventListener('keydown', handleCapture, { capture: true });
}, [captureFor]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== 'configurable') return;
const { forAction, pending, conflictWith } = conflict;
setDraft((prev: ShortcutsConfig) => ({
...prev,
[forAction]: pending,
[conflictWith.action]: prev[forAction],
}));
setConflict(null);
}, [conflict]);
const handleCancelConflict = useCallback(() => setConflict(null), []);
const handleSave = useCallback(async () => {
setShortcuts(draft);
await persistShortcuts(draft);
toast.success('Keyboard shortcuts saved');
closeConfig();
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
const handleReset = useCallback(() => {
setDraft({ ...DEFAULT_SHORTCUTS });
toast.info('Reset to default shortcuts — click Save to apply');
}, []);
const handleClose = useCallback(() => {
setCaptureFor(null);
setConflict(null);
closeConfig();
}, [closeConfig]);
return (
<Dialog open={isConfigOpen} onOpenChange={(open: boolean) => { if (!open) handleClose(); }}>
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Keyboard className="w-4 h-4 text-[#34B27B]" />
Keyboard Shortcuts
</DialogTitle>
</DialogHeader>
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Configurable</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? 'Press Esc to cancel' : 'Click to change'}
className={[
'px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none',
isCapturing
? 'bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse'
: hasConflict
? 'bg-amber-500/10 border-amber-500/50 text-amber-400'
: 'bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer',
].join(' ')}
>
{isCapturing ? 'Press a key…' : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === 'configurable' && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
Already used by <strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
Swap
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Fixed</p>
{FIXED_SHORTCUTS.map(({ label, display }) => (
<div
key={label}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">{label}</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
</div>
))}
</div>
<p className="text-[10px] text-slate-500 mt-1">
Click a shortcut then press the new key combination. Press{' '}
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
</p>
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white gap-1.5"
onClick={handleReset}
>
<RotateCcw className="w-3 h-3" />
Reset to defaults
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
size="sm"
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
onClick={handleSave}
>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+440 -32
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
@@ -10,6 +10,15 @@ import PlaybackControls from "./PlaybackControls";
import TimelineEditor from "./timeline/TimelineEditor";
import { SettingsPanel } from "./SettingsPanel";
import { ExportDialog } from "./ExportDialog";
import {
WALLPAPER_PATHS,
createProjectData,
deriveNextId,
fromFileUrl,
normalizeProjectEditor,
toFileUrl,
validateProjectData,
} from "./projectPersistence";
import type { Span } from "dnd-timeline";
import {
@@ -20,23 +29,28 @@ import {
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
type CursorTelemetryPoint,
type TrimRegion,
type AnnotationRegion,
type CropRegion,
type FigureData,
type SpeedRegion,
type PlaybackSpeed,
} 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 { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
const WALLPAPER_COUNT = 18;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
export default function VideoEditor() {
const [videoPath, setVideoPath] = useState<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -50,9 +64,12 @@ export default function VideoEditor() {
const [padding, setPadding] = useState(50);
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [speedRegions, setSpeedRegions] = useState<SpeedRegion[]>([]);
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
@@ -66,49 +83,330 @@ export default function VideoEditor() {
const [gifLoop, setGifLoop] = useState(true);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>('medium');
const [exportedFilePath, setExportedFilePath] = useState<string | undefined>(undefined);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const nextSpeedIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
const exporterRef = useRef<VideoExporter | null>(null);
// Helper to convert file path to proper file:// URL
const toFileUrl = (filePath: string): string => {
// Normalize path separators to forward slashes
const normalized = filePath.replace(/\\/g, '/');
// Check if it's a Windows absolute path (e.g., C:/Users/...)
if (normalized.match(/^[a-zA-Z]:/)) {
const fileUrl = `file:///${normalized}`;
return fileUrl;
const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => {
if (!validateProjectData(candidate)) {
return false;
}
// Unix-style absolute path
const fileUrl = `file://${normalized}`;
return fileUrl;
};
const project = candidate;
const sourcePath = project.videoPath;
const normalizedEditor = normalizeProjectEditor(project.editor);
try {
videoPlaybackRef.current?.pause();
} catch {
// no-op
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setCurrentProjectPath(path ?? null);
setWallpaper(normalizedEditor.wallpaper);
setShadowIntensity(normalizedEditor.shadowIntensity);
setShowBlur(normalizedEditor.showBlur);
setMotionBlurEnabled(normalizedEditor.motionBlurEnabled);
setBorderRadius(normalizedEditor.borderRadius);
setPadding(normalizedEditor.padding);
setCropRegion(normalizedEditor.cropRegion);
setZoomRegions(normalizedEditor.zoomRegions);
setTrimRegions(normalizedEditor.trimRegions);
setSpeedRegions(normalizedEditor.speedRegions);
setAnnotationRegions(normalizedEditor.annotationRegions);
setAspectRatio(normalizedEditor.aspectRatio);
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
setGifFrameRate(normalizedEditor.gifFrameRate);
setGifLoop(normalizedEditor.gifLoop);
setGifSizePreset(normalizedEditor.gifSizePreset);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
nextZoomIdRef.current = deriveNextId("zoom", normalizedEditor.zoomRegions.map((region) => region.id));
nextTrimIdRef.current = deriveNextId("trim", normalizedEditor.trimRegions.map((region) => region.id));
nextSpeedIdRef.current = deriveNextId("speed", normalizedEditor.speedRegions.map((region) => region.id));
nextAnnotationIdRef.current = deriveNextId(
"annotation",
normalizedEditor.annotationRegions.map((region) => region.id),
);
nextAnnotationZIndexRef.current =
normalizedEditor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1;
setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor)));
return true;
}, []);
const currentProjectSnapshot = useMemo(() => {
const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!sourcePath) {
return null;
}
return JSON.stringify(
createProjectData(sourcePath, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
}),
);
}, [
videoPath,
videoSourcePath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
const hasUnsavedChanges = Boolean(
currentProjectPath &&
currentProjectSnapshot &&
lastSavedSnapshot &&
currentProjectSnapshot !== lastSavedSnapshot,
);
useEffect(() => {
async function loadVideo() {
async function loadInitialData() {
try {
const currentProjectResult = await window.electronAPI.loadCurrentProjectFile();
if (currentProjectResult.success && currentProjectResult.project) {
const restored = await applyLoadedProject(
currentProjectResult.project,
currentProjectResult.path ?? null,
);
if (restored) {
return;
}
}
const result = await window.electronAPI.getCurrentVideoPath();
if (result.success && result.path) {
const videoUrl = toFileUrl(result.path);
setVideoPath(videoUrl);
setVideoSourcePath(result.path);
setVideoPath(toFileUrl(result.path));
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
} else {
setError('No video to load. Please record or select a video.');
setError("No video to load. Please record or select a video.");
}
} catch (err) {
setError('Error loading video: ' + String(err));
setError("Error loading video: " + String(err));
} finally {
setLoading(false);
}
}
loadVideo();
}, []);
loadInitialData();
}, [applyLoadedProject]);
const saveProject = useCallback(async (forceSaveAs: boolean) => {
if (!videoPath) {
toast.error('No video loaded');
return;
}
const sourcePath = videoSourcePath ?? fromFileUrl(videoPath);
if (!sourcePath) {
toast.error('Unable to determine source video path');
return;
}
const projectData = createProjectData(sourcePath, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
});
const fileNameBase = sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || `project-${Date.now()}`;
const projectSnapshot = JSON.stringify(projectData);
const result = await window.electronAPI.saveProjectFile(
projectData,
fileNameBase,
forceSaveAs ? undefined : currentProjectPath ?? undefined,
);
if (result.canceled) {
toast.info("Project save canceled");
return;
}
if (!result.success) {
toast.error(result.message || 'Failed to save project');
return;
}
if (result.path) {
setCurrentProjectPath(result.path);
}
setLastSavedSnapshot(projectSnapshot);
toast.success(`Project saved to ${result.path}`);
}, [
videoPath,
videoSourcePath,
currentProjectPath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasUnsavedChanges) {
return;
}
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
const handleSaveProject = useCallback(async () => {
await saveProject(false);
}, [saveProject]);
const handleSaveProjectAs = useCallback(async () => {
await saveProject(true);
}, [saveProject]);
const handleLoadProject = useCallback(async () => {
const result = await window.electronAPI.loadProjectFile();
if (result.canceled) {
return;
}
if (!result.success) {
toast.error(result.message || 'Failed to load project');
return;
}
const restored = await applyLoadedProject(result.project, result.path ?? null);
if (!restored) {
toast.error('Invalid project file format');
return;
}
toast.success(`Project loaded from ${result.path}`);
}, [applyLoadedProject]);
useEffect(() => {
const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject);
const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs);
return () => {
removeLoadListener?.();
removeSaveListener?.();
removeSaveAsListener?.();
};
}, [handleLoadProject, handleSaveProject, handleSaveProjectAs]);
useEffect(() => {
let mounted = true;
async function loadCursorTelemetry() {
if (!videoPath) {
if (mounted) {
setCursorTelemetry([]);
}
return;
}
try {
const result = await window.electronAPI.getCursorTelemetry(fromFileUrl(videoPath));
if (mounted) {
setCursorTelemetry(result.success ? result.samples : []);
}
} catch (telemetryError) {
console.warn('Unable to load cursor telemetry:', telemetryError);
if (mounted) {
setCursorTelemetry([]);
}
}
}
loadCursorTelemetry();
return () => {
mounted = false;
};
}, [videoPath]);
// Initialize default wallpaper with resolved asset path
useEffect(() => {
@@ -181,6 +479,21 @@ export default function VideoEditor() {
setSelectedAnnotationId(null);
}, []);
const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => {
const id = `zoom-${nextZoomIdRef.current++}`;
const newRegion: ZoomRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
};
setZoomRegions((prev) => [...prev, newRegion]);
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
}, []);
const handleTrimAdded = useCallback((span: Span) => {
const id = `trim-${nextTrimIdRef.current++}`;
const newRegion: TrimRegion = {
@@ -264,6 +577,60 @@ export default function VideoEditor() {
}
}, [selectedTrimId]);
const handleSelectSpeed = useCallback((id: string | null) => {
setSelectedSpeedId(id);
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
}
}, []);
const handleSpeedAdded = useCallback((span: Span) => {
const id = `speed-${nextSpeedIdRef.current++}`;
const newRegion: SpeedRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
speed: DEFAULT_PLAYBACK_SPEED,
};
setSpeedRegions((prev) => [...prev, newRegion]);
setSelectedSpeedId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
}, []);
const handleSpeedSpanChange = useCallback((id: string, span: Span) => {
setSpeedRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
}, []);
const handleSpeedDelete = useCallback((id: string) => {
setSpeedRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedSpeedId === id) {
setSelectedSpeedId(null);
}
}, [selectedSpeedId]);
const handleSpeedChange = useCallback((speed: PlaybackSpeed) => {
if (!selectedSpeedId) return;
setSpeedRegions((prev) =>
prev.map((region) =>
region.id === selectedSpeedId ? { ...region, speed } : region,
),
);
}, [selectedSpeedId]);
const handleAnnotationAdded = useCallback((span: Span) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
@@ -399,7 +766,7 @@ export default function VideoEditor() {
e.preventDefault();
}
if (e.key === ' ' || e.code === 'Space') {
if (matchesShortcut(e, shortcuts.playPause, isMac)) {
// Allow space only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
@@ -419,7 +786,7 @@ export default function VideoEditor() {
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, []);
}, [shortcuts, isMac]);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
@@ -439,6 +806,12 @@ export default function VideoEditor() {
}
}, [selectedAnnotationId, annotationRegions]);
useEffect(() => {
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
setSelectedSpeedId(null);
}
}, [selectedSpeedId, speedRegions]);
const handleExport = useCallback(async (settings: ExportSettings) => {
if (!videoPath) {
toast.error('No video loaded');
@@ -483,6 +856,7 @@ export default function VideoEditor() {
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
@@ -514,6 +888,10 @@ export default function VideoEditor() {
} else if (saveResult.success && saveResult.path) {
showExportSuccessToast(saveResult.path);
setExportedFilePath(saveResult.path);
if (saveResult.canceled) {
toast.info('Export canceled');
} else if (saveResult.success) {
toast.success(`GIF exported successfully to ${saveResult.path}`);
} else {
setExportError(saveResult.message || 'Failed to save GIF');
toast.error(saveResult.message || 'Failed to save GIF');
@@ -610,6 +988,7 @@ export default function VideoEditor() {
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
@@ -640,6 +1019,10 @@ export default function VideoEditor() {
} else if (saveResult.success && saveResult.path) {
showExportSuccessToast(saveResult.path);
setExportedFilePath(saveResult.path);
if (saveResult.canceled) {
toast.info('Export canceled');
} else if (saveResult.success) {
toast.success(`Video exported successfully to ${saveResult.path}`);
} else {
setExportError(saveResult.message || 'Failed to save video');
toast.error(saveResult.message || 'Failed to save video');
@@ -666,7 +1049,7 @@ export default function VideoEditor() {
setShowExportDialog(false);
setExportProgress(null);
}
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
}, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
const handleOpenExportDialog = useCallback(() => {
if (!videoPath) {
@@ -707,7 +1090,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);
@@ -750,7 +1133,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>
);
}
@@ -776,6 +1168,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 || ''}
@@ -798,6 +1191,7 @@ export default function VideoEditor() {
padding={padding}
cropRegion={cropRegion}
trimRegions={trimRegions}
speedRegions={speedRegions}
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
@@ -832,8 +1226,10 @@ export default function VideoEditor() {
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
cursorTelemetry={cursorTelemetry}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
@@ -844,6 +1240,12 @@ export default function VideoEditor() {
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
speedRegions={speedRegions}
onSpeedAdded={handleSpeedAdded}
onSpeedSpanChange={handleSpeedSpanChange}
onSpeedDelete={handleSpeedDelete}
selectedSpeedId={selectedSpeedId}
onSelectSpeed={handleSelectSpeed}
annotationRegions={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
@@ -906,6 +1308,12 @@ 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}
onSpeedDelete={handleSpeedDelete}
/>
</div>
@@ -923,4 +1331,4 @@ export default function VideoEditor() {
/>
</div>
);
}
}
@@ -2,7 +2,7 @@ import type React from "react";
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
import { getAssetPath } from "@/lib/assetPath";
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types";
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type SpeedRegion, type AnnotationRegion } from "./types";
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
import { clamp01 } from "./videoPlayback/mathUtils";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
@@ -35,6 +35,7 @@ interface VideoPlaybackProps {
padding?: number;
cropRegion?: import('./types').CropRegion;
trimRegions?: TrimRegion[];
speedRegions?: SpeedRegion[];
aspectRatio: AspectRatio;
annotationRegions?: AnnotationRegion[];
selectedAnnotationId?: string | null;
@@ -74,6 +75,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
padding = 50,
cropRegion,
trimRegions = [],
speedRegions = [],
aspectRatio,
annotationRegions = [],
selectedAnnotationId,
@@ -111,6 +113,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
const motionBlurEnabledRef = useRef(motionBlurEnabled);
const videoReadyRafRef = useRef<number | null>(null);
@@ -319,6 +322,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
trimRegionsRef.current = trimRegions;
}, [trimRegions]);
useEffect(() => {
speedRegionsRef.current = speedRegions;
}, [speedRegions]);
useEffect(() => {
motionBlurEnabledRef.current = motionBlurEnabled;
}, [motionBlurEnabled]);
@@ -557,6 +564,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
onPlayStateChange,
onTimeUpdate,
trimRegionsRef,
speedRegionsRef,
});
video.addEventListener('play', handlePlay);
@@ -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,
};
}
+22 -8
View File
@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { cn } from "@/lib/utils";
import { ZoomIn, Scissors, MessageSquare } from "lucide-react";
import { ZoomIn, Scissors, MessageSquare, Gauge } from "lucide-react";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -13,7 +13,8 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
variant?: 'zoom' | 'trim' | 'annotation';
speedValue?: number;
variant?: 'zoom' | 'trim' | 'annotation' | 'speed';
}
// Map zoom depth to multiplier labels
@@ -36,13 +37,14 @@ function formatMs(ms: number): string {
return `${seconds.toFixed(1)}s`;
}
export default function Item({
id,
span,
rowId,
isSelected = false,
onSelect,
export default function Item({
id,
span,
rowId,
isSelected = false,
onSelect,
zoomDepth = 1,
speedValue,
variant = 'zoom',
children
}: ItemProps) {
@@ -54,17 +56,22 @@ export default function Item({
const isZoom = variant === 'zoom';
const isTrim = variant === 'trim';
const isSpeed = variant === 'speed';
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
: isSpeed
? glassStyles.glassAmber
: glassStyles.glassYellow;
const endCapColor = isZoom
? '#21916A'
: isTrim
? '#ef4444'
: isSpeed
? '#d97706'
: '#B4A046';
const timeLabel = useMemo(
@@ -121,6 +128,13 @@ export default function Item({
Trim
</span>
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : 'Speed'}
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
@@ -76,6 +76,32 @@
z-index: 10;
}
.glassAmber {
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassAmber:hover {
background: rgba(245, 158, 11, 0.25);
border-color: rgba(245, 158, 11, 0.5);
box-shadow: 0 4px 20px 0 rgba(245, 158, 11, 0.2) inset;
}
.glassAmber.selected {
background: rgba(245, 158, 11, 0.35);
border-color: #f59e0b;
box-shadow: 0 0 0 1px #f59e0b, 0 4px 20px 0 rgba(245, 158, 11, 0.3) inset;
z-index: 10;
}
.zoomEndCap {
position: absolute;
top: 0;
@@ -92,7 +118,9 @@
.glassRed:hover .zoomEndCap,
.glassRed.selected .zoomEndCap,
.glassYellow:hover .zoomEndCap,
.glassYellow.selected .zoomEndCap {
.glassYellow.selected .zoomEndCap,
.glassAmber:hover .zoomEndCap,
.glassAmber.selected .zoomEndCap {
opacity: 1;
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTimelineContext } from "dnd-timeline";
import { Button } from "@/components/ui/button";
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check } from "lucide-react";
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge, WandSparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import TimelineWrapper from "./TimelineWrapper";
@@ -9,7 +9,7 @@ import Row from "./Row";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
import type { Range, Span } from "dnd-timeline";
import type { ZoomRegion, TrimRegion, AnnotationRegion } from "../types";
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint, ZoomFocus } from "../types";
import { v4 as uuidv4 } from 'uuid';
import {
DropdownMenu,
@@ -20,19 +20,26 @@ import {
import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
import { TutorialHelp } from "../TutorialHelp";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const SPEED_ROW_ID = "row-speed";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
const SUGGESTION_SPACING_MS = 1800;
interface TimelineEditorProps {
videoDuration: number;
currentTime: number;
onSeek?: (time: number) => void;
cursorTelemetry?: CursorTelemetryPoint[];
zoomRegions: ZoomRegion[];
onZoomAdded: (span: Span) => void;
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
onZoomSpanChange: (id: string, span: Span) => void;
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
@@ -49,6 +56,12 @@ interface TimelineEditorProps {
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
speedRegions?: SpeedRegion[];
onSpeedAdded?: (span: Span) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
onSpeedDelete?: (id: string) => void;
selectedSpeedId?: string | null;
onSelectSpeed?: (id: string | null) => void;
aspectRatio: AspectRatio;
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
}
@@ -67,7 +80,8 @@ interface TimelineRenderItem {
span: Span;
label: string;
zoomDepth?: number;
variant: 'zoom' | 'trim' | 'annotation';
speedValue?: number;
variant: 'zoom' | 'trim' | 'annotation' | 'speed';
}
const SCALE_CANDIDATES = [
@@ -396,9 +410,11 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectSpeed,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedSpeedId,
keyframes = [],
}: {
items: TimelineRenderItem[];
@@ -409,9 +425,11 @@ function Timeline({
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectSpeed?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
selectedSpeedId?: string | null;
keyframes?: { id: string; time: number }[];
}) {
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
@@ -430,6 +448,7 @@ function Timeline({
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
onSelectSpeed?.(null);
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -441,11 +460,12 @@ function Timeline({
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
const speedItems = items.filter(item => item.rowId === SPEED_ROW_ID);
return (
<div
@@ -512,6 +532,23 @@ function Timeline({
</Item>
))}
</Row>
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
{speedItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedSpeedId}
onSelect={() => onSelectSpeed?.(item.id)}
variant="speed"
speedValue={item.speedValue}
>
{item.label}
</Item>
))}
</Row>
</div>
);
}
@@ -520,8 +557,10 @@ export default function TimelineEditor({
videoDuration,
currentTime,
onSeek,
cursorTelemetry = [],
zoomRegions,
onZoomAdded,
onZoomSuggested,
onZoomSpanChange,
onZoomDelete,
selectedZoomId,
@@ -538,6 +577,12 @@ export default function TimelineEditor({
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
speedRegions = [],
onSpeedAdded,
onSpeedSpanChange,
onSpeedDelete,
selectedSpeedId,
onSelectSpeed,
aspectRatio,
onAspectRatioChange,
}: TimelineEditorProps) {
@@ -552,16 +597,17 @@ export default function TimelineEditor({
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
const [shortcuts, setShortcuts] = useState({
const [scrollLabels, setScrollLabels] = useState({
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
const timelineContainerRef = useRef<HTMLDivElement>(null);
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
useEffect(() => {
formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
formatShortcut(['mod', 'Scroll']).then(zoom => {
setShortcuts({ pan, zoom });
setScrollLabels({ pan, zoom });
});
});
}, []);
@@ -606,6 +652,12 @@ export default function TimelineEditor({
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
const deleteSelectedSpeed = useCallback(() => {
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
onSpeedDelete(selectedSpeedId);
onSelectSpeed(null);
}, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
useEffect(() => {
setRange(createInitialRange(totalMs));
}, [totalMs]);
@@ -615,8 +667,10 @@ export default function TimelineEditor({
// this effect on every drag/resize and races with dnd-timeline's internal state.
const zoomRegionsRef = useRef(zoomRegions);
const trimRegionsRef = useRef(trimRegions);
const speedRegionsRef = useRef(speedRegions);
zoomRegionsRef.current = zoomRegions;
trimRegionsRef.current = trimRegions;
speedRegionsRef.current = speedRegions;
useEffect(() => {
if (totalMs === 0 || safeMinDurationMs <= 0) {
@@ -646,21 +700,34 @@ export default function TimelineEditor({
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
speedRegionsRef.current.forEach((region) => {
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
const minEnd = clampedStart + safeMinDurationMs;
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
// Only re-run when the timeline scale changes, not on every region edit
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
// Determine which row the item belongs to
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
const isTrimItem = trimRegions.some(r => r.id === excludeId);
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
const isSpeedItem = speedRegions.some(r => r.id === excludeId);
if (isAnnotationItem) {
return false;
}
// Helper to check overlap against a specific set of regions
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion)[]) => {
return regions.some((region) => {
if (region.id === excludeId) return false;
// True overlap: regions actually intersect (not just adjacent)
@@ -676,8 +743,12 @@ export default function TimelineEditor({
return checkOverlap(trimRegions);
}
if (isSpeedItem) {
return checkOverlap(speedRegions);
}
return false;
}, [zoomRegions, trimRegions, annotationRegions]);
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
// is always wide enough to grab and resize comfortably.
@@ -716,6 +787,91 @@ export default function TimelineEditor({
onZoomAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
const handleSuggestZooms = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
return;
}
if (!onZoomSuggested) {
toast.error("Zoom suggestion handler unavailable");
return;
}
if (cursorTelemetry.length < 2) {
toast.info("No cursor telemetry available", {
description: "Record a screencast first to generate cursor-based suggestions.",
});
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
const reservedSpans = [...zoomRegions]
.map((region) => ({ start: region.startMs, end: region.endMs }))
.sort((a, b) => a.start - b.start);
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
if (normalizedSamples.length < 2) {
toast.info("No usable cursor telemetry", {
description: "The recording does not include enough cursor movement data.",
});
return;
}
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
if (dwellCandidates.length === 0) {
toast.info("No clear cursor dwell moments found", {
description: "Try a recording with slower cursor pauses on important actions.",
});
return;
}
const sortedCandidates = [...dwellCandidates].sort((a, b) => b.strength - a.strength);
const acceptedCenters: number[] = [];
let addedCount = 0;
sortedCandidates.forEach((candidate) => {
const tooCloseToAccepted = acceptedCenters.some(
(center) => Math.abs(center - candidate.centerTimeMs) < SUGGESTION_SPACING_MS,
);
if (tooCloseToAccepted) {
return;
}
const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2);
const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration));
const candidateEnd = candidateStart + defaultDuration;
const hasOverlap = reservedSpans.some(
(span) => candidateEnd > span.start && candidateStart < span.end,
);
if (hasOverlap) {
return;
}
reservedSpans.push({ start: candidateStart, end: candidateEnd });
acceptedCenters.push(candidate.centerTimeMs);
onZoomSuggested({ start: candidateStart, end: candidateEnd }, candidate.focus);
addedCount += 1;
});
if (addedCount === 0) {
toast.info("No auto-zoom slots available", {
description: "Detected dwell points overlap existing zoom regions.",
});
return;
}
toast.success(`Added ${addedCount} cursor-based zoom suggestion${addedCount === 1 ? "" : "s"}`);
}, [videoDuration, totalMs, defaultRegionDurationMs, zoomRegions, onZoomSuggested, cursorTelemetry]);
const handleAddTrim = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
return;
@@ -746,6 +902,36 @@ export default function TimelineEditor({
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
const handleAddSpeed = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
// Always place speed region at playhead
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
// Find the next speed region after the playhead
const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find(region => region.startMs > startPos);
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
// Check if playhead is inside any speed region
const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place speed here", {
description: "Speed region already exists at this location or not enough space available.",
});
return;
}
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
const handleAddAnnotation = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
@@ -769,18 +955,21 @@ export default function TimelineEditor({
return;
}
if (e.key === 'f' || e.key === 'F') {
if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) {
addKeyframe();
}
if (e.key === 'z' || e.key === 'Z') {
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) {
handleAddZoom();
}
if (e.key === 't' || e.key === 'T') {
if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) {
handleAddTrim();
}
if (e.key === 'a' || e.key === 'A') {
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
handleAddSpeed();
}
// Tab: Cycle through overlapping annotations at current time
if (e.key === 'Tab' && annotationRegions.length > 0) {
@@ -805,7 +994,7 @@ export default function TimelineEditor({
}
}
// Delete key or Ctrl+D / Cmd+D
if (e.key === 'Delete' || e.key === 'Backspace' || ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))) {
if (e.key === 'Delete' || e.key === 'Backspace' || matchesShortcut(e, keyShortcuts.deleteSelected, isMac)) {
if (selectedKeyframeId) {
deleteSelectedKeyframe();
} else if (selectedZoomId) {
@@ -814,12 +1003,14 @@ export default function TimelineEditor({
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]);
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]);
const clampedRange = useMemo<Range>(() => {
if (totalMs === 0) {
@@ -872,26 +1063,38 @@ export default function TimelineEditor({
};
});
return [...zooms, ...trims, ...annotations];
}, [zoomRegions, trimRegions, annotationRegions]);
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
id: region.id,
rowId: SPEED_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Speed ${index + 1}`,
speedValue: region.speed,
variant: 'speed',
}));
return [...zooms, ...trims, ...annotations, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
return [...zooms, ...trims];
}, [zoomRegions, trimRegions]);
const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
return [...zooms, ...trims, ...speeds];
}, [zoomRegions, trimRegions, speedRegions]);
const handleItemSpanChange = useCallback((id: string, span: Span) => {
// Check if it's a zoom or trim item
// Check if it's a zoom, trim, speed, or annotation item
if (zoomRegions.some(r => r.id === id)) {
onZoomSpanChange(id, span);
} else if (trimRegions.some(r => r.id === id)) {
onTrimSpanChange?.(id, span);
} else if (speedRegions.some(r => r.id === id)) {
onSpeedSpanChange?.(id, span);
} else if (annotationRegions.some(r => r.id === id)) {
onAnnotationSpanChange?.(id, span);
}
}, [zoomRegions, trimRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
}, [zoomRegions, trimRegions, speedRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange]);
if (!videoDuration || videoDuration === 0) {
return (
@@ -920,6 +1123,15 @@ export default function TimelineEditor({
>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
onClick={handleSuggestZooms}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
title="Suggest Zooms from Cursor"
>
<WandSparkles className="w-4 h-4" />
</Button>
<Button
onClick={handleAddTrim}
variant="ghost"
@@ -938,6 +1150,15 @@ export default function TimelineEditor({
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
onClick={handleAddSpeed}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
title="Add Speed (S)"
>
<Gauge className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
@@ -970,11 +1191,11 @@ export default function TimelineEditor({
<div className="flex-1" />
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.pan}</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{scrollLabels.pan}</kbd>
<span>Pan</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.zoom}</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{scrollLabels.zoom}</kbd>
<span>Zoom</span>
</span>
</div>
@@ -1012,11 +1233,12 @@ export default function TimelineEditor({
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
onSelectSpeed={onSelectSpeed}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
selectedSpeedId={selectedSpeedId}
keyframes={keyframes}
/>
</TimelineWrapper>
</div>
@@ -0,0 +1,75 @@
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
export const MIN_DWELL_DURATION_MS = 450;
export const MAX_DWELL_DURATION_MS = 2600;
export const DWELL_MOVE_THRESHOLD = 0.02;
export interface ZoomDwellCandidate {
centerTimeMs: number;
focus: ZoomFocus;
strength: number;
}
function normalizeTelemetrySample(sample: CursorTelemetryPoint, totalMs: number): CursorTelemetryPoint {
return {
timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)),
cx: Math.max(0, Math.min(sample.cx, 1)),
cy: Math.max(0, Math.min(sample.cy, 1)),
};
}
export function normalizeCursorTelemetry(
telemetry: CursorTelemetryPoint[],
totalMs: number,
): CursorTelemetryPoint[] {
return [...telemetry]
.filter((sample) => Number.isFinite(sample.timeMs) && Number.isFinite(sample.cx) && Number.isFinite(sample.cy))
.sort((a, b) => a.timeMs - b.timeMs)
.map((sample) => normalizeTelemetrySample(sample, totalMs));
}
export function detectZoomDwellCandidates(samples: CursorTelemetryPoint[]): ZoomDwellCandidate[] {
if (samples.length < 2) {
return [];
}
const dwellCandidates: ZoomDwellCandidate[] = [];
let runStart = 0;
const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => {
if (endIndexExclusive - startIndex < 2) {
return;
}
const start = samples[startIndex];
const end = samples[endIndexExclusive - 1];
const runDuration = end.timeMs - start.timeMs;
if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) {
return;
}
const runSamples = samples.slice(startIndex, endIndexExclusive);
const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length;
const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length;
dwellCandidates.push({
centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2),
focus: { cx: avgCx, cy: avgCy },
strength: runDuration,
});
};
for (let index = 1; index < samples.length; index += 1) {
const prev = samples[index - 1];
const curr = samples[index];
const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy);
if (distance > DWELL_MOVE_THRESHOLD) {
pushRunIfDwell(runStart, index);
runStart = index;
}
}
pushRunIfDwell(runStart, samples.length);
return dwellCandidates;
}
+27
View File
@@ -13,6 +13,12 @@ export interface ZoomRegion {
focus: ZoomFocus;
}
export interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
}
export interface TrimRegion {
id: string;
startMs: number;
@@ -108,6 +114,27 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
export interface SpeedRegion {
id: string;
startMs: number;
endMs: number;
speed: PlaybackSpeed;
}
export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
{ speed: 0.25, label: "0.25×" },
{ speed: 0.5, label: "0.5×" },
{ speed: 0.75, label: "0.75×" },
{ speed: 1.25, label: "1.25×" },
{ speed: 1.5, label: "1.5×" },
{ speed: 1.75, label: "1.75×" },
{ speed: 2, label: "2×" },
];
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
1: 1.25,
2: 1.5,
@@ -1,5 +1,5 @@
import type React from 'react';
import type { TrimRegion } from '../types';
import type { TrimRegion, SpeedRegion } from '../types';
interface VideoEventHandlersParams {
video: HTMLVideoElement;
@@ -11,6 +11,7 @@ interface VideoEventHandlersParams {
onPlayStateChange: (playing: boolean) => void;
onTimeUpdate: (time: number) => void;
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
}
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
@@ -24,6 +25,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
onPlayStateChange,
onTimeUpdate,
trimRegionsRef,
speedRegionsRef,
} = params;
const emitTime = (timeValue: number) => {
@@ -39,16 +41,23 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
) || null;
};
// Helper function to find the active speed region at the current time
const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => {
return speedRegionsRef.current.find(
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs
) || null;
};
function updateTime() {
if (!video) return;
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
// If we're in a trim region during playback, skip to the end of it
if (activeTrimRegion && !video.paused && !video.ended) {
const skipToTime = activeTrimRegion.endMs / 1000;
// If the skip would take us past the video duration, pause instead
if (skipToTime >= video.duration) {
video.pause();
@@ -57,9 +66,12 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
emitTime(skipToTime);
}
} else {
// Apply playback speed from active speed region
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
emitTime(video.currentTime);
}
if (!video.paused && !video.ended) {
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
}
+60
View File
@@ -0,0 +1,60 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from '@/lib/shortcuts';
import { isMac as getIsMac } from '@/utils/platformUtils';
interface ShortcutsContextValue {
shortcuts: ShortcutsConfig;
isMac: boolean;
setShortcuts: (config: ShortcutsConfig) => void;
persistShortcuts: (config?: ShortcutsConfig) => Promise<void>;
isConfigOpen: boolean;
openConfig: () => void;
closeConfig: () => void;
}
const ShortcutsContext = createContext<ShortcutsContextValue | null>(null);
export function useShortcuts(): ShortcutsContextValue {
const ctx = useContext(ShortcutsContext);
if (!ctx) throw new Error('useShortcuts must be used within <ShortcutsProvider>');
return ctx;
}
export function ShortcutsProvider({ children }: { children: ReactNode }) {
const [shortcuts, setShortcuts] = useState<ShortcutsConfig>(DEFAULT_SHORTCUTS);
const [isMac, setIsMac] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
useEffect(() => {
getIsMac().then(setIsMac).catch(() => {});
window.electronAPI.getShortcuts?.()
.then((saved) => {
if (saved) {
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
}
})
.catch(() => {});
}, []);
const persistShortcuts = useCallback(
async (config?: ShortcutsConfig) => {
await window.electronAPI.saveShortcuts?.(config ?? shortcuts);
},
[shortcuts],
);
const openConfig = useCallback(() => setIsConfigOpen(true), []);
const closeConfig = useCallback(() => setIsConfigOpen(false), []);
const value = useMemo<ShortcutsContextValue>(
() => ({ shortcuts, isMac, setShortcuts, persistShortcuts, isConfigOpen, openConfig, closeConfig }),
[shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig],
);
return (
<ShortcutsContext.Provider value={value}>
{children}
</ShortcutsContext.Provider>
);
}
+43 -18
View File
@@ -1,6 +1,36 @@
import { useState, useRef, useEffect } from "react";
import { fixWebmDuration } from "@fix-webm-duration/fix";
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
const TARGET_FRAME_RATE = 60;
const MIN_FRAME_RATE = 30;
const TARGET_WIDTH = 3840;
const TARGET_HEIGHT = 2160;
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
const QHD_WIDTH = 2560;
const QHD_HEIGHT = 1440;
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
// Bitrates (bits per second) per resolution tier
const BITRATE_4K = 45_000_000;
const BITRATE_QHD = 28_000_000;
const BITRATE_BASE = 18_000_000;
const HIGH_FRAME_RATE_THRESHOLD = 60;
const HIGH_FRAME_RATE_BOOST = 1.7;
// Fallback track settings when the driver reports nothing
const DEFAULT_WIDTH = 1920;
const DEFAULT_HEIGHT = 1080;
// Codec alignment: VP9/AV1 require dimensions divisible by 2
const CODEC_ALIGNMENT = 2;
const RECORDER_TIMESLICE_MS = 1000;
const BITS_PER_MEGABIT = 1_000_000;
const CHROME_MEDIA_SOURCE = "desktop";
const RECORDING_FILE_PREFIX = "recording-";
const VIDEO_FILE_EXTENSION = ".webm";
type UseScreenRecorderReturn = {
recording: boolean;
toggleRecording: () => void;
@@ -13,11 +43,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const chunks = useRef<Blob[]>([]);
const startTime = useRef<number>(0);
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
const TARGET_FRAME_RATE = 60;
const TARGET_WIDTH = 3840;
const TARGET_HEIGHT = 2160;
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
const selectMimeType = () => {
const preferred = [
"video/webm;codecs=av1",
@@ -32,17 +57,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const computeBitrate = (width: number, height: number) => {
const pixels = width * height;
const highFrameRateBoost = TARGET_FRAME_RATE >= 60 ? 1.7 : 1;
const highFrameRateBoost = TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
if (pixels >= FOUR_K_PIXELS) {
return Math.round(45_000_000 * highFrameRateBoost);
return Math.round(BITRATE_4K * highFrameRateBoost);
}
if (pixels >= 2560 * 1440) {
return Math.round(28_000_000 * highFrameRateBoost);
if (pixels >= QHD_PIXELS) {
return Math.round(BITRATE_QHD * highFrameRateBoost);
}
return Math.round(18_000_000 * highFrameRateBoost);
return Math.round(BITRATE_BASE * highFrameRateBoost);
};
const stopRecording = useRef(() => {
@@ -91,12 +116,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: selectedSource.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
minFrameRate: 30,
minFrameRate: MIN_FRAME_RATE,
},
},
});
@@ -115,18 +140,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
console.warn("Unable to lock 4K/60fps constraints, using best available track settings.", error);
}
let { width = 1920, height = 1080, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings();
let { width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings();
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
width = Math.floor(width / 2) * 2;
height = Math.floor(height / 2) * 2;
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
const videoBitsPerSecond = computeBitrate(width, height);
const mimeType = selectMimeType();
console.log(
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
videoBitsPerSecond / 1_000_000
videoBitsPerSecond / BITS_PER_MEGABIT
)} Mbps`
);
@@ -148,7 +173,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
// Clear chunks early to free memory immediately after blob creation
chunks.current = [];
const timestamp = Date.now();
const videoFileName = `recording-${timestamp}.webm`;
const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`;
try {
const videoBlob = await fixWebmDuration(buggyBlob, duration);
@@ -169,7 +194,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
};
recorder.onerror = () => setRecording(false);
recorder.start(1000);
recorder.start(RECORDER_TIMESLICE_MS);
startTime.current = Date.now();
setRecording(true);
window.electronAPI?.setRecordingState(true);
+27 -2
View File
@@ -138,7 +138,12 @@ function renderText(
const style = annotation.style;
ctx.save();
// Clip text to annotation box bounds (matches editor's overflow: hidden)
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.clip();
const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal';
const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal';
const scaledFontSize = style.fontSize * scaleFactor;
@@ -161,7 +166,27 @@ function renderText(
ctx.textAlign = 'left';
}
const lines = annotation.content.split('\n');
const availableWidth = width - containerPadding * 2;
const rawLines = annotation.content.split('\n');
const lines: string[] = [];
for (const rawLine of rawLines) {
if (!rawLine) {
lines.push('');
continue;
}
const words = rawLine.split(/(\s+)/);
let current = '';
for (const word of words) {
const test = current + word;
if (current && ctx.measureText(test).width > availableWidth) {
lines.push(current);
current = word.trimStart();
} else {
current = test;
}
}
if (current) lines.push(current);
}
const lineHeight = scaledFontSize * 1.4;
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
+3 -2
View File
@@ -1,5 +1,5 @@
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types';
import type { ZoomRegion, CropRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform';
@@ -22,6 +22,7 @@ interface FrameRenderConfig {
videoWidth: number;
videoHeight: number;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
previewWidth?: number;
previewHeight?: number;
}
@@ -385,7 +386,7 @@ export class FrameRenderer {
private clampFocusToStage(focus: { cx: number; cy: number }, depth: number): { cx: number; cy: number } {
if (!this.layoutCache) return focus;
return clampFocusToStageUtil(focus, depth as any, this.layoutCache);
return clampFocusToStageUtil(focus, depth as any, this.layoutCache.stageSize);
}
private updateAnimationState(timeMs: number): number {
-474
View File
@@ -1,474 +0,0 @@
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import { calculateOutputDimensions } from './gifExporter';
import { GIF_SIZE_PRESETS, GifSizePreset } from './types';
/**
* Property 2: Loop Encoding Correctness
*
* *For any* GIF export configuration, when loop is enabled the output GIF SHALL
* have a loop count of 0 (infinite), and when loop is disabled the output GIF
* SHALL have a loop count of 1 (play once).
*
* **Validates: Requirements 3.2, 3.3**
*
* Feature: gif-export, Property 2: Loop Encoding Correctness
*/
describe('GIF Exporter', () => {
describe('Property 2: Loop Encoding Correctness', () => {
/**
* Test the loop configuration mapping logic.
* In gif.js: repeat=0 means infinite loop, repeat=1 means play once (no loop)
*/
it('should map loop=true to repeat=0 (infinite) and loop=false to repeat=1 (once)', () => {
fc.assert(
fc.property(
fc.boolean(),
(loopEnabled: boolean) => {
// This is the logic used in GifExporter constructor
const repeat = loopEnabled ? 0 : 1;
if (loopEnabled) {
// When loop is enabled, repeat should be 0 (infinite loop)
expect(repeat).toBe(0);
} else {
// When loop is disabled, repeat should be 1 (play once)
expect(repeat).toBe(1);
}
}
),
{ numRuns: 100 }
);
});
it('should always produce valid repeat values (0 or 1)', () => {
fc.assert(
fc.property(
fc.boolean(),
(loopEnabled: boolean) => {
const repeat = loopEnabled ? 0 : 1;
expect([0, 1]).toContain(repeat);
}
),
{ numRuns: 100 }
);
});
});
/**
* Property 4: Aspect Ratio Preservation
*
* *For any* source video with aspect ratio R and any size preset, the exported
* GIF SHALL have an aspect ratio within 0.01 of R.
*
* **Validates: Requirements 4.4**
*
* Feature: gif-export, Property 4: Aspect Ratio Preservation
*/
describe('Property 4: Aspect Ratio Preservation', () => {
const sizePresets: GifSizePreset[] = ['medium', 'large', 'original'];
it('should preserve aspect ratio within 0.01 tolerance for all size presets', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 4000 }), // sourceWidth
fc.integer({ min: 100, max: 4000 }), // sourceHeight
fc.constantFrom(...sizePresets),
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
const originalAspectRatio = sourceWidth / sourceHeight;
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
sizePreset,
GIF_SIZE_PRESETS
);
const outputAspectRatio = width / height;
// Aspect ratio should be preserved within 0.01 tolerance
// (small deviation allowed due to rounding to even numbers)
expect(Math.abs(originalAspectRatio - outputAspectRatio)).toBeLessThan(0.02);
}
),
{ numRuns: 100 }
);
});
it('should return original dimensions when source is smaller than preset max height', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 400 }), // sourceWidth (small)
fc.integer({ min: 100, max: 400 }), // sourceHeight (small, less than 720p)
(sourceWidth: number, sourceHeight: number) => {
// For 'medium' preset with maxHeight 720, if source is smaller, use original
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
'medium',
GIF_SIZE_PRESETS
);
expect(width).toBe(sourceWidth);
expect(height).toBe(sourceHeight);
}
),
{ numRuns: 100 }
);
});
it('should return original dimensions for "original" preset regardless of size', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 4000 }),
fc.integer({ min: 100, max: 4000 }),
(sourceWidth: number, sourceHeight: number) => {
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
'original',
GIF_SIZE_PRESETS
);
expect(width).toBe(sourceWidth);
expect(height).toBe(sourceHeight);
}
),
{ numRuns: 100 }
);
});
it('should scale down to preset max height when source is larger', () => {
fc.assert(
fc.property(
fc.integer({ min: 1000, max: 4000 }), // sourceWidth (large)
fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than 720p)
(sourceWidth: number, sourceHeight: number) => {
// For 'medium' preset with maxHeight 720
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
'medium',
GIF_SIZE_PRESETS
);
// Height should be at most 720 (or 722 due to even rounding)
expect(height).toBeLessThanOrEqual(722);
// Width should be scaled proportionally
expect(width).toBeLessThan(sourceWidth);
}
),
{ numRuns: 100 }
);
});
});
});
/**
* Property 3: Size Preset Resolution Mapping
*
* *For any* valid size preset and source video dimensions, the GIF_Exporter SHALL
* produce output with height matching the preset's max height (or source height if smaller),
* with width calculated to maintain aspect ratio.
*
* **Validates: Requirements 4.2**
*
* Feature: gif-export, Property 3: Size Preset Resolution Mapping
*/
describe('Property 3: Size Preset Resolution Mapping', () => {
it('should map size presets to correct max heights', () => {
fc.assert(
fc.property(
fc.integer({ min: 800, max: 4000 }), // sourceWidth (large enough to trigger scaling)
fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than all presets except original)
fc.constantFrom('medium', 'large') as fc.Arbitrary<GifSizePreset>,
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
const { height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
sizePreset,
GIF_SIZE_PRESETS
);
const expectedMaxHeight = GIF_SIZE_PRESETS[sizePreset].maxHeight;
// Height should be at or below the preset's max height
// (allowing +2 for even number rounding)
expect(height).toBeLessThanOrEqual(expectedMaxHeight + 2);
}
),
{ numRuns: 100 }
);
});
it('should use source dimensions when smaller than preset', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 400 }), // sourceWidth
fc.integer({ min: 100, max: 400 }), // sourceHeight (smaller than 720p 'medium' preset)
fc.constantFrom('medium', 'large', 'original') as fc.Arbitrary<GifSizePreset>,
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
sizePreset,
GIF_SIZE_PRESETS
);
// When source is smaller than preset, use original dimensions
expect(width).toBe(sourceWidth);
expect(height).toBe(sourceHeight);
}
),
{ numRuns: 100 }
);
});
it('should produce even dimensions for encoder compatibility', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 4000 }),
fc.integer({ min: 100, max: 4000 }),
fc.constantFrom('medium', 'large', 'original') as fc.Arbitrary<GifSizePreset>,
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
sizePreset,
GIF_SIZE_PRESETS
);
// When scaling occurs, dimensions should be even
// (original dimensions are passed through as-is)
if (sourceHeight > GIF_SIZE_PRESETS[sizePreset].maxHeight && sizePreset !== 'original') {
expect(width % 2).toBe(0);
expect(height % 2).toBe(0);
}
}
),
{ numRuns: 100 }
);
});
});
/**
* Property 6: Frame Count Consistency
*
* *For any* video with effective duration D (excluding trim regions) and frame rate F,
* the exported GIF SHALL contain approximately D × F frames (within ±1 frame tolerance).
*
* **Validates: Requirements 5.1**
*
* Feature: gif-export, Property 6: Frame Count Consistency
*/
describe('Property 6: Frame Count Consistency', () => {
// Helper function to calculate expected frame count
const calculateExpectedFrameCount = (durationSeconds: number, frameRate: number): number => {
return Math.ceil(durationSeconds * frameRate);
};
it('should calculate correct frame count for duration and frame rate', () => {
fc.assert(
fc.property(
fc.float({ min: 0.5, max: 60, noNaN: true }), // duration in seconds
fc.constantFrom(10, 15, 20, 25, 30), // valid frame rates
(duration: number, frameRate: number) => {
const expectedFrames = calculateExpectedFrameCount(duration, frameRate);
// Frame count should be positive
expect(expectedFrames).toBeGreaterThan(0);
// Frame count should be approximately duration * frameRate
const approximateFrames = duration * frameRate;
expect(Math.abs(expectedFrames - approximateFrames)).toBeLessThanOrEqual(1);
}
),
{ numRuns: 100 }
);
});
it('should produce more frames with higher frame rates', () => {
fc.assert(
fc.property(
fc.float({ min: 1, max: 30, noNaN: true }), // duration in seconds
(duration: number) => {
const frames10fps = calculateExpectedFrameCount(duration, 10);
const frames30fps = calculateExpectedFrameCount(duration, 30);
// 30fps should produce approximately 3x more frames than 10fps
expect(frames30fps).toBeGreaterThan(frames10fps);
expect(frames30fps / frames10fps).toBeCloseTo(3, 0);
}
),
{ numRuns: 100 }
);
});
it('should handle trim regions by reducing effective duration', () => {
fc.assert(
fc.property(
fc.float({ min: 5, max: 60, noNaN: true }), // total duration
fc.float({ min: 0.5, max: 2, noNaN: true }), // trim duration (smaller than total)
fc.constantFrom(10, 15, 20, 25, 30),
(totalDuration: number, trimDuration: number, frameRate: number) => {
const effectiveDuration = totalDuration - trimDuration;
const framesWithTrim = calculateExpectedFrameCount(effectiveDuration, frameRate);
const framesWithoutTrim = calculateExpectedFrameCount(totalDuration, frameRate);
// Trimmed video should have fewer frames
expect(framesWithTrim).toBeLessThan(framesWithoutTrim);
}
),
{ numRuns: 100 }
);
});
});
/**
* Property 5: Valid GIF Output (Configuration Validation)
*
* *For any* successful GIF export, the output blob SHALL be a valid GIF file.
* This test validates the GIF configuration parameters are correctly set up.
*
* **Validates: Requirements 5.3**
*
* Feature: gif-export, Property 5: Valid GIF Output
*
* Note: Full GIF encoding validation requires browser environment with video.
* This test validates configuration correctness.
*/
describe('Property 5: Valid GIF Output (Configuration)', () => {
it('should generate valid GIF configuration for all frame rates', () => {
fc.assert(
fc.property(
fc.constantFrom(10, 15, 20, 25, 30),
fc.integer({ min: 100, max: 1920 }),
fc.integer({ min: 100, max: 1080 }),
fc.boolean(),
(frameRate: number, width: number, height: number, loop: boolean) => {
// Validate frame delay calculation (gif.js uses milliseconds)
const frameDelay = Math.round(1000 / frameRate);
// Frame delay should be positive and reasonable
expect(frameDelay).toBeGreaterThan(0);
expect(frameDelay).toBeLessThanOrEqual(100); // 10fps = 100ms delay
// Loop configuration
const repeat = loop ? 0 : 1;
expect([0, 1]).toContain(repeat);
// Dimensions should be positive
expect(width).toBeGreaterThan(0);
expect(height).toBeGreaterThan(0);
}
),
{ numRuns: 100 }
);
});
it('should calculate correct frame delays for each frame rate', () => {
const expectedDelays: Record<number, number> = {
10: 100, // 1000ms / 10fps = 100ms
15: 67, // 1000ms / 15fps ≈ 67ms
20: 50, // 1000ms / 20fps = 50ms
25: 40, // 1000ms / 25fps = 40ms
30: 33, // 1000ms / 30fps ≈ 33ms
};
for (const [fps, expectedDelay] of Object.entries(expectedDelays)) {
const frameRate = Number(fps);
const actualDelay = Math.round(1000 / frameRate);
expect(actualDelay).toBe(expectedDelay);
}
});
});
/**
* Property 7: MP4 Export Regression
*
* *For any* valid MP4 export configuration that worked before this feature,
* the Video_Exporter SHALL continue to produce valid MP4 output.
*
* **Validates: Requirements 7.2**
*
* Feature: gif-export, Property 7: MP4 Export Regression
*
* Note: This test validates that MP4 export configuration remains unchanged.
*/
describe('Property 7: MP4 Export Regression', () => {
it('should maintain valid MP4 quality presets', () => {
const qualityPresets = ['medium', 'good', 'source'];
fc.assert(
fc.property(
fc.constantFrom(...qualityPresets),
(quality: string) => {
// Quality presets should be valid
expect(['medium', 'good', 'source']).toContain(quality);
}
),
{ numRuns: 100 }
);
});
it('should calculate valid MP4 export dimensions', () => {
fc.assert(
fc.property(
fc.integer({ min: 640, max: 3840 }), // sourceWidth
fc.integer({ min: 480, max: 2160 }), // sourceHeight
fc.constantFrom('medium', 'good', 'source'),
(sourceWidth: number, sourceHeight: number, quality: string) => {
let exportWidth: number;
let exportHeight: number;
const aspectRatio = sourceWidth / sourceHeight;
if (quality === 'source') {
// Source quality uses original dimensions (may be odd)
exportWidth = sourceWidth;
exportHeight = sourceHeight;
// Dimensions should be positive
expect(exportWidth).toBeGreaterThan(0);
expect(exportHeight).toBeGreaterThan(0);
} else {
const targetHeight = quality === 'medium' ? 720 : 1080;
exportHeight = Math.floor(targetHeight / 2) * 2;
exportWidth = Math.floor((exportHeight * aspectRatio) / 2) * 2;
// Dimensions should be positive and even for non-source quality
expect(exportWidth).toBeGreaterThan(0);
expect(exportHeight).toBeGreaterThan(0);
expect(exportWidth % 2).toBe(0);
expect(exportHeight % 2).toBe(0);
}
}
),
{ numRuns: 100 }
);
});
it('should maintain aspect ratio in MP4 export', () => {
fc.assert(
fc.property(
fc.integer({ min: 640, max: 3840 }),
fc.integer({ min: 480, max: 2160 }),
fc.constantFrom('medium', 'good'),
(sourceWidth: number, sourceHeight: number, quality: string) => {
const originalAspectRatio = sourceWidth / sourceHeight;
const targetHeight = quality === 'medium' ? 720 : 1080;
const exportHeight = Math.floor(targetHeight / 2) * 2;
const exportWidth = Math.floor((exportHeight * originalAspectRatio) / 2) * 2;
const exportAspectRatio = exportWidth / exportHeight;
// Aspect ratio should be preserved within tolerance (due to even rounding)
expect(Math.abs(originalAspectRatio - exportAspectRatio)).toBeLessThan(0.05);
}
),
{ numRuns: 100 }
);
});
});
+5 -2
View File
@@ -2,7 +2,7 @@ import GIF from 'gif.js';
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString();
@@ -16,6 +16,7 @@ interface GifExporterConfig {
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
speedRegions?: SpeedRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
@@ -100,6 +101,7 @@ export class GifExporter {
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
});
@@ -122,7 +124,7 @@ export class GifExporter {
});
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions);
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
// Calculate frame delay in milliseconds (gif.js uses ms)
@@ -142,6 +144,7 @@ export class GifExporter {
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
+42 -9
View File
@@ -1,5 +1,5 @@
import { WebDemuxer } from 'web-demuxer';
import type { TrimRegion } from '@/components/video-editor/types';
import type { TrimRegion, SpeedRegion } from '@/components/video-editor/types';
export interface DecodedVideoInfo {
width: number;
@@ -67,6 +67,7 @@ export class StreamingVideoDecoder {
async decodeAll(
targetFrameRate: number,
trimRegions: TrimRegion[] | undefined,
speedRegions: SpeedRegion[] | undefined,
onFrame: OnFrameCallback
): Promise<void> {
if (!this.demuxer || !this.metadata) {
@@ -74,7 +75,10 @@ export class StreamingVideoDecoder {
}
const decoderConfig = await this.demuxer.getDecoderConfig('video');
const segments = this.computeSegments(this.metadata.duration, trimRegions);
const segments = this.splitBySpeed(
this.computeSegments(this.metadata.duration, trimRegions),
speedRegions
);
const frameDurationUs = 1_000_000 / targetFrameRate;
// Async frame queue — decoder pushes, consumer pulls
@@ -218,7 +222,7 @@ export class StreamingVideoDecoder {
*/
private async deliverSegment(
frames: VideoFrame[],
segment: { startSec: number; endSec: number },
segment: { startSec: number; endSec: number; speed: number },
targetFrameRate: number,
frameDurationUs: number,
startExportFrameIndex: number,
@@ -226,7 +230,9 @@ export class StreamingVideoDecoder {
): Promise<number> {
if (frames.length === 0) return startExportFrameIndex;
const segmentFrameCount = Math.ceil((segment.endSec - segment.startSec) * targetFrameRate);
const segmentFrameCount = Math.ceil(
(segment.endSec - segment.startSec) / segment.speed * targetFrameRate
);
let exportFrameIndex = startExportFrameIndex;
for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) {
@@ -271,12 +277,39 @@ export class StreamingVideoDecoder {
return segments;
}
getEffectiveDuration(trimRegions?: TrimRegion[]): number {
getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number {
if (!this.metadata) throw new Error('Must call loadMetadata() first');
const trimmed = (trimRegions || []).reduce(
(sum, r) => sum + (r.endMs - r.startMs) / 1000, 0
);
return this.metadata.duration - trimmed;
const trimSegments = this.computeSegments(this.metadata.duration, trimRegions);
const speedSegments = this.splitBySpeed(trimSegments, speedRegions);
return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0);
}
private splitBySpeed(
segments: Array<{ startSec: number; endSec: number }>,
speedRegions?: SpeedRegion[]
): Array<{ startSec: number; endSec: number; speed: number }> {
if (!speedRegions || speedRegions.length === 0)
return segments.map(s => ({ ...s, speed: 1 }));
const result: Array<{ startSec: number; endSec: number; speed: number }> = [];
for (const segment of segments) {
const overlapping = speedRegions
.filter(sr => (sr.startMs / 1000) < segment.endSec && (sr.endMs / 1000) > segment.startSec)
.sort((a, b) => a.startMs - b.startMs);
if (overlapping.length === 0) { result.push({ ...segment, speed: 1 }); continue; }
let cursor = segment.startSec;
for (const sr of overlapping) {
const srStart = Math.max(sr.startMs / 1000, segment.startSec);
const srEnd = Math.min(sr.endMs / 1000, segment.endSec);
if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 });
result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed });
cursor = srEnd;
}
if (cursor < segment.endSec) result.push({ startSec: cursor, endSec: segment.endSec, speed: 1 });
}
return result.filter(s => s.endSec - s.startSec > 0.0001);
}
cancel(): void {
-63
View File
@@ -1,63 +0,0 @@
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import {
isValidGifFrameRate,
VALID_GIF_FRAME_RATES,
GifFrameRate
} from './types';
/**
* Property 1: Valid Frame Rate Acceptance
*
* *For any* frame rate value, the GIF_Exporter SHALL accept it if and only if
* it is one of the valid presets (15, 20, 25, 30 FPS). Invalid frame rates
* should be rejected with an error.
*
* **Validates: Requirements 2.2**
*
* Feature: gif-export, Property 1: Valid Frame Rate Acceptance
*/
describe('GIF Export Types', () => {
describe('Property 1: Valid Frame Rate Acceptance', () => {
// Property test: Valid frame rates should be accepted
it('should accept all valid frame rates (15, 20, 25, 30)', () => {
fc.assert(
fc.property(
fc.constantFrom(...VALID_GIF_FRAME_RATES),
(frameRate: GifFrameRate) => {
expect(isValidGifFrameRate(frameRate)).toBe(true);
}
),
{ numRuns: 100 }
);
});
// Property test: Invalid frame rates should be rejected
it('should reject any frame rate not in the valid set', () => {
fc.assert(
fc.property(
fc.integer().filter(n => !VALID_GIF_FRAME_RATES.includes(n as GifFrameRate)),
(invalidFrameRate: number) => {
expect(isValidGifFrameRate(invalidFrameRate)).toBe(false);
}
),
{ numRuns: 100 }
);
});
// Property test: Frame rate validation is deterministic
it('should return consistent results for the same input', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 60 }),
(frameRate: number) => {
const result1 = isValidGifFrameRate(frameRate);
const result2 = isValidGifFrameRate(frameRate);
expect(result1).toBe(result2);
}
),
{ numRuns: 100 }
);
});
});
});
+8 -5
View File
@@ -2,13 +2,14 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import { VideoMuxer } from './muxer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
speedRegions?: SpeedRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
@@ -68,6 +69,7 @@ export class VideoExporter {
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
});
@@ -81,7 +83,7 @@ export class VideoExporter {
await this.muxer.initialize();
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions);
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
@@ -96,6 +98,7 @@ export class VideoExporter {
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
@@ -125,8 +128,8 @@ export class VideoExporter {
});
// Check encoder queue before encoding to keep it full
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 0));
while (this.encoder && this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 5));
}
if (this.encoder && this.encoder.state === 'configured') {
@@ -250,7 +253,7 @@ export class VideoExporter {
height: this.config.height,
bitrate: this.config.bitrate,
framerate: this.config.frameRate,
latencyMode: 'realtime',
latencyMode: 'quality', // Changed from 'realtime' to 'quality' for better throughput
bitrateMode: 'variable',
hardwareAcceleration: 'prefer-hardware',
};
+125
View File
@@ -0,0 +1,125 @@
export const SHORTCUT_ACTIONS = [
'addZoom',
'addTrim',
'addSpeed',
'addAnnotation',
'addKeyframe',
'deleteSelected',
'playPause',
] as const;
export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number];
export interface ShortcutBinding {
key: string;
/** Maps to Cmd on macOS, Ctrl on Windows/Linux */
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
}
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
export interface FixedShortcut {
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ label: 'Cycle Annotations Forward', display: 'Tab', bindings: [{ key: 'tab' }] },
{ label: 'Cycle Annotations Backward', display: 'Shift + Tab', bindings: [{ key: 'tab', shift: true }] },
{ label: 'Delete Selected (alt)', display: 'Del / ⌫', bindings: [{ key: 'delete' }, { key: 'backspace' }] },
{ label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll', bindings: [] },
{ label: 'Zoom Timeline', display: 'Ctrl + Scroll', bindings: [] },
];
export type ShortcutConflict =
| { type: 'configurable'; action: ShortcutAction }
| { type: 'fixed'; label: string };
export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean {
return (
a.key.toLowerCase() === b.key.toLowerCase() &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.alt === !!b.alt
);
}
export function findConflict(
binding: ShortcutBinding,
forAction: ShortcutAction,
config: ShortcutsConfig,
): ShortcutConflict | null {
for (const fixed of FIXED_SHORTCUTS) {
if (fixed.bindings.some((b) => bindingsEqual(b, binding))) {
return { type: 'fixed', label: fixed.label };
}
}
for (const action of SHORTCUT_ACTIONS) {
if (action !== forAction && bindingsEqual(config[action], binding)) {
return { type: 'configurable', action };
}
}
return null;
}
export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addZoom: { key: 'z' },
addTrim: { key: 't' },
addSpeed: { key: 's' },
addAnnotation: { key: 'a' },
addKeyframe: { key: 'f' },
deleteSelected: { key: 'd', ctrl: true },
playPause: { key: ' ' },
};
export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
addZoom: 'Add Zoom',
addTrim: 'Add Trim',
addSpeed: 'Add Speed',
addAnnotation: 'Add Annotation',
addKeyframe: 'Add Keyframe',
deleteSelected: 'Delete Selected',
playPause: 'Play / Pause',
};
export function matchesShortcut(
e: KeyboardEvent,
binding: ShortcutBinding,
isMacPlatform: boolean,
): boolean {
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
if (primaryMod !== !!binding.ctrl) return false;
if (e.shiftKey !== !!binding.shift) return false;
if (e.altKey !== !!binding.alt) return false;
return true;
}
const KEY_LABELS: Record<string, string> = {
' ': 'Space', 'delete': 'Del', 'backspace': '⌫', 'escape': 'Esc',
'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→',
};
export function formatBinding(binding: ShortcutBinding, isMac: boolean): string {
const parts: string[] = [];
if (binding.ctrl) parts.push(isMac ? '⌘' : 'Ctrl');
if (binding.shift) parts.push(isMac ? '⇧' : 'Shift');
if (binding.alt) parts.push(isMac ? '⌥' : 'Alt');
parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase());
return parts.join(' + ');
}
export function mergeWithDefaults(partial: Partial<ShortcutsConfig>): ShortcutsConfig {
const merged = { ...DEFAULT_SHORTCUTS };
for (const action of SHORTCUT_ACTIONS) {
if (partial[action]) {
merged[action] = partial[action] as ShortcutBinding;
}
}
return merged;
}
+49 -11
View File
@@ -9,6 +9,12 @@ interface ProcessedDesktopSource {
appIcon: string | null;
}
interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
}
interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>
@@ -30,17 +36,49 @@ interface Window {
}>
getAssetBasePath: () => Promise<string | null>
setRecordingState: (recording: boolean) => Promise<void>
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
}
}
}