Compare commits

...
32 Commits
Author SHA1 Message Date
Siddharth 6a8b99c7bd spacing 2025-11-27 22:33:34 -07:00
Siddharth 1241de6e1a trim integration export 2025-11-27 22:24:17 -07:00
Siddharth 3998af5398 skip trimmed area seeking 2025-11-27 21:44:07 -07:00
Siddharth 2b5b15f3e8 basic trim setup 2025-11-27 16:35:21 -07:00
Siddharth e549850b75 hud overlay UX improvements 2025-11-27 15:12:38 -07:00
Siddharth b6d6fe6a70 draggable UI resizer 2025-11-27 14:17:10 -07:00
Siddharth e6cb86fafc prevent branch target runs 2025-11-26 11:46:52 -07:00
Sid 1dbbf8721c Merge pull request #11 from FlyingThaCat/main
add untracked uuid packages
2025-11-26 10:43:30 -08:00
john c63f5eddeb make the hud in right bottom corner 2025-11-26 23:12:04 +07:00
john 6475814541 add untracked uuid packages 2025-11-26 22:46:47 +07:00
Siddharth d85e6e1254 wallpaper count 2025-11-25 22:03:37 -07:00
Siddharth 6baeebec96 more zoom options, info popup 2025-11-25 21:43:30 -07:00
Siddharth ddf30ed60e record/ select your own video 2025-11-25 21:18:57 -07:00
Siddharth 98d6acaa6a keyframes 2025-11-25 18:45:37 -07:00
Siddharth 060a7bab92 timeline updates 2025-11-25 17:24:35 -07:00
Siddharth 48253cc31d file dialog choose location 2025-11-25 15:37:03 -07:00
Siddharth f887d09865 shadow intensity 2025-11-25 15:00:06 -07:00
Siddharth 6634d0eb53 editor ui improvements 2025-11-25 14:20:13 -07:00
Siddharth 6617fd39f6 configsupportcheck and throttling 2025-11-24 21:39:03 -07:00
Siddharth 8ec426d200 level 5.1 w software encoding 2025-11-24 18:49:08 -07:00
Siddharth 188ba94aad test win codec fix 2025-11-24 17:11:37 -07:00
Siddharth 7129d55f86 add support in app 2025-11-24 02:39:12 -07:00
Siddharth 483ace8e46 update fndg 2025-11-24 02:00:34 -07:00
Siddharth bf63154801 arm64 support-fix 2025-11-24 01:06:51 -07:00
Siddharth 437eded23a arm64 support 2025-11-24 01:03:23 -07:00
Siddharth 472d4053f4 arm64 support 2025-11-24 00:56:36 -07:00
Siddharth 864902b660 testing win editor issue 2025-11-24 00:44:46 -07:00
Siddharth dac826a5bc icon for win in builder 2025-11-24 00:02:15 -07:00
Siddharth dd17551c18 test actions 2025-11-23 23:56:06 -07:00
Siddharth dae7dc5212 rm uiohook-napi 2025-11-23 23:32:52 -07:00
Siddharth 210977faf4 pr template 2025-11-23 22:06:30 -07:00
Sid 9c6ac891e4 Merge pull request #2 from siddharthvaddem/v0.1.1
V0.1.1
2025-11-23 17:04:49 -07:00
48 changed files with 9795 additions and 820 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
buy_me_a_coffee: siddharthvaddem
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+43
View File
@@ -0,0 +1,43 @@
# Pull Request Template
## Description
<!-- Briefly describe the purpose of this PR. -->
## Motivation
<!-- Explain why this change is needed. What problem does it solve? -->
## Type of Change
- [ ] New Feature
- [ ] Bug Fix
- [ ] Refactor / Code Cleanup
- [ ] Documentation Update
- [ ] Other (please specify)
## Related Issue(s)
<!-- Link to any related issue(s) (e.g., #123) -->
## Screenshots / Video
<!-- Include screenshots or a short video demonstrating the change. If the change adds a new UI feature, attach an image. If it adds functionality best shown via video, embed a video. -->
**Screenshot** (if applicable):
```markdown
![Screenshot Description](path/to/screenshot.png)
```
**Video** (if applicable):
```html
<video src="path/to/video.mp4" controls width="600"></video>
```
## Testing
<!-- Describe how reviewers can test the changes. Include steps, commands, or environment setup. -->
## Checklist
- [ ] I have performed a self-review of my code.
- [ ] I have added any necessary screenshots or videos.
- [ ] I have linked related issue(s) and updated the changelog if applicable.
---
*Thank you for contributing!*
+70
View File
@@ -0,0 +1,70 @@
name: Build Electron App
on:
workflow_dispatch:
jobs:
build-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build Windows app
run: npm run build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Windows build
uses: actions/upload-artifact@v4
with:
name: windows-installer
path: release/**/*.exe
retention-days: 30
build-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build macOS app
run: npm run build:mac
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload macOS build
uses: actions/upload-artifact@v4
with:
name: macos-installer
path: release/**/*.dmg
retention-days: 30
+339 -285
View File
@@ -1,343 +1,397 @@
import { BrowserWindow as P, screen as O, ipcMain as c, desktopCapturer as M, shell as W, app as p, nativeImage as L, Tray as V, Menu as U } from "electron";
import { fileURLToPath as E } from "node:url";
import t from "node:path";
import m from "node:fs/promises";
import { uIOhook as w } from "uiohook-napi";
const S = t.dirname(E(import.meta.url)), A = t.join(S, ".."), y = process.env.VITE_DEV_SERVER_URL, x = t.join(A, "dist");
function C() {
const e = new P({
width: 250,
height: 80,
minWidth: 250,
maxWidth: 250,
minHeight: 80,
maxHeight: 80,
frame: !1,
transparent: !0,
resizable: !1,
alwaysOnTop: !0,
skipTaskbar: !0,
hasShadow: !1,
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();
}
});
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,
minWidth: 500,
maxWidth: 500,
minHeight: 100,
maxHeight: 100,
x,
y,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
webPreferences: {
preload: t.join(S, "preload.mjs"),
nodeIntegration: !1,
contextIsolation: !0,
backgroundThrottling: !1
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false
}
});
return e.webContents.on("did-finish-load", () => {
e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
}), y ? e.loadURL(y + "?windowType=hud-overlay") : e.loadFile(t.join(x, "index.html"), {
query: { windowType: "hud-overlay" }
}), e;
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;
}
function N() {
const e = new P({
function createEditorWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
transparent: !1,
resizable: !0,
alwaysOnTop: !1,
skipTaskbar: !1,
transparent: false,
resizable: true,
alwaysOnTop: false,
skipTaskbar: false,
title: "OpenScreen",
backgroundColor: "#000000",
webPreferences: {
preload: t.join(S, "preload.mjs"),
nodeIntegration: !1,
contextIsolation: !0,
webSecurity: !1
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
backgroundThrottling: false
}
});
return e.maximize(), e.webContents.on("did-finish-load", () => {
e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
}), y ? e.loadURL(y + "?windowType=editor") : e.loadFile(t.join(x, "index.html"), {
query: { windowType: "editor" }
}), e;
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;
}
function H() {
const { width: e, height: n } = O.getPrimaryDisplay().workAreaSize, i = new P({
function createSourceSelectorWindow() {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
const win = new BrowserWindow({
width: 620,
height: 420,
minHeight: 350,
maxHeight: 500,
x: Math.round((e - 620) / 2),
y: Math.round((n - 420) / 2),
frame: !1,
resizable: !1,
alwaysOnTop: !0,
transparent: !0,
x: Math.round((width - 620) / 2),
y: Math.round((height - 420) / 2),
frame: false,
resizable: false,
alwaysOnTop: true,
transparent: true,
backgroundColor: "#00000000",
webPreferences: {
preload: t.join(S, "preload.mjs"),
nodeIntegration: !1,
contextIsolation: !0
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true
}
});
return y ? i.loadURL(y + "?windowType=source-selector") : i.loadFile(t.join(x, "index.html"), {
query: { windowType: "source-selector" }
}), i;
}
let u = !1, _ = !1, d = 0, f = [];
function z() {
if (u)
return { success: !1, message: "Already tracking" };
if (u = !0, d = performance.now(), f = [], _)
return { success: !0, message: "Mouse tracking resumed", startTime: d };
q();
try {
return w.start(), _ = !0, { success: !0, message: "Mouse tracking started", startTime: d };
} catch (e) {
return console.error("Failed to start mouse tracking:", e), u = !1, { success: !1, message: "Failed to start hook", error: e };
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;
}
function B() {
if (!u)
return { success: !1, message: "Not currently tracking" };
u = !1;
const e = performance.now() - d;
return {
success: !0,
message: "Mouse tracking stopped",
data: {
startTime: d,
events: f,
duration: e
}
};
}
function q() {
w.on("mousemove", (e) => {
if (u) {
const i = {
type: "move",
timestamp: performance.now() - d,
x: e.x,
y: e.y
};
f.push(i);
}
}), w.on("mousedown", (e) => {
if (u) {
const i = {
type: "down",
timestamp: performance.now() - d,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
};
f.push(i);
}
}), w.on("mouseup", (e) => {
if (u) {
const i = {
type: "up",
timestamp: performance.now() - d,
x: e.x,
y: e.y,
button: e.button
};
f.push(i);
}
}), w.on("click", (e) => {
if (u) {
const i = {
type: "click",
timestamp: performance.now() - d,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
};
f.push(i);
}
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
}));
});
}
function $() {
return [...f];
}
function G() {
if (_)
try {
w.stop(), _ = !1, u = !1, f = [];
} catch (e) {
console.error("Error cleaning up mouse tracking:", e);
ipcMain.handle("select-source", (_, source) => {
selectedSource = source;
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.close();
}
}
let b = null;
function J(e, n, i, v, T) {
c.handle("get-sources", async (o, a) => (await M.getSources(a)).map((r) => ({
id: r.id,
name: r.name,
display_id: r.display_id,
thumbnail: r.thumbnail ? r.thumbnail.toDataURL() : null,
appIcon: r.appIcon ? r.appIcon.toDataURL() : null
}))), c.handle("select-source", (o, a) => {
b = a;
const s = v();
return s && s.close(), b;
}), c.handle("get-selected-source", () => b), c.handle("open-source-selector", () => {
const o = v();
if (o) {
o.focus();
return selectedSource;
});
ipcMain.handle("get-selected-source", () => {
return selectedSource;
});
ipcMain.handle("open-source-selector", () => {
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.focus();
return;
}
n();
}), c.handle("switch-to-editor", () => {
const o = i();
o && o.close(), e();
}), c.handle("start-mouse-tracking", () => z()), c.handle("stop-mouse-tracking", () => B()), c.handle("store-recorded-video", async (o, a, s) => {
createSourceSelectorWindow2();
});
ipcMain.handle("switch-to-editor", () => {
const mainWin = getMainWindow();
if (mainWin) {
mainWin.close();
}
createEditorWindow2();
});
ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => {
try {
const r = t.join(h, s);
return await m.writeFile(r, Buffer.from(a)), {
success: !0,
path: r,
const videoPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
currentVideoPath = videoPath;
return {
success: true,
path: videoPath,
message: "Video stored successfully"
};
} catch (r) {
return console.error("Failed to store video:", r), {
success: !1,
} catch (error) {
console.error("Failed to store video:", error);
return {
success: false,
message: "Failed to store video",
error: String(r)
};
}
}), c.handle("store-mouse-tracking-data", async (o, a) => {
try {
const s = $();
if (s.length === 0)
return { success: !1, message: "No tracking data to save" };
const r = t.join(h, a);
return await m.writeFile(r, JSON.stringify(s, null, 2), "utf-8"), {
success: !0,
path: r,
eventCount: s.length,
message: "Mouse tracking data stored successfully"
};
} catch (s) {
return console.error("Failed to store mouse tracking data:", s), {
success: !1,
message: "Failed to store mouse tracking data",
error: String(s)
};
}
}), c.handle("get-recorded-video-path", async () => {
try {
const a = (await m.readdir(h)).filter((R) => R.endsWith(".webm"));
if (a.length === 0)
return { success: !1, message: "No recorded video found" };
const s = a.sort().reverse()[0];
return { success: !0, path: t.join(h, s) };
} catch (o) {
return console.error("Failed to get video path:", o), { success: !1, message: "Failed to get video path", error: String(o) };
}
}), c.handle("set-recording-state", (o, a) => {
T && T(a, (b || { name: "Screen" }).name);
}), c.handle("open-external-url", async (o, a) => {
try {
return await W.openExternal(a), { success: !0 };
} catch (s) {
return console.error("Failed to open URL:", s), { success: !1, error: String(s) };
}
}), c.handle("get-asset-base-path", () => {
try {
return p.isPackaged ? t.join(process.resourcesPath, "assets") : t.join(p.getAppPath(), "public", "assets");
} catch (o) {
return console.error("Failed to resolve asset base path:", o), null;
}
}), c.handle("save-exported-video", async (o, a, s) => {
try {
const r = p.getPath("downloads"), R = t.join(r, s);
return await m.writeFile(R, Buffer.from(a)), {
success: !0,
path: R,
message: "Video exported successfully"
};
} catch (r) {
return console.error("Failed to save exported video:", r), {
success: !1,
message: "Failed to save exported video",
error: String(r)
error: String(error)
};
}
});
}
const K = t.dirname(E(import.meta.url)), h = t.join(p.getPath("userData"), "recordings");
async function Q() {
try {
const e = await m.readdir(h), n = Date.now(), i = 1 * 24 * 60 * 60 * 1e3;
for (const v of e) {
const T = t.join(h, v), o = await m.stat(T);
n - o.mtimeMs > i && (await m.unlink(T), console.log(`Deleted old recording: ${v}`));
ipcMain.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) };
}
} catch (e) {
console.error("Failed to cleanup old recordings:", e);
}
});
ipcMain.handle("set-recording-state", (_, recording) => {
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
}
});
ipcMain.handle("open-external-url", async (_, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error("Failed to open URL:", error);
return { success: false, error: String(error) };
}
});
ipcMain.handle("get-asset-base-path", () => {
try {
if (app.isPackaged) {
return path.join(process.resourcesPath, "assets");
}
return path.join(app.getAppPath(), "public", "assets");
} catch (err) {
console.error("Failed to resolve asset base path:", err);
return null;
}
});
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
try {
const result = await dialog.showSaveDialog({
title: "Save Exported Video",
defaultPath: path.join(app.getPath("downloads"), fileName),
filters: [
{ name: "MP4 Video", extensions: ["mp4"] }
],
properties: ["createDirectory", "showOverwriteConfirmation"]
});
if (result.canceled || !result.filePath) {
return {
success: false,
cancelled: true,
message: "Export cancelled"
};
}
await fs.writeFile(result.filePath, Buffer.from(videoData));
return {
success: true,
path: result.filePath,
message: "Video exported successfully"
};
} catch (error) {
console.error("Failed to save exported video:", error);
return {
success: false,
message: "Failed to save exported video",
error: String(error)
};
}
});
ipcMain.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Select Video File",
defaultPath: RECORDINGS_DIR,
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,
message: "Failed to open file picker",
error: String(error)
};
}
});
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 };
});
}
async function X() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function ensureRecordingsDir() {
try {
await m.mkdir(h, { recursive: !0 }), console.log("Recordings directory ready:", h);
} catch (e) {
console.error("Failed to create recordings directory:", e);
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);
}
}
process.env.APP_ROOT = t.join(K, "..");
const Y = process.env.VITE_DEV_SERVER_URL, ie = t.join(process.env.APP_ROOT, "dist-electron"), I = t.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = Y ? t.join(process.env.APP_ROOT, "public") : I;
let l = null, k = null, g = null, j = "";
function D() {
l = C();
process.env.APP_ROOT = path.join(__dirname, "..");
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST;
let mainWindow = null;
let sourceSelectorWindow = null;
let tray = null;
let selectedSourceName = "";
function createWindow() {
mainWindow = createHudOverlayWindow();
}
function Z() {
const e = t.join(process.env.VITE_PUBLIC || I, "rec-button.png");
let n = L.createFromPath(e);
n = n.resize({ width: 24, height: 24, quality: "best" }), g = new V(n), F();
function createTray() {
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png");
let icon = nativeImage.createFromPath(iconPath);
icon = icon.resize({ width: 24, height: 24, quality: "best" });
tray = new Tray(icon);
updateTrayMenu();
}
function F() {
if (!g) return;
const e = [
function updateTrayMenu() {
if (!tray) return;
const menuTemplate = [
{
label: "Stop Recording",
click: () => {
l && !l.isDestroyed() && l.webContents.send("stop-recording-from-tray");
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
}
}
], n = U.buildFromTemplate(e);
g.setContextMenu(n), g.setToolTip(`Recording: ${j}`);
];
const contextMenu = Menu.buildFromTemplate(menuTemplate);
tray.setContextMenu(contextMenu);
tray.setToolTip(`Recording: ${selectedSourceName}`);
}
function ee() {
l && (l.close(), l = null), l = N();
function createEditorWindowWrapper() {
if (mainWindow) {
mainWindow.close();
mainWindow = null;
}
mainWindow = createEditorWindow();
}
function te() {
return k = H(), k.on("closed", () => {
k = null;
}), k;
function createSourceSelectorWindowWrapper() {
sourceSelectorWindow = createSourceSelectorWindow();
sourceSelectorWindow.on("closed", () => {
sourceSelectorWindow = null;
});
return sourceSelectorWindow;
}
p.on("window-all-closed", () => {
app.on("window-all-closed", () => {
});
p.on("activate", () => {
P.getAllWindows().length === 0 && D();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
p.on("before-quit", async (e) => {
e.preventDefault(), G(), await Q(), p.exit(0);
});
p.whenReady().then(async () => {
await X(), J(
ee,
te,
() => l,
() => k,
(e, n) => {
j = n, e ? (g || Z(), F(), l && l.minimize()) : (g && (g.destroy(), g = null), l && l.restore());
app.whenReady().then(async () => {
const { ipcMain: ipcMain2 } = await import("electron");
ipcMain2.on("hud-overlay-close", () => {
if (process.platform === "darwin") {
app.quit();
}
), D();
});
await ensureRecordingsDir();
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording, sourceName) => {
selectedSourceName = sourceName;
if (recording) {
if (!tray) createTray();
updateTrayMenu();
} else {
if (tray) {
tray.destroy();
tray = null;
}
if (mainWindow) mainWindow.restore();
}
}
);
createWindow();
});
export {
ie as MAIN_DIST,
h as RECORDINGS_DIR,
I as RENDERER_DIST,
Y as VITE_DEV_SERVER_URL
MAIN_DIST,
RECORDINGS_DIR,
RENDERER_DIST,
VITE_DEV_SERVER_URL
};
+60 -1
View File
@@ -1 +1,60 @@
"use strict";const e=require("electron");e.contextBridge.exposeInMainWorld("electronAPI",{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"),startMouseTracking:()=>e.ipcRenderer.invoke("start-mouse-tracking"),stopMouseTracking:()=>e.ipcRenderer.invoke("stop-mouse-tracking"),storeRecordedVideo:(r,t)=>e.ipcRenderer.invoke("store-recorded-video",r,t),storeMouseTrackingData:r=>e.ipcRenderer.invoke("store-mouse-tracking-data",r),getRecordedVideoPath:()=>e.ipcRenderer.invoke("get-recorded-video-path"),setRecordingState:r=>e.ipcRenderer.invoke("set-recording-state",r),onStopRecordingFromTray:r=>{const t=()=>r();return e.ipcRenderer.on("stop-recording-from-tray",t),()=>e.ipcRenderer.removeListener("stop-recording-from-tray",t)},openExternalUrl:r=>e.ipcRenderer.invoke("open-external-url",r),saveExportedVideo:(r,t)=>e.ipcRenderer.invoke("save-exported-video",r,t)});
"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");
}
});
+15 -8
View File
@@ -26,20 +26,27 @@
"to": "assets/wallpapers"
}
],
"asarUnpack": [
"**/node_modules/uiohook-napi/**/*"
],
"mac": {
"target": [
"dmg"
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${version}-Installer.${ext}"
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
}
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}"
},
"linux": {
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}.${ext}"
},
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
}
}
+7 -4
View File
@@ -29,15 +29,18 @@ interface Window {
openSourceSelector: () => Promise<void>
selectSource: (source: any) => Promise<any>
getSelectedSource: () => Promise<any>
startMouseTracking: () => Promise<void>
stopMouseTracking: () => Promise<void>
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
storeMouseTrackingData: (fileName: string) => Promise<{ success: boolean; path?: string; eventCount?: number; message?: string }>
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>
setRecordingState: (recording: boolean) => Promise<void>
onStopRecordingFromTray: (callback: () => void) => () => void
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: 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 }>
hudOverlayHide: () => void;
hudOverlayClose: () => void;
}
}
+68 -39
View File
@@ -1,5 +1,5 @@
import { ipcMain, desktopCapturer, BrowserWindow, shell, app } from 'electron'
import { startMouseTracking, stopMouseTracking, getTrackingData } from './mouseTracking'
import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog } from 'electron'
import fs from 'node:fs/promises'
import path from 'node:path'
import { RECORDINGS_DIR } from '../main'
@@ -54,19 +54,13 @@ export function registerIpcHandlers(
createEditorWindow()
})
ipcMain.handle('start-mouse-tracking', () => {
return startMouseTracking()
})
ipcMain.handle('stop-mouse-tracking', () => {
return stopMouseTracking()
})
ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName)
await fs.writeFile(videoPath, Buffer.from(videoData))
currentVideoPath = videoPath;
return {
success: true,
path: videoPath,
@@ -82,32 +76,7 @@ export function registerIpcHandlers(
}
})
ipcMain.handle('store-mouse-tracking-data', async (_, fileName: string) => {
try {
const data = getTrackingData()
if (data.length === 0) {
return { success: false, message: 'No tracking data to save' }
}
const trackingPath = path.join(RECORDINGS_DIR, fileName)
await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), 'utf-8')
return {
success: true,
path: trackingPath,
eventCount: data.length,
message: 'Mouse tracking data stored successfully'
}
} catch (error) {
console.error('Failed to store mouse tracking data:', error)
return {
success: false,
message: 'Failed to store mouse tracking data',
error: String(error)
}
}
})
ipcMain.handle('get-recorded-video-path', async () => {
try {
@@ -160,15 +129,29 @@ export function registerIpcHandlers(
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
const downloadsPath = app.getPath('downloads')
const videoPath = path.join(downloadsPath, fileName)
await fs.writeFile(videoPath, Buffer.from(videoData))
const result = await dialog.showSaveDialog({
title: 'Save Exported Video',
defaultPath: path.join(app.getPath('downloads'), fileName),
filters: [
{ name: 'MP4 Video', extensions: ['mp4'] }
],
properties: ['createDirectory', 'showOverwriteConfirmation']
});
if (result.canceled || !result.filePath) {
return {
success: false,
cancelled: true,
message: 'Export cancelled'
};
}
await fs.writeFile(result.filePath, Buffer.from(videoData));
return {
success: true,
path: videoPath,
path: result.filePath,
message: 'Video exported successfully'
}
};
} catch (error) {
console.error('Failed to save exported video:', error)
return {
@@ -178,4 +161,50 @@ export function registerIpcHandlers(
}
}
})
ipcMain.handle('open-video-file-picker', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Select Video File',
defaultPath: RECORDINGS_DIR,
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,
message: 'Failed to open file picker',
error: String(error)
};
}
});
let currentVideoPath: string | null = null;
ipcMain.handle('set-current-video-path', (_, path: string) => {
currentVideoPath = path;
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 };
});
}
-155
View File
@@ -1,155 +0,0 @@
// uiohook-napi [WIP: scoping out mouse tracking with cross platform support for potential auto zoom/ post processing cursor effects]
// not currently being used.
import { uIOhook } from 'uiohook-napi'
let isMouseTrackingActive = false
let isHookStarted = false
let recordingStartTime: number = 0
let mouseEventData: MouseEvent[] = []
export interface MouseEvent {
type: 'move' | 'down' | 'up' | 'click'
timestamp: number // milliseconds since recording started
x: number
y: number
button?: unknown
clicks?: number
}
export interface MouseTrackingSession {
startTime: number
events: MouseEvent[]
duration: number
}
export function startMouseTracking() {
if (isMouseTrackingActive) {
return { success: false, message: 'Already tracking' }
}
isMouseTrackingActive = true
// Reset data for new recording session
recordingStartTime = performance.now()
mouseEventData = []
// Only start the hook once
if (!isHookStarted) {
setupMouseEventListeners()
try {
uIOhook.start()
isHookStarted = true
return { success: true, message: 'Mouse tracking started', startTime: recordingStartTime }
} catch (error) {
console.error('Failed to start mouse tracking:', error)
isMouseTrackingActive = false
return { success: false, message: 'Failed to start hook', error }
}
} else {
return { success: true, message: 'Mouse tracking resumed', startTime: recordingStartTime }
}
}
export function stopMouseTracking(): { success: boolean; message: string; data?: MouseTrackingSession } {
if (!isMouseTrackingActive) {
return { success: false, message: 'Not currently tracking' }
}
isMouseTrackingActive = false
const duration = performance.now() - recordingStartTime
const session: MouseTrackingSession = {
startTime: recordingStartTime,
events: mouseEventData,
duration: duration
}
return {
success: true,
message: 'Mouse tracking stopped',
data: session
}
}
function setupMouseEventListeners() {
// Track mouse movement
uIOhook.on('mousemove', (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime
const event: MouseEvent = {
type: 'move',
timestamp,
x: e.x,
y: e.y
}
mouseEventData.push(event)
}
})
// Track mouse button press
uIOhook.on('mousedown', (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime
const event: MouseEvent = {
type: 'down',
timestamp,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
}
mouseEventData.push(event)
}
})
// Track mouse button release
uIOhook.on('mouseup', (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime
const event: MouseEvent = {
type: 'up',
timestamp,
x: e.x,
y: e.y,
button: e.button
}
mouseEventData.push(event)
}
})
// Track complete click events
uIOhook.on('click', (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime
const event: MouseEvent = {
type: 'click',
timestamp,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
}
mouseEventData.push(event)
}
})
}
export function getTrackingData(): MouseEvent[] {
return [...mouseEventData]
}
export function cleanupMouseTracking() {
if (isHookStarted) {
try {
uIOhook.stop()
isHookStarted = false
isMouseTrackingActive = false
mouseEventData = []
} catch (error) {
console.error('Error cleaning up mouse tracking:', error)
}
}
}
+12 -31
View File
@@ -4,37 +4,18 @@ import path from 'node:path'
import fs from 'node:fs/promises'
import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows'
import { registerIpcHandlers } from './ipc/handlers'
import { cleanupMouseTracking } from './ipc/mouseTracking'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings')
// Cleanup old recordings (older than 1 day)
async function cleanupOldRecordings() {
try {
const files = await fs.readdir(RECORDINGS_DIR)
const now = Date.now()
const maxAge = 1 * 24 * 60 * 60 * 1000
for (const file of files) {
const filePath = path.join(RECORDINGS_DIR, file)
const stats = await fs.stat(filePath)
if (now - stats.mtimeMs > maxAge) {
await fs.unlink(filePath)
console.log(`Deleted old recording: ${file}`)
}
}
} catch (error) {
console.error('Failed to cleanup old recordings:', error)
}
}
async function ensureRecordingsDir() {
try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true })
console.log('Recordings directory ready:', RECORDINGS_DIR)
console.log('RECORDINGS_DIR:', RECORDINGS_DIR)
console.log('User Data Path:', app.getPath('userData'))
} catch (error) {
console.error('Failed to create recordings directory:', error)
}
@@ -123,19 +104,20 @@ app.on('activate', () => {
}
})
// Cleanup old recordings on quit (both macOS and other platforms)
app.on('before-quit', async (event) => {
event.preventDefault()
cleanupMouseTracking()
await cleanupOldRecordings()
app.exit(0)
})
// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
// Listen for HUD overlay quit event (macOS only)
const { ipcMain } = await import('electron');
ipcMain.on('hud-overlay-close', () => {
if (process.platform === 'darwin') {
app.quit();
}
});
// Ensure recordings directory exists
await ensureRecordingsDir()
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
@@ -146,7 +128,6 @@ app.whenReady().then(async () => {
if (recording) {
if (!tray) createTray();
updateTrayMenu();
if (mainWindow) mainWindow.minimize();
} else {
if (tray) {
tray.destroy();
+20 -9
View File
@@ -1,6 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
hudOverlayHide: () => {
ipcRenderer.send('hud-overlay-hide');
},
hudOverlayClose: () => {
ipcRenderer.send('hud-overlay-close');
},
getAssetBasePath: async () => {
// ask main process for the correct base path (production vs dev)
return await ipcRenderer.invoke('get-asset-base-path')
@@ -20,18 +26,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSelectedSource: () => {
return ipcRenderer.invoke('get-selected-source')
},
startMouseTracking: () => {
return ipcRenderer.invoke('start-mouse-tracking')
},
stopMouseTracking: () => {
return ipcRenderer.invoke('stop-mouse-tracking')
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke('store-recorded-video', videoData, fileName)
},
storeMouseTrackingData: (fileName: string) => {
return ipcRenderer.invoke('store-mouse-tracking-data', fileName)
},
getRecordedVideoPath: () => {
return ipcRenderer.invoke('get-recorded-video-path')
},
@@ -49,4 +48,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke('save-exported-video', videoData, fileName)
},
openVideoFilePicker: () => {
return ipcRenderer.invoke('open-video-file-picker')
},
setCurrentVideoPath: (path: string) => {
return ipcRenderer.invoke('set-current-video-path', path)
},
getCurrentVideoPath: () => {
return ipcRenderer.invoke('get-current-video-path')
},
clearCurrentVideoPath: () => {
return ipcRenderer.invoke('clear-current-video-path')
},
})
+37 -6
View File
@@ -1,4 +1,5 @@
import { BrowserWindow, screen } from 'electron'
import { ipcMain } from 'electron'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -8,14 +9,34 @@ const APP_ROOT = path.join(__dirname, '..')
const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
const RENDERER_DIST = path.join(APP_ROOT, 'dist')
let hudOverlayWindow: BrowserWindow | null = null;
ipcMain.on('hud-overlay-hide', () => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
}
});
export function createHudOverlayWindow(): BrowserWindow {
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: 250,
height: 80,
minWidth: 250,
maxWidth: 250,
minHeight: 80,
maxHeight: 80,
width: windowWidth,
height: windowHeight,
minWidth: 500,
maxWidth: 500,
minHeight: 100,
maxHeight: 100,
x: x,
y: y,
frame: false,
transparent: true,
resizable: false,
@@ -35,6 +56,15 @@ export function createHudOverlayWindow(): BrowserWindow {
win?.webContents.send('main-process-message', (new Date).toLocaleString())
})
hudOverlayWindow = win;
win.on('closed', () => {
if (hudOverlayWindow === win) {
hudOverlayWindow = null;
}
});
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay')
} else {
@@ -65,6 +95,7 @@ export function createEditorWindow(): BrowserWindow {
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
backgroundThrottling: false,
},
})
+91 -22
View File
@@ -16,6 +16,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-colorful": "^2.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -27,14 +28,16 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uiohook-napi": "^1.5.4"
"uuid": "^13.0.0"
},
"devDependencies": {
"@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",
@@ -3516,6 +3519,13 @@
"@types/node": "*"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/verror": {
"version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
@@ -3729,9 +3739,9 @@
}
},
"node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.2.tgz",
"integrity": "sha512-ibw9OS29S7GlL+vDwU3p5XG3vhR7XdzUecydpZbakUeg2Td6nfsnrCAX9sbLwQ73p0abO42v+V4qRaWq+7/BjQ==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
@@ -3758,6 +3768,18 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-alpha/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-colorful": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-colorful/-/react-color-colorful-2.9.0.tgz",
@@ -3778,6 +3800,18 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-colorful/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-hue": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-hue/-/react-color-hue-2.9.0.tgz",
@@ -3796,6 +3830,18 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-hue/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-saturation": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-saturation/-/react-color-saturation-2.9.0.tgz",
@@ -3814,6 +3860,18 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-saturation/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-drag-event-interactive": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.9.0.tgz",
@@ -7618,6 +7676,16 @@
"node": ">= 6"
}
},
"node_modules/icon-gen/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/iconv-corefoundation": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
@@ -9384,6 +9452,7 @@
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"dev": true,
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
@@ -10519,6 +10588,16 @@
}
}
},
"node_modules/react-resizable-panels": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
"license": "MIT",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -12262,19 +12341,6 @@
"node": ">=14.17"
}
},
"node_modules/uiohook-napi": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.4.tgz",
"integrity": "sha512-7vPVDNwgb6MwTgviA/dnF2MrW0X5xm76fAqaOAC3cEKkswqAZOPw1USu14Sr6383s5qhXegcJaR63CpJOPCNAg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "4.x.x"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -12447,13 +12513,16 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/validate-npm-package-license": {
+6 -2
View File
@@ -8,7 +8,8 @@
"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:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
@@ -19,6 +20,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-colorful": "^2.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -30,14 +32,16 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uiohook-napi": "^1.5.4"
"uuid": "^13.0.0"
},
"devDependencies": {
"@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",
+8053
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- electron
- esbuild
- lzma-native
- phantomjs-prebuilt
Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 KiB

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

@@ -1,6 +1,37 @@
.electronDrag {
-webkit-app-region: drag;
}
.electronNoDrag {
-webkit-app-region: no-drag;
}
.folderButton {
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.folderText {
color: #cbd5e1;
transition: text-decoration 0.15s;
}
.folderButton:hover .folderText {
text-decoration: underline;
}
.hudOverlayButton {
cursor: pointer;
background: none;
border: none;
color: #fff;
opacity: 0.7;
transition: opacity 0.15s;
}
.hudOverlayButton:hover {
opacity: 0.7;
background: none !important;
}
+109 -15
View File
@@ -5,9 +5,39 @@ import { Button } from "../ui/button";
import { BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { MdMonitor } from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { FaFolderMinus } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
export function LaunchWindow() {
const { recording, toggleRecording } = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) setRecordingStart(Date.now());
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
}
}, 1000);
} else {
setRecordingStart(null);
setElapsed(0);
if (timer) clearInterval(timer);
}
return () => {
if (timer) clearInterval(timer);
};
}, [recording, recordingStart]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
@@ -42,51 +72,115 @@ export function LaunchWindow() {
}
};
const openVideoFile = async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (result.cancelled) {
return;
}
if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
await window.electronAPI.switchToEditor();
}
};
// IPC events for hide/close
const sendHudOverlayHide = () => {
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
window.electronAPI.hudOverlayHide();
}
};
const sendHudOverlayClose = () => {
if (window.electronAPI && window.electronAPI.hudOverlayClose) {
window.electronAPI.hudOverlayClose();
}
};
return (
<div className="w-full h-full flex items-center bg-transparent">
<div
className={`w-full max-w-2xl mx-auto flex items-center justify-between px-3 py-1.5 ${styles.electronDrag}`}
className={`w-full max-w-[500px] mx-auto flex items-center justify-between px-4 py-2 ${styles.electronDrag}`}
style={{
borderRadius: 14,
background: 'linear-gradient(135deg, rgba(30,30,40,0.85) 0%, rgba(20,20,30,0.75) 100%)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
boxShadow: '0 4px 16px 0 rgba(0,0,0,0.24), 0 1px 3px 0 rgba(0,0,0,0.12) inset',
border: '1px solid rgba(80,80,120,0.18)',
minHeight: 36,
borderRadius: 16,
background: 'linear-gradient(135deg, rgba(30,30,40,0.92) 0%, rgba(20,20,30,0.85) 100%)',
backdropFilter: 'blur(32px) saturate(180%)',
WebkitBackdropFilter: 'blur(32px) saturate(180%)',
boxShadow: '0 4px 24px 0 rgba(0,0,0,0.28), 0 1px 3px 0 rgba(0,0,0,0.14) inset',
border: '1px solid rgba(80,80,120,0.22)',
minHeight: 44,
}}
>
<div className={`flex items-center gap-1 ${styles.electronDrag}`}> <RxDragHandleDots2 size={18} className="text-white/40" /> </div>
<Button
variant="link"
size="sm"
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left text-xs ${styles.electronNoDrag}`}
onClick={openSourceSelector}
>
<MdMonitor size={13} className="text-white" />
{truncateText(selectedSource)}
<MdMonitor size={14} className="text-white" />
{truncateText(selectedSource, 6)}
</Button>
<div className="w-px h-5 bg-white/30" />
<div className="w-px h-6 bg-white/30" />
<Button
variant="link"
size="sm"
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
className={`gap-1 bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag}`}
className={`gap-1 bg-transparent hover:bg-transparent px-0 flex-1 text-center text-xs ${styles.electronNoDrag}`}
>
{recording ? (
<>
<FaRegStopCircle size={13} className="text-red-400" />
<span className="text-red-400">Stop</span>
<FaRegStopCircle size={14} className="text-red-400" />
<span className="text-red-400">{formatTime(elapsed)}</span>
</>
) : (
<>
<BsRecordCircle size={13} className={hasSelectedSource ? "text-white" : "text-white/50"} />
<BsRecordCircle size={14} className={hasSelectedSource ? "text-white" : "text-white/50"} />
<span className={hasSelectedSource ? "text-white" : "text-white/50"}>Record</span>
</>
)}
</Button>
<div className="w-px h-6 bg-white/30" />
<Button
variant="link"
size="sm"
onClick={openVideoFile}
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag} ${styles.folderButton}`}
>
<FaFolderMinus size={14} className="text-white" />
<span className={styles.folderText}>Open</span>
</Button>
{/* Separator before hide/close buttons */}
<div className="w-px h-6 bg-white/30 mx-2" />
<Button
variant="link"
size="icon"
className={`ml-2 ${styles.electronNoDrag} hudOverlayButton`}
title="Hide HUD"
onClick={sendHudOverlayHide}
>
<FiMinus size={18} style={{ color: '#fff', opacity: 0.7 }} />
</Button>
<Button
variant="link"
size="icon"
className={`ml-1 ${styles.electronNoDrag} hudOverlayButton`}
title="Close App"
onClick={sendHudOverlayClose}
>
<FiX size={18} style={{ color: '#fff', opacity: 0.7 }} />
</Button>
</div>
</div>
);
+2 -1
View File
@@ -7,7 +7,8 @@ interface ItemContentProps extends PropsWithChildren {
function ItemContent({ children, classes }: ItemContentProps) {
return (
<div
className={`border-2 rounded-sm shadow-md w-full overflow-hidden flex flex-row pl-3 h-items-center ${classes}`}
className={`bg-white/5 border border-white/10 rounded-md shadow-sm w-full flex flex-row items-center px-3 py-1 gap-2 transition-all duration-150 hover:bg-[#34B27B]/10 hover:shadow-lg ${classes}`}
style={{ minHeight: 40 }}
>
{children}
</div>
+3 -3
View File
@@ -15,10 +15,10 @@ const Slider = React.forwardRef<
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10">
<SliderPrimitive.Range className="absolute h-full bg-[#34B27B]" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-[#34B27B] bg-[#34B27B] shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
@@ -0,0 +1,34 @@
import { HelpCircle } from "lucide-react";
export function KeyboardShortcutsHelp() {
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="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 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">Delete Selected</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + D</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⇧ + ⌘ + Scroll</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + Scroll</kbd>
</div>
</div>
</div>
</div>
);
}
@@ -31,7 +31,7 @@ export default function PlaybackControls({
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className="flex items-center gap-3 px-4 py-2 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
<div className="flex items-center gap-2 px-1 py-0.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
<Button
onClick={onTogglePlayPause}
size="icon"
@@ -50,7 +50,7 @@ export default function PlaybackControls({
)}
</Button>
<span className="text-[10px] font-medium text-slate-300 tabular-nums w-[35px] text-right">
<span className="text-[9px] font-medium text-slate-300 tabular-nums w-[30px] text-right">
{formatTime(currentTime)}
</span>
@@ -84,7 +84,7 @@ export default function PlaybackControls({
/>
</div>
<span className="text-[10px] font-medium text-slate-500 tabular-nums w-[35px]">
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
{formatTime(duration)}
</span>
</div>
+79 -53
View File
@@ -1,6 +1,7 @@
import { cn } from "@/lib/utils";
import { useEffect, useRef } from "react";
import { getAssetPath } from "@/lib/assetPath";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
@@ -8,11 +9,13 @@ import { useState } from "react";
import Colorful from '@uiw/react-color-colorful';
import { hsvaToHex } from '@uiw/color-convert';
import { Trash2, Download, Crop, X, Bug, Upload } from "lucide-react";
import { GiHearts } from "react-icons/gi";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion } from "./types";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
const WALLPAPER_COUNT = 23;
const WALLPAPER_COUNT = 20;
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
@@ -48,8 +51,8 @@ interface SettingsPanelProps {
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
showShadow?: boolean;
onShadowChange?: (showShadow: boolean) => void;
shadowIntensity?: number;
onShadowChange?: (intensity: number) => void;
showBlur?: boolean;
onBlurChange?: (showBlur: boolean) => void;
cropRegion?: CropRegion;
@@ -66,9 +69,10 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 3, label: "1.8×" },
{ depth: 4, label: "2.2×" },
{ depth: 5, label: "3.5×" },
{ depth: 6, label: "5×" },
];
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement, onExport }: SettingsPanelProps) {
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, shadowIntensity = 0, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement, onExport }: SettingsPanelProps) {
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -145,17 +149,20 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
};
return (
<div className="flex-[3] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-6 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-8">
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">Zoom Level</span>
{zoomEnabled && selectedZoomDepth && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label} Active
</span>
)}
<div className="flex items-center gap-3">
{zoomEnabled && selectedZoomDepth && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label} Active
</span>
)}
<KeyboardShortcutsHelp />
</div>
</div>
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-6 gap-2">
{ZOOM_DEPTH_OPTIONS.map((option) => {
const isActive = selectedZoomDepth === option.depth;
return (
@@ -194,26 +201,36 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
)}
</div>
<div className="mb-8 space-y-4">
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div className="text-sm font-medium text-slate-200">Drop Shadow</div>
<Switch
checked={showShadow}
onCheckedChange={onShadowChange}
className="data-[state=checked]:bg-[#34B27B]"
/>
</div>
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div className="text-sm font-medium text-slate-200">Blur Background</div>
<Switch
checked={showBlur}
onCheckedChange={onBlurChange}
className="data-[state=checked]:bg-[#34B27B]"
/>
<div className="mb-6">
<div className="grid grid-cols-2 gap-3">
{/* Drop Shadow Slider */}
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Shadow</div>
<span className="text-[10px] text-slate-400 font-mono">{Math.round(shadowIntensity * 100)}%</span>
</div>
<Slider
value={[shadowIntensity]}
onValueChange={(values) => onShadowChange?.(values[0])}
min={0}
max={1}
step={0.01}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]"
/>
</div>
{/* Blur Background Switch */}
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div className="text-xs font-medium text-slate-200">Blur</div>
<Switch
checked={showBlur}
onCheckedChange={onBlurChange}
className="data-[state=checked]:bg-[#34B27B]"
/>
</div>
</div>
</div>
<div className="mb-8">
<div className="mb-6">
<Button
onClick={() => setShowCropDropdown(!showCropDropdown)}
variant="outline"
@@ -292,7 +309,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
Upload Custom Image
</Button>
<div className="grid grid-cols-6 gap-2">
<div className="grid grid-cols-6 gap-2.5">
{/* Custom Images */}
{customImages.map((imageUrl, idx) => {
const isSelected = selected === imageUrl;
@@ -300,10 +317,10 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
<div
key={`custom-${idx}`}
className={cn(
"aspect-square rounded-lg border-2 overflow-hidden cursor-pointer transition-all duration-200 relative group",
"aspect-square w-12 h-12 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 relative group shadow-sm",
isSelected
? "border-[#34B27B] ring-2 ring-[#34B27B]/30 scale-105 shadow-lg shadow-[#34B27B]/10"
: "border-white/5 hover:border-white/20 hover:scale-105 opacity-70 hover:opacity-100"
: "border-white/10 hover:border-[#34B27B]/40 hover:scale-105 opacity-80 hover:opacity-100 bg-white/5"
)}
style={{ backgroundImage: `url(${imageUrl})`, backgroundSize: "cover", backgroundPosition: "center" }}
aria-label={`Custom Image ${idx + 1}`}
@@ -312,10 +329,10 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
>
<button
onClick={(e) => handleRemoveCustomImage(imageUrl, e)}
className="absolute top-1 right-1 w-5 h-5 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
className="absolute top-1 right-1 w-4 h-4 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
aria-label="Remove custom image"
>
<X className="w-3 h-3 text-white" />
<X className="w-2.5 h-2.5 text-white" />
</button>
</div>
);
@@ -325,25 +342,22 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
{(wallpaperPaths.length > 0 ? wallpaperPaths : WALLPAPER_RELATIVE.map(p => `/${p}`)).map((path, idx) => {
const isSelected = (() => {
if (!selected) return false;
if (selected === path) return true;
try {
const clean = (s: string) => s.replace(/^file:\/\//, '').replace(/^\//, '')
if (clean(selected).endsWith(clean(path))) return true;
if (clean(path).endsWith(clean(selected))) return true;
} catch {
}
} catch {}
return false;
})();
return (
<div
key={path}
className={cn(
"aspect-square rounded-lg border-2 overflow-hidden cursor-pointer transition-all duration-200",
"aspect-square w-12 h-12 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
isSelected
? "border-[#34B27B] ring-2 ring-[#34B27B]/30 scale-105 shadow-lg shadow-[#34B27B]/10"
: "border-white/5 hover:border-white/20 hover:scale-105 opacity-70 hover:opacity-100"
: "border-white/10 hover:border-[#34B27B]/40 hover:scale-105 opacity-80 hover:opacity-100 bg-white/5"
)}
style={{ backgroundImage: `url(${path})`, backgroundSize: "cover", backgroundPosition: "center" }}
aria-label={`Wallpaper ${idx + 1}`}
@@ -370,15 +384,15 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</TabsContent>
<TabsContent value="gradient" className="mt-0">
<div className="grid grid-cols-6 gap-2">
<div className="grid grid-cols-6 gap-2.5">
{GRADIENTS.map((g, idx) => (
<div
key={g}
className={cn(
"aspect-square rounded-lg border-2 overflow-hidden cursor-pointer transition-all duration-200",
"aspect-square w-12 h-12 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
gradient === g
? "border-[#34B27B] ring-2 ring-[#34B27B]/30 scale-105 shadow-lg shadow-[#34B27B]/10"
: "border-white/5 hover:border-white/20 hover:scale-105 opacity-70 hover:opacity-100"
: "border-white/10 hover:border-[#34B27B]/40 hover:scale-105 opacity-80 hover:opacity-100 bg-white/5"
)}
style={{ background: g }}
aria-label={`Gradient ${idx + 1}`}
@@ -401,16 +415,28 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
<Download className="w-5 h-5" />
<span>Export Video</span>
</Button>
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen/issues/new');
}}
className="w-full mt-4 flex items-center justify-center gap-2 text-xs text-slate-500 hover:text-slate-300 transition-colors py-2 group"
>
<Bug className="w-3 h-3 group-hover:text-[#34B27B] transition-colors" />
<span>Report a Bug</span>
</button>
<div className="flex gap-2 mt-4">
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen/issues/new');
}}
className="flex-1 flex items-center justify-center gap-2 text-xs py-2"
>
<Bug className="w-3 h-3 text-[#34B27B]" />
<span>Report a Bug</span>
</button>
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://buymeacoffee.com/siddharthvaddem');
}}
className="flex-1 flex items-center justify-center gap-2 text-xs"
>
<GiHearts className="w-3 h-3 text-red-500" />
<span>Support my work</span>
</button>
</div>
</div>
</div>
);
+136 -51
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import PlaybackControls from "./PlaybackControls";
@@ -18,11 +19,12 @@ import {
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
type TrimRegion,
type CropRegion,
} from "./types";
import { VideoExporter, type ExportProgress } from "@/lib/exporter";
const WALLPAPER_COUNT = 23;
const WALLPAPER_COUNT = 20;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
export default function VideoEditor() {
@@ -33,11 +35,13 @@ export default function VideoEditor() {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
const [showShadow, setShowShadow] = useState(false);
const [shadowIntensity, setShadowIntensity] = useState(0);
const [showBlur, setShowBlur] = useState(false);
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
@@ -45,16 +49,35 @@ export default function VideoEditor() {
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
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;
}
// Unix-style absolute path
const fileUrl = `file://${normalized}`;
return fileUrl;
};
useEffect(() => {
async function loadVideo() {
try {
const result = await window.electronAPI.getRecordedVideoPath();
const result = await window.electronAPI.getCurrentVideoPath();
if (result.success && result.path) {
setVideoPath(`file://${result.path}`);
const videoUrl = toFileUrl(result.path);
setVideoPath(videoUrl);
} else {
setError(result.message || 'Failed to load video');
setError('No video to load. Please record or select a video.');
}
} catch (err) {
setError('Error loading video: ' + String(err));
@@ -68,7 +91,6 @@ export default function VideoEditor() {
function togglePlayPause() {
const playback = videoPlaybackRef.current;
const video = playback?.video;
console.log('Toggle play/pause:', { hasVideo: !!video, isPlaying, action: isPlaying ? 'pause' : 'play' });
if (!playback || !video) return;
if (isPlaying) {
@@ -86,6 +108,12 @@ export default function VideoEditor() {
const handleSelectZoom = useCallback((id: string | null) => {
setSelectedZoomId(id);
if (id) setSelectedTrimId(null);
}, []);
const handleSelectTrim = useCallback((id: string | null) => {
setSelectedTrimId(id);
if (id) setSelectedZoomId(null);
}, []);
const handleZoomAdded = useCallback((span: Span) => {
@@ -100,6 +128,20 @@ export default function VideoEditor() {
console.log('Zoom region added:', newRegion);
setZoomRegions((prev) => [...prev, newRegion]);
setSelectedZoomId(id);
setSelectedTrimId(null);
}, []);
const handleTrimAdded = useCallback((span: Span) => {
const id = `trim-${nextTrimIdRef.current++}`;
const newRegion: TrimRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
};
console.log('Trim region added:', newRegion);
setTrimRegions((prev) => [...prev, newRegion]);
setSelectedTrimId(id);
setSelectedZoomId(null);
}, []);
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
@@ -117,6 +159,21 @@ export default function VideoEditor() {
);
}, []);
const handleTrimSpanChange = useCallback((id: string, span: Span) => {
console.log('Trim span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) });
setTrimRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
}, []);
const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => {
setZoomRegions((prev) =>
prev.map((region) =>
@@ -153,7 +210,13 @@ export default function VideoEditor() {
}
}, [selectedZoomId]);
const handleTrimDelete = useCallback((id: string) => {
console.log('Trim region deleted:', id);
setTrimRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedTrimId === id) {
setSelectedTrimId(null);
}
}, [selectedTrimId]);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
@@ -161,6 +224,12 @@ export default function VideoEditor() {
}
}, [selectedZoomId, zoomRegions]);
useEffect(() => {
if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) {
setSelectedTrimId(null);
}
}, [selectedTrimId, trimRegions]);
const handleExport = useCallback(async () => {
if (!videoPath) {
toast.error('No video loaded');
@@ -212,7 +281,9 @@ export default function VideoEditor() {
codec: 'avc1.640033',
wallpaper,
zoomRegions,
showShadow,
trimRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
cropRegion,
onProgress: (progress: ExportProgress) => {
@@ -230,8 +301,10 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.success) {
toast.success('Video exported successfully!');
if (saveResult.cancelled) {
toast.info('Export cancelled');
} 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');
@@ -253,7 +326,7 @@ export default function VideoEditor() {
setIsExporting(false);
exporterRef.current = null;
}
}, [videoPath, wallpaper, zoomRegions, showShadow, showBlur, cropRegion, isPlaying]);
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, cropRegion, isPlaying]);
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
@@ -281,29 +354,26 @@ export default function VideoEditor() {
);
}
const isMac = navigator.userAgent.includes('Mac');
return (
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
{/* Drag region for window - more padding on macOS for traffic lights */}
<div
className={`h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between ${isMac ? 'pl-20 pr-4' : 'px-4'} z-50`}
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<div className="flex-1" />
</div>
<div className="flex-1 p-4 gap-4 flex min-h-0 relative">
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
{/* Left Column - Video & Timeline */}
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
{/* Video Preview Area */}
<div className="flex-shrink-0 bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
<div className="flex flex-col">
{/* Video Container - Fixed aspect ratio */}
<div className="relative w-full" style={{ paddingBottom: '56.25%' }}> {/* 16:9 aspect ratio */}
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="relative w-full h-full max-w-full max-h-full">
<PanelGroup direction="vertical" className="gap-3">
{/* Top section: video preview and controls */}
<Panel defaultSize={70} minSize={40}>
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
{/* Video preview */}
<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: '16/9', maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
<VideoPlayback
ref={videoPlaybackRef}
videoPath={videoPath || ''}
@@ -317,44 +387,59 @@ export default function VideoEditor() {
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
isPlaying={isPlaying}
showShadow={showShadow}
showShadow={shadowIntensity > 0}
shadowIntensity={shadowIntensity}
showBlur={showBlur}
cropRegion={cropRegion}
trimRegions={trimRegions}
/>
</div>
</div>
{/* Playback controls */}
<div className="w-full flex justify-center items-center" style={{ height: '48px', flexShrink: 0, padding: '6px 12px', margin: '6px 0 6px 0' }}>
<div style={{ width: '100%', maxWidth: '700px' }}>
<PlaybackControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
/>
</div>
</div>
</div>
{/* Playback Controls - Below video */}
<div className="px-4 pb-3 pt-2">
<PlaybackControls
isPlaying={isPlaying}
</Panel>
<PanelResizeHandle className="h-3 bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full mx-4 flex items-center justify-center">
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
</PanelResizeHandle>
{/* Timeline section */}
<Panel defaultSize={30} minSize={20}>
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
<TimelineEditor
videoDuration={duration}
currentTime={currentTime}
duration={duration}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
trimRegions={trimRegions}
onTrimAdded={handleTrimAdded}
onTrimSpanChange={handleTrimSpanChange}
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
/>
</div>
</div>
</div>
{/* Timeline Area */}
<div className="flex-1 min-h-[180px] bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
<TimelineEditor
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
/>
</div>
</Panel>
</PanelGroup>
</div>
{/* Right Column - Settings */}
{/* Right section: settings panel */}
<SettingsPanel
selected={wallpaper}
onWallpaperChange={setWallpaper}
@@ -362,8 +447,8 @@ export default function VideoEditor() {
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
showShadow={showShadow}
onShadowChange={setShowShadow}
shadowIntensity={shadowIntensity}
onShadowChange={setShadowIntensity}
showBlur={showBlur}
onBlurChange={setShowBlur}
cropRegion={cropRegion}
+20 -4
View File
@@ -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 } from "./types";
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion } from "./types";
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
import { clamp01 } from "./videoPlayback/mathUtils";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
@@ -25,8 +25,10 @@ interface VideoPlaybackProps {
onZoomFocusChange: (id: string, focus: ZoomFocus) => void;
isPlaying: boolean;
showShadow?: boolean;
shadowIntensity?: number;
showBlur?: boolean;
cropRegion?: import('./types').CropRegion;
trimRegions?: TrimRegion[];
}
export interface VideoPlaybackRef {
@@ -51,8 +53,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
onZoomFocusChange,
isPlaying,
showShadow,
shadowIntensity = 0,
showBlur,
cropRegion,
trimRegions = [],
}, ref) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -83,6 +87,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
const allowPlaybackRef = useRef(false);
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -285,6 +290,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
isPlayingRef.current = isPlaying;
}, [isPlaying]);
useEffect(() => {
trimRegionsRef.current = trimRegions;
}, [trimRegions]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -511,6 +520,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
timeUpdateAnimationRef,
onPlayStateChange,
onTimeUpdate,
trimRegionsRef,
});
video.addEventListener('play', handlePlay);
@@ -663,7 +673,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
video.pause();
allowPlaybackRef.current = false;
currentTimeRef.current = 0;
setVideoReady(true);
// hacky fix: To ensure video is fully ready for PixiJS
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setVideoReady(true);
});
});
};
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
@@ -729,8 +745,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
ref={containerRef}
className="absolute inset-0"
style={{
filter: showShadow
? 'drop-shadow(0 12px 48px rgba(0,0,0,0.7)) drop-shadow(0 4px 16px rgba(0,0,0,0.5)) drop-shadow(0 2px 8px rgba(0,0,0,0.3))'
filter: (showShadow && shadowIntensity > 0)
? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
: 'none',
}}
/>
@@ -1,12 +0,0 @@
.itemDark {
background: #23232a;
border: 1px solid #34B27B;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.18);
color: #e5e7eb;
transition: box-shadow 0.2s, border 0.2s, background 0.2s;
}
.squircle {
border-radius: 12px;
-corner-smoothing: antialiased;
}
+43 -12
View File
@@ -1,7 +1,7 @@
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { cn } from "@/lib/utils";
import { ZoomIn } from "lucide-react";
import { ZoomIn, Scissors } from "lucide-react";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -11,7 +11,8 @@ interface ItemProps {
children: React.ReactNode;
isSelected?: boolean;
onSelect?: () => void;
zoomDepth: number;
zoomDepth?: number;
variant?: 'zoom' | 'trim';
}
// Map zoom depth to multiplier labels
@@ -23,13 +24,25 @@ const ZOOM_LABELS: Record<number, string> = {
5: "3.5×",
};
export default function Item({ id, span, rowId, isSelected = false, onSelect, zoomDepth }: ItemProps) {
export default function Item({
id,
span,
rowId,
isSelected = false,
onSelect,
zoomDepth = 1,
variant = 'zoom'
}: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
data: { rowId },
});
const isZoom = variant === 'zoom';
const glassClass = isZoom ? glassStyles.glassGreen : glassStyles.glassRed;
const endCapColor = isZoom ? '#21916A' : '#ef4444';
return (
<div
ref={setNodeRef}
@@ -42,25 +55,43 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo
<div style={itemContentStyle}>
<div
className={cn(
glassClass,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
glassStyles.glassGreen,
isSelected && glassStyles.selected
)}
style={{ height: 48 }}
style={{ height: 40, color: '#fff' }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<div className={cn(glassStyles.zoomEndCap, glassStyles.left)} />
<div className={cn(glassStyles.zoomEndCap, glassStyles.right)} />
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize left"
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex items-center gap-1.5 text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none">
<ZoomIn className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : (
<>
<Scissors className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
Trim
</span>
</>
)}
</div>
</div>
</div>
@@ -24,11 +24,36 @@
z-index: 10;
}
.glassRed {
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 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);
}
.glassRed:hover {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 4px 20px 0 rgba(239, 68, 68, 0.2) inset;
}
.glassRed.selected {
background: rgba(239, 68, 68, 0.35);
border-color: #ef4444;
box-shadow: 0 0 0 1px #ef4444, 0 4px 20px 0 rgba(239, 68, 68, 0.3) inset;
z-index: 10;
}
.zoomEndCap {
position: absolute;
top: 0;
bottom: 0;
background: #34B27B;
width: 4px;
pointer-events: none;
z-index: 2;
@@ -0,0 +1,50 @@
import React from "react";
import { useTimelineContext } from "dnd-timeline";
interface Keyframe {
id: string;
time: number;
}
interface KeyframeMarkersProps {
keyframes: Keyframe[];
selectedKeyframeId: string | null;
setSelectedKeyframeId: (id: string | null) => void;
}
const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({ keyframes, selectedKeyframeId, setSelectedKeyframeId }) => {
const { sidebarWidth, range, valueToPixels } = useTimelineContext();
return (
<>
{keyframes.map(kf => {
const offset = valueToPixels(kf.time - range.start);
const isSelected = kf.id === selectedKeyframeId;
return (
<div
key={kf.id}
className={`absolute top-8 cursor-pointer ${isSelected ? 'ring-2 ring-[#34B27B]' : ''}`}
style={{ left: `${sidebarWidth + offset - 8}px`, zIndex: 40 }}
onClick={e => {
e.stopPropagation();
setSelectedKeyframeId(kf.id);
}}
title={`Keyframe @ ${kf.time}ms`}
>
<div style={{
width: '10px',
height: '10px',
background: '#ffe100ff',
transform: 'rotate(45deg)',
border: 'none',
opacity: isSelected ? 1 : 0.6,
transition: 'opacity 0.15s',
}} />
</div>
);
})}
</>
);
};
export default KeyframeMarkers;
+1 -1
View File
@@ -11,7 +11,7 @@ export default function Row({ id, children }: RowProps) {
return (
<div
className="border-b border-[#18181b] bg-[#18181b]"
style={{ ...rowWrapperStyle, minHeight: 88 }}
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
>
<div ref={setNodeRef} style={rowStyle}>
{children}
@@ -1,16 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTimelineContext } from "dnd-timeline";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { Plus, Scissors, ZoomIn } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import TimelineWrapper from "./TimelineWrapper";
import Row from "./Row";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
import type { Range, Span } from "dnd-timeline";
import type { ZoomRegion } from "../types";
import type { ZoomRegion, TrimRegion } from "../types";
import { v4 as uuidv4 } from 'uuid';
const ROW_ID = "row-1";
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
@@ -24,6 +27,13 @@ interface TimelineEditorProps {
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
// Trim props
trimRegions?: TrimRegion[];
onTrimAdded?: (span: Span) => void;
onTrimSpanChange?: (id: string, span: Span) => void;
onTrimDelete?: (id: string) => void;
selectedTrimId?: string | null;
onSelectTrim?: (id: string | null) => void;
}
interface TimelineScaleConfig {
@@ -39,7 +49,8 @@ interface TimelineRenderItem {
rowId: string;
span: Span;
label: string;
zoomDepth: number;
zoomDepth?: number;
variant: 'zoom' | 'trim';
}
const SCALE_CANDIDATES = [
@@ -297,7 +308,9 @@ function Timeline({
currentTimeMs,
onSeek,
onSelectZoom,
onSelectTrim,
selectedZoomId,
selectedTrimId,
}: {
items: TimelineRenderItem[];
videoDurationMs: number;
@@ -305,13 +318,19 @@ function Timeline({
currentTimeMs: number;
onSeek?: (time: number) => void;
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
}) {
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!onSeek || videoDurationMs <= 0) return;
// Only clear selection if clicking on empty space (not on items)
// This is handled by event propagation - items stop propagation
onSelectZoom?.(null);
onSelectTrim?.(null);
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -323,7 +342,10 @@ function Timeline({
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
}, [onSeek, onSelectZoom, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
}, [onSeek, onSelectZoom, onSelectTrim, 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);
return (
<div
@@ -335,8 +357,9 @@ function Timeline({
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis intervalMs={intervalMs} videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} />
<Row id={ROW_ID}>
{items.map((item) => (
<Row id={ZOOM_ROW_ID}>
{zoomItems.map((item) => (
<Item
id={item.id}
key={item.id}
@@ -345,6 +368,23 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
variant="zoom"
>
{item.label}
</Item>
))}
</Row>
<Row id={TRIM_ROW_ID}>
{trimItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedTrimId}
onSelect={() => onSelectTrim?.(item.id)}
variant="trim"
>
{item.label}
</Item>
@@ -361,8 +401,15 @@ export default function TimelineEditor({
zoomRegions,
onZoomAdded,
onZoomSpanChange,
onZoomDelete,
selectedZoomId,
onSelectZoom,
trimRegions = [],
onTrimAdded,
onTrimSpanChange,
onTrimDelete,
selectedTrimId,
onSelectTrim,
}: TimelineEditorProps) {
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
@@ -373,6 +420,37 @@ 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);
// Add keyframe at current playhead position
const addKeyframe = useCallback(() => {
if (totalMs === 0) return;
const time = Math.max(0, Math.min(currentTimeMs, totalMs));
if (keyframes.some(kf => Math.abs(kf.time - time) < 1)) return;
setKeyframes(prev => [...prev, { id: uuidv4(), time }]);
}, [currentTimeMs, totalMs, keyframes]);
// Delete selected keyframe
const deleteSelectedKeyframe = useCallback(() => {
if (!selectedKeyframeId) return;
setKeyframes(prev => prev.filter(kf => kf.id !== selectedKeyframeId));
setSelectedKeyframeId(null);
}, [selectedKeyframeId]);
// Delete selected zoom item
const deleteSelectedZoom = useCallback(() => {
if (!selectedZoomId) return;
onZoomDelete(selectedZoomId);
onSelectZoom(null);
}, [selectedZoomId, onZoomDelete, onSelectZoom]);
// Delete selected trim item
const deleteSelectedTrim = useCallback(() => {
if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return;
onTrimDelete(selectedTrimId);
onSelectTrim(null);
}, [selectedTrimId, onTrimDelete, onSelectTrim]);
useEffect(() => {
setRange(createInitialRange(totalMs));
@@ -394,53 +472,138 @@ export default function TimelineEditor({
onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
}, [zoomRegions, totalMs, safeMinDurationMs, onZoomSpanChange]);
trimRegions.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) {
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
}, [zoomRegions, trimRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
// Snap if gap is 2ms or less
return zoomRegions.some((region) => {
if (region.id === excludeId) return false;
const gapBefore = newSpan.start - region.endMs;
const gapAfter = region.startMs - newSpan.end;
if (gapBefore > 0 && gapBefore <= 2) return true;
if (gapAfter > 0 && gapAfter <= 2) return true;
return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs);
});
}, [zoomRegions]);
// Determine which row the item belongs to
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
const isTrimItem = trimRegions.some(r => r.id === excludeId);
// Helper to check overlap against a specific set of regions
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
return regions.some((region) => {
if (region.id === excludeId) return false;
const gapBefore = newSpan.start - region.endMs;
const gapAfter = region.startMs - newSpan.end;
// Snap if gap is 2ms or less
if (gapBefore > 0 && gapBefore <= 2) return true;
if (gapAfter > 0 && gapAfter <= 2) return true;
return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs);
});
};
if (isZoomItem) {
return checkOverlap(zoomRegions);
}
if (isTrimItem) {
return checkOverlap(trimRegions);
}
return false;
}, [zoomRegions, trimRegions]);
const handleAddZoom = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
return;
}
const defaultDuration = Math.min(
Math.max(3000, safeMinDurationMs),
totalMs,
);
const defaultDuration = Math.min(1000, totalMs);
if (defaultDuration <= 0) {
return;
}
let startPos = 0;
// Always place zoom at playhead
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
// Find the next zoom region after the playhead
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find(region => region.startMs > startPos);
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
for (const region of sorted) {
if (startPos + defaultDuration <= region.startMs) {
break;
}
startPos = Math.max(startPos, region.endMs);
}
if (startPos + defaultDuration > totalMs) {
toast.error("No space available", {
description: "Remove or resize existing zoom regions to add more.",
// Check if playhead is inside any zoom region
const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place zoom here", {
description: "Zoom already exists at this location or not enough space available.",
});
return;
}
onZoomAdded({ start: startPos, end: startPos + defaultDuration });
}, [videoDuration, totalMs, timelineScale.defaultItemDurationMs, safeMinDurationMs, zoomRegions, onZoomAdded]);
const actualDuration = Math.min(1000, gapToNext);
onZoomAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded]);
const handleAddTrim = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
return;
}
const defaultDuration = Math.min(1000, totalMs);
if (defaultDuration <= 0) {
return;
}
// Always place trim at playhead
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
// Find the next trim region after the playhead
const sorted = [...trimRegions].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 trim region
const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place trim here", {
description: "Trim already exists at this location or not enough space available.",
});
return;
}
const actualDuration = Math.min(1000, gapToNext);
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded]);
// Listen for F key to add keyframe, Z key to add zoom, T key to add trim, Ctrl+D to remove selected keyframe or zoom item
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (e.key === 'f' || e.key === 'F') {
addKeyframe();
}
if (e.key === 'z' || e.key === 'Z') {
handleAddZoom();
}
if (e.key === 't' || e.key === 'T') {
handleAddTrim();
}
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) {
if (selectedKeyframeId) {
deleteSelectedKeyframe();
} else if (selectedZoomId) {
deleteSelectedZoom();
} else if (selectedTrimId) {
deleteSelectedTrim();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [addKeyframe, handleAddZoom, handleAddTrim, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, selectedKeyframeId, selectedZoomId, selectedTrimId]);
const clampedRange = useMemo<Range>(() => {
if (totalMs === 0) {
@@ -454,16 +617,34 @@ export default function TimelineEditor({
}, [range, totalMs]);
const timelineItems = useMemo<TimelineRenderItem[]>(() => {
return [...zoomRegions]
.sort((a, b) => a.startMs - b.startMs)
.map((region, index) => ({
id: region.id,
rowId: ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Zoom ${index + 1}`,
zoomDepth: region.depth,
}));
}, [zoomRegions]);
const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
id: region.id,
rowId: ZOOM_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Zoom ${index + 1}`,
zoomDepth: region.depth,
variant: 'zoom',
}));
const trims: TimelineRenderItem[] = trimRegions.map((region, index) => ({
id: region.id,
rowId: TRIM_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Trim ${index + 1}`,
variant: 'trim',
}));
return [...zooms, ...trims];
}, [zoomRegions, trimRegions]);
const handleItemSpanChange = useCallback((id: string, span: Span) => {
// Check if it's a zoom or trim item
if (zoomRegions.some(r => r.id === id)) {
onZoomSpanChange(id, span);
} else if (trimRegions.some(r => r.id === id)) {
onTrimSpanChange?.(id, span);
}
}, [zoomRegions, trimRegions, onZoomSpanChange, onTrimSpanChange]);
if (!videoDuration || videoDuration === 0) {
return (
@@ -481,29 +662,42 @@ export default function TimelineEditor({
return (
<div className="flex-1 flex flex-col bg-[#09090b] overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2 border-b border-white/5 bg-[#09090b]">
<Button
onClick={handleAddZoom}
variant="outline"
size="sm"
className="gap-2 h-7 px-3 text-xs bg-white/5 border-white/10 text-slate-200 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all"
>
<Plus className="w-3.5 h-3.5" />
Add Zoom
</Button>
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/5 bg-[#09090b]">
<div className="flex items-center gap-1">
<Button
onClick={handleAddZoom}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
title="Add Zoom (Z)"
>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
onClick={handleAddTrim}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
title="Add Trim (T)"
>
<Scissors className="w-4 h-4" />
</Button>
</div>
<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-slate-400 font-sans">⇧ + ⌘ + Scroll</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⇧ + ⌘ + Scroll</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-slate-400 font-sans">⌘ + Scroll</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⌘ + Scroll</kbd>
<span>Zoom</span>
</span>
</div>
</div>
<div className="flex-1 overflow-hidden bg-[#09090b] relative">
<div className="flex-1 overflow-hidden bg-[#09090b] relative"
onClick={() => setSelectedKeyframeId(null)}
>
<TimelineWrapper
range={clampedRange}
videoDuration={videoDuration}
@@ -512,8 +706,13 @@ export default function TimelineEditor({
minItemDurationMs={timelineScale.minItemDurationMs}
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
gridSizeMs={timelineScale.gridMs}
onItemSpanChange={onZoomSpanChange}
onItemSpanChange={handleItemSpanChange}
>
<KeyframeMarkers
keyframes={keyframes}
selectedKeyframeId={selectedKeyframeId}
setSelectedKeyframeId={setSelectedKeyframeId}
/>
<Timeline
items={timelineItems}
videoDurationMs={totalMs}
@@ -521,7 +720,9 @@ export default function TimelineEditor({
currentTimeMs={currentTimeMs}
onSeek={onSeek}
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
/>
</TimelineWrapper>
</div>
+8 -1
View File
@@ -1,4 +1,4 @@
export type ZoomDepth = 1 | 2 | 3 | 4 | 5;
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export interface ZoomFocus {
cx: number; // normalized horizontal center (0-1)
@@ -13,6 +13,12 @@ export interface ZoomRegion {
focus: ZoomFocus;
}
export interface TrimRegion {
id: string;
startMs: number;
endMs: number;
}
export interface CropRegion {
x: number; // 0-1 normalized
y: number; // 0-1 normalized
@@ -33,6 +39,7 @@ export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
3: 1.8,
4: 2.2,
5: 3.5,
6: 5.0,
};
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
@@ -1,4 +1,5 @@
import type React from 'react';
import type { TrimRegion } from '../types';
interface VideoEventHandlersParams {
video: HTMLVideoElement;
@@ -9,6 +10,7 @@ interface VideoEventHandlersParams {
timeUpdateAnimationRef: React.MutableRefObject<number | null>;
onPlayStateChange: (playing: boolean) => void;
onTimeUpdate: (time: number) => void;
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
}
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
@@ -21,6 +23,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
timeUpdateAnimationRef,
onPlayStateChange,
onTimeUpdate,
trimRegionsRef,
} = params;
const emitTime = (timeValue: number) => {
@@ -28,9 +31,35 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
onTimeUpdate(timeValue);
};
// Helper function to check if current time is within a trim region
const findActiveTrimRegion = (currentTimeMs: number): TrimRegion | null => {
const trimRegions = trimRegionsRef.current;
return trimRegions.find(
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs
) || null;
};
function updateTime() {
if (!video) return;
emitTime(video.currentTime);
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();
} else {
video.currentTime = skipToTime;
emitTime(skipToTime);
}
} else {
emitTime(video.currentTime);
}
if (!video.paused && !video.ended) {
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
}
@@ -68,10 +97,25 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const handleSeeked = () => {
isSeekingRef.current = false;
if (!isPlayingRef.current && !video.paused) {
video.pause();
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
// If we seeked into a trim region while playing, skip to the end
if (activeTrimRegion && isPlayingRef.current && !video.paused) {
const skipToTime = activeTrimRegion.endMs / 1000;
if (skipToTime >= video.duration) {
video.pause();
} else {
video.currentTime = skipToTime;
emitTime(skipToTime);
}
} else {
if (!isPlayingRef.current && !video.paused) {
video.pause();
}
emitTime(video.currentTime);
}
emitTime(video.currentTime);
};
const handleSeeking = () => {
+4 -7
View File
@@ -20,7 +20,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
mediaRecorder.current.stop();
setRecording(false);
window.electronAPI.stopMouseTracking();
window.electronAPI?.setRecordingState(false);
}
});
@@ -54,7 +54,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
alert("Please select a source to record");
return;
}
await window.electronAPI.startMouseTracking();
// Capture screen at source resolution without constraints
const mediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
@@ -109,7 +109,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
chunks.current = [];
const timestamp = Date.now();
const videoFileName = `recording-${timestamp}.webm`;
const trackingFileName = `recording-${timestamp}_tracking.json`;
try {
const videoBlob = await fixWebmDuration(buggyBlob, duration);
const arrayBuffer = await videoBlob.arrayBuffer();
@@ -118,10 +118,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
console.error('Failed to store video:', videoResult.message);
return;
}
const trackingResult = await window.electronAPI.storeMouseTrackingData(trackingFileName);
if (!trackingResult.success) {
console.warn('Failed to store mouse tracking:', trackingResult.message);
}
await window.electronAPI.switchToEditor();
} catch (error) {
console.error('Error saving recording:', error);
+15 -3
View File
@@ -12,6 +12,7 @@ interface FrameRenderConfig {
wallpaper: string;
zoomRegions: ZoomRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
cropRegion: CropRegion;
videoWidth: number;
@@ -434,7 +435,7 @@ export class FrameRenderer {
if (this.config.showBlur) {
ctx.save();
ctx.filter = 'blur(2px)';
ctx.filter = 'blur(6px)'; // Canvas blur is weaker than CSS
ctx.drawImage(bgCanvas, 0, 0, w, h);
ctx.restore();
} else {
@@ -445,11 +446,22 @@ export class FrameRenderer {
}
// Draw video layer with shadows on top of background
if (this.config.showShadow && this.shadowCanvas && this.shadowCtx) {
if (this.config.showShadow && this.config.shadowIntensity > 0 && this.shadowCanvas && this.shadowCtx) {
const shadowCtx = this.shadowCtx;
shadowCtx.clearRect(0, 0, w, h);
shadowCtx.save();
shadowCtx.filter = 'drop-shadow(0 12px 48px rgba(0,0,0,0.7)) drop-shadow(0 4px 16px rgba(0,0,0,0.5)) drop-shadow(0 2px 8px rgba(0,0,0,0.3))';
// Calculate shadow parameters based on intensity (0-1)
const intensity = this.config.shadowIntensity;
const baseBlur1 = 48 * intensity;
const baseBlur2 = 16 * intensity;
const baseBlur3 = 8 * intensity;
const baseAlpha1 = 0.7 * intensity;
const baseAlpha2 = 0.5 * intensity;
const baseAlpha3 = 0.3 * intensity;
const baseOffset = 12 * intensity;
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset/3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset/6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
shadowCtx.drawImage(videoCanvas, 0, 0, w, h);
shadowCtx.restore();
ctx.drawImage(this.shadowCanvas, 0, 0, w, h);
+89 -14
View File
@@ -2,13 +2,15 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types';
import { VideoFileDecoder } from './videoDecoder';
import { FrameRenderer } from './frameRenderer';
import { VideoMuxer } from './muxer';
import type { ZoomRegion, CropRegion } from '@/components/video-editor/types';
import type { ZoomRegion, CropRegion, TrimRegion } from '@/components/video-editor/types';
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
cropRegion: CropRegion;
onProgress?: (progress: ExportProgress) => void;
@@ -34,6 +36,36 @@ export class VideoExporter {
this.config = config;
}
// Calculate the total duration excluding trim regions (in seconds)
private getEffectiveDuration(totalDuration: number): number {
const trimRegions = this.config.trimRegions || [];
const totalTrimDuration = trimRegions.reduce((sum, region) => {
return sum + (region.endMs - region.startMs) / 1000;
}, 0);
return totalDuration - totalTrimDuration;
}
private mapEffectiveToSourceTime(effectiveTimeMs: number): number {
const trimRegions = this.config.trimRegions || [];
// Sort trim regions by start time
const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
let sourceTimeMs = effectiveTimeMs;
for (const trim of sortedTrims) {
// If the source time hasn't reached this trim region yet, we're done
if (sourceTimeMs < trim.startMs) {
break;
}
// Add the duration of this trim region to the source time
const trimDuration = trim.endMs - trim.startMs;
sourceTimeMs += trimDuration;
}
return sourceTimeMs;
}
async export(): Promise<ExportResult> {
try {
this.cleanup();
@@ -50,6 +82,7 @@ export class VideoExporter {
wallpaper: this.config.wallpaper,
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
showBlur: this.config.showBlur,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
@@ -58,7 +91,6 @@ export class VideoExporter {
await this.renderer.initialize();
// Initialize video encoder
const totalFrames = Math.ceil(videoInfo.duration * this.config.frameRate);
await this.initializeEncoder();
// Initialize muxer
@@ -71,6 +103,14 @@ export class VideoExporter {
throw new Error('Video element not available');
}
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
console.log('[VideoExporter] Effective duration:', effectiveDuration, 's');
console.log('[VideoExporter] Total frames to export:', totalFrames);
// Process frames continuously without batching delays
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
@@ -79,15 +119,25 @@ export class VideoExporter {
while (frameIndex < totalFrames && !this.cancelled) {
const i = frameIndex;
const timestamp = i * frameDuration;
const videoTime = i * timeStep;
// Map effective time to source time (accounting for trim regions)
const effectiveTimeMs = (i * timeStep) * 1000;
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
const videoTime = sourceTimeMs / 1000;
// Seek if needed or wait for first frame to be ready
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
if (needsSeek || i === 0) {
if (needsSeek) {
videoElement.currentTime = videoTime;
}
// Wait for video frame to be ready
if (needsSeek) {
// Attach listener BEFORE setting currentTime to avoid race condition
const seekedPromise = new Promise<void>(resolve => {
videoElement.addEventListener('seeked', () => resolve(), { once: true });
});
videoElement.currentTime = videoTime;
await seekedPromise;
} else if (i === 0) {
// Only for the very first frame, wait for it to be ready
await new Promise<void>(resolve => {
videoElement.requestVideoFrameCallback(() => resolve());
});
@@ -98,8 +148,9 @@ export class VideoExporter {
timestamp,
});
// Render the frame with all effects
await this.renderer!.renderFrame(videoFrame, timestamp);
// Render the frame with all effects using source timestamp
const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
videoFrame.close();
@@ -126,11 +177,13 @@ export class VideoExporter {
if (this.encoder && this.encoder.state === 'configured') {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: i % 150 === 0 });
} else {
console.warn(`[Frame ${i}] Encoder not ready! State: ${this.encoder?.state}`);
}
exportFrame.close();
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
@@ -226,13 +279,15 @@ export class VideoExporter {
this.encodeQueue--;
},
error: (error) => {
console.error('VideoEncoder error:', error);
console.error('[VideoExporter] Encoder error:', error);
// Stop export encoding failed
this.cancelled = true;
},
});
const codec = this.config.codec || 'avc1.640033';
this.encoder.configure({
const encoderConfig: VideoEncoderConfig = {
codec,
width: this.config.width,
height: this.config.height,
@@ -241,7 +296,27 @@ export class VideoExporter {
latencyMode: 'realtime',
bitrateMode: 'variable',
hardwareAcceleration: 'prefer-hardware',
} as VideoEncoderConfig);
};
// Check hardware support first
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (hardwareSupport.supported) {
// Use hardware encoding
console.log('[VideoExporter] Using hardware acceleration');
this.encoder.configure(encoderConfig);
} else {
// Fall back to software encoding
console.log('[VideoExporter] Hardware not supported, using software encoding');
encoderConfig.hardwareAcceleration = 'prefer-software';
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (!softwareSupport.supported) {
throw new Error('Video encoding not supported on this system');
}
this.encoder.configure(encoderConfig);
}
}
cancel(): void {
+5 -9
View File
@@ -16,21 +16,12 @@ interface Window {
openSourceSelector: () => Promise<void>
selectSource: (source: any) => Promise<any>
getSelectedSource: () => Promise<any>
startMouseTracking: () => Promise<{ success: boolean; startTime?: number }>
stopMouseTracking: () => Promise<{ success: boolean; data?: any }>
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
success: boolean
path?: string
message: string
error?: string
}>
storeMouseTrackingData: (fileName: string) => Promise<{
success: boolean
path?: string
eventCount?: number
message: string
error?: string
}>
getRecordedVideoPath: () => Promise<{
success: boolean
path?: string
@@ -45,6 +36,11 @@ interface Window {
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 }>
}
}
+1 -3
View File
@@ -14,9 +14,7 @@ export default defineConfig({
entry: 'electron/main.ts',
vite: {
build: {
rollupOptions: {
external: ['uiohook-napi']
}
}
}
},