Compare commits

...
45 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
Siddharth 1306c6e4ea logo update 2025-11-23 17:03:56 -07:00
Siddharth b181546ad3 reduce installer size 50% and app bundle size by 30% 2025-11-23 16:49:53 -07:00
Siddharth 0d5c4529d1 migrate to mediabunny 2025-11-23 12:24:56 -07:00
Sid 4a78bb999b Update README with macOS installation instructions 2025-11-23 00:56:35 -07:00
Sid f034c9fe01 Merge pull request #1 from siddharthvaddem/v0.1
V0.1
2025-11-23 00:53:37 -07:00
Siddharth 325c239a3a ... 2025-11-23 00:48:16 -07:00
Siddharth 9fedc5c167 3x faster exports 2025-11-23 00:33:10 -07:00
Siddharth 0e0f5003ff fix screen recording, optimize exporting pipeline 2025-11-22 23:40:39 -07:00
Siddharth 55a373c7ef recording optimizations 2025-11-22 22:28:58 -07:00
Siddharth Vaddem e14ecbff56 Modify funding sources in FUNDING.yml 2025-11-22 18:45:33 -07:00
Siddharth Vaddem 996d0d8c5b Update issue templates 2025-11-20 22:20:12 -07:00
Siddharth ba72eaf83c update readme 2025-11-20 22:09:17 -07:00
Siddharth 7f5b010c00 wallpaper fix 2025-11-20 21:22:21 -07:00
77 changed files with 12226 additions and 960 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: siddharthvaddem
patreon: # Replace with a single Patreon username
open_collective: # Replace with a sissgle Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
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: siddharthvaddem
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+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!*
+40 -13
View File
@@ -1,15 +1,12 @@
name: Build Electron App
on:
push:
branches: [ main, master ]
workflow_dispatch:
jobs:
build-macos:
runs-on: macos-latest
build-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
@@ -18,11 +15,6 @@ jobs:
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
@@ -30,14 +22,49 @@ jobs:
- 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
retention-days: 30
+4 -7
View File
@@ -33,14 +33,10 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist
- Add manual zooms (customizable depth levels)
- Customize the duration and position of zooms however you please
- Crop video recordings to hide parts
- Choose between wallpapers, solid colors, or gradients for your background
- Choose between wallpapers, solid colors, gradients or your own picture for your background
- Motion blur and exponential easing for smoother pan and zoom effects
**Note:**
- After you install the app, you'll need to grant it accessibility and screen recording permissions for it to work properly.
## macOS Installation Instructions
## macOS Installation instructions
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
@@ -67,4 +63,5 @@ _I'm new to open source, idk what I'm doing lol. If something is wrong please ra
## License
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
+94 -180
View File
@@ -1,20 +1,33 @@
import { BrowserWindow, screen, ipcMain, desktopCapturer, shell, app, nativeImage, Tray, Menu } from "electron";
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";
import { uIOhook } from "uiohook-napi";
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: 250,
height: 80,
minWidth: 250,
maxWidth: 250,
minHeight: 80,
maxHeight: 80,
width: windowWidth,
height: windowHeight,
minWidth: 500,
maxWidth: 500,
minHeight: 100,
maxHeight: 100,
x,
y,
frame: false,
transparent: true,
resizable: false,
@@ -31,6 +44,12 @@ function createHudOverlayWindow() {
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 {
@@ -58,7 +77,8 @@ function createEditorWindow() {
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
webSecurity: false
webSecurity: false,
backgroundThrottling: false
}
});
win.maximize();
@@ -103,119 +123,6 @@ function createSourceSelectorWindow() {
}
return win;
}
let isMouseTrackingActive = false;
let isHookStarted = false;
let recordingStartTime = 0;
let mouseEventData = [];
function startMouseTracking() {
if (isMouseTrackingActive) {
return { success: false, message: "Already tracking" };
}
isMouseTrackingActive = true;
recordingStartTime = performance.now();
mouseEventData = [];
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 };
}
}
function stopMouseTracking() {
if (!isMouseTrackingActive) {
return { success: false, message: "Not currently tracking" };
}
isMouseTrackingActive = false;
const duration = performance.now() - recordingStartTime;
const session = {
startTime: recordingStartTime,
events: mouseEventData,
duration
};
return {
success: true,
message: "Mouse tracking stopped",
data: session
};
}
function setupMouseEventListeners() {
uIOhook.on("mousemove", (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime;
const event = {
type: "move",
timestamp,
x: e.x,
y: e.y
};
mouseEventData.push(event);
}
});
uIOhook.on("mousedown", (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime;
const event = {
type: "down",
timestamp,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
};
mouseEventData.push(event);
}
});
uIOhook.on("mouseup", (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime;
const event = {
type: "up",
timestamp,
x: e.x,
y: e.y,
button: e.button
};
mouseEventData.push(event);
}
});
uIOhook.on("click", (e) => {
if (isMouseTrackingActive) {
const timestamp = performance.now() - recordingStartTime;
const event = {
type: "click",
timestamp,
x: e.x,
y: e.y,
button: e.button,
clicks: e.clicks
};
mouseEventData.push(event);
}
});
}
function getTrackingData() {
return [...mouseEventData];
}
function cleanupMouseTracking() {
if (isHookStarted) {
try {
uIOhook.stop();
isHookStarted = false;
isMouseTrackingActive = false;
mouseEventData = [];
} catch (error) {
console.error("Error cleaning up mouse tracking:", error);
}
}
}
let selectedSource = null;
function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) {
ipcMain.handle("get-sources", async (_, opts) => {
@@ -254,16 +161,11 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
}
createEditorWindow2();
});
ipcMain.handle("start-mouse-tracking", () => {
return startMouseTracking();
});
ipcMain.handle("stop-mouse-tracking", () => {
return stopMouseTracking();
});
ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
currentVideoPath = videoPath;
return {
success: true,
path: videoPath,
@@ -278,29 +180,6 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
};
}
});
ipcMain.handle("store-mouse-tracking-data", async (_, fileName) => {
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 {
const files = await fs.readdir(RECORDINGS_DIR);
@@ -344,12 +223,25 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
});
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
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) {
@@ -361,30 +253,53 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
};
}
});
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 };
});
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function cleanupOldRecordings() {
try {
const files = await fs.readdir(RECORDINGS_DIR);
const now = Date.now();
const maxAge = 1 * 24 * 60 * 60 * 1e3;
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);
}
@@ -445,13 +360,13 @@ app.on("activate", () => {
createWindow();
}
});
app.on("before-quit", async (event) => {
event.preventDefault();
cleanupMouseTracking();
await cleanupOldRecordings();
app.exit(0);
});
app.whenReady().then(async () => {
const { ipcMain: ipcMain2 } = await import("electron");
ipcMain2.on("hud-overlay-close", () => {
if (process.platform === "darwin") {
app.quit();
}
});
await ensureRecordingsDir();
registerIpcHandlers(
createEditorWindowWrapper,
@@ -463,7 +378,6 @@ app.whenReady().then(async () => {
if (recording) {
if (!tray) createTray();
updateTrayMenu();
if (mainWindow) mainWindow.minimize();
} else {
if (tray) {
tray.destroy();
+18 -9
View File
@@ -1,6 +1,12 @@
"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");
},
@@ -19,18 +25,9 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
getSelectedSource: () => {
return electron.ipcRenderer.invoke("get-selected-source");
},
startMouseTracking: () => {
return electron.ipcRenderer.invoke("start-mouse-tracking");
},
stopMouseTracking: () => {
return electron.ipcRenderer.invoke("stop-mouse-tracking");
},
storeRecordedVideo: (videoData, fileName) => {
return electron.ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
storeMouseTrackingData: (fileName) => {
return electron.ipcRenderer.invoke("store-mouse-tracking-data", fileName);
},
getRecordedVideoPath: () => {
return electron.ipcRenderer.invoke("get-recorded-video-path");
},
@@ -47,5 +44,17 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
},
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");
}
});
+23 -6
View File
@@ -6,12 +6,19 @@
"productName": "Openscreen",
"npmRebuild": true,
"buildDependenciesFromSource": true,
"compression": "maximum",
"directories": {
"output": "release/${version}"
},
"files": [
"dist",
"dist-electron"
"dist-electron",
"!*.png",
"!preview*.png",
"!*.md",
"!README.md",
"!CONTRIBUTING.md",
"!LICENSE"
],
"extraResources": [
{
@@ -19,17 +26,27 @@
"to": "assets/wallpapers"
}
],
"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,
},
})
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 798 KiB

