Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8b99c7bd | ||
|
|
1241de6e1a | ||
|
|
3998af5398 | ||
|
|
2b5b15f3e8 | ||
|
|
e549850b75 | ||
|
|
b6d6fe6a70 | ||
|
|
e6cb86fafc | ||
|
|
1dbbf8721c | ||
|
|
c63f5eddeb | ||
|
|
6475814541 | ||
|
|
d85e6e1254 | ||
|
|
6baeebec96 | ||
|
|
ddf30ed60e | ||
|
|
98d6acaa6a | ||
|
|
060a7bab92 | ||
|
|
48253cc31d | ||
|
|
f887d09865 | ||
|
|
6634d0eb53 | ||
|
|
6617fd39f6 | ||
|
|
8ec426d200 | ||
|
|
188ba94aad | ||
|
|
7129d55f86 | ||
|
|
483ace8e46 | ||
|
|
bf63154801 | ||
|
|
437eded23a | ||
|
|
472d4053f4 | ||
|
|
864902b660 | ||
|
|
dac826a5bc | ||
|
|
dd17551c18 | ||
|
|
dae7dc5212 | ||
|
|
210977faf4 | ||
|
|
9c6ac891e4 | ||
|
|
1306c6e4ea | ||
|
|
b181546ad3 | ||
|
|
0d5c4529d1 | ||
|
|
4a78bb999b | ||
|
|
f034c9fe01 | ||
|
|
325c239a3a | ||
|
|
9fedc5c167 | ||
|
|
0e0f5003ff | ||
|
|
55a373c7ef | ||
|
|
e14ecbff56 | ||
|
|
996d0d8c5b | ||
|
|
ba72eaf83c | ||
|
|
7f5b010c00 |
@@ -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']
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||

|
||||
```
|
||||
|
||||
**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!*
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 798 KiB After Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 660 B After Width: | Height: | Size: 630 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 713 KiB After Width: | Height: | Size: 853 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
- lzma-native
|
||||
- phantomjs-prebuilt
|
||||
|
Before Width: | Height: | Size: 530 KiB After Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 549 KiB |
|
Before Width: | Height: | Size: 399 KiB After Width: | Height: | Size: 581 KiB |
|
Before Width: | Height: | Size: 20 MiB |
|
Before Width: | Height: | Size: 11 MiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 20 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
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 |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 890 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 890 KiB After Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 827 KiB |
|
Before Width: | Height: | Size: 18 MiB After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 20 MiB After Width: | Height: | Size: 538 KiB |
|
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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||