mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-03 14:40:47 +08:00
Merge branch 'main' into feat/pt-BR-translation
This commit is contained in:
+14
-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/
|
||||
@@ -38,6 +46,7 @@ release/**
|
||||
test-results
|
||||
playwright-report/
|
||||
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
|
||||
@@ -49,4 +58,8 @@ result-*
|
||||
.direnv/
|
||||
|
||||
#kilocode
|
||||
.kilo/
|
||||
.kilo/
|
||||
|
||||
#others
|
||||
|
||||
**/*.import
|
||||
|
||||
@@ -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.
|
||||
@@ -111,6 +111,7 @@ Smoke-test the helper directly:
|
||||
|
||||
```powershell
|
||||
npm run test:wgc-helper:win
|
||||
npm run test:wgc-helper:win -- --capture-cursor
|
||||
npm run test:wgc-window:win
|
||||
npm run test:wgc-audio:win
|
||||
npm run test:wgc-mic:win
|
||||
|
||||
@@ -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
+87
-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,
|
||||
@@ -60,6 +81,12 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
openRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
appendRecordingChunk: (
|
||||
fileName: string,
|
||||
chunk: ArrayBuffer,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
closeRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getRecordedVideoPath: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
@@ -78,6 +105,13 @@ interface Window {
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
isNativeMacCaptureAvailable: () => Promise<{
|
||||
success: boolean;
|
||||
available: boolean;
|
||||
helperPath?: string;
|
||||
reason?: "unsupported-platform" | "missing-helper" | string;
|
||||
error?: string;
|
||||
}>;
|
||||
startNativeWindowsRecording: (
|
||||
request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest,
|
||||
) => Promise<import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingStartResult>;
|
||||
@@ -89,6 +123,45 @@ interface Window {
|
||||
discarded?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
pauseNativeWindowsRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
resumeNativeWindowsRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
startNativeMacRecording: (
|
||||
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
|
||||
) => Promise<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 +211,12 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
preparePreviewAudioTrack: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
path?: string | null;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
@@ -175,9 +254,16 @@ interface Window {
|
||||
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
getShortcuts: () => Promise<Record<string, unknown> | null>;
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||
updateGlobalShortcut: (binding: {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
}) => Promise<{ success: boolean }>;
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
|
||||
moveHudOverlayBy: (deltaX: number, deltaY: number) => void;
|
||||
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
|
||||
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
|
||||
hideCountdownOverlay: (runId: number) => Promise<void>;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { globalShortcut } from "electron";
|
||||
import { type ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import { SHORTCUTS_FILE } from "./ipc/handlers";
|
||||
|
||||
const DEFAULT_OPEN_APP_BINDING: ShortcutBinding = { key: "o", ctrl: true, shift: true };
|
||||
|
||||
// Maps KeyboardEvent.key values to Electron accelerator key names
|
||||
const KEY_TO_ACCELERATOR: Record<string, string> = {
|
||||
" ": "Space",
|
||||
"+": "Plus",
|
||||
"-": "numsub",
|
||||
"*": "nummult",
|
||||
"/": "numdiv",
|
||||
arrowup: "Up",
|
||||
arrowdown: "Down",
|
||||
arrowleft: "Left",
|
||||
arrowright: "Right",
|
||||
escape: "Escape",
|
||||
enter: "Return",
|
||||
backspace: "Backspace",
|
||||
delete: "Delete",
|
||||
tab: "Tab",
|
||||
};
|
||||
|
||||
function bindingToAccelerator(binding: ShortcutBinding): string {
|
||||
const parts: string[] = [];
|
||||
if (binding.ctrl) parts.push("CommandOrControl");
|
||||
if (binding.shift) parts.push("Shift");
|
||||
if (binding.alt) parts.push("Alt");
|
||||
|
||||
const keyLower = binding.key.toLowerCase();
|
||||
const acceleratorKey = KEY_TO_ACCELERATOR[keyLower] ?? binding.key.toUpperCase();
|
||||
parts.push(acceleratorKey);
|
||||
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
let currentAccelerator: string | null = null;
|
||||
|
||||
export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean {
|
||||
const accelerator = bindingToAccelerator(binding);
|
||||
|
||||
// Same shortcut already registered, nothing to do
|
||||
if (accelerator === currentAccelerator) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to register new shortcut first (before unregistering old one)
|
||||
const success = globalShortcut.register(accelerator, onTrigger);
|
||||
|
||||
if (success) {
|
||||
// Only unregister old shortcut after new one succeeds
|
||||
if (currentAccelerator) {
|
||||
globalShortcut.unregister(currentAccelerator);
|
||||
}
|
||||
currentAccelerator = accelerator;
|
||||
console.log(`Global shortcut registered: ${accelerator}`);
|
||||
} else {
|
||||
console.warn(`Failed to register global shortcut: ${accelerator}`);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
export async function loadAndRegisterGlobalShortcut(onTrigger: () => void): Promise<void> {
|
||||
try {
|
||||
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
|
||||
const shortcuts = JSON.parse(data);
|
||||
const binding = shortcuts.openApp || DEFAULT_OPEN_APP_BINDING;
|
||||
registerOpenAppShortcut(binding, onTrigger);
|
||||
} catch {
|
||||
registerOpenAppShortcut(DEFAULT_OPEN_APP_BINDING, onTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterAllGlobalShortcuts(): void {
|
||||
globalShortcut.unregisterAll();
|
||||
}
|
||||
+28
-8
@@ -9,10 +9,14 @@ import commonEs from "../src/i18n/locales/es/common.json";
|
||||
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
|
||||
import commonFr from "../src/i18n/locales/fr/common.json";
|
||||
import dialogsFr from "../src/i18n/locales/fr/dialogs.json";
|
||||
import commonIt from "../src/i18n/locales/it/common.json";
|
||||
import dialogsIt from "../src/i18n/locales/it/dialogs.json";
|
||||
import commonJa from "../src/i18n/locales/ja-JP/common.json";
|
||||
import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json";
|
||||
import commonKo from "../src/i18n/locales/ko-KR/common.json";
|
||||
import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json";
|
||||
import commonRu from "../src/i18n/locales/ru/common.json";
|
||||
import dialogsRu from "../src/i18n/locales/ru/dialogs.json";
|
||||
import commonTr from "../src/i18n/locales/tr/common.json";
|
||||
import dialogsTr from "../src/i18n/locales/tr/dialogs.json";
|
||||
import commonVi from "../src/i18n/locales/vi/common.json";
|
||||
@@ -22,21 +26,35 @@ import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
|
||||
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
|
||||
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
|
||||
|
||||
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi";
|
||||
type Locale =
|
||||
| "en"
|
||||
| "ar"
|
||||
| "es"
|
||||
| "fr"
|
||||
| "it"
|
||||
| "ja-JP"
|
||||
| "ko-KR"
|
||||
| "ru"
|
||||
| "tr"
|
||||
| "vi"
|
||||
| "zh-CN"
|
||||
| "zh-TW";
|
||||
type Namespace = "common" | "dialogs";
|
||||
type MessageMap = Record<string, unknown>;
|
||||
|
||||
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
|
||||
en: { common: commonEn, dialogs: dialogsEn },
|
||||
"zh-CN": { common: commonZh, dialogs: dialogsZh },
|
||||
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
|
||||
ar: { common: commonAr, dialogs: dialogsAr },
|
||||
es: { common: commonEs, dialogs: dialogsEs },
|
||||
fr: { common: commonFr, dialogs: dialogsFr },
|
||||
it: { common: commonIt, dialogs: dialogsIt },
|
||||
"ja-JP": { common: commonJa, dialogs: dialogsJa },
|
||||
"ko-KR": { common: commonKo, dialogs: dialogsKo },
|
||||
ru: { common: commonRu, dialogs: dialogsRu },
|
||||
tr: { common: commonTr, dialogs: dialogsTr },
|
||||
ar: { common: commonAr, dialogs: dialogsAr },
|
||||
vi: { common: commonVi, dialogs: dialogsVi },
|
||||
"zh-CN": { common: commonZh, dialogs: dialogsZh },
|
||||
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
|
||||
};
|
||||
|
||||
let currentLocale: Locale = "en";
|
||||
@@ -44,15 +62,17 @@ let currentLocale: Locale = "en";
|
||||
export function setMainLocale(locale: string) {
|
||||
if (
|
||||
locale === "en" ||
|
||||
locale === "zh-CN" ||
|
||||
locale === "zh-TW" ||
|
||||
locale === "ar" ||
|
||||
locale === "es" ||
|
||||
locale === "fr" ||
|
||||
locale === "it" ||
|
||||
locale === "ja-JP" ||
|
||||
locale === "ko-KR" ||
|
||||
locale === "ru" ||
|
||||
locale === "tr" ||
|
||||
locale === "ar" ||
|
||||
locale === "vi"
|
||||
locale === "vi" ||
|
||||
locale === "zh-CN" ||
|
||||
locale === "zh-TW"
|
||||
) {
|
||||
currentLocale = locale;
|
||||
}
|
||||
|
||||
+940
-30
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { RecordingStreamRegistry } from "./recordingStream";
|
||||
|
||||
describe("RecordingStreamRegistry", () => {
|
||||
let dir: string;
|
||||
const pathFor = (name: string) => path.join(dir, name);
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("streams chunks to disk in order and reports streamed on finalize", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("hello "));
|
||||
await registry.append("rec.webm", Buffer.from("world"));
|
||||
|
||||
const streamed = await registry.finalize("rec.webm");
|
||||
|
||||
expect(streamed).toBe(true);
|
||||
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world");
|
||||
// A second finalize has nothing to close.
|
||||
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports not-streamed when no stream was opened", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
expect(await registry.finalize("missing.webm")).toBe(false);
|
||||
expect(registry.has("missing.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
// Parent directory does not exist, so createWriteStream emits 'error' on open.
|
||||
await expect(
|
||||
registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")),
|
||||
).rejects.toThrow();
|
||||
// A failed open must not register a stream the renderer would treat as live.
|
||||
expect(registry.has("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects append when no stream is open", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow(
|
||||
/No active recording stream/,
|
||||
);
|
||||
});
|
||||
|
||||
it("discard closes the stream and removes the partial file", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("partial"));
|
||||
|
||||
await registry.discard("rec.webm", pathFor("rec.webm"));
|
||||
|
||||
expect(registry.has("rec.webm")).toBe(false);
|
||||
await expect(stat(pathFor("rec.webm"))).rejects.toThrow();
|
||||
// Nothing left to finalize after a discard.
|
||||
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("discard tolerates a missing file", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("opening the same file twice replaces the prior stream", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("first"));
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("second"));
|
||||
await registry.finalize("rec.webm");
|
||||
|
||||
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createWriteStream, type WriteStream } from "node:fs";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import type { IpcMain } from "electron";
|
||||
|
||||
/**
|
||||
* Owns the lifecycle of on-disk write streams for in-progress recordings, keyed
|
||||
* by the recording's output file name. Browser MediaRecorder chunks are appended
|
||||
* here as they arrive so a long recording never buffers the whole video in the
|
||||
* renderer (the #616 fix).
|
||||
*
|
||||
* The file name is the key because it is the one value the renderer and main
|
||||
* process already exchange and it is globally unique per recording, so there is
|
||||
* no derived/offset key to keep in sync across the IPC boundary.
|
||||
*/
|
||||
export class RecordingStreamRegistry {
|
||||
private readonly streams = new Map<string, WriteStream>();
|
||||
|
||||
/**
|
||||
* Open a write stream and resolve only once the OS confirms it is writable.
|
||||
* Resolving on the `open` event (rather than on `createWriteStream` returning)
|
||||
* means a bad path or permission error rejects here instead of surfacing as a
|
||||
* silent chunk drop later, so the renderer's fallback can take over.
|
||||
*/
|
||||
async open(fileName: string, filePath: string): Promise<void> {
|
||||
await this.endStream(fileName);
|
||||
|
||||
const ws = createWriteStream(filePath, { flags: "w" });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => reject(error);
|
||||
ws.once("error", onError);
|
||||
ws.once("open", () => {
|
||||
ws.removeListener("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
// Keep a listener for the stream's lifetime so a late error logs rather
|
||||
// than crashing the main process with an unhandled 'error' event. Per-write
|
||||
// failures still surface through the `append` callback below.
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[recording-stream] ${fileName}:`, error);
|
||||
});
|
||||
|
||||
this.streams.set(fileName, ws);
|
||||
}
|
||||
|
||||
has(fileName: string): boolean {
|
||||
return this.streams.has(fileName);
|
||||
}
|
||||
|
||||
/** Append a chunk; rejects if no stream is open or the write fails. */
|
||||
async append(fileName: string, chunk: Buffer): Promise<void> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
throw new Error(`No active recording stream for ${fileName}`);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.write(chunk, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush and close the stream, keeping the file. Returns whether a stream was
|
||||
* open — i.e. whether the recording was streamed to disk (true) or needs its
|
||||
* in-memory buffer written by the caller (false).
|
||||
*/
|
||||
async finalize(fileName: string): Promise<boolean> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
return false;
|
||||
}
|
||||
this.streams.delete(fileName);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.end((error?: Error | null) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the stream (if any) and delete the partial file. Used when a streamed
|
||||
* recording is discarded or fails before a successful save, so cancelled runs
|
||||
* don't leak file descriptors or orphan partial recordings on disk.
|
||||
*/
|
||||
async discard(fileName: string, filePath: string): Promise<void> {
|
||||
await this.endStream(fileName);
|
||||
await unlink(filePath).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async endStream(fileName: string): Promise<void> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
this.streams.delete(fileName);
|
||||
await new Promise<void>((resolve) => ws.end(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the streaming IPC handlers. Thin wrappers that translate the
|
||||
* registry's throw-on-failure contract into the `{ success, error }` shape the
|
||||
* renderer expects.
|
||||
*/
|
||||
export function registerRecordingStreamHandlers(
|
||||
ipcMain: IpcMain,
|
||||
registry: RecordingStreamRegistry,
|
||||
resolveRecordingOutputPath: (fileName: string) => string,
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
"open-recording-stream",
|
||||
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.open(fileName, resolveRecordingOutputPath(fileName));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"append-recording-chunk",
|
||||
async (
|
||||
_,
|
||||
fileName: string,
|
||||
chunk: ArrayBuffer,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.append(fileName, Buffer.from(chunk));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"close-recording-stream",
|
||||
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.discard(fileName, resolveRecordingOutputPath(fileName));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
+38
-14
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
@@ -12,8 +11,14 @@ import {
|
||||
systemPreferences,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import {
|
||||
loadAndRegisterGlobalShortcut,
|
||||
registerOpenAppShortcut,
|
||||
unregisterAllGlobalShortcuts,
|
||||
} from "./globalShortcut";
|
||||
import { mainT, setMainLocale } from "./i18n";
|
||||
import { registerIpcHandlers } from "./ipc/handlers";
|
||||
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
|
||||
import {
|
||||
createCountdownOverlayWindow,
|
||||
createEditorWindow,
|
||||
@@ -441,6 +446,10 @@ app.on("activate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
unregisterAllGlobalShortcuts();
|
||||
});
|
||||
|
||||
// Register all IPC handlers when app is ready
|
||||
app.whenReady().then(async () => {
|
||||
// Force the app into "regular" activation policy so the Dock icon appears.
|
||||
@@ -477,23 +486,30 @@ app.whenReady().then(async () => {
|
||||
callback(allowed.includes(permission));
|
||||
});
|
||||
|
||||
// Request microphone and screen recording permissions from macOS
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
(request, callback) => {
|
||||
const source = getSelectedDesktopSource();
|
||||
if (!request.videoRequested || !source) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
callback({
|
||||
video: source,
|
||||
...(request.audioRequested && process.platform === "win32" ? { audio: "loopback" } : {}),
|
||||
});
|
||||
},
|
||||
{ useSystemPicker: false },
|
||||
);
|
||||
|
||||
// Request microphone permission from macOS. Screen Recording is requested
|
||||
// lazily from the source-picker action so the system prompt is not hidden
|
||||
// behind OpenScreen's source selector window.
|
||||
if (process.platform === "darwin") {
|
||||
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
||||
if (micStatus !== "granted") {
|
||||
await systemPreferences.askForMediaAccess("microphone");
|
||||
}
|
||||
|
||||
// Screen recording has no askForMediaAccess equivalent — the TCC prompt is
|
||||
// triggered by the first desktopCapturer.getSources() call. Firing it here
|
||||
// at startup settles the permission state early and prevents repeated prompts
|
||||
// driven by later getSources() calls (fixes repeated permission dialog).
|
||||
const screenStatus = systemPreferences.getMediaAccessStatus("screen");
|
||||
if (screenStatus === "not-determined") {
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {
|
||||
// This only triggers the system prompt; permission state is read separately.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
@@ -506,6 +522,11 @@ app.whenReady().then(async () => {
|
||||
updateTrayMenu();
|
||||
});
|
||||
|
||||
ipcMain.handle("update-global-shortcut", (_, binding: ShortcutBinding) => {
|
||||
const success = registerOpenAppShortcut(binding, showMainWindow);
|
||||
return { success };
|
||||
});
|
||||
|
||||
createTray();
|
||||
updateTrayMenu();
|
||||
setupApplicationMenu();
|
||||
@@ -539,5 +560,8 @@ app.whenReady().then(async () => {
|
||||
},
|
||||
switchToHudWrapper,
|
||||
);
|
||||
|
||||
await loadAndRegisterGlobalShortcut(showMainWindow);
|
||||
|
||||
createWindow();
|
||||
});
|
||||
|
||||
@@ -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,391 +0,0 @@
|
||||
export function buildPowerShellScript(sampleIntervalMs: number, windowHandle?: string | null) {
|
||||
const targetWindowHandle =
|
||||
typeof windowHandle === "string" && /^(?:0x[0-9a-fA-F]+|\d+)$/.test(windowHandle)
|
||||
? `'${windowHandle}'`
|
||||
: "$null";
|
||||
const script = String.raw`
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
$targetWindowHandle = ${targetWindowHandle}
|
||||
|
||||
$source = @"
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class OpenScreenCursorInterop {
|
||||
private const int WH_MOUSE_LL = 14;
|
||||
private const int WM_LBUTTONDOWN = 0x0201;
|
||||
private const int WM_LBUTTONUP = 0x0202;
|
||||
private static readonly object MouseSync = new object();
|
||||
private static int LeftDownCount = 0;
|
||||
private static int LeftUpCount = 0;
|
||||
private static IntPtr MouseHook = IntPtr.Zero;
|
||||
private static LowLevelMouseProc MouseProcDelegate = MouseHookCallback;
|
||||
|
||||
public delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
public struct MouseButtonEvents {
|
||||
public int LeftDownCount;
|
||||
public int LeftUpCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT {
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct CURSORINFO {
|
||||
public int cbSize;
|
||||
public int flags;
|
||||
public IntPtr hCursor;
|
||||
public POINT ptScreenPos;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct ICONINFO {
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool fIcon;
|
||||
public int xHotspot;
|
||||
public int yHotspot;
|
||||
public IntPtr hbmMask;
|
||||
public IntPtr hbmColor;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT {
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
public static bool InstallMouseHook() {
|
||||
if (MouseHook != IntPtr.Zero) {
|
||||
return true;
|
||||
}
|
||||
|
||||
using (Process process = Process.GetCurrentProcess())
|
||||
using (ProcessModule module = process.MainModule) {
|
||||
MouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseProcDelegate, GetModuleHandle(module.ModuleName), 0);
|
||||
}
|
||||
|
||||
return MouseHook != IntPtr.Zero;
|
||||
}
|
||||
|
||||
public static MouseButtonEvents ConsumeMouseButtonEvents() {
|
||||
lock (MouseSync) {
|
||||
MouseButtonEvents events = new MouseButtonEvents {
|
||||
LeftDownCount = LeftDownCount,
|
||||
LeftUpCount = LeftUpCount
|
||||
};
|
||||
LeftDownCount = 0;
|
||||
LeftUpCount = 0;
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) {
|
||||
if (nCode >= 0) {
|
||||
int message = wParam.ToInt32();
|
||||
if (message == WM_LBUTTONDOWN || message == WM_LBUTTONUP) {
|
||||
lock (MouseSync) {
|
||||
if (message == WM_LBUTTONDOWN) {
|
||||
LeftDownCount += 1;
|
||||
} else {
|
||||
LeftUpCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CallNextHookEx(MouseHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetCursorInfo(ref CURSORINFO pci);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern short GetAsyncKeyState(int vKey);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool IsWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern IntPtr CopyIcon(IntPtr hIcon);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool DestroyIcon(IntPtr hIcon);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);
|
||||
|
||||
[DllImport("gdi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool DeleteObject(IntPtr hObject);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
private static extern IntPtr GetModuleHandle(string lpModuleName);
|
||||
}
|
||||
"@
|
||||
|
||||
Add-Type -TypeDefinition $source
|
||||
|
||||
$standardCursors = @{
|
||||
arrow = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32512))
|
||||
text = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32513))
|
||||
wait = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32514))
|
||||
crosshair = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32515))
|
||||
'up-arrow' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32516))
|
||||
'resize-nwse' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32642))
|
||||
'resize-nesw' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32643))
|
||||
'resize-ew' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32644))
|
||||
'resize-ns' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32645))
|
||||
move = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32646))
|
||||
'not-allowed' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32648))
|
||||
pointer = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32649))
|
||||
'app-starting' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32650))
|
||||
help = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32651))
|
||||
}
|
||||
|
||||
function Get-StandardCursorType($cursorHandle) {
|
||||
if ($cursorHandle -eq [IntPtr]::Zero) {
|
||||
return $null
|
||||
}
|
||||
|
||||
foreach ($entry in $standardCursors.GetEnumerator()) {
|
||||
if ($entry.Value -eq $cursorHandle) {
|
||||
return $entry.Key
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Write-JsonLine($payload) {
|
||||
[Console]::Out.WriteLine(($payload | ConvertTo-Json -Compress -Depth 6))
|
||||
}
|
||||
|
||||
function Get-CustomCursorType($bitmap, $hotspotX, $hotspotY) {
|
||||
if ($bitmap.Width -lt 24 -or $bitmap.Height -lt 24 -or $bitmap.Width -gt 64 -or $bitmap.Height -gt 64) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($hotspotX -lt ($bitmap.Width * 0.25) -or $hotspotX -gt ($bitmap.Width * 0.75) -or
|
||||
$hotspotY -lt ($bitmap.Height * 0.15) -or $hotspotY -gt ($bitmap.Height * 0.55)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$opaquePixels = 0
|
||||
$topHalfOpaquePixels = 0
|
||||
$left = $bitmap.Width
|
||||
$top = $bitmap.Height
|
||||
$right = -1
|
||||
$bottom = -1
|
||||
|
||||
for ($y = 0; $y -lt $bitmap.Height; $y++) {
|
||||
for ($x = 0; $x -lt $bitmap.Width; $x++) {
|
||||
if ($bitmap.GetPixel($x, $y).A -le 32) {
|
||||
continue
|
||||
}
|
||||
|
||||
$opaquePixels += 1
|
||||
if ($y -lt ($bitmap.Height / 2)) {
|
||||
$topHalfOpaquePixels += 1
|
||||
}
|
||||
if ($x -lt $left) { $left = $x }
|
||||
if ($x -gt $right) { $right = $x }
|
||||
if ($y -lt $top) { $top = $y }
|
||||
if ($y -gt $bottom) { $bottom = $y }
|
||||
}
|
||||
}
|
||||
|
||||
if ($opaquePixels -lt 90 -or $right -lt $left -or $bottom -lt $top) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$opaqueWidth = $right - $left + 1
|
||||
$opaqueHeight = $bottom - $top + 1
|
||||
if ($opaqueWidth -lt ($bitmap.Width * 0.35) -or $opaqueWidth -gt ($bitmap.Width * 0.9) -or
|
||||
$opaqueHeight -lt ($bitmap.Height * 0.45) -or $opaqueHeight -gt $bitmap.Height) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($top -gt ($bitmap.Height * 0.45) -or $bottom -lt ($bitmap.Height * 0.65)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($topHalfOpaquePixels -gt ($opaquePixels * 0.55)) {
|
||||
return 'closed-hand'
|
||||
}
|
||||
|
||||
return 'open-hand'
|
||||
}
|
||||
|
||||
function Get-TargetBounds() {
|
||||
if ([string]::IsNullOrWhiteSpace($targetWindowHandle)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$handleValue = [int64]::Parse($targetWindowHandle)
|
||||
$windowHandle = [IntPtr]::new($handleValue)
|
||||
if (-not [OpenScreenCursorInterop]::IsWindow($windowHandle)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$rect = New-Object OpenScreenCursorInterop+RECT
|
||||
if (-not [OpenScreenCursorInterop]::GetWindowRect($windowHandle, [ref]$rect)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -le 0 -or $height -le 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return @{
|
||||
x = $rect.Left
|
||||
y = $rect.Top
|
||||
width = $width
|
||||
height = $height
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CursorAsset($cursorHandle, $cursorId) {
|
||||
$copiedHandle = [OpenScreenCursorInterop]::CopyIcon($cursorHandle)
|
||||
if ($copiedHandle -eq [IntPtr]::Zero) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$iconInfo = New-Object OpenScreenCursorInterop+ICONINFO
|
||||
$hasIconInfo = [OpenScreenCursorInterop]::GetIconInfo($copiedHandle, [ref]$iconInfo)
|
||||
|
||||
try {
|
||||
$icon = [System.Drawing.Icon]::FromHandle($copiedHandle)
|
||||
$bitmap = New-Object System.Drawing.Bitmap $icon.Width, $icon.Height, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$memoryStream = New-Object System.IO.MemoryStream
|
||||
|
||||
try {
|
||||
$graphics.Clear([System.Drawing.Color]::Transparent)
|
||||
$graphics.DrawIcon($icon, 0, 0)
|
||||
$hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 }
|
||||
$hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 }
|
||||
$customCursorType = Get-CustomCursorType -bitmap $bitmap -hotspotX $hotspotX -hotspotY $hotspotY
|
||||
$bitmap.Save($memoryStream, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$base64 = [System.Convert]::ToBase64String($memoryStream.ToArray())
|
||||
|
||||
return @{
|
||||
id = $cursorId
|
||||
imageDataUrl = "data:image/png;base64,$base64"
|
||||
width = $bitmap.Width
|
||||
height = $bitmap.Height
|
||||
hotspotX = $hotspotX
|
||||
hotspotY = $hotspotY
|
||||
cursorType = $customCursorType
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$memoryStream.Dispose()
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
$icon.Dispose()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($hasIconInfo) {
|
||||
if ($iconInfo.hbmMask -ne [IntPtr]::Zero) {
|
||||
[OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmMask) | Out-Null
|
||||
}
|
||||
if ($iconInfo.hbmColor -ne [IntPtr]::Zero) {
|
||||
[OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmColor) | Out-Null
|
||||
}
|
||||
}
|
||||
[OpenScreenCursorInterop]::DestroyIcon($copiedHandle) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
[OpenScreenCursorInterop]::InstallMouseHook() | Out-Null
|
||||
[OpenScreenCursorInterop]::GetAsyncKeyState(0x01) | Out-Null
|
||||
Write-JsonLine @{ type = 'ready'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
|
||||
|
||||
$lastCursorId = $null
|
||||
while ($true) {
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
$mouseEvents = [OpenScreenCursorInterop]::ConsumeMouseButtonEvents()
|
||||
$cursorInfo = New-Object OpenScreenCursorInterop+CURSORINFO
|
||||
$cursorInfo.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type][OpenScreenCursorInterop+CURSORINFO])
|
||||
|
||||
if (-not [OpenScreenCursorInterop]::GetCursorInfo([ref]$cursorInfo)) {
|
||||
Write-JsonLine @{ type = 'error'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); message = 'GetCursorInfo failed' }
|
||||
Start-Sleep -Milliseconds ${sampleIntervalMs}
|
||||
continue
|
||||
}
|
||||
|
||||
$visible = ($cursorInfo.flags -band 1) -ne 0
|
||||
$cursorId = if ($cursorInfo.hCursor -eq [IntPtr]::Zero) { $null } else { ('0x{0:X}' -f $cursorInfo.hCursor.ToInt64()) }
|
||||
$cursorType = Get-StandardCursorType $cursorInfo.hCursor
|
||||
$leftButtonState = [OpenScreenCursorInterop]::GetAsyncKeyState(0x01)
|
||||
$leftButtonDown = ($leftButtonState -band 0x8000) -ne 0
|
||||
$leftButtonPressed = ($mouseEvents.LeftDownCount -gt 0) -or (($leftButtonState -band 0x0001) -ne 0)
|
||||
$leftButtonReleased = $mouseEvents.LeftUpCount -gt 0
|
||||
$asset = $null
|
||||
|
||||
if ($visible -and $cursorId -and $cursorId -ne $lastCursorId) {
|
||||
$asset = Get-CursorAsset -cursorHandle $cursorInfo.hCursor -cursorId $cursorId
|
||||
if ($asset -and $cursorType) {
|
||||
$asset.cursorType = $cursorType
|
||||
} elseif ($asset -and $asset.cursorType) {
|
||||
$cursorType = $asset.cursorType
|
||||
}
|
||||
$lastCursorId = $cursorId
|
||||
}
|
||||
|
||||
Write-JsonLine @{
|
||||
type = 'sample'
|
||||
timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
||||
x = $cursorInfo.ptScreenPos.X
|
||||
y = $cursorInfo.ptScreenPos.Y
|
||||
visible = $visible
|
||||
handle = $cursorId
|
||||
cursorType = $cursorType
|
||||
leftButtonDown = $leftButtonDown
|
||||
leftButtonPressed = $leftButtonPressed
|
||||
leftButtonReleased = $leftButtonReleased
|
||||
bounds = Get-TargetBounds
|
||||
asset = $asset
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds ${sampleIntervalMs}
|
||||
}
|
||||
`;
|
||||
|
||||
return script;
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import { screen } from "electron";
|
||||
import { app, screen } from "electron";
|
||||
import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording";
|
||||
import type {
|
||||
CursorRecordingData,
|
||||
@@ -12,12 +10,32 @@ import type {
|
||||
NativeCursorAsset,
|
||||
} from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
import { buildPowerShellScript } from "./windowsNativeRecordingSession.script";
|
||||
import type {
|
||||
WindowsCursorEvent,
|
||||
WindowsNativeRecordingSessionOptions,
|
||||
} from "./windowsNativeRecordingSession.types";
|
||||
|
||||
function getCursorSamplerCandidates(): string[] {
|
||||
const envPath = process.env.OPENSCREEN_CURSOR_SAMPLER_EXE?.trim();
|
||||
const archTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64";
|
||||
const resolve = (...segs: string[]) => {
|
||||
const p = join(app.getAppPath(), ...segs);
|
||||
return app.isPackaged ? p.replace(/\.asar([/\\])/, ".asar.unpacked$1") : p;
|
||||
};
|
||||
return [
|
||||
envPath,
|
||||
resolve("electron", "native", "wgc-capture", "build", "cursor-sampler.exe"),
|
||||
resolve("electron", "native", "bin", archTag, "cursor-sampler.exe"),
|
||||
].filter((c): c is string => Boolean(c));
|
||||
}
|
||||
|
||||
function findCursorSamplerPath(): string | null {
|
||||
for (const candidate of getCursorSamplerCandidates()) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const READY_TIMEOUT_MS = 5_000;
|
||||
|
||||
interface NormalizedSample {
|
||||
@@ -29,7 +47,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
private assets = new Map<string, NativeCursorAsset>();
|
||||
private samples: CursorRecordingSample[] = [];
|
||||
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
||||
private helperScriptPath: string | null = null;
|
||||
private lineBuffer = "";
|
||||
private startTimeMs = 0;
|
||||
private readyResolve: (() => void) | null = null;
|
||||
@@ -50,41 +67,26 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
this.outOfBoundsSampleCount = 0;
|
||||
this.previousLeftButtonDown = false;
|
||||
|
||||
const script = buildPowerShellScript(
|
||||
this.options.sampleIntervalMs,
|
||||
parseWindowHandleFromSourceId(this.options.sourceId),
|
||||
);
|
||||
const helperScriptDir = join(tmpdir(), "openscreen-cursor-native");
|
||||
mkdirSync(helperScriptDir, { recursive: true });
|
||||
const helperScriptPath = join(
|
||||
helperScriptDir,
|
||||
`cursor-sampler-${process.pid}-${Date.now()}-${randomUUID()}.ps1`,
|
||||
);
|
||||
writeFileSync(helperScriptPath, script, "utf8");
|
||||
this.helperScriptPath = helperScriptPath;
|
||||
const child = spawn(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
helperScriptPath,
|
||||
],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
const helperPath = findCursorSamplerPath();
|
||||
if (!helperPath) {
|
||||
throw new Error("Windows cursor sampler helper is not available.");
|
||||
}
|
||||
|
||||
const windowHandle = parseWindowHandleFromSourceId(this.options.sourceId);
|
||||
const args = [String(this.options.sampleIntervalMs)];
|
||||
if (windowHandle) args.push(windowHandle);
|
||||
|
||||
const child = spawn(helperPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.process = child;
|
||||
this.logDiagnostic("spawn", {
|
||||
pid: child.pid ?? null,
|
||||
sampleIntervalMs: this.options.sampleIntervalMs,
|
||||
sourceId: this.options.sourceId ?? null,
|
||||
windowHandle: parseWindowHandleFromSourceId(this.options.sourceId),
|
||||
windowHandle,
|
||||
});
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
@@ -100,7 +102,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
console.error("[cursor-native]", message);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
this.cleanupHelperScript(helperScriptPath);
|
||||
this.logDiagnostic("exit", {
|
||||
code,
|
||||
signal,
|
||||
@@ -113,7 +114,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
);
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
this.cleanupHelperScript(helperScriptPath);
|
||||
this.logDiagnostic("process-error", { message: error.message });
|
||||
this.rejectReady(error);
|
||||
});
|
||||
@@ -122,7 +122,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
await this.waitUntilReady();
|
||||
} catch (error) {
|
||||
this.terminateHelperProcess();
|
||||
this.cleanupHelperScript(helperScriptPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -315,25 +314,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
this.readyReject = null;
|
||||
}
|
||||
|
||||
private cleanupHelperScript(scriptPath = this.helperScriptPath) {
|
||||
if (!scriptPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(scriptPath, { force: true });
|
||||
} catch (error) {
|
||||
this.logDiagnostic("script-cleanup-error", {
|
||||
path: scriptPath,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
if (this.helperScriptPath === scriptPath) {
|
||||
this.helperScriptPath = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private logDiagnostic(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[cursor-native][win32]",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,3 +49,19 @@ target_link_libraries(wgc-capture PRIVATE
|
||||
runtimeobject
|
||||
windowsapp
|
||||
)
|
||||
|
||||
add_executable(cursor-sampler
|
||||
src/cursor-sampler.cpp
|
||||
)
|
||||
|
||||
target_compile_definitions(cursor-sampler PRIVATE
|
||||
NOMINMAX
|
||||
_WIN32_WINNT=0x0A00
|
||||
)
|
||||
|
||||
target_compile_options(cursor-sampler PRIVATE /EHsc /W4 /utf-8)
|
||||
|
||||
target_link_libraries(cursor-sampler PRIVATE
|
||||
gdi32
|
||||
gdiplus
|
||||
)
|
||||
|
||||
@@ -279,6 +279,7 @@ bool AudioMixer::start() {
|
||||
stopRequested_ = false;
|
||||
emittedFrames_ = 0;
|
||||
timelineStarted_ = false;
|
||||
paused_ = false;
|
||||
thread_ = std::thread([this] {
|
||||
mixLoop();
|
||||
});
|
||||
@@ -296,6 +297,18 @@ void AudioMixer::beginTimeline() {
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::setPaused(bool paused) {
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
paused_ = paused;
|
||||
if (paused_) {
|
||||
systemQueue_.clear();
|
||||
microphoneQueue_.clear();
|
||||
}
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::stop() {
|
||||
stopRequested_ = true;
|
||||
cv_.notify_all();
|
||||
@@ -311,6 +324,9 @@ void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) {
|
||||
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (paused_) {
|
||||
return;
|
||||
}
|
||||
append(systemQueue_, data, byteCount, systemFormat_, 1.0);
|
||||
}
|
||||
cv_.notify_all();
|
||||
@@ -323,6 +339,9 @@ void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) {
|
||||
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (paused_) {
|
||||
return;
|
||||
}
|
||||
append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_);
|
||||
}
|
||||
cv_.notify_all();
|
||||
@@ -371,13 +390,13 @@ void AudioMixer::mixLoop() {
|
||||
const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes;
|
||||
const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty();
|
||||
return stopRequested_.load() ||
|
||||
(timelineStarted_ && (hasSystem || hasMicrophone) && hasAnySource);
|
||||
(timelineStarted_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource);
|
||||
});
|
||||
|
||||
if (stopRequested_) {
|
||||
break;
|
||||
}
|
||||
if (!timelineStarted_) {
|
||||
if (!timelineStarted_ || paused_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ public:
|
||||
|
||||
bool start();
|
||||
void beginTimeline();
|
||||
void setPaused(bool paused);
|
||||
void stop();
|
||||
void pushSystem(const BYTE* data, DWORD byteCount);
|
||||
void pushMicrophone(const BYTE* data, DWORD byteCount);
|
||||
@@ -81,5 +82,6 @@ private:
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
bool timelineStarted_ = false;
|
||||
bool paused_ = false;
|
||||
uint64_t emittedFrames_ = 0;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
#include <windows.h>
|
||||
#include <gdiplus.h>
|
||||
#include <objbase.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cinttypes>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Global mouse-hook state
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static HHOOK g_mouseHook = nullptr;
|
||||
static DWORD g_mainThreadId = 0;
|
||||
static std::atomic<int> g_leftDownCount{0};
|
||||
static std::atomic<int> g_leftUpCount{0};
|
||||
static std::atomic<bool> g_stop{false};
|
||||
static std::mutex g_stdoutMtx;
|
||||
|
||||
static LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) {
|
||||
if (nCode >= 0) {
|
||||
if (wParam == WM_LBUTTONDOWN) g_leftDownCount.fetch_add(1, std::memory_order_relaxed);
|
||||
else if (wParam == WM_LBUTTONUP) g_leftUpCount.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
return CallNextHookEx(g_mouseHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static int64_t nowMs() {
|
||||
return static_cast<int64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count());
|
||||
}
|
||||
|
||||
static void writeJsonLine(const std::string& json) {
|
||||
std::lock_guard<std::mutex> lock(g_stdoutMtx);
|
||||
std::cout << json << '\n';
|
||||
std::cout.flush();
|
||||
}
|
||||
|
||||
static std::string jsonEscape(const std::string& s) {
|
||||
std::string r;
|
||||
r.reserve(s.size());
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': r += "\\\""; break;
|
||||
case '\\': r += "\\\\"; break;
|
||||
case '\n': r += "\\n"; break;
|
||||
case '\r': r += "\\r"; break;
|
||||
case '\t': r += "\\t"; break;
|
||||
default: r.push_back(static_cast<char>(c)); break;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static const char kBase64Chars[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
static std::string base64Encode(const uint8_t* data, size_t len) {
|
||||
std::string out;
|
||||
out.reserve(((len + 2) / 3) * 4);
|
||||
for (size_t i = 0; i < len; i += 3) {
|
||||
const uint32_t b =
|
||||
(static_cast<uint32_t>(data[i]) << 16) |
|
||||
(i + 1 < len ? static_cast<uint32_t>(data[i + 1]) << 8 : 0u) |
|
||||
(i + 2 < len ? static_cast<uint32_t>(data[i + 2]) : 0u);
|
||||
out.push_back(kBase64Chars[(b >> 18) & 0x3F]);
|
||||
out.push_back(kBase64Chars[(b >> 12) & 0x3F]);
|
||||
out.push_back(i + 1 < len ? kBase64Chars[(b >> 6) & 0x3F] : '=');
|
||||
out.push_back(i + 2 < len ? kBase64Chars[(b ) & 0x3F] : '=');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GDI+ PNG encoder CLSID
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static bool getPngClsid(CLSID& out) {
|
||||
UINT num = 0, sz = 0;
|
||||
if (Gdiplus::GetImageEncodersSize(&num, &sz) != Gdiplus::Ok || sz == 0) return false;
|
||||
std::vector<uint8_t> buf(sz);
|
||||
auto* enc = reinterpret_cast<Gdiplus::ImageCodecInfo*>(buf.data());
|
||||
if (Gdiplus::GetImageEncoders(num, sz, enc) != Gdiplus::Ok) return false;
|
||||
for (UINT i = 0; i < num; ++i) {
|
||||
if (std::wstring(enc[i].MimeType) == L"image/png") {
|
||||
out = enc[i].Clsid;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Standard cursor-type lookup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static const char* standardCursorType(HCURSOR hc) {
|
||||
if (!hc) return nullptr;
|
||||
static const struct { WORD id; const char* name; } kMap[] = {
|
||||
{32512, "arrow"},
|
||||
{32513, "text"},
|
||||
{32514, "wait"},
|
||||
{32515, "crosshair"},
|
||||
{32516, "up-arrow"},
|
||||
{32642, "resize-nwse"},
|
||||
{32643, "resize-nesw"},
|
||||
{32644, "resize-ew"},
|
||||
{32645, "resize-ns"},
|
||||
{32646, "move"},
|
||||
{32648, "not-allowed"},
|
||||
{32649, "pointer"},
|
||||
{32650, "app-starting"},
|
||||
{32651, "help"},
|
||||
};
|
||||
static constexpr int N = static_cast<int>(sizeof(kMap) / sizeof(kMap[0]));
|
||||
static HCURSOR g_handles[N] = {};
|
||||
static bool g_init = false;
|
||||
if (!g_init) {
|
||||
for (int i = 0; i < N; ++i)
|
||||
g_handles[i] = LoadCursor(nullptr, MAKEINTRESOURCE(kMap[i].id));
|
||||
g_init = true;
|
||||
}
|
||||
for (int i = 0; i < N; ++i)
|
||||
if (g_handles[i] && g_handles[i] == hc) return kMap[i].name;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Custom cursor-type detection (replicates the PowerShell heuristic)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static const char* detectCustomCursorType(
|
||||
const uint32_t* pixels, int w, int h, int hotX, int hotY)
|
||||
{
|
||||
if (w < 24 || h < 24 || w > 64 || h > 64) return nullptr;
|
||||
if (hotX < w * 0.25 || hotX > w * 0.75) return nullptr;
|
||||
if (hotY < h * 0.15 || hotY > h * 0.55) return nullptr;
|
||||
|
||||
int opaque = 0, topHalf = 0;
|
||||
int left = w, top = h, right = -1, bottom = -1;
|
||||
|
||||
for (int y = 0; y < h; ++y) {
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const uint8_t a = static_cast<uint8_t>(pixels[y * w + x] >> 24);
|
||||
if (a <= 32) continue;
|
||||
++opaque;
|
||||
if (y < h / 2) ++topHalf;
|
||||
if (x < left) left = x;
|
||||
if (x > right) right = x;
|
||||
if (y < top) top = y;
|
||||
if (y > bottom) bottom = y;
|
||||
}
|
||||
}
|
||||
|
||||
if (opaque < 90 || right < left || bottom < top) return nullptr;
|
||||
|
||||
const int ow = right - left + 1;
|
||||
const int oh = bottom - top + 1;
|
||||
if (ow < w * 0.35 || ow > w * 0.9) return nullptr;
|
||||
if (oh < h * 0.45 || oh > static_cast<double>(h)) return nullptr;
|
||||
if (top > h * 0.45 || bottom < h * 0.65) return nullptr;
|
||||
|
||||
return topHalf > opaque * 0.55 ? "closed-hand" : "open-hand";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Build asset JSON for the given cursor (returns empty string on failure)
|
||||
//
|
||||
// Renders the cursor via GDI DrawIconEx onto a 32-bpp transparent DIB section
|
||||
// and then encodes to PNG — matching the PowerShell approach of
|
||||
// Graphics.Clear(Transparent) + Graphics.DrawIcon(). This correctly preserves
|
||||
// per-pixel alpha for 32-bit cursors, unlike Gdiplus::Bitmap::FromHICON which
|
||||
// can produce incorrect alpha for cursor handles.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static std::string buildAssetJson(
|
||||
HCURSOR hCursor,
|
||||
const std::string& handleStr,
|
||||
const CLSID& pngClsid,
|
||||
const char** outCustomType)
|
||||
{
|
||||
*outCustomType = nullptr;
|
||||
|
||||
// Get hotspot and cursor dimensions from the icon info.
|
||||
// For color cursors hbmColor gives the size; for monochrome cursors the
|
||||
// mask bitmap is twice the cursor height (AND mask stacked on XOR mask).
|
||||
ICONINFO ii{};
|
||||
if (!GetIconInfo(hCursor, &ii)) return {};
|
||||
const int hotX = static_cast<int>(ii.xHotspot);
|
||||
const int hotY = static_cast<int>(ii.yHotspot);
|
||||
|
||||
int w = 0, h = 0;
|
||||
if (ii.hbmColor) {
|
||||
BITMAP bm{};
|
||||
if (GetObject(ii.hbmColor, sizeof(bm), &bm)) { w = bm.bmWidth; h = bm.bmHeight; }
|
||||
}
|
||||
if (ii.hbmMask && (w == 0 || h == 0)) {
|
||||
BITMAP bm{};
|
||||
if (GetObject(ii.hbmMask, sizeof(bm), &bm)) {
|
||||
w = bm.bmWidth;
|
||||
h = ii.hbmColor ? bm.bmHeight : bm.bmHeight / 2;
|
||||
}
|
||||
}
|
||||
if (ii.hbmMask) DeleteObject(ii.hbmMask);
|
||||
if (ii.hbmColor) DeleteObject(ii.hbmColor);
|
||||
if (w <= 0 || h <= 0) return {};
|
||||
|
||||
// Copy the cursor handle so DrawIconEx cannot affect the live system cursor.
|
||||
const HICON hCopy = CopyIcon(hCursor);
|
||||
if (!hCopy) return {};
|
||||
|
||||
// Allocate a 32-bpp top-down DIB section and clear it to transparent black,
|
||||
// then draw the cursor with DI_NORMAL. For 32-bit alpha cursors Windows
|
||||
// writes correct per-pixel alpha into the high byte of each BGRA pixel.
|
||||
const int stride = w * 4;
|
||||
BITMAPINFOHEADER bih{};
|
||||
bih.biSize = sizeof(bih);
|
||||
bih.biWidth = w;
|
||||
bih.biHeight = -h; // negative = top-down scanline order
|
||||
bih.biPlanes = 1;
|
||||
bih.biBitCount = 32;
|
||||
bih.biCompression = BI_RGB;
|
||||
|
||||
void* pBits = nullptr;
|
||||
HDC hDC = CreateCompatibleDC(nullptr);
|
||||
HBITMAP hBmp = hDC ? CreateDIBSection(hDC,
|
||||
reinterpret_cast<const BITMAPINFO*>(&bih),
|
||||
DIB_RGB_COLORS, &pBits, nullptr, 0)
|
||||
: nullptr;
|
||||
|
||||
if (!hBmp || !pBits) {
|
||||
if (hBmp) DeleteObject(hBmp);
|
||||
if (hDC) DeleteDC(hDC);
|
||||
DestroyIcon(hCopy);
|
||||
return {};
|
||||
}
|
||||
|
||||
HGDIOBJ hOld = SelectObject(hDC, hBmp);
|
||||
std::memset(pBits, 0, static_cast<size_t>(stride * h)); // transparent black
|
||||
DrawIconEx(hDC, 0, 0, hCopy, w, h, 0, nullptr, DI_NORMAL);
|
||||
GdiFlush();
|
||||
SelectObject(hDC, hOld);
|
||||
DeleteDC(hDC);
|
||||
DestroyIcon(hCopy);
|
||||
|
||||
// GDI's 32-bit DIB stores pixels as BGRA in memory. GDI+'s
|
||||
// PixelFormat32bppARGB interprets each 32-bit word as 0xAARRGGBB which is
|
||||
// identical to BGRA on little-endian, so the alpha byte is always >> 24.
|
||||
{
|
||||
const auto* px = static_cast<const uint32_t*>(pBits);
|
||||
*outCustomType = detectCustomCursorType(px, w, h, hotX, hotY);
|
||||
}
|
||||
|
||||
// Wrap the DIB pixels in a GDI+ Bitmap (zero-copy) and save to PNG.
|
||||
// Keep hBmp alive until after gBmp is destroyed so pBits remains valid.
|
||||
std::vector<uint8_t> pngData;
|
||||
{
|
||||
Gdiplus::Bitmap gBmp(w, h, stride, PixelFormat32bppARGB,
|
||||
static_cast<BYTE*>(pBits));
|
||||
if (gBmp.GetLastStatus() == Gdiplus::Ok) {
|
||||
IStream* pStream = nullptr;
|
||||
if (SUCCEEDED(CreateStreamOnHGlobal(nullptr, TRUE, &pStream))) {
|
||||
if (gBmp.Save(pStream, &pngClsid) == Gdiplus::Ok) {
|
||||
ULARGE_INTEGER sz{};
|
||||
LARGE_INTEGER zero{};
|
||||
pStream->Seek(zero, STREAM_SEEK_END, &sz);
|
||||
pStream->Seek(zero, STREAM_SEEK_SET, nullptr);
|
||||
pngData.resize(static_cast<size_t>(sz.QuadPart));
|
||||
ULONG n = 0;
|
||||
pStream->Read(pngData.data(), static_cast<ULONG>(pngData.size()), &n);
|
||||
pngData.resize(n);
|
||||
}
|
||||
pStream->Release();
|
||||
}
|
||||
}
|
||||
} // gBmp destroyed here; pBits (owned by hBmp) still valid
|
||||
DeleteObject(hBmp);
|
||||
|
||||
if (pngData.empty()) return {};
|
||||
|
||||
const std::string dataUrl =
|
||||
"data:image/png;base64," + base64Encode(pngData.data(), pngData.size());
|
||||
|
||||
std::string json;
|
||||
json.reserve(dataUrl.size() + 128);
|
||||
json = "{\"id\":\"" + handleStr + "\"";
|
||||
json += ",\"imageDataUrl\":\"" + jsonEscape(dataUrl) + "\"";
|
||||
json += ",\"width\":" + std::to_string(w);
|
||||
json += ",\"height\":" + std::to_string(h);
|
||||
json += ",\"hotspotX\":" + std::to_string(hotX);
|
||||
json += ",\"hotspotY\":" + std::to_string(hotY);
|
||||
if (*outCustomType) {
|
||||
json += ",\"cursorType\":\"";
|
||||
json += *outCustomType;
|
||||
json += "\"";
|
||||
} else {
|
||||
json += ",\"cursorType\":null";
|
||||
}
|
||||
json += "}";
|
||||
return json;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sampling loop (background thread)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngClsid) {
|
||||
HCURSOR lastCursor = nullptr;
|
||||
|
||||
while (!g_stop.load(std::memory_order_relaxed)) {
|
||||
const int downCount = g_leftDownCount.exchange(0, std::memory_order_relaxed);
|
||||
const int upCount = g_leftUpCount.exchange(0, std::memory_order_relaxed);
|
||||
|
||||
CURSORINFO ci{};
|
||||
ci.cbSize = sizeof(ci);
|
||||
if (!GetCursorInfo(&ci)) {
|
||||
char buf[160];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"{\"type\":\"error\",\"timestampMs\":%" PRId64 ",\"message\":\"GetCursorInfo failed\"}",
|
||||
nowMs());
|
||||
writeJsonLine(buf);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool visible = (ci.flags & CURSOR_SHOWING) != 0;
|
||||
const HCURSOR hc = ci.hCursor;
|
||||
|
||||
// Handle string ("0xHEX" or empty for null cursor)
|
||||
char handleBuf[32] = {};
|
||||
if (hc)
|
||||
std::snprintf(handleBuf, sizeof(handleBuf),
|
||||
"0x%" PRIX64, static_cast<uint64_t>(reinterpret_cast<uintptr_t>(hc)));
|
||||
const std::string handleStr = hc ? handleBuf : "";
|
||||
|
||||
// Standard cursor type
|
||||
const char* cursorType = standardCursorType(hc);
|
||||
|
||||
// Mouse button state
|
||||
const SHORT ks = GetAsyncKeyState(VK_LBUTTON);
|
||||
const bool leftDown = (ks & 0x8000) != 0;
|
||||
const bool leftPressed = downCount > 0 || (ks & 0x0001) != 0;
|
||||
const bool leftReleased = upCount > 0;
|
||||
|
||||
// Asset — only when the cursor handle changes
|
||||
std::string assetJson;
|
||||
if (visible && hc && hc != lastCursor) {
|
||||
const char* customType = nullptr;
|
||||
assetJson = buildAssetJson(hc, handleStr, pngClsid, &customType);
|
||||
if (!assetJson.empty() && !cursorType && customType)
|
||||
cursorType = customType;
|
||||
lastCursor = hc;
|
||||
}
|
||||
|
||||
// Window bounds
|
||||
std::string boundsJson = "null";
|
||||
if (targetWindow && IsWindow(targetWindow)) {
|
||||
RECT r{};
|
||||
if (GetWindowRect(targetWindow, &r)) {
|
||||
const int bw = r.right - r.left;
|
||||
const int bh = r.bottom - r.top;
|
||||
if (bw > 0 && bh > 0) {
|
||||
char buf[128];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}",
|
||||
r.left, r.top, bw, bh);
|
||||
boundsJson = buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit sample JSON
|
||||
std::string out;
|
||||
out.reserve(256);
|
||||
out += "{\"type\":\"sample\"";
|
||||
out += ",\"timestampMs\":"; out += std::to_string(nowMs());
|
||||
out += ",\"x\":"; out += std::to_string(ci.ptScreenPos.x);
|
||||
out += ",\"y\":"; out += std::to_string(ci.ptScreenPos.y);
|
||||
out += ",\"visible\":"; out += visible ? "true" : "false";
|
||||
out += ",\"handle\":"; out += hc ? ("\"" + handleStr + "\"") : "null";
|
||||
out += ",\"cursorType\":"; out += cursorType ? ("\"" + std::string(cursorType) + "\"") : "null";
|
||||
out += ",\"leftButtonDown\":"; out += leftDown ? "true" : "false";
|
||||
out += ",\"leftButtonPressed\":"; out += leftPressed ? "true" : "false";
|
||||
out += ",\"leftButtonReleased\":"; out += leftReleased ? "true" : "false";
|
||||
out += ",\"bounds\":"; out += boundsJson;
|
||||
out += ",\"asset\":"; out += assetJson.empty() ? "null" : assetJson;
|
||||
out += "}";
|
||||
|
||||
writeJsonLine(out);
|
||||
|
||||
// Exit if stdout pipe is broken (parent process died)
|
||||
if (std::cout.fail()) {
|
||||
PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0);
|
||||
break;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// main
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 2) {
|
||||
std::cerr << "Usage: cursor-sampler <intervalMs> [windowHandle]" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const int intervalMs = std::max(1, std::atoi(argv[1]));
|
||||
|
||||
HWND targetWindow = nullptr;
|
||||
if (argc >= 3) {
|
||||
const std::string arg = argv[2];
|
||||
if (!arg.empty() && arg != "null") {
|
||||
try {
|
||||
const int base = (arg.rfind("0x", 0) == 0 || arg.rfind("0X", 0) == 0) ? 16 : 10;
|
||||
const uint64_t v = std::stoull(arg, nullptr, base);
|
||||
if (v) targetWindow = reinterpret_cast<HWND>(static_cast<uintptr_t>(v));
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize GDI+
|
||||
Gdiplus::GdiplusStartupInput gdipInput{};
|
||||
ULONG_PTR gdipToken = 0;
|
||||
if (Gdiplus::GdiplusStartup(&gdipToken, &gdipInput, nullptr) != Gdiplus::Ok) {
|
||||
std::cerr << "GDI+ init failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
CLSID pngClsid{};
|
||||
if (!getPngClsid(pngClsid)) {
|
||||
std::cerr << "PNG encoder not found" << std::endl;
|
||||
Gdiplus::GdiplusShutdown(gdipToken);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Install global low-level mouse hook on this thread
|
||||
g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, GetModuleHandle(nullptr), 0);
|
||||
if (!g_mouseHook) {
|
||||
std::cerr << "SetWindowsHookEx failed" << std::endl;
|
||||
Gdiplus::GdiplusShutdown(gdipToken);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Prime GetAsyncKeyState so the first poll doesn't return stale "since-last-call" bits
|
||||
GetAsyncKeyState(VK_LBUTTON);
|
||||
|
||||
// Signal readiness
|
||||
g_mainThreadId = GetCurrentThreadId();
|
||||
{
|
||||
char buf[80];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"{\"type\":\"ready\",\"timestampMs\":%" PRId64 "}", nowMs());
|
||||
writeJsonLine(buf);
|
||||
}
|
||||
|
||||
// Start sampling on a background thread
|
||||
std::thread sampler(runSamplingLoop, intervalMs, targetWindow, std::cref(pngClsid));
|
||||
|
||||
// Run the message pump on the main thread — required for WH_MOUSE_LL callbacks
|
||||
MSG msg;
|
||||
while (GetMessage(&msg, nullptr, 0, 0) > 0) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
g_stop.store(true, std::memory_order_relaxed);
|
||||
if (sampler.joinable()) sampler.join();
|
||||
UnhookWindowsHookEx(g_mouseHook);
|
||||
Gdiplus::GdiplusShutdown(gdipToken);
|
||||
return 0;
|
||||
}
|
||||
@@ -5,22 +5,18 @@
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <exception>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
|
||||
namespace {
|
||||
|
||||
const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
|
||||
const CLSID CLSID_NullRendererLocal = {0xC1F400A4, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
|
||||
|
||||
MIDL_INTERFACE("0579154A-2B53-4994-B0D0-E773148EFF85")
|
||||
ISampleGrabberCB : public IUnknown {
|
||||
public:
|
||||
virtual HRESULT STDMETHODCALLTYPE SampleCB(double sampleTime, IMediaSample* sample) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE BufferCB(double sampleTime, BYTE* buffer, long bufferLength) = 0;
|
||||
};
|
||||
|
||||
MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F")
|
||||
ISampleGrabber : public IUnknown {
|
||||
public:
|
||||
@@ -30,7 +26,7 @@ public:
|
||||
virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetCallback(ISampleGrabberCB* callback, long whichMethodToCallback) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetCallback(IUnknown* callback, long whichMethodToCallback) = 0;
|
||||
};
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
@@ -43,6 +39,34 @@ bool succeeded(HRESULT hr, const char* label) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string guidToString(const GUID& guid) {
|
||||
if (guid == MEDIASUBTYPE_RGB32) {
|
||||
return "RGB32";
|
||||
}
|
||||
if (guid == MEDIASUBTYPE_YUY2) {
|
||||
return "YUY2";
|
||||
}
|
||||
if (guid == MEDIASUBTYPE_NV12) {
|
||||
return "NV12";
|
||||
}
|
||||
|
||||
std::ostringstream stream;
|
||||
stream << std::hex << std::setfill('0')
|
||||
<< '{' << std::setw(8) << guid.Data1
|
||||
<< '-' << std::setw(4) << guid.Data2
|
||||
<< '-' << std::setw(4) << guid.Data3
|
||||
<< '-';
|
||||
for (int index = 0; index < 2; index += 1) {
|
||||
stream << std::setw(2) << static_cast<int>(guid.Data4[index]);
|
||||
}
|
||||
stream << '-';
|
||||
for (int index = 2; index < 8; index += 1) {
|
||||
stream << std::setw(2) << static_cast<int>(guid.Data4[index]);
|
||||
}
|
||||
stream << '}';
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
void freeMediaType(AM_MEDIA_TYPE& type) {
|
||||
if (type.cbFormat != 0) {
|
||||
CoTaskMemFree(type.pbFormat);
|
||||
@@ -55,6 +79,20 @@ void freeMediaType(AM_MEDIA_TYPE& type) {
|
||||
}
|
||||
}
|
||||
|
||||
BYTE clampToByte(int value) {
|
||||
return static_cast<BYTE>(std::clamp(value, 0, 255));
|
||||
}
|
||||
|
||||
std::array<BYTE, 3> yuvToBgr(int y, int u, int v) {
|
||||
const int c = y - 16;
|
||||
const int d = u - 128;
|
||||
const int e = v - 128;
|
||||
const int blue = (298 * c + 516 * d + 128) >> 8;
|
||||
const int green = (298 * c - 100 * d - 208 * e + 128) >> 8;
|
||||
const int red = (298 * c + 409 * e + 128) >> 8;
|
||||
return {clampToByte(blue), clampToByte(green), clampToByte(red)};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct DirectShowWebcamCapture::Impl {
|
||||
@@ -137,9 +175,8 @@ bool DirectShowWebcamCapture::initialize(
|
||||
|
||||
AM_MEDIA_TYPE requestedType{};
|
||||
requestedType.majortype = MEDIATYPE_Video;
|
||||
requestedType.subtype = MEDIASUBTYPE_RGB32;
|
||||
requestedType.formattype = FORMAT_VideoInfo;
|
||||
if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow RGB32)")) {
|
||||
if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow video)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -170,17 +207,40 @@ bool DirectShowWebcamCapture::initialize(
|
||||
if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
if (connectedType.subtype == MEDIASUBTYPE_YUY2) {
|
||||
pixelFormat_ = PixelFormat::Yuy2;
|
||||
} else if (connectedType.subtype == MEDIASUBTYPE_NV12) {
|
||||
pixelFormat_ = PixelFormat::Nv12;
|
||||
} else if (connectedType.subtype == MEDIASUBTYPE_RGB32) {
|
||||
pixelFormat_ = PixelFormat::Bgra;
|
||||
} else {
|
||||
std::cerr << "ERROR: Unsupported DirectShow webcam media subtype "
|
||||
<< guidToString(connectedType.subtype) << std::endl;
|
||||
freeMediaType(connectedType);
|
||||
return false;
|
||||
}
|
||||
if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) {
|
||||
const auto* videoInfo = reinterpret_cast<VIDEOINFOHEADER*>(connectedType.pbFormat);
|
||||
width_ = std::abs(videoInfo->bmiHeader.biWidth);
|
||||
height_ = std::abs(videoInfo->bmiHeader.biHeight);
|
||||
sourceTopDown_ = videoInfo->bmiHeader.biHeight < 0;
|
||||
const int bitsPerPixel = videoInfo->bmiHeader.biBitCount > 0 ? videoInfo->bmiHeader.biBitCount : 16;
|
||||
if (pixelFormat_ == PixelFormat::Nv12) {
|
||||
sourceStride_ = ((width_ + 3) / 4) * 4;
|
||||
} else {
|
||||
sourceStride_ = ((width_ * bitsPerPixel + 31) / 32) * 4;
|
||||
}
|
||||
sourceTopDown_ = pixelFormat_ != PixelFormat::Bgra || videoInfo->bmiHeader.biHeight < 0;
|
||||
}
|
||||
std::cerr << "INFO: DirectShow webcam connected subtype " << guidToString(connectedType.subtype)
|
||||
<< " " << width_ << "x" << height_ << " stride=" << sourceStride_ << std::endl;
|
||||
freeMediaType(connectedType);
|
||||
if (width_ <= 0 || height_ <= 0) {
|
||||
width_ = requestedWidth > 0 ? requestedWidth : 1280;
|
||||
height_ = requestedHeight > 0 ? requestedHeight : 720;
|
||||
}
|
||||
if (sourceStride_ <= 0) {
|
||||
sourceStride_ = pixelFormat_ == PixelFormat::Bgra ? width_ * 4 : ((width_ + 3) / 4) * 4;
|
||||
}
|
||||
|
||||
impl_->sampleGrabber->SetBufferSamples(TRUE);
|
||||
impl_->sampleGrabber->SetOneShot(FALSE);
|
||||
@@ -262,36 +322,91 @@ void DirectShowWebcamCapture::captureLoop() {
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) {
|
||||
const int stride = width_ * 4;
|
||||
const int expectedLength = stride * height_;
|
||||
const int destinationStride = width_ * 4;
|
||||
const int sourceStride = sourceStride_ > 0 ? sourceStride_ : destinationStride;
|
||||
const int expectedLength = pixelFormat_ == PixelFormat::Nv12
|
||||
? sourceStride * height_ + sourceStride * ((height_ + 1) / 2)
|
||||
: sourceStride * height_;
|
||||
if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<BYTE> frame(static_cast<size_t>(expectedLength));
|
||||
std::vector<BYTE> frame(static_cast<size_t>(destinationStride * height_));
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
const int sourceY = sourceTopDown_ ? y : height_ - 1 - y;
|
||||
const BYTE* source = buffer + sourceY * stride;
|
||||
BYTE* destination = frame.data() + y * stride;
|
||||
std::copy(source, source + stride, destination);
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
destination[x * 4 + 3] = 255;
|
||||
const BYTE* source = buffer + sourceY * sourceStride;
|
||||
BYTE* destination = frame.data() + y * destinationStride;
|
||||
if (pixelFormat_ == PixelFormat::Bgra) {
|
||||
std::copy(source, source + destinationStride, destination);
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
destination[x * 4 + 3] = 255;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pixelFormat_ == PixelFormat::Nv12) {
|
||||
const BYTE* yPlane = buffer + sourceY * sourceStride;
|
||||
const BYTE* uvPlane = buffer + sourceStride * height_ + (sourceY / 2) * sourceStride;
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
const int uvX = (x / 2) * 2;
|
||||
const auto color = yuvToBgr(yPlane[x], uvPlane[uvX], uvPlane[uvX + 1]);
|
||||
BYTE* pixel = destination + x * 4;
|
||||
pixel[0] = color[0];
|
||||
pixel[1] = color[1];
|
||||
pixel[2] = color[2];
|
||||
pixel[3] = 255;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int x = 0; x + 1 < width_; x += 2) {
|
||||
const BYTE y0 = source[x * 2];
|
||||
const BYTE u = source[x * 2 + 1];
|
||||
const BYTE y1 = source[x * 2 + 2];
|
||||
const BYTE v = source[x * 2 + 3];
|
||||
const auto first = yuvToBgr(y0, u, v);
|
||||
const auto second = yuvToBgr(y1, u, v);
|
||||
BYTE* firstPixel = destination + x * 4;
|
||||
BYTE* secondPixel = firstPixel + 4;
|
||||
firstPixel[0] = first[0];
|
||||
firstPixel[1] = first[1];
|
||||
firstPixel[2] = first[2];
|
||||
firstPixel[3] = 255;
|
||||
secondPixel[0] = second[0];
|
||||
secondPixel[1] = second[1];
|
||||
secondPixel[2] = second[2];
|
||||
secondPixel[3] = 255;
|
||||
}
|
||||
if (width_ % 2 == 1) {
|
||||
const int x = width_ - 1;
|
||||
const int previousPairStart = ((x - 1) / 2) * 4;
|
||||
const BYTE y = source[x * 2];
|
||||
const BYTE u = source[previousPairStart + 1];
|
||||
const BYTE v = source[previousPairStart + 3];
|
||||
const auto color = yuvToBgr(y, u, v);
|
||||
BYTE* pixel = destination + x * 4;
|
||||
pixel[0] = color[0];
|
||||
pixel[1] = color[1];
|
||||
pixel[2] = color[2];
|
||||
pixel[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
latestFrame_ = std::move(frame);
|
||||
latestFrameSequence_ += 1;
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height) {
|
||||
bool DirectShowWebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) {
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination = latestFrame_;
|
||||
width = width_;
|
||||
height = height_;
|
||||
destination.data = latestFrame_;
|
||||
destination.width = width_;
|
||||
destination.height = height_;
|
||||
destination.sequence = latestFrameSequence_;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
#include <Windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
struct WebcamFrameSnapshot {
|
||||
std::vector<BYTE> data;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
uint64_t sequence = 0;
|
||||
};
|
||||
|
||||
class DirectShowWebcamCapture {
|
||||
public:
|
||||
DirectShowWebcamCapture() = default;
|
||||
@@ -25,7 +33,7 @@ public:
|
||||
int requestedFps);
|
||||
bool start();
|
||||
void stop();
|
||||
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height);
|
||||
bool copyLatestFrame(WebcamFrameSnapshot& destination);
|
||||
|
||||
int width() const;
|
||||
int height() const;
|
||||
@@ -34,6 +42,12 @@ public:
|
||||
void storeFrame(const BYTE* buffer, long length);
|
||||
|
||||
private:
|
||||
enum class PixelFormat {
|
||||
Bgra,
|
||||
Nv12,
|
||||
Yuy2,
|
||||
};
|
||||
|
||||
struct Impl;
|
||||
void captureLoop();
|
||||
|
||||
@@ -42,9 +56,12 @@ private:
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::mutex frameMutex_;
|
||||
std::vector<BYTE> latestFrame_;
|
||||
uint64_t latestFrameSequence_ = 0;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
int sourceStride_ = 0;
|
||||
bool sourceTopDown_ = false;
|
||||
PixelFormat pixelFormat_ = PixelFormat::Bgra;
|
||||
std::wstring selectedDeviceName_;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <condition_variable>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -29,6 +30,7 @@ struct CaptureConfig {
|
||||
std::string sourceId;
|
||||
std::string windowHandle;
|
||||
std::string outputPath;
|
||||
std::string webcamOutputPath;
|
||||
int fps = 60;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
@@ -49,6 +51,37 @@ struct CaptureConfig {
|
||||
int webcamFps = 0;
|
||||
};
|
||||
|
||||
struct CaptureControl {
|
||||
std::atomic<bool> stopRequested = false;
|
||||
std::atomic<bool> paused = false;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::chrono::steady_clock::time_point pauseStartedAt;
|
||||
std::chrono::steady_clock::duration totalPausedDuration{};
|
||||
|
||||
int64_t pausedDurationHns() {
|
||||
std::scoped_lock lock(mutex);
|
||||
auto total = totalPausedDuration;
|
||||
if (paused.load()) {
|
||||
total += std::chrono::steady_clock::now() - pauseStartedAt;
|
||||
}
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(total).count() / 100;
|
||||
}
|
||||
|
||||
void setPaused(bool nextPaused) {
|
||||
std::scoped_lock lock(mutex);
|
||||
if (nextPaused == paused.load()) {
|
||||
return;
|
||||
}
|
||||
if (nextPaused) {
|
||||
pauseStartedAt = std::chrono::steady_clock::now();
|
||||
} else {
|
||||
totalPausedDuration += std::chrono::steady_clock::now() - pauseStartedAt;
|
||||
}
|
||||
paused = nextPaused;
|
||||
}
|
||||
};
|
||||
|
||||
std::wstring utf8ToWide(const std::string& value) {
|
||||
if (value.empty()) {
|
||||
return {};
|
||||
@@ -311,23 +344,38 @@ bool parseConfig(const std::string& json, CaptureConfig& config) {
|
||||
config.webcamDeviceId = findString(json, "webcamDeviceId");
|
||||
config.webcamDeviceName = findString(json, "webcamDeviceName");
|
||||
config.webcamDirectShowClsid = findString(json, "webcamDirectShowClsid");
|
||||
config.webcamOutputPath = findString(json, "webcamPath");
|
||||
config.webcamWidth = findInt(json, "webcamWidth", 0);
|
||||
config.webcamHeight = findInt(json, "webcamHeight", 0);
|
||||
config.webcamFps = findInt(json, "webcamFps", 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
void readStopCommands(std::atomic<bool>& stopRequested, std::condition_variable& cv) {
|
||||
void readCaptureCommands(CaptureControl& control, const std::function<void(bool)>& onPauseChanged) {
|
||||
std::string line;
|
||||
while (std::getline(std::cin, line)) {
|
||||
if (line == "stop" || line == "q" || line == "quit") {
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (line == "pause") {
|
||||
control.setPaused(true);
|
||||
onPauseChanged(true);
|
||||
std::cout << "{\"event\":\"recording-paused\",\"schemaVersion\":2}" << std::endl;
|
||||
control.cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
if (line == "resume") {
|
||||
control.setPaused(false);
|
||||
onPauseChanged(false);
|
||||
std::cout << "{\"event\":\"recording-resumed\",\"schemaVersion\":2}" << std::endl;
|
||||
control.cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -389,6 +437,7 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
WebcamCapture webcamCapture;
|
||||
bool webcamActive = false;
|
||||
bool writeSeparateWebcam = false;
|
||||
if (config.webcamEnabled) {
|
||||
if (!webcamCapture.initialize(
|
||||
utf8ToWide(config.webcamDeviceId),
|
||||
@@ -405,6 +454,7 @@ int main(int argc, char* argv[]) {
|
||||
<< ",\"fps\":" << webcamCapture.fps()
|
||||
<< ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName()))
|
||||
<< "\"}" << std::endl;
|
||||
writeSeparateWebcam = !config.webcamOutputPath.empty();
|
||||
}
|
||||
|
||||
WasapiLoopbackCapture loopbackCapture;
|
||||
@@ -466,9 +516,26 @@ int main(int argc, char* argv[]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
MFEncoder webcamEncoder;
|
||||
if (writeSeparateWebcam) {
|
||||
const int webcamPixels = std::max(1, webcamCapture.width()) * std::max(1, webcamCapture.height());
|
||||
const int webcamBitrate = webcamPixels >= 1280 * 720 ? 8'000'000 : 4'000'000;
|
||||
if (!webcamEncoder.initialize(
|
||||
utf8ToWide(config.webcamOutputPath),
|
||||
webcamCapture.width(),
|
||||
webcamCapture.height(),
|
||||
webcamCapture.fps(),
|
||||
webcamBitrate,
|
||||
session.device(),
|
||||
session.context(),
|
||||
nullptr)) {
|
||||
std::cerr << "ERROR: Failed to initialize native webcam encoder" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::atomic<bool> stopRequested = false;
|
||||
CaptureControl control;
|
||||
std::atomic<bool> firstFrameWritten = false;
|
||||
std::atomic<bool> encodeFailed = false;
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> latestFrameTexture;
|
||||
@@ -477,10 +544,11 @@ int main(int argc, char* argv[]) {
|
||||
std::vector<BYTE> latestWebcamFrame;
|
||||
int latestWebcamWidth = 0;
|
||||
int latestWebcamHeight = 0;
|
||||
uint64_t latestWebcamSequence = 0;
|
||||
bool hasVisibleWebcamFrame = false;
|
||||
|
||||
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
|
||||
if (stopRequested) {
|
||||
if (control.stopRequested || control.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -493,8 +561,8 @@ int main(int argc, char* argv[]) {
|
||||
desc.MiscFlags = 0;
|
||||
if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -502,27 +570,38 @@ int main(int argc, char* argv[]) {
|
||||
session.context()->CopyResource(latestFrameTexture.Get(), texture);
|
||||
latestFrameTimestampHns = timestampHns;
|
||||
if (!firstFrameWritten.exchange(true)) {
|
||||
cv.notify_all();
|
||||
control.cv.notify_all();
|
||||
}
|
||||
});
|
||||
|
||||
auto writeVideoFrames = [&]() {
|
||||
const auto startedAt = std::chrono::steady_clock::now();
|
||||
const auto frameDuration = std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(1.0 / config.fps));
|
||||
uint64_t frameIndex = 0;
|
||||
uint64_t lastWrittenWebcamSequence = 0;
|
||||
uint64_t webcamOutputFrameIndex = 0;
|
||||
int64_t lastEncodedVideoTimestampHns = -1;
|
||||
|
||||
while (!stopRequested && !encodeFailed) {
|
||||
while (!control.stopRequested && !encodeFailed) {
|
||||
{
|
||||
std::scoped_lock lock(mutex);
|
||||
std::unique_lock lock(mutex);
|
||||
control.cv.wait(lock, [&] {
|
||||
return control.stopRequested.load() ||
|
||||
encodeFailed.load() ||
|
||||
(!control.paused.load() && latestFrameTexture);
|
||||
});
|
||||
if (control.stopRequested || encodeFailed) {
|
||||
break;
|
||||
}
|
||||
if (webcamActive) {
|
||||
std::vector<BYTE> candidateWebcamFrame;
|
||||
int candidateWebcamWidth = 0;
|
||||
int candidateWebcamHeight = 0;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame);
|
||||
latestWebcamWidth = candidateWebcamWidth;
|
||||
latestWebcamHeight = candidateWebcamHeight;
|
||||
WebcamFrameSnapshot candidateWebcamFrame;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
|
||||
candidateWebcamFrame.sequence != latestWebcamSequence &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame.data)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame.data);
|
||||
latestWebcamWidth = candidateWebcamFrame.width;
|
||||
latestWebcamHeight = candidateWebcamFrame.height;
|
||||
latestWebcamSequence = candidateWebcamFrame.sequence;
|
||||
hasVisibleWebcamFrame = true;
|
||||
}
|
||||
}
|
||||
@@ -539,19 +618,34 @@ int main(int argc, char* argv[]) {
|
||||
firstFrameTimestampHns = sourceTimestampHns;
|
||||
}
|
||||
int64_t frameTimestampHns =
|
||||
std::max<int64_t>(0, sourceTimestampHns - firstFrameTimestampHns);
|
||||
std::max<int64_t>(
|
||||
0,
|
||||
sourceTimestampHns - firstFrameTimestampHns - control.pausedDurationHns());
|
||||
if (lastEncodedVideoTimestampHns >= 0 &&
|
||||
frameTimestampHns <= lastEncodedVideoTimestampHns) {
|
||||
frameTimestampHns =
|
||||
lastEncodedVideoTimestampHns + static_cast<int64_t>(10'000'000ULL / config.fps);
|
||||
}
|
||||
if (writeSeparateWebcam && webcamFrame.data &&
|
||||
latestWebcamSequence != lastWrittenWebcamSequence) {
|
||||
const int64_t webcamTimestampHns = static_cast<int64_t>(
|
||||
(webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps()));
|
||||
if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
return;
|
||||
}
|
||||
lastWrittenWebcamSequence = latestWebcamSequence;
|
||||
webcamOutputFrameIndex += 1;
|
||||
}
|
||||
if (latestFrameTexture && !encoder.writeFrame(
|
||||
latestFrameTexture.Get(),
|
||||
frameTimestampHns,
|
||||
webcamFrame.data ? &webcamFrame : nullptr)) {
|
||||
!writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (latestFrameTexture) {
|
||||
@@ -560,10 +654,7 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
|
||||
frameIndex += 1;
|
||||
const auto nextDeadline = startedAt +
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(static_cast<double>(frameIndex) / config.fps));
|
||||
std::this_thread::sleep_until(nextDeadline);
|
||||
std::this_thread::sleep_for(frameDuration);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -595,8 +686,8 @@ int main(int argc, char* argv[]) {
|
||||
[&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) {
|
||||
encodeFailed = true;
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -611,7 +702,7 @@ int main(int argc, char* argv[]) {
|
||||
if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
(void)timestampHns;
|
||||
(void)durationHns;
|
||||
if (stopRequested || !audioMixer) {
|
||||
if (control.stopRequested || !audioMixer) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -627,7 +718,7 @@ int main(int argc, char* argv[]) {
|
||||
if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
(void)timestampHns;
|
||||
(void)durationHns;
|
||||
if (stopRequested || !audioMixer) {
|
||||
if (control.stopRequested || !audioMixer) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -659,14 +750,13 @@ int main(int argc, char* argv[]) {
|
||||
webcamActive = true;
|
||||
const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||
while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) {
|
||||
std::vector<BYTE> candidateWebcamFrame;
|
||||
int candidateWebcamWidth = 0;
|
||||
int candidateWebcamHeight = 0;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame);
|
||||
latestWebcamWidth = candidateWebcamWidth;
|
||||
latestWebcamHeight = candidateWebcamHeight;
|
||||
WebcamFrameSnapshot candidateWebcamFrame;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame.data)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame.data);
|
||||
latestWebcamWidth = candidateWebcamFrame.width;
|
||||
latestWebcamHeight = candidateWebcamFrame.height;
|
||||
latestWebcamSequence = candidateWebcamFrame.sequence;
|
||||
hasVisibleWebcamFrame = true;
|
||||
break;
|
||||
}
|
||||
@@ -689,16 +779,20 @@ int main(int argc, char* argv[]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv));
|
||||
std::thread stdinThread(readCaptureCommands, std::ref(control), [&](bool isPaused) {
|
||||
if (audioMixer) {
|
||||
audioMixer->setPaused(isPaused);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] {
|
||||
return firstFrameWritten.load() || stopRequested.load();
|
||||
const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] {
|
||||
return firstFrameWritten.load() || control.stopRequested.load();
|
||||
});
|
||||
if (!started || !firstFrameWritten) {
|
||||
stopRequested = true;
|
||||
cv.notify_all();
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
if (stdinThread.joinable()) {
|
||||
stdinThread.detach();
|
||||
}
|
||||
@@ -724,8 +818,8 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
cv.wait(lock, [&] {
|
||||
return stopRequested.load();
|
||||
control.cv.wait(lock, [&] {
|
||||
return control.stopRequested.load();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -740,6 +834,9 @@ int main(int argc, char* argv[]) {
|
||||
{
|
||||
std::scoped_lock lock(mutex);
|
||||
encoder.finalize();
|
||||
if (writeSeparateWebcam) {
|
||||
webcamEncoder.finalize();
|
||||
}
|
||||
}
|
||||
|
||||
if (stdinThread.joinable()) {
|
||||
@@ -752,7 +849,11 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
|
||||
std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\""
|
||||
<< jsonEscape(config.outputPath) << "\"}" << std::endl;
|
||||
<< jsonEscape(config.outputPath) << "\"";
|
||||
if (writeSeparateWebcam) {
|
||||
std::cout << ",\"webcamPath\":\"" << jsonEscape(config.webcamOutputPath) << "\"";
|
||||
}
|
||||
std::cout << "}" << std::endl;
|
||||
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -254,6 +254,45 @@ bool MFEncoder::copyFrameToBuffer(
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize) {
|
||||
if (!frame.data || frame.width <= 0 || frame.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
|
||||
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
|
||||
if (destinationSize < requiredBytes) {
|
||||
std::cerr << "ERROR: Media Foundation webcam buffer is too small" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frame.width == width_ && frame.height == height_) {
|
||||
for (DWORD i = 0; i < requiredBytes; i += 4) {
|
||||
destination[i] = frame.data[i];
|
||||
destination[i + 1] = frame.data[i + 1];
|
||||
destination[i + 2] = frame.data[i + 2];
|
||||
destination[i + 3] = 255;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
const int sourceY = static_cast<int>((static_cast<int64_t>(y) * frame.height) / height_);
|
||||
BYTE* destinationRow = destination + rowBytes * y;
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
const int sourceX = static_cast<int>((static_cast<int64_t>(x) * frame.width) / width_);
|
||||
const BYTE* source = frame.data + (sourceY * frame.width + sourceX) * 4;
|
||||
BYTE* target = destinationRow + x * 4;
|
||||
target[0] = source[0];
|
||||
target[1] = source[1];
|
||||
target[2] = source[2];
|
||||
target[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_) {
|
||||
@@ -302,6 +341,54 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const
|
||||
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample");
|
||||
}
|
||||
|
||||
bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstTimestampHns_ < 0) {
|
||||
firstTimestampHns_ = timestampHns;
|
||||
}
|
||||
|
||||
int64_t sampleTime = timestampHns - firstTimestampHns_;
|
||||
if (sampleTime <= lastTimestampHns_) {
|
||||
sampleTime = lastTimestampHns_ + (10'000'000LL / fps_);
|
||||
}
|
||||
const int64_t sampleDuration = 10'000'000LL / fps_;
|
||||
lastTimestampHns_ = sampleTime;
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
const DWORD frameBytes = static_cast<DWORD>(width_ * height_ * 4);
|
||||
if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BYTE* data = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (!succeeded(buffer->Lock(&data, &maxLength, ¤tLength), "IMFMediaBuffer::Lock(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool copied = copyBgraFrameToBuffer(frame, data, maxLength);
|
||||
buffer->Unlock();
|
||||
if (!copied) {
|
||||
return false;
|
||||
}
|
||||
buffer->SetCurrentLength(frameBytes);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
sample->AddBuffer(buffer.Get());
|
||||
sample->SetSampleTime(sampleTime);
|
||||
sample->SetSampleDuration(sampleDuration);
|
||||
|
||||
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)");
|
||||
}
|
||||
|
||||
bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_ || !hasAudioStream_) {
|
||||
|
||||
@@ -44,6 +44,7 @@ public:
|
||||
ID3D11DeviceContext* context,
|
||||
const AudioInputFormat* audioFormat = nullptr);
|
||||
bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr);
|
||||
bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns);
|
||||
bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns);
|
||||
bool finalize();
|
||||
|
||||
@@ -54,6 +55,7 @@ private:
|
||||
BYTE* destination,
|
||||
DWORD destinationSize,
|
||||
const BgraFrameView* webcamFrame);
|
||||
bool copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize);
|
||||
bool configureAudioStream(const AudioInputFormat& audioFormat);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSinkWriter> sinkWriter_;
|
||||
|
||||
@@ -365,6 +365,7 @@ void WebcamCapture::captureLoop() {
|
||||
if (currentLength >= expectedLength && expectedLength > 0) {
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
latestFrame_.assign(data, data + expectedLength);
|
||||
latestFrameSequence_ += 1;
|
||||
}
|
||||
|
||||
buffer->Unlock();
|
||||
@@ -373,18 +374,19 @@ void WebcamCapture::captureLoop() {
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
bool WebcamCapture::copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height) {
|
||||
bool WebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.copyLatestFrame(destination, width, height);
|
||||
return directShowCapture_.copyLatestFrame(destination);
|
||||
}
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination = latestFrame_;
|
||||
width = width_;
|
||||
height = height_;
|
||||
destination.data = latestFrame_;
|
||||
destination.width = width_;
|
||||
destination.height = height_;
|
||||
destination.sequence = latestFrameSequence_;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public:
|
||||
int requestedFps);
|
||||
bool start();
|
||||
void stop();
|
||||
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height);
|
||||
bool copyLatestFrame(WebcamFrameSnapshot& destination);
|
||||
|
||||
int width() const;
|
||||
int height() const;
|
||||
@@ -50,6 +50,7 @@ private:
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::mutex frameMutex_;
|
||||
std::vector<BYTE> latestFrame_;
|
||||
uint64_t latestFrameSequence_ = 0;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
|
||||
@@ -140,11 +140,41 @@ bool WgcSession::createCaptureItem(HWND window) {
|
||||
return width_ > 0 && height_ > 0;
|
||||
}
|
||||
|
||||
void WgcSession::applySessionOptions(bool captureCursor) {
|
||||
bool WgcSession::applySessionOptions(bool captureCursor) {
|
||||
captureCursor_ = captureCursor;
|
||||
|
||||
try {
|
||||
session_.IsCursorCaptureEnabled(captureCursor);
|
||||
auto session2 = session_.try_as<wgcap::IGraphicsCaptureSession2>();
|
||||
if (!session2) {
|
||||
if (!captureCursor) {
|
||||
std::cerr << "ERROR: WGC cursor suppression is not supported by this Windows runtime"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
session2.IsCursorCaptureEnabled(captureCursor);
|
||||
const bool appliedCursorCapture = session2.IsCursorCaptureEnabled();
|
||||
std::cout << "{\"event\":\"cursor-capture\",\"schemaVersion\":2,\"requested\":"
|
||||
<< (captureCursor ? "true" : "false")
|
||||
<< ",\"applied\":" << (appliedCursorCapture ? "true" : "false") << "}"
|
||||
<< std::endl;
|
||||
|
||||
if (appliedCursorCapture != captureCursor) {
|
||||
std::cerr << "ERROR: WGC cursor capture setting did not apply" << std::endl;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (winrt::hresult_error const& error) {
|
||||
std::cerr << "ERROR: Failed to configure WGC cursor capture (hr=0x" << std::hex
|
||||
<< static_cast<uint32_t>(error.code()) << std::dec << ")" << std::endl;
|
||||
if (!captureCursor) {
|
||||
return false;
|
||||
}
|
||||
} catch (...) {
|
||||
// Older WGC builds can omit this property. They will keep the OS default.
|
||||
std::cerr << "ERROR: Failed to configure WGC cursor capture" << std::endl;
|
||||
if (!captureCursor) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -152,6 +182,8 @@ void WgcSession::applySessionOptions(bool captureCursor) {
|
||||
} catch (...) {
|
||||
// IsBorderRequired is Windows 11-only. Ignore it on older builds.
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) {
|
||||
@@ -170,7 +202,9 @@ bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) {
|
||||
item_.Size());
|
||||
session_ = framePool_.CreateCaptureSession(item_);
|
||||
|
||||
applySessionOptions(captureCursor);
|
||||
if (!applySessionOptions(captureCursor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
|
||||
return true;
|
||||
@@ -192,7 +226,9 @@ bool WgcSession::initialize(HWND window, int fps, bool captureCursor) {
|
||||
item_.Size());
|
||||
session_ = framePool_.CreateCaptureSession(item_);
|
||||
|
||||
applySessionOptions(captureCursor);
|
||||
if (!applySessionOptions(captureCursor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
|
||||
return true;
|
||||
@@ -207,6 +243,9 @@ bool WgcSession::start() {
|
||||
if (!session_) {
|
||||
return false;
|
||||
}
|
||||
if (!applySessionOptions(captureCursor_)) {
|
||||
return false;
|
||||
}
|
||||
session_.StartCapture();
|
||||
started_ = true;
|
||||
return true;
|
||||
|
||||
@@ -37,7 +37,7 @@ private:
|
||||
bool createD3DDevice();
|
||||
bool createCaptureItem(HMONITOR monitor);
|
||||
bool createCaptureItem(HWND window);
|
||||
void applySessionOptions(bool captureCursor);
|
||||
bool applySessionOptions(bool captureCursor);
|
||||
void onFrameArrived(
|
||||
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
|
||||
winrt::Windows::Foundation::IInspectable const&);
|
||||
@@ -54,5 +54,6 @@ private:
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 60;
|
||||
bool captureCursor_ = false;
|
||||
bool started_ = false;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
|
||||
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
||||
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||
import type { ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
||||
|
||||
// Asset base URL is passed from the main process via webPreferences.additionalArguments
|
||||
@@ -24,6 +26,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
|
||||
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
|
||||
},
|
||||
moveHudOverlayBy: (deltaX: number, deltaY: number) => {
|
||||
ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY);
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke("get-sources", opts);
|
||||
},
|
||||
@@ -48,12 +53,27 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
requestCameraAccess: () => {
|
||||
return ipcRenderer.invoke("request-camera-access");
|
||||
},
|
||||
requestScreenAccess: () => {
|
||||
return ipcRenderer.invoke("request-screen-access");
|
||||
},
|
||||
requestNativeMacCursorAccess: () => {
|
||||
return ipcRenderer.invoke("request-native-mac-cursor-access");
|
||||
},
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
||||
return ipcRenderer.invoke("store-recorded-session", payload);
|
||||
},
|
||||
openRecordingStream: (fileName: string) => {
|
||||
return ipcRenderer.invoke("open-recording-stream", fileName);
|
||||
},
|
||||
appendRecordingChunk: (fileName: string, chunk: ArrayBuffer) => {
|
||||
return ipcRenderer.invoke("append-recording-chunk", fileName, chunk);
|
||||
},
|
||||
closeRecordingStream: (fileName: string) => {
|
||||
return ipcRenderer.invoke("close-recording-stream", fileName);
|
||||
},
|
||||
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
@@ -68,12 +88,41 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
isNativeWindowsCaptureAvailable: () => {
|
||||
return ipcRenderer.invoke("is-native-windows-capture-available");
|
||||
},
|
||||
isNativeMacCaptureAvailable: () => {
|
||||
return ipcRenderer.invoke("is-native-mac-capture-available");
|
||||
},
|
||||
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
|
||||
return ipcRenderer.invoke("start-native-windows-recording", request);
|
||||
},
|
||||
stopNativeWindowsRecording: (discard?: boolean) => {
|
||||
return ipcRenderer.invoke("stop-native-windows-recording", discard);
|
||||
},
|
||||
pauseNativeWindowsRecording: () => {
|
||||
return ipcRenderer.invoke("pause-native-windows-recording");
|
||||
},
|
||||
resumeNativeWindowsRecording: () => {
|
||||
return ipcRenderer.invoke("resume-native-windows-recording");
|
||||
},
|
||||
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
|
||||
return ipcRenderer.invoke("start-native-mac-recording", request);
|
||||
},
|
||||
pauseNativeMacRecording: () => {
|
||||
return ipcRenderer.invoke("pause-native-mac-recording");
|
||||
},
|
||||
resumeNativeMacRecording: () => {
|
||||
return ipcRenderer.invoke("resume-native-mac-recording");
|
||||
},
|
||||
stopNativeMacRecording: (discard?: boolean) => {
|
||||
return ipcRenderer.invoke("stop-native-mac-recording", discard);
|
||||
},
|
||||
attachNativeMacWebcamRecording: (payload: {
|
||||
screenVideoPath: string;
|
||||
recordingId: number;
|
||||
webcam: { fileName: string; videoData: ArrayBuffer };
|
||||
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
|
||||
}) => {
|
||||
return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload);
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||
},
|
||||
@@ -112,6 +161,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
readBinaryFile: (filePath: string) => {
|
||||
return ipcRenderer.invoke("read-binary-file", filePath);
|
||||
},
|
||||
preparePreviewAudioTrack: (filePath: string) => {
|
||||
return ipcRenderer.invoke("prepare-preview-audio-track", filePath);
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("clear-current-video-path");
|
||||
},
|
||||
@@ -151,6 +203,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
saveShortcuts: (shortcuts: unknown) => {
|
||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||
},
|
||||
updateGlobalShortcut: (binding: ShortcutBinding) => {
|
||||
return ipcRenderer.invoke("update-global-shortcut", binding);
|
||||
},
|
||||
setLocale: (locale: string) => {
|
||||
return ipcRenderer.invoke("set-locale", locale);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { WebmFile } from "@fix-webm-duration/parser";
|
||||
|
||||
export type DurationPatchResult =
|
||||
| { patched: true }
|
||||
| { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" };
|
||||
|
||||
/**
|
||||
* Patch the WebM Duration header on a finalized recording file.
|
||||
*
|
||||
* Browser MediaRecorder writes WebM with no Duration EBML element. With the
|
||||
* streaming-to-disk path the renderer never holds the blob, so the historical
|
||||
* `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after
|
||||
* `WriteStream.end()` produces an equivalent result: the editor's seek bar and
|
||||
* timeline read a real duration instead of `N/A`.
|
||||
*
|
||||
* Atomic by design: writes the patched bytes to `<filePath>.duration-patch.tmp`
|
||||
* and renames in place. If the process crashes mid-rewrite, the original file
|
||||
* survives intact, so the user never loses their recording to a partial write.
|
||||
*
|
||||
* Best-effort by intent: any failure (read, parse, write) logs and returns a
|
||||
* non-`patched` result rather than throwing. The file is still playable without
|
||||
* the patch (decoders walk frames sequentially); the only cost is that the
|
||||
* editor's seek bar and timeline break until it is patched.
|
||||
*
|
||||
* Memory: reads the whole file into a main-process Buffer, the same footprint
|
||||
* as the pre-streaming renderer path, just on the side without V8's heap cap.
|
||||
*/
|
||||
export async function patchWebmDurationOnDisk(
|
||||
filePath: string,
|
||||
durationMs: number,
|
||||
): Promise<DurationPatchResult> {
|
||||
try {
|
||||
const fileBytes = await fs.readFile(filePath);
|
||||
const webm = new WebmFile(new Uint8Array(fileBytes));
|
||||
|
||||
const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
|
||||
if (!patched) {
|
||||
// fixParsedWebmDuration returns false for: missing Segment, missing
|
||||
// Info, or a Duration that is already valid. The first two mean a
|
||||
// malformed (most likely truncated) file; the third is a no-op.
|
||||
const reason = inferUnpatchedReason(webm);
|
||||
if (reason === "no-section") {
|
||||
console.warn(
|
||||
`[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`,
|
||||
);
|
||||
}
|
||||
return { patched: false, reason };
|
||||
}
|
||||
|
||||
if (!webm.source) {
|
||||
console.error(`[webm-duration] patched but source missing for ${filePath}`);
|
||||
return { patched: false, reason: "internal" };
|
||||
}
|
||||
|
||||
const tmpPath = `${filePath}.duration-patch.tmp`;
|
||||
const patchedBytes = Buffer.from(
|
||||
webm.source.buffer,
|
||||
webm.source.byteOffset,
|
||||
webm.source.byteLength,
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(tmpPath, patchedBytes);
|
||||
await fs.rename(tmpPath, filePath);
|
||||
return { patched: true };
|
||||
} catch (writeError) {
|
||||
console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError);
|
||||
// Best-effort cleanup of the temp file; if unlink also fails, leave it.
|
||||
// The original recording is untouched because the rename never ran.
|
||||
await fs.unlink(tmpPath).catch(() => undefined);
|
||||
return { patched: false, reason: "io-error" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[webm-duration] failed to patch ${filePath}:`, error);
|
||||
return { patched: false, reason: "io-error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish "no Segment/Info section" (malformed/truncated file) from "Info
|
||||
* present but Duration already valid" (patch unnecessary).
|
||||
*
|
||||
* The IDs are the length-descriptor-stripped form that @fix-webm-duration/parser
|
||||
* uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified
|
||||
* against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML
|
||||
* IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would
|
||||
* never match.
|
||||
*/
|
||||
function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" {
|
||||
const segment = webm.getSectionById?.(0x8538067);
|
||||
if (!segment) return "no-section";
|
||||
const info = (
|
||||
segment as unknown as { getSectionById?: (id: number) => unknown }
|
||||
).getSectionById?.(0x549a966);
|
||||
return info ? "already-valid" : "no-section";
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -37,6 +37,21 @@ function findVcVarsAll() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findWindowsSdkUmLibDir() {
|
||||
const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib";
|
||||
if (!fs.existsSync(sdkLibRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(sdkLibRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64"))
|
||||
.filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib")))
|
||||
.sort()
|
||||
.at(-1);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
@@ -64,6 +79,8 @@ async function runInVsEnv(command) {
|
||||
);
|
||||
}
|
||||
|
||||
const sdkUmLibDir = findWindowsSdkUmLibDir();
|
||||
|
||||
const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`);
|
||||
fs.writeFileSync(
|
||||
cmdPath,
|
||||
@@ -72,9 +89,9 @@ async function runInVsEnv(command) {
|
||||
`call "${vcvarsAll}" x64`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`,
|
||||
`for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
|
||||
`for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`set "LIB=${COMPAT_LIB_DIR};%LIB%"`,
|
||||
`set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`,
|
||||
command,
|
||||
"exit /b %errorlevel%",
|
||||
"",
|
||||
@@ -104,9 +121,19 @@ if (!fs.existsSync(outputPath)) {
|
||||
throw new Error(`WGC helper build completed but ${outputPath} was not found.`);
|
||||
}
|
||||
|
||||
const cursorSamplerOutputPath = path.join(BUILD_DIR, "cursor-sampler.exe");
|
||||
if (!fs.existsSync(cursorSamplerOutputPath)) {
|
||||
throw new Error(`WGC helper build completed but ${cursorSamplerOutputPath} was not found.`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
const distributablePath = path.join(BIN_DIR, "wgc-capture.exe");
|
||||
fs.copyFileSync(outputPath, distributablePath);
|
||||
|
||||
const cursorSamplerDistributablePath = path.join(BIN_DIR, "cursor-sampler.exe");
|
||||
fs.copyFileSync(cursorSamplerOutputPath, cursorSamplerDistributablePath);
|
||||
|
||||
console.log(`Built ${outputPath}`);
|
||||
console.log(`Copied ${distributablePath}`);
|
||||
console.log(`Built ${cursorSamplerOutputPath}`);
|
||||
console.log(`Copied ${cursorSamplerDistributablePath}`);
|
||||
|
||||
@@ -23,6 +23,9 @@ const WITH_WINDOW =
|
||||
process.env.OPENSCREEN_WGC_TEST_WINDOW === "true" || process.argv.includes("--window");
|
||||
const WITH_WEBCAM =
|
||||
process.env.OPENSCREEN_WGC_TEST_WEBCAM === "true" || process.argv.includes("--webcam");
|
||||
const CAPTURE_CURSOR =
|
||||
process.env.OPENSCREEN_WGC_TEST_CAPTURE_CURSOR === "true" ||
|
||||
process.argv.includes("--capture-cursor");
|
||||
|
||||
function runHelper(config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -227,6 +230,7 @@ const outputPath = path.join(
|
||||
os.tmpdir(),
|
||||
`openscreen-wgc-helper-${WITH_WEBCAM ? "webcam" : WITH_WINDOW ? "window" : WITH_SYSTEM_AUDIO || WITH_MICROPHONE ? "audio" : "video"}-${process.pid}-${Date.now()}-${randomUUID()}.mp4`,
|
||||
);
|
||||
const webcamOutputPath = WITH_WEBCAM ? outputPath.replace(/\.mp4$/i, "-webcam.mp4") : null;
|
||||
|
||||
const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null;
|
||||
|
||||
@@ -247,6 +251,7 @@ const config = {
|
||||
hasDisplayBounds: true,
|
||||
captureSystemAudio: WITH_SYSTEM_AUDIO,
|
||||
captureMic: WITH_MICROPHONE,
|
||||
captureCursor: CAPTURE_CURSOR,
|
||||
microphoneDeviceId: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_ID ?? "default",
|
||||
microphoneDeviceName: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME ?? "",
|
||||
microphoneGain: 1.4,
|
||||
@@ -259,7 +264,10 @@ const config = {
|
||||
webcamWidth: 640,
|
||||
webcamHeight: 360,
|
||||
webcamFps: 30,
|
||||
outputs: { screenPath: outputPath },
|
||||
outputs: {
|
||||
screenPath: outputPath,
|
||||
...(webcamOutputPath ? { webcamPath: webcamOutputPath } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
let result;
|
||||
@@ -285,8 +293,13 @@ if (result.code !== 0) {
|
||||
if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
|
||||
throw new Error(`WGC helper did not produce a video at ${outputPath}`);
|
||||
}
|
||||
if (WITH_WEBCAM && (!fs.existsSync(webcamOutputPath) || fs.statSync(webcamOutputPath).size === 0)) {
|
||||
throw new Error(`WGC helper did not produce a webcam video at ${webcamOutputPath}`);
|
||||
}
|
||||
|
||||
const streams = probeStreams(outputPath);
|
||||
const webcamStreams =
|
||||
webcamOutputPath && fs.existsSync(webcamOutputPath) ? probeStreams(webcamOutputPath) : [];
|
||||
const hasVideo = streams.some((stream) => stream.codec_type === "video");
|
||||
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
|
||||
const webcamFormatLine = result.stdout
|
||||
@@ -297,6 +310,10 @@ const audioFormatLine = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes('"event":"audio-format"'));
|
||||
const audioFormat = audioFormatLine ? JSON.parse(audioFormatLine) : null;
|
||||
const cursorCaptureLine = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes('"event":"cursor-capture"'));
|
||||
const cursorCapture = cursorCaptureLine ? JSON.parse(cursorCaptureLine) : null;
|
||||
const nativeWebcamDiagnostics = result.stderr
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("Native webcam candidate"));
|
||||
@@ -310,6 +327,18 @@ const nativeMicrophoneDiagnostics = result.stderr
|
||||
if (!hasVideo) {
|
||||
throw new Error(`WGC helper output has no video stream: ${outputPath}`);
|
||||
}
|
||||
if (WITH_WEBCAM && !webcamStreams.some((stream) => stream.codec_type === "video")) {
|
||||
throw new Error(`WGC helper webcam output has no video stream: ${webcamOutputPath}`);
|
||||
}
|
||||
if (
|
||||
(CAPTURE_CURSOR && !cursorCapture) ||
|
||||
(cursorCapture &&
|
||||
(cursorCapture.requested !== CAPTURE_CURSOR || cursorCapture.applied !== CAPTURE_CURSOR))
|
||||
) {
|
||||
throw new Error(
|
||||
`WGC helper did not apply requested cursor capture mode (${CAPTURE_CURSOR}): ${result.stdout}`,
|
||||
);
|
||||
}
|
||||
if ((WITH_SYSTEM_AUDIO || WITH_MICROPHONE) && !hasAudio) {
|
||||
throw new Error(`WGC helper output has no audio stream: ${outputPath}`);
|
||||
}
|
||||
@@ -325,13 +354,27 @@ console.log(
|
||||
{
|
||||
success: true,
|
||||
outputPath,
|
||||
webcamOutputPath,
|
||||
bytes: fs.statSync(outputPath).size,
|
||||
webcamBytes:
|
||||
webcamOutputPath && fs.existsSync(webcamOutputPath)
|
||||
? fs.statSync(webcamOutputPath).size
|
||||
: undefined,
|
||||
streams: streams.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecType: stream.codec_type,
|
||||
codecName: stream.codec_name,
|
||||
duration: stream.duration,
|
||||
})),
|
||||
webcamStreams: webcamStreams.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecType: stream.codec_type,
|
||||
codecName: stream.codec_name,
|
||||
width: stream.width,
|
||||
height: stream.height,
|
||||
duration: stream.duration,
|
||||
})),
|
||||
cursorCapture,
|
||||
selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName,
|
||||
selectedWebcamDeviceName: webcamFormat?.deviceName,
|
||||
nativeMicrophoneDiagnostics,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +50,11 @@ interface AnnotationSettingsPanelProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
const FONT_FAMILIES: Array<
|
||||
| { value: string; labelKey: string; name?: never }
|
||||
| { value: string; labelKey?: never; name: string }
|
||||
> = [
|
||||
{ value: "Inter", name: "Inter" },
|
||||
{ value: "system-ui, -apple-system, sans-serif", labelKey: "classic" },
|
||||
{ value: "Georgia, serif", labelKey: "editor" },
|
||||
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
|
||||
@@ -59,6 +63,21 @@ const FONT_FAMILIES = [
|
||||
{ value: "Arial, sans-serif", labelKey: "simple" },
|
||||
{ value: "Verdana, sans-serif", labelKey: "modern" },
|
||||
{ value: "Trebuchet MS, sans-serif", labelKey: "clean" },
|
||||
{ value: '"Plus Jakarta Sans", sans-serif', name: "Plus Jakarta Sans" },
|
||||
{ value: '"Space Grotesk", sans-serif', name: "Space Grotesk" },
|
||||
{ value: '"DM Sans", sans-serif', name: "DM Sans" },
|
||||
{ value: "Sora, sans-serif", name: "Sora" },
|
||||
{ value: "Manrope, sans-serif", name: "Manrope" },
|
||||
{ value: '"IBM Plex Sans", sans-serif', name: "IBM Plex Sans" },
|
||||
{ value: '"Playfair Display", Georgia, serif', name: "Playfair Display" },
|
||||
{ value: "Merriweather, Georgia, serif", name: "Merriweather" },
|
||||
{ value: "Lora, Georgia, serif", name: "Lora" },
|
||||
{ value: '"IBM Plex Mono", monospace', name: "IBM Plex Mono" },
|
||||
{ value: '"Fira Code", monospace', name: "Fira Code" },
|
||||
{ value: '"Bebas Neue", sans-serif', name: "Bebas Neue" },
|
||||
{ value: "Oswald, sans-serif", name: "Oswald" },
|
||||
{ value: "Caveat, cursive", name: "Caveat" },
|
||||
{ value: '"Permanent Marker", cursive', name: "Permanent Marker" },
|
||||
];
|
||||
|
||||
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
|
||||
@@ -85,6 +104,8 @@ export function AnnotationSettingsPanel({
|
||||
modern: t("fontStyles.modern"),
|
||||
clean: t("fontStyles.clean"),
|
||||
};
|
||||
const getFontLabel = (font: (typeof FONT_FAMILIES)[number]) =>
|
||||
font.labelKey ? fontStyleLabels[font.labelKey] : font.name;
|
||||
|
||||
// Load custom fonts on mount
|
||||
useEffect(() => {
|
||||
@@ -231,7 +252,7 @@ export function AnnotationSettingsPanel({
|
||||
value={font.value}
|
||||
style={{ fontFamily: font.value }}
|
||||
>
|
||||
{fontStyleLabels[font.labelKey]}
|
||||
{getFontLabel(font)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{customFonts.length > 0 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { DEFAULT_SOURCE_DIMENSIONS } from "./editorDefaults";
|
||||
|
||||
interface CropRegion {
|
||||
x: number; // 0-1 normalized
|
||||
@@ -32,8 +33,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = videoElement.videoWidth || 1920;
|
||||
canvas.height = videoElement.videoHeight || 1080;
|
||||
canvas.width = videoElement.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
canvas.height = videoElement.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
|
||||
const draw = () => {
|
||||
if (videoElement.readyState >= 2) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import {
|
||||
Brackets,
|
||||
Bug,
|
||||
Crop,
|
||||
Download,
|
||||
@@ -40,7 +41,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
||||
import {
|
||||
calculateEffectiveSourceDimensions,
|
||||
GIF_FRAME_RATES,
|
||||
GIF_SIZE_PRESETS,
|
||||
} from "@/lib/exporter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
||||
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
@@ -48,7 +53,17 @@ import { getTestId } from "@/utils/getTestId";
|
||||
import ColorPicker from "../ui/color-picker";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { BlurSettingsPanel } from "./BlurSettingsPanel";
|
||||
import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
import {
|
||||
DEFAULT_CURSOR_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_SOURCE_DIMENSIONS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
@@ -66,8 +81,6 @@ import type {
|
||||
ZoomFocusMode,
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MAX_ZOOM_SCALE,
|
||||
MIN_ZOOM_SCALE,
|
||||
ROTATION_3D_PRESET_ORDER,
|
||||
@@ -86,37 +99,38 @@ function CustomSpeedInput({
|
||||
onError: () => void;
|
||||
}) {
|
||||
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(value));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const prevValue = useRef(value);
|
||||
if (!isFocused && prevValue.current !== value) {
|
||||
prevValue.current = value;
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const digits = e.target.value.replace(/\D/g, "");
|
||||
if (digits === "") {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
const num = Number(digits);
|
||||
if (num > MAX_PLAYBACK_SPEED) {
|
||||
const result = parseCustomPlaybackSpeedInput(e.target.value);
|
||||
if (result.status === "too-fast") {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
setDraft(digits);
|
||||
if (num >= 1) onChange(num);
|
||||
|
||||
setDraft(result.draft);
|
||||
if (result.status === "valid") {
|
||||
onChange(result.speed);
|
||||
}
|
||||
},
|
||||
[onChange, onError],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (!draft || Number(draft) < 1) {
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
const result = parseCustomPlaybackSpeedInput(draft);
|
||||
if (result.status === "valid") {
|
||||
setDraft(String(result.speed));
|
||||
} else {
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
}, [draft, isPreset, value]);
|
||||
|
||||
@@ -124,8 +138,8 @@ function CustomSpeedInput({
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*[.]?[0-9]*"
|
||||
placeholder="--"
|
||||
value={draft}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
@@ -226,6 +240,8 @@ interface SettingsPanelProps {
|
||||
selectedZoomCustomScale?: number | null;
|
||||
onZoomCustomScaleChange?: (scale: number) => void;
|
||||
onZoomCustomScaleCommit?: () => void;
|
||||
onZoomPreviewStart?: () => void;
|
||||
onZoomPreviewEnd?: () => void;
|
||||
selectedZoomFocusMode?: ZoomFocusMode | null;
|
||||
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
|
||||
selectedZoomFocus?: ZoomFocus | null;
|
||||
@@ -243,6 +259,8 @@ interface SettingsPanelProps {
|
||||
onShadowCommit?: () => void;
|
||||
showBlur?: boolean;
|
||||
onBlurChange?: (showBlur: boolean) => void;
|
||||
showTrimWaveform?: boolean;
|
||||
onTrimWaveformChange?: (show: boolean) => void;
|
||||
motionBlurAmount?: number;
|
||||
onMotionBlurChange?: (amount: number) => void;
|
||||
onMotionBlurCommit?: () => void;
|
||||
@@ -269,6 +287,7 @@ interface SettingsPanelProps {
|
||||
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
||||
gifOutputDimensions?: { width: number; height: number };
|
||||
onExport?: () => void;
|
||||
onExportPanelOpen?: () => void;
|
||||
unsavedExport?: {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
fileName: string;
|
||||
@@ -311,6 +330,8 @@ interface SettingsPanelProps {
|
||||
onCursorMotionBlurChange?: (blur: number) => void;
|
||||
cursorClickBounce?: number;
|
||||
onCursorClickBounceChange?: (bounce: number) => void;
|
||||
cursorClipToBounds?: boolean;
|
||||
onCursorClipToBoundsChange?: (clip: boolean) => void;
|
||||
hasCursorData?: boolean;
|
||||
showCursorSettings?: boolean;
|
||||
}
|
||||
@@ -326,7 +347,24 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
|
||||
{ depth: 6, label: "5×" },
|
||||
];
|
||||
|
||||
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export";
|
||||
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export" | "timeline";
|
||||
|
||||
const MP4_EXPORT_SHORT_SIDES = {
|
||||
medium: 720,
|
||||
good: 1080,
|
||||
} as const;
|
||||
|
||||
function formatSourceDimensions(videoElement?: HTMLVideoElement | null, cropRegion?: CropRegion) {
|
||||
const width = videoElement?.videoWidth ?? 0;
|
||||
const height = videoElement?.videoHeight ?? 0;
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dimensions = calculateEffectiveSourceDimensions(width, height, cropRegion);
|
||||
return { ...dimensions, shortSide: Math.min(dimensions.width, dimensions.height) };
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
selected,
|
||||
@@ -336,6 +374,8 @@ export function SettingsPanel({
|
||||
selectedZoomCustomScale,
|
||||
onZoomCustomScaleChange,
|
||||
onZoomCustomScaleCommit,
|
||||
onZoomPreviewStart,
|
||||
onZoomPreviewEnd,
|
||||
selectedZoomFocusMode,
|
||||
onZoomFocusModeChange,
|
||||
selectedZoomFocus,
|
||||
@@ -353,31 +393,34 @@ export function SettingsPanel({
|
||||
onShadowCommit,
|
||||
showBlur,
|
||||
onBlurChange,
|
||||
showTrimWaveform = false,
|
||||
onTrimWaveformChange,
|
||||
motionBlurAmount = 0,
|
||||
onMotionBlurChange,
|
||||
onMotionBlurCommit,
|
||||
borderRadius = 0,
|
||||
onBorderRadiusChange,
|
||||
onBorderRadiusCommit,
|
||||
padding = 50,
|
||||
padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
onPaddingChange,
|
||||
onPaddingCommit,
|
||||
cropRegion,
|
||||
onCropChange,
|
||||
aspectRatio,
|
||||
videoElement,
|
||||
exportQuality = "good",
|
||||
exportQuality = DEFAULT_EXPORT_SETTINGS.quality,
|
||||
onExportQualityChange,
|
||||
exportFormat = "mp4",
|
||||
exportFormat = DEFAULT_EXPORT_SETTINGS.format,
|
||||
onExportFormatChange,
|
||||
gifFrameRate = 15,
|
||||
gifFrameRate = DEFAULT_GIF_SETTINGS.frameRate,
|
||||
onGifFrameRateChange,
|
||||
gifLoop = true,
|
||||
gifLoop = DEFAULT_GIF_SETTINGS.loop,
|
||||
onGifLoopChange,
|
||||
gifSizePreset = "medium",
|
||||
gifSizePreset = DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
onGifSizePresetChange,
|
||||
gifOutputDimensions = { width: 1280, height: 720 },
|
||||
gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions,
|
||||
onExport,
|
||||
onExportPanelOpen,
|
||||
unsavedExport,
|
||||
onSaveUnsavedExport,
|
||||
selectedAnnotationId,
|
||||
@@ -398,29 +441,32 @@ export function SettingsPanel({
|
||||
onSpeedChange,
|
||||
onSpeedDelete,
|
||||
hasWebcam = false,
|
||||
webcamLayoutPreset = "picture-in-picture",
|
||||
webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
onWebcamLayoutPresetChange,
|
||||
webcamMaskShape = "rectangle",
|
||||
webcamMaskShape = DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
onWebcamMaskShapeChange,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
onSaveDiagnostic,
|
||||
showCursor = true,
|
||||
showCursor = DEFAULT_CURSOR_SETTINGS.show,
|
||||
onShowCursorChange,
|
||||
cursorSize = 3.0,
|
||||
cursorSize = DEFAULT_CURSOR_SETTINGS.size,
|
||||
onCursorSizeChange,
|
||||
cursorSmoothing = 0.67,
|
||||
cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing,
|
||||
onCursorSmoothingChange,
|
||||
cursorMotionBlur = 0.35,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur,
|
||||
onCursorMotionBlurChange,
|
||||
cursorClickBounce = 2.5,
|
||||
cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce,
|
||||
onCursorClickBounceChange,
|
||||
cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds,
|
||||
onCursorClipToBoundsChange,
|
||||
hasCursorData = false,
|
||||
showCursorSettings = true,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [activePanelMode, setActivePanelMode] = useState<SettingsPanelMode>("background");
|
||||
const sourceDimensions = formatSourceDimensions(videoElement, cropRegion);
|
||||
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
|
||||
// `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted
|
||||
// on click — never the machine-specific file:// URL.
|
||||
@@ -452,8 +498,8 @@ export function SettingsPanel({
|
||||
const [cropAspectRatio, setCropAspectRatio] = useState("");
|
||||
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
|
||||
|
||||
const videoWidth = videoElement?.videoWidth || 1920;
|
||||
const videoHeight = videoElement?.videoHeight || 1080;
|
||||
const videoWidth = videoElement?.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
const videoHeight = videoElement?.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
|
||||
const handleCropNumericChange = useCallback(
|
||||
(field: "x" | "y" | "width" | "height", pixelValue: number) => {
|
||||
@@ -564,6 +610,7 @@ export function SettingsPanel({
|
||||
{ id: "background", label: t("background.title"), icon: Palette },
|
||||
{ id: "effects", label: t("effects.title"), icon: SlidersHorizontal },
|
||||
{ id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam },
|
||||
{ id: "timeline", label: t("timeline.title"), icon: Brackets },
|
||||
...(hasCursorPanel
|
||||
? [
|
||||
{
|
||||
@@ -585,8 +632,10 @@ export function SettingsPanel({
|
||||
: selectedSpeedId
|
||||
? t("speed.playbackSpeed")
|
||||
: t("trim.deleteRegion")
|
||||
: ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ??
|
||||
t("background.title"));
|
||||
: activePanelMode === "timeline"
|
||||
? t("timeline.title")
|
||||
: ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ??
|
||||
t("background.title"));
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (selectedZoomId && onZoomDelete) {
|
||||
@@ -606,9 +655,7 @@ export function SettingsPanel({
|
||||
|
||||
const file = files[0];
|
||||
|
||||
// Validate file type - only allow JPG/JPEG
|
||||
const validTypes = ["image/jpeg", "image/jpg"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
if (!isSupportedBackgroundImageType(file.type, file.name)) {
|
||||
toast.error(t("imageUpload.invalidFileType"), {
|
||||
description: t("imageUpload.jpgOnly"),
|
||||
});
|
||||
@@ -666,7 +713,7 @@ export function SettingsPanel({
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<Bug className="w-3 h-3 text-[#34B27B]" />
|
||||
{t("links.reportBug")}
|
||||
{t("support.reportBug")}
|
||||
</button>
|
||||
{onSaveDiagnostic && (
|
||||
<button
|
||||
@@ -675,7 +722,7 @@ export function SettingsPanel({
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<FileDown className="w-3 h-3 text-slate-400" />
|
||||
Save Diagnostics
|
||||
{t("support.saveDiagnostics")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -686,7 +733,7 @@ export function SettingsPanel({
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<Star className="w-3 h-3 text-yellow-400" />
|
||||
{t("links.starOnGithub")}
|
||||
{t("support.starOnGithub")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -782,9 +829,13 @@ export function SettingsPanel({
|
||||
<Crop className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
data-testid={getTestId("export-panel-button")}
|
||||
type="button"
|
||||
title={exportPanelMode.label}
|
||||
onClick={() => setActivePanelMode(exportPanelMode.id)}
|
||||
onClick={() => {
|
||||
setActivePanelMode(exportPanelMode.id);
|
||||
onExportPanelOpen?.();
|
||||
}}
|
||||
className={cn(
|
||||
"mt-auto flex h-8 w-8 items-center justify-center rounded-lg border transition-all",
|
||||
activePanelMode === "export" && !hasTimelineSelection
|
||||
@@ -914,6 +965,31 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{zoomEnabled && onZoomPreviewStart && onZoomPreviewEnd && (
|
||||
<Button
|
||||
type="button"
|
||||
onPointerDown={() => onZoomPreviewStart()}
|
||||
onPointerUp={() => onZoomPreviewEnd()}
|
||||
onPointerLeave={() => onZoomPreviewEnd()}
|
||||
onPointerCancel={() => onZoomPreviewEnd()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
|
||||
e.preventDefault();
|
||||
onZoomPreviewStart();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onZoomPreviewEnd();
|
||||
}
|
||||
}}
|
||||
onBlur={() => onZoomPreviewEnd()}
|
||||
className="h-7 w-full select-none rounded-md border border-white/[0.08] bg-white/[0.04] text-[10px] font-semibold text-slate-300 transition-all duration-150 ease-out hover:bg-white/[0.08] hover:text-slate-100 active:border-[#34B27B]/50 active:bg-[#34B27B] active:text-white cursor-pointer"
|
||||
>
|
||||
{t("zoom.previewHold")}
|
||||
</Button>
|
||||
)}
|
||||
{zoomEnabled &&
|
||||
selectedZoomFocusMode !== "auto" &&
|
||||
selectedZoomFocus &&
|
||||
@@ -1347,7 +1423,7 @@ export function SettingsPanel({
|
||||
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
|
||||
onValueCommit={() => onBorderRadiusCommit?.()}
|
||||
min={0}
|
||||
max={16}
|
||||
max={64}
|
||||
step={0.5}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
@@ -1381,7 +1457,9 @@ export function SettingsPanel({
|
||||
{activePanelMode === "cursor" && showCursorSettings && hasCursorData && (
|
||||
<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}
|
||||
@@ -1389,78 +1467,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>
|
||||
)}
|
||||
@@ -1505,7 +1598,7 @@ export function SettingsPanel({
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
accept=".jpg,.jpeg,image/jpeg"
|
||||
accept={BACKGROUND_IMAGE_ACCEPT}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
@@ -1616,6 +1709,28 @@ export function SettingsPanel({
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
{activePanelMode === "timeline" && (
|
||||
<AccordionItem value="timeline" className="editor-panel-section px-3">
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brackets className="w-4 h-4 text-[#34B27B]" />
|
||||
<span className="text-xs font-medium">{t("timeline.title")}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg editor-control-surface">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("timeline.waveform")}
|
||||
</div>
|
||||
<Switch
|
||||
checked={showTrimWaveform}
|
||||
onCheckedChange={onTrimWaveformChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90 ml-2 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
@@ -1749,6 +1864,7 @@ export function SettingsPanel({
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<button
|
||||
data-testid={getTestId("mp4-format-button")}
|
||||
onClick={() => onExportFormatChange?.("mp4")}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
|
||||
@@ -1776,40 +1892,82 @@ export function SettingsPanel({
|
||||
</div>
|
||||
|
||||
{exportFormat === "mp4" && (
|
||||
<div className="mb-3 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("medium")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === "medium"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{t("exportQuality.low")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("good")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === "good"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{t("exportQuality.medium")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("source")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === "source"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{t("exportQuality.high")}
|
||||
</button>
|
||||
<div className="mb-3 space-y-1.5">
|
||||
{sourceDimensions && (
|
||||
<div className="flex items-center justify-between px-0.5 text-[10px] leading-none text-slate-500">
|
||||
<span>{t("exportQuality.title")}</span>
|
||||
<span>
|
||||
Source {sourceDimensions.width}x{sourceDimensions.height}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-9 rounded-lg">
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("medium")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
|
||||
exportQuality === "medium"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span>{t("exportQuality.low")}</span>
|
||||
{sourceDimensions &&
|
||||
sourceDimensions.shortSide < MP4_EXPORT_SHORT_SIDES.medium && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
exportQuality === "medium" ? "text-black/55" : "text-amber-300/80",
|
||||
)}
|
||||
>
|
||||
Upscale
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("good")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
|
||||
exportQuality === "good"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span>{t("exportQuality.medium")}</span>
|
||||
{sourceDimensions &&
|
||||
sourceDimensions.shortSide < MP4_EXPORT_SHORT_SIDES.good && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
exportQuality === "good" ? "text-black/55" : "text-amber-300/80",
|
||||
)}
|
||||
>
|
||||
Upscale
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("source")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
|
||||
exportQuality === "source"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span>{t("exportQuality.high")}</span>
|
||||
{sourceDimensions && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
exportQuality === "source" ? "text-black/55" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{sourceDimensions.shortSide}p
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -102,10 +102,14 @@ export function ShortcutsConfigDialog() {
|
||||
const handleCancelConflict = useCallback(() => setConflict(null), []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setShortcuts(draft);
|
||||
await persistShortcuts(draft);
|
||||
toast.success(t("savedToast"));
|
||||
closeConfig();
|
||||
const success = await persistShortcuts(draft);
|
||||
if (success) {
|
||||
setShortcuts(draft);
|
||||
toast.success(t("savedToast"));
|
||||
closeConfig();
|
||||
} else {
|
||||
toast.error(t("registrationFailed"));
|
||||
}
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
@@ -222,7 +226,7 @@ export function ShortcutsConfigDialog() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white gap-1.5"
|
||||
className="text-slate-400 gap-1.5"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
|
||||
@@ -18,6 +18,8 @@ import { type Locale } from "@/i18n/config";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor";
|
||||
import {
|
||||
calculateEffectiveSourceDimensions,
|
||||
calculateMp4ExportSettings,
|
||||
calculateOutputDimensions,
|
||||
type ExportFormat,
|
||||
type ExportProgress,
|
||||
@@ -47,6 +49,12 @@ import {
|
||||
isPortraitAspectRatio,
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
import {
|
||||
DEFAULT_CURSOR_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_SOURCE_DIMENSIONS,
|
||||
} from "./editorDefaults";
|
||||
import PlaybackControls from "./PlaybackControls";
|
||||
import {
|
||||
createProjectData,
|
||||
@@ -69,10 +77,6 @@ import {
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
@@ -165,6 +169,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -191,6 +196,7 @@ export default function VideoEditor() {
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [isPreviewingZoom, setIsPreviewingZoom] = useState(false);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
@@ -200,11 +206,15 @@ export default function VideoEditor() {
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
|
||||
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
|
||||
const [gifLoop, setGifLoop] = useState(true);
|
||||
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>(
|
||||
DEFAULT_EXPORT_SETTINGS.quality,
|
||||
);
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>(DEFAULT_EXPORT_SETTINGS.format);
|
||||
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(DEFAULT_GIF_SETTINGS.frameRate);
|
||||
const [gifLoop, setGifLoop] = useState(DEFAULT_GIF_SETTINGS.loop);
|
||||
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>(
|
||||
DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
);
|
||||
const [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
|
||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
|
||||
const [unsavedExport, setUnsavedExport] = useState<{
|
||||
@@ -235,11 +245,14 @@ export default function VideoEditor() {
|
||||
}, [cursorRecordingData, cursorTelemetry]);
|
||||
|
||||
// Cursor & motion blur visual settings (non-undoable preferences)
|
||||
const [showCursor, setShowCursor] = useState(true);
|
||||
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE);
|
||||
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING);
|
||||
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR);
|
||||
const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE);
|
||||
const [showCursor, setShowCursor] = useState(DEFAULT_CURSOR_SETTINGS.show);
|
||||
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SETTINGS.size);
|
||||
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SETTINGS.smoothing);
|
||||
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_SETTINGS.motionBlur);
|
||||
const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_SETTINGS.clickBounce);
|
||||
const [cursorClipToBounds, setCursorClipToBounds] = useState(
|
||||
DEFAULT_CURSOR_SETTINGS.clipToBounds,
|
||||
);
|
||||
const [nativePlatform, setNativePlatform] = useState<NativePlatform | null>(null);
|
||||
const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] =
|
||||
useState<CursorCaptureMode | null>(null);
|
||||
@@ -251,13 +264,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();
|
||||
@@ -343,6 +356,7 @@ export default function VideoEditor() {
|
||||
wallpaper: normalizedEditor.wallpaper,
|
||||
shadowIntensity: normalizedEditor.shadowIntensity,
|
||||
showBlur: normalizedEditor.showBlur,
|
||||
showTrimWaveform: normalizedEditor.showTrimWaveform,
|
||||
motionBlurAmount: normalizedEditor.motionBlurAmount,
|
||||
borderRadius: normalizedEditor.borderRadius,
|
||||
padding: normalizedEditor.padding,
|
||||
@@ -414,6 +428,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -437,6 +452,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -560,6 +576,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -619,6 +636,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -796,6 +814,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
if (id) {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
@@ -805,6 +824,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
@@ -815,6 +835,7 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
@@ -843,6 +864,7 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
@@ -860,11 +882,10 @@ export default function VideoEditor() {
|
||||
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
|
||||
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
|
||||
};
|
||||
// Bulk suggest must not steal selection — keeping a zoom selected hides
|
||||
// the export panel (SettingsPanel gates it on !hasTimelineSelection),
|
||||
// trapping users who just want to export after auto-zoom.
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -880,6 +901,7 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] }));
|
||||
setSelectedTrimId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
@@ -1115,6 +1137,7 @@ export default function VideoEditor() {
|
||||
setSelectedAnnotationId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
@@ -1188,6 +1211,8 @@ export default function VideoEditor() {
|
||||
setSelectedAnnotationId(duplicateId);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -1572,8 +1597,13 @@ export default function VideoEditor() {
|
||||
videoPlaybackRef.current?.pause();
|
||||
}
|
||||
|
||||
const sourceWidth = video.videoWidth || 1920;
|
||||
const sourceHeight = video.videoHeight || 1080;
|
||||
const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
const effectiveSourceDimensions = calculateEffectiveSourceDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
cropRegion,
|
||||
);
|
||||
const aspectRatioValue =
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
|
||||
@@ -1582,8 +1612,8 @@ export default function VideoEditor() {
|
||||
// Get preview CONTAINER dimensions for scaling
|
||||
const playbackRef = videoPlaybackRef.current;
|
||||
const containerElement = playbackRef?.containerRef?.current;
|
||||
const previewWidth = containerElement?.clientWidth || 1920;
|
||||
const previewHeight = containerElement?.clientHeight || 1080;
|
||||
const previewWidth = containerElement?.clientWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
const previewHeight = containerElement?.clientHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
|
||||
if (settings.format === "gif" && settings.gifConfig) {
|
||||
// GIF Export
|
||||
@@ -1612,6 +1642,7 @@ export default function VideoEditor() {
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClipToBounds,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
@@ -1667,83 +1698,16 @@ export default function VideoEditor() {
|
||||
} else {
|
||||
// MP4 Export
|
||||
const quality = settings.quality || exportQuality;
|
||||
let exportWidth: number;
|
||||
let exportHeight: number;
|
||||
let bitrate: number;
|
||||
|
||||
if (quality === "source") {
|
||||
exportWidth = sourceWidth;
|
||||
exportHeight = sourceHeight;
|
||||
|
||||
// Use the source's longer dimension as the long axis of the export so
|
||||
// a landscape recording can still fill a portrait target (and vice versa).
|
||||
const sourceLongDim = Math.max(sourceWidth, sourceHeight);
|
||||
|
||||
if (aspectRatioValue === 1) {
|
||||
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
|
||||
exportWidth = baseDimension;
|
||||
exportHeight = baseDimension;
|
||||
} else if (aspectRatioValue > 1) {
|
||||
const baseWidth = Math.floor(sourceLongDim / 2) * 2;
|
||||
let found = false;
|
||||
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
|
||||
const h = Math.round(w / aspectRatioValue);
|
||||
if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportWidth = baseWidth;
|
||||
exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2;
|
||||
}
|
||||
} else {
|
||||
const baseHeight = Math.floor(sourceLongDim / 2) * 2;
|
||||
let found = false;
|
||||
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
|
||||
const w = Math.round(h * aspectRatioValue);
|
||||
if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportHeight = baseHeight;
|
||||
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
bitrate = 50_000_000;
|
||||
} else if (totalPixels > 2560 * 1440) {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
} else {
|
||||
// Quality presets target the SHORT side; the long side derives from the
|
||||
// aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080.
|
||||
const targetShortDim = quality === "medium" ? 720 : 1080;
|
||||
|
||||
if (aspectRatioValue >= 1) {
|
||||
exportHeight = Math.floor(targetShortDim / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
|
||||
} else {
|
||||
exportWidth = Math.floor(targetShortDim / 2) * 2;
|
||||
exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2;
|
||||
}
|
||||
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
if (totalPixels <= 1280 * 720) {
|
||||
bitrate = 10_000_000;
|
||||
} else if (totalPixels <= 1920 * 1080) {
|
||||
bitrate = 20_000_000;
|
||||
} else {
|
||||
bitrate = 30_000_000;
|
||||
}
|
||||
}
|
||||
const {
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
bitrate,
|
||||
} = calculateMp4ExportSettings({
|
||||
quality,
|
||||
sourceWidth: effectiveSourceDimensions.width,
|
||||
sourceHeight: effectiveSourceDimensions.height,
|
||||
aspectRatioValue,
|
||||
});
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: videoPath,
|
||||
@@ -1769,6 +1733,7 @@ export default function VideoEditor() {
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClipToBounds,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
@@ -1884,6 +1849,7 @@ export default function VideoEditor() {
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClipToBounds,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1901,15 +1867,20 @@ export default function VideoEditor() {
|
||||
}
|
||||
|
||||
// Build export settings from current state
|
||||
const sourceWidth = video.videoWidth || 1920;
|
||||
const sourceHeight = video.videoHeight || 1080;
|
||||
const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
const effectiveSourceDimensions = calculateEffectiveSourceDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
cropRegion,
|
||||
);
|
||||
const aspectRatioValue =
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
|
||||
: getAspectRatioValue(aspectRatio);
|
||||
const gifDimensions = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
effectiveSourceDimensions.width,
|
||||
effectiveSourceDimensions.height,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatioValue,
|
||||
@@ -2101,8 +2072,10 @@ export default function VideoEditor() {
|
||||
aspectRatio:
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
@@ -2161,6 +2134,8 @@ export default function VideoEditor() {
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
isPreviewingZoom={isPreviewingZoom}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2196,6 +2171,8 @@ export default function VideoEditor() {
|
||||
}
|
||||
onZoomCustomScaleChange={handleZoomCustomScaleChange}
|
||||
onZoomCustomScaleCommit={handleZoomCustomScaleCommit}
|
||||
onZoomPreviewStart={() => setIsPreviewingZoom(true)}
|
||||
onZoomPreviewEnd={() => setIsPreviewingZoom(false)}
|
||||
selectedZoomFocusMode={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
|
||||
@@ -2229,6 +2206,8 @@ export default function VideoEditor() {
|
||||
onShadowCommit={commitState}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={(v) => pushState({ showBlur: v })}
|
||||
showTrimWaveform={showTrimWaveform}
|
||||
onTrimWaveformChange={(v) => pushState({ showTrimWaveform: v })}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
|
||||
onMotionBlurCommit={commitState}
|
||||
@@ -2266,19 +2245,38 @@ export default function VideoEditor() {
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
calculateEffectiveSourceDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
).width,
|
||||
calculateEffectiveSourceDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
).height,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
onExportPanelOpen={() => {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
}}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
@@ -2313,6 +2311,8 @@ export default function VideoEditor() {
|
||||
onCursorMotionBlurChange={setCursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
onCursorClickBounceChange={setCursorClickBounce}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
onCursorClipToBoundsChange={setCursorClipToBounds}
|
||||
hasCursorData={
|
||||
cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData)
|
||||
}
|
||||
@@ -2376,6 +2376,8 @@ export default function VideoEditor() {
|
||||
: webcamLayoutPreset,
|
||||
})
|
||||
}
|
||||
videoUrl={videoPath ?? undefined}
|
||||
showTrimWaveform={showTrimWaveform}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -49,15 +49,16 @@ import {
|
||||
getNativeAspectRatioValue,
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
import {
|
||||
DEFAULT_CURSOR_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_SOURCE_DIMENSIONS,
|
||||
} from "./editorDefaults";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type CursorTelemetryPoint,
|
||||
computeRotation3DContainScale,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_ROTATION_3D,
|
||||
isRotation3DIdentity,
|
||||
lerpRotation3D,
|
||||
@@ -148,6 +149,10 @@ interface VideoPlaybackProps {
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorClipToBounds?: boolean;
|
||||
// When true, render the selected zoom at the playhead even while paused —
|
||||
// lets the editor preview the zoom effect without leaving the focus-edit view.
|
||||
isPreviewingZoom?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -195,6 +200,26 @@ function getEndedVideoDuration(video: HTMLVideoElement): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
type AudioTrackListLike = {
|
||||
length: number;
|
||||
[index: number]: { enabled: boolean };
|
||||
};
|
||||
|
||||
type VideoElementWithAudioTracks = HTMLVideoElement & {
|
||||
audioTracks?: AudioTrackListLike;
|
||||
};
|
||||
|
||||
function enableAllPreviewAudioTracks(video: HTMLVideoElement) {
|
||||
const audioTracks = (video as VideoElementWithAudioTracks).audioTracks;
|
||||
if (!audioTracks || audioTracks.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < audioTracks.length; index += 1) {
|
||||
audioTracks[index].enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
(
|
||||
{
|
||||
@@ -223,7 +248,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
showBlur,
|
||||
motionBlurAmount = 0,
|
||||
borderRadius = 0,
|
||||
padding = 50,
|
||||
padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
cropRegion,
|
||||
trimRegions = [],
|
||||
speedRegions = [],
|
||||
@@ -244,14 +269,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorTelemetry = [],
|
||||
cursorClickTimestamps = [],
|
||||
showCursor = false,
|
||||
cursorSize = DEFAULT_CURSOR_SIZE,
|
||||
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
|
||||
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
cursorSize = DEFAULT_CURSOR_SETTINGS.size,
|
||||
cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur,
|
||||
cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce,
|
||||
cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds,
|
||||
isPreviewingZoom = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const videoRef = useRef<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 +289,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 +345,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const cursorSmoothingRef = useRef(cursorSmoothing);
|
||||
const cursorMotionBlurRef = useRef(cursorMotionBlur);
|
||||
const cursorClickBounceRef = useRef(cursorClickBounce);
|
||||
const cursorClipToBoundsRef = useRef(cursorClipToBounds);
|
||||
const isPreviewingZoomRef = useRef(isPreviewingZoom);
|
||||
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
@@ -334,6 +365,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 +564,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 +616,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 +641,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
return;
|
||||
}
|
||||
video.pause();
|
||||
supplementalAudioRef.current?.pause();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -790,6 +834,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorClickBounceRef.current = cursorClickBounce;
|
||||
}, [cursorClickBounce]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClipToBoundsRef.current = cursorClipToBounds;
|
||||
}, [cursorClipToBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
isPreviewingZoomRef.current = isPreviewingZoom;
|
||||
}, [isPreviewingZoom]);
|
||||
|
||||
// Sync cursor overlay config when settings change
|
||||
useEffect(() => {
|
||||
const overlay = cursorOverlayRef.current;
|
||||
@@ -1005,11 +1057,30 @@ const VideoPlayback = forwardRef<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 +1097,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;
|
||||
|
||||
@@ -1214,7 +1319,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
// If a zoom is selected but video is not playing, show default unzoomed view
|
||||
const selectedId = selectedZoomIdRef.current;
|
||||
const hasSelectedZoom = selectedId !== null;
|
||||
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
||||
const shouldShowUnzoomedView =
|
||||
hasSelectedZoom && !isPlayingRef.current && !isPreviewingZoomRef.current;
|
||||
|
||||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
@@ -1396,6 +1502,9 @@ const VideoPlayback = forwardRef<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 +1545,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 +1555,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 +1576,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 +1645,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 +1664,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 +1686,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);
|
||||
@@ -1693,8 +1835,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
aspectRatio,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
lockedVideoDimensionsRef.current?.width || 1920,
|
||||
lockedVideoDimensionsRef.current?.height || 1080,
|
||||
lockedVideoDimensionsRef.current?.width || DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
lockedVideoDimensionsRef.current?.height || DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: undefined,
|
||||
@@ -1727,18 +1869,6 @@ const VideoPlayback = forwardRef<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 +2050,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 +2079,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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSupportedBackgroundImageType } from "./backgroundImageUpload";
|
||||
|
||||
describe("background image upload validation", () => {
|
||||
it("accepts PNG images for custom backgrounds", () => {
|
||||
expect(isSupportedBackgroundImageType("image/png", "生成画像1.png")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts PNG images by extension when the browser does not provide a MIME type", () => {
|
||||
expect(isSupportedBackgroundImageType("", "生成画像1.png")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps rejecting non-image uploads", () => {
|
||||
expect(isSupportedBackgroundImageType("text/plain", "notes.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not allow extension fallback for explicit unsupported MIME types", () => {
|
||||
expect(isSupportedBackgroundImageType("text/plain", "notes.png")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const SUPPORTED_BACKGROUND_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png"]);
|
||||
const SUPPORTED_BACKGROUND_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]);
|
||||
|
||||
export const BACKGROUND_IMAGE_ACCEPT = ".jpg,.jpeg,.png,image/jpeg,image/png";
|
||||
|
||||
export function isSupportedBackgroundImageType(type: string, fileName: string): boolean {
|
||||
const normalizedType = type.trim().toLowerCase();
|
||||
if (SUPPORTED_BACKGROUND_IMAGE_TYPES.has(normalizedType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerName = fileName.trim().toLowerCase();
|
||||
return [...SUPPORTED_BACKGROUND_IMAGE_EXTENSIONS].some((extension) =>
|
||||
lowerName.endsWith(extension),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
|
||||
describe("parseCustomPlaybackSpeedInput", () => {
|
||||
it("accepts decimal playback speeds", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a single decimal point while typing", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.23",
|
||||
speed: 1.23,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows sub-1 custom speeds down to the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "0.1",
|
||||
speed: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds below the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.09")).toEqual({
|
||||
status: "too-slow",
|
||||
draft: "0.09",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts comma decimal input by normalizing to a dot", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds above the editor maximum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({
|
||||
status: "too-fast",
|
||||
draft: "16.1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
clampPlaybackSpeed,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type PlaybackSpeed,
|
||||
} from "./types";
|
||||
|
||||
export type CustomPlaybackSpeedInputResult =
|
||||
| { status: "empty"; draft: string }
|
||||
| { status: "too-fast"; draft: string }
|
||||
| { status: "too-slow"; draft: string }
|
||||
| { status: "valid"; draft: string; speed: PlaybackSpeed };
|
||||
|
||||
export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult {
|
||||
const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, "");
|
||||
const [whole = "", ...fractionParts] = decimalDraft.split(".");
|
||||
const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole;
|
||||
|
||||
if (draft === "" || draft === ".") {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
const speed = Number(draft);
|
||||
if (!Number.isFinite(speed)) {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
if (speed > MAX_PLAYBACK_SPEED) {
|
||||
return { status: "too-fast", draft };
|
||||
}
|
||||
|
||||
if (speed < MIN_PLAYBACK_SPEED) {
|
||||
return { status: "too-slow", draft };
|
||||
}
|
||||
|
||||
return { status: "valid", draft, speed: clampPlaybackSpeed(speed) };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
|
||||
import { DEFAULT_PREFS } from "@/lib/userPreferences";
|
||||
import {
|
||||
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import { normalizeProjectEditor } from "./projectPersistence";
|
||||
|
||||
describe("editor defaults SSOT", () => {
|
||||
it("keeps history defaults aligned with editor defaults", () => {
|
||||
expect(INITIAL_EDITOR_STATE).toMatchObject({
|
||||
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
|
||||
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps user preference defaults aligned with editor and export defaults", () => {
|
||||
expect(DEFAULT_PREFS).toMatchObject({
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
|
||||
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps project fallback normalization aligned with editor defaults", () => {
|
||||
expect(normalizeProjectEditor({})).toMatchObject({
|
||||
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
|
||||
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
|
||||
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
|
||||
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
|
||||
gifFrameRate: DEFAULT_GIF_SETTINGS.frameRate,
|
||||
gifLoop: DEFAULT_GIF_SETTINGS.loop,
|
||||
gifSizePreset: DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type CursorVisualSettings,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_CLIP_TO_BOUNDS,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type WebcamSizePreset,
|
||||
} from "./types";
|
||||
|
||||
export const DEFAULT_SOURCE_DIMENSIONS = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_GIF_OUTPUT_DIMENSIONS = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: {
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
showTrimWaveform: boolean;
|
||||
} = {
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
borderRadius: 0,
|
||||
showTrimWaveform: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_EDITOR_LAYOUT_SETTINGS: {
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
cropRegion: typeof DEFAULT_CROP_REGION;
|
||||
wallpaper: string;
|
||||
} = {
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
cropRegion: DEFAULT_CROP_REGION,
|
||||
wallpaper: DEFAULT_WALLPAPER,
|
||||
};
|
||||
|
||||
export const DEFAULT_WEBCAM_SETTINGS = {
|
||||
layoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
maskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
sizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
position: DEFAULT_WEBCAM_POSITION,
|
||||
} as const satisfies {
|
||||
layoutPreset: WebcamLayoutPreset;
|
||||
maskShape: WebcamMaskShape;
|
||||
sizePreset: WebcamSizePreset;
|
||||
position: WebcamPosition | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean } = {
|
||||
show: true,
|
||||
size: DEFAULT_CURSOR_SIZE,
|
||||
smoothing: DEFAULT_CURSOR_SMOOTHING,
|
||||
motionBlur: DEFAULT_CURSOR_MOTION_BLUR,
|
||||
clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS,
|
||||
};
|
||||
|
||||
export const DEFAULT_EXPORT_SETTINGS: {
|
||||
quality: ExportQuality;
|
||||
format: ExportFormat;
|
||||
} = {
|
||||
quality: "good",
|
||||
format: "mp4",
|
||||
};
|
||||
|
||||
export const DEFAULT_GIF_SETTINGS: {
|
||||
frameRate: GifFrameRate;
|
||||
loop: boolean;
|
||||
sizePreset: GifSizePreset;
|
||||
outputDimensions: typeof DEFAULT_GIF_OUTPUT_DIMENSIONS;
|
||||
} = {
|
||||
frameRate: 15,
|
||||
loop: true,
|
||||
sizePreset: "medium",
|
||||
outputDimensions: DEFAULT_GIF_OUTPUT_DIMENSIONS,
|
||||
};
|
||||
@@ -4,6 +4,13 @@ import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
||||
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
@@ -15,14 +22,10 @@ import {
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
DEFAULT_ZOOM_MOTION_BLUR,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
@@ -62,6 +65,7 @@ export interface ProjectEditorState {
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
showTrimWaveform: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
@@ -104,13 +108,13 @@ function computeNormalizedWebcamLayoutPreset(
|
||||
case "vertical-stack":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
? webcamLayoutPreset
|
||||
: DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||
: DEFAULT_WEBCAM_SETTINGS.layoutPreset;
|
||||
case "dual-frame":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
? DEFAULT_WEBCAM_LAYOUT_PRESET
|
||||
? DEFAULT_WEBCAM_SETTINGS.layoutPreset
|
||||
: webcamLayoutPreset;
|
||||
default:
|
||||
return DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||
return DEFAULT_WEBCAM_SETTINGS.layoutPreset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +215,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.aspectRatio as AspectRatio,
|
||||
)
|
||||
? (editor.aspectRatio as AspectRatio)
|
||||
: "16:9";
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio;
|
||||
const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
|
||||
editor.webcamLayoutPreset,
|
||||
normalizedAspectRatio,
|
||||
@@ -226,7 +230,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
|
||||
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
|
||||
}
|
||||
: DEFAULT_WEBCAM_POSITION;
|
||||
: DEFAULT_WEBCAM_SETTINGS.position;
|
||||
|
||||
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
||||
? editor.zoomRegions
|
||||
@@ -413,16 +417,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
|
||||
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
|
||||
? editor.cropRegion.x
|
||||
: DEFAULT_CROP_REGION.x;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.x;
|
||||
const rawCropY = isFiniteNumber(editor.cropRegion?.y)
|
||||
? editor.cropRegion.y
|
||||
: DEFAULT_CROP_REGION.y;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.y;
|
||||
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width)
|
||||
? editor.cropRegion.width
|
||||
: DEFAULT_CROP_REGION.width;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.width;
|
||||
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
|
||||
? editor.cropRegion.height
|
||||
: DEFAULT_CROP_REGION.height;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.height;
|
||||
|
||||
const cropX = clamp(rawCropX, 0, 1);
|
||||
const cropY = clamp(rawCropY, 0, 1);
|
||||
@@ -433,18 +437,33 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
wallpaper:
|
||||
typeof editor.wallpaper === "string"
|
||||
? normalizeWallpaperValue(editor.wallpaper)
|
||||
: DEFAULT_WALLPAPER,
|
||||
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
||||
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
shadowIntensity:
|
||||
typeof editor.shadowIntensity === "number"
|
||||
? editor.shadowIntensity
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity,
|
||||
showBlur:
|
||||
typeof editor.showBlur === "boolean"
|
||||
? editor.showBlur
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur,
|
||||
showTrimWaveform:
|
||||
typeof editor.showTrimWaveform === "boolean"
|
||||
? editor.showTrimWaveform
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform,
|
||||
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
||||
? clamp(editor.motionBlurAmount, 0, 1)
|
||||
: typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean"
|
||||
? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled
|
||||
? 0.35
|
||||
: 0
|
||||
: 0,
|
||||
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
|
||||
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
||||
? DEFAULT_ZOOM_MOTION_BLUR
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount,
|
||||
borderRadius:
|
||||
typeof editor.borderRadius === "number"
|
||||
? editor.borderRadius
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius,
|
||||
padding: isFiniteNumber(editor.padding)
|
||||
? clamp(editor.padding, 0, 100)
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
cropRegion: {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
@@ -463,31 +482,31 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.webcamMaskShape === "square" ||
|
||||
editor.webcamMaskShape === "rounded"
|
||||
? editor.webcamMaskShape
|
||||
: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
: DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
webcamSizePreset:
|
||||
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
|
||||
? Math.max(10, Math.min(50, editor.webcamSizePreset))
|
||||
: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: normalizedWebcamPosition,
|
||||
exportQuality:
|
||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
: "good",
|
||||
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
|
||||
: DEFAULT_EXPORT_SETTINGS.quality,
|
||||
exportFormat: editor.exportFormat === "gif" ? "gif" : DEFAULT_EXPORT_SETTINGS.format,
|
||||
gifFrameRate:
|
||||
editor.gifFrameRate === 15 ||
|
||||
editor.gifFrameRate === 20 ||
|
||||
editor.gifFrameRate === 25 ||
|
||||
editor.gifFrameRate === 30
|
||||
? editor.gifFrameRate
|
||||
: 15,
|
||||
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
|
||||
: DEFAULT_GIF_SETTINGS.frameRate,
|
||||
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : DEFAULT_GIF_SETTINGS.loop,
|
||||
gifSizePreset:
|
||||
editor.gifSizePreset === "medium" ||
|
||||
editor.gifSizePreset === "large" ||
|
||||
editor.gifSizePreset === "original"
|
||||
? editor.gifSizePreset
|
||||
: "medium",
|
||||
: DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface BackgroundWaveformProps {
|
||||
/** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */
|
||||
peaks: Float32Array | null;
|
||||
videoDurationMs: number;
|
||||
/**
|
||||
* Pixels to inset the drawn waveform from the top of the canvas row,
|
||||
* so it aligns with the item content top edge. Defaults to 0.
|
||||
*/
|
||||
topInset?: number;
|
||||
/**
|
||||
* Pixels to inset the drawn waveform from the bottom of the canvas row,
|
||||
* so it aligns with the item content bottom edge. Defaults to 0.
|
||||
*/
|
||||
bottomInset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a rectified (half-wave) audio waveform on a `<canvas>` that fills
|
||||
* its containing block. Designed to be passed as the `background` prop of
|
||||
* `<Row>`, which already provides `relative overflow-hidden` — no wrapper
|
||||
* element needed.
|
||||
*
|
||||
* The canvas always uses `inset-0` (full row height). Vertical alignment with
|
||||
* the item content is achieved via `topInset`/`bottomInset` in the draw calls
|
||||
* rather than CSS positioning, so the result is immune to sub-pixel CSS layout
|
||||
* differences.
|
||||
*
|
||||
* - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`).
|
||||
* - Redraws whenever the timeline zoom/pan range changes.
|
||||
* - `pointer-events: none` — never blocks drag-to-create interactions.
|
||||
*/
|
||||
export default function BackgroundWaveform({
|
||||
peaks,
|
||||
videoDurationMs,
|
||||
topInset = 0,
|
||||
bottomInset = 0,
|
||||
}: BackgroundWaveformProps) {
|
||||
const { range } = useTimelineContext();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
|
||||
|
||||
// Observe the canvas itself — Row's `relative overflow-hidden` parent
|
||||
// makes it fill the row exactly, so no wrapper div is needed.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
setCanvasSize({ w: width, h: height });
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Redraw whenever peaks, range, or canvas size changes.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || canvasSize.w <= 0 || canvasSize.h <= 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(canvasSize.w * dpr);
|
||||
canvas.height = Math.round(canvasSize.h * dpr);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, canvasSize.w, canvasSize.h);
|
||||
|
||||
if (!peaks || peaks.length === 0) return;
|
||||
|
||||
const W = canvasSize.w;
|
||||
const H = canvasSize.h;
|
||||
const rangeMs = range.end - range.start;
|
||||
if (rangeMs <= 0 || videoDurationMs <= 0) return;
|
||||
|
||||
// Draw within [topY, bottomY] so the waveform aligns with item bounds
|
||||
// regardless of CSS sub-pixel rounding on the canvas element itself.
|
||||
const topY = topInset;
|
||||
const bottomY = H - bottomInset;
|
||||
const drawHeight = bottomY - topY;
|
||||
if (drawHeight <= 0) return;
|
||||
|
||||
const N = peaks.length / 2;
|
||||
const amp = drawHeight * 0.9;
|
||||
|
||||
// Rectified (half-wave): amplitude = max(|min|, |max|), drawn upward from bottomY.
|
||||
const colAmp = new Float32Array(W);
|
||||
for (let x = 0; x < W; x++) {
|
||||
const startMs = range.start + (x / W) * rangeMs;
|
||||
const endMs = range.start + ((x + 1) / W) * rangeMs;
|
||||
const lo = Math.max(0, Math.floor((startMs / videoDurationMs) * N));
|
||||
const hi = Math.min(N - 1, Math.ceil((endMs / videoDurationMs) * N));
|
||||
|
||||
let absMax = 0;
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const a = Math.abs(peaks[i * 2]);
|
||||
const b = Math.abs(peaks[i * 2 + 1]);
|
||||
if (a > absMax) absMax = a;
|
||||
if (b > absMax) absMax = b;
|
||||
}
|
||||
colAmp[x] = absMax;
|
||||
}
|
||||
|
||||
// Filled polygon: bottom-left → top silhouette → bottom-right.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, bottomY);
|
||||
for (let x = 0; x < W; x++) {
|
||||
ctx.lineTo(x, bottomY - colAmp[x] * amp);
|
||||
}
|
||||
ctx.lineTo(W, bottomY);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(74, 222, 128, 0.55)";
|
||||
ctx.fill();
|
||||
|
||||
// Crisp top-edge stroke for the sharp silhouette.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, bottomY - colAmp[0] * amp);
|
||||
for (let x = 1; x < W; x++) {
|
||||
ctx.lineTo(x, bottomY - colAmp[x] * amp);
|
||||
}
|
||||
ctx.strokeStyle = "rgba(74, 222, 128, 0.85)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}, [peaks, range, canvasSize, videoDurationMs, topInset, bottomInset]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 pointer-events-none w-full h-full" />;
|
||||
}
|
||||
@@ -5,9 +5,15 @@ interface RowProps extends RowDefinition {
|
||||
children: React.ReactNode;
|
||||
hint?: string;
|
||||
isEmpty?: boolean;
|
||||
background?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Row({ id, children, hint, isEmpty }: RowProps) {
|
||||
/**
|
||||
* A single horizontal lane in the timeline. Wraps the dnd-timeline `useRow`
|
||||
* hook and adds an optional `background` layer (e.g. `BackgroundWaveform`),
|
||||
* an empty-state hint label, and a minimum height.
|
||||
*/
|
||||
export default function Row({ id, children, hint, isEmpty, background }: RowProps) {
|
||||
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
|
||||
|
||||
return (
|
||||
@@ -15,6 +21,7 @@ export default function Row({ id, children, hint, isEmpty }: RowProps) {
|
||||
className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden"
|
||||
style={{ ...rowWrapperStyle, minHeight: 36 }}
|
||||
>
|
||||
{background}
|
||||
{isEmpty && hint && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
|
||||
<span className="text-[11px] text-white/[0.12] font-medium">{hint}</span>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { useAudioPeaks } from "@/hooks/useAudioPeaks";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
ZoomFocus,
|
||||
ZoomRegion,
|
||||
} from "../types";
|
||||
import BackgroundWaveform from "./BackgroundWaveform";
|
||||
import Item from "./Item";
|
||||
import KeyframeMarkers from "./KeyframeMarkers";
|
||||
import Row from "./Row";
|
||||
@@ -88,6 +90,8 @@ interface TimelineEditorProps {
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
|
||||
videoUrl?: string;
|
||||
showTrimWaveform?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineScaleConfig {
|
||||
@@ -237,6 +241,31 @@ function formatPlayheadTime(ms: number): string {
|
||||
return `${sec.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function shouldStartTimelineScrub(target: EventTarget | null, timelineElement: HTMLElement) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let element: HTMLElement | null = target; element && element !== timelineElement; ) {
|
||||
const className = element.className;
|
||||
const classText = typeof className === "string" ? className : "";
|
||||
|
||||
if (
|
||||
classText.split(/\s+/).includes("group") ||
|
||||
classText.includes("cursor-grab") ||
|
||||
classText.includes("cursor-grabbing") ||
|
||||
classText.includes("cursor-ew-resize") ||
|
||||
element.style.cursor === "col-resize"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
videoDurationMs,
|
||||
@@ -542,6 +571,8 @@ function Timeline({
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
keyframes = [],
|
||||
videoUrl,
|
||||
showTrimWaveform = false,
|
||||
}: {
|
||||
items: TimelineRenderItem[];
|
||||
videoDurationMs: number;
|
||||
@@ -559,10 +590,15 @@ function Timeline({
|
||||
selectedBlurId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
videoUrl?: string;
|
||||
showTrimWaveform?: boolean;
|
||||
}) {
|
||||
const t = useScopedT("timeline");
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||
const isScrubbingTimelineRef = useRef(false);
|
||||
const scrubPointerIdRef = useRef<number | null>(null);
|
||||
const peaks = useAudioPeaks(showTrimWaveform ? videoUrl : undefined);
|
||||
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -572,43 +608,105 @@ function Timeline({
|
||||
[setTimelineRef],
|
||||
);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return;
|
||||
const seekTimelineAtClientX = useCallback(
|
||||
(timelineElement: HTMLDivElement, clientX: number) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return false;
|
||||
|
||||
// Only clear selection if clicking on empty space (not on items)
|
||||
// This is handled by event propagation - items stop propagation
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectBlur?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
const rect = timelineElement.getBoundingClientRect();
|
||||
const clickX = clientX - rect.left - sidebarWidth;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
|
||||
if (clickX < 0) return;
|
||||
if (clickX < 0) return false;
|
||||
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
const timeInSeconds = absoluteMs / 1000;
|
||||
|
||||
onSeek(timeInSeconds);
|
||||
onSeek(absoluteMs / 1000);
|
||||
return true;
|
||||
},
|
||||
[
|
||||
onSeek,
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
range.start,
|
||||
pixelsToValue,
|
||||
],
|
||||
[onSeek, videoDurationMs, sidebarWidth, pixelsToValue, range.start],
|
||||
);
|
||||
|
||||
const clearTimelineSelection = useCallback(() => {
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectBlur?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
}, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectBlur, onSelectSpeed]);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only clear selection if clicking on empty space (not on items)
|
||||
// This is handled by event propagation - items stop propagation
|
||||
clearTimelineSelection();
|
||||
seekTimelineAtClientX(e.currentTarget, e.clientX);
|
||||
},
|
||||
[clearTimelineSelection, seekTimelineAtClientX],
|
||||
);
|
||||
|
||||
const handleTimelinePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldStartTimelineScrub(e.target, e.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!seekTimelineAtClientX(e.currentTarget, e.clientX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimelineSelection();
|
||||
isScrubbingTimelineRef.current = true;
|
||||
scrubPointerIdRef.current = e.pointerId;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
},
|
||||
[clearTimelineSelection, seekTimelineAtClientX],
|
||||
);
|
||||
|
||||
const handleTimelinePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
seekTimelineAtClientX(e.currentTarget, e.clientX);
|
||||
e.preventDefault();
|
||||
},
|
||||
[seekTimelineAtClientX],
|
||||
);
|
||||
|
||||
const stopTimelineScrub = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isScrubbingTimelineRef.current = false;
|
||||
scrubPointerIdRef.current = null;
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTimelinePointerLeave = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isScrubbingTimelineRef.current && scrubPointerIdRef.current === e.pointerId) {
|
||||
seekTimelineAtClientX(e.currentTarget, e.clientX);
|
||||
}
|
||||
},
|
||||
[seekTimelineAtClientX],
|
||||
);
|
||||
|
||||
const handleTimelineLostPointerCapture = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (scrubPointerIdRef.current === e.pointerId) {
|
||||
isScrubbingTimelineRef.current = false;
|
||||
scrubPointerIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTimelineWheel = useCallback(
|
||||
(event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
|
||||
@@ -658,9 +756,15 @@ function Timeline({
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
style={style}
|
||||
style={{ ...style, touchAction: "none" }}
|
||||
className="select-none bg-[#0b0c0f] min-h-[190px] relative cursor-pointer group"
|
||||
onClick={handleTimelineClick}
|
||||
onPointerDown={handleTimelinePointerDown}
|
||||
onPointerMove={handleTimelinePointerMove}
|
||||
onPointerUp={stopTimelineScrub}
|
||||
onPointerCancel={stopTimelineScrub}
|
||||
onPointerLeave={handleTimelinePointerLeave}
|
||||
onLostPointerCapture={handleTimelineLostPointerCapture}
|
||||
onWheel={handleTimelineWheel}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff05_1px,transparent_1px)] bg-[length:24px_100%] pointer-events-none" />
|
||||
@@ -693,7 +797,21 @@ function Timeline({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")}>
|
||||
<Row
|
||||
id={TRIM_ROW_ID}
|
||||
isEmpty={trimItems.length === 0}
|
||||
hint={t("hints.pressTrim")}
|
||||
background={
|
||||
showTrimWaveform ? (
|
||||
<BackgroundWaveform
|
||||
peaks={peaks}
|
||||
videoDurationMs={videoDurationMs}
|
||||
topInset={3}
|
||||
bottomInset={3}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{trimItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -804,6 +922,8 @@ export default function TimelineEditor({
|
||||
onSelectSpeed,
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
videoUrl,
|
||||
showTrimWaveform = false,
|
||||
}: TimelineEditorProps) {
|
||||
const t = useScopedT("timeline");
|
||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||
@@ -1605,6 +1725,8 @@ export default function TimelineEditor({
|
||||
selectedBlurId={selectedBlurId}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
keyframes={keyframes}
|
||||
videoUrl={videoUrl}
|
||||
showTrimWaveform={showTrimWaveform}
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -188,12 +188,16 @@ export interface CursorVisualSettings {
|
||||
smoothing: number;
|
||||
motionBlur: number;
|
||||
clickBounce: number;
|
||||
clipToBounds: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CURSOR_SIZE = 3.0;
|
||||
export const DEFAULT_CURSOR_SMOOTHING = 0.67;
|
||||
export const DEFAULT_CURSOR_MOTION_BLUR = 0.35;
|
||||
export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5;
|
||||
// false = allow the cursor to overflow into the background by default.
|
||||
// true = clip the native cursor to the video canvas bounds.
|
||||
export const DEFAULT_CURSOR_CLIP_TO_BOUNDS = false;
|
||||
export const DEFAULT_ZOOM_MOTION_BLUR = 0.35;
|
||||
|
||||
export interface TrimRegion {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -112,6 +112,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
window.electronAPI?.setLocale?.(locale);
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ShortcutsContextValue {
|
||||
shortcuts: ShortcutsConfig;
|
||||
isMac: boolean;
|
||||
setShortcuts: (config: ShortcutsConfig) => void;
|
||||
persistShortcuts: (config?: ShortcutsConfig) => Promise<void>;
|
||||
persistShortcuts: (config?: ShortcutsConfig) => Promise<boolean>;
|
||||
isConfigOpen: boolean;
|
||||
openConfig: () => void;
|
||||
closeConfig: () => void;
|
||||
@@ -54,7 +54,11 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const persistShortcuts = useCallback(
|
||||
async (config?: ShortcutsConfig) => {
|
||||
await window.electronAPI.saveShortcuts?.(config ?? shortcuts);
|
||||
const configToSave = config ?? shortcuts;
|
||||
await window.electronAPI.saveShortcuts?.(configToSave);
|
||||
|
||||
const result = await window.electronAPI.updateGlobalShortcut?.(configToSave.openApp);
|
||||
return result ? result.success : true;
|
||||
},
|
||||
[shortcuts],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Web Worker: computes min/max peak pairs from raw audio channel data.
|
||||
*
|
||||
* Input message: { channels: Float32Array[]; duration: number }
|
||||
* Output message: Float32Array of length 2*N — [min0, max0, min1, max1, …]
|
||||
*
|
||||
* Channel buffers are transferred (zero-copy) from the caller.
|
||||
* The peaks buffer is transferred back.
|
||||
*/
|
||||
self.onmessage = (event: MessageEvent<{ channels: Float32Array[]; duration: number }>) => {
|
||||
const { channels, duration } = event.data;
|
||||
const nCh = channels.length;
|
||||
if (nCh === 0) {
|
||||
(self as unknown as Worker).postMessage(new Float32Array(0));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSamples = channels[0].length;
|
||||
const N = Math.min(24000, Math.ceil(duration * 200));
|
||||
const blockSize = totalSamples / N;
|
||||
const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …]
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const start = Math.floor(i * blockSize);
|
||||
const end = Math.floor((i + 1) * blockSize);
|
||||
let minVal = 0;
|
||||
let maxVal = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
let sample = 0;
|
||||
for (let c = 0; c < nCh; c++) sample += channels[c][j];
|
||||
sample /= nCh;
|
||||
if (sample < minVal) minVal = sample;
|
||||
if (sample > maxVal) maxVal = sample;
|
||||
}
|
||||
peaks[i * 2] = minVal;
|
||||
peaks[i * 2 + 1] = maxVal;
|
||||
}
|
||||
|
||||
(self as unknown as Worker).postMessage(peaks, [peaks.buffer]);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRecorderHandle } from "./recorderHandle";
|
||||
|
||||
type ElectronAPI = Window["electronAPI"];
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
const decode = (buffer: ArrayBuffer) => new TextDecoder().decode(new Uint8Array(buffer));
|
||||
|
||||
/** Minimal MediaRecorder stand-in the tests can drive directly. */
|
||||
class FakeMediaRecorder {
|
||||
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
state: "inactive" | "recording" = "inactive";
|
||||
|
||||
start(): void {
|
||||
this.state = "recording";
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.state = "inactive";
|
||||
this.onstop?.();
|
||||
}
|
||||
|
||||
emit(data: Blob): void {
|
||||
this.ondataavailable?.({ data } as BlobEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function stubElectronAPI(api: Partial<ElectronAPI>): void {
|
||||
window.electronAPI = api as unknown as ElectronAPI;
|
||||
}
|
||||
|
||||
function driver(handle: { recorder: MediaRecorder }): FakeMediaRecorder {
|
||||
return handle.recorder as unknown as FakeMediaRecorder;
|
||||
}
|
||||
|
||||
describe("createRecorderHandle", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
window.electronAPI = undefined as unknown as ElectronAPI;
|
||||
});
|
||||
|
||||
it("streams chunks to disk in arrival order and resolves an empty blob", async () => {
|
||||
const appended: string[] = [];
|
||||
const openRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
const appendRecordingChunk = vi.fn(async (_fileName: string, buffer: ArrayBuffer) => {
|
||||
appended.push(decode(buffer));
|
||||
return { success: true };
|
||||
});
|
||||
stubElectronAPI({ openRecordingStream, appendRecordingChunk });
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["a"])); // arrives before open resolves -> buffered
|
||||
await tick(); // open resolves -> buffered chunk flushes, mode becomes streaming
|
||||
fake.emit(new Blob(["b"]));
|
||||
fake.emit(new Blob(["c"]));
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
expect(openRecordingStream).toHaveBeenCalledWith("rec.webm");
|
||||
expect(appended).toEqual(["a", "b", "c"]);
|
||||
expect(blob.size).toBe(0);
|
||||
expect(handle.isStreaming()).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to a complete in-memory blob when the stream fails to open", async () => {
|
||||
const openRecordingStream = vi.fn(async () => ({ success: false, error: "nope" }));
|
||||
const appendRecordingChunk = vi.fn(async () => ({ success: true }));
|
||||
stubElectronAPI({ openRecordingStream, appendRecordingChunk });
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["a"]));
|
||||
await tick(); // open resolves false -> buffering, keep everything in memory
|
||||
fake.emit(new Blob(["bc"]));
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
expect(appendRecordingChunk).not.toHaveBeenCalled();
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
expect(blob.size).toBe(3);
|
||||
expect(decode(await blob.arrayBuffer())).toBe("abc");
|
||||
});
|
||||
|
||||
it("falls back to in-memory buffering when the open IPC call rejects", async () => {
|
||||
const openRecordingStream = vi.fn(async () => {
|
||||
throw new Error("ipc channel closed");
|
||||
});
|
||||
stubElectronAPI({
|
||||
openRecordingStream,
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["a"]));
|
||||
await tick(); // open rejects -> treated as a failed open, keep buffering
|
||||
fake.emit(new Blob(["b"]));
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
expect(blob.size).toBe(2);
|
||||
expect(decode(await blob.arrayBuffer())).toBe("ab");
|
||||
});
|
||||
|
||||
it("waits for in-flight chunk writes before stop resolves (no truncation)", async () => {
|
||||
let releaseAppend: () => void = () => undefined;
|
||||
const appendGate = new Promise<void>((resolve) => {
|
||||
releaseAppend = resolve;
|
||||
});
|
||||
const appendRecordingChunk = vi.fn(async () => {
|
||||
await appendGate;
|
||||
return { success: true };
|
||||
});
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
appendRecordingChunk,
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
await tick(); // open resolves
|
||||
fake.emit(new Blob(["a"])); // write blocks on the gate
|
||||
fake.stop();
|
||||
|
||||
let resolved = false;
|
||||
void handle.recordedBlobPromise.then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await tick();
|
||||
expect(resolved).toBe(false); // must not resolve while the write is in flight
|
||||
|
||||
releaseAppend();
|
||||
await handle.recordedBlobPromise;
|
||||
expect(resolved).toBe(true);
|
||||
expect(appendRecordingChunk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects when a chunk fails to write mid-stream", async () => {
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: false, error: "disk full" })),
|
||||
closeRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
await tick();
|
||||
fake.emit(new Blob(["a"]));
|
||||
fake.stop();
|
||||
|
||||
await expect(handle.recordedBlobPromise).rejects.toThrow(/disk full/);
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a rejected append the same as a failed write", async () => {
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
appendRecordingChunk: vi.fn(async () => {
|
||||
throw new Error("kernel said no");
|
||||
}),
|
||||
closeRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
await tick();
|
||||
fake.emit(new Blob(["a"]));
|
||||
fake.stop();
|
||||
|
||||
await expect(handle.recordedBlobPromise).rejects.toThrow(/kernel said no/);
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
});
|
||||
|
||||
it("buffers in memory and never opens a stream when no file name is given", async () => {
|
||||
const openRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
stubElectronAPI({
|
||||
openRecordingStream,
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" });
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["xy"]));
|
||||
await tick();
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
expect(openRecordingStream).not.toHaveBeenCalled();
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
expect(blob.size).toBe(2);
|
||||
});
|
||||
|
||||
it("buffers in memory when appendRecordingChunk is unavailable (version skew)", async () => {
|
||||
const openRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
// appendRecordingChunk intentionally omitted to simulate renderer/main skew.
|
||||
stubElectronAPI({ openRecordingStream });
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["a"]));
|
||||
await tick();
|
||||
fake.emit(new Blob(["b"]));
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
// Never even attempts to open the stream when it can't append to it.
|
||||
expect(openRecordingStream).not.toHaveBeenCalled();
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
expect(blob.size).toBe(2);
|
||||
});
|
||||
|
||||
it("discard closes the disk stream for a streamed recording", async () => {
|
||||
const closeRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||
closeRecordingStream,
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
await tick();
|
||||
fake.emit(new Blob(["a"]));
|
||||
fake.stop();
|
||||
await handle.recordedBlobPromise;
|
||||
|
||||
await handle.discard();
|
||||
expect(closeRecordingStream).toHaveBeenCalledWith("rec.webm");
|
||||
});
|
||||
|
||||
it("discard is a no-op when the stream never opened", async () => {
|
||||
const closeRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: false })),
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||
closeRecordingStream,
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
await tick();
|
||||
fake.stop();
|
||||
await handle.recordedBlobPromise;
|
||||
|
||||
await handle.discard();
|
||||
expect(closeRecordingStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
const RECORDER_TIMESLICE_MS = 1000;
|
||||
|
||||
export type RecorderHandle = {
|
||||
recorder: MediaRecorder;
|
||||
/**
|
||||
* Resolves once the recording has fully drained. For a streamed recording the
|
||||
* blob is empty (the bytes are already on disk); for an in-memory recording it
|
||||
* holds the full WebM. Rejects if a chunk failed to write to disk mid-stream,
|
||||
* so a truncated recording surfaces as an error instead of a silent partial save.
|
||||
*/
|
||||
recordedBlobPromise: Promise<Blob>;
|
||||
/**
|
||||
* Whether the recording's bytes went to disk via the streaming path. Computed
|
||||
* at finalize time rather than construction, so a stream that fails to open is
|
||||
* correctly reported as not-streamed and its in-memory fallback is used.
|
||||
*/
|
||||
isStreaming: () => boolean;
|
||||
/**
|
||||
* Close the disk stream (if one opened) and delete its partial file. Called
|
||||
* when a recording is discarded or fails before a successful save, so cancelled
|
||||
* runs don't leak the stream or orphan a partial file. No-op for in-memory
|
||||
* recorders.
|
||||
*/
|
||||
discard: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a MediaRecorder, optionally streaming its chunks to disk.
|
||||
*
|
||||
* When `fileName` is given, chunks are written to disk in arrival order through
|
||||
* the main process as they arrive, so a long recording never buffers the whole
|
||||
* video in the renderer (the #616 fix). Until the disk stream confirms it is
|
||||
* open, chunks are held in memory; if the open fails, that buffer becomes a
|
||||
* complete in-memory fallback so nothing is lost. Native-capture webcam sidecars
|
||||
* omit `fileName` and always buffer in memory, since their finalize path reads
|
||||
* the blob directly to attach the webcam track.
|
||||
*/
|
||||
export function createRecorderHandle(
|
||||
stream: MediaStream,
|
||||
options: MediaRecorderOptions,
|
||||
fileName?: string,
|
||||
): RecorderHandle {
|
||||
const recorder = new MediaRecorder(stream, options);
|
||||
const mimeType = options.mimeType || "video/webm";
|
||||
const api = window.electronAPI;
|
||||
|
||||
// Chunks held in memory: everything before the stream opens, plus everything
|
||||
// when not streaming at all. On a successful open these flush to disk and are
|
||||
// dropped; on open failure they remain as the complete fallback recording.
|
||||
const memoryChunks: Blob[] = [];
|
||||
let mode: "pending" | "streaming" | "buffering" = fileName ? "pending" : "buffering";
|
||||
let streamOpened = false;
|
||||
let appendError: Error | null = null;
|
||||
|
||||
// Serialize chunk writes so they land on disk in arrival order, and so stop
|
||||
// can await every in-flight write before the main process closes the stream
|
||||
// (otherwise a late chunk arrives after close and truncates the recording).
|
||||
let writeChain: Promise<void> = Promise.resolve();
|
||||
const enqueueWrite = (chunk: Blob) => {
|
||||
writeChain = writeChain.then(async () => {
|
||||
if (appendError || !fileName || !api?.appendRecordingChunk) {
|
||||
return;
|
||||
}
|
||||
// Capture both outcomes — a `{ success: false }` result and an outright
|
||||
// rejection (channel/handler error) — into appendError, so writeChain
|
||||
// never rejects and isStreaming() stays consistent after a failure.
|
||||
try {
|
||||
const buffer = await chunk.arrayBuffer();
|
||||
const result = await api.appendRecordingChunk(fileName, buffer);
|
||||
if (!result.success) {
|
||||
appendError = new Error(result.error ?? "Failed to write recording chunk to disk");
|
||||
}
|
||||
} catch (error) {
|
||||
appendError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Require BOTH stream IPC methods before attempting to stream. If only
|
||||
// openRecordingStream exists (renderer/main version skew), streaming would
|
||||
// open but every append would silently no-op, saving an empty file — so in
|
||||
// that case fall through to in-memory buffering instead.
|
||||
const openPromise: Promise<{ success: boolean; error?: string }> =
|
||||
fileName !== undefined &&
|
||||
typeof api?.openRecordingStream === "function" &&
|
||||
typeof api?.appendRecordingChunk === "function"
|
||||
? api.openRecordingStream(fileName)
|
||||
: Promise.resolve({ success: false });
|
||||
|
||||
void openPromise.then(
|
||||
(result) => {
|
||||
if (result.success) {
|
||||
streamOpened = true;
|
||||
mode = "streaming";
|
||||
for (const chunk of memoryChunks) {
|
||||
enqueueWrite(chunk);
|
||||
}
|
||||
memoryChunks.length = 0;
|
||||
} else {
|
||||
mode = "buffering";
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// The IPC call itself rejected (channel or handler error). Treat it the
|
||||
// same as a failed open: keep buffering in memory so nothing is lost.
|
||||
mode = "buffering";
|
||||
},
|
||||
);
|
||||
|
||||
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
|
||||
recorder.ondataavailable = (event: BlobEvent) => {
|
||||
if (!event.data || event.data.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (mode === "streaming") {
|
||||
enqueueWrite(event.data);
|
||||
} else {
|
||||
// "pending" (stream not open yet) or "buffering" (not streaming).
|
||||
memoryChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onerror = () => {
|
||||
reject(new Error("Recording failed"));
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
resolve(finalizeBlob());
|
||||
};
|
||||
});
|
||||
|
||||
async function finalizeBlob(): Promise<Blob> {
|
||||
// Wait for the open attempt to settle so its flush (or fallback switch) has
|
||||
// been applied, then for every queued write to land, so we never resolve
|
||||
// while chunks are still in flight to the about-to-close disk stream.
|
||||
await openPromise.catch(() => undefined);
|
||||
await writeChain;
|
||||
if (appendError) {
|
||||
throw appendError;
|
||||
}
|
||||
if (mode === "streaming") {
|
||||
return new Blob([], { type: mimeType });
|
||||
}
|
||||
return new Blob(memoryChunks, { type: mimeType });
|
||||
}
|
||||
|
||||
async function discard(): Promise<void> {
|
||||
if (streamOpened && fileName && api?.closeRecordingStream) {
|
||||
await api.closeRecordingStream(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
recorder.start(RECORDER_TIMESLICE_MS);
|
||||
return {
|
||||
recorder,
|
||||
recordedBlobPromise,
|
||||
isStreaming: () => mode === "streaming" && !appendError,
|
||||
discard,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder";
|
||||
|
||||
let _audioCtx: AudioContext | null = null;
|
||||
/** Returns the shared AudioContext, creating it lazily on first call. */
|
||||
function getAudioCtx(): AudioContext {
|
||||
if (!_audioCtx) _audioCtx = new AudioContext();
|
||||
return _audioCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offloads peak computation to a Web Worker (zero-copy via Transferable).
|
||||
* Accepts an optional AbortSignal — if aborted, the worker is terminated
|
||||
* immediately and the promise rejects with an AbortError.
|
||||
*/
|
||||
function computePeaksInWorker(
|
||||
audioBuffer: AudioBuffer,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Float32Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const worker = new Worker(new URL("./audioPeaksWorker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
worker.terminate();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
// slice() creates an owned copy so the transfer is safe and the
|
||||
// AudioBuffer remains valid if anything else holds a reference.
|
||||
const channels: Float32Array[] = [];
|
||||
for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
|
||||
channels.push(audioBuffer.getChannelData(c).slice());
|
||||
}
|
||||
|
||||
worker.onmessage = (e: MessageEvent<Float32Array>) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
worker.terminate();
|
||||
resolve(e.data);
|
||||
};
|
||||
|
||||
worker.onerror = (e) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
worker.terminate();
|
||||
reject(e);
|
||||
};
|
||||
|
||||
worker.postMessage(
|
||||
{ channels, duration: audioBuffer.duration },
|
||||
channels.map((ch) => ch.buffer),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes audio from `videoUrl` and returns a Float32Array of paired
|
||||
* [min, max] peak values (length = 2 * N blocks). Returns `null` while
|
||||
* decoding is in progress, and stays `null` when the file has no audio
|
||||
* track or decoding fails (silent degradation).
|
||||
*
|
||||
* - File loading uses the Electron IPC bridge for local paths (same as the exporter).
|
||||
* - Peak computation runs in a Web Worker to avoid blocking the main thread.
|
||||
* - Results are cached in a ref scoped to the hook instance (survives re-renders
|
||||
* and waveform toggle off/on, but not component unmount).
|
||||
*/
|
||||
export function useAudioPeaks(videoUrl?: string): Float32Array | null {
|
||||
const cacheRef = useRef<Map<string, Float32Array>>(new Map());
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(() =>
|
||||
videoUrl ? (cacheRef.current.get(videoUrl) ?? null) : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl) {
|
||||
setPeaks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = cacheRef.current.get(videoUrl);
|
||||
if (cached) {
|
||||
setPeaks(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setPeaks(null);
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl);
|
||||
if (cancelled) return;
|
||||
const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer);
|
||||
if (cancelled) return;
|
||||
const p = await computePeaksInWorker(audioBuffer, controller.signal);
|
||||
if (cancelled) return;
|
||||
cacheRef.current.set(videoUrl, p);
|
||||
setPeaks(p);
|
||||
} catch (err) {
|
||||
// AbortError means the effect cleaned up — no state update needed.
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
// No audio track or unsupported format — clear stale data silently.
|
||||
if (!cancelled) setPeaks(null);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [videoUrl]);
|
||||
|
||||
return peaks;
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "@/components/video-editor/editorDefaults";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CropRegion,
|
||||
@@ -10,14 +15,7 @@ import type {
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import {
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
} from "@/components/video-editor/types";
|
||||
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
|
||||
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||
@@ -31,6 +29,7 @@ export interface EditorState {
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
showTrimWaveform: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
@@ -47,17 +46,18 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
speedRegions: [],
|
||||
annotationRegions: [],
|
||||
cropRegion: DEFAULT_CROP_REGION,
|
||||
wallpaper: DEFAULT_WALLPAPER,
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
borderRadius: 0,
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamPosition: DEFAULT_WEBCAM_POSITION,
|
||||
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity,
|
||||
showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur,
|
||||
showTrimWaveform: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform,
|
||||
motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount,
|
||||
borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius,
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
|
||||
};
|
||||
|
||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||
|
||||
+597
-116
@@ -2,12 +2,18 @@ import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import {
|
||||
type NativeMacRecordingRequest,
|
||||
parseMacDisplayIdFromSourceId,
|
||||
parseMacWindowIdFromSourceId,
|
||||
} from "@/lib/nativeMacRecording";
|
||||
import {
|
||||
type NativeWindowsRecordingRequest,
|
||||
parseWindowHandleFromSourceId,
|
||||
} from "@/lib/nativeWindowsRecording";
|
||||
import type { CursorCaptureMode } from "@/lib/recordingSession";
|
||||
import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession";
|
||||
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||
import { createRecorderHandle, type RecorderHandle } from "./recorderHandle";
|
||||
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
const MIN_FRAME_RATE = 30;
|
||||
@@ -29,7 +35,6 @@ const DEFAULT_HEIGHT = 1080;
|
||||
|
||||
const CODEC_ALIGNMENT = 2;
|
||||
|
||||
const RECORDER_TIMESLICE_MS = 1000;
|
||||
const BITS_PER_MEGABIT = 1_000_000;
|
||||
const CHROME_MEDIA_SOURCE = "desktop";
|
||||
const RECORDING_FILE_PREFIX = "recording-";
|
||||
@@ -40,8 +45,6 @@ const AUDIO_BITRATE_VOICE = 128_000;
|
||||
const AUDIO_BITRATE_SYSTEM = 192_000;
|
||||
|
||||
const MIC_GAIN_BOOST = 1.4;
|
||||
const WEBCAM_TARGET_WIDTH = 1280;
|
||||
const WEBCAM_TARGET_HEIGHT = 720;
|
||||
const WEBCAM_TARGET_FRAME_RATE = 30;
|
||||
|
||||
type UseScreenRecorderReturn = {
|
||||
@@ -50,6 +53,7 @@ type UseScreenRecorderReturn = {
|
||||
elapsedSeconds: number;
|
||||
toggleRecording: () => void;
|
||||
togglePaused: () => void;
|
||||
canPauseRecording: boolean;
|
||||
restartRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
microphoneEnabled: boolean;
|
||||
@@ -70,37 +74,18 @@ type UseScreenRecorderReturn = {
|
||||
setCursorCaptureMode: (mode: CursorCaptureMode) => void;
|
||||
};
|
||||
|
||||
type RecorderHandle = {
|
||||
recorder: MediaRecorder;
|
||||
recordedBlobPromise: Promise<Blob>;
|
||||
};
|
||||
|
||||
type NativeWindowsRecordingHandle = {
|
||||
recordingId: number;
|
||||
finalizing: boolean;
|
||||
paused: boolean;
|
||||
webcamRecorder: RecorderHandle | null;
|
||||
};
|
||||
|
||||
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
||||
const recorder = new MediaRecorder(stream, options);
|
||||
const chunks: Blob[] = [];
|
||||
const mimeType = options.mimeType || "video/webm";
|
||||
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
|
||||
recorder.ondataavailable = (event: BlobEvent) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onerror = () => {
|
||||
reject(new Error("Recording failed"));
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
resolve(new Blob(chunks, { type: mimeType }));
|
||||
};
|
||||
});
|
||||
|
||||
recorder.start(RECORDER_TIMESLICE_MS);
|
||||
return { recorder, recordedBlobPromise };
|
||||
}
|
||||
type NativeMacRecordingHandle = {
|
||||
recordingId: number;
|
||||
finalizing: boolean;
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const t = useScopedT("editor");
|
||||
@@ -118,6 +103,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const screenRecorder = useRef<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 +120,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [countdownActive, setCountdownActive] = useState(false);
|
||||
const webcamReady = useRef(false);
|
||||
const webcamAcquireId = useRef(0);
|
||||
const canPauseRecording =
|
||||
recording &&
|
||||
Boolean(
|
||||
(nativeWindowsRecording.current && !nativeWindowsRecording.current.finalizing) ||
|
||||
(nativeMacRecording.current && !nativeMacRecording.current.finalizing) ||
|
||||
(screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"),
|
||||
);
|
||||
|
||||
const getRecordingDurationMs = useCallback(() => {
|
||||
const segmentDuration =
|
||||
@@ -247,13 +240,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
video: webcamDeviceId
|
||||
? {
|
||||
deviceId: { exact: webcamDeviceId },
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
}
|
||||
: {
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
},
|
||||
});
|
||||
@@ -339,46 +328,65 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
window.electronAPI?.setRecordingState(false);
|
||||
|
||||
void (async () => {
|
||||
// Each disk stream must end up either saved or explicitly discarded.
|
||||
// store-recorded-session finalizes the streams included in a successful
|
||||
// save; the finally block discards everything else.
|
||||
let storeSucceeded = false;
|
||||
let webcamIncludedInSave = false;
|
||||
try {
|
||||
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
||||
if (discardRecordingId.current === activeRecordingId) {
|
||||
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
|
||||
return;
|
||||
}
|
||||
if (screenBlob.size === 0) {
|
||||
// When streaming succeeded the blob is empty — the data is already on disk.
|
||||
if (!activeScreenRecorder.isStreaming() && screenBlob.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
|
||||
let fixedWebcamBlob: Blob | null = null;
|
||||
if (activeWebcamRecorder) {
|
||||
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
||||
if (webcamBlob && webcamBlob.size > 0) {
|
||||
fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
||||
}
|
||||
}
|
||||
|
||||
const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
|
||||
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
||||
|
||||
// Only fix duration / convert to ArrayBuffer for in-memory data;
|
||||
// streamed recordings are patched on disk by the main process.
|
||||
let screenVideoData: ArrayBuffer = new ArrayBuffer(0);
|
||||
if (!activeScreenRecorder.isStreaming() && screenBlob.size > 0) {
|
||||
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
|
||||
screenVideoData = await fixedScreenBlob.arrayBuffer();
|
||||
}
|
||||
|
||||
let webcamVideoData: ArrayBuffer | undefined;
|
||||
if (activeWebcamRecorder) {
|
||||
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
||||
if (!activeWebcamRecorder.isStreaming() && webcamBlob && webcamBlob.size > 0) {
|
||||
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
||||
webcamVideoData = await fixedWebcamBlob.arrayBuffer();
|
||||
} else if (activeWebcamRecorder.isStreaming()) {
|
||||
webcamVideoData = new ArrayBuffer(0);
|
||||
}
|
||||
}
|
||||
webcamIncludedInSave = webcamVideoData !== undefined;
|
||||
|
||||
const result = await window.electronAPI.storeRecordedSession({
|
||||
screen: {
|
||||
videoData: await fixedScreenBlob.arrayBuffer(),
|
||||
videoData: screenVideoData,
|
||||
fileName: screenFileName,
|
||||
},
|
||||
webcam: fixedWebcamBlob
|
||||
? {
|
||||
videoData: await fixedWebcamBlob.arrayBuffer(),
|
||||
fileName: webcamFileName,
|
||||
}
|
||||
: undefined,
|
||||
webcam:
|
||||
webcamVideoData !== undefined
|
||||
? { videoData: webcamVideoData, fileName: webcamFileName }
|
||||
: undefined,
|
||||
createdAt: activeRecordingId,
|
||||
cursorCaptureMode,
|
||||
durationMs: duration,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Failed to store recording session:", result.message);
|
||||
return;
|
||||
}
|
||||
// store-recorded-session has flushed and closed the saved streams.
|
||||
storeSucceeded = true;
|
||||
|
||||
if (result.session) {
|
||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||
@@ -390,6 +398,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
} catch (error) {
|
||||
console.error("Error saving recording:", error);
|
||||
} finally {
|
||||
// Discard any recorder whose data was not part of a successful save
|
||||
// — a discarded run, a failed save, or a webcam whose disk write
|
||||
// failed (so it was omitted while the screen still saved) — so no
|
||||
// stream or partial file is left open or orphaned.
|
||||
if (!storeSucceeded) {
|
||||
await activeScreenRecorder.discard().catch(() => undefined);
|
||||
}
|
||||
if (activeWebcamRecorder && !(storeSucceeded && webcamIncludedInSave)) {
|
||||
await activeWebcamRecorder.discard().catch(() => undefined);
|
||||
}
|
||||
if (finalizingRecordingId.current === activeRecordingId) {
|
||||
finalizingRecordingId.current = null;
|
||||
}
|
||||
@@ -402,64 +420,215 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
[cursorCaptureMode, teardownMedia],
|
||||
);
|
||||
|
||||
const finalizeNativeWindowsRecording = useCallback(async (discard = false) => {
|
||||
const activeNativeRecording = nativeWindowsRecording.current;
|
||||
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeNativeRecording.finalizing = true;
|
||||
|
||||
const clearNativeRecordingState = () => {
|
||||
nativeWindowsRecording.current = null;
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
|
||||
if (discard || result.discarded) {
|
||||
clearNativeRecordingState();
|
||||
return true;
|
||||
const finalizeNativeWindowsRecording = useCallback(
|
||||
async (discard = false) => {
|
||||
const activeNativeRecording = nativeWindowsRecording.current;
|
||||
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
||||
return false;
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error("Failed to stop native Windows recording:", result.error);
|
||||
toast.error(result.error ?? "Failed to stop native Windows recording");
|
||||
|
||||
activeNativeRecording.finalizing = true;
|
||||
const activeWebcamRecorder = activeNativeRecording.webcamRecorder;
|
||||
const duration = Math.max(0, getRecordingDurationMs());
|
||||
if (
|
||||
activeWebcamRecorder?.recorder.state === "recording" ||
|
||||
activeWebcamRecorder?.recorder.state === "paused"
|
||||
) {
|
||||
try {
|
||||
activeWebcamRecorder.recorder.stop();
|
||||
} catch {
|
||||
// Recorder may already be stopping.
|
||||
}
|
||||
}
|
||||
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
|
||||
webcamRecorder.current = null;
|
||||
}
|
||||
|
||||
const clearNativeRecordingState = () => {
|
||||
nativeWindowsRecording.current = null;
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
|
||||
if (discard || result.discarded) {
|
||||
clearNativeRecordingState();
|
||||
return true;
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error("Failed to stop native Windows recording:", result.error);
|
||||
toast.error(result.error ?? "Failed to stop native Windows recording");
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const nativeScreenPath = result.session?.screenVideoPath ?? result.path;
|
||||
let storedSession = result.session;
|
||||
if (activeWebcamRecorder && nativeScreenPath) {
|
||||
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
||||
const screenRead = await window.electronAPI.readBinaryFile(nativeScreenPath);
|
||||
if (webcamBlob && webcamBlob.size > 0 && screenRead.success && screenRead.data) {
|
||||
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
||||
const nativeScreenFileName =
|
||||
nativeScreenPath.split(/[\\/]/).pop() ??
|
||||
`${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`;
|
||||
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
||||
const stored = await window.electronAPI.storeRecordedSession({
|
||||
screen: {
|
||||
videoData: screenRead.data,
|
||||
fileName: nativeScreenFileName,
|
||||
},
|
||||
webcam: {
|
||||
videoData: await fixedWebcamBlob.arrayBuffer(),
|
||||
fileName: webcamFileName,
|
||||
},
|
||||
createdAt: activeNativeRecording.recordingId,
|
||||
cursorCaptureMode,
|
||||
});
|
||||
if (stored.success && stored.session) {
|
||||
storedSession = stored.session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearNativeRecordingState();
|
||||
if (storedSession) {
|
||||
await window.electronAPI.setCurrentRecordingSession(storedSession);
|
||||
} else if (result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error saving native Windows recording:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save native Windows recording",
|
||||
);
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
} finally {
|
||||
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
||||
discardRecordingId.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[cursorCaptureMode, getRecordingDurationMs],
|
||||
);
|
||||
|
||||
const finalizeNativeMacRecording = useCallback(
|
||||
async (discard = false) => {
|
||||
const activeNativeRecording = nativeMacRecording.current;
|
||||
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearNativeRecordingState();
|
||||
if (result.session) {
|
||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||
} else if (result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
activeNativeRecording.finalizing = true;
|
||||
const duration = Math.max(0, getRecordingDurationMs());
|
||||
const activeWebcamRecorder = webcamRecorder.current;
|
||||
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
|
||||
webcamRecorder.current = null;
|
||||
}
|
||||
const webcamAssetPromise = (async (): Promise<RecordedVideoAssetInput | undefined> => {
|
||||
if (!activeWebcamRecorder) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error saving native Windows recording:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save native Windows recording",
|
||||
);
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
} finally {
|
||||
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
||||
discardRecordingId.current = null;
|
||||
try {
|
||||
if (activeWebcamRecorder.recorder.state !== "inactive") {
|
||||
activeWebcamRecorder.recorder.stop();
|
||||
}
|
||||
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise;
|
||||
if (!webcamBlob || webcamBlob.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
||||
return {
|
||||
videoData: await fixedWebcamBlob.arrayBuffer(),
|
||||
fileName: `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to finalize native macOS webcam recording:", error);
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
const clearNativeRecordingState = () => {
|
||||
nativeMacRecording.current = null;
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.stopNativeMacRecording(discard);
|
||||
const webcamAsset = await webcamAssetPromise;
|
||||
if (discard || result.discarded) {
|
||||
clearNativeRecordingState();
|
||||
return true;
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error("Failed to stop native macOS recording:", result.error);
|
||||
toast.error(result.error ?? "Failed to stop native macOS recording");
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (webcamAsset && result.path) {
|
||||
const attachResult = await window.electronAPI.attachNativeMacWebcamRecording({
|
||||
screenVideoPath: result.path,
|
||||
recordingId: activeNativeRecording.recordingId,
|
||||
webcam: webcamAsset,
|
||||
cursorCaptureMode,
|
||||
});
|
||||
if (attachResult.success) {
|
||||
result.session = attachResult.session;
|
||||
} else {
|
||||
console.error("Failed to attach native macOS webcam recording:", attachResult.error);
|
||||
toast.error(attachResult.error ?? "Failed to store webcam recording");
|
||||
}
|
||||
}
|
||||
|
||||
clearNativeRecordingState();
|
||||
if (result.session) {
|
||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||
} else if (result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error saving native macOS recording:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save native macOS recording",
|
||||
);
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
} finally {
|
||||
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
||||
discardRecordingId.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[cursorCaptureMode, getRecordingDurationMs],
|
||||
);
|
||||
|
||||
const stopRecording = useRef(() => {
|
||||
if (nativeWindowsRecording.current) {
|
||||
void finalizeNativeWindowsRecording(false);
|
||||
return;
|
||||
}
|
||||
if (nativeMacRecording.current) {
|
||||
void finalizeNativeMacRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder) {
|
||||
@@ -529,6 +698,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (nativeWindowsRecording.current) {
|
||||
void finalizeNativeWindowsRecording(true);
|
||||
}
|
||||
if (nativeMacRecording.current) {
|
||||
void finalizeNativeMacRecording(true);
|
||||
}
|
||||
|
||||
if (
|
||||
screenRecorder.current?.recorder.state === "recording" ||
|
||||
@@ -554,7 +726,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
webcamRecorder.current = null;
|
||||
teardownMedia();
|
||||
};
|
||||
}, [teardownMedia, safeHideCountdownOverlay, finalizeNativeWindowsRecording]);
|
||||
}, [
|
||||
teardownMedia,
|
||||
safeHideCountdownOverlay,
|
||||
finalizeNativeWindowsRecording,
|
||||
finalizeNativeMacRecording,
|
||||
]);
|
||||
|
||||
const safeShowCountdownOverlay = async (value: number, runId: number) => {
|
||||
try {
|
||||
@@ -584,6 +761,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const isCountdownRunActive = (runId?: number) =>
|
||||
runId === undefined || countdownRunId.current === runId;
|
||||
|
||||
const waitForWebcamReady = async () => {
|
||||
if (webcamReady.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (webcamReady.current) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
};
|
||||
|
||||
const startNativeWindowsRecordingIfAvailable = async (
|
||||
selectedSource: ProcessedDesktopSource,
|
||||
countdownRunToken?: number,
|
||||
@@ -599,12 +795,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (availability.reason === "unsupported-os") {
|
||||
return false;
|
||||
}
|
||||
if (availability.reason === "missing-helper") {
|
||||
console.warn("Native Windows capture helper is not available; using browser capture.");
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
availability.reason === "missing-helper"
|
||||
? "Native Windows capture helper is not available."
|
||||
: (availability.error ?? "Native Windows capture is not available."),
|
||||
);
|
||||
throw new Error(availability.error ?? "Native Windows capture is not available.");
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
@@ -616,6 +812,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
|
||||
const windowHandle = parseWindowHandleFromSourceId(selectedSource.id);
|
||||
if (webcamEnabled) {
|
||||
await waitForWebcamReady();
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const browserWebcamRecorder =
|
||||
webcamEnabled && webcamStream.current
|
||||
? createRecorderHandle(webcamStream.current, {
|
||||
mimeType: selectMimeType(),
|
||||
videoBitsPerSecond: BITRATE_BASE,
|
||||
})
|
||||
: null;
|
||||
if (webcamEnabled && !browserWebcamRecorder) {
|
||||
stopWebcamPreviewStream();
|
||||
}
|
||||
const request: NativeWindowsRecordingRequest = {
|
||||
@@ -643,11 +852,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
},
|
||||
},
|
||||
webcam: {
|
||||
enabled: webcamEnabled,
|
||||
enabled: webcamEnabled && !browserWebcamRecorder,
|
||||
deviceId: webcamDeviceId,
|
||||
deviceName: webcamDeviceName,
|
||||
width: WEBCAM_TARGET_WIDTH,
|
||||
height: WEBCAM_TARGET_HEIGHT,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fps: WEBCAM_TARGET_FRAME_RATE,
|
||||
},
|
||||
cursor: {
|
||||
@@ -656,6 +865,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
const result = await window.electronAPI.startNativeWindowsRecording(request);
|
||||
if (!result.success || !result.recordingId) {
|
||||
if (
|
||||
browserWebcamRecorder?.recorder.state === "recording" ||
|
||||
browserWebcamRecorder?.recorder.state === "paused"
|
||||
) {
|
||||
browserWebcamRecorder.recorder.stop();
|
||||
}
|
||||
throw new Error(result.error ?? "Native Windows capture failed.");
|
||||
}
|
||||
|
||||
@@ -663,7 +878,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
nativeWindowsRecording.current = {
|
||||
recordingId: result.recordingId,
|
||||
finalizing: false,
|
||||
paused: false,
|
||||
webcamRecorder: browserWebcamRecorder,
|
||||
};
|
||||
webcamRecorder.current = browserWebcamRecorder;
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
@@ -677,6 +895,150 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
};
|
||||
|
||||
const startNativeMacRecordingIfAvailable = async (
|
||||
selectedSource: ProcessedDesktopSource,
|
||||
countdownRunToken?: number,
|
||||
) => {
|
||||
try {
|
||||
const platform = await window.electronAPI.getPlatform();
|
||||
if (platform !== "darwin") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const availability = await window.electronAPI.isNativeMacCaptureAvailable();
|
||||
if (!availability.success || !availability.available) {
|
||||
if (availability.reason === "unsupported-platform") {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
availability.reason === "missing-helper"
|
||||
? "Native macOS capture helper is not available."
|
||||
: (availability.error ?? "Native macOS capture is not available."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const activeRecordingId = Date.now();
|
||||
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
|
||||
const displayId =
|
||||
Number(selectedSource.display_id) || parseMacDisplayIdFromSourceId(selectedSource.id);
|
||||
const windowId = parseMacWindowIdFromSourceId(selectedSource.id);
|
||||
let nativeWebcamRecorder: RecorderHandle | null = null;
|
||||
if (webcamEnabled) {
|
||||
if (!webcamReady.current) {
|
||||
await new Promise<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: 0,
|
||||
height: 0,
|
||||
fps: WEBCAM_TARGET_FRAME_RATE,
|
||||
},
|
||||
cursor: {
|
||||
mode: cursorCaptureMode,
|
||||
},
|
||||
outputs: {
|
||||
screenPath: "",
|
||||
},
|
||||
};
|
||||
const result = await window.electronAPI.startNativeMacRecording(request);
|
||||
if (!result.success || !result.recordingId) {
|
||||
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
|
||||
nativeWebcamRecorder.recorder.stop();
|
||||
}
|
||||
throw new Error(result.error ?? "Native macOS capture failed.");
|
||||
}
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
|
||||
nativeWebcamRecorder.recorder.stop();
|
||||
}
|
||||
await window.electronAPI.stopNativeMacRecording(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
recordingId.current = result.recordingId;
|
||||
nativeMacRecording.current = {
|
||||
recordingId: result.recordingId,
|
||||
finalizing: false,
|
||||
paused: false,
|
||||
};
|
||||
webcamRecorder.current = nativeWebcamRecorder;
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
setRecording(true);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Native macOS capture failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const startRecordCountdown = async () => {
|
||||
if (countdownActive || recording) {
|
||||
return;
|
||||
@@ -684,7 +1046,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
const runId = countdownRunId.current + 1;
|
||||
countdownRunId.current = runId;
|
||||
setCountdownActive(true);
|
||||
|
||||
let selectedSource: ProcessedDesktopSource | null = null;
|
||||
try {
|
||||
@@ -705,6 +1066,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const platform = await window.electronAPI.getPlatform();
|
||||
if (platform === "darwin" && cursorCaptureMode === "editable-overlay") {
|
||||
const access = await window.electronAPI.requestNativeMacCursorAccess();
|
||||
if (!access.granted) {
|
||||
toast.info(t("recording.accessibilityAllowAndRetry"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to preflight macOS cursor accessibility before countdown:", error);
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(runId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCountdownActive(true);
|
||||
|
||||
let overlayHiddenBeforeStart = false;
|
||||
try {
|
||||
const values = [3, 2, 1];
|
||||
@@ -767,6 +1147,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) {
|
||||
return;
|
||||
}
|
||||
if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let screenMediaStream: MediaStream;
|
||||
const platform = await window.electronAPI.getPlatform();
|
||||
@@ -955,13 +1338,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
recordingId.current = Date.now();
|
||||
const activeRecordingId = recordingId.current;
|
||||
screenRecorder.current = createRecorderHandle(stream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
...(hasAudio
|
||||
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
||||
: {}),
|
||||
});
|
||||
screenRecorder.current = createRecorderHandle(
|
||||
stream.current,
|
||||
{
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
...(hasAudio
|
||||
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
||||
: {}),
|
||||
},
|
||||
`${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`,
|
||||
);
|
||||
screenRecorder.current.recorder.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
@@ -971,10 +1358,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
);
|
||||
|
||||
if (webcamStream.current) {
|
||||
webcamRecorder.current = createRecorderHandle(webcamStream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE),
|
||||
});
|
||||
webcamRecorder.current = createRecorderHandle(
|
||||
webcamStream.current,
|
||||
{ mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE) },
|
||||
`${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`,
|
||||
);
|
||||
}
|
||||
|
||||
accumulatedDurationMs.current = 0;
|
||||
@@ -1024,6 +1412,79 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
|
||||
const togglePaused = () => {
|
||||
const activeNativeWindowsRecording = nativeWindowsRecording.current;
|
||||
if (activeNativeWindowsRecording && !activeNativeWindowsRecording.finalizing) {
|
||||
void (async () => {
|
||||
try {
|
||||
if (activeNativeWindowsRecording.paused) {
|
||||
const result = await window.electronAPI.resumeNativeWindowsRecording();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to resume native Windows recording");
|
||||
}
|
||||
activeNativeWindowsRecording.paused = false;
|
||||
segmentStartedAt.current = Date.now();
|
||||
setPaused(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const pausedAtMs = getRecordingDurationMs();
|
||||
const result = await window.electronAPI.pauseNativeWindowsRecording();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to pause native Windows recording");
|
||||
}
|
||||
activeNativeWindowsRecording.paused = true;
|
||||
accumulatedDurationMs.current = pausedAtMs;
|
||||
segmentStartedAt.current = null;
|
||||
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
|
||||
setPaused(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle native Windows pause state:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to toggle pause state");
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeNativeMacRecording = nativeMacRecording.current;
|
||||
if (activeNativeMacRecording && !activeNativeMacRecording.finalizing) {
|
||||
void (async () => {
|
||||
const activeWebcamRecorder = webcamRecorder.current?.recorder;
|
||||
try {
|
||||
if (activeNativeMacRecording.paused) {
|
||||
const result = await window.electronAPI.resumeNativeMacRecording();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to resume native macOS recording");
|
||||
}
|
||||
if (activeWebcamRecorder?.state === "paused") {
|
||||
activeWebcamRecorder.resume();
|
||||
}
|
||||
activeNativeMacRecording.paused = false;
|
||||
segmentStartedAt.current = Date.now();
|
||||
setPaused(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const pausedAtMs = getRecordingDurationMs();
|
||||
const result = await window.electronAPI.pauseNativeMacRecording();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to pause native macOS recording");
|
||||
}
|
||||
if (activeWebcamRecorder?.state === "recording") {
|
||||
activeWebcamRecorder.pause();
|
||||
}
|
||||
activeNativeMacRecording.paused = true;
|
||||
accumulatedDurationMs.current = pausedAtMs;
|
||||
segmentStartedAt.current = null;
|
||||
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
|
||||
setPaused(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle native macOS pause state:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to toggle pause state");
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current?.recorder;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") {
|
||||
return;
|
||||
@@ -1092,6 +1553,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (nativeMacRecording.current) {
|
||||
const activeRecordingId = recordingId.current;
|
||||
restarting.current = true;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
try {
|
||||
await finalizeNativeMacRecording(true);
|
||||
await startRecording();
|
||||
} finally {
|
||||
restarting.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
|
||||
@@ -1157,6 +1630,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
void finalizeNativeWindowsRecording(true);
|
||||
return;
|
||||
}
|
||||
if (nativeMacRecording.current) {
|
||||
const activeRecordingId = recordingId.current;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
void finalizeNativeMacRecording(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (
|
||||
@@ -1183,6 +1663,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
elapsedSeconds,
|
||||
toggleRecording,
|
||||
togglePaused,
|
||||
canPauseRecording,
|
||||
restartRecording,
|
||||
cancelRecording,
|
||||
microphoneEnabled,
|
||||
|
||||
@@ -4,6 +4,7 @@ import arDialogs from "@/i18n/locales/ar/dialogs.json";
|
||||
import enDialogs from "@/i18n/locales/en/dialogs.json";
|
||||
import esDialogs from "@/i18n/locales/es/dialogs.json";
|
||||
import frDialogs from "@/i18n/locales/fr/dialogs.json";
|
||||
import itDialogs from "@/i18n/locales/it/dialogs.json";
|
||||
import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json";
|
||||
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
|
||||
import ruDialogs from "@/i18n/locales/ru/dialogs.json";
|
||||
@@ -39,16 +40,17 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des
|
||||
|
||||
const dialogsByLocale = {
|
||||
en: enDialogs,
|
||||
"zh-CN": zhCNDialogs,
|
||||
"zh-TW": zhTWDialogs,
|
||||
ar: arDialogs,
|
||||
es: esDialogs,
|
||||
fr: frDialogs,
|
||||
tr: trDialogs,
|
||||
it: itDialogs,
|
||||
"ja-JP": jaJPDialogs,
|
||||
"ko-KR": koKRDialogs,
|
||||
ru: ruDialogs,
|
||||
"ja-JP": jaJPDialogs,
|
||||
ar: arDialogs,
|
||||
tr: trDialogs,
|
||||
vi: viDialogs,
|
||||
"zh-CN": zhCNDialogs,
|
||||
"zh-TW": zhTWDialogs,
|
||||
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
|
||||
|
||||
describe("TutorialHelp translations", () => {
|
||||
|
||||
+6
-5
@@ -1,17 +1,18 @@
|
||||
export const DEFAULT_LOCALE = "en" as const;
|
||||
export const SUPPORTED_LOCALES = [
|
||||
"en",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"ar",
|
||||
"es",
|
||||
"fr",
|
||||
"tr",
|
||||
"ko-KR",
|
||||
"it",
|
||||
"ja-JP",
|
||||
"ar",
|
||||
"ko-KR",
|
||||
"ru",
|
||||
"tr",
|
||||
"vi",
|
||||
"pt-BR",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
|
||||
@@ -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": "استخدام مؤشر النظام"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"zoom": {
|
||||
"previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
|
||||
"level": "مستوى التكبير",
|
||||
"selectRegion": "حدد منطقة التكبير للتعديل",
|
||||
"deleteZoom": "حذف التكبير",
|
||||
@@ -8,6 +9,21 @@
|
||||
"manual": "يدوي",
|
||||
"auto": "تلقائي",
|
||||
"autoDescription": "الكاميرا تتبع موضع المؤشر المسجل"
|
||||
},
|
||||
"customScale": "تكبير مخصص",
|
||||
"position": {
|
||||
"title": "موضع التركيز",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "دوران ثلاثي الأبعاد",
|
||||
"preset": {
|
||||
"iso": "متساوي القياس",
|
||||
"left": "يسار",
|
||||
"right": "يمين"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -28,7 +44,8 @@
|
||||
"verticalStack": "تكدس عمودي",
|
||||
"dualFrame": "إطار مزدوج",
|
||||
"webcamShape": "شكل الكاميرا",
|
||||
"webcamSize": "حجم كاميرا الويب"
|
||||
"webcamSize": "حجم كاميرا الويب",
|
||||
"noWebcam": "بدون كاميرا"
|
||||
},
|
||||
"effects": {
|
||||
"title": "تأثيرات الفيديو",
|
||||
@@ -69,10 +86,10 @@
|
||||
"gifDescription": "صورة متحركة للمشاركة"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "جودة التصدير",
|
||||
"low": "منخفضة",
|
||||
"medium": "متوسطة",
|
||||
"high": "عالية"
|
||||
"title": "دقة التصدير",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "معدل إطارات GIF",
|
||||
@@ -88,8 +105,9 @@
|
||||
"gifButton": "تصدير GIF",
|
||||
"chooseSaveLocation": "اختيار موقع الحفظ"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "الإبلاغ عن خطأ",
|
||||
"saveDiagnostics": "حفظ التشخيصات",
|
||||
"starOnGithub": "إعطاء نجمة على GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
@@ -175,7 +193,19 @@
|
||||
"errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
|
||||
"errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google."
|
||||
},
|
||||
"cursor": {
|
||||
"show": "إظهار المؤشر",
|
||||
"size": "الحجم",
|
||||
"smoothing": "التنعيم",
|
||||
"motionBlur": "ضبابية الحركة",
|
||||
"clickBounce": "ارتداد النقر",
|
||||
"clipToBounds": "القص ضمن اللوحة"
|
||||
},
|
||||
"language": {
|
||||
"title": "اللغة"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "المخطط الزمني",
|
||||
"waveform": "عرض الموجة الصوتية على مسار القطع"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.",
|
||||
"savedToast": "تم حفظ اختصارات لوحة المفاتيح",
|
||||
"resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق",
|
||||
"registrationFailed": "فشل في تسجيل الاختصار. قد يكون مستخدمًا من قبل تطبيق آخر. جرب مفتاحًا مختلفًا.",
|
||||
"actions": {
|
||||
"openApp": "فتح التطبيق",
|
||||
"addZoom": "إضافة تكبير",
|
||||
"addTrim": "إضافة قص",
|
||||
"addSpeed": "إضافة سرعة",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"zoom": {
|
||||
"previewHold": "Hold to preview zoom effect",
|
||||
"level": "Zoom Level",
|
||||
"customScale": "Custom Zoom",
|
||||
"selectRegion": "Select a zoom region to adjust",
|
||||
@@ -85,10 +86,10 @@
|
||||
"gifDescription": "Animated image for sharing"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Export Quality",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
"title": "Export resolution",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF Frame Rate",
|
||||
@@ -104,13 +105,14 @@
|
||||
"gifButton": "Export GIF",
|
||||
"chooseSaveLocation": "Choose Save Location"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Report Bug",
|
||||
"saveDiagnostics": "Save Diagnostics",
|
||||
"starOnGithub": "Star on GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Invalid file type",
|
||||
"jpgOnly": "Please upload a JPG or JPEG image file.",
|
||||
"jpgOnly": "Please upload a JPG, JPEG, or PNG image file.",
|
||||
"uploadSuccess": "Custom image uploaded successfully!",
|
||||
"failedToUpload": "Failed to upload image",
|
||||
"errorReading": "There was an error reading the file."
|
||||
@@ -191,7 +193,19 @@
|
||||
"errorTimeout": "Font took too long to load. Please check the URL and try again.",
|
||||
"errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct."
|
||||
},
|
||||
"cursor": {
|
||||
"show": "Show Cursor",
|
||||
"size": "Size",
|
||||
"smoothing": "Smoothing",
|
||||
"motionBlur": "Motion Blur",
|
||||
"clickBounce": "Click Bounce",
|
||||
"clipToBounds": "Clip to Canvas"
|
||||
},
|
||||
"language": {
|
||||
"title": "Language"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
"waveform": "Show Audio Waveform on Trim Track"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.",
|
||||
"savedToast": "Keyboard shortcuts saved",
|
||||
"resetToast": "Reset to default shortcuts — click Save to apply",
|
||||
"registrationFailed": "Failed to register shortcut. It may be in use by another app. Try a different key.",
|
||||
"actions": {
|
||||
"openApp": "Open App",
|
||||
"addZoom": "Add Zoom",
|
||||
"addTrim": "Add Trim",
|
||||
"addSpeed": "Add Speed",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"zoom": {
|
||||
"previewHold": "Mantener para previsualizar el efecto de zoom",
|
||||
"level": "Nivel de zoom",
|
||||
"selectRegion": "Selecciona una región de zoom para ajustar",
|
||||
"deleteZoom": "Eliminar zoom",
|
||||
@@ -16,6 +17,13 @@
|
||||
"left": "Izquierda",
|
||||
"right": "Derecha"
|
||||
}
|
||||
},
|
||||
"customScale": "Zoom personalizado",
|
||||
"position": {
|
||||
"title": "Posición de enfoque",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = extremo izquierdo / superior, 100 = extremo derecho / inferior"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -36,7 +44,8 @@
|
||||
"verticalStack": "Apilado vertical",
|
||||
"dualFrame": "Marco dual",
|
||||
"webcamShape": "Forma de cámara",
|
||||
"webcamSize": "Tamaño de cámara"
|
||||
"webcamSize": "Tamaño de cámara",
|
||||
"noWebcam": "Sin cámara"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Efectos de video",
|
||||
@@ -45,7 +54,8 @@
|
||||
"off": "desactivado",
|
||||
"shadow": "Sombra",
|
||||
"roundness": "Redondez",
|
||||
"padding": "Relleno"
|
||||
"padding": "Relleno",
|
||||
"on": "activado"
|
||||
},
|
||||
"background": {
|
||||
"title": "Fondo",
|
||||
@@ -76,10 +86,10 @@
|
||||
"gifDescription": "Imagen animada para compartir"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Calidad de exportación",
|
||||
"low": "Baja",
|
||||
"medium": "Media",
|
||||
"high": "Alta"
|
||||
"title": "Resolución de exportación",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "Velocidad de cuadros del GIF",
|
||||
@@ -95,13 +105,14 @@
|
||||
"gifButton": "Exportar GIF",
|
||||
"chooseSaveLocation": "Elegir ubicación de guardado"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Reportar error",
|
||||
"saveDiagnostics": "Guardar diagnósticos",
|
||||
"starOnGithub": "Dar estrella en GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Tipo de archivo no válido",
|
||||
"jpgOnly": "Por favor sube un archivo de imagen JPG o JPEG.",
|
||||
"jpgOnly": "Por favor sube un archivo de imagen JPG, JPEG o PNG.",
|
||||
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
|
||||
"failedToUpload": "Error al subir la imagen",
|
||||
"errorReading": "Hubo un error al leer el archivo."
|
||||
@@ -182,7 +193,19 @@
|
||||
"errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
|
||||
"errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta."
|
||||
},
|
||||
"cursor": {
|
||||
"show": "Mostrar cursor",
|
||||
"size": "Tamaño",
|
||||
"smoothing": "Suavizado",
|
||||
"motionBlur": "Desenfoque de movimiento",
|
||||
"clickBounce": "Rebote al clic",
|
||||
"clipToBounds": "Recortar al lienzo"
|
||||
},
|
||||
"language": {
|
||||
"title": "Idioma"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Línea de tiempo",
|
||||
"waveform": "Mostrar forma de onda en pista de recorte"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.",
|
||||
"savedToast": "Atajos de teclado guardados",
|
||||
"resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar",
|
||||
"registrationFailed": "Error al registrar el atajo. Puede estar en uso por otra aplicación. Prueba otra tecla.",
|
||||
"actions": {
|
||||
"openApp": "Abrir aplicación",
|
||||
"addZoom": "Agregar zoom",
|
||||
"addTrim": "Agregar recorte",
|
||||
"addSpeed": "Agregar velocidad",
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"zoom": {
|
||||
"previewHold": "Maintenir pour prévisualiser l’effet de zoom",
|
||||
"level": "Niveau de zoom",
|
||||
"selectRegion": "Sélectionnez une région de zoom à ajuster",
|
||||
"deleteZoom": "Supprimer le zoom",
|
||||
@@ -9,13 +10,7 @@
|
||||
"auto": "Auto",
|
||||
"autoDescription": "La caméra suit la position du curseur enregistré"
|
||||
},
|
||||
"speed": {
|
||||
"title": "Vitesse du zoom",
|
||||
"instant": "Instantané",
|
||||
"fast": "Rapide",
|
||||
"smooth": "Fluide",
|
||||
"lazy": "Lent"
|
||||
},
|
||||
"speed": {},
|
||||
"threeD": {
|
||||
"title": "Rotation 3D",
|
||||
"preset": {
|
||||
@@ -23,6 +18,13 @@
|
||||
"left": "Gauche",
|
||||
"right": "Droite"
|
||||
}
|
||||
},
|
||||
"customScale": "Zoom personnalisé",
|
||||
"position": {
|
||||
"title": "Position du focus",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = tout à gauche / en haut, 100 = tout à droite / en bas"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -43,7 +45,8 @@
|
||||
"verticalStack": "Empilement vertical",
|
||||
"dualFrame": "Double cadre",
|
||||
"webcamShape": "Forme de la caméra",
|
||||
"webcamSize": "Taille de la caméra"
|
||||
"webcamSize": "Taille de la caméra",
|
||||
"noWebcam": "Sans webcam"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Effets vidéo",
|
||||
@@ -52,7 +55,8 @@
|
||||
"off": "désactivé",
|
||||
"shadow": "Ombre",
|
||||
"roundness": "Arrondi",
|
||||
"padding": "Marge"
|
||||
"padding": "Marge",
|
||||
"on": "activé"
|
||||
},
|
||||
"background": {
|
||||
"title": "Arrière-plan",
|
||||
@@ -83,10 +87,10 @@
|
||||
"gifDescription": "Image animée pour le partage"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Qualité d'export",
|
||||
"low": "Faible",
|
||||
"medium": "Moyenne",
|
||||
"high": "Haute"
|
||||
"title": "Résolution d'export",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "Fréquence d'images GIF",
|
||||
@@ -102,13 +106,14 @@
|
||||
"gifButton": "Exporter le GIF",
|
||||
"chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Signaler un bug",
|
||||
"saveDiagnostics": "Enregistrer les diagnostics",
|
||||
"starOnGithub": "Étoile sur GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Type de fichier invalide",
|
||||
"jpgOnly": "Veuillez téléverser un fichier image JPG ou JPEG.",
|
||||
"jpgOnly": "Veuillez téléverser un fichier image JPG, JPEG ou PNG.",
|
||||
"uploadSuccess": "Image personnalisée téléversée avec succès !",
|
||||
"failedToUpload": "Échec du téléversement de l'image",
|
||||
"errorReading": "Une erreur s'est produite lors de la lecture du fichier."
|
||||
@@ -189,7 +194,19 @@
|
||||
"errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
|
||||
"errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte."
|
||||
},
|
||||
"cursor": {
|
||||
"show": "Afficher le curseur",
|
||||
"size": "Taille",
|
||||
"smoothing": "Lissage",
|
||||
"motionBlur": "Flou de mouvement",
|
||||
"clickBounce": "Rebond au clic",
|
||||
"clipToBounds": "Rogner au canevas"
|
||||
},
|
||||
"language": {
|
||||
"title": "Langue"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Montage",
|
||||
"waveform": "Afficher la forme d'onde sur la piste de découpe"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.",
|
||||
"savedToast": "Raccourcis clavier enregistrés",
|
||||
"resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer",
|
||||
"registrationFailed": "Échec de l'enregistrement du raccourci. Il est peut-être utilisé par une autre application. Essayez une autre touche.",
|
||||
"actions": {
|
||||
"openApp": "Ouvrir l'application",
|
||||
"addZoom": "Ajouter un zoom",
|
||||
"addTrim": "Ajouter une coupe",
|
||||
"addSpeed": "Ajouter une vitesse",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva",
|
||||
"delete": "Elimina",
|
||||
"close": "Chiudi",
|
||||
"share": "Condividi",
|
||||
"done": "Fatto",
|
||||
"open": "Apri",
|
||||
"upload": "Carica",
|
||||
"export": "Esporta",
|
||||
"showInFolder": "Mostra nella cartella",
|
||||
"file": "File",
|
||||
"edit": "Modifica",
|
||||
"view": "Visualizza",
|
||||
"window": "Finestra",
|
||||
"quit": "Esci",
|
||||
"stopRecording": "Interrompi registrazione",
|
||||
"undo": "Annulla",
|
||||
"redo": "Ripeti",
|
||||
"cut": "Taglia",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"selectAll": "Seleziona tutto",
|
||||
"minimize": "Riduci a icona",
|
||||
"reload": "Ricarica",
|
||||
"forceReload": "Forza ricarica",
|
||||
"toggleDevTools": "Attiva/disattiva strumenti sviluppatore",
|
||||
"actualSize": "Dimensione effettiva",
|
||||
"zoomIn": "Ingrandisci",
|
||||
"zoomOut": "Riduci",
|
||||
"toggleFullScreen": "Attiva/disattiva schermo intero",
|
||||
"recordingStatus": "Registrazione: {{source}}",
|
||||
"about": "Info su OpenScreen",
|
||||
"services": "Servizi",
|
||||
"hide": "Nascondi OpenScreen",
|
||||
"hideOthers": "Nascondi gli altri",
|
||||
"unhide": "Mostra tutto"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Riproduci",
|
||||
"pause": "Pausa",
|
||||
"fullscreen": "Schermo intero",
|
||||
"exitFullscreen": "Esci dallo schermo intero"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Italiano",
|
||||
"short": "IT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "Esportazione completata",
|
||||
"yourFormatReady": "Il tuo {{format}} è pronto",
|
||||
"showInFolder": "Mostra nella cartella",
|
||||
"finalizingVideo": "Finalizzazione esportazione video...",
|
||||
"compilingGifProgress": "Compilazione GIF... {{progress}}%",
|
||||
"compilingGifWait": "Compilazione GIF... Potrebbe richiedere del tempo",
|
||||
"takeMoment": "Ci potrebbe impiegare un momento...",
|
||||
"failed": "Esportazione fallita",
|
||||
"tryAgain": "Riprova",
|
||||
"finalizingVideoTitle": "Finalizzazione video",
|
||||
"compilingGif": "Compilazione GIF",
|
||||
"exportingFormat": "Esportazione {{format}}",
|
||||
"compiling": "Compilazione",
|
||||
"renderingFrames": "Rendering fotogrammi",
|
||||
"processing": "Elaborazione...",
|
||||
"finalizing": "Finalizzazione...",
|
||||
"compilingStatus": "Compilazione...",
|
||||
"status": "Stato",
|
||||
"format": "Formato",
|
||||
"frames": "Fotogrammi",
|
||||
"cancelExport": "Annulla esportazione",
|
||||
"savedSuccessfully": "{{format}} salvato con successo!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "Come funziona il taglio",
|
||||
"title": "Come funziona il taglio",
|
||||
"description": "Capire come eliminare le parti indesiderate del tuo video.",
|
||||
"explanationBefore": "Lo strumento Taglia funziona definendo i segmenti che vuoi",
|
||||
"remove": "rimuovere",
|
||||
"explanationMiddle": " — tutto ciò",
|
||||
"covered": "coperto",
|
||||
"explanationAfter": "da un segmento rosso verrà eliminato durante l'esportazione.",
|
||||
"visualExample": "Esempio visivo",
|
||||
"removed": "RIMOSSO",
|
||||
"kept": "Mantenuto",
|
||||
"part1": "Parte 1",
|
||||
"part2": "Parte 2",
|
||||
"part3": "Parte 3",
|
||||
"finalVideo": "Video finale",
|
||||
"step1Title": "1. Aggiungi taglio",
|
||||
"step1DescriptionBefore": "Premi ",
|
||||
"step1DescriptionAfter": " o clicca sull'icona delle forbici per contrassegnare una sezione da rimuovere.",
|
||||
"step2Title": "2. Regolazione",
|
||||
"step2Description": "Trascina i bordi della regione rossa per coprire esattamente ciò che vuoi eliminare."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Modifiche non salvate",
|
||||
"message": "Hai delle modifiche non salvate.",
|
||||
"detail": "Vuoi salvare il progetto prima di chiudere?",
|
||||
"saveAndClose": "Salva e chiudi",
|
||||
"discardAndClose": "Scarta e chiudi",
|
||||
"loadProject": "Carica progetto…",
|
||||
"saveProject": "Salva progetto…",
|
||||
"saveProjectAs": "Salva progetto come…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "Salva GIF esportata",
|
||||
"saveVideo": "Salva video esportato",
|
||||
"selectVideo": "Seleziona file video",
|
||||
"saveProject": "Salva progetto OpenScreen",
|
||||
"openProject": "Apri progetto OpenScreen",
|
||||
"gifImage": "Immagine GIF",
|
||||
"mp4Video": "Video MP4",
|
||||
"videoFiles": "File video",
|
||||
"openscreenProject": "Progetto OpenScreen",
|
||||
"allFiles": "Tutti i file"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "Torna al registratore",
|
||||
"description": "La sessione corrente è stata salvata.",
|
||||
"cancel": "Annulla",
|
||||
"confirm": "Conferma"
|
||||
},
|
||||
"loadingVideo": "Caricamento video...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "Nessun video caricato",
|
||||
"videoNotReady": "Video non pronto",
|
||||
"unableToDetermineSourcePath": "Impossibile determinare il percorso del video sorgente",
|
||||
"failedToSaveGif": "Impossibile salvare la GIF",
|
||||
"gifExportFailed": "Esportazione GIF fallita",
|
||||
"failedToSaveVideo": "Impossibile salvare il video",
|
||||
"exportFailed": "Esportazione fallita",
|
||||
"exportFailedWithError": "Esportazione fallita: {{error}}",
|
||||
"exportBackgroundLoadFailed": "Esportazione fallita: impossibile caricare l'immagine di sfondo ({{url}})",
|
||||
"failedToSaveExport": "Impossibile salvare l'esportazione",
|
||||
"failedToSaveExportedVideo": "Impossibile salvare il video esportato",
|
||||
"failedToRevealInFolder": "Errore durante la visualizzazione nella cartella: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Esportazione annullata",
|
||||
"exportedSuccessfully": "{{format}} esportato con successo"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Salvataggio progetto annullato",
|
||||
"failedToSave": "Impossibile salvare il progetto",
|
||||
"savedTo": "Progetto salvato in {{path}}",
|
||||
"failedToLoad": "Impossibile caricare il progetto",
|
||||
"invalidFormat": "Formato file progetto non valido",
|
||||
"loadedFrom": "Progetto caricato da {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Impossibile richiedere l'accesso alla fotocamera.",
|
||||
"cameraBlocked": "L'accesso alla fotocamera è bloccato. Abilita nelle impostazioni di sistema l'utilizzo della webcam.",
|
||||
"systemAudioUnavailable": "Audio di sistema non disponibile. Registrazione senza audio di sistema.",
|
||||
"microphoneDenied": "Accesso al microfono negato. La registrazione continuerà senza audio.",
|
||||
"cameraDenied": "Accesso alla fotocamera negato. La registrazione continuerà senza webcam.",
|
||||
"cameraDisconnected": "Webcam disconnessa.",
|
||||
"cameraNotFound": "Fotocamera non trovata.",
|
||||
"permissionDenied": "Autorizzazione di registrazione negata. Consenti la registrazione dello schermo.",
|
||||
"accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Nascondi",
|
||||
"closeApp": "Chiudi app",
|
||||
"restartRecording": "Riavvia registrazione",
|
||||
"cancelRecording": "Annulla registrazione",
|
||||
"pauseRecording": "Metti in pausa registrazione",
|
||||
"resumeRecording": "Riprendi registrazione",
|
||||
"openVideoFile": "Apri file video",
|
||||
"openProject": "Apri progetto"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Abilita audio di sistema",
|
||||
"disableSystemAudio": "Disabilita audio di sistema",
|
||||
"enableMicrophone": "Abilita microfono",
|
||||
"disableMicrophone": "Disabilita microfono",
|
||||
"defaultMicrophone": "Microfono predefinito"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Abilita webcam",
|
||||
"disableWebcam": "Disabilita webcam",
|
||||
"defaultCamera": "Fotocamera predefinita",
|
||||
"searching": "Ricerca in corso...",
|
||||
"noneFound": "Nessuna fotocamera trovata",
|
||||
"unavailable": "Fotocamera non disponibile"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "Usa cursore modificabile",
|
||||
"useSystemCursor": "Usa cursore di sistema"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Caricamento sorgenti...",
|
||||
"screens": "Schermi ({{count}})",
|
||||
"windows": "Finestre ({{count}})",
|
||||
"defaultSourceName": "Schermo"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "Seleziona una sorgente da registrare"
|
||||
},
|
||||
"language": "Lingua",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "Usare la lingua del sistema?",
|
||||
"description": "Abbiamo rilevato {{language}} come lingua del sistema. Vuoi passare OpenScreen a {{language}}?",
|
||||
"switch": "Passa a {{language}}",
|
||||
"keepDefault": "Mantieni la lingua corrente"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"zoom": {
|
||||
"previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
|
||||
"level": "Livello zoom",
|
||||
"customScale": "Zoom personalizzato",
|
||||
"selectRegion": "Seleziona una regione zoom da regolare",
|
||||
"deleteZoom": "Elimina zoom",
|
||||
"focusMode": {
|
||||
"title": "Modalità messa a fuoco",
|
||||
"manual": "Manuale",
|
||||
"auto": "Automatico",
|
||||
"autoDescription": "La fotocamera segue la posizione del cursore registrato"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "Rotazione 3D",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "Sinistra",
|
||||
"right": "Destra"
|
||||
}
|
||||
},
|
||||
"position": {
|
||||
"title": "Posizione messa a fuoco",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = più a sinistra / in alto, 100 = più a destra / in basso"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Velocità di riproduzione",
|
||||
"selectRegion": "Seleziona una regione velocità da regolare",
|
||||
"deleteRegion": "Elimina regione velocità",
|
||||
"customPlaybackSpeed": "Velocità di riproduzione personalizzata",
|
||||
"maxSpeedError": "La velocità non può superare 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Elimina regione taglio"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout",
|
||||
"preset": "Predefinito",
|
||||
"selectPreset": "Seleziona predefinito",
|
||||
"pictureInPicture": "Immagine nell'immagine",
|
||||
"verticalStack": "Pila verticale",
|
||||
"dualFrame": "Doppio frame",
|
||||
"noWebcam": "Nessuna webcam",
|
||||
"webcamShape": "Forma fotocamera",
|
||||
"webcamSize": "Dimensione webcam"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Effetti video",
|
||||
"blurBg": "Sfuma sfondo",
|
||||
"motionBlur": "Sfocatura movimento",
|
||||
"off": "spento",
|
||||
"on": "acceso",
|
||||
"shadow": "Ombra",
|
||||
"roundness": "Arrotondamento",
|
||||
"padding": "Spaziatura"
|
||||
},
|
||||
"background": {
|
||||
"title": "Sfondo",
|
||||
"image": "Immagine",
|
||||
"color": "Colore",
|
||||
"gradient": "Sfumatura",
|
||||
"uploadCustom": "Carica personalizzato",
|
||||
"gradientLabel": "Sfumatura {{index}}",
|
||||
"colorWheel": "Ruota dei colori",
|
||||
"colorPalette": "Tavolozza dei colori"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Ritaglia",
|
||||
"cropVideo": "Ritaglia video",
|
||||
"dragInstruction": "Trascina su ogni lato per regolare l'area di ritaglio",
|
||||
"ratio": "Proporzioni",
|
||||
"free": "Libero",
|
||||
"done": "Fatto",
|
||||
"lockAspectRatio": "Blocca proporzioni",
|
||||
"unlockAspectRatio": "Sblocca proporzioni"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "Video MP4",
|
||||
"mp4Description": "File video di alta qualità",
|
||||
"gifAnimation": "Animazione GIF",
|
||||
"gifDescription": "Immagine animata per la condivisione"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Risoluzione esportazione",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Originale"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "Frequenza fotogrammi GIF",
|
||||
"size": "Dimensione GIF",
|
||||
"loop": "GIF in loop"
|
||||
},
|
||||
"project": {
|
||||
"save": "Salva progetto",
|
||||
"load": "Carica progetto"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Esporta video",
|
||||
"gifButton": "Esporta GIF",
|
||||
"chooseSaveLocation": "Scegli posizione di salvataggio"
|
||||
},
|
||||
"support": {
|
||||
"reportBug": "Segnala bug",
|
||||
"saveDiagnostics": "Salva dati diagnostici",
|
||||
"starOnGithub": "Metti stella su GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Tipo di file non valido",
|
||||
"jpgOnly": "Carica un file immagine JPG o JPEG.",
|
||||
"uploadSuccess": "Immagine personalizzata caricata con successo!",
|
||||
"failedToUpload": "Impossibile caricare l'immagine",
|
||||
"errorReading": "Si è verificato un errore durante la lettura del file."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "Impostazioni annotazione",
|
||||
"active": "Attivo",
|
||||
"typeText": "Testo",
|
||||
"typeImage": "Immagine",
|
||||
"typeArrow": "Freccia",
|
||||
"typeBlur": "Sfocatura",
|
||||
"textContent": "Contenuto testo",
|
||||
"textPlaceholder": "Inserisci il tuo testo...",
|
||||
"fontStyle": "Stile carattere",
|
||||
"selectStyle": "Seleziona stile",
|
||||
"size": "Dimensione",
|
||||
"customFonts": "Caratteri personalizzati",
|
||||
"textColor": "Colore testo",
|
||||
"background": "Sfondo",
|
||||
"none": "Nessuno",
|
||||
"color": "Colore",
|
||||
"colorWheel": "Ruota dei colori",
|
||||
"colorPalette": "Tavolozza dei colori",
|
||||
"clearBackground": "Rimuovi sfondo",
|
||||
"uploadImage": "Carica immagine",
|
||||
"supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Direzione freccia",
|
||||
"strokeWidth": "Larghezza tratto: {{width}}px",
|
||||
"arrowColor": "Colore freccia",
|
||||
"blurType": "Tipo sfocatura",
|
||||
"blurTypeBlur": "Sfocatura",
|
||||
"blurTypeMosaic": "Sfocatura mosaico",
|
||||
"blurColor": "Colore sfocatura",
|
||||
"blurColorWhite": "Bianco",
|
||||
"blurColorBlack": "Nero",
|
||||
"blurShape": "Forma sfocatura",
|
||||
"blurIntensity": "Intensità sfocatura",
|
||||
"mosaicBlockSize": "Dimensione blocco mosaico",
|
||||
"blurShapeRectangle": "Rettangolo",
|
||||
"blurShapeOval": "Ovale",
|
||||
"blurShapeFreehand": "A mano libera",
|
||||
"deleteAnnotation": "Elimina annotazione",
|
||||
"shortcutsAndTips": "Scorciatoie e suggerimenti",
|
||||
"tipMovePlayhead": "Sposta la testina di riproduzione sulla sezione di annotazione sovrapposta e seleziona un elemento.",
|
||||
"tipTabCycle": "Usa Tab per scorrere gli elementi sovrapposti.",
|
||||
"tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.",
|
||||
"invalidImageType": "Tipo di file non valido",
|
||||
"imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.",
|
||||
"imageUploadSuccess": "Immagine caricata con successo!",
|
||||
"failedImageUpload": "Impossibile caricare l'immagine"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Classico",
|
||||
"editor": "Editor",
|
||||
"strong": "Forte",
|
||||
"typewriter": "Macchina da scrivere",
|
||||
"deco": "Deco",
|
||||
"simple": "Semplice",
|
||||
"modern": "Moderno",
|
||||
"clean": "Pulito"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Aggiungi font Google",
|
||||
"urlLabel": "URL importazione Google Fonts",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Ottieni questo da Google Fonts: Seleziona un font → Clicca \"Ottieni font\" → Copia l'URL @import",
|
||||
"nameLabel": "Nome visualizzato",
|
||||
"namePlaceholder": "Il mio font personalizzato",
|
||||
"nameHelp": "Così apparirà il font nel selettore",
|
||||
"addButton": "Aggiungi font",
|
||||
"addingButton": "Aggiunta in corso...",
|
||||
"errorEmptyUrl": "Inserisci un URL di importazione Google Fonts",
|
||||
"errorInvalidUrl": "Inserisci un URL Google Fonts valido",
|
||||
"errorEmptyName": "Inserisci un nome per il font",
|
||||
"errorExtractFailed": "Impossibile estrarre la famiglia di font dall'URL",
|
||||
"successMessage": "Font \"{{fontName}}\" aggiunto con successo",
|
||||
"failedToAdd": "Impossibile aggiungere il font",
|
||||
"errorTimeout": "Il font ha impiegato troppo tempo a caricarsi. Controlla l'URL e riprova.",
|
||||
"errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto."
|
||||
},
|
||||
"cursor": {
|
||||
"show": "Mostra cursore",
|
||||
"size": "Dimensione",
|
||||
"smoothing": "Smussatura",
|
||||
"motionBlur": "Sfocatura movimento",
|
||||
"clickBounce": "Rimbalzo clic",
|
||||
"clipToBounds": "Ritaglia al canvas"
|
||||
},
|
||||
"language": {
|
||||
"title": "Lingua"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
"waveform": "Mostra la forma d'onda sulla traccia di ritaglio"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"title": "Scorciatoie tastiera",
|
||||
"customize": "Personalizza",
|
||||
"configurable": "Configurabile",
|
||||
"fixed": "Fisso",
|
||||
"pressKey": "Premi un tasto…",
|
||||
"clickToChange": "Clicca per cambiare",
|
||||
"pressEscToCancel": "Premi Esc per annullare",
|
||||
"helpText": "Clicca una scorciatoia e premi la nuova combinazione di tasti. Premi Esc per annullare.",
|
||||
"resetToDefaults": "Ripristina predefiniti",
|
||||
"alreadyUsedBy": "Già utilizzata da {{action}}",
|
||||
"swap": "Scambia",
|
||||
"reservedShortcut": "Questa scorciatoia è riservata a \"{{label}}\" e non può essere riassegnata.",
|
||||
"savedToast": "Scorciatoie tastiera salvate",
|
||||
"resetToast": "Ripristino alle scorciatoie predefinite — clicca Salva per applicare",
|
||||
"registrationFailed": "Impossibile registrare la scorciatoia. Potrebbe essere in uso da un'altra applicazione. Prova un altro tasto.",
|
||||
"actions": {
|
||||
"openApp": "Apri applicazione",
|
||||
"addZoom": "Aggiungi zoom",
|
||||
"addTrim": "Aggiungi taglio",
|
||||
"addSpeed": "Aggiungi velocità",
|
||||
"addAnnotation": "Aggiungi annotazione",
|
||||
"addBlur": "Aggiungi sfocatura",
|
||||
"addKeyframe": "Aggiungi fotogramma chiave",
|
||||
"deleteSelected": "Elimina selezionato",
|
||||
"playPause": "Riproduci / Pausa"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Annulla",
|
||||
"redo": "Ripeti",
|
||||
"cycleAnnotationsForward": "Scorri annotazioni in avanti",
|
||||
"cycleAnnotationsBackward": "Scorri annotazioni indietro",
|
||||
"deleteSelectedAlt": "Elimina selezionato (alt)",
|
||||
"panTimeline": "Panoramica timeline",
|
||||
"zoomTimeline": "Zoom timeline",
|
||||
"frameBack": "Fotogramma indietro",
|
||||
"frameForward": "Fotogramma avanti"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Aggiungi zoom (Z)",
|
||||
"suggestZooms": "Suggerisci zoom dal cursore",
|
||||
"addTrim": "Aggiungi taglio (T)",
|
||||
"addAnnotation": "Aggiungi annotazione (A)",
|
||||
"addBlur": "Aggiungi sfocatura (B)",
|
||||
"addSpeed": "Aggiungi velocità (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Premi Z per aggiungere zoom",
|
||||
"pressTrim": "Premi T per aggiungere taglio",
|
||||
"pressAnnotation": "Premi A per aggiungere annotazione",
|
||||
"pressBlur": "Premi B per aggiungere regione sfocatura",
|
||||
"pressSpeed": "Premi S per aggiungere velocità"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Panoramica",
|
||||
"zoom": "Zoom",
|
||||
"trim": "Taglio",
|
||||
"speed": "Velocità",
|
||||
"zoomItem": "Zoom {{index}}",
|
||||
"trimItem": "Taglio {{index}}",
|
||||
"speedItem": "Velocità {{index}}",
|
||||
"annotationItem": "Annotazione",
|
||||
"blurItem": "Sfocatura {{index}}",
|
||||
"imageItem": "Immagine",
|
||||
"emptyText": "Testo vuoto"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Nessun video caricato",
|
||||
"dragAndDrop": "Trascina e rilascia un video per iniziare a modificare"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Impossibile posizionare lo zoom qui",
|
||||
"zoomExistsAtLocation": "Lo zoom esiste già in questa posizione o non c'è spazio sufficiente.",
|
||||
"zoomSuggestionUnavailable": "Gestore suggerimenti zoom non disponibile",
|
||||
"noCursorTelemetry": "Nessuna telemetria del cursore disponibile",
|
||||
"noCursorTelemetryDescription": "Registra prima uno screencast per generare suggerimenti basati sul cursore.",
|
||||
"noUsableTelemetry": "Nessuna telemetria del cursore utilizzabile",
|
||||
"noUsableTelemetryDescription": "La registrazione non include dati sufficienti sul movimento del cursore.",
|
||||
"noDwellMoments": "Nessun momento di sosta del cursore trovato",
|
||||
"noDwellMomentsDescription": "Prova una registrazione con pause del cursore più lente sulle azioni importanti.",
|
||||
"noAutoZoomSlots": "Nessuno slot di zoom automatico disponibile",
|
||||
"noAutoZoomSlotsDescription": "I punti di sosta rilevati si sovrappongono alle regioni zoom esistenti.",
|
||||
"cannotPlaceTrim": "Impossibile posizionare il taglio qui",
|
||||
"trimExistsAtLocation": "Il taglio esiste già in questa posizione o non c'è spazio sufficiente.",
|
||||
"cannotPlaceSpeed": "Impossibile posizionare la velocità qui",
|
||||
"speedExistsAtLocation": "La regione velocità esiste già in questa posizione o non c'è spazio sufficiente."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "Aggiunto {{count}} suggerimento zoom basato sul cursore",
|
||||
"addedZoomSuggestionsPlural": "Aggiunti {{count}} suggerimenti zoom basati sul cursore"
|
||||
}
|
||||
}
|
||||
@@ -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にアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"zoom": {
|
||||
"previewHold": "押している間ズーム効果をプレビュー",
|
||||
"level": "ズーム倍率",
|
||||
"selectRegion": "ズーム範囲を選択して調整",
|
||||
"deleteZoom": "ズームを削除",
|
||||
@@ -16,6 +17,13 @@
|
||||
"left": "左",
|
||||
"right": "右"
|
||||
}
|
||||
},
|
||||
"customScale": "カスタムズーム",
|
||||
"position": {
|
||||
"title": "フォーカス位置",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = 左端 / 上端、100 = 右端 / 下端"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -36,7 +44,8 @@
|
||||
"verticalStack": "縦並び",
|
||||
"dualFrame": "デュアルフレーム",
|
||||
"webcamShape": "カメラの形状",
|
||||
"webcamSize": "カメラのサイズ"
|
||||
"webcamSize": "カメラのサイズ",
|
||||
"noWebcam": "Webカメラなし"
|
||||
},
|
||||
"effects": {
|
||||
"title": "動画効果",
|
||||
@@ -45,7 +54,8 @@
|
||||
"off": "オフ",
|
||||
"shadow": "影",
|
||||
"roundness": "丸み",
|
||||
"padding": "余白"
|
||||
"padding": "余白",
|
||||
"on": "オン"
|
||||
},
|
||||
"background": {
|
||||
"title": "背景",
|
||||
@@ -76,10 +86,10 @@
|
||||
"gifDescription": "共有用のアニメーション画像"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "エクスポート品質",
|
||||
"low": "低画質",
|
||||
"medium": "中画質",
|
||||
"high": "高画質"
|
||||
"title": "書き出し解像度",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF フレームレート",
|
||||
@@ -95,13 +105,14 @@
|
||||
"gifButton": "GIF をエクスポート",
|
||||
"chooseSaveLocation": "保存場所を選択"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "バグを報告",
|
||||
"saveDiagnostics": "診断情報を保存",
|
||||
"starOnGithub": "GitHub でスターを付ける"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "無効なファイル形式",
|
||||
"jpgOnly": "JPG または JPEG 画像ファイルを選択してください。",
|
||||
"jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
|
||||
"uploadSuccess": "カスタム画像を読み込みました。",
|
||||
"failedToUpload": "画像の読み込みに失敗しました",
|
||||
"errorReading": "ファイルの読み取り中にエラーが発生しました。"
|
||||
@@ -182,7 +193,19 @@
|
||||
"errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
|
||||
"errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。"
|
||||
},
|
||||
"cursor": {
|
||||
"show": "カーソルを表示",
|
||||
"size": "サイズ",
|
||||
"smoothing": "スムージング",
|
||||
"motionBlur": "モーションブラー",
|
||||
"clickBounce": "クリックバウンス",
|
||||
"clipToBounds": "キャンバスにクリップ"
|
||||
},
|
||||
"language": {
|
||||
"title": "言語"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "タイムライン",
|
||||
"waveform": "トリムトラックに音声波形を表示"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "このショートカットは \"{{label}}\" に予約されており、再割り当てできません。",
|
||||
"savedToast": "キーボードショートカットが保存されました",
|
||||
"resetToast": "デフォルトのショートカットにリセット — 保存をクリックして適用",
|
||||
"registrationFailed": "ショートカットの登録に失敗しました。他のアプリで使用中の可能性があります。別のキーをお試しください。",
|
||||
"actions": {
|
||||
"openApp": "アプリを開く",
|
||||
"addZoom": "ズームを追加",
|
||||
"addTrim": "トリムを追加",
|
||||
"addSpeed": "速度を追加",
|
||||
|
||||
@@ -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의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"zoom": {
|
||||
"previewHold": "누르고 있으면 줌 효과 미리보기",
|
||||
"level": "줌 레벨",
|
||||
"customScale": "커스텀 줌",
|
||||
"selectRegion": "조정할 줌 구간을 선택하세요",
|
||||
"deleteZoom": "줌 삭제",
|
||||
"focusMode": {
|
||||
@@ -16,6 +18,12 @@
|
||||
"left": "왼쪽",
|
||||
"right": "오른쪽"
|
||||
}
|
||||
},
|
||||
"position": {
|
||||
"title": "포커스 위치",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -36,13 +44,15 @@
|
||||
"verticalStack": "세로 배치",
|
||||
"webcamShape": "카메라 모양",
|
||||
"webcamSize": "웹캠 크기",
|
||||
"dualFrame": "듀얼 프레임"
|
||||
"dualFrame": "듀얼 프레임",
|
||||
"noWebcam": "웹캠 없음"
|
||||
},
|
||||
"effects": {
|
||||
"title": "비디오 효과",
|
||||
"blurBg": "배경 흐림",
|
||||
"motionBlur": "모션 블러",
|
||||
"off": "끄기",
|
||||
"on": "켜기",
|
||||
"shadow": "그림자",
|
||||
"roundness": "모서리 둥글기",
|
||||
"padding": "여백"
|
||||
@@ -76,10 +86,10 @@
|
||||
"gifDescription": "공유용 애니메이션 이미지"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "내보내기 품질",
|
||||
"low": "낮음",
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
"title": "내보내기 해상도",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF 프레임 속도",
|
||||
@@ -95,13 +105,14 @@
|
||||
"gifButton": "GIF 내보내기",
|
||||
"chooseSaveLocation": "저장 위치 선택"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "버그 신고",
|
||||
"saveDiagnostics": "Save Diagnostics",
|
||||
"starOnGithub": "GitHub에 Star 남기기"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "지원하지 않는 파일 형식입니다",
|
||||
"jpgOnly": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요.",
|
||||
"jpgOnly": "JPG, JPEG 또는 PNG 이미지 파일을 업로드해 주세요.",
|
||||
"uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
|
||||
"failedToUpload": "이미지 업로드에 실패했습니다",
|
||||
"errorReading": "파일을 읽는 중 오류가 발생했습니다."
|
||||
@@ -182,7 +193,19 @@
|
||||
"errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
|
||||
"errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요."
|
||||
},
|
||||
"cursor": {
|
||||
"show": "커서 표시",
|
||||
"size": "크기",
|
||||
"smoothing": "부드러움",
|
||||
"motionBlur": "모션 블러",
|
||||
"clickBounce": "클릭 바운스",
|
||||
"clipToBounds": "캔버스에 맞춰 자르기"
|
||||
},
|
||||
"language": {
|
||||
"title": "언어"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "타임라인",
|
||||
"waveform": "트림 트랙에 오디오 파형 표시"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "이 단축키는 \"{{label}}\"에 예약되어 있어 변경할 수 없습니다.",
|
||||
"savedToast": "키보드 단축키가 저장되었습니다",
|
||||
"resetToast": "기본 단축키로 초기화되었습니다 — 저장을 클릭해 적용하세요",
|
||||
"registrationFailed": "단축키 등록에 실패했습니다. 다른 앱에서 사용 중일 수 있습니다. 다른 키를 시도하세요.",
|
||||
"actions": {
|
||||
"openApp": "앱 열기",
|
||||
"addZoom": "줌 추가",
|
||||
"addTrim": "트림 추가",
|
||||
"addSpeed": "속도 추가",
|
||||
|
||||
@@ -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": "Использовать системный курсор"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user