Merge branch 'main' into i18n/ko-kr-missing-keys
This commit is contained in:
+9
-1
@@ -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/
|
||||
@@ -49,4 +57,4 @@ result-*
|
||||
.direnv/
|
||||
|
||||
#kilocode
|
||||
.kilo/
|
||||
.kilo/
|
||||
|
||||
@@ -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.
|
||||
@@ -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-*/*"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Vendored
+67
-1
@@ -31,7 +31,16 @@ interface Window {
|
||||
switchToEditor: () => Promise<void>;
|
||||
switchToHud: () => Promise<void>;
|
||||
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
openSourceSelector: () => Promise<{
|
||||
opened: boolean;
|
||||
reason?: string;
|
||||
access?: {
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
};
|
||||
}>;
|
||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||
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,
|
||||
@@ -78,6 +99,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<import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingStartResult>;
|
||||
@@ -89,6 +117,37 @@ interface Window {
|
||||
discarded?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
startNativeMacRecording: (
|
||||
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
|
||||
) => Promise<import("../src/lib/nativeMacRecording").NativeMacRecordingStartResult>;
|
||||
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<void>;
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
@@ -138,6 +197,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,
|
||||
@@ -178,6 +243,7 @@ interface Window {
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
|
||||
moveHudOverlayBy: (deltaX: number, deltaY: number) => void;
|
||||
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
|
||||
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
|
||||
hideCountdownOverlay: (runId: number) => Promise<void>;
|
||||
|
||||
+805
-27
@@ -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,6 +38,7 @@ 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 { registerNativeBridgeHandlers } from "./nativeBridge";
|
||||
|
||||
@@ -43,6 +47,8 @@ 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 +108,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[],
|
||||
@@ -215,6 +317,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<string, DesktopCapturerSource>();
|
||||
@@ -276,6 +385,16 @@ let nativeWindowsCaptureRecordingId: number | null = null;
|
||||
let nativeWindowsCursorOffsetMs = 0;
|
||||
let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay";
|
||||
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 +618,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 +817,62 @@ 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 waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -785,6 +989,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<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Timed out waiting for native macOS capture to start"));
|
||||
}, 10_000);
|
||||
|
||||
const inspect = (event: Record<string, unknown>) => {
|
||||
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<string, unknown>) => 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<string>((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<string, unknown>) => {
|
||||
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<string, unknown>) => 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 +1227,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 +1340,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 +1395,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 +1454,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) => {
|
||||
@@ -1217,6 +1641,201 @@ export function registerIpcHandlers(
|
||||
},
|
||||
);
|
||||
|
||||
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("stop-native-windows-recording", async (_, discard?: boolean) => {
|
||||
const proc = nativeWindowsCaptureProcess;
|
||||
const preferredPath = nativeWindowsCaptureTargetPath;
|
||||
@@ -1301,6 +1920,152 @@ 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),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
|
||||
try {
|
||||
return await storeRecordedSessionFiles(payload);
|
||||
@@ -1620,6 +2385,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) => {
|
||||
|
||||
+3
-13
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
@@ -493,23 +492,14 @@ app.whenReady().then(async () => {
|
||||
{ useSystemPicker: false },
|
||||
);
|
||||
|
||||
// Request microphone and screen recording permissions from macOS
|
||||
// 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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, Readable, Readable> | 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<void> {
|
||||
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<CursorRecordingData> {
|
||||
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 (<THRESHOLD samples) are handled by
|
||||
// clip-path — the cursor clips to the canvas edge instead of snapping invisible.
|
||||
// Sustained exits (≥THRESHOLD samples, ≈100ms) mark visible=false to prevent
|
||||
// ghost cursors and motion trails from multi-display movement.
|
||||
if (isOutsideDisplay) {
|
||||
this.consecutiveOutsideSamples++;
|
||||
} else {
|
||||
this.consecutiveOutsideSamples = 0;
|
||||
}
|
||||
const visible =
|
||||
this.consecutiveOutsideSamples < MacNativeCursorRecordingSession.OUTSIDE_HIDE_THRESHOLD;
|
||||
const interactionType =
|
||||
leftButtonPressed || (leftButtonDown && !this.previousLeftButtonDown)
|
||||
? "click"
|
||||
: leftButtonReleased || (!leftButtonDown && this.previousLeftButtonDown)
|
||||
? "mouseup"
|
||||
: "move";
|
||||
this.previousLeftButtonDown = leftButtonDown;
|
||||
|
||||
this.samples.push({
|
||||
timeMs: Math.max(0, timestampMs - this.startTimeMs),
|
||||
cx: clamp(normalizedX, 0, 1),
|
||||
cy: clamp(normalizedY, 0, 1),
|
||||
visible,
|
||||
interactionType,
|
||||
...(cursorType ? { cursorType } : {}),
|
||||
});
|
||||
|
||||
if (this.samples.length > this.options.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private waitUntilReady() {
|
||||
return new Promise<void>((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<null, Readable, Readable>) {
|
||||
if (child.killed) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 500).unref();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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<MouseButtonTracker>.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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
||||
@@ -24,6 +25,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,6 +52,12 @@ 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);
|
||||
},
|
||||
@@ -68,12 +78,35 @@ 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);
|
||||
},
|
||||
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 +145,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");
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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<HTMLButtonElement | null>(null);
|
||||
const languageMenuPanelRef = useRef<HTMLDivElement | null>(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<boolean | undefined>(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<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setHudMouseEventsEnabled(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragLastPositionRef.current = { x: event.screenX, y: event.screenY };
|
||||
};
|
||||
const handleHudDragPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 && (
|
||||
<div
|
||||
@@ -548,9 +590,23 @@ export function LaunchWindow() {
|
||||
<div
|
||||
data-hud-interactive="true"
|
||||
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
|
||||
onPointerEnter={() => setHudMouseEventsEnabled(true)}
|
||||
onPointerDown={() => setHudMouseEventsEnabled(true)}
|
||||
onMouseEnter={() => setHudMouseEventsEnabled(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!isLanguageMenuOpen) {
|
||||
setHudMouseEventsEnabled(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
<div
|
||||
className={`flex h-8 w-7 cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
|
||||
onPointerDown={handleHudDragPointerDown}
|
||||
onPointerMove={handleHudDragPointerMove}
|
||||
onPointerUp={handleHudDragPointerEnd}
|
||||
onPointerCancel={handleHudDragPointerEnd}
|
||||
>
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
@@ -609,7 +665,7 @@ export function LaunchWindow() {
|
||||
? getIcon("webcamOn", "text-green-400")
|
||||
: getIcon("webcamOff", "text-white/40")}
|
||||
</button>
|
||||
{isWindows && (
|
||||
{supportsCursorModeToggle && (
|
||||
<button
|
||||
data-testid="launch-cursor-mode-button"
|
||||
className={`${hudIconBtnClasses} ${
|
||||
@@ -668,13 +724,18 @@ export function LaunchWindow() {
|
||||
|
||||
{recording && (
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<Tooltip
|
||||
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||
>
|
||||
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
|
||||
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{canPauseRecording && (
|
||||
<Tooltip
|
||||
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||
>
|
||||
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
|
||||
{getIcon(
|
||||
paused ? "resume" : "pause",
|
||||
paused ? "text-amber-400" : "text-white/60",
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button className={hudAuxIconBtnClasses} onClick={restartRecording}>
|
||||
{getIcon("restart", "text-white/60")}
|
||||
@@ -737,6 +798,7 @@ export function LaunchWindow() {
|
||||
? createPortal(
|
||||
<div
|
||||
ref={languageMenuPanelRef}
|
||||
data-hud-interactive="true"
|
||||
role="menu"
|
||||
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
|
||||
style={
|
||||
@@ -749,6 +811,12 @@ export function LaunchWindow() {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onPointerEnter={() => setHudMouseEventsEnabled(true)}
|
||||
onPointerMove={() => setHudMouseEventsEnabled(true)}
|
||||
onWheel={(event) => {
|
||||
setHudMouseEventsEnabled(true);
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{availableLocales.map((loc) => (
|
||||
<button
|
||||
|
||||
@@ -315,6 +315,8 @@ interface SettingsPanelProps {
|
||||
onCursorMotionBlurChange?: (blur: number) => void;
|
||||
cursorClickBounce?: number;
|
||||
onCursorClickBounceChange?: (bounce: number) => void;
|
||||
cursorClipToBounds?: boolean;
|
||||
onCursorClipToBoundsChange?: (clip: boolean) => void;
|
||||
hasCursorData?: boolean;
|
||||
showCursorSettings?: boolean;
|
||||
}
|
||||
@@ -437,6 +439,8 @@ export function SettingsPanel({
|
||||
onCursorMotionBlurChange,
|
||||
cursorClickBounce = 2.5,
|
||||
onCursorClickBounceChange,
|
||||
cursorClipToBounds = false,
|
||||
onCursorClipToBoundsChange,
|
||||
hasCursorData = false,
|
||||
showCursorSettings = true,
|
||||
}: SettingsPanelProps) {
|
||||
@@ -1403,7 +1407,9 @@ export function SettingsPanel({
|
||||
{activePanelMode === "cursor" && showCursorSettings && hasCursorData && (
|
||||
<div className="p-2 rounded-lg editor-control-surface mt-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-medium text-slate-300">Show Cursor</div>
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("cursor.show")}
|
||||
</div>
|
||||
<Switch
|
||||
checked={showCursor}
|
||||
onCheckedChange={onShowCursorChange}
|
||||
@@ -1411,78 +1417,93 @@ export function SettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
{showCursor && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Size</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{cursorSize.toFixed(1)}
|
||||
</span>
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("cursor.clipToBounds")}
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorSize]}
|
||||
onValueChange={(values) => 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"
|
||||
<Switch
|
||||
checked={cursorClipToBounds}
|
||||
onCheckedChange={onCursorClipToBoundsChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90"
|
||||
aria-label={t("cursor.clipToBounds")}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
Smoothing
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("cursor.size")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{cursorSize.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{Math.round(cursorSmoothing * 100)}%
|
||||
</span>
|
||||
<Slider
|
||||
value={[cursorSize]}
|
||||
onValueChange={(values) => 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"
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorSmoothing]}
|
||||
onValueChange={(values) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
Motion Blur
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("cursor.smoothing")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{Math.round(cursorSmoothing * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{Math.round(cursorMotionBlur * 100)}%
|
||||
</span>
|
||||
<Slider
|
||||
value={[cursorSmoothing]}
|
||||
onValueChange={(values) => 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"
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorMotionBlur]}
|
||||
onValueChange={(values) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
Click Bounce
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("cursor.motionBlur")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{Math.round(cursorMotionBlur * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{cursorClickBounce.toFixed(1)}
|
||||
</span>
|
||||
<Slider
|
||||
value={[cursorMotionBlur]}
|
||||
onValueChange={(values) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("cursor.clickBounce")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{cursorClickBounce.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorClickBounce]}
|
||||
onValueChange={(values) => 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"
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorClickBounce]}
|
||||
onValueChange={(values) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_CLIP_TO_BOUNDS,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
@@ -242,6 +243,7 @@ export default function VideoEditor() {
|
||||
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING);
|
||||
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR);
|
||||
const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE);
|
||||
const [cursorClipToBounds, setCursorClipToBounds] = useState(DEFAULT_CURSOR_CLIP_TO_BOUNDS);
|
||||
const [nativePlatform, setNativePlatform] = useState<NativePlatform | null>(null);
|
||||
const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] =
|
||||
useState<CursorCaptureMode | null>(null);
|
||||
@@ -253,13 +255,13 @@ export default function VideoEditor() {
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
// Windows-only: the synthetic cursor overlay + cursor customization settings
|
||||
// only apply when there's an actual native cursor recording (cursor frames +
|
||||
// position samples produced by WindowsNativeRecordingSession). Mac and Linux
|
||||
// keep their telemetry positions for auto-zoom but never render a synthetic
|
||||
// cursor or expose cursor customization settings.
|
||||
// Native Windows recordings include captured cursor assets. Native macOS
|
||||
// recordings hide the system cursor in ScreenCaptureKit and use telemetry
|
||||
// samples with OpenScreen's default arrow asset for the editable overlay.
|
||||
const hasEditableCursorRecording =
|
||||
nativePlatform === "win32" && hasNativeCursorRecordingData(cursorRecordingData);
|
||||
recordingCursorCaptureMode === "editable-overlay" &&
|
||||
(nativePlatform === "win32" || nativePlatform === "darwin") &&
|
||||
hasNativeCursorRecordingData(cursorRecordingData);
|
||||
const effectiveShowCursor = showCursor && hasEditableCursorRecording;
|
||||
const showCursorSettings = hasEditableCursorRecording;
|
||||
const { locale, setLocale, t: rawT } = useI18n();
|
||||
@@ -1619,6 +1621,7 @@ export default function VideoEditor() {
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClipToBounds,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
@@ -1709,6 +1712,7 @@ export default function VideoEditor() {
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClipToBounds,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
@@ -1824,6 +1828,7 @@ export default function VideoEditor() {
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClipToBounds,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -2106,6 +2111,7 @@ export default function VideoEditor() {
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2266,6 +2272,8 @@ export default function VideoEditor() {
|
||||
onCursorMotionBlurChange={setCursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
onCursorClickBounceChange={setCursorClickBounce}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
onCursorClipToBoundsChange={setCursorClipToBounds}
|
||||
hasCursorData={
|
||||
cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData)
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ interface VideoPlaybackProps {
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorClipToBounds?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -195,6 +196,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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
(
|
||||
{
|
||||
@@ -248,10 +269,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
|
||||
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
cursorClipToBounds = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const supplementalAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<Application | null>(null);
|
||||
@@ -261,6 +284,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const timeUpdateAnimationRef = useRef<number | null>(null);
|
||||
const [pixiReady, setPixiReady] = useState(false);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [supplementalAudioPath, setSupplementalAudioPath] = useState<string | null>(null);
|
||||
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
|
||||
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -316,6 +340,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const cursorSmoothingRef = useRef(cursorSmoothing);
|
||||
const cursorMotionBlurRef = useRef(cursorMotionBlur);
|
||||
const cursorClickBounceRef = useRef(cursorClickBounce);
|
||||
const cursorClipToBoundsRef = useRef(cursorClipToBounds);
|
||||
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
@@ -334,6 +359,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const nativeCursorImageIdRef = useRef<string | null>(null);
|
||||
const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState());
|
||||
const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState());
|
||||
const nativeCursorClipRef = useRef<HTMLDivElement | null>(null);
|
||||
const borderRadiusRef = useRef<number>(0);
|
||||
|
||||
const hasNativeCursorRecording = useMemo(
|
||||
() => hasNativeCursorRecordingData(cursorRecordingData),
|
||||
@@ -531,6 +558,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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 +610,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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 +635,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
return;
|
||||
}
|
||||
video.pause();
|
||||
supplementalAudioRef.current?.pause();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -790,6 +828,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorClickBounceRef.current = cursorClickBounce;
|
||||
}, [cursorClickBounce]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClipToBoundsRef.current = cursorClipToBounds;
|
||||
}, [cursorClipToBounds]);
|
||||
|
||||
// Sync cursor overlay config when settings change
|
||||
useEffect(() => {
|
||||
const overlay = cursorOverlayRef.current;
|
||||
@@ -1005,11 +1047,30 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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 +1087,42 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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;
|
||||
|
||||
@@ -1396,6 +1491,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
nativeCursorImage.style.display = "none";
|
||||
nativeCursorImage.style.filter = "none";
|
||||
}
|
||||
if (nativeCursorClipRef.current) {
|
||||
nativeCursorClipRef.current.style.clipPath = "";
|
||||
}
|
||||
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
|
||||
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
|
||||
};
|
||||
@@ -1436,11 +1534,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
})
|
||||
: 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 +1544,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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 +1565,32 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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 +1634,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
composite3D.style.willChange = "auto";
|
||||
lastTransformIsIdentity = true;
|
||||
}
|
||||
if (nativeCursorClipRef.current) {
|
||||
nativeCursorClipRef.current.style.transform = "";
|
||||
}
|
||||
if (lastPerspectiveValue !== 0) {
|
||||
outerWrapper.style.perspective = "";
|
||||
lastPerspectiveValue = 0;
|
||||
@@ -1526,6 +1653,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
);
|
||||
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 +1675,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
const video = e.currentTarget;
|
||||
enableAllPreviewAudioTracks(video);
|
||||
const hasResolvedDuration = syncResolvedDuration(video);
|
||||
if (!hasResolvedDuration) {
|
||||
forceResolveDuration(video);
|
||||
@@ -1727,18 +1858,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
ref={nativeCursorImageRef}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-0 select-none"
|
||||
style={{
|
||||
display: "none",
|
||||
pointerEvents: "none",
|
||||
transformOrigin: "0 0",
|
||||
zIndex: 18,
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath &&
|
||||
(() => {
|
||||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||
@@ -1920,6 +2039,27 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div
|
||||
ref={nativeCursorClipRef}
|
||||
className="absolute inset-0"
|
||||
style={{ zIndex: 18, pointerEvents: "none" }}
|
||||
>
|
||||
<img
|
||||
ref={nativeCursorImageRef}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-0 select-none"
|
||||
style={{
|
||||
display: "none",
|
||||
pointerEvents: "none",
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoPath}
|
||||
@@ -1928,22 +2068,28 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
playsInline
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onDurationChange={(e) => {
|
||||
enableAllPreviewAudioTracks(e.currentTarget);
|
||||
if (!syncResolvedDuration(e.currentTarget)) {
|
||||
forceResolveDuration(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
onLoadedData={(e) => {
|
||||
enableAllPreviewAudioTracks(e.currentTarget);
|
||||
if (!syncResolvedDuration(e.currentTarget)) {
|
||||
forceResolveDuration(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
onCanPlay={(e) => {
|
||||
enableAllPreviewAudioTracks(e.currentTarget);
|
||||
if (!syncResolvedDuration(e.currentTarget)) {
|
||||
forceResolveDuration(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
onError={() => onError("Failed to load video")}
|
||||
/>
|
||||
{supplementalAudioPath && (
|
||||
<audio ref={supplementalAudioRef} src={supplementalAudioPath} preload="auto" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -194,6 +194,9 @@ 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 {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -2,11 +2,16 @@ 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";
|
||||
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
@@ -50,6 +55,7 @@ type UseScreenRecorderReturn = {
|
||||
elapsedSeconds: number;
|
||||
toggleRecording: () => void;
|
||||
togglePaused: () => void;
|
||||
canPauseRecording: boolean;
|
||||
restartRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
microphoneEnabled: boolean;
|
||||
@@ -80,6 +86,12 @@ type NativeWindowsRecordingHandle = {
|
||||
finalizing: boolean;
|
||||
};
|
||||
|
||||
type NativeMacRecordingHandle = {
|
||||
recordingId: number;
|
||||
finalizing: boolean;
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
||||
const recorder = new MediaRecorder(stream, options);
|
||||
const chunks: Blob[] = [];
|
||||
@@ -118,6 +130,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const screenRecorder = useRef<RecorderHandle | null>(null);
|
||||
const webcamRecorder = useRef<RecorderHandle | null>(null);
|
||||
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(null);
|
||||
const nativeMacRecording = useRef<NativeMacRecordingHandle | null>(null);
|
||||
const stream = useRef<MediaStream | null>(null);
|
||||
const screenStream = useRef<MediaStream | null>(null);
|
||||
const microphoneStream = useRef<MediaStream | null>(null);
|
||||
@@ -134,6 +147,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [countdownActive, setCountdownActive] = useState(false);
|
||||
const webcamReady = useRef(false);
|
||||
const webcamAcquireId = useRef(0);
|
||||
const canPauseRecording =
|
||||
recording &&
|
||||
!nativeWindowsRecording.current &&
|
||||
Boolean(
|
||||
(nativeMacRecording.current && !nativeMacRecording.current.finalizing) ||
|
||||
(screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"),
|
||||
);
|
||||
|
||||
const getRecordingDurationMs = useCallback(() => {
|
||||
const segmentDuration =
|
||||
@@ -455,11 +475,115 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finalizeNativeMacRecording = useCallback(
|
||||
async (discard = false) => {
|
||||
const activeNativeRecording = nativeMacRecording.current;
|
||||
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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<RecordedVideoAssetInput | undefined> => {
|
||||
if (!activeWebcamRecorder) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 +653,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 +681,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
webcamRecorder.current = null;
|
||||
teardownMedia();
|
||||
};
|
||||
}, [teardownMedia, safeHideCountdownOverlay, finalizeNativeWindowsRecording]);
|
||||
}, [
|
||||
teardownMedia,
|
||||
safeHideCountdownOverlay,
|
||||
finalizeNativeWindowsRecording,
|
||||
finalizeNativeMacRecording,
|
||||
]);
|
||||
|
||||
const safeShowCountdownOverlay = async (value: number, runId: number) => {
|
||||
try {
|
||||
@@ -677,6 +809,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<void>((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: WEBCAM_TARGET_WIDTH,
|
||||
height: WEBCAM_TARGET_HEIGHT,
|
||||
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 +960,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
const runId = countdownRunId.current + 1;
|
||||
countdownRunId.current = runId;
|
||||
setCountdownActive(true);
|
||||
|
||||
let selectedSource: ProcessedDesktopSource | null = null;
|
||||
try {
|
||||
@@ -705,6 +980,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 +1061,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();
|
||||
@@ -1024,6 +1321,46 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
|
||||
const togglePaused = () => {
|
||||
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 +1429,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 +1506,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 +1539,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
elapsedSeconds,
|
||||
toggleRecording,
|
||||
togglePaused,
|
||||
canPauseRecording,
|
||||
restartRecording,
|
||||
cancelRecording,
|
||||
microphoneEnabled,
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.",
|
||||
"cameraDisconnected": "تم فصل كاميرا الويب.",
|
||||
"cameraNotFound": "لم يتم العثور على كاميرا.",
|
||||
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة."
|
||||
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.",
|
||||
"accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,5 +39,9 @@
|
||||
"description": "اكتشفنا أن {{language}} هي لغة نظامك. هل تريد تبديل OpenScreen إلى {{language}}؟",
|
||||
"switch": "التبديل إلى {{language}}",
|
||||
"keepDefault": "الاحتفاظ باللغة الحالية"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "استخدام مؤشر قابل للتحرير",
|
||||
"useSystemCursor": "استخدام مؤشر النظام"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,21 @@
|
||||
"manual": "يدوي",
|
||||
"auto": "تلقائي",
|
||||
"autoDescription": "الكاميرا تتبع موضع المؤشر المسجل"
|
||||
},
|
||||
"customScale": "تكبير مخصص",
|
||||
"position": {
|
||||
"title": "موضع التركيز",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "دوران ثلاثي الأبعاد",
|
||||
"preset": {
|
||||
"iso": "متساوي القياس",
|
||||
"left": "يسار",
|
||||
"right": "يمين"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -28,7 +43,8 @@
|
||||
"verticalStack": "تكدس عمودي",
|
||||
"dualFrame": "إطار مزدوج",
|
||||
"webcamShape": "شكل الكاميرا",
|
||||
"webcamSize": "حجم كاميرا الويب"
|
||||
"webcamSize": "حجم كاميرا الويب",
|
||||
"noWebcam": "بدون كاميرا"
|
||||
},
|
||||
"effects": {
|
||||
"title": "تأثيرات الفيديو",
|
||||
@@ -175,6 +191,14 @@
|
||||
"errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
|
||||
"errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google."
|
||||
},
|
||||
"cursor": {
|
||||
"show": "إظهار المؤشر",
|
||||
"size": "الحجم",
|
||||
"smoothing": "التنعيم",
|
||||
"motionBlur": "ضبابية الحركة",
|
||||
"clickBounce": "ارتداد النقر",
|
||||
"clipToBounds": "القص ضمن اللوحة"
|
||||
},
|
||||
"language": {
|
||||
"title": "اللغة"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,14 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -16,6 +16,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 +43,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 +53,8 @@
|
||||
"off": "desactivado",
|
||||
"shadow": "Sombra",
|
||||
"roundness": "Redondez",
|
||||
"padding": "Relleno"
|
||||
"padding": "Relleno",
|
||||
"on": "activado"
|
||||
},
|
||||
"background": {
|
||||
"title": "Fondo",
|
||||
@@ -182,6 +191,14 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
@@ -9,13 +9,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 +17,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 +44,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 +54,8 @@
|
||||
"off": "désactivé",
|
||||
"shadow": "Ombre",
|
||||
"roundness": "Arrondi",
|
||||
"padding": "Marge"
|
||||
"padding": "Marge",
|
||||
"on": "activé"
|
||||
},
|
||||
"background": {
|
||||
"title": "Arrière-plan",
|
||||
@@ -189,6 +192,14 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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": "再生",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。",
|
||||
"permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。",
|
||||
"cameraDisconnected": "ウェブカメラが切断されました。",
|
||||
"cameraNotFound": "カメラが見つかりません。"
|
||||
"cameraNotFound": "カメラが見つかりません。",
|
||||
"accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
"left": "左",
|
||||
"right": "右"
|
||||
}
|
||||
},
|
||||
"customScale": "カスタムズーム",
|
||||
"position": {
|
||||
"title": "フォーカス位置",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = 左端 / 上端、100 = 右端 / 下端"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -36,7 +43,8 @@
|
||||
"verticalStack": "縦並び",
|
||||
"dualFrame": "デュアルフレーム",
|
||||
"webcamShape": "カメラの形状",
|
||||
"webcamSize": "カメラのサイズ"
|
||||
"webcamSize": "カメラのサイズ",
|
||||
"noWebcam": "Webカメラなし"
|
||||
},
|
||||
"effects": {
|
||||
"title": "動画効果",
|
||||
@@ -45,7 +53,8 @@
|
||||
"off": "オフ",
|
||||
"shadow": "影",
|
||||
"roundness": "丸み",
|
||||
"padding": "余白"
|
||||
"padding": "余白",
|
||||
"on": "オン"
|
||||
},
|
||||
"background": {
|
||||
"title": "背景",
|
||||
@@ -182,6 +191,14 @@
|
||||
"errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
|
||||
"errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。"
|
||||
},
|
||||
"cursor": {
|
||||
"show": "カーソルを表示",
|
||||
"size": "サイズ",
|
||||
"smoothing": "スムージング",
|
||||
"motionBlur": "モーションブラー",
|
||||
"clickBounce": "クリックバウンス",
|
||||
"clipToBounds": "キャンバスにクリップ"
|
||||
},
|
||||
"language": {
|
||||
"title": "言語"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"minimize": "최소화",
|
||||
"reload": "새로고침",
|
||||
"forceReload": "강제 새로고침",
|
||||
"toggleDevTools": "개발자 도구 열기/닫기",
|
||||
"toggleDevTools": "개발자 도구 전환",
|
||||
"actualSize": "실제 크기",
|
||||
"zoomIn": "확대",
|
||||
"zoomOut": "축소",
|
||||
@@ -33,8 +33,8 @@
|
||||
"recordingStatus": "녹화 중: {{source}}",
|
||||
"about": "OpenScreen 정보",
|
||||
"services": "서비스",
|
||||
"hide": "OpenScreen 가리기",
|
||||
"hideOthers": "다른 항목 가리기",
|
||||
"hide": "OpenScreen 숨기기",
|
||||
"hideOthers": "다른 항목 숨기기",
|
||||
"unhide": "모두 보기"
|
||||
},
|
||||
"playback": {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.",
|
||||
"permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.",
|
||||
"cameraDisconnected": "웹캠 연결이 끊어졌습니다.",
|
||||
"cameraNotFound": "카메라를 찾을 수 없습니다."
|
||||
"cameraNotFound": "카메라를 찾을 수 없습니다.",
|
||||
"accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
"right": "오른쪽"
|
||||
}
|
||||
},
|
||||
"customScale": "사용자 지정 확대",
|
||||
"position": {
|
||||
"title": "포커스 위치",
|
||||
"title": "초점 위치",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽"
|
||||
@@ -54,7 +55,8 @@
|
||||
"on": "켜기",
|
||||
"shadow": "그림자",
|
||||
"roundness": "모서리 둥글기",
|
||||
"padding": "여백"
|
||||
"padding": "여백",
|
||||
"on": "켜짐"
|
||||
},
|
||||
"background": {
|
||||
"title": "배경",
|
||||
@@ -191,6 +193,14 @@
|
||||
"errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
|
||||
"errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요."
|
||||
},
|
||||
"cursor": {
|
||||
"show": "커서 표시",
|
||||
"size": "크기",
|
||||
"smoothing": "부드러움",
|
||||
"motionBlur": "모션 블러",
|
||||
"clickBounce": "클릭 바운스",
|
||||
"clipToBounds": "캔버스에 맞춰 자르기"
|
||||
},
|
||||
"language": {
|
||||
"title": "언어"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"cameraDenied": "Доступ к камере запрещён. Запись продолжится без веб-камеры.",
|
||||
"cameraDisconnected": "Веб-камера отключена.",
|
||||
"cameraNotFound": "Камера не найдена.",
|
||||
"permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана."
|
||||
"permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.",
|
||||
"accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,5 +39,9 @@
|
||||
"description": "Мы обнаружили {{language}} как системный язык. Хотите переключить OpenScreen на {{language}}?",
|
||||
"switch": "Переключить на {{language}}",
|
||||
"keepDefault": "Оставить текущий язык"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "Использовать редактируемый курсор",
|
||||
"useSystemCursor": "Использовать системный курсор"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
"left": "Слева",
|
||||
"right": "Справа"
|
||||
}
|
||||
},
|
||||
"customScale": "Пользовательский масштаб",
|
||||
"position": {
|
||||
"title": "Положение фокуса",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = край слева / сверху, 100 = край справа / снизу"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -36,7 +43,8 @@
|
||||
"verticalStack": "Вертикальный стек",
|
||||
"dualFrame": "Двойной кадр",
|
||||
"webcamShape": "Форма камеры",
|
||||
"webcamSize": "Размер веб-камеры"
|
||||
"webcamSize": "Размер веб-камеры",
|
||||
"noWebcam": "Без веб-камеры"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Видеоэффекты",
|
||||
@@ -183,6 +191,14 @@
|
||||
"errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
|
||||
"errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts."
|
||||
},
|
||||
"cursor": {
|
||||
"show": "Показывать курсор",
|
||||
"size": "Размер",
|
||||
"smoothing": "Сглаживание",
|
||||
"motionBlur": "Размытие движения",
|
||||
"clickBounce": "Отскок при клике",
|
||||
"clipToBounds": "Обрезать по холсту"
|
||||
},
|
||||
"language": {
|
||||
"title": "Язык"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16,6 +16,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 +43,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 +53,8 @@
|
||||
"off": "kapalı",
|
||||
"shadow": "Gölge",
|
||||
"roundness": "Yuvarlaklık",
|
||||
"padding": "Dolgu"
|
||||
"padding": "Dolgu",
|
||||
"on": "açık"
|
||||
},
|
||||
"background": {
|
||||
"title": "Arka Plan",
|
||||
@@ -182,18 +191,15 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,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 +43,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 +53,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 +62,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",
|
||||
@@ -139,7 +158,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,6 +191,14 @@
|
||||
"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ữ"
|
||||
}
|
||||
|
||||
@@ -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": "播放",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
|
||||
"cameraDisconnected": "摄像头已断开连接。",
|
||||
"cameraNotFound": "未找到摄像头。",
|
||||
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
|
||||
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。",
|
||||
"accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
"left": "左",
|
||||
"right": "右"
|
||||
}
|
||||
},
|
||||
"customScale": "自定义缩放",
|
||||
"position": {
|
||||
"title": "焦点位置",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = 最左 / 最上,100 = 最右 / 最下"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -36,7 +43,8 @@
|
||||
"verticalStack": "垂直堆叠",
|
||||
"dualFrame": "双画框",
|
||||
"webcamShape": "摄像头形状",
|
||||
"webcamSize": "摄像头大小"
|
||||
"webcamSize": "摄像头大小",
|
||||
"noWebcam": "无摄像头"
|
||||
},
|
||||
"effects": {
|
||||
"title": "视频效果",
|
||||
@@ -45,7 +53,8 @@
|
||||
"off": "关",
|
||||
"shadow": "阴影",
|
||||
"roundness": "圆角",
|
||||
"padding": "内边距"
|
||||
"padding": "内边距",
|
||||
"on": "开"
|
||||
},
|
||||
"background": {
|
||||
"title": "背景",
|
||||
@@ -182,6 +191,14 @@
|
||||
"errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
|
||||
"errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。"
|
||||
},
|
||||
"cursor": {
|
||||
"show": "显示光标",
|
||||
"size": "大小",
|
||||
"smoothing": "平滑",
|
||||
"motionBlur": "运动模糊",
|
||||
"clickBounce": "点击弹跳",
|
||||
"clipToBounds": "裁剪到画布"
|
||||
},
|
||||
"language": {
|
||||
"title": "语言"
|
||||
}
|
||||
|
||||
@@ -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": "播放",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。",
|
||||
"permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。",
|
||||
"cameraDisconnected": "網路攝影機已中斷連線。",
|
||||
"cameraNotFound": "找不到攝影機。"
|
||||
"cameraNotFound": "找不到攝影機。",
|
||||
"accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,7 @@
|
||||
"auto": "自動",
|
||||
"autoDescription": "攝影機跟隨錄製時的游標位置"
|
||||
},
|
||||
"speed": {
|
||||
"title": "縮放速度",
|
||||
"instant": "即時",
|
||||
"fast": "快速",
|
||||
"smooth": "平滑",
|
||||
"lazy": "緩慢"
|
||||
},
|
||||
"speed": {},
|
||||
"threeD": {
|
||||
"title": "3D 旋轉",
|
||||
"preset": {
|
||||
@@ -23,6 +17,13 @@
|
||||
"left": "左",
|
||||
"right": "右"
|
||||
}
|
||||
},
|
||||
"customScale": "自訂縮放",
|
||||
"position": {
|
||||
"title": "焦點位置",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = 最左 / 最上,100 = 最右 / 最下"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -43,7 +44,8 @@
|
||||
"verticalStack": "垂直堆疊",
|
||||
"dualFrame": "雙畫框",
|
||||
"webcamShape": "攝影機形狀",
|
||||
"webcamSize": "攝影機大小"
|
||||
"webcamSize": "攝影機大小",
|
||||
"noWebcam": "無網路攝影機"
|
||||
},
|
||||
"effects": {
|
||||
"title": "影片效果",
|
||||
@@ -52,7 +54,8 @@
|
||||
"off": "關",
|
||||
"shadow": "陰影",
|
||||
"roundness": "圓角",
|
||||
"padding": "內邊距"
|
||||
"padding": "內邊距",
|
||||
"on": "開"
|
||||
},
|
||||
"background": {
|
||||
"title": "背景",
|
||||
@@ -189,6 +192,14 @@
|
||||
"errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
|
||||
"errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。"
|
||||
},
|
||||
"cursor": {
|
||||
"show": "顯示游標",
|
||||
"size": "大小",
|
||||
"smoothing": "平滑",
|
||||
"motionBlur": "動態模糊",
|
||||
"clickBounce": "點擊彈跳",
|
||||
"clipToBounds": "裁切至畫布"
|
||||
},
|
||||
"language": {
|
||||
"title": "語言"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,6 +220,33 @@ const PRETTY_NATIVE_CURSOR_ASSETS: Partial<Record<NativeCursorType, PrettyNative
|
||||
},
|
||||
};
|
||||
|
||||
const TELEMETRY_CURSOR_ASSET: NativeCursorAsset = {
|
||||
id: "telemetry-arrow",
|
||||
platform: "darwin",
|
||||
imageDataUrl: arrowUrl,
|
||||
width: PRETTY_NATIVE_CURSOR_ASSETS.arrow?.width ?? 32,
|
||||
height: PRETTY_NATIVE_CURSOR_ASSETS.arrow?.height ?? 32,
|
||||
hotspotX: PRETTY_NATIVE_CURSOR_ASSETS.arrow?.hotspotX ?? 16,
|
||||
hotspotY: PRETTY_NATIVE_CURSOR_ASSETS.arrow?.hotspotY ?? 16,
|
||||
cursorType: "arrow",
|
||||
};
|
||||
|
||||
function getTelemetryCursorAsset(sample?: CursorRecordingSample): NativeCursorAsset {
|
||||
const cursorType = sample?.cursorType ?? "arrow";
|
||||
const prettyAsset = PRETTY_NATIVE_CURSOR_ASSETS[cursorType] ?? PRETTY_NATIVE_CURSOR_ASSETS.arrow;
|
||||
|
||||
return {
|
||||
...TELEMETRY_CURSOR_ASSET,
|
||||
id: `telemetry-${cursorType}`,
|
||||
imageDataUrl: prettyAsset?.imageDataUrl ?? TELEMETRY_CURSOR_ASSET.imageDataUrl,
|
||||
width: prettyAsset?.width ?? TELEMETRY_CURSOR_ASSET.width,
|
||||
height: prettyAsset?.height ?? TELEMETRY_CURSOR_ASSET.height,
|
||||
hotspotX: prettyAsset?.hotspotX ?? TELEMETRY_CURSOR_ASSET.hotspotX,
|
||||
hotspotY: prettyAsset?.hotspotY ?? TELEMETRY_CURSOR_ASSET.hotspotY,
|
||||
cursorType,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveUntypedPrettyNativeCursorAsset(asset: NativeCursorAsset) {
|
||||
if (
|
||||
asset.cursorType ||
|
||||
@@ -244,9 +271,8 @@ export function hasNativeCursorRecordingData(
|
||||
): recordingData is CursorRecordingData {
|
||||
return Boolean(
|
||||
recordingData &&
|
||||
recordingData.provider === "native" &&
|
||||
recordingData.samples.length > 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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -53,6 +53,7 @@ interface GifExporterConfig {
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorClipToBounds?: boolean;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
@@ -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,
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface VideoExporterConfig extends ExportConfig {
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorClipToBounds?: boolean;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: {},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user