diff --git a/electron/i18n.ts b/electron/i18n.ts index e16ac86..1492578 100644 --- a/electron/i18n.ts +++ b/electron/i18n.ts @@ -9,10 +9,14 @@ import commonEs from "../src/i18n/locales/es/common.json"; import dialogsEs from "../src/i18n/locales/es/dialogs.json"; import commonFr from "../src/i18n/locales/fr/common.json"; import dialogsFr from "../src/i18n/locales/fr/dialogs.json"; +import commonIt from "../src/i18n/locales/it/common.json"; +import dialogsIt from "../src/i18n/locales/it/dialogs.json"; import commonJa from "../src/i18n/locales/ja-JP/common.json"; import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json"; import commonKo from "../src/i18n/locales/ko-KR/common.json"; import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json"; +import commonRu from "../src/i18n/locales/ru/common.json"; +import dialogsRu from "../src/i18n/locales/ru/dialogs.json"; import commonTr from "../src/i18n/locales/tr/common.json"; import dialogsTr from "../src/i18n/locales/tr/dialogs.json"; import commonVi from "../src/i18n/locales/vi/common.json"; @@ -22,21 +26,35 @@ import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json"; import commonZhTw from "../src/i18n/locales/zh-TW/common.json"; import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json"; -type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi"; +type Locale = + | "en" + | "ar" + | "es" + | "fr" + | "it" + | "ja-JP" + | "ko-KR" + | "ru" + | "tr" + | "vi" + | "zh-CN" + | "zh-TW"; type Namespace = "common" | "dialogs"; type MessageMap = Record; const messages: Record> = { en: { common: commonEn, dialogs: dialogsEn }, - "zh-CN": { common: commonZh, dialogs: dialogsZh }, - "zh-TW": { common: commonZhTw, dialogs: dialogsZhTw }, + ar: { common: commonAr, dialogs: dialogsAr }, es: { common: commonEs, dialogs: dialogsEs }, fr: { common: commonFr, dialogs: dialogsFr }, + it: { common: commonIt, dialogs: dialogsIt }, "ja-JP": { common: commonJa, dialogs: dialogsJa }, "ko-KR": { common: commonKo, dialogs: dialogsKo }, + ru: { common: commonRu, dialogs: dialogsRu }, tr: { common: commonTr, dialogs: dialogsTr }, - ar: { common: commonAr, dialogs: dialogsAr }, vi: { common: commonVi, dialogs: dialogsVi }, + "zh-CN": { common: commonZh, dialogs: dialogsZh }, + "zh-TW": { common: commonZhTw, dialogs: dialogsZhTw }, }; let currentLocale: Locale = "en"; @@ -44,15 +62,17 @@ let currentLocale: Locale = "en"; export function setMainLocale(locale: string) { if ( locale === "en" || - locale === "zh-CN" || - locale === "zh-TW" || + locale === "ar" || locale === "es" || locale === "fr" || + locale === "it" || locale === "ja-JP" || locale === "ko-KR" || + locale === "ru" || locale === "tr" || - locale === "ar" || - locale === "vi" + locale === "vi" || + locale === "zh-CN" || + locale === "zh-TW" ) { currentLocale = locale; } diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts deleted file mode 100644 index bed037a..0000000 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts +++ /dev/null @@ -1,391 +0,0 @@ -export function buildPowerShellScript(sampleIntervalMs: number, windowHandle?: string | null) { - const targetWindowHandle = - typeof windowHandle === "string" && /^(?:0x[0-9a-fA-F]+|\d+)$/.test(windowHandle) - ? `'${windowHandle}'` - : "$null"; - const script = String.raw` -$ErrorActionPreference = 'Stop' -Add-Type -AssemblyName System.Drawing -Add-Type -AssemblyName System.Windows.Forms - -$targetWindowHandle = ${targetWindowHandle} - -$source = @" -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; - -public static class OpenScreenCursorInterop { - private const int WH_MOUSE_LL = 14; - private const int WM_LBUTTONDOWN = 0x0201; - private const int WM_LBUTTONUP = 0x0202; - private static readonly object MouseSync = new object(); - private static int LeftDownCount = 0; - private static int LeftUpCount = 0; - private static IntPtr MouseHook = IntPtr.Zero; - private static LowLevelMouseProc MouseProcDelegate = MouseHookCallback; - - public delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam); - - public struct MouseButtonEvents { - public int LeftDownCount; - public int LeftUpCount; - } - - [StructLayout(LayoutKind.Sequential)] - public struct POINT { - public int X; - public int Y; - } - - [StructLayout(LayoutKind.Sequential)] - public struct CURSORINFO { - public int cbSize; - public int flags; - public IntPtr hCursor; - public POINT ptScreenPos; - } - - [StructLayout(LayoutKind.Sequential)] - public struct ICONINFO { - [MarshalAs(UnmanagedType.Bool)] - public bool fIcon; - public int xHotspot; - public int yHotspot; - public IntPtr hbmMask; - public IntPtr hbmColor; - } - - [StructLayout(LayoutKind.Sequential)] - public struct RECT { - public int Left; - public int Top; - public int Right; - public int Bottom; - } - - public static bool InstallMouseHook() { - if (MouseHook != IntPtr.Zero) { - return true; - } - - using (Process process = Process.GetCurrentProcess()) - using (ProcessModule module = process.MainModule) { - MouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseProcDelegate, GetModuleHandle(module.ModuleName), 0); - } - - return MouseHook != IntPtr.Zero; - } - - public static MouseButtonEvents ConsumeMouseButtonEvents() { - lock (MouseSync) { - MouseButtonEvents events = new MouseButtonEvents { - LeftDownCount = LeftDownCount, - LeftUpCount = LeftUpCount - }; - LeftDownCount = 0; - LeftUpCount = 0; - return events; - } - } - - private static IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) { - if (nCode >= 0) { - int message = wParam.ToInt32(); - if (message == WM_LBUTTONDOWN || message == WM_LBUTTONUP) { - lock (MouseSync) { - if (message == WM_LBUTTONDOWN) { - LeftDownCount += 1; - } else { - LeftUpCount += 1; - } - } - } - } - - return CallNextHookEx(MouseHook, nCode, wParam, lParam); - } - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool GetCursorInfo(ref CURSORINFO pci); - - [DllImport("user32.dll")] - public static extern short GetAsyncKeyState(int vKey); - - [DllImport("user32.dll", SetLastError = true)] - public static extern IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool IsWindow(IntPtr hWnd); - - [DllImport("user32.dll", SetLastError = true)] - public static extern IntPtr CopyIcon(IntPtr hIcon); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool DestroyIcon(IntPtr hIcon); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo); - - [DllImport("gdi32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool DeleteObject(IntPtr hObject); - - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId); - - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); - - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern IntPtr GetModuleHandle(string lpModuleName); -} -"@ - -Add-Type -TypeDefinition $source - -$standardCursors = @{ - arrow = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32512)) - text = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32513)) - wait = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32514)) - crosshair = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32515)) - 'up-arrow' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32516)) - 'resize-nwse' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32642)) - 'resize-nesw' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32643)) - 'resize-ew' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32644)) - 'resize-ns' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32645)) - move = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32646)) - 'not-allowed' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32648)) - pointer = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32649)) - 'app-starting' = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32650)) - help = [OpenScreenCursorInterop]::LoadCursor([IntPtr]::Zero, [IntPtr]::new(32651)) -} - -function Get-StandardCursorType($cursorHandle) { - if ($cursorHandle -eq [IntPtr]::Zero) { - return $null - } - - foreach ($entry in $standardCursors.GetEnumerator()) { - if ($entry.Value -eq $cursorHandle) { - return $entry.Key - } - } - - return $null -} - -function Write-JsonLine($payload) { - [Console]::Out.WriteLine(($payload | ConvertTo-Json -Compress -Depth 6)) -} - -function Get-CustomCursorType($bitmap, $hotspotX, $hotspotY) { - if ($bitmap.Width -lt 24 -or $bitmap.Height -lt 24 -or $bitmap.Width -gt 64 -or $bitmap.Height -gt 64) { - return $null - } - - if ($hotspotX -lt ($bitmap.Width * 0.25) -or $hotspotX -gt ($bitmap.Width * 0.75) -or - $hotspotY -lt ($bitmap.Height * 0.15) -or $hotspotY -gt ($bitmap.Height * 0.55)) { - return $null - } - - $opaquePixels = 0 - $topHalfOpaquePixels = 0 - $left = $bitmap.Width - $top = $bitmap.Height - $right = -1 - $bottom = -1 - - for ($y = 0; $y -lt $bitmap.Height; $y++) { - for ($x = 0; $x -lt $bitmap.Width; $x++) { - if ($bitmap.GetPixel($x, $y).A -le 32) { - continue - } - - $opaquePixels += 1 - if ($y -lt ($bitmap.Height / 2)) { - $topHalfOpaquePixels += 1 - } - if ($x -lt $left) { $left = $x } - if ($x -gt $right) { $right = $x } - if ($y -lt $top) { $top = $y } - if ($y -gt $bottom) { $bottom = $y } - } - } - - if ($opaquePixels -lt 90 -or $right -lt $left -or $bottom -lt $top) { - return $null - } - - $opaqueWidth = $right - $left + 1 - $opaqueHeight = $bottom - $top + 1 - if ($opaqueWidth -lt ($bitmap.Width * 0.35) -or $opaqueWidth -gt ($bitmap.Width * 0.9) -or - $opaqueHeight -lt ($bitmap.Height * 0.45) -or $opaqueHeight -gt $bitmap.Height) { - return $null - } - - if ($top -gt ($bitmap.Height * 0.45) -or $bottom -lt ($bitmap.Height * 0.65)) { - return $null - } - - if ($topHalfOpaquePixels -gt ($opaquePixels * 0.55)) { - return 'closed-hand' - } - - return 'open-hand' -} - -function Get-TargetBounds() { - if ([string]::IsNullOrWhiteSpace($targetWindowHandle)) { - return $null - } - - try { - $handleValue = [int64]::Parse($targetWindowHandle) - $windowHandle = [IntPtr]::new($handleValue) - if (-not [OpenScreenCursorInterop]::IsWindow($windowHandle)) { - return $null - } - - $rect = New-Object OpenScreenCursorInterop+RECT - if (-not [OpenScreenCursorInterop]::GetWindowRect($windowHandle, [ref]$rect)) { - return $null - } - - $width = $rect.Right - $rect.Left - $height = $rect.Bottom - $rect.Top - if ($width -le 0 -or $height -le 0) { - return $null - } - - return @{ - x = $rect.Left - y = $rect.Top - width = $width - height = $height - } - } - catch { - return $null - } -} - -function Get-CursorAsset($cursorHandle, $cursorId) { - $copiedHandle = [OpenScreenCursorInterop]::CopyIcon($cursorHandle) - if ($copiedHandle -eq [IntPtr]::Zero) { - return $null - } - - $iconInfo = New-Object OpenScreenCursorInterop+ICONINFO - $hasIconInfo = [OpenScreenCursorInterop]::GetIconInfo($copiedHandle, [ref]$iconInfo) - - try { - $icon = [System.Drawing.Icon]::FromHandle($copiedHandle) - $bitmap = New-Object System.Drawing.Bitmap $icon.Width, $icon.Height, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) - $graphics = [System.Drawing.Graphics]::FromImage($bitmap) - $memoryStream = New-Object System.IO.MemoryStream - - try { - $graphics.Clear([System.Drawing.Color]::Transparent) - $graphics.DrawIcon($icon, 0, 0) - $hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 } - $hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 } - $customCursorType = Get-CustomCursorType -bitmap $bitmap -hotspotX $hotspotX -hotspotY $hotspotY - $bitmap.Save($memoryStream, [System.Drawing.Imaging.ImageFormat]::Png) - $base64 = [System.Convert]::ToBase64String($memoryStream.ToArray()) - - return @{ - id = $cursorId - imageDataUrl = "data:image/png;base64,$base64" - width = $bitmap.Width - height = $bitmap.Height - hotspotX = $hotspotX - hotspotY = $hotspotY - cursorType = $customCursorType - } - } - finally { - $memoryStream.Dispose() - $graphics.Dispose() - $bitmap.Dispose() - $icon.Dispose() - } - } - finally { - if ($hasIconInfo) { - if ($iconInfo.hbmMask -ne [IntPtr]::Zero) { - [OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmMask) | Out-Null - } - if ($iconInfo.hbmColor -ne [IntPtr]::Zero) { - [OpenScreenCursorInterop]::DeleteObject($iconInfo.hbmColor) | Out-Null - } - } - [OpenScreenCursorInterop]::DestroyIcon($copiedHandle) | Out-Null - } -} - -[OpenScreenCursorInterop]::InstallMouseHook() | Out-Null -[OpenScreenCursorInterop]::GetAsyncKeyState(0x01) | Out-Null -Write-JsonLine @{ type = 'ready'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() } - -$lastCursorId = $null -while ($true) { - [System.Windows.Forms.Application]::DoEvents() - $mouseEvents = [OpenScreenCursorInterop]::ConsumeMouseButtonEvents() - $cursorInfo = New-Object OpenScreenCursorInterop+CURSORINFO - $cursorInfo.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type][OpenScreenCursorInterop+CURSORINFO]) - - if (-not [OpenScreenCursorInterop]::GetCursorInfo([ref]$cursorInfo)) { - Write-JsonLine @{ type = 'error'; timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); message = 'GetCursorInfo failed' } - Start-Sleep -Milliseconds ${sampleIntervalMs} - continue - } - - $visible = ($cursorInfo.flags -band 1) -ne 0 - $cursorId = if ($cursorInfo.hCursor -eq [IntPtr]::Zero) { $null } else { ('0x{0:X}' -f $cursorInfo.hCursor.ToInt64()) } - $cursorType = Get-StandardCursorType $cursorInfo.hCursor - $leftButtonState = [OpenScreenCursorInterop]::GetAsyncKeyState(0x01) - $leftButtonDown = ($leftButtonState -band 0x8000) -ne 0 - $leftButtonPressed = ($mouseEvents.LeftDownCount -gt 0) -or (($leftButtonState -band 0x0001) -ne 0) - $leftButtonReleased = $mouseEvents.LeftUpCount -gt 0 - $asset = $null - - if ($visible -and $cursorId -and $cursorId -ne $lastCursorId) { - $asset = Get-CursorAsset -cursorHandle $cursorInfo.hCursor -cursorId $cursorId - if ($asset -and $cursorType) { - $asset.cursorType = $cursorType - } elseif ($asset -and $asset.cursorType) { - $cursorType = $asset.cursorType - } - $lastCursorId = $cursorId - } - - Write-JsonLine @{ - type = 'sample' - timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - x = $cursorInfo.ptScreenPos.X - y = $cursorInfo.ptScreenPos.Y - visible = $visible - handle = $cursorId - cursorType = $cursorType - leftButtonDown = $leftButtonDown - leftButtonPressed = $leftButtonPressed - leftButtonReleased = $leftButtonReleased - bounds = Get-TargetBounds - asset = $asset - } - - Start-Sleep -Milliseconds ${sampleIntervalMs} -} -`; - - return script; -} diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index dd4aab0..5c318f0 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -1,10 +1,8 @@ import { type ChildProcessByStdio, spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import type { Readable } from "node:stream"; -import { screen } from "electron"; +import { app, screen } from "electron"; import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording"; import type { CursorRecordingData, @@ -12,12 +10,32 @@ import type { NativeCursorAsset, } from "../../../../src/native/contracts"; import type { CursorRecordingSession } from "./session"; -import { buildPowerShellScript } from "./windowsNativeRecordingSession.script"; import type { WindowsCursorEvent, WindowsNativeRecordingSessionOptions, } from "./windowsNativeRecordingSession.types"; +function getCursorSamplerCandidates(): string[] { + const envPath = process.env.OPENSCREEN_CURSOR_SAMPLER_EXE?.trim(); + const archTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64"; + const resolve = (...segs: string[]) => { + const p = join(app.getAppPath(), ...segs); + return app.isPackaged ? p.replace(/\.asar([/\\])/, ".asar.unpacked$1") : p; + }; + return [ + envPath, + resolve("electron", "native", "wgc-capture", "build", "cursor-sampler.exe"), + resolve("electron", "native", "bin", archTag, "cursor-sampler.exe"), + ].filter((c): c is string => Boolean(c)); +} + +function findCursorSamplerPath(): string | null { + for (const candidate of getCursorSamplerCandidates()) { + if (existsSync(candidate)) return candidate; + } + return null; +} + const READY_TIMEOUT_MS = 5_000; interface NormalizedSample { @@ -29,7 +47,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { private assets = new Map(); private samples: CursorRecordingSample[] = []; private process: ChildProcessByStdio | null = null; - private helperScriptPath: string | null = null; private lineBuffer = ""; private startTimeMs = 0; private readyResolve: (() => void) | null = null; @@ -50,41 +67,26 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { this.outOfBoundsSampleCount = 0; this.previousLeftButtonDown = false; - const script = buildPowerShellScript( - this.options.sampleIntervalMs, - parseWindowHandleFromSourceId(this.options.sourceId), - ); - const helperScriptDir = join(tmpdir(), "openscreen-cursor-native"); - mkdirSync(helperScriptDir, { recursive: true }); - const helperScriptPath = join( - helperScriptDir, - `cursor-sampler-${process.pid}-${Date.now()}-${randomUUID()}.ps1`, - ); - writeFileSync(helperScriptPath, script, "utf8"); - this.helperScriptPath = helperScriptPath; - const child = spawn( - "powershell.exe", - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - helperScriptPath, - ], - { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }, - ); + const helperPath = findCursorSamplerPath(); + if (!helperPath) { + throw new Error("Windows cursor sampler helper is not available."); + } + + const windowHandle = parseWindowHandleFromSourceId(this.options.sourceId); + const args = [String(this.options.sampleIntervalMs)]; + if (windowHandle) args.push(windowHandle); + + const child = spawn(helperPath, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); this.process = child; this.logDiagnostic("spawn", { pid: child.pid ?? null, sampleIntervalMs: this.options.sampleIntervalMs, sourceId: this.options.sourceId ?? null, - windowHandle: parseWindowHandleFromSourceId(this.options.sourceId), + windowHandle, }); child.stdout.setEncoding("utf8"); @@ -100,7 +102,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { console.error("[cursor-native]", message); }); child.once("exit", (code, signal) => { - this.cleanupHelperScript(helperScriptPath); this.logDiagnostic("exit", { code, signal, @@ -113,7 +114,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { ); }); child.once("error", (error) => { - this.cleanupHelperScript(helperScriptPath); this.logDiagnostic("process-error", { message: error.message }); this.rejectReady(error); }); @@ -122,7 +122,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { await this.waitUntilReady(); } catch (error) { this.terminateHelperProcess(); - this.cleanupHelperScript(helperScriptPath); throw error; } } @@ -315,25 +314,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { this.readyReject = null; } - private cleanupHelperScript(scriptPath = this.helperScriptPath) { - if (!scriptPath) { - return; - } - - try { - rmSync(scriptPath, { force: true }); - } catch (error) { - this.logDiagnostic("script-cleanup-error", { - path: scriptPath, - message: error instanceof Error ? error.message : String(error), - }); - } finally { - if (this.helperScriptPath === scriptPath) { - this.helperScriptPath = null; - } - } - } - private logDiagnostic(event: string, data: Record) { console.info( "[cursor-native][win32]", diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 7503658..32c5d6e 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -49,3 +49,19 @@ target_link_libraries(wgc-capture PRIVATE runtimeobject windowsapp ) + +add_executable(cursor-sampler + src/cursor-sampler.cpp +) + +target_compile_definitions(cursor-sampler PRIVATE + NOMINMAX + _WIN32_WINNT=0x0A00 +) + +target_compile_options(cursor-sampler PRIVATE /EHsc /W4 /utf-8) + +target_link_libraries(cursor-sampler PRIVATE + gdi32 + gdiplus +) diff --git a/electron/native/wgc-capture/src/cursor-sampler.cpp b/electron/native/wgc-capture/src/cursor-sampler.cpp new file mode 100644 index 0000000..21558c7 --- /dev/null +++ b/electron/native/wgc-capture/src/cursor-sampler.cpp @@ -0,0 +1,482 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +// Global mouse-hook state +// ───────────────────────────────────────────────────────────────────────────── +static HHOOK g_mouseHook = nullptr; +static DWORD g_mainThreadId = 0; +static std::atomic g_leftDownCount{0}; +static std::atomic g_leftUpCount{0}; +static std::atomic g_stop{false}; +static std::mutex g_stdoutMtx; + +static LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) { + if (nCode >= 0) { + if (wParam == WM_LBUTTONDOWN) g_leftDownCount.fetch_add(1, std::memory_order_relaxed); + else if (wParam == WM_LBUTTONUP) g_leftUpCount.fetch_add(1, std::memory_order_relaxed); + } + return CallNextHookEx(g_mouseHook, nCode, wParam, lParam); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Utilities +// ───────────────────────────────────────────────────────────────────────────── +static int64_t nowMs() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); +} + +static void writeJsonLine(const std::string& json) { + std::lock_guard lock(g_stdoutMtx); + std::cout << json << '\n'; + std::cout.flush(); +} + +static std::string jsonEscape(const std::string& s) { + std::string r; + r.reserve(s.size()); + for (unsigned char c : s) { + switch (c) { + case '"': r += "\\\""; break; + case '\\': r += "\\\\"; break; + case '\n': r += "\\n"; break; + case '\r': r += "\\r"; break; + case '\t': r += "\\t"; break; + default: r.push_back(static_cast(c)); break; + } + } + return r; +} + +static const char kBase64Chars[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static std::string base64Encode(const uint8_t* data, size_t len) { + std::string out; + out.reserve(((len + 2) / 3) * 4); + for (size_t i = 0; i < len; i += 3) { + const uint32_t b = + (static_cast(data[i]) << 16) | + (i + 1 < len ? static_cast(data[i + 1]) << 8 : 0u) | + (i + 2 < len ? static_cast(data[i + 2]) : 0u); + out.push_back(kBase64Chars[(b >> 18) & 0x3F]); + out.push_back(kBase64Chars[(b >> 12) & 0x3F]); + out.push_back(i + 1 < len ? kBase64Chars[(b >> 6) & 0x3F] : '='); + out.push_back(i + 2 < len ? kBase64Chars[(b ) & 0x3F] : '='); + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// GDI+ PNG encoder CLSID +// ───────────────────────────────────────────────────────────────────────────── +static bool getPngClsid(CLSID& out) { + UINT num = 0, sz = 0; + if (Gdiplus::GetImageEncodersSize(&num, &sz) != Gdiplus::Ok || sz == 0) return false; + std::vector buf(sz); + auto* enc = reinterpret_cast(buf.data()); + if (Gdiplus::GetImageEncoders(num, sz, enc) != Gdiplus::Ok) return false; + for (UINT i = 0; i < num; ++i) { + if (std::wstring(enc[i].MimeType) == L"image/png") { + out = enc[i].Clsid; + return true; + } + } + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Standard cursor-type lookup +// ───────────────────────────────────────────────────────────────────────────── +static const char* standardCursorType(HCURSOR hc) { + if (!hc) return nullptr; + static const struct { WORD id; const char* name; } kMap[] = { + {32512, "arrow"}, + {32513, "text"}, + {32514, "wait"}, + {32515, "crosshair"}, + {32516, "up-arrow"}, + {32642, "resize-nwse"}, + {32643, "resize-nesw"}, + {32644, "resize-ew"}, + {32645, "resize-ns"}, + {32646, "move"}, + {32648, "not-allowed"}, + {32649, "pointer"}, + {32650, "app-starting"}, + {32651, "help"}, + }; + static constexpr int N = static_cast(sizeof(kMap) / sizeof(kMap[0])); + static HCURSOR g_handles[N] = {}; + static bool g_init = false; + if (!g_init) { + for (int i = 0; i < N; ++i) + g_handles[i] = LoadCursor(nullptr, MAKEINTRESOURCE(kMap[i].id)); + g_init = true; + } + for (int i = 0; i < N; ++i) + if (g_handles[i] && g_handles[i] == hc) return kMap[i].name; + return nullptr; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Custom cursor-type detection (replicates the PowerShell heuristic) +// ───────────────────────────────────────────────────────────────────────────── +static const char* detectCustomCursorType( + const uint32_t* pixels, int w, int h, int hotX, int hotY) +{ + if (w < 24 || h < 24 || w > 64 || h > 64) return nullptr; + if (hotX < w * 0.25 || hotX > w * 0.75) return nullptr; + if (hotY < h * 0.15 || hotY > h * 0.55) return nullptr; + + int opaque = 0, topHalf = 0; + int left = w, top = h, right = -1, bottom = -1; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + const uint8_t a = static_cast(pixels[y * w + x] >> 24); + if (a <= 32) continue; + ++opaque; + if (y < h / 2) ++topHalf; + if (x < left) left = x; + if (x > right) right = x; + if (y < top) top = y; + if (y > bottom) bottom = y; + } + } + + if (opaque < 90 || right < left || bottom < top) return nullptr; + + const int ow = right - left + 1; + const int oh = bottom - top + 1; + if (ow < w * 0.35 || ow > w * 0.9) return nullptr; + if (oh < h * 0.45 || oh > static_cast(h)) return nullptr; + if (top > h * 0.45 || bottom < h * 0.65) return nullptr; + + return topHalf > opaque * 0.55 ? "closed-hand" : "open-hand"; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Build asset JSON for the given cursor (returns empty string on failure) +// +// Renders the cursor via GDI DrawIconEx onto a 32-bpp transparent DIB section +// and then encodes to PNG — matching the PowerShell approach of +// Graphics.Clear(Transparent) + Graphics.DrawIcon(). This correctly preserves +// per-pixel alpha for 32-bit cursors, unlike Gdiplus::Bitmap::FromHICON which +// can produce incorrect alpha for cursor handles. +// ───────────────────────────────────────────────────────────────────────────── +static std::string buildAssetJson( + HCURSOR hCursor, + const std::string& handleStr, + const CLSID& pngClsid, + const char** outCustomType) +{ + *outCustomType = nullptr; + + // Get hotspot and cursor dimensions from the icon info. + // For color cursors hbmColor gives the size; for monochrome cursors the + // mask bitmap is twice the cursor height (AND mask stacked on XOR mask). + ICONINFO ii{}; + if (!GetIconInfo(hCursor, &ii)) return {}; + const int hotX = static_cast(ii.xHotspot); + const int hotY = static_cast(ii.yHotspot); + + int w = 0, h = 0; + if (ii.hbmColor) { + BITMAP bm{}; + if (GetObject(ii.hbmColor, sizeof(bm), &bm)) { w = bm.bmWidth; h = bm.bmHeight; } + } + if (ii.hbmMask && (w == 0 || h == 0)) { + BITMAP bm{}; + if (GetObject(ii.hbmMask, sizeof(bm), &bm)) { + w = bm.bmWidth; + h = ii.hbmColor ? bm.bmHeight : bm.bmHeight / 2; + } + } + if (ii.hbmMask) DeleteObject(ii.hbmMask); + if (ii.hbmColor) DeleteObject(ii.hbmColor); + if (w <= 0 || h <= 0) return {}; + + // Copy the cursor handle so DrawIconEx cannot affect the live system cursor. + const HICON hCopy = CopyIcon(hCursor); + if (!hCopy) return {}; + + // Allocate a 32-bpp top-down DIB section and clear it to transparent black, + // then draw the cursor with DI_NORMAL. For 32-bit alpha cursors Windows + // writes correct per-pixel alpha into the high byte of each BGRA pixel. + const int stride = w * 4; + BITMAPINFOHEADER bih{}; + bih.biSize = sizeof(bih); + bih.biWidth = w; + bih.biHeight = -h; // negative = top-down scanline order + bih.biPlanes = 1; + bih.biBitCount = 32; + bih.biCompression = BI_RGB; + + void* pBits = nullptr; + HDC hDC = CreateCompatibleDC(nullptr); + HBITMAP hBmp = hDC ? CreateDIBSection(hDC, + reinterpret_cast(&bih), + DIB_RGB_COLORS, &pBits, nullptr, 0) + : nullptr; + + if (!hBmp || !pBits) { + if (hBmp) DeleteObject(hBmp); + if (hDC) DeleteDC(hDC); + DestroyIcon(hCopy); + return {}; + } + + HGDIOBJ hOld = SelectObject(hDC, hBmp); + std::memset(pBits, 0, static_cast(stride * h)); // transparent black + DrawIconEx(hDC, 0, 0, hCopy, w, h, 0, nullptr, DI_NORMAL); + GdiFlush(); + SelectObject(hDC, hOld); + DeleteDC(hDC); + DestroyIcon(hCopy); + + // GDI's 32-bit DIB stores pixels as BGRA in memory. GDI+'s + // PixelFormat32bppARGB interprets each 32-bit word as 0xAARRGGBB which is + // identical to BGRA on little-endian, so the alpha byte is always >> 24. + { + const auto* px = static_cast(pBits); + *outCustomType = detectCustomCursorType(px, w, h, hotX, hotY); + } + + // Wrap the DIB pixels in a GDI+ Bitmap (zero-copy) and save to PNG. + // Keep hBmp alive until after gBmp is destroyed so pBits remains valid. + std::vector pngData; + { + Gdiplus::Bitmap gBmp(w, h, stride, PixelFormat32bppARGB, + static_cast(pBits)); + if (gBmp.GetLastStatus() == Gdiplus::Ok) { + IStream* pStream = nullptr; + if (SUCCEEDED(CreateStreamOnHGlobal(nullptr, TRUE, &pStream))) { + if (gBmp.Save(pStream, &pngClsid) == Gdiplus::Ok) { + ULARGE_INTEGER sz{}; + LARGE_INTEGER zero{}; + pStream->Seek(zero, STREAM_SEEK_END, &sz); + pStream->Seek(zero, STREAM_SEEK_SET, nullptr); + pngData.resize(static_cast(sz.QuadPart)); + ULONG n = 0; + pStream->Read(pngData.data(), static_cast(pngData.size()), &n); + pngData.resize(n); + } + pStream->Release(); + } + } + } // gBmp destroyed here; pBits (owned by hBmp) still valid + DeleteObject(hBmp); + + if (pngData.empty()) return {}; + + const std::string dataUrl = + "data:image/png;base64," + base64Encode(pngData.data(), pngData.size()); + + std::string json; + json.reserve(dataUrl.size() + 128); + json = "{\"id\":\"" + handleStr + "\""; + json += ",\"imageDataUrl\":\"" + jsonEscape(dataUrl) + "\""; + json += ",\"width\":" + std::to_string(w); + json += ",\"height\":" + std::to_string(h); + json += ",\"hotspotX\":" + std::to_string(hotX); + json += ",\"hotspotY\":" + std::to_string(hotY); + if (*outCustomType) { + json += ",\"cursorType\":\""; + json += *outCustomType; + json += "\""; + } else { + json += ",\"cursorType\":null"; + } + json += "}"; + return json; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sampling loop (background thread) +// ───────────────────────────────────────────────────────────────────────────── +static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngClsid) { + HCURSOR lastCursor = nullptr; + + while (!g_stop.load(std::memory_order_relaxed)) { + const int downCount = g_leftDownCount.exchange(0, std::memory_order_relaxed); + const int upCount = g_leftUpCount.exchange(0, std::memory_order_relaxed); + + CURSORINFO ci{}; + ci.cbSize = sizeof(ci); + if (!GetCursorInfo(&ci)) { + char buf[160]; + std::snprintf(buf, sizeof(buf), + "{\"type\":\"error\",\"timestampMs\":%" PRId64 ",\"message\":\"GetCursorInfo failed\"}", + nowMs()); + writeJsonLine(buf); + std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs)); + continue; + } + + const bool visible = (ci.flags & CURSOR_SHOWING) != 0; + const HCURSOR hc = ci.hCursor; + + // Handle string ("0xHEX" or empty for null cursor) + char handleBuf[32] = {}; + if (hc) + std::snprintf(handleBuf, sizeof(handleBuf), + "0x%" PRIX64, static_cast(reinterpret_cast(hc))); + const std::string handleStr = hc ? handleBuf : ""; + + // Standard cursor type + const char* cursorType = standardCursorType(hc); + + // Mouse button state + const SHORT ks = GetAsyncKeyState(VK_LBUTTON); + const bool leftDown = (ks & 0x8000) != 0; + const bool leftPressed = downCount > 0 || (ks & 0x0001) != 0; + const bool leftReleased = upCount > 0; + + // Asset — only when the cursor handle changes + std::string assetJson; + if (visible && hc && hc != lastCursor) { + const char* customType = nullptr; + assetJson = buildAssetJson(hc, handleStr, pngClsid, &customType); + if (!assetJson.empty() && !cursorType && customType) + cursorType = customType; + lastCursor = hc; + } + + // Window bounds + std::string boundsJson = "null"; + if (targetWindow && IsWindow(targetWindow)) { + RECT r{}; + if (GetWindowRect(targetWindow, &r)) { + const int bw = r.right - r.left; + const int bh = r.bottom - r.top; + if (bw > 0 && bh > 0) { + char buf[128]; + std::snprintf(buf, sizeof(buf), + "{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}", + r.left, r.top, bw, bh); + boundsJson = buf; + } + } + } + + // Emit sample JSON + std::string out; + out.reserve(256); + out += "{\"type\":\"sample\""; + out += ",\"timestampMs\":"; out += std::to_string(nowMs()); + out += ",\"x\":"; out += std::to_string(ci.ptScreenPos.x); + out += ",\"y\":"; out += std::to_string(ci.ptScreenPos.y); + out += ",\"visible\":"; out += visible ? "true" : "false"; + out += ",\"handle\":"; out += hc ? ("\"" + handleStr + "\"") : "null"; + out += ",\"cursorType\":"; out += cursorType ? ("\"" + std::string(cursorType) + "\"") : "null"; + out += ",\"leftButtonDown\":"; out += leftDown ? "true" : "false"; + out += ",\"leftButtonPressed\":"; out += leftPressed ? "true" : "false"; + out += ",\"leftButtonReleased\":"; out += leftReleased ? "true" : "false"; + out += ",\"bounds\":"; out += boundsJson; + out += ",\"asset\":"; out += assetJson.empty() ? "null" : assetJson; + out += "}"; + + writeJsonLine(out); + + // Exit if stdout pipe is broken (parent process died) + if (std::cout.fail()) { + PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0); + break; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs)); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// main +// ───────────────────────────────────────────────────────────────────────────── +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "Usage: cursor-sampler [windowHandle]" << std::endl; + return 1; + } + + const int intervalMs = std::max(1, std::atoi(argv[1])); + + HWND targetWindow = nullptr; + if (argc >= 3) { + const std::string arg = argv[2]; + if (!arg.empty() && arg != "null") { + try { + const int base = (arg.rfind("0x", 0) == 0 || arg.rfind("0X", 0) == 0) ? 16 : 10; + const uint64_t v = std::stoull(arg, nullptr, base); + if (v) targetWindow = reinterpret_cast(static_cast(v)); + } catch (...) {} + } + } + + // Initialize GDI+ + Gdiplus::GdiplusStartupInput gdipInput{}; + ULONG_PTR gdipToken = 0; + if (Gdiplus::GdiplusStartup(&gdipToken, &gdipInput, nullptr) != Gdiplus::Ok) { + std::cerr << "GDI+ init failed" << std::endl; + return 1; + } + + CLSID pngClsid{}; + if (!getPngClsid(pngClsid)) { + std::cerr << "PNG encoder not found" << std::endl; + Gdiplus::GdiplusShutdown(gdipToken); + return 1; + } + + // Install global low-level mouse hook on this thread + g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, GetModuleHandle(nullptr), 0); + if (!g_mouseHook) { + std::cerr << "SetWindowsHookEx failed" << std::endl; + Gdiplus::GdiplusShutdown(gdipToken); + return 1; + } + + // Prime GetAsyncKeyState so the first poll doesn't return stale "since-last-call" bits + GetAsyncKeyState(VK_LBUTTON); + + // Signal readiness + g_mainThreadId = GetCurrentThreadId(); + { + char buf[80]; + std::snprintf(buf, sizeof(buf), + "{\"type\":\"ready\",\"timestampMs\":%" PRId64 "}", nowMs()); + writeJsonLine(buf); + } + + // Start sampling on a background thread + std::thread sampler(runSamplingLoop, intervalMs, targetWindow, std::cref(pngClsid)); + + // Run the message pump on the main thread — required for WH_MOUSE_LL callbacks + MSG msg; + while (GetMessage(&msg, nullptr, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + + g_stop.store(true, std::memory_order_relaxed); + if (sampler.joinable()) sampler.join(); + UnhookWindowsHookEx(g_mouseHook); + Gdiplus::GdiplusShutdown(gdipToken); + return 0; +} diff --git a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp index 14cb888..7e3f8b7 100644 --- a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp +++ b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp @@ -5,22 +5,18 @@ #include #include +#include #include #include +#include #include +#include namespace { const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}}; const CLSID CLSID_NullRendererLocal = {0xC1F400A4, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}}; -MIDL_INTERFACE("0579154A-2B53-4994-B0D0-E773148EFF85") -ISampleGrabberCB : public IUnknown { -public: - virtual HRESULT STDMETHODCALLTYPE SampleCB(double sampleTime, IMediaSample* sample) = 0; - virtual HRESULT STDMETHODCALLTYPE BufferCB(double sampleTime, BYTE* buffer, long bufferLength) = 0; -}; - MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F") ISampleGrabber : public IUnknown { public: @@ -30,7 +26,7 @@ public: virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0; virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0; virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 0; - virtual HRESULT STDMETHODCALLTYPE SetCallback(ISampleGrabberCB* callback, long whichMethodToCallback) = 0; + virtual HRESULT STDMETHODCALLTYPE SetCallback(IUnknown* callback, long whichMethodToCallback) = 0; }; bool succeeded(HRESULT hr, const char* label) { @@ -43,6 +39,34 @@ bool succeeded(HRESULT hr, const char* label) { return false; } +std::string guidToString(const GUID& guid) { + if (guid == MEDIASUBTYPE_RGB32) { + return "RGB32"; + } + if (guid == MEDIASUBTYPE_YUY2) { + return "YUY2"; + } + if (guid == MEDIASUBTYPE_NV12) { + return "NV12"; + } + + std::ostringstream stream; + stream << std::hex << std::setfill('0') + << '{' << std::setw(8) << guid.Data1 + << '-' << std::setw(4) << guid.Data2 + << '-' << std::setw(4) << guid.Data3 + << '-'; + for (int index = 0; index < 2; index += 1) { + stream << std::setw(2) << static_cast(guid.Data4[index]); + } + stream << '-'; + for (int index = 2; index < 8; index += 1) { + stream << std::setw(2) << static_cast(guid.Data4[index]); + } + stream << '}'; + return stream.str(); +} + void freeMediaType(AM_MEDIA_TYPE& type) { if (type.cbFormat != 0) { CoTaskMemFree(type.pbFormat); @@ -55,6 +79,20 @@ void freeMediaType(AM_MEDIA_TYPE& type) { } } +BYTE clampToByte(int value) { + return static_cast(std::clamp(value, 0, 255)); +} + +std::array yuvToBgr(int y, int u, int v) { + const int c = y - 16; + const int d = u - 128; + const int e = v - 128; + const int blue = (298 * c + 516 * d + 128) >> 8; + const int green = (298 * c - 100 * d - 208 * e + 128) >> 8; + const int red = (298 * c + 409 * e + 128) >> 8; + return {clampToByte(blue), clampToByte(green), clampToByte(red)}; +} + } // namespace struct DirectShowWebcamCapture::Impl { @@ -137,9 +175,8 @@ bool DirectShowWebcamCapture::initialize( AM_MEDIA_TYPE requestedType{}; requestedType.majortype = MEDIATYPE_Video; - requestedType.subtype = MEDIASUBTYPE_RGB32; requestedType.formattype = FORMAT_VideoInfo; - if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow RGB32)")) { + if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow video)")) { return false; } @@ -170,17 +207,40 @@ bool DirectShowWebcamCapture::initialize( if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) { return false; } + if (connectedType.subtype == MEDIASUBTYPE_YUY2) { + pixelFormat_ = PixelFormat::Yuy2; + } else if (connectedType.subtype == MEDIASUBTYPE_NV12) { + pixelFormat_ = PixelFormat::Nv12; + } else if (connectedType.subtype == MEDIASUBTYPE_RGB32) { + pixelFormat_ = PixelFormat::Bgra; + } else { + std::cerr << "ERROR: Unsupported DirectShow webcam media subtype " + << guidToString(connectedType.subtype) << std::endl; + freeMediaType(connectedType); + return false; + } if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) { const auto* videoInfo = reinterpret_cast(connectedType.pbFormat); width_ = std::abs(videoInfo->bmiHeader.biWidth); height_ = std::abs(videoInfo->bmiHeader.biHeight); - sourceTopDown_ = videoInfo->bmiHeader.biHeight < 0; + const int bitsPerPixel = videoInfo->bmiHeader.biBitCount > 0 ? videoInfo->bmiHeader.biBitCount : 16; + if (pixelFormat_ == PixelFormat::Nv12) { + sourceStride_ = ((width_ + 3) / 4) * 4; + } else { + sourceStride_ = ((width_ * bitsPerPixel + 31) / 32) * 4; + } + sourceTopDown_ = pixelFormat_ != PixelFormat::Bgra || videoInfo->bmiHeader.biHeight < 0; } + std::cerr << "INFO: DirectShow webcam connected subtype " << guidToString(connectedType.subtype) + << " " << width_ << "x" << height_ << " stride=" << sourceStride_ << std::endl; freeMediaType(connectedType); if (width_ <= 0 || height_ <= 0) { width_ = requestedWidth > 0 ? requestedWidth : 1280; height_ = requestedHeight > 0 ? requestedHeight : 720; } + if (sourceStride_ <= 0) { + sourceStride_ = pixelFormat_ == PixelFormat::Bgra ? width_ * 4 : ((width_ + 3) / 4) * 4; + } impl_->sampleGrabber->SetBufferSamples(TRUE); impl_->sampleGrabber->SetOneShot(FALSE); @@ -262,36 +322,91 @@ void DirectShowWebcamCapture::captureLoop() { } void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) { - const int stride = width_ * 4; - const int expectedLength = stride * height_; + const int destinationStride = width_ * 4; + const int sourceStride = sourceStride_ > 0 ? sourceStride_ : destinationStride; + const int expectedLength = pixelFormat_ == PixelFormat::Nv12 + ? sourceStride * height_ + sourceStride * ((height_ + 1) / 2) + : sourceStride * height_; if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) { return; } - std::vector frame(static_cast(expectedLength)); + std::vector frame(static_cast(destinationStride * height_)); for (int y = 0; y < height_; y += 1) { const int sourceY = sourceTopDown_ ? y : height_ - 1 - y; - const BYTE* source = buffer + sourceY * stride; - BYTE* destination = frame.data() + y * stride; - std::copy(source, source + stride, destination); - for (int x = 0; x < width_; x += 1) { - destination[x * 4 + 3] = 255; + const BYTE* source = buffer + sourceY * sourceStride; + BYTE* destination = frame.data() + y * destinationStride; + if (pixelFormat_ == PixelFormat::Bgra) { + std::copy(source, source + destinationStride, destination); + for (int x = 0; x < width_; x += 1) { + destination[x * 4 + 3] = 255; + } + continue; + } + + if (pixelFormat_ == PixelFormat::Nv12) { + const BYTE* yPlane = buffer + sourceY * sourceStride; + const BYTE* uvPlane = buffer + sourceStride * height_ + (sourceY / 2) * sourceStride; + for (int x = 0; x < width_; x += 1) { + const int uvX = (x / 2) * 2; + const auto color = yuvToBgr(yPlane[x], uvPlane[uvX], uvPlane[uvX + 1]); + BYTE* pixel = destination + x * 4; + pixel[0] = color[0]; + pixel[1] = color[1]; + pixel[2] = color[2]; + pixel[3] = 255; + } + continue; + } + + for (int x = 0; x + 1 < width_; x += 2) { + const BYTE y0 = source[x * 2]; + const BYTE u = source[x * 2 + 1]; + const BYTE y1 = source[x * 2 + 2]; + const BYTE v = source[x * 2 + 3]; + const auto first = yuvToBgr(y0, u, v); + const auto second = yuvToBgr(y1, u, v); + BYTE* firstPixel = destination + x * 4; + BYTE* secondPixel = firstPixel + 4; + firstPixel[0] = first[0]; + firstPixel[1] = first[1]; + firstPixel[2] = first[2]; + firstPixel[3] = 255; + secondPixel[0] = second[0]; + secondPixel[1] = second[1]; + secondPixel[2] = second[2]; + secondPixel[3] = 255; + } + if (width_ % 2 == 1) { + const int x = width_ - 1; + const int previousPairStart = ((x - 1) / 2) * 4; + const BYTE y = source[x * 2]; + const BYTE u = source[previousPairStart + 1]; + const BYTE v = source[previousPairStart + 3]; + const auto color = yuvToBgr(y, u, v); + BYTE* pixel = destination + x * 4; + pixel[0] = color[0]; + pixel[1] = color[1]; + pixel[2] = color[2]; + pixel[3] = 255; } } std::scoped_lock lock(frameMutex_); latestFrame_ = std::move(frame); + latestFrameSequence_ += 1; } -bool DirectShowWebcamCapture::copyLatestFrame(std::vector& destination, int& width, int& height) { +bool DirectShowWebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) { std::scoped_lock lock(frameMutex_); if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) { return false; } - destination = latestFrame_; - width = width_; - height = height_; + destination.data = latestFrame_; + destination.width = width_; + destination.height = height_; + destination.sequence = latestFrameSequence_; return true; } diff --git a/electron/native/wgc-capture/src/dshow_webcam_capture.h b/electron/native/wgc-capture/src/dshow_webcam_capture.h index 906da8f..3debcbe 100644 --- a/electron/native/wgc-capture/src/dshow_webcam_capture.h +++ b/electron/native/wgc-capture/src/dshow_webcam_capture.h @@ -3,11 +3,19 @@ #include #include +#include #include #include #include #include +struct WebcamFrameSnapshot { + std::vector data; + int width = 0; + int height = 0; + uint64_t sequence = 0; +}; + class DirectShowWebcamCapture { public: DirectShowWebcamCapture() = default; @@ -25,7 +33,7 @@ public: int requestedFps); bool start(); void stop(); - bool copyLatestFrame(std::vector& destination, int& width, int& height); + bool copyLatestFrame(WebcamFrameSnapshot& destination); int width() const; int height() const; @@ -34,6 +42,12 @@ public: void storeFrame(const BYTE* buffer, long length); private: + enum class PixelFormat { + Bgra, + Nv12, + Yuy2, + }; + struct Impl; void captureLoop(); @@ -42,9 +56,12 @@ private: std::atomic stopRequested_ = false; std::mutex frameMutex_; std::vector latestFrame_; + uint64_t latestFrameSequence_ = 0; int width_ = 0; int height_ = 0; int fps_ = 30; + int sourceStride_ = 0; bool sourceTopDown_ = false; + PixelFormat pixelFormat_ = PixelFormat::Bgra; std::wstring selectedDeviceName_; }; diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index f8f56cd..7968d94 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -29,6 +29,7 @@ struct CaptureConfig { std::string sourceId; std::string windowHandle; std::string outputPath; + std::string webcamOutputPath; int fps = 60; int width = 0; int height = 0; @@ -311,6 +312,7 @@ 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); @@ -389,6 +391,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 +408,7 @@ int main(int argc, char* argv[]) { << ",\"fps\":" << webcamCapture.fps() << ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName())) << "\"}" << std::endl; + writeSeparateWebcam = !config.webcamOutputPath.empty(); } WasapiLoopbackCapture loopbackCapture; @@ -466,6 +470,24 @@ int main(int argc, char* argv[]) { return 1; } + MFEncoder webcamEncoder; + if (writeSeparateWebcam) { + const int webcamPixels = std::max(1, webcamCapture.width()) * std::max(1, webcamCapture.height()); + const int webcamBitrate = webcamPixels >= 1280 * 720 ? 8'000'000 : 4'000'000; + if (!webcamEncoder.initialize( + utf8ToWide(config.webcamOutputPath), + webcamCapture.width(), + webcamCapture.height(), + webcamCapture.fps(), + webcamBitrate, + session.device(), + session.context(), + nullptr)) { + std::cerr << "ERROR: Failed to initialize native webcam encoder" << std::endl; + return 1; + } + } + std::mutex mutex; std::condition_variable cv; std::atomic stopRequested = false; @@ -477,6 +499,7 @@ int main(int argc, char* argv[]) { std::vector latestWebcamFrame; int latestWebcamWidth = 0; int latestWebcamHeight = 0; + uint64_t latestWebcamSequence = 0; bool hasVisibleWebcamFrame = false; session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { @@ -509,20 +532,22 @@ int main(int argc, char* argv[]) { auto writeVideoFrames = [&]() { const auto startedAt = std::chrono::steady_clock::now(); uint64_t frameIndex = 0; + uint64_t lastWrittenWebcamSequence = 0; + uint64_t webcamOutputFrameIndex = 0; int64_t lastEncodedVideoTimestampHns = -1; while (!stopRequested && !encodeFailed) { { std::scoped_lock lock(mutex); if (webcamActive) { - std::vector candidateWebcamFrame; - int candidateWebcamWidth = 0; - int candidateWebcamHeight = 0; - if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) && - hasVisibleBgraContent(candidateWebcamFrame)) { - latestWebcamFrame = std::move(candidateWebcamFrame); - latestWebcamWidth = candidateWebcamWidth; - latestWebcamHeight = candidateWebcamHeight; + WebcamFrameSnapshot candidateWebcamFrame; + if (webcamCapture.copyLatestFrame(candidateWebcamFrame) && + candidateWebcamFrame.sequence != latestWebcamSequence && + hasVisibleBgraContent(candidateWebcamFrame.data)) { + latestWebcamFrame = std::move(candidateWebcamFrame.data); + latestWebcamWidth = candidateWebcamFrame.width; + latestWebcamHeight = candidateWebcamFrame.height; + latestWebcamSequence = candidateWebcamFrame.sequence; hasVisibleWebcamFrame = true; } } @@ -545,10 +570,23 @@ int main(int argc, char* argv[]) { frameTimestampHns = lastEncodedVideoTimestampHns + static_cast(10'000'000ULL / config.fps); } + if (writeSeparateWebcam && webcamFrame.data && + latestWebcamSequence != lastWrittenWebcamSequence) { + const int64_t webcamTimestampHns = static_cast( + (webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps())); + if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) { + encodeFailed = true; + stopRequested = true; + cv.notify_all(); + return; + } + lastWrittenWebcamSequence = latestWebcamSequence; + webcamOutputFrameIndex += 1; + } if (latestFrameTexture && !encoder.writeFrame( latestFrameTexture.Get(), frameTimestampHns, - webcamFrame.data ? &webcamFrame : nullptr)) { + !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) { encodeFailed = true; stopRequested = true; cv.notify_all(); @@ -659,14 +697,13 @@ int main(int argc, char* argv[]) { webcamActive = true; const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) { - std::vector candidateWebcamFrame; - int candidateWebcamWidth = 0; - int candidateWebcamHeight = 0; - if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) && - hasVisibleBgraContent(candidateWebcamFrame)) { - latestWebcamFrame = std::move(candidateWebcamFrame); - latestWebcamWidth = candidateWebcamWidth; - latestWebcamHeight = candidateWebcamHeight; + WebcamFrameSnapshot candidateWebcamFrame; + if (webcamCapture.copyLatestFrame(candidateWebcamFrame) && + hasVisibleBgraContent(candidateWebcamFrame.data)) { + latestWebcamFrame = std::move(candidateWebcamFrame.data); + latestWebcamWidth = candidateWebcamFrame.width; + latestWebcamHeight = candidateWebcamFrame.height; + latestWebcamSequence = candidateWebcamFrame.sequence; hasVisibleWebcamFrame = true; break; } @@ -740,6 +777,9 @@ int main(int argc, char* argv[]) { { std::scoped_lock lock(mutex); encoder.finalize(); + if (writeSeparateWebcam) { + webcamEncoder.finalize(); + } } if (stdinThread.joinable()) { @@ -752,7 +792,11 @@ int main(int argc, char* argv[]) { } std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\"" - << jsonEscape(config.outputPath) << "\"}" << std::endl; + << jsonEscape(config.outputPath) << "\""; + if (writeSeparateWebcam) { + std::cout << ",\"webcamPath\":\"" << jsonEscape(config.webcamOutputPath) << "\""; + } + std::cout << "}" << std::endl; std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; return 0; } diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index b56386e..18bc4cc 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -254,6 +254,45 @@ bool MFEncoder::copyFrameToBuffer( return true; } +bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize) { + if (!frame.data || frame.width <= 0 || frame.height <= 0) { + return false; + } + + const DWORD rowBytes = static_cast(width_ * 4); + const DWORD requiredBytes = rowBytes * static_cast(height_); + if (destinationSize < requiredBytes) { + std::cerr << "ERROR: Media Foundation webcam buffer is too small" << std::endl; + return false; + } + + if (frame.width == width_ && frame.height == height_) { + for (DWORD i = 0; i < requiredBytes; i += 4) { + destination[i] = frame.data[i]; + destination[i + 1] = frame.data[i + 1]; + destination[i + 2] = frame.data[i + 2]; + destination[i + 3] = 255; + } + return true; + } + + for (int y = 0; y < height_; y += 1) { + const int sourceY = static_cast((static_cast(y) * frame.height) / height_); + BYTE* destinationRow = destination + rowBytes * y; + for (int x = 0; x < width_; x += 1) { + const int sourceX = static_cast((static_cast(x) * frame.width) / width_); + const BYTE* source = frame.data + (sourceY * frame.width + sourceX) * 4; + BYTE* target = destinationRow + x * 4; + target[0] = source[0]; + target[1] = source[1]; + target[2] = source[2]; + target[3] = 255; + } + } + + return true; +} + bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) { std::scoped_lock writerLock(writerMutex_); if (!sinkWriter_ || finalized_) { @@ -302,6 +341,54 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample"); } +bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) { + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } + + if (firstTimestampHns_ < 0) { + firstTimestampHns_ = timestampHns; + } + + int64_t sampleTime = timestampHns - firstTimestampHns_; + if (sampleTime <= lastTimestampHns_) { + sampleTime = lastTimestampHns_ + (10'000'000LL / fps_); + } + const int64_t sampleDuration = 10'000'000LL / fps_; + lastTimestampHns_ = sampleTime; + + Microsoft::WRL::ComPtr buffer; + const DWORD frameBytes = static_cast(width_ * height_ * 4); + if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer(webcam)")) { + return false; + } + + BYTE* data = nullptr; + DWORD maxLength = 0; + DWORD currentLength = 0; + if (!succeeded(buffer->Lock(&data, &maxLength, ¤tLength), "IMFMediaBuffer::Lock(webcam)")) { + return false; + } + + const bool copied = copyBgraFrameToBuffer(frame, data, maxLength); + buffer->Unlock(); + if (!copied) { + return false; + } + buffer->SetCurrentLength(frameBytes); + + Microsoft::WRL::ComPtr sample; + if (!succeeded(MFCreateSample(&sample), "MFCreateSample(webcam)")) { + return false; + } + sample->AddBuffer(buffer.Get()); + sample->SetSampleTime(sampleTime); + sample->SetSampleDuration(sampleDuration); + + return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)"); +} + bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { std::scoped_lock writerLock(writerMutex_); if (!sinkWriter_ || finalized_ || !hasAudioStream_) { diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index a82a940..e7821e9 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -44,6 +44,7 @@ public: ID3D11DeviceContext* context, const AudioInputFormat* audioFormat = nullptr); bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr); + bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns); bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns); bool finalize(); @@ -54,6 +55,7 @@ private: BYTE* destination, DWORD destinationSize, const BgraFrameView* webcamFrame); + bool copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize); bool configureAudioStream(const AudioInputFormat& audioFormat); Microsoft::WRL::ComPtr sinkWriter_; diff --git a/electron/native/wgc-capture/src/webcam_capture.cpp b/electron/native/wgc-capture/src/webcam_capture.cpp index aff9fdb..783b854 100644 --- a/electron/native/wgc-capture/src/webcam_capture.cpp +++ b/electron/native/wgc-capture/src/webcam_capture.cpp @@ -365,6 +365,7 @@ void WebcamCapture::captureLoop() { if (currentLength >= expectedLength && expectedLength > 0) { std::scoped_lock lock(frameMutex_); latestFrame_.assign(data, data + expectedLength); + latestFrameSequence_ += 1; } buffer->Unlock(); @@ -373,18 +374,19 @@ void WebcamCapture::captureLoop() { CoUninitialize(); } -bool WebcamCapture::copyLatestFrame(std::vector& destination, int& width, int& height) { +bool WebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) { if (usingDirectShow_) { - return directShowCapture_.copyLatestFrame(destination, width, height); + return directShowCapture_.copyLatestFrame(destination); } std::scoped_lock lock(frameMutex_); if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) { return false; } - destination = latestFrame_; - width = width_; - height = height_; + destination.data = latestFrame_; + destination.width = width_; + destination.height = height_; + destination.sequence = latestFrameSequence_; return true; } diff --git a/electron/native/wgc-capture/src/webcam_capture.h b/electron/native/wgc-capture/src/webcam_capture.h index c539d02..5b61aa6 100644 --- a/electron/native/wgc-capture/src/webcam_capture.h +++ b/electron/native/wgc-capture/src/webcam_capture.h @@ -31,7 +31,7 @@ public: int requestedFps); bool start(); void stop(); - bool copyLatestFrame(std::vector& destination, int& width, int& height); + bool copyLatestFrame(WebcamFrameSnapshot& destination); int width() const; int height() const; @@ -50,6 +50,7 @@ private: std::atomic stopRequested_ = false; std::mutex frameMutex_; std::vector latestFrame_; + uint64_t latestFrameSequence_ = 0; int width_ = 0; int height_ = 0; int fps_ = 30; diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 85da01e..46a1f05 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -104,9 +104,19 @@ if (!fs.existsSync(outputPath)) { throw new Error(`WGC helper build completed but ${outputPath} was not found.`); } +const cursorSamplerOutputPath = path.join(BUILD_DIR, "cursor-sampler.exe"); +if (!fs.existsSync(cursorSamplerOutputPath)) { + throw new Error(`WGC helper build completed but ${cursorSamplerOutputPath} was not found.`); +} + fs.mkdirSync(BIN_DIR, { recursive: true }); const distributablePath = path.join(BIN_DIR, "wgc-capture.exe"); fs.copyFileSync(outputPath, distributablePath); +const cursorSamplerDistributablePath = path.join(BIN_DIR, "cursor-sampler.exe"); +fs.copyFileSync(cursorSamplerOutputPath, cursorSamplerDistributablePath); + console.log(`Built ${outputPath}`); console.log(`Copied ${distributablePath}`); +console.log(`Built ${cursorSamplerOutputPath}`); +console.log(`Copied ${cursorSamplerDistributablePath}`); diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index 53cea19..5dd2dcc 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -230,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; @@ -263,7 +264,10 @@ const config = { webcamWidth: 640, webcamHeight: 360, webcamFps: 30, - outputs: { screenPath: outputPath }, + outputs: { + screenPath: outputPath, + ...(webcamOutputPath ? { webcamPath: webcamOutputPath } : {}), + }, }; let result; @@ -289,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 @@ -318,6 +327,9 @@ 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 && @@ -342,13 +354,26 @@ 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, diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 383fc48..d7a3d4e 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,6 +53,7 @@ import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; +import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, @@ -71,7 +72,6 @@ import type { } from "./types"; import { DEFAULT_WEBCAM_SIZE_PRESET, - MAX_PLAYBACK_SPEED, MAX_ZOOM_SCALE, MIN_ZOOM_SCALE, ROTATION_3D_PRESET_ORDER, @@ -90,37 +90,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) => { - 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]); @@ -128,8 +129,8 @@ function CustomSpeedInput({
setIsFocused(true)} @@ -696,7 +697,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" > - {t("links.reportBug")} + {t("support.reportBug")} {onSaveDiagnostic && ( )}
); @@ -812,6 +813,7 @@ export function SettingsPanel({