Merge pull request #14 from siddharthvaddem/feature/trim

Feature/trim
This commit is contained in:
Sid
2025-11-27 22:31:33 -08:00
committed by GitHub
20 changed files with 708 additions and 175 deletions
+34 -18
View File
@@ -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();
+6
View File
@@ -1,6 +1,12 @@
"use strict";
const electron = require("electron");
electron.contextBridge.exposeInMainWorld("electronAPI", {
hudOverlayHide: () => {
electron.ipcRenderer.send("hud-overlay-hide");
},
hudOverlayClose: () => {
electron.ipcRenderer.send("hud-overlay-close");
},
getAssetBasePath: async () => {
return await electron.ipcRenderer.invoke("get-asset-base-path");
},
+2
View File
@@ -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
View File
@@ -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();
+6
View File
@@ -1,6 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
hudOverlayHide: () => {
ipcRenderer.send('hud-overlay-hide');
},
hudOverlayClose: () => {
ipcRenderer.send('hud-overlay-close');
},
getAssetBasePath: async () => {
// ask main process for the correct base path (production vs dev)
return await ipcRenderer.invoke('get-asset-base-path')
+27 -12
View File
@@ -1,4 +1,5 @@
import { BrowserWindow, screen } from 'electron'
import { ipcMain } from 'electron'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -8,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
}
+31 -6
View File
@@ -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": {
+1
View File
@@ -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",
+23 -1
View File
@@ -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;
}
+83 -19
View File
@@ -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>
+121 -51
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import PlaybackControls from "./PlaybackControls";
@@ -18,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);
+35 -11
View File
@@ -1,7 +1,7 @@
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { cn } from "@/lib/utils";
import { ZoomIn } from "lucide-react";
import { ZoomIn, Scissors } from "lucide-react";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -11,7 +11,8 @@ interface ItemProps {
children: React.ReactNode;
isSelected?: boolean;
onSelect?: () => void;
zoomDepth: number;
zoomDepth?: number;
variant?: 'zoom' | 'trim';
}
// Map zoom depth to multiplier labels
@@ -23,13 +24,25 @@ const ZOOM_LABELS: Record<number, string> = {
5: "3.5×",
};
export default function Item({ id, span, rowId, isSelected = false, onSelect, zoomDepth }: ItemProps) {
export default function Item({
id,
span,
rowId,
isSelected = false,
onSelect,
zoomDepth = 1,
variant = 'zoom'
}: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
data: { rowId },
});
const isZoom = variant === 'zoom';
const glassClass = isZoom ? glassStyles.glassGreen : glassStyles.glassRed;
const endCapColor = isZoom ? '#21916A' : '#ef4444';
return (
<div
ref={setNodeRef}
@@ -42,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;
+1 -1
View File
@@ -11,7 +11,7 @@ export default function Row({ id, children }: RowProps) {
return (
<div
className="border-b border-[#18181b] bg-[#18181b]"
style={{ ...rowWrapperStyle, minHeight: 88 }}
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
>
<div ref={setNodeRef} style={rowStyle}>
{children}
@@ -1,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>
+6
View File
@@ -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 = () => {
+48 -5
View File
@@ -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();