After

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 660 B

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 353 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 KiB

After

Width:  |  Height:  |  Size: 853 KiB

+2505 -43
View File
File diff suppressed because it is too large Load Diff
+10 -4
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,41 +20,46 @@
"@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",
"dnd-timeline": "^2.2.0",
"lucide-react": "^0.545.0",
"mp4-muxer": "^5.2.2",
"mediabunny": "^1.25.1",
"mp4box": "^2.2.0",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"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",
"autoprefixer": "^10.4.21",
"electron": "^30.0.1",
"electron-builder": "^24.13.3",
"electron-icon-builder": "^2.0.1",
"electron-rebuild": "^3.2.9",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"terser": "^5.44.1",
"typescript": "^5.2.2",
"vite": "^5.1.6",
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5"
},
"main": "dist-electron/main.js"
}
}
+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
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

After

Width:  |  Height:  |  Size: 460 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 549 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 KiB

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Before

Width:  |  Height:  |  Size: 3.1 MiB

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: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 MiB

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 MiB

After

Width:  |  Height:  |  Size: 897 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>
+80 -54
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"
@@ -223,7 +240,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
Crop Video
</Button>
<p className="text-[10px] text-slate-500 text-center mt-3 px-4 leading-relaxed">
If the preview looks weirdly positioned at any time, try force reloading (you will lose all your progress)
If the preview looks weirdly positioned or doesn't load, try force reloading the app a few times till it works.
</p>
</div>
@@ -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>
);
+155 -54
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');
@@ -184,19 +253,37 @@ export default function VideoEditor() {
videoPlaybackRef.current?.pause();
}
const width = 1920;
const height = 1080;
// Get actual video dimensions to match recording resolution
const video = videoPlaybackRef.current?.video;
if (!video) {
toast.error('Video not ready');
return;
}
const width = video.videoWidth || 1920;
const height = video.videoHeight || 1080;
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = width * height;
let bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
}
const exporter = new VideoExporter({
videoUrl: videoPath,
width,
height,
frameRate: 60,
bitrate: 15_000_000,
bitrate,
codec: 'avc1.640033',
wallpaper,
zoomRegions,
showShadow,
trimRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
cropRegion,
onProgress: (progress: ExportProgress) => {
@@ -214,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');
@@ -237,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) {
@@ -265,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 || ''}
@@ -301,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}
@@ -346,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}
+39 -23
View File
@@ -1,8 +1,8 @@
import type React from "react";
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
import { getAssetPath } from "@/lib/assetPath";
import * as PIXI from 'pixi.js';
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth } from "./types";
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
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,15 +25,17 @@ 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 {
video: HTMLVideoElement | null;
app: PIXI.Application | null;
videoSprite: PIXI.Sprite | null;
videoContainer: PIXI.Container | null;
app: Application | null;
videoSprite: Sprite | null;
videoContainer: Container | null;
play: () => Promise<void>;
pause: () => void;
}
@@ -51,15 +53,17 @@ 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);
const appRef = useRef<PIXI.Application | null>(null);
const videoSpriteRef = useRef<PIXI.Sprite | null>(null);
const videoContainerRef = useRef<PIXI.Container | null>(null);
const cameraContainerRef = useRef<PIXI.Container | null>(null);
const appRef = useRef<Application | null>(null);
const videoSpriteRef = useRef<Sprite | null>(null);
const videoContainerRef = useRef<Container | null>(null);
const cameraContainerRef = useRef<Container | null>(null);
const timeUpdateAnimationRef = useRef<number | null>(null);
const [pixiReady, setPixiReady] = useState(false);
const [videoReady, setVideoReady] = useState(false);
@@ -69,7 +73,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({ scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy });
const blurFilterRef = useRef<PIXI.BlurFilter | null>(null);
const blurFilterRef = useRef<BlurFilter | null>(null);
const isDraggingFocusRef = useRef(false);
const stageSizeRef = useRef({ width: 0, height: 0 });
const videoSizeRef = useRef({ width: 0, height: 0 });
@@ -77,12 +81,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
const baseOffsetRef = useRef({ x: 0, y: 0 });
const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 });
const maskGraphicsRef = useRef<PIXI.Graphics | null>(null);
const maskGraphicsRef = useRef<Graphics | null>(null);
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
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;
@@ -398,10 +407,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
if (!container) return;
let mounted = true;
let app: PIXI.Application | null = null;
let app: Application | null = null;
(async () => {
app = new PIXI.Application();
app = new Application();
await app.init({
width: container.clientWidth,
@@ -423,12 +432,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
container.appendChild(app.canvas);
// Camera container - this will be scaled/positioned for zoom
const cameraContainer = new PIXI.Container();
const cameraContainer = new Container();
cameraContainerRef.current = cameraContainer;
app.stage.addChild(cameraContainer);
// Video container - holds the masked video sprite
const videoContainer = new PIXI.Container();
const videoContainer = new Container();
videoContainerRef.current = videoContainer;
cameraContainer.addChild(videoContainer);
@@ -468,19 +477,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
if (!video || !app || !videoContainer) return;
if (video.videoWidth === 0 || video.videoHeight === 0) return;
const source = PIXI.VideoSource.from(video);
const source = VideoSource.from(video);
if ('autoPlay' in source) {
(source as { autoPlay?: boolean }).autoPlay = false;
}
if ('autoUpdate' in source) {
(source as { autoUpdate?: boolean }).autoUpdate = true;
}
const videoTexture = PIXI.Texture.from(source);
const videoTexture = Texture.from(source);
const videoSprite = new PIXI.Sprite(videoTexture);
const videoSprite = new Sprite(videoTexture);
videoSpriteRef.current = videoSprite;
const maskGraphics = new PIXI.Graphics();
const maskGraphics = new Graphics();
videoContainer.addChild(videoSprite);
videoContainer.addChild(maskGraphics);
videoContainer.mask = maskGraphics;
@@ -492,7 +501,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
focusY: DEFAULT_FOCUS.cy,
};
const blurFilter = new PIXI.BlurFilter();
const blurFilter = new BlurFilter();
blurFilter.quality = 3;
blurFilter.resolution = app.renderer.resolution;
blurFilter.blur = 0;
@@ -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,12 +1,12 @@
import * as PIXI from 'pixi.js';
import { Application, Sprite, Graphics } from 'pixi.js';
import { VIEWPORT_SCALE } from "./constants";
import type { CropRegion } from '../types';
interface LayoutParams {
container: HTMLDivElement;
app: PIXI.Application;
videoSprite: PIXI.Sprite;
maskGraphics: PIXI.Graphics;
app: Application;
videoSprite: Sprite;
maskGraphics: Graphics;
videoElement: HTMLVideoElement;
cropRegion?: CropRegion;
lockedVideoDimensions?: { width: number; height: number } | null;
@@ -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 = () => {
@@ -1,8 +1,8 @@
import * as PIXI from 'pixi.js';
import { Container, BlurFilter } from 'pixi.js';
interface TransformParams {
cameraContainer: PIXI.Container;
blurFilter: PIXI.BlurFilter | null;
cameraContainer: Container;
blurFilter: BlurFilter | null;
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
zoomScale: number;
+29 -13
View File
@@ -20,7 +20,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
mediaRecorder.current.stop();
setRecording(false);
window.electronAPI.stopMouseTracking();
window.electronAPI?.setRecordingState(false);
}
});
@@ -54,13 +54,15 @@ 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,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: selectedSource.id,
frameRate: { ideal: 60, max: 60 }
},
},
});
@@ -69,16 +71,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
throw new Error("Media stream is not available.");
}
const videoTrack = stream.current.getVideoTracks()[0];
const { width = 1920, height = 1080 } = videoTrack.getSettings();
let { width = 1920, height = 1080 } = videoTrack.getSettings();
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
width = Math.floor(width / 2) * 2;
height = Math.floor(height / 2) * 2;
console.log(`Recording at ${width}x${height}`);
const totalPixels = width * height;
let bitrate = 150_000_000;
// Use visually lossless bitrates optimized for quality and file size balance
let bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 250_000_000;
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 400_000_000;
bitrate = 80_000_000;
}
chunks.current = [];
const mimeType = "video/webm;codecs=vp9";
// Prefer AV1 codec for better compression, fallback to VP9 then VP8
const supportedCodecs = [
'video/webm;codecs=av1',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8'
];
const mimeType = supportedCodecs.find(codec => MediaRecorder.isTypeSupported(codec)) || 'video/webm;codecs=vp9';
const recorder = new MediaRecorder(stream.current, { mimeType, videoBitsPerSecond: bitrate });
mediaRecorder.current = recorder;
recorder.ondataavailable = e => {
@@ -89,9 +105,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (chunks.current.length === 0) return;
const duration = Date.now() - startTime.current;
const buggyBlob = new Blob(chunks.current, { type: mimeType });
// Clear chunks early to free memory immediately after blob creation
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();
@@ -100,17 +118,15 @@ 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);
}
};
recorder.onerror = () => setRecording(false);
recorder.start(1000);
// Use larger timeslice to reduce recording overhead and improve smoothness
recorder.start(5000);
startTime.current = Date.now();
setRecording(true);
window.electronAPI?.setRecordingState(true);
+40 -26
View File
@@ -1,4 +1,4 @@
import * as PIXI from 'pixi.js';
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
import type { ZoomRegion, CropRegion } from '@/components/video-editor/types';
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
@@ -12,6 +12,7 @@ interface FrameRenderConfig {
wallpaper: string;
zoomRegions: ZoomRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
cropRegion: CropRegion;
videoWidth: number;
@@ -27,13 +28,13 @@ interface AnimationState {
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
export class FrameRenderer {
private app: PIXI.Application | null = null;
private cameraContainer: PIXI.Container | null = null;
private videoContainer: PIXI.Container | null = null;
private videoSprite: PIXI.Sprite | null = null;
private backgroundSprite: PIXI.Sprite | null = null;
private maskGraphics: PIXI.Graphics | null = null;
private blurFilter: PIXI.BlurFilter | null = null;
private app: Application | null = null;
private cameraContainer: Container | null = null;
private videoContainer: Container | null = null;
private videoSprite: Sprite | null = null;
private backgroundSprite: Sprite | null = null;
private maskGraphics: Graphics | null = null;
private blurFilter: BlurFilter | null = null;
private shadowCanvas: HTMLCanvasElement | null = null;
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
@@ -53,7 +54,7 @@ export class FrameRenderer {
}
async initialize(): Promise<void> {
// Create offscreen canvas with sRGB color space for fidelity
// Create canvas for rendering
const canvas = document.createElement('canvas');
canvas.width = this.config.width;
canvas.height = this.config.height;
@@ -69,21 +70,21 @@ export class FrameRenderer {
console.warn('[FrameRenderer] colorSpace not supported on this platform:', error);
}
// Initialize PixiJS app with transparent background (background rendered separately)
this.app = new PIXI.Application();
// Initialize PixiJS with optimized settings for export performance
this.app = new Application();
await this.app.init({
canvas,
width: this.config.width,
height: this.config.height,
backgroundAlpha: 0,
antialias: true,
resolution: 2,
antialias: false,
resolution: 1,
autoDensity: true,
});
// Setup containers
this.cameraContainer = new PIXI.Container();
this.videoContainer = new PIXI.Container();
this.cameraContainer = new Container();
this.videoContainer = new Container();
this.app.stage.addChild(this.cameraContainer);
this.cameraContainer.addChild(this.videoContainer);
@@ -91,7 +92,7 @@ export class FrameRenderer {
await this.setupBackground();
// Setup blur filter for video container
this.blurFilter = new PIXI.BlurFilter();
this.blurFilter = new BlurFilter();
this.blurFilter.quality = 3;
this.blurFilter.resolution = this.app.renderer.resolution;
this.blurFilter.blur = 0;
@@ -120,7 +121,7 @@ export class FrameRenderer {
}
// Setup mask
this.maskGraphics = new PIXI.Graphics();
this.maskGraphics = new Graphics();
this.videoContainer.addChild(this.maskGraphics);
this.videoContainer.mask = this.maskGraphics;
}
@@ -251,13 +252,15 @@ export class FrameRenderer {
// Create or update video sprite from VideoFrame
if (!this.videoSprite) {
const texture = PIXI.Texture.from(videoFrame as any);
this.videoSprite = new PIXI.Sprite(texture);
const texture = Texture.from(videoFrame as any);
this.videoSprite = new Sprite(texture);
this.videoContainer.addChild(this.videoSprite);
} else {
// Update texture with new frame
const texture = PIXI.Texture.from(videoFrame as any);
this.videoSprite.texture = texture;
// Destroy old texture to avoid memory leaks, then create new one
const oldTexture = this.videoSprite.texture;
const newTexture = Texture.from(videoFrame as any);
this.videoSprite.texture = newTexture;
oldTexture.destroy(true);
}
// Apply layout
@@ -432,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 {
@@ -442,12 +445,23 @@ export class FrameRenderer {
console.warn('[FrameRenderer] No background sprite found during compositing!');
}
// Step 2: Draw video layer with shadows on top of background
if (this.config.showShadow && this.shadowCanvas && this.shadowCtx) {
// Draw video layer with shadows on top of background
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);
+57 -57
View File
@@ -1,31 +1,20 @@
import type { ExportConfig } from './types';
interface MP4MuxerOptions {
target: any; // ArrayBufferTarget instance
video: {
codec: string;
width: number;
height: number;
};
audio?: {
codec: string;
numberOfChannels: number;
sampleRate: number;
};
fastStart?: 'in-memory' | 'fragmented';
}
interface Muxer {
addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void;
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
finalize(): void;
target?: any;
}
import {
Output,
Mp4OutputFormat,
BufferTarget,
EncodedVideoPacketSource,
EncodedAudioPacketSource,
EncodedPacket
} from 'mediabunny';
export class VideoMuxer {
private muxer: Muxer | null = null;
private config: ExportConfig;
private output: Output | null = null;
private videoSource: EncodedVideoPacketSource | null = null;
private audioSource: EncodedAudioPacketSource | null = null;
private hasAudio: boolean;
private target: BufferTarget | null = null;
private config: ExportConfig;
constructor(config: ExportConfig, hasAudio = false) {
this.config = config;
@@ -33,57 +22,68 @@ export class VideoMuxer {
}
async initialize(): Promise<void> {
const MP4MuxerModule = await import('mp4-muxer');
const MP4MuxerClass = (MP4MuxerModule as any).Muxer || MP4MuxerModule.default;
const ArrayBufferTarget = (MP4MuxerModule as any).ArrayBufferTarget;
// Create the buffer target
this.target = new BufferTarget();
const target = new ArrayBufferTarget();
const options: MP4MuxerOptions = {
target,
video: {
codec: 'avc',
width: this.config.width,
height: this.config.height,
},
fastStart: 'in-memory',
};
this.output = new Output({
format: new Mp4OutputFormat({
fastStart: 'in-memory',
}),
target: this.target,
});
// Create video source - codec will be deduced from metadata
this.videoSource = new EncodedVideoPacketSource('avc');
this.output.addVideoTrack(this.videoSource, {
frameRate: this.config.frameRate,
});
// Create audio source if needed
if (this.hasAudio) {
options.audio = {
codec: 'opus',
numberOfChannels: 2,
sampleRate: 48000,
};
this.audioSource = new EncodedAudioPacketSource('opus');
this.output.addAudioTrack(this.audioSource);
}
this.muxer = new MP4MuxerClass(options) as Muxer;
// Start the output to begin accepting media data
await this.output.start();
}
addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void {
if (!this.muxer) {
async addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): Promise<void> {
if (!this.videoSource) {
throw new Error('Muxer not initialized');
}
this.muxer.addVideoChunk(chunk, meta);
// Convert WebCodecs chunk to Mediabunny packet
const packet = EncodedPacket.fromEncodedChunk(chunk);
// Add metadata with the first chunk
await this.videoSource.add(packet, meta);
}
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void {
if (!this.muxer) {
throw new Error('Muxer not initialized');
}
if (!this.hasAudio) {
async addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): Promise<void> {
if (!this.audioSource) {
throw new Error('Audio not configured for this muxer');
}
this.muxer.addAudioChunk(chunk, meta);
// Convert WebCodecs chunk to Mediabunny packet
const packet = EncodedPacket.fromEncodedChunk(chunk);
// Add metadata with the first chunk
await this.audioSource.add(packet, meta);
}
finalize(): Blob {
if (!this.muxer) {
async finalize(): Promise<Blob> {
if (!this.output || !this.target) {
throw new Error('Muxer not initialized');
}
this.muxer.finalize();
const buffer = this.muxer.target.buffer;
await this.output.finalize();
const buffer = this.target.buffer;
if (!buffer) {
throw new Error('Failed to finalize output');
}
return new Blob([buffer], { type: 'video/mp4' });
}
}
-29
View File
@@ -7,7 +7,6 @@ export interface DecodedVideoInfo {
}
export class VideoFileDecoder {
private decoder: VideoDecoder | null = null;
private info: DecodedVideoInfo | null = null;
private videoElement: HTMLVideoElement | null = null;
@@ -44,27 +43,6 @@ export class VideoFileDecoder {
return this.videoElement;
}
/**
* Seek to a specific time and wait for the frame to be ready
*/
async seekToTime(timeInSeconds: number): Promise<void> {
if (!this.videoElement) {
throw new Error('Video not loaded');
}
return new Promise((resolve) => {
const video = this.videoElement!;
const onSeeked = () => {
video.removeEventListener('seeked', onSeeked);
resolve();
};
video.addEventListener('seeked', onSeeked);
video.currentTime = timeInSeconds;
});
}
getInfo(): DecodedVideoInfo | null {
return this.info;
}
@@ -75,12 +53,5 @@ export class VideoFileDecoder {
this.videoElement.src = '';
this.videoElement = null;
}
if (this.decoder) {
if (this.decoder.state !== 'closed') {
this.decoder.close();
}
this.decoder = null;
}
}
}
+161 -66
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;
@@ -21,15 +23,49 @@ export class VideoExporter {
private encoder: VideoEncoder | null = null;
private muxer: VideoMuxer | null = null;
private cancelled = false;
private encodedChunks: EncodedVideoChunk[] = [];
private encodeQueue = 0;
private readonly MAX_ENCODE_QUEUE = 60;
// Increased queue size for better throughput with hardware encoding
private readonly MAX_ENCODE_QUEUE = 120;
private videoDescription: Uint8Array | undefined;
private videoColorSpace: VideoColorSpaceInit | undefined;
// Track muxing promises for parallel processing
private muxingPromises: Promise<void>[] = [];
private chunkCount = 0;
constructor(config: VideoExporterConfig) {
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();
@@ -46,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,
@@ -54,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
@@ -67,38 +103,44 @@ export class VideoExporter {
throw new Error('Video element not available');
}
// Process frames with optimized seeking
// 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;
const timeStep = 1 / this.config.frameRate;
// Pre-load first frame
videoElement.currentTime = 0;
await new Promise(resolve => {
const onSeeked = () => {
videoElement.removeEventListener('seeked', onSeeked);
resolve(null);
};
videoElement.addEventListener('seeked', onSeeked);
});
while (frameIndex < totalFrames && !this.cancelled) {
const timestamp = frameIndex * frameDuration;
const videoTime = frameIndex * timeStep;
// Seek to frame (only seek if not already there)
if (Math.abs(videoElement.currentTime - videoTime) > 0.001) {
const i = frameIndex;
const timestamp = i * frameDuration;
// 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) {
// 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 Promise.race([
new Promise(resolve => {
const onSeeked = () => {
videoElement.removeEventListener('seeked', onSeeked);
// Wait for video to render the frame
videoElement.requestVideoFrameCallback(() => resolve(null));
};
videoElement.addEventListener('seeked', onSeeked, { once: true });
}),
new Promise(resolve => setTimeout(resolve, 200)) // higher this number, slower the export, but better capture/ no frame drops
]);
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());
});
}
// Create a VideoFrame from the video element (on GPU!)
@@ -106,21 +148,16 @@ 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();
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 1));
}
if (this.cancelled) break;
const canvas = this.renderer!.getCanvas();
// @ts-ignore - TypeScript definitions may not include all VideoFrameInit properties
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
@@ -132,14 +169,22 @@ export class VideoExporter {
},
});
// Check encoder queue before encoding to keep it full
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 0));
}
if (this.encoder && this.encoder.state === 'configured') {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
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({
currentFrame: frameIndex,
@@ -159,26 +204,11 @@ export class VideoExporter {
await this.encoder.flush();
}
// Add all chunks to muxer with metadata
for (let i = 0; i < this.encodedChunks.length; i++) {
const chunk = this.encodedChunks[i];
const meta: EncodedVideoChunkMetadata = {};
// Add decoder config for the first chunk
if (i === 0 && this.videoDescription) {
meta.decoderConfig = {
codec: this.config.codec || 'avc1.640033',
codedWidth: this.config.width,
codedHeight: this.config.height,
description: this.videoDescription,
};
}
this.muxer!.addVideoChunk(chunk, meta);
}
// Wait for all muxing operations to complete
await Promise.all(this.muxingPromises);
// Finalize muxer and get output blob
const blob = this.muxer!.finalize();
const blob = await this.muxer!.finalize();
return { success: true, blob };
} catch (error) {
@@ -193,28 +223,71 @@ export class VideoExporter {
}
private async initializeEncoder(): Promise<void> {
this.encodedChunks = [];
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
let videoDescription: Uint8Array | undefined;
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
// Capture decoder config metadata from encoder output
if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description;
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
this.videoDescription = videoDescription;
}
this.encodedChunks.push(chunk);
// Capture colorSpace from encoder metadata if provided
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
this.videoColorSpace = meta.decoderConfig.colorSpace;
}
// Stream chunk to muxer immediately (parallel processing)
const isFirstChunk = this.chunkCount === 0;
this.chunkCount++;
const muxingPromise = (async () => {
try {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
};
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {
codec: this.config.codec || 'avc1.640033',
codedWidth: this.config.width,
codedHeight: this.config.height,
description: this.videoDescription,
colorSpace,
},
};
await this.muxer!.addVideoChunk(chunk, metadata);
} else {
await this.muxer!.addVideoChunk(chunk, meta);
}
} catch (error) {
console.error('Muxing error:', error);
}
})();
this.muxingPromises.push(muxingPromise);
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,
@@ -223,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 {
@@ -262,8 +355,10 @@ export class VideoExporter {
}
this.muxer = null;
this.encodedChunks = [];
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
this.videoDescription = undefined;
this.videoColorSpace = undefined;
}
}
+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 }>
}
}
+22 -3
View File
@@ -14,9 +14,7 @@ export default defineConfig({
entry: 'electron/main.ts',
vite: {
build: {
rollupOptions: {
external: ['uiohook-napi']
}
}
}
},
@@ -39,4 +37,25 @@ export default defineConfig({
'@': path.resolve(__dirname, 'src'),
},
},
build: {
target: 'esnext',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.debug']
}
},
rollupOptions: {
output: {
manualChunks: {
'pixi': ['pixi.js'],
'react-vendor': ['react', 'react-dom'],
'video-processing': ['mediabunny', 'mp4box', '@fix-webm-duration/fix']
}
}
},
chunkSizeWarningLimit: 1000
}
})