+34
-18
@@ -1,25 +1,31 @@
|
||||
import { screen, BrowserWindow, 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 path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
|
||||
const APP_ROOT = path.join(__dirname$2, "..");
|
||||
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 = 350;
|
||||
const windowHeight = 80;
|
||||
const x = Math.floor(workArea.x + workArea.width - windowWidth - 5);
|
||||
const y = Math.floor(workArea.y + workArea.height - (windowHeight - 30));
|
||||
const windowWidth = 500;
|
||||
const windowHeight = 100;
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 350,
|
||||
maxWidth: 350,
|
||||
minHeight: 80,
|
||||
maxHeight: 80,
|
||||
minWidth: 500,
|
||||
maxWidth: 500,
|
||||
minHeight: 100,
|
||||
maxHeight: 100,
|
||||
x,
|
||||
y,
|
||||
frame: false,
|
||||
@@ -29,7 +35,7 @@ function createHudOverlayWindow() {
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname$2, "preload.mjs"),
|
||||
preload: path.join(__dirname$1, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false
|
||||
@@ -38,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 {
|
||||
@@ -62,7 +74,7 @@ function createEditorWindow() {
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname$2, "preload.mjs"),
|
||||
preload: path.join(__dirname$1, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
@@ -80,7 +92,6 @@ function createEditorWindow() {
|
||||
query: { windowType: "editor" }
|
||||
});
|
||||
}
|
||||
win.webContents.openDevTools();
|
||||
return win;
|
||||
}
|
||||
function createSourceSelectorWindow() {
|
||||
@@ -98,7 +109,7 @@ function createSourceSelectorWindow() {
|
||||
transparent: true,
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname$2, "preload.mjs"),
|
||||
preload: path.join(__dirname$1, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
@@ -282,7 +293,7 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
||||
async function ensureRecordingsDir() {
|
||||
try {
|
||||
@@ -293,7 +304,7 @@ async function ensureRecordingsDir() {
|
||||
console.error("Failed to create recordings directory:", error);
|
||||
}
|
||||
}
|
||||
process.env.APP_ROOT = path.join(__dirname$1, "..");
|
||||
process.env.APP_ROOT = path.join(__dirname, "..");
|
||||
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||
const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
|
||||
const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
|
||||
@@ -350,6 +361,12 @@ app.on("activate", () => {
|
||||
}
|
||||
});
|
||||
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,
|
||||
@@ -361,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");
|
||||
},
|
||||
|
||||
Vendored
+2
@@ -39,6 +39,8 @@ interface Window {
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
|
||||
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
|
||||
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()
|
||||
|
||||
@@ -121,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')
|
||||
|
||||
+27
-12
@@ -1,4 +1,5 @@
|
||||
import { BrowserWindow, screen } from 'electron'
|
||||
import { ipcMain } from 'electron'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
@@ -8,25 +9,32 @@ 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;
|
||||
|
||||
// Define the desired window size
|
||||
const windowWidth = 350;
|
||||
const windowHeight = 80;
|
||||
|
||||
const x = Math.floor(workArea.x + workArea.width - windowWidth - 5); // Align to the right edge of the work area
|
||||
// const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); // Center horizontally within the work area
|
||||
const y = Math.floor(workArea.y + workArea.height - (windowHeight - 30));
|
||||
const windowWidth = 500;
|
||||
const windowHeight = 100;
|
||||
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 350,
|
||||
maxWidth: 350,
|
||||
minHeight: 80,
|
||||
maxHeight: 80,
|
||||
minWidth: 500,
|
||||
maxWidth: 500,
|
||||
minHeight: 100,
|
||||
maxHeight: 100,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
@@ -48,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 {
|
||||
@@ -97,8 +114,6 @@ export function createEditorWindow(): BrowserWindow {
|
||||
})
|
||||
}
|
||||
|
||||
win.webContents.openDevTools();
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
|
||||
Generated
+31
-6
@@ -28,9 +28,11 @@
|
||||
"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"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.64",
|
||||
@@ -7674,6 +7676,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/icon-gen/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-corefoundation": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
||||
@@ -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": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
@@ -12491,13 +12513,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"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",
|
||||
|
||||
@@ -6,10 +6,32 @@
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
|
||||
.folderButton {
|
||||
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;
|
||||
}
|
||||
|
||||
.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 { 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);
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full h-full flex items-center bg-transparent">
|
||||
<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={{
|
||||
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={16} className="text-white/40" />
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${styles.electronDrag}`}> <RxDragHandleDots2 size={18} className="text-white/40" /> </div>
|
||||
|
||||
<Button
|
||||
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}`}
|
||||
onClick={openSourceSelector}
|
||||
>
|
||||
<MdMonitor size={13} className="text-white" />
|
||||
<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"
|
||||
@@ -96,26 +134,52 @@ export function LaunchWindow() {
|
||||
>
|
||||
{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" />
|
||||
|
||||
<div className="w-px h-5 bg-white/30" />
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
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>
|
||||
</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>
|
||||
|
||||
@@ -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,6 +19,7 @@ import {
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
type TrimRegion,
|
||||
type CropRegion,
|
||||
} from "./types";
|
||||
import { VideoExporter, type ExportProgress } from "@/lib/exporter";
|
||||
@@ -38,6 +40,8 @@ export default function VideoEditor() {
|
||||
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,6 +49,7 @@ 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
|
||||
@@ -103,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) => {
|
||||
@@ -117,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) => {
|
||||
@@ -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) => {
|
||||
setZoomRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
@@ -170,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)) {
|
||||
@@ -178,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');
|
||||
@@ -229,6 +281,7 @@ export default function VideoEditor() {
|
||||
codec: 'avc1.640033',
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -273,7 +326,7 @@ export default function VideoEditor() {
|
||||
setIsExporting(false);
|
||||
exporterRef.current = null;
|
||||
}
|
||||
}, [videoPath, wallpaper, zoomRegions, shadowIntensity, showBlur, cropRegion, isPlaying]);
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, cropRegion, isPlaying]);
|
||||
|
||||
const handleCancelExport = useCallback(() => {
|
||||
if (exporterRef.current) {
|
||||
@@ -311,62 +364,79 @@ export default function VideoEditor() {
|
||||
<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">
|
||||
{/* Top section: video preview and controls */}
|
||||
<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%' }}>
|
||||
{/* Video preview */}
|
||||
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', padding: '24px 0' }}>
|
||||
<div className="relative" style={{ width: '100%', maxWidth: '1000px', aspectRatio: '16/9', boxSizing: 'border-box', overflow: 'hidden' }}>
|
||||
<VideoPlayback
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ''}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
<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 || ''}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
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}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
isPlaying={isPlaying}
|
||||
showShadow={shadowIntensity > 0}
|
||||
shadowIntensity={shadowIntensity}
|
||||
showBlur={showBlur}
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
onTrimAdded={handleTrimAdded}
|
||||
onTrimSpanChange={handleTrimSpanChange}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Playback controls */}
|
||||
<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>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Right section: settings panel */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from "react";
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth } from "./types";
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion } from "./types";
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||
@@ -28,6 +28,7 @@ interface VideoPlaybackProps {
|
||||
shadowIntensity?: number;
|
||||
showBlur?: boolean;
|
||||
cropRegion?: import('./types').CropRegion;
|
||||
trimRegions?: TrimRegion[];
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -55,6 +56,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
shadowIntensity = 0,
|
||||
showBlur,
|
||||
cropRegion,
|
||||
trimRegions = [],
|
||||
}, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -85,6 +87,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
const allowPlaybackRef = useRef(false);
|
||||
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
@@ -287,6 +290,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
isPlayingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
trimRegionsRef.current = trimRegions;
|
||||
}, [trimRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
|
||||
@@ -513,6 +520,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
timeUpdateAnimationRef,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
});
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
|
||||
@@ -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,11 +55,11 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo
|
||||
<div style={itemContentStyle}>
|
||||
<div
|
||||
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",
|
||||
isSelected && glassStyles.selected
|
||||
)}
|
||||
style={{ height: 48, color: '#fff' }}
|
||||
style={{ height: 40, color: '#fff' }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
@@ -54,20 +67,31 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo
|
||||
>
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
{/* 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,6 +24,32 @@
|
||||
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;
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
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";
|
||||
@@ -9,10 +9,11 @@ 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;
|
||||
|
||||
@@ -26,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 {
|
||||
@@ -41,7 +49,8 @@ interface TimelineRenderItem {
|
||||
rowId: string;
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth: number;
|
||||
zoomDepth?: number;
|
||||
variant: 'zoom' | 'trim';
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -299,7 +308,9 @@ function Timeline({
|
||||
currentTimeMs,
|
||||
onSeek,
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
}: {
|
||||
items: TimelineRenderItem[];
|
||||
videoDurationMs: number;
|
||||
@@ -307,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;
|
||||
@@ -325,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
|
||||
@@ -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" />
|
||||
<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}
|
||||
@@ -347,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>
|
||||
@@ -366,6 +404,12 @@ export default function TimelineEditor({
|
||||
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]);
|
||||
@@ -401,6 +445,13 @@ export default function TimelineEditor({
|
||||
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));
|
||||
}, [totalMs]);
|
||||
@@ -421,26 +472,53 @@ 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(1000, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
@@ -466,26 +544,66 @@ export default function TimelineEditor({
|
||||
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [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(() => {
|
||||
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, deleteSelectedKeyframe, deleteSelectedZoom, selectedKeyframeId, selectedZoomId]);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, selectedKeyframeId, selectedZoomId, selectedTrimId]);
|
||||
|
||||
const clampedRange = useMemo<Range>(() => {
|
||||
if (totalMs === 0) {
|
||||
@@ -499,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 (
|
||||
@@ -526,16 +662,27 @@ 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">
|
||||
@@ -559,7 +706,7 @@ export default function TimelineEditor({
|
||||
minItemDurationMs={timelineScale.minItemDurationMs}
|
||||
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
|
||||
gridSizeMs={timelineScale.gridMs}
|
||||
onItemSpanChange={onZoomSpanChange}
|
||||
onItemSpanChange={handleItemSpanChange}
|
||||
>
|
||||
<KeyframeMarkers
|
||||
keyframes={keyframes}
|
||||
@@ -573,7 +720,9 @@ export default function TimelineEditor({
|
||||
currentTimeMs={currentTimeMs}
|
||||
onSeek={onSeek}
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -2,12 +2,13 @@ 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;
|
||||
@@ -35,6 +36,36 @@ export class VideoExporter {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// Calculate the total duration excluding trim regions (in seconds)
|
||||
private getEffectiveDuration(totalDuration: number): number {
|
||||
const trimRegions = this.config.trimRegions || [];
|
||||
const totalTrimDuration = trimRegions.reduce((sum, region) => {
|
||||
return sum + (region.endMs - region.startMs) / 1000;
|
||||
}, 0);
|
||||
return totalDuration - totalTrimDuration;
|
||||
}
|
||||
|
||||
private mapEffectiveToSourceTime(effectiveTimeMs: number): number {
|
||||
const trimRegions = this.config.trimRegions || [];
|
||||
// Sort trim regions by start time
|
||||
const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
|
||||
|
||||
let sourceTimeMs = effectiveTimeMs;
|
||||
|
||||
for (const trim of sortedTrims) {
|
||||
// If the source time hasn't reached this trim region yet, we're done
|
||||
if (sourceTimeMs < trim.startMs) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add the duration of this trim region to the source time
|
||||
const trimDuration = trim.endMs - trim.startMs;
|
||||
sourceTimeMs += trimDuration;
|
||||
}
|
||||
|
||||
return sourceTimeMs;
|
||||
}
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
try {
|
||||
this.cleanup();
|
||||
@@ -60,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
|
||||
@@ -73,6 +103,14 @@ export class VideoExporter {
|
||||
throw new Error('Video element not available');
|
||||
}
|
||||
|
||||
// Calculate effective duration and frame count (excluding trim regions)
|
||||
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
|
||||
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
|
||||
console.log('[VideoExporter] Effective duration:', effectiveDuration, 's');
|
||||
console.log('[VideoExporter] Total frames to export:', totalFrames);
|
||||
|
||||
// Process frames continuously without batching delays
|
||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||
let frameIndex = 0;
|
||||
@@ -81,7 +119,11 @@ export class VideoExporter {
|
||||
while (frameIndex < totalFrames && !this.cancelled) {
|
||||
const i = frameIndex;
|
||||
const timestamp = i * frameDuration;
|
||||
const videoTime = i * timeStep;
|
||||
|
||||
// Map effective time to source time (accounting for trim regions)
|
||||
const effectiveTimeMs = (i * timeStep) * 1000;
|
||||
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
|
||||
const videoTime = sourceTimeMs / 1000;
|
||||
|
||||
// Seek if needed or wait for first frame to be ready
|
||||
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
|
||||
@@ -106,8 +148,9 @@ export class VideoExporter {
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Render the frame with all effects
|
||||
await this.renderer!.renderFrame(videoFrame, timestamp);
|
||||
// Render the frame with all effects using source timestamp
|
||||
const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
|
||||
await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
|
||||
|
||||
videoFrame.close();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user