mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-03 14:40:47 +08:00
Compare commits
10
Commits
v0.2.0
...
feature/trim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8b99c7bd | ||
|
|
1241de6e1a | ||
|
|
3998af5398 | ||
|
|
2b5b15f3e8 | ||
|
|
e549850b75 | ||
|
|
b6d6fe6a70 | ||
|
|
e6cb86fafc | ||
|
|
1dbbf8721c | ||
|
|
c63f5eddeb | ||
|
|
6475814541 |
@@ -1,8 +1,7 @@
|
|||||||
|
|
||||||
name: Build Electron App
|
name: Build Electron App
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ main, master ]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
+33
-8
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserWindow, screen, ipcMain, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron";
|
import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
@@ -6,14 +6,28 @@ const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const APP_ROOT = path.join(__dirname$1, "..");
|
const APP_ROOT = path.join(__dirname$1, "..");
|
||||||
const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"];
|
const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"];
|
||||||
const RENDERER_DIST$1 = path.join(APP_ROOT, "dist");
|
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() {
|
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({
|
const win = new BrowserWindow({
|
||||||
width: 350,
|
width: windowWidth,
|
||||||
height: 80,
|
height: windowHeight,
|
||||||
minWidth: 350,
|
minWidth: 500,
|
||||||
maxWidth: 350,
|
maxWidth: 500,
|
||||||
minHeight: 80,
|
minHeight: 100,
|
||||||
maxHeight: 80,
|
maxHeight: 100,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
frame: false,
|
frame: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
@@ -30,6 +44,12 @@ function createHudOverlayWindow() {
|
|||||||
win.webContents.on("did-finish-load", () => {
|
win.webContents.on("did-finish-load", () => {
|
||||||
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
|
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) {
|
if (VITE_DEV_SERVER_URL$1) {
|
||||||
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=hud-overlay");
|
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=hud-overlay");
|
||||||
} else {
|
} else {
|
||||||
@@ -341,6 +361,12 @@ app.on("activate", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
const { ipcMain: ipcMain2 } = await import("electron");
|
||||||
|
ipcMain2.on("hud-overlay-close", () => {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
await ensureRecordingsDir();
|
await ensureRecordingsDir();
|
||||||
registerIpcHandlers(
|
registerIpcHandlers(
|
||||||
createEditorWindowWrapper,
|
createEditorWindowWrapper,
|
||||||
@@ -352,7 +378,6 @@ app.whenReady().then(async () => {
|
|||||||
if (recording) {
|
if (recording) {
|
||||||
if (!tray) createTray();
|
if (!tray) createTray();
|
||||||
updateTrayMenu();
|
updateTrayMenu();
|
||||||
if (mainWindow) mainWindow.minimize();
|
|
||||||
} else {
|
} else {
|
||||||
if (tray) {
|
if (tray) {
|
||||||
tray.destroy();
|
tray.destroy();
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
const electron = require("electron");
|
const electron = require("electron");
|
||||||
electron.contextBridge.exposeInMainWorld("electronAPI", {
|
electron.contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
|
hudOverlayHide: () => {
|
||||||
|
electron.ipcRenderer.send("hud-overlay-hide");
|
||||||
|
},
|
||||||
|
hudOverlayClose: () => {
|
||||||
|
electron.ipcRenderer.send("hud-overlay-close");
|
||||||
|
},
|
||||||
getAssetBasePath: async () => {
|
getAssetBasePath: async () => {
|
||||||
return await electron.ipcRenderer.invoke("get-asset-base-path");
|
return await electron.ipcRenderer.invoke("get-asset-base-path");
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+2
@@ -39,6 +39,8 @@ interface Window {
|
|||||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
|
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
|
||||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
|
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
|
||||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>
|
clearCurrentVideoPath: () => Promise<{ success: boolean }>
|
||||||
|
hudOverlayHide: () => void;
|
||||||
|
hudOverlayClose: () => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -108,6 +108,13 @@ app.on('activate', () => {
|
|||||||
|
|
||||||
// Register all IPC handlers when app is ready
|
// Register all IPC handlers when app is ready
|
||||||
app.whenReady().then(async () => {
|
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
|
// Ensure recordings directory exists
|
||||||
await ensureRecordingsDir()
|
await ensureRecordingsDir()
|
||||||
|
|
||||||
@@ -121,7 +128,6 @@ app.whenReady().then(async () => {
|
|||||||
if (recording) {
|
if (recording) {
|
||||||
if (!tray) createTray();
|
if (!tray) createTray();
|
||||||
updateTrayMenu();
|
updateTrayMenu();
|
||||||
if (mainWindow) mainWindow.minimize();
|
|
||||||
} else {
|
} else {
|
||||||
if (tray) {
|
if (tray) {
|
||||||
tray.destroy();
|
tray.destroy();
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
hudOverlayHide: () => {
|
||||||
|
ipcRenderer.send('hud-overlay-hide');
|
||||||
|
},
|
||||||
|
hudOverlayClose: () => {
|
||||||
|
ipcRenderer.send('hud-overlay-close');
|
||||||
|
},
|
||||||
getAssetBasePath: async () => {
|
getAssetBasePath: async () => {
|
||||||
// ask main process for the correct base path (production vs dev)
|
// ask main process for the correct base path (production vs dev)
|
||||||
return await ipcRenderer.invoke('get-asset-base-path')
|
return await ipcRenderer.invoke('get-asset-base-path')
|
||||||
|
|||||||
+36
-6
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserWindow, screen } from 'electron'
|
import { BrowserWindow, screen } from 'electron'
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
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 VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
|
||||||
const RENDERER_DIST = path.join(APP_ROOT, 'dist')
|
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 {
|
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({
|
const win = new BrowserWindow({
|
||||||
width: 350,
|
width: windowWidth,
|
||||||
height: 80,
|
height: windowHeight,
|
||||||
minWidth: 350,
|
minWidth: 500,
|
||||||
maxWidth: 350,
|
maxWidth: 500,
|
||||||
minHeight: 80,
|
minHeight: 100,
|
||||||
maxHeight: 80,
|
maxHeight: 100,
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
frame: false,
|
frame: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
@@ -35,6 +56,15 @@ export function createHudOverlayWindow(): BrowserWindow {
|
|||||||
win?.webContents.send('main-process-message', (new Date).toLocaleString())
|
win?.webContents.send('main-process-message', (new Date).toLocaleString())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
hudOverlayWindow = win;
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
if (hudOverlayWindow === win) {
|
||||||
|
hudOverlayWindow = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
if (VITE_DEV_SERVER_URL) {
|
if (VITE_DEV_SERVER_URL) {
|
||||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay')
|
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Generated
+31
-6
@@ -28,9 +28,11 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
@@ -7674,6 +7676,16 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/icon-gen/node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-corefoundation": {
|
"node_modules/iconv-corefoundation": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
||||||
@@ -10576,6 +10588,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-resizable-panels": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
@@ -12491,13 +12513,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "8.3.2",
|
"version": "13.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
"dev": true,
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist-node/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/validate-npm-package-license": {
|
"node_modules/validate-npm-package-license": {
|
||||||
|
|||||||
+3
-1
@@ -32,9 +32,11 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
|
|||||||
Generated
+8053
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- electron
|
||||||
|
- esbuild
|
||||||
|
- lzma-native
|
||||||
|
- phantomjs-prebuilt
|
||||||
@@ -6,10 +6,32 @@
|
|||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.folderButton {
|
.folderButton {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folderButton:hover {
|
.folderText {
|
||||||
|
color: #cbd5e1;
|
||||||
|
transition: text-decoration 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folderButton:hover .folderText {
|
||||||
text-decoration: underline;
|
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;
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,37 @@ import { FaRegStopCircle } from "react-icons/fa";
|
|||||||
import { MdMonitor } from "react-icons/md";
|
import { MdMonitor } from "react-icons/md";
|
||||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||||
import { FaFolderMinus } from "react-icons/fa6";
|
import { FaFolderMinus } from "react-icons/fa6";
|
||||||
|
import { FiMinus, FiX } from "react-icons/fi";
|
||||||
|
|
||||||
export function LaunchWindow() {
|
export function LaunchWindow() {
|
||||||
const { recording, toggleRecording } = useScreenRecorder();
|
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 [selectedSource, setSelectedSource] = useState("Screen");
|
||||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||||
|
|
||||||
@@ -57,23 +85,33 @@ export function LaunchWindow() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="w-full h-full flex items-center bg-transparent">
|
<div className="w-full h-full flex items-center bg-transparent">
|
||||||
<div
|
<div
|
||||||
className={`w-full max-w-3xl 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={{
|
style={{
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
background: 'linear-gradient(135deg, rgba(30,30,40,0.85) 0%, rgba(20,20,30,0.75) 100%)',
|
background: 'linear-gradient(135deg, rgba(30,30,40,0.92) 0%, rgba(20,20,30,0.85) 100%)',
|
||||||
backdropFilter: 'blur(24px) saturate(180%)',
|
backdropFilter: 'blur(32px) saturate(180%)',
|
||||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
WebkitBackdropFilter: 'blur(32px) saturate(180%)',
|
||||||
boxShadow: '0 4px 16px 0 rgba(0,0,0,0.24), 0 1px 3px 0 rgba(0,0,0,0.12) inset',
|
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.18)',
|
border: '1px solid rgba(80,80,120,0.22)',
|
||||||
minHeight: 36,
|
minHeight: 44,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`flex items-center gap-1 ${styles.electronDrag}`}>
|
<div className={`flex items-center gap-1 ${styles.electronDrag}`}> <RxDragHandleDots2 size={18} className="text-white/40" /> </div>
|
||||||
<RxDragHandleDots2 size={16} className="text-white/40" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -81,11 +119,11 @@ export function LaunchWindow() {
|
|||||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left text-xs ${styles.electronNoDrag}`}
|
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left text-xs ${styles.electronNoDrag}`}
|
||||||
onClick={openSourceSelector}
|
onClick={openSourceSelector}
|
||||||
>
|
>
|
||||||
<MdMonitor size={13} className="text-white" />
|
<MdMonitor size={14} className="text-white" />
|
||||||
{truncateText(selectedSource, 6)}
|
{truncateText(selectedSource, 6)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-white/30" />
|
<div className="w-px h-6 bg-white/30" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -96,26 +134,52 @@ export function LaunchWindow() {
|
|||||||
>
|
>
|
||||||
{recording ? (
|
{recording ? (
|
||||||
<>
|
<>
|
||||||
<FaRegStopCircle size={13} className="text-red-400" />
|
<FaRegStopCircle size={14} className="text-red-400" />
|
||||||
<span className="text-red-400">Stop</span>
|
<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>
|
<span className={hasSelectedSource ? "text-white" : "text-white/50"}>Record</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/30" />
|
||||||
|
|
||||||
<div className="w-px h-5 bg-white/30" />
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={openVideoFile}
|
onClick={openVideoFile}
|
||||||
className={`gap-1 bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag} folderButton`}
|
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag} ${styles.folderButton}`}
|
||||||
>
|
>
|
||||||
<FaFolderMinus size={13} className="text-white" />
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function PlaybackControls({
|
|||||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
return (
|
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
|
<Button
|
||||||
onClick={onTogglePlayPause}
|
onClick={onTogglePlayPause}
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -50,7 +50,7 @@ export default function PlaybackControls({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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)}
|
{formatTime(currentTime)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export default function PlaybackControls({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
||||||
import PlaybackControls from "./PlaybackControls";
|
import PlaybackControls from "./PlaybackControls";
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
type ZoomDepth,
|
type ZoomDepth,
|
||||||
type ZoomFocus,
|
type ZoomFocus,
|
||||||
type ZoomRegion,
|
type ZoomRegion,
|
||||||
|
type TrimRegion,
|
||||||
type CropRegion,
|
type CropRegion,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { VideoExporter, type ExportProgress } from "@/lib/exporter";
|
import { VideoExporter, type ExportProgress } from "@/lib/exporter";
|
||||||
@@ -38,6 +40,8 @@ export default function VideoEditor() {
|
|||||||
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
|
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
|
||||||
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
|
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
|
||||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
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 [isExporting, setIsExporting] = useState(false);
|
||||||
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
|
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
@@ -45,6 +49,7 @@ export default function VideoEditor() {
|
|||||||
|
|
||||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||||
const nextZoomIdRef = useRef(1);
|
const nextZoomIdRef = useRef(1);
|
||||||
|
const nextTrimIdRef = useRef(1);
|
||||||
const exporterRef = useRef<VideoExporter | null>(null);
|
const exporterRef = useRef<VideoExporter | null>(null);
|
||||||
|
|
||||||
// Helper to convert file path to proper file:// URL
|
// Helper to convert file path to proper file:// URL
|
||||||
@@ -103,6 +108,12 @@ export default function VideoEditor() {
|
|||||||
|
|
||||||
const handleSelectZoom = useCallback((id: string | null) => {
|
const handleSelectZoom = useCallback((id: string | null) => {
|
||||||
setSelectedZoomId(id);
|
setSelectedZoomId(id);
|
||||||
|
if (id) setSelectedTrimId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectTrim = useCallback((id: string | null) => {
|
||||||
|
setSelectedTrimId(id);
|
||||||
|
if (id) setSelectedZoomId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleZoomAdded = useCallback((span: Span) => {
|
const handleZoomAdded = useCallback((span: Span) => {
|
||||||
@@ -117,6 +128,20 @@ export default function VideoEditor() {
|
|||||||
console.log('Zoom region added:', newRegion);
|
console.log('Zoom region added:', newRegion);
|
||||||
setZoomRegions((prev) => [...prev, newRegion]);
|
setZoomRegions((prev) => [...prev, newRegion]);
|
||||||
setSelectedZoomId(id);
|
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) => {
|
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
|
||||||
@@ -134,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) => {
|
const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => {
|
||||||
setZoomRegions((prev) =>
|
setZoomRegions((prev) =>
|
||||||
prev.map((region) =>
|
prev.map((region) =>
|
||||||
@@ -170,7 +210,13 @@ export default function VideoEditor() {
|
|||||||
}
|
}
|
||||||
}, [selectedZoomId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
|
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
|
||||||
@@ -178,6 +224,12 @@ export default function VideoEditor() {
|
|||||||
}
|
}
|
||||||
}, [selectedZoomId, zoomRegions]);
|
}, [selectedZoomId, zoomRegions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) {
|
||||||
|
setSelectedTrimId(null);
|
||||||
|
}
|
||||||
|
}, [selectedTrimId, trimRegions]);
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
toast.error('No video loaded');
|
toast.error('No video loaded');
|
||||||
@@ -229,6 +281,7 @@ export default function VideoEditor() {
|
|||||||
codec: 'avc1.640033',
|
codec: 'avc1.640033',
|
||||||
wallpaper,
|
wallpaper,
|
||||||
zoomRegions,
|
zoomRegions,
|
||||||
|
trimRegions,
|
||||||
showShadow: shadowIntensity > 0,
|
showShadow: shadowIntensity > 0,
|
||||||
shadowIntensity,
|
shadowIntensity,
|
||||||
showBlur,
|
showBlur,
|
||||||
@@ -273,7 +326,7 @@ export default function VideoEditor() {
|
|||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
exporterRef.current = null;
|
exporterRef.current = null;
|
||||||
}
|
}
|
||||||
}, [videoPath, wallpaper, zoomRegions, shadowIntensity, showBlur, cropRegion, isPlaying]);
|
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, cropRegion, isPlaying]);
|
||||||
|
|
||||||
const handleCancelExport = useCallback(() => {
|
const handleCancelExport = useCallback(() => {
|
||||||
if (exporterRef.current) {
|
if (exporterRef.current) {
|
||||||
@@ -311,62 +364,79 @@ export default function VideoEditor() {
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</div>
|
</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 */}
|
{/* Left Column - Video & Timeline */}
|
||||||
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
|
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
|
||||||
{/* Top section: video preview and controls */}
|
<PanelGroup direction="vertical" className="gap-3">
|
||||||
<div className="w-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden" style={{ height: '80%' }}>
|
{/* Top section: video preview and controls */}
|
||||||
{/* Video preview */}
|
<Panel defaultSize={70} minSize={40}>
|
||||||
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', padding: '24px 0' }}>
|
<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">
|
||||||
<div className="relative" style={{ width: '100%', maxWidth: '1000px', aspectRatio: '16/9', boxSizing: 'border-box', overflow: 'hidden' }}>
|
{/* Video preview */}
|
||||||
<VideoPlayback
|
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', margin: '6px 0 0' }}>
|
||||||
ref={videoPlaybackRef}
|
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: '16/9', maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
|
||||||
videoPath={videoPath || ''}
|
<VideoPlayback
|
||||||
onDurationChange={setDuration}
|
ref={videoPlaybackRef}
|
||||||
onTimeUpdate={setCurrentTime}
|
videoPath={videoPath || ''}
|
||||||
onPlayStateChange={setIsPlaying}
|
onDurationChange={setDuration}
|
||||||
onError={setError}
|
onTimeUpdate={setCurrentTime}
|
||||||
wallpaper={wallpaper}
|
onPlayStateChange={setIsPlaying}
|
||||||
|
onError={setError}
|
||||||
|
wallpaper={wallpaper}
|
||||||
|
zoomRegions={zoomRegions}
|
||||||
|
selectedZoomId={selectedZoomId}
|
||||||
|
onSelectZoom={handleSelectZoom}
|
||||||
|
onZoomFocusChange={handleZoomFocusChange}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
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>
|
||||||
|
</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}
|
||||||
|
onSeek={handleSeek}
|
||||||
zoomRegions={zoomRegions}
|
zoomRegions={zoomRegions}
|
||||||
|
onZoomAdded={handleZoomAdded}
|
||||||
|
onZoomSpanChange={handleZoomSpanChange}
|
||||||
|
onZoomDelete={handleZoomDelete}
|
||||||
selectedZoomId={selectedZoomId}
|
selectedZoomId={selectedZoomId}
|
||||||
onSelectZoom={handleSelectZoom}
|
onSelectZoom={handleSelectZoom}
|
||||||
onZoomFocusChange={handleZoomFocusChange}
|
trimRegions={trimRegions}
|
||||||
isPlaying={isPlaying}
|
onTrimAdded={handleTrimAdded}
|
||||||
showShadow={shadowIntensity > 0}
|
onTrimSpanChange={handleTrimSpanChange}
|
||||||
shadowIntensity={shadowIntensity}
|
onTrimDelete={handleTrimDelete}
|
||||||
showBlur={showBlur}
|
selectedTrimId={selectedTrimId}
|
||||||
cropRegion={cropRegion}
|
onSelectTrim={handleSelectTrim}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
{/* Playback controls */}
|
</PanelGroup>
|
||||||
<div className="w-full flex justify-center items-center" style={{ padding: '0 0 24px 0' }}>
|
|
||||||
<div style={{ maxWidth: '700px', width: '80%' }}>
|
|
||||||
<PlaybackControls
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
currentTime={currentTime}
|
|
||||||
duration={duration}
|
|
||||||
onTogglePlayPause={togglePlayPause}
|
|
||||||
onSeek={handleSeek}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline section */}
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right section: settings panel */}
|
{/* Right section: settings panel */}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type React from "react";
|
|||||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
|
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
|
||||||
import { getAssetPath } from "@/lib/assetPath";
|
import { getAssetPath } from "@/lib/assetPath";
|
||||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
|
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
|
||||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth } from "./types";
|
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion } from "./types";
|
||||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
|
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
|
||||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||||
@@ -28,6 +28,7 @@ interface VideoPlaybackProps {
|
|||||||
shadowIntensity?: number;
|
shadowIntensity?: number;
|
||||||
showBlur?: boolean;
|
showBlur?: boolean;
|
||||||
cropRegion?: import('./types').CropRegion;
|
cropRegion?: import('./types').CropRegion;
|
||||||
|
trimRegions?: TrimRegion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoPlaybackRef {
|
export interface VideoPlaybackRef {
|
||||||
@@ -55,6 +56,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
|||||||
shadowIntensity = 0,
|
shadowIntensity = 0,
|
||||||
showBlur,
|
showBlur,
|
||||||
cropRegion,
|
cropRegion,
|
||||||
|
trimRegions = [],
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -85,6 +87,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
|||||||
const allowPlaybackRef = useRef(false);
|
const allowPlaybackRef = useRef(false);
|
||||||
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
||||||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||||
|
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||||
|
|
||||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||||
@@ -287,6 +290,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
|||||||
isPlayingRef.current = isPlaying;
|
isPlayingRef.current = isPlaying;
|
||||||
}, [isPlaying]);
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trimRegionsRef.current = trimRegions;
|
||||||
|
}, [trimRegions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pixiReady || !videoReady) return;
|
if (!pixiReady || !videoReady) return;
|
||||||
|
|
||||||
@@ -513,6 +520,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
|||||||
timeUpdateAnimationRef,
|
timeUpdateAnimationRef,
|
||||||
onPlayStateChange,
|
onPlayStateChange,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
|
trimRegionsRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
video.addEventListener('play', handlePlay);
|
video.addEventListener('play', handlePlay);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useItem } from "dnd-timeline";
|
import { useItem } from "dnd-timeline";
|
||||||
import type { Span } from "dnd-timeline";
|
import type { Span } from "dnd-timeline";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ZoomIn } from "lucide-react";
|
import { ZoomIn, Scissors } from "lucide-react";
|
||||||
import glassStyles from "./ItemGlass.module.css";
|
import glassStyles from "./ItemGlass.module.css";
|
||||||
|
|
||||||
interface ItemProps {
|
interface ItemProps {
|
||||||
@@ -11,7 +11,8 @@ interface ItemProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
zoomDepth: number;
|
zoomDepth?: number;
|
||||||
|
variant?: 'zoom' | 'trim';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map zoom depth to multiplier labels
|
// Map zoom depth to multiplier labels
|
||||||
@@ -23,13 +24,25 @@ const ZOOM_LABELS: Record<number, string> = {
|
|||||||
5: "3.5×",
|
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({
|
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
||||||
id,
|
id,
|
||||||
span,
|
span,
|
||||||
data: { rowId },
|
data: { rowId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isZoom = variant === 'zoom';
|
||||||
|
const glassClass = isZoom ? glassStyles.glassGreen : glassStyles.glassRed;
|
||||||
|
const endCapColor = isZoom ? '#21916A' : '#ef4444';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
@@ -42,11 +55,11 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo
|
|||||||
<div style={itemContentStyle}>
|
<div style={itemContentStyle}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
glassStyles.glassGreen,
|
glassClass,
|
||||||
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
|
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
|
||||||
isSelected && glassStyles.selected
|
isSelected && glassStyles.selected
|
||||||
)}
|
)}
|
||||||
style={{ height: 48, color: '#fff' }}
|
style={{ height: 40, color: '#fff' }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onSelect?.();
|
onSelect?.();
|
||||||
@@ -54,20 +67,31 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
|
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
|
||||||
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: '#21916A' }}
|
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
|
||||||
title="Resize left"
|
title="Resize left"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
|
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
|
||||||
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: '#21916A' }}
|
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
|
||||||
title="Resize right"
|
title="Resize right"
|
||||||
/>
|
/>
|
||||||
{/* Content */}
|
{/* 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">
|
<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" />
|
{isZoom ? (
|
||||||
<span className="text-[11px] font-semibold tracking-tight">
|
<>
|
||||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
<ZoomIn className="w-3.5 h-3.5" />
|
||||||
</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,32 @@
|
|||||||
z-index: 10;
|
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 {
|
.zoomEndCap {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function Row({ id, children }: RowProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="border-b border-[#18181b] bg-[#18181b]"
|
className="border-b border-[#18181b] bg-[#18181b]"
|
||||||
style={{ ...rowWrapperStyle, minHeight: 88 }}
|
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
|
||||||
>
|
>
|
||||||
<div ref={setNodeRef} style={rowStyle}>
|
<div ref={setNodeRef} style={rowStyle}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTimelineContext } from "dnd-timeline";
|
import { useTimelineContext } from "dnd-timeline";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Scissors, ZoomIn } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import TimelineWrapper from "./TimelineWrapper";
|
import TimelineWrapper from "./TimelineWrapper";
|
||||||
@@ -9,10 +9,11 @@ import Row from "./Row";
|
|||||||
import Item from "./Item";
|
import Item from "./Item";
|
||||||
import KeyframeMarkers from "./KeyframeMarkers";
|
import KeyframeMarkers from "./KeyframeMarkers";
|
||||||
import type { Range, Span } from "dnd-timeline";
|
import type { Range, Span } from "dnd-timeline";
|
||||||
import type { ZoomRegion } from "../types";
|
import type { ZoomRegion, TrimRegion } from "../types";
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
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 FALLBACK_RANGE_MS = 1000;
|
||||||
const TARGET_MARKER_COUNT = 12;
|
const TARGET_MARKER_COUNT = 12;
|
||||||
|
|
||||||
@@ -26,6 +27,13 @@ interface TimelineEditorProps {
|
|||||||
onZoomDelete: (id: string) => void;
|
onZoomDelete: (id: string) => void;
|
||||||
selectedZoomId: string | null;
|
selectedZoomId: string | null;
|
||||||
onSelectZoom: (id: string | null) => void;
|
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 {
|
interface TimelineScaleConfig {
|
||||||
@@ -41,7 +49,8 @@ interface TimelineRenderItem {
|
|||||||
rowId: string;
|
rowId: string;
|
||||||
span: Span;
|
span: Span;
|
||||||
label: string;
|
label: string;
|
||||||
zoomDepth: number;
|
zoomDepth?: number;
|
||||||
|
variant: 'zoom' | 'trim';
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCALE_CANDIDATES = [
|
const SCALE_CANDIDATES = [
|
||||||
@@ -299,7 +308,9 @@ function Timeline({
|
|||||||
currentTimeMs,
|
currentTimeMs,
|
||||||
onSeek,
|
onSeek,
|
||||||
onSelectZoom,
|
onSelectZoom,
|
||||||
|
onSelectTrim,
|
||||||
selectedZoomId,
|
selectedZoomId,
|
||||||
|
selectedTrimId,
|
||||||
}: {
|
}: {
|
||||||
items: TimelineRenderItem[];
|
items: TimelineRenderItem[];
|
||||||
videoDurationMs: number;
|
videoDurationMs: number;
|
||||||
@@ -307,13 +318,19 @@ function Timeline({
|
|||||||
currentTimeMs: number;
|
currentTimeMs: number;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
onSelectZoom?: (id: string | null) => void;
|
onSelectZoom?: (id: string | null) => void;
|
||||||
|
onSelectTrim?: (id: string | null) => void;
|
||||||
selectedZoomId: string | null;
|
selectedZoomId: string | null;
|
||||||
|
selectedTrimId?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||||
|
|
||||||
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!onSeek || videoDurationMs <= 0) return;
|
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);
|
onSelectZoom?.(null);
|
||||||
|
onSelectTrim?.(null);
|
||||||
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||||
@@ -325,7 +342,10 @@ function Timeline({
|
|||||||
const timeInSeconds = absoluteMs / 1000;
|
const timeInSeconds = absoluteMs / 1000;
|
||||||
|
|
||||||
onSeek(timeInSeconds);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -337,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" />
|
<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} />
|
<TimelineAxis intervalMs={intervalMs} videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||||
<PlaybackCursor currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} />
|
<PlaybackCursor currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} />
|
||||||
<Row id={ROW_ID}>
|
|
||||||
{items.map((item) => (
|
<Row id={ZOOM_ROW_ID}>
|
||||||
|
{zoomItems.map((item) => (
|
||||||
<Item
|
<Item
|
||||||
id={item.id}
|
id={item.id}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -347,6 +368,23 @@ function Timeline({
|
|||||||
isSelected={item.id === selectedZoomId}
|
isSelected={item.id === selectedZoomId}
|
||||||
onSelect={() => onSelectZoom?.(item.id)}
|
onSelect={() => onSelectZoom?.(item.id)}
|
||||||
zoomDepth={item.zoomDepth}
|
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.label}
|
||||||
</Item>
|
</Item>
|
||||||
@@ -366,6 +404,12 @@ export default function TimelineEditor({
|
|||||||
onZoomDelete,
|
onZoomDelete,
|
||||||
selectedZoomId,
|
selectedZoomId,
|
||||||
onSelectZoom,
|
onSelectZoom,
|
||||||
|
trimRegions = [],
|
||||||
|
onTrimAdded,
|
||||||
|
onTrimSpanChange,
|
||||||
|
onTrimDelete,
|
||||||
|
selectedTrimId,
|
||||||
|
onSelectTrim,
|
||||||
}: TimelineEditorProps) {
|
}: TimelineEditorProps) {
|
||||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||||
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
||||||
@@ -401,6 +445,13 @@ export default function TimelineEditor({
|
|||||||
onSelectZoom(null);
|
onSelectZoom(null);
|
||||||
}, [selectedZoomId, onZoomDelete, onSelectZoom]);
|
}, [selectedZoomId, onZoomDelete, onSelectZoom]);
|
||||||
|
|
||||||
|
// Delete selected trim item
|
||||||
|
const deleteSelectedTrim = useCallback(() => {
|
||||||
|
if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return;
|
||||||
|
onTrimDelete(selectedTrimId);
|
||||||
|
onSelectTrim(null);
|
||||||
|
}, [selectedTrimId, onTrimDelete, onSelectTrim]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRange(createInitialRange(totalMs));
|
setRange(createInitialRange(totalMs));
|
||||||
}, [totalMs]);
|
}, [totalMs]);
|
||||||
@@ -421,26 +472,53 @@ export default function TimelineEditor({
|
|||||||
onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd });
|
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 => {
|
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
|
||||||
// Snap if gap is 2ms or less
|
// Determine which row the item belongs to
|
||||||
return zoomRegions.some((region) => {
|
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
|
||||||
if (region.id === excludeId) return false;
|
const isTrimItem = trimRegions.some(r => r.id === excludeId);
|
||||||
const gapBefore = newSpan.start - region.endMs;
|
|
||||||
const gapAfter = region.startMs - newSpan.end;
|
// Helper to check overlap against a specific set of regions
|
||||||
if (gapBefore > 0 && gapBefore <= 2) return true;
|
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
|
||||||
if (gapAfter > 0 && gapAfter <= 2) return true;
|
return regions.some((region) => {
|
||||||
return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs);
|
if (region.id === excludeId) return false;
|
||||||
});
|
const gapBefore = newSpan.start - region.endMs;
|
||||||
}, [zoomRegions]);
|
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(() => {
|
const handleAddZoom = useCallback(() => {
|
||||||
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const defaultDuration = Math.min(1000, totalMs);
|
const defaultDuration = Math.min(1000, totalMs);
|
||||||
if (defaultDuration <= 0) {
|
if (defaultDuration <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -466,26 +544,66 @@ export default function TimelineEditor({
|
|||||||
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
||||||
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded]);
|
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded]);
|
||||||
|
|
||||||
// Listen for F key to add keyframe, Z key to add zoom, Ctrl+D to remove selected keyframe or zoom item
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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') {
|
if (e.key === 'f' || e.key === 'F') {
|
||||||
addKeyframe();
|
addKeyframe();
|
||||||
}
|
}
|
||||||
if (e.key === 'z' || e.key === 'Z') {
|
if (e.key === 'z' || e.key === 'Z') {
|
||||||
handleAddZoom();
|
handleAddZoom();
|
||||||
}
|
}
|
||||||
|
if (e.key === 't' || e.key === 'T') {
|
||||||
|
handleAddTrim();
|
||||||
|
}
|
||||||
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) {
|
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) {
|
||||||
if (selectedKeyframeId) {
|
if (selectedKeyframeId) {
|
||||||
deleteSelectedKeyframe();
|
deleteSelectedKeyframe();
|
||||||
} else if (selectedZoomId) {
|
} else if (selectedZoomId) {
|
||||||
deleteSelectedZoom();
|
deleteSelectedZoom();
|
||||||
|
} else if (selectedTrimId) {
|
||||||
|
deleteSelectedTrim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [addKeyframe, handleAddZoom, deleteSelectedKeyframe, deleteSelectedZoom, selectedKeyframeId, selectedZoomId]);
|
}, [addKeyframe, handleAddZoom, handleAddTrim, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, selectedKeyframeId, selectedZoomId, selectedTrimId]);
|
||||||
|
|
||||||
const clampedRange = useMemo<Range>(() => {
|
const clampedRange = useMemo<Range>(() => {
|
||||||
if (totalMs === 0) {
|
if (totalMs === 0) {
|
||||||
@@ -499,16 +617,34 @@ export default function TimelineEditor({
|
|||||||
}, [range, totalMs]);
|
}, [range, totalMs]);
|
||||||
|
|
||||||
const timelineItems = useMemo<TimelineRenderItem[]>(() => {
|
const timelineItems = useMemo<TimelineRenderItem[]>(() => {
|
||||||
return [...zoomRegions]
|
const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
|
||||||
.sort((a, b) => a.startMs - b.startMs)
|
id: region.id,
|
||||||
.map((region, index) => ({
|
rowId: ZOOM_ROW_ID,
|
||||||
id: region.id,
|
span: { start: region.startMs, end: region.endMs },
|
||||||
rowId: ROW_ID,
|
label: `Zoom ${index + 1}`,
|
||||||
span: { start: region.startMs, end: region.endMs },
|
zoomDepth: region.depth,
|
||||||
label: `Zoom ${index + 1}`,
|
variant: 'zoom',
|
||||||
zoomDepth: region.depth,
|
}));
|
||||||
}));
|
|
||||||
}, [zoomRegions]);
|
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) {
|
if (!videoDuration || videoDuration === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -526,16 +662,27 @@ export default function TimelineEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-[#09090b] overflow-hidden">
|
<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]">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/5 bg-[#09090b]">
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
onClick={handleAddZoom}
|
<Button
|
||||||
variant="outline"
|
onClick={handleAddZoom}
|
||||||
size="sm"
|
variant="ghost"
|
||||||
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"
|
size="icon"
|
||||||
>
|
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||||
<Plus className="w-3.5 h-3.5" />
|
title="Add Zoom (Z)"
|
||||||
Add Zoom
|
>
|
||||||
</Button>
|
<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-1" />
|
||||||
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
|
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
@@ -559,7 +706,7 @@ export default function TimelineEditor({
|
|||||||
minItemDurationMs={timelineScale.minItemDurationMs}
|
minItemDurationMs={timelineScale.minItemDurationMs}
|
||||||
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
|
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
|
||||||
gridSizeMs={timelineScale.gridMs}
|
gridSizeMs={timelineScale.gridMs}
|
||||||
onItemSpanChange={onZoomSpanChange}
|
onItemSpanChange={handleItemSpanChange}
|
||||||
>
|
>
|
||||||
<KeyframeMarkers
|
<KeyframeMarkers
|
||||||
keyframes={keyframes}
|
keyframes={keyframes}
|
||||||
@@ -573,7 +720,9 @@ export default function TimelineEditor({
|
|||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
onSelectZoom={onSelectZoom}
|
onSelectZoom={onSelectZoom}
|
||||||
|
onSelectTrim={onSelectTrim}
|
||||||
selectedZoomId={selectedZoomId}
|
selectedZoomId={selectedZoomId}
|
||||||
|
selectedTrimId={selectedTrimId}
|
||||||
/>
|
/>
|
||||||
</TimelineWrapper>
|
</TimelineWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export interface ZoomRegion {
|
|||||||
focus: ZoomFocus;
|
focus: ZoomFocus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrimRegion {
|
||||||
|
id: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CropRegion {
|
export interface CropRegion {
|
||||||
x: number; // 0-1 normalized
|
x: number; // 0-1 normalized
|
||||||
y: number; // 0-1 normalized
|
y: number; // 0-1 normalized
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
import type { TrimRegion } from '../types';
|
||||||
|
|
||||||
interface VideoEventHandlersParams {
|
interface VideoEventHandlersParams {
|
||||||
video: HTMLVideoElement;
|
video: HTMLVideoElement;
|
||||||
@@ -9,6 +10,7 @@ interface VideoEventHandlersParams {
|
|||||||
timeUpdateAnimationRef: React.MutableRefObject<number | null>;
|
timeUpdateAnimationRef: React.MutableRefObject<number | null>;
|
||||||
onPlayStateChange: (playing: boolean) => void;
|
onPlayStateChange: (playing: boolean) => void;
|
||||||
onTimeUpdate: (time: number) => void;
|
onTimeUpdate: (time: number) => void;
|
||||||
|
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||||
@@ -21,6 +23,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
|||||||
timeUpdateAnimationRef,
|
timeUpdateAnimationRef,
|
||||||
onPlayStateChange,
|
onPlayStateChange,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
|
trimRegionsRef,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const emitTime = (timeValue: number) => {
|
const emitTime = (timeValue: number) => {
|
||||||
@@ -28,9 +31,35 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
|||||||
onTimeUpdate(timeValue);
|
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() {
|
function updateTime() {
|
||||||
if (!video) return;
|
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) {
|
if (!video.paused && !video.ended) {
|
||||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||||
}
|
}
|
||||||
@@ -68,10 +97,25 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
|||||||
const handleSeeked = () => {
|
const handleSeeked = () => {
|
||||||
isSeekingRef.current = false;
|
isSeekingRef.current = false;
|
||||||
|
|
||||||
if (!isPlayingRef.current && !video.paused) {
|
const currentTimeMs = video.currentTime * 1000;
|
||||||
video.pause();
|
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 = () => {
|
const handleSeeking = () => {
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types';
|
|||||||
import { VideoFileDecoder } from './videoDecoder';
|
import { VideoFileDecoder } from './videoDecoder';
|
||||||
import { FrameRenderer } from './frameRenderer';
|
import { FrameRenderer } from './frameRenderer';
|
||||||
import { VideoMuxer } from './muxer';
|
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 {
|
interface VideoExporterConfig extends ExportConfig {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
wallpaper: string;
|
wallpaper: string;
|
||||||
zoomRegions: ZoomRegion[];
|
zoomRegions: ZoomRegion[];
|
||||||
|
trimRegions?: TrimRegion[];
|
||||||
showShadow: boolean;
|
showShadow: boolean;
|
||||||
shadowIntensity: number;
|
shadowIntensity: number;
|
||||||
showBlur: boolean;
|
showBlur: boolean;
|
||||||
@@ -35,6 +36,36 @@ export class VideoExporter {
|
|||||||
this.config = config;
|
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> {
|
async export(): Promise<ExportResult> {
|
||||||
try {
|
try {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
@@ -60,7 +91,6 @@ export class VideoExporter {
|
|||||||
await this.renderer.initialize();
|
await this.renderer.initialize();
|
||||||
|
|
||||||
// Initialize video encoder
|
// Initialize video encoder
|
||||||
const totalFrames = Math.ceil(videoInfo.duration * this.config.frameRate);
|
|
||||||
await this.initializeEncoder();
|
await this.initializeEncoder();
|
||||||
|
|
||||||
// Initialize muxer
|
// Initialize muxer
|
||||||
@@ -73,6 +103,14 @@ export class VideoExporter {
|
|||||||
throw new Error('Video element not available');
|
throw new Error('Video element not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate effective duration and frame count (excluding trim regions)
|
||||||
|
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
|
||||||
|
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||||
|
|
||||||
|
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
|
||||||
|
console.log('[VideoExporter] Effective duration:', effectiveDuration, 's');
|
||||||
|
console.log('[VideoExporter] Total frames to export:', totalFrames);
|
||||||
|
|
||||||
// Process frames continuously without batching delays
|
// Process frames continuously without batching delays
|
||||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||||
let frameIndex = 0;
|
let frameIndex = 0;
|
||||||
@@ -81,7 +119,11 @@ export class VideoExporter {
|
|||||||
while (frameIndex < totalFrames && !this.cancelled) {
|
while (frameIndex < totalFrames && !this.cancelled) {
|
||||||
const i = frameIndex;
|
const i = frameIndex;
|
||||||
const timestamp = i * frameDuration;
|
const timestamp = i * frameDuration;
|
||||||
const videoTime = i * timeStep;
|
|
||||||
|
// Map effective time to source time (accounting for trim regions)
|
||||||
|
const effectiveTimeMs = (i * timeStep) * 1000;
|
||||||
|
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
|
||||||
|
const videoTime = sourceTimeMs / 1000;
|
||||||
|
|
||||||
// Seek if needed or wait for first frame to be ready
|
// Seek if needed or wait for first frame to be ready
|
||||||
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
|
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
|
||||||
@@ -106,8 +148,9 @@ export class VideoExporter {
|
|||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render the frame with all effects
|
// Render the frame with all effects using source timestamp
|
||||||
await this.renderer!.renderFrame(videoFrame, timestamp);
|
const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
|
||||||
|
await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
|
||||||
|
|
||||||
videoFrame.close();
|
videoFrame.close();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user