diff --git a/dist-electron/main.js b/dist-electron/main.js index 5828cc1..84b48e9 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -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(); diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index 7fe77fe..42f5816 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -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"); }, diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index fed165e..9328bbb 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; } } diff --git a/electron/main.ts b/electron/main.ts index 2722632..0cd4cbd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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(); diff --git a/electron/preload.ts b/electron/preload.ts index f853d10..e8644e4 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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') diff --git a/electron/windows.ts b/electron/windows.ts index c0759b4..0f57dc8 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -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 } diff --git a/package-lock.json b/package-lock.json index 5813422..1a9f766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 37101d5..e15e098 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 2eb5eb1..12d33b5 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -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; } \ No newline at end of file diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index e952c37..feb8183 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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(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 (
-
- -
+
-
+
+ + +
-
+ + {/* Separator before hide/close buttons */} +
+ + +
diff --git a/src/components/video-editor/PlaybackControls.tsx b/src/components/video-editor/PlaybackControls.tsx index cfa0f44..7c776dd 100644 --- a/src/components/video-editor/PlaybackControls.tsx +++ b/src/components/video-editor/PlaybackControls.tsx @@ -31,7 +31,7 @@ export default function PlaybackControls({ const progress = duration > 0 ? (currentTime / duration) * 100 : 0; return ( -
+
- + {formatTime(duration)}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index d272417..6e13d72 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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(DEFAULT_CROP_REGION); const [zoomRegions, setZoomRegions] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); + const [trimRegions, setTrimRegions] = useState([]); + const [selectedTrimId, setSelectedTrimId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); @@ -45,6 +49,7 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); + const nextTrimIdRef = useRef(1); const exporterRef = useRef(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() {
-
+
{/* Left Column - Video & Timeline */}
- {/* Top section: video preview and controls */} -
- {/* Video preview */} -
-
- + {/* Top section: video preview and controls */} + +
+ {/* Video preview */} +
+
+ 0} + shadowIntensity={shadowIntensity} + showBlur={showBlur} + cropRegion={cropRegion} + trimRegions={trimRegions} + /> +
+
+ {/* Playback controls */} +
+
+ +
+
+
+
+ + +
+
+ + {/* Timeline section */} + +
+ 0} - shadowIntensity={shadowIntensity} - showBlur={showBlur} - cropRegion={cropRegion} + trimRegions={trimRegions} + onTrimAdded={handleTrimAdded} + onTrimSpanChange={handleTrimSpanChange} + onTrimDelete={handleTrimDelete} + selectedTrimId={selectedTrimId} + onSelectTrim={handleSelectTrim} />
-
- {/* Playback controls */} -
-
- -
-
-
- - {/* Timeline section */} -
- -
+ +
{/* Right section: settings panel */} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 74cec08..197acb6 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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(({ shadowIntensity = 0, showBlur, cropRegion, + trimRegions = [], }, ref) => { const videoRef = useRef(null); const containerRef = useRef(null); @@ -85,6 +87,7 @@ const VideoPlayback = forwardRef(({ const allowPlaybackRef = useRef(false); const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null); const layoutVideoContentRef = useRef<(() => void) | null>(null); + const trimRegionsRef = useRef([]); const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { return clampFocusToStageUtil(focus, depth, stageSizeRef.current); @@ -287,6 +290,10 @@ const VideoPlayback = forwardRef(({ isPlayingRef.current = isPlaying; }, [isPlaying]); + useEffect(() => { + trimRegionsRef.current = trimRegions; + }, [trimRegions]); + useEffect(() => { if (!pixiReady || !videoReady) return; @@ -513,6 +520,7 @@ const VideoPlayback = forwardRef(({ timeUpdateAnimationRef, onPlayStateChange, onTimeUpdate, + trimRegionsRef, }); video.addEventListener('play', handlePlay); diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 66c10cb..60767cf 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -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 = { 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 (
{ event.stopPropagation(); onSelect?.(); @@ -54,20 +67,31 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo >
{/* Content */}
- - - {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`} - + {isZoom ? ( + <> + + + {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`} + + + ) : ( + <> + + + Trim + + + )}
diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index adef673..2a6e45f 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -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; diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 3456b5a..4027021 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -11,7 +11,7 @@ export default function Row({ id, children }: RowProps) { return (
{children} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 6049799..14688ab 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -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) => { 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 (
- - {items.map((item) => ( + + + {zoomItems.map((item) => ( onSelectZoom?.(item.id)} zoomDepth={item.zoomDepth} + variant="zoom" + > + {item.label} + + ))} + + + + {trimItems.map((item) => ( + onSelectTrim?.(item.id)} + variant="trim" > {item.label} @@ -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(() => { if (totalMs === 0) { @@ -499,16 +617,34 @@ export default function TimelineEditor({ }, [range, totalMs]); const timelineItems = useMemo(() => { - 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 (
-
- +
+
+ + +
@@ -559,7 +706,7 @@ export default function TimelineEditor({ minItemDurationMs={timelineScale.minItemDurationMs} minVisibleRangeMs={timelineScale.minVisibleRangeMs} gridSizeMs={timelineScale.gridMs} - onItemSpanChange={onZoomSpanChange} + onItemSpanChange={handleItemSpanChange} >
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index e75c965..273a4ad 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -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 diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 86b8201..8a55545 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -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; onPlayStateChange: (playing: boolean) => void; onTimeUpdate: (time: number) => void; + trimRegionsRef: React.MutableRefObject; } 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 = () => { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index c4d468d..9631598 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -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 { 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();