Merge remote-tracking branch 'origin/main' into feat/zoom-hold-preview
This commit is contained in:
+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;
|
||||
}
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
@@ -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<bool> stopRequested = false;
|
||||
@@ -477,6 +499,7 @@ 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) {
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
@@ -545,10 +570,23 @@ int main(int argc, char* argv[]) {
|
||||
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();
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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]);
|
||||
|
||||
@@ -128,8 +129,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)}
|
||||
@@ -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"
|
||||
>
|
||||
<Bug className="w-3 h-3 text-[#34B27B]" />
|
||||
{t("links.reportBug")}
|
||||
{t("support.reportBug")}
|
||||
</button>
|
||||
{onSaveDiagnostic && (
|
||||
<button
|
||||
@@ -705,7 +706,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
|
||||
@@ -716,7 +717,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>
|
||||
);
|
||||
@@ -812,6 +813,7 @@ 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)}
|
||||
@@ -1821,6 +1823,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",
|
||||
|
||||
@@ -865,11 +865,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],
|
||||
);
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
window.electronAPI?.setLocale?.(locale);
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+146
-64
@@ -45,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 = {
|
||||
@@ -84,6 +82,7 @@ type RecorderHandle = {
|
||||
type NativeWindowsRecordingHandle = {
|
||||
recordingId: number;
|
||||
finalizing: boolean;
|
||||
webcamRecorder: RecorderHandle | null;
|
||||
};
|
||||
|
||||
type NativeMacRecordingHandle = {
|
||||
@@ -267,13 +266,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 },
|
||||
},
|
||||
});
|
||||
@@ -422,58 +417,105 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 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) => {
|
||||
@@ -716,6 +758,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,
|
||||
@@ -731,12 +792,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)) {
|
||||
@@ -748,6 +809,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 = {
|
||||
@@ -775,11 +849,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: {
|
||||
@@ -788,6 +862,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.");
|
||||
}
|
||||
|
||||
@@ -795,7 +875,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
nativeWindowsRecording.current = {
|
||||
recordingId: result.recordingId,
|
||||
finalizing: false,
|
||||
webcamRecorder: browserWebcamRecorder,
|
||||
};
|
||||
webcamRecorder.current = browserWebcamRecorder;
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
@@ -907,8 +989,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
enabled: webcamEnabled,
|
||||
deviceId: webcamDeviceId,
|
||||
deviceName: webcamDeviceName,
|
||||
width: WEBCAM_TARGET_WIDTH,
|
||||
height: WEBCAM_TARGET_HEIGHT,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fps: WEBCAM_TARGET_FRAME_RATE,
|
||||
},
|
||||
cursor: {
|
||||
|
||||
@@ -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,16 +1,17 @@
|
||||
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",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
|
||||
@@ -105,8 +105,9 @@
|
||||
"gifButton": "تصدير GIF",
|
||||
"chooseSaveLocation": "اختيار موقع الحفظ"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "الإبلاغ عن خطأ",
|
||||
"saveDiagnostics": "حفظ التشخيصات",
|
||||
"starOnGithub": "إعطاء نجمة على GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -105,8 +105,9 @@
|
||||
"gifButton": "Export GIF",
|
||||
"chooseSaveLocation": "Choose Save Location"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Report Bug",
|
||||
"saveDiagnostics": "Save Diagnostics",
|
||||
"starOnGithub": "Star on GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -105,8 +105,9 @@
|
||||
"gifButton": "Exportar GIF",
|
||||
"chooseSaveLocation": "Elegir ubicación de guardado"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Reportar error",
|
||||
"saveDiagnostics": "Guardar diagnósticos",
|
||||
"starOnGithub": "Dar estrella en GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -106,8 +106,9 @@
|
||||
"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": {
|
||||
|
||||
@@ -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,206 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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",
|
||||
"actions": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -105,8 +105,9 @@
|
||||
"gifButton": "GIF をエクスポート",
|
||||
"chooseSaveLocation": "保存場所を選択"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "バグを報告",
|
||||
"saveDiagnostics": "診断情報を保存",
|
||||
"starOnGithub": "GitHub でスターを付ける"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"zoom": {
|
||||
"previewHold": "누르고 있으면 줌 효과 미리보기",
|
||||
"level": "줌 레벨",
|
||||
"customScale": "커스텀 줌",
|
||||
"selectRegion": "조정할 줌 구간을 선택하세요",
|
||||
"deleteZoom": "줌 삭제",
|
||||
"focusMode": {
|
||||
@@ -18,9 +19,8 @@
|
||||
"right": "오른쪽"
|
||||
}
|
||||
},
|
||||
"customScale": "사용자 지정 확대",
|
||||
"position": {
|
||||
"title": "초점 위치",
|
||||
"title": "포커스 위치",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽"
|
||||
@@ -52,10 +52,10 @@
|
||||
"blurBg": "배경 흐림",
|
||||
"motionBlur": "모션 블러",
|
||||
"off": "끄기",
|
||||
"on": "켜기",
|
||||
"shadow": "그림자",
|
||||
"roundness": "모서리 둥글기",
|
||||
"padding": "여백",
|
||||
"on": "켜짐"
|
||||
"padding": "여백"
|
||||
},
|
||||
"background": {
|
||||
"title": "배경",
|
||||
@@ -105,8 +105,9 @@
|
||||
"gifButton": "GIF 내보내기",
|
||||
"chooseSaveLocation": "저장 위치 선택"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "버그 신고",
|
||||
"saveDiagnostics": "Save Diagnostics",
|
||||
"starOnGithub": "GitHub에 Star 남기기"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -105,8 +105,9 @@
|
||||
"gifButton": "Экспорт GIF",
|
||||
"chooseSaveLocation": "Выбрать место сохранения"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Сообщить об ошибке",
|
||||
"saveDiagnostics": "Сохранить диагностику",
|
||||
"starOnGithub": "Звезда на GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -105,8 +105,9 @@
|
||||
"gifButton": "GIF Olarak Dışa Aktar",
|
||||
"chooseSaveLocation": "Kayıt Konumu Seç"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Hata Bildir",
|
||||
"saveDiagnostics": "Teşhis Verilerini Kaydet",
|
||||
"starOnGithub": "GitHub'da Yıldızla"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -105,8 +105,9 @@
|
||||
"gifButton": "Xuất GIF",
|
||||
"chooseSaveLocation": "Chọn vị trí lưu"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Báo cáo lỗi",
|
||||
"saveDiagnostics": "Lưu thông tin chẩn đoán",
|
||||
"starOnGithub": "Đánh giá sao trên GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -105,8 +105,9 @@
|
||||
"gifButton": "导出 GIF",
|
||||
"chooseSaveLocation": "选择保存位置"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "报告错误",
|
||||
"saveDiagnostics": "保存诊断信息",
|
||||
"starOnGithub": "在 GitHub 上加星"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -106,8 +106,9 @@
|
||||
"gifButton": "匯出 GIF",
|
||||
"chooseSaveLocation": "選擇儲存位置"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "回報錯誤",
|
||||
"saveDiagnostics": "儲存診斷資料",
|
||||
"starOnGithub": "在 GitHub 上加星"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -11,9 +11,9 @@ import type {
|
||||
import { BackgroundLoadError } from "@/lib/wallpaper";
|
||||
import type { CursorRecordingData } from "@/native/contracts";
|
||||
import { getPlatform } from "@/utils/platformUtils";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
import { FrameRenderer } from "./frameRenderer";
|
||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||
import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue";
|
||||
import type {
|
||||
ExportProgress,
|
||||
ExportResult,
|
||||
@@ -124,7 +124,7 @@ export class GifExporter {
|
||||
}
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
||||
let webcamFrameQueue: TimestampedVideoFrameQueue | null = null;
|
||||
|
||||
const warnings: string[] = [];
|
||||
const onWarning = (message: string) => warnings.push(message);
|
||||
@@ -216,7 +216,7 @@ export class GifExporter {
|
||||
console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
||||
|
||||
let frameIndex = 0;
|
||||
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
|
||||
webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null;
|
||||
let stopWebcamDecode = false;
|
||||
let webcamDecodeError: Error | null = null;
|
||||
const webcamDecodePromise =
|
||||
@@ -228,7 +228,7 @@ export class GifExporter {
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
async (webcamFrame) => {
|
||||
async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => {
|
||||
while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export class GifExporter {
|
||||
webcamFrame.close();
|
||||
return;
|
||||
}
|
||||
queue.enqueue(webcamFrame);
|
||||
queue.enqueue(webcamFrame, webcamSourceTimestampMs);
|
||||
},
|
||||
onWarning,
|
||||
)
|
||||
@@ -266,7 +266,9 @@ export class GifExporter {
|
||||
return;
|
||||
}
|
||||
|
||||
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
|
||||
webcamFrame = webcamFrameQueue
|
||||
? await webcamFrameQueue.frameAt(sourceTimestampMs)
|
||||
: null;
|
||||
const renderer = this.renderer;
|
||||
if (this.cancelled || !renderer) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue";
|
||||
|
||||
class MockVideoFrame {
|
||||
timestamp: number;
|
||||
closed = false;
|
||||
|
||||
constructor(source: MockVideoFrame | number) {
|
||||
this.timestamp = typeof source === "number" ? source : source.timestamp;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreVideoFrame(originalVideoFrame: typeof globalThis.VideoFrame | undefined) {
|
||||
if (originalVideoFrame === undefined) {
|
||||
delete (globalThis as { VideoFrame?: typeof globalThis.VideoFrame }).VideoFrame;
|
||||
return;
|
||||
}
|
||||
|
||||
vi.stubGlobal("VideoFrame", originalVideoFrame);
|
||||
}
|
||||
|
||||
describe("TimestampedVideoFrameQueue", () => {
|
||||
it("samples the latest webcam frame at or before the requested source timestamp", async () => {
|
||||
const originalVideoFrame = globalThis.VideoFrame;
|
||||
vi.stubGlobal("VideoFrame", MockVideoFrame);
|
||||
try {
|
||||
const queue = new TimestampedVideoFrameQueue();
|
||||
const frame0 = new MockVideoFrame(0) as unknown as VideoFrame;
|
||||
const frame33 = new MockVideoFrame(33_000) as unknown as VideoFrame;
|
||||
const frame66 = new MockVideoFrame(66_000) as unknown as VideoFrame;
|
||||
|
||||
queue.enqueue(frame0, 0);
|
||||
queue.enqueue(frame33, 33);
|
||||
queue.enqueue(frame66, 66);
|
||||
queue.close();
|
||||
|
||||
const sampled0 = await queue.frameAt(0);
|
||||
const sampled20 = await queue.frameAt(20);
|
||||
const sampled40 = await queue.frameAt(40);
|
||||
const sampled80 = await queue.frameAt(80);
|
||||
|
||||
expect(sampled0?.timestamp).toBe(0);
|
||||
expect(sampled20?.timestamp).toBe(0);
|
||||
expect(sampled40?.timestamp).toBe(33_000);
|
||||
expect(sampled80?.timestamp).toBe(66_000);
|
||||
|
||||
sampled0?.close();
|
||||
sampled20?.close();
|
||||
sampled40?.close();
|
||||
sampled80?.close();
|
||||
queue.destroy();
|
||||
} finally {
|
||||
restoreVideoFrame(originalVideoFrame);
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for a newer frame before falling back to the held frame while open", async () => {
|
||||
const originalVideoFrame = globalThis.VideoFrame;
|
||||
vi.stubGlobal("VideoFrame", MockVideoFrame);
|
||||
try {
|
||||
const queue = new TimestampedVideoFrameQueue();
|
||||
const frame0 = new MockVideoFrame(0) as unknown as VideoFrame;
|
||||
const frame33 = new MockVideoFrame(33_000) as unknown as VideoFrame;
|
||||
|
||||
queue.enqueue(frame0, 0);
|
||||
const sampled0 = await queue.frameAt(0);
|
||||
let resolved = false;
|
||||
const pending = queue.frameAt(33).then((frame) => {
|
||||
resolved = true;
|
||||
return frame;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
queue.enqueue(frame33, 33);
|
||||
const sampled33 = await pending;
|
||||
|
||||
expect(sampled0?.timestamp).toBe(0);
|
||||
expect(sampled33?.timestamp).toBe(33_000);
|
||||
|
||||
sampled0?.close();
|
||||
sampled33?.close();
|
||||
queue.destroy();
|
||||
} finally {
|
||||
restoreVideoFrame(originalVideoFrame);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
type TimestampedVideoFrame = {
|
||||
frame: VideoFrame;
|
||||
sourceTimestampMs: number;
|
||||
};
|
||||
|
||||
type PendingConsumer = {
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
const TIMESTAMP_EPSILON_MS = 0.5;
|
||||
|
||||
export class TimestampedVideoFrameQueue {
|
||||
private frames: TimestampedVideoFrame[] = [];
|
||||
private consumers: PendingConsumer[] = [];
|
||||
private error: Error | null = null;
|
||||
private closed = false;
|
||||
private heldFrame: TimestampedVideoFrame | null = null;
|
||||
|
||||
get length() {
|
||||
return this.frames.length;
|
||||
}
|
||||
|
||||
enqueue(frame: VideoFrame, sourceTimestampMs: number) {
|
||||
if (this.closed) {
|
||||
frame.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.frames.push({ frame, sourceTimestampMs });
|
||||
const consumers = this.consumers.splice(0);
|
||||
for (const consumer of consumers) {
|
||||
consumer.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
fail(error: Error) {
|
||||
this.error = error;
|
||||
this.closed = true;
|
||||
const consumers = this.consumers.splice(0);
|
||||
for (const consumer of consumers) {
|
||||
consumer.reject(error);
|
||||
}
|
||||
this.closeOwnedFrames();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
const consumers = this.consumers.splice(0);
|
||||
for (const consumer of consumers) {
|
||||
consumer.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
async frameAt(sourceTimestampMs: number): Promise<VideoFrame | null> {
|
||||
for (;;) {
|
||||
if (this.error) {
|
||||
throw this.error;
|
||||
}
|
||||
|
||||
const next = this.frames[0] ?? null;
|
||||
if (next && next.sourceTimestampMs <= sourceTimestampMs + TIMESTAMP_EPSILON_MS) {
|
||||
this.replaceHeldFrame(this.frames.shift() ?? null);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.heldFrame &&
|
||||
(next ||
|
||||
this.closed ||
|
||||
this.heldFrame.sourceTimestampMs >= sourceTimestampMs - TIMESTAMP_EPSILON_MS)
|
||||
) {
|
||||
return new VideoFrame(this.heldFrame.frame, {
|
||||
timestamp: this.heldFrame.frame.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
if (next || this.closed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.consumers.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.closeOwnedFrames();
|
||||
}
|
||||
|
||||
private replaceHeldFrame(frame: TimestampedVideoFrame | null) {
|
||||
if (this.heldFrame) {
|
||||
this.heldFrame.frame.close();
|
||||
}
|
||||
this.heldFrame = frame;
|
||||
}
|
||||
|
||||
private closeOwnedFrames() {
|
||||
if (this.heldFrame) {
|
||||
this.heldFrame.frame.close();
|
||||
this.heldFrame = null;
|
||||
}
|
||||
for (const item of this.frames) {
|
||||
item.frame.close();
|
||||
}
|
||||
this.frames = [];
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import type {
|
||||
import { BackgroundLoadError } from "@/lib/wallpaper";
|
||||
import type { CursorRecordingData } from "@/native/contracts";
|
||||
import { getPlatform } from "@/utils/platformUtils";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
import { AudioProcessor } from "./audioEncoder";
|
||||
import { FrameRenderer } from "./frameRenderer";
|
||||
import { VideoMuxer } from "./muxer";
|
||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||
import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue";
|
||||
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
|
||||
|
||||
const ENCODER_STALL_TIMEOUT_MS = 15_000;
|
||||
@@ -195,7 +195,7 @@ export class VideoExporter {
|
||||
private async exportWithEncoderPreference(
|
||||
encoderPreference: HardwareAcceleration,
|
||||
): Promise<ExportResult> {
|
||||
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
||||
let webcamFrameQueue: TimestampedVideoFrameQueue | null = null;
|
||||
let stopWebcamDecode = false;
|
||||
let webcamDecodeError: Error | null = null;
|
||||
let webcamDecodePromise: Promise<void> | null = null;
|
||||
@@ -290,7 +290,7 @@ export class VideoExporter {
|
||||
? Math.min(this.MAX_ENCODE_QUEUE, 32)
|
||||
: this.MAX_ENCODE_QUEUE;
|
||||
|
||||
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
|
||||
webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null;
|
||||
webcamDecodePromise =
|
||||
webcamDecoder && webcamFrameQueue
|
||||
? (() => {
|
||||
@@ -300,7 +300,7 @@ export class VideoExporter {
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
async (webcamFrame) => {
|
||||
async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => {
|
||||
while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
@@ -308,7 +308,7 @@ export class VideoExporter {
|
||||
webcamFrame.close();
|
||||
return;
|
||||
}
|
||||
queue.enqueue(webcamFrame);
|
||||
queue.enqueue(webcamFrame, webcamSourceTimestampMs);
|
||||
},
|
||||
onWarning,
|
||||
)
|
||||
@@ -342,7 +342,9 @@ export class VideoExporter {
|
||||
}
|
||||
|
||||
const timestamp = frameIndex * frameDuration;
|
||||
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
|
||||
webcamFrame = webcamFrameQueue
|
||||
? await webcamFrameQueue.frameAt(sourceTimestampMs)
|
||||
: null;
|
||||
if (this.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function getAspectRatioDimensions(
|
||||
}
|
||||
|
||||
export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
|
||||
if (aspectRatio === "native") return "Native";
|
||||
if (aspectRatio === "native") return "Original";
|
||||
return aspectRatio;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export type TestId = `gif-size-button-${string}` | "export-button" | `gif-format-button`;
|
||||
export type TestId =
|
||||
| `gif-size-button-${string}`
|
||||
| "export-button"
|
||||
| "export-panel-button"
|
||||
| "gif-format-button"
|
||||
| "mp4-format-button";
|
||||
|
||||
export function getTestId(testId: TestId) {
|
||||
return `testId-${testId}`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -9,8 +10,8 @@ const ROOT = path.join(__dirname, "../..");
|
||||
const MAIN_JS = path.join(ROOT, "dist-electron/main.js");
|
||||
const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm");
|
||||
|
||||
test("exports a GIF from a loaded video", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`);
|
||||
async function exportFromLoadedVideo(format: "gif" | "mp4"): Promise<Buffer> {
|
||||
const outputPath = path.join(os.tmpdir(), `test-${format}-export-${Date.now()}.${format}`);
|
||||
let testVideoInRecordings = "";
|
||||
|
||||
const app = await electron.launch({
|
||||
@@ -27,42 +28,39 @@ test("exports a GIF from a loaded video", async () => {
|
||||
HEADLESS: process.env["HEADLESS"] ?? "true",
|
||||
},
|
||||
});
|
||||
const electronProcess = app.process();
|
||||
|
||||
// Print all main-process stdout/stderr so failures are diagnosable.
|
||||
app.process().stdout?.on("data", (d) => process.stdout.write(`[electron] ${d}`));
|
||||
app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`));
|
||||
|
||||
try {
|
||||
// ── 1. Wait for the HUD overlay window. The window is created after
|
||||
// registerIpcHandlers() completes, so all IPC handlers are live
|
||||
// by the time firstWindow() resolves.
|
||||
const hudWindow = await app.firstWindow({ timeout: 60_000 });
|
||||
await hudWindow.waitForLoadState("domcontentloaded");
|
||||
|
||||
// ── 2. Intercept the native save dialog in the main process.
|
||||
// Must happen after firstWindow() so registerIpcHandlers() has
|
||||
// already registered its version — otherwise our early handle()
|
||||
// call causes registerIpcHandlers() to throw and abort, leaving
|
||||
// other handlers (like set-current-video-path) never registered.
|
||||
// Store the exported buffer as a base64 global in the main process.
|
||||
// We can't use require() or import() inside app.evaluate() because the
|
||||
// main process is ESM and Playwright runs the callback via eval(), which
|
||||
// has no dynamic-import hook. We retrieve and write the file below after
|
||||
// the export finishes.
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler("save-exported-video");
|
||||
await app.evaluate(({ ipcMain }, targetPath: string) => {
|
||||
ipcMain.removeHandler("pick-export-save-path");
|
||||
ipcMain.removeHandler("write-export-to-path");
|
||||
ipcMain.handle("pick-export-save-path", () => ({
|
||||
success: true,
|
||||
path: targetPath,
|
||||
canceled: false,
|
||||
}));
|
||||
ipcMain.handle(
|
||||
"save-exported-video",
|
||||
(_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer) => {
|
||||
"write-export-to-path",
|
||||
(_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer, filePath: string) => {
|
||||
if (filePath !== targetPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Unexpected export path: ${filePath}`,
|
||||
};
|
||||
}
|
||||
(globalThis as Record<string, unknown>)["__testExportData"] =
|
||||
Buffer.from(buffer).toString("base64");
|
||||
return { success: true, path: "pending" };
|
||||
return { success: true, path: filePath };
|
||||
},
|
||||
);
|
||||
});
|
||||
}, outputPath);
|
||||
|
||||
// Copy the test fixture into the app's recordings directory so it passes
|
||||
// the path security check in set-current-video-path.
|
||||
const userDataDir = await app.evaluate(({ app: electronApp }) => {
|
||||
return electronApp.getPath("userData");
|
||||
});
|
||||
@@ -71,62 +69,73 @@ test("exports a GIF from a loaded video", async () => {
|
||||
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||
fs.copyFileSync(TEST_VIDEO, testVideoInRecordings);
|
||||
|
||||
await hudWindow.evaluate(
|
||||
(videoPath: string) => window.electronAPI.setCurrentVideoPath(videoPath),
|
||||
testVideoInRecordings,
|
||||
);
|
||||
try {
|
||||
await hudWindow.evaluate((videoPath: string) => {
|
||||
window.electronAPI.setCurrentVideoPath(videoPath);
|
||||
window.electronAPI.switchToEditor();
|
||||
}, testVideoInRecordings);
|
||||
} catch {
|
||||
// Expected: switchToEditor() closes the HUD window, terminating
|
||||
// the Playwright page context before evaluate() can resolve.
|
||||
await hudWindow.evaluate(() => window.electronAPI.switchToEditor());
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!/closed|destroyed|target page|target closed/i.test(error.message)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Switch to the editor window. This closes the HUD and opens
|
||||
// a new BrowserWindow with ?windowType=editor.
|
||||
const editorWindow = await app.waitForEvent("window", {
|
||||
predicate: (w) => w.url().includes("windowType=editor"),
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// WebCodecs (VideoEncoder) may not be registered in the renderer on first
|
||||
// load of a second BrowserWindow. A single reload ensures the feature is
|
||||
// fully initialized before we start encoding.
|
||||
// WebCodecs may not be registered in the renderer on first load.
|
||||
await editorWindow.reload();
|
||||
await editorWindow.waitForLoadState("domcontentloaded");
|
||||
await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// ── 5. Select GIF as the export format.
|
||||
await editorWindow.getByTestId("testId-gif-format-button").click();
|
||||
await editorWindow.getByTestId("testId-export-panel-button").click();
|
||||
await editorWindow.getByTestId(`testId-${format}-format-button`).click();
|
||||
await editorWindow.getByTestId("testId-export-button").click();
|
||||
|
||||
// ── 6. Wait for the success toast.
|
||||
await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({
|
||||
timeout: 90_000,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
app.evaluate(() => Boolean((globalThis as Record<string, unknown>)["__testExportData"])),
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// ── 7. Write the captured buffer from the main-process global to disk.
|
||||
const base64 = await app.evaluate(
|
||||
() => (globalThis as Record<string, unknown>)["__testExportData"] as string,
|
||||
);
|
||||
fs.writeFileSync(outputPath, Buffer.from(base64, "base64"));
|
||||
|
||||
// ── 8. Verify the file on disk is a valid GIF.
|
||||
expect(fs.existsSync(outputPath), `GIF not found at ${outputPath}`).toBe(true);
|
||||
|
||||
const header = Buffer.alloc(6);
|
||||
const fd = fs.openSync(outputPath, "r");
|
||||
fs.readSync(fd, header, 0, 6, 0);
|
||||
fs.closeSync(fd);
|
||||
|
||||
// GIF magic bytes are either "GIF87a" or "GIF89a"
|
||||
expect(header.toString("ascii")).toMatch(/^GIF8[79]a/);
|
||||
|
||||
expect(fs.existsSync(outputPath), `${format.toUpperCase()} not found at ${outputPath}`).toBe(
|
||||
true,
|
||||
);
|
||||
const stats = fs.statSync(outputPath);
|
||||
expect(stats.size).toBeGreaterThan(1024); // at least 1 KB
|
||||
expect(stats.size).toBeGreaterThan(1024);
|
||||
return fs.readFileSync(outputPath);
|
||||
} finally {
|
||||
await app.close();
|
||||
await app
|
||||
.evaluate(({ app: electronApp }) => {
|
||||
electronApp.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
// The process may already be gone after export completes.
|
||||
});
|
||||
if (electronProcess.pid) {
|
||||
if (process.platform === "win32") {
|
||||
spawnSync("taskkill", ["/PID", String(electronProcess.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
} else if (!electronProcess.killed) {
|
||||
electronProcess.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(outputPath)) {
|
||||
fs.unlinkSync(outputPath);
|
||||
}
|
||||
@@ -134,4 +143,16 @@ test("exports a GIF from a loaded video", async () => {
|
||||
fs.unlinkSync(testVideoInRecordings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("exports an MP4 from a loaded video", async () => {
|
||||
const exported = await exportFromLoadedVideo("mp4");
|
||||
|
||||
expect(exported.subarray(4, 8).toString("ascii")).toBe("ftyp");
|
||||
});
|
||||
|
||||
test("exports a GIF from a loaded video", async () => {
|
||||
const exported = await exportFromLoadedVideo("gif");
|
||||
|
||||
expect(exported.subarray(0, 6).toString("ascii")).toMatch(/^GIF8[79]a/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user