diff --git a/.gitignore b/.gitignore index 84a5a1ce..82fc468b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,16 @@ dist-ssr # Native helper build outputs /electron/native/wgc-capture/build/ +/electron/native/screencapturekit/build/ +/electron/native/screencapturekit/.build/ +/electron/native/screencapturekit/.swiftpm/ /electron/native/bin/ +# Native macOS generated files +DerivedData/ +*.xcuserstate +xcuserdata/ + # Editor directories and files .vscode/* .zed/ @@ -38,6 +46,7 @@ release/** test-results playwright-report/ + # Vitest browser mode screenshots __screenshots__/ @@ -49,4 +58,8 @@ result-* .direnv/ #kilocode -.kilo/ \ No newline at end of file +.kilo/ + +#others + +**/*.import diff --git a/docs/engineering/macos-native-recorder-roadmap.md b/docs/engineering/macos-native-recorder-roadmap.md new file mode 100644 index 00000000..63afc423 --- /dev/null +++ b/docs/engineering/macos-native-recorder-roadmap.md @@ -0,0 +1,210 @@ +# macOS Native Recorder Roadmap + +OpenScreen's macOS recorder should follow the same architecture boundaries as the Windows native recorder: Electron owns session orchestration and persistence, while a platform-native helper owns capture, timing, encoding, and platform-specific permissions. + +This work is intentionally scoped as a macOS-only port. Windows native capture remains owned by the WGC helper, and Linux remains on the existing Electron path. + +## Goals + +- Capture displays and windows through ScreenCaptureKit. +- Exclude the real system cursor during capture when using the editable OpenScreen cursor overlay. +- Preserve the current high-quality cursor overlay path in preview and export. +- Capture macOS system audio through ScreenCaptureKit on supported macOS versions. +- Capture microphone audio through the same native timing domain where the OS supports it, or through an explicit companion path until it can be moved into the helper. +- Mix system audio and microphone audio into the primary MP4 without renderer-side track assembly. +- Capture webcam video natively and compose it into the helper-owned MP4 during the native-recording migration. +- Keep screen video, audio, webcam, and cursor aligned to one native timing origin. +- Package per-architecture helper binaries with macOS builds. + +## Non-Goals + +- Replacing the editor/export pipeline. +- Changing Windows native capture behavior. +- Adding Linux native capture. +- Shipping a silent fallback from native macOS capture to Electron capture when the user explicitly requested a native-only feature. + +## Architecture + +The renderer keeps the existing recording controls. On macOS, `useScreenRecorder` should eventually send a complete recording request to Electron instead of assembling display, audio, microphone, webcam, and cursor streams in the browser. + +Electron owns the native recording session: + +- resolves the selected display/window source; +- resolves output paths; +- starts cursor telemetry capture when editable cursor mode is selected; +- starts the ScreenCaptureKit helper process; +- sends pause/resume/stop/cancel commands; +- writes `RecordingSession` manifests; +- reports explicit errors when a macOS-native capability is unavailable. + +The helper owns macOS media capture: + +- ScreenCaptureKit display/window frames; +- ScreenCaptureKit system audio where supported; +- microphone capture or helper-owned companion audio capture; +- webcam capture and initial picture-in-picture composition; +- AVFoundation/VideoToolbox encoding and muxing; +- stream timestamp normalization. + +## Helper Contract V1 + +The helper receives a single JSON argument: + +```json +{ + "schemaVersion": 1, + "recordingId": 1234567890, + "source": { + "type": "display", + "sourceId": "screen:0:0", + "displayId": 1, + "windowId": null, + "bounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 } + }, + "video": { + "fps": 60, + "width": 1920, + "height": 1080, + "bitrate": 18000000, + "hideSystemCursor": true + }, + "audio": { + "system": { "enabled": true }, + "microphone": { + "enabled": true, + "deviceId": "default", + "deviceName": "MacBook Pro Microphone", + "gain": 1.4 + } + }, + "webcam": { + "enabled": true, + "deviceId": "default", + "deviceName": "FaceTime HD Camera", + "width": 1280, + "height": 720, + "fps": 30 + }, + "cursor": { + "mode": "editable-overlay" + }, + "outputs": { + "screenPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.mp4", + "manifestPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.session.json" + } +} +``` + +The helper emits newline-delimited JSON events to stdout: + +```json +{ "event": "ready", "schemaVersion": 1 } +{ "event": "recording-started", "timestampMs": 1234567890 } +{ "event": "warning", "code": "microphone-unavailable", "message": "..." } +{ "event": "recording-stopped", "screenPath": "..." } +{ "event": "error", "code": "screen-permission-denied", "message": "..." } +``` + +## Implementation Phases + +Current PR status: macOS screen/window capture routes through the ScreenCaptureKit helper when it is available so editable-cursor recordings can hide the system cursor. The helper now writes ScreenCaptureKit system audio into the primary MP4 and attempts runtime-gated native microphone capture on macOS versions that expose ScreenCaptureKit microphone output. Webcam capture is currently an Electron-recorded sidecar attached to the same recording session; native AVFoundation webcam composition remains the target end state. + +### 1. Native Session Boundary + +- Add a structured macOS native recording request type. +- Add a macOS helper resolver and build script placeholders. +- Keep the helper contract process-based, matching the Windows helper boundary. +- Do not route production macOS recording through this helper until the helper is available and validated. + +Acceptance: + +- TypeScript build passes. +- The macOS helper path and request contract are documented and testable without affecting Windows/Linux behavior. + +### 2. ScreenCaptureKit Display Capture + +- Implement a Swift helper using ScreenCaptureKit. +- Select display captures by `displayId`. +- Encode H.264 MP4 through AVFoundation/VideoToolbox. +- Set `showsCursor = false` when editable cursor overlay mode is selected. + +Acceptance: + +- Display-only recording produces a valid MP4. +- The real cursor is not baked into editable-cursor recordings. + +### 3. ScreenCaptureKit Window Capture + +- Resolve Electron `window:*` selections to ScreenCaptureKit window ids. +- Capture `SCContentFilter(desktopIndependentWindow:)`. +- Handle closed/minimized/protected windows with explicit errors. +- Keep window selection and capture source resolution in Electron/main, not the renderer. + +Acceptance: + +- Capturing a normal app window works with cursor/audio/webcam disabled. +- Unsupported windows return clear native errors. + +### 4. System Audio + +- Enable ScreenCaptureKit system audio on supported macOS versions. +- Keep audio format and timing owned by the helper. +- Encode or mux AAC audio into the primary MP4. + +Acceptance: + +- System-audio-only recordings produce a valid AAC track. +- Unsupported macOS versions return an explicit capability error. + +### 5. Microphone + +- Resolve the selected microphone device from the renderer-provided browser `deviceId` and user-visible label. +- Capture microphone audio in the helper timing domain. +- Apply OpenScreen microphone gain policy. +- Mix system and microphone audio before final AAC output. + +Acceptance: + +- Mic-only and mic-plus-system recordings produce a valid, balanced AAC track. +- Device selection honors the selected microphone, not only the default device. + +### 6. Webcam Composition + +- Capture the selected camera natively through AVFoundation. +- Match browser device id first where possible, then user-visible label. +- Compose an initial picture-in-picture overlay into the primary MP4. +- Hide webcam output until the first usable frame to avoid black startup flashes. + +Acceptance: + +- Native display/window recordings can include webcam without returning to Electron capture. +- Selected camera is honored. + +### 7. Runtime Controls + +- Add pause/resume commands to the helper. +- Add cancel command that removes partial outputs. +- Keep restart as stop-discard-start until the helper exposes a native restart operation. + +Acceptance: + +- Pause/resume keeps output duration coherent. +- Cancel leaves no stale media/session files. + +### 8. Test Pipeline + +- `npm run build:native:mac`: builds Swift helper binaries on macOS. +- `npm run test:sck-helper:mac`: display-only helper smoke test. +- `npm run test:sck-window:mac`: window capture smoke test. +- `npm run test:sck-audio:mac`: system audio smoke test when supported. +- `npm run test:sck-mic:mac`: microphone smoke test. +- `npm run test:sck-webcam:mac`: webcam smoke test when a webcam is available. +- Packaging check: confirms helpers are available under `electron/native/bin/darwin-${arch}` in packaged builds. + +## SSOT Rules + +- `src/lib/nativeMacRecording.ts` is the renderer/main TypeScript request contract. +- This document is the feature-level contract and phase checklist. +- The Swift helper owns ScreenCaptureKit/AVFoundation media timing. +- Electron owns output paths, session manifests, and selected source/device resolution. +- Renderer code must use existing hooks/client APIs and should not bind directly to helper process details. diff --git a/docs/testing/windows-native-cursor.md b/docs/testing/windows-native-cursor.md index f6d214b5..c8c5ac0b 100644 --- a/docs/testing/windows-native-cursor.md +++ b/docs/testing/windows-native-cursor.md @@ -111,6 +111,7 @@ Smoke-test the helper directly: ```powershell npm run test:wgc-helper:win +npm run test:wgc-helper:win -- --capture-cursor npm run test:wgc-window:win npm run test:wgc-audio:win npm run test:wgc-mic:win diff --git a/electron-builder.json5 b/electron-builder.json5 index dc6687d9..8ad4a80e 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -46,6 +46,13 @@ ], "icon": "icons/icons/mac/icon.icns", "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", + "to": "electron/native/bin", + "filter": ["darwin-*/*"] + } + ], "extendInfo": { "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", @@ -73,7 +80,7 @@ { "from": "electron/native/bin", "to": "electron/native/bin", - "filter": ["**/*"] + "filter": ["win32-*/*"] } ] }, diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e92ce19f..0c6eb8b1 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -31,7 +31,16 @@ interface Window { switchToEditor: () => Promise; switchToHud: () => Promise; startNewRecording: () => Promise<{ success: boolean; error?: string }>; - openSourceSelector: () => Promise; + openSourceSelector: () => Promise<{ + opened: boolean; + reason?: string; + access?: { + success: boolean; + granted: boolean; + status: string; + error?: string; + }; + }>; selectSource: (source: ProcessedDesktopSource) => Promise; getSelectedSource: () => Promise; requestCameraAccess: () => Promise<{ @@ -40,6 +49,18 @@ interface Window { status: string; error?: string; }>; + requestScreenAccess: () => Promise<{ + success: boolean; + granted: boolean; + status: string; + error?: string; + }>; + requestNativeMacCursorAccess: () => Promise<{ + success: boolean; + granted: boolean; + status: string; + error?: string; + }>; assetBaseUrl: string; storeRecordedVideo: ( videoData: ArrayBuffer, @@ -60,6 +81,12 @@ interface Window { message?: string; error?: string; }>; + openRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>; + appendRecordingChunk: ( + fileName: string, + chunk: ArrayBuffer, + ) => Promise<{ success: boolean; error?: string }>; + closeRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>; getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; @@ -78,6 +105,13 @@ interface Window { reason?: string; error?: string; }>; + isNativeMacCaptureAvailable: () => Promise<{ + success: boolean; + available: boolean; + helperPath?: string; + reason?: "unsupported-platform" | "missing-helper" | string; + error?: string; + }>; startNativeWindowsRecording: ( request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest, ) => Promise; @@ -89,6 +123,45 @@ interface Window { discarded?: boolean; error?: string; }>; + pauseNativeWindowsRecording: () => Promise<{ + success: boolean; + error?: string; + }>; + resumeNativeWindowsRecording: () => Promise<{ + success: boolean; + error?: string; + }>; + startNativeMacRecording: ( + request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest, + ) => Promise; + pauseNativeMacRecording: () => Promise<{ + success: boolean; + error?: string; + }>; + resumeNativeMacRecording: () => Promise<{ + success: boolean; + error?: string; + }>; + stopNativeMacRecording: (discard?: boolean) => Promise<{ + success: boolean; + path?: string; + session?: import("../src/lib/recordingSession").RecordingSession; + message?: string; + discarded?: boolean; + error?: string; + }>; + attachNativeMacWebcamRecording: (payload: { + screenVideoPath: string; + recordingId: number; + webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput; + cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode; + }) => Promise<{ + success: boolean; + path?: string; + session?: import("../src/lib/recordingSession").RecordingSession; + message?: string; + error?: string; + }>; discardCursorTelemetry: (recordingId: number) => Promise; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; @@ -138,6 +211,12 @@ interface Window { message?: string; error?: string; }>; + preparePreviewAudioTrack: (filePath: string) => Promise<{ + success: boolean; + path?: string | null; + message?: string; + error?: string; + }>; clearCurrentVideoPath: () => Promise<{ success: boolean }>; saveProjectFile: ( projectData: unknown, @@ -175,9 +254,16 @@ interface Window { ) => Promise<{ success: boolean; error?: string; message?: string }>; getShortcuts: () => Promise | null>; saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; + updateGlobalShortcut: (binding: { + key: string; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; + }) => Promise<{ success: boolean }>; hudOverlayHide: () => void; hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; + moveHudOverlayBy: (deltaX: number, deltaY: number) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; diff --git a/electron/globalShortcut.ts b/electron/globalShortcut.ts new file mode 100644 index 00000000..f1b046a3 --- /dev/null +++ b/electron/globalShortcut.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import { globalShortcut } from "electron"; +import { type ShortcutBinding } from "../src/lib/shortcuts"; +import { SHORTCUTS_FILE } from "./ipc/handlers"; + +const DEFAULT_OPEN_APP_BINDING: ShortcutBinding = { key: "o", ctrl: true, shift: true }; + +// Maps KeyboardEvent.key values to Electron accelerator key names +const KEY_TO_ACCELERATOR: Record = { + " ": "Space", + "+": "Plus", + "-": "numsub", + "*": "nummult", + "/": "numdiv", + arrowup: "Up", + arrowdown: "Down", + arrowleft: "Left", + arrowright: "Right", + escape: "Escape", + enter: "Return", + backspace: "Backspace", + delete: "Delete", + tab: "Tab", +}; + +function bindingToAccelerator(binding: ShortcutBinding): string { + const parts: string[] = []; + if (binding.ctrl) parts.push("CommandOrControl"); + if (binding.shift) parts.push("Shift"); + if (binding.alt) parts.push("Alt"); + + const keyLower = binding.key.toLowerCase(); + const acceleratorKey = KEY_TO_ACCELERATOR[keyLower] ?? binding.key.toUpperCase(); + parts.push(acceleratorKey); + + return parts.join("+"); +} + +let currentAccelerator: string | null = null; + +export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean { + const accelerator = bindingToAccelerator(binding); + + // Same shortcut already registered, nothing to do + if (accelerator === currentAccelerator) { + return true; + } + + // Try to register new shortcut first (before unregistering old one) + const success = globalShortcut.register(accelerator, onTrigger); + + if (success) { + // Only unregister old shortcut after new one succeeds + if (currentAccelerator) { + globalShortcut.unregister(currentAccelerator); + } + currentAccelerator = accelerator; + console.log(`Global shortcut registered: ${accelerator}`); + } else { + console.warn(`Failed to register global shortcut: ${accelerator}`); + } + + return success; +} + +export async function loadAndRegisterGlobalShortcut(onTrigger: () => void): Promise { + try { + const data = await fs.readFile(SHORTCUTS_FILE, "utf-8"); + const shortcuts = JSON.parse(data); + const binding = shortcuts.openApp || DEFAULT_OPEN_APP_BINDING; + registerOpenAppShortcut(binding, onTrigger); + } catch { + registerOpenAppShortcut(DEFAULT_OPEN_APP_BINDING, onTrigger); + } +} + +export function unregisterAllGlobalShortcuts(): void { + globalShortcut.unregisterAll(); +} diff --git a/electron/i18n.ts b/electron/i18n.ts index e16ac861..1492578c 100644 --- a/electron/i18n.ts +++ b/electron/i18n.ts @@ -9,10 +9,14 @@ import commonEs from "../src/i18n/locales/es/common.json"; import dialogsEs from "../src/i18n/locales/es/dialogs.json"; import commonFr from "../src/i18n/locales/fr/common.json"; import dialogsFr from "../src/i18n/locales/fr/dialogs.json"; +import commonIt from "../src/i18n/locales/it/common.json"; +import dialogsIt from "../src/i18n/locales/it/dialogs.json"; import commonJa from "../src/i18n/locales/ja-JP/common.json"; import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json"; import commonKo from "../src/i18n/locales/ko-KR/common.json"; import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json"; +import commonRu from "../src/i18n/locales/ru/common.json"; +import dialogsRu from "../src/i18n/locales/ru/dialogs.json"; import commonTr from "../src/i18n/locales/tr/common.json"; import dialogsTr from "../src/i18n/locales/tr/dialogs.json"; import commonVi from "../src/i18n/locales/vi/common.json"; @@ -22,21 +26,35 @@ import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json"; import commonZhTw from "../src/i18n/locales/zh-TW/common.json"; import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json"; -type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi"; +type Locale = + | "en" + | "ar" + | "es" + | "fr" + | "it" + | "ja-JP" + | "ko-KR" + | "ru" + | "tr" + | "vi" + | "zh-CN" + | "zh-TW"; type Namespace = "common" | "dialogs"; type MessageMap = Record; const messages: Record> = { en: { common: commonEn, dialogs: dialogsEn }, - "zh-CN": { common: commonZh, dialogs: dialogsZh }, - "zh-TW": { common: commonZhTw, dialogs: dialogsZhTw }, + ar: { common: commonAr, dialogs: dialogsAr }, es: { common: commonEs, dialogs: dialogsEs }, fr: { common: commonFr, dialogs: dialogsFr }, + it: { common: commonIt, dialogs: dialogsIt }, "ja-JP": { common: commonJa, dialogs: dialogsJa }, "ko-KR": { common: commonKo, dialogs: dialogsKo }, + ru: { common: commonRu, dialogs: dialogsRu }, tr: { common: commonTr, dialogs: dialogsTr }, - ar: { common: commonAr, dialogs: dialogsAr }, vi: { common: commonVi, dialogs: dialogsVi }, + "zh-CN": { common: commonZh, dialogs: dialogsZh }, + "zh-TW": { common: commonZhTw, dialogs: dialogsZhTw }, }; let currentLocale: Locale = "en"; @@ -44,15 +62,17 @@ let currentLocale: Locale = "en"; export function setMainLocale(locale: string) { if ( locale === "en" || - locale === "zh-CN" || - locale === "zh-TW" || + locale === "ar" || locale === "es" || locale === "fr" || + locale === "it" || locale === "ja-JP" || locale === "ko-KR" || + locale === "ru" || locale === "tr" || - locale === "ar" || - locale === "vi" + locale === "vi" || + locale === "zh-CN" || + locale === "zh-TW" ) { currentLocale = locale; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9797c95c..2c01ca28 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,4 +1,5 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; @@ -15,6 +16,7 @@ import { shell, systemPreferences, } from "electron"; +import type { NativeMacRecordingRequest } from "../../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording"; import { type CursorCaptureMode, @@ -22,6 +24,7 @@ import { normalizeProjectMedia, normalizeRecordingSession, type ProjectMedia, + type RecordedVideoAssetInput, type RecordingSession, type StoreRecordedSessionInput, } from "../../src/lib/recordingSession"; @@ -35,14 +38,19 @@ import type { import { mainT } from "../i18n"; import { RECORDINGS_DIR } from "../main"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; +import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; +import { patchWebmDurationOnDisk } from "../recording/webm-duration"; import { registerNativeBridgeHandlers } from "./nativeBridge"; +import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream"; const PROJECT_FILE_EXTENSION = "openscreen"; -const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); +export 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 PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio"); +const nativeMacCaptureEvents = new EventEmitter(); /** * Paths explicitly approved by the user via file picker dialogs or project loads. @@ -102,6 +110,102 @@ function hasAllowedImportVideoExtension(filePath: string): boolean { return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } +function runProcess( + command: string, + args: string[], +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +function parseAfinfoAudioTrackBitrates(output: string): number[] { + const bitrates: number[] = []; + const trackSections = output.split(/\n----\n/g).slice(1); + for (const section of trackSections) { + const match = section.match(/\bbit rate:\s*([0-9]+)\s*bits per second/i); + bitrates.push(match ? Number(match[1]) : 0); + } + return bitrates; +} + +async function prepareSupplementalPreviewAudioTrack(videoPath: string) { + const normalizedPath = await approveReadableVideoPath(videoPath); + if (!normalizedPath) { + return { + success: false, + message: "File path is not approved or is not a supported video file", + }; + } + + if (process.platform !== "darwin" || path.extname(normalizedPath).toLowerCase() !== ".mp4") { + return { success: true, path: null }; + } + + const afinfo = await runProcess("/usr/bin/afinfo", [normalizedPath]); + if (afinfo.code !== 0) { + return { success: true, path: null }; + } + + const bitrates = parseAfinfoAudioTrackBitrates(`${afinfo.stdout}\n${afinfo.stderr}`); + if (bitrates.length <= 1) { + return { success: true, path: null }; + } + + let supplementalTrackIndex = 1; + for (let index = 2; index < bitrates.length; index += 1) { + if (bitrates[index] > bitrates[supplementalTrackIndex]) { + supplementalTrackIndex = index; + } + } + + await fs.mkdir(PREVIEW_AUDIO_DIR, { recursive: true }); + const sourceStat = await fs.stat(normalizedPath); + const parsedPath = path.parse(normalizedPath); + const outputPath = path.join( + PREVIEW_AUDIO_DIR, + `${parsedPath.name}.track-${supplementalTrackIndex}.${Math.round(sourceStat.mtimeMs)}.m4a`, + ); + + try { + const outputStat = await fs.stat(outputPath); + if (outputStat.mtimeMs >= sourceStat.mtimeMs) { + return { success: true, path: pathToFileURL(outputPath).toString() }; + } + } catch { + // Generate below. + } + + const conversion = await runProcess("/usr/bin/afconvert", [ + "--read-track", + String(supplementalTrackIndex), + "-f", + "m4af", + "-d", + "aac", + normalizedPath, + outputPath, + ]); + if (conversion.code !== 0) { + return { + success: false, + message: conversion.stderr || conversion.stdout || "Failed to prepare preview audio", + }; + } + + return { success: true, path: pathToFileURL(outputPath).toString() }; +} + async function approveReadableVideoPath( filePath?: string | null, trustedDirs?: string[], @@ -163,6 +267,30 @@ function resolveRecordingOutputPath(fileName: string): string { return path.join(RECORDINGS_DIR, parsedPath.base); } +function isValidDurationMs(value: number | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +/** + * Finalize a single recording file: if it was streamed to disk, flush and close + * the stream; otherwise (a short recording, or the stream failed to open and the + * renderer fell back to in-memory buffering) write the buffered bytes. Returns + * whether the file was streamed, which the caller uses to decide whether the + * WebM duration needs patching on disk. + */ +async function finalizeRecordingFile( + registry: RecordingStreamRegistry, + fileName: string, + filePath: string, + videoData?: ArrayBuffer, +): Promise { + const streamed = await registry.finalize(fileName); + if (!streamed && videoData && videoData.byteLength > 0) { + await fs.writeFile(filePath, Buffer.from(videoData)); + } + return streamed; +} + async function getApprovedProjectSession( project: unknown, projectFilePath?: string, @@ -215,6 +343,13 @@ type SelectedSource = { [key: string]: unknown; }; +type AttachNativeMacWebcamRecordingInput = { + screenVideoPath?: string; + recordingId?: number; + webcam?: RecordedVideoAssetInput; + cursorCaptureMode?: CursorCaptureMode; +}; + let selectedSource: SelectedSource | null = null; let selectedDesktopSource: DesktopCapturerSource | null = null; let lastEnumeratedSources = new Map(); @@ -275,7 +410,21 @@ let nativeWindowsCaptureWebcamTargetPath: string | null = null; let nativeWindowsCaptureRecordingId: number | null = null; let nativeWindowsCursorOffsetMs = 0; let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay"; +let nativeWindowsCursorRecordingStartMs = 0; +let nativeWindowsPauseStartedAtMs: number | null = null; +let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = []; +let nativeWindowsIsPaused = false; const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000; +let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null; +let nativeMacCaptureOutput = ""; +let nativeMacCaptureTargetPath: string | null = null; +let nativeMacCaptureRecordingId: number | null = null; +let nativeMacCursorOffsetMs = 0; +let nativeMacCursorCaptureMode: CursorCaptureMode = "editable-overlay"; +let nativeMacCursorRecordingStartMs = 0; +let nativeMacPauseStartedAtMs: number | null = null; +let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = []; +let nativeMacIsPaused = false; function normalizeCursorSample(sample: unknown): CursorRecordingSample | null { if (!sample || typeof sample !== "object") { @@ -499,6 +648,35 @@ async function findNativeWindowsCaptureHelperPath() { return null; } +function getNativeMacCaptureHelperCandidates() { + const envPath = process.env.OPENSCREEN_SCK_CAPTURE_EXE?.trim(); + const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + const helperName = "openscreen-screencapturekit-helper"; + return [ + envPath, + resolveUnpackedAppPath("electron", "native", "screencapturekit", "build", helperName), + resolveUnpackedAppPath("electron", "native", "bin", archTag, helperName), + resolvePackagedResourcePath("electron", "native", "bin", archTag, helperName), + ].filter((candidate): candidate is string => Boolean(candidate)); +} + +async function findNativeMacCaptureHelperPath() { + if (process.platform !== "darwin") { + return null; + } + + for (const candidate of getNativeMacCaptureHelperCandidates()) { + try { + await fs.access(candidate, fsConstants.X_OK); + return candidate; + } catch { + // Try the next configured helper location. + } + } + + return null; +} + function isWindowsGraphicsCaptureOsSupported() { if (process.platform !== "win32") { return false; @@ -669,6 +847,74 @@ function shiftPendingCursorTelemetry(offsetMs: number) { }; } +function compactPendingCursorTelemetryPauseRanges( + ranges: Array<{ startMs: number; endMs: number }>, +) { + if (!pendingCursorRecordingData || ranges.length === 0) { + return; + } + + const normalizedRanges = ranges + .map((range) => ({ + startMs: Math.max(0, Math.min(range.startMs, range.endMs)), + endMs: Math.max(0, Math.max(range.startMs, range.endMs)), + })) + .filter((range) => Number.isFinite(range.startMs) && Number.isFinite(range.endMs)) + .filter((range) => range.endMs > range.startMs) + .sort((a, b) => a.startMs - b.startMs); + + if (normalizedRanges.length === 0) { + return; + } + + pendingCursorRecordingData = { + ...pendingCursorRecordingData, + samples: pendingCursorRecordingData.samples + .map((sample) => { + let pausedBeforeSampleMs = 0; + for (const range of normalizedRanges) { + if (sample.timeMs >= range.startMs && sample.timeMs <= range.endMs) { + return null; + } + if (sample.timeMs > range.endMs) { + pausedBeforeSampleMs += range.endMs - range.startMs; + } + } + + return { + ...sample, + timeMs: Math.max(0, sample.timeMs - pausedBeforeSampleMs), + }; + }) + .filter((sample): sample is CursorRecordingSample => Boolean(sample)) + .sort((a, b) => a.timeMs - b.timeMs), + }; +} + +function completeNativeMacCursorPauseRange(endMs = Date.now()) { + if (nativeMacPauseStartedAtMs === null || nativeMacCursorRecordingStartMs <= 0) { + return; + } + + nativeMacPauseRanges.push({ + startMs: Math.max(0, nativeMacPauseStartedAtMs - nativeMacCursorRecordingStartMs), + endMs: Math.max(0, endMs - nativeMacCursorRecordingStartMs), + }); + nativeMacPauseStartedAtMs = null; +} + +function completeNativeWindowsCursorPauseRange(endMs = Date.now()) { + if (nativeWindowsPauseStartedAtMs === null || nativeWindowsCursorRecordingStartMs <= 0) { + return; + } + + nativeWindowsPauseRanges.push({ + startMs: Math.max(0, nativeWindowsPauseStartedAtMs - nativeWindowsCursorRecordingStartMs), + endMs: Math.max(0, endMs - nativeWindowsCursorRecordingStartMs), + }); + nativeWindowsPauseStartedAtMs = null; +} + function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -785,6 +1031,157 @@ function readNativeWindowsWebcamFormat(output: string) { } } +function tryParseNativeHelperEvent(line: string) { + try { + const parsed = JSON.parse(line); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function inspectNativeMacCaptureOutput() { + for (const line of nativeMacCaptureOutput.split(/\r?\n/)) { + const event = tryParseNativeHelperEvent(line.trim()); + if (event) { + nativeMacCaptureEvents.emit("helper-event", event); + } + } +} + +function attachNativeMacCaptureOutputDrain(proc: ChildProcessWithoutNullStreams) { + let lineBuffer = ""; + const drain = (chunk: Buffer) => { + const text = chunk.toString(); + nativeMacCaptureOutput += text; + lineBuffer += text; + const lines = lineBuffer.split(/\r?\n/); + lineBuffer = lines.pop() ?? ""; + for (const line of lines) { + const event = tryParseNativeHelperEvent(line.trim()); + if (event) { + nativeMacCaptureEvents.emit("helper-event", event); + } + } + }; + const cleanup = () => { + proc.stdout.off("data", drain); + proc.stderr.off("data", drain); + proc.off("close", cleanup); + proc.off("error", cleanup); + }; + + proc.stdout.on("data", drain); + proc.stderr.on("data", drain); + proc.once("close", cleanup); + proc.once("error", cleanup); +} + +function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for native macOS capture to start")); + }, 10_000); + + const inspect = (event: Record) => { + if (event.event === "recording-started") { + cleanup(); + resolve(); + return; + } + if (event.event === "error") { + cleanup(); + reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed"))); + } + }; + + const onOutput = (event: Record) => inspect(event); + const onClose = (code: number | null) => { + cleanup(); + reject( + new Error( + nativeMacCaptureOutput.trim() || + `Native macOS capture exited before recording started (code=${code ?? "unknown"})`, + ), + ); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + clearTimeout(timer); + nativeMacCaptureEvents.off("helper-event", onOutput); + proc.off("close", onClose); + proc.off("error", onError); + }; + + nativeMacCaptureEvents.on("helper-event", onOutput); + proc.once("close", onClose); + proc.once("error", onError); + inspectNativeMacCaptureOutput(); + }); +} + +function waitForNativeMacCaptureStop(proc: ChildProcessWithoutNullStreams) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject( + new Error( + `Timed out waiting for native macOS capture to stop. Output path: ${ + nativeMacCaptureTargetPath ?? "unknown" + }. Output: ${nativeMacCaptureOutput.trim()}`, + ), + ); + }, 30_000); + + const inspect = (event: Record) => { + if (event.event === "recording-stopped") { + cleanup(); + resolve(String(event.screenPath ?? nativeMacCaptureTargetPath ?? "")); + return; + } + if (event.event === "error") { + cleanup(); + reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed"))); + } + }; + + const onOutput = (event: Record) => inspect(event); + const onClose = (code: number | null) => { + if (code === 0 && nativeMacCaptureTargetPath) { + cleanup(); + resolve(nativeMacCaptureTargetPath); + return; + } + cleanup(); + reject( + new Error( + nativeMacCaptureOutput.trim() || + `Native macOS capture exited with code=${code ?? "unknown"}`, + ), + ); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + clearTimeout(timer); + nativeMacCaptureEvents.off("helper-event", onOutput); + proc.off("close", onClose); + proc.off("error", onError); + }; + + nativeMacCaptureEvents.on("helper-event", onOutput); + proc.once("close", onClose); + proc.once("error", onError); + inspectNativeMacCaptureOutput(); + }); +} + function setCurrentRecordingSessionState(session: RecordingSession | null) { currentRecordingSession = session; currentVideoPath = session?.screenVideoPath ?? null; @@ -872,6 +1269,43 @@ export function registerIpcHandlers( onRecordingStateChange?: (recording: boolean, sourceName: string) => void, _switchToHud?: () => void, ) { + async function requestScreenAccess() { + if (process.platform !== "darwin") { + return { success: true, granted: true, status: "granted" }; + } + + try { + const status = systemPreferences.getMediaAccessStatus("screen"); + if (status === "granted") { + return { success: true, granted: true, status }; + } + + // Screen recording has no askForMediaAccess equivalent. Trigger the + // TCC prompt without opening OpenScreen's source selector above it. + if (status === "not-determined") { + const mainWin = getMainWindow(); + if (mainWin && !mainWin.isDestroyed()) { + if (!mainWin.isVisible()) { + mainWin.show(); + } + mainWin.focus(); + } + app.focus({ steal: true }); + desktopCapturer + .getSources({ types: ["screen"], thumbnailSize: { width: 1, height: 1 } }) + .catch(() => { + // Permission probing failure is reported by the explicit status check below. + }); + return { success: true, granted: false, status: "not-determined" }; + } + + return { success: true, granted: false, status }; + } catch (error) { + console.error("Failed to request screen access:", error); + return { success: false, granted: false, status: "unknown", error: String(error) }; + } + } + ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); lastEnumeratedSources = new Map(sources.map((source) => [source.id, source])); @@ -948,40 +1382,51 @@ export function registerIpcHandlers( }); ipcMain.handle("request-screen-access", async () => { - if (process.platform !== "darwin") { - return { success: true, granted: true, status: "granted" }; - } - - try { - const status = systemPreferences.getMediaAccessStatus("screen"); - if (status === "granted") { - return { success: true, granted: true, status }; - } - - // Screen recording has no askForMediaAccess equivalent — the TCC prompt - // is triggered by desktopCapturer.getSources(). Fire it and return so - // the renderer can re-check status after the user responds. - if (status === "not-determined") { - desktopCapturer.getSources({ types: ["screen"] }).catch(() => { - // Permission probing failure is reported by the explicit status check below. - }); - return { success: true, granted: false, status: "not-determined" }; - } - - return { success: true, granted: false, status }; - } catch (error) { - console.error("Failed to request screen access:", error); - return { success: false, granted: false, status: "unknown", error: String(error) }; - } + return requestScreenAccess(); }); - ipcMain.handle("open-source-selector", () => { + ipcMain.handle("request-native-mac-cursor-access", async () => { + return requestMacCursorAccessibilityAccess(); + }); + + ipcMain.handle("open-source-selector", async () => { + const access = await requestScreenAccess(); + if (!access.granted) { + if (process.platform === "darwin" && access.status !== "not-determined") { + const mainWin = getMainWindow(); + const messageOptions = { + type: "warning", + buttons: ["Open System Settings", "Cancel"], + defaultId: 0, + cancelId: 1, + message: "Screen Recording permission is required", + detail: + "Allow OpenScreen in macOS System Settings, then come back and choose a screen or window.", + } satisfies Electron.MessageBoxOptions; + const result = + mainWin && !mainWin.isDestroyed() + ? await dialog.showMessageBox(mainWin, messageOptions) + : await dialog.showMessageBox(messageOptions); + if (result.response === 0) { + await shell.openExternal( + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", + ); + } + } + return { + opened: false, + reason: "screen-access-required", + access, + }; + } + const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin) { sourceSelectorWin.focus(); - return; + return { opened: true }; } createSourceSelectorWindow(); + return { opened: true }; }); ipcMain.handle("switch-to-editor", () => { @@ -992,6 +1437,16 @@ export function registerIpcHandlers( createEditorWindow(); }); + ipcMain.handle("switch-to-hud", () => { + _switchToHud?.(); + return { success: true }; + }); + + ipcMain.handle("start-new-recording", () => { + _switchToHud?.(); + return { success: true }; + }); + ipcMain.handle("countdown-overlay-show", async (_, value: number, runId: number) => { const overlayWindow = getCountdownOverlayWindow?.() ?? createCountdownOverlayWindow(); if (overlayWindow.isDestroyed()) { @@ -1041,6 +1496,17 @@ export function registerIpcHandlers( : { success: true, available: false, reason: "missing-helper" }; }); + ipcMain.handle("is-native-mac-capture-available", async () => { + if (process.platform !== "darwin") { + return { success: true, available: false, reason: "unsupported-platform" }; + } + + const helperPath = await findNativeMacCaptureHelperPath(); + return helperPath + ? { success: true, available: true, helperPath } + : { success: true, available: false, reason: "missing-helper" }; + }); + ipcMain.handle( "start-native-windows-recording", async (_, request: NativeWindowsRecordingRequest) => { @@ -1159,9 +1625,14 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = recordingId; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = cursorCaptureMode; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { + nativeWindowsCursorRecordingStartMs = cursorStartTimeMs; await startCursorRecording(cursorStartTimeMs); console.info("[native-wgc] cursor sampler ready", { cursorStartTimeMs, @@ -1211,12 +1682,255 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; await stopCursorRecording(); return { success: false, error: String(error) }; } }, ); + ipcMain.handle("start-native-mac-recording", async (_, request: NativeMacRecordingRequest) => { + try { + if (process.platform !== "darwin") { + return { success: false, error: "Native macOS capture requires macOS." }; + } + if (nativeMacCaptureProcess) { + return { success: false, error: "Native macOS capture is already running." }; + } + + const helperPath = await findNativeMacCaptureHelperPath(); + if (!helperPath) { + return { success: false, error: "Native macOS capture helper is not available." }; + } + + if (!request?.source?.sourceId) { + return { success: false, error: "Native macOS capture request is missing a source." }; + } + + const recordingId = + typeof request.recordingId === "number" && Number.isFinite(request.recordingId) + ? request.recordingId + : Date.now(); + const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); + const cursorCaptureMode = + normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay"; + try { + await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 1, height: 1 }, + }); + } catch { + // The helper reports the final ScreenCaptureKit permission status. + } + if (request.audio?.microphone?.enabled) { + const micStatus = systemPreferences.getMediaAccessStatus("microphone"); + if (micStatus !== "granted") { + await systemPreferences.askForMediaAccess("microphone"); + } + } + const sourceDisplay = + request.source.type === "display" && typeof request.source.displayId === "number" + ? (screen.getAllDisplays().find((display) => display.id === request.source.displayId) ?? + null) + : getSelectedDisplay(); + const bounds = request.source.bounds ?? sourceDisplay?.bounds ?? getSelectedSourceBounds(); + const config: NativeMacRecordingRequest = { + ...request, + schemaVersion: 1, + recordingId, + source: { + ...request.source, + bounds, + }, + video: { + ...request.video, + hideSystemCursor: cursorCaptureMode === "editable-overlay", + }, + webcam: { + ...request.webcam, + enabled: false, + }, + cursor: { + mode: cursorCaptureMode, + }, + outputs: { + screenPath: outputPath, + manifestPath: path.join( + RECORDINGS_DIR, + `${RECORDING_FILE_PREFIX}${recordingId}${RECORDING_SESSION_SUFFIX}`, + ), + }, + }; + + console.info("[native-sck] starting macOS capture", { + helperPath, + source: config.source, + audio: config.audio, + webcam: config.webcam, + cursor: config.cursor, + outputPath, + }); + + await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + nativeMacCaptureOutput = ""; + nativeMacCaptureTargetPath = outputPath; + nativeMacCaptureRecordingId = recordingId; + nativeMacCursorOffsetMs = 0; + nativeMacCursorCaptureMode = cursorCaptureMode; + nativeMacCursorRecordingStartMs = 0; + nativeMacPauseStartedAtMs = null; + nativeMacPauseRanges = []; + nativeMacIsPaused = false; + + const cursorStartTimeMs = Date.now(); + if (cursorCaptureMode === "editable-overlay") { + nativeMacCursorRecordingStartMs = cursorStartTimeMs; + await startCursorRecording(cursorStartTimeMs); + } else { + pendingCursorRecordingData = null; + } + + const proc = spawn(helperPath, [JSON.stringify(config)], { + cwd: RECORDINGS_DIR, + stdio: ["pipe", "pipe", "pipe"], + }); + nativeMacCaptureProcess = proc; + attachNativeMacCaptureOutputDrain(proc); + + await waitForNativeMacCaptureStart(proc); + const captureStartedAtMs = Date.now(); + nativeMacCursorOffsetMs = + cursorCaptureMode === "editable-overlay" + ? Math.max(0, captureStartedAtMs - cursorStartTimeMs) + : 0; + + const source = selectedSource || { name: "Screen" }; + if (onRecordingStateChange) { + onRecordingStateChange(true, source.name); + } + + return { + success: true, + recordingId, + path: outputPath, + helperPath, + }; + } catch (error) { + console.error("Failed to start native macOS recording:", error); + nativeMacCaptureProcess?.kill(); + nativeMacCaptureProcess = null; + nativeMacCaptureTargetPath = null; + nativeMacCaptureRecordingId = null; + nativeMacCursorOffsetMs = 0; + nativeMacCursorCaptureMode = "editable-overlay"; + nativeMacCursorRecordingStartMs = 0; + nativeMacPauseStartedAtMs = null; + nativeMacPauseRanges = []; + nativeMacIsPaused = false; + await stopCursorRecording(); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle("pause-native-mac-recording", async () => { + if (process.platform !== "darwin") { + return { success: false, error: "Native macOS capture requires macOS." }; + } + + const proc = nativeMacCaptureProcess; + if (!proc) { + return { success: false, error: "Native macOS capture is not running." }; + } + if (nativeMacIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native macOS capture command channel is closed." }; + } + + try { + proc.stdin.write("pause\n"); + nativeMacIsPaused = true; + nativeMacPauseStartedAtMs = Date.now(); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle("resume-native-mac-recording", async () => { + if (process.platform !== "darwin") { + return { success: false, error: "Native macOS capture requires macOS." }; + } + + const proc = nativeMacCaptureProcess; + if (!proc) { + return { success: false, error: "Native macOS capture is not running." }; + } + if (!nativeMacIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native macOS capture command channel is closed." }; + } + + try { + proc.stdin.write("resume\n"); + completeNativeMacCursorPauseRange(); + nativeMacIsPaused = false; + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle("pause-native-windows-recording", async () => { + const proc = nativeWindowsCaptureProcess; + if (!proc) { + return { success: false, error: "Native Windows capture is not running." }; + } + if (nativeWindowsIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native Windows capture command channel is closed." }; + } + + try { + proc.stdin.write("pause\n"); + nativeWindowsIsPaused = true; + nativeWindowsPauseStartedAtMs = Date.now(); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle("resume-native-windows-recording", async () => { + const proc = nativeWindowsCaptureProcess; + if (!proc) { + return { success: false, error: "Native Windows capture is not running." }; + } + if (!nativeWindowsIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native Windows capture command channel is closed." }; + } + + try { + proc.stdin.write("resume\n"); + completeNativeWindowsCursorPauseRange(); + nativeWindowsIsPaused = false; + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + ipcMain.handle("stop-native-windows-recording", async (_, discard?: boolean) => { const proc = nativeWindowsCaptureProcess; const preferredPath = nativeWindowsCaptureTargetPath; @@ -1229,6 +1943,7 @@ export function registerIpcHandlers( } try { + completeNativeWindowsCursorPauseRange(); const stoppedPathPromise = waitForNativeWindowsCaptureStop(proc); proc.stdin.write("stop\n"); const stoppedPath = await stoppedPathPromise; @@ -1253,6 +1968,7 @@ export function registerIpcHandlers( } if (cursorCaptureMode === "editable-overlay") { + compactPendingCursorTelemetryPauseRanges(nativeWindowsPauseRanges); shiftPendingCursorTelemetry(nativeWindowsCursorOffsetMs); await writePendingCursorTelemetry(screenVideoPath); } @@ -1294,6 +2010,10 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; const source = selectedSource || { name: "Screen" }; if (onRecordingStateChange) { onRecordingStateChange(false, source.name); @@ -1301,6 +2021,158 @@ export function registerIpcHandlers( } }); + ipcMain.handle("stop-native-mac-recording", async (_, discard?: boolean) => { + if (process.platform !== "darwin") { + return { success: false, error: "Native macOS capture requires macOS." }; + } + + const proc = nativeMacCaptureProcess; + const preferredPath = nativeMacCaptureTargetPath; + const recordingId = nativeMacCaptureRecordingId ?? Date.now(); + const cursorCaptureMode = nativeMacCursorCaptureMode; + + if (!proc) { + return { success: false, error: "Native macOS capture is not running." }; + } + + try { + completeNativeMacCursorPauseRange(); + const stoppedPathPromise = waitForNativeMacCaptureStop(proc); + proc.stdin.write("stop\n"); + const stoppedPath = await stoppedPathPromise; + const screenVideoPath = stoppedPath || preferredPath; + if (!screenVideoPath) { + throw new Error("Native macOS capture did not return an output path."); + } + + if (cursorCaptureMode === "editable-overlay") { + await stopCursorRecording(); + } else { + pendingCursorRecordingData = null; + } + if (discard) { + pendingCursorRecordingData = null; + await Promise.all([ + fs.rm(screenVideoPath, { force: true }), + fs.rm(`${screenVideoPath}.cursor.json`, { force: true }), + ]); + return { success: true, discarded: true }; + } + + if (cursorCaptureMode === "editable-overlay") { + compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges); + shiftPendingCursorTelemetry(nativeMacCursorOffsetMs); + await writePendingCursorTelemetry(screenVideoPath); + } + + const session: RecordingSession = { + screenVideoPath, + createdAt: recordingId, + cursorCaptureMode, + }; + setCurrentRecordingSessionState(session); + currentProjectPath = null; + + const sessionManifestPath = path.join( + RECORDINGS_DIR, + `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, + ); + await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + + return { + success: true, + path: screenVideoPath, + session, + message: "Native macOS recording session stored successfully", + }; + } catch (error) { + console.error("Failed to stop native macOS recording:", error); + await stopCursorRecording(); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + nativeMacCaptureProcess = null; + nativeMacCaptureTargetPath = null; + nativeMacCaptureRecordingId = null; + nativeMacCursorOffsetMs = 0; + nativeMacCursorCaptureMode = "editable-overlay"; + nativeMacCursorRecordingStartMs = 0; + nativeMacPauseStartedAtMs = null; + nativeMacPauseRanges = []; + nativeMacIsPaused = false; + const source = selectedSource || { name: "Screen" }; + if (onRecordingStateChange) { + onRecordingStateChange(false, source.name); + } + } + }); + + ipcMain.handle( + "attach-native-mac-webcam-recording", + async (_, payload: AttachNativeMacWebcamRecordingInput) => { + try { + if (process.platform !== "darwin") { + return { success: false, error: "Native macOS webcam attachment requires macOS." }; + } + + const screenVideoPath = normalizeVideoSourcePath(payload.screenVideoPath); + if (!screenVideoPath || !isPathWithinDir(screenVideoPath, RECORDINGS_DIR)) { + return { + success: false, + error: "Native macOS webcam attachment requires a recording output path.", + }; + } + + await fs.access(screenVideoPath, fsConstants.R_OK); + + if (!payload.webcam?.fileName || !payload.webcam.videoData) { + return { success: false, error: "Native macOS webcam attachment is missing video data." }; + } + + const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); + await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + + const createdAt = + typeof payload.recordingId === "number" && Number.isFinite(payload.recordingId) + ? payload.recordingId + : Date.now(); + const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode); + const session: RecordingSession = { + screenVideoPath, + webcamVideoPath, + createdAt, + ...(cursorCaptureMode ? { cursorCaptureMode } : {}), + }; + setCurrentRecordingSessionState(session); + currentProjectPath = null; + + const sessionManifestPath = path.join( + RECORDINGS_DIR, + `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, + ); + await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + + return { + success: true, + path: screenVideoPath, + session, + message: "Native macOS webcam recording attached successfully", + }; + } catch (error) { + console.error("Failed to attach native macOS webcam recording:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + ); + + // On-disk write streams for in-progress recordings, keyed by output file name. + // Chunks are appended as they arrive from ondataavailable so the renderer + // never buffers the full video in memory (the #616 fix). + const recordingStreams = new RecordingStreamRegistry(); + registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); + ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { return await storeRecordedSessionFiles(payload); @@ -1321,12 +2193,37 @@ export function registerIpcHandlers( : Date.now(); const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode); const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName); - await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); + const screenStreamed = await finalizeRecordingFile( + recordingStreams, + payload.screen.fileName, + screenVideoPath, + payload.screen.videoData, + ); let webcamVideoPath: string | undefined; + let webcamStreamed = false; if (payload.webcam) { webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); - await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + webcamStreamed = await finalizeRecordingFile( + recordingStreams, + payload.webcam.fileName, + webcamVideoPath, + payload.webcam.videoData, + ); + } + + // Streamed files lack the WebM Duration header (the renderer no longer holds + // the blob to patch). Patch on disk so the editor's seek bar and timeline + // work. Best-effort and independent per file, so the patches run together. + if (isValidDurationMs(payload.durationMs)) { + const patches: Promise[] = []; + if (screenStreamed) { + patches.push(patchWebmDurationOnDisk(screenVideoPath, payload.durationMs)); + } + if (webcamStreamed && webcamVideoPath) { + patches.push(patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs)); + } + await Promise.all(patches); } const session: RecordingSession = webcamVideoPath @@ -1620,6 +2517,19 @@ export function registerIpcHandlers( } }); + ipcMain.handle("prepare-preview-audio-track", async (_, filePath: string) => { + try { + return await prepareSupplementalPreviewAudioTrack(filePath); + } catch (error) { + console.error("Failed to prepare preview audio track:", error); + return { + success: false, + message: "Failed to prepare preview audio track", + error: String(error), + }; + } + }); + ipcMain.handle( "save-project-file", async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { diff --git a/electron/ipc/recordingStream.test.ts b/electron/ipc/recordingStream.test.ts new file mode 100644 index 00000000..776fcf12 --- /dev/null +++ b/electron/ipc/recordingStream.test.ts @@ -0,0 +1,84 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { RecordingStreamRegistry } from "./recordingStream"; + +describe("RecordingStreamRegistry", () => { + let dir: string; + const pathFor = (name: string) => path.join(dir, name); + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("streams chunks to disk in order and reports streamed on finalize", async () => { + const registry = new RecordingStreamRegistry(); + await registry.open("rec.webm", pathFor("rec.webm")); + await registry.append("rec.webm", Buffer.from("hello ")); + await registry.append("rec.webm", Buffer.from("world")); + + const streamed = await registry.finalize("rec.webm"); + + expect(streamed).toBe(true); + expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world"); + // A second finalize has nothing to close. + expect(await registry.finalize("rec.webm")).toBe(false); + }); + + it("reports not-streamed when no stream was opened", async () => { + const registry = new RecordingStreamRegistry(); + expect(await registry.finalize("missing.webm")).toBe(false); + expect(registry.has("missing.webm")).toBe(false); + }); + + it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => { + const registry = new RecordingStreamRegistry(); + // Parent directory does not exist, so createWriteStream emits 'error' on open. + await expect( + registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")), + ).rejects.toThrow(); + // A failed open must not register a stream the renderer would treat as live. + expect(registry.has("rec.webm")).toBe(false); + }); + + it("rejects append when no stream is open", async () => { + const registry = new RecordingStreamRegistry(); + await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow( + /No active recording stream/, + ); + }); + + it("discard closes the stream and removes the partial file", async () => { + const registry = new RecordingStreamRegistry(); + await registry.open("rec.webm", pathFor("rec.webm")); + await registry.append("rec.webm", Buffer.from("partial")); + + await registry.discard("rec.webm", pathFor("rec.webm")); + + expect(registry.has("rec.webm")).toBe(false); + await expect(stat(pathFor("rec.webm"))).rejects.toThrow(); + // Nothing left to finalize after a discard. + expect(await registry.finalize("rec.webm")).toBe(false); + }); + + it("discard tolerates a missing file", async () => { + const registry = new RecordingStreamRegistry(); + await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined(); + }); + + it("opening the same file twice replaces the prior stream", async () => { + const registry = new RecordingStreamRegistry(); + await registry.open("rec.webm", pathFor("rec.webm")); + await registry.append("rec.webm", Buffer.from("first")); + await registry.open("rec.webm", pathFor("rec.webm")); + await registry.append("rec.webm", Buffer.from("second")); + await registry.finalize("rec.webm"); + + expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second"); + }); +}); diff --git a/electron/ipc/recordingStream.ts b/electron/ipc/recordingStream.ts new file mode 100644 index 00000000..3dce5b95 --- /dev/null +++ b/electron/ipc/recordingStream.ts @@ -0,0 +1,147 @@ +import { createWriteStream, type WriteStream } from "node:fs"; +import { unlink } from "node:fs/promises"; +import type { IpcMain } from "electron"; + +/** + * Owns the lifecycle of on-disk write streams for in-progress recordings, keyed + * by the recording's output file name. Browser MediaRecorder chunks are appended + * here as they arrive so a long recording never buffers the whole video in the + * renderer (the #616 fix). + * + * The file name is the key because it is the one value the renderer and main + * process already exchange and it is globally unique per recording, so there is + * no derived/offset key to keep in sync across the IPC boundary. + */ +export class RecordingStreamRegistry { + private readonly streams = new Map(); + + /** + * Open a write stream and resolve only once the OS confirms it is writable. + * Resolving on the `open` event (rather than on `createWriteStream` returning) + * means a bad path or permission error rejects here instead of surfacing as a + * silent chunk drop later, so the renderer's fallback can take over. + */ + async open(fileName: string, filePath: string): Promise { + await this.endStream(fileName); + + const ws = createWriteStream(filePath, { flags: "w" }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + ws.once("error", onError); + ws.once("open", () => { + ws.removeListener("error", onError); + resolve(); + }); + }); + // Keep a listener for the stream's lifetime so a late error logs rather + // than crashing the main process with an unhandled 'error' event. Per-write + // failures still surface through the `append` callback below. + ws.on("error", (error) => { + console.error(`[recording-stream] ${fileName}:`, error); + }); + + this.streams.set(fileName, ws); + } + + has(fileName: string): boolean { + return this.streams.has(fileName); + } + + /** Append a chunk; rejects if no stream is open or the write fails. */ + async append(fileName: string, chunk: Buffer): Promise { + const ws = this.streams.get(fileName); + if (!ws) { + throw new Error(`No active recording stream for ${fileName}`); + } + await new Promise((resolve, reject) => { + ws.write(chunk, (error) => (error ? reject(error) : resolve())); + }); + } + + /** + * Flush and close the stream, keeping the file. Returns whether a stream was + * open — i.e. whether the recording was streamed to disk (true) or needs its + * in-memory buffer written by the caller (false). + */ + async finalize(fileName: string): Promise { + const ws = this.streams.get(fileName); + if (!ws) { + return false; + } + this.streams.delete(fileName); + await new Promise((resolve, reject) => { + ws.end((error?: Error | null) => (error ? reject(error) : resolve())); + }); + return true; + } + + /** + * Close the stream (if any) and delete the partial file. Used when a streamed + * recording is discarded or fails before a successful save, so cancelled runs + * don't leak file descriptors or orphan partial recordings on disk. + */ + async discard(fileName: string, filePath: string): Promise { + await this.endStream(fileName); + await unlink(filePath).catch(() => undefined); + } + + private async endStream(fileName: string): Promise { + const ws = this.streams.get(fileName); + if (!ws) { + return; + } + this.streams.delete(fileName); + await new Promise((resolve) => ws.end(() => resolve())); + } +} + +/** + * Register the streaming IPC handlers. Thin wrappers that translate the + * registry's throw-on-failure contract into the `{ success, error }` shape the + * renderer expects. + */ +export function registerRecordingStreamHandlers( + ipcMain: IpcMain, + registry: RecordingStreamRegistry, + resolveRecordingOutputPath: (fileName: string) => string, +): void { + ipcMain.handle( + "open-recording-stream", + async (_, fileName: string): Promise<{ success: boolean; error?: string }> => { + try { + await registry.open(fileName, resolveRecordingOutputPath(fileName)); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle( + "append-recording-chunk", + async ( + _, + fileName: string, + chunk: ArrayBuffer, + ): Promise<{ success: boolean; error?: string }> => { + try { + await registry.append(fileName, Buffer.from(chunk)); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle( + "close-recording-stream", + async (_, fileName: string): Promise<{ success: boolean; error?: string }> => { + try { + await registry.discard(fileName, resolveRecordingOutputPath(fileName)); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); +} diff --git a/electron/main.ts b/electron/main.ts index 24d06ac1..d668285f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, - desktopCapturer, ipcMain, Menu, nativeImage, @@ -12,8 +11,14 @@ import { systemPreferences, Tray, } from "electron"; +import { ShortcutBinding } from "../src/lib/shortcuts"; +import { + loadAndRegisterGlobalShortcut, + registerOpenAppShortcut, + unregisterAllGlobalShortcuts, +} from "./globalShortcut"; import { mainT, setMainLocale } from "./i18n"; -import { registerIpcHandlers } from "./ipc/handlers"; +import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; import { createCountdownOverlayWindow, createEditorWindow, @@ -441,6 +446,10 @@ app.on("activate", () => { } }); +app.on("will-quit", () => { + unregisterAllGlobalShortcuts(); +}); + // Register all IPC handlers when app is ready app.whenReady().then(async () => { // Force the app into "regular" activation policy so the Dock icon appears. @@ -477,23 +486,30 @@ app.whenReady().then(async () => { callback(allowed.includes(permission)); }); - // Request microphone and screen recording permissions from macOS + session.defaultSession.setDisplayMediaRequestHandler( + (request, callback) => { + const source = getSelectedDesktopSource(); + if (!request.videoRequested || !source) { + callback({}); + return; + } + + callback({ + video: source, + ...(request.audioRequested && process.platform === "win32" ? { audio: "loopback" } : {}), + }); + }, + { useSystemPicker: false }, + ); + + // Request microphone permission from macOS. Screen Recording is requested + // lazily from the source-picker action so the system prompt is not hidden + // behind OpenScreen's source selector window. if (process.platform === "darwin") { const micStatus = systemPreferences.getMediaAccessStatus("microphone"); if (micStatus !== "granted") { await systemPreferences.askForMediaAccess("microphone"); } - - // Screen recording has no askForMediaAccess equivalent — the TCC prompt is - // triggered by the first desktopCapturer.getSources() call. Firing it here - // at startup settles the permission state early and prevents repeated prompts - // driven by later getSources() calls (fixes repeated permission dialog). - const screenStatus = systemPreferences.getMediaAccessStatus("screen"); - if (screenStatus === "not-determined") { - desktopCapturer.getSources({ types: ["screen"] }).catch(() => { - // This only triggers the system prompt; permission state is read separately. - }); - } } // Listen for HUD overlay quit event (macOS only) @@ -506,6 +522,11 @@ app.whenReady().then(async () => { updateTrayMenu(); }); + ipcMain.handle("update-global-shortcut", (_, binding: ShortcutBinding) => { + const success = registerOpenAppShortcut(binding, showMainWindow); + return { success }; + }); + createTray(); updateTrayMenu(); setupApplicationMenu(); @@ -539,5 +560,8 @@ app.whenReady().then(async () => { }, switchToHudWrapper, ); + + await loadAndRegisterGlobalShortcut(showMainWindow); + createWindow(); }); diff --git a/electron/native-bridge/cursor/recording/factory.ts b/electron/native-bridge/cursor/recording/factory.ts index e072b75d..0ba30778 100644 --- a/electron/native-bridge/cursor/recording/factory.ts +++ b/electron/native-bridge/cursor/recording/factory.ts @@ -1,4 +1,5 @@ import type { Rectangle } from "electron"; +import { MacNativeCursorRecordingSession } from "./macNativeCursorRecordingSession"; import type { CursorRecordingSession } from "./session"; import { TelemetryRecordingSession } from "./telemetryRecordingSession"; import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession"; @@ -25,9 +26,17 @@ export function createCursorRecordingSession( }); } - // macOS / Linux: capture cursor positions via Electron's `screen` API on an - // interval. No cursor sprites/assets and no clicks — just position telemetry, - // which is what auto-zoom and other features consume. + if (options.platform === "darwin") { + return new MacNativeCursorRecordingSession({ + getDisplayBounds: options.getDisplayBounds, + maxSamples: options.maxSamples, + sampleIntervalMs: options.sampleIntervalMs, + startTimeMs: options.startTimeMs, + }); + } + + // Linux: capture cursor positions via Electron's `screen` API on an interval. + // No cursor sprites/assets and no clicks — just position telemetry. return new TelemetryRecordingSession({ getDisplayBounds: options.getDisplayBounds, maxSamples: options.maxSamples, diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts new file mode 100644 index 00000000..95ed10ce --- /dev/null +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts @@ -0,0 +1,411 @@ +import { type ChildProcessByStdio, spawn } from "node:child_process"; +import { accessSync, constants as fsConstants } from "node:fs"; +import path from "node:path"; +import type { Readable } from "node:stream"; +import { type Rectangle, screen, systemPreferences } from "electron"; +import type { + CursorRecordingData, + CursorRecordingSample, + NativeCursorType, +} from "../../../../src/native/contracts"; +import type { CursorRecordingSession } from "./session"; + +interface MacNativeCursorRecordingSessionOptions { + getDisplayBounds: () => Rectangle | null; + maxSamples: number; + sampleIntervalMs: number; + startTimeMs?: number; +} + +type MacCursorEvent = + | { + type: "ready"; + timestampMs: number; + accessibilityTrusted?: boolean; + mouseTapReady?: boolean; + } + | { + type: "sample"; + timestampMs: number; + cursorType?: NativeCursorType | null; + leftButtonDown?: boolean; + leftButtonPressed?: boolean; + leftButtonReleased?: boolean; + }; + +const HELPER_NAME = "openscreen-macos-cursor-helper"; +const READY_TIMEOUT_MS = 5_000; + +function helperCandidates() { + const envPath = process.env.OPENSCREEN_MAC_CURSOR_HELPER_EXE?.trim(); + const appRoot = process.env.APP_ROOT ? path.resolve(process.env.APP_ROOT) : process.cwd(); + const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + const resourceRoot = + typeof process.resourcesPath === "string" + ? process.resourcesPath + : path.join(appRoot, "resources"); + + return [ + envPath, + path.join(appRoot, "electron", "native", "screencapturekit", "build", HELPER_NAME), + path.join(appRoot, "electron", "native", "bin", archTag, HELPER_NAME), + path.join(resourceRoot, "electron", "native", "bin", archTag, HELPER_NAME), + ].filter((candidate): candidate is string => Boolean(candidate)); +} + +export function findMacCursorHelperPath() { + for (const candidate of helperCandidates()) { + try { + accessSync(candidate, fsConstants.X_OK); + return candidate; + } catch { + // Try the next helper location. + } + } + + return null; +} + +export async function requestMacCursorAccessibilityAccess() { + if (process.platform !== "darwin") { + return { success: true, granted: true, status: "granted" }; + } + + try { + systemPreferences.isTrustedAccessibilityClient(true); + } catch { + // Continue with helper probing; it can trigger the same macOS prompt. + } + + const helperPath = findMacCursorHelperPath(); + if (!helperPath) { + return { success: true, granted: false, status: "missing-helper" }; + } + + return new Promise<{ success: boolean; granted: boolean; status: string; error?: string }>( + (resolve) => { + const child = spawn(helperPath, [JSON.stringify({ sampleIntervalMs: 250 })], { + stdio: ["ignore", "pipe", "pipe"], + }); + let settled = false; + let lineBuffer = ""; + const finish = (result: { + success: boolean; + granted: boolean; + status: string; + error?: string; + }) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (!child.killed) { + child.kill("SIGTERM"); + } + resolve(result); + }; + const timer = setTimeout(() => { + finish({ + success: false, + granted: false, + status: "timeout", + error: "Timed out waiting for macOS cursor helper", + }); + }, READY_TIMEOUT_MS); + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + lineBuffer += chunk; + const lines = lineBuffer.split(/\r?\n/); + lineBuffer = lines.pop() ?? ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const event = JSON.parse(trimmed) as MacCursorEvent; + if (event.type === "ready") { + finish({ + success: true, + granted: event.accessibilityTrusted === true, + status: event.accessibilityTrusted === true ? "granted" : "not-determined", + }); + return; + } + } catch { + // Ignore non-JSON helper output. + } + } + }); + + child.once("error", (error) => { + finish({ + success: false, + granted: false, + status: "error", + error: error.message, + }); + }); + child.once("exit", (code, signal) => { + finish({ + success: false, + granted: false, + status: "exited", + error: `macOS cursor helper exited before ready (code=${code}, signal=${signal})`, + }); + }); + }, + ); +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function normalizeCursorType(value: unknown): NativeCursorType | null { + return value === "arrow" || value === "pointer" || value === "text" ? value : null; +} + +export class MacNativeCursorRecordingSession implements CursorRecordingSession { + private samples: CursorRecordingSample[] = []; + private process: ChildProcessByStdio | null = null; + private lineBuffer = ""; + private startTimeMs = 0; + private fallbackInterval: NodeJS.Timeout | null = null; + private readyResolve: (() => void) | null = null; + private readyReject: ((error: Error) => void) | null = null; + private readyTimer: NodeJS.Timeout | null = null; + private previousLeftButtonDown = false; + private consecutiveOutsideSamples = 0; + // Only hide after this many consecutive out-of-bounds samples (≈100ms at 33ms interval). + // Fast swipes that briefly exit the display are clipped by clip-path instead of disappearing. + private static readonly OUTSIDE_HIDE_THRESHOLD = 3; + + constructor(private readonly options: MacNativeCursorRecordingSessionOptions) {} + + async start(): Promise { + this.samples = []; + this.lineBuffer = ""; + this.startTimeMs = this.options.startTimeMs ?? Date.now(); + this.previousLeftButtonDown = false; + this.consecutiveOutsideSamples = 0; + + try { + systemPreferences.isTrustedAccessibilityClient(true); + } catch { + // Link cursor detection degrades to arrow when Accessibility is unavailable. + } + + const helperPath = findMacCursorHelperPath(); + if (!helperPath) { + this.startPositionOnlyFallback(); + return; + } + + const child = spawn( + helperPath, + [ + JSON.stringify({ + sampleIntervalMs: this.options.sampleIntervalMs, + }), + ], + { + stdio: ["ignore", "pipe", "pipe"], + }, + ); + this.process = child; + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => this.handleStdoutChunk(chunk)); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + const message = chunk.trim(); + if (message) { + console.error("[cursor-macos]", message); + } + }); + child.once("exit", (code, signal) => { + this.rejectReady( + new Error(`macOS cursor helper exited before ready (code=${code}, signal=${signal})`), + ); + this.process = null; + }); + child.once("error", (error) => { + this.rejectReady(error); + this.process = null; + }); + + try { + await this.waitUntilReady(); + } catch (error) { + this.killHelperProcess(child); + this.process = null; + console.warn("[cursor-macos] falling back to position-only cursor telemetry:", error); + this.startPositionOnlyFallback(); + } + } + + async stop(): Promise { + const child = this.process; + this.process = null; + this.clearReadyState(); + + if (this.fallbackInterval) { + clearInterval(this.fallbackInterval); + this.fallbackInterval = null; + } + + if (child) { + this.killHelperProcess(child); + } + + return { + version: 2, + provider: "none", + samples: this.samples, + assets: [], + }; + } + + private startPositionOnlyFallback() { + this.captureSample(Date.now(), null, false, false, false); + this.fallbackInterval = setInterval(() => { + this.captureSample(Date.now(), null, false, false, false); + }, this.options.sampleIntervalMs); + } + + private handleStdoutChunk(chunk: string) { + this.lineBuffer += chunk; + const lines = this.lineBuffer.split(/\r?\n/); + this.lineBuffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) { + continue; + } + + try { + this.handleEvent(JSON.parse(trimmedLine) as MacCursorEvent); + } catch (error) { + console.error("Failed to parse macOS cursor helper output:", error, trimmedLine); + } + } + } + + private handleEvent(payload: MacCursorEvent) { + if (payload.type === "ready") { + if (payload.accessibilityTrusted === false) { + console.warn( + "[cursor-macos] Accessibility is not trusted; cursor shape detection will be arrow-only.", + ); + } + this.resolveReady(); + return; + } + + if (payload.type === "sample") { + this.captureSample( + payload.timestampMs, + normalizeCursorType(payload.cursorType), + payload.leftButtonDown === true, + payload.leftButtonPressed === true, + payload.leftButtonReleased === true, + ); + } + } + + private captureSample( + timestampMs: number, + cursorType: NativeCursorType | null, + leftButtonDown: boolean, + leftButtonPressed: boolean, + leftButtonReleased: boolean, + ) { + const cursor = screen.getCursorScreenPoint(); + const bounds = this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(cursor).bounds; + const width = Math.max(1, bounds.width); + const height = Math.max(1, bounds.height); + const normalizedX = (cursor.x - bounds.x) / width; + const normalizedY = (cursor.y - bounds.y) / height; + const isOutsideDisplay = + normalizedX < 0 || normalizedX > 1 || normalizedY < 0 || normalizedY > 1; + // Fast swipes that briefly exit the display ( this.options.maxSamples) { + this.samples.shift(); + } + } + + private waitUntilReady() { + return new Promise((resolve, reject) => { + this.readyResolve = resolve; + this.readyReject = reject; + this.readyTimer = setTimeout(() => { + this.rejectReady(new Error("Timed out waiting for macOS cursor helper")); + }, READY_TIMEOUT_MS); + }); + } + + private resolveReady() { + const resolve = this.readyResolve; + this.clearReadyState(); + resolve?.(); + } + + private rejectReady(error: Error) { + const reject = this.readyReject; + this.clearReadyState(); + reject?.(error); + } + + private clearReadyState() { + if (this.readyTimer) { + clearTimeout(this.readyTimer); + this.readyTimer = null; + } + this.readyResolve = null; + this.readyReject = null; + } + + private killHelperProcess(child: ChildProcessByStdio) { + if (child.killed) { + return; + } + + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, 500).unref(); + } +} diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts deleted file mode 100644 index bed037a1..00000000 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts +++ /dev/null @@ -1,391 +0,0 @@ -export function buildPowerShellScript(sampleIntervalMs: number, windowHandle?: string | null) { - const targetWindowHandle = - typeof windowHandle === "string" && /^(?:0x[0-9a-fA-F]+|\d+)$/.test(windowHandle) - ? `'${windowHandle}'` - : "$null"; - const script = String.raw` -$ErrorActionPreference = 'Stop' -Add-Type -AssemblyName System.Drawing -Add-Type -AssemblyName System.Windows.Forms - -$targetWindowHandle = ${targetWindowHandle} - -$source = @" -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; - -public static class OpenScreenCursorInterop { - private const int WH_MOUSE_LL = 14; - private const int WM_LBUTTONDOWN = 0x0201; - private const int WM_LBUTTONUP = 0x0202; - private static readonly object MouseSync = new object(); - private static int LeftDownCount = 0; - private static int LeftUpCount = 0; - private static IntPtr MouseHook = IntPtr.Zero; - private static LowLevelMouseProc MouseProcDelegate = MouseHookCallback; - - public delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam); - - public struct MouseButtonEvents { - public int LeftDownCount; - public int LeftUpCount; - } - - [StructLayout(LayoutKind.Sequential)] - public struct POINT { - public int X; - public int Y; - } - - [StructLayout(LayoutKind.Sequential)] - public struct CURSORINFO { - public int cbSize; - public int flags; - public IntPtr hCursor; - public POINT ptScreenPos; - } - - [StructLayout(LayoutKind.Sequential)] - public struct ICONINFO { - [MarshalAs(UnmanagedType.Bool)] - public bool fIcon; - public int xHotspot; - public int yHotspot; - public IntPtr hbmMask; - public IntPtr hbmColor; - } - - [StructLayout(LayoutKind.Sequential)] - public struct RECT { - public int Left; - public int Top; - public int Right; - public int Bottom; - } - - public static bool InstallMouseHook() { - if (MouseHook != IntPtr.Zero) { - return true; - } - - using (Process process = Process.GetCurrentProcess()) - using (ProcessModule module = process.MainModule) { - MouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseProcDelegate, GetModuleHandle(module.ModuleName), 0); - } - - return MouseHook != IntPtr.Zero; - } - - public static MouseButtonEvents ConsumeMouseButtonEvents() { - lock (MouseSync) { - MouseButtonEvents events = new MouseButtonEvents { - LeftDownCount = LeftDownCount, - LeftUpCount = LeftUpCount - }; - LeftDownCount = 0; - LeftUpCount = 0; - return events; - } - } - - private static IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) { - if (nCode >= 0) { - int message = wParam.ToInt32(); - if (message == WM_LBUTTONDOWN || message == WM_LBUTTONUP) { - lock (MouseSync) { - if (message == WM_LBUTTONDOWN) { - LeftDownCount += 1; - } else { - LeftUpCount += 1; - } - } - } - } - - return CallNextHookEx(MouseHook, nCode, wParam, lParam); - } - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool GetCursorInfo(ref CURSORINFO pci); - - [DllImport("user32.dll")] - public static extern short GetAsyncKeyState(int vKey); - - [DllImport("user32.dll", SetLastError = true)] - public static extern IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool IsWindow(IntPtr hWnd); - - [DllImport("user32.dll", SetLastError = true)] - public static extern IntPtr CopyIcon(IntPtr hIcon); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool DestroyIcon(IntPtr hIcon); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo); - - [DllImport("gdi32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool DeleteObject(IntPtr hObject); - - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId); - - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); - - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern IntPtr GetModuleHandle(string lpModuleName); -} -"@ - -Add-Type -TypeDefinition $source - -$standardCursors = @{ - arrow = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32512)) - text = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32513)) - wait = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32514)) - crosshair = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32515)) - 'up-arrow' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32516)) - 'resize-nwse' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32642)) - 'resize-nesw' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32643)) - 'resize-ew' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32644)) - 'resize-ns' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32645)) - move = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32646)) - 'not-allowed' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32648)) - pointer = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32649)) - 'app-starting' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32650)) - help = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32651)) -} - -function Get-StandardCursorType($cursorHandle) { - if ($cursorHandle -eq [IntPtr]::Zero) { - return $null - } - - foreach ($entry in $standardCursors.GetEnumerator()) { - if ($entry.Value -eq $cursorHandle) { - return $entry.Key - } - } - - return $null -} - -function Write-JsonLine($payload) { - [Console]::Out.WriteLine(($payload | ConvertTo-Json -Compress -Depth 6)) -} - -function Get-CustomCursorType($bitmap, $hotspotX, $hotspotY) { - if ($bitmap.Width -lt 24 -or $bitmap.Height -lt 24 -or $bitmap.Width -gt 64 -or $bitmap.Height -gt 64) { - 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 - $top = $bitmap.Height - $right = -1 - $bottom = -1 - - for ($y = 0; $y -lt $bitmap.Height; $y++) { - for ($x = 0; $x -lt $bitmap.Width; $x++) { - if ($bitmap.GetPixel($x, $y).A -le 32) { - continue - } - - $opaquePixels += 1 - if ($y -lt ($bitmap.Height / 2)) { - $topHalfOpaquePixels += 1 - } - if ($x -lt $left) { $left = $x } - if ($x -gt $right) { $right = $x } - if ($y -lt $top) { $top = $y } - if ($y -gt $bottom) { $bottom = $y } - } - } - - if ($opaquePixels -lt 90 -or $right -lt $left -or $bottom -lt $top) { - return $null - } - - $opaqueWidth = $right - $left + 1 - $opaqueHeight = $bottom - $top + 1 - 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 - } - - if ($top -gt ($bitmap.Height * 0.45) -or $bottom -lt ($bitmap.Height * 0.65)) { - return $null - } - - if ($topHalfOpaquePixels -gt ($opaquePixels * 0.55)) { - return 'closed-hand' - } - - return 'open-hand' -} - -function Get-TargetBounds() { - if ([string]::IsNullOrWhiteSpace($targetWindowHandle)) { - return $null - } - - try { - $handleValue = [int64]::Parse($targetWindowHandle) - $windowHandle = [IntPtr]::new($handleValue) - if (-not [OpenScreenCursorInterop]::IsWindow($windowHandle)) { - return $null - } - - $rect = New-Object OpenScreenCursorInterop+RECT - if (-not [OpenScreenCursorInterop]::GetWindowRect($windowHandle, [ref]$rect)) { - return $null - } - - $width = $rect.Right - $rect.Left - $height = $rect.Bottom - $rect.Top - if ($width -le 0 -or $height -le 0) { - return $null - } - - return @{ - x = $rect.Left - y = $rect.Top - width = $width - height = $height - } - } - catch { - return $null - } -} - -function Get-CursorAsset($cursorHandle, $cursorId) { - $copiedHandle = [OpenScreenCursorInterop]::CopyIcon($cursorHandle) - if ($copiedHandle -eq [IntPtr]::Zero) { - return $null - } - - $iconInfo = New-Object OpenScreenCursorInterop+ICONINFO - $hasIconInfo = [OpenScreenCursorInterop]::GetIconInfo($copiedHandle, [ref]$iconInfo) - - 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) - $memoryStream = New-Object System.IO.MemoryStream - - try { - $graphics.Clear([System.Drawing.Color]::Transparent) - $graphics.DrawIcon($icon, 0, 0) - $hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 } - $hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 } - $customCursorType = Get-CustomCursorType -bitmap $bitmap -hotspotX $hotspotX -hotspotY $hotspotY - $bitmap.Save($memoryStream, [System.Drawing.Imaging.ImageFormat]::Png) - $base64 = [System.Convert]::ToBase64String($memoryStream.ToArray()) - - return @{ - id = $cursorId - imageDataUrl = "data:image/png;base64,$base64" - width = $bitmap.Width - height = $bitmap.Height - hotspotX = $hotspotX - hotspotY = $hotspotY - cursorType = $customCursorType - } - } - finally { - $memoryStream.Dispose() - $graphics.Dispose() - $bitmap.Dispose() - $icon.Dispose() - } - } - finally { - if ($hasIconInfo) { - if ($iconInfo.hbmMask -ne [IntPtr]::Zero) { - [OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmMask) | Out-Null - } - if ($iconInfo.hbmColor -ne [IntPtr]::Zero) { - [OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmColor) | Out-Null - } - } - [OpenScreenCursorInterop]::DestroyIcon($copiedHandle) | Out-Null - } -} - -[OpenScreenCursorInterop]::InstallMouseHook() | Out-Null -[OpenScreenCursorInterop]::GetAsyncKeyState(0x01) | Out-Null -Write-JsonLine @{ type = 'ready'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() } - -$lastCursorId = $null -while ($true) { - [System.Windows.Forms.Application]::DoEvents() - $mouseEvents = [OpenScreenCursorInterop]::ConsumeMouseButtonEvents() - $cursorInfo = New-Object OpenScreenCursorInterop+CURSORINFO - $cursorInfo.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type][OpenScreenCursorInterop+CURSORINFO]) - - if (-not [OpenScreenCursorInterop]::GetCursorInfo([ref]$cursorInfo)) { - Write-JsonLine @{ type = 'error'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); message = 'GetCursorInfo failed' } - Start-Sleep -Milliseconds ${sampleIntervalMs} - continue - } - - $visible = ($cursorInfo.flags -band 1) -ne 0 - $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) - $leftButtonDown = ($leftButtonState -band 0x8000) -ne 0 - $leftButtonPressed = ($mouseEvents.LeftDownCount -gt 0) -or (($leftButtonState -band 0x0001) -ne 0) - $leftButtonReleased = $mouseEvents.LeftUpCount -gt 0 - $asset = $null - - if ($visible -and $cursorId -and $cursorId -ne $lastCursorId) { - $asset = Get-CursorAsset -cursorHandle $cursorInfo.hCursor -cursorId $cursorId - if ($asset -and $cursorType) { - $asset.cursorType = $cursorType - } elseif ($asset -and $asset.cursorType) { - $cursorType = $asset.cursorType - } - $lastCursorId = $cursorId - } - - Write-JsonLine @{ - type = 'sample' - timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - x = $cursorInfo.ptScreenPos.X - y = $cursorInfo.ptScreenPos.Y - visible = $visible - handle = $cursorId - cursorType = $cursorType - leftButtonDown = $leftButtonDown - leftButtonPressed = $leftButtonPressed - leftButtonReleased = $leftButtonReleased - bounds = Get-TargetBounds - asset = $asset - } - - Start-Sleep -Milliseconds ${sampleIntervalMs} -} -`; - - return script; -} diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index dd4aab07..5c318f0f 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -1,10 +1,8 @@ import { type ChildProcessByStdio, spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import type { Readable } from "node:stream"; -import { screen } from "electron"; +import { app, screen } from "electron"; import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording"; import type { CursorRecordingData, @@ -12,12 +10,32 @@ import type { NativeCursorAsset, } from "../../../../src/native/contracts"; import type { CursorRecordingSession } from "./session"; -import { buildPowerShellScript } from "./windowsNativeRecordingSession.script"; import type { WindowsCursorEvent, WindowsNativeRecordingSessionOptions, } from "./windowsNativeRecordingSession.types"; +function getCursorSamplerCandidates(): string[] { + const envPath = process.env.OPENSCREEN_CURSOR_SAMPLER_EXE?.trim(); + const archTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64"; + const resolve = (...segs: string[]) => { + const p = join(app.getAppPath(), ...segs); + return app.isPackaged ? p.replace(/\.asar([/\\])/, ".asar.unpacked$1") : p; + }; + return [ + envPath, + resolve("electron", "native", "wgc-capture", "build", "cursor-sampler.exe"), + resolve("electron", "native", "bin", archTag, "cursor-sampler.exe"), + ].filter((c): c is string => Boolean(c)); +} + +function findCursorSamplerPath(): string | null { + for (const candidate of getCursorSamplerCandidates()) { + if (existsSync(candidate)) return candidate; + } + return null; +} + const READY_TIMEOUT_MS = 5_000; interface NormalizedSample { @@ -29,7 +47,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { private assets = new Map(); private samples: CursorRecordingSample[] = []; private process: ChildProcessByStdio | null = null; - private helperScriptPath: string | null = null; private lineBuffer = ""; private startTimeMs = 0; private readyResolve: (() => void) | null = null; @@ -50,41 +67,26 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { this.outOfBoundsSampleCount = 0; this.previousLeftButtonDown = false; - const script = buildPowerShellScript( - this.options.sampleIntervalMs, - parseWindowHandleFromSourceId(this.options.sourceId), - ); - const helperScriptDir = join(tmpdir(), "openscreen-cursor-native"); - mkdirSync(helperScriptDir, { recursive: true }); - const helperScriptPath = join( - helperScriptDir, - `cursor-sampler-${process.pid}-${Date.now()}-${randomUUID()}.ps1`, - ); - writeFileSync(helperScriptPath, script, "utf8"); - this.helperScriptPath = helperScriptPath; - const child = spawn( - "powershell.exe", - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - helperScriptPath, - ], - { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }, - ); + const helperPath = findCursorSamplerPath(); + if (!helperPath) { + throw new Error("Windows cursor sampler helper is not available."); + } + + const windowHandle = parseWindowHandleFromSourceId(this.options.sourceId); + const args = [String(this.options.sampleIntervalMs)]; + if (windowHandle) args.push(windowHandle); + + const child = spawn(helperPath, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); this.process = child; this.logDiagnostic("spawn", { pid: child.pid ?? null, sampleIntervalMs: this.options.sampleIntervalMs, sourceId: this.options.sourceId ?? null, - windowHandle: parseWindowHandleFromSourceId(this.options.sourceId), + windowHandle, }); child.stdout.setEncoding("utf8"); @@ -100,7 +102,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { console.error("[cursor-native]", message); }); child.once("exit", (code, signal) => { - this.cleanupHelperScript(helperScriptPath); this.logDiagnostic("exit", { code, signal, @@ -113,7 +114,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { ); }); child.once("error", (error) => { - this.cleanupHelperScript(helperScriptPath); this.logDiagnostic("process-error", { message: error.message }); this.rejectReady(error); }); @@ -122,7 +122,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { await this.waitUntilReady(); } catch (error) { this.terminateHelperProcess(); - this.cleanupHelperScript(helperScriptPath); throw error; } } @@ -315,25 +314,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { this.readyReject = null; } - private cleanupHelperScript(scriptPath = this.helperScriptPath) { - if (!scriptPath) { - return; - } - - try { - rmSync(scriptPath, { force: true }); - } catch (error) { - this.logDiagnostic("script-cleanup-error", { - path: scriptPath, - message: error instanceof Error ? error.message : String(error), - }); - } finally { - if (this.helperScriptPath === scriptPath) { - this.helperScriptPath = null; - } - } - } - private logDiagnostic(event: string, data: Record) { console.info( "[cursor-native][win32]", diff --git a/electron/native/README.md b/electron/native/README.md index 659829cd..59930ba3 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -1,5 +1,38 @@ # Native capture helpers +## macOS + +macOS native recording will use a ScreenCaptureKit helper with the same process boundary as the Windows WGC helper: + +1. Electron resolves the selected source, output paths, and user-selected devices. +2. The helper receives one structured JSON request. +3. The helper owns ScreenCaptureKit/AVFoundation capture, timing, encoding, and muxing. +4. Electron persists the resulting media/session manifest and reports helper errors explicitly. + +Helper locations: + +1. `OPENSCREEN_SCK_CAPTURE_EXE`, for local development and diagnostics. +2. `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, for locally built Swift output. +3. `electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper` or `electron/native/bin/darwin-x64/openscreen-screencapturekit-helper`, for packaged prebuilt helpers. + +The macOS cursor-shape helper is resolved from `OPENSCREEN_MAC_CURSOR_HELPER_EXE` first, then the matching `openscreen-macos-cursor-helper` binary in the same local build and packaged `electron/native/bin/darwin-${arch}` directories. + +Build the macOS helper with: + +```bash +npm run build:native:mac +``` + +On non-macOS hosts this command exits successfully and does not affect Windows/Linux development. On macOS it builds the Swift package at `electron/native/screencapturekit`, writes the development binaries to `electron/native/screencapturekit/build`, and copies redistributable binaries to `electron/native/bin/darwin-${arch}`. + +The current helper implementation supports display/window ScreenCaptureKit video capture, cursor exclusion through `SCStreamConfiguration.showsCursor`, H.264 encoding, MP4 muxing, and ScreenCaptureKit system audio. It also attempts native ScreenCaptureKit microphone capture when the running macOS version exposes that capability. Webcam recording currently stays as an Electron sidecar and is attached to the same recording session after the native screen capture stops. + +Electron exposes `is-native-mac-capture-available` for capability probing. It resolves the same helper locations listed above and reports `missing-helper` until a Swift helper binary is present. When available, macOS recording routes screen/window capture through the native helper so editable cursor recordings do not bake the system cursor into the video. Cursor positions are sampled in Electron; when the cursor helper is available and Accessibility is granted, samples are also tagged with link/text cursor hints such as `pointer`. + +See `docs/engineering/macos-native-recorder-roadmap.md` for the contract, rollout phases, and SSOT rules. + +## Windows + Windows native recording is resolved from one of these locations: 1. `OPENSCREEN_WGC_CAPTURE_EXE`, for local development and diagnostics. diff --git a/electron/native/screencapturekit/Package.swift b/electron/native/screencapturekit/Package.swift new file mode 100644 index 00000000..ec3b1d98 --- /dev/null +++ b/electron/native/screencapturekit/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "OpenScreenScreenCaptureKitHelper", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable( + name: "openscreen-screencapturekit-helper", + targets: ["OpenScreenScreenCaptureKitHelper"] + ), + .executable( + name: "openscreen-macos-cursor-helper", + targets: ["OpenScreenMacOSCursorHelper"] + ) + ], + targets: [ + .executableTarget( + name: "OpenScreenScreenCaptureKitHelper", + path: "Sources/OpenScreenScreenCaptureKitHelper" + ), + .executableTarget( + name: "OpenScreenMacOSCursorHelper", + path: "Sources/OpenScreenMacOSCursorHelper" + ) + ] +) diff --git a/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift new file mode 100644 index 00000000..672e86f7 --- /dev/null +++ b/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift @@ -0,0 +1,268 @@ +import AppKit +import ApplicationServices +import Foundation + +struct CursorHelperRequest: Decodable { + let sampleIntervalMs: Int? +} + +final class MouseButtonTracker { + private let lock = NSLock() + private var leftDownCount = 0 + private var leftUpCount = 0 + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + + struct Events { + let leftDownCount: Int + let leftUpCount: Int + } + + func start() -> Bool { + let mask = + (1 << CGEventType.leftMouseDown.rawValue) | + (1 << CGEventType.leftMouseUp.rawValue) + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: CGEventMask(mask), + callback: { _, type, event, userInfo in + if let userInfo { + let tracker = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + tracker.record(type) + } + return Unmanaged.passUnretained(event) + }, + userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) else { + return false + } + + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { + return false + } + + eventTap = tap + runLoopSource = source + CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + return true + } + + func pump() { + CFRunLoopRunInMode(.defaultMode, 0.001, false) + } + + func consume() -> Events { + lock.lock() + defer { lock.unlock() } + let events = Events(leftDownCount: leftDownCount, leftUpCount: leftUpCount) + leftDownCount = 0 + leftUpCount = 0 + return events + } + + private func record(_ type: CGEventType) { + lock.lock() + defer { lock.unlock() } + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + reenableTap() + return + } + if type == .leftMouseDown { + leftDownCount += 1 + } else if type == .leftMouseUp { + leftUpCount += 1 + } + } + + private func reenableTap() { + if let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: true) + } + } +} + +func emit(_ fields: [String: Any?]) { + let compacted = fields.compactMapValues { $0 } + if let data = try? JSONSerialization.data(withJSONObject: compacted, options: []), + let line = String(data: data, encoding: .utf8) + { + print(line) + fflush(stdout) + } +} + +func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + guard result == .success else { + return nil + } + + return value as? String +} + +func parentElement(_ element: AXUIElement) -> AXUIElement? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &value) + guard result == .success else { + return nil + } + + guard CFGetTypeID(value) == AXUIElementGetTypeID() else { + return nil + } + + return (value as! AXUIElement) +} + +func roleDescription(_ element: AXUIElement) -> String? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, kAXRoleDescriptionAttribute as CFString, &value) + guard result == .success else { + return nil + } + + return value as? String +} + +func actionNames(_ element: AXUIElement) -> [String] { + var value: CFArray? + let result = AXUIElementCopyActionNames(element, &value) + guard result == .success, let value else { + return [] + } + + return (value as NSArray).compactMap { $0 as? String } +} +func isTextInputRole(_ role: String?) -> Bool { + role == "AXTextField" || + role == "AXTextArea" || + role == "AXTextView" || + role == "AXComboBox" +} + +func isPointerRole(_ role: String?, _ subrole: String?, _ description: String?) -> Bool { + if role == "AXLink" || + subrole?.localizedCaseInsensitiveContains("link") == true || + description?.contains("link") == true + { + return true + } + + return role == "AXButton" || + role == "AXMenuButton" || + role == "AXPopUpButton" || + role == "AXCheckBox" || + role == "AXRadioButton" || + role == "AXSwitch" || + role == "AXDisclosureTriangle" || + role == "AXTab" || + role == "AXMenuItem" +} + +func cursorTypeForElement(_ element: AXUIElement) -> String? { + var current: AXUIElement? = element + + for _ in 0..<5 { + guard let element = current else { + break + } + + let role = stringAttribute(element, kAXRoleAttribute) + let subrole = stringAttribute(element, kAXSubroleAttribute) + let description = roleDescription(element)?.lowercased() + + if isTextInputRole(role) { + return "text" + } + + if isPointerRole(role, subrole, description) { + return "pointer" + } + + current = parentElement(element) + } + + return nil +} + +func accessibilityPointForMouse() -> CGPoint { + let mouse = NSEvent.mouseLocation + let primaryHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0 + return CGPoint(x: mouse.x, y: primaryHeight - mouse.y) +} + +func currentCursorType() -> String? { + guard AXIsProcessTrusted() else { + return nil + } + + let point = accessibilityPointForMouse() + let systemWide = AXUIElementCreateSystemWide() + var element: AXUIElement? + let result = AXUIElementCopyElementAtPosition( + systemWide, + Float(point.x), + Float(point.y), + &element + ) + + guard result == .success, let element else { + return "arrow" + } + + return cursorTypeForElement(element) ?? "arrow" +} + +func timestampMs() -> Int { + Int(Date().timeIntervalSince1970 * 1000) +} + +func leftButtonDown() -> Bool { + CGEventSource.buttonState(.hidSystemState, button: .left) +} + +func requestAccessibilityTrust() -> Bool { + let options = [ + kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true + ] as CFDictionary + return AXIsProcessTrustedWithOptions(options) +} + +let request: CursorHelperRequest +if CommandLine.arguments.count >= 2, + let data = CommandLine.arguments[1].data(using: .utf8), + let decoded = try? JSONDecoder().decode(CursorHelperRequest.self, from: data) +{ + request = decoded +} else { + request = CursorHelperRequest(sampleIntervalMs: nil) +} + +let intervalMs = max(8, request.sampleIntervalMs ?? 33) +let accessibilityTrusted = requestAccessibilityTrust() +let mouseTracker = MouseButtonTracker() +let mouseTapReady = mouseTracker.start() +emit([ + "type": "ready", + "timestampMs": timestampMs(), + "accessibilityTrusted": accessibilityTrusted, + "mouseTapReady": mouseTapReady, +]) + +while true { + mouseTracker.pump() + let mouseEvents = mouseTracker.consume() + emit([ + "type": "sample", + "timestampMs": timestampMs(), + "cursorType": currentCursorType(), + "leftButtonDown": leftButtonDown(), + "leftButtonPressed": mouseEvents.leftDownCount > 0, + "leftButtonReleased": mouseEvents.leftUpCount > 0, + ]) + Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0) +} diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift new file mode 100644 index 00000000..14860b03 --- /dev/null +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift @@ -0,0 +1,673 @@ +import AVFoundation +import CoreGraphics +import CoreMedia +import Foundation +import ScreenCaptureKit + +struct Rectangle: Decodable { + let x: Double + let y: Double + let width: Double + let height: Double +} + +struct RecordingRequest: Decodable { + struct Source: Decodable { + let type: String + let sourceId: String + let displayId: UInt32? + let windowId: UInt32? + let bounds: Rectangle? + } + + struct Video: Decodable { + let fps: Int + let width: Int + let height: Int + let bitrate: Int? + let hideSystemCursor: Bool + } + + struct Audio: Decodable { + struct SystemAudio: Decodable { + let enabled: Bool + } + + struct Microphone: Decodable { + let enabled: Bool + let deviceId: String? + let deviceName: String? + let gain: Double + } + + let system: SystemAudio + let microphone: Microphone + } + + struct Webcam: Decodable { + let enabled: Bool + let deviceId: String? + let deviceName: String? + let width: Int + let height: Int + let fps: Int + } + + struct Cursor: Decodable { + let mode: String + } + + struct Outputs: Decodable { + let screenPath: String + let manifestPath: String? + } + + let schemaVersion: Int? + let recordingId: Int? + let source: Source + let video: Video + let audio: Audio + let webcam: Webcam + let cursor: Cursor + let outputs: Outputs +} + +enum HelperError: Error, CustomStringConvertible { + case invalidArguments + case unsupportedMacOS + case unsupportedFeature(String) + case sourceNotFound(String) + case invalidSourceType(String) + case permissionDenied(String) + case writerSetupFailed(String) + + var description: String { + switch self { + case .invalidArguments: + return "Expected one JSON recording request argument." + case .unsupportedMacOS: + return "ScreenCaptureKit recording requires macOS 13 or newer." + case .unsupportedFeature(let message): + return message + case .sourceNotFound(let message): + return message + case .invalidSourceType(let sourceType): + return "Unsupported source type: \(sourceType)." + case .permissionDenied(let message): + return message + case .writerSetupFailed(let message): + return message + } + } +} + +func emit(_ fields: [String: Any]) { + if let data = try? JSONSerialization.data(withJSONObject: fields, options: []), + let line = String(data: data, encoding: .utf8) + { + print(line) + fflush(stdout) + } +} + +func emitError(code: String, message: String) { + emit([ + "event": "error", + "code": code, + "message": message, + ]) +} + +@available(macOS 13.0, *) +final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { + private struct CaptureTarget { + let filter: SCContentFilter + let width: Int + let height: Int + } + + private let request: RecordingRequest + private let sampleQueue = DispatchQueue(label: "app.openscreen.sck-helper.samples") + private let stateQueue = DispatchQueue(label: "app.openscreen.sck-helper.state") + private var stream: SCStream? + private var writer: AVAssetWriter? + private var videoInput: AVAssetWriterInput? + private var systemAudioInput: AVAssetWriterInput? + private var microphoneAudioInput: AVAssetWriterInput? + private var didStartWriting = false + private var didEmitRecordingStarted = false + private var isStopping = false + private var isPaused = false + private var pauseStartedAt: CMTime? + private var totalPausedDuration = CMTime.zero + private var nativeMicrophoneEnabled = false + private var outputWidth = 1920 + private var outputHeight = 1080 + private let microphoneOutputTypeRawValue = 2 + private let hostClock = CMClockGetHostTimeClock() + + init(request: RecordingRequest) { + self.request = request + } + + func start() async throws { + try ensureRequestedPermissions() + + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: true + ) + let target = try makeCaptureTarget(from: content) + outputWidth = target.width + outputHeight = target.height + let configuration = makeStreamConfiguration() + let stream = SCStream(filter: target.filter, configuration: configuration, delegate: self) + + try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: sampleQueue) + if request.audio.system.enabled { + try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: sampleQueue) + } + if nativeMicrophoneEnabled { + guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else { + throw HelperError.unsupportedFeature( + "Native microphone capture requires a macOS version with ScreenCaptureKit microphone output." + ) + } + try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: sampleQueue) + } + try setupWriter() + + self.stream = stream + emit(["event": "ready", "schemaVersion": 1]) + try await stream.startCapture() + } + + func stop() async { + let shouldStop = stateQueue.sync { + if isStopping { + return false + } + isStopping = true + return true + } + if !shouldStop { + return + } + + do { + try await stream?.stopCapture() + } catch { + emit([ + "event": "warning", + "code": "stop-capture-failed", + "message": "\(error)", + ]) + } + + await finishWriter() + } + + func pause() { + let didPause = stateQueue.sync { + if isStopping || isPaused { + return false + } + + isPaused = true + pauseStartedAt = CMClockGetTime(hostClock) + return true + } + + if didPause { + emit([ + "event": "recording-paused", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + ]) + } + } + + func resume() { + let didResume = stateQueue.sync { + if isStopping || !isPaused { + return false + } + + if let pauseStartedAt { + let now = CMClockGetTime(hostClock) + totalPausedDuration = CMTimeAdd( + totalPausedDuration, + CMTimeSubtract(now, pauseStartedAt) + ) + } + isPaused = false + pauseStartedAt = nil + return true + } + + if didResume { + emit([ + "event": "recording-resumed", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + ]) + } + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + emitError(code: "capture-stopped-with-error", message: "\(error)") + Task { + await stop() + } + } + + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + guard CMSampleBufferDataIsReady(sampleBuffer) else { + return + } + let pauseState = currentPauseState() + if pauseState.paused { + return + } + guard let sampleBuffer = retimedSampleBuffer(sampleBuffer, subtracting: pauseState.offset) else { + return + } + + if type == .audio { + appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput) + return + } + + if type.rawValue == microphoneOutputTypeRawValue { + appendAudioSampleBuffer(sampleBuffer, to: microphoneAudioInput) + return + } + + guard type == .screen else { + return + } + guard isCompleteFrame(sampleBuffer) else { + return + } + guard let videoInput, let writer else { + return + } + let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + if !didStartWriting { + writer.startWriting() + writer.startSession(atSourceTime: presentationTime) + didStartWriting = true + } + + if videoInput.isReadyForMoreMediaData { + if videoInput.append(sampleBuffer), !didEmitRecordingStarted { + didEmitRecordingStarted = true + emit([ + "event": "recording-started", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + "width": outputWidth, + "height": outputHeight, + ]) + } + } + } + + private func ensureRequestedPermissions() throws { + if !CGPreflightScreenCaptureAccess() { + let granted = CGRequestScreenCaptureAccess() + if !granted { + throw HelperError.permissionDenied("Screen recording permission is required for ScreenCaptureKit capture.") + } + } + + if request.audio.microphone.enabled { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + break + case .notDetermined: + let semaphore = DispatchSemaphore(value: 0) + AVCaptureDevice.requestAccess(for: .audio) { _ in + semaphore.signal() + } + let waitResult = semaphore.wait(timeout: .now() + 30) + if waitResult == .timedOut || AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { + throw HelperError.permissionDenied("Microphone permission is required for native microphone capture.") + } + default: + throw HelperError.permissionDenied("Microphone permission is required for native microphone capture.") + } + } + } + + private func makeCaptureTarget(from content: SCShareableContent) throws -> CaptureTarget { + switch request.source.type { + case "display": + guard let displayId = request.source.displayId else { + throw HelperError.sourceNotFound("Display capture requires source.displayId.") + } + guard let display = content.displays.first(where: { $0.displayID == displayId }) else { + throw HelperError.sourceNotFound("No ScreenCaptureKit display found for id \(displayId).") + } + let width = Int(CGDisplayPixelsWide(display.displayID)) + let height = Int(CGDisplayPixelsHigh(display.displayID)) + return CaptureTarget( + filter: SCContentFilter(display: display, excludingWindows: []), + width: clampCaptureDimension(width, fallback: request.video.width), + height: clampCaptureDimension(height, fallback: request.video.height) + ) + case "window": + guard let windowId = request.source.windowId else { + throw HelperError.sourceNotFound("Window capture requires source.windowId.") + } + guard let window = content.windows.first(where: { $0.windowID == windowId }) else { + throw HelperError.sourceNotFound("No ScreenCaptureKit window found for id \(windowId).") + } + let candidateDisplay = content.displays.first { + $0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY)) + } + let scaleFactor = Self.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID()) + let width = Int(window.frame.width) * scaleFactor + let height = Int(window.frame.height) * scaleFactor + return CaptureTarget( + filter: SCContentFilter(desktopIndependentWindow: window), + width: clampCaptureDimension(width, fallback: request.video.width), + height: clampCaptureDimension(height, fallback: request.video.height) + ) + default: + throw HelperError.invalidSourceType(request.source.type) + } + } + + private func makeStreamConfiguration() -> SCStreamConfiguration { + let configuration = SCStreamConfiguration() + configuration.width = outputWidth + configuration.height = outputHeight + configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, request.video.fps))) + configuration.queueDepth = 6 + configuration.showsCursor = !request.video.hideSystemCursor + configuration.pixelFormat = kCVPixelFormatType_32BGRA + configuration.sampleRate = 48_000 + configuration.channelCount = 2 + configuration.excludesCurrentProcessAudio = true + configuration.capturesAudio = request.audio.system.enabled + + if request.audio.microphone.enabled { + guard supportsNativeMicrophoneCapture(streamConfig: configuration) else { + nativeMicrophoneEnabled = false + emit([ + "event": "warning", + "code": "microphone-unavailable", + "message": "Native microphone capture requires ScreenCaptureKit microphone support on this macOS version.", + ]) + return configuration + } + nativeMicrophoneEnabled = true + configuration.capturesAudio = true + configuration.setValue(true, forKey: "captureMicrophone") + if let deviceId = resolveMicrophoneCaptureDeviceID() { + configuration.setValue(deviceId, forKey: "microphoneCaptureDeviceID") + } + } else { + nativeMicrophoneEnabled = false + } + + return configuration + } + + private func setupWriter() throws { + let outputUrl = URL(fileURLWithPath: request.outputs.screenPath) + try? FileManager.default.removeItem(at: outputUrl) + try FileManager.default.createDirectory( + at: outputUrl.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let writer = try AVAssetWriter(outputURL: outputUrl, fileType: .mp4) + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: outputWidth, + AVVideoHeightKey: outputHeight, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: request.video.bitrate ?? 18_000_000, + AVVideoExpectedSourceFrameRateKey: request.video.fps, + ], + ] + let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + input.expectsMediaDataInRealTime = true + + guard writer.canAdd(input) else { + throw HelperError.writerSetupFailed("Unable to add H.264 video input to AVAssetWriter.") + } + + writer.add(input) + self.writer = writer + self.videoInput = input + + if request.audio.system.enabled { + systemAudioInput = try addAudioInput(to: writer, bitRate: 192_000) + } + if nativeMicrophoneEnabled { + microphoneAudioInput = try addAudioInput(to: writer, bitRate: 128_000) + } + } + + private func finishWriter() async { + guard let writer else { + return + } + + videoInput?.markAsFinished() + systemAudioInput?.markAsFinished() + microphoneAudioInput?.markAsFinished() + + await withCheckedContinuation { continuation in + writer.finishWriting { + continuation.resume() + } + } + + if writer.status == .completed { + emit([ + "event": "recording-stopped", + "screenPath": request.outputs.screenPath, + ]) + } else { + emitError( + code: "writer-failed", + message: writer.error.map { "\($0)" } ?? "AVAssetWriter failed with status \(writer.status.rawValue)." + ) + } + } + + private func addAudioInput(to writer: AVAssetWriter, bitRate: Int) throws -> AVAssetWriterInput { + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 48_000, + AVNumberOfChannelsKey: 2, + AVEncoderBitRateKey: bitRate, + ] + let input = AVAssetWriterInput(mediaType: .audio, outputSettings: settings) + input.expectsMediaDataInRealTime = true + + guard writer.canAdd(input) else { + throw HelperError.writerSetupFailed("Unable to add AAC audio input to AVAssetWriter.") + } + + writer.add(input) + return input + } + + private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput?) { + guard didStartWriting else { + return + } + guard let input, input.isReadyForMoreMediaData else { + return + } + + input.append(sampleBuffer) + } + + private func currentPauseState() -> (paused: Bool, offset: CMTime) { + stateQueue.sync { + (isPaused, totalPausedDuration) + } + } + + private func retimedSampleBuffer(_ sampleBuffer: CMSampleBuffer, subtracting offset: CMTime) -> CMSampleBuffer? { + if !offset.isValid || offset == .zero { + return sampleBuffer + } + + let sampleCount = CMSampleBufferGetNumSamples(sampleBuffer) + if sampleCount <= 0 { + return sampleBuffer + } + + var timing = Array(repeating: CMSampleTimingInfo(), count: sampleCount) + let timingStatus = CMSampleBufferGetSampleTimingInfoArray( + sampleBuffer, + entryCount: sampleCount, + arrayToFill: &timing, + entriesNeededOut: nil + ) + if timingStatus != noErr { + emit([ + "event": "warning", + "code": "sample-retime-failed", + "message": "Unable to read sample timing info: \(timingStatus).", + ]) + return sampleBuffer + } + + for index in timing.indices { + if timing[index].presentationTimeStamp.isValid { + timing[index].presentationTimeStamp = CMTimeSubtract( + timing[index].presentationTimeStamp, + offset + ) + } + if timing[index].decodeTimeStamp.isValid { + timing[index].decodeTimeStamp = CMTimeSubtract(timing[index].decodeTimeStamp, offset) + } + } + + var retimedBuffer: CMSampleBuffer? + let copyStatus = CMSampleBufferCreateCopyWithNewTiming( + allocator: kCFAllocatorDefault, + sampleBuffer: sampleBuffer, + sampleTimingEntryCount: sampleCount, + sampleTimingArray: &timing, + sampleBufferOut: &retimedBuffer + ) + if copyStatus != noErr { + emit([ + "event": "warning", + "code": "sample-retime-failed", + "message": "Unable to copy sample timing info: \(copyStatus).", + ]) + return sampleBuffer + } + + return retimedBuffer + } + + private func isCompleteFrame(_ sampleBuffer: CMSampleBuffer) -> Bool { + guard let attachments = CMSampleBufferGetSampleAttachmentsArray( + sampleBuffer, + createIfNecessary: false + ) as? [[SCStreamFrameInfo: Any]], + let attachment = attachments.first, + let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int, + let status = SCFrameStatus(rawValue: statusRawValue) + else { + return true + } + + return status == .complete + } + + private func clampCaptureDimension(_ value: Int, fallback: Int) -> Int { + let requested = max(2, fallback) + let candidate = value > 0 ? value : requested + let clamped = min(candidate, requested) + return max(2, clamped - (clamped % 2)) + } + + private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int { + guard let mode = CGDisplayCopyDisplayMode(displayId) else { + return 1 + } + + return max(1, mode.pixelWidth / max(1, mode.width)) + } + + private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool { + streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) && + streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) && + SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil + } + + private func resolveMicrophoneCaptureDeviceID() -> String? { + let devices = AVCaptureDevice.devices(for: .audio) + + if let deviceName = request.audio.microphone.deviceName?.trimmingCharacters(in: .whitespacesAndNewlines), + !deviceName.isEmpty, + let device = devices.first(where: { $0.localizedName == deviceName }) + { + return device.uniqueID + } + + if let deviceId = request.audio.microphone.deviceId?.trimmingCharacters(in: .whitespacesAndNewlines), + !deviceId.isEmpty, + devices.contains(where: { $0.uniqueID == deviceId }) + { + return deviceId + } + + return nil + } +} + +@main +struct OpenScreenScreenCaptureKitHelper { + static func main() async { + do { + guard CommandLine.arguments.count == 2 else { + throw HelperError.invalidArguments + } + + guard #available(macOS 13.0, *) else { + throw HelperError.unsupportedMacOS + } + + let requestData = Data(CommandLine.arguments[1].utf8) + let decoder = JSONDecoder() + let request = try decoder.decode(RecordingRequest.self, from: requestData) + let recorder = ScreenCaptureRecorder(request: request) + let stopTask = Task.detached { + while let line = readLine() { + let command = line.trimmingCharacters(in: .whitespacesAndNewlines) + switch command { + case "pause": + recorder.pause() + case "resume": + recorder.resume() + case "stop": + await recorder.stop() + exit(0) + default: + break + } + } + } + + try await recorder.start() + await stopTask.value + } catch let error as HelperError { + emitError(code: "helper-error", message: error.description) + exit(1) + } catch { + emitError(code: "helper-error", message: "\(error)") + exit(1) + } + } +} diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 75036581..32c5d6ef 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -49,3 +49,19 @@ target_link_libraries(wgc-capture PRIVATE runtimeobject windowsapp ) + +add_executable(cursor-sampler + src/cursor-sampler.cpp +) + +target_compile_definitions(cursor-sampler PRIVATE + NOMINMAX + _WIN32_WINNT=0x0A00 +) + +target_compile_options(cursor-sampler PRIVATE /EHsc /W4 /utf-8) + +target_link_libraries(cursor-sampler PRIVATE + gdi32 + gdiplus +) diff --git a/electron/native/wgc-capture/src/audio_sample_utils.cpp b/electron/native/wgc-capture/src/audio_sample_utils.cpp index c816fead..6b503250 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils.cpp @@ -279,6 +279,7 @@ bool AudioMixer::start() { stopRequested_ = false; emittedFrames_ = 0; timelineStarted_ = false; + paused_ = false; thread_ = std::thread([this] { mixLoop(); }); @@ -296,6 +297,18 @@ void AudioMixer::beginTimeline() { cv_.notify_all(); } +void AudioMixer::setPaused(bool paused) { + { + std::scoped_lock lock(mutex_); + paused_ = paused; + if (paused_) { + systemQueue_.clear(); + microphoneQueue_.clear(); + } + } + cv_.notify_all(); +} + void AudioMixer::stop() { stopRequested_ = true; cv_.notify_all(); @@ -311,6 +324,9 @@ void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) { { std::scoped_lock lock(mutex_); + if (paused_) { + return; + } append(systemQueue_, data, byteCount, systemFormat_, 1.0); } cv_.notify_all(); @@ -323,6 +339,9 @@ void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) { { std::scoped_lock lock(mutex_); + if (paused_) { + return; + } append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_); } cv_.notify_all(); @@ -371,13 +390,13 @@ void AudioMixer::mixLoop() { const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes; const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty(); return stopRequested_.load() || - (timelineStarted_ && (hasSystem || hasMicrophone) && hasAnySource); + (timelineStarted_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource); }); if (stopRequested_) { break; } - if (!timelineStarted_) { + if (!timelineStarted_ || paused_) { continue; } diff --git a/electron/native/wgc-capture/src/audio_sample_utils.h b/electron/native/wgc-capture/src/audio_sample_utils.h index 4e61252c..0bdbc080 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.h +++ b/electron/native/wgc-capture/src/audio_sample_utils.h @@ -52,6 +52,7 @@ public: bool start(); void beginTimeline(); + void setPaused(bool paused); void stop(); void pushSystem(const BYTE* data, DWORD byteCount); void pushMicrophone(const BYTE* data, DWORD byteCount); @@ -81,5 +82,6 @@ private: std::thread thread_; std::atomic stopRequested_ = false; bool timelineStarted_ = false; + bool paused_ = false; uint64_t emittedFrames_ = 0; }; diff --git a/electron/native/wgc-capture/src/cursor-sampler.cpp b/electron/native/wgc-capture/src/cursor-sampler.cpp new file mode 100644 index 00000000..21558c79 --- /dev/null +++ b/electron/native/wgc-capture/src/cursor-sampler.cpp @@ -0,0 +1,482 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +// Global mouse-hook state +// ───────────────────────────────────────────────────────────────────────────── +static HHOOK g_mouseHook = nullptr; +static DWORD g_mainThreadId = 0; +static std::atomic g_leftDownCount{0}; +static std::atomic g_leftUpCount{0}; +static std::atomic g_stop{false}; +static std::mutex g_stdoutMtx; + +static LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) { + if (nCode >= 0) { + if (wParam == WM_LBUTTONDOWN) g_leftDownCount.fetch_add(1, std::memory_order_relaxed); + else if (wParam == WM_LBUTTONUP) g_leftUpCount.fetch_add(1, std::memory_order_relaxed); + } + return CallNextHookEx(g_mouseHook, nCode, wParam, lParam); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Utilities +// ───────────────────────────────────────────────────────────────────────────── +static int64_t nowMs() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); +} + +static void writeJsonLine(const std::string& json) { + std::lock_guard lock(g_stdoutMtx); + std::cout << json << '\n'; + std::cout.flush(); +} + +static std::string jsonEscape(const std::string& s) { + std::string r; + r.reserve(s.size()); + for (unsigned char c : s) { + switch (c) { + case '"': r += "\\\""; break; + case '\\': r += "\\\\"; break; + case '\n': r += "\\n"; break; + case '\r': r += "\\r"; break; + case '\t': r += "\\t"; break; + default: r.push_back(static_cast(c)); break; + } + } + return r; +} + +static const char kBase64Chars[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static std::string base64Encode(const uint8_t* data, size_t len) { + std::string out; + out.reserve(((len + 2) / 3) * 4); + for (size_t i = 0; i < len; i += 3) { + const uint32_t b = + (static_cast(data[i]) << 16) | + (i + 1 < len ? static_cast(data[i + 1]) << 8 : 0u) | + (i + 2 < len ? static_cast(data[i + 2]) : 0u); + out.push_back(kBase64Chars[(b >> 18) & 0x3F]); + out.push_back(kBase64Chars[(b >> 12) & 0x3F]); + out.push_back(i + 1 < len ? kBase64Chars[(b >> 6) & 0x3F] : '='); + out.push_back(i + 2 < len ? kBase64Chars[(b ) & 0x3F] : '='); + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// GDI+ PNG encoder CLSID +// ───────────────────────────────────────────────────────────────────────────── +static bool getPngClsid(CLSID& out) { + UINT num = 0, sz = 0; + if (Gdiplus::GetImageEncodersSize(&num, &sz) != Gdiplus::Ok || sz == 0) return false; + std::vector buf(sz); + auto* enc = reinterpret_cast(buf.data()); + if (Gdiplus::GetImageEncoders(num, sz, enc) != Gdiplus::Ok) return false; + for (UINT i = 0; i < num; ++i) { + if (std::wstring(enc[i].MimeType) == L"image/png") { + out = enc[i].Clsid; + return true; + } + } + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Standard cursor-type lookup +// ───────────────────────────────────────────────────────────────────────────── +static const char* standardCursorType(HCURSOR hc) { + if (!hc) return nullptr; + static const struct { WORD id; const char* name; } kMap[] = { + {32512, "arrow"}, + {32513, "text"}, + {32514, "wait"}, + {32515, "crosshair"}, + {32516, "up-arrow"}, + {32642, "resize-nwse"}, + {32643, "resize-nesw"}, + {32644, "resize-ew"}, + {32645, "resize-ns"}, + {32646, "move"}, + {32648, "not-allowed"}, + {32649, "pointer"}, + {32650, "app-starting"}, + {32651, "help"}, + }; + static constexpr int N = static_cast(sizeof(kMap) / sizeof(kMap[0])); + static HCURSOR g_handles[N] = {}; + static bool g_init = false; + if (!g_init) { + for (int i = 0; i < N; ++i) + g_handles[i] = LoadCursor(nullptr, MAKEINTRESOURCE(kMap[i].id)); + g_init = true; + } + for (int i = 0; i < N; ++i) + if (g_handles[i] && g_handles[i] == hc) return kMap[i].name; + return nullptr; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Custom cursor-type detection (replicates the PowerShell heuristic) +// ───────────────────────────────────────────────────────────────────────────── +static const char* detectCustomCursorType( + const uint32_t* pixels, int w, int h, int hotX, int hotY) +{ + if (w < 24 || h < 24 || w > 64 || h > 64) return nullptr; + if (hotX < w * 0.25 || hotX > w * 0.75) return nullptr; + if (hotY < h * 0.15 || hotY > h * 0.55) return nullptr; + + int opaque = 0, topHalf = 0; + int left = w, top = h, right = -1, bottom = -1; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + const uint8_t a = static_cast(pixels[y * w + x] >> 24); + if (a <= 32) continue; + ++opaque; + if (y < h / 2) ++topHalf; + if (x < left) left = x; + if (x > right) right = x; + if (y < top) top = y; + if (y > bottom) bottom = y; + } + } + + if (opaque < 90 || right < left || bottom < top) return nullptr; + + const int ow = right - left + 1; + const int oh = bottom - top + 1; + if (ow < w * 0.35 || ow > w * 0.9) return nullptr; + if (oh < h * 0.45 || oh > static_cast(h)) return nullptr; + if (top > h * 0.45 || bottom < h * 0.65) return nullptr; + + return topHalf > opaque * 0.55 ? "closed-hand" : "open-hand"; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Build asset JSON for the given cursor (returns empty string on failure) +// +// Renders the cursor via GDI DrawIconEx onto a 32-bpp transparent DIB section +// and then encodes to PNG — matching the PowerShell approach of +// Graphics.Clear(Transparent) + Graphics.DrawIcon(). This correctly preserves +// per-pixel alpha for 32-bit cursors, unlike Gdiplus::Bitmap::FromHICON which +// can produce incorrect alpha for cursor handles. +// ───────────────────────────────────────────────────────────────────────────── +static std::string buildAssetJson( + HCURSOR hCursor, + const std::string& handleStr, + const CLSID& pngClsid, + const char** outCustomType) +{ + *outCustomType = nullptr; + + // Get hotspot and cursor dimensions from the icon info. + // For color cursors hbmColor gives the size; for monochrome cursors the + // mask bitmap is twice the cursor height (AND mask stacked on XOR mask). + ICONINFO ii{}; + if (!GetIconInfo(hCursor, &ii)) return {}; + const int hotX = static_cast(ii.xHotspot); + const int hotY = static_cast(ii.yHotspot); + + int w = 0, h = 0; + if (ii.hbmColor) { + BITMAP bm{}; + if (GetObject(ii.hbmColor, sizeof(bm), &bm)) { w = bm.bmWidth; h = bm.bmHeight; } + } + if (ii.hbmMask && (w == 0 || h == 0)) { + BITMAP bm{}; + if (GetObject(ii.hbmMask, sizeof(bm), &bm)) { + w = bm.bmWidth; + h = ii.hbmColor ? bm.bmHeight : bm.bmHeight / 2; + } + } + if (ii.hbmMask) DeleteObject(ii.hbmMask); + if (ii.hbmColor) DeleteObject(ii.hbmColor); + if (w <= 0 || h <= 0) return {}; + + // Copy the cursor handle so DrawIconEx cannot affect the live system cursor. + const HICON hCopy = CopyIcon(hCursor); + if (!hCopy) return {}; + + // Allocate a 32-bpp top-down DIB section and clear it to transparent black, + // then draw the cursor with DI_NORMAL. For 32-bit alpha cursors Windows + // writes correct per-pixel alpha into the high byte of each BGRA pixel. + const int stride = w * 4; + BITMAPINFOHEADER bih{}; + bih.biSize = sizeof(bih); + bih.biWidth = w; + bih.biHeight = -h; // negative = top-down scanline order + bih.biPlanes = 1; + bih.biBitCount = 32; + bih.biCompression = BI_RGB; + + void* pBits = nullptr; + HDC hDC = CreateCompatibleDC(nullptr); + HBITMAP hBmp = hDC ? CreateDIBSection(hDC, + reinterpret_cast(&bih), + DIB_RGB_COLORS, &pBits, nullptr, 0) + : nullptr; + + if (!hBmp || !pBits) { + if (hBmp) DeleteObject(hBmp); + if (hDC) DeleteDC(hDC); + DestroyIcon(hCopy); + return {}; + } + + HGDIOBJ hOld = SelectObject(hDC, hBmp); + std::memset(pBits, 0, static_cast(stride * h)); // transparent black + DrawIconEx(hDC, 0, 0, hCopy, w, h, 0, nullptr, DI_NORMAL); + GdiFlush(); + SelectObject(hDC, hOld); + DeleteDC(hDC); + DestroyIcon(hCopy); + + // GDI's 32-bit DIB stores pixels as BGRA in memory. GDI+'s + // PixelFormat32bppARGB interprets each 32-bit word as 0xAARRGGBB which is + // identical to BGRA on little-endian, so the alpha byte is always >> 24. + { + const auto* px = static_cast(pBits); + *outCustomType = detectCustomCursorType(px, w, h, hotX, hotY); + } + + // Wrap the DIB pixels in a GDI+ Bitmap (zero-copy) and save to PNG. + // Keep hBmp alive until after gBmp is destroyed so pBits remains valid. + std::vector pngData; + { + Gdiplus::Bitmap gBmp(w, h, stride, PixelFormat32bppARGB, + static_cast(pBits)); + if (gBmp.GetLastStatus() == Gdiplus::Ok) { + IStream* pStream = nullptr; + if (SUCCEEDED(CreateStreamOnHGlobal(nullptr, TRUE, &pStream))) { + if (gBmp.Save(pStream, &pngClsid) == Gdiplus::Ok) { + ULARGE_INTEGER sz{}; + LARGE_INTEGER zero{}; + pStream->Seek(zero, STREAM_SEEK_END, &sz); + pStream->Seek(zero, STREAM_SEEK_SET, nullptr); + pngData.resize(static_cast(sz.QuadPart)); + ULONG n = 0; + pStream->Read(pngData.data(), static_cast(pngData.size()), &n); + pngData.resize(n); + } + pStream->Release(); + } + } + } // gBmp destroyed here; pBits (owned by hBmp) still valid + DeleteObject(hBmp); + + if (pngData.empty()) return {}; + + const std::string dataUrl = + "data:image/png;base64," + base64Encode(pngData.data(), pngData.size()); + + std::string json; + json.reserve(dataUrl.size() + 128); + json = "{\"id\":\"" + handleStr + "\""; + json += ",\"imageDataUrl\":\"" + jsonEscape(dataUrl) + "\""; + json += ",\"width\":" + std::to_string(w); + json += ",\"height\":" + std::to_string(h); + json += ",\"hotspotX\":" + std::to_string(hotX); + json += ",\"hotspotY\":" + std::to_string(hotY); + if (*outCustomType) { + json += ",\"cursorType\":\""; + json += *outCustomType; + json += "\""; + } else { + json += ",\"cursorType\":null"; + } + json += "}"; + return json; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sampling loop (background thread) +// ───────────────────────────────────────────────────────────────────────────── +static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngClsid) { + HCURSOR lastCursor = nullptr; + + while (!g_stop.load(std::memory_order_relaxed)) { + const int downCount = g_leftDownCount.exchange(0, std::memory_order_relaxed); + const int upCount = g_leftUpCount.exchange(0, std::memory_order_relaxed); + + CURSORINFO ci{}; + ci.cbSize = sizeof(ci); + if (!GetCursorInfo(&ci)) { + char buf[160]; + std::snprintf(buf, sizeof(buf), + "{\"type\":\"error\",\"timestampMs\":%" PRId64 ",\"message\":\"GetCursorInfo failed\"}", + nowMs()); + writeJsonLine(buf); + std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs)); + continue; + } + + const bool visible = (ci.flags & CURSOR_SHOWING) != 0; + const HCURSOR hc = ci.hCursor; + + // Handle string ("0xHEX" or empty for null cursor) + char handleBuf[32] = {}; + if (hc) + std::snprintf(handleBuf, sizeof(handleBuf), + "0x%" PRIX64, static_cast(reinterpret_cast(hc))); + const std::string handleStr = hc ? handleBuf : ""; + + // Standard cursor type + const char* cursorType = standardCursorType(hc); + + // Mouse button state + const SHORT ks = GetAsyncKeyState(VK_LBUTTON); + const bool leftDown = (ks & 0x8000) != 0; + const bool leftPressed = downCount > 0 || (ks & 0x0001) != 0; + const bool leftReleased = upCount > 0; + + // Asset — only when the cursor handle changes + std::string assetJson; + if (visible && hc && hc != lastCursor) { + const char* customType = nullptr; + assetJson = buildAssetJson(hc, handleStr, pngClsid, &customType); + if (!assetJson.empty() && !cursorType && customType) + cursorType = customType; + lastCursor = hc; + } + + // Window bounds + std::string boundsJson = "null"; + if (targetWindow && IsWindow(targetWindow)) { + RECT r{}; + if (GetWindowRect(targetWindow, &r)) { + const int bw = r.right - r.left; + const int bh = r.bottom - r.top; + if (bw > 0 && bh > 0) { + char buf[128]; + std::snprintf(buf, sizeof(buf), + "{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}", + r.left, r.top, bw, bh); + boundsJson = buf; + } + } + } + + // Emit sample JSON + std::string out; + out.reserve(256); + out += "{\"type\":\"sample\""; + out += ",\"timestampMs\":"; out += std::to_string(nowMs()); + out += ",\"x\":"; out += std::to_string(ci.ptScreenPos.x); + out += ",\"y\":"; out += std::to_string(ci.ptScreenPos.y); + out += ",\"visible\":"; out += visible ? "true" : "false"; + out += ",\"handle\":"; out += hc ? ("\"" + handleStr + "\"") : "null"; + out += ",\"cursorType\":"; out += cursorType ? ("\"" + std::string(cursorType) + "\"") : "null"; + out += ",\"leftButtonDown\":"; out += leftDown ? "true" : "false"; + out += ",\"leftButtonPressed\":"; out += leftPressed ? "true" : "false"; + out += ",\"leftButtonReleased\":"; out += leftReleased ? "true" : "false"; + out += ",\"bounds\":"; out += boundsJson; + out += ",\"asset\":"; out += assetJson.empty() ? "null" : assetJson; + out += "}"; + + writeJsonLine(out); + + // Exit if stdout pipe is broken (parent process died) + if (std::cout.fail()) { + PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0); + break; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs)); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// main +// ───────────────────────────────────────────────────────────────────────────── +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "Usage: cursor-sampler [windowHandle]" << std::endl; + return 1; + } + + const int intervalMs = std::max(1, std::atoi(argv[1])); + + HWND targetWindow = nullptr; + if (argc >= 3) { + const std::string arg = argv[2]; + if (!arg.empty() && arg != "null") { + try { + const int base = (arg.rfind("0x", 0) == 0 || arg.rfind("0X", 0) == 0) ? 16 : 10; + const uint64_t v = std::stoull(arg, nullptr, base); + if (v) targetWindow = reinterpret_cast(static_cast(v)); + } catch (...) {} + } + } + + // Initialize GDI+ + Gdiplus::GdiplusStartupInput gdipInput{}; + ULONG_PTR gdipToken = 0; + if (Gdiplus::GdiplusStartup(&gdipToken, &gdipInput, nullptr) != Gdiplus::Ok) { + std::cerr << "GDI+ init failed" << std::endl; + return 1; + } + + CLSID pngClsid{}; + if (!getPngClsid(pngClsid)) { + std::cerr << "PNG encoder not found" << std::endl; + Gdiplus::GdiplusShutdown(gdipToken); + return 1; + } + + // Install global low-level mouse hook on this thread + g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, GetModuleHandle(nullptr), 0); + if (!g_mouseHook) { + std::cerr << "SetWindowsHookEx failed" << std::endl; + Gdiplus::GdiplusShutdown(gdipToken); + return 1; + } + + // Prime GetAsyncKeyState so the first poll doesn't return stale "since-last-call" bits + GetAsyncKeyState(VK_LBUTTON); + + // Signal readiness + g_mainThreadId = GetCurrentThreadId(); + { + char buf[80]; + std::snprintf(buf, sizeof(buf), + "{\"type\":\"ready\",\"timestampMs\":%" PRId64 "}", nowMs()); + writeJsonLine(buf); + } + + // Start sampling on a background thread + std::thread sampler(runSamplingLoop, intervalMs, targetWindow, std::cref(pngClsid)); + + // Run the message pump on the main thread — required for WH_MOUSE_LL callbacks + MSG msg; + while (GetMessage(&msg, nullptr, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + + g_stop.store(true, std::memory_order_relaxed); + if (sampler.joinable()) sampler.join(); + UnhookWindowsHookEx(g_mouseHook); + Gdiplus::GdiplusShutdown(gdipToken); + return 0; +} diff --git a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp index 14cb888c..7e3f8b7a 100644 --- a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp +++ b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp @@ -5,22 +5,18 @@ #include #include +#include #include #include +#include #include +#include namespace { const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}}; const CLSID CLSID_NullRendererLocal = {0xC1F400A4, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}}; -MIDL_INTERFACE("0579154A-2B53-4994-B0D0-E773148EFF85") -ISampleGrabberCB : public IUnknown { -public: - virtual HRESULT STDMETHODCALLTYPE SampleCB(double sampleTime, IMediaSample* sample) = 0; - virtual HRESULT STDMETHODCALLTYPE BufferCB(double sampleTime, BYTE* buffer, long bufferLength) = 0; -}; - MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F") ISampleGrabber : public IUnknown { public: @@ -30,7 +26,7 @@ public: virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0; virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0; virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 0; - virtual HRESULT STDMETHODCALLTYPE SetCallback(ISampleGrabberCB* callback, long whichMethodToCallback) = 0; + virtual HRESULT STDMETHODCALLTYPE SetCallback(IUnknown* callback, long whichMethodToCallback) = 0; }; bool succeeded(HRESULT hr, const char* label) { @@ -43,6 +39,34 @@ bool succeeded(HRESULT hr, const char* label) { return false; } +std::string guidToString(const GUID& guid) { + if (guid == MEDIASUBTYPE_RGB32) { + return "RGB32"; + } + if (guid == MEDIASUBTYPE_YUY2) { + return "YUY2"; + } + if (guid == MEDIASUBTYPE_NV12) { + return "NV12"; + } + + std::ostringstream stream; + stream << std::hex << std::setfill('0') + << '{' << std::setw(8) << guid.Data1 + << '-' << std::setw(4) << guid.Data2 + << '-' << std::setw(4) << guid.Data3 + << '-'; + for (int index = 0; index < 2; index += 1) { + stream << std::setw(2) << static_cast(guid.Data4[index]); + } + stream << '-'; + for (int index = 2; index < 8; index += 1) { + stream << std::setw(2) << static_cast(guid.Data4[index]); + } + stream << '}'; + return stream.str(); +} + void freeMediaType(AM_MEDIA_TYPE& type) { if (type.cbFormat != 0) { CoTaskMemFree(type.pbFormat); @@ -55,6 +79,20 @@ void freeMediaType(AM_MEDIA_TYPE& type) { } } +BYTE clampToByte(int value) { + return static_cast(std::clamp(value, 0, 255)); +} + +std::array yuvToBgr(int y, int u, int v) { + const int c = y - 16; + const int d = u - 128; + const int e = v - 128; + const int blue = (298 * c + 516 * d + 128) >> 8; + const int green = (298 * c - 100 * d - 208 * e + 128) >> 8; + const int red = (298 * c + 409 * e + 128) >> 8; + return {clampToByte(blue), clampToByte(green), clampToByte(red)}; +} + } // namespace struct DirectShowWebcamCapture::Impl { @@ -137,9 +175,8 @@ bool DirectShowWebcamCapture::initialize( AM_MEDIA_TYPE requestedType{}; requestedType.majortype = MEDIATYPE_Video; - requestedType.subtype = MEDIASUBTYPE_RGB32; requestedType.formattype = FORMAT_VideoInfo; - if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow RGB32)")) { + if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow video)")) { return false; } @@ -170,17 +207,40 @@ bool DirectShowWebcamCapture::initialize( if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) { return false; } + if (connectedType.subtype == MEDIASUBTYPE_YUY2) { + pixelFormat_ = PixelFormat::Yuy2; + } else if (connectedType.subtype == MEDIASUBTYPE_NV12) { + pixelFormat_ = PixelFormat::Nv12; + } else if (connectedType.subtype == MEDIASUBTYPE_RGB32) { + pixelFormat_ = PixelFormat::Bgra; + } else { + std::cerr << "ERROR: Unsupported DirectShow webcam media subtype " + << guidToString(connectedType.subtype) << std::endl; + freeMediaType(connectedType); + return false; + } if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) { const auto* videoInfo = reinterpret_cast(connectedType.pbFormat); width_ = std::abs(videoInfo->bmiHeader.biWidth); height_ = std::abs(videoInfo->bmiHeader.biHeight); - sourceTopDown_ = videoInfo->bmiHeader.biHeight < 0; + const int bitsPerPixel = videoInfo->bmiHeader.biBitCount > 0 ? videoInfo->bmiHeader.biBitCount : 16; + if (pixelFormat_ == PixelFormat::Nv12) { + sourceStride_ = ((width_ + 3) / 4) * 4; + } else { + sourceStride_ = ((width_ * bitsPerPixel + 31) / 32) * 4; + } + sourceTopDown_ = pixelFormat_ != PixelFormat::Bgra || videoInfo->bmiHeader.biHeight < 0; } + std::cerr << "INFO: DirectShow webcam connected subtype " << guidToString(connectedType.subtype) + << " " << width_ << "x" << height_ << " stride=" << sourceStride_ << std::endl; freeMediaType(connectedType); if (width_ <= 0 || height_ <= 0) { width_ = requestedWidth > 0 ? requestedWidth : 1280; height_ = requestedHeight > 0 ? requestedHeight : 720; } + if (sourceStride_ <= 0) { + sourceStride_ = pixelFormat_ == PixelFormat::Bgra ? width_ * 4 : ((width_ + 3) / 4) * 4; + } impl_->sampleGrabber->SetBufferSamples(TRUE); impl_->sampleGrabber->SetOneShot(FALSE); @@ -262,36 +322,91 @@ void DirectShowWebcamCapture::captureLoop() { } void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) { - const int stride = width_ * 4; - const int expectedLength = stride * height_; + const int destinationStride = width_ * 4; + const int sourceStride = sourceStride_ > 0 ? sourceStride_ : destinationStride; + const int expectedLength = pixelFormat_ == PixelFormat::Nv12 + ? sourceStride * height_ + sourceStride * ((height_ + 1) / 2) + : sourceStride * height_; if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) { return; } - std::vector frame(static_cast(expectedLength)); + std::vector frame(static_cast(destinationStride * height_)); for (int y = 0; y < height_; y += 1) { const int sourceY = sourceTopDown_ ? y : height_ - 1 - y; - const BYTE* source = buffer + sourceY * stride; - BYTE* destination = frame.data() + y * stride; - std::copy(source, source + stride, destination); - for (int x = 0; x < width_; x += 1) { - destination[x * 4 + 3] = 255; + const BYTE* source = buffer + sourceY * sourceStride; + BYTE* destination = frame.data() + y * destinationStride; + if (pixelFormat_ == PixelFormat::Bgra) { + std::copy(source, source + destinationStride, destination); + for (int x = 0; x < width_; x += 1) { + destination[x * 4 + 3] = 255; + } + continue; + } + + if (pixelFormat_ == PixelFormat::Nv12) { + const BYTE* yPlane = buffer + sourceY * sourceStride; + const BYTE* uvPlane = buffer + sourceStride * height_ + (sourceY / 2) * sourceStride; + for (int x = 0; x < width_; x += 1) { + const int uvX = (x / 2) * 2; + const auto color = yuvToBgr(yPlane[x], uvPlane[uvX], uvPlane[uvX + 1]); + BYTE* pixel = destination + x * 4; + pixel[0] = color[0]; + pixel[1] = color[1]; + pixel[2] = color[2]; + pixel[3] = 255; + } + continue; + } + + for (int x = 0; x + 1 < width_; x += 2) { + const BYTE y0 = source[x * 2]; + const BYTE u = source[x * 2 + 1]; + const BYTE y1 = source[x * 2 + 2]; + const BYTE v = source[x * 2 + 3]; + const auto first = yuvToBgr(y0, u, v); + const auto second = yuvToBgr(y1, u, v); + BYTE* firstPixel = destination + x * 4; + BYTE* secondPixel = firstPixel + 4; + firstPixel[0] = first[0]; + firstPixel[1] = first[1]; + firstPixel[2] = first[2]; + firstPixel[3] = 255; + secondPixel[0] = second[0]; + secondPixel[1] = second[1]; + secondPixel[2] = second[2]; + secondPixel[3] = 255; + } + if (width_ % 2 == 1) { + const int x = width_ - 1; + const int previousPairStart = ((x - 1) / 2) * 4; + const BYTE y = source[x * 2]; + const BYTE u = source[previousPairStart + 1]; + const BYTE v = source[previousPairStart + 3]; + const auto color = yuvToBgr(y, u, v); + BYTE* pixel = destination + x * 4; + pixel[0] = color[0]; + pixel[1] = color[1]; + pixel[2] = color[2]; + pixel[3] = 255; } } std::scoped_lock lock(frameMutex_); latestFrame_ = std::move(frame); + latestFrameSequence_ += 1; } -bool DirectShowWebcamCapture::copyLatestFrame(std::vector& destination, int& width, int& height) { +bool DirectShowWebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) { std::scoped_lock lock(frameMutex_); if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) { return false; } - destination = latestFrame_; - width = width_; - height = height_; + destination.data = latestFrame_; + destination.width = width_; + destination.height = height_; + destination.sequence = latestFrameSequence_; return true; } diff --git a/electron/native/wgc-capture/src/dshow_webcam_capture.h b/electron/native/wgc-capture/src/dshow_webcam_capture.h index 906da8fa..3debcbe0 100644 --- a/electron/native/wgc-capture/src/dshow_webcam_capture.h +++ b/electron/native/wgc-capture/src/dshow_webcam_capture.h @@ -3,11 +3,19 @@ #include #include +#include #include #include #include #include +struct WebcamFrameSnapshot { + std::vector data; + int width = 0; + int height = 0; + uint64_t sequence = 0; +}; + class DirectShowWebcamCapture { public: DirectShowWebcamCapture() = default; @@ -25,7 +33,7 @@ public: int requestedFps); bool start(); void stop(); - bool copyLatestFrame(std::vector& destination, int& width, int& height); + bool copyLatestFrame(WebcamFrameSnapshot& destination); int width() const; int height() const; @@ -34,6 +42,12 @@ public: void storeFrame(const BYTE* buffer, long length); private: + enum class PixelFormat { + Bgra, + Nv12, + Yuy2, + }; + struct Impl; void captureLoop(); @@ -42,9 +56,12 @@ private: std::atomic stopRequested_ = false; std::mutex frameMutex_; std::vector latestFrame_; + uint64_t latestFrameSequence_ = 0; int width_ = 0; int height_ = 0; int fps_ = 30; + int sourceStride_ = 0; bool sourceTopDown_ = false; + PixelFormat pixelFormat_ = PixelFormat::Bgra; std::wstring selectedDeviceName_; }; diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index f8f56cd0..bb741d33 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,7 @@ struct CaptureConfig { std::string sourceId; std::string windowHandle; std::string outputPath; + std::string webcamOutputPath; int fps = 60; int width = 0; int height = 0; @@ -49,6 +51,37 @@ struct CaptureConfig { int webcamFps = 0; }; +struct CaptureControl { + std::atomic stopRequested = false; + std::atomic paused = false; + std::mutex mutex; + std::condition_variable cv; + std::chrono::steady_clock::time_point pauseStartedAt; + std::chrono::steady_clock::duration totalPausedDuration{}; + + int64_t pausedDurationHns() { + std::scoped_lock lock(mutex); + auto total = totalPausedDuration; + if (paused.load()) { + total += std::chrono::steady_clock::now() - pauseStartedAt; + } + return std::chrono::duration_cast(total).count() / 100; + } + + void setPaused(bool nextPaused) { + std::scoped_lock lock(mutex); + if (nextPaused == paused.load()) { + return; + } + if (nextPaused) { + pauseStartedAt = std::chrono::steady_clock::now(); + } else { + totalPausedDuration += std::chrono::steady_clock::now() - pauseStartedAt; + } + paused = nextPaused; + } +}; + std::wstring utf8ToWide(const std::string& value) { if (value.empty()) { return {}; @@ -311,23 +344,38 @@ bool parseConfig(const std::string& json, CaptureConfig& config) { config.webcamDeviceId = findString(json, "webcamDeviceId"); config.webcamDeviceName = findString(json, "webcamDeviceName"); config.webcamDirectShowClsid = findString(json, "webcamDirectShowClsid"); + config.webcamOutputPath = findString(json, "webcamPath"); config.webcamWidth = findInt(json, "webcamWidth", 0); config.webcamHeight = findInt(json, "webcamHeight", 0); config.webcamFps = findInt(json, "webcamFps", 0); return true; } -void readStopCommands(std::atomic& stopRequested, std::condition_variable& cv) { +void readCaptureCommands(CaptureControl& control, const std::function& onPauseChanged) { std::string line; while (std::getline(std::cin, line)) { if (line == "stop" || line == "q" || line == "quit") { - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } + if (line == "pause") { + control.setPaused(true); + onPauseChanged(true); + std::cout << "{\"event\":\"recording-paused\",\"schemaVersion\":2}" << std::endl; + control.cv.notify_all(); + continue; + } + if (line == "resume") { + control.setPaused(false); + onPauseChanged(false); + std::cout << "{\"event\":\"recording-resumed\",\"schemaVersion\":2}" << std::endl; + control.cv.notify_all(); + continue; + } } - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); } } // namespace @@ -389,6 +437,7 @@ int main(int argc, char* argv[]) { WebcamCapture webcamCapture; bool webcamActive = false; + bool writeSeparateWebcam = false; if (config.webcamEnabled) { if (!webcamCapture.initialize( utf8ToWide(config.webcamDeviceId), @@ -405,6 +454,7 @@ int main(int argc, char* argv[]) { << ",\"fps\":" << webcamCapture.fps() << ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName())) << "\"}" << std::endl; + writeSeparateWebcam = !config.webcamOutputPath.empty(); } WasapiLoopbackCapture loopbackCapture; @@ -466,9 +516,26 @@ int main(int argc, char* argv[]) { return 1; } + MFEncoder webcamEncoder; + if (writeSeparateWebcam) { + const int webcamPixels = std::max(1, webcamCapture.width()) * std::max(1, webcamCapture.height()); + const int webcamBitrate = webcamPixels >= 1280 * 720 ? 8'000'000 : 4'000'000; + if (!webcamEncoder.initialize( + utf8ToWide(config.webcamOutputPath), + webcamCapture.width(), + webcamCapture.height(), + webcamCapture.fps(), + webcamBitrate, + session.device(), + session.context(), + nullptr)) { + std::cerr << "ERROR: Failed to initialize native webcam encoder" << std::endl; + return 1; + } + } + std::mutex mutex; - std::condition_variable cv; - std::atomic stopRequested = false; + CaptureControl control; std::atomic firstFrameWritten = false; std::atomic encodeFailed = false; Microsoft::WRL::ComPtr latestFrameTexture; @@ -477,10 +544,11 @@ int main(int argc, char* argv[]) { std::vector latestWebcamFrame; int latestWebcamWidth = 0; int latestWebcamHeight = 0; + uint64_t latestWebcamSequence = 0; bool hasVisibleWebcamFrame = false; session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { - if (stopRequested) { + if (control.stopRequested || control.paused) { return; } @@ -493,8 +561,8 @@ int main(int argc, char* argv[]) { desc.MiscFlags = 0; if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } } @@ -502,27 +570,38 @@ int main(int argc, char* argv[]) { session.context()->CopyResource(latestFrameTexture.Get(), texture); latestFrameTimestampHns = timestampHns; if (!firstFrameWritten.exchange(true)) { - cv.notify_all(); + control.cv.notify_all(); } }); auto writeVideoFrames = [&]() { - const auto startedAt = std::chrono::steady_clock::now(); + const auto frameDuration = std::chrono::duration_cast( + std::chrono::duration(1.0 / config.fps)); uint64_t frameIndex = 0; + uint64_t lastWrittenWebcamSequence = 0; + uint64_t webcamOutputFrameIndex = 0; int64_t lastEncodedVideoTimestampHns = -1; - while (!stopRequested && !encodeFailed) { + while (!control.stopRequested && !encodeFailed) { { - std::scoped_lock lock(mutex); + std::unique_lock lock(mutex); + control.cv.wait(lock, [&] { + return control.stopRequested.load() || + encodeFailed.load() || + (!control.paused.load() && latestFrameTexture); + }); + if (control.stopRequested || encodeFailed) { + break; + } if (webcamActive) { - std::vector candidateWebcamFrame; - int candidateWebcamWidth = 0; - int candidateWebcamHeight = 0; - if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) && - hasVisibleBgraContent(candidateWebcamFrame)) { - latestWebcamFrame = std::move(candidateWebcamFrame); - latestWebcamWidth = candidateWebcamWidth; - latestWebcamHeight = candidateWebcamHeight; + WebcamFrameSnapshot candidateWebcamFrame; + if (webcamCapture.copyLatestFrame(candidateWebcamFrame) && + candidateWebcamFrame.sequence != latestWebcamSequence && + hasVisibleBgraContent(candidateWebcamFrame.data)) { + latestWebcamFrame = std::move(candidateWebcamFrame.data); + latestWebcamWidth = candidateWebcamFrame.width; + latestWebcamHeight = candidateWebcamFrame.height; + latestWebcamSequence = candidateWebcamFrame.sequence; hasVisibleWebcamFrame = true; } } @@ -539,19 +618,34 @@ int main(int argc, char* argv[]) { firstFrameTimestampHns = sourceTimestampHns; } int64_t frameTimestampHns = - std::max(0, sourceTimestampHns - firstFrameTimestampHns); + std::max( + 0, + sourceTimestampHns - firstFrameTimestampHns - control.pausedDurationHns()); if (lastEncodedVideoTimestampHns >= 0 && frameTimestampHns <= lastEncodedVideoTimestampHns) { frameTimestampHns = lastEncodedVideoTimestampHns + static_cast(10'000'000ULL / config.fps); } + if (writeSeparateWebcam && webcamFrame.data && + latestWebcamSequence != lastWrittenWebcamSequence) { + const int64_t webcamTimestampHns = static_cast( + (webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps())); + if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) { + encodeFailed = true; + stopRequested = true; + cv.notify_all(); + return; + } + lastWrittenWebcamSequence = latestWebcamSequence; + webcamOutputFrameIndex += 1; + } if (latestFrameTexture && !encoder.writeFrame( latestFrameTexture.Get(), frameTimestampHns, - webcamFrame.data ? &webcamFrame : nullptr)) { + !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } if (latestFrameTexture) { @@ -560,10 +654,7 @@ int main(int argc, char* argv[]) { } frameIndex += 1; - const auto nextDeadline = startedAt + - std::chrono::duration_cast( - std::chrono::duration(static_cast(frameIndex) / config.fps)); - std::this_thread::sleep_until(nextDeadline); + std::this_thread::sleep_for(frameDuration); } }; @@ -595,8 +686,8 @@ int main(int argc, char* argv[]) { [&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return false; } return true; @@ -611,7 +702,7 @@ int main(int argc, char* argv[]) { if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { (void)timestampHns; (void)durationHns; - if (stopRequested || !audioMixer) { + if (control.stopRequested || !audioMixer) { return; } @@ -627,7 +718,7 @@ int main(int argc, char* argv[]) { if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { (void)timestampHns; (void)durationHns; - if (stopRequested || !audioMixer) { + if (control.stopRequested || !audioMixer) { return; } @@ -659,14 +750,13 @@ int main(int argc, char* argv[]) { webcamActive = true; const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) { - std::vector candidateWebcamFrame; - int candidateWebcamWidth = 0; - int candidateWebcamHeight = 0; - if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) && - hasVisibleBgraContent(candidateWebcamFrame)) { - latestWebcamFrame = std::move(candidateWebcamFrame); - latestWebcamWidth = candidateWebcamWidth; - latestWebcamHeight = candidateWebcamHeight; + WebcamFrameSnapshot candidateWebcamFrame; + if (webcamCapture.copyLatestFrame(candidateWebcamFrame) && + hasVisibleBgraContent(candidateWebcamFrame.data)) { + latestWebcamFrame = std::move(candidateWebcamFrame.data); + latestWebcamWidth = candidateWebcamFrame.width; + latestWebcamHeight = candidateWebcamFrame.height; + latestWebcamSequence = candidateWebcamFrame.sequence; hasVisibleWebcamFrame = true; break; } @@ -689,16 +779,20 @@ int main(int argc, char* argv[]) { return 1; } - std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv)); + std::thread stdinThread(readCaptureCommands, std::ref(control), [&](bool isPaused) { + if (audioMixer) { + audioMixer->setPaused(isPaused); + } + }); { std::unique_lock lock(mutex); - const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] { - return firstFrameWritten.load() || stopRequested.load(); + const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] { + return firstFrameWritten.load() || control.stopRequested.load(); }); if (!started || !firstFrameWritten) { - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); if (stdinThread.joinable()) { stdinThread.detach(); } @@ -724,8 +818,8 @@ int main(int argc, char* argv[]) { { std::unique_lock lock(mutex); - cv.wait(lock, [&] { - return stopRequested.load(); + control.cv.wait(lock, [&] { + return control.stopRequested.load(); }); } @@ -740,6 +834,9 @@ int main(int argc, char* argv[]) { { std::scoped_lock lock(mutex); encoder.finalize(); + if (writeSeparateWebcam) { + webcamEncoder.finalize(); + } } if (stdinThread.joinable()) { @@ -752,7 +849,11 @@ int main(int argc, char* argv[]) { } std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\"" - << jsonEscape(config.outputPath) << "\"}" << std::endl; + << jsonEscape(config.outputPath) << "\""; + if (writeSeparateWebcam) { + std::cout << ",\"webcamPath\":\"" << jsonEscape(config.webcamOutputPath) << "\""; + } + std::cout << "}" << std::endl; std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; return 0; } diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index b56386ef..18bc4cca 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -254,6 +254,45 @@ bool MFEncoder::copyFrameToBuffer( return true; } +bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize) { + if (!frame.data || frame.width <= 0 || frame.height <= 0) { + return false; + } + + const DWORD rowBytes = static_cast(width_ * 4); + const DWORD requiredBytes = rowBytes * static_cast(height_); + if (destinationSize < requiredBytes) { + std::cerr << "ERROR: Media Foundation webcam buffer is too small" << std::endl; + return false; + } + + if (frame.width == width_ && frame.height == height_) { + for (DWORD i = 0; i < requiredBytes; i += 4) { + destination[i] = frame.data[i]; + destination[i + 1] = frame.data[i + 1]; + destination[i + 2] = frame.data[i + 2]; + destination[i + 3] = 255; + } + return true; + } + + for (int y = 0; y < height_; y += 1) { + const int sourceY = static_cast((static_cast(y) * frame.height) / height_); + BYTE* destinationRow = destination + rowBytes * y; + for (int x = 0; x < width_; x += 1) { + const int sourceX = static_cast((static_cast(x) * frame.width) / width_); + const BYTE* source = frame.data + (sourceY * frame.width + sourceX) * 4; + BYTE* target = destinationRow + x * 4; + target[0] = source[0]; + target[1] = source[1]; + target[2] = source[2]; + target[3] = 255; + } + } + + return true; +} + bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) { std::scoped_lock writerLock(writerMutex_); if (!sinkWriter_ || finalized_) { @@ -302,6 +341,54 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample"); } +bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) { + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } + + if (firstTimestampHns_ < 0) { + firstTimestampHns_ = timestampHns; + } + + int64_t sampleTime = timestampHns - firstTimestampHns_; + if (sampleTime <= lastTimestampHns_) { + sampleTime = lastTimestampHns_ + (10'000'000LL / fps_); + } + const int64_t sampleDuration = 10'000'000LL / fps_; + lastTimestampHns_ = sampleTime; + + Microsoft::WRL::ComPtr buffer; + const DWORD frameBytes = static_cast(width_ * height_ * 4); + if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer(webcam)")) { + return false; + } + + BYTE* data = nullptr; + DWORD maxLength = 0; + DWORD currentLength = 0; + if (!succeeded(buffer->Lock(&data, &maxLength, ¤tLength), "IMFMediaBuffer::Lock(webcam)")) { + return false; + } + + const bool copied = copyBgraFrameToBuffer(frame, data, maxLength); + buffer->Unlock(); + if (!copied) { + return false; + } + buffer->SetCurrentLength(frameBytes); + + Microsoft::WRL::ComPtr sample; + if (!succeeded(MFCreateSample(&sample), "MFCreateSample(webcam)")) { + return false; + } + sample->AddBuffer(buffer.Get()); + sample->SetSampleTime(sampleTime); + sample->SetSampleDuration(sampleDuration); + + return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)"); +} + bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { std::scoped_lock writerLock(writerMutex_); if (!sinkWriter_ || finalized_ || !hasAudioStream_) { diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index a82a9402..e7821e91 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -44,6 +44,7 @@ public: ID3D11DeviceContext* context, const AudioInputFormat* audioFormat = nullptr); bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr); + bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns); bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns); bool finalize(); @@ -54,6 +55,7 @@ private: BYTE* destination, DWORD destinationSize, const BgraFrameView* webcamFrame); + bool copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize); bool configureAudioStream(const AudioInputFormat& audioFormat); Microsoft::WRL::ComPtr sinkWriter_; diff --git a/electron/native/wgc-capture/src/webcam_capture.cpp b/electron/native/wgc-capture/src/webcam_capture.cpp index aff9fdbf..783b854a 100644 --- a/electron/native/wgc-capture/src/webcam_capture.cpp +++ b/electron/native/wgc-capture/src/webcam_capture.cpp @@ -365,6 +365,7 @@ void WebcamCapture::captureLoop() { if (currentLength >= expectedLength && expectedLength > 0) { std::scoped_lock lock(frameMutex_); latestFrame_.assign(data, data + expectedLength); + latestFrameSequence_ += 1; } buffer->Unlock(); @@ -373,18 +374,19 @@ void WebcamCapture::captureLoop() { CoUninitialize(); } -bool WebcamCapture::copyLatestFrame(std::vector& destination, int& width, int& height) { +bool WebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) { if (usingDirectShow_) { - return directShowCapture_.copyLatestFrame(destination, width, height); + return directShowCapture_.copyLatestFrame(destination); } std::scoped_lock lock(frameMutex_); if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) { return false; } - destination = latestFrame_; - width = width_; - height = height_; + destination.data = latestFrame_; + destination.width = width_; + destination.height = height_; + destination.sequence = latestFrameSequence_; return true; } diff --git a/electron/native/wgc-capture/src/webcam_capture.h b/electron/native/wgc-capture/src/webcam_capture.h index c539d025..5b61aa6b 100644 --- a/electron/native/wgc-capture/src/webcam_capture.h +++ b/electron/native/wgc-capture/src/webcam_capture.h @@ -31,7 +31,7 @@ public: int requestedFps); bool start(); void stop(); - bool copyLatestFrame(std::vector& destination, int& width, int& height); + bool copyLatestFrame(WebcamFrameSnapshot& destination); int width() const; int height() const; @@ -50,6 +50,7 @@ private: std::atomic stopRequested_ = false; std::mutex frameMutex_; std::vector latestFrame_; + uint64_t latestFrameSequence_ = 0; int width_ = 0; int height_ = 0; int fps_ = 30; diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index fbdc5a57..e20096c4 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -140,11 +140,41 @@ bool WgcSession::createCaptureItem(HWND window) { return width_ > 0 && height_ > 0; } -void WgcSession::applySessionOptions(bool captureCursor) { +bool WgcSession::applySessionOptions(bool captureCursor) { + captureCursor_ = captureCursor; + try { - session_.IsCursorCaptureEnabled(captureCursor); + auto session2 = session_.try_as(); + if (!session2) { + if (!captureCursor) { + std::cerr << "ERROR: WGC cursor suppression is not supported by this Windows runtime" + << std::endl; + return false; + } + } else { + session2.IsCursorCaptureEnabled(captureCursor); + const bool appliedCursorCapture = session2.IsCursorCaptureEnabled(); + std::cout << "{\"event\":\"cursor-capture\",\"schemaVersion\":2,\"requested\":" + << (captureCursor ? "true" : "false") + << ",\"applied\":" << (appliedCursorCapture ? "true" : "false") << "}" + << std::endl; + + if (appliedCursorCapture != captureCursor) { + std::cerr << "ERROR: WGC cursor capture setting did not apply" << std::endl; + return false; + } + } + } catch (winrt::hresult_error const& error) { + std::cerr << "ERROR: Failed to configure WGC cursor capture (hr=0x" << std::hex + << static_cast(error.code()) << std::dec << ")" << std::endl; + if (!captureCursor) { + return false; + } } catch (...) { - // Older WGC builds can omit this property. They will keep the OS default. + std::cerr << "ERROR: Failed to configure WGC cursor capture" << std::endl; + if (!captureCursor) { + return false; + } } try { @@ -152,6 +182,8 @@ void WgcSession::applySessionOptions(bool captureCursor) { } catch (...) { // IsBorderRequired is Windows 11-only. Ignore it on older builds. } + + return true; } bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) { @@ -170,7 +202,9 @@ bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) { item_.Size()); session_ = framePool_.CreateCaptureSession(item_); - applySessionOptions(captureCursor); + if (!applySessionOptions(captureCursor)) { + return false; + } frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived}); return true; @@ -192,7 +226,9 @@ bool WgcSession::initialize(HWND window, int fps, bool captureCursor) { item_.Size()); session_ = framePool_.CreateCaptureSession(item_); - applySessionOptions(captureCursor); + if (!applySessionOptions(captureCursor)) { + return false; + } frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived}); return true; @@ -207,6 +243,9 @@ bool WgcSession::start() { if (!session_) { return false; } + if (!applySessionOptions(captureCursor_)) { + return false; + } session_.StartCapture(); started_ = true; return true; diff --git a/electron/native/wgc-capture/src/wgc_session.h b/electron/native/wgc-capture/src/wgc_session.h index 4b7a0c4b..43de21a8 100644 --- a/electron/native/wgc-capture/src/wgc_session.h +++ b/electron/native/wgc-capture/src/wgc_session.h @@ -37,7 +37,7 @@ private: bool createD3DDevice(); bool createCaptureItem(HMONITOR monitor); bool createCaptureItem(HWND window); - void applySessionOptions(bool captureCursor); + bool applySessionOptions(bool captureCursor); void onFrameArrived( winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, winrt::Windows::Foundation::IInspectable const&); @@ -54,5 +54,6 @@ private: int width_ = 0; int height_ = 0; int fps_ = 60; + bool captureCursor_ = false; bool started_ = false; }; diff --git a/electron/preload.ts b/electron/preload.ts index 8302b959..60916ffe 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,6 +1,8 @@ import { contextBridge, ipcRenderer } from "electron"; +import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; +import type { ShortcutBinding } from "../src/lib/shortcuts"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; // Asset base URL is passed from the main process via webPreferences.additionalArguments @@ -24,6 +26,9 @@ contextBridge.exposeInMainWorld("electronAPI", { setHudOverlayIgnoreMouseEvents: (ignore: boolean) => { ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore); }, + moveHudOverlayBy: (deltaX: number, deltaY: number) => { + ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY); + }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, @@ -48,12 +53,27 @@ contextBridge.exposeInMainWorld("electronAPI", { requestCameraAccess: () => { return ipcRenderer.invoke("request-camera-access"); }, + requestScreenAccess: () => { + return ipcRenderer.invoke("request-screen-access"); + }, + requestNativeMacCursorAccess: () => { + return ipcRenderer.invoke("request-native-mac-cursor-access"); + }, storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke("store-recorded-video", videoData, fileName); }, storeRecordedSession: (payload: StoreRecordedSessionInput) => { return ipcRenderer.invoke("store-recorded-session", payload); }, + openRecordingStream: (fileName: string) => { + return ipcRenderer.invoke("open-recording-stream", fileName); + }, + appendRecordingChunk: (fileName: string, chunk: ArrayBuffer) => { + return ipcRenderer.invoke("append-recording-chunk", fileName, chunk); + }, + closeRecordingStream: (fileName: string) => { + return ipcRenderer.invoke("close-recording-stream", fileName); + }, getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); @@ -68,12 +88,41 @@ contextBridge.exposeInMainWorld("electronAPI", { isNativeWindowsCaptureAvailable: () => { return ipcRenderer.invoke("is-native-windows-capture-available"); }, + isNativeMacCaptureAvailable: () => { + return ipcRenderer.invoke("is-native-mac-capture-available"); + }, startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => { return ipcRenderer.invoke("start-native-windows-recording", request); }, stopNativeWindowsRecording: (discard?: boolean) => { return ipcRenderer.invoke("stop-native-windows-recording", discard); }, + pauseNativeWindowsRecording: () => { + return ipcRenderer.invoke("pause-native-windows-recording"); + }, + resumeNativeWindowsRecording: () => { + return ipcRenderer.invoke("resume-native-windows-recording"); + }, + startNativeMacRecording: (request: NativeMacRecordingRequest) => { + return ipcRenderer.invoke("start-native-mac-recording", request); + }, + pauseNativeMacRecording: () => { + return ipcRenderer.invoke("pause-native-mac-recording"); + }, + resumeNativeMacRecording: () => { + return ipcRenderer.invoke("resume-native-mac-recording"); + }, + stopNativeMacRecording: (discard?: boolean) => { + return ipcRenderer.invoke("stop-native-mac-recording", discard); + }, + attachNativeMacWebcamRecording: (payload: { + screenVideoPath: string; + recordingId: number; + webcam: { fileName: string; videoData: ArrayBuffer }; + cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode; + }) => { + return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload); + }, getCursorTelemetry: (videoPath?: string) => { return ipcRenderer.invoke("get-cursor-telemetry", videoPath); }, @@ -112,6 +161,9 @@ contextBridge.exposeInMainWorld("electronAPI", { readBinaryFile: (filePath: string) => { return ipcRenderer.invoke("read-binary-file", filePath); }, + preparePreviewAudioTrack: (filePath: string) => { + return ipcRenderer.invoke("prepare-preview-audio-track", filePath); + }, clearCurrentVideoPath: () => { return ipcRenderer.invoke("clear-current-video-path"); }, @@ -151,6 +203,9 @@ contextBridge.exposeInMainWorld("electronAPI", { saveShortcuts: (shortcuts: unknown) => { return ipcRenderer.invoke("save-shortcuts", shortcuts); }, + updateGlobalShortcut: (binding: ShortcutBinding) => { + return ipcRenderer.invoke("update-global-shortcut", binding); + }, setLocale: (locale: string) => { return ipcRenderer.invoke("set-locale", locale); }, diff --git a/electron/recording/webm-duration.ts b/electron/recording/webm-duration.ts new file mode 100644 index 00000000..5b2c197c --- /dev/null +++ b/electron/recording/webm-duration.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import { fixParsedWebmDuration } from "@fix-webm-duration/fix"; +import { WebmFile } from "@fix-webm-duration/parser"; + +export type DurationPatchResult = + | { patched: true } + | { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" }; + +/** + * Patch the WebM Duration header on a finalized recording file. + * + * Browser MediaRecorder writes WebM with no Duration EBML element. With the + * streaming-to-disk path the renderer never holds the blob, so the historical + * `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after + * `WriteStream.end()` produces an equivalent result: the editor's seek bar and + * timeline read a real duration instead of `N/A`. + * + * Atomic by design: writes the patched bytes to `.duration-patch.tmp` + * and renames in place. If the process crashes mid-rewrite, the original file + * survives intact, so the user never loses their recording to a partial write. + * + * Best-effort by intent: any failure (read, parse, write) logs and returns a + * non-`patched` result rather than throwing. The file is still playable without + * the patch (decoders walk frames sequentially); the only cost is that the + * editor's seek bar and timeline break until it is patched. + * + * Memory: reads the whole file into a main-process Buffer, the same footprint + * as the pre-streaming renderer path, just on the side without V8's heap cap. + */ +export async function patchWebmDurationOnDisk( + filePath: string, + durationMs: number, +): Promise { + try { + const fileBytes = await fs.readFile(filePath); + const webm = new WebmFile(new Uint8Array(fileBytes)); + + const patched = fixParsedWebmDuration(webm, durationMs, { logger: false }); + if (!patched) { + // fixParsedWebmDuration returns false for: missing Segment, missing + // Info, or a Duration that is already valid. The first two mean a + // malformed (most likely truncated) file; the third is a no-op. + const reason = inferUnpatchedReason(webm); + if (reason === "no-section") { + console.warn( + `[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`, + ); + } + return { patched: false, reason }; + } + + if (!webm.source) { + console.error(`[webm-duration] patched but source missing for ${filePath}`); + return { patched: false, reason: "internal" }; + } + + const tmpPath = `${filePath}.duration-patch.tmp`; + const patchedBytes = Buffer.from( + webm.source.buffer, + webm.source.byteOffset, + webm.source.byteLength, + ); + try { + await fs.writeFile(tmpPath, patchedBytes); + await fs.rename(tmpPath, filePath); + return { patched: true }; + } catch (writeError) { + console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError); + // Best-effort cleanup of the temp file; if unlink also fails, leave it. + // The original recording is untouched because the rename never ran. + await fs.unlink(tmpPath).catch(() => undefined); + return { patched: false, reason: "io-error" }; + } + } catch (error) { + console.error(`[webm-duration] failed to patch ${filePath}:`, error); + return { patched: false, reason: "io-error" }; + } +} + +/** + * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info + * present but Duration already valid" (patch unnecessary). + * + * The IDs are the length-descriptor-stripped form that @fix-webm-duration/parser + * uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified + * against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML + * IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would + * never match. + */ +function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" { + const segment = webm.getSectionById?.(0x8538067); + if (!segment) return "no-section"; + const info = ( + segment as unknown as { getSectionById?: (id: number) => unknown } + ).getSectionById?.(0x549a966); + return info ? "already-valid" : "no-section"; +} diff --git a/electron/windows.ts b/electron/windows.ts index 4d4e7520..3a7350ed 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -30,6 +30,20 @@ ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => { } }); +ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => { + if ( + !hudOverlayWindow || + hudOverlayWindow.isDestroyed() || + !Number.isFinite(deltaX) || + !Number.isFinite(deltaY) + ) { + return; + } + + const [x, y] = hudOverlayWindow.getPosition(); + hudOverlayWindow.setPosition(Math.round(x + deltaX), Math.round(y + deltaY), false); +}); + /** * Creates the always-on-top HUD overlay window centred at the bottom of the * primary display. The window is frameless, transparent, and follows the user diff --git a/package.json b/package.json index 9388bcd3..fd0c4cf3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "format": "biome format --write .", "i18n:check": "node scripts/i18n-check.mjs", "preview": "vite preview", - "build:mac": "tsc && vite build && electron-builder --mac", + "build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs", + "build:mac": "npm run build:native:mac && tsc && vite build && electron-builder --mac", "build:native:win": "node scripts/build-windows-wgc-helper.mjs", "build:win": "npm run build:native:win && tsc && vite build && electron-builder --win --config.npmRebuild=false", "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false", diff --git a/scripts/build-macos-screencapturekit-helper.mjs b/scripts/build-macos-screencapturekit-helper.mjs new file mode 100644 index 00000000..8e7c9739 --- /dev/null +++ b/scripts/build-macos-screencapturekit-helper.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +if (process.platform !== "darwin") { + console.log("Skipping macOS ScreenCaptureKit helper build: host platform is not macOS."); + process.exit(0); +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const helperName = "openscreen-screencapturekit-helper"; +const cursorHelperName = "openscreen-macos-cursor-helper"; +const packageDir = path.join(root, "electron", "native", "screencapturekit"); +const buildDir = path.join(packageDir, "build"); +const swiftBuildDir = path.join(buildDir, "swiftpm"); +const builtHelperPath = path.join(swiftBuildDir, "release", helperName); +const localHelperPath = path.join(buildDir, helperName); +const builtCursorHelperPath = path.join(swiftBuildDir, "release", cursorHelperName); +const localCursorHelperPath = path.join(buildDir, cursorHelperName); +const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; +const distributableDir = path.join(root, "electron", "native", "bin", archTag); +const distributablePath = path.join(distributableDir, helperName); +const distributableCursorHelperPath = path.join(distributableDir, cursorHelperName); + +const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], { + cwd: root, + encoding: "utf8", +}); + +if (xcodebuildVersion.status !== 0) { + const message = `${xcodebuildVersion.stderr ?? ""}${xcodebuildVersion.stdout ?? ""}`.trim(); + console.error( + [ + "Unable to build the macOS ScreenCaptureKit helper because full Xcode is not active.", + "", + message, + "", + "Install Xcode from the App Store or Apple Developer downloads, then run:", + " sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer", + " sudo xcodebuild -license accept", + "", + "Command Line Tools alone may not include the Swift SDK/platform metadata required by SwiftPM.", + ].join("\n"), + ); + process.exit(1); +} + +const result = spawnSync( + "swift", + ["build", "-c", "release", "--package-path", packageDir, "--build-path", swiftBuildDir], + { + cwd: root, + stdio: "inherit", + }, +); + +if (result.error) { + console.error(`Failed to start Swift build: ${result.error.message}`); + process.exit(1); +} + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +fs.mkdirSync(buildDir, { recursive: true }); +fs.mkdirSync(distributableDir, { recursive: true }); +for (const artifactPath of [builtHelperPath, builtCursorHelperPath]) { + if (!fs.existsSync(artifactPath)) { + console.error(`Swift build completed but expected artifact was not found: ${artifactPath}`); + process.exit(1); + } +} +fs.copyFileSync(builtHelperPath, localHelperPath); +fs.copyFileSync(builtHelperPath, distributablePath); +fs.copyFileSync(builtCursorHelperPath, localCursorHelperPath); +fs.copyFileSync(builtCursorHelperPath, distributableCursorHelperPath); +fs.chmodSync(localHelperPath, 0o755); +fs.chmodSync(distributablePath, 0o755); +fs.chmodSync(localCursorHelperPath, 0o755); +fs.chmodSync(distributableCursorHelperPath, 0o755); + +console.log(`Built macOS ScreenCaptureKit helper: ${localHelperPath}`); +console.log(`Copied redistributable helper: ${distributablePath}`); +console.log(`Built macOS cursor helper: ${localCursorHelperPath}`); +console.log(`Copied redistributable cursor helper: ${distributableCursorHelperPath}`); diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 85da01e5..29df4d84 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -37,6 +37,21 @@ function findVcVarsAll() { return null; } +function findWindowsSdkUmLibDir() { + const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib"; + if (!fs.existsSync(sdkLibRoot)) { + return null; + } + + return fs + .readdirSync(sdkLibRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64")) + .filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib"))) + .sort() + .at(-1); +} + function run(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -64,6 +79,8 @@ async function runInVsEnv(command) { ); } + const sdkUmLibDir = findWindowsSdkUmLibDir(); + const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`); fs.writeFileSync( cmdPath, @@ -72,9 +89,9 @@ async function runInVsEnv(command) { `call "${vcvarsAll}" x64`, "if errorlevel 1 exit /b %errorlevel%", `if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`, - `for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, + `for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, "if errorlevel 1 exit /b %errorlevel%", - `set "LIB=${COMPAT_LIB_DIR};%LIB%"`, + `set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`, command, "exit /b %errorlevel%", "", @@ -104,9 +121,19 @@ if (!fs.existsSync(outputPath)) { throw new Error(`WGC helper build completed but ${outputPath} was not found.`); } +const cursorSamplerOutputPath = path.join(BUILD_DIR, "cursor-sampler.exe"); +if (!fs.existsSync(cursorSamplerOutputPath)) { + throw new Error(`WGC helper build completed but ${cursorSamplerOutputPath} was not found.`); +} + fs.mkdirSync(BIN_DIR, { recursive: true }); const distributablePath = path.join(BIN_DIR, "wgc-capture.exe"); fs.copyFileSync(outputPath, distributablePath); +const cursorSamplerDistributablePath = path.join(BIN_DIR, "cursor-sampler.exe"); +fs.copyFileSync(cursorSamplerOutputPath, cursorSamplerDistributablePath); + console.log(`Built ${outputPath}`); console.log(`Copied ${distributablePath}`); +console.log(`Built ${cursorSamplerOutputPath}`); +console.log(`Copied ${cursorSamplerDistributablePath}`); diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index 82a0a8da..5dd2dccf 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -23,6 +23,9 @@ const WITH_WINDOW = process.env.OPENSCREEN_WGC_TEST_WINDOW === "true" || process.argv.includes("--window"); const WITH_WEBCAM = process.env.OPENSCREEN_WGC_TEST_WEBCAM === "true" || process.argv.includes("--webcam"); +const CAPTURE_CURSOR = + process.env.OPENSCREEN_WGC_TEST_CAPTURE_CURSOR === "true" || + process.argv.includes("--capture-cursor"); function runHelper(config) { return new Promise((resolve, reject) => { @@ -227,6 +230,7 @@ const outputPath = path.join( os.tmpdir(), `openscreen-wgc-helper-${WITH_WEBCAM ? "webcam" : WITH_WINDOW ? "window" : WITH_SYSTEM_AUDIO || WITH_MICROPHONE ? "audio" : "video"}-${process.pid}-${Date.now()}-${randomUUID()}.mp4`, ); +const webcamOutputPath = WITH_WEBCAM ? outputPath.replace(/\.mp4$/i, "-webcam.mp4") : null; const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null; @@ -247,6 +251,7 @@ const config = { hasDisplayBounds: true, captureSystemAudio: WITH_SYSTEM_AUDIO, captureMic: WITH_MICROPHONE, + captureCursor: CAPTURE_CURSOR, microphoneDeviceId: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_ID ?? "default", microphoneDeviceName: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME ?? "", microphoneGain: 1.4, @@ -259,7 +264,10 @@ const config = { webcamWidth: 640, webcamHeight: 360, webcamFps: 30, - outputs: { screenPath: outputPath }, + outputs: { + screenPath: outputPath, + ...(webcamOutputPath ? { webcamPath: webcamOutputPath } : {}), + }, }; let result; @@ -285,8 +293,13 @@ if (result.code !== 0) { if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) { throw new Error(`WGC helper did not produce a video at ${outputPath}`); } +if (WITH_WEBCAM && (!fs.existsSync(webcamOutputPath) || fs.statSync(webcamOutputPath).size === 0)) { + throw new Error(`WGC helper did not produce a webcam video at ${webcamOutputPath}`); +} const streams = probeStreams(outputPath); +const webcamStreams = + webcamOutputPath && fs.existsSync(webcamOutputPath) ? probeStreams(webcamOutputPath) : []; const hasVideo = streams.some((stream) => stream.codec_type === "video"); const hasAudio = streams.some((stream) => stream.codec_type === "audio"); const webcamFormatLine = result.stdout @@ -297,6 +310,10 @@ const audioFormatLine = result.stdout .split(/\r?\n/) .find((line) => line.includes('"event":"audio-format"')); const audioFormat = audioFormatLine ? JSON.parse(audioFormatLine) : null; +const cursorCaptureLine = result.stdout + .split(/\r?\n/) + .find((line) => line.includes('"event":"cursor-capture"')); +const cursorCapture = cursorCaptureLine ? JSON.parse(cursorCaptureLine) : null; const nativeWebcamDiagnostics = result.stderr .split(/\r?\n/) .filter((line) => line.includes("Native webcam candidate")); @@ -310,6 +327,18 @@ const nativeMicrophoneDiagnostics = result.stderr if (!hasVideo) { throw new Error(`WGC helper output has no video stream: ${outputPath}`); } +if (WITH_WEBCAM && !webcamStreams.some((stream) => stream.codec_type === "video")) { + throw new Error(`WGC helper webcam output has no video stream: ${webcamOutputPath}`); +} +if ( + (CAPTURE_CURSOR && !cursorCapture) || + (cursorCapture && + (cursorCapture.requested !== CAPTURE_CURSOR || cursorCapture.applied !== CAPTURE_CURSOR)) +) { + throw new Error( + `WGC helper did not apply requested cursor capture mode (${CAPTURE_CURSOR}): ${result.stdout}`, + ); +} if ((WITH_SYSTEM_AUDIO || WITH_MICROPHONE) && !hasAudio) { throw new Error(`WGC helper output has no audio stream: ${outputPath}`); } @@ -325,13 +354,27 @@ console.log( { success: true, outputPath, + webcamOutputPath, bytes: fs.statSync(outputPath).size, + webcamBytes: + webcamOutputPath && fs.existsSync(webcamOutputPath) + ? fs.statSync(webcamOutputPath).size + : undefined, streams: streams.map((stream) => ({ index: stream.index, codecType: stream.codec_type, codecName: stream.codec_name, duration: stream.duration, })), + webcamStreams: webcamStreams.map((stream) => ({ + index: stream.index, + codecType: stream.codec_type, + codecName: stream.codec_name, + width: stream.width, + height: stream.height, + duration: stream.duration, + })), + cursorCapture, selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName, selectedWebcamDeviceName: webcamFormat?.deviceName, nativeMicrophoneDiagnostics, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 056a4fd8..570ec280 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,5 @@ import { Check, ChevronDown, Languages } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; @@ -98,6 +98,7 @@ export function LaunchWindow() { elapsedSeconds, toggleRecording, togglePaused, + canPauseRecording, restartRecording, cancelRecording, microphoneEnabled, @@ -127,7 +128,7 @@ export function LaunchWindow() { const [isWebcamFocused, setIsWebcamFocused] = useState(false); const webcamExpanded = isWebcamHovered || isWebcamFocused; const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); - const [isWindows, setIsWindows] = useState(false); + const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); const languageTriggerRef = useRef(null); const languageMenuPanelRef = useRef(null); const [languageMenuStyle, setLanguageMenuStyle] = useState<{ @@ -192,12 +193,12 @@ export function LaunchWindow() { .getPlatform() .then((platform) => { if (!cancelled) { - setIsWindows(platform === "win32"); + setSupportsCursorModeToggle(platform === "win32" || platform === "darwin"); } }) .catch(() => { if (!cancelled) { - setIsWindows(false); + setSupportsCursorModeToggle(false); } }); @@ -281,12 +282,25 @@ export function LaunchWindow() { return () => cancelAnimationFrame(id); }, [isLanguageMenuOpen]); + const hudMouseEventsEnabledRef = useRef(undefined); + const setHudMouseEventsEnabled = useCallback((enabled: boolean) => { + if (hudMouseEventsEnabledRef.current === enabled) { + return; + } + hudMouseEventsEnabledRef.current = enabled; + window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!enabled); + }, []); + useEffect(() => { - window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true); + setHudMouseEventsEnabled(false); return () => { window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false); }; - }, []); + }, [setHudMouseEventsEnabled]); + + useEffect(() => { + setHudMouseEventsEnabled(isLanguageMenuOpen); + }, [isLanguageMenuOpen, setHudMouseEventsEnabled]); const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); @@ -357,6 +371,29 @@ export function LaunchWindow() { setMicrophoneEnabled(!microphoneEnabled); } }; + const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null); + const handleHudDragPointerDown = (event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + setHudMouseEventsEnabled(true); + event.currentTarget.setPointerCapture(event.pointerId); + dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; + }; + const handleHudDragPointerMove = (event: React.PointerEvent) => { + const lastPosition = dragLastPositionRef.current; + if (!lastPosition) return; + const deltaX = event.screenX - lastPosition.x; + const deltaY = event.screenY - lastPosition.y; + dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; + window.electronAPI?.moveHudOverlayBy?.(deltaX, deltaY); + }; + const handleHudDragPointerEnd = (event: React.PointerEvent) => { + dragLastPositionRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + setHudMouseEventsEnabled(false); + }; return ( // Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh): @@ -367,10 +404,15 @@ export function LaunchWindow() { className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`} onPointerMove={(event) => { const target = event.target as HTMLElement | null; - const shouldCapture = Boolean(target?.closest("[data-hud-interactive='true']")); - window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!shouldCapture); + const shouldCapture = + isLanguageMenuOpen || Boolean(target?.closest("[data-hud-interactive='true']")); + setHudMouseEventsEnabled(shouldCapture); + }} + onPointerLeave={() => { + if (!isLanguageMenuOpen) { + setHudMouseEventsEnabled(false); + } }} - onPointerLeave={() => window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true)} > {systemLocaleSuggestion && (
setHudMouseEventsEnabled(true)} + onPointerDown={() => setHudMouseEventsEnabled(true)} + onMouseEnter={() => setHudMouseEventsEnabled(true)} + onMouseLeave={() => { + if (!isLanguageMenuOpen) { + setHudMouseEventsEnabled(false); + } + }} > {/* Drag handle */} -
+
{getIcon("drag", "text-white/30")}
@@ -609,7 +665,7 @@ export function LaunchWindow() { ? getIcon("webcamOn", "text-green-400") : getIcon("webcamOff", "text-white/40")} - {isWindows && ( + {supportsCursorModeToggle && ( - + {canPauseRecording && ( + + + + )} {onSaveDiagnostic && ( )}
); @@ -782,9 +829,13 @@ export function SettingsPanel({
)} + {zoomEnabled && onZoomPreviewStart && onZoomPreviewEnd && ( + + )} {zoomEnabled && selectedZoomFocusMode !== "auto" && selectedZoomFocus && @@ -1347,7 +1423,7 @@ export function SettingsPanel({ onValueChange={(values) => onBorderRadiusChange?.(values[0])} onValueCommit={() => onBorderRadiusCommit?.()} min={0} - max={16} + max={64} step={0.5} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" /> @@ -1381,7 +1457,9 @@ export function SettingsPanel({ {activePanelMode === "cursor" && showCursorSettings && hasCursorData && (
-
Show Cursor
+
+ {t("cursor.show")} +
{showCursor && ( -
-
-
-
Size
- - {cursorSize.toFixed(1)} - + <> +
+
+ {t("cursor.clipToBounds")}
- 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 +
+
+
+
+ {t("cursor.size")} +
+ + {cursorSize.toFixed(1)} +
- - {Math.round(cursorSmoothing * 100)}% - + 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" + />
- 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 +
+
+
+ {t("cursor.smoothing")} +
+ + {Math.round(cursorSmoothing * 100)}% +
- - {Math.round(cursorMotionBlur * 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" + />
- 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 +
+
+
+ {t("cursor.motionBlur")} +
+ + {Math.round(cursorMotionBlur * 100)}% +
- - {cursorClickBounce.toFixed(1)} - + 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" + /> +
+
+
+
+ {t("cursor.clickBounce")} +
+ + {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" + />
- 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" - />
-
+ )}
)} @@ -1505,7 +1598,7 @@ export function SettingsPanel({ type="file" ref={fileInputRef} onChange={handleImageUpload} - accept=".jpg,.jpeg,image/jpeg" + accept={BACKGROUND_IMAGE_ACCEPT} className="hidden" />
@@ -1749,6 +1864,7 @@ export function SettingsPanel({ <>
{exportFormat === "mp4" && ( -
- - - +
+ {sourceDimensions && ( +
+ {t("exportQuality.title")} + + Source {sourceDimensions.width}x{sourceDimensions.height} + +
+ )} +
+ + + +
)} diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index 6b9ef783..c5e95039 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -102,10 +102,14 @@ export function ShortcutsConfigDialog() { const handleCancelConflict = useCallback(() => setConflict(null), []); const handleSave = useCallback(async () => { - setShortcuts(draft); - await persistShortcuts(draft); - toast.success(t("savedToast")); - closeConfig(); + const success = await persistShortcuts(draft); + if (success) { + setShortcuts(draft); + toast.success(t("savedToast")); + closeConfig(); + } else { + toast.error(t("registrationFailed")); + } }, [draft, setShortcuts, persistShortcuts, closeConfig, t]); const handleReset = useCallback(() => { @@ -222,7 +226,7 @@ export function ShortcutsConfigDialog() {
@@ -2196,6 +2171,8 @@ export default function VideoEditor() { } onZoomCustomScaleChange={handleZoomCustomScaleChange} onZoomCustomScaleCommit={handleZoomCustomScaleCommit} + onZoomPreviewStart={() => setIsPreviewingZoom(true)} + onZoomPreviewEnd={() => setIsPreviewingZoom(false)} selectedZoomFocusMode={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") @@ -2229,6 +2206,8 @@ export default function VideoEditor() { onShadowCommit={commitState} showBlur={showBlur} onBlurChange={(v) => pushState({ showBlur: v })} + showTrimWaveform={showTrimWaveform} + onTrimWaveformChange={(v) => pushState({ showTrimWaveform: v })} motionBlurAmount={motionBlurAmount} onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} onMotionBlurCommit={commitState} @@ -2266,19 +2245,38 @@ export default function VideoEditor() { gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} gifOutputDimensions={calculateOutputDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + calculateEffectiveSourceDimensions( + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, + cropRegion, + ).width, + calculateEffectiveSourceDimensions( + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, + cropRegion, + ).height, gifSizePreset, GIF_SIZE_PRESETS, aspectRatio === "native" ? getNativeAspectRatioValue( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ) : getAspectRatioValue(aspectRatio), )} onExport={handleOpenExportDialog} + onExportPanelOpen={() => { + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + }} selectedAnnotationId={selectedAnnotationId} annotationRegions={annotationOnlyRegions} onAnnotationContentChange={handleAnnotationContentChange} @@ -2313,6 +2311,8 @@ export default function VideoEditor() { onCursorMotionBlurChange={setCursorMotionBlur} cursorClickBounce={cursorClickBounce} onCursorClickBounceChange={setCursorClickBounce} + cursorClipToBounds={cursorClipToBounds} + onCursorClipToBoundsChange={setCursorClipToBounds} hasCursorData={ cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData) } @@ -2376,6 +2376,8 @@ export default function VideoEditor() { : webcamLayoutPreset, }) } + videoUrl={videoPath ?? undefined} + showTrimWaveform={showTrimWaveform} />
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 8b53ff9a..28c7b50a 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -49,15 +49,16 @@ import { getNativeAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; +import { + DEFAULT_CURSOR_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_SOURCE_DIMENSIONS, +} from "./editorDefaults"; import { type AnnotationRegion, type BlurData, type CursorTelemetryPoint, computeRotation3DContainScale, - DEFAULT_CURSOR_CLICK_BOUNCE, - DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_SMOOTHING, DEFAULT_ROTATION_3D, isRotation3DIdentity, lerpRotation3D, @@ -148,6 +149,10 @@ interface VideoPlaybackProps { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorClipToBounds?: boolean; + // When true, render the selected zoom at the playhead even while paused — + // lets the editor preview the zoom effect without leaving the focus-edit view. + isPreviewingZoom?: boolean; } export interface VideoPlaybackRef { @@ -195,6 +200,26 @@ function getEndedVideoDuration(video: HTMLVideoElement): number | null { return null; } +type AudioTrackListLike = { + length: number; + [index: number]: { enabled: boolean }; +}; + +type VideoElementWithAudioTracks = HTMLVideoElement & { + audioTracks?: AudioTrackListLike; +}; + +function enableAllPreviewAudioTracks(video: HTMLVideoElement) { + const audioTracks = (video as VideoElementWithAudioTracks).audioTracks; + if (!audioTracks || audioTracks.length <= 1) { + return; + } + + for (let index = 0; index < audioTracks.length; index += 1) { + audioTracks[index].enabled = true; + } +} + const VideoPlayback = forwardRef( ( { @@ -223,7 +248,7 @@ const VideoPlayback = forwardRef( showBlur, motionBlurAmount = 0, borderRadius = 0, - padding = 50, + padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, cropRegion, trimRegions = [], speedRegions = [], @@ -244,14 +269,17 @@ const VideoPlayback = forwardRef( cursorTelemetry = [], cursorClickTimestamps = [], showCursor = false, - cursorSize = DEFAULT_CURSOR_SIZE, - cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, - cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, - cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, + cursorSize = DEFAULT_CURSOR_SETTINGS.size, + cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing, + cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur, + cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce, + cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds, + isPreviewingZoom = false, }, ref, ) => { const videoRef = useRef(null); + const supplementalAudioRef = useRef(null); const webcamVideoRef = useRef(null); const containerRef = useRef(null); const appRef = useRef(null); @@ -261,6 +289,7 @@ const VideoPlayback = forwardRef( const timeUpdateAnimationRef = useRef(null); const [pixiReady, setPixiReady] = useState(false); const [videoReady, setVideoReady] = useState(false); + const [supplementalAudioPath, setSupplementalAudioPath] = useState(null); const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 }); const [overlayElement, setOverlayElement] = useState(null); const overlayRef = useRef(null); @@ -316,6 +345,8 @@ const VideoPlayback = forwardRef( const cursorSmoothingRef = useRef(cursorSmoothing); const cursorMotionBlurRef = useRef(cursorMotionBlur); const cursorClickBounceRef = useRef(cursorClickBounce); + const cursorClipToBoundsRef = useRef(cursorClipToBounds); + const isPreviewingZoomRef = useRef(isPreviewingZoom); const motionBlurStateRef = useRef(createMotionBlurState()); const onTimeUpdateRef = useRef(onTimeUpdate); const onPlayStateChangeRef = useRef(onPlayStateChange); @@ -334,6 +365,8 @@ const VideoPlayback = forwardRef( const nativeCursorImageIdRef = useRef(null); const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState()); const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState()); + const nativeCursorClipRef = useRef(null); + const borderRadiusRef = useRef(0); const hasNativeCursorRecording = useMemo( () => hasNativeCursorRecordingData(cursorRecordingData), @@ -531,6 +564,7 @@ const VideoPlayback = forwardRef( baseScaleRef.current = result.baseScale; baseOffsetRef.current = result.baseOffset; baseMaskRef.current = result.maskRect; + borderRadiusRef.current = result.maskBorderRadius; cropBoundsRef.current = result.cropBounds; setWebcamLayout(result.webcamRect); @@ -582,10 +616,19 @@ const VideoPlayback = forwardRef( if (!vid) return; try { allowPlaybackRef.current = true; + enableAllPreviewAudioTracks(vid); await vid.play().catch((err) => { console.log("PLAY ERROR:", err); throw err; }); + const supplementalAudio = supplementalAudioRef.current; + if (supplementalAudio) { + supplementalAudio.currentTime = vid.currentTime; + supplementalAudio.playbackRate = vid.playbackRate; + await supplementalAudio.play().catch(() => { + // The main video remains the source of truth for playback state. + }); + } } catch (error) { allowPlaybackRef.current = false; throw error; @@ -598,6 +641,7 @@ const VideoPlayback = forwardRef( return; } video.pause(); + supplementalAudioRef.current?.pause(); }, })); @@ -790,6 +834,14 @@ const VideoPlayback = forwardRef( cursorClickBounceRef.current = cursorClickBounce; }, [cursorClickBounce]); + useEffect(() => { + cursorClipToBoundsRef.current = cursorClipToBounds; + }, [cursorClipToBounds]); + + useEffect(() => { + isPreviewingZoomRef.current = isPreviewingZoom; + }, [isPreviewingZoom]); + // Sync cursor overlay config when settings change useEffect(() => { const overlay = cursorOverlayRef.current; @@ -1005,11 +1057,30 @@ const VideoPlayback = forwardRef( lastResolvedDurationRef.current = null; isResolvingDurationRef.current = false; setVideoReady(false); + setSupplementalAudioPath(null); return; } + let cancelled = false; + window.electronAPI + ?.preparePreviewAudioTrack?.(videoPath) + .then((result) => { + if (!cancelled) { + setSupplementalAudioPath(result.success ? (result.path ?? null) : null); + } + }) + .catch(() => { + if (!cancelled) { + setSupplementalAudioPath(null); + } + }); + const video = videoRef.current; - if (!video) return; + if (!video) { + return () => { + cancelled = true; + }; + } video.pause(); video.currentTime = 0; allowPlaybackRef.current = false; @@ -1026,8 +1097,42 @@ const VideoPlayback = forwardRef( videoReadyRafRef.current = null; } video.load(); + + return () => { + cancelled = true; + }; }, [videoPath]); + useEffect(() => { + const video = videoRef.current; + const supplementalAudio = supplementalAudioRef.current; + if (!video || !supplementalAudio || !supplementalAudioPath) { + return; + } + + const activeSpeedRegion = + speedRegions.find( + (region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs, + ) ?? null; + supplementalAudio.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; + + if (!isPlaying) { + supplementalAudio.pause(); + if (Math.abs(supplementalAudio.currentTime - currentTime) > 0.05) { + supplementalAudio.currentTime = currentTime; + } + return; + } + + if (Math.abs(supplementalAudio.currentTime - video.currentTime) > 0.15) { + supplementalAudio.currentTime = video.currentTime; + } + + supplementalAudio.play().catch(() => { + // Keep video playback running even if supplemental preview audio is unavailable. + }); + }, [currentTime, isPlaying, speedRegions, supplementalAudioPath]); + useEffect(() => { if (!pixiReady || !videoReady) return; @@ -1214,7 +1319,8 @@ const VideoPlayback = forwardRef( // If a zoom is selected but video is not playing, show default unzoomed view const selectedId = selectedZoomIdRef.current; const hasSelectedZoom = selectedId !== null; - const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; + const shouldShowUnzoomedView = + hasSelectedZoom && !isPlayingRef.current && !isPreviewingZoomRef.current; if (region && strength > 0 && !shouldShowUnzoomedView) { const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; @@ -1396,6 +1502,9 @@ const VideoPlayback = forwardRef( nativeCursorImage.style.display = "none"; nativeCursorImage.style.filter = "none"; } + if (nativeCursorClipRef.current) { + nativeCursorClipRef.current.style.clipPath = ""; + } resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current); resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current); }; @@ -1436,11 +1545,9 @@ const VideoPlayback = forwardRef( }) : null; if (projectedLocalPoint && projectedStagePoint) { - const renderAsset = resolveNativeCursorRenderAsset( - frame.asset, - window.devicePixelRatio || 1, - displaySample, - ); + // Pass deviceScaleFactor=1 — asset.scaleFactor already encodes DPR. + // Size is normalized below so preview matches export proportionally. + const renderAsset = resolveNativeCursorRenderAsset(frame.asset, 1, displaySample); const bounceProgress = getNativeCursorClickBounceProgress( cursorRecordingDataRef.current, timeMs, @@ -1448,7 +1555,13 @@ const VideoPlayback = forwardRef( const scale = Math.max(0, cursorSizeRef.current) * getNativeCursorClickBounceScale(cursorClickBounceRef.current, bounceProgress); - const transformedScale = scale * Math.abs(cameraContainer?.scale.x || 1); + // Normalize cursor size to the displayed video width so the cursor + // appears at the same fraction of the video in both preview and export. + const crop = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 }; + const croppedVideoWidth = (videoRef.current?.videoWidth ?? 0) * crop.width; + const sizeNorm = + croppedVideoWidth > 0 ? baseMaskRef.current.width / croppedVideoWidth : 1; + const transformedScale = scale * Math.abs(cameraContainer?.scale.x || 1) * sizeNorm; const blurPx = !isPlayingRef.current || isSeekingRef.current ? 0 @@ -1463,10 +1576,32 @@ const VideoPlayback = forwardRef( nativeCursorImageIdRef.current = renderAsset.id; } nativeCursorImage.style.display = "block"; + // Update clip-path on nativeCursorClipRef to the camera-aware video boundary. + // clip-path works correctly here because nativeCursorClipRef is outside preserve-3d. + // When cursorClipToBounds is off, allow the cursor to overflow the canvas. + if (nativeCursorClipRef.current) { + if (!cursorClipToBoundsRef.current) { + nativeCursorClipRef.current.style.clipPath = "none"; + } else { + const mask = baseMaskRef.current; + const stage = stageSizeRef.current; + const br = borderRadiusRef.current; + const s = cameraContainer ? Math.abs(cameraContainer.scale.x) : 1; + const camX = cameraContainer ? cameraContainer.position.x : 0; + const camY = cameraContainer ? cameraContainer.position.y : 0; + const clipLeft = camX + s * mask.x; + const clipTop = camY + s * mask.y; + const clipRight = camX + s * (mask.x + mask.width); + const clipBottom = camY + s * (mask.y + mask.height); + nativeCursorClipRef.current.style.clipPath = `inset(${clipTop}px ${stage.width - clipRight}px ${stage.height - clipBottom}px ${clipLeft}px round ${br * s}px)`; + } + } 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"; + // translate3d is relative to nativeCursorClipRef (absolute inset-0 = stage origin). + // projectedStagePoint.x is the stage-space cursor position — no offset needed. nativeCursorImage.style.transform = `translate3d(${ projectedStagePoint.x - renderAsset.hotspotX * transformedScale }px, ${projectedStagePoint.y - renderAsset.hotspotY * transformedScale}px, 0)`; @@ -1510,6 +1645,9 @@ const VideoPlayback = forwardRef( composite3D.style.willChange = "auto"; lastTransformIsIdentity = true; } + if (nativeCursorClipRef.current) { + nativeCursorClipRef.current.style.transform = ""; + } if (lastPerspectiveValue !== 0) { outerWrapper.style.perspective = ""; lastPerspectiveValue = 0; @@ -1526,6 +1664,9 @@ const VideoPlayback = forwardRef( ); composite3D.style.transform = `scale(${containScale}) rotateX(${effectiveRotation.rotationX}deg) rotateY(${effectiveRotation.rotationY}deg) rotateZ(${effectiveRotation.rotationZ}deg)`; composite3D.style.willChange = "transform"; + if (nativeCursorClipRef.current) { + nativeCursorClipRef.current.style.transform = composite3D.style.transform; + } lastTransformIsIdentity = false; if (persp !== lastPerspectiveValue) { outerWrapper.style.perspective = `${persp}px`; @@ -1545,6 +1686,7 @@ const VideoPlayback = forwardRef( const handleLoadedMetadata = (e: React.SyntheticEvent) => { const video = e.currentTarget; + enableAllPreviewAudioTracks(video); const hasResolvedDuration = syncResolvedDuration(video); if (!hasResolvedDuration) { forceResolveDuration(video); @@ -1693,8 +1835,8 @@ const VideoPlayback = forwardRef( aspectRatio, aspectRatio === "native" ? getNativeAspectRatioValue( - lockedVideoDimensionsRef.current?.width || 1920, - lockedVideoDimensionsRef.current?.height || 1080, + lockedVideoDimensionsRef.current?.width || DEFAULT_SOURCE_DIMENSIONS.width, + lockedVideoDimensionsRef.current?.height || DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ) : undefined, @@ -1727,18 +1869,6 @@ const VideoPlayback = forwardRef( : "none", }} /> - {webcamVideoPath && (() => { const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle"); @@ -1920,6 +2050,27 @@ const VideoPlayback = forwardRef(
)}
+ {/* Clip the native cursor overlay to the exact video canvas boundary. + Placed OUTSIDE composite3DRef (preserve-3d) so clip-path works + correctly even during 3D zoom rotation regions. + clip-path is set dynamically to the camera-aware video bounds. */} +
+ +
); }, diff --git a/src/components/video-editor/backgroundImageUpload.test.ts b/src/components/video-editor/backgroundImageUpload.test.ts new file mode 100644 index 00000000..d83ca3a0 --- /dev/null +++ b/src/components/video-editor/backgroundImageUpload.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isSupportedBackgroundImageType } from "./backgroundImageUpload"; + +describe("background image upload validation", () => { + it("accepts PNG images for custom backgrounds", () => { + expect(isSupportedBackgroundImageType("image/png", "生成画像1.png")).toBe(true); + }); + + it("accepts PNG images by extension when the browser does not provide a MIME type", () => { + expect(isSupportedBackgroundImageType("", "生成画像1.png")).toBe(true); + }); + + it("keeps rejecting non-image uploads", () => { + expect(isSupportedBackgroundImageType("text/plain", "notes.txt")).toBe(false); + }); + + it("does not allow extension fallback for explicit unsupported MIME types", () => { + expect(isSupportedBackgroundImageType("text/plain", "notes.png")).toBe(false); + }); +}); diff --git a/src/components/video-editor/backgroundImageUpload.ts b/src/components/video-editor/backgroundImageUpload.ts new file mode 100644 index 00000000..ebc256a2 --- /dev/null +++ b/src/components/video-editor/backgroundImageUpload.ts @@ -0,0 +1,20 @@ +const SUPPORTED_BACKGROUND_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png"]); +const SUPPORTED_BACKGROUND_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]); + +export const BACKGROUND_IMAGE_ACCEPT = ".jpg,.jpeg,.png,image/jpeg,image/png"; + +export function isSupportedBackgroundImageType(type: string, fileName: string): boolean { + const normalizedType = type.trim().toLowerCase(); + if (SUPPORTED_BACKGROUND_IMAGE_TYPES.has(normalizedType)) { + return true; + } + + if (normalizedType) { + return false; + } + + const lowerName = fileName.trim().toLowerCase(); + return [...SUPPORTED_BACKGROUND_IMAGE_EXTENSIONS].some((extension) => + lowerName.endsWith(extension), + ); +} diff --git a/src/components/video-editor/customPlaybackSpeed.test.ts b/src/components/video-editor/customPlaybackSpeed.test.ts new file mode 100644 index 00000000..223175cf --- /dev/null +++ b/src/components/video-editor/customPlaybackSpeed.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed"; + +describe("parseCustomPlaybackSpeedInput", () => { + it("accepts decimal playback speeds", () => { + expect(parseCustomPlaybackSpeedInput("1.1")).toEqual({ + status: "valid", + draft: "1.1", + speed: 1.1, + }); + }); + + it("keeps a single decimal point while typing", () => { + expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({ + status: "valid", + draft: "1.23", + speed: 1.23, + }); + }); + + it("allows sub-1 custom speeds down to the editor minimum", () => { + expect(parseCustomPlaybackSpeedInput("0.1")).toEqual({ + status: "valid", + draft: "0.1", + speed: 0.1, + }); + }); + + it("rejects speeds below the editor minimum", () => { + expect(parseCustomPlaybackSpeedInput("0.09")).toEqual({ + status: "too-slow", + draft: "0.09", + }); + }); + + it("accepts comma decimal input by normalizing to a dot", () => { + expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({ + status: "valid", + draft: "1.1", + speed: 1.1, + }); + }); + + it("rejects speeds above the editor maximum", () => { + expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({ + status: "too-fast", + draft: "16.1", + }); + }); +}); diff --git a/src/components/video-editor/customPlaybackSpeed.ts b/src/components/video-editor/customPlaybackSpeed.ts new file mode 100644 index 00000000..64cc3e3e --- /dev/null +++ b/src/components/video-editor/customPlaybackSpeed.ts @@ -0,0 +1,37 @@ +import { + clampPlaybackSpeed, + MAX_PLAYBACK_SPEED, + MIN_PLAYBACK_SPEED, + type PlaybackSpeed, +} from "./types"; + +export type CustomPlaybackSpeedInputResult = + | { status: "empty"; draft: string } + | { status: "too-fast"; draft: string } + | { status: "too-slow"; draft: string } + | { status: "valid"; draft: string; speed: PlaybackSpeed }; + +export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult { + const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, ""); + const [whole = "", ...fractionParts] = decimalDraft.split("."); + const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole; + + if (draft === "" || draft === ".") { + return { status: "empty", draft }; + } + + const speed = Number(draft); + if (!Number.isFinite(speed)) { + return { status: "empty", draft }; + } + + if (speed > MAX_PLAYBACK_SPEED) { + return { status: "too-fast", draft }; + } + + if (speed < MIN_PLAYBACK_SPEED) { + return { status: "too-slow", draft }; + } + + return { status: "valid", draft, speed: clampPlaybackSpeed(speed) }; +} diff --git a/src/components/video-editor/editorDefaults.test.ts b/src/components/video-editor/editorDefaults.test.ts new file mode 100644 index 00000000..8b515f1c --- /dev/null +++ b/src/components/video-editor/editorDefaults.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory"; +import { DEFAULT_PREFS } from "@/lib/userPreferences"; +import { + DEFAULT_EDITOR_APPEARANCE_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_WEBCAM_SETTINGS, +} from "./editorDefaults"; +import { normalizeProjectEditor } from "./projectPersistence"; + +describe("editor defaults SSOT", () => { + it("keeps history defaults aligned with editor defaults", () => { + expect(INITIAL_EDITOR_STATE).toMatchObject({ + ...DEFAULT_EDITOR_APPEARANCE_SETTINGS, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion, + wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, + webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, + webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, + }); + }); + + it("keeps user preference defaults aligned with editor and export defaults", () => { + expect(DEFAULT_PREFS).toMatchObject({ + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + exportQuality: DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: DEFAULT_EXPORT_SETTINGS.format, + }); + }); + + it("keeps project fallback normalization aligned with editor defaults", () => { + expect(normalizeProjectEditor({})).toMatchObject({ + ...DEFAULT_EDITOR_APPEARANCE_SETTINGS, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion, + wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, + webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, + webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, + exportQuality: DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: DEFAULT_EXPORT_SETTINGS.format, + gifFrameRate: DEFAULT_GIF_SETTINGS.frameRate, + gifLoop: DEFAULT_GIF_SETTINGS.loop, + gifSizePreset: DEFAULT_GIF_SETTINGS.sizePreset, + }); + }); +}); diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts new file mode 100644 index 00000000..914ec769 --- /dev/null +++ b/src/components/video-editor/editorDefaults.ts @@ -0,0 +1,97 @@ +import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { DEFAULT_WALLPAPER } from "@/lib/wallpaper"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { + type CursorVisualSettings, + DEFAULT_CROP_REGION, + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_CLIP_TO_BOUNDS, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SMOOTHING, + DEFAULT_WEBCAM_LAYOUT_PRESET, + DEFAULT_WEBCAM_MASK_SHAPE, + DEFAULT_WEBCAM_POSITION, + DEFAULT_WEBCAM_SIZE_PRESET, + type WebcamLayoutPreset, + type WebcamMaskShape, + type WebcamPosition, + type WebcamSizePreset, +} from "./types"; + +export const DEFAULT_SOURCE_DIMENSIONS = { + width: 1920, + height: 1080, +} as const; + +export const DEFAULT_GIF_OUTPUT_DIMENSIONS = { + width: 1280, + height: 720, +} as const; + +export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { + shadowIntensity: number; + showBlur: boolean; + motionBlurAmount: number; + borderRadius: number; + showTrimWaveform: boolean; +} = { + shadowIntensity: 0, + showBlur: false, + motionBlurAmount: 0, + borderRadius: 0, + showTrimWaveform: false, +}; + +export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { + padding: number; + aspectRatio: AspectRatio; + cropRegion: typeof DEFAULT_CROP_REGION; + wallpaper: string; +} = { + padding: 50, + aspectRatio: "16:9", + cropRegion: DEFAULT_CROP_REGION, + wallpaper: DEFAULT_WALLPAPER, +}; + +export const DEFAULT_WEBCAM_SETTINGS = { + layoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET, + maskShape: DEFAULT_WEBCAM_MASK_SHAPE, + sizePreset: DEFAULT_WEBCAM_SIZE_PRESET, + position: DEFAULT_WEBCAM_POSITION, +} as const satisfies { + layoutPreset: WebcamLayoutPreset; + maskShape: WebcamMaskShape; + sizePreset: WebcamSizePreset; + position: WebcamPosition | null; +}; + +export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean } = { + show: true, + size: DEFAULT_CURSOR_SIZE, + smoothing: DEFAULT_CURSOR_SMOOTHING, + motionBlur: DEFAULT_CURSOR_MOTION_BLUR, + clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE, + clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS, +}; + +export const DEFAULT_EXPORT_SETTINGS: { + quality: ExportQuality; + format: ExportFormat; +} = { + quality: "good", + format: "mp4", +}; + +export const DEFAULT_GIF_SETTINGS: { + frameRate: GifFrameRate; + loop: boolean; + sizePreset: GifSizePreset; + outputDimensions: typeof DEFAULT_GIF_OUTPUT_DIMENSIONS; +} = { + frameRate: 15, + loop: true, + sizePreset: "medium", + outputDimensions: DEFAULT_GIF_OUTPUT_DIMENSIONS, +}; diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 7360142b..46673290 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -4,6 +4,13 @@ import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; +import { + DEFAULT_EDITOR_APPEARANCE_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_WEBCAM_SETTINGS, +} from "./editorDefaults"; import { type AnnotationRegion, type CropRegion, @@ -15,14 +22,10 @@ import { DEFAULT_BLUR_DATA, DEFAULT_BLUR_FREEHAND_POINTS, DEFAULT_BLUR_INTENSITY, - DEFAULT_CROP_REGION, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, - DEFAULT_WEBCAM_LAYOUT_PRESET, - DEFAULT_WEBCAM_MASK_SHAPE, - DEFAULT_WEBCAM_POSITION, - DEFAULT_WEBCAM_SIZE_PRESET, DEFAULT_ZOOM_DEPTH, + DEFAULT_ZOOM_MOTION_BLUR, MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, MAX_PLAYBACK_SPEED, @@ -62,6 +65,7 @@ export interface ProjectEditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; + showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -104,13 +108,13 @@ function computeNormalizedWebcamLayoutPreset( case "vertical-stack": return isPortraitAspectRatio(normalizedAspectRatio) ? webcamLayoutPreset - : DEFAULT_WEBCAM_LAYOUT_PRESET; + : DEFAULT_WEBCAM_SETTINGS.layoutPreset; case "dual-frame": return isPortraitAspectRatio(normalizedAspectRatio) - ? DEFAULT_WEBCAM_LAYOUT_PRESET + ? DEFAULT_WEBCAM_SETTINGS.layoutPreset : webcamLayoutPreset; default: - return DEFAULT_WEBCAM_LAYOUT_PRESET; + return DEFAULT_WEBCAM_SETTINGS.layoutPreset; } } @@ -211,7 +215,7 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.aspectRatio as AspectRatio, ) ? (editor.aspectRatio as AspectRatio) - : "16:9"; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio; const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset( editor.webcamLayoutPreset, normalizedAspectRatio, @@ -226,7 +230,7 @@ export function normalizeProjectEditor(editor: Partial): Pro cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1), cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1), } - : DEFAULT_WEBCAM_POSITION; + : DEFAULT_WEBCAM_SETTINGS.position; const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) ? editor.zoomRegions @@ -413,16 +417,16 @@ export function normalizeProjectEditor(editor: Partial): Pro const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x - : DEFAULT_CROP_REGION.x; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.x; const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y - : DEFAULT_CROP_REGION.y; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.y; const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width - : DEFAULT_CROP_REGION.width; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.width; const rawCropHeight = isFiniteNumber(editor.cropRegion?.height) ? editor.cropRegion.height - : DEFAULT_CROP_REGION.height; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.height; const cropX = clamp(rawCropX, 0, 1); const cropY = clamp(rawCropY, 0, 1); @@ -433,18 +437,33 @@ export function normalizeProjectEditor(editor: Partial): Pro wallpaper: typeof editor.wallpaper === "string" ? normalizeWallpaperValue(editor.wallpaper) - : DEFAULT_WALLPAPER, - shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0, - showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false, + : DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + shadowIntensity: + typeof editor.shadowIntensity === "number" + ? editor.shadowIntensity + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, + showBlur: + typeof editor.showBlur === "boolean" + ? editor.showBlur + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + showTrimWaveform: + typeof editor.showTrimWaveform === "boolean" + ? editor.showTrimWaveform + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) ? clamp(editor.motionBlurAmount, 0, 1) : typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean" ? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled - ? 0.35 - : 0 - : 0, - borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0, - padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50, + ? DEFAULT_ZOOM_MOTION_BLUR + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, + borderRadius: + typeof editor.borderRadius === "number" + ? editor.borderRadius + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, + padding: isFiniteNumber(editor.padding) + ? clamp(editor.padding, 0, 100) + : DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, cropRegion: { x: cropX, y: cropY, @@ -463,31 +482,31 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.webcamMaskShape === "square" || editor.webcamMaskShape === "rounded" ? editor.webcamMaskShape - : DEFAULT_WEBCAM_MASK_SHAPE, + : DEFAULT_WEBCAM_SETTINGS.maskShape, webcamSizePreset: typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset) ? Math.max(10, Math.min(50, editor.webcamSizePreset)) - : DEFAULT_WEBCAM_SIZE_PRESET, + : DEFAULT_WEBCAM_SETTINGS.sizePreset, webcamPosition: normalizedWebcamPosition, exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality - : "good", - exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4", + : DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: editor.exportFormat === "gif" ? "gif" : DEFAULT_EXPORT_SETTINGS.format, gifFrameRate: editor.gifFrameRate === 15 || editor.gifFrameRate === 20 || editor.gifFrameRate === 25 || editor.gifFrameRate === 30 ? editor.gifFrameRate - : 15, - gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true, + : DEFAULT_GIF_SETTINGS.frameRate, + gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : DEFAULT_GIF_SETTINGS.loop, gifSizePreset: editor.gifSizePreset === "medium" || editor.gifSizePreset === "large" || editor.gifSizePreset === "original" ? editor.gifSizePreset - : "medium", + : DEFAULT_GIF_SETTINGS.sizePreset, }; } diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx new file mode 100644 index 00000000..815b472d --- /dev/null +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -0,0 +1,131 @@ +import { useTimelineContext } from "dnd-timeline"; +import { useEffect, useRef, useState } from "react"; + +export interface BackgroundWaveformProps { + /** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */ + peaks: Float32Array | null; + videoDurationMs: number; + /** + * Pixels to inset the drawn waveform from the top of the canvas row, + * so it aligns with the item content top edge. Defaults to 0. + */ + topInset?: number; + /** + * Pixels to inset the drawn waveform from the bottom of the canvas row, + * so it aligns with the item content bottom edge. Defaults to 0. + */ + bottomInset?: number; +} + +/** + * Renders a rectified (half-wave) audio waveform on a `` that fills + * its containing block. Designed to be passed as the `background` prop of + * ``, which already provides `relative overflow-hidden` — no wrapper + * element needed. + * + * The canvas always uses `inset-0` (full row height). Vertical alignment with + * the item content is achieved via `topInset`/`bottomInset` in the draw calls + * rather than CSS positioning, so the result is immune to sub-pixel CSS layout + * differences. + * + * - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`). + * - Redraws whenever the timeline zoom/pan range changes. + * - `pointer-events: none` — never blocks drag-to-create interactions. + */ +export default function BackgroundWaveform({ + peaks, + videoDurationMs, + topInset = 0, + bottomInset = 0, +}: BackgroundWaveformProps) { + const { range } = useTimelineContext(); + const canvasRef = useRef(null); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + + // Observe the canvas itself — Row's `relative overflow-hidden` parent + // makes it fill the row exactly, so no wrapper div is needed. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ro = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setCanvasSize({ w: width, h: height }); + }); + ro.observe(canvas); + return () => ro.disconnect(); + }, []); + + // Redraw whenever peaks, range, or canvas size changes. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || canvasSize.w <= 0 || canvasSize.h <= 0) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(canvasSize.w * dpr); + canvas.height = Math.round(canvasSize.h * dpr); + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, canvasSize.w, canvasSize.h); + + if (!peaks || peaks.length === 0) return; + + const W = canvasSize.w; + const H = canvasSize.h; + const rangeMs = range.end - range.start; + if (rangeMs <= 0 || videoDurationMs <= 0) return; + + // Draw within [topY, bottomY] so the waveform aligns with item bounds + // regardless of CSS sub-pixel rounding on the canvas element itself. + const topY = topInset; + const bottomY = H - bottomInset; + const drawHeight = bottomY - topY; + if (drawHeight <= 0) return; + + const N = peaks.length / 2; + const amp = drawHeight * 0.9; + + // Rectified (half-wave): amplitude = max(|min|, |max|), drawn upward from bottomY. + const colAmp = new Float32Array(W); + for (let x = 0; x < W; x++) { + const startMs = range.start + (x / W) * rangeMs; + const endMs = range.start + ((x + 1) / W) * rangeMs; + const lo = Math.max(0, Math.floor((startMs / videoDurationMs) * N)); + const hi = Math.min(N - 1, Math.ceil((endMs / videoDurationMs) * N)); + + let absMax = 0; + for (let i = lo; i <= hi; i++) { + const a = Math.abs(peaks[i * 2]); + const b = Math.abs(peaks[i * 2 + 1]); + if (a > absMax) absMax = a; + if (b > absMax) absMax = b; + } + colAmp[x] = absMax; + } + + // Filled polygon: bottom-left → top silhouette → bottom-right. + ctx.beginPath(); + ctx.moveTo(0, bottomY); + for (let x = 0; x < W; x++) { + ctx.lineTo(x, bottomY - colAmp[x] * amp); + } + ctx.lineTo(W, bottomY); + ctx.closePath(); + ctx.fillStyle = "rgba(74, 222, 128, 0.55)"; + ctx.fill(); + + // Crisp top-edge stroke for the sharp silhouette. + ctx.beginPath(); + ctx.moveTo(0, bottomY - colAmp[0] * amp); + for (let x = 1; x < W; x++) { + ctx.lineTo(x, bottomY - colAmp[x] * amp); + } + ctx.strokeStyle = "rgba(74, 222, 128, 0.85)"; + ctx.lineWidth = 1; + ctx.stroke(); + }, [peaks, range, canvasSize, videoDurationMs, topInset, bottomInset]); + + return ; +} diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 77fb52de..17a59e83 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -5,9 +5,15 @@ interface RowProps extends RowDefinition { children: React.ReactNode; hint?: string; isEmpty?: boolean; + background?: React.ReactNode; } -export default function Row({ id, children, hint, isEmpty }: RowProps) { +/** + * A single horizontal lane in the timeline. Wraps the dnd-timeline `useRow` + * hook and adds an optional `background` layer (e.g. `BackgroundWaveform`), + * an empty-state hint label, and a minimum height. + */ +export default function Row({ id, children, hint, isEmpty, background }: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); return ( @@ -15,6 +21,7 @@ export default function Row({ id, children, hint, isEmpty }: RowProps) { className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden" style={{ ...rowWrapperStyle, minHeight: 36 }} > + {background} {isEmpty && hint && (
{hint} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 20faf655..81cbf9d6 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { useAudioPeaks } from "@/hooks/useAudioPeaks"; import { matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; @@ -34,6 +35,7 @@ import type { ZoomFocus, ZoomRegion, } from "../types"; +import BackgroundWaveform from "./BackgroundWaveform"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; @@ -88,6 +90,8 @@ interface TimelineEditorProps { onSelectSpeed?: (id: string | null) => void; aspectRatio: AspectRatio; onAspectRatioChange: (aspectRatio: AspectRatio) => void; + videoUrl?: string; + showTrimWaveform?: boolean; } interface TimelineScaleConfig { @@ -237,6 +241,31 @@ function formatPlayheadTime(ms: number): string { return `${sec.toFixed(1)}s`; } +function shouldStartTimelineScrub(target: EventTarget | null, timelineElement: HTMLElement) { + if (!(target instanceof HTMLElement)) { + return false; + } + + for (let element: HTMLElement | null = target; element && element !== timelineElement; ) { + const className = element.className; + const classText = typeof className === "string" ? className : ""; + + if ( + classText.split(/\s+/).includes("group") || + classText.includes("cursor-grab") || + classText.includes("cursor-grabbing") || + classText.includes("cursor-ew-resize") || + element.style.cursor === "col-resize" + ) { + return false; + } + + element = element.parentElement; + } + + return true; +} + function PlaybackCursor({ currentTimeMs, videoDurationMs, @@ -542,6 +571,8 @@ function Timeline({ selectedBlurId, selectedSpeedId, keyframes = [], + videoUrl, + showTrimWaveform = false, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -559,10 +590,15 @@ function Timeline({ selectedBlurId?: string | null; selectedSpeedId?: string | null; keyframes?: { id: string; time: number }[]; + videoUrl?: string; + showTrimWaveform?: boolean; }) { const t = useScopedT("timeline"); const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); const localTimelineRef = useRef(null); + const isScrubbingTimelineRef = useRef(false); + const scrubPointerIdRef = useRef(null); + const peaks = useAudioPeaks(showTrimWaveform ? videoUrl : undefined); const setRefs = useCallback( (node: HTMLDivElement | null) => { @@ -572,43 +608,105 @@ function Timeline({ [setTimelineRef], ); - const handleTimelineClick = useCallback( - (e: React.MouseEvent) => { - if (!onSeek || videoDurationMs <= 0) return; + const seekTimelineAtClientX = useCallback( + (timelineElement: HTMLDivElement, clientX: number) => { + if (!onSeek || videoDurationMs <= 0) return false; - // Only clear selection if clicking on empty space (not on items) - // This is handled by event propagation - items stop propagation - onSelectZoom?.(null); - onSelectTrim?.(null); - onSelectAnnotation?.(null); - onSelectBlur?.(null); - onSelectSpeed?.(null); + const rect = timelineElement.getBoundingClientRect(); + const clickX = clientX - rect.left - sidebarWidth; - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left - sidebarWidth; - - if (clickX < 0) return; + if (clickX < 0) return false; const relativeMs = pixelsToValue(clickX); const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); - const timeInSeconds = absoluteMs / 1000; - onSeek(timeInSeconds); + onSeek(absoluteMs / 1000); + return true; }, - [ - onSeek, - onSelectZoom, - onSelectTrim, - onSelectAnnotation, - onSelectBlur, - onSelectSpeed, - videoDurationMs, - sidebarWidth, - range.start, - pixelsToValue, - ], + [onSeek, videoDurationMs, sidebarWidth, pixelsToValue, range.start], ); + const clearTimelineSelection = useCallback(() => { + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectAnnotation?.(null); + onSelectBlur?.(null); + onSelectSpeed?.(null); + }, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectBlur, onSelectSpeed]); + + const handleTimelineClick = useCallback( + (e: React.MouseEvent) => { + // Only clear selection if clicking on empty space (not on items) + // This is handled by event propagation - items stop propagation + clearTimelineSelection(); + seekTimelineAtClientX(e.currentTarget, e.clientX); + }, + [clearTimelineSelection, seekTimelineAtClientX], + ); + + const handleTimelinePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) { + return; + } + + if (!shouldStartTimelineScrub(e.target, e.currentTarget)) { + return; + } + + if (!seekTimelineAtClientX(e.currentTarget, e.clientX)) { + return; + } + + clearTimelineSelection(); + isScrubbingTimelineRef.current = true; + scrubPointerIdRef.current = e.pointerId; + e.currentTarget.setPointerCapture(e.pointerId); + e.preventDefault(); + }, + [clearTimelineSelection, seekTimelineAtClientX], + ); + + const handleTimelinePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) { + return; + } + + seekTimelineAtClientX(e.currentTarget, e.clientX); + e.preventDefault(); + }, + [seekTimelineAtClientX], + ); + + const stopTimelineScrub = useCallback((e: React.PointerEvent) => { + if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) { + return; + } + + isScrubbingTimelineRef.current = false; + scrubPointerIdRef.current = null; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }, []); + + const handleTimelinePointerLeave = useCallback( + (e: React.PointerEvent) => { + if (isScrubbingTimelineRef.current && scrubPointerIdRef.current === e.pointerId) { + seekTimelineAtClientX(e.currentTarget, e.clientX); + } + }, + [seekTimelineAtClientX], + ); + + const handleTimelineLostPointerCapture = useCallback((e: React.PointerEvent) => { + if (scrubPointerIdRef.current === e.pointerId) { + isScrubbingTimelineRef.current = false; + scrubPointerIdRef.current = null; + } + }, []); + const handleTimelineWheel = useCallback( (event: React.WheelEvent) => { if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) { @@ -658,9 +756,15 @@ function Timeline({ return (
@@ -693,7 +797,21 @@ function Timeline({ ))} - + + ) : undefined + } + > {trimItems.map((item) => ( Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); @@ -1605,6 +1725,8 @@ export default function TimelineEditor({ selectedBlurId={selectedBlurId} selectedSpeedId={selectedSpeedId} keyframes={keyframes} + videoUrl={videoUrl} + showTrimWaveform={showTrimWaveform} />
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index d6571be9..fce4a199 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -188,12 +188,16 @@ export interface CursorVisualSettings { smoothing: number; motionBlur: number; clickBounce: number; + clipToBounds: boolean; } export const DEFAULT_CURSOR_SIZE = 3.0; export const DEFAULT_CURSOR_SMOOTHING = 0.67; export const DEFAULT_CURSOR_MOTION_BLUR = 0.35; export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5; +// false = allow the cursor to overflow into the background by default. +// true = clip the native cursor to the video canvas bounds. +export const DEFAULT_CURSOR_CLIP_TO_BOUNDS = false; export const DEFAULT_ZOOM_MOTION_BLUR = 0.35; export interface TrimRegion { diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 4b713cff..6bf09463 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -32,6 +32,7 @@ interface LayoutResult { baseScale: number; baseOffset: { x: number; y: number }; maskRect: RenderRect; + maskBorderRadius: number; webcamRect: StyledRenderRect | null; cropBounds: { startX: number; endX: number; startY: number; endY: number }; } @@ -150,6 +151,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { baseScale: scale, baseOffset: { x: spriteX, y: spriteY }, maskRect: compositeLayout.screenRect, + maskBorderRadius: + compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius), webcamRect: compositeLayout.webcamRect, cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY }, }; diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 1056749b..5d7534b3 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -112,6 +112,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { useEffect(() => { document.documentElement.lang = locale; + window.electronAPI?.setLocale?.(locale); }, [locale]); useEffect(() => { diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx index acbe25a1..aab5f45c 100644 --- a/src/contexts/ShortcutsContext.tsx +++ b/src/contexts/ShortcutsContext.tsx @@ -14,7 +14,7 @@ interface ShortcutsContextValue { shortcuts: ShortcutsConfig; isMac: boolean; setShortcuts: (config: ShortcutsConfig) => void; - persistShortcuts: (config?: ShortcutsConfig) => Promise; + persistShortcuts: (config?: ShortcutsConfig) => Promise; isConfigOpen: boolean; openConfig: () => void; closeConfig: () => void; @@ -54,7 +54,11 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) { const persistShortcuts = useCallback( async (config?: ShortcutsConfig) => { - await window.electronAPI.saveShortcuts?.(config ?? shortcuts); + const configToSave = config ?? shortcuts; + await window.electronAPI.saveShortcuts?.(configToSave); + + const result = await window.electronAPI.updateGlobalShortcut?.(configToSave.openApp); + return result ? result.success : true; }, [shortcuts], ); diff --git a/src/hooks/audioPeaksWorker.ts b/src/hooks/audioPeaksWorker.ts new file mode 100644 index 00000000..27812fd9 --- /dev/null +++ b/src/hooks/audioPeaksWorker.ts @@ -0,0 +1,40 @@ +/** + * Web Worker: computes min/max peak pairs from raw audio channel data. + * + * Input message: { channels: Float32Array[]; duration: number } + * Output message: Float32Array of length 2*N — [min0, max0, min1, max1, …] + * + * Channel buffers are transferred (zero-copy) from the caller. + * The peaks buffer is transferred back. + */ +self.onmessage = (event: MessageEvent<{ channels: Float32Array[]; duration: number }>) => { + const { channels, duration } = event.data; + const nCh = channels.length; + if (nCh === 0) { + (self as unknown as Worker).postMessage(new Float32Array(0)); + return; + } + + const totalSamples = channels[0].length; + const N = Math.min(24000, Math.ceil(duration * 200)); + const blockSize = totalSamples / N; + const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] + + for (let i = 0; i < N; i++) { + const start = Math.floor(i * blockSize); + const end = Math.floor((i + 1) * blockSize); + let minVal = 0; + let maxVal = 0; + for (let j = start; j < end; j++) { + let sample = 0; + for (let c = 0; c < nCh; c++) sample += channels[c][j]; + sample /= nCh; + if (sample < minVal) minVal = sample; + if (sample > maxVal) maxVal = sample; + } + peaks[i * 2] = minVal; + peaks[i * 2 + 1] = maxVal; + } + + (self as unknown as Worker).postMessage(peaks, [peaks.buffer]); +}; diff --git a/src/hooks/recorderHandle.test.ts b/src/hooks/recorderHandle.test.ts new file mode 100644 index 00000000..3f16437e --- /dev/null +++ b/src/hooks/recorderHandle.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRecorderHandle } from "./recorderHandle"; + +type ElectronAPI = Window["electronAPI"]; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const decode = (buffer: ArrayBuffer) => new TextDecoder().decode(new Uint8Array(buffer)); + +/** Minimal MediaRecorder stand-in the tests can drive directly. */ +class FakeMediaRecorder { + ondataavailable: ((event: BlobEvent) => void) | null = null; + onstop: (() => void) | null = null; + onerror: (() => void) | null = null; + state: "inactive" | "recording" = "inactive"; + + start(): void { + this.state = "recording"; + } + + stop(): void { + this.state = "inactive"; + this.onstop?.(); + } + + emit(data: Blob): void { + this.ondataavailable?.({ data } as BlobEvent); + } +} + +function stubElectronAPI(api: Partial): void { + window.electronAPI = api as unknown as ElectronAPI; +} + +function driver(handle: { recorder: MediaRecorder }): FakeMediaRecorder { + return handle.recorder as unknown as FakeMediaRecorder; +} + +describe("createRecorderHandle", () => { + beforeEach(() => { + vi.stubGlobal("MediaRecorder", FakeMediaRecorder); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + window.electronAPI = undefined as unknown as ElectronAPI; + }); + + it("streams chunks to disk in arrival order and resolves an empty blob", async () => { + const appended: string[] = []; + const openRecordingStream = vi.fn(async () => ({ success: true })); + const appendRecordingChunk = vi.fn(async (_fileName: string, buffer: ArrayBuffer) => { + appended.push(decode(buffer)); + return { success: true }; + }); + stubElectronAPI({ openRecordingStream, appendRecordingChunk }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + fake.emit(new Blob(["a"])); // arrives before open resolves -> buffered + await tick(); // open resolves -> buffered chunk flushes, mode becomes streaming + fake.emit(new Blob(["b"])); + fake.emit(new Blob(["c"])); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + expect(openRecordingStream).toHaveBeenCalledWith("rec.webm"); + expect(appended).toEqual(["a", "b", "c"]); + expect(blob.size).toBe(0); + expect(handle.isStreaming()).toBe(true); + }); + + it("falls back to a complete in-memory blob when the stream fails to open", async () => { + const openRecordingStream = vi.fn(async () => ({ success: false, error: "nope" })); + const appendRecordingChunk = vi.fn(async () => ({ success: true })); + stubElectronAPI({ openRecordingStream, appendRecordingChunk }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + fake.emit(new Blob(["a"])); + await tick(); // open resolves false -> buffering, keep everything in memory + fake.emit(new Blob(["bc"])); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + expect(appendRecordingChunk).not.toHaveBeenCalled(); + expect(handle.isStreaming()).toBe(false); + expect(blob.size).toBe(3); + expect(decode(await blob.arrayBuffer())).toBe("abc"); + }); + + it("falls back to in-memory buffering when the open IPC call rejects", async () => { + const openRecordingStream = vi.fn(async () => { + throw new Error("ipc channel closed"); + }); + stubElectronAPI({ + openRecordingStream, + appendRecordingChunk: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + fake.emit(new Blob(["a"])); + await tick(); // open rejects -> treated as a failed open, keep buffering + fake.emit(new Blob(["b"])); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + expect(handle.isStreaming()).toBe(false); + expect(blob.size).toBe(2); + expect(decode(await blob.arrayBuffer())).toBe("ab"); + }); + + it("waits for in-flight chunk writes before stop resolves (no truncation)", async () => { + let releaseAppend: () => void = () => undefined; + const appendGate = new Promise((resolve) => { + releaseAppend = resolve; + }); + const appendRecordingChunk = vi.fn(async () => { + await appendGate; + return { success: true }; + }); + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: true })), + appendRecordingChunk, + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + await tick(); // open resolves + fake.emit(new Blob(["a"])); // write blocks on the gate + fake.stop(); + + let resolved = false; + void handle.recordedBlobPromise.then(() => { + resolved = true; + }); + await tick(); + expect(resolved).toBe(false); // must not resolve while the write is in flight + + releaseAppend(); + await handle.recordedBlobPromise; + expect(resolved).toBe(true); + expect(appendRecordingChunk).toHaveBeenCalledTimes(1); + }); + + it("rejects when a chunk fails to write mid-stream", async () => { + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: true })), + appendRecordingChunk: vi.fn(async () => ({ success: false, error: "disk full" })), + closeRecordingStream: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + await tick(); + fake.emit(new Blob(["a"])); + fake.stop(); + + await expect(handle.recordedBlobPromise).rejects.toThrow(/disk full/); + expect(handle.isStreaming()).toBe(false); + }); + + it("treats a rejected append the same as a failed write", async () => { + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: true })), + appendRecordingChunk: vi.fn(async () => { + throw new Error("kernel said no"); + }), + closeRecordingStream: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + await tick(); + fake.emit(new Blob(["a"])); + fake.stop(); + + await expect(handle.recordedBlobPromise).rejects.toThrow(/kernel said no/); + expect(handle.isStreaming()).toBe(false); + }); + + it("buffers in memory and never opens a stream when no file name is given", async () => { + const openRecordingStream = vi.fn(async () => ({ success: true })); + stubElectronAPI({ + openRecordingStream, + appendRecordingChunk: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }); + const fake = driver(handle); + + fake.emit(new Blob(["xy"])); + await tick(); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + expect(openRecordingStream).not.toHaveBeenCalled(); + expect(handle.isStreaming()).toBe(false); + expect(blob.size).toBe(2); + }); + + it("buffers in memory when appendRecordingChunk is unavailable (version skew)", async () => { + const openRecordingStream = vi.fn(async () => ({ success: true })); + // appendRecordingChunk intentionally omitted to simulate renderer/main skew. + stubElectronAPI({ openRecordingStream }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + fake.emit(new Blob(["a"])); + await tick(); + fake.emit(new Blob(["b"])); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + // Never even attempts to open the stream when it can't append to it. + expect(openRecordingStream).not.toHaveBeenCalled(); + expect(handle.isStreaming()).toBe(false); + expect(blob.size).toBe(2); + }); + + it("discard closes the disk stream for a streamed recording", async () => { + const closeRecordingStream = vi.fn(async () => ({ success: true })); + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: true })), + appendRecordingChunk: vi.fn(async () => ({ success: true })), + closeRecordingStream, + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + await tick(); + fake.emit(new Blob(["a"])); + fake.stop(); + await handle.recordedBlobPromise; + + await handle.discard(); + expect(closeRecordingStream).toHaveBeenCalledWith("rec.webm"); + }); + + it("discard is a no-op when the stream never opened", async () => { + const closeRecordingStream = vi.fn(async () => ({ success: true })); + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: false })), + appendRecordingChunk: vi.fn(async () => ({ success: true })), + closeRecordingStream, + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + await tick(); + fake.stop(); + await handle.recordedBlobPromise; + + await handle.discard(); + expect(closeRecordingStream).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/recorderHandle.ts b/src/hooks/recorderHandle.ts new file mode 100644 index 00000000..e98000e6 --- /dev/null +++ b/src/hooks/recorderHandle.ts @@ -0,0 +1,160 @@ +const RECORDER_TIMESLICE_MS = 1000; + +export type RecorderHandle = { + recorder: MediaRecorder; + /** + * Resolves once the recording has fully drained. For a streamed recording the + * blob is empty (the bytes are already on disk); for an in-memory recording it + * holds the full WebM. Rejects if a chunk failed to write to disk mid-stream, + * so a truncated recording surfaces as an error instead of a silent partial save. + */ + recordedBlobPromise: Promise; + /** + * Whether the recording's bytes went to disk via the streaming path. Computed + * at finalize time rather than construction, so a stream that fails to open is + * correctly reported as not-streamed and its in-memory fallback is used. + */ + isStreaming: () => boolean; + /** + * Close the disk stream (if one opened) and delete its partial file. Called + * when a recording is discarded or fails before a successful save, so cancelled + * runs don't leak the stream or orphan a partial file. No-op for in-memory + * recorders. + */ + discard: () => Promise; +}; + +/** + * Wrap a MediaRecorder, optionally streaming its chunks to disk. + * + * When `fileName` is given, chunks are written to disk in arrival order through + * the main process as they arrive, so a long recording never buffers the whole + * video in the renderer (the #616 fix). Until the disk stream confirms it is + * open, chunks are held in memory; if the open fails, that buffer becomes a + * complete in-memory fallback so nothing is lost. Native-capture webcam sidecars + * omit `fileName` and always buffer in memory, since their finalize path reads + * the blob directly to attach the webcam track. + */ +export function createRecorderHandle( + stream: MediaStream, + options: MediaRecorderOptions, + fileName?: string, +): RecorderHandle { + const recorder = new MediaRecorder(stream, options); + const mimeType = options.mimeType || "video/webm"; + const api = window.electronAPI; + + // Chunks held in memory: everything before the stream opens, plus everything + // when not streaming at all. On a successful open these flush to disk and are + // dropped; on open failure they remain as the complete fallback recording. + const memoryChunks: Blob[] = []; + let mode: "pending" | "streaming" | "buffering" = fileName ? "pending" : "buffering"; + let streamOpened = false; + let appendError: Error | null = null; + + // Serialize chunk writes so they land on disk in arrival order, and so stop + // can await every in-flight write before the main process closes the stream + // (otherwise a late chunk arrives after close and truncates the recording). + let writeChain: Promise = Promise.resolve(); + const enqueueWrite = (chunk: Blob) => { + writeChain = writeChain.then(async () => { + if (appendError || !fileName || !api?.appendRecordingChunk) { + return; + } + // Capture both outcomes — a `{ success: false }` result and an outright + // rejection (channel/handler error) — into appendError, so writeChain + // never rejects and isStreaming() stays consistent after a failure. + try { + const buffer = await chunk.arrayBuffer(); + const result = await api.appendRecordingChunk(fileName, buffer); + if (!result.success) { + appendError = new Error(result.error ?? "Failed to write recording chunk to disk"); + } + } catch (error) { + appendError = error instanceof Error ? error : new Error(String(error)); + } + }); + }; + + // Require BOTH stream IPC methods before attempting to stream. If only + // openRecordingStream exists (renderer/main version skew), streaming would + // open but every append would silently no-op, saving an empty file — so in + // that case fall through to in-memory buffering instead. + const openPromise: Promise<{ success: boolean; error?: string }> = + fileName !== undefined && + typeof api?.openRecordingStream === "function" && + typeof api?.appendRecordingChunk === "function" + ? api.openRecordingStream(fileName) + : Promise.resolve({ success: false }); + + void openPromise.then( + (result) => { + if (result.success) { + streamOpened = true; + mode = "streaming"; + for (const chunk of memoryChunks) { + enqueueWrite(chunk); + } + memoryChunks.length = 0; + } else { + mode = "buffering"; + } + }, + () => { + // The IPC call itself rejected (channel or handler error). Treat it the + // same as a failed open: keep buffering in memory so nothing is lost. + mode = "buffering"; + }, + ); + + const recordedBlobPromise = new Promise((resolve, reject) => { + recorder.ondataavailable = (event: BlobEvent) => { + if (!event.data || event.data.size === 0) { + return; + } + if (mode === "streaming") { + enqueueWrite(event.data); + } else { + // "pending" (stream not open yet) or "buffering" (not streaming). + memoryChunks.push(event.data); + } + }; + + recorder.onerror = () => { + reject(new Error("Recording failed")); + }; + + recorder.onstop = () => { + resolve(finalizeBlob()); + }; + }); + + async function finalizeBlob(): Promise { + // Wait for the open attempt to settle so its flush (or fallback switch) has + // been applied, then for every queued write to land, so we never resolve + // while chunks are still in flight to the about-to-close disk stream. + await openPromise.catch(() => undefined); + await writeChain; + if (appendError) { + throw appendError; + } + if (mode === "streaming") { + return new Blob([], { type: mimeType }); + } + return new Blob(memoryChunks, { type: mimeType }); + } + + async function discard(): Promise { + if (streamOpened && fileName && api?.closeRecordingStream) { + await api.closeRecordingStream(fileName); + } + } + + recorder.start(RECORDER_TIMESLICE_MS); + return { + recorder, + recordedBlobPromise, + isStreaming: () => mode === "streaming" && !appendError, + discard, + }; +} diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts new file mode 100644 index 00000000..3be6ac61 --- /dev/null +++ b/src/hooks/useAudioPeaks.ts @@ -0,0 +1,120 @@ +import { useEffect, useRef, useState } from "react"; +import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; + +let _audioCtx: AudioContext | null = null; +/** Returns the shared AudioContext, creating it lazily on first call. */ +function getAudioCtx(): AudioContext { + if (!_audioCtx) _audioCtx = new AudioContext(); + return _audioCtx; +} + +/** + * Offloads peak computation to a Web Worker (zero-copy via Transferable). + * Accepts an optional AbortSignal — if aborted, the worker is terminated + * immediately and the promise rejects with an AbortError. + */ +function computePeaksInWorker( + audioBuffer: AudioBuffer, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + + const worker = new Worker(new URL("./audioPeaksWorker.ts", import.meta.url), { + type: "module", + }); + + const onAbort = () => { + worker.terminate(); + reject(new DOMException("Aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + // slice() creates an owned copy so the transfer is safe and the + // AudioBuffer remains valid if anything else holds a reference. + const channels: Float32Array[] = []; + for (let c = 0; c < audioBuffer.numberOfChannels; c++) { + channels.push(audioBuffer.getChannelData(c).slice()); + } + + worker.onmessage = (e: MessageEvent) => { + signal?.removeEventListener("abort", onAbort); + worker.terminate(); + resolve(e.data); + }; + + worker.onerror = (e) => { + signal?.removeEventListener("abort", onAbort); + worker.terminate(); + reject(e); + }; + + worker.postMessage( + { channels, duration: audioBuffer.duration }, + channels.map((ch) => ch.buffer), + ); + }); +} + +/** + * Decodes audio from `videoUrl` and returns a Float32Array of paired + * [min, max] peak values (length = 2 * N blocks). Returns `null` while + * decoding is in progress, and stays `null` when the file has no audio + * track or decoding fails (silent degradation). + * + * - File loading uses the Electron IPC bridge for local paths (same as the exporter). + * - Peak computation runs in a Web Worker to avoid blocking the main thread. + * - Results are cached in a ref scoped to the hook instance (survives re-renders + * and waveform toggle off/on, but not component unmount). + */ +export function useAudioPeaks(videoUrl?: string): Float32Array | null { + const cacheRef = useRef>(new Map()); + const [peaks, setPeaks] = useState(() => + videoUrl ? (cacheRef.current.get(videoUrl) ?? null) : null, + ); + + useEffect(() => { + if (!videoUrl) { + setPeaks(null); + return; + } + + const cached = cacheRef.current.get(videoUrl); + if (cached) { + setPeaks(cached); + return; + } + + setPeaks(null); + let cancelled = false; + const controller = new AbortController(); + + (async () => { + try { + const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl); + if (cancelled) return; + const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); + if (cancelled) return; + const p = await computePeaksInWorker(audioBuffer, controller.signal); + if (cancelled) return; + cacheRef.current.set(videoUrl, p); + setPeaks(p); + } catch (err) { + // AbortError means the effect cleaned up — no state update needed. + if (err instanceof DOMException && err.name === "AbortError") return; + // No audio track or unsupported format — clear stale data silently. + if (!cancelled) setPeaks(null); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [videoUrl]); + + return peaks; +} diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index bd410dae..bfa1a4be 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -1,4 +1,9 @@ import { useCallback, useRef, useState } from "react"; +import { + DEFAULT_EDITOR_APPEARANCE_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_WEBCAM_SETTINGS, +} from "@/components/video-editor/editorDefaults"; import type { AnnotationRegion, CropRegion, @@ -10,14 +15,7 @@ import type { WebcamSizePreset, ZoomRegion, } from "@/components/video-editor/types"; -import { - DEFAULT_CROP_REGION, - DEFAULT_WEBCAM_LAYOUT_PRESET, - DEFAULT_WEBCAM_MASK_SHAPE, - DEFAULT_WEBCAM_POSITION, - DEFAULT_WEBCAM_SIZE_PRESET, -} from "@/components/video-editor/types"; -import { DEFAULT_WALLPAPER } from "@/lib/wallpaper"; +import { DEFAULT_CROP_REGION } from "@/components/video-editor/types"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; // Undoable state — selection IDs are intentionally excluded (undoing a @@ -31,6 +29,7 @@ export interface EditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; + showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -47,17 +46,18 @@ export const INITIAL_EDITOR_STATE: EditorState = { speedRegions: [], annotationRegions: [], cropRegion: DEFAULT_CROP_REGION, - wallpaper: DEFAULT_WALLPAPER, - shadowIntensity: 0, - showBlur: false, - motionBlurAmount: 0, - borderRadius: 0, - padding: 50, - aspectRatio: "16:9", - webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET, - webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE, - webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET, - webcamPosition: DEFAULT_WEBCAM_POSITION, + wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, + showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + showTrimWaveform: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, + motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, + borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, + webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, + webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, }; type StateUpdate = Partial | ((prev: EditorState) => Partial); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 45aa7b3b..f5fb9203 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -2,12 +2,18 @@ import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; +import { + type NativeMacRecordingRequest, + parseMacDisplayIdFromSourceId, + parseMacWindowIdFromSourceId, +} from "@/lib/nativeMacRecording"; import { type NativeWindowsRecordingRequest, parseWindowHandleFromSourceId, } from "@/lib/nativeWindowsRecording"; -import type { CursorCaptureMode } from "@/lib/recordingSession"; +import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession"; import { requestCameraAccess } from "@/lib/requestCameraAccess"; +import { createRecorderHandle, type RecorderHandle } from "./recorderHandle"; const TARGET_FRAME_RATE = 60; const MIN_FRAME_RATE = 30; @@ -29,7 +35,6 @@ const DEFAULT_HEIGHT = 1080; const CODEC_ALIGNMENT = 2; -const RECORDER_TIMESLICE_MS = 1000; const BITS_PER_MEGABIT = 1_000_000; const CHROME_MEDIA_SOURCE = "desktop"; const RECORDING_FILE_PREFIX = "recording-"; @@ -40,8 +45,6 @@ const AUDIO_BITRATE_VOICE = 128_000; const AUDIO_BITRATE_SYSTEM = 192_000; const MIC_GAIN_BOOST = 1.4; -const WEBCAM_TARGET_WIDTH = 1280; -const WEBCAM_TARGET_HEIGHT = 720; const WEBCAM_TARGET_FRAME_RATE = 30; type UseScreenRecorderReturn = { @@ -50,6 +53,7 @@ type UseScreenRecorderReturn = { elapsedSeconds: number; toggleRecording: () => void; togglePaused: () => void; + canPauseRecording: boolean; restartRecording: () => void; cancelRecording: () => void; microphoneEnabled: boolean; @@ -70,37 +74,18 @@ type UseScreenRecorderReturn = { setCursorCaptureMode: (mode: CursorCaptureMode) => void; }; -type RecorderHandle = { - recorder: MediaRecorder; - recordedBlobPromise: Promise; -}; - type NativeWindowsRecordingHandle = { recordingId: number; finalizing: boolean; + paused: boolean; + webcamRecorder: RecorderHandle | null; }; -function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle { - const recorder = new MediaRecorder(stream, options); - const chunks: Blob[] = []; - const mimeType = options.mimeType || "video/webm"; - const recordedBlobPromise = new Promise((resolve, reject) => { - recorder.ondataavailable = (event: BlobEvent) => { - if (event.data && event.data.size > 0) { - chunks.push(event.data); - } - }; - recorder.onerror = () => { - reject(new Error("Recording failed")); - }; - recorder.onstop = () => { - resolve(new Blob(chunks, { type: mimeType })); - }; - }); - - recorder.start(RECORDER_TIMESLICE_MS); - return { recorder, recordedBlobPromise }; -} +type NativeMacRecordingHandle = { + recordingId: number; + finalizing: boolean; + paused: boolean; +}; export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); @@ -118,6 +103,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const screenRecorder = useRef(null); const webcamRecorder = useRef(null); const nativeWindowsRecording = useRef(null); + const nativeMacRecording = useRef(null); const stream = useRef(null); const screenStream = useRef(null); const microphoneStream = useRef(null); @@ -134,6 +120,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [countdownActive, setCountdownActive] = useState(false); const webcamReady = useRef(false); const webcamAcquireId = useRef(0); + const canPauseRecording = + recording && + Boolean( + (nativeWindowsRecording.current && !nativeWindowsRecording.current.finalizing) || + (nativeMacRecording.current && !nativeMacRecording.current.finalizing) || + (screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"), + ); const getRecordingDurationMs = useCallback(() => { const segmentDuration = @@ -247,13 +240,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { video: webcamDeviceId ? { deviceId: { exact: webcamDeviceId }, - width: { ideal: WEBCAM_TARGET_WIDTH }, - height: { ideal: WEBCAM_TARGET_HEIGHT }, frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, } : { - width: { ideal: WEBCAM_TARGET_WIDTH }, - height: { ideal: WEBCAM_TARGET_HEIGHT }, frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, }, }); @@ -339,46 +328,65 @@ export function useScreenRecorder(): UseScreenRecorderReturn { window.electronAPI?.setRecordingState(false); void (async () => { + // Each disk stream must end up either saved or explicitly discarded. + // store-recorded-session finalizes the streams included in a successful + // save; the finally block discards everything else. + let storeSucceeded = false; + let webcamIncludedInSave = false; try { const screenBlob = await activeScreenRecorder.recordedBlobPromise; if (discardRecordingId.current === activeRecordingId) { window.electronAPI?.discardCursorTelemetry(activeRecordingId); return; } - if (screenBlob.size === 0) { + // When streaming succeeded the blob is empty — the data is already on disk. + if (!activeScreenRecorder.isStreaming() && screenBlob.size === 0) { return; } - const fixedScreenBlob = await fixWebmDuration(screenBlob, duration); - let fixedWebcamBlob: Blob | null = null; - if (activeWebcamRecorder) { - const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); - if (webcamBlob && webcamBlob.size > 0) { - fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); - } - } - const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`; const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + + // Only fix duration / convert to ArrayBuffer for in-memory data; + // streamed recordings are patched on disk by the main process. + let screenVideoData: ArrayBuffer = new ArrayBuffer(0); + if (!activeScreenRecorder.isStreaming() && screenBlob.size > 0) { + const fixedScreenBlob = await fixWebmDuration(screenBlob, duration); + screenVideoData = await fixedScreenBlob.arrayBuffer(); + } + + let webcamVideoData: ArrayBuffer | undefined; + if (activeWebcamRecorder) { + const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); + if (!activeWebcamRecorder.isStreaming() && webcamBlob && webcamBlob.size > 0) { + const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); + webcamVideoData = await fixedWebcamBlob.arrayBuffer(); + } else if (activeWebcamRecorder.isStreaming()) { + webcamVideoData = new ArrayBuffer(0); + } + } + webcamIncludedInSave = webcamVideoData !== undefined; + const result = await window.electronAPI.storeRecordedSession({ screen: { - videoData: await fixedScreenBlob.arrayBuffer(), + videoData: screenVideoData, fileName: screenFileName, }, - webcam: fixedWebcamBlob - ? { - videoData: await fixedWebcamBlob.arrayBuffer(), - fileName: webcamFileName, - } - : undefined, + webcam: + webcamVideoData !== undefined + ? { videoData: webcamVideoData, fileName: webcamFileName } + : undefined, createdAt: activeRecordingId, cursorCaptureMode, + durationMs: duration, }); if (!result.success) { console.error("Failed to store recording session:", result.message); return; } + // store-recorded-session has flushed and closed the saved streams. + storeSucceeded = true; if (result.session) { await window.electronAPI.setCurrentRecordingSession(result.session); @@ -390,6 +398,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } catch (error) { console.error("Error saving recording:", error); } finally { + // Discard any recorder whose data was not part of a successful save + // — a discarded run, a failed save, or a webcam whose disk write + // failed (so it was omitted while the screen still saved) — so no + // stream or partial file is left open or orphaned. + if (!storeSucceeded) { + await activeScreenRecorder.discard().catch(() => undefined); + } + if (activeWebcamRecorder && !(storeSucceeded && webcamIncludedInSave)) { + await activeWebcamRecorder.discard().catch(() => undefined); + } if (finalizingRecordingId.current === activeRecordingId) { finalizingRecordingId.current = null; } @@ -402,64 +420,215 @@ export function useScreenRecorder(): UseScreenRecorderReturn { [cursorCaptureMode, teardownMedia], ); - const finalizeNativeWindowsRecording = useCallback(async (discard = false) => { - const activeNativeRecording = nativeWindowsRecording.current; - if (!activeNativeRecording || activeNativeRecording.finalizing) { - return false; - } - - activeNativeRecording.finalizing = true; - - const clearNativeRecordingState = () => { - nativeWindowsRecording.current = null; - setRecording(false); - setPaused(false); - setElapsedSeconds(0); - accumulatedDurationMs.current = 0; - segmentStartedAt.current = null; - }; - - try { - const result = await window.electronAPI.stopNativeWindowsRecording(discard); - if (discard || result.discarded) { - clearNativeRecordingState(); - return true; + const finalizeNativeWindowsRecording = useCallback( + async (discard = false) => { + const activeNativeRecording = nativeWindowsRecording.current; + if (!activeNativeRecording || activeNativeRecording.finalizing) { + return false; } - if (!result.success) { - console.error("Failed to stop native Windows recording:", result.error); - toast.error(result.error ?? "Failed to stop native Windows recording"); + + activeNativeRecording.finalizing = true; + const activeWebcamRecorder = activeNativeRecording.webcamRecorder; + const duration = Math.max(0, getRecordingDurationMs()); + if ( + activeWebcamRecorder?.recorder.state === "recording" || + activeWebcamRecorder?.recorder.state === "paused" + ) { + try { + activeWebcamRecorder.recorder.stop(); + } catch { + // Recorder may already be stopping. + } + } + if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) { + webcamRecorder.current = null; + } + + const clearNativeRecordingState = () => { + nativeWindowsRecording.current = null; + setRecording(false); + setPaused(false); + setElapsedSeconds(0); + accumulatedDurationMs.current = 0; + segmentStartedAt.current = null; + }; + + try { + const result = await window.electronAPI.stopNativeWindowsRecording(discard); + if (discard || result.discarded) { + clearNativeRecordingState(); + return true; + } + if (!result.success) { + console.error("Failed to stop native Windows recording:", result.error); + toast.error(result.error ?? "Failed to stop native Windows recording"); + activeNativeRecording.finalizing = false; + return true; + } + + const nativeScreenPath = result.session?.screenVideoPath ?? result.path; + let storedSession = result.session; + if (activeWebcamRecorder && nativeScreenPath) { + const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); + const screenRead = await window.electronAPI.readBinaryFile(nativeScreenPath); + if (webcamBlob && webcamBlob.size > 0 && screenRead.success && screenRead.data) { + const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); + const nativeScreenFileName = + nativeScreenPath.split(/[\\/]/).pop() ?? + `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`; + const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + const stored = await window.electronAPI.storeRecordedSession({ + screen: { + videoData: screenRead.data, + fileName: nativeScreenFileName, + }, + webcam: { + videoData: await fixedWebcamBlob.arrayBuffer(), + fileName: webcamFileName, + }, + createdAt: activeNativeRecording.recordingId, + cursorCaptureMode, + }); + if (stored.success && stored.session) { + storedSession = stored.session; + } + } + } + + clearNativeRecordingState(); + if (storedSession) { + await window.electronAPI.setCurrentRecordingSession(storedSession); + } else if (result.path) { + await window.electronAPI.setCurrentVideoPath(result.path); + } + + await window.electronAPI.switchToEditor(); + return true; + } catch (error) { + console.error("Error saving native Windows recording:", error); + toast.error( + error instanceof Error ? error.message : "Failed to save native Windows recording", + ); activeNativeRecording.finalizing = false; return true; + } finally { + if (discardRecordingId.current === activeNativeRecording.recordingId) { + discardRecordingId.current = null; + } + } + }, + [cursorCaptureMode, getRecordingDurationMs], + ); + + const finalizeNativeMacRecording = useCallback( + async (discard = false) => { + const activeNativeRecording = nativeMacRecording.current; + if (!activeNativeRecording || activeNativeRecording.finalizing) { + return false; } - clearNativeRecordingState(); - if (result.session) { - await window.electronAPI.setCurrentRecordingSession(result.session); - } else if (result.path) { - await window.electronAPI.setCurrentVideoPath(result.path); + activeNativeRecording.finalizing = true; + const duration = Math.max(0, getRecordingDurationMs()); + const activeWebcamRecorder = webcamRecorder.current; + if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) { + webcamRecorder.current = null; } + const webcamAssetPromise = (async (): Promise => { + if (!activeWebcamRecorder) { + return undefined; + } - await window.electronAPI.switchToEditor(); - return true; - } catch (error) { - console.error("Error saving native Windows recording:", error); - toast.error( - error instanceof Error ? error.message : "Failed to save native Windows recording", - ); - activeNativeRecording.finalizing = false; - return true; - } finally { - if (discardRecordingId.current === activeNativeRecording.recordingId) { - discardRecordingId.current = null; + try { + if (activeWebcamRecorder.recorder.state !== "inactive") { + activeWebcamRecorder.recorder.stop(); + } + const webcamBlob = await activeWebcamRecorder.recordedBlobPromise; + if (!webcamBlob || webcamBlob.size === 0) { + return undefined; + } + const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); + return { + videoData: await fixedWebcamBlob.arrayBuffer(), + fileName: `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`, + }; + } catch (error) { + console.error("Failed to finalize native macOS webcam recording:", error); + return undefined; + } + })(); + + const clearNativeRecordingState = () => { + nativeMacRecording.current = null; + setRecording(false); + setPaused(false); + setElapsedSeconds(0); + accumulatedDurationMs.current = 0; + segmentStartedAt.current = null; + }; + + try { + const result = await window.electronAPI.stopNativeMacRecording(discard); + const webcamAsset = await webcamAssetPromise; + if (discard || result.discarded) { + clearNativeRecordingState(); + return true; + } + if (!result.success) { + console.error("Failed to stop native macOS recording:", result.error); + toast.error(result.error ?? "Failed to stop native macOS recording"); + activeNativeRecording.finalizing = false; + return true; + } + + if (webcamAsset && result.path) { + const attachResult = await window.electronAPI.attachNativeMacWebcamRecording({ + screenVideoPath: result.path, + recordingId: activeNativeRecording.recordingId, + webcam: webcamAsset, + cursorCaptureMode, + }); + if (attachResult.success) { + result.session = attachResult.session; + } else { + console.error("Failed to attach native macOS webcam recording:", attachResult.error); + toast.error(attachResult.error ?? "Failed to store webcam recording"); + } + } + + clearNativeRecordingState(); + if (result.session) { + await window.electronAPI.setCurrentRecordingSession(result.session); + } else if (result.path) { + await window.electronAPI.setCurrentVideoPath(result.path); + } + + await window.electronAPI.switchToEditor(); + return true; + } catch (error) { + console.error("Error saving native macOS recording:", error); + toast.error( + error instanceof Error ? error.message : "Failed to save native macOS recording", + ); + activeNativeRecording.finalizing = false; + return true; + } finally { + if (discardRecordingId.current === activeNativeRecording.recordingId) { + discardRecordingId.current = null; + } } - } - }, []); + }, + [cursorCaptureMode, getRecordingDurationMs], + ); const stopRecording = useRef(() => { if (nativeWindowsRecording.current) { void finalizeNativeWindowsRecording(false); return; } + if (nativeMacRecording.current) { + void finalizeNativeMacRecording(false); + return; + } const activeScreenRecorder = screenRecorder.current; if (!activeScreenRecorder) { @@ -529,6 +698,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (nativeWindowsRecording.current) { void finalizeNativeWindowsRecording(true); } + if (nativeMacRecording.current) { + void finalizeNativeMacRecording(true); + } if ( screenRecorder.current?.recorder.state === "recording" || @@ -554,7 +726,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamRecorder.current = null; teardownMedia(); }; - }, [teardownMedia, safeHideCountdownOverlay, finalizeNativeWindowsRecording]); + }, [ + teardownMedia, + safeHideCountdownOverlay, + finalizeNativeWindowsRecording, + finalizeNativeMacRecording, + ]); const safeShowCountdownOverlay = async (value: number, runId: number) => { try { @@ -584,6 +761,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const isCountdownRunActive = (runId?: number) => runId === undefined || countdownRunId.current === runId; + const waitForWebcamReady = async () => { + if (webcamReady.current) { + return; + } + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (webcamReady.current) { + clearInterval(interval); + resolve(); + } + }, 50); + setTimeout(() => { + clearInterval(interval); + resolve(); + }, 5000); + }); + }; + const startNativeWindowsRecordingIfAvailable = async ( selectedSource: ProcessedDesktopSource, countdownRunToken?: number, @@ -599,12 +795,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (availability.reason === "unsupported-os") { return false; } + if (availability.reason === "missing-helper") { + console.warn("Native Windows capture helper is not available; using browser capture."); + 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)) { @@ -616,6 +812,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display"; const windowHandle = parseWindowHandleFromSourceId(selectedSource.id); if (webcamEnabled) { + await waitForWebcamReady(); + if (!isCountdownRunActive(countdownRunToken)) { + return true; + } + } + const browserWebcamRecorder = + webcamEnabled && webcamStream.current + ? createRecorderHandle(webcamStream.current, { + mimeType: selectMimeType(), + videoBitsPerSecond: BITRATE_BASE, + }) + : null; + if (webcamEnabled && !browserWebcamRecorder) { stopWebcamPreviewStream(); } const request: NativeWindowsRecordingRequest = { @@ -643,11 +852,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, }, webcam: { - enabled: webcamEnabled, + enabled: webcamEnabled && !browserWebcamRecorder, deviceId: webcamDeviceId, deviceName: webcamDeviceName, - width: WEBCAM_TARGET_WIDTH, - height: WEBCAM_TARGET_HEIGHT, + width: 0, + height: 0, fps: WEBCAM_TARGET_FRAME_RATE, }, cursor: { @@ -656,6 +865,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const result = await window.electronAPI.startNativeWindowsRecording(request); if (!result.success || !result.recordingId) { + if ( + browserWebcamRecorder?.recorder.state === "recording" || + browserWebcamRecorder?.recorder.state === "paused" + ) { + browserWebcamRecorder.recorder.stop(); + } throw new Error(result.error ?? "Native Windows capture failed."); } @@ -663,7 +878,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { nativeWindowsRecording.current = { recordingId: result.recordingId, finalizing: false, + paused: false, + webcamRecorder: browserWebcamRecorder, }; + webcamRecorder.current = browserWebcamRecorder; accumulatedDurationMs.current = 0; segmentStartedAt.current = Date.now(); allowAutoFinalize.current = true; @@ -677,6 +895,150 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + const startNativeMacRecordingIfAvailable = async ( + selectedSource: ProcessedDesktopSource, + countdownRunToken?: number, + ) => { + try { + const platform = await window.electronAPI.getPlatform(); + if (platform !== "darwin") { + return false; + } + + const availability = await window.electronAPI.isNativeMacCaptureAvailable(); + if (!availability.success || !availability.available) { + if (availability.reason === "unsupported-platform") { + return false; + } + + throw new Error( + availability.reason === "missing-helper" + ? "Native macOS capture helper is not available." + : (availability.error ?? "Native macOS capture is not available."), + ); + } + + if (!isCountdownRunActive(countdownRunToken)) { + return true; + } + + const activeRecordingId = Date.now(); + const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display"; + const displayId = + Number(selectedSource.display_id) || parseMacDisplayIdFromSourceId(selectedSource.id); + const windowId = parseMacWindowIdFromSourceId(selectedSource.id); + let nativeWebcamRecorder: RecorderHandle | null = null; + if (webcamEnabled) { + if (!webcamReady.current) { + await new Promise((resolve) => { + const interval = setInterval(() => { + if (webcamReady.current) { + clearInterval(interval); + resolve(); + } + }, 50); + setTimeout(() => { + clearInterval(interval); + resolve(); + }, 5000); + }); + } + if (!isCountdownRunActive(countdownRunToken)) { + return true; + } + if (webcamStream.current) { + nativeWebcamRecorder = createRecorderHandle(webcamStream.current, { + mimeType: selectMimeType(), + videoBitsPerSecond: BITRATE_BASE, + }); + } else { + webcamAcquireId.current++; + setWebcamEnabledState(false); + } + } + if (!isCountdownRunActive(countdownRunToken)) { + if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { + nativeWebcamRecorder.recorder.stop(); + } + return true; + } + const request: NativeMacRecordingRequest = { + schemaVersion: 1, + recordingId: activeRecordingId, + source: { + type: sourceType, + sourceId: selectedSource.id, + ...(displayId ? { displayId } : {}), + ...(windowId ? { windowId } : {}), + }, + video: { + fps: TARGET_FRAME_RATE, + width: TARGET_WIDTH, + height: TARGET_HEIGHT, + bitrate: computeBitrate(TARGET_WIDTH, TARGET_HEIGHT), + hideSystemCursor: cursorCaptureMode === "editable-overlay", + }, + audio: { + system: { + enabled: systemAudioEnabled, + }, + microphone: { + enabled: microphoneEnabled, + deviceId: microphoneDeviceId, + deviceName: microphoneDeviceName, + gain: MIC_GAIN_BOOST, + }, + }, + webcam: { + enabled: webcamEnabled, + deviceId: webcamDeviceId, + deviceName: webcamDeviceName, + width: 0, + height: 0, + fps: WEBCAM_TARGET_FRAME_RATE, + }, + cursor: { + mode: cursorCaptureMode, + }, + outputs: { + screenPath: "", + }, + }; + const result = await window.electronAPI.startNativeMacRecording(request); + if (!result.success || !result.recordingId) { + if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { + nativeWebcamRecorder.recorder.stop(); + } + throw new Error(result.error ?? "Native macOS capture failed."); + } + if (!isCountdownRunActive(countdownRunToken)) { + if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { + nativeWebcamRecorder.recorder.stop(); + } + await window.electronAPI.stopNativeMacRecording(true); + return true; + } + + recordingId.current = result.recordingId; + nativeMacRecording.current = { + recordingId: result.recordingId, + finalizing: false, + paused: false, + }; + webcamRecorder.current = nativeWebcamRecorder; + accumulatedDurationMs.current = 0; + segmentStartedAt.current = Date.now(); + allowAutoFinalize.current = true; + setRecording(true); + setPaused(false); + setElapsedSeconds(0); + return true; + } catch (error) { + console.error("Native macOS capture failed:", error); + throw error; + } + }; + const startRecordCountdown = async () => { if (countdownActive || recording) { return; @@ -684,7 +1046,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const runId = countdownRunId.current + 1; countdownRunId.current = runId; - setCountdownActive(true); let selectedSource: ProcessedDesktopSource | null = null; try { @@ -705,6 +1066,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } + try { + const platform = await window.electronAPI.getPlatform(); + if (platform === "darwin" && cursorCaptureMode === "editable-overlay") { + const access = await window.electronAPI.requestNativeMacCursorAccess(); + if (!access.granted) { + toast.info(t("recording.accessibilityAllowAndRetry")); + return; + } + } + } catch (error) { + console.warn("Failed to preflight macOS cursor accessibility before countdown:", error); + } + + if (!isCountdownRunActive(runId)) { + return; + } + + setCountdownActive(true); + let overlayHiddenBeforeStart = false; try { const values = [3, 2, 1]; @@ -767,6 +1147,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) { return; } + if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) { + return; + } let screenMediaStream: MediaStream; const platform = await window.electronAPI.getPlatform(); @@ -955,13 +1338,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingId.current = Date.now(); const activeRecordingId = recordingId.current; - screenRecorder.current = createRecorderHandle(stream.current, { - mimeType, - videoBitsPerSecond, - ...(hasAudio - ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } - : {}), - }); + screenRecorder.current = createRecorderHandle( + stream.current, + { + mimeType, + videoBitsPerSecond, + ...(hasAudio + ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } + : {}), + }, + `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`, + ); screenRecorder.current.recorder.addEventListener( "error", () => { @@ -971,10 +1358,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); if (webcamStream.current) { - webcamRecorder.current = createRecorderHandle(webcamStream.current, { - mimeType, - videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE), - }); + webcamRecorder.current = createRecorderHandle( + webcamStream.current, + { mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE) }, + `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`, + ); } accumulatedDurationMs.current = 0; @@ -1024,6 +1412,79 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const togglePaused = () => { + const activeNativeWindowsRecording = nativeWindowsRecording.current; + if (activeNativeWindowsRecording && !activeNativeWindowsRecording.finalizing) { + void (async () => { + try { + if (activeNativeWindowsRecording.paused) { + const result = await window.electronAPI.resumeNativeWindowsRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to resume native Windows recording"); + } + activeNativeWindowsRecording.paused = false; + segmentStartedAt.current = Date.now(); + setPaused(false); + return; + } + + const pausedAtMs = getRecordingDurationMs(); + const result = await window.electronAPI.pauseNativeWindowsRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to pause native Windows recording"); + } + activeNativeWindowsRecording.paused = true; + accumulatedDurationMs.current = pausedAtMs; + segmentStartedAt.current = null; + setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); + setPaused(true); + } catch (error) { + console.error("Failed to toggle native Windows pause state:", error); + toast.error(error instanceof Error ? error.message : "Failed to toggle pause state"); + } + })(); + return; + } + + const activeNativeMacRecording = nativeMacRecording.current; + if (activeNativeMacRecording && !activeNativeMacRecording.finalizing) { + void (async () => { + const activeWebcamRecorder = webcamRecorder.current?.recorder; + try { + if (activeNativeMacRecording.paused) { + const result = await window.electronAPI.resumeNativeMacRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to resume native macOS recording"); + } + if (activeWebcamRecorder?.state === "paused") { + activeWebcamRecorder.resume(); + } + activeNativeMacRecording.paused = false; + segmentStartedAt.current = Date.now(); + setPaused(false); + return; + } + + const pausedAtMs = getRecordingDurationMs(); + const result = await window.electronAPI.pauseNativeMacRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to pause native macOS recording"); + } + if (activeWebcamRecorder?.state === "recording") { + activeWebcamRecorder.pause(); + } + activeNativeMacRecording.paused = true; + accumulatedDurationMs.current = pausedAtMs; + segmentStartedAt.current = null; + setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); + setPaused(true); + } catch (error) { + console.error("Failed to toggle native macOS pause state:", error); + toast.error(error instanceof Error ? error.message : "Failed to toggle pause state"); + } + })(); + return; + } + const activeScreenRecorder = screenRecorder.current?.recorder; if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") { return; @@ -1092,6 +1553,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } return; } + if (nativeMacRecording.current) { + const activeRecordingId = recordingId.current; + restarting.current = true; + discardRecordingId.current = activeRecordingId; + try { + await finalizeNativeMacRecording(true); + await startRecording(); + } finally { + restarting.current = false; + } + return; + } const activeScreenRecorder = screenRecorder.current; if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return; @@ -1157,6 +1630,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { void finalizeNativeWindowsRecording(true); return; } + if (nativeMacRecording.current) { + const activeRecordingId = recordingId.current; + discardRecordingId.current = activeRecordingId; + allowAutoFinalize.current = false; + void finalizeNativeMacRecording(true); + return; + } const activeScreenRecorder = screenRecorder.current; if ( @@ -1183,6 +1663,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { elapsedSeconds, toggleRecording, togglePaused, + canPauseRecording, restartRecording, cancelRecording, microphoneEnabled, diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts index b0295707..5d06b790 100644 --- a/src/i18n/__tests__/tutorialHelpTranslations.test.ts +++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts @@ -4,6 +4,7 @@ import arDialogs from "@/i18n/locales/ar/dialogs.json"; import enDialogs from "@/i18n/locales/en/dialogs.json"; import esDialogs from "@/i18n/locales/es/dialogs.json"; import frDialogs from "@/i18n/locales/fr/dialogs.json"; +import itDialogs from "@/i18n/locales/it/dialogs.json"; import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json"; import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json"; import ruDialogs from "@/i18n/locales/ru/dialogs.json"; @@ -39,16 +40,17 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des const dialogsByLocale = { en: enDialogs, - "zh-CN": zhCNDialogs, - "zh-TW": zhTWDialogs, + ar: arDialogs, es: esDialogs, fr: frDialogs, - tr: trDialogs, + it: itDialogs, + "ja-JP": jaJPDialogs, "ko-KR": koKRDialogs, ru: ruDialogs, - "ja-JP": jaJPDialogs, - ar: arDialogs, + tr: trDialogs, vi: viDialogs, + "zh-CN": zhCNDialogs, + "zh-TW": zhTWDialogs, } satisfies Record }>; describe("TutorialHelp translations", () => { diff --git a/src/i18n/config.ts b/src/i18n/config.ts index a30974e3..107ff840 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -1,17 +1,18 @@ export const DEFAULT_LOCALE = "en" as const; export const SUPPORTED_LOCALES = [ "en", - "zh-CN", - "zh-TW", + "ar", "es", "fr", - "tr", - "ko-KR", + "it", "ja-JP", - "ar", + "ko-KR", "ru", + "tr", "vi", "pt-BR", + "zh-CN", + "zh-TW", ] as const; export const I18N_NAMESPACES = [ "common", diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index a246f011..1eec625b 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.", "cameraDisconnected": "تم فصل كاميرا الويب.", "cameraNotFound": "لم يتم العثور على كاميرا.", - "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة." + "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.", + "accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي." } } diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index 19da8fb7..449872f8 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -39,5 +39,9 @@ "description": "اكتشفنا أن {{language}} هي لغة نظامك. هل تريد تبديل OpenScreen إلى {{language}}؟", "switch": "التبديل إلى {{language}}", "keepDefault": "الاحتفاظ باللغة الحالية" + }, + "cursor": { + "useEditableCursor": "استخدام مؤشر قابل للتحرير", + "useSystemCursor": "استخدام مؤشر النظام" } } diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 47fdca49..216740a1 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير", "level": "مستوى التكبير", "selectRegion": "حدد منطقة التكبير للتعديل", "deleteZoom": "حذف التكبير", @@ -8,6 +9,21 @@ "manual": "يدوي", "auto": "تلقائي", "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل" + }, + "customScale": "تكبير مخصص", + "position": { + "title": "موضع التركيز", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل" + }, + "threeD": { + "title": "دوران ثلاثي الأبعاد", + "preset": { + "iso": "متساوي القياس", + "left": "يسار", + "right": "يمين" + } } }, "speed": { @@ -28,7 +44,8 @@ "verticalStack": "تكدس عمودي", "dualFrame": "إطار مزدوج", "webcamShape": "شكل الكاميرا", - "webcamSize": "حجم كاميرا الويب" + "webcamSize": "حجم كاميرا الويب", + "noWebcam": "بدون كاميرا" }, "effects": { "title": "تأثيرات الفيديو", @@ -69,10 +86,10 @@ "gifDescription": "صورة متحركة للمشاركة" }, "exportQuality": { - "title": "جودة التصدير", - "low": "منخفضة", - "medium": "متوسطة", - "high": "عالية" + "title": "دقة التصدير", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "معدل إطارات GIF", @@ -88,8 +105,9 @@ "gifButton": "تصدير GIF", "chooseSaveLocation": "اختيار موقع الحفظ" }, - "links": { + "support": { "reportBug": "الإبلاغ عن خطأ", + "saveDiagnostics": "حفظ التشخيصات", "starOnGithub": "إعطاء نجمة على GitHub" }, "imageUpload": { @@ -175,7 +193,19 @@ "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.", "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google." }, + "cursor": { + "show": "إظهار المؤشر", + "size": "الحجم", + "smoothing": "التنعيم", + "motionBlur": "ضبابية الحركة", + "clickBounce": "ارتداد النقر", + "clipToBounds": "القص ضمن اللوحة" + }, "language": { "title": "اللغة" + }, + "timeline": { + "title": "المخطط الزمني", + "waveform": "عرض الموجة الصوتية على مسار القطع" } } diff --git a/src/i18n/locales/ar/shortcuts.json b/src/i18n/locales/ar/shortcuts.json index a560c06a..b18e3195 100644 --- a/src/i18n/locales/ar/shortcuts.json +++ b/src/i18n/locales/ar/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.", "savedToast": "تم حفظ اختصارات لوحة المفاتيح", "resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق", + "registrationFailed": "فشل في تسجيل الاختصار. قد يكون مستخدمًا من قبل تطبيق آخر. جرب مفتاحًا مختلفًا.", "actions": { + "openApp": "فتح التطبيق", "addZoom": "إضافة تكبير", "addTrim": "إضافة قص", "addSpeed": "إضافة سرعة", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 13e2e139..aad37003 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "Camera access denied. Recording will continue without webcam.", "cameraDisconnected": "Webcam disconnected.", "cameraNotFound": "Camera not found.", - "permissionDenied": "Recording permission denied. Please allow screen recording." + "permissionDenied": "Recording permission denied. Please allow screen recording.", + "accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown." } } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9d417361..b34e0c29 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Hold to preview zoom effect", "level": "Zoom Level", "customScale": "Custom Zoom", "selectRegion": "Select a zoom region to adjust", @@ -85,10 +86,10 @@ "gifDescription": "Animated image for sharing" }, "exportQuality": { - "title": "Export Quality", - "low": "Low", - "medium": "Medium", - "high": "High" + "title": "Export resolution", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF Frame Rate", @@ -104,13 +105,14 @@ "gifButton": "Export GIF", "chooseSaveLocation": "Choose Save Location" }, - "links": { + "support": { "reportBug": "Report Bug", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "Star on GitHub" }, "imageUpload": { "invalidFileType": "Invalid file type", - "jpgOnly": "Please upload a JPG or JPEG image file.", + "jpgOnly": "Please upload a JPG, JPEG, or PNG image file.", "uploadSuccess": "Custom image uploaded successfully!", "failedToUpload": "Failed to upload image", "errorReading": "There was an error reading the file." @@ -191,7 +193,19 @@ "errorTimeout": "Font took too long to load. Please check the URL and try again.", "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct." }, + "cursor": { + "show": "Show Cursor", + "size": "Size", + "smoothing": "Smoothing", + "motionBlur": "Motion Blur", + "clickBounce": "Click Bounce", + "clipToBounds": "Clip to Canvas" + }, "language": { "title": "Language" + }, + "timeline": { + "title": "Timeline", + "waveform": "Show Audio Waveform on Trim Track" } } diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json index 2c26aa48..8994df13 100644 --- a/src/i18n/locales/en/shortcuts.json +++ b/src/i18n/locales/en/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.", "savedToast": "Keyboard shortcuts saved", "resetToast": "Reset to default shortcuts — click Save to apply", + "registrationFailed": "Failed to register shortcut. It may be in use by another app. Try a different key.", "actions": { + "openApp": "Open App", "addZoom": "Add Zoom", "addTrim": "Add Trim", "addSpeed": "Add Speed", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 13cccb0b..48160a9c 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -15,7 +15,27 @@ "view": "Vista", "window": "Ventana", "quit": "Salir", - "stopRecording": "Detener grabación" + "stopRecording": "Detener grabación", + "undo": "Deshacer", + "redo": "Rehacer", + "cut": "Cortar", + "copy": "Copiar", + "paste": "Pegar", + "selectAll": "Seleccionar todo", + "minimize": "Minimizar", + "reload": "Recargar", + "forceReload": "Forzar recarga", + "toggleDevTools": "Alternar herramientas de desarrollador", + "actualSize": "Tamaño real", + "zoomIn": "Acercar", + "zoomOut": "Alejar", + "toggleFullScreen": "Alternar pantalla completa", + "recordingStatus": "Grabando: {{source}}", + "about": "Acerca de OpenScreen", + "services": "Servicios", + "hide": "Ocultar OpenScreen", + "hideOthers": "Ocultar otros", + "unhide": "Mostrar todo" }, "playback": { "play": "Reproducir", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 8f6ad13e..27e0caed 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -33,7 +33,8 @@ "cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.", "cameraDisconnected": "Cámara web desconectada.", "cameraNotFound": "Cámara no encontrada.", - "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla." + "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla.", + "accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás." }, "loadingVideo": "Cargando video...", "newRecording": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 20a9ec39..b7b1b2e0 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Mantener para previsualizar el efecto de zoom", "level": "Nivel de zoom", "selectRegion": "Selecciona una región de zoom para ajustar", "deleteZoom": "Eliminar zoom", @@ -16,6 +17,13 @@ "left": "Izquierda", "right": "Derecha" } + }, + "customScale": "Zoom personalizado", + "position": { + "title": "Posición de enfoque", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior" } }, "speed": { @@ -36,7 +44,8 @@ "verticalStack": "Apilado vertical", "dualFrame": "Marco dual", "webcamShape": "Forma de cámara", - "webcamSize": "Tamaño de cámara" + "webcamSize": "Tamaño de cámara", + "noWebcam": "Sin cámara" }, "effects": { "title": "Efectos de video", @@ -45,7 +54,8 @@ "off": "desactivado", "shadow": "Sombra", "roundness": "Redondez", - "padding": "Relleno" + "padding": "Relleno", + "on": "activado" }, "background": { "title": "Fondo", @@ -76,10 +86,10 @@ "gifDescription": "Imagen animada para compartir" }, "exportQuality": { - "title": "Calidad de exportación", - "low": "Baja", - "medium": "Media", - "high": "Alta" + "title": "Resolución de exportación", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Velocidad de cuadros del GIF", @@ -95,13 +105,14 @@ "gifButton": "Exportar GIF", "chooseSaveLocation": "Elegir ubicación de guardado" }, - "links": { + "support": { "reportBug": "Reportar error", + "saveDiagnostics": "Guardar diagnósticos", "starOnGithub": "Dar estrella en GitHub" }, "imageUpload": { "invalidFileType": "Tipo de archivo no válido", - "jpgOnly": "Por favor sube un archivo de imagen JPG o JPEG.", + "jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG.", "uploadSuccess": "¡Imagen personalizada subida exitosamente!", "failedToUpload": "Error al subir la imagen", "errorReading": "Hubo un error al leer el archivo." @@ -182,7 +193,19 @@ "errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.", "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta." }, + "cursor": { + "show": "Mostrar cursor", + "size": "Tamaño", + "smoothing": "Suavizado", + "motionBlur": "Desenfoque de movimiento", + "clickBounce": "Rebote al clic", + "clipToBounds": "Recortar al lienzo" + }, "language": { "title": "Idioma" + }, + "timeline": { + "title": "Línea de tiempo", + "waveform": "Mostrar forma de onda en pista de recorte" } } diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json index 888ded03..49767d56 100644 --- a/src/i18n/locales/es/shortcuts.json +++ b/src/i18n/locales/es/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.", "savedToast": "Atajos de teclado guardados", "resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar", + "registrationFailed": "Error al registrar el atajo. Puede estar en uso por otra aplicación. Prueba otra tecla.", "actions": { + "openApp": "Abrir aplicación", "addZoom": "Agregar zoom", "addTrim": "Agregar recorte", "addSpeed": "Agregar velocidad", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 5d092142..f53ec438 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -15,7 +15,27 @@ "view": "Affichage", "window": "Fenêtre", "quit": "Quitter", - "stopRecording": "Arrêter l'enregistrement" + "stopRecording": "Arrêter l'enregistrement", + "undo": "Annuler", + "redo": "Rétablir", + "cut": "Couper", + "copy": "Copier", + "paste": "Coller", + "selectAll": "Tout sélectionner", + "minimize": "Réduire", + "reload": "Recharger", + "forceReload": "Forcer le rechargement", + "toggleDevTools": "Afficher/masquer les outils de développement", + "actualSize": "Taille réelle", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "toggleFullScreen": "Basculer en plein écran", + "recordingStatus": "Enregistrement : {{source}}", + "about": "À propos d’OpenScreen", + "services": "Services", + "hide": "Masquer OpenScreen", + "hideOthers": "Masquer les autres", + "unhide": "Tout afficher" }, "playback": { "play": "Lecture", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 6380c6b3..7195aedf 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -39,7 +39,8 @@ "cameraDenied": "Accès à la caméra refusé. L'enregistrement continuera sans webcam.", "cameraDisconnected": "Webcam déconnectée.", "cameraNotFound": "Caméra introuvable.", - "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran." + "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran.", + "accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours." }, "loadingVideo": "Chargement de la vidéo..." } diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 66a6d74c..b3e7fce3 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Maintenir pour prévisualiser l’effet de zoom", "level": "Niveau de zoom", "selectRegion": "Sélectionnez une région de zoom à ajuster", "deleteZoom": "Supprimer le zoom", @@ -9,13 +10,7 @@ "auto": "Auto", "autoDescription": "La caméra suit la position du curseur enregistré" }, - "speed": { - "title": "Vitesse du zoom", - "instant": "Instantané", - "fast": "Rapide", - "smooth": "Fluide", - "lazy": "Lent" - }, + "speed": {}, "threeD": { "title": "Rotation 3D", "preset": { @@ -23,6 +18,13 @@ "left": "Gauche", "right": "Droite" } + }, + "customScale": "Zoom personnalisé", + "position": { + "title": "Position du focus", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas" } }, "speed": { @@ -43,7 +45,8 @@ "verticalStack": "Empilement vertical", "dualFrame": "Double cadre", "webcamShape": "Forme de la caméra", - "webcamSize": "Taille de la caméra" + "webcamSize": "Taille de la caméra", + "noWebcam": "Sans webcam" }, "effects": { "title": "Effets vidéo", @@ -52,7 +55,8 @@ "off": "désactivé", "shadow": "Ombre", "roundness": "Arrondi", - "padding": "Marge" + "padding": "Marge", + "on": "activé" }, "background": { "title": "Arrière-plan", @@ -83,10 +87,10 @@ "gifDescription": "Image animée pour le partage" }, "exportQuality": { - "title": "Qualité d'export", - "low": "Faible", - "medium": "Moyenne", - "high": "Haute" + "title": "Résolution d'export", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Fréquence d'images GIF", @@ -102,13 +106,14 @@ "gifButton": "Exporter le GIF", "chooseSaveLocation": "Choisir l'emplacement d'enregistrement" }, - "links": { + "support": { "reportBug": "Signaler un bug", + "saveDiagnostics": "Enregistrer les diagnostics", "starOnGithub": "Étoile sur GitHub" }, "imageUpload": { "invalidFileType": "Type de fichier invalide", - "jpgOnly": "Veuillez téléverser un fichier image JPG ou JPEG.", + "jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.", "uploadSuccess": "Image personnalisée téléversée avec succès !", "failedToUpload": "Échec du téléversement de l'image", "errorReading": "Une erreur s'est produite lors de la lecture du fichier." @@ -189,7 +194,19 @@ "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.", "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte." }, + "cursor": { + "show": "Afficher le curseur", + "size": "Taille", + "smoothing": "Lissage", + "motionBlur": "Flou de mouvement", + "clickBounce": "Rebond au clic", + "clipToBounds": "Rogner au canevas" + }, "language": { "title": "Langue" + }, + "timeline": { + "title": "Montage", + "waveform": "Afficher la forme d'onde sur la piste de découpe" } } diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json index a0ce83f7..eec8a591 100644 --- a/src/i18n/locales/fr/shortcuts.json +++ b/src/i18n/locales/fr/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.", "savedToast": "Raccourcis clavier enregistrés", "resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer", + "registrationFailed": "Échec de l'enregistrement du raccourci. Il est peut-être utilisé par une autre application. Essayez une autre touche.", "actions": { + "openApp": "Ouvrir l'application", "addZoom": "Ajouter un zoom", "addTrim": "Ajouter une coupe", "addSpeed": "Ajouter une vitesse", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json new file mode 100644 index 00000000..c829a98a --- /dev/null +++ b/src/i18n/locales/it/common.json @@ -0,0 +1,50 @@ +{ + "actions": { + "cancel": "Annulla", + "save": "Salva", + "delete": "Elimina", + "close": "Chiudi", + "share": "Condividi", + "done": "Fatto", + "open": "Apri", + "upload": "Carica", + "export": "Esporta", + "showInFolder": "Mostra nella cartella", + "file": "File", + "edit": "Modifica", + "view": "Visualizza", + "window": "Finestra", + "quit": "Esci", + "stopRecording": "Interrompi registrazione", + "undo": "Annulla", + "redo": "Ripeti", + "cut": "Taglia", + "copy": "Copia", + "paste": "Incolla", + "selectAll": "Seleziona tutto", + "minimize": "Riduci a icona", + "reload": "Ricarica", + "forceReload": "Forza ricarica", + "toggleDevTools": "Attiva/disattiva strumenti sviluppatore", + "actualSize": "Dimensione effettiva", + "zoomIn": "Ingrandisci", + "zoomOut": "Riduci", + "toggleFullScreen": "Attiva/disattiva schermo intero", + "recordingStatus": "Registrazione: {{source}}", + "about": "Info su OpenScreen", + "services": "Servizi", + "hide": "Nascondi OpenScreen", + "hideOthers": "Nascondi gli altri", + "unhide": "Mostra tutto" + }, + "playback": { + "play": "Riproduci", + "pause": "Pausa", + "fullscreen": "Schermo intero", + "exitFullscreen": "Esci dallo schermo intero" + }, + "locale": { + "name": "Italiano", + "short": "IT" + } +} diff --git a/src/i18n/locales/it/dialogs.json b/src/i18n/locales/it/dialogs.json new file mode 100644 index 00000000..5593b308 --- /dev/null +++ b/src/i18n/locales/it/dialogs.json @@ -0,0 +1,70 @@ +{ + "export": { + "complete": "Esportazione completata", + "yourFormatReady": "Il tuo {{format}} è pronto", + "showInFolder": "Mostra nella cartella", + "finalizingVideo": "Finalizzazione esportazione video...", + "compilingGifProgress": "Compilazione GIF... {{progress}}%", + "compilingGifWait": "Compilazione GIF... Potrebbe richiedere del tempo", + "takeMoment": "Ci potrebbe impiegare un momento...", + "failed": "Esportazione fallita", + "tryAgain": "Riprova", + "finalizingVideoTitle": "Finalizzazione video", + "compilingGif": "Compilazione GIF", + "exportingFormat": "Esportazione {{format}}", + "compiling": "Compilazione", + "renderingFrames": "Rendering fotogrammi", + "processing": "Elaborazione...", + "finalizing": "Finalizzazione...", + "compilingStatus": "Compilazione...", + "status": "Stato", + "format": "Formato", + "frames": "Fotogrammi", + "cancelExport": "Annulla esportazione", + "savedSuccessfully": "{{format}} salvato con successo!" + }, + "tutorial": { + "triggerLabel": "Come funziona il taglio", + "title": "Come funziona il taglio", + "description": "Capire come eliminare le parti indesiderate del tuo video.", + "explanationBefore": "Lo strumento Taglia funziona definendo i segmenti che vuoi", + "remove": "rimuovere", + "explanationMiddle": " — tutto ciò", + "covered": "coperto", + "explanationAfter": "da un segmento rosso verrà eliminato durante l'esportazione.", + "visualExample": "Esempio visivo", + "removed": "RIMOSSO", + "kept": "Mantenuto", + "part1": "Parte 1", + "part2": "Parte 2", + "part3": "Parte 3", + "finalVideo": "Video finale", + "step1Title": "1. Aggiungi taglio", + "step1DescriptionBefore": "Premi ", + "step1DescriptionAfter": " o clicca sull'icona delle forbici per contrassegnare una sezione da rimuovere.", + "step2Title": "2. Regolazione", + "step2Description": "Trascina i bordi della regione rossa per coprire esattamente ciò che vuoi eliminare." + }, + "unsavedChanges": { + "title": "Modifiche non salvate", + "message": "Hai delle modifiche non salvate.", + "detail": "Vuoi salvare il progetto prima di chiudere?", + "saveAndClose": "Salva e chiudi", + "discardAndClose": "Scarta e chiudi", + "loadProject": "Carica progetto…", + "saveProject": "Salva progetto…", + "saveProjectAs": "Salva progetto come…" + }, + "fileDialogs": { + "saveGif": "Salva GIF esportata", + "saveVideo": "Salva video esportato", + "selectVideo": "Seleziona file video", + "saveProject": "Salva progetto OpenScreen", + "openProject": "Apri progetto OpenScreen", + "gifImage": "Immagine GIF", + "mp4Video": "Video MP4", + "videoFiles": "File video", + "openscreenProject": "Progetto OpenScreen", + "allFiles": "Tutti i file" + } +} diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json new file mode 100644 index 00000000..336d3e6b --- /dev/null +++ b/src/i18n/locales/it/editor.json @@ -0,0 +1,46 @@ +{ + "newRecording": { + "title": "Torna al registratore", + "description": "La sessione corrente è stata salvata.", + "cancel": "Annulla", + "confirm": "Conferma" + }, + "loadingVideo": "Caricamento video...", + "errors": { + "noVideoLoaded": "Nessun video caricato", + "videoNotReady": "Video non pronto", + "unableToDetermineSourcePath": "Impossibile determinare il percorso del video sorgente", + "failedToSaveGif": "Impossibile salvare la GIF", + "gifExportFailed": "Esportazione GIF fallita", + "failedToSaveVideo": "Impossibile salvare il video", + "exportFailed": "Esportazione fallita", + "exportFailedWithError": "Esportazione fallita: {{error}}", + "exportBackgroundLoadFailed": "Esportazione fallita: impossibile caricare l'immagine di sfondo ({{url}})", + "failedToSaveExport": "Impossibile salvare l'esportazione", + "failedToSaveExportedVideo": "Impossibile salvare il video esportato", + "failedToRevealInFolder": "Errore durante la visualizzazione nella cartella: {{error}}" + }, + "export": { + "canceled": "Esportazione annullata", + "exportedSuccessfully": "{{format}} esportato con successo" + }, + "project": { + "saveCanceled": "Salvataggio progetto annullato", + "failedToSave": "Impossibile salvare il progetto", + "savedTo": "Progetto salvato in {{path}}", + "failedToLoad": "Impossibile caricare il progetto", + "invalidFormat": "Formato file progetto non valido", + "loadedFrom": "Progetto caricato da {{path}}" + }, + "recording": { + "failedCameraAccess": "Impossibile richiedere l'accesso alla fotocamera.", + "cameraBlocked": "L'accesso alla fotocamera è bloccato. Abilita nelle impostazioni di sistema l'utilizzo della webcam.", + "systemAudioUnavailable": "Audio di sistema non disponibile. Registrazione senza audio di sistema.", + "microphoneDenied": "Accesso al microfono negato. La registrazione continuerà senza audio.", + "cameraDenied": "Accesso alla fotocamera negato. La registrazione continuerà senza webcam.", + "cameraDisconnected": "Webcam disconnessa.", + "cameraNotFound": "Fotocamera non trovata.", + "permissionDenied": "Autorizzazione di registrazione negata. Consenti la registrazione dello schermo.", + "accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia." + } +} diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json new file mode 100644 index 00000000..fc7019e5 --- /dev/null +++ b/src/i18n/locales/it/launch.json @@ -0,0 +1,47 @@ +{ + "tooltips": { + "hideHUD": "Nascondi", + "closeApp": "Chiudi app", + "restartRecording": "Riavvia registrazione", + "cancelRecording": "Annulla registrazione", + "pauseRecording": "Metti in pausa registrazione", + "resumeRecording": "Riprendi registrazione", + "openVideoFile": "Apri file video", + "openProject": "Apri progetto" + }, + "audio": { + "enableSystemAudio": "Abilita audio di sistema", + "disableSystemAudio": "Disabilita audio di sistema", + "enableMicrophone": "Abilita microfono", + "disableMicrophone": "Disabilita microfono", + "defaultMicrophone": "Microfono predefinito" + }, + "webcam": { + "enableWebcam": "Abilita webcam", + "disableWebcam": "Disabilita webcam", + "defaultCamera": "Fotocamera predefinita", + "searching": "Ricerca in corso...", + "noneFound": "Nessuna fotocamera trovata", + "unavailable": "Fotocamera non disponibile" + }, + "cursor": { + "useEditableCursor": "Usa cursore modificabile", + "useSystemCursor": "Usa cursore di sistema" + }, + "sourceSelector": { + "loading": "Caricamento sorgenti...", + "screens": "Schermi ({{count}})", + "windows": "Finestre ({{count}})", + "defaultSourceName": "Schermo" + }, + "recording": { + "selectSource": "Seleziona una sorgente da registrare" + }, + "language": "Lingua", + "systemLanguagePrompt": { + "title": "Usare la lingua del sistema?", + "description": "Abbiamo rilevato {{language}} come lingua del sistema. Vuoi passare OpenScreen a {{language}}?", + "switch": "Passa a {{language}}", + "keepDefault": "Mantieni la lingua corrente" + } +} diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json new file mode 100644 index 00000000..46e6083a --- /dev/null +++ b/src/i18n/locales/it/settings.json @@ -0,0 +1,211 @@ +{ + "zoom": { + "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom", + "level": "Livello zoom", + "customScale": "Zoom personalizzato", + "selectRegion": "Seleziona una regione zoom da regolare", + "deleteZoom": "Elimina zoom", + "focusMode": { + "title": "Modalità messa a fuoco", + "manual": "Manuale", + "auto": "Automatico", + "autoDescription": "La fotocamera segue la posizione del cursore registrato" + }, + "threeD": { + "title": "Rotazione 3D", + "preset": { + "iso": "Iso", + "left": "Sinistra", + "right": "Destra" + } + }, + "position": { + "title": "Posizione messa a fuoco", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso" + } + }, + "speed": { + "playbackSpeed": "Velocità di riproduzione", + "selectRegion": "Seleziona una regione velocità da regolare", + "deleteRegion": "Elimina regione velocità", + "customPlaybackSpeed": "Velocità di riproduzione personalizzata", + "maxSpeedError": "La velocità non può superare 16×" + }, + "trim": { + "deleteRegion": "Elimina regione taglio" + }, + "layout": { + "title": "Layout", + "preset": "Predefinito", + "selectPreset": "Seleziona predefinito", + "pictureInPicture": "Immagine nell'immagine", + "verticalStack": "Pila verticale", + "dualFrame": "Doppio frame", + "noWebcam": "Nessuna webcam", + "webcamShape": "Forma fotocamera", + "webcamSize": "Dimensione webcam" + }, + "effects": { + "title": "Effetti video", + "blurBg": "Sfuma sfondo", + "motionBlur": "Sfocatura movimento", + "off": "spento", + "on": "acceso", + "shadow": "Ombra", + "roundness": "Arrotondamento", + "padding": "Spaziatura" + }, + "background": { + "title": "Sfondo", + "image": "Immagine", + "color": "Colore", + "gradient": "Sfumatura", + "uploadCustom": "Carica personalizzato", + "gradientLabel": "Sfumatura {{index}}", + "colorWheel": "Ruota dei colori", + "colorPalette": "Tavolozza dei colori" + }, + "crop": { + "title": "Ritaglia", + "cropVideo": "Ritaglia video", + "dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio", + "ratio": "Proporzioni", + "free": "Libero", + "done": "Fatto", + "lockAspectRatio": "Blocca proporzioni", + "unlockAspectRatio": "Sblocca proporzioni" + }, + "exportFormat": { + "mp4": "MP4", + "gif": "GIF", + "mp4Video": "Video MP4", + "mp4Description": "File video di alta qualità", + "gifAnimation": "Animazione GIF", + "gifDescription": "Immagine animata per la condivisione" + }, + "exportQuality": { + "title": "Risoluzione esportazione", + "low": "720p", + "medium": "1080p", + "high": "Originale" + }, + "gifSettings": { + "frameRate": "Frequenza fotogrammi GIF", + "size": "Dimensione GIF", + "loop": "GIF in loop" + }, + "project": { + "save": "Salva progetto", + "load": "Carica progetto" + }, + "export": { + "videoButton": "Esporta video", + "gifButton": "Esporta GIF", + "chooseSaveLocation": "Scegli posizione di salvataggio" + }, + "support": { + "reportBug": "Segnala bug", + "saveDiagnostics": "Salva dati diagnostici", + "starOnGithub": "Metti stella su GitHub" + }, + "imageUpload": { + "invalidFileType": "Tipo di file non valido", + "jpgOnly": "Carica un file immagine JPG o JPEG.", + "uploadSuccess": "Immagine personalizzata caricata con successo!", + "failedToUpload": "Impossibile caricare l'immagine", + "errorReading": "Si è verificato un errore durante la lettura del file." + }, + "annotation": { + "title": "Impostazioni annotazione", + "active": "Attivo", + "typeText": "Testo", + "typeImage": "Immagine", + "typeArrow": "Freccia", + "typeBlur": "Sfocatura", + "textContent": "Contenuto testo", + "textPlaceholder": "Inserisci il tuo testo...", + "fontStyle": "Stile carattere", + "selectStyle": "Seleziona stile", + "size": "Dimensione", + "customFonts": "Caratteri personalizzati", + "textColor": "Colore testo", + "background": "Sfondo", + "none": "Nessuno", + "color": "Colore", + "colorWheel": "Ruota dei colori", + "colorPalette": "Tavolozza dei colori", + "clearBackground": "Rimuovi sfondo", + "uploadImage": "Carica immagine", + "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP", + "arrowDirection": "Direzione freccia", + "strokeWidth": "Larghezza tratto: {{width}}px", + "arrowColor": "Colore freccia", + "blurType": "Tipo sfocatura", + "blurTypeBlur": "Sfocatura", + "blurTypeMosaic": "Sfocatura mosaico", + "blurColor": "Colore sfocatura", + "blurColorWhite": "Bianco", + "blurColorBlack": "Nero", + "blurShape": "Forma sfocatura", + "blurIntensity": "Intensità sfocatura", + "mosaicBlockSize": "Dimensione blocco mosaico", + "blurShapeRectangle": "Rettangolo", + "blurShapeOval": "Ovale", + "blurShapeFreehand": "A mano libera", + "deleteAnnotation": "Elimina annotazione", + "shortcutsAndTips": "Scorciatoie e suggerimenti", + "tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.", + "tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.", + "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.", + "invalidImageType": "Tipo di file non valido", + "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.", + "imageUploadSuccess": "Immagine caricata con successo!", + "failedImageUpload": "Impossibile caricare l'immagine" + }, + "fontStyles": { + "classic": "Classico", + "editor": "Editor", + "strong": "Forte", + "typewriter": "Macchina da scrivere", + "deco": "Deco", + "simple": "Semplice", + "modern": "Moderno", + "clean": "Pulito" + }, + "customFont": { + "dialogTitle": "Aggiungi font Google", + "urlLabel": "URL importazione Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import", + "nameLabel": "Nome visualizzato", + "namePlaceholder": "Il mio font personalizzato", + "nameHelp": "Così apparirà il font nel selettore", + "addButton": "Aggiungi font", + "addingButton": "Aggiunta in corso...", + "errorEmptyUrl": "Inserisci un URL di importazione Google Fonts", + "errorInvalidUrl": "Inserisci un URL Google Fonts valido", + "errorEmptyName": "Inserisci un nome per il font", + "errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL", + "successMessage": "Font \"{{fontName}}\" aggiunto con successo", + "failedToAdd": "Impossibile aggiungere il font", + "errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.", + "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto." + }, + "cursor": { + "show": "Mostra cursore", + "size": "Dimensione", + "smoothing": "Smussatura", + "motionBlur": "Sfocatura movimento", + "clickBounce": "Rimbalzo clic", + "clipToBounds": "Ritaglia al canvas" + }, + "language": { + "title": "Lingua" + }, + "timeline": { + "title": "Timeline", + "waveform": "Mostra la forma d'onda sulla traccia di ritaglio" + } +} diff --git a/src/i18n/locales/it/shortcuts.json b/src/i18n/locales/it/shortcuts.json new file mode 100644 index 00000000..051a8887 --- /dev/null +++ b/src/i18n/locales/it/shortcuts.json @@ -0,0 +1,39 @@ +{ + "title": "Scorciatoie tastiera", + "customize": "Personalizza", + "configurable": "Configurabile", + "fixed": "Fisso", + "pressKey": "Premi un tasto…", + "clickToChange": "Clicca per cambiare", + "pressEscToCancel": "Premi Esc per annullare", + "helpText": "Clicca una scorciatoia e premi la nuova combinazione di tasti. Premi Esc per annullare.", + "resetToDefaults": "Ripristina predefiniti", + "alreadyUsedBy": "Già utilizzata da {{action}}", + "swap": "Scambia", + "reservedShortcut": "Questa scorciatoia è riservata a \"{{label}}\" e non può essere riassegnata.", + "savedToast": "Scorciatoie tastiera salvate", + "resetToast": "Ripristino alle scorciatoie predefinite — clicca Salva per applicare", + "registrationFailed": "Impossibile registrare la scorciatoia. Potrebbe essere in uso da un'altra applicazione. Prova un altro tasto.", + "actions": { + "openApp": "Apri applicazione", + "addZoom": "Aggiungi zoom", + "addTrim": "Aggiungi taglio", + "addSpeed": "Aggiungi velocità", + "addAnnotation": "Aggiungi annotazione", + "addBlur": "Aggiungi sfocatura", + "addKeyframe": "Aggiungi fotogramma chiave", + "deleteSelected": "Elimina selezionato", + "playPause": "Riproduci / Pausa" + }, + "fixedActions": { + "undo": "Annulla", + "redo": "Ripeti", + "cycleAnnotationsForward": "Scorri annotazioni in avanti", + "cycleAnnotationsBackward": "Scorri annotazioni indietro", + "deleteSelectedAlt": "Elimina selezionato (alt)", + "panTimeline": "Panoramica timeline", + "zoomTimeline": "Zoom timeline", + "frameBack": "Fotogramma indietro", + "frameForward": "Fotogramma avanti" + } +} diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json new file mode 100644 index 00000000..499a39a3 --- /dev/null +++ b/src/i18n/locales/it/timeline.json @@ -0,0 +1,55 @@ +{ + "buttons": { + "addZoom": "Aggiungi zoom (Z)", + "suggestZooms": "Suggerisci zoom dal cursore", + "addTrim": "Aggiungi taglio (T)", + "addAnnotation": "Aggiungi annotazione (A)", + "addBlur": "Aggiungi sfocatura (B)", + "addSpeed": "Aggiungi velocità (S)" + }, + "hints": { + "pressZoom": "Premi Z per aggiungere zoom", + "pressTrim": "Premi T per aggiungere taglio", + "pressAnnotation": "Premi A per aggiungere annotazione", + "pressBlur": "Premi B per aggiungere regione sfocatura", + "pressSpeed": "Premi S per aggiungere velocità" + }, + "labels": { + "pan": "Panoramica", + "zoom": "Zoom", + "trim": "Taglio", + "speed": "Velocità", + "zoomItem": "Zoom {{index}}", + "trimItem": "Taglio {{index}}", + "speedItem": "Velocità {{index}}", + "annotationItem": "Annotazione", + "blurItem": "Sfocatura {{index}}", + "imageItem": "Immagine", + "emptyText": "Testo vuoto" + }, + "emptyState": { + "noVideo": "Nessun video caricato", + "dragAndDrop": "Trascina e rilascia un video per iniziare a modificare" + }, + "errors": { + "cannotPlaceZoom": "Impossibile posizionare lo zoom qui", + "zoomExistsAtLocation": "Lo zoom esiste già in questa posizione o non c'è spazio sufficiente.", + "zoomSuggestionUnavailable": "Gestore suggerimenti zoom non disponibile", + "noCursorTelemetry": "Nessuna telemetria del cursore disponibile", + "noCursorTelemetryDescription": "Registra prima uno screencast per generare suggerimenti basati sul cursore.", + "noUsableTelemetry": "Nessuna telemetria del cursore utilizzabile", + "noUsableTelemetryDescription": "La registrazione non include dati sufficienti sul movimento del cursore.", + "noDwellMoments": "Nessun momento di sosta del cursore trovato", + "noDwellMomentsDescription": "Prova una registrazione con pause del cursore più lente sulle azioni importanti.", + "noAutoZoomSlots": "Nessuno slot di zoom automatico disponibile", + "noAutoZoomSlotsDescription": "I punti di sosta rilevati si sovrappongono alle regioni zoom esistenti.", + "cannotPlaceTrim": "Impossibile posizionare il taglio qui", + "trimExistsAtLocation": "Il taglio esiste già in questa posizione o non c'è spazio sufficiente.", + "cannotPlaceSpeed": "Impossibile posizionare la velocità qui", + "speedExistsAtLocation": "La regione velocità esiste già in questa posizione o non c'è spazio sufficiente." + }, + "success": { + "addedZoomSuggestions": "Aggiunto {{count}} suggerimento zoom basato sul cursore", + "addedZoomSuggestionsPlural": "Aggiunti {{count}} suggerimenti zoom basati sul cursore" + } +} diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index ee804f02..74bd986a 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -15,7 +15,27 @@ "view": "表示", "window": "ウィンドウ", "quit": "終了", - "stopRecording": "録画を停止" + "stopRecording": "録画を停止", + "undo": "元に戻す", + "redo": "やり直す", + "cut": "切り取り", + "copy": "コピー", + "paste": "貼り付け", + "selectAll": "すべて選択", + "minimize": "最小化", + "reload": "再読み込み", + "forceReload": "強制再読み込み", + "toggleDevTools": "開発者ツールを切り替え", + "actualSize": "実際のサイズ", + "zoomIn": "拡大", + "zoomOut": "縮小", + "toggleFullScreen": "フルスクリーンを切り替え", + "recordingStatus": "録画中: {{source}}", + "about": "OpenScreenについて", + "services": "サービス", + "hide": "OpenScreenを隠す", + "hideOthers": "ほかを隠す", + "unhide": "すべて表示" }, "playback": { "play": "再生", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 051335f3..d37e132c 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。", "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", "cameraDisconnected": "ウェブカメラが切断されました。", - "cameraNotFound": "カメラが見つかりません。" + "cameraNotFound": "カメラが見つかりません。", + "accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。" } } diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 1ccc3815..8280e00c 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "押している間ズーム効果をプレビュー", "level": "ズーム倍率", "selectRegion": "ズーム範囲を選択して調整", "deleteZoom": "ズームを削除", @@ -16,6 +17,13 @@ "left": "左", "right": "右" } + }, + "customScale": "カスタムズーム", + "position": { + "title": "フォーカス位置", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = 左端 / 上端、100 = 右端 / 下端" } }, "speed": { @@ -36,7 +44,8 @@ "verticalStack": "縦並び", "dualFrame": "デュアルフレーム", "webcamShape": "カメラの形状", - "webcamSize": "カメラのサイズ" + "webcamSize": "カメラのサイズ", + "noWebcam": "Webカメラなし" }, "effects": { "title": "動画効果", @@ -45,7 +54,8 @@ "off": "オフ", "shadow": "影", "roundness": "丸み", - "padding": "余白" + "padding": "余白", + "on": "オン" }, "background": { "title": "背景", @@ -76,10 +86,10 @@ "gifDescription": "共有用のアニメーション画像" }, "exportQuality": { - "title": "エクスポート品質", - "low": "低画質", - "medium": "中画質", - "high": "高画質" + "title": "書き出し解像度", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF フレームレート", @@ -95,13 +105,14 @@ "gifButton": "GIF をエクスポート", "chooseSaveLocation": "保存場所を選択" }, - "links": { + "support": { "reportBug": "バグを報告", + "saveDiagnostics": "診断情報を保存", "starOnGithub": "GitHub でスターを付ける" }, "imageUpload": { "invalidFileType": "無効なファイル形式", - "jpgOnly": "JPG または JPEG 画像ファイルを選択してください。", + "jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。", "uploadSuccess": "カスタム画像を読み込みました。", "failedToUpload": "画像の読み込みに失敗しました", "errorReading": "ファイルの読み取り中にエラーが発生しました。" @@ -182,7 +193,19 @@ "errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。", "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。" }, + "cursor": { + "show": "カーソルを表示", + "size": "サイズ", + "smoothing": "スムージング", + "motionBlur": "モーションブラー", + "clickBounce": "クリックバウンス", + "clipToBounds": "キャンバスにクリップ" + }, "language": { "title": "言語" + }, + "timeline": { + "title": "タイムライン", + "waveform": "トリムトラックに音声波形を表示" } } diff --git a/src/i18n/locales/ja-JP/shortcuts.json b/src/i18n/locales/ja-JP/shortcuts.json index d4b4a90e..1d574198 100644 --- a/src/i18n/locales/ja-JP/shortcuts.json +++ b/src/i18n/locales/ja-JP/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "このショートカットは \"{{label}}\" に予約されており、再割り当てできません。", "savedToast": "キーボードショートカットが保存されました", "resetToast": "デフォルトのショートカットにリセット — 保存をクリックして適用", + "registrationFailed": "ショートカットの登録に失敗しました。他のアプリで使用中の可能性があります。別のキーをお試しください。", "actions": { + "openApp": "アプリを開く", "addZoom": "ズームを追加", "addTrim": "トリムを追加", "addSpeed": "速度を追加", diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index 5d901180..8f8fb69b 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -15,7 +15,27 @@ "view": "보기", "window": "창", "quit": "종료", - "stopRecording": "녹화 중지" + "stopRecording": "녹화 중지", + "undo": "실행 취소", + "redo": "다시 실행", + "cut": "잘라내기", + "copy": "복사", + "paste": "붙여넣기", + "selectAll": "모두 선택", + "minimize": "최소화", + "reload": "새로고침", + "forceReload": "강제 새로고침", + "toggleDevTools": "개발자 도구 전환", + "actualSize": "실제 크기", + "zoomIn": "확대", + "zoomOut": "축소", + "toggleFullScreen": "전체 화면 전환", + "recordingStatus": "녹화 중: {{source}}", + "about": "OpenScreen 정보", + "services": "서비스", + "hide": "OpenScreen 숨기기", + "hideOthers": "다른 항목 숨기기", + "unhide": "모두 보기" }, "playback": { "play": "재생", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index ce162447..13c8bfd2 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.", "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.", "cameraDisconnected": "웹캠 연결이 끊어졌습니다.", - "cameraNotFound": "카메라를 찾을 수 없습니다." + "cameraNotFound": "카메라를 찾을 수 없습니다.", + "accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요." } } diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 268ba57c..9ee96664 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -1,6 +1,8 @@ { "zoom": { + "previewHold": "누르고 있으면 줌 효과 미리보기", "level": "줌 레벨", + "customScale": "커스텀 줌", "selectRegion": "조정할 줌 구간을 선택하세요", "deleteZoom": "줌 삭제", "focusMode": { @@ -16,6 +18,12 @@ "left": "왼쪽", "right": "오른쪽" } + }, + "position": { + "title": "포커스 위치", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽" } }, "speed": { @@ -36,13 +44,15 @@ "verticalStack": "세로 배치", "webcamShape": "카메라 모양", "webcamSize": "웹캠 크기", - "dualFrame": "듀얼 프레임" + "dualFrame": "듀얼 프레임", + "noWebcam": "웹캠 없음" }, "effects": { "title": "비디오 효과", "blurBg": "배경 흐림", "motionBlur": "모션 블러", "off": "끄기", + "on": "켜기", "shadow": "그림자", "roundness": "모서리 둥글기", "padding": "여백" @@ -76,10 +86,10 @@ "gifDescription": "공유용 애니메이션 이미지" }, "exportQuality": { - "title": "내보내기 품질", - "low": "낮음", - "medium": "보통", - "high": "높음" + "title": "내보내기 해상도", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF 프레임 속도", @@ -95,13 +105,14 @@ "gifButton": "GIF 내보내기", "chooseSaveLocation": "저장 위치 선택" }, - "links": { + "support": { "reportBug": "버그 신고", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "GitHub에 Star 남기기" }, "imageUpload": { "invalidFileType": "지원하지 않는 파일 형식입니다", - "jpgOnly": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요.", + "jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.", "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!", "failedToUpload": "이미지 업로드에 실패했습니다", "errorReading": "파일을 읽는 중 오류가 발생했습니다." @@ -182,7 +193,19 @@ "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.", "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요." }, + "cursor": { + "show": "커서 표시", + "size": "크기", + "smoothing": "부드러움", + "motionBlur": "모션 블러", + "clickBounce": "클릭 바운스", + "clipToBounds": "캔버스에 맞춰 자르기" + }, "language": { "title": "언어" + }, + "timeline": { + "title": "타임라인", + "waveform": "트림 트랙에 오디오 파형 표시" } } diff --git a/src/i18n/locales/ko-KR/shortcuts.json b/src/i18n/locales/ko-KR/shortcuts.json index 13b11a6b..ddac2954 100644 --- a/src/i18n/locales/ko-KR/shortcuts.json +++ b/src/i18n/locales/ko-KR/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "이 단축키는 \"{{label}}\"에 예약되어 있어 변경할 수 없습니다.", "savedToast": "키보드 단축키가 저장되었습니다", "resetToast": "기본 단축키로 초기화되었습니다 — 저장을 클릭해 적용하세요", + "registrationFailed": "단축키 등록에 실패했습니다. 다른 앱에서 사용 중일 수 있습니다. 다른 키를 시도하세요.", "actions": { + "openApp": "앱 열기", "addZoom": "줌 추가", "addTrim": "트림 추가", "addSpeed": "속도 추가", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index c5616d2f..5452124f 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "Доступ к камере запрещён. Запись продолжится без веб-камеры.", "cameraDisconnected": "Веб-камера отключена.", "cameraNotFound": "Камера не найдена.", - "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана." + "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.", + "accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет." } } diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index e4373e33..7582a692 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -39,5 +39,9 @@ "description": "Мы обнаружили {{language}} как системный язык. Хотите переключить OpenScreen на {{language}}?", "switch": "Переключить на {{language}}", "keepDefault": "Оставить текущий язык" + }, + "cursor": { + "useEditableCursor": "Использовать редактируемый курсор", + "useSystemCursor": "Использовать системный курсор" } } diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index dc15c3f5..ebfca9d3 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Удерживайте для предпросмотра эффекта зума", "level": "Уровень масштабирования", "selectRegion": "Выберите область масштабирования для настройки", "deleteZoom": "Удалить масштабирование", @@ -16,6 +17,13 @@ "left": "Слева", "right": "Справа" } + }, + "customScale": "Пользовательский масштаб", + "position": { + "title": "Положение фокуса", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = край слева / сверху, 100 = край справа / снизу" } }, "speed": { @@ -36,7 +44,8 @@ "verticalStack": "Вертикальный стек", "dualFrame": "Двойной кадр", "webcamShape": "Форма камеры", - "webcamSize": "Размер веб-камеры" + "webcamSize": "Размер веб-камеры", + "noWebcam": "Без веб-камеры" }, "effects": { "title": "Видеоэффекты", @@ -77,10 +86,10 @@ "gifDescription": "Анимированное изображение для обмена" }, "exportQuality": { - "title": "Качество экспорта", - "low": "Низкое", - "medium": "Среднее", - "high": "Высокое" + "title": "Разрешение экспорта", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Частота кадров GIF", @@ -96,8 +105,9 @@ "gifButton": "Экспорт GIF", "chooseSaveLocation": "Выбрать место сохранения" }, - "links": { + "support": { "reportBug": "Сообщить об ошибке", + "saveDiagnostics": "Сохранить диагностику", "starOnGithub": "Звезда на GitHub" }, "imageUpload": { @@ -183,7 +193,19 @@ "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.", "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts." }, + "cursor": { + "show": "Показывать курсор", + "size": "Размер", + "smoothing": "Сглаживание", + "motionBlur": "Размытие движения", + "clickBounce": "Отскок при клике", + "clipToBounds": "Обрезать по холсту" + }, "language": { "title": "Язык" + }, + "timeline": { + "title": "Таймлайн", + "waveform": "Показать волну на дорожке обрезки" } } diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json index ab6eae87..b6e1faa5 100644 --- a/src/i18n/locales/ru/shortcuts.json +++ b/src/i18n/locales/ru/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "Эта горячая клавиша зарезервирована для \"{{label}}\" и не может быть переназначена.", "savedToast": "Горячие клавиши сохранены", "resetToast": "Сброс к горячим клавишам по умолчанию — нажмите Сохранить для применения", + "registrationFailed": "Не удалось зарегистрировать горячую клавишу. Возможно, она используется другим приложением. Попробуйте другую клавишу.", "actions": { + "openApp": "Открыть приложение", "addZoom": "Добавить масштабирование", "addTrim": "Добавить обрезку", "addSpeed": "Изменить скорость", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 13d006bd..69face44 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -15,7 +15,27 @@ "view": "Görünüm", "window": "Pencere", "quit": "Çıkış", - "stopRecording": "Kaydı Durdur" + "stopRecording": "Kaydı Durdur", + "undo": "Geri Al", + "redo": "Yinele", + "cut": "Kes", + "copy": "Kopyala", + "paste": "Yapıştır", + "selectAll": "Tümünü Seç", + "minimize": "Simge Durumuna Küçült", + "reload": "Yeniden Yükle", + "forceReload": "Zorla Yeniden Yükle", + "toggleDevTools": "Geliştirici Araçlarını Aç/Kapat", + "actualSize": "Gerçek Boyut", + "zoomIn": "Yakınlaştır", + "zoomOut": "Uzaklaştır", + "toggleFullScreen": "Tam Ekranı Aç/Kapat", + "recordingStatus": "Kaydediliyor: {{source}}", + "about": "OpenScreen Hakkında", + "services": "Servisler", + "hide": "OpenScreen’i Gizle", + "hideOthers": "Diğerlerini Gizle", + "unhide": "Tümünü Göster" }, "playback": { "play": "Oynat", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 0aece8ae..b50630a9 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -33,7 +33,8 @@ "cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.", "permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin.", "cameraDisconnected": "Webcam bağlantısı kesildi.", - "cameraNotFound": "Kamera bulunamadı." + "cameraNotFound": "Kamera bulunamadı.", + "accessibilityAllowAndRetry": "OpenScreen için Erişilebilirlik erişimine izin verin, ardından geri sayımı başlatmak için tekrar kayda basın." }, "loadingVideo": "Video yükleniyor...", "newRecording": { diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 19039c84..39a91be5 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -14,18 +14,7 @@ "disableSystemAudio": "Sistem sesini devre dışı bırak", "enableMicrophone": "Mikrofonu etkinleştir", "disableMicrophone": "Mikrofonu devre dışı bırak", - "defaultMicrophone": "Varsayılan Mikrofon", - "enableNoiseReduction": "Gürültü azaltmayı etkinleştir (yapay zeka destekli)", - "disableNoiseReduction": "Gürültü azaltmayı devre dışı bırak", - "noiseReduction": "Gürültü azaltma", - "clickToCycle": "Seviye değiştirmek için tıklayın", - "nrLevel": { - "light": "Hafif", - "moderate": "Orta", - "aggressive": "Güçlü" - }, - "noiseReductionPrompt": "Daha net ses için yapay zeka destekli gürültü azaltmayı etkinleştirmek ister misiniz?", - "enableNoiseReductionShort": "Etkinleştir" + "defaultMicrophone": "Varsayılan Mikrofon" }, "webcam": { "enableWebcam": "Kamerayı etkinleştir", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index f6395582..5b318edb 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun", "level": "Yakınlaştırma Seviyesi", "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin", "deleteZoom": "Yakınlaştırmayı Sil", @@ -16,6 +17,13 @@ "left": "Sol", "right": "Sağ" } + }, + "customScale": "Özel Yakınlaştırma", + "position": { + "title": "Odak Konumu", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = en sol / en üst, 100 = en sağ / en alt" } }, "speed": { @@ -36,7 +44,8 @@ "verticalStack": "Dikey Yığın", "webcamShape": "Kamera Şekli", "dualFrame": "Çift Kare", - "webcamSize": "Webcam Boyutu" + "webcamSize": "Webcam Boyutu", + "noWebcam": "Web kamerası yok" }, "effects": { "title": "Video Efektleri", @@ -45,7 +54,8 @@ "off": "kapalı", "shadow": "Gölge", "roundness": "Yuvarlaklık", - "padding": "Dolgu" + "padding": "Dolgu", + "on": "açık" }, "background": { "title": "Arka Plan", @@ -76,10 +86,10 @@ "gifDescription": "Paylaşım için hareketli görüntü" }, "exportQuality": { - "title": "Dışa Aktarım Kalitesi", - "low": "Düşük", - "medium": "Orta", - "high": "Yüksek" + "title": "Dışa aktarma çözünürlüğü", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF Kare Hızı", @@ -95,13 +105,14 @@ "gifButton": "GIF Olarak Dışa Aktar", "chooseSaveLocation": "Kayıt Konumu Seç" }, - "links": { + "support": { "reportBug": "Hata Bildir", + "saveDiagnostics": "Teşhis Verilerini Kaydet", "starOnGithub": "GitHub'da Yıldızla" }, "imageUpload": { "invalidFileType": "Geçersiz dosya türü", - "jpgOnly": "Lütfen bir JPG veya JPEG görüntü dosyası yükleyin.", + "jpgOnly": "Lütfen JPG, JPEG veya PNG görüntü dosyası yükleyin.", "uploadSuccess": "Özel görüntü başarıyla yüklendi!", "failedToUpload": "Görüntü yüklenemedi", "errorReading": "Dosya okunurken bir hata oluştu." @@ -182,18 +193,19 @@ "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.", "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin." }, + "cursor": { + "show": "İmleci Göster", + "size": "Boyut", + "smoothing": "Yumuşatma", + "motionBlur": "Hareket Bulanıklığı", + "clickBounce": "Tıklama Sıçraması", + "clipToBounds": "Tuvale Kırp" + }, "language": { "title": "Dil" }, - "audio": { - "title": "Ses", - "noiseReduction": "Gürültü Azaltma", - "level": "Seviye", - "nrLevel": { - "light": "Hafif", - "moderate": "Orta", - "aggressive": "Güçlü" - }, - "nrDescription": "Yapay zeka destekli gürültü azaltma arka plan gürültüsünü temizler. Daha yüksek seviyeler daha agresiftir ancak ses kalitesini etkileyebilir." + "timeline": { + "title": "Zaman Tüneli", + "waveform": "Kırpma Parçasında Ses Dalgasını Göster" } } diff --git a/src/i18n/locales/tr/shortcuts.json b/src/i18n/locales/tr/shortcuts.json index 69b28eda..62cdfaf5 100644 --- a/src/i18n/locales/tr/shortcuts.json +++ b/src/i18n/locales/tr/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "Bu kısayol \"{{label}}\" için ayrılmıştır ve yeniden atanamaz.", "savedToast": "Klavye kısayolları kaydedildi", "resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın", + "registrationFailed": "Kısayol kaydedilemedi. Başka bir uygulama tarafından kullanılıyor olabilir. Farklı bir tuş deneyin.", "actions": { + "openApp": "Uygulamayı Aç", "addZoom": "Yakınlaştırma Ekle", "addTrim": "Kırpma Ekle", "addSpeed": "Hız Ekle", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 3a3ff5ff..50c1f815 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -15,7 +15,27 @@ "view": "Xem", "window": "Cửa sổ", "quit": "Thoát", - "stopRecording": "Dừng ghi hình" + "stopRecording": "Dừng ghi hình", + "undo": "Hoàn tác", + "redo": "Làm lại", + "cut": "Cắt", + "copy": "Sao chép", + "paste": "Dán", + "selectAll": "Chọn tất cả", + "minimize": "Thu nhỏ", + "reload": "Tải lại", + "forceReload": "Buộc tải lại", + "toggleDevTools": "Bật/tắt công cụ nhà phát triển", + "actualSize": "Kích thước thực", + "zoomIn": "Phóng to", + "zoomOut": "Thu nhỏ", + "toggleFullScreen": "Bật/tắt toàn màn hình", + "recordingStatus": "Đang ghi: {{source}}", + "about": "Giới thiệu OpenScreen", + "services": "Dịch vụ", + "hide": "Ẩn OpenScreen", + "hideOthers": "Ẩn ứng dụng khác", + "unhide": "Hiển thị tất cả" }, "playback": { "play": "Phát", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index a45bf3e2..03e909f4 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "Quyền truy cập máy ảnh bị từ chối. Sẽ tiếp tục ghi hình không có webcam.", "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." + "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.", + "accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược." } } diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 132e822a..84146d10 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -39,5 +39,9 @@ "description": "Chúng tôi phát hiện {{language}} là ngôn ngữ hệ thống của bạn. Bạn có muốn chuyển OpenScreen sang {{language}} không?", "switch": "Chuyển sang {{language}}", "keepDefault": "Giữ ngôn ngữ hiện tại" + }, + "cursor": { + "useEditableCursor": "Dùng con trỏ có thể chỉnh sửa", + "useSystemCursor": "Dùng con trỏ hệ thống" } } diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index e6a897d8..d3b67ac8 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Giữ để xem trước hiệu ứng phóng to", "level": "Mức độ thu phóng", "selectRegion": "Chọn vùng thu phóng để điều chỉnh", "deleteZoom": "Xóa thu phóng", @@ -8,6 +9,21 @@ "manual": "Thủ công", "auto": "Tự động", "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi" + }, + "customScale": "Thu phóng tùy chỉnh", + "position": { + "title": "Vị trí tiêu điểm", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới" + }, + "threeD": { + "title": "Xoay 3D", + "preset": { + "iso": "Đẳng phối", + "left": "Trái", + "right": "Phải" + } } }, "speed": { @@ -28,7 +44,8 @@ "verticalStack": "Xếp chồng dọc", "dualFrame": "Khung kép", "webcamShape": "Hình dạng máy ảnh", - "webcamSize": "Kích thước Webcam" + "webcamSize": "Kích thước Webcam", + "noWebcam": "Không có webcam" }, "effects": { "title": "Hiệu ứng video", @@ -37,7 +54,8 @@ "off": "tắt", "shadow": "Bóng đổ", "roundness": "Độ bo tròn", - "padding": "Phần đệm" + "padding": "Phần đệm", + "on": "bật" }, "background": { "title": "Nền", @@ -45,7 +63,9 @@ "color": "Màu sắc", "gradient": "Dải màu", "uploadCustom": "Tải lên tùy chỉnh", - "gradientLabel": "Dải màu {{index}}" + "gradientLabel": "Dải màu {{index}}", + "colorPalette": "Bảng màu", + "colorWheel": "Vòng màu" }, "crop": { "title": "Cắt xén", @@ -66,10 +86,10 @@ "gifDescription": "Hình ảnh động để chia sẻ" }, "exportQuality": { - "title": "Chất lượng xuất", - "low": "Thấp", - "medium": "Trung bình", - "high": "Cao" + "title": "Độ phân giải xuất", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Tốc độ khung hình GIF", @@ -85,8 +105,9 @@ "gifButton": "Xuất GIF", "chooseSaveLocation": "Chọn vị trí lưu" }, - "links": { + "support": { "reportBug": "Báo cáo lỗi", + "saveDiagnostics": "Lưu thông tin chẩn đoán", "starOnGithub": "Đánh giá sao trên GitHub" }, "imageUpload": { @@ -139,7 +160,9 @@ "invalidImageType": "Loại tệp không hợp lệ", "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.", "imageUploadSuccess": "Tải lên hình ảnh thành công!", - "failedImageUpload": "Tải lên hình ảnh thất bại" + "failedImageUpload": "Tải lên hình ảnh thất bại", + "colorPalette": "Bảng màu", + "colorWheel": "Vòng màu" }, "fontStyles": { "classic": "Cổ điển", @@ -170,7 +193,19 @@ "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.", "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác." }, + "cursor": { + "show": "Hiện con trỏ", + "size": "Kích thước", + "smoothing": "Làm mượt", + "motionBlur": "Làm mờ chuyển động", + "clickBounce": "Nảy khi nhấp", + "clipToBounds": "Cắt theo khung" + }, "language": { "title": "Ngôn ngữ" + }, + "timeline": { + "title": "Dòng thời gian", + "waveform": "Hiển thị dạng sóng âm thanh trên rãnh cắt" } } diff --git a/src/i18n/locales/vi/shortcuts.json b/src/i18n/locales/vi/shortcuts.json index 918254fd..46ec9b5e 100644 --- a/src/i18n/locales/vi/shortcuts.json +++ b/src/i18n/locales/vi/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "Phím tắt này được dành riêng cho \"{{label}}\" và không thể gán lại.", "savedToast": "Đã lưu phím tắt", "resetToast": "Đã đặt lại về phím tắt mặc định — nhấp Lưu để áp dụng", + "registrationFailed": "Không thể đăng ký phím tắt. Có thể đang được sử dụng bởi ứng dụng khác. Hãy thử phím khác.", "actions": { + "openApp": "Mở ứng dụng", "addZoom": "Thêm Thu phóng", "addTrim": "Thêm Cắt", "addSpeed": "Thêm Tốc độ", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 2291fa62..e1b8ffc5 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -15,7 +15,27 @@ "view": "视图", "window": "窗口", "quit": "退出", - "stopRecording": "停止录制" + "stopRecording": "停止录制", + "undo": "撤销", + "redo": "重做", + "cut": "剪切", + "copy": "复制", + "paste": "粘贴", + "selectAll": "全选", + "minimize": "最小化", + "reload": "重新加载", + "forceReload": "强制重新加载", + "toggleDevTools": "切换开发者工具", + "actualSize": "实际大小", + "zoomIn": "放大", + "zoomOut": "缩小", + "toggleFullScreen": "切换全屏", + "recordingStatus": "正在录制:{{source}}", + "about": "关于 OpenScreen", + "services": "服务", + "hide": "隐藏 OpenScreen", + "hideOthers": "隐藏其他", + "unhide": "显示全部" }, "playback": { "play": "播放", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index f6c02d4c..56a36f8d 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。", "cameraDisconnected": "摄像头已断开连接。", "cameraNotFound": "未找到摄像头。", - "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。" + "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。", + "accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。" } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ff157dcd..15aa57d7 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "按住预览放大效果", "level": "缩放级别", "selectRegion": "选择要调整的缩放区域", "deleteZoom": "删除缩放", @@ -16,6 +17,13 @@ "left": "左", "right": "右" } + }, + "customScale": "自定义缩放", + "position": { + "title": "焦点位置", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = 最左 / 最上,100 = 最右 / 最下" } }, "speed": { @@ -36,7 +44,8 @@ "verticalStack": "垂直堆叠", "dualFrame": "双画框", "webcamShape": "摄像头形状", - "webcamSize": "摄像头大小" + "webcamSize": "摄像头大小", + "noWebcam": "无摄像头" }, "effects": { "title": "视频效果", @@ -45,7 +54,8 @@ "off": "关", "shadow": "阴影", "roundness": "圆角", - "padding": "内边距" + "padding": "内边距", + "on": "开" }, "background": { "title": "背景", @@ -76,10 +86,10 @@ "gifDescription": "可分享的动态图片" }, "exportQuality": { - "title": "导出质量", - "low": "低", - "medium": "中", - "high": "高" + "title": "导出分辨率", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF 帧率", @@ -95,13 +105,14 @@ "gifButton": "导出 GIF", "chooseSaveLocation": "选择保存位置" }, - "links": { + "support": { "reportBug": "报告错误", + "saveDiagnostics": "保存诊断信息", "starOnGithub": "在 GitHub 上加星" }, "imageUpload": { "invalidFileType": "无效的文件类型", - "jpgOnly": "请上传 JPG 或 JPEG 格式的图片文件。", + "jpgOnly": "请上传 JPG、JPEG 或 PNG 格式的图片文件。", "uploadSuccess": "自定义图片上传成功!", "failedToUpload": "上传图片失败", "errorReading": "读取文件时出错。" @@ -182,7 +193,19 @@ "errorTimeout": "字体加载时间过长。请检查 URL 并重试。", "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。" }, + "cursor": { + "show": "显示光标", + "size": "大小", + "smoothing": "平滑", + "motionBlur": "运动模糊", + "clickBounce": "点击弹跳", + "clipToBounds": "裁剪到画布" + }, "language": { "title": "语言" + }, + "timeline": { + "title": "时间轴", + "waveform": "在剪辑轨道上显示音频波形" } } diff --git a/src/i18n/locales/zh-CN/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json index 33282396..eb357e0e 100644 --- a/src/i18n/locales/zh-CN/shortcuts.json +++ b/src/i18n/locales/zh-CN/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "此快捷键已保留给 \"{{label}}\",无法重新分配。", "savedToast": "键盘快捷键已保存", "resetToast": "已恢复默认快捷键 — 点击保存以应用", + "registrationFailed": "快捷键注册失败。可能已被其他应用占用。请尝试其他按键。", "actions": { + "openApp": "打开应用", "addZoom": "添加缩放", "addTrim": "添加剪辑", "addSpeed": "添加速度", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 1ddab48c..39e78cdd 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -15,7 +15,27 @@ "view": "檢視", "window": "視窗", "quit": "退出", - "stopRecording": "停止錄製" + "stopRecording": "停止錄製", + "undo": "復原", + "redo": "重做", + "cut": "剪下", + "copy": "複製", + "paste": "貼上", + "selectAll": "全選", + "minimize": "最小化", + "reload": "重新載入", + "forceReload": "強制重新載入", + "toggleDevTools": "切換開發者工具", + "actualSize": "實際大小", + "zoomIn": "放大", + "zoomOut": "縮小", + "toggleFullScreen": "切換全螢幕", + "recordingStatus": "錄製中:{{source}}", + "about": "關於 OpenScreen", + "services": "服務", + "hide": "隱藏 OpenScreen", + "hideOthers": "隱藏其他", + "unhide": "全部顯示" }, "playback": { "play": "播放", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 21f3ba6f..d4ad23f1 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。", "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。", "cameraDisconnected": "網路攝影機已中斷連線。", - "cameraNotFound": "找不到攝影機。" + "cameraNotFound": "找不到攝影機。", + "accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。" } } diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 50ca00cb..e579aeae 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "按住預覽放大效果", "level": "縮放級別", "selectRegion": "選擇要調整的縮放區域", "deleteZoom": "刪除縮放", @@ -9,13 +10,7 @@ "auto": "自動", "autoDescription": "攝影機跟隨錄製時的游標位置" }, - "speed": { - "title": "縮放速度", - "instant": "即時", - "fast": "快速", - "smooth": "平滑", - "lazy": "緩慢" - }, + "speed": {}, "threeD": { "title": "3D 旋轉", "preset": { @@ -23,6 +18,13 @@ "left": "左", "right": "右" } + }, + "customScale": "自訂縮放", + "position": { + "title": "焦點位置", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = 最左 / 最上,100 = 最右 / 最下" } }, "speed": { @@ -43,7 +45,8 @@ "verticalStack": "垂直堆疊", "dualFrame": "雙畫框", "webcamShape": "攝影機形狀", - "webcamSize": "攝影機大小" + "webcamSize": "攝影機大小", + "noWebcam": "無網路攝影機" }, "effects": { "title": "影片效果", @@ -52,7 +55,8 @@ "off": "關", "shadow": "陰影", "roundness": "圓角", - "padding": "內邊距" + "padding": "內邊距", + "on": "開" }, "background": { "title": "背景", @@ -83,10 +87,10 @@ "gifDescription": "可分享的動態圖片" }, "exportQuality": { - "title": "匯出品質", - "low": "低", - "medium": "中", - "high": "高" + "title": "匯出解析度", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF 影格率", @@ -102,13 +106,14 @@ "gifButton": "匯出 GIF", "chooseSaveLocation": "選擇儲存位置" }, - "links": { + "support": { "reportBug": "回報錯誤", + "saveDiagnostics": "儲存診斷資料", "starOnGithub": "在 GitHub 上加星" }, "imageUpload": { "invalidFileType": "無效的檔案類型", - "jpgOnly": "請上傳 JPG 或 JPEG 格式的圖片檔案。", + "jpgOnly": "請上傳 JPG、JPEG 或 PNG 格式的圖片檔案。", "uploadSuccess": "自訂圖片上傳成功!", "failedToUpload": "上傳圖片失敗", "errorReading": "讀取檔案時出錯。" @@ -189,7 +194,19 @@ "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。", "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。" }, + "cursor": { + "show": "顯示游標", + "size": "大小", + "smoothing": "平滑", + "motionBlur": "動態模糊", + "clickBounce": "點擊彈跳", + "clipToBounds": "裁切至畫布" + }, "language": { "title": "語言" + }, + "timeline": { + "title": "時間軸", + "waveform": "在剪輯軌道上顯示音訊波形" } } diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json index 54c0cfca..34ab7de8 100644 --- a/src/i18n/locales/zh-TW/shortcuts.json +++ b/src/i18n/locales/zh-TW/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "此快捷鍵已保留給 \"{{label}}\",無法重新指定。", "savedToast": "鍵盤快捷鍵已儲存", "resetToast": "已還原預設快捷鍵 — 點擊儲存以套用", + "registrationFailed": "快捷鍵註冊失敗。可能已被其他應用程式佔用。請嘗試其他按鍵。", "actions": { + "openApp": "開啟應用程式", "addZoom": "新增縮放", "addTrim": "新增剪輯", "addSpeed": "新增速度", diff --git a/src/index.css b/src/index.css index d62f153c..61621c75 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,5 @@ +@import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Caveat:wght@400;700&family=DM+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Fira+Code:wght@400;700&family=IBM+Plex+Mono:ital,wght@0,400;0,700;1,400;1,700&family=IBM+Plex+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Inter:ital,wght@0,400;0,700;1,400;1,700&family=Lora:ital,wght@0,400;0,700;1,400;1,700&family=Manrope:wght@400;700&family=Merriweather:ital,wght@0,400;0,700;1,400;1,700&family=Oswald:wght@400;700&family=Permanent+Marker&family=Playfair+Display:ital,wght@0,400;0,700;1,400;1,700&family=Plus+Jakarta+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Space+Grotesk:wght@400;700&family=Sora:wght@400;700&display=swap"); + @tailwind base; @tailwind components; @tailwind utilities; diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 596eb755..65cdfd57 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -17,6 +17,21 @@ describe("computeCompositeLayout", () => { expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2); }); + it("scales small screen content up to the export canvas when no padding is applied", () => { + const layout = computeCompositeLayout({ + canvasSize: { width: 1280, height: 720 }, + screenSize: { width: 854, height: 480 }, + }); + + expect(layout).not.toBeNull(); + expect(layout!.screenRect).toEqual({ + x: 0, + y: 0, + width: 1280, + height: 720, + }); + }); + it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => { const layout = computeCompositeLayout({ canvasSize: { width: 1280, height: 720 }, @@ -128,6 +143,21 @@ describe("computeCompositeLayout", () => { expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height); }); + it("snaps rounding-only source aspect gaps to the full canvas", () => { + const layout = computeCompositeLayout({ + canvasSize: { width: 319, height: 199 }, + maxContentSize: { width: 319, height: 199 }, + screenSize: { width: 1680, height: 1050 }, + }); + + expect(layout?.screenRect).toEqual({ + x: 0, + y: 0, + width: 319, + height: 199, + }); + }); + it("centers the combined screen and webcam stack in vertical stack mode", () => { const layout = computeCompositeLayout({ canvasSize: { width: 1920, height: 1080 }, diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index 93161c06..abb6b0f7 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -401,10 +401,24 @@ function centerRectInBounds(params: { bounds: RenderRect; size: Size; maxSize: S const { x: boundsX, y: boundsY, width: boundsWidth, height: boundsHeight } = bounds; const { width, height } = size; const { width: maxWidth, height: maxHeight } = maxSize; - const scale = Math.min(maxWidth / width, maxHeight / height, 1); + const scale = Math.min(maxWidth / width, maxHeight / height); const resolvedWidth = Math.round(width * scale); const resolvedHeight = Math.round(height * scale); + if ( + maxWidth >= boundsWidth && + maxHeight >= boundsHeight && + Math.abs(boundsWidth - resolvedWidth) <= 4 && + Math.abs(boundsHeight - resolvedHeight) <= 4 + ) { + return { + x: boundsX, + y: boundsY, + width: boundsWidth, + height: boundsHeight, + }; + } + return { x: boundsX + Math.max(0, Math.floor((boundsWidth - resolvedWidth) / 2)), y: boundsY + Math.max(0, Math.floor((boundsHeight - resolvedHeight) / 2)), diff --git a/src/lib/cursor/nativeCursor.test.ts b/src/lib/cursor/nativeCursor.test.ts index 1b919d65..75cdc77b 100644 --- a/src/lib/cursor/nativeCursor.test.ts +++ b/src/lib/cursor/nativeCursor.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { getNativeCursorClickBounceProgress, getNativeCursorClickBounceScale, + hasNativeCursorRecordingData, + resolveInterpolatedNativeCursorFrame, } from "./nativeCursor"; describe("native cursor click bounce", () => { @@ -31,4 +33,37 @@ describe("native cursor click bounce", () => { expect(getNativeCursorClickBounceScale(5, 0.28)).toBeGreaterThan(1.05); expect(getNativeCursorClickBounceScale(5, 0)).toBe(1); }); + + it("uses the default cursor asset for telemetry-only macOS recordings", () => { + const recordingData = { + version: 2, + provider: "none" as const, + assets: [], + samples: [ + { timeMs: 0, cx: 0.25, cy: 0.4, visible: true }, + { timeMs: 100, cx: 0.75, cy: 0.6, visible: true }, + ], + }; + + expect(hasNativeCursorRecordingData(recordingData)).toBe(true); + const frame = resolveInterpolatedNativeCursorFrame(recordingData, 50); + expect(frame?.asset.cursorType).toBe("arrow"); + expect(frame?.sample.cx).toBeCloseTo(0.5); + expect(frame?.sample.cy).toBeCloseTo(0.5); + }); + + it("applies click bounce to telemetry-only macOS recordings", () => { + const recordingData = { + version: 2, + provider: "none" as const, + assets: [], + samples: [ + { timeMs: 0, cx: 0.5, cy: 0.5, visible: true, interactionType: "move" as const }, + { timeMs: 100, cx: 0.5, cy: 0.5, visible: true, interactionType: "click" as const }, + { timeMs: 133, cx: 0.5, cy: 0.5, visible: true, interactionType: "move" as const }, + ], + }; + + expect(getNativeCursorClickBounceProgress(recordingData, 133)).toBeGreaterThan(0); + }); }); diff --git a/src/lib/cursor/nativeCursor.ts b/src/lib/cursor/nativeCursor.ts index 9ce308a8..f20fd425 100644 --- a/src/lib/cursor/nativeCursor.ts +++ b/src/lib/cursor/nativeCursor.ts @@ -220,6 +220,33 @@ const PRETTY_NATIVE_CURSOR_ASSETS: Partial 0 && - recordingData.assets.length > 0, + (recordingData.assets.length > 0 || recordingData.provider === "none"), ); } @@ -329,7 +355,7 @@ export function getNativeCursorClickBounceProgress( recordingData: CursorRecordingData | null | undefined, timeMs: number, ) { - if (!recordingData || recordingData.provider !== "native" || recordingData.samples.length === 0) { + if (!recordingData || recordingData.samples.length === 0) { return 0; } @@ -444,11 +470,13 @@ 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); + const asset = sample.assetId + ? getNativeCursorAsset(recordingData, sample.assetId) + : getTelemetryCursorAsset(sample); if (!asset) { return null; } @@ -475,11 +503,13 @@ 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); + const asset = activeSample.assetId + ? getNativeCursorAsset(recordingData, activeSample.assetId) + : getTelemetryCursorAsset(activeSample); if (!asset) { return null; } @@ -489,7 +519,7 @@ export function resolveInterpolatedNativeCursorFrame( !nextSample || nextSample.timeMs <= activeSample.timeMs || nextSample.visible === false || - nextSample.assetId !== activeSample.assetId || + (nextSample.assetId ?? null) !== (activeSample.assetId ?? null) || timeMs <= activeSample.timeMs ) { return { asset, sample: activeSample }; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index f6a64c03..85821581 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -92,6 +92,7 @@ interface FrameRenderConfig { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorClipToBounds?: boolean; videoWidth: number; videoHeight: number; webcamSize?: Size | null; @@ -124,6 +125,7 @@ interface LayoutCache { baseScale: number; baseOffset: { x: number; y: number }; maskRect: { x: number; y: number; width: number; height: number }; + maskBorderRadius: number; webcamRect: StyledRenderRect | null; } @@ -520,6 +522,28 @@ export class FrameRenderer { } } + // The video's actual on-screen boundary, accounting for the zoom camera + // transform. The PIXI mask lives inside cameraContainer, so during zoom the + // visible video extends beyond the static maskRect — a static clip would crop + // it. Mirrors the preview, which clips via the same camera-scaled bounds. + private cameraAwareMaskRect() { + if (!this.layoutCache) return null; + const { x: maskX, y: maskY, width: maskW, height: maskH } = this.layoutCache.maskRect; + const camS = this.animationState.appliedScale; + const camX = this.animationState.x; + const camY = this.animationState.y; + // No stage clamping: canvas naturally clips to its bounds, matching CSS inset() behavior. + // Clamping x/y would shift rounded corners to the stage edge rather than the true mask + // boundary, causing preview/export mismatch when zoom/pan pushes the mask off-stage. + return { + x: camX + camS * maskX, + y: camY + camS * maskY, + width: camS * maskW, + height: camS * maskH, + br: this.layoutCache.maskBorderRadius * camS, + }; + } + private async drawNativeCursor(timeMs: number) { if (!this.foregroundCtx || !this.layoutCache) { return; @@ -573,6 +597,12 @@ export class FrameRenderer { getNativeCursorClickBounceProgress(this.config.cursorRecordingData, timeMs), ); const appliedScale = this.animationState.appliedScale; + // Normalize cursor size so it appears at the same fraction of the video width + // as in the preview — both paths now use maskRect.width / croppedVideoWidth. + const sizeNorm = + this.layoutCache.videoSize.width > 0 + ? this.layoutCache.maskRect.width / this.layoutCache.videoSize.width + : 1; const canvasX = projectedPoint.x * appliedScale + this.animationState.x; const canvasY = projectedPoint.y * appliedScale + this.animationState.y; const blurPx = getNativeCursorMotionBlurPx({ @@ -581,18 +611,33 @@ export class FrameRenderer { state: this.nativeCursorMotionBlurState, timeMs, }); + // Clip only when explicitly enabled; by default the cursor may overflow the canvas. + const cursorClip = this.config.cursorClipToBounds === true ? this.cameraAwareMaskRect() : null; + this.foregroundCtx.save(); + this.foregroundCtx.beginPath(); + if (cursorClip) { + this.foregroundCtx.roundRect( + cursorClip.x, + cursorClip.y, + cursorClip.width, + cursorClip.height, + cursorClip.br, + ); + this.foregroundCtx.clip(); + } 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, - canvasY - renderAsset.hotspotY * scale * appliedScale, - renderAsset.width * scale * appliedScale, - renderAsset.height * scale * appliedScale, + canvasX - renderAsset.hotspotX * scale * appliedScale * sizeNorm, + canvasY - renderAsset.hotspotY * scale * appliedScale * sizeNorm, + renderAsset.width * scale * appliedScale * sizeNorm, + renderAsset.height * scale * appliedScale * sizeNorm, ); this.foregroundCtx.filter = previousFilter; + this.foregroundCtx.restore(); } private async getCursorImage(asset: { id: string; imageDataUrl: string }) { @@ -717,6 +762,7 @@ export class FrameRenderer { y: compositeLayout.screenRect.y + coverOffsetY - cropPixelY, }, maskRect: compositeLayout.screenRect, + maskBorderRadius: scaledBorderRadius, webcamRect: compositeLayout.webcamRect, }; } @@ -980,8 +1026,48 @@ export class FrameRenderer { shadowCtx.drawImage(videoCanvas, 0, 0, w, h); shadowCtx.restore(); fgCtx.drawImage(this.shadowCanvas, 0, 0, w, h); + // Erase square corners left by PIXI WebGL alpha, then redraw video with explicit + // 2D clip so shadow extends beyond the rounded area but video is precisely clipped. + // The clip is camera-aware so zoom doesn't crop the magnified video. + const shadowClip = + (this.layoutCache?.maskBorderRadius ?? 0) > 0 ? this.cameraAwareMaskRect() : null; + if (shadowClip) { + const { x: smx, y: smy, width: smw, height: smh, br: sbr } = shadowClip; + fgCtx.save(); + fgCtx.globalCompositeOperation = "destination-out"; + fgCtx.beginPath(); + fgCtx.rect(smx, smy, smw, smh); + fgCtx.roundRect(smx, smy, smw, smh, sbr); + fgCtx.fill("evenodd"); + fgCtx.restore(); + fgCtx.save(); + fgCtx.beginPath(); + fgCtx.roundRect(smx, smy, smw, smh, sbr); + fgCtx.clip(); + fgCtx.drawImage(videoCanvas, 0, 0, w, h); + fgCtx.restore(); + } } else { - fgCtx.drawImage(videoCanvas, 0, 0, w, h); + // Direct path: explicit 2D clip guarantees rounded corners regardless of PIXI + // WebGL alpha. Camera-aware so zoom doesn't crop the magnified video. + const directClip = + (this.layoutCache?.maskBorderRadius ?? 0) > 0 ? this.cameraAwareMaskRect() : null; + if (directClip) { + fgCtx.save(); + fgCtx.beginPath(); + fgCtx.roundRect( + directClip.x, + directClip.y, + directClip.width, + directClip.height, + directClip.br, + ); + fgCtx.clip(); + fgCtx.drawImage(videoCanvas, 0, 0, w, h); + fgCtx.restore(); + } else { + fgCtx.drawImage(videoCanvas, 0, 0, w, h); + } } const webcamRect = this.layoutCache?.webcamRect ?? null; diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index c7cde097..7c0d2a66 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -11,9 +11,9 @@ import type { import { BackgroundLoadError } from "@/lib/wallpaper"; import type { CursorRecordingData } from "@/native/contracts"; import { getPlatform } from "@/utils/platformUtils"; -import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; import { FrameRenderer } from "./frameRenderer"; import { StreamingVideoDecoder } from "./streamingDecoder"; +import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue"; import type { ExportProgress, ExportResult, @@ -53,6 +53,7 @@ interface GifExporterConfig { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorClipToBounds?: boolean; annotationRegions?: AnnotationRegion[]; previewWidth?: number; previewHeight?: number; @@ -123,7 +124,7 @@ export class GifExporter { } async export(): Promise { - let webcamFrameQueue: AsyncVideoFrameQueue | null = null; + let webcamFrameQueue: TimestampedVideoFrameQueue | null = null; const warnings: string[] = []; const onWarning = (message: string) => warnings.push(message); @@ -161,6 +162,7 @@ export class GifExporter { cursorSmoothing: this.config.cursorSmoothing, cursorMotionBlur: this.config.cursorMotionBlur, cursorClickBounce: this.config.cursorClickBounce, + cursorClipToBounds: this.config.cursorClipToBounds, videoWidth: videoInfo.width, videoHeight: videoInfo.height, webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null, @@ -214,7 +216,7 @@ export class GifExporter { console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)"); let frameIndex = 0; - webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null; + webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null; let stopWebcamDecode = false; let webcamDecodeError: Error | null = null; const webcamDecodePromise = @@ -226,7 +228,7 @@ export class GifExporter { this.config.frameRate, this.config.trimRegions, this.config.speedRegions, - async (webcamFrame) => { + async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => { while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) { await new Promise((resolve) => setTimeout(resolve, 2)); } @@ -234,7 +236,7 @@ export class GifExporter { webcamFrame.close(); return; } - queue.enqueue(webcamFrame); + queue.enqueue(webcamFrame, webcamSourceTimestampMs); }, onWarning, ) @@ -264,7 +266,9 @@ export class GifExporter { return; } - webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null; + webcamFrame = webcamFrameQueue + ? await webcamFrameQueue.frameAt(sourceTimestampMs) + : null; const renderer = this.renderer; if (this.cancelled || !renderer) { return; diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts index e93166c7..8ac404fa 100644 --- a/src/lib/exporter/index.ts +++ b/src/lib/exporter/index.ts @@ -1,5 +1,10 @@ export { FrameRenderer } from "./frameRenderer"; export { calculateOutputDimensions, GifExporter } from "./gifExporter"; +export { + calculateEffectiveSourceDimensions, + calculateMp4ExportSettings, + type Mp4ExportSettings, +} from "./mp4ExportSettings"; export { VideoMuxer } from "./muxer"; export { StreamingVideoDecoder } from "./streamingDecoder"; export type { diff --git a/src/lib/exporter/mp4ExportSettings.test.ts b/src/lib/exporter/mp4ExportSettings.test.ts new file mode 100644 index 00000000..2a941547 --- /dev/null +++ b/src/lib/exporter/mp4ExportSettings.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + calculateEffectiveSourceDimensions, + calculateMp4ExportSettings, +} from "./mp4ExportSettings"; + +describe("calculateMp4ExportSettings", () => { + it("keeps 1080p explicit even when it upscales short native captures", () => { + const aspectRatioValue = 1920 / 1032; + + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: 1920, + sourceHeight: 1032, + aspectRatioValue, + }), + ).toMatchObject({ + width: 2008, + height: 1080, + bitrate: 30_000_000, + }); + + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: 1920, + sourceHeight: 1032, + aspectRatioValue, + }), + ).toMatchObject({ + width: 1920, + height: 1032, + bitrate: 30_000_000, + }); + }); + + it("keeps lower quality presets below the source size when downscaling is useful", () => { + expect( + calculateMp4ExportSettings({ + quality: "medium", + sourceWidth: 1920, + sourceHeight: 1032, + aspectRatioValue: 1920 / 1032, + }), + ).toMatchObject({ + width: 1338, + height: 720, + bitrate: 20_000_000, + }); + }); + + it("keeps 1080p explicit even for 720p source dimensions", () => { + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: 1280, + sourceHeight: 720, + aspectRatioValue: 16 / 9, + }), + ).toMatchObject({ + width: 1920, + height: 1080, + bitrate: 20_000_000, + }); + }); + + it("preserves source-sized High exports when the source is already 1080p or larger", () => { + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: 1920, + sourceHeight: 1080, + aspectRatioValue: 16 / 9, + }), + ).toMatchObject({ + width: 1920, + height: 1080, + bitrate: 30_000_000, + }); + + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: 3840, + sourceHeight: 2160, + aspectRatioValue: 16 / 9, + }), + ).toMatchObject({ + width: 3840, + height: 2160, + bitrate: 80_000_000, + }); + }); + + it("keeps portrait presets on the short side", () => { + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: 1080, + sourceHeight: 1920, + aspectRatioValue: 9 / 16, + }), + ).toMatchObject({ + width: 1080, + height: 1920, + bitrate: 20_000_000, + }); + }); + + it("uses the cropped area as the effective source size", () => { + const effectiveSource = calculateEffectiveSourceDimensions(3840, 2160, { + width: 854 / 3840, + height: 480 / 2160, + }); + + expect(effectiveSource).toEqual({ + width: 854, + height: 480, + }); + + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: effectiveSource.width, + sourceHeight: effectiveSource.height, + aspectRatioValue: effectiveSource.width / effectiveSource.height, + }), + ).toMatchObject({ + width: 854, + height: 480, + bitrate: 30_000_000, + }); + + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: effectiveSource.width, + sourceHeight: effectiveSource.height, + aspectRatioValue: effectiveSource.width / effectiveSource.height, + }), + ).toMatchObject({ + width: 1920, + height: 1080, + bitrate: 20_000_000, + }); + }); +}); diff --git a/src/lib/exporter/mp4ExportSettings.ts b/src/lib/exporter/mp4ExportSettings.ts new file mode 100644 index 00000000..b75193fc --- /dev/null +++ b/src/lib/exporter/mp4ExportSettings.ts @@ -0,0 +1,143 @@ +import type { ExportQuality } from "./types"; + +export interface Mp4ExportSettings { + width: number; + height: number; + bitrate: number; +} + +interface SourceCropRegion { + width: number; + height: number; +} + +const MEDIUM_SHORT_SIDE = 720; +const HIGH_SHORT_SIDE = 1080; + +function even(value: number) { + return Math.floor(value / 2) * 2; +} + +function atLeastEven(value: number) { + return Math.max(2, even(value)); +} + +export function calculateEffectiveSourceDimensions( + sourceWidth: number, + sourceHeight: number, + cropRegion?: SourceCropRegion, +) { + const cropWidth = cropRegion?.width ?? 1; + const cropHeight = cropRegion?.height ?? 1; + + return { + width: atLeastEven(Math.round(sourceWidth * cropWidth)), + height: atLeastEven(Math.round(sourceHeight * cropHeight)), + }; +} + +function calculateDimensionsForShortSide(targetShortSide: number, aspectRatioValue: number) { + if (aspectRatioValue >= 1) { + const height = even(targetShortSide); + return { + width: even(height * aspectRatioValue), + height, + }; + } + + const width = even(targetShortSide); + return { + width, + height: even(width / aspectRatioValue), + }; +} + +function calculateSourceDimensions( + sourceWidth: number, + sourceHeight: number, + aspectRatioValue: number, +) { + const sourceLongDim = Math.max(sourceWidth, sourceHeight); + + if (aspectRatioValue === 1) { + const baseDimension = even(Math.min(sourceWidth, sourceHeight)); + return { + width: baseDimension, + height: baseDimension, + }; + } + + if (aspectRatioValue > 1) { + const baseWidth = even(sourceLongDim); + for (let width = baseWidth; width >= 100; width -= 2) { + const height = Math.round(width / aspectRatioValue); + if (height % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { + return { width, height }; + } + } + return { + width: baseWidth, + height: even(baseWidth / aspectRatioValue), + }; + } + + const baseHeight = even(sourceLongDim); + for (let height = baseHeight; height >= 100; height -= 2) { + const width = Math.round(height * aspectRatioValue); + if (width % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { + return { width, height }; + } + } + return { + width: even(baseHeight * aspectRatioValue), + height: baseHeight, + }; +} + +function calculateBitrate(width: number, height: number, quality: ExportQuality) { + const totalPixels = width * height; + + if (quality === "source") { + if (totalPixels > 2560 * 1440) return 80_000_000; + if (totalPixels > 1920 * 1080) return 50_000_000; + return 30_000_000; + } + + if (totalPixels <= 1280 * 720) return 10_000_000; + if (totalPixels <= 1920 * 1080) return 20_000_000; + return 30_000_000; +} + +export function calculateMp4ExportSettings({ + quality, + sourceWidth, + sourceHeight, + aspectRatioValue, +}: { + quality: ExportQuality; + sourceWidth: number; + sourceHeight: number; + aspectRatioValue: number; +}): Mp4ExportSettings { + if (quality === "medium") { + const dimensions = calculateDimensionsForShortSide(MEDIUM_SHORT_SIDE, aspectRatioValue); + return { + ...dimensions, + bitrate: calculateBitrate(dimensions.width, dimensions.height, quality), + }; + } + + if (quality === "good") { + const dimensions = calculateDimensionsForShortSide(HIGH_SHORT_SIDE, aspectRatioValue); + return { + ...dimensions, + bitrate: calculateBitrate(dimensions.width, dimensions.height, quality), + }; + } + + const sourceDimensions = calculateSourceDimensions(sourceWidth, sourceHeight, aspectRatioValue); + return { + ...sourceDimensions, + bitrate: calculateBitrate(sourceDimensions.width, sourceDimensions.height, quality), + }; +} diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index e8093df0..752d5cd4 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -137,6 +137,27 @@ export function shouldFailDecodeEndedEarly({ return true; } +/** + * Loads a video file as an ArrayBuffer, delegating to + * `StreamingVideoDecoder.loadLocalSourceFile` for local paths (Electron IPC) + * and `StreamingVideoDecoder.loadRemoteSourceFile` for remote / blob / data URLs. + * Also returns the `contentType` derived from the blob (empty string for local + * IPC reads where no Content-Type is available). + */ +export async function loadFileAsArrayBuffer( + videoUrl: string, +): Promise<{ data: ArrayBuffer; contentType: string }> { + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + + if (!isRemoteUrl && window.electronAPI) { + const { blob } = await StreamingVideoDecoder.loadLocalSourceFile(videoUrl); + return { data: await blob.arrayBuffer(), contentType: "" }; + } + + const { blob } = await StreamingVideoDecoder.loadRemoteSourceFile(videoUrl); + return { data: await blob.arrayBuffer(), contentType: blob.type }; +} + /** Caller must close the VideoFrame after use. */ type OnFrameCallback = ( frame: VideoFrame, @@ -157,40 +178,47 @@ export class StreamingVideoDecoder { private cancelled = false; private metadata: DecodedVideoInfo | null = null; + /** Routes to the appropriate loader based on whether the source is local or remote. */ private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); - - if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { - const result = await this.withTimeout( - window.electronAPI.readBinaryFile(videoUrl), + if (!isRemoteUrl && window.electronAPI) { + return this.withTimeout( + StreamingVideoDecoder.loadLocalSourceFile(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); - if (!result.success || !result.data) { - throw new Error(result.message || result.error || "Failed to read source video"); - } - - const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; - const blob = new Blob([result.data]); - return { - blob, - file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), - }; } - - const response = await this.withTimeout( - fetch(videoUrl), + return this.withTimeout( + StreamingVideoDecoder.loadRemoteSourceFile(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); + } + + /** Loads a local video file via the Electron IPC bridge. */ + static async loadLocalSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { + const result = await window.electronAPI.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + throw new Error(result.message || result.error || "Failed to read source video"); + } + + const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; + const blob = new Blob([result.data]); + return { + blob, + file: new File([blob], filename, { + type: blob.type || "application/octet-stream", + }), + }; + } + + /** Loads a remote or blob video URL via fetch. */ + static async loadRemoteSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { + const response = await fetch(videoUrl); if (!response.ok) { throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`); } - const blob = await this.withTimeout( - response.blob(), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while reading the source video.", - ); + const blob = await response.blob(); const filename = videoUrl.split("/").pop() || "video"; return { blob, @@ -620,6 +648,10 @@ export class StreamingVideoDecoder { } } + /** + * Converts trim regions into the segments that should be kept. + * Returns a single full-duration segment when no trim regions are present. + */ private computeSegments( totalDuration: number, trimRegions?: TrimRegion[], @@ -648,6 +680,11 @@ export class StreamingVideoDecoder { return segments; } + /** + * Calculates the effective output duration (in seconds) and total frame count + * for a given combination of trim and speed regions at the target frame rate. + * Requires `loadMetadata()` to have been called first. + */ getExportMetrics( targetFrameRate: number, trimRegions?: TrimRegion[], @@ -668,6 +705,10 @@ export class StreamingVideoDecoder { }; } + /** + * Splits keep-segments by overlapping speed regions, annotating each + * sub-segment with its playback speed multiplier (defaults to 1×). + */ private splitBySpeed( segments: Array<{ startSec: number; endSec: number }>, speedRegions?: SpeedRegion[], @@ -700,14 +741,17 @@ export class StreamingVideoDecoder { return result.filter((s) => s.endSec - s.startSec > 0.0001); } + /** Returns the underlying WebDemuxer instance, or null if not yet loaded. */ getDemuxer(): WebDemuxer | null { return this.demuxer; } + /** Signals the decoder to stop processing at the next cancellation checkpoint. */ cancel(): void { this.cancelled = true; } + /** Cancels decoding and releases the VideoDecoder and WebDemuxer resources. */ destroy(): void { this.cancelled = true; @@ -730,6 +774,7 @@ export class StreamingVideoDecoder { } } + /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { return new Promise((resolve, reject) => { const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); diff --git a/src/lib/exporter/timestampedVideoFrameQueue.test.ts b/src/lib/exporter/timestampedVideoFrameQueue.test.ts new file mode 100644 index 00000000..9645c985 --- /dev/null +++ b/src/lib/exporter/timestampedVideoFrameQueue.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; +import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue"; + +class MockVideoFrame { + timestamp: number; + closed = false; + + constructor(source: MockVideoFrame | number) { + this.timestamp = typeof source === "number" ? source : source.timestamp; + } + + close() { + this.closed = true; + } +} + +function restoreVideoFrame(originalVideoFrame: typeof globalThis.VideoFrame | undefined) { + if (originalVideoFrame === undefined) { + delete (globalThis as { VideoFrame?: typeof globalThis.VideoFrame }).VideoFrame; + return; + } + + vi.stubGlobal("VideoFrame", originalVideoFrame); +} + +describe("TimestampedVideoFrameQueue", () => { + it("samples the latest webcam frame at or before the requested source timestamp", async () => { + const originalVideoFrame = globalThis.VideoFrame; + vi.stubGlobal("VideoFrame", MockVideoFrame); + try { + const queue = new TimestampedVideoFrameQueue(); + const frame0 = new MockVideoFrame(0) as unknown as VideoFrame; + const frame33 = new MockVideoFrame(33_000) as unknown as VideoFrame; + const frame66 = new MockVideoFrame(66_000) as unknown as VideoFrame; + + queue.enqueue(frame0, 0); + queue.enqueue(frame33, 33); + queue.enqueue(frame66, 66); + queue.close(); + + const sampled0 = await queue.frameAt(0); + const sampled20 = await queue.frameAt(20); + const sampled40 = await queue.frameAt(40); + const sampled80 = await queue.frameAt(80); + + expect(sampled0?.timestamp).toBe(0); + expect(sampled20?.timestamp).toBe(0); + expect(sampled40?.timestamp).toBe(33_000); + expect(sampled80?.timestamp).toBe(66_000); + + sampled0?.close(); + sampled20?.close(); + sampled40?.close(); + sampled80?.close(); + queue.destroy(); + } finally { + restoreVideoFrame(originalVideoFrame); + } + }); + + it("waits for a newer frame before falling back to the held frame while open", async () => { + const originalVideoFrame = globalThis.VideoFrame; + vi.stubGlobal("VideoFrame", MockVideoFrame); + try { + const queue = new TimestampedVideoFrameQueue(); + const frame0 = new MockVideoFrame(0) as unknown as VideoFrame; + const frame33 = new MockVideoFrame(33_000) as unknown as VideoFrame; + + queue.enqueue(frame0, 0); + const sampled0 = await queue.frameAt(0); + let resolved = false; + const pending = queue.frameAt(33).then((frame) => { + resolved = true; + return frame; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + + queue.enqueue(frame33, 33); + const sampled33 = await pending; + + expect(sampled0?.timestamp).toBe(0); + expect(sampled33?.timestamp).toBe(33_000); + + sampled0?.close(); + sampled33?.close(); + queue.destroy(); + } finally { + restoreVideoFrame(originalVideoFrame); + } + }); +}); diff --git a/src/lib/exporter/timestampedVideoFrameQueue.ts b/src/lib/exporter/timestampedVideoFrameQueue.ts new file mode 100644 index 00000000..86c0fe91 --- /dev/null +++ b/src/lib/exporter/timestampedVideoFrameQueue.ts @@ -0,0 +1,110 @@ +type TimestampedVideoFrame = { + frame: VideoFrame; + sourceTimestampMs: number; +}; + +type PendingConsumer = { + resolve: () => void; + reject: (error: Error) => void; +}; + +const TIMESTAMP_EPSILON_MS = 0.5; + +export class TimestampedVideoFrameQueue { + private frames: TimestampedVideoFrame[] = []; + private consumers: PendingConsumer[] = []; + private error: Error | null = null; + private closed = false; + private heldFrame: TimestampedVideoFrame | null = null; + + get length() { + return this.frames.length; + } + + enqueue(frame: VideoFrame, sourceTimestampMs: number) { + if (this.closed) { + frame.close(); + return; + } + + this.frames.push({ frame, sourceTimestampMs }); + const consumers = this.consumers.splice(0); + for (const consumer of consumers) { + consumer.resolve(); + } + } + + fail(error: Error) { + this.error = error; + this.closed = true; + const consumers = this.consumers.splice(0); + for (const consumer of consumers) { + consumer.reject(error); + } + this.closeOwnedFrames(); + } + + close() { + this.closed = true; + const consumers = this.consumers.splice(0); + for (const consumer of consumers) { + consumer.resolve(); + } + } + + async frameAt(sourceTimestampMs: number): Promise { + for (;;) { + if (this.error) { + throw this.error; + } + + const next = this.frames[0] ?? null; + if (next && next.sourceTimestampMs <= sourceTimestampMs + TIMESTAMP_EPSILON_MS) { + this.replaceHeldFrame(this.frames.shift() ?? null); + continue; + } + + if ( + this.heldFrame && + (next || + this.closed || + this.heldFrame.sourceTimestampMs >= sourceTimestampMs - TIMESTAMP_EPSILON_MS) + ) { + return new VideoFrame(this.heldFrame.frame, { + timestamp: this.heldFrame.frame.timestamp, + }); + } + + if (next || this.closed) { + return null; + } + + await new Promise((resolve, reject) => { + this.consumers.push({ resolve, reject }); + }); + } + } + + destroy() { + this.close(); + this.closeOwnedFrames(); + } + + private replaceHeldFrame(frame: TimestampedVideoFrame | null) { + if (this.heldFrame) { + this.heldFrame.frame.close(); + } + this.heldFrame = frame; + } + + private closeOwnedFrames() { + if (this.heldFrame) { + this.heldFrame.frame.close(); + this.heldFrame = null; + } + for (const item of this.frames) { + item.frame.close(); + } + this.frames = []; + } +} diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index d0f178f9..35c3d559 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -10,11 +10,11 @@ import type { import { BackgroundLoadError } from "@/lib/wallpaper"; import type { CursorRecordingData } from "@/native/contracts"; import { getPlatform } from "@/utils/platformUtils"; -import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; import { AudioProcessor } from "./audioEncoder"; import { FrameRenderer } from "./frameRenderer"; import { VideoMuxer } from "./muxer"; import { StreamingVideoDecoder } from "./streamingDecoder"; +import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue"; import type { ExportConfig, ExportProgress, ExportResult } from "./types"; const ENCODER_STALL_TIMEOUT_MS = 15_000; @@ -44,6 +44,7 @@ export interface VideoExporterConfig extends ExportConfig { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorClipToBounds?: boolean; annotationRegions?: AnnotationRegion[]; previewWidth?: number; previewHeight?: number; @@ -194,7 +195,7 @@ export class VideoExporter { private async exportWithEncoderPreference( encoderPreference: HardwareAcceleration, ): Promise { - let webcamFrameQueue: AsyncVideoFrameQueue | null = null; + let webcamFrameQueue: TimestampedVideoFrameQueue | null = null; let stopWebcamDecode = false; let webcamDecodeError: Error | null = null; let webcamDecodePromise: Promise | null = null; @@ -241,6 +242,7 @@ export class VideoExporter { cursorSmoothing: this.config.cursorSmoothing, cursorMotionBlur: this.config.cursorMotionBlur, cursorClickBounce: this.config.cursorClickBounce, + cursorClipToBounds: this.config.cursorClipToBounds, videoWidth: videoInfo.width, videoHeight: videoInfo.height, webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null, @@ -288,7 +290,7 @@ export class VideoExporter { ? Math.min(this.MAX_ENCODE_QUEUE, 32) : this.MAX_ENCODE_QUEUE; - webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null; + webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null; webcamDecodePromise = webcamDecoder && webcamFrameQueue ? (() => { @@ -298,7 +300,7 @@ export class VideoExporter { this.config.frameRate, this.config.trimRegions, this.config.speedRegions, - async (webcamFrame) => { + async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => { while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) { await new Promise((resolve) => setTimeout(resolve, 2)); } @@ -306,7 +308,7 @@ export class VideoExporter { webcamFrame.close(); return; } - queue.enqueue(webcamFrame); + queue.enqueue(webcamFrame, webcamSourceTimestampMs); }, onWarning, ) @@ -340,7 +342,9 @@ export class VideoExporter { } const timestamp = frameIndex * frameDuration; - webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null; + webcamFrame = webcamFrameQueue + ? await webcamFrameQueue.frameAt(sourceTimestampMs) + : null; if (this.cancelled) { return; } diff --git a/src/lib/nativeMacRecording.test.ts b/src/lib/nativeMacRecording.test.ts new file mode 100644 index 00000000..fce88f6a --- /dev/null +++ b/src/lib/nativeMacRecording.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parseMacDisplayIdFromSourceId, parseMacWindowIdFromSourceId } from "./nativeMacRecording"; + +describe("nativeMacRecording source parsing", () => { + it("parses Electron window source ids into ScreenCaptureKit window ids", () => { + expect(parseMacWindowIdFromSourceId("window:12345:0")).toBe(12345); + expect(parseMacWindowIdFromSourceId("window:987")).toBe(987); + }); + + it("rejects non-window source ids for window parsing", () => { + expect(parseMacWindowIdFromSourceId("screen:1:0")).toBeNull(); + expect(parseMacWindowIdFromSourceId("window:not-a-number:0")).toBeNull(); + expect(parseMacWindowIdFromSourceId(null)).toBeNull(); + }); + + it("parses Electron display source ids into ScreenCaptureKit display ids", () => { + expect(parseMacDisplayIdFromSourceId("screen:1:0")).toBe(1); + expect(parseMacDisplayIdFromSourceId("screen:69733248")).toBe(69733248); + }); + + it("rejects non-display source ids for display parsing", () => { + expect(parseMacDisplayIdFromSourceId("window:123:0")).toBeNull(); + expect(parseMacDisplayIdFromSourceId("screen:not-a-number:0")).toBeNull(); + expect(parseMacDisplayIdFromSourceId(undefined)).toBeNull(); + }); +}); diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts new file mode 100644 index 00000000..4202132f --- /dev/null +++ b/src/lib/nativeMacRecording.ts @@ -0,0 +1,117 @@ +import type { Rectangle } from "electron"; +import type { CursorCaptureMode } from "./recordingSession"; + +export type NativeMacSourceType = "display" | "window"; + +export type NativeMacRecordingRequest = { + schemaVersion: 1; + recordingId?: number; + source: { + type: NativeMacSourceType; + sourceId: string; + displayId?: number; + windowId?: number; + bounds?: Rectangle; + }; + video: { + fps: number; + width: number; + height: number; + bitrate?: number; + hideSystemCursor: boolean; + }; + audio: { + system: { + enabled: boolean; + }; + microphone: { + enabled: boolean; + deviceId?: string; + deviceName?: string; + gain: number; + }; + }; + webcam: { + enabled: boolean; + deviceId?: string; + deviceName?: string; + width: number; + height: number; + fps: number; + }; + cursor: { + mode: CursorCaptureMode; + }; + outputs: { + screenPath: string; + manifestPath?: string; + }; +}; + +export type NativeMacHelperReadyEvent = { + event: "ready"; + schemaVersion: 1; +}; + +export type NativeMacHelperRecordingStartedEvent = { + event: "recording-started"; + timestampMs: number; +}; + +export type NativeMacHelperRecordingStoppedEvent = { + event: "recording-stopped"; + screenPath: string; +}; + +export type NativeMacHelperWarningEvent = { + event: "warning"; + code: string; + message: string; +}; + +export type NativeMacHelperErrorEvent = { + event: "error"; + code: string; + message: string; +}; + +export type NativeMacHelperEvent = + | NativeMacHelperReadyEvent + | NativeMacHelperRecordingStartedEvent + | NativeMacHelperRecordingStoppedEvent + | NativeMacHelperWarningEvent + | NativeMacHelperErrorEvent; + +export type NativeMacRecordingStartResult = { + success: boolean; + recordingId?: number; + path?: string; + helperPath?: string; + error?: string; +}; + +export function parseMacWindowIdFromSourceId(sourceId?: string | null) { + if (!sourceId?.startsWith("window:")) { + return null; + } + + const windowIdPart = sourceId.split(":")[1]; + if (!windowIdPart || !/^\d+$/.test(windowIdPart)) { + return null; + } + + return Number(windowIdPart); +} + +export function parseMacDisplayIdFromSourceId(sourceId?: string | null) { + if (!sourceId?.startsWith("screen:")) { + return null; + } + + const displayIdPart = sourceId.split(":")[1]; + if (!displayIdPart || !/^\d+$/.test(displayIdPart)) { + return null; + } + + return Number(displayIdPart); +} diff --git a/src/lib/recordingSession.ts b/src/lib/recordingSession.ts index f5ebf9ca..12a6afd2 100644 --- a/src/lib/recordingSession.ts +++ b/src/lib/recordingSession.ts @@ -20,6 +20,14 @@ export interface StoreRecordedSessionInput { webcam?: RecordedVideoAssetInput; createdAt?: number; cursorCaptureMode?: CursorCaptureMode; + /** + * Recording wall-clock duration in milliseconds. Used by the main process + * to patch the WebM Duration header on streamed recordings, since the + * renderer no longer holds the bytes. Browser MediaRecorder writes WebM + * with no/zero duration; without this patch, the editor's seek bar and + * timeline break for any recording that took the streaming path. + */ + durationMs?: number; } export function normalizeCursorCaptureMode(value: unknown): CursorCaptureMode | undefined { diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 96592a41..485fe89d 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -1,4 +1,5 @@ export const SHORTCUT_ACTIONS = [ + "openApp", "addZoom", "addTrim", "addSpeed", @@ -105,6 +106,7 @@ export function findConflict( } export const DEFAULT_SHORTCUTS: ShortcutsConfig = { + openApp: { key: "o", ctrl: true, shift: true }, addZoom: { key: "z" }, addTrim: { key: "t" }, addSpeed: { key: "s" }, @@ -116,6 +118,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { }; export const SHORTCUT_LABELS: Record = { + openApp: "Open App", addZoom: "Add Zoom", addTrim: "Add Trim", addSpeed: "Add Speed", diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 5b7bc86d..28a45068 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, +} from "@/components/video-editor/editorDefaults"; import type { ExportFormat, ExportQuality } from "@/lib/exporter"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; @@ -27,11 +31,11 @@ export interface UserPreferences { exportFolder: string | null; } -const DEFAULT_PREFS: UserPreferences = { - padding: 50, - aspectRatio: "16:9", - exportQuality: "good", - exportFormat: "mp4", +export const DEFAULT_PREFS: UserPreferences = { + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + exportQuality: DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: DEFAULT_EXPORT_SETTINGS.format, exportFolder: null, }; diff --git a/src/utils/aspectRatioUtils.test.ts b/src/utils/aspectRatioUtils.test.ts new file mode 100644 index 00000000..6909f3df --- /dev/null +++ b/src/utils/aspectRatioUtils.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { getNativeAspectRatioValue } from "./aspectRatioUtils"; + +const FALLBACK_RATIO = 16 / 9; + +describe("getNativeAspectRatioValue", () => { + it("returns the video ratio when no crop region is provided", () => { + expect(getNativeAspectRatioValue(1920, 1080)).toBe(16 / 9); + }); + + it("applies crop width and height to the video ratio", () => { + expect(getNativeAspectRatioValue(1920, 1080, { x: 0, y: 0, width: 0.5, height: 1 })).toBe( + 8 / 9, + ); + }); + + it("falls back when video metadata is zero or non-finite", () => { + expect(getNativeAspectRatioValue(0, 1080)).toBe(FALLBACK_RATIO); + expect(getNativeAspectRatioValue(1920, 0)).toBe(FALLBACK_RATIO); + expect(getNativeAspectRatioValue(Number.NaN, 1080)).toBe(FALLBACK_RATIO); + expect(getNativeAspectRatioValue(1920, Number.POSITIVE_INFINITY)).toBe(FALLBACK_RATIO); + }); + + it("falls back when crop dimensions are non-positive or non-finite", () => { + expect(getNativeAspectRatioValue(1920, 1080, { x: 0, y: 0, width: 0, height: 1 })).toBe( + FALLBACK_RATIO, + ); + expect(getNativeAspectRatioValue(1920, 1080, { x: 0, y: 0, width: 1, height: -1 })).toBe( + FALLBACK_RATIO, + ); + expect( + getNativeAspectRatioValue(1920, 1080, { + x: 0, + y: 0, + width: Number.POSITIVE_INFINITY, + height: 1, + }), + ).toBe(FALLBACK_RATIO); + }); +}); diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts index 887b543f..5e174ab3 100644 --- a/src/utils/aspectRatioUtils.ts +++ b/src/utils/aspectRatioUtils.ts @@ -11,6 +11,8 @@ export const ASPECT_RATIOS = [ export type AspectRatio = (typeof ASPECT_RATIOS)[number]; +const NATIVE_ASPECT_RATIO_FALLBACK = 16 / 9; + /** * Returns the numeric value of an aspect ratio. * For "native", returns a fallback ratio of 16/9. @@ -33,7 +35,7 @@ export function getAspectRatioValue(aspectRatio: AspectRatio): number { case "10:16": return 10 / 16; case "native": - return 16 / 9; + return NATIVE_ASPECT_RATIO_FALLBACK; default: { const _exhaustiveCheck: never = aspectRatio; return _exhaustiveCheck; @@ -48,7 +50,21 @@ export function getNativeAspectRatioValue( ): number { const cropW = cropRegion?.width ?? 1; const cropH = cropRegion?.height ?? 1; - return (videoWidth * cropW) / (videoHeight * cropH); + if ( + !Number.isFinite(videoWidth) || + !Number.isFinite(videoHeight) || + !Number.isFinite(cropW) || + !Number.isFinite(cropH) || + videoWidth <= 0 || + videoHeight <= 0 || + cropW <= 0 || + cropH <= 0 + ) { + return NATIVE_ASPECT_RATIO_FALLBACK; + } + + const ratio = (videoWidth * cropW) / (videoHeight * cropH); + return Number.isFinite(ratio) && ratio > 0 ? ratio : NATIVE_ASPECT_RATIO_FALLBACK; } export function getAspectRatioDimensions( @@ -63,7 +79,7 @@ export function getAspectRatioDimensions( } export function getAspectRatioLabel(aspectRatio: AspectRatio): string { - if (aspectRatio === "native") return "Native"; + if (aspectRatio === "native") return "Original"; return aspectRatio; } @@ -72,6 +88,6 @@ export function isPortraitAspectRatio(aspectRatio: AspectRatio): boolean { } export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string { - if (aspectRatio === "native") return String(nativeRatio ?? 16 / 9); + if (aspectRatio === "native") return String(nativeRatio ?? NATIVE_ASPECT_RATIO_FALLBACK); return aspectRatio.replace(":", "/"); } diff --git a/src/utils/getTestId.ts b/src/utils/getTestId.ts index 453f7c99..07454e4d 100644 --- a/src/utils/getTestId.ts +++ b/src/utils/getTestId.ts @@ -1,4 +1,9 @@ -export type TestId = `gif-size-button-${string}` | "export-button" | `gif-format-button`; +export type TestId = + | `gif-size-button-${string}` + | "export-button" + | "export-panel-button" + | "gif-format-button" + | "mp4-format-button"; export function getTestId(testId: TestId) { return `testId-${testId}`; diff --git a/tests/e2e/gif-export.spec.ts b/tests/e2e/gif-export.spec.ts index d1fa3f7a..18277ccb 100644 --- a/tests/e2e/gif-export.spec.ts +++ b/tests/e2e/gif-export.spec.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,8 +10,8 @@ const ROOT = path.join(__dirname, "../.."); const MAIN_JS = path.join(ROOT, "dist-electron/main.js"); const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm"); -test("exports a GIF from a loaded video", async () => { - const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`); +async function exportFromLoadedVideo(format: "gif" | "mp4"): Promise { + const outputPath = path.join(os.tmpdir(), `test-${format}-export-${Date.now()}.${format}`); let testVideoInRecordings = ""; const app = await electron.launch({ @@ -27,42 +28,39 @@ test("exports a GIF from a loaded video", async () => { HEADLESS: process.env["HEADLESS"] ?? "true", }, }); + const electronProcess = app.process(); - // Print all main-process stdout/stderr so failures are diagnosable. app.process().stdout?.on("data", (d) => process.stdout.write(`[electron] ${d}`)); app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`)); try { - // ── 1. Wait for the HUD overlay window. The window is created after - // registerIpcHandlers() completes, so all IPC handlers are live - // by the time firstWindow() resolves. const hudWindow = await app.firstWindow({ timeout: 60_000 }); await hudWindow.waitForLoadState("domcontentloaded"); - // ── 2. Intercept the native save dialog in the main process. - // Must happen after firstWindow() so registerIpcHandlers() has - // already registered its version — otherwise our early handle() - // call causes registerIpcHandlers() to throw and abort, leaving - // other handlers (like set-current-video-path) never registered. - // Store the exported buffer as a base64 global in the main process. - // We can't use require() or import() inside app.evaluate() because the - // main process is ESM and Playwright runs the callback via eval(), which - // has no dynamic-import hook. We retrieve and write the file below after - // the export finishes. - await app.evaluate(({ ipcMain }) => { - ipcMain.removeHandler("save-exported-video"); + await app.evaluate(({ ipcMain }, targetPath: string) => { + ipcMain.removeHandler("pick-export-save-path"); + ipcMain.removeHandler("write-export-to-path"); + ipcMain.handle("pick-export-save-path", () => ({ + success: true, + path: targetPath, + canceled: false, + })); ipcMain.handle( - "save-exported-video", - (_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer) => { + "write-export-to-path", + (_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer, filePath: string) => { + if (filePath !== targetPath) { + return { + success: false, + error: `Unexpected export path: ${filePath}`, + }; + } (globalThis as Record)["__testExportData"] = Buffer.from(buffer).toString("base64"); - return { success: true, path: "pending" }; + return { success: true, path: filePath }; }, ); - }); + }, outputPath); - // Copy the test fixture into the app's recordings directory so it passes - // the path security check in set-current-video-path. const userDataDir = await app.evaluate(({ app: electronApp }) => { return electronApp.getPath("userData"); }); @@ -71,62 +69,73 @@ test("exports a GIF from a loaded video", async () => { fs.mkdirSync(recordingsDir, { recursive: true }); fs.copyFileSync(TEST_VIDEO, testVideoInRecordings); + await hudWindow.evaluate( + (videoPath: string) => window.electronAPI.setCurrentVideoPath(videoPath), + testVideoInRecordings, + ); try { - await hudWindow.evaluate((videoPath: string) => { - window.electronAPI.setCurrentVideoPath(videoPath); - window.electronAPI.switchToEditor(); - }, testVideoInRecordings); - } catch { - // Expected: switchToEditor() closes the HUD window, terminating - // the Playwright page context before evaluate() can resolve. + await hudWindow.evaluate(() => window.electronAPI.switchToEditor()); + } catch (error) { + if ( + !(error instanceof Error) || + !/closed|destroyed|target page|target closed/i.test(error.message) + ) { + throw error; + } } - // ── 3. Switch to the editor window. This closes the HUD and opens - // a new BrowserWindow with ?windowType=editor. const editorWindow = await app.waitForEvent("window", { predicate: (w) => w.url().includes("windowType=editor"), timeout: 15_000, }); - // WebCodecs (VideoEncoder) may not be registered in the renderer on first - // load of a second BrowserWindow. A single reload ensures the feature is - // fully initialized before we start encoding. + // WebCodecs may not be registered in the renderer on first load. await editorWindow.reload(); await editorWindow.waitForLoadState("domcontentloaded"); await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({ timeout: 15_000, }); - // ── 5. Select GIF as the export format. - await editorWindow.getByTestId("testId-gif-format-button").click(); + await editorWindow.getByTestId("testId-export-panel-button").click(); + await editorWindow.getByTestId(`testId-${format}-format-button`).click(); await editorWindow.getByTestId("testId-export-button").click(); - // ── 6. Wait for the success toast. - await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({ - timeout: 90_000, - }); + await expect + .poll( + () => + app.evaluate(() => Boolean((globalThis as Record)["__testExportData"])), + { timeout: 90_000 }, + ) + .toBe(true); - // ── 7. Write the captured buffer from the main-process global to disk. const base64 = await app.evaluate( () => (globalThis as Record)["__testExportData"] as string, ); fs.writeFileSync(outputPath, Buffer.from(base64, "base64")); - // ── 8. Verify the file on disk is a valid GIF. - expect(fs.existsSync(outputPath), `GIF not found at ${outputPath}`).toBe(true); - - const header = Buffer.alloc(6); - const fd = fs.openSync(outputPath, "r"); - fs.readSync(fd, header, 0, 6, 0); - fs.closeSync(fd); - - // GIF magic bytes are either "GIF87a" or "GIF89a" - expect(header.toString("ascii")).toMatch(/^GIF8[79]a/); - + expect(fs.existsSync(outputPath), `${format.toUpperCase()} not found at ${outputPath}`).toBe( + true, + ); const stats = fs.statSync(outputPath); - expect(stats.size).toBeGreaterThan(1024); // at least 1 KB + expect(stats.size).toBeGreaterThan(1024); + return fs.readFileSync(outputPath); } finally { - await app.close(); + await app + .evaluate(({ app: electronApp }) => { + electronApp.exit(0); + }) + .catch(() => { + // The process may already be gone after export completes. + }); + if (electronProcess.pid) { + if (process.platform === "win32") { + spawnSync("taskkill", ["/PID", String(electronProcess.pid), "/T", "/F"], { + stdio: "ignore", + }); + } else if (!electronProcess.killed) { + electronProcess.kill("SIGKILL"); + } + } if (fs.existsSync(outputPath)) { fs.unlinkSync(outputPath); } @@ -134,4 +143,16 @@ test("exports a GIF from a loaded video", async () => { fs.unlinkSync(testVideoInRecordings); } } +} + +test("exports an MP4 from a loaded video", async () => { + const exported = await exportFromLoadedVideo("mp4"); + + expect(exported.subarray(4, 8).toString("ascii")).toBe("ftyp"); +}); + +test("exports a GIF from a loaded video", async () => { + const exported = await exportFromLoadedVideo("gif"); + + expect(exported.subarray(0, 6).toString("ascii")).toMatch(/^GIF8[79]a/); }); diff --git a/vite.config.ts b/vite.config.ts index 55b55966..0779e135 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,11 @@ export default defineConfig({ electron({ main: { entry: "electron/main.ts", + onstart({ startup }) { + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + return startup(["."], { env }); + }, vite: { build: {}, }, diff --git a/vitest.config.ts b/vitest.config.ts index 9108f699..5a52a9be 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: "jsdom", - include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + include: ["{src,electron}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], exclude: ["src/**/*.browser.test.{ts,tsx}"], }, resolve: {