diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..599213786 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +# Force LF line endings for all source/config files so that git's +# core.autocrlf does not interfere with Biome's lineEnding: lf setting. +# Without this, core.autocrlf=true on Windows converts LF→CRLF on every +# checkout/stash restore, causing Biome's pre-commit check to fail even +# on files that were correctly formatted with LF before staging. + +*.ts text eol=lf +*.tsx text eol=lf +*.mts text eol=lf +*.cts text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.json text eol=lf +*.md text eol=lf +*.css text eol=lf +*.html text eol=lf +*.svg text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.sh text eol=lf diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e92ce19fc..8d629d08d 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -166,6 +166,17 @@ interface Window { canceled?: boolean; error?: string; }>; + getPathForFile: (file: File) => string; + loadProjectFileFromPath: (filePath: string) => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + onMenuNewProject: (callback: () => void) => () => void; + onMenuImportVideo: (callback: () => void) => () => void; onMenuLoadProject: (callback: () => void) => () => void; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; @@ -187,6 +198,15 @@ interface Window { onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; onRequestCloseConfirm: (callback: () => void) => () => void; sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void; + onCursorTypeChange: ( + callback: ( + cursorType: import("../src/native/contracts").NativeCursorType | null, + asset: + | import("./native-bridge/cursor/recording/windowsNativeRecordingSession.types").CursorOverlayAsset + | null, + osCursorHidden: boolean, + ) => void, + ) => () => void; setLocale: (locale: string) => Promise; saveDiagnostic: (payload: { error: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9797c95c0..8dec28916 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,5 +1,6 @@ -import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; -import { constants as fsConstants } from "node:fs"; +import { type ChildProcessWithoutNullStreams, spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants as fsConstants, mkdirSync, rmSync, writeFileSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -36,13 +37,32 @@ import { mainT } from "../i18n"; import { RECORDINGS_DIR } from "../main"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; +import { + createCursorOverlayWindow, + destroyCursorOverlayWindow, + sendToCursorOverlay, + startCursorOverlayZOrderPolling, + startHudCursorPolling, + stopCursorOverlayZOrderPolling, + stopHudCursorPolling, +} from "../windows"; import { registerNativeBridgeHandlers } from "./nativeBridge"; const PROJECT_FILE_EXTENSION = "openscreen"; const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); const RECORDING_FILE_PREFIX = "recording-"; const RECORDING_SESSION_SUFFIX = ".session.json"; -const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]); +const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([ + ".webm", + ".mp4", + ".mov", + ".avi", + ".mkv", + ".m4v", + ".wmv", + ".flv", + ".ts", +]); /** * Paths explicitly approved by the user via file picker dialogs or project loads. @@ -463,6 +483,130 @@ function resolvePackagedResourcePath(...segments: string[]) { return path.join(process.resourcesPath, ...segments); } +// ── System-cursor hide / restore (Windows only) ────────────────────────────── +// When recording in editable-overlay mode via getUserMedia, Chromium bakes the +// OS cursor into every captured frame regardless of the `cursor` constraint. +// We work around this by replacing every system cursor with a 32×32 fully +// transparent cursor bitmap so the OS cursor is invisible (and therefore absent +// from the footage), while a virtual SVG cursor rendered in the excluded HUD +// gives the user visual feedback. On recording stop we restore all cursors +// from the registry with SPI_SETCURSORS (87). + +function runPowerShellOneShot(script: string): Promise { + return new Promise((resolve) => { + if (process.platform !== "win32") { + resolve(); + return; + } + + // Write to a temp .ps1 file and run with -File so that multiline + // here-strings and embedded quotes are preserved exactly. Passing the + // script via -Command mangles embedded newlines / double-quotes through + // Node's CreateProcess argument encoding. + const scriptDir = path.join(os.tmpdir(), "openscreen-cursor-scripts"); + const scriptPath = path.join( + scriptDir, + `cursor-${process.pid}-${Date.now()}-${randomUUID()}.ps1`, + ); + try { + mkdirSync(scriptDir, { recursive: true }); + writeFileSync(scriptPath, script, "utf8"); + } catch (err) { + console.error("[cursor-hide] failed to write PS1 script:", err); + resolve(); + return; + } + + const proc = spawn( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ], + { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, + ); + + const cleanup = () => { + try { + rmSync(scriptPath, { force: true }); + } catch { + // best-effort cleanup + } + }; + + const timer = setTimeout(() => { + try { + proc.kill(); + } catch { + // best-effort process kill — ignore ESRCH / already-dead errors + } + cleanup(); + resolve(); + }, 5_000); + + proc.once("exit", () => { + clearTimeout(timer); + cleanup(); + resolve(); + }); + proc.once("error", (err) => { + clearTimeout(timer); + cleanup(); + console.error("[cursor-hide] PowerShell error:", err.message); + resolve(); // best-effort — never block recording on this + }); + }); +} + +const HIDE_CURSOR_SCRIPT = ` +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public class OpenScreenCursorHider { + [DllImport("user32.dll")] public static extern bool SetSystemCursor(IntPtr hcur, uint id); + [DllImport("user32.dll")] public static extern IntPtr CreateCursor(IntPtr hInst, int xHotspot, int yHotspot, int nWidth, int nHeight, byte[] pvANDPlane, byte[] pvXORPlane); + public static readonly uint[] Ids = new uint[]{32512,32513,32514,32515,32516,32640,32641,32642,32643,32644,32645,32646,32648,32649,32650,32651}; + public static void HideAll() { + byte[] andMask = new byte[128]; byte[] xorMask = new byte[128]; + for (int i = 0; i < 128; i++) { andMask[i] = 0xFF; xorMask[i] = 0x00; } + foreach (uint id in Ids) { + IntPtr h = CreateCursor(IntPtr.Zero, 0, 0, 32, 32, andMask, xorMask); + if (h != IntPtr.Zero) SetSystemCursor(h, id); + } + } +} +"@ +[OpenScreenCursorHider]::HideAll() +`; + +const RESTORE_CURSOR_SCRIPT = ` +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public class OpenScreenCursorRestorer { + [DllImport("user32.dll")] public static extern bool SystemParametersInfo(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni); +} +"@ +[OpenScreenCursorRestorer]::SystemParametersInfo(87, 0, [IntPtr]::Zero, 0) +`; + +async function hideSystemCursor(): Promise { + if (process.platform !== "win32") return; + console.info("[cursor-hide] hiding OS cursor for editable-overlay recording"); + await runPowerShellOneShot(HIDE_CURSOR_SCRIPT); +} + +async function restoreSystemCursor(): Promise { + if (process.platform !== "win32") return; + console.info("[cursor-hide] restoring OS cursor"); + await runPowerShellOneShot(RESTORE_CURSOR_SCRIPT); +} + function getNativeWindowsCaptureHelperCandidates() { const envPath = process.env.OPENSCREEN_WGC_CAPTURE_EXE?.trim(); const archTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64"; @@ -620,6 +764,9 @@ async function startCursorRecording(recordingId?: number) { sourceId: getSelectedSourceId(), startTimeMs: typeof recordingId === "number" && Number.isFinite(recordingId) ? recordingId : undefined, + onCursorTypeChange: (cursorType, asset, osCursorHidden) => { + sendToCursorOverlay("cursor-type-change", cursorType, asset ?? null, osCursorHidden === true); + }, }); try { @@ -985,29 +1132,39 @@ export function registerIpcHandlers( }); ipcMain.handle("switch-to-editor", () => { - const mainWin = getMainWindow(); - if (mainWin) { - mainWin.close(); - } + // createEditorWindow is createEditorWindowWrapper — it already closes + // the current mainWindow (the HUD) before opening the editor. Closing + // it here too causes a double-close which leaves ghost transparent + // windows and makes the HUD shadow compound on each cycle. createEditorWindow(); }); + ipcMain.handle("start-new-recording", () => { + if (_switchToHud) { + _switchToHud(); + } + return { success: true }; + }); + ipcMain.handle("countdown-overlay-show", async (_, value: number, runId: number) => { const overlayWindow = getCountdownOverlayWindow?.() ?? createCountdownOverlayWindow(); if (overlayWindow.isDestroyed()) { return; } - if (!overlayWindow.isVisible()) { - overlayWindow.showInactive(); - } - + // Wait for the first frame to be painted before showing the window. + // Showing before ready-to-show produces a black rectangle flash because + // Chromium hasn't rendered any pixels yet. if (overlayWindow.webContents.isLoading()) { await new Promise((resolve) => { - overlayWindow.webContents.once("did-finish-load", () => resolve()); + overlayWindow.once("ready-to-show", resolve); }); } + if (!overlayWindow.isVisible()) { + overlayWindow.showInactive(); + } + overlayWindow.webContents.send("countdown-overlay-value", value, runId); }); @@ -1320,6 +1477,15 @@ export function registerIpcHandlers( ? payload.createdAt : Date.now(); const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode); + + // The renderer calls setRecordingState(false) fire-and-forget, then immediately + // calls store-recorded-session. The IPC handlers run on the same process but + // stopCursorRecording() inside set-recording-state may not have completed yet. + // Calling stopCursorRecording() here is safe and idempotent — if the session is + // already stopped it returns immediately; if it is still running this ensures the + // pending cursor data is captured before we try to write the telemetry file. + await stopCursorRecording(); + const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName); await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); @@ -1404,8 +1570,29 @@ export function registerIpcHandlers( normalizeCursorCaptureMode(cursorCaptureMode) ?? "editable-overlay"; if (recording && normalizedCursorCaptureMode === "editable-overlay") { await startCursorRecording(recordingId); + // Drive HUD interactivity from the main process so stop/pause/discard + // remain responsive even when the OS cursor is transparent. + startHudCursorPolling(); + // Hide the OS cursor so getUserMedia stops baking it into raw frames. + // A virtual cursor rendered in the excluded overlay window gives the + // user visual feedback without appearing in the recorded footage. + await hideSystemCursor(); + createCursorOverlayWindow(); + // Re-assert HWND_TOPMOST every 500 ms so the virtual cursor stays + // visible above the Windows taskbar (which also sits at TOPMOST and + // can win the z-order battle after initial window creation). + startCursorOverlayZOrderPolling(); } else { + stopCursorOverlayZOrderPolling(); + destroyCursorOverlayWindow(); + // Fire cursor restore without awaiting — Add-Type C# compilation in + // PowerShell takes 3-5 s and must not block the stop-recording flow. + // The cursor will be visible again within a few seconds. + restoreSystemCursor().catch((err) => + console.error("[cursor-hide] async restore failed:", err), + ); await stopCursorRecording(); + stopHudCursorPolling(); } const source = selectedSource || { name: "Screen" }; @@ -1533,7 +1720,7 @@ export function registerIpcHandlers( filters: [ { name: mainT("dialogs", "fileDialogs.videoFiles"), - extensions: ["webm", "mp4", "mov", "avi", "mkv"], + extensions: ["webm", "mp4", "mov", "avi", "mkv", "m4v", "wmv", "flv", "ts"], }, { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, ], @@ -1748,6 +1935,51 @@ export function registerIpcHandlers( } } + ipcMain.handle("load-project-file-from-path", async (_event, filePath: string) => { + return loadProjectFileFromPath(filePath); + }); + + async function loadProjectFileFromPath(filePath: string): Promise { + try { + if (!filePath || typeof filePath !== "string") { + return { success: false, message: "Invalid file path" }; + } + // Validate extension and readability + if (path.extname(filePath).toLowerCase() !== `.${PROJECT_FILE_EXTENSION}`) { + return { success: false, message: "Not an Openscreen project file" }; + } + const stats = await fs.stat(filePath).catch(() => null); + if (!stats?.isFile()) { + return { success: false, message: "File not found" }; + } + const content = await fs.readFile(filePath, "utf-8"); + const project = JSON.parse(content); + currentProjectPath = filePath; + + // Approve session paths; tolerate failures (e.g. video moved outside + // trusted dirs) so the project still loads and the renderer can surface + // a "video not found" error rather than a generic load failure. + let session: import("../../src/lib/recordingSession").RecordingSession | null = null; + try { + session = await getApprovedProjectSession(project, filePath); + } catch (sessionError) { + console.warn( + "[loadProjectFileFromPath] Could not approve session paths, proceeding without session:", + sessionError, + ); + } + setCurrentRecordingSessionState(session); + return { success: true, path: filePath, project }; + } catch (error) { + console.error("Failed to load project file from path:", error); + return { + success: false, + message: "Failed to load project file", + error: String(error), + }; + } + } + ipcMain.handle("load-current-project-file", async () => { return loadCurrentProjectFile(); }); @@ -1830,6 +2062,8 @@ export function registerIpcHandlers( function clearCurrentVideoPath(): ProjectPathResult { currentVideoPath = null; + currentProjectPath = null; + setCurrentRecordingSessionState(null); return { success: true }; } @@ -1897,6 +2131,42 @@ export function registerIpcHandlers( }, ); + // Safety net: always restore the OS cursor on quit in case recording was + // active and the user closed the app without stopping it first. + if (process.platform === "win32") { + app.once("before-quit", () => { + stopCursorOverlayZOrderPolling(); + destroyCursorOverlayWindow(); + // Synchronous restore on quit — write to temp file to avoid the + // -Command quote-mangling issue (same fix as runPowerShellOneShot). + try { + const scriptPath = path.join( + os.tmpdir(), + `openscreen-cursor-scripts`, + `cursor-quit-${process.pid}.ps1`, + ); + mkdirSync(path.dirname(scriptPath), { recursive: true }); + writeFileSync(scriptPath, RESTORE_CURSOR_SCRIPT, "utf8"); + spawnSync( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ], + { windowsHide: true, timeout: 3_000 }, + ); + rmSync(scriptPath, { force: true }); + } catch { + // best-effort — don't crash the quit sequence + } + }); + } + registerNativeBridgeHandlers({ getPlatform: () => process.platform, getCurrentProjectPath: () => currentProjectPath, @@ -1904,6 +2174,7 @@ export function registerIpcHandlers( saveProjectFile, loadProjectFile, loadCurrentProjectFile, + loadProjectFileFromPath, setCurrentVideoPath, getCurrentVideoPathResult, clearCurrentVideoPath, diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 7f7b24b51..425f93e1a 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -27,6 +27,7 @@ export interface NativeBridgeContext { ) => Promise; loadProjectFile: () => Promise; loadCurrentProjectFile: () => Promise; + loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; getCurrentVideoPathResult: () => ProjectPathResult; clearCurrentVideoPath: () => ProjectPathResult; @@ -100,6 +101,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { saveProjectFile: context.saveProjectFile, loadProjectFile: context.loadProjectFile, loadCurrentProjectFile: context.loadCurrentProjectFile, + loadProjectFileFromPath: context.loadProjectFileFromPath, setCurrentVideoPath: context.setCurrentVideoPath, getCurrentVideoPathResult: context.getCurrentVideoPathResult, clearCurrentVideoPath: context.clearCurrentVideoPath, @@ -168,6 +170,11 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { requestId, await projectService.loadCurrentProjectFile(), ); + case "loadProjectFileFromPath": + return createSuccessResponse( + requestId, + await projectService.loadProjectFileFromPath(request.payload.path), + ); case "setCurrentVideoPath": return createSuccessResponse( requestId, diff --git a/electron/main.ts b/electron/main.ts index 24d06ac12..e9018a156 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -109,7 +109,7 @@ function isEditorWindow(window: BrowserWindow) { } function sendEditorMenuAction( - channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as", + channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as" | "menu-new-project", ) { let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -168,6 +168,12 @@ function setupApplicationMenu() { { label: mainT("common", "actions.file") || "File", submenu: [ + { + label: mainT("dialogs", "unsavedChanges.newProject") || "New Project", + accelerator: "CmdOrCtrl+N", + click: () => sendEditorMenuAction("menu-new-project"), + }, + { type: "separator" as const }, { label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…", accelerator: "CmdOrCtrl+O", diff --git a/electron/native-bridge/cursor/recording/factory.ts b/electron/native-bridge/cursor/recording/factory.ts index e072b75d9..abcc270ba 100644 --- a/electron/native-bridge/cursor/recording/factory.ts +++ b/electron/native-bridge/cursor/recording/factory.ts @@ -2,6 +2,7 @@ import type { Rectangle } from "electron"; import type { CursorRecordingSession } from "./session"; import { TelemetryRecordingSession } from "./telemetryRecordingSession"; import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession"; +import type { CursorOverlayAsset } from "./windowsNativeRecordingSession.types"; interface CreateCursorRecordingSessionOptions { getDisplayBounds: () => Rectangle | null; @@ -10,6 +11,11 @@ interface CreateCursorRecordingSessionOptions { sampleIntervalMs: number; sourceId?: string | null; startTimeMs?: number; + onCursorTypeChange?: ( + cursorType: import("../../../../src/native/contracts").NativeCursorType | null, + asset: CursorOverlayAsset | null, + osCursorHidden: boolean, + ) => void; } export function createCursorRecordingSession( @@ -22,6 +28,7 @@ export function createCursorRecordingSession( sampleIntervalMs: options.sampleIntervalMs, sourceId: options.sourceId, startTimeMs: options.startTimeMs, + onCursorTypeChange: options.onCursorTypeChange, }); } diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts index bed037a1e..a6ea3a732 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts @@ -192,11 +192,6 @@ function Get-CustomCursorType($bitmap, $hotspotX, $hotspotY) { return $null } - if ($hotspotX -lt ($bitmap.Width * 0.25) -or $hotspotX -gt ($bitmap.Width * 0.75) -or - $hotspotY -lt ($bitmap.Height * 0.15) -or $hotspotY -gt ($bitmap.Height * 0.55)) { - return $null - } - $opaquePixels = 0 $topHalfOpaquePixels = 0 $left = $bitmap.Width @@ -221,12 +216,62 @@ function Get-CustomCursorType($bitmap, $hotspotX, $hotspotY) { } } - if ($opaquePixels -lt 90 -or $right -lt $left -or $bottom -lt $top) { + if ($opaquePixels -lt 20 -or $right -lt $left -or $bottom -lt $top) { return $null } $opaqueWidth = $right - $left + 1 $opaqueHeight = $bottom - $top + 1 + + # I-beam / text cursor: very narrow body, tall, horizontally centred hotspot. + # Detected before the wide-cursor checks that would otherwise reject it. + if ($opaqueWidth -lt ($bitmap.Width * 0.35) -and $opaqueHeight -gt ($bitmap.Height * 0.5) -and + $hotspotX -gt ($bitmap.Width * 0.2) -and $hotspotX -lt ($bitmap.Width * 0.8)) { + return 'text' + } + + # Arrow cursor: hotspot is at the very top-left corner of the *opaque* area. + # + # We measure the hotspot's distance from the opaque bounding box corner + # ($left, $top) rather than its absolute position in the bitmap. This is + # scale-independent: Chrome's private arrow cursor keeps its tip at the + # top-left of the opaque area regardless of whether the bitmap is rendered + # at 1× (hotspot ≈ 3 px from the bitmap edge) or 2× HiDPI (hotspot ≈ 8 px + # from the bitmap edge but still ≈ 0 px from the opaque area's corner). + # + # An extra guard — significant rightward extent from the hotspot — distinguishes + # the diagonal arrow body from a pointer finger that extends mainly downward. + $hotspotNearOpaqueLeft = ($hotspotX - $left) -le [Math]::Max(2, $bitmap.Width * 0.10) + $hotspotNearOpaqueTop = ($hotspotY - $top) -le [Math]::Max(2, $bitmap.Height * 0.10) + $arrowRightExtent = [double]($right - $hotspotX) / $bitmap.Width + if ($hotspotNearOpaqueLeft -and $hotspotNearOpaqueTop -and + $arrowRightExtent -gt 0.30 -and + $opaqueWidth -gt ($bitmap.Width * 0.2) -and $opaqueHeight -gt ($bitmap.Height * 0.3)) { + return 'arrow' + } + + # Pointer / hand cursor: hotspot is at the fingertip — near the top of the + # opaque area but NOT at the far-left corner (arrow was already handled above). + # We again measure distance from the opaque bounding box rather than using + # an absolute percentage of the full bitmap, so this works at any DPI scale. + $hotspotNearOpaqueTop2 = ($hotspotY - $top) -le [Math]::Max(2, $bitmap.Height * 0.15) + if ($hotspotNearOpaqueTop2 -and + $hotspotX -gt ($bitmap.Width * 0.05) -and $hotspotX -lt ($bitmap.Width * 0.90) -and + $opaqueHeight -gt ($bitmap.Height * 0.5) -and + $opaqueWidth -gt ($bitmap.Width * 0.25)) { + return 'pointer' + } + + # Remaining checks are for open-hand / closed-hand cursors. + if ($opaquePixels -lt 90) { + return $null + } + + if ($hotspotX -lt ($bitmap.Width * 0.25) -or $hotspotX -gt ($bitmap.Width * 0.75) -or + $hotspotY -lt ($bitmap.Height * 0.15) -or $hotspotY -gt ($bitmap.Height * 0.55)) { + return $null + } + if ($opaqueWidth -lt ($bitmap.Width * 0.35) -or $opaqueWidth -gt ($bitmap.Width * 0.9) -or $opaqueHeight -lt ($bitmap.Height * 0.45) -or $opaqueHeight -gt $bitmap.Height) { return $null @@ -289,13 +334,18 @@ function Get-CursorAsset($cursorHandle, $cursorId) { try { $icon = [System.Drawing.Icon]::FromHandle($copiedHandle) - $bitmap = New-Object System.Drawing.Bitmap $icon.Width, $icon.Height, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) - $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $bitmap = $null $memoryStream = New-Object System.IO.MemoryStream try { - $graphics.Clear([System.Drawing.Color]::Transparent) - $graphics.DrawIcon($icon, 0, 0) + # Icon.ToBitmap() correctly converts cursor icons to 32bppArgb with + # proper alpha channel for ALL cursor types, including monochrome cursors + # such as I-beam and hourglass. The previous Graphics.DrawIcon approach + # drew RGB but left A=0 for every pixel on a transparent surface, making + # monochrome cursors completely invisible. ToBitmap() applies the AND + # mask correctly so transparent areas have A=0 and drawn pixels have A>0. + $bitmap = $icon.ToBitmap() + $hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 } $hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 } $customCursorType = Get-CustomCursorType -bitmap $bitmap -hotspotX $hotspotX -hotspotY $hotspotY @@ -314,8 +364,7 @@ function Get-CursorAsset($cursorHandle, $cursorId) { } finally { $memoryStream.Dispose() - $graphics.Dispose() - $bitmap.Dispose() + if ($bitmap) { $bitmap.Dispose() } $icon.Dispose() } } @@ -337,6 +386,10 @@ function Get-CursorAsset($cursorHandle, $cursorId) { Write-JsonLine @{ type = 'ready'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() } $lastCursorId = $null +# Tracks the most-recently resolved cursor type so that custom cursors (e.g. Chrome's +# hand cursor which doesn't match IDC_HAND) keep their classified type on every sample +# after the first, not just the frame when the handle first appeared. +$lastResolvedCursorType = $null while ($true) { [System.Windows.Forms.Application]::DoEvents() $mouseEvents = [OpenScreenCursorInterop]::ConsumeMouseButtonEvents() @@ -349,7 +402,18 @@ while ($true) { continue } + # CURSOR_SHOWING = 0x1. When an app (Figma, Photoshop, games) calls + # ShowCursor(false) the OS cursor is hidden for that window's input + # context, and GetCursorInfo's flags drops to 0. We capture this as + # $osCursorHidden so the helper, editor, and exporter can skip drawing + # our virtual cursor on top of the app's own drawn cursor and avoid + # showing the user a duplicate. + # + # This signal is INDEPENDENT of any SetSystemCursor(transparent) we do + # for capture: SetSystemCursor only replaces the cursor bitmap, the + # CURSOR_SHOWING flag still reflects the app's hide/show intent. $visible = ($cursorInfo.flags -band 1) -ne 0 + $osCursorHidden = -not $visible $cursorId = if ($cursorInfo.hCursor -eq [IntPtr]::Zero) { $null } else { ('0x{0:X}' -f $cursorInfo.hCursor.ToInt64()) } $cursorType = Get-StandardCursorType $cursorInfo.hCursor $leftButtonState = [OpenScreenCursorInterop]::GetAsyncKeyState(0x01) @@ -359,6 +423,7 @@ while ($true) { $asset = $null if ($visible -and $cursorId -and $cursorId -ne $lastCursorId) { + # Cursor handle changed — capture bitmap and resolve type via shape analysis. $asset = Get-CursorAsset -cursorHandle $cursorInfo.hCursor -cursorId $cursorId if ($asset -and $cursorType) { $asset.cursorType = $cursorType @@ -366,6 +431,12 @@ while ($true) { $cursorType = $asset.cursorType } $lastCursorId = $cursorId + $lastResolvedCursorType = $cursorType + } elseif (-not $cursorType -and $cursorId -and $cursorId -eq $lastCursorId) { + # Same handle, but Get-StandardCursorType returned null (custom cursor such as + # Chrome's hand). Reuse the type resolved when this handle first appeared so + # the cursor type doesn't revert to null on every subsequent sample. + $cursorType = $lastResolvedCursorType } Write-JsonLine @{ @@ -374,6 +445,7 @@ while ($true) { x = $cursorInfo.ptScreenPos.X y = $cursorInfo.ptScreenPos.Y visible = $visible + osCursorHidden = $osCursorHidden handle = $cursorId cursorType = $cursorType leftButtonDown = $leftButtonDown diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index dd4aab070..714757764 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -38,6 +38,9 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { private sampleCount = 0; private outOfBoundsSampleCount = 0; private previousLeftButtonDown = false; + private lastEmittedCursorType: string | null | undefined = undefined; // undefined = not yet emitted + private lastEmittedAssetId: string | null | undefined = undefined; + private lastEmittedOsCursorHidden: boolean | undefined = undefined; constructor(private readonly options: WindowsNativeRecordingSessionOptions) {} @@ -49,6 +52,9 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { this.sampleCount = 0; this.outOfBoundsSampleCount = 0; this.previousLeftButtonDown = false; + this.lastEmittedCursorType = undefined; + this.lastEmittedAssetId = undefined; + this.lastEmittedOsCursorHidden = undefined; const script = buildPowerShellScript( this.options.sampleIntervalMs, @@ -142,7 +148,12 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { return { version: 2, - provider: this.assets.size > 0 ? "native" : "none", + // Set provider to "native" whenever we have position/type samples, even + // when no bitmap assets were captured (editable-overlay recording where + // SetSystemCursor makes all OS bitmaps transparent). The editor's + // native-os cursor mode can still render using the pretty SVG fallbacks + // derived from the cursor-type field of each sample. + provider: this.samples.length > 0 ? "native" : "none", samples: this.samples, assets: [...this.assets.values()], }; @@ -211,6 +222,45 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { this.outOfBoundsSampleCount += 1; } + // Relay cursor-type changes in real time so the cursor overlay window can + // render the correct cursor shape while the OS cursor is hidden. + // We also pass the captured asset bitmap so the overlay can render the + // actual OS cursor image (whatever theme the user has) rather than an + // SVG approximation. We re-fire whenever the asset handle changes too, + // because a new handle means a new bitmap even if the type string is the + // same (e.g. two different app-defined arrow variants). + // + // We also re-fire whenever osCursorHidden toggles (cursor moved into / + // out of a Figma-style app that hides the OS cursor) so the helper can + // show/hide its SVG to match the app's intent. + const currentType = normalized.sample.cursorType ?? null; + const currentAssetId = normalized.sample.assetId ?? null; + const currentOsCursorHidden = normalized.sample.osCursorHidden === true; + if ( + this.options.onCursorTypeChange && + (currentType !== this.lastEmittedCursorType || + currentAssetId !== this.lastEmittedAssetId || + currentOsCursorHidden !== this.lastEmittedOsCursorHidden) + ) { + this.lastEmittedCursorType = currentType; + this.lastEmittedAssetId = currentAssetId; + this.lastEmittedOsCursorHidden = currentOsCursorHidden; + const nativeAsset = currentAssetId ? this.assets.get(currentAssetId) : null; + this.options.onCursorTypeChange( + currentType, + nativeAsset + ? { + imageDataUrl: nativeAsset.imageDataUrl, + hotspotX: nativeAsset.hotspotX, + hotspotY: nativeAsset.hotspotY, + width: nativeAsset.width, + height: nativeAsset.height, + } + : null, + currentOsCursorHidden, + ); + } + this.samples.push(normalized.sample); if (this.samples.length > this.options.maxSamples) { @@ -263,6 +313,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { visible: payload.visible && withinBounds, cursorType: payload.cursorType ?? payload.asset?.cursorType ?? null, interactionType, + osCursorHidden: payload.osCursorHidden === true, }, }; } diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts index f3b69da0f..21926ca53 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts @@ -7,6 +7,13 @@ export interface WindowsCursorSampleEvent { x: number; y: number; visible: boolean; + /** + * True when GetCursorInfo reports the OS cursor as hidden (an app called + * ShowCursor(false)). Independent of SetSystemCursor(transparent) we do + * for capture, so it specifically signals an app's intent to hide the + * cursor. Helper / editor / exporter skip rendering when this is true. + */ + osCursorHidden?: boolean; handle: string | null; cursorType?: NativeCursorType | null; leftButtonDown?: boolean; @@ -47,10 +54,36 @@ export type WindowsCursorEvent = | WindowsCursorReadyEvent | WindowsCursorErrorEvent; +export interface CursorOverlayAsset { + imageDataUrl: string; + hotspotX: number; + hotspotY: number; + width: number; + height: number; +} + export interface WindowsNativeRecordingSessionOptions { getDisplayBounds: () => Rectangle | null; maxSamples: number; sampleIntervalMs: number; sourceId?: string | null; startTimeMs?: number; + /** + * Called whenever the active cursor shape changes during recording, OR + * when the OS cursor is hidden/un-hidden by an app (Figma etc.). + * + * `asset` carries the actual captured cursor bitmap + hotspot so the + * overlay window can render the real OS cursor image instead of an SVG + * approximation. May be null if the bitmap hasn't been captured yet. + * + * `osCursorHidden` mirrors GetCursorInfo's CURSOR_SHOWING flag: when + * true, an app has hidden the OS cursor and the helper should not draw + * a virtual cursor on top (avoids the double-cursor issue in Figma / + * Photoshop / games that draw their own cursors). + */ + onCursorTypeChange?: ( + cursorType: NativeCursorType | null, + asset: CursorOverlayAsset | null, + osCursorHidden: boolean, + ) => void; } diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts index 965b4fb70..9e96aa22d 100644 --- a/electron/native-bridge/services/projectService.ts +++ b/electron/native-bridge/services/projectService.ts @@ -16,6 +16,7 @@ interface ProjectServiceOptions { ) => Promise; loadProjectFile: () => Promise; loadCurrentProjectFile: () => Promise; + loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; getCurrentVideoPathResult: () => ProjectPathResult; clearCurrentVideoPath: () => ProjectPathResult; @@ -60,6 +61,12 @@ export class ProjectService { return result; } + async loadProjectFileFromPath(path: string) { + const result = await this.options.loadProjectFileFromPath(path); + this.getCurrentContext(); + return result; + } + async setCurrentVideoPath(path: string) { const result = await this.options.setCurrentVideoPath(path); this.getCurrentContext(); diff --git a/electron/native-bridge/win32/topmost.ts b/electron/native-bridge/win32/topmost.ts new file mode 100644 index 000000000..345092e35 --- /dev/null +++ b/electron/native-bridge/win32/topmost.ts @@ -0,0 +1,167 @@ +// Win32 SetWindowPos wrapper for forcing a BrowserWindow above the Windows 11 +// taskbar. +// +// Why this exists: +// Electron's `BrowserWindow.setAlwaysOnTop(true, "screen-saver")` maps to +// `SetWindowPos(HWND_TOPMOST)` internally, but Electron short-circuits the +// call when the alwaysOnTop flag is already true. That means once we set +// it once, repeat calls are no-ops and the Win11 shell tray (Shell_TrayWnd) +// — which also sits at HWND_TOPMOST and re-asserts itself continuously — +// wins the activation race and paints over us. Toggling false→true forces +// a real call, but Electron's path still goes through window-state tracking +// that can lag a frame or two behind the taskbar. +// +// Going direct to Win32 lets us: +// 1) re-promote via SetWindowPos every tick with NO state-tracking lag, and +// 2) pass SWP_NOSENDCHANGING + SWP_NOACTIVATE, which suppress the focus/ +// activation side-effects that normally invite the shell to win. +// +// We use koffi (runtime FFI, no native compilation) so we don't need a +// node-gyp build step or electron-rebuild dance. +import type { BrowserWindow } from "electron"; +import koffi from "koffi"; + +// HWND_TOPMOST = -1 cast to a window handle. When SetWindowPos's +// hWndInsertAfter argument is HWND_TOPMOST the target window is placed above +// all non-topmost windows and is added to the topmost group. +const HWND_TOPMOST_VALUE = -1; + +// SetWindowPos flag bits (winuser.h). +const SWP_NOSIZE = 0x0001; +const SWP_NOMOVE = 0x0002; +const SWP_NOACTIVATE = 0x0010; +// SWP_NOSENDCHANGING tells the OS not to send WM_WINDOWPOSCHANGING to the +// window — which is what other topmost windows (like the shell tray) listen +// for to re-assert their own z-order. Skipping it makes our promotion stick. +const SWP_NOSENDCHANGING = 0x0400; +const PROMOTE_FLAGS = SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_NOSENDCHANGING; + +interface User32Bindings { + SetWindowPos: ( + hWnd: number | bigint, + hWndInsertAfter: number | bigint, + x: number, + y: number, + cx: number, + cy: number, + uFlags: number, + ) => number; + GetLastError: () => number; +} + +let cachedBindings: User32Bindings | null = null; +let loadAttempted = false; + +function loadUser32(): User32Bindings | null { + if (loadAttempted) { + return cachedBindings; + } + loadAttempted = true; + + if (process.platform !== "win32") { + return null; + } + + try { + const user32 = koffi.load("user32.dll"); + // CRITICAL: HWND and HWND_INSERT_AFTER are pointer-sized — on Win x64 + // that's 64 bits. Koffi's "long" is only 32 bits on Windows (matching + // the Win32 LONG type), which would silently truncate every HWND we + // pass and break the call without an obvious error. Use intptr_t so + // koffi picks the native pointer width on both x86 and x64. + const bindings: User32Bindings = { + SetWindowPos: user32.func("__stdcall", "SetWindowPos", "int", [ + "intptr_t", // HWND + "intptr_t", // HWND_INSERT_AFTER (HWND_TOPMOST = -1) + "int", + "int", + "int", + "int", + "uint", + ]) as unknown as User32Bindings["SetWindowPos"], + GetLastError: koffi + .load("kernel32.dll") + .func("__stdcall", "GetLastError", "uint", []) as unknown as User32Bindings["GetLastError"], + }; + cachedBindings = bindings; + return bindings; + } catch (err) { + // FFI load failed (missing dll on weird Windows variant, koffi binary + // mismatch, etc.) — caller will fall back to setAlwaysOnTop polling. + console.warn("[win32-topmost] Failed to load user32.dll via koffi:", err); + return null; + } +} + +// Read the HWND from BrowserWindow.getNativeWindowHandle() (returns a Buffer). +// On 64-bit Windows the buffer is 8 bytes (HWND is pointer-sized). +// On 32-bit Windows the buffer is 4 bytes (we still cast to bigint). +function readHwnd(buf: Buffer): bigint { + if (buf.length >= 8) { + return buf.readBigUInt64LE(0); + } + if (buf.length >= 4) { + return BigInt(buf.readUInt32LE(0)); + } + return BigInt(0); +} + +/** + * Force the given BrowserWindow above every other topmost window (including + * the Win11 taskbar). Safe to call every animation frame. + * + * Returns true if the SetWindowPos call succeeded, false on any failure. + * Falls back silently to no-op on non-Windows platforms and when the FFI + * bindings can't be loaded — callers should keep their existing + * setAlwaysOnTop polling as a backup path. + */ +export function promoteAboveTaskbar(win: BrowserWindow): boolean { + if (process.platform !== "win32") return false; + if (!win || win.isDestroyed()) return false; + + const user32 = loadUser32(); + if (!user32) return false; + + let hwnd: bigint; + try { + const handleBuf = win.getNativeWindowHandle(); + hwnd = readHwnd(handleBuf); + } catch (err) { + console.warn("[win32-topmost] getNativeWindowHandle threw:", err); + return false; + } + + if (hwnd === BigInt(0)) return false; + + try { + const result = user32.SetWindowPos(hwnd, BigInt(HWND_TOPMOST_VALUE), 0, 0, 0, 0, PROMOTE_FLAGS); + if (result === 0) { + // SetWindowPos returns 0 on failure. Log once with the error code + // so the user has something to inspect; further failures stay quiet. + const err = user32.GetLastError(); + if (!suppressErrorLog) { + console.warn( + `[win32-topmost] SetWindowPos failed for HWND=0x${hwnd.toString(16)}, GetLastError=${err}`, + ); + suppressErrorLog = true; + } + return false; + } + if (!loggedFirstSuccess) { + console.log( + `[win32-topmost] SetWindowPos(HWND_TOPMOST) OK for HWND=0x${hwnd.toString(16)} — overlay is now above shell tray`, + ); + loggedFirstSuccess = true; + } + return true; + } catch (err) { + if (!suppressErrorLog) { + console.warn(`[win32-topmost] SetWindowPos threw for HWND=0x${hwnd.toString(16)}:`, err); + suppressErrorLog = true; + } + return false; + } +} + +let suppressErrorLog = false; +let loggedFirstSuccess = false; diff --git a/electron/preload.ts b/electron/preload.ts index 8302b959f..e658e3611 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; @@ -121,9 +121,29 @@ contextBridge.exposeInMainWorld("electronAPI", { loadProjectFile: () => { return ipcRenderer.invoke("load-project-file"); }, + loadProjectFileFromPath: (filePath: string) => { + return ipcRenderer.invoke("load-project-file-from-path", filePath); + }, + getPathForFile: (file: File) => { + try { + return webUtils.getPathForFile(file); + } catch { + return ""; + } + }, loadCurrentProjectFile: () => { return ipcRenderer.invoke("load-current-project-file"); }, + onMenuNewProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-new-project", listener); + return () => ipcRenderer.removeListener("menu-new-project", listener); + }, + onMenuImportVideo: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-import-video", listener); + return () => ipcRenderer.removeListener("menu-import-video", listener); + }, onMenuLoadProject: (callback: () => void) => { const listener = () => callback(); ipcRenderer.on("menu-load-project", listener); @@ -202,4 +222,24 @@ contextBridge.exposeInMainWorld("electronAPI", { sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => { ipcRenderer.send("close-confirm-response", choice); }, + onCursorTypeChange: ( + callback: ( + cursorType: import("../src/native/contracts").NativeCursorType | null, + asset: + | import("../electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types").CursorOverlayAsset + | null, + osCursorHidden: boolean, + ) => void, + ) => { + const listener = ( + _event: unknown, + cursorType: import("../src/native/contracts").NativeCursorType | null, + asset: + | import("../electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types").CursorOverlayAsset + | null, + osCursorHidden: boolean, + ) => callback(cursorType, asset, osCursorHidden === true); + ipcRenderer.on("cursor-type-change", listener); + return () => ipcRenderer.removeListener("cursor-type-change", listener); + }, }); diff --git a/electron/windows.ts b/electron/windows.ts index 4d4e75206..c3e6271cc 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { BrowserWindow, ipcMain, screen } from "electron"; +import { promoteAboveTaskbar } from "./native-bridge/win32/topmost"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -17,6 +18,77 @@ const ASSET_BASE_DIR = process.defaultApp const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BASE_DIR}${path.sep}`).toString()}`; let hudOverlayWindow: BrowserWindow | null = null; +let cursorOverlayWindow: BrowserWindow | null = null; + +// ── Cursor-overlay z-order polling ────────────────────────────────────────── +// The Windows 11 taskbar (Shell_TrayWnd) sits at HWND_TOPMOST and continuously +// re-asserts its own z-order via the Explorer shell, which can push our overlay +// behind it whenever the cursor crosses the taskbar area. +// +// On Windows we use a direct Win32 SetWindowPos call (via koffi FFI) with +// SWP_NOSENDCHANGING | SWP_NOACTIVATE — this beats the shell tray because +// Electron's setAlwaysOnTop short-circuits when the flag is already set, and +// even when it doesn't, the path goes through window-state tracking that lags +// the shell's own re-promotion. Going straight to the Win32 API at ~60 fps +// keeps the virtual cursor strictly above the taskbar. +// +// On non-Windows platforms (and as a safety fallback if the koffi binding +// fails to load) we keep the old Electron-API toggle path. +let cursorOverlayZOrderInterval: NodeJS.Timeout | null = null; + +export function startCursorOverlayZOrderPolling(): void { + stopCursorOverlayZOrderPolling(); + cursorOverlayZOrderInterval = setInterval(() => { + if (!cursorOverlayWindow || cursorOverlayWindow.isDestroyed()) return; + // Try the direct Win32 path first. Returns false on non-Windows or if + // koffi failed to load user32 — in either case fall back to the Electron + // toggle so cross-platform behavior is unchanged. + const promoted = promoteAboveTaskbar(cursorOverlayWindow); + if (!promoted) { + cursorOverlayWindow.setAlwaysOnTop(false); + cursorOverlayWindow.setAlwaysOnTop(true, "screen-saver"); + cursorOverlayWindow.moveTop(); + } + }, 16); +} + +export function stopCursorOverlayZOrderPolling(): void { + if (cursorOverlayZOrderInterval !== null) { + clearInterval(cursorOverlayZOrderInterval); + cursorOverlayZOrderInterval = null; + } +} + +// ── HUD cursor-polling ─────────────────────────────────────────────────────── +// When getUserMedia captures the desktop with `cursor: "never"`, Chromium's +// capture pipeline may interfere with the { forward: true } mouse-move relay +// that the renderer uses to decide when to enable/disable click-through. +// As a reliable fallback, the main process polls the cursor position every +// 50 ms and drives setIgnoreMouseEvents directly — no renderer events needed. +let hudCursorPollInterval: NodeJS.Timeout | null = null; + +export function startHudCursorPolling(): void { + stopHudCursorPolling(); + hudCursorPollInterval = setInterval(() => { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) return; + const cursor = screen.getCursorScreenPoint(); + const { x, y, width, height } = hudOverlayWindow.getBounds(); + const isOverHud = + cursor.x >= x && cursor.x <= x + width && cursor.y >= y && cursor.y <= y + height; + hudOverlayWindow.setIgnoreMouseEvents(!isOverHud, { forward: true }); + }, 50); +} + +export function stopHudCursorPolling(): void { + if (hudCursorPollInterval !== null) { + clearInterval(hudCursorPollInterval); + hudCursorPollInterval = null; + } + // Restore normal pass-through so the renderer's onPointerMove takes over. + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayWindow.setIgnoreMouseEvents(true, { forward: true }); + } +} ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { @@ -39,19 +111,25 @@ export function createHudOverlayWindow(): BrowserWindow { const primaryDisplay = screen.getPrimaryDisplay(); const { workArea } = primaryDisplay; - const windowWidth = 600; - const windowHeight = 160; - + // Extra padding around the visible pill so CSS box-shadows (60px blur) + // aren't clipped by the transparent window boundary. + // The pill sits at CSS `bottom-20` (80px from window bottom) so the + // downward shadow has ~80px of transparent space to expand into. + // The window is positioned so the pill's screen position stays unchanged. + const windowWidth = 800; + const windowHeight = 320; + // Pill is bottom-20 (80px) instead of bottom-5 (20px), so shift window + // down 60px to keep the pill at the same visual screen position. const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); - const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); + const y = Math.floor(workArea.y + workArea.height - windowHeight + 55); const win = new BrowserWindow({ width: windowWidth, height: windowHeight, - minWidth: 600, - maxWidth: 600, - minHeight: 160, - maxHeight: 160, + minWidth: 800, + maxWidth: 800, + minHeight: 320, + maxHeight: 320, x: x, y: y, frame: false, @@ -60,7 +138,7 @@ export function createHudOverlayWindow(): BrowserWindow { alwaysOnTop: true, skipTaskbar: true, hasShadow: false, - show: !HEADLESS, + show: false, // shown via ready-to-show to avoid black flash webPreferences: { preload: path.join(__dirname, "preload.mjs"), additionalArguments: [ASSET_BASE_URL_ARG], @@ -71,12 +149,23 @@ export function createHudOverlayWindow(): BrowserWindow { }); win.setIgnoreMouseEvents(true, { forward: true }); + // Exclude the HUD from desktop screen captures so the recording controls + // never appear in the user's footage. On Windows this calls + // SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE); on macOS it uses the + // equivalent CGWindowLevel exclusion path. + win.setContentProtection(true); + // Follow the user across macOS Spaces (virtual desktops). // Without this the HUD stays pinned to the Space it was first opened on. if (process.platform === "darwin") { win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } + // Show only once content is painted — prevents black rectangle flash + win.once("ready-to-show", () => { + if (!HEADLESS) win.show(); + }); + win.webContents.on("did-finish-load", () => { win?.webContents.send("main-process-message", new Date().toLocaleString()); }); @@ -121,8 +210,8 @@ export function createEditorWindow(): BrowserWindow { alwaysOnTop: false, skipTaskbar: false, title: "OpenScreen", - backgroundColor: "#000000", - show: !HEADLESS, + backgroundColor: "#09090b", + show: false, // shown via ready-to-show to avoid white flash on first load webPreferences: { preload: path.join(__dirname, "preload.mjs"), additionalArguments: [ASSET_BASE_URL_ARG], @@ -136,6 +225,21 @@ export function createEditorWindow(): BrowserWindow { // Maximize the window by default win.maximize(); + // Show only once content is painted — prevents white flash on cold Vite start + win.once("ready-to-show", () => { + if (!HEADLESS) win.show(); + }); + + // Inject dark background before any React paint so the sub-titlebar area + // never flashes white even on the very first cold Vite load + win.webContents.on("dom-ready", () => { + win.webContents.insertCSS("html, body, #root { background: #09090b !important; }").catch(() => { + // Cosmetic-only background hint — if insertCSS fails (e.g. webContents + // destroyed during teardown) the renderer's own CSS still paints the + // dark background a frame later, so there's nothing to recover. + }); + }); + win.webContents.on("did-finish-load", () => { win?.webContents.send("main-process-message", new Date().toLocaleString()); }); @@ -195,6 +299,103 @@ export function createSourceSelectorWindow(): BrowserWindow { return win; } +/** + * Creates a full-screen transparent click-through window that renders a + * virtual software cursor. Used when the OS cursor is hidden during + * editable-overlay recording so the user can still see where their cursor is + * without it appearing in the raw footage. + * + * The window is excluded from screen capture via setContentProtection so the + * virtual cursor is never baked into recorded video. + */ +export function createCursorOverlayWindow(): BrowserWindow { + if (cursorOverlayWindow && !cursorOverlayWindow.isDestroyed()) { + return cursorOverlayWindow; + } + + const { bounds } = screen.getPrimaryDisplay(); + + const win = new BrowserWindow({ + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + frame: false, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + focusable: false, + transparent: true, + backgroundColor: "#00000000", + hasShadow: false, + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + additionalArguments: [ASSET_BASE_URL_ARG], + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + + // Sit above everything including the HUD so the virtual cursor renders on top. + win.setAlwaysOnTop(true, "screen-saver"); + // Click-through: mouse events pass to windows below, but the renderer DOM + // still fires mousemove so we can track cursor position. + win.setIgnoreMouseEvents(true, { forward: true }); + // Excluded from screen capture — virtual cursor must NOT appear in footage. + win.setContentProtection(true); + + if (process.platform === "darwin") { + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + + win.once("ready-to-show", () => { + if (!HEADLESS) win.show(); + // Promote above the taskbar via direct Win32 SetWindowPos as soon as the + // HWND exists. startCursorOverlayZOrderPolling() will keep re-promoting + // at ~60 fps, but this first call ensures the very first paint is above + // the shell tray. + promoteAboveTaskbar(win); + }); + + cursorOverlayWindow = win; + win.on("closed", () => { + if (cursorOverlayWindow === win) { + cursorOverlayWindow = null; + } + }); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=cursor-overlay"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "cursor-overlay" }, + }); + } + + return win; +} + +/** + * Closes and cleans up the virtual cursor overlay window. + */ +export function destroyCursorOverlayWindow(): void { + if (cursorOverlayWindow && !cursorOverlayWindow.isDestroyed()) { + cursorOverlayWindow.close(); + } + cursorOverlayWindow = null; +} + +/** + * Sends an IPC message to the cursor overlay window if it is alive. + */ +export function sendToCursorOverlay(channel: string, ...args: unknown[]): void { + if (cursorOverlayWindow && !cursorOverlayWindow.isDestroyed()) { + cursorOverlayWindow.webContents.send(channel, ...args); + } +} + /** * Creates a centered transparent countdown overlay window that sits above the * HUD while recording pre-roll is running. diff --git a/index.html b/index.html index ce1c274aa..510c15ddd 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,12 @@ - + - +
diff --git a/package-lock.json b/package-lock.json index 50ecc9d88..14f292896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "fix-webm-duration": "^1.0.6", "gif.js": "^0.2.0", "gsap": "^3.15.0", + "koffi": "^2.16.2", "lucide-react": "^0.545.0", "mediabunny": "^1.40.1", "motion": "^12.38.0", @@ -7415,6 +7416,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/koffi": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", + "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", diff --git a/package.json b/package.json index 9388bcd31..9c781225d 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "fix-webm-duration": "^1.0.6", "gif.js": "^0.2.0", "gsap": "^3.15.0", + "koffi": "^2.16.2", "lucide-react": "^0.545.0", "mediabunny": "^1.40.1", "motion": "^12.38.0", diff --git a/src/App.tsx b/src/App.tsx index 6f737b9b0..8259a778b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,11 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; +import { CursorOverlay } from "./components/launch/CursorOverlay"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; import { Toaster } from "./components/ui/sonner"; import { TooltipProvider } from "./components/ui/tooltip"; +import { useScopedT } from "./contexts/I18nContext"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; @@ -18,6 +20,7 @@ export default function App() { const [windowType, setWindowType] = useState( () => new URLSearchParams(window.location.search).get("windowType") || "", ); + const tEditor = useScopedT("editor"); useEffect(() => { const type = new URLSearchParams(window.location.search).get("windowType") || ""; @@ -25,7 +28,12 @@ export default function App() { setWindowType(type); } - if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") { + if ( + type === "hud-overlay" || + type === "source-selector" || + type === "countdown-overlay" || + type === "cursor-overlay" + ) { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); @@ -61,10 +69,40 @@ export default function App() { return ; case "countdown-overlay": return ; + case "cursor-overlay": + return ; case "editor": return ( - }> + + + + + + {tEditor("loadingEditor")} + + } + > diff --git a/src/assets/cursors/Cursor=AeroDefault.svg b/src/assets/cursors/Cursor=AeroDefault.svg new file mode 100644 index 000000000..bb2469a8d --- /dev/null +++ b/src/assets/cursors/Cursor=AeroDefault.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/launch/CursorOverlay.tsx b/src/components/launch/CursorOverlay.tsx new file mode 100644 index 000000000..d97e81978 --- /dev/null +++ b/src/components/launch/CursorOverlay.tsx @@ -0,0 +1,279 @@ +import { useEffect, useRef, useState } from "react"; +import arrowUrl from "@/assets/cursors/Cursor=AeroDefault.svg"; +import crosshairUrl from "@/assets/cursors/Cursor=Cross.svg"; +import closedHandUrl from "@/assets/cursors/Cursor=Hand-(Grabbing).svg"; +import openHandUrl from "@/assets/cursors/Cursor=Hand-(Open).svg"; +import pointerUrl from "@/assets/cursors/Cursor=Hand-(Pointing).svg"; +import notAllowedUrl from "@/assets/cursors/Cursor=Not-Allowed.svg"; +import resizeNeswUrl from "@/assets/cursors/Cursor=Resize-North-East-South-West.svg"; +import resizeNsUrl from "@/assets/cursors/Cursor=Resize-North-South.svg"; +import resizeNwseUrl from "@/assets/cursors/Cursor=Resize-North-West-South-East.svg"; +import resizeEwUrl from "@/assets/cursors/Cursor=Resize-West-East.svg"; +import textUrl from "@/assets/cursors/Cursor=Text-Cursor.svg"; +import type { NativeCursorType } from "../../native/contracts"; + +/** + * Full-screen virtual cursor rendered in the excluded cursor-overlay window. + * + * The cursor-overlay BrowserWindow is: + * - transparent + click-through with { forward: true } — DOM still receives mousemove + * - setContentProtection(true) — excluded from ALL screen capture APIs + * - screen-saver always-on-top — rendered above HUD and all other app windows + * + * When the OS cursor is hidden during editable-overlay recording (so it doesn't + * appear in raw footage), this component tracks the real mouse position and + * renders a platform-accurate cursor so the user still has visual feedback. + * + * Cursor hiding is Windows-only (SetSystemCursor), so this overlay is only ever + * shown on Windows. + * + * The active cursor shape changes in real time as the OS cursor type changes — + * the main process relays `cursor-type-change` IPC messages from the PowerShell + * sampler which reads the current Win32 cursor handle on every sample interval. + * + * We use the same cursor SVG assets as the editor's native-os preview so the + * recording helper and the editor look identical. The raw bitmap asset from the + * PowerShell sampler is deliberately ignored: during editable-overlay recording + * SetSystemCursor replaces every OS cursor handle with a transparent 32×32 + * bitmap, and even Chrome's private handles are only available while the cursor + * is over a Chrome window. Relying on the bitmap would cause the cursor to + * vanish whenever a transparent handle is captured (e.g. the brief wait / + * app-starting cursor triggered by clicking a link). The SVG approach is + * always opaque and always correct. + */ + +interface CursorAsset { + /** Vite-resolved URL of the cursor SVG. */ + url: string; + /** Hotspot X within the 32×32 SVG coordinate space. */ + hotspotX: number; + /** Hotspot Y within the 32×32 SVG coordinate space. */ + hotspotY: number; + /** Rendered CSS width in pixels. */ + width: number; + /** Rendered CSS height in pixels. */ + height: number; +} + +// These dimensions and hotspots mirror PRETTY_NATIVE_CURSOR_ASSETS in +// src/lib/cursor/nativeCursor.ts so the recording overlay and the editor +// preview use identical cursor geometry. +const ARROW: CursorAsset = { + url: arrowUrl, + // Cursor=AeroDefault.svg viewBox is "0 0 32 33" — same canonical box as the + // pointer/hand/etc. SVGs so the arrow renders at the same size as the rest of + // the cursor set. The arrow shape sits in the lower-middle of the 32×33 + // canvas; the polygon tip is at SVG (13.38, 12.90) so the hotspot is (13, 13). + width: 32, + height: 33, + hotspotX: 13, + hotspotY: 13, +}; +const POINTER: CursorAsset = { + url: pointerUrl, + hotspotX: 16.65, + hotspotY: 14.24, + width: 32, + height: 33, +}; +const TEXT: CursorAsset = { + url: textUrl, + hotspotX: 16, + hotspotY: 16, + width: 32, + height: 32, +}; +const CROSSHAIR: CursorAsset = { + url: crosshairUrl, + hotspotX: 16, + hotspotY: 16, + width: 32, + height: 32, +}; +const RESIZE_EW: CursorAsset = { + url: resizeEwUrl, + hotspotX: 16, + hotspotY: 16, + width: 32, + height: 32, +}; +const RESIZE_NS: CursorAsset = { + url: resizeNsUrl, + hotspotX: 16, + hotspotY: 16, + width: 32, + height: 32, +}; +const RESIZE_NESW: CursorAsset = { + url: resizeNeswUrl, + hotspotX: 16, + hotspotY: 16, + width: 32, + height: 32, +}; +const RESIZE_NWSE: CursorAsset = { + url: resizeNwseUrl, + hotspotX: 16, + hotspotY: 16, + width: 32, + height: 32, +}; +const NOT_ALLOWED: CursorAsset = { + url: notAllowedUrl, + hotspotX: 16, + hotspotY: 16, + width: 32, + height: 32, +}; +const OPEN_HAND: CursorAsset = { + url: openHandUrl, + hotspotX: 16, + hotspotY: 9, + width: 32, + height: 32, +}; +const CLOSED_HAND: CursorAsset = { + url: closedHandUrl, + hotspotX: 16, + hotspotY: 9, + width: 32, + height: 32, +}; + +const CURSOR_MAP: Partial> & { default: CursorAsset } = { + default: ARROW, + arrow: ARROW, + pointer: POINTER, + text: TEXT, + crosshair: CROSSHAIR, + "resize-ew": RESIZE_EW, + "resize-ns": RESIZE_NS, + "resize-nesw": RESIZE_NESW, + "resize-nwse": RESIZE_NWSE, + "not-allowed": NOT_ALLOWED, + "open-hand": OPEN_HAND, + "closed-hand": CLOSED_HAND, +}; + +function getCursorAsset(cursorType: NativeCursorType | null): CursorAsset { + if (!cursorType) return CURSOR_MAP.default; + return CURSOR_MAP[cursorType] ?? CURSOR_MAP.default; +} + +export function CursorOverlay() { + const cursorRef = useRef(null); + const [cursorType, setCursorType] = useState(null); + // True when the foreground app (Figma, Photoshop, games) has hidden the OS + // cursor via ShowCursor(false). In that case the app is drawing its own + // cursor, so we must NOT render our virtual SVG cursor on top — otherwise + // the user sees a double cursor. + const [osCursorHidden, setOsCursorHidden] = useState(false); + + // ── Listen for real-time cursor type from the main process ────────────────── + // We render the SVG cursor assets at their canonical CSS-pixel sizes (defined + // per-type in CURSOR_MAP below). The browser handles HiDPI scaling natively — + // 32 CSS px renders as 64 physical px on a 2× display, matching the OS cursor. + // We deliberately ignore the raw bitmap and the asset's scaleFactor: forcing + // our own size by physical-pixel data would either shrink the cursor (when the + // captured bitmap is in physical pixels) or stretch it. The pretty SVG sizes + // match the editor's native-OS preview exactly, so what you see while + // recording is what the final video shows. + useEffect(() => { + const cleanup = window.electronAPI?.onCursorTypeChange?.((type, _asset, hiddenByApp) => { + setCursorType(type); + setOsCursorHidden(hiddenByApp === true); + }); + return () => { + cleanup?.(); + }; + }, []); + + // ── Track mouse position via mousemove + rAF ───────────────────────────── + useEffect(() => { + // Suppress any residual CSS cursor — belt-and-braces since the OS cursor + // is already transparent, but prevents ghost outlines in some GPU paths. + document.documentElement.style.cursor = "none"; + document.body.style.cursor = "none"; + + let rafId = 0; + let lastX = -200; + let lastY = -200; + + const onMouseMove = (e: MouseEvent) => { + lastX = e.clientX; + lastY = e.clientY; + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + if (cursorRef.current) { + cursorRef.current.style.transform = `translate(${lastX}px, ${lastY}px)`; + } + }); + }; + + window.addEventListener("mousemove", onMouseMove); + return () => { + window.removeEventListener("mousemove", onMouseMove); + cancelAnimationFrame(rafId); + }; + }, []); + + const svgAsset = getCursorAsset(cursorType); + + return ( +
+ {/* + * Wrapper div is translated to the exact cursor position each frame. + * Starts 200px off-screen until the first mousemove fires. + * willChange:transform promotes this to its own compositor layer so + * rAF position updates avoid triggering main-thread layout. + */} +
+ {/* + * Cursor SVG rendered as an so it uses the same pixel-perfect + * Windows Aero cursor artwork as the editor's native-os preview. + * + * The image is offset by its hotspot so the active pixel of the cursor + * (tip of the arrow, fingertip of the hand, etc.) is exactly at the + * mouse position tracked by the mousemove listener above. + * + * Dimensions are scaled from the SVG's natural 32px design size to the + * logical cursor size derived from the recorded OS cursor bitmap + * (asset.width / asset.scaleFactor). + */} + +
+
+ ); +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 3b2bebfe2..55be106c0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, Languages } from "lucide-react"; +import { Check, ChevronDown, Clapperboard, Languages } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; @@ -318,29 +318,6 @@ export function LaunchWindow() { } }; - const openVideoFile = async () => { - const result = await window.electronAPI.openVideoFilePicker(); - - if (result.canceled) { - return; - } - - if (result.success && result.path) { - const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path); - if (!setVideoPathResult.success) { - console.error("Failed to set current video path:", setVideoPathResult); - return; - } - await window.electronAPI.switchToEditor(); - } - }; - - const openProjectFile = async () => { - const result = await nativeBridgeClient.project.loadProjectFile(); - if (result.canceled || !result.success) return; - await window.electronAPI.switchToEditor(); - }; - const sendHudOverlayHide = () => { if (window.electronAPI && window.electronAPI.hudOverlayHide) { window.electronAPI.hudOverlayHide(); @@ -412,7 +389,7 @@ export function LaunchWindow() { {(showMicControls || showWebcamControls) && (
{/* Mic selector */} {showMicControls && ( @@ -546,7 +523,7 @@ export function LaunchWindow() { {/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
{/* Drag handle */}
@@ -688,29 +665,15 @@ export function LaunchWindow() { )} {!recording && ( - <> - {/* Open video file */} - - - - - {/* Open project */} - - - - + + + )} {/* Right sidebar controls */} diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx new file mode 100644 index 000000000..511323abe --- /dev/null +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -0,0 +1,202 @@ +import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useScopedT } from "@/contexts/I18nContext"; +import { nativeBridgeClient } from "@/native"; + +interface EditorEmptyStateProps { + onVideoImported: (videoPath: string) => void; + /** Called with the loaded project data — handles both button click and drag-drop */ + onProjectOpened: (project: unknown, path: string | null) => void; +} + +type DropError = "unsupported-format" | "load-failed" | null; + +export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmptyStateProps) { + const te = useScopedT("editor"); + const tc = useScopedT("common"); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const [dropError, setDropError] = useState(null); + // Freeze the last non-null error type so dialog content doesn't snap to the + // else-branch during the closing animation (same pattern as UnsavedChangesDialog). + const lastDropErrorRef = useRef>("unsupported-format"); + if (dropError !== null) { + lastDropErrorRef.current = dropError; + } + + const handleImportVideo = useCallback(async () => { + const result = await window.electronAPI.openVideoFilePicker(); + if (result.canceled || !result.success || !result.path) return; + + const setResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path); + if (!setResult.success) return; + + onVideoImported(result.path); + }, [onVideoImported]); + + const handleLoadProject = useCallback(async () => { + const result = await nativeBridgeClient.project.loadProjectFile(); + if (result.canceled || !result.success || !result.project) return; + onProjectOpened(result.project, result.path ?? null); + }, [onProjectOpened]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer.items.length > 0) { + setIsDraggingOver(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDraggingOver(false); + } + }, []); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingOver(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + const projectFile = files.find((f) => f.name.endsWith(".openscreen")); + if (!projectFile) { + setDropError("unsupported-format"); + return; + } + + // Use Electron's webUtils.getPathForFile — File.path was removed in Electron 32+ + let filePath: string; + try { + filePath = window.electronAPI.getPathForFile(projectFile); + } catch { + setDropError("load-failed"); + return; + } + if (!filePath) { + setDropError("load-failed"); + return; + } + + let result: Awaited>; + try { + result = await window.electronAPI.loadProjectFileFromPath(filePath); + } catch { + setDropError("load-failed"); + return; + } + if (!result.success || !result.project) { + setDropError("load-failed"); + return; + } + + onProjectOpened(result.project, result.path ?? null); + }, + [onProjectOpened], + ); + + return ( +
+ {/* Drop overlay */} + {isDraggingOver && ( +
+ +

{te("emptyState.dropOverlay")}

+
+ )} + + {/* Drop error dialog */} + !open && setDropError(null)}> + + +
+ + + {lastDropErrorRef.current === "unsupported-format" + ? te("emptyState.dropErrors.unsupportedFormatTitle") + : te("emptyState.dropErrors.couldNotOpenTitle")} + +
+
+ +
+
+ +
+

+ {lastDropErrorRef.current === "unsupported-format" + ? te("emptyState.dropErrors.unsupportedFormatMessage") + : te("emptyState.dropErrors.couldNotOpenMessage")} +

+
+ + +
+
+ +
+ {/* Logo */} + + +
+

{te("emptyState.title")}

+

+ {te("emptyState.description")} +

+
+ + {/* Actions */} +
+ + +
+ +
+

{te("emptyState.supportedFormats")}

+
+ + {te("emptyState.dragDropHint")} +
+
+
+
+ ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index cae959c00..03b356382 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -303,6 +303,8 @@ interface SettingsPanelProps { onSaveDiagnostic?: () => Promise; showCursor?: boolean; onShowCursorChange?: (show: boolean) => void; + cursorDisplayMode?: "native-os" | "custom"; + onCursorDisplayModeChange?: (mode: "native-os" | "custom") => void; cursorSize?: number; onCursorSizeChange?: (size: number) => void; cursorSmoothing?: number; @@ -408,6 +410,8 @@ export function SettingsPanel({ onSaveDiagnostic, showCursor = true, onShowCursorChange, + cursorDisplayMode = "native-os", + onCursorDisplayModeChange, cursorSize = 3.0, onCursorSizeChange, cursorSmoothing = 0.67, @@ -1380,6 +1384,7 @@ export function SettingsPanel({ {activePanelMode === "cursor" && showCursorSettings && hasCursorData && (
+ {/* Show / hide cursor toggle */}
Show Cursor
- {showCursor && ( -
+ + {showCursor && showCursorSettings && ( + <> + {/* Cursor style dropdown — Native OS vs Custom (only when native data exists) */}
-
-
Size
- - {cursorSize.toFixed(1)} - +
+ Cursor Style
- onCursorSizeChange?.(values[0])} - min={0.5} - max={10} - step={0.1} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> +
-
-
-
- Smoothing + + {/* Custom cursor sliders — only when "custom" mode is active */} + {cursorDisplayMode === "custom" && ( +
+
+
+
+ Size +
+ + {cursorSize.toFixed(1)} + +
+ onCursorSizeChange?.(values[0])} + min={0.5} + max={10} + step={0.1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + />
- - {Math.round(cursorSmoothing * 100)}% - -
- onCursorSmoothingChange?.(values[0])} - min={0} - max={1} - step={0.01} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- Motion Blur +
+
+
+ Smoothing +
+ + {Math.round(cursorSmoothing * 100)}% + +
+ onCursorSmoothingChange?.(values[0])} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + />
- - {Math.round(cursorMotionBlur * 100)}% - -
- onCursorMotionBlurChange?.(values[0])} - min={0} - max={1} - step={0.01} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- Click Bounce +
+
+
+ Motion Blur +
+ + {Math.round(cursorMotionBlur * 100)}% + +
+ + onCursorMotionBlurChange?.(values[0]) + } + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ Click Bounce +
+ + {cursorClickBounce.toFixed(1)} + +
+ + onCursorClickBounceChange?.(values[0]) + } + min={0} + max={5} + step={0.1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + />
- - {cursorClickBounce.toFixed(1)} -
- onCursorClickBounceChange?.(values[0])} - min={0} - max={5} - step={0.1} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
+ )} + )}
)} diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index 902b1427b..f7423b620 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -10,6 +10,7 @@ import { useScopedT } from "@/contexts/I18nContext"; interface UnsavedChangesDialogProps { isOpen: boolean; + variant?: "close" | "newProject" | "loadProject"; onSaveAndClose: () => void; onDiscardAndClose: () => void; onCancel: () => void; @@ -17,6 +18,7 @@ interface UnsavedChangesDialogProps { export function UnsavedChangesDialog({ isOpen, + variant = "close", onSaveAndClose, onDiscardAndClose, onCancel, @@ -24,6 +26,25 @@ export function UnsavedChangesDialog({ const td = useScopedT("dialogs"); const tc = useScopedT("common"); + const detail = + variant === "newProject" + ? td("unsavedChanges.detailNewProject") + : variant === "loadProject" + ? td("unsavedChanges.detailLoadProject") + : td("unsavedChanges.detail"); + const saveLabel = + variant === "newProject" + ? td("unsavedChanges.saveAndNewProject") + : variant === "loadProject" + ? td("unsavedChanges.saveAndLoadProject") + : td("unsavedChanges.saveAndClose"); + const discardLabel = + variant === "newProject" + ? td("unsavedChanges.discardAndNewProject") + : variant === "loadProject" + ? td("unsavedChanges.discardAndLoadProject") + : td("unsavedChanges.discardAndClose"); + return ( !open && onCancel()}> @@ -42,9 +63,7 @@ export function UnsavedChangesDialog({

{td("unsavedChanges.message")}

- - {td("unsavedChanges.detail")} - + {detail}
+
-
- - {/* Top section: preview and contextual settings */} - -
-
-
- {/* Video preview */} -
-
- updateState({ webcamPosition: pos })} - onWebcamPositionDragEnd={commitState} - onDurationChange={setDuration} - onTimeUpdate={setCurrentTime} - currentTime={currentTime} - onPlayStateChange={setIsPlaying} - onError={setError} - wallpaper={wallpaper} - zoomRegions={zoomRegions} - selectedZoomId={selectedZoomId} - onSelectZoom={handleSelectZoom} - onZoomFocusChange={handleZoomFocusChange} - onZoomFocusDragEnd={commitState} - isPlaying={isPlaying} - showShadow={shadowIntensity > 0} - shadowIntensity={shadowIntensity} - showBlur={showBlur} - motionBlurAmount={motionBlurAmount} - borderRadius={borderRadius} - padding={padding} - cropRegion={cropRegion} - cursorRecordingData={cursorRecordingData} - trimRegions={trimRegions} - speedRegions={speedRegions} - annotationRegions={annotationOnlyRegions} - selectedAnnotationId={selectedAnnotationId} - onSelectAnnotation={handleSelectAnnotation} - onAnnotationPositionChange={handleAnnotationPositionChange} - onAnnotationSizeChange={handleAnnotationSizeChange} - blurRegions={blurRegions} - selectedBlurId={selectedBlurId} - onSelectBlur={handleSelectBlur} - onBlurPositionChange={handleAnnotationPositionChange} - onBlurSizeChange={handleAnnotationSizeChange} - onBlurDataChange={handleBlurDataPreviewChange} - onBlurDataCommit={commitState} - cursorTelemetry={cursorTelemetry} - cursorClickTimestamps={cursorClickTimestamps} - showCursor={effectiveShowCursor} - cursorSize={cursorSize} - cursorSmoothing={cursorSmoothing} - cursorMotionBlur={cursorMotionBlur} - cursorClickBounce={cursorClickBounce} - /> + {/* Empty state — shown when no video is loaded */} + {!videoPath && ( +
+ { + setVideoPath(toFileUrl(path)); + setVideoSourcePath(path); + setWebcamVideoPath(null); + setWebcamVideoSourcePath(null); + }} + onProjectOpened={async (project, path) => { + const restored = await applyLoadedProject(project, path); + if (!restored) { + toast.error(t("project.invalidFormat")); + } + }} + /> +
+ )} + + {videoPath && ( +
+ + {/* Top section: preview and contextual settings */} + +
+
+
+ {/* Video preview */} +
+
+ updateState({ webcamPosition: pos })} + onWebcamPositionDragEnd={commitState} + onDurationChange={setDuration} + onTimeUpdate={setCurrentTime} + currentTime={currentTime} + onPlayStateChange={setIsPlaying} + onError={setError} + wallpaper={wallpaper} + zoomRegions={zoomRegions} + selectedZoomId={selectedZoomId} + onSelectZoom={handleSelectZoom} + onZoomFocusChange={handleZoomFocusChange} + onZoomFocusDragEnd={commitState} + isPlaying={isPlaying} + showShadow={shadowIntensity > 0} + shadowIntensity={shadowIntensity} + showBlur={showBlur} + motionBlurAmount={motionBlurAmount} + borderRadius={borderRadius} + padding={padding} + cropRegion={cropRegion} + cursorRecordingData={cursorRecordingData} + trimRegions={trimRegions} + speedRegions={speedRegions} + annotationRegions={annotationOnlyRegions} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} + onAnnotationPositionChange={handleAnnotationPositionChange} + onAnnotationSizeChange={handleAnnotationSizeChange} + blurRegions={blurRegions} + selectedBlurId={selectedBlurId} + onSelectBlur={handleSelectBlur} + onBlurPositionChange={handleAnnotationPositionChange} + onBlurSizeChange={handleAnnotationSizeChange} + onBlurDataChange={handleBlurDataPreviewChange} + onBlurDataCommit={commitState} + cursorTelemetry={cursorTelemetry} + cursorClickTimestamps={cursorClickTimestamps} + showCursor={effectiveShowCursor} + cursorDisplayMode={cursorDisplayMode} + cursorSize={cursorSize} + cursorSmoothing={cursorSmoothing} + cursorMotionBlur={cursorMotionBlur} + cursorClickBounce={cursorClickBounce} + /> +
-
- {/* Playback controls */} -
-
- + {/* Playback controls */} +
+
+ +
-
-
- pushState({ wallpaper: w })} - selectedZoomDepth={ - selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null - } - onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} - selectedZoomCustomScale={ - selectedZoomId - ? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null) - : null - } - onZoomCustomScaleChange={handleZoomCustomScaleChange} - onZoomCustomScaleCommit={handleZoomCustomScaleCommit} - selectedZoomFocusMode={ - selectedZoomId - ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") - : null - } - onZoomFocusModeChange={(mode) => - selectedZoomId && handleZoomFocusModeChange(mode) - } - selectedZoomFocus={ - selectedZoomId - ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null) - : null - } - onZoomFocusCoordinateChange={(focus) => - selectedZoomId && handleZoomFocusChange(selectedZoomId, focus) - } - onZoomFocusCoordinateCommit={commitState} - hasCursorTelemetry={cursorTelemetry.length > 0} - selectedZoomId={selectedZoomId} +
+ pushState({ wallpaper: w })} + selectedZoomDepth={ + selectedZoomId + ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth + : null + } + onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} + selectedZoomCustomScale={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null) + : null + } + onZoomCustomScaleChange={handleZoomCustomScaleChange} + onZoomCustomScaleCommit={handleZoomCustomScaleCommit} + selectedZoomFocusMode={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") + : null + } + onZoomFocusModeChange={(mode) => + selectedZoomId && handleZoomFocusModeChange(mode) + } + selectedZoomFocus={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null) + : null + } + onZoomFocusCoordinateChange={(focus) => + selectedZoomId && handleZoomFocusChange(selectedZoomId, focus) + } + onZoomFocusCoordinateCommit={commitState} + hasCursorTelemetry={cursorTelemetry.length > 0} + selectedZoomId={selectedZoomId} + onZoomDelete={handleZoomDelete} + selectedZoomRotationPreset={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null) + : null + } + onZoomRotationPresetChange={handleZoomRotationPresetChange} + selectedTrimId={selectedTrimId} + onTrimDelete={handleTrimDelete} + shadowIntensity={shadowIntensity} + onShadowChange={(v) => updateState({ shadowIntensity: v })} + onShadowCommit={commitState} + showBlur={showBlur} + onBlurChange={(v) => pushState({ showBlur: v })} + motionBlurAmount={motionBlurAmount} + onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} + onMotionBlurCommit={commitState} + borderRadius={borderRadius} + onBorderRadiusChange={(v) => updateState({ borderRadius: v })} + onBorderRadiusCommit={commitState} + padding={padding} + onPaddingChange={(v) => updateState({ padding: v })} + onPaddingCommit={commitState} + cropRegion={cropRegion} + onCropChange={(r) => pushState({ cropRegion: r })} + aspectRatio={aspectRatio} + hasWebcam={Boolean(webcamVideoPath)} + webcamLayoutPreset={webcamLayoutPreset} + onWebcamLayoutPresetChange={(preset) => + pushState({ + webcamLayoutPreset: preset, + webcamPosition: preset === "picture-in-picture" ? webcamPosition : null, + }) + } + webcamMaskShape={webcamMaskShape} + onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} + webcamSizePreset={webcamSizePreset} + onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })} + onWebcamSizePresetCommit={commitState} + videoElement={videoPlaybackRef.current?.video || null} + exportQuality={exportQuality} + onExportQualityChange={setExportQuality} + exportFormat={exportFormat} + onExportFormatChange={setExportFormat} + gifFrameRate={gifFrameRate} + onGifFrameRateChange={setGifFrameRate} + gifLoop={gifLoop} + onGifLoopChange={setGifLoop} + gifSizePreset={gifSizePreset} + onGifSizePresetChange={setGifSizePreset} + gifOutputDimensions={calculateOutputDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + gifSizePreset, + GIF_SIZE_PRESETS, + aspectRatio === "native" + ? getNativeAspectRatioValue( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ) + : getAspectRatioValue(aspectRatio), + )} + onExport={handleOpenExportDialog} + selectedAnnotationId={selectedAnnotationId} + annotationRegions={annotationOnlyRegions} + onAnnotationContentChange={handleAnnotationContentChange} + onAnnotationTypeChange={handleAnnotationTypeChange} + onAnnotationStyleChange={handleAnnotationStyleChange} + onAnnotationFigureDataChange={handleAnnotationFigureDataChange} + onAnnotationDuplicate={handleAnnotationDuplicate} + onAnnotationDelete={handleAnnotationDelete} + selectedBlurId={selectedBlurId} + blurRegions={blurRegions} + onBlurDataChange={handleBlurDataPanelChange} + onBlurDataCommit={commitState} + onBlurDelete={handleAnnotationDelete} + selectedSpeedId={selectedSpeedId} + selectedSpeedValue={ + selectedSpeedId + ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) + : null + } + onSpeedChange={handleSpeedChange} + onSpeedDelete={handleSpeedDelete} + unsavedExport={unsavedExport} + onSaveUnsavedExport={handleSaveUnsavedExport} + onSaveDiagnostic={handleSaveDiagnostic} + showCursor={showCursor} + onShowCursorChange={setShowCursor} + cursorDisplayMode={cursorDisplayMode} + onCursorDisplayModeChange={setCursorDisplayMode} + cursorSize={cursorSize} + onCursorSizeChange={setCursorSize} + cursorSmoothing={cursorSmoothing} + onCursorSmoothingChange={setCursorSmoothing} + cursorMotionBlur={cursorMotionBlur} + onCursorMotionBlurChange={setCursorMotionBlur} + cursorClickBounce={cursorClickBounce} + onCursorClickBounceChange={setCursorClickBounce} + hasCursorData={ + cursorTelemetry.length > 0 || + hasNativeCursorRecordingData(cursorRecordingData) + } + showCursorSettings={showCursorSettings} + /> +
+
+ + + +
+
+ + {/* Full-width timeline */} + +
+ z.id === selectedZoomId)?.rotationPreset ?? null) - : null - } - onZoomRotationPresetChange={handleZoomRotationPresetChange} - selectedTrimId={selectedTrimId} + selectedZoomId={selectedZoomId} + onSelectZoom={handleSelectZoom} + trimRegions={trimRegions} + onTrimAdded={handleTrimAdded} + onTrimSpanChange={handleTrimSpanChange} onTrimDelete={handleTrimDelete} - shadowIntensity={shadowIntensity} - onShadowChange={(v) => updateState({ shadowIntensity: v })} - onShadowCommit={commitState} - showBlur={showBlur} - onBlurChange={(v) => pushState({ showBlur: v })} - motionBlurAmount={motionBlurAmount} - onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} - onMotionBlurCommit={commitState} - borderRadius={borderRadius} - onBorderRadiusChange={(v) => updateState({ borderRadius: v })} - onBorderRadiusCommit={commitState} - padding={padding} - onPaddingChange={(v) => updateState({ padding: v })} - onPaddingCommit={commitState} - cropRegion={cropRegion} - onCropChange={(r) => pushState({ cropRegion: r })} - aspectRatio={aspectRatio} - hasWebcam={Boolean(webcamVideoPath)} - webcamLayoutPreset={webcamLayoutPreset} - onWebcamLayoutPresetChange={(preset) => - pushState({ - webcamLayoutPreset: preset, - webcamPosition: preset === "picture-in-picture" ? webcamPosition : null, - }) - } - webcamMaskShape={webcamMaskShape} - onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} - webcamSizePreset={webcamSizePreset} - onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })} - onWebcamSizePresetCommit={commitState} - videoElement={videoPlaybackRef.current?.video || null} - exportQuality={exportQuality} - onExportQualityChange={setExportQuality} - exportFormat={exportFormat} - onExportFormatChange={setExportFormat} - gifFrameRate={gifFrameRate} - onGifFrameRateChange={setGifFrameRate} - gifLoop={gifLoop} - onGifLoopChange={setGifLoop} - gifSizePreset={gifSizePreset} - onGifSizePresetChange={setGifSizePreset} - gifOutputDimensions={calculateOutputDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - gifSizePreset, - GIF_SIZE_PRESETS, - aspectRatio === "native" - ? getNativeAspectRatioValue( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - cropRegion, - ) - : getAspectRatioValue(aspectRatio), - )} - onExport={handleOpenExportDialog} - selectedAnnotationId={selectedAnnotationId} + selectedTrimId={selectedTrimId} + onSelectTrim={handleSelectTrim} + speedRegions={speedRegions} + onSpeedAdded={handleSpeedAdded} + onSpeedSpanChange={handleSpeedSpanChange} + onSpeedDelete={handleSpeedDelete} + selectedSpeedId={selectedSpeedId} + onSelectSpeed={handleSelectSpeed} annotationRegions={annotationOnlyRegions} - onAnnotationContentChange={handleAnnotationContentChange} - onAnnotationTypeChange={handleAnnotationTypeChange} - onAnnotationStyleChange={handleAnnotationStyleChange} - onAnnotationFigureDataChange={handleAnnotationFigureDataChange} - onAnnotationDuplicate={handleAnnotationDuplicate} + onAnnotationAdded={handleAnnotationAdded} + onAnnotationSpanChange={handleAnnotationSpanChange} onAnnotationDelete={handleAnnotationDelete} - selectedBlurId={selectedBlurId} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} blurRegions={blurRegions} - onBlurDataChange={handleBlurDataPanelChange} - onBlurDataCommit={commitState} + onBlurAdded={handleBlurAdded} + onBlurSpanChange={handleAnnotationSpanChange} onBlurDelete={handleAnnotationDelete} - selectedSpeedId={selectedSpeedId} - selectedSpeedValue={ - selectedSpeedId - ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) - : null - } - onSpeedChange={handleSpeedChange} - onSpeedDelete={handleSpeedDelete} - unsavedExport={unsavedExport} - onSaveUnsavedExport={handleSaveUnsavedExport} - onSaveDiagnostic={handleSaveDiagnostic} - showCursor={showCursor} - onShowCursorChange={setShowCursor} - cursorSize={cursorSize} - onCursorSizeChange={setCursorSize} - cursorSmoothing={cursorSmoothing} - onCursorSmoothingChange={setCursorSmoothing} - cursorMotionBlur={cursorMotionBlur} - onCursorMotionBlurChange={setCursorMotionBlur} - cursorClickBounce={cursorClickBounce} - onCursorClickBounceChange={setCursorClickBounce} - hasCursorData={ - cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData) + selectedBlurId={selectedBlurId} + onSelectBlur={handleSelectBlur} + aspectRatio={aspectRatio} + onAspectRatioChange={(ar) => + pushState({ + aspectRatio: ar, + webcamLayoutPreset: + (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || + (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") + ? "picture-in-picture" + : webcamLayoutPreset, + }) } - showCursorSettings={showCursorSettings} />
-
-
- - -
-
- - {/* Full-width timeline */} - -
- - pushState({ - aspectRatio: ar, - webcamLayoutPreset: - (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || - (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") - ? "picture-in-picture" - : webcamLayoutPreset, - }) - } - /> -
-
-
-
+ + +
+ )} setConfirmDialogVariant(null) + : handleCloseConfirmCancel + } />
); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 8b53ff9a1..68d9d1111 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -14,6 +14,7 @@ import { useCallback, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -28,9 +29,6 @@ import { import { createNativeCursorMotionBlurState, createNativeCursorSmoothingState, - getNativeCursorClickBounceProgress, - getNativeCursorClickBounceScale, - getNativeCursorMotionBlurPx, hasNativeCursorRecordingData, projectNativeCursorToLocal, projectNativeCursorToStage, @@ -59,13 +57,13 @@ import { DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, DEFAULT_ROTATION_3D, + getZoomScale, isRotation3DIdentity, lerpRotation3D, rotation3DPerspective, type SpeedRegion, type TrimRegion, ZOOM_DEPTH_SCALES, - type ZoomDepth, type ZoomFocus, type ZoomRegion, } from "./types"; @@ -83,7 +81,7 @@ import { PixiCursorOverlay, preloadCursorAssets, } from "./videoPlayback/cursorRenderer"; -import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; +import { clampFocusToScale } from "./videoPlayback/focusUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { clamp01 } from "./videoPlayback/mathUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; @@ -148,6 +146,10 @@ interface VideoPlaybackProps { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + /** Whether to render native OS cursor bitmaps ("native-os") or the custom + * stylised SVG cursor ("custom"). Defaults to "native-os" when native + * recording data is present. */ + cursorDisplayMode?: "native-os" | "custom"; } export interface VideoPlaybackRef { @@ -248,6 +250,7 @@ const VideoPlayback = forwardRef( cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, + cursorDisplayMode = "native-os", }, ref, ) => { @@ -326,6 +329,7 @@ const VideoPlayback = forwardRef( const lastResolvedDurationRef = useRef(null); const isResolvingDurationRef = useRef(false); const hasNativeCursorRecordingRef = useRef(false); + const cursorDisplayModeRef = useRef<"native-os" | "custom">(cursorDisplayMode); const cursorRecordingDataRef = useRef(cursorRecordingData); const cropRegionRef = useRef(cropRegion); const nativeCursorSpriteRef = useRef(null); @@ -444,8 +448,19 @@ const VideoPlayback = forwardRef( [onDurationChange, syncResolvedDuration], ); - const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { - return clampFocusToStageUtil(focus, depth, stageSizeRef.current); + // IMPORTANT: must use clampFocusToScale(focus, getZoomScale(region)) here, + // NOT clampFocusToStage(focus, region.depth). + // + // region.depth is the preset slot (1×/2×/4×) and ignores customScale entirely. + // getZoomScale(region) returns customScale when set, falling back to the preset + // depth scale — so drag-to-reposition respects the actual zoom level the user + // configured, not the preset bucket it sits in. + // + // This was previously broken (invisible drag boundaries near canvas edges) and + // has been fixed twice. If you're refactoring this drag handler, keep this call + // as clampFocusForRegion(focus, region) — do not switch it back to region.depth. + const clampFocusForRegion = useCallback((focus: ZoomFocus, region: ZoomRegion) => { + return clampFocusToScale(focus, getZoomScale(region)); }, []); const updateOverlayForRegion = useCallback( @@ -628,7 +643,7 @@ const VideoPlayback = forwardRef( cx: clamp01(localX / stageWidth), cy: clamp01(localY / stageHeight), }; - const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); + const clampedFocus = clampFocusForRegion(unclampedFocus, region); onZoomFocusChange(region.id, clampedFocus); updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); @@ -760,10 +775,26 @@ const VideoPlayback = forwardRef( showCursorRef.current = showCursor; }, [showCursor]); - useEffect(() => { + // useLayoutEffect (not useEffect) so the ref is updated synchronously + // before the browser paints. The PIXI ticker runs in requestAnimationFrame + // which fires just before paint; if these refs were updated in useEffect + // (which fires AFTER paint) the ticker would read stale values for one + // frame, causing the PIXI cursor to bleed through for a single frame when + // switching to "native-os" mode. + useLayoutEffect(() => { hasNativeCursorRecordingRef.current = hasNativeCursorRecording; }, [hasNativeCursorRecording]); + useLayoutEffect(() => { + cursorDisplayModeRef.current = cursorDisplayMode; + // When switching away from the custom PIXI cursor, explicitly reset the + // overlay so any residual sprite state (position, visibility) is cleared + // and can't bleed through on the next render tick. + if (cursorDisplayMode !== "custom") { + cursorOverlayRef.current?.reset(); + } + }, [cursorDisplayMode]); + useEffect(() => { cursorRecordingDataRef.current = cursorRecordingData; resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current); @@ -1380,7 +1411,8 @@ const VideoPlayback = forwardRef( cursorTelemetryRef.current, timeMs, baseMaskRef.current, - showCursorRef.current && !hasNativeCursorRecordingRef.current, + showCursorRef.current && + (!hasNativeCursorRecordingRef.current || cursorDisplayModeRef.current === "custom"), !isPlayingRef.current || isSeekingRef.current, ); } @@ -1400,17 +1432,27 @@ const VideoPlayback = forwardRef( resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current); }; if (nativeCursorImage) { - if (hasNativeCursorRecordingRef.current && showCursorRef.current) { + if ( + hasNativeCursorRecordingRef.current && + showCursorRef.current && + cursorDisplayModeRef.current !== "custom" + ) { const timeMs = currentTimeRef.current; // already in ms const frame = resolveInterpolatedNativeCursorFrame( cursorRecordingDataRef.current, timeMs, ); - if (frame) { + // Skip rendering when the recording captured a moment where the + // foreground app had hidden the OS cursor (Figma, Photoshop, games). + // The app's own drawn cursor is baked into the video, so adding our SVG + // on top would produce a duplicate. + if (frame && !frame.sample.osCursorHidden) { + // Native-OS cursor: no smoothing — behave like a real OS cursor + // (crisp, zero-latency, no interpolation). const displaySample = smoothNativeCursorSample({ - forceSnap: !isPlayingRef.current || isSeekingRef.current, + forceSnap: true, sample: frame.sample, - smoothing: cursorSmoothingRef.current, + smoothing: 0, state: nativeCursorSmoothingStateRef.current, timeMs, }); @@ -1441,47 +1483,64 @@ const VideoPlayback = forwardRef( window.devicePixelRatio || 1, displaySample, ); - const bounceProgress = getNativeCursorClickBounceProgress( - cursorRecordingDataRef.current, - timeMs, - ); - const scale = - Math.max(0, cursorSizeRef.current) * - getNativeCursorClickBounceScale(cursorClickBounceRef.current, bounceProgress); - const transformedScale = scale * Math.abs(cameraContainer?.scale.x || 1); - const blurPx = - !isPlayingRef.current || isSeekingRef.current - ? 0 - : getNativeCursorMotionBlurPx({ - motionBlur: cursorMotionBlurRef.current, - point: projectedStagePoint, - state: nativeCursorMotionBlurStateRef.current, - timeMs, - }); + // Native-OS cursor: no click bounce, no motion blur. Size must match + // what the user saw during recording, taking into account two things + // the editor scales the video by but the cursor needs to follow: + // 1) maskRect.width / videoSize.width — how much the preview shrinks + // the source video to fit the editor panel. + // 2) asset.scaleFactor — the source display's DPI (a 32-CSS-px cursor + // on a 1.5× display was captured as 48 physical-px in the video). + // Without both factors the cursor renders at its SVG natural size + // (32 CSS px), which is roughly 2× too big relative to the displayed + // video on a HiDPI source. Then camera zoom is applied on top. + const sourceScaleFactor = frame.asset.scaleFactor ?? 1; + const maskRect = baseMaskRef.current; + const videoNativeWidth = videoSizeRef.current.width || 1; + const videoDisplayScale = + maskRect.width > 0 ? (maskRect.width * sourceScaleFactor) / videoNativeWidth : 1; + const transformedScale = + videoDisplayScale * Math.abs(cameraContainer?.scale.x || 1); + const blurPx = 0; + // Anti-flicker: only mutate src + intrinsic width/height when the + // cursor TYPE changes. Every frame we only update transform — + // which is a GPU-compositor operation with zero layout cost. + // (The custom-cursor PIXI overlay achieves the same effect by + // pre-allocating one sprite per type and toggling visibility.) + // Previously we re-set width/height every frame, which forced + // a DOM layout pass on each rAF tick and caused the cursor to + // blink during normal playback. if (nativeCursorImageIdRef.current !== renderAsset.id) { nativeCursorImage.src = renderAsset.imageDataUrl; + nativeCursorImage.style.width = `${renderAsset.width}px`; + nativeCursorImage.style.height = `${renderAsset.height}px`; nativeCursorImageIdRef.current = renderAsset.id; } nativeCursorImage.style.display = "block"; - nativeCursorImage.style.width = `${renderAsset.width * transformedScale}px`; - nativeCursorImage.style.height = `${renderAsset.height * transformedScale}px`; nativeCursorImage.style.filter = blurPx > 0 ? `blur(${blurPx.toFixed(2)}px)` : "none"; + // Order matters: translate is applied AFTER scale (CSS right-to- + // left), so scale is around transformOrigin (0,0) and translate + // then places the scaled top-left at (X, Y). Hotspot offset is + // already pre-scaled in X, Y. nativeCursorImage.style.transform = `translate3d(${ projectedStagePoint.x - renderAsset.hotspotX * transformedScale - }px, ${projectedStagePoint.y - renderAsset.hotspotY * transformedScale}px, 0)`; + }px, ${ + projectedStagePoint.y - renderAsset.hotspotY * transformedScale + }px, 0) scale(${transformedScale})`; if (nativeCursorSprite) { nativeCursorSprite.visible = false; if (nativeCursorTextureIdRef.current !== renderAsset.id) { nativeCursorSprite.texture = Texture.from(renderAsset.imageDataUrl); nativeCursorTextureIdRef.current = renderAsset.id; } + // Sprite lives inside cameraContainer so it gets camera zoom for free — + // only apply the videoDisplayScale here. nativeCursorSprite.position.set( - projectedLocalPoint.x - renderAsset.hotspotX * scale, - projectedLocalPoint.y - renderAsset.hotspotY * scale, + projectedLocalPoint.x - renderAsset.hotspotX * videoDisplayScale, + projectedLocalPoint.y - renderAsset.hotspotY * videoDisplayScale, ); - nativeCursorSprite.width = renderAsset.width * scale; - nativeCursorSprite.height = renderAsset.height * scale; + nativeCursorSprite.width = renderAsset.width * videoDisplayScale; + nativeCursorSprite.height = renderAsset.height * videoDisplayScale; } } else { hideNativeCursorPreview(); @@ -1721,6 +1780,12 @@ const VideoPlayback = forwardRef( ref={containerRef} className="absolute inset-0" style={{ + // Explicit z-index anchors the PIXI WebGL canvas at a known + // GPU compositing-layer level (10) so the native cursor + // at z-index 18 is definitively above it. Without an explicit + // value, Chromium/Electron may promote the WebGL canvas above + // sibling elements regardless of DOM order. + zIndex: 10, filter: showShadow && shadowIntensity > 0 ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` @@ -1736,7 +1801,11 @@ const VideoPlayback = forwardRef( display: "none", pointerEvents: "none", transformOrigin: "0 0", + // Above PIXI canvas layer (z-index 10) but below webcam PiP (20). zIndex: 18, + // Force a dedicated GPU compositing layer so Chromium orders + // this element correctly above the WebGL canvas. + willChange: "transform", }} /> {webcamVideoPath && diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index bd410dae9..c131a3e77 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -130,6 +130,11 @@ export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) { dirtyRef.current = false; }, []); + const resetState = useCallback((newInitial: EditorState = INITIAL_EDITOR_STATE) => { + setHistory({ past: [], present: newInitial, future: [] }); + dirtyRef.current = false; + }, []); + return { state: history.present, pushState, @@ -137,6 +142,7 @@ export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) { commitState, undo, redo, + resetState, canUndo: history.past.length > 0, canRedo: history.future.length > 0, }; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 45aa7b3b5..80ea532b7 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -596,15 +596,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const availability = await window.electronAPI.isNativeWindowsCaptureAvailable(); if (!availability.success || !availability.available) { - if (availability.reason === "unsupported-os") { + // Fall back to the web MediaRecorder path when the native helper + // binary isn't installed (dev mode) or the OS isn't supported. + if (availability.reason === "unsupported-os" || availability.reason === "missing-helper") { return false; } - throw new Error( - availability.reason === "missing-helper" - ? "Native Windows capture helper is not available." - : (availability.error ?? "Native Windows capture is not available."), - ); + throw new Error(availability.error ?? "Native Windows capture is not available."); } if (!isCountdownRunActive(countdownRunToken)) { @@ -769,58 +767,48 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } let screenMediaStream: MediaStream; - const platform = await window.electronAPI.getPlatform(); - if (platform === "win32") { - // getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the - // pre-selected source. Editable cursor mode excludes the system cursor so - // the editor can render a replacement; system mode bakes it into the video. - screenMediaStream = await navigator.mediaDevices.getDisplayMedia({ - video: { - cursor: cursorCaptureMode === "editable-overlay" ? "never" : "always", - width: { max: TARGET_WIDTH }, - height: { max: TARGET_HEIGHT }, - frameRate: { ideal: TARGET_FRAME_RATE }, - } as MediaTrackConstraints, - audio: systemAudioEnabled, - } as DisplayMediaStreamOptions); - } else { - const videoConstraints = { - mandatory: { - chromeMediaSource: CHROME_MEDIA_SOURCE, - chromeMediaSourceId: selectedSource.id, - maxWidth: TARGET_WIDTH, - maxHeight: TARGET_HEIGHT, - maxFrameRate: TARGET_FRAME_RATE, - minFrameRate: MIN_FRAME_RATE, - }, - }; - - if (systemAudioEnabled) { - try { - screenMediaStream = await navigator.mediaDevices.getUserMedia({ - audio: { - mandatory: { - chromeMediaSource: CHROME_MEDIA_SOURCE, - chromeMediaSourceId: selectedSource.id, - }, + // Use getUserMedia with chromeMediaSource on all platforms — this + // supplies the pre-selected source directly and works reliably on + // both macOS and Windows. The previous Windows-specific getDisplayMedia + // path required setDisplayMediaRequestHandler to be wired up in the + // main process (it never was), causing "Not supported" errors. + const videoConstraints = { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: selectedSource.id, + maxWidth: TARGET_WIDTH, + maxHeight: TARGET_HEIGHT, + maxFrameRate: TARGET_FRAME_RATE, + minFrameRate: MIN_FRAME_RATE, + }, + }; + + if (systemAudioEnabled) { + try { + screenMediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: selectedSource.id, }, - video: videoConstraints, - } as unknown as MediaStreamConstraints); - } catch (audioErr) { - console.warn("System audio capture failed, falling back to video-only:", audioErr); - toast.error(t("recording.systemAudioUnavailable")); - screenMediaStream = await navigator.mediaDevices.getUserMedia({ - audio: false, - video: videoConstraints, - } as unknown as MediaStreamConstraints); - } - } else { + }, + video: videoConstraints, + } as unknown as MediaStreamConstraints); + } catch (audioErr) { + console.warn("System audio capture failed, falling back to video-only:", audioErr); + toast.error(t("recording.systemAudioUnavailable")); + setSystemAudioEnabled(false); screenMediaStream = await navigator.mediaDevices.getUserMedia({ audio: false, video: videoConstraints, } as unknown as MediaStreamConstraints); } + } else { + screenMediaStream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: videoConstraints, + } as unknown as MediaStreamConstraints); } screenStream.current = screenMediaStream; diff --git a/src/i18n/locales/ar/dialogs.json b/src/i18n/locales/ar/dialogs.json index 2263f600b..568c542cf 100644 --- a/src/i18n/locales/ar/dialogs.json +++ b/src/i18n/locales/ar/dialogs.json @@ -51,6 +51,12 @@ "detail": "هل تريد حفظ مشروعك قبل الإغلاق؟", "saveAndClose": "حفظ وإغلاق", "discardAndClose": "تجاهل وإغلاق", + "detailNewProject": "هل تريد حفظ مشروعك قبل إنشاء مشروع جديد؟", + "saveAndNewProject": "حفظ وإنشاء مشروع جديد", + "discardAndNewProject": "تجاهل وإنشاء مشروع جديد", + "detailLoadProject": "هل تريد حفظ مشروعك قبل تحميل مشروع آخر؟", + "saveAndLoadProject": "حفظ وتحميل مشروع", + "discardAndLoadProject": "تجاهل وتحميل مشروع", "loadProject": "تحميل مشروع...", "saveProject": "حفظ المشروع...", "saveProjectAs": "حفظ المشروع باسم..." diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index a246f011a..4bd93ed6f 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -6,6 +6,7 @@ "confirm": "تأكيد" }, "loadingVideo": "جاري تحميل الفيديو...", + "loadingEditor": "جارٍ تحميل المحرر...", "errors": { "noVideoLoaded": "لم يتم تحميل أي فيديو", "videoNotReady": "الفيديو غير جاهز", @@ -41,5 +42,20 @@ "cameraDisconnected": "تم فصل كاميرا الويب.", "cameraNotFound": "لم يتم العثور على كاميرا.", "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة." + }, + "emptyState": { + "title": "لا يوجد مشروع مفتوح", + "description": "استورد مقطع فيديو للبدء في التحرير، أو حمّل مشروع OpenScreen موجود.", + "importVideoButton": "استيراد ملف فيديو...", + "loadProjectButton": "تحميل مشروع...", + "supportedFormats": "الصيغ المدعومة: MP4، MOV، WebM، MKV، AVI، M4V، WMV", + "dragDropHint": "أو اسحب وأفلت ملف مشروع .openscreen هنا", + "dropOverlay": "أفلت ملف المشروع لفتحه", + "dropErrors": { + "unsupportedFormatTitle": "تنسيق غير مدعوم", + "unsupportedFormatMessage": "يمكن إسقاط ملفات مشروع .openscreen فقط هنا. لاستيراد مقطع فيديو، استخدم زر \"استيراد ملف فيديو...\" بدلاً من ذلك.", + "couldNotOpenTitle": "تعذّر فتح الملف", + "couldNotOpenMessage": "تعذّر فتح ملف المشروع. ربما تم نقل الفيديو المرجعي أو حذفه." + } } } diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 47fdca499..36b96e7b8 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -81,7 +81,8 @@ }, "project": { "save": "حفظ المشروع", - "load": "تحميل المشروع" + "load": "تحميل المشروع", + "new": "مشروع جديد" }, "export": { "videoButton": "تصدير الفيديو", diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index a84b5fda8..f4d8d4e59 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -52,6 +52,14 @@ "detail": "Do you want to save your project before closing?", "saveAndClose": "Save & Close", "discardAndClose": "Discard & Close", + "detailNewProject": "Do you want to save your project before creating a new one?", + "saveAndNewProject": "Save & New Project", + "discardAndNewProject": "Discard & New Project", + "detailLoadProject": "Do you want to save your project before loading another one?", + "saveAndLoadProject": "Save & Load Project", + "discardAndLoadProject": "Discard & Load Project", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Load Project…", "saveProject": "Save Project…", "saveProjectAs": "Save Project As…" diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 13e2e1397..6fcf3b6fb 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -6,6 +6,7 @@ "confirm": "Confirm" }, "loadingVideo": "Loading video...", + "loadingEditor": "Loading editor...", "errors": { "noVideoLoaded": "No video loaded", "videoNotReady": "Video not ready", @@ -41,5 +42,20 @@ "cameraDisconnected": "Webcam disconnected.", "cameraNotFound": "Camera not found.", "permissionDenied": "Recording permission denied. Please allow screen recording." + }, + "emptyState": { + "title": "No project open", + "description": "Import a video to start editing, or load an existing OpenScreen project.", + "importVideoButton": "Import Video File…", + "loadProjectButton": "Load Project…", + "supportedFormats": "Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "or drag & drop a .openscreen project file here", + "dropOverlay": "Drop project file to open", + "dropErrors": { + "unsupportedFormatTitle": "Unsupported Format", + "unsupportedFormatMessage": "Only .openscreen project files can be dropped here. To import a video file, use the \"Import Video File…\" button on this screen.", + "couldNotOpenTitle": "Could Not Open File", + "couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted." + } } } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 133a96127..044204071 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -7,7 +7,8 @@ "pauseRecording": "Pause recording", "resumeRecording": "Resume recording", "openVideoFile": "Open video file", - "openProject": "Open project" + "openProject": "Open project", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "Enable system audio", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9d4173611..0e5cd72b3 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -97,7 +97,8 @@ }, "project": { "save": "Save Project", - "load": "Load Project" + "load": "Load Project", + "new": "New Project" }, "export": { "videoButton": "Export Video", diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index f8a5e63ff..0b9090f14 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -51,6 +51,12 @@ "detail": "¿Deseas guardar tu proyecto antes de cerrar?", "saveAndClose": "Guardar y cerrar", "discardAndClose": "Descartar y cerrar", + "detailNewProject": "¿Deseas guardar tu proyecto antes de crear uno nuevo?", + "saveAndNewProject": "Guardar y nuevo proyecto", + "discardAndNewProject": "Descartar y nuevo proyecto", + "detailLoadProject": "¿Deseas guardar tu proyecto antes de cargar otro?", + "saveAndLoadProject": "Guardar y cargar proyecto", + "discardAndLoadProject": "Descartar y cargar proyecto", "loadProject": "Cargar proyecto…", "saveProject": "Guardar proyecto…", "saveProjectAs": "Guardar proyecto como…" diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 8f6ad13e7..bdb012559 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -36,10 +36,26 @@ "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla." }, "loadingVideo": "Cargando video...", + "loadingEditor": "Cargando editor...", "newRecording": { "title": "Volver a la grabadora", "description": "Tu sesión actual ha sido guardada.", "cancel": "Cancelar", "confirm": "Confirmar" + }, + "emptyState": { + "title": "No hay proyecto abierto", + "description": "Importa un video para empezar a editar o carga un proyecto de OpenScreen existente.", + "importVideoButton": "Importar archivo de video…", + "loadProjectButton": "Cargar proyecto…", + "supportedFormats": "Formatos compatibles: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "o arrastra y suelta un archivo .openscreen aquí", + "dropOverlay": "Suelta el archivo de proyecto para abrirlo", + "dropErrors": { + "unsupportedFormatTitle": "Formato no compatible", + "unsupportedFormatMessage": "Solo se pueden soltar aquí archivos de proyecto .openscreen. Para importar un video, usa el botón \"Importar archivo de video...\" en su lugar.", + "couldNotOpenTitle": "No se pudo abrir el archivo", + "couldNotOpenMessage": "No se pudo abrir el archivo de proyecto. El video al que hace referencia puede haber sido movido o eliminado." + } } } diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 20a9ec39d..496d45746 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "Guardar proyecto", - "load": "Cargar proyecto" + "load": "Cargar proyecto", + "new": "Nuevo proyecto" }, "export": { "videoButton": "Exportar video", diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json index dbaae385f..e67e18637 100644 --- a/src/i18n/locales/fr/dialogs.json +++ b/src/i18n/locales/fr/dialogs.json @@ -51,6 +51,12 @@ "detail": "Voulez-vous enregistrer votre projet avant de fermer ?", "saveAndClose": "Enregistrer et fermer", "discardAndClose": "Ignorer et fermer", + "detailNewProject": "Voulez-vous enregistrer votre projet avant d'en créer un nouveau ?", + "saveAndNewProject": "Enregistrer et nouveau projet", + "discardAndNewProject": "Ignorer et nouveau projet", + "detailLoadProject": "Voulez-vous enregistrer votre projet avant d'en charger un autre ?", + "saveAndLoadProject": "Enregistrer et charger un projet", + "discardAndLoadProject": "Ignorer et charger un projet", "loadProject": "Charger un projet…", "saveProject": "Enregistrer le projet…", "saveProjectAs": "Enregistrer le projet sous…" diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 6380c6b3d..bf5c8a4eb 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -41,5 +41,21 @@ "cameraNotFound": "Caméra introuvable.", "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran." }, - "loadingVideo": "Chargement de la vidéo..." + "loadingVideo": "Chargement de la vidéo...", + "loadingEditor": "Chargement de l'éditeur...", + "emptyState": { + "title": "Aucun projet ouvert", + "description": "Importez une vidéo pour commencer à éditer, ou chargez un projet OpenScreen existant.", + "importVideoButton": "Importer un fichier vidéo…", + "loadProjectButton": "Charger un projet…", + "supportedFormats": "Formats pris en charge : MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "ou glissez-déposez un fichier .openscreen ici", + "dropOverlay": "Déposez le fichier de projet pour l'ouvrir", + "dropErrors": { + "unsupportedFormatTitle": "Format non pris en charge", + "unsupportedFormatMessage": "Seuls les fichiers .openscreen peuvent être déposés ici. Pour importer une vidéo, utilisez plutôt le bouton \"Importer un fichier vidéo...\".", + "couldNotOpenTitle": "Impossible d'ouvrir le fichier", + "couldNotOpenMessage": "Le fichier de projet n'a pas pu être ouvert. La vidéo qu'il référence a peut-être été déplacée ou supprimée." + } + } } diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 66a6d74c0..143a5c63b 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -95,7 +95,8 @@ }, "project": { "save": "Enregistrer le projet", - "load": "Charger un projet" + "load": "Charger un projet", + "new": "Nouveau projet" }, "export": { "videoButton": "Exporter la vidéo", diff --git a/src/i18n/locales/ja-JP/dialogs.json b/src/i18n/locales/ja-JP/dialogs.json index a59cde7ce..e523ce4d9 100644 --- a/src/i18n/locales/ja-JP/dialogs.json +++ b/src/i18n/locales/ja-JP/dialogs.json @@ -52,6 +52,12 @@ "detail": "閉じる前にプロジェクトを保存しますか?", "saveAndClose": "保存して閉じる", "discardAndClose": "破棄して閉じる", + "detailNewProject": "新しいプロジェクトを作成する前に保存しますか?", + "saveAndNewProject": "保存して新規プロジェクト", + "discardAndNewProject": "破棄して新規プロジェクト", + "detailLoadProject": "別のプロジェクトを読み込む前にプロジェクトを保存しますか?", + "saveAndLoadProject": "保存してプロジェクトを読み込む", + "discardAndLoadProject": "破棄してプロジェクトを読み込む", "loadProject": "プロジェクトを読み込む…", "saveProject": "プロジェクトを保存…", "saveProjectAs": "プロジェクトを名前を付けて保存…" diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 051335f30..799a82693 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -6,6 +6,7 @@ "confirm": "確認" }, "loadingVideo": "動画を読み込み中...", + "loadingEditor": "エディターを読み込み中...", "errors": { "noVideoLoaded": "動画が読み込まれていません", "videoNotReady": "動画の準備ができていません", @@ -41,5 +42,20 @@ "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", "cameraDisconnected": "ウェブカメラが切断されました。", "cameraNotFound": "カメラが見つかりません。" + }, + "emptyState": { + "title": "プロジェクトが開かれていません", + "description": "動画をインポートして編集を開始するか、既存の OpenScreen プロジェクトを読み込んでください。", + "importVideoButton": "動画ファイルをインポート…", + "loadProjectButton": "プロジェクトを読み込む…", + "supportedFormats": "対応フォーマット:MP4、MOV、WebM、MKV、AVI、M4V、WMV", + "dragDropHint": ".openscreen プロジェクトファイルをここにドラッグ&ドロップ", + "dropOverlay": "プロジェクトファイルをドロップして開く", + "dropErrors": { + "unsupportedFormatTitle": "非対応フォーマット", + "unsupportedFormatMessage": "ここにドロップできるのは .openscreen プロジェクトファイルのみです。動画をインポートするには「動画ファイルをインポート...」ボタンをご使用ください。", + "couldNotOpenTitle": "ファイルを開けませんでした", + "couldNotOpenMessage": "プロジェクトファイルを開けませんでした。参照している動画が移動または削除された可能性があります。" + } } } diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 1ccc3815f..2a6f9cbfd 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "プロジェクトを保存", - "load": "プロジェクトを読み込む" + "load": "プロジェクトを読み込む", + "new": "新規プロジェクト" }, "export": { "videoButton": "動画をエクスポート", diff --git a/src/i18n/locales/ko-KR/dialogs.json b/src/i18n/locales/ko-KR/dialogs.json index 3093cdfd2..6bff11dc9 100644 --- a/src/i18n/locales/ko-KR/dialogs.json +++ b/src/i18n/locales/ko-KR/dialogs.json @@ -51,6 +51,12 @@ "detail": "닫기 전에 프로젝트를 저장하시겠습니까?", "saveAndClose": "저장 후 닫기", "discardAndClose": "저장하지 않고 닫기", + "detailNewProject": "새 프로젝트를 만들기 전에 저장하시겠습니까?", + "saveAndNewProject": "저장 후 새 프로젝트", + "discardAndNewProject": "저장하지 않고 새 프로젝트", + "detailLoadProject": "다른 프로젝트를 불러오기 전에 저장하시겠습니까?", + "saveAndLoadProject": "저장 후 프로젝트 불러오기", + "discardAndLoadProject": "저장하지 않고 프로젝트 불러오기", "loadProject": "프로젝트 불러오기...", "saveProject": "프로젝트 저장...", "saveProjectAs": "다른 이름으로 프로젝트 저장..." diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index ce1624476..7d85e5b5a 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -6,6 +6,7 @@ "confirm": "확인" }, "loadingVideo": "비디오 로드 중...", + "loadingEditor": "편집기 로드 중...", "errors": { "noVideoLoaded": "불러온 비디오가 없습니다", "videoNotReady": "비디오가 준비되지 않았습니다", @@ -41,5 +42,20 @@ "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.", "cameraDisconnected": "웹캠 연결이 끊어졌습니다.", "cameraNotFound": "카메라를 찾을 수 없습니다." + }, + "emptyState": { + "title": "열린 프로젝트 없음", + "description": "동영상을 가져와 편집을 시작하거나 기존 OpenScreen 프로젝트를 불러오세요.", + "importVideoButton": "동영상 파일 가져오기…", + "loadProjectButton": "프로젝트 불러오기…", + "supportedFormats": "지원 형식: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": ".openscreen 프로젝트 파일을 여기에 드래그 앤 드롭", + "dropOverlay": "프로젝트 파일을 드롭하여 열기", + "dropErrors": { + "unsupportedFormatTitle": "지원되지 않는 형식", + "unsupportedFormatMessage": ".openscreen 프로젝트 파일만 여기에 드롭할 수 있습니다. 동영상을 가져오려면 \"동영상 파일 가져오기...\" 버튼을 사용하세요.", + "couldNotOpenTitle": "파일을 열 수 없음", + "couldNotOpenMessage": "프로젝트 파일을 열 수 없습니다. 참조된 동영상이 이동되었거나 삭제되었을 수 있습니다." + } } } diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 268ba57ca..7f497cab7 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "프로젝트 저장", - "load": "프로젝트 불러오기" + "load": "프로젝트 불러오기", + "new": "새 프로젝트" }, "export": { "videoButton": "비디오 내보내기", diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index 40b4113ab..8d1f0aa2a 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -51,6 +51,12 @@ "detail": "Хотите сохранить проект перед закрытием?", "saveAndClose": "Сохранить и закрыть", "discardAndClose": "Отменить и закрыть", + "detailNewProject": "Хотите сохранить проект перед созданием нового?", + "saveAndNewProject": "Сохранить и новый проект", + "discardAndNewProject": "Отменить и новый проект", + "detailLoadProject": "Хотите сохранить проект перед загрузкой другого?", + "saveAndLoadProject": "Сохранить и загрузить проект", + "discardAndLoadProject": "Отменить и загрузить проект", "loadProject": "Загрузить проект…", "saveProject": "Сохранить проект…", "saveProjectAs": "Сохранить проект как…" diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index c5616d2f6..3e25f6995 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -6,6 +6,7 @@ "confirm": "Подтвердить" }, "loadingVideo": "Загрузка видео...", + "loadingEditor": "Загрузка редактора...", "errors": { "noVideoLoaded": "Видео не загружено", "videoNotReady": "Видео не готово", @@ -41,5 +42,20 @@ "cameraDisconnected": "Веб-камера отключена.", "cameraNotFound": "Камера не найдена.", "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана." + }, + "emptyState": { + "title": "Нет открытых проектов", + "description": "Импортируйте видео для начала редактирования или загрузите существующий проект OpenScreen.", + "importVideoButton": "Импортировать видеофайл…", + "loadProjectButton": "Загрузить проект…", + "supportedFormats": "Поддерживаемые форматы: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "или перетащите файл проекта .openscreen сюда", + "dropOverlay": "Перетащите файл проекта для открытия", + "dropErrors": { + "unsupportedFormatTitle": "Неподдерживаемый формат", + "unsupportedFormatMessage": "Сюда можно перетаскивать только файлы проекта .openscreen. Для импорта видео используйте кнопку «Импортировать видеофайл...».", + "couldNotOpenTitle": "Не удалось открыть файл", + "couldNotOpenMessage": "Не удалось открыть файл проекта. Видео, на которое он ссылается, возможно, было перемещено или удалено." + } } } diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index dc15c3f59..4f185e05e 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -89,7 +89,8 @@ }, "project": { "save": "Сохранить проект", - "load": "Загрузить проект" + "load": "Загрузить проект", + "new": "Новый проект" }, "export": { "videoButton": "Экспорт видео", diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json index 9fab50dfb..1b62e7072 100644 --- a/src/i18n/locales/tr/dialogs.json +++ b/src/i18n/locales/tr/dialogs.json @@ -51,6 +51,12 @@ "detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?", "saveAndClose": "Kaydet ve Kapat", "discardAndClose": "Kaydetmeden Kapat", + "detailNewProject": "Yeni proje oluşturmadan önce kaydetmek ister misiniz?", + "saveAndNewProject": "Kaydet ve Yeni Proje", + "discardAndNewProject": "Kaydetmeden Yeni Proje", + "detailLoadProject": "Başka bir proje yüklemeden önce kaydetmek ister misiniz?", + "saveAndLoadProject": "Kaydet ve Proje Yükle", + "discardAndLoadProject": "Kaydetmeden Proje Yükle", "loadProject": "Proje Yükle…", "saveProject": "Proje Kaydet…", "saveProjectAs": "Farklı Kaydet…" diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 0aece8aec..f544c6dc1 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -36,10 +36,26 @@ "cameraNotFound": "Kamera bulunamadı." }, "loadingVideo": "Video yükleniyor...", + "loadingEditor": "Editör yükleniyor...", "newRecording": { "title": "Kaydediciye Dön", "description": "Mevcut oturumunuz kaydedildi.", "cancel": "İptal", "confirm": "Onayla" + }, + "emptyState": { + "title": "Açık proje yok", + "description": "Düzenlemeye başlamak için bir video içe aktarın veya mevcut bir OpenScreen projesi yükleyin.", + "importVideoButton": "Video Dosyası İçe Aktar…", + "loadProjectButton": "Proje Yükle…", + "supportedFormats": "Desteklenen formatlar: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "veya bir .openscreen proje dosyasını buraya sürükleyip bırakın", + "dropOverlay": "Açmak için proje dosyasını bırakın", + "dropErrors": { + "unsupportedFormatTitle": "Desteklenmeyen Format", + "unsupportedFormatMessage": "Buraya yalnızca .openscreen proje dosyaları bırakılabilir. Video içe aktarmak için \"Video Dosyası İçe Aktar...\" düğmesini kullanın.", + "couldNotOpenTitle": "Dosya Açılamadı", + "couldNotOpenMessage": "Proje dosyası açılamadı. Başvurulan video taşınmış veya silinmiş olabilir." + } } } diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index f63955829..ce837a77a 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "Projeyi Kaydet", - "load": "Proje Yükle" + "load": "Proje Yükle", + "new": "Yeni Proje" }, "export": { "videoButton": "Videoyu Dışa Aktar", diff --git a/src/i18n/locales/vi/dialogs.json b/src/i18n/locales/vi/dialogs.json index c94dbaa14..b7216ad1d 100644 --- a/src/i18n/locales/vi/dialogs.json +++ b/src/i18n/locales/vi/dialogs.json @@ -51,6 +51,12 @@ "detail": "Bạn có muốn lưu dự án của mình trước khi đóng không?", "saveAndClose": "Lưu & Đóng", "discardAndClose": "Bỏ qua & Đóng", + "detailNewProject": "Bạn có muốn lưu dự án trước khi tạo dự án mới không?", + "saveAndNewProject": "Lưu & Dự án mới", + "discardAndNewProject": "Bỏ qua & Dự án mới", + "detailLoadProject": "Bạn có muốn lưu dự án của mình trước khi tải dự án khác không?", + "saveAndLoadProject": "Lưu & Tải dự án", + "discardAndLoadProject": "Bỏ qua & Tải dự án", "loadProject": "Tải dự án…", "saveProject": "Lưu dự án…", "saveProjectAs": "Lưu dự án thành…" diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index a45bf3e26..5045ccc86 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -6,6 +6,7 @@ "confirm": "Xác nhận" }, "loadingVideo": "Đang tải video...", + "loadingEditor": "Đang tải trình chỉnh sửa...", "errors": { "noVideoLoaded": "Chưa tải video nào", "videoNotReady": "Video chưa sẵn sàng", @@ -41,5 +42,20 @@ "cameraDisconnected": "Webcam bị ngắt kết nối.", "cameraNotFound": "Không tìm thấy máy ảnh.", "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình." + }, + "emptyState": { + "title": "Không có dự án nào được mở", + "description": "Nhập video để bắt đầu chỉnh sửa hoặc tải một dự án OpenScreen hiện có.", + "importVideoButton": "Nhập tệp video…", + "loadProjectButton": "Tải dự án…", + "supportedFormats": "Định dạng được hỗ trợ: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "hoặc kéo và thả tệp dự án .openscreen vào đây", + "dropOverlay": "Thả tệp dự án để mở", + "dropErrors": { + "unsupportedFormatTitle": "Định dạng không được hỗ trợ", + "unsupportedFormatMessage": "Chỉ có thể thả các tệp dự án .openscreen vào đây. Để nhập video, hãy sử dụng nút \"Nhập tệp video...\" thay thế.", + "couldNotOpenTitle": "Không thể mở tệp", + "couldNotOpenMessage": "Không thể mở tệp dự án. Video mà nó tham chiếu có thể đã bị di chuyển hoặc xóa." + } } } diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index e6a897d8c..38c78939a 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -78,7 +78,8 @@ }, "project": { "save": "Lưu dự án", - "load": "Tải dự án" + "load": "Tải dự án", + "new": "Dự án mới" }, "export": { "videoButton": "Xuất Video", diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 0385b36f3..246ea4859 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -51,6 +51,12 @@ "detail": "是否在关闭前保存项目?", "saveAndClose": "保存并关闭", "discardAndClose": "放弃并关闭", + "detailNewProject": "是否在创建新项目前保存当前项目?", + "saveAndNewProject": "保存并新建项目", + "discardAndNewProject": "放弃并新建项目", + "detailLoadProject": "是否在加载其他项目前保存当前项目?", + "saveAndLoadProject": "保存并加载项目", + "discardAndLoadProject": "放弃并加载项目", "loadProject": "加载项目…", "saveProject": "保存项目…", "saveProjectAs": "项目另存为…" diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index f6c02d4c3..fda75250d 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -6,6 +6,7 @@ "confirm": "确认" }, "loadingVideo": "正在加载视频...", + "loadingEditor": "正在加载编辑器...", "errors": { "noVideoLoaded": "未加载视频", "videoNotReady": "视频未就绪", @@ -41,5 +42,20 @@ "cameraDisconnected": "摄像头已断开连接。", "cameraNotFound": "未找到摄像头。", "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。" + }, + "emptyState": { + "title": "未打开任何项目", + "description": "导入视频开始编辑,或加载已有的 OpenScreen 项目。", + "importVideoButton": "导入视频文件…", + "loadProjectButton": "加载项目…", + "supportedFormats": "支持的格式:MP4、MOV、WebM、MKV、AVI、M4V、WMV", + "dragDropHint": "或将 .openscreen 项目文件拖放到此处", + "dropOverlay": "将项目文件拖放至此以打开", + "dropErrors": { + "unsupportedFormatTitle": "不支持的格式", + "unsupportedFormatMessage": "此处只能拖放 .openscreen 项目文件。要导入视频,请使用\"导入视频文件...\"按钮。", + "couldNotOpenTitle": "无法打开文件", + "couldNotOpenMessage": "无法打开项目文件。它引用的视频可能已被移动或删除。" + } } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ff157dcd7..93dd3fcdf 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "保存项目", - "load": "加载项目" + "load": "加载项目", + "new": "新建项目" }, "export": { "videoButton": "导出视频", diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json index b582aba8d..f5ba7f9a4 100644 --- a/src/i18n/locales/zh-TW/dialogs.json +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -51,6 +51,12 @@ "detail": "是否在關閉前儲存專案?", "saveAndClose": "儲存並關閉", "discardAndClose": "捨棄並關閉", + "detailNewProject": "是否在建立新專案前儲存目前的專案?", + "saveAndNewProject": "儲存並建立新專案", + "discardAndNewProject": "捨棄並建立新專案", + "detailLoadProject": "是否在載入其他專案前儲存目前的專案?", + "saveAndLoadProject": "儲存並載入專案", + "discardAndLoadProject": "捨棄並載入專案", "loadProject": "載入專案…", "saveProject": "儲存專案…", "saveProjectAs": "專案另存新檔…" diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 21f3ba6f9..445c304a9 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -6,6 +6,7 @@ "confirm": "確認" }, "loadingVideo": "正在載入影片...", + "loadingEditor": "正在載入編輯器...", "errors": { "noVideoLoaded": "未載入影片", "videoNotReady": "影片未就緒", @@ -41,5 +42,20 @@ "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。", "cameraDisconnected": "網路攝影機已中斷連線。", "cameraNotFound": "找不到攝影機。" + }, + "emptyState": { + "title": "未開啟任何專案", + "description": "匯入影片以開始編輯,或載入現有的 OpenScreen 專案。", + "importVideoButton": "匯入影片檔案…", + "loadProjectButton": "載入專案…", + "supportedFormats": "支援的格式:MP4、MOV、WebM、MKV、AVI、M4V、WMV", + "dragDropHint": "或將 .openscreen 專案檔案拖放至此", + "dropOverlay": "將專案檔案拖放至此以開啟", + "dropErrors": { + "unsupportedFormatTitle": "不支援的格式", + "unsupportedFormatMessage": "此處只能拖放 .openscreen 專案檔案。要匯入影片,請使用「匯入影片檔案...」按鈕。", + "couldNotOpenTitle": "無法開啟檔案", + "couldNotOpenMessage": "無法開啟專案檔案。它所參照的影片可能已被移動或刪除。" + } } } diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 50ca00cbe..82dcb8420 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -95,7 +95,8 @@ }, "project": { "save": "儲存專案", - "load": "載入專案" + "load": "載入專案", + "new": "新增專案" }, "export": { "videoButton": "匯出影片", diff --git a/src/lib/cursor/nativeCursor.ts b/src/lib/cursor/nativeCursor.ts index 9ce308a80..65b9fdb57 100644 --- a/src/lib/cursor/nativeCursor.ts +++ b/src/lib/cursor/nativeCursor.ts @@ -1,7 +1,7 @@ import { type Container, Point } from "pixi.js"; +import arrowUrl from "@/assets/cursors/Cursor=AeroDefault.svg"; import appStartingUrl from "@/assets/cursors/Cursor=App-Starting.svg"; import crosshairUrl from "@/assets/cursors/Cursor=Cross.svg"; -import arrowUrl from "@/assets/cursors/Cursor=Default.svg"; import closedHandUrl from "@/assets/cursors/Cursor=Hand-(Grabbing).svg"; import openHandUrl from "@/assets/cursors/Cursor=Hand-(Open).svg"; import pointerUrl from "@/assets/cursors/Cursor=Hand-(Pointing).svg"; @@ -97,7 +97,7 @@ function getNativeCursorAsset(recordingData: CursorRecordingData, assetId: strin return getNativeCursorAssetMap(recordingData).get(assetId) ?? null; } -interface PrettyNativeCursorAsset { +export interface PrettyNativeCursorAsset { imageDataUrl: string; width: number; height: number; @@ -105,13 +105,20 @@ interface PrettyNativeCursorAsset { hotspotY: number; } -const PRETTY_NATIVE_CURSOR_ASSETS: Partial> = { +export const PRETTY_NATIVE_CURSOR_ASSETS: Partial< + Record +> = { arrow: { imageDataUrl: arrowUrl, + // Cursor=AeroDefault.svg viewBox is "0 0 32 33" — same canonical box as the + // pointer/hand/etc. SVGs so the arrow renders at the same size as the rest + // of the cursor set ("unified" perceived size). The arrow shape sits in + // the lower-middle of the 32×33 canvas; the polygon tip is at SVG (13.38, + // 12.90) so the hotspot is (13, 13). width: 32, - height: 32, - hotspotX: 16.25, - hotspotY: 15.03, + height: 33, + hotspotX: 13, + hotspotY: 13, }, text: { imageDataUrl: textUrl, @@ -220,33 +227,15 @@ const PRETTY_NATIVE_CURSOR_ASSETS: Partial 64 || - asset.height < 24 || - asset.height > 64 - ) { - return null; - } - - const hotspotXNorm = asset.hotspotX / asset.width; - const hotspotYNorm = asset.hotspotY / asset.height; - const looksLikeChromiumGrabCursor = - hotspotXNorm >= 0.22 && hotspotXNorm <= 0.55 && hotspotYNorm >= 0.2 && hotspotYNorm <= 0.45; - - return looksLikeChromiumGrabCursor ? (PRETTY_NATIVE_CURSOR_ASSETS["open-hand"] ?? null) : null; -} - export function hasNativeCursorRecordingData( recordingData: CursorRecordingData | null | undefined, ): recordingData is CursorRecordingData { + // assets.length is intentionally NOT required here: editable-overlay recordings + // hide the OS cursor with SetSystemCursor so every captured bitmap is transparent + // and no assets are stored, but the samples still carry cursor-type and position + // data that the editor can render using the pretty SVG cursor assets. return Boolean( - recordingData && - recordingData.provider === "native" && - recordingData.samples.length > 0 && - recordingData.assets.length > 0, + recordingData && recordingData.provider === "native" && recordingData.samples.length > 0, ); } @@ -405,6 +394,16 @@ export function getNativeCursorMotionBlurPx({ return clamp(speedPxPerSecond * clampedMotionBlur * 0.004, 0, NATIVE_CURSOR_MOTION_BLUR_MAX_PX); } +// Tolerance around the [0, 1] crop region used by getCroppedCursorPosition. +// Recorded cursor samples near the edge of the display (over the taskbar, +// at the very right edge, etc.) can jitter by a fraction of a normalized +// pixel just past 1.0, which made the cursor flicker every other frame in +// the editor playback as projection alternated between in-bounds (clamped) +// and out-of-bounds (null). 5% slack absorbs that jitter — within slack we +// clamp; outside it we still return null so cropped-away regions stay +// genuinely hidden. +const NATIVE_CURSOR_CROP_TOLERANCE = 0.05; + function getCroppedCursorPosition(sample: CursorRecordingSample, cropRegion: CropRegion) { if (cropRegion.width <= 0 || cropRegion.height <= 0) { return null; @@ -413,7 +412,12 @@ function getCroppedCursorPosition(sample: CursorRecordingSample, cropRegion: Cro const croppedCx = (sample.cx - cropRegion.x) / cropRegion.width; const croppedCy = (sample.cy - cropRegion.y) / cropRegion.height; - if (croppedCx < 0 || croppedCx > 1 || croppedCy < 0 || croppedCy > 1) { + if ( + croppedCx < -NATIVE_CURSOR_CROP_TOLERANCE || + croppedCx > 1 + NATIVE_CURSOR_CROP_TOLERANCE || + croppedCy < -NATIVE_CURSOR_CROP_TOLERANCE || + croppedCy > 1 + NATIVE_CURSOR_CROP_TOLERANCE + ) { return null; } @@ -432,6 +436,31 @@ function getNativeCursorMaskPoint(sample: CursorRecordingSample, cropRegion: Cro return new Point(croppedPosition.cx, croppedPosition.cy); } +/** + * Synthesises a NativeCursorAsset from the pretty-cursor SVG library for + * samples that have a known cursor type but no captured bitmap (e.g. every + * editable-overlay recording where SetSystemCursor makes all OS bitmaps + * transparent and no assets are stored). + */ +function syntheticAssetForCursorType( + cursorType: NativeCursorType | null | undefined, +): NativeCursorAsset | null { + const type = cursorType ?? "arrow"; + const pretty = PRETTY_NATIVE_CURSOR_ASSETS[type] ?? PRETTY_NATIVE_CURSOR_ASSETS.arrow; + if (!pretty) return null; + return { + id: `type-only:${type}`, + platform: "win32", + imageDataUrl: pretty.imageDataUrl, + width: pretty.width, + height: pretty.height, + hotspotX: pretty.hotspotX, + hotspotY: pretty.hotspotY, + scaleFactor: 1, + cursorType: type, + }; +} + export function resolveActiveNativeCursorFrame( recordingData: CursorRecordingData | null | undefined, timeMs: number, @@ -444,16 +473,19 @@ export function resolveActiveNativeCursorFrame( if (index >= 0) { const sample = recordingData.samples[index]; - if (sample.visible === false || !sample.assetId) { + if (sample.visible === false) { return null; } - const asset = getNativeCursorAsset(recordingData, sample.assetId); - if (!asset) { - return null; + if (sample.assetId) { + const asset = getNativeCursorAsset(recordingData, sample.assetId); + return asset ? { sample, asset } : null; } - return { sample, asset }; + // No captured bitmap asset — editable-overlay recording. + // Fall back to the pretty SVG asset for the detected cursor type. + const asset = syntheticAssetForCursorType(sample.cursorType); + return asset ? { sample, asset } : null; } return null; @@ -475,21 +507,38 @@ export function resolveInterpolatedNativeCursorFrame( } const activeSample = samples[activeIndex]; - if (activeSample.visible === false || !activeSample.assetId) { + if (activeSample.visible === false) { return null; } - const asset = getNativeCursorAsset(recordingData, activeSample.assetId); + // Resolve or synthesise the cursor asset for this sample. + let asset: NativeCursorAsset | null; + if (activeSample.assetId) { + asset = getNativeCursorAsset(recordingData, activeSample.assetId); + } else { + // No captured bitmap — editable-overlay recording. + // Use the pretty SVG for the detected cursor type so the cursor is + // always visible and matches the Windows Aero aesthetic. + asset = syntheticAssetForCursorType(activeSample.cursorType); + } if (!asset) { return null; } const nextSample = samples[activeIndex + 1]; + + // For interpolation, the assets must match so we're sliding between two + // positions of the same visual cursor. For type-only samples we match by + // cursorType instead of assetId. + const assetsMatch = activeSample.assetId + ? nextSample?.assetId === activeSample.assetId + : nextSample?.cursorType === activeSample.cursorType; + if ( !nextSample || nextSample.timeMs <= activeSample.timeMs || nextSample.visible === false || - nextSample.assetId !== activeSample.assetId || + !assetsMatch || timeMs <= activeSample.timeMs ) { return { asset, sample: activeSample }; @@ -556,10 +605,15 @@ export function resolvePrettyNativeCursorAsset( asset: NativeCursorAsset, sample?: CursorRecordingSample, ) { + // IMPORTANT: keep this in lock-step with src/components/launch/CursorOverlay.tsx + // (the recording helper). The helper renders cursors by direct cursor-type + // lookup with no heuristics — falling back to the default arrow when the type + // is null. We deliberately do NOT call resolveUntypedPrettyNativeCursorAsset + // here: its grab-cursor heuristic would make the editor preview / exported + // video show a different icon (e.g. open-hand) than the helper showed during + // recording, breaking the "what you saw is what you get" guarantee. const cursorType = sample?.cursorType ?? asset.cursorType ?? null; - return cursorType - ? (PRETTY_NATIVE_CURSOR_ASSETS[cursorType] ?? null) - : resolveUntypedPrettyNativeCursorAsset(asset); + return cursorType ? (PRETTY_NATIVE_CURSOR_ASSETS[cursorType] ?? null) : null; } export function resolveNativeCursorRenderAsset( @@ -567,10 +621,22 @@ export function resolveNativeCursorRenderAsset( deviceScaleFactor: number, sample?: CursorRecordingSample, ) { - const prettyAsset = resolvePrettyNativeCursorAsset(asset, sample); + // Prefer the pretty SVG asset (from cursor-type lookup or shape heuristic). + // Fall back to the arrow SVG when the cursor type is unknown or the captured + // bitmap is transparent (e.g. editable-overlay recordings where SetSystemCursor + // replaces every OS handle with a transparent 32×32 bitmap). This guarantees + // the cursor is always visible even when the bitmap can't be classified. + const prettyAsset = + resolvePrettyNativeCursorAsset(asset, sample) ?? PRETTY_NATIVE_CURSOR_ASSETS.arrow; if (prettyAsset) { + // Render at the SVG's canonical CSS-pixel size — the browser handles HiDPI + // scaling automatically (32 CSS px → 64 physical px on a 2× display). We + // deliberately do NOT scale by asset.scaleFactor: the captured bitmap may + // be in physical pixels which would double-apply DPI and shrink the cursor. + // Per-cursor-type sizes live in PRETTY_NATIVE_CURSOR_ASSETS so the editor + // preview matches the recording overlay exactly. return { - id: `pretty:${sample?.cursorType ?? asset.cursorType}`, + id: `pretty:${sample?.cursorType ?? asset.cursorType ?? "arrow"}`, imageDataUrl: prettyAsset.imageDataUrl, width: prettyAsset.width, height: prettyAsset.height, diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index f6a64c036..f781ae917 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -53,9 +53,6 @@ import { import { createNativeCursorMotionBlurState, createNativeCursorSmoothingState, - getNativeCursorClickBounceProgress, - getNativeCursorClickBounceScale, - getNativeCursorMotionBlurPx, projectNativeCursorToLocal, resetNativeCursorMotionBlurState, resetNativeCursorSmoothingState, @@ -540,9 +537,22 @@ export class FrameRenderer { resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState); return; } + // Skip rendering when the recording captured a moment where the + // foreground app had hidden the OS cursor (Figma, Photoshop, games). + // The app's own drawn cursor is already baked into the source video + // frame, so adding our SVG on top would produce a duplicate. + if (activeNativeCursor.sample.osCursorHidden) { + resetNativeCursorSmoothingState(this.nativeCursorSmoothingState); + resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState); + return; + } + // Native-OS cursor: no smoothing — behave like a real OS cursor + // (crisp, zero-latency, no interpolation). Matches VideoPlayback.tsx + // so the editor preview and exported video render identically. const displaySample = smoothNativeCursorSample({ + forceSnap: true, sample: activeNativeCursor.sample, - smoothing: this.config.cursorSmoothing ?? 0, + smoothing: 0, state: this.nativeCursorSmoothingState, timeMs, }); @@ -566,25 +576,19 @@ export class FrameRenderer { this.warnOnce("native-cursor-image-load", "Failed to load native cursor asset", error); return; } - const scale = - Math.max(0, this.config.cursorScale ?? 1) * - getNativeCursorClickBounceScale( - this.config.cursorClickBounce ?? 0, - getNativeCursorClickBounceProgress(this.config.cursorRecordingData, timeMs), - ); + // Native-OS cursor: no cursorScale multiplier, no click bounce, no motion + // blur (cursorScale slider only applies to the preview-only custom cursor + // mode; cursorScale<=0 early-exit above still hides the cursor entirely). + // We DO multiply by the source display's scaleFactor so the rendered + // cursor occupies the same fraction of the source video frame as it did + // during recording. A 32-CSS-px cursor on a 1.5× display was captured as + // 48 physical px in the source video; without this factor the export + // would render it back at 32 px on the 1920-wide canvas (1.67% of frame) + // instead of the recorded 48 px (2.5% of frame). + const scale = activeNativeCursor.asset.scaleFactor ?? 1; const appliedScale = this.animationState.appliedScale; const canvasX = projectedPoint.x * appliedScale + this.animationState.x; const canvasY = projectedPoint.y * appliedScale + this.animationState.y; - const blurPx = getNativeCursorMotionBlurPx({ - motionBlur: this.config.cursorMotionBlur ?? 0, - point: { x: canvasX, y: canvasY }, - state: this.nativeCursorMotionBlurState, - timeMs, - }); - const previousFilter = this.foregroundCtx.filter; - if (blurPx > 0) { - this.foregroundCtx.filter = `blur(${blurPx.toFixed(2)}px)`; - } this.foregroundCtx.drawImage( image, canvasX - renderAsset.hotspotX * scale * appliedScale, @@ -592,7 +596,6 @@ export class FrameRenderer { renderAsset.width * scale * appliedScale, renderAsset.height * scale * appliedScale, ); - this.foregroundCtx.filter = previousFilter; } private async getCursorImage(asset: { id: string; imageDataUrl: string }) { diff --git a/src/native/client.ts b/src/native/client.ts index 3f53ce483..9ff60d357 100644 --- a/src/native/client.ts +++ b/src/native/client.ts @@ -94,6 +94,12 @@ export const nativeBridgeClient = { domain: "project", action: "loadCurrentProjectFile", }), + loadProjectFileFromPath: (path: string) => + requireNativeBridgeData({ + domain: "project", + action: "loadProjectFileFromPath", + payload: { path }, + }), setCurrentVideoPath: (path: string) => requireNativeBridgeData({ domain: "project", diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 6836095ac..36ab88bee 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -32,6 +32,20 @@ export interface CursorRecordingSample extends CursorTelemetryPoint { visible?: boolean; cursorType?: NativeCursorType | null; interactionType?: "move" | "click" | "mouseup"; + /** + * True when the foreground app has hidden the OS cursor (e.g. Figma, + * Photoshop, games — apps that draw their own cursor and call + * ShowCursor(false)). When true, the recording helper, editor playback, + * and exporter all skip drawing our virtual cursor so the user sees only + * the app's own cursor instead of a duplicate. + * + * Detected via Win32 GetCursorInfo's CURSOR_SHOWING flag: 0 means hidden, + * 1 means the OS thinks the cursor is visible (even if we've replaced its + * bitmap with a transparent one for capture). So this flag survives our + * own SetSystemCursor(transparent) override and only fires when another + * app actually wanted the cursor hidden. + */ + osCursorHidden?: boolean; } export interface NativeCursorAsset { @@ -174,6 +188,12 @@ export type NativeBridgeRequest = payload?: EmptyPayload; requestId?: string; } + | { + domain: "project"; + action: "loadProjectFileFromPath"; + payload: { path: string }; + requestId?: string; + } | { domain: "project"; action: "setCurrentVideoPath"; diff --git a/vite.config.ts b/vite.config.ts index 55b55966b..6f54cbb33 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,16 @@ export default defineConfig({ main: { entry: "electron/main.ts", vite: { - build: {}, + build: { + rollupOptions: { + // koffi is a runtime FFI library that ships pre-built .node + // binaries. Vite's commonjs-resolver chokes on its binary + // file (MZ-headered Windows PE), so we mark it external and + // let Electron's runtime require() resolve it from + // node_modules instead of bundling it. + external: ["koffi"], + }, + }, }, }, preload: {