Merge remote-tracking branch 'origin/main' into feat/zoom-hold-preview

This commit is contained in:
Siddharth
2026-05-22 20:11:28 -07:00
47 changed files with 2027 additions and 687 deletions
+28 -8
View File
@@ -9,10 +9,14 @@ import commonEs from "../src/i18n/locales/es/common.json";
import dialogsEs from "../src/i18n/locales/es/dialogs.json"; import dialogsEs from "../src/i18n/locales/es/dialogs.json";
import commonFr from "../src/i18n/locales/fr/common.json"; import commonFr from "../src/i18n/locales/fr/common.json";
import dialogsFr from "../src/i18n/locales/fr/dialogs.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 commonJa from "../src/i18n/locales/ja-JP/common.json";
import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json"; import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json";
import commonKo from "../src/i18n/locales/ko-KR/common.json"; import commonKo from "../src/i18n/locales/ko-KR/common.json";
import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.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 commonTr from "../src/i18n/locales/tr/common.json";
import dialogsTr from "../src/i18n/locales/tr/dialogs.json"; import dialogsTr from "../src/i18n/locales/tr/dialogs.json";
import commonVi from "../src/i18n/locales/vi/common.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 commonZhTw from "../src/i18n/locales/zh-TW/common.json";
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.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 Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>; type MessageMap = Record<string, unknown>;
const messages: Record<Locale, Record<Namespace, MessageMap>> = { const messages: Record<Locale, Record<Namespace, MessageMap>> = {
en: { common: commonEn, dialogs: dialogsEn }, en: { common: commonEn, dialogs: dialogsEn },
"zh-CN": { common: commonZh, dialogs: dialogsZh }, ar: { common: commonAr, dialogs: dialogsAr },
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
es: { common: commonEs, dialogs: dialogsEs }, es: { common: commonEs, dialogs: dialogsEs },
fr: { common: commonFr, dialogs: dialogsFr }, fr: { common: commonFr, dialogs: dialogsFr },
it: { common: commonIt, dialogs: dialogsIt },
"ja-JP": { common: commonJa, dialogs: dialogsJa }, "ja-JP": { common: commonJa, dialogs: dialogsJa },
"ko-KR": { common: commonKo, dialogs: dialogsKo }, "ko-KR": { common: commonKo, dialogs: dialogsKo },
ru: { common: commonRu, dialogs: dialogsRu },
tr: { common: commonTr, dialogs: dialogsTr }, tr: { common: commonTr, dialogs: dialogsTr },
ar: { common: commonAr, dialogs: dialogsAr },
vi: { common: commonVi, dialogs: dialogsVi }, vi: { common: commonVi, dialogs: dialogsVi },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
}; };
let currentLocale: Locale = "en"; let currentLocale: Locale = "en";
@@ -44,15 +62,17 @@ let currentLocale: Locale = "en";
export function setMainLocale(locale: string) { export function setMainLocale(locale: string) {
if ( if (
locale === "en" || locale === "en" ||
locale === "zh-CN" || locale === "ar" ||
locale === "zh-TW" ||
locale === "es" || locale === "es" ||
locale === "fr" || locale === "fr" ||
locale === "it" ||
locale === "ja-JP" || locale === "ja-JP" ||
locale === "ko-KR" || locale === "ko-KR" ||
locale === "ru" ||
locale === "tr" || locale === "tr" ||
locale === "ar" || locale === "vi" ||
locale === "vi" locale === "zh-CN" ||
locale === "zh-TW"
) { ) {
currentLocale = locale; 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 { type ChildProcessByStdio, spawn } from "node:child_process";
import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
import { screen } from "electron"; import { app, screen } from "electron";
import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording"; import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording";
import type { import type {
CursorRecordingData, CursorRecordingData,
@@ -12,12 +10,32 @@ import type {
NativeCursorAsset, NativeCursorAsset,
} from "../../../../src/native/contracts"; } from "../../../../src/native/contracts";
import type { CursorRecordingSession } from "./session"; import type { CursorRecordingSession } from "./session";
import { buildPowerShellScript } from "./windowsNativeRecordingSession.script";
import type { import type {
WindowsCursorEvent, WindowsCursorEvent,
WindowsNativeRecordingSessionOptions, WindowsNativeRecordingSessionOptions,
} from "./windowsNativeRecordingSession.types"; } 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; const READY_TIMEOUT_MS = 5_000;
interface NormalizedSample { interface NormalizedSample {
@@ -29,7 +47,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
private assets = new Map<string, NativeCursorAsset>(); private assets = new Map<string, NativeCursorAsset>();
private samples: CursorRecordingSample[] = []; private samples: CursorRecordingSample[] = [];
private process: ChildProcessByStdio<null, Readable, Readable> | null = null; private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
private helperScriptPath: string | null = null;
private lineBuffer = ""; private lineBuffer = "";
private startTimeMs = 0; private startTimeMs = 0;
private readyResolve: (() => void) | null = null; private readyResolve: (() => void) | null = null;
@@ -50,41 +67,26 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
this.outOfBoundsSampleCount = 0; this.outOfBoundsSampleCount = 0;
this.previousLeftButtonDown = false; this.previousLeftButtonDown = false;
const script = buildPowerShellScript( const helperPath = findCursorSamplerPath();
this.options.sampleIntervalMs, if (!helperPath) {
parseWindowHandleFromSourceId(this.options.sourceId), throw new Error("Windows cursor sampler helper is not available.");
); }
const helperScriptDir = join(tmpdir(), "openscreen-cursor-native");
mkdirSync(helperScriptDir, { recursive: true }); const windowHandle = parseWindowHandleFromSourceId(this.options.sourceId);
const helperScriptPath = join( const args = [String(this.options.sampleIntervalMs)];
helperScriptDir, if (windowHandle) args.push(windowHandle);
`cursor-sampler-${process.pid}-${Date.now()}-${randomUUID()}.ps1`,
); const child = spawn(helperPath, args, {
writeFileSync(helperScriptPath, script, "utf8"); stdio: ["ignore", "pipe", "pipe"],
this.helperScriptPath = helperScriptPath; windowsHide: true,
const child = spawn( });
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
helperScriptPath,
],
{
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
},
);
this.process = child; this.process = child;
this.logDiagnostic("spawn", { this.logDiagnostic("spawn", {
pid: child.pid ?? null, pid: child.pid ?? null,
sampleIntervalMs: this.options.sampleIntervalMs, sampleIntervalMs: this.options.sampleIntervalMs,
sourceId: this.options.sourceId ?? null, sourceId: this.options.sourceId ?? null,
windowHandle: parseWindowHandleFromSourceId(this.options.sourceId), windowHandle,
}); });
child.stdout.setEncoding("utf8"); child.stdout.setEncoding("utf8");
@@ -100,7 +102,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
console.error("[cursor-native]", message); console.error("[cursor-native]", message);
}); });
child.once("exit", (code, signal) => { child.once("exit", (code, signal) => {
this.cleanupHelperScript(helperScriptPath);
this.logDiagnostic("exit", { this.logDiagnostic("exit", {
code, code,
signal, signal,
@@ -113,7 +114,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
); );
}); });
child.once("error", (error) => { child.once("error", (error) => {
this.cleanupHelperScript(helperScriptPath);
this.logDiagnostic("process-error", { message: error.message }); this.logDiagnostic("process-error", { message: error.message });
this.rejectReady(error); this.rejectReady(error);
}); });
@@ -122,7 +122,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
await this.waitUntilReady(); await this.waitUntilReady();
} catch (error) { } catch (error) {
this.terminateHelperProcess(); this.terminateHelperProcess();
this.cleanupHelperScript(helperScriptPath);
throw error; throw error;
} }
} }
@@ -315,25 +314,6 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
this.readyReject = null; 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>) { private logDiagnostic(event: string, data: Record<string, unknown>) {
console.info( console.info(
"[cursor-native][win32]", "[cursor-native][win32]",
@@ -49,3 +49,19 @@ target_link_libraries(wgc-capture PRIVATE
runtimeobject runtimeobject
windowsapp 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 <wrl/client.h>
#include <algorithm> #include <algorithm>
#include <array>
#include <chrono> #include <chrono>
#include <exception> #include <exception>
#include <iomanip>
#include <iostream> #include <iostream>
#include <sstream>
namespace { namespace {
const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}}; 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}}; 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") MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F")
ISampleGrabber : public IUnknown { ISampleGrabber : public IUnknown {
public: public:
@@ -30,7 +26,7 @@ public:
virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0; virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0;
virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0; virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0;
virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 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) { bool succeeded(HRESULT hr, const char* label) {
@@ -43,6 +39,34 @@ bool succeeded(HRESULT hr, const char* label) {
return false; 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) { void freeMediaType(AM_MEDIA_TYPE& type) {
if (type.cbFormat != 0) { if (type.cbFormat != 0) {
CoTaskMemFree(type.pbFormat); 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 } // namespace
struct DirectShowWebcamCapture::Impl { struct DirectShowWebcamCapture::Impl {
@@ -137,9 +175,8 @@ bool DirectShowWebcamCapture::initialize(
AM_MEDIA_TYPE requestedType{}; AM_MEDIA_TYPE requestedType{};
requestedType.majortype = MEDIATYPE_Video; requestedType.majortype = MEDIATYPE_Video;
requestedType.subtype = MEDIASUBTYPE_RGB32;
requestedType.formattype = FORMAT_VideoInfo; requestedType.formattype = FORMAT_VideoInfo;
if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow RGB32)")) { if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow video)")) {
return false; return false;
} }
@@ -170,17 +207,40 @@ bool DirectShowWebcamCapture::initialize(
if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) { if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) {
return false; 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) { if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) {
const auto* videoInfo = reinterpret_cast<VIDEOINFOHEADER*>(connectedType.pbFormat); const auto* videoInfo = reinterpret_cast<VIDEOINFOHEADER*>(connectedType.pbFormat);
width_ = std::abs(videoInfo->bmiHeader.biWidth); width_ = std::abs(videoInfo->bmiHeader.biWidth);
height_ = std::abs(videoInfo->bmiHeader.biHeight); 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); freeMediaType(connectedType);
if (width_ <= 0 || height_ <= 0) { if (width_ <= 0 || height_ <= 0) {
width_ = requestedWidth > 0 ? requestedWidth : 1280; width_ = requestedWidth > 0 ? requestedWidth : 1280;
height_ = requestedHeight > 0 ? requestedHeight : 720; height_ = requestedHeight > 0 ? requestedHeight : 720;
} }
if (sourceStride_ <= 0) {
sourceStride_ = pixelFormat_ == PixelFormat::Bgra ? width_ * 4 : ((width_ + 3) / 4) * 4;
}
impl_->sampleGrabber->SetBufferSamples(TRUE); impl_->sampleGrabber->SetBufferSamples(TRUE);
impl_->sampleGrabber->SetOneShot(FALSE); impl_->sampleGrabber->SetOneShot(FALSE);
@@ -262,36 +322,91 @@ void DirectShowWebcamCapture::captureLoop() {
} }
void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) { void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) {
const int stride = width_ * 4; const int destinationStride = width_ * 4;
const int expectedLength = stride * height_; 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) { if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) {
return; 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) { for (int y = 0; y < height_; y += 1) {
const int sourceY = sourceTopDown_ ? y : height_ - 1 - y; const int sourceY = sourceTopDown_ ? y : height_ - 1 - y;
const BYTE* source = buffer + sourceY * stride; const BYTE* source = buffer + sourceY * sourceStride;
BYTE* destination = frame.data() + y * stride; BYTE* destination = frame.data() + y * destinationStride;
std::copy(source, source + stride, destination); if (pixelFormat_ == PixelFormat::Bgra) {
for (int x = 0; x < width_; x += 1) { std::copy(source, source + destinationStride, destination);
destination[x * 4 + 3] = 255; 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_); std::scoped_lock lock(frameMutex_);
latestFrame_ = std::move(frame); 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_); std::scoped_lock lock(frameMutex_);
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) { if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
return false; return false;
} }
destination = latestFrame_; destination.data = latestFrame_;
width = width_; destination.width = width_;
height = height_; destination.height = height_;
destination.sequence = latestFrameSequence_;
return true; return true;
} }
@@ -3,11 +3,19 @@
#include <Windows.h> #include <Windows.h>
#include <atomic> #include <atomic>
#include <cstdint>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector> #include <vector>
struct WebcamFrameSnapshot {
std::vector<BYTE> data;
int width = 0;
int height = 0;
uint64_t sequence = 0;
};
class DirectShowWebcamCapture { class DirectShowWebcamCapture {
public: public:
DirectShowWebcamCapture() = default; DirectShowWebcamCapture() = default;
@@ -25,7 +33,7 @@ public:
int requestedFps); int requestedFps);
bool start(); bool start();
void stop(); void stop();
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height); bool copyLatestFrame(WebcamFrameSnapshot& destination);
int width() const; int width() const;
int height() const; int height() const;
@@ -34,6 +42,12 @@ public:
void storeFrame(const BYTE* buffer, long length); void storeFrame(const BYTE* buffer, long length);
private: private:
enum class PixelFormat {
Bgra,
Nv12,
Yuy2,
};
struct Impl; struct Impl;
void captureLoop(); void captureLoop();
@@ -42,9 +56,12 @@ private:
std::atomic<bool> stopRequested_ = false; std::atomic<bool> stopRequested_ = false;
std::mutex frameMutex_; std::mutex frameMutex_;
std::vector<BYTE> latestFrame_; std::vector<BYTE> latestFrame_;
uint64_t latestFrameSequence_ = 0;
int width_ = 0; int width_ = 0;
int height_ = 0; int height_ = 0;
int fps_ = 30; int fps_ = 30;
int sourceStride_ = 0;
bool sourceTopDown_ = false; bool sourceTopDown_ = false;
PixelFormat pixelFormat_ = PixelFormat::Bgra;
std::wstring selectedDeviceName_; std::wstring selectedDeviceName_;
}; };
+62 -18
View File
@@ -29,6 +29,7 @@ struct CaptureConfig {
std::string sourceId; std::string sourceId;
std::string windowHandle; std::string windowHandle;
std::string outputPath; std::string outputPath;
std::string webcamOutputPath;
int fps = 60; int fps = 60;
int width = 0; int width = 0;
int height = 0; int height = 0;
@@ -311,6 +312,7 @@ bool parseConfig(const std::string& json, CaptureConfig& config) {
config.webcamDeviceId = findString(json, "webcamDeviceId"); config.webcamDeviceId = findString(json, "webcamDeviceId");
config.webcamDeviceName = findString(json, "webcamDeviceName"); config.webcamDeviceName = findString(json, "webcamDeviceName");
config.webcamDirectShowClsid = findString(json, "webcamDirectShowClsid"); config.webcamDirectShowClsid = findString(json, "webcamDirectShowClsid");
config.webcamOutputPath = findString(json, "webcamPath");
config.webcamWidth = findInt(json, "webcamWidth", 0); config.webcamWidth = findInt(json, "webcamWidth", 0);
config.webcamHeight = findInt(json, "webcamHeight", 0); config.webcamHeight = findInt(json, "webcamHeight", 0);
config.webcamFps = findInt(json, "webcamFps", 0); config.webcamFps = findInt(json, "webcamFps", 0);
@@ -389,6 +391,7 @@ int main(int argc, char* argv[]) {
WebcamCapture webcamCapture; WebcamCapture webcamCapture;
bool webcamActive = false; bool webcamActive = false;
bool writeSeparateWebcam = false;
if (config.webcamEnabled) { if (config.webcamEnabled) {
if (!webcamCapture.initialize( if (!webcamCapture.initialize(
utf8ToWide(config.webcamDeviceId), utf8ToWide(config.webcamDeviceId),
@@ -405,6 +408,7 @@ int main(int argc, char* argv[]) {
<< ",\"fps\":" << webcamCapture.fps() << ",\"fps\":" << webcamCapture.fps()
<< ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName())) << ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName()))
<< "\"}" << std::endl; << "\"}" << std::endl;
writeSeparateWebcam = !config.webcamOutputPath.empty();
} }
WasapiLoopbackCapture loopbackCapture; WasapiLoopbackCapture loopbackCapture;
@@ -466,6 +470,24 @@ int main(int argc, char* argv[]) {
return 1; 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::mutex mutex;
std::condition_variable cv; std::condition_variable cv;
std::atomic<bool> stopRequested = false; std::atomic<bool> stopRequested = false;
@@ -477,6 +499,7 @@ int main(int argc, char* argv[]) {
std::vector<BYTE> latestWebcamFrame; std::vector<BYTE> latestWebcamFrame;
int latestWebcamWidth = 0; int latestWebcamWidth = 0;
int latestWebcamHeight = 0; int latestWebcamHeight = 0;
uint64_t latestWebcamSequence = 0;
bool hasVisibleWebcamFrame = false; bool hasVisibleWebcamFrame = false;
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
@@ -509,20 +532,22 @@ int main(int argc, char* argv[]) {
auto writeVideoFrames = [&]() { auto writeVideoFrames = [&]() {
const auto startedAt = std::chrono::steady_clock::now(); const auto startedAt = std::chrono::steady_clock::now();
uint64_t frameIndex = 0; uint64_t frameIndex = 0;
uint64_t lastWrittenWebcamSequence = 0;
uint64_t webcamOutputFrameIndex = 0;
int64_t lastEncodedVideoTimestampHns = -1; int64_t lastEncodedVideoTimestampHns = -1;
while (!stopRequested && !encodeFailed) { while (!stopRequested && !encodeFailed) {
{ {
std::scoped_lock lock(mutex); std::scoped_lock lock(mutex);
if (webcamActive) { if (webcamActive) {
std::vector<BYTE> candidateWebcamFrame; WebcamFrameSnapshot candidateWebcamFrame;
int candidateWebcamWidth = 0; if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
int candidateWebcamHeight = 0; candidateWebcamFrame.sequence != latestWebcamSequence &&
if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) && hasVisibleBgraContent(candidateWebcamFrame.data)) {
hasVisibleBgraContent(candidateWebcamFrame)) { latestWebcamFrame = std::move(candidateWebcamFrame.data);
latestWebcamFrame = std::move(candidateWebcamFrame); latestWebcamWidth = candidateWebcamFrame.width;
latestWebcamWidth = candidateWebcamWidth; latestWebcamHeight = candidateWebcamFrame.height;
latestWebcamHeight = candidateWebcamHeight; latestWebcamSequence = candidateWebcamFrame.sequence;
hasVisibleWebcamFrame = true; hasVisibleWebcamFrame = true;
} }
} }
@@ -545,10 +570,23 @@ int main(int argc, char* argv[]) {
frameTimestampHns = frameTimestampHns =
lastEncodedVideoTimestampHns + static_cast<int64_t>(10'000'000ULL / config.fps); 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( if (latestFrameTexture && !encoder.writeFrame(
latestFrameTexture.Get(), latestFrameTexture.Get(),
frameTimestampHns, frameTimestampHns,
webcamFrame.data ? &webcamFrame : nullptr)) { !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) {
encodeFailed = true; encodeFailed = true;
stopRequested = true; stopRequested = true;
cv.notify_all(); cv.notify_all();
@@ -659,14 +697,13 @@ int main(int argc, char* argv[]) {
webcamActive = true; webcamActive = true;
const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) { while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) {
std::vector<BYTE> candidateWebcamFrame; WebcamFrameSnapshot candidateWebcamFrame;
int candidateWebcamWidth = 0; if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
int candidateWebcamHeight = 0; hasVisibleBgraContent(candidateWebcamFrame.data)) {
if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) && latestWebcamFrame = std::move(candidateWebcamFrame.data);
hasVisibleBgraContent(candidateWebcamFrame)) { latestWebcamWidth = candidateWebcamFrame.width;
latestWebcamFrame = std::move(candidateWebcamFrame); latestWebcamHeight = candidateWebcamFrame.height;
latestWebcamWidth = candidateWebcamWidth; latestWebcamSequence = candidateWebcamFrame.sequence;
latestWebcamHeight = candidateWebcamHeight;
hasVisibleWebcamFrame = true; hasVisibleWebcamFrame = true;
break; break;
} }
@@ -740,6 +777,9 @@ int main(int argc, char* argv[]) {
{ {
std::scoped_lock lock(mutex); std::scoped_lock lock(mutex);
encoder.finalize(); encoder.finalize();
if (writeSeparateWebcam) {
webcamEncoder.finalize();
}
} }
if (stdinThread.joinable()) { if (stdinThread.joinable()) {
@@ -752,7 +792,11 @@ int main(int argc, char* argv[]) {
} }
std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\"" 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; std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
return 0; return 0;
} }
@@ -254,6 +254,45 @@ bool MFEncoder::copyFrameToBuffer(
return true; 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) { bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) {
std::scoped_lock writerLock(writerMutex_); std::scoped_lock writerLock(writerMutex_);
if (!sinkWriter_ || finalized_) { if (!sinkWriter_ || finalized_) {
@@ -302,6 +341,54 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample"); 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, &currentLength), "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) { bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
std::scoped_lock writerLock(writerMutex_); std::scoped_lock writerLock(writerMutex_);
if (!sinkWriter_ || finalized_ || !hasAudioStream_) { if (!sinkWriter_ || finalized_ || !hasAudioStream_) {
@@ -44,6 +44,7 @@ public:
ID3D11DeviceContext* context, ID3D11DeviceContext* context,
const AudioInputFormat* audioFormat = nullptr); const AudioInputFormat* audioFormat = nullptr);
bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = 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 writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns);
bool finalize(); bool finalize();
@@ -54,6 +55,7 @@ private:
BYTE* destination, BYTE* destination,
DWORD destinationSize, DWORD destinationSize,
const BgraFrameView* webcamFrame); const BgraFrameView* webcamFrame);
bool copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize);
bool configureAudioStream(const AudioInputFormat& audioFormat); bool configureAudioStream(const AudioInputFormat& audioFormat);
Microsoft::WRL::ComPtr<IMFSinkWriter> sinkWriter_; Microsoft::WRL::ComPtr<IMFSinkWriter> sinkWriter_;
@@ -365,6 +365,7 @@ void WebcamCapture::captureLoop() {
if (currentLength >= expectedLength && expectedLength > 0) { if (currentLength >= expectedLength && expectedLength > 0) {
std::scoped_lock lock(frameMutex_); std::scoped_lock lock(frameMutex_);
latestFrame_.assign(data, data + expectedLength); latestFrame_.assign(data, data + expectedLength);
latestFrameSequence_ += 1;
} }
buffer->Unlock(); buffer->Unlock();
@@ -373,18 +374,19 @@ void WebcamCapture::captureLoop() {
CoUninitialize(); CoUninitialize();
} }
bool WebcamCapture::copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height) { bool WebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) {
if (usingDirectShow_) { if (usingDirectShow_) {
return directShowCapture_.copyLatestFrame(destination, width, height); return directShowCapture_.copyLatestFrame(destination);
} }
std::scoped_lock lock(frameMutex_); std::scoped_lock lock(frameMutex_);
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) { if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
return false; return false;
} }
destination = latestFrame_; destination.data = latestFrame_;
width = width_; destination.width = width_;
height = height_; destination.height = height_;
destination.sequence = latestFrameSequence_;
return true; return true;
} }
@@ -31,7 +31,7 @@ public:
int requestedFps); int requestedFps);
bool start(); bool start();
void stop(); void stop();
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height); bool copyLatestFrame(WebcamFrameSnapshot& destination);
int width() const; int width() const;
int height() const; int height() const;
@@ -50,6 +50,7 @@ private:
std::atomic<bool> stopRequested_ = false; std::atomic<bool> stopRequested_ = false;
std::mutex frameMutex_; std::mutex frameMutex_;
std::vector<BYTE> latestFrame_; std::vector<BYTE> latestFrame_;
uint64_t latestFrameSequence_ = 0;
int width_ = 0; int width_ = 0;
int height_ = 0; int height_ = 0;
int fps_ = 30; int fps_ = 30;
+10
View File
@@ -104,9 +104,19 @@ if (!fs.existsSync(outputPath)) {
throw new Error(`WGC helper build completed but ${outputPath} was not found.`); 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 }); fs.mkdirSync(BIN_DIR, { recursive: true });
const distributablePath = path.join(BIN_DIR, "wgc-capture.exe"); const distributablePath = path.join(BIN_DIR, "wgc-capture.exe");
fs.copyFileSync(outputPath, distributablePath); fs.copyFileSync(outputPath, distributablePath);
const cursorSamplerDistributablePath = path.join(BIN_DIR, "cursor-sampler.exe");
fs.copyFileSync(cursorSamplerOutputPath, cursorSamplerDistributablePath);
console.log(`Built ${outputPath}`); console.log(`Built ${outputPath}`);
console.log(`Copied ${distributablePath}`); console.log(`Copied ${distributablePath}`);
console.log(`Built ${cursorSamplerOutputPath}`);
console.log(`Copied ${cursorSamplerDistributablePath}`);
+26 -1
View File
@@ -230,6 +230,7 @@ const outputPath = path.join(
os.tmpdir(), os.tmpdir(),
`openscreen-wgc-helper-${WITH_WEBCAM ? "webcam" : WITH_WINDOW ? "window" : WITH_SYSTEM_AUDIO || WITH_MICROPHONE ? "audio" : "video"}-${process.pid}-${Date.now()}-${randomUUID()}.mp4`, `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; const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null;
@@ -263,7 +264,10 @@ const config = {
webcamWidth: 640, webcamWidth: 640,
webcamHeight: 360, webcamHeight: 360,
webcamFps: 30, webcamFps: 30,
outputs: { screenPath: outputPath }, outputs: {
screenPath: outputPath,
...(webcamOutputPath ? { webcamPath: webcamOutputPath } : {}),
},
}; };
let result; let result;
@@ -289,8 +293,13 @@ if (result.code !== 0) {
if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) { if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
throw new Error(`WGC helper did not produce a video at ${outputPath}`); 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 streams = probeStreams(outputPath);
const webcamStreams =
webcamOutputPath && fs.existsSync(webcamOutputPath) ? probeStreams(webcamOutputPath) : [];
const hasVideo = streams.some((stream) => stream.codec_type === "video"); const hasVideo = streams.some((stream) => stream.codec_type === "video");
const hasAudio = streams.some((stream) => stream.codec_type === "audio"); const hasAudio = streams.some((stream) => stream.codec_type === "audio");
const webcamFormatLine = result.stdout const webcamFormatLine = result.stdout
@@ -318,6 +327,9 @@ const nativeMicrophoneDiagnostics = result.stderr
if (!hasVideo) { if (!hasVideo) {
throw new Error(`WGC helper output has no video stream: ${outputPath}`); 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 ( if (
(CAPTURE_CURSOR && !cursorCapture) || (CAPTURE_CURSOR && !cursorCapture) ||
(cursorCapture && (cursorCapture &&
@@ -342,13 +354,26 @@ console.log(
{ {
success: true, success: true,
outputPath, outputPath,
webcamOutputPath,
bytes: fs.statSync(outputPath).size, bytes: fs.statSync(outputPath).size,
webcamBytes:
webcamOutputPath && fs.existsSync(webcamOutputPath)
? fs.statSync(webcamOutputPath).size
: undefined,
streams: streams.map((stream) => ({ streams: streams.map((stream) => ({
index: stream.index, index: stream.index,
codecType: stream.codec_type, codecType: stream.codec_type,
codecName: stream.codec_name, codecName: stream.codec_name,
duration: stream.duration, 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, cursorCapture,
selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName, selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName,
selectedWebcamDeviceName: webcamFormat?.deviceName, selectedWebcamDeviceName: webcamFormat?.deviceName,
+22 -19
View File
@@ -53,6 +53,7 @@ import ColorPicker from "../ui/color-picker";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { CropControl } from "./CropControl"; import { CropControl } from "./CropControl";
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type { import type {
AnnotationRegion, AnnotationRegion,
@@ -71,7 +72,6 @@ import type {
} from "./types"; } from "./types";
import { import {
DEFAULT_WEBCAM_SIZE_PRESET, DEFAULT_WEBCAM_SIZE_PRESET,
MAX_PLAYBACK_SPEED,
MAX_ZOOM_SCALE, MAX_ZOOM_SCALE,
MIN_ZOOM_SCALE, MIN_ZOOM_SCALE,
ROTATION_3D_PRESET_ORDER, ROTATION_3D_PRESET_ORDER,
@@ -90,37 +90,38 @@ function CustomSpeedInput({
onError: () => void; onError: () => void;
}) { }) {
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value); 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 [isFocused, setIsFocused] = useState(false);
const prevValue = useRef(value); const prevValue = useRef(value);
if (!isFocused && prevValue.current !== value) { if (!isFocused && prevValue.current !== value) {
prevValue.current = value; prevValue.current = value;
setDraft(isPreset ? "" : String(Math.round(value))); setDraft(isPreset ? "" : String(value));
} }
const handleChange = useCallback( const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, ""); const result = parseCustomPlaybackSpeedInput(e.target.value);
if (digits === "") { if (result.status === "too-fast") {
setDraft("");
return;
}
const num = Number(digits);
if (num > MAX_PLAYBACK_SPEED) {
onError(); onError();
return; return;
} }
setDraft(digits);
if (num >= 1) onChange(num); setDraft(result.draft);
if (result.status === "valid") {
onChange(result.speed);
}
}, },
[onChange, onError], [onChange, onError],
); );
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setIsFocused(false); setIsFocused(false);
if (!draft || Number(draft) < 1) { const result = parseCustomPlaybackSpeedInput(draft);
setDraft(isPreset ? "" : String(Math.round(value))); if (result.status === "valid") {
setDraft(String(result.speed));
} else {
setDraft(isPreset ? "" : String(value));
} }
}, [draft, isPreset, value]); }, [draft, isPreset, value]);
@@ -128,8 +129,8 @@ function CustomSpeedInput({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <input
type="text" type="text"
inputMode="numeric" inputMode="decimal"
pattern="[0-9]*" pattern="[0-9]*[.]?[0-9]*"
placeholder="--" placeholder="--"
value={draft} value={draft}
onFocus={() => setIsFocused(true)} 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" 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]" /> <Bug className="w-3 h-3 text-[#34B27B]" />
{t("links.reportBug")} {t("support.reportBug")}
</button> </button>
{onSaveDiagnostic && ( {onSaveDiagnostic && (
<button <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" 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" /> <FileDown className="w-3 h-3 text-slate-400" />
Save Diagnostics {t("support.saveDiagnostics")}
</button> </button>
)} )}
<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" 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" /> <Star className="w-3 h-3 text-yellow-400" />
{t("links.starOnGithub")} {t("support.starOnGithub")}
</button> </button>
</div> </div>
); );
@@ -812,6 +813,7 @@ export function SettingsPanel({
<Crop className="h-4 w-4" /> <Crop className="h-4 w-4" />
</button> </button>
<button <button
data-testid={getTestId("export-panel-button")}
type="button" type="button"
title={exportPanelMode.label} title={exportPanelMode.label}
onClick={() => setActivePanelMode(exportPanelMode.id)} onClick={() => setActivePanelMode(exportPanelMode.id)}
@@ -1821,6 +1823,7 @@ export function SettingsPanel({
<> <>
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<button <button
data-testid={getTestId("mp4-format-button")}
onClick={() => onExportFormatChange?.("mp4")} onClick={() => onExportFormatChange?.("mp4")}
className={cn( className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium", "flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
+3 -4
View File
@@ -865,11 +865,10 @@ export default function VideoEditor() {
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
focus: clampFocusToDepth(focus, 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] })); pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}, },
[pushState], [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) };
}
+1
View File
@@ -112,6 +112,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
document.documentElement.lang = locale; document.documentElement.lang = locale;
window.electronAPI?.setLocale?.(locale);
}, [locale]); }, [locale]);
useEffect(() => { useEffect(() => {
+146 -64
View File
@@ -45,8 +45,6 @@ const AUDIO_BITRATE_VOICE = 128_000;
const AUDIO_BITRATE_SYSTEM = 192_000; const AUDIO_BITRATE_SYSTEM = 192_000;
const MIC_GAIN_BOOST = 1.4; const MIC_GAIN_BOOST = 1.4;
const WEBCAM_TARGET_WIDTH = 1280;
const WEBCAM_TARGET_HEIGHT = 720;
const WEBCAM_TARGET_FRAME_RATE = 30; const WEBCAM_TARGET_FRAME_RATE = 30;
type UseScreenRecorderReturn = { type UseScreenRecorderReturn = {
@@ -84,6 +82,7 @@ type RecorderHandle = {
type NativeWindowsRecordingHandle = { type NativeWindowsRecordingHandle = {
recordingId: number; recordingId: number;
finalizing: boolean; finalizing: boolean;
webcamRecorder: RecorderHandle | null;
}; };
type NativeMacRecordingHandle = { type NativeMacRecordingHandle = {
@@ -267,13 +266,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
video: webcamDeviceId video: webcamDeviceId
? { ? {
deviceId: { exact: 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 }, 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 }, frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
}, },
}); });
@@ -422,58 +417,105 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
[cursorCaptureMode, teardownMedia], [cursorCaptureMode, teardownMedia],
); );
const finalizeNativeWindowsRecording = useCallback(async (discard = false) => { const finalizeNativeWindowsRecording = useCallback(
const activeNativeRecording = nativeWindowsRecording.current; async (discard = false) => {
if (!activeNativeRecording || activeNativeRecording.finalizing) { const activeNativeRecording = nativeWindowsRecording.current;
return false; 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;
} }
if (!result.success) {
console.error("Failed to stop native Windows recording:", result.error); activeNativeRecording.finalizing = true;
toast.error(result.error ?? "Failed to stop native Windows recording"); 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; activeNativeRecording.finalizing = false;
return true; return true;
} finally {
if (discardRecordingId.current === activeNativeRecording.recordingId) {
discardRecordingId.current = null;
}
} }
},
clearNativeRecordingState(); [cursorCaptureMode, getRecordingDurationMs],
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;
}
}
}, []);
const finalizeNativeMacRecording = useCallback( const finalizeNativeMacRecording = useCallback(
async (discard = false) => { async (discard = false) => {
@@ -716,6 +758,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const isCountdownRunActive = (runId?: number) => const isCountdownRunActive = (runId?: number) =>
runId === undefined || countdownRunId.current === runId; 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 ( const startNativeWindowsRecordingIfAvailable = async (
selectedSource: ProcessedDesktopSource, selectedSource: ProcessedDesktopSource,
countdownRunToken?: number, countdownRunToken?: number,
@@ -731,12 +792,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (availability.reason === "unsupported-os") { if (availability.reason === "unsupported-os") {
return false; return false;
} }
if (availability.reason === "missing-helper") {
console.warn("Native Windows capture helper is not available; using browser capture.");
return false;
}
throw new Error( throw new Error(availability.error ?? "Native Windows capture is not available.");
availability.reason === "missing-helper"
? "Native Windows capture helper is not available."
: (availability.error ?? "Native Windows capture is not available."),
);
} }
if (!isCountdownRunActive(countdownRunToken)) { if (!isCountdownRunActive(countdownRunToken)) {
@@ -748,6 +809,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display"; const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
const windowHandle = parseWindowHandleFromSourceId(selectedSource.id); const windowHandle = parseWindowHandleFromSourceId(selectedSource.id);
if (webcamEnabled) { 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(); stopWebcamPreviewStream();
} }
const request: NativeWindowsRecordingRequest = { const request: NativeWindowsRecordingRequest = {
@@ -775,11 +849,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}, },
}, },
webcam: { webcam: {
enabled: webcamEnabled, enabled: webcamEnabled && !browserWebcamRecorder,
deviceId: webcamDeviceId, deviceId: webcamDeviceId,
deviceName: webcamDeviceName, deviceName: webcamDeviceName,
width: WEBCAM_TARGET_WIDTH, width: 0,
height: WEBCAM_TARGET_HEIGHT, height: 0,
fps: WEBCAM_TARGET_FRAME_RATE, fps: WEBCAM_TARGET_FRAME_RATE,
}, },
cursor: { cursor: {
@@ -788,6 +862,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}; };
const result = await window.electronAPI.startNativeWindowsRecording(request); const result = await window.electronAPI.startNativeWindowsRecording(request);
if (!result.success || !result.recordingId) { 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."); throw new Error(result.error ?? "Native Windows capture failed.");
} }
@@ -795,7 +875,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
nativeWindowsRecording.current = { nativeWindowsRecording.current = {
recordingId: result.recordingId, recordingId: result.recordingId,
finalizing: false, finalizing: false,
webcamRecorder: browserWebcamRecorder,
}; };
webcamRecorder.current = browserWebcamRecorder;
accumulatedDurationMs.current = 0; accumulatedDurationMs.current = 0;
segmentStartedAt.current = Date.now(); segmentStartedAt.current = Date.now();
allowAutoFinalize.current = true; allowAutoFinalize.current = true;
@@ -907,8 +989,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
enabled: webcamEnabled, enabled: webcamEnabled,
deviceId: webcamDeviceId, deviceId: webcamDeviceId,
deviceName: webcamDeviceName, deviceName: webcamDeviceName,
width: WEBCAM_TARGET_WIDTH, width: 0,
height: WEBCAM_TARGET_HEIGHT, height: 0,
fps: WEBCAM_TARGET_FRAME_RATE, fps: WEBCAM_TARGET_FRAME_RATE,
}, },
cursor: { cursor: {
@@ -4,6 +4,7 @@ import arDialogs from "@/i18n/locales/ar/dialogs.json";
import enDialogs from "@/i18n/locales/en/dialogs.json"; import enDialogs from "@/i18n/locales/en/dialogs.json";
import esDialogs from "@/i18n/locales/es/dialogs.json"; import esDialogs from "@/i18n/locales/es/dialogs.json";
import frDialogs from "@/i18n/locales/fr/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 jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json";
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json"; import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
import ruDialogs from "@/i18n/locales/ru/dialogs.json"; import ruDialogs from "@/i18n/locales/ru/dialogs.json";
@@ -39,16 +40,17 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des
const dialogsByLocale = { const dialogsByLocale = {
en: enDialogs, en: enDialogs,
"zh-CN": zhCNDialogs, ar: arDialogs,
"zh-TW": zhTWDialogs,
es: esDialogs, es: esDialogs,
fr: frDialogs, fr: frDialogs,
tr: trDialogs, it: itDialogs,
"ja-JP": jaJPDialogs,
"ko-KR": koKRDialogs, "ko-KR": koKRDialogs,
ru: ruDialogs, ru: ruDialogs,
"ja-JP": jaJPDialogs, tr: trDialogs,
ar: arDialogs,
vi: viDialogs, vi: viDialogs,
"zh-CN": zhCNDialogs,
"zh-TW": zhTWDialogs,
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>; } satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
describe("TutorialHelp translations", () => { describe("TutorialHelp translations", () => {
+6 -5
View File
@@ -1,16 +1,17 @@
export const DEFAULT_LOCALE = "en" as const; export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = [ export const SUPPORTED_LOCALES = [
"en", "en",
"zh-CN", "ar",
"zh-TW",
"es", "es",
"fr", "fr",
"tr", "it",
"ko-KR",
"ja-JP", "ja-JP",
"ar", "ko-KR",
"ru", "ru",
"tr",
"vi", "vi",
"zh-CN",
"zh-TW",
] as const; ] as const;
export const I18N_NAMESPACES = [ export const I18N_NAMESPACES = [
"common", "common",
+2 -1
View File
@@ -105,8 +105,9 @@
"gifButton": "تصدير GIF", "gifButton": "تصدير GIF",
"chooseSaveLocation": "اختيار موقع الحفظ" "chooseSaveLocation": "اختيار موقع الحفظ"
}, },
"links": { "support": {
"reportBug": "الإبلاغ عن خطأ", "reportBug": "الإبلاغ عن خطأ",
"saveDiagnostics": "حفظ التشخيصات",
"starOnGithub": "إعطاء نجمة على GitHub" "starOnGithub": "إعطاء نجمة على GitHub"
}, },
"imageUpload": { "imageUpload": {
+2 -1
View File
@@ -105,8 +105,9 @@
"gifButton": "Export GIF", "gifButton": "Export GIF",
"chooseSaveLocation": "Choose Save Location" "chooseSaveLocation": "Choose Save Location"
}, },
"links": { "support": {
"reportBug": "Report Bug", "reportBug": "Report Bug",
"saveDiagnostics": "Save Diagnostics",
"starOnGithub": "Star on GitHub" "starOnGithub": "Star on GitHub"
}, },
"imageUpload": { "imageUpload": {
+2 -1
View File
@@ -105,8 +105,9 @@
"gifButton": "Exportar GIF", "gifButton": "Exportar GIF",
"chooseSaveLocation": "Elegir ubicación de guardado" "chooseSaveLocation": "Elegir ubicación de guardado"
}, },
"links": { "support": {
"reportBug": "Reportar error", "reportBug": "Reportar error",
"saveDiagnostics": "Guardar diagnósticos",
"starOnGithub": "Dar estrella en GitHub" "starOnGithub": "Dar estrella en GitHub"
}, },
"imageUpload": { "imageUpload": {
+2 -1
View File
@@ -106,8 +106,9 @@
"gifButton": "Exporter le GIF", "gifButton": "Exporter le GIF",
"chooseSaveLocation": "Choisir l'emplacement d'enregistrement" "chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
}, },
"links": { "support": {
"reportBug": "Signaler un bug", "reportBug": "Signaler un bug",
"saveDiagnostics": "Enregistrer les diagnostics",
"starOnGithub": "Étoile sur GitHub" "starOnGithub": "Étoile sur GitHub"
}, },
"imageUpload": { "imageUpload": {
+50
View File
@@ -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"
}
}
+70
View File
@@ -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"
}
}
+46
View 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."
}
}
+47
View File
@@ -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"
}
}
+206
View File
@@ -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"
}
}
+37
View File
@@ -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"
}
}
+55
View File
@@ -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"
}
}
+2 -1
View File
@@ -105,8 +105,9 @@
"gifButton": "GIF をエクスポート", "gifButton": "GIF をエクスポート",
"chooseSaveLocation": "保存場所を選択" "chooseSaveLocation": "保存場所を選択"
}, },
"links": { "support": {
"reportBug": "バグを報告", "reportBug": "バグを報告",
"saveDiagnostics": "診断情報を保存",
"starOnGithub": "GitHub でスターを付ける" "starOnGithub": "GitHub でスターを付ける"
}, },
"imageUpload": { "imageUpload": {
+6 -5
View File
@@ -2,6 +2,7 @@
"zoom": { "zoom": {
"previewHold": "누르고 있으면 줌 효과 미리보기", "previewHold": "누르고 있으면 줌 효과 미리보기",
"level": "줌 레벨", "level": "줌 레벨",
"customScale": "커스텀 줌",
"selectRegion": "조정할 줌 구간을 선택하세요", "selectRegion": "조정할 줌 구간을 선택하세요",
"deleteZoom": "줌 삭제", "deleteZoom": "줌 삭제",
"focusMode": { "focusMode": {
@@ -18,9 +19,8 @@
"right": "오른쪽" "right": "오른쪽"
} }
}, },
"customScale": "사용자 지정 확대",
"position": { "position": {
"title": "초점 위치", "title": "포커스 위치",
"x": "X (%)", "x": "X (%)",
"y": "Y (%)", "y": "Y (%)",
"hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽" "hint": "0 = 가장 왼쪽 / 위쪽, 100 = 가장 오른쪽 / 아래쪽"
@@ -52,10 +52,10 @@
"blurBg": "배경 흐림", "blurBg": "배경 흐림",
"motionBlur": "모션 블러", "motionBlur": "모션 블러",
"off": "끄기", "off": "끄기",
"on": "켜기",
"shadow": "그림자", "shadow": "그림자",
"roundness": "모서리 둥글기", "roundness": "모서리 둥글기",
"padding": "여백", "padding": "여백"
"on": "켜짐"
}, },
"background": { "background": {
"title": "배경", "title": "배경",
@@ -105,8 +105,9 @@
"gifButton": "GIF 내보내기", "gifButton": "GIF 내보내기",
"chooseSaveLocation": "저장 위치 선택" "chooseSaveLocation": "저장 위치 선택"
}, },
"links": { "support": {
"reportBug": "버그 신고", "reportBug": "버그 신고",
"saveDiagnostics": "Save Diagnostics",
"starOnGithub": "GitHub에 Star 남기기" "starOnGithub": "GitHub에 Star 남기기"
}, },
"imageUpload": { "imageUpload": {
+2 -1
View File
@@ -105,8 +105,9 @@
"gifButton": "Экспорт GIF", "gifButton": "Экспорт GIF",
"chooseSaveLocation": "Выбрать место сохранения" "chooseSaveLocation": "Выбрать место сохранения"
}, },
"links": { "support": {
"reportBug": "Сообщить об ошибке", "reportBug": "Сообщить об ошибке",
"saveDiagnostics": "Сохранить диагностику",
"starOnGithub": "Звезда на GitHub" "starOnGithub": "Звезда на GitHub"
}, },
"imageUpload": { "imageUpload": {
+2 -1
View File
@@ -105,8 +105,9 @@
"gifButton": "GIF Olarak Dışa Aktar", "gifButton": "GIF Olarak Dışa Aktar",
"chooseSaveLocation": "Kayıt Konumu Seç" "chooseSaveLocation": "Kayıt Konumu Seç"
}, },
"links": { "support": {
"reportBug": "Hata Bildir", "reportBug": "Hata Bildir",
"saveDiagnostics": "Teşhis Verilerini Kaydet",
"starOnGithub": "GitHub'da Yıldızla" "starOnGithub": "GitHub'da Yıldızla"
}, },
"imageUpload": { "imageUpload": {
+2 -1
View File
@@ -105,8 +105,9 @@
"gifButton": "Xuất GIF", "gifButton": "Xuất GIF",
"chooseSaveLocation": "Chọn vị trí lưu" "chooseSaveLocation": "Chọn vị trí lưu"
}, },
"links": { "support": {
"reportBug": "Báo cáo lỗi", "reportBug": "Báo cáo lỗi",
"saveDiagnostics": "Lưu thông tin chẩn đoán",
"starOnGithub": "Đánh giá sao trên GitHub" "starOnGithub": "Đánh giá sao trên GitHub"
}, },
"imageUpload": { "imageUpload": {
+2 -1
View File
@@ -105,8 +105,9 @@
"gifButton": "导出 GIF", "gifButton": "导出 GIF",
"chooseSaveLocation": "选择保存位置" "chooseSaveLocation": "选择保存位置"
}, },
"links": { "support": {
"reportBug": "报告错误", "reportBug": "报告错误",
"saveDiagnostics": "保存诊断信息",
"starOnGithub": "在 GitHub 上加星" "starOnGithub": "在 GitHub 上加星"
}, },
"imageUpload": { "imageUpload": {
+2 -1
View File
@@ -106,8 +106,9 @@
"gifButton": "匯出 GIF", "gifButton": "匯出 GIF",
"chooseSaveLocation": "選擇儲存位置" "chooseSaveLocation": "選擇儲存位置"
}, },
"links": { "support": {
"reportBug": "回報錯誤", "reportBug": "回報錯誤",
"saveDiagnostics": "儲存診斷資料",
"starOnGithub": "在 GitHub 上加星" "starOnGithub": "在 GitHub 上加星"
}, },
"imageUpload": { "imageUpload": {
+8 -6
View File
@@ -11,9 +11,9 @@ import type {
import { BackgroundLoadError } from "@/lib/wallpaper"; import { BackgroundLoadError } from "@/lib/wallpaper";
import type { CursorRecordingData } from "@/native/contracts"; import type { CursorRecordingData } from "@/native/contracts";
import { getPlatform } from "@/utils/platformUtils"; import { getPlatform } from "@/utils/platformUtils";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
import { FrameRenderer } from "./frameRenderer"; import { FrameRenderer } from "./frameRenderer";
import { StreamingVideoDecoder } from "./streamingDecoder"; import { StreamingVideoDecoder } from "./streamingDecoder";
import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue";
import type { import type {
ExportProgress, ExportProgress,
ExportResult, ExportResult,
@@ -124,7 +124,7 @@ export class GifExporter {
} }
async export(): Promise<ExportResult> { async export(): Promise<ExportResult> {
let webcamFrameQueue: AsyncVideoFrameQueue | null = null; let webcamFrameQueue: TimestampedVideoFrameQueue | null = null;
const warnings: string[] = []; const warnings: string[] = [];
const onWarning = (message: string) => warnings.push(message); const onWarning = (message: string) => warnings.push(message);
@@ -216,7 +216,7 @@ export class GifExporter {
console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)"); console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)");
let frameIndex = 0; let frameIndex = 0;
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null; webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null;
let stopWebcamDecode = false; let stopWebcamDecode = false;
let webcamDecodeError: Error | null = null; let webcamDecodeError: Error | null = null;
const webcamDecodePromise = const webcamDecodePromise =
@@ -228,7 +228,7 @@ export class GifExporter {
this.config.frameRate, this.config.frameRate,
this.config.trimRegions, this.config.trimRegions,
this.config.speedRegions, this.config.speedRegions,
async (webcamFrame) => { async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => {
while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) { while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) {
await new Promise((resolve) => setTimeout(resolve, 2)); await new Promise((resolve) => setTimeout(resolve, 2));
} }
@@ -236,7 +236,7 @@ export class GifExporter {
webcamFrame.close(); webcamFrame.close();
return; return;
} }
queue.enqueue(webcamFrame); queue.enqueue(webcamFrame, webcamSourceTimestampMs);
}, },
onWarning, onWarning,
) )
@@ -266,7 +266,9 @@ export class GifExporter {
return; return;
} }
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null; webcamFrame = webcamFrameQueue
? await webcamFrameQueue.frameAt(sourceTimestampMs)
: null;
const renderer = this.renderer; const renderer = this.renderer;
if (this.cancelled || !renderer) { if (this.cancelled || !renderer) {
return; 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 = [];
}
}
+8 -6
View File
@@ -10,11 +10,11 @@ import type {
import { BackgroundLoadError } from "@/lib/wallpaper"; import { BackgroundLoadError } from "@/lib/wallpaper";
import type { CursorRecordingData } from "@/native/contracts"; import type { CursorRecordingData } from "@/native/contracts";
import { getPlatform } from "@/utils/platformUtils"; import { getPlatform } from "@/utils/platformUtils";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
import { AudioProcessor } from "./audioEncoder"; import { AudioProcessor } from "./audioEncoder";
import { FrameRenderer } from "./frameRenderer"; import { FrameRenderer } from "./frameRenderer";
import { VideoMuxer } from "./muxer"; import { VideoMuxer } from "./muxer";
import { StreamingVideoDecoder } from "./streamingDecoder"; import { StreamingVideoDecoder } from "./streamingDecoder";
import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue";
import type { ExportConfig, ExportProgress, ExportResult } from "./types"; import type { ExportConfig, ExportProgress, ExportResult } from "./types";
const ENCODER_STALL_TIMEOUT_MS = 15_000; const ENCODER_STALL_TIMEOUT_MS = 15_000;
@@ -195,7 +195,7 @@ export class VideoExporter {
private async exportWithEncoderPreference( private async exportWithEncoderPreference(
encoderPreference: HardwareAcceleration, encoderPreference: HardwareAcceleration,
): Promise<ExportResult> { ): Promise<ExportResult> {
let webcamFrameQueue: AsyncVideoFrameQueue | null = null; let webcamFrameQueue: TimestampedVideoFrameQueue | null = null;
let stopWebcamDecode = false; let stopWebcamDecode = false;
let webcamDecodeError: Error | null = null; let webcamDecodeError: Error | null = null;
let webcamDecodePromise: Promise<void> | null = null; let webcamDecodePromise: Promise<void> | null = null;
@@ -290,7 +290,7 @@ export class VideoExporter {
? Math.min(this.MAX_ENCODE_QUEUE, 32) ? Math.min(this.MAX_ENCODE_QUEUE, 32)
: this.MAX_ENCODE_QUEUE; : this.MAX_ENCODE_QUEUE;
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null; webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null;
webcamDecodePromise = webcamDecodePromise =
webcamDecoder && webcamFrameQueue webcamDecoder && webcamFrameQueue
? (() => { ? (() => {
@@ -300,7 +300,7 @@ export class VideoExporter {
this.config.frameRate, this.config.frameRate,
this.config.trimRegions, this.config.trimRegions,
this.config.speedRegions, this.config.speedRegions,
async (webcamFrame) => { async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => {
while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) { while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) {
await new Promise((resolve) => setTimeout(resolve, 2)); await new Promise((resolve) => setTimeout(resolve, 2));
} }
@@ -308,7 +308,7 @@ export class VideoExporter {
webcamFrame.close(); webcamFrame.close();
return; return;
} }
queue.enqueue(webcamFrame); queue.enqueue(webcamFrame, webcamSourceTimestampMs);
}, },
onWarning, onWarning,
) )
@@ -342,7 +342,9 @@ export class VideoExporter {
} }
const timestamp = frameIndex * frameDuration; const timestamp = frameIndex * frameDuration;
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null; webcamFrame = webcamFrameQueue
? await webcamFrameQueue.frameAt(sourceTimestampMs)
: null;
if (this.cancelled) { if (this.cancelled) {
return; return;
} }
+1 -1
View File
@@ -79,7 +79,7 @@ export function getAspectRatioDimensions(
} }
export function getAspectRatioLabel(aspectRatio: AspectRatio): string { export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
if (aspectRatio === "native") return "Native"; if (aspectRatio === "native") return "Original";
return aspectRatio; return aspectRatio;
} }
+6 -1
View File
@@ -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) { export function getTestId(testId: TestId) {
return `testId-${testId}`; return `testId-${testId}`;
+77 -56
View File
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; 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 MAIN_JS = path.join(ROOT, "dist-electron/main.js");
const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm"); const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm");
test("exports a GIF from a loaded video", async () => { async function exportFromLoadedVideo(format: "gif" | "mp4"): Promise<Buffer> {
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`); const outputPath = path.join(os.tmpdir(), `test-${format}-export-${Date.now()}.${format}`);
let testVideoInRecordings = ""; let testVideoInRecordings = "";
const app = await electron.launch({ const app = await electron.launch({
@@ -27,42 +28,39 @@ test("exports a GIF from a loaded video", async () => {
HEADLESS: process.env["HEADLESS"] ?? "true", 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().stdout?.on("data", (d) => process.stdout.write(`[electron] ${d}`));
app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`)); app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`));
try { 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 }); const hudWindow = await app.firstWindow({ timeout: 60_000 });
await hudWindow.waitForLoadState("domcontentloaded"); await hudWindow.waitForLoadState("domcontentloaded");
// ── 2. Intercept the native save dialog in the main process. await app.evaluate(({ ipcMain }, targetPath: string) => {
// Must happen after firstWindow() so registerIpcHandlers() has ipcMain.removeHandler("pick-export-save-path");
// already registered its version — otherwise our early handle() ipcMain.removeHandler("write-export-to-path");
// call causes registerIpcHandlers() to throw and abort, leaving ipcMain.handle("pick-export-save-path", () => ({
// other handlers (like set-current-video-path) never registered. success: true,
// Store the exported buffer as a base64 global in the main process. path: targetPath,
// We can't use require() or import() inside app.evaluate() because the canceled: false,
// 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");
ipcMain.handle( ipcMain.handle(
"save-exported-video", "write-export-to-path",
(_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer) => { (_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer, filePath: string) => {
if (filePath !== targetPath) {
return {
success: false,
error: `Unexpected export path: ${filePath}`,
};
}
(globalThis as Record<string, unknown>)["__testExportData"] = (globalThis as Record<string, unknown>)["__testExportData"] =
Buffer.from(buffer).toString("base64"); 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 }) => { const userDataDir = await app.evaluate(({ app: electronApp }) => {
return electronApp.getPath("userData"); return electronApp.getPath("userData");
}); });
@@ -71,62 +69,73 @@ test("exports a GIF from a loaded video", async () => {
fs.mkdirSync(recordingsDir, { recursive: true }); fs.mkdirSync(recordingsDir, { recursive: true });
fs.copyFileSync(TEST_VIDEO, testVideoInRecordings); fs.copyFileSync(TEST_VIDEO, testVideoInRecordings);
await hudWindow.evaluate(
(videoPath: string) => window.electronAPI.setCurrentVideoPath(videoPath),
testVideoInRecordings,
);
try { try {
await hudWindow.evaluate((videoPath: string) => { await hudWindow.evaluate(() => window.electronAPI.switchToEditor());
window.electronAPI.setCurrentVideoPath(videoPath); } catch (error) {
window.electronAPI.switchToEditor(); if (
}, testVideoInRecordings); !(error instanceof Error) ||
} catch { !/closed|destroyed|target page|target closed/i.test(error.message)
// Expected: switchToEditor() closes the HUD window, terminating ) {
// the Playwright page context before evaluate() can resolve. 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", { const editorWindow = await app.waitForEvent("window", {
predicate: (w) => w.url().includes("windowType=editor"), predicate: (w) => w.url().includes("windowType=editor"),
timeout: 15_000, timeout: 15_000,
}); });
// WebCodecs (VideoEncoder) may not be registered in the renderer on first // WebCodecs may not be registered in the renderer on first load.
// load of a second BrowserWindow. A single reload ensures the feature is
// fully initialized before we start encoding.
await editorWindow.reload(); await editorWindow.reload();
await editorWindow.waitForLoadState("domcontentloaded"); await editorWindow.waitForLoadState("domcontentloaded");
await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({ await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({
timeout: 15_000, timeout: 15_000,
}); });
// ── 5. Select GIF as the export format. await editorWindow.getByTestId("testId-export-panel-button").click();
await editorWindow.getByTestId("testId-gif-format-button").click(); await editorWindow.getByTestId(`testId-${format}-format-button`).click();
await editorWindow.getByTestId("testId-export-button").click(); await editorWindow.getByTestId("testId-export-button").click();
// ── 6. Wait for the success toast. await expect
await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({ .poll(
timeout: 90_000, () =>
}); 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( const base64 = await app.evaluate(
() => (globalThis as Record<string, unknown>)["__testExportData"] as string, () => (globalThis as Record<string, unknown>)["__testExportData"] as string,
); );
fs.writeFileSync(outputPath, Buffer.from(base64, "base64")); fs.writeFileSync(outputPath, Buffer.from(base64, "base64"));
// ── 8. Verify the file on disk is a valid GIF. expect(fs.existsSync(outputPath), `${format.toUpperCase()} not found at ${outputPath}`).toBe(
expect(fs.existsSync(outputPath), `GIF not found at ${outputPath}`).toBe(true); 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/);
const stats = fs.statSync(outputPath); const stats = fs.statSync(outputPath);
expect(stats.size).toBeGreaterThan(1024); // at least 1 KB expect(stats.size).toBeGreaterThan(1024);
return fs.readFileSync(outputPath);
} finally { } 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)) { if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath); fs.unlinkSync(outputPath);
} }
@@ -134,4 +143,16 @@ test("exports a GIF from a loaded video", async () => {
fs.unlinkSync(testVideoInRecordings); 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/);
}); });