Date: Mon, 6 Apr 2026 20:37:05 +0200
Subject: [PATCH 02/31] add color wheel to background and annotations
---
package-lock.json | 137 ++++++++++++++
package.json | 1 +
.../video-editor/AnnotationSettingsPanel.tsx | 168 +++++++++++++++---
src/components/video-editor/SettingsPanel.tsx | 105 +++++++++--
src/i18n/locales/en/settings.json | 2 +
src/i18n/locales/es/settings.json | 2 +
src/i18n/locales/zh-CN/settings.json | 2 +
7 files changed, 376 insertions(+), 41 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index fdbd6b9..2ff6cd6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
+ "@uiw/react-color-colorful": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
@@ -4875,6 +4876,36 @@
"@babel/runtime": ">=7.19.0"
}
},
+ "node_modules/@uiw/react-color-alpha": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-alpha/-/react-color-alpha-2.9.6.tgz",
+ "integrity": "sha512-DNzEVHZ0Izp4NAwzKqTcl4rLdPjSFjyZCP6Q2vKJEglugZ/bdPsmZaos9IYOrgnd1kPDmTSKZ/p8nI7vBIATGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@uiw/color-convert": "2.9.6",
+ "@uiw/react-drag-event-interactive": "2.9.6"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-alpha/node_modules/@uiw/color-convert": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.6.tgz",
+ "integrity": "sha512-w8TpU3MRcquurQJxWR1daKcRygu/a0hLP/VGsLMA3ebb41sAZGxMQLHtS+zC/e3ciFNB7BbPrSPlzOcz6w6cRg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0"
+ }
+ },
"node_modules/@uiw/react-color-block": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-block/-/react-color-block-2.9.2.tgz",
@@ -4894,6 +4925,38 @@
"react-dom": ">=16.9.0"
}
},
+ "node_modules/@uiw/react-color-colorful": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-colorful/-/react-color-colorful-2.9.6.tgz",
+ "integrity": "sha512-h74zo+ve9Rpv7xwb1dRfoa23yN39b6eYScDIm7V2d5FzkXN6hR7jnnJ7ZUD9Joz/rdaCz1eFQD9ig+wp8+wSnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@uiw/color-convert": "2.9.6",
+ "@uiw/react-color-alpha": "2.9.6",
+ "@uiw/react-color-hue": "2.9.6",
+ "@uiw/react-color-saturation": "2.9.6"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-colorful/node_modules/@uiw/color-convert": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.6.tgz",
+ "integrity": "sha512-w8TpU3MRcquurQJxWR1daKcRygu/a0hLP/VGsLMA3ebb41sAZGxMQLHtS+zC/e3ciFNB7BbPrSPlzOcz6w6cRg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0"
+ }
+ },
"node_modules/@uiw/react-color-editable-input": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input/-/react-color-editable-input-2.9.2.tgz",
@@ -4908,6 +4971,66 @@
"react-dom": ">=16.9.0"
}
},
+ "node_modules/@uiw/react-color-hue": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-hue/-/react-color-hue-2.9.6.tgz",
+ "integrity": "sha512-B99dW2/AHMD3py83BrXl94bhXeGCZR1FMpU/FNbIIbUrV9QTiIXDs2/SB/tMD9ltcSP59RD5Sc5m2vCb/8anjw==",
+ "license": "MIT",
+ "dependencies": {
+ "@uiw/color-convert": "2.9.6",
+ "@uiw/react-color-alpha": "2.9.6"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-hue/node_modules/@uiw/color-convert": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.6.tgz",
+ "integrity": "sha512-w8TpU3MRcquurQJxWR1daKcRygu/a0hLP/VGsLMA3ebb41sAZGxMQLHtS+zC/e3ciFNB7BbPrSPlzOcz6w6cRg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0"
+ }
+ },
+ "node_modules/@uiw/react-color-saturation": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-saturation/-/react-color-saturation-2.9.6.tgz",
+ "integrity": "sha512-R1tiKbTG2WiJXerkmuaKnBFfzgyZUn08q9OjQSvNH1f3ov2/YeUVlOwQY9MbQE7ytZv+9x+1h0Lpk4QG7AdulQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@uiw/color-convert": "2.9.6",
+ "@uiw/react-drag-event-interactive": "2.9.6"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-saturation/node_modules/@uiw/color-convert": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.6.tgz",
+ "integrity": "sha512-w8TpU3MRcquurQJxWR1daKcRygu/a0hLP/VGsLMA3ebb41sAZGxMQLHtS+zC/e3ciFNB7BbPrSPlzOcz6w6cRg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0"
+ }
+ },
"node_modules/@uiw/react-color-swatch": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-swatch/-/react-color-swatch-2.9.2.tgz",
@@ -4925,6 +5048,20 @@
"react-dom": ">=16.9.0"
}
},
+ "node_modules/@uiw/react-drag-event-interactive": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.9.6.tgz",
+ "integrity": "sha512-jXzt3Xis/BIYap2Hj2++gB3aEUD0mZoVNGfckurrwjAwxasxNiwkmTGxV5er3due0ZgaVKdOAfTRoYKlgZukSg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
diff --git a/package.json b/package.json
index 8817372..2496471 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
+ "@uiw/react-color-colorful": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx
index b289392..c897c03 100644
--- a/src/components/video-editor/AnnotationSettingsPanel.tsx
+++ b/src/components/video-editor/AnnotationSettingsPanel.tsx
@@ -1,4 +1,5 @@
import Block from "@uiw/react-color-block";
+import Colorful from "@uiw/react-color-colorful";
import {
AlignCenter,
AlignLeft,
@@ -67,7 +68,7 @@ export function AnnotationSettingsPanel({
const t = useScopedT("settings");
const fileInputRef = useRef
(null);
const [customFonts, setCustomFonts] = useState([]);
-
+ const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel");
const fontStyleLabels: Record = {
classic: t("fontStyles.classic"),
editor: t("fontStyles.editor"),
@@ -139,6 +140,15 @@ export function AnnotationSettingsPanel({
event.target.value = "";
};
+ const getTextColor = (color: string) => {
+ if (color === "transparent") return "#ffffff";
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const b = parseInt(color.slice(5, 7), 16);
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
+ if (luminance > 186) return "#000000";
+ return "#ffffff";
+ };
return (
@@ -380,17 +390,68 @@ export function AnnotationSettingsPanel({
-
- {
- onStyleChange({ color: color.hex });
- }}
- style={{
- borderRadius: "8px",
- }}
- />
+
+
+ {colorMode === "palette" && (
+
{
+ onStyleChange({ color: color.hex });
+ }}
+ style={{
+ borderRadius: "8px",
+ }}
+ />
+ )}
+ {colorMode === "wheel" && (
+ <>
+
+
+ {annotation.style.color}
+
+
+ {
+ onStyleChange({ color: color.hex });
+ }}
+ style={{
+ borderRadius: "8px",
+ }}
+ disableAlpha={true}
+ />
+ >
+ )}
+
+ setColorMode("wheel")}
+ >
+
+ {t("annotation.colorWheel")}
+
+
+ setColorMode("palette")}
+ >
+
+ {t("annotation.colorPalette")}
+
+
+
+
@@ -419,21 +480,74 @@ export function AnnotationSettingsPanel({
-
- {
- onStyleChange({ backgroundColor: color.hex });
- }}
- style={{
- borderRadius: "8px",
- }}
- />
+
+
+ {colorMode === "palette" && (
+
{
+ onStyleChange({ backgroundColor: color.hex });
+ }}
+ style={{
+ borderRadius: "8px",
+ }}
+ />
+ )}
+ {colorMode === "wheel" && (
+ <>
+
+
+ {annotation.style.backgroundColor}
+
+
+ {
+ onStyleChange({ backgroundColor: color.hex });
+ }}
+ style={{
+ borderRadius: "8px",
+ }}
+ disableAlpha={true}
+ />
+ >
+ )}
+
+ setColorMode("wheel")}
+ >
+
+ {t("annotation.colorWheel")}
+
+
+ setColorMode("palette")}
+ >
+
+ {t("annotation.colorPalette")}
+
+
+
+
([]);
const [customImages, setCustomImages] = useState([]);
+ const [backgroundColorMode, setBackgroundColorMode] = useState<"wheel" | "palette">("wheel");
const fileInputRef = useRef(null);
useEffect(() => {
@@ -319,6 +322,16 @@ export function SettingsPanel({
[cropRegion, onCropChange, videoWidth, videoHeight, cropAspectLocked],
);
+ const getTextColor = (color: string) => {
+ if (color === "transparent") return "#ffffff";
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const b = parseInt(color.slice(5, 7), 16);
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
+ if (luminance > 186) return "#000000";
+ return "#ffffff";
+ };
+
const applyCropAspectPreset = useCallback(
(preset: string) => {
if (!cropRegion || !onCropChange) return;
@@ -900,7 +913,7 @@ export function SettingsPanel({
-
+
-
-
{
- setSelectedColor(color.hex);
- onWallpaperChange(color.hex);
- }}
- style={{
- width: "100%",
- borderRadius: "8px",
- }}
- />
+
+
+ setBackgroundColorMode("wheel")}
+ style={{
+ backgroundColor:
+ backgroundColorMode === "wheel" ? "#34B27B" : "transparent",
+ }}
+ >
+
+ {t("annotation.colorWheel")}
+
+
+ setBackgroundColorMode("palette")}
+ style={{
+ backgroundColor:
+ backgroundColorMode === "palette" ? "#34B27B" : "transparent",
+ }}
+ >
+
+ {t("annotation.colorPalette")}
+
+
+
+ {backgroundColorMode === "wheel" && (
+ <>
+
+
+ {selectedColor}
+
+
+
{
+ setSelectedColor(color.hex);
+ onWallpaperChange(color.hex);
+ }}
+ style={{
+ borderRadius: "8px",
+ }}
+ disableAlpha={true}
+ />
+ {
+ setSelectedColor(e.target.value);
+ onWallpaperChange(e.target.value);
+ }}
+ />
+ >
+ )}
+ {backgroundColorMode === "palette" && (
+ {
+ setSelectedColor(color.hex);
+ onWallpaperChange(color.hex);
+ }}
+ style={{
+ width: "100%",
+ borderRadius: "8px",
+ }}
+ />
+ )}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 632a569..da98aea 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -108,6 +108,8 @@
"background": "Background",
"none": "None",
"color": "Color",
+ "colorWheel": "Color Wheel",
+ "colorPalette": "Color Palette",
"clearBackground": "Clear Background",
"uploadImage": "Upload Image",
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 586e840..9af4632 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -108,6 +108,8 @@
"background": "Fondo",
"none": "Ninguno",
"color": "Color",
+ "colorWheel": "Rueda de colores",
+ "colorPalette": "Paleta de colores",
"clearBackground": "Quitar fondo",
"uploadImage": "Subir imagen",
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index ab0d41b..a9aa32d 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -108,6 +108,8 @@
"background": "背景",
"none": "无",
"color": "颜色",
+ "colorWheel": "颜色轮",
+ "colorPalette": "颜色调色板",
"clearBackground": "清除背景",
"uploadImage": "上传图片",
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
From 2c10073d308550fbfd09d7767134c97c73f90513 Mon Sep 17 00:00:00 2001
From: BaptisteAuscher
Date: Mon, 6 Apr 2026 21:02:50 +0200
Subject: [PATCH 03/31] ai review changes
---
src/components/video-editor/SettingsPanel.tsx | 4 ++--
src/i18n/locales/en/settings.json | 4 +++-
src/i18n/locales/es/settings.json | 4 +++-
src/i18n/locales/zh-CN/settings.json | 4 +++-
4 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 7e4ff35..6df3574 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1014,7 +1014,7 @@ export function SettingsPanel({
}}
>
- {t("annotation.colorWheel")}
+ {t("background.colorWheel")}
- {t("annotation.colorPalette")}
+ {t("background.colorPalette")}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index da98aea..0d18efd 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -41,7 +41,9 @@
"color": "Color",
"gradient": "Gradient",
"uploadCustom": "Upload Custom",
- "gradientLabel": "Gradient {{index}}"
+ "gradientLabel": "Gradient {{index}}",
+ "colorWheel": "Color Wheel",
+ "colorPalette": "Color Palette"
},
"crop": {
"title": "Crop",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 9af4632..1eb6d46 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -41,7 +41,9 @@
"color": "Color",
"gradient": "Degradado",
"uploadCustom": "Subir personalizado",
- "gradientLabel": "Degradado {{index}}"
+ "gradientLabel": "Degradado {{index}}",
+ "colorWheel": "Rueda de colores",
+ "colorPalette": "Paleta de colores"
},
"crop": {
"title": "Recortar",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index a9aa32d..8b554a4 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -41,7 +41,9 @@
"color": "颜色",
"gradient": "渐变",
"uploadCustom": "上传自定义",
- "gradientLabel": "渐变 {{index}}"
+ "gradientLabel": "渐变 {{index}}",
+ "colorWheel": "颜色轮",
+ "colorPalette": "颜色调色板"
},
"crop": {
"title": "裁剪",
From 10a8feb71d337b84bc75169a7d158c580cbaf1e5 Mon Sep 17 00:00:00 2001
From: BaptisteAuscher
Date: Tue, 7 Apr 2026 22:33:39 +0200
Subject: [PATCH 04/31] changes after review, factor the color picker component
and add validation for the input
---
src/components/ui/color-picker.tsx | 141 +++++++++++++++
.../video-editor/AnnotationSettingsPanel.tsx | 166 +++---------------
src/components/video-editor/SettingsPanel.tsx | 105 ++---------
3 files changed, 178 insertions(+), 234 deletions(-)
create mode 100644 src/components/ui/color-picker.tsx
diff --git a/src/components/ui/color-picker.tsx b/src/components/ui/color-picker.tsx
new file mode 100644
index 0000000..ea5eb30
--- /dev/null
+++ b/src/components/ui/color-picker.tsx
@@ -0,0 +1,141 @@
+import Block from "@uiw/react-color-block";
+import Colorful from "@uiw/react-color-colorful";
+import { useEffect, useState } from "react";
+import { Button } from "./button";
+import { Input } from "./input";
+
+export default function ColorPicker({
+ selectedColor,
+ colorPalette,
+ translations,
+ clearBackgroundOption = false,
+ onUpdateColor,
+}: {
+ selectedColor: string;
+ colorPalette: string[];
+ translations: Record<"colorWheel" | "colorPalette", string> &
+ Partial>;
+ clearBackgroundOption?: boolean;
+ onUpdateColor: (color: string) => void;
+}) {
+ const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel");
+ const [hexInput, setHexInput] = useState(selectedColor);
+
+ useEffect(() => {
+ setHexInput(selectedColor);
+ }, [selectedColor]);
+
+ const getTextColor = (color: string) => {
+ if (color === "transparent") return "#ffffff";
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const b = parseInt(color.slice(5, 7), 16);
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
+ if (luminance > 186) return "#000000";
+ return "#ffffff";
+ };
+
+ // Normalize the hex input.
+ // Adds a # at the beginning of the input if it's not there.
+ const normalizeHexDraft = (raw: string) => {
+ const trimmed = raw.trim();
+ if (trimmed === "") return "";
+ if (/^[0-9A-Fa-f]/.test(trimmed[0])) return `#${trimmed}`;
+ return trimmed;
+ };
+
+ const handleColorInputChange = (e: React.ChangeEvent) => {
+ const normalized = normalizeHexDraft(e.target.value);
+ setHexInput(normalized);
+ // Check if the normalized hex is a valid hex color.
+ // It should follow the format #RRGGBB or #RGB.
+ const isValidHexColor =
+ /^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized);
+ if (isValidHexColor) {
+ onUpdateColor(normalized);
+ }
+ };
+ return (
+
+
+ setColorMode("wheel")}
+ style={{
+ backgroundColor: colorMode === "wheel" ? "#34B27B" : "transparent",
+ }}
+ >
+
+ {translations.colorWheel}
+
+
+ setColorMode("palette")}
+ style={{
+ backgroundColor: colorMode === "palette" ? "#34B27B" : "transparent",
+ }}
+ >
+
+ {translations.colorPalette}
+
+
+
+ {colorMode === "wheel" && (
+ <>
+
+ {selectedColor}
+
+
{
+ onUpdateColor(color.hex);
+ }}
+ style={{
+ borderRadius: "8px",
+ }}
+ disableAlpha={true}
+ />
+
+ >
+ )}
+ {colorMode === "palette" && (
+ {
+ onUpdateColor(color.hex);
+ }}
+ style={{
+ width: "100%",
+ borderRadius: "8px",
+ }}
+ />
+ )}
+ {clearBackgroundOption && (
+ {
+ onUpdateColor("transparent");
+ }}
+ >
+ {translations.clearBackground}
+
+ )}
+
+ );
+}
diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx
index c897c03..eb6a9be 100644
--- a/src/components/video-editor/AnnotationSettingsPanel.tsx
+++ b/src/components/video-editor/AnnotationSettingsPanel.tsx
@@ -1,5 +1,4 @@
import Block from "@uiw/react-color-block";
-import Colorful from "@uiw/react-color-colorful";
import {
AlignCenter,
AlignLeft,
@@ -31,6 +30,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useScopedT } from "@/contexts/I18nContext";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
+import ColorPicker from "../ui/color-picker";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
@@ -68,7 +68,6 @@ export function AnnotationSettingsPanel({
const t = useScopedT("settings");
const fileInputRef = useRef(null);
const [customFonts, setCustomFonts] = useState([]);
- const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel");
const fontStyleLabels: Record = {
classic: t("fontStyles.classic"),
editor: t("fontStyles.editor"),
@@ -140,15 +139,6 @@ export function AnnotationSettingsPanel({
event.target.value = "";
};
- const getTextColor = (color: string) => {
- if (color === "transparent") return "#ffffff";
- const r = parseInt(color.slice(1, 3), 16);
- const g = parseInt(color.slice(3, 5), 16);
- const b = parseInt(color.slice(5, 7), 16);
- const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
- if (luminance > 186) return "#000000";
- return "#ffffff";
- };
return (
@@ -394,64 +384,17 @@ export function AnnotationSettingsPanel({
side="top"
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
>
-
- {colorMode === "palette" && (
-
{
- onStyleChange({ color: color.hex });
- }}
- style={{
- borderRadius: "8px",
- }}
- />
- )}
- {colorMode === "wheel" && (
- <>
-
-
- {annotation.style.color}
-
-
- {
- onStyleChange({ color: color.hex });
- }}
- style={{
- borderRadius: "8px",
- }}
- disableAlpha={true}
- />
- >
- )}
-
- setColorMode("wheel")}
- >
-
- {t("annotation.colorWheel")}
-
-
- setColorMode("palette")}
- >
-
- {t("annotation.colorPalette")}
-
-
-
-
+
{
+ onStyleChange({ color: color });
+ }}
+ />
@@ -484,80 +427,19 @@ export function AnnotationSettingsPanel({
side="top"
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
>
-
- {colorMode === "palette" && (
-
{
- onStyleChange({ backgroundColor: color.hex });
- }}
- style={{
- borderRadius: "8px",
- }}
- />
- )}
- {colorMode === "wheel" && (
- <>
-
-
- {annotation.style.backgroundColor}
-
-
- {
- onStyleChange({ backgroundColor: color.hex });
- }}
- style={{
- borderRadius: "8px",
- }}
- disableAlpha={true}
- />
- >
- )}
-
- setColorMode("wheel")}
- >
-
- {t("annotation.colorWheel")}
-
-
- setColorMode("palette")}
- >
-
- {t("annotation.colorPalette")}
-
-
-
-
-
{
- onStyleChange({ backgroundColor: "transparent" });
+
- {t("annotation.clearBackground")}
-
+ clearBackgroundOption={true}
+ onUpdateColor={(color) => {
+ onStyleChange({ backgroundColor: color });
+ }}
+ />
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 6df3574..05d4940 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1,5 +1,3 @@
-import Block from "@uiw/react-color-block";
-import Colorful from "@uiw/react-color-colorful";
import {
Bug,
Crop,
@@ -42,7 +40,7 @@ import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
-import { Input } from "../ui/input";
+import ColorPicker from "../ui/color-picker";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
@@ -229,7 +227,6 @@ export function SettingsPanel({
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState([]);
const [customImages, setCustomImages] = useState([]);
- const [backgroundColorMode, setBackgroundColorMode] = useState<"wheel" | "palette">("wheel");
const fileInputRef = useRef(null);
useEffect(() => {
@@ -322,16 +319,6 @@ export function SettingsPanel({
[cropRegion, onCropChange, videoWidth, videoHeight, cropAspectLocked],
);
- const getTextColor = (color: string) => {
- if (color === "transparent") return "#ffffff";
- const r = parseInt(color.slice(1, 3), 16);
- const g = parseInt(color.slice(3, 5), 16);
- const b = parseInt(color.slice(5, 7), 16);
- const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
- if (luminance > 186) return "#000000";
- return "#ffffff";
- };
-
const applyCropAspectPreset = useCallback(
(preset: string) => {
if (!cropRegion || !onCropChange) return;
@@ -1001,84 +988,18 @@ export function SettingsPanel({
-
-
- setBackgroundColorMode("wheel")}
- style={{
- backgroundColor:
- backgroundColorMode === "wheel" ? "#34B27B" : "transparent",
- }}
- >
-
- {t("background.colorWheel")}
-
-
- setBackgroundColorMode("palette")}
- style={{
- backgroundColor:
- backgroundColorMode === "palette" ? "#34B27B" : "transparent",
- }}
- >
-
- {t("background.colorPalette")}
-
-
-
- {backgroundColorMode === "wheel" && (
- <>
-
-
- {selectedColor}
-
-
-
{
- setSelectedColor(color.hex);
- onWallpaperChange(color.hex);
- }}
- style={{
- borderRadius: "8px",
- }}
- disableAlpha={true}
- />
- {
- setSelectedColor(e.target.value);
- onWallpaperChange(e.target.value);
- }}
- />
- >
- )}
- {backgroundColorMode === "palette" && (
- {
- setSelectedColor(color.hex);
- onWallpaperChange(color.hex);
- }}
- style={{
- width: "100%",
- borderRadius: "8px",
- }}
- />
- )}
-
+ {
+ setSelectedColor(color);
+ onWallpaperChange(color);
+ }}
+ />
From 545c02b5bbfaf7fd9d56c60ed50620b7847423fb Mon Sep 17 00:00:00 2001
From: BaptisteAuscher
Date: Wed, 8 Apr 2026 22:04:19 +0200
Subject: [PATCH 05/31] handle transparent values for the color wheel
---
src/components/ui/color-picker.tsx | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/src/components/ui/color-picker.tsx b/src/components/ui/color-picker.tsx
index ea5eb30..eed2f02 100644
--- a/src/components/ui/color-picker.tsx
+++ b/src/components/ui/color-picker.tsx
@@ -1,3 +1,4 @@
+import { HsvaColor, hexToHsva } from "@uiw/color-convert";
import Block from "@uiw/react-color-block";
import Colorful from "@uiw/react-color-colorful";
import { useEffect, useState } from "react";
@@ -20,6 +21,12 @@ export default function ColorPicker({
}) {
const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel");
const [hexInput, setHexInput] = useState(selectedColor);
+ const [transparentColorHSVA, setTransparentColorHSVA] = useState({
+ h: 0,
+ s: 0,
+ v: 0,
+ a: 0,
+ });
useEffect(() => {
setHexInput(selectedColor);
@@ -55,6 +62,12 @@ export default function ColorPicker({
onUpdateColor(normalized);
}
};
+
+ const toTransparent = (color: string) => {
+ const hsva = hexToHsva(color);
+ hsva.a = 0;
+ return hsva;
+ };
return (
@@ -94,7 +107,7 @@ export default function ColorPicker({
{selectedColor}
{
onUpdateColor(color.hex);
}}
@@ -130,6 +143,8 @@ export default function ColorPicker({
size="sm"
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
onClick={() => {
+ const hsva = toTransparent(selectedColor);
+ setTransparentColorHSVA(hsva);
onUpdateColor("transparent");
}}
>
From 765434b93545be95e65a786e41e735237e233579 Mon Sep 17 00:00:00 2001
From: BaptisteAuscher
Date: Wed, 8 Apr 2026 22:23:52 +0200
Subject: [PATCH 06/31] code rabbit
---
src/components/ui/color-picker.tsx | 33 +++++++++++++++++-------------
1 file changed, 19 insertions(+), 14 deletions(-)
diff --git a/src/components/ui/color-picker.tsx b/src/components/ui/color-picker.tsx
index eed2f02..a1b78d5 100644
--- a/src/components/ui/color-picker.tsx
+++ b/src/components/ui/color-picker.tsx
@@ -5,20 +5,24 @@ import { useEffect, useState } from "react";
import { Button } from "./button";
import { Input } from "./input";
-export default function ColorPicker({
- selectedColor,
- colorPalette,
- translations,
- clearBackgroundOption = false,
- onUpdateColor,
-}: {
+type BaseProps = {
selectedColor: string;
colorPalette: string[];
- translations: Record<"colorWheel" | "colorPalette", string> &
- Partial>;
- clearBackgroundOption?: boolean;
onUpdateColor: (color: string) => void;
-}) {
+};
+
+type ColorPickerProps =
+ | (BaseProps & {
+ clearBackgroundOption?: false;
+ translations: Record<"colorWheel" | "colorPalette", string>;
+ })
+ | (BaseProps & {
+ clearBackgroundOption: true;
+ translations: Record<"colorWheel" | "colorPalette" | "clearBackground", string>;
+ });
+
+export default function ColorPicker(props: ColorPickerProps) {
+ const { selectedColor, colorPalette, translations, onUpdateColor } = props;
const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel");
const [hexInput, setHexInput] = useState(selectedColor);
const [transparentColorHSVA, setTransparentColorHSVA] = useState({
@@ -64,6 +68,7 @@ export default function ColorPicker({
};
const toTransparent = (color: string) => {
+ if (color === "transparent") return;
const hsva = hexToHsva(color);
hsva.a = 0;
return hsva;
@@ -137,18 +142,18 @@ export default function ColorPicker({
}}
/>
)}
- {clearBackgroundOption && (
+ {props.clearBackgroundOption === true && (
{
const hsva = toTransparent(selectedColor);
- setTransparentColorHSVA(hsva);
+ if (hsva) setTransparentColorHSVA(hsva);
onUpdateColor("transparent");
}}
>
- {translations.clearBackground}
+ {props.translations.clearBackground}
)}
From c3faca19fd7f6dfcbe06b61c5c1965b1431ca21b Mon Sep 17 00:00:00 2001
From: BaptisteAuscher
Date: Wed, 8 Apr 2026 22:45:27 +0200
Subject: [PATCH 07/31] small fix: color block handles transparent values
---
src/components/ui/color-picker.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/ui/color-picker.tsx b/src/components/ui/color-picker.tsx
index a1b78d5..d8ec2b3 100644
--- a/src/components/ui/color-picker.tsx
+++ b/src/components/ui/color-picker.tsx
@@ -131,7 +131,7 @@ export default function ColorPicker(props: ColorPickerProps) {
)}
{colorMode === "palette" && (
{
onUpdateColor(color.hex);
From 283fa406b259097f1b95c908b727dfe80ad57fa6 Mon Sep 17 00:00:00 2001
From: BaptisteAuscher
Date: Wed, 8 Apr 2026 23:00:33 +0200
Subject: [PATCH 08/31] langages : tr and fr
---
src/i18n/locales/fr/settings.json | 6 +++++-
src/i18n/locales/tr/settings.json | 6 +++++-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index dd7610f..48ce7e3 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -41,7 +41,9 @@
"color": "Couleur",
"gradient": "Dégradé",
"uploadCustom": "Téléverser une image",
- "gradientLabel": "Dégradé {{index}}"
+ "gradientLabel": "Dégradé {{index}}",
+ "colorWheel": "Roue chromatique",
+ "colorPalette": "Palette de couleurs"
},
"crop": {
"title": "Recadrage",
@@ -108,6 +110,8 @@
"background": "Arrière-plan",
"none": "Aucun",
"color": "Couleur",
+ "colorWheel": "Roue chromatique",
+ "colorPalette": "Palette de couleurs",
"clearBackground": "Supprimer l'arrière-plan",
"uploadImage": "Téléverser une image",
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 1fa4668..3cf33b1 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -41,7 +41,9 @@
"color": "Renk",
"gradient": "Gradyan",
"uploadCustom": "Özel Yükle",
- "gradientLabel": "Gradyan {{index}}"
+ "gradientLabel": "Gradyan {{index}}",
+ "colorWheel": "Renk çarkı",
+ "colorPalette": "Renk paleti"
},
"crop": {
"title": "Kırpma",
@@ -108,6 +110,8 @@
"background": "Arka Plan",
"none": "Yok",
"color": "Renk",
+ "colorWheel": "Renk çarkı",
+ "colorPalette": "Renk paleti",
"clearBackground": "Arka Planı Temizle",
"uploadImage": "Görüntü Yükle",
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
From 31f0483c6574f12366820200a26d3796dd1719d7 Mon Sep 17 00:00:00 2001
From: psychosomat
Date: Wed, 22 Apr 2026 02:01:20 +0300
Subject: [PATCH 09/31] Improve Arch Linux support and fix video export on
Hyprland
- Add pacman package build target for Arch Linux in electron-builder.json5
- Update build:linux script in package.json to include pacman target
- Fix dialog window issues on Wayland/Hyprland:
* Pass mainWindow reference to dialog.showSaveDialog and dialog.showOpenDialog in electron/ipc/handlers.ts
* Required for proper dialog functionality on Wayland compositors
* Previously dialogs opened without parent window attachment causing issues on Hyprland
Changes ensure:
- Correct video export on Arch Linux + Hyprland systems
- Ability to install via pacman package manager
- Improved compatibility with Wayland compositors
---
electron-builder.json5 | 4 +-
electron/ipc/handlers.ts | 147 +++++++++++++++++++++++++++------------
electron/main.ts | 12 ++++
package.json | 2 +-
4 files changed, 117 insertions(+), 48 deletions(-)
diff --git a/electron-builder.json5 b/electron-builder.json5
index 18498df..b005c3f 100644
--- a/electron-builder.json5
+++ b/electron-builder.json5
@@ -50,7 +50,9 @@
},
"linux": {
"target": [
- "AppImage"
+ "AppImage",
+ "deb",
+ "pacman"
],
"icon": "icons/icons/png",
"artifactName": "${productName}-Linux-${version}.${ext}",
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 261d93f..e5665a9 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -359,7 +359,9 @@ export function registerIpcHandlers(
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
switchToHud?: () => void,
) {
- const supportsWindowOpacity = process.platform !== "linux";
+ const isWayland =
+ process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
+ const supportsWindowOpacity = process.platform !== "linux" || isWayland;
const countdownOverlayState = {
visible: false,
value: null as number | null,
@@ -834,14 +836,24 @@ export function registerIpcHandlers(
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
- const result = await dialog.showSaveDialog({
- title: isGif
- ? mainT("dialogs", "fileDialogs.saveGif")
- : mainT("dialogs", "fileDialogs.saveVideo"),
- defaultPath: path.join(app.getPath("downloads"), fileName),
- filters,
- properties: ["createDirectory", "showOverwriteConfirmation"],
- });
+ const mainWindow = getMainWindow();
+ const result = mainWindow
+ ? await dialog.showSaveDialog(mainWindow, {
+ title: isGif
+ ? mainT("dialogs", "fileDialogs.saveGif")
+ : mainT("dialogs", "fileDialogs.saveVideo"),
+ defaultPath: path.join(app.getPath("downloads"), fileName),
+ filters,
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ })
+ : await dialog.showSaveDialog({
+ title: isGif
+ ? mainT("dialogs", "fileDialogs.saveGif")
+ : mainT("dialogs", "fileDialogs.saveVideo"),
+ defaultPath: path.join(app.getPath("downloads"), fileName),
+ filters,
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ });
if (result.canceled || !result.filePath) {
return {
@@ -876,18 +888,32 @@ export function registerIpcHandlers(
});
ipcMain.handle("open-video-file-picker", async () => {
try {
- const result = await dialog.showOpenDialog({
- title: mainT("dialogs", "fileDialogs.selectVideo"),
- defaultPath: RECORDINGS_DIR,
- filters: [
- {
- name: mainT("dialogs", "fileDialogs.videoFiles"),
- extensions: ["webm", "mp4", "mov", "avi", "mkv"],
- },
- { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
- ],
- properties: ["openFile"],
- });
+ const mainWindow = getMainWindow();
+ const result = mainWindow
+ ? await dialog.showOpenDialog(mainWindow, {
+ title: mainT("dialogs", "fileDialogs.selectVideo"),
+ defaultPath: RECORDINGS_DIR,
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.videoFiles"),
+ extensions: ["webm", "mp4", "mov", "avi", "mkv"],
+ },
+ { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ })
+ : await dialog.showOpenDialog({
+ title: mainT("dialogs", "fileDialogs.selectVideo"),
+ defaultPath: RECORDINGS_DIR,
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.videoFiles"),
+ extensions: ["webm", "mp4", "mov", "avi", "mkv"],
+ },
+ { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ });
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
@@ -966,18 +992,32 @@ export function registerIpcHandlers(
? safeName
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
- const result = await dialog.showSaveDialog({
- title: mainT("dialogs", "fileDialogs.saveProject"),
- defaultPath: path.join(RECORDINGS_DIR, defaultName),
- filters: [
- {
- name: mainT("dialogs", "fileDialogs.openscreenProject"),
- extensions: [PROJECT_FILE_EXTENSION],
- },
- { name: "JSON", extensions: ["json"] },
- ],
- properties: ["createDirectory", "showOverwriteConfirmation"],
- });
+ const mainWindow = getMainWindow();
+ const result = mainWindow
+ ? await dialog.showSaveDialog(mainWindow, {
+ title: mainT("dialogs", "fileDialogs.saveProject"),
+ defaultPath: path.join(RECORDINGS_DIR, defaultName),
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.openscreenProject"),
+ extensions: [PROJECT_FILE_EXTENSION],
+ },
+ { name: "JSON", extensions: ["json"] },
+ ],
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ })
+ : await dialog.showSaveDialog({
+ title: mainT("dialogs", "fileDialogs.saveProject"),
+ defaultPath: path.join(RECORDINGS_DIR, defaultName),
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.openscreenProject"),
+ extensions: [PROJECT_FILE_EXTENSION],
+ },
+ { name: "JSON", extensions: ["json"] },
+ ],
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ });
if (result.canceled || !result.filePath) {
return {
@@ -1008,19 +1048,34 @@ export function registerIpcHandlers(
ipcMain.handle("load-project-file", async () => {
try {
- const result = await dialog.showOpenDialog({
- title: mainT("dialogs", "fileDialogs.openProject"),
- defaultPath: RECORDINGS_DIR,
- filters: [
- {
- name: mainT("dialogs", "fileDialogs.openscreenProject"),
- extensions: [PROJECT_FILE_EXTENSION],
- },
- { name: "JSON", extensions: ["json"] },
- { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
- ],
- properties: ["openFile"],
- });
+ const mainWindow = getMainWindow();
+ const result = mainWindow
+ ? await dialog.showOpenDialog(mainWindow, {
+ title: mainT("dialogs", "fileDialogs.openProject"),
+ defaultPath: RECORDINGS_DIR,
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.openscreenProject"),
+ extensions: [PROJECT_FILE_EXTENSION],
+ },
+ { name: "JSON", extensions: ["json"] },
+ { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ })
+ : await dialog.showOpenDialog({
+ title: mainT("dialogs", "fileDialogs.openProject"),
+ defaultPath: RECORDINGS_DIR,
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.openscreenProject"),
+ extensions: [PROJECT_FILE_EXTENSION],
+ },
+ { name: "JSON", extensions: ["json"] },
+ { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ });
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true, message: "Open project canceled" };
diff --git a/electron/main.ts b/electron/main.ts
index ad0a33f..3f77ead 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -30,6 +30,18 @@ if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
}
+// Enable Wayland support for proper screen capture and window management
+// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
+if (process.platform === "linux") {
+ const isWayland =
+ process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
+ if (isWayland) {
+ app.commandLine.appendSwitch("ozone-platform", "wayland");
+ // Enable PipeWire for screen capture on Wayland
+ app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,PipeWire");
+ }
+}
+
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function ensureRecordingsDir() {
diff --git a/package.json b/package.json
index d41fd40..37b3762 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
- "build:linux": "tsc && vite build && electron-builder --linux AppImage deb",
+ "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman",
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
From d6d872e5298002c802d2e5249264b670c9d0a43e Mon Sep 17 00:00:00 2001
From: psychosomat
Date: Wed, 22 Apr 2026 02:23:31 +0300
Subject: [PATCH 10/31] Fix CodeRabbit review comments
- Add buildDialogOptions helper function to safely attach parent window only when valid and not destroyed
- Update all dialog calls (save-exported-video, open-video-file-picker, save-project-file, load-project-file) to use the helper
- Fix supportsWindowOpacity logic by removing || isWayland so Linux always follows no-opacity codepath
- Change incorrect Chromium feature name 'PipeWire' to 'WebRTCPipeWireCapturer' in main.ts
- Remove unused isWayland variable in handlers.ts
---
electron/ipc/handlers.ts | 178 +++++++++++++++++----------------------
electron/main.ts | 4 +-
2 files changed, 79 insertions(+), 103 deletions(-)
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index e5665a9..eafca1e 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -52,6 +52,21 @@ function isPathAllowed(filePath: string): boolean {
return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir));
}
+/**
+ * Helper function to build dialog options with a parent window only when it's valid.
+ * This prevents passing stale or destroyed BrowserWindow references to dialog calls.
+ */
+function buildDialogOptions(
+ baseOptions: T,
+ parentWindow: BrowserWindow | null,
+): T & { parent?: BrowserWindow } {
+ const mainWindow = parentWindow;
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ return { ...baseOptions, parent: mainWindow };
+ }
+ return baseOptions;
+}
+
function hasAllowedImportVideoExtension(filePath: string): boolean {
return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
@@ -359,9 +374,7 @@ export function registerIpcHandlers(
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
switchToHud?: () => void,
) {
- const isWayland =
- process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
- const supportsWindowOpacity = process.platform !== "linux" || isWayland;
+ const supportsWindowOpacity = process.platform !== "linux";
const countdownOverlayState = {
visible: false,
value: null as number | null,
@@ -836,24 +849,18 @@ export function registerIpcHandlers(
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
- const mainWindow = getMainWindow();
- const result = mainWindow
- ? await dialog.showSaveDialog(mainWindow, {
- title: isGif
- ? mainT("dialogs", "fileDialogs.saveGif")
- : mainT("dialogs", "fileDialogs.saveVideo"),
- defaultPath: path.join(app.getPath("downloads"), fileName),
- filters,
- properties: ["createDirectory", "showOverwriteConfirmation"],
- })
- : await dialog.showSaveDialog({
- title: isGif
- ? mainT("dialogs", "fileDialogs.saveGif")
- : mainT("dialogs", "fileDialogs.saveVideo"),
- defaultPath: path.join(app.getPath("downloads"), fileName),
- filters,
- properties: ["createDirectory", "showOverwriteConfirmation"],
- });
+ const dialogOptions = buildDialogOptions(
+ {
+ title: isGif
+ ? mainT("dialogs", "fileDialogs.saveGif")
+ : mainT("dialogs", "fileDialogs.saveVideo"),
+ defaultPath: path.join(app.getPath("downloads"), fileName),
+ filters,
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ },
+ getMainWindow(),
+ );
+ const result = await dialog.showSaveDialog(dialogOptions);
if (result.canceled || !result.filePath) {
return {
@@ -888,32 +895,22 @@ export function registerIpcHandlers(
});
ipcMain.handle("open-video-file-picker", async () => {
try {
- const mainWindow = getMainWindow();
- const result = mainWindow
- ? await dialog.showOpenDialog(mainWindow, {
- title: mainT("dialogs", "fileDialogs.selectVideo"),
- defaultPath: RECORDINGS_DIR,
- filters: [
- {
- name: mainT("dialogs", "fileDialogs.videoFiles"),
- extensions: ["webm", "mp4", "mov", "avi", "mkv"],
- },
- { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
- ],
- properties: ["openFile"],
- })
- : await dialog.showOpenDialog({
- title: mainT("dialogs", "fileDialogs.selectVideo"),
- defaultPath: RECORDINGS_DIR,
- filters: [
- {
- name: mainT("dialogs", "fileDialogs.videoFiles"),
- extensions: ["webm", "mp4", "mov", "avi", "mkv"],
- },
- { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
- ],
- properties: ["openFile"],
- });
+ const dialogOptions = buildDialogOptions(
+ {
+ title: mainT("dialogs", "fileDialogs.selectVideo"),
+ defaultPath: RECORDINGS_DIR,
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.videoFiles"),
+ extensions: ["webm", "mp4", "mov", "avi", "mkv"],
+ },
+ { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ },
+ getMainWindow(),
+ );
+ const result = await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
@@ -992,32 +989,22 @@ export function registerIpcHandlers(
? safeName
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
- const mainWindow = getMainWindow();
- const result = mainWindow
- ? await dialog.showSaveDialog(mainWindow, {
- title: mainT("dialogs", "fileDialogs.saveProject"),
- defaultPath: path.join(RECORDINGS_DIR, defaultName),
- filters: [
- {
- name: mainT("dialogs", "fileDialogs.openscreenProject"),
- extensions: [PROJECT_FILE_EXTENSION],
- },
- { name: "JSON", extensions: ["json"] },
- ],
- properties: ["createDirectory", "showOverwriteConfirmation"],
- })
- : await dialog.showSaveDialog({
- title: mainT("dialogs", "fileDialogs.saveProject"),
- defaultPath: path.join(RECORDINGS_DIR, defaultName),
- filters: [
- {
- name: mainT("dialogs", "fileDialogs.openscreenProject"),
- extensions: [PROJECT_FILE_EXTENSION],
- },
- { name: "JSON", extensions: ["json"] },
- ],
- properties: ["createDirectory", "showOverwriteConfirmation"],
- });
+ const dialogOptions = buildDialogOptions(
+ {
+ title: mainT("dialogs", "fileDialogs.saveProject"),
+ defaultPath: path.join(RECORDINGS_DIR, defaultName),
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.openscreenProject"),
+ extensions: [PROJECT_FILE_EXTENSION],
+ },
+ { name: "JSON", extensions: ["json"] },
+ ],
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ },
+ getMainWindow(),
+ );
+ const result = await dialog.showSaveDialog(dialogOptions);
if (result.canceled || !result.filePath) {
return {
@@ -1048,34 +1035,23 @@ export function registerIpcHandlers(
ipcMain.handle("load-project-file", async () => {
try {
- const mainWindow = getMainWindow();
- const result = mainWindow
- ? await dialog.showOpenDialog(mainWindow, {
- title: mainT("dialogs", "fileDialogs.openProject"),
- defaultPath: RECORDINGS_DIR,
- filters: [
- {
- name: mainT("dialogs", "fileDialogs.openscreenProject"),
- extensions: [PROJECT_FILE_EXTENSION],
- },
- { name: "JSON", extensions: ["json"] },
- { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
- ],
- properties: ["openFile"],
- })
- : await dialog.showOpenDialog({
- title: mainT("dialogs", "fileDialogs.openProject"),
- defaultPath: RECORDINGS_DIR,
- filters: [
- {
- name: mainT("dialogs", "fileDialogs.openscreenProject"),
- extensions: [PROJECT_FILE_EXTENSION],
- },
- { name: "JSON", extensions: ["json"] },
- { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
- ],
- properties: ["openFile"],
- });
+ const dialogOptions = buildDialogOptions(
+ {
+ title: mainT("dialogs", "fileDialogs.openProject"),
+ defaultPath: RECORDINGS_DIR,
+ filters: [
+ {
+ name: mainT("dialogs", "fileDialogs.openscreenProject"),
+ extensions: [PROJECT_FILE_EXTENSION],
+ },
+ { name: "JSON", extensions: ["json"] },
+ { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ },
+ getMainWindow(),
+ );
+ const result = await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true, message: "Open project canceled" };
diff --git a/electron/main.ts b/electron/main.ts
index 3f77ead..1da3603 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -37,8 +37,8 @@ if (process.platform === "linux") {
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
if (isWayland) {
app.commandLine.appendSwitch("ozone-platform", "wayland");
- // Enable PipeWire for screen capture on Wayland
- app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,PipeWire");
+ // Enable WebRTCPipeWireCapturer for screen capture on Wayland
+ app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer");
}
}
From 8e8b194454e48f7216e3fed21cf4882bd5d373df Mon Sep 17 00:00:00 2001
From: BaptisteAuscher
Date: Thu, 30 Apr 2026 22:22:46 +0200
Subject: [PATCH 11/31] adds support for japanese and chineese (taiwan)
---
src/i18n/locales/ja-JP/settings.json | 6 +++++-
src/i18n/locales/ko-KR/settings.json | 6 +++++-
src/i18n/locales/zh-TW/settings.json | 6 +++++-
3 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 9cad3ef..129217c 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -52,7 +52,9 @@
"color": "色",
"gradient": "グラデーション",
"uploadCustom": "カスタムをアップロード",
- "gradientLabel": "グラデーション {{index}}"
+ "gradientLabel": "グラデーション {{index}}",
+ "colorWheel": "カラーホイール",
+ "colorPalette": "カラーパレット"
},
"crop": {
"title": "クロップ",
@@ -120,6 +122,8 @@
"background": "背景",
"none": "なし",
"color": "色",
+ "colorWheel": "カラーホイール",
+ "colorPalette": "カラーパレット",
"clearBackground": "背景をクリア",
"uploadImage": "画像をアップロード",
"supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index cd9f734..5defbb6 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -44,7 +44,9 @@
"color": "색상",
"gradient": "그라디언트",
"uploadCustom": "직접 업로드",
- "gradientLabel": "그라디언트 {{index}}"
+ "gradientLabel": "그라디언트 {{index}}",
+ "colorWheel": "색상 휠",
+ "colorPalette": "색상 팔레트"
},
"crop": {
"title": "자르기",
@@ -111,6 +113,8 @@
"background": "배경",
"none": "없음",
"color": "색상",
+ "colorWheel": "색상 휠",
+ "colorPalette": "색상 팔레트",
"clearBackground": "배경 지우기",
"uploadImage": "이미지 업로드",
"supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 6344a99..652ab5a 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -52,7 +52,9 @@
"color": "顏色",
"gradient": "漸層",
"uploadCustom": "上傳自訂",
- "gradientLabel": "漸層 {{index}}"
+ "gradientLabel": "漸層 {{index}}",
+ "colorWheel": "色輪",
+ "colorPalette": "調色盤"
},
"crop": {
"title": "裁剪",
@@ -120,6 +122,8 @@
"background": "背景",
"none": "無",
"color": "顏色",
+ "colorWheel": "色輪",
+ "colorPalette": "調色盤",
"clearBackground": "清除背景",
"uploadImage": "上傳圖片",
"supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
From d59db3d8392ba6de30fcfaa817881beee7d11079 Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sat, 2 May 2026 17:34:47 -0700
Subject: [PATCH 12/31] fix missing spanish locale
---
src/i18n/locales/es/editor.json | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json
index c71368a..8f6ad13 100644
--- a/src/i18n/locales/es/editor.json
+++ b/src/i18n/locales/es/editor.json
@@ -34,5 +34,12 @@
"cameraDisconnected": "Cámara web desconectada.",
"cameraNotFound": "Cámara no encontrada.",
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
+ },
+ "loadingVideo": "Cargando video...",
+ "newRecording": {
+ "title": "Volver a la grabadora",
+ "description": "Tu sesión actual ha sido guardada.",
+ "cancel": "Cancelar",
+ "confirm": "Confirmar"
}
}
From 0f28cc0f3813863f9ce7b610080de86a755402eb Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sat, 2 May 2026 17:44:56 -0700
Subject: [PATCH 13/31] fix missing locales
---
.../video-editor/ShortcutsConfigDialog.tsx | 162 +++++++++---------
src/i18n/locales/fr/editor.json | 3 +-
src/i18n/locales/ja-JP/editor.json | 7 +-
src/i18n/locales/ko-KR/editor.json | 4 +-
src/i18n/locales/ko-KR/launch.json | 8 +-
src/i18n/locales/ko-KR/settings.json | 18 +-
src/i18n/locales/ko-KR/shortcuts.json | 3 +-
src/i18n/locales/ko-KR/timeline.json | 9 +-
src/i18n/locales/tr/editor.json | 11 +-
src/i18n/locales/tr/launch.json | 8 +-
src/i18n/locales/tr/settings.json | 17 +-
src/i18n/locales/zh-CN/settings.json | 9 +-
src/i18n/locales/zh-TW/editor.json | 4 +-
src/i18n/locales/zh-TW/launch.json | 8 +-
src/i18n/locales/zh-TW/settings.json | 9 +-
15 files changed, 181 insertions(+), 99 deletions(-)
diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx
index faa7513..6b9ef78 100644
--- a/src/components/video-editor/ShortcutsConfigDialog.tsx
+++ b/src/components/video-editor/ShortcutsConfigDialog.tsx
@@ -126,95 +126,99 @@ export function ShortcutsConfigDialog() {
if (!open) handleClose();
}}
>
-
-
+
+
{t("title")}
-
-
- {t("configurable")}
-
- {SHORTCUT_ACTIONS.map((action) => {
- const isCapturing = captureFor === action;
- const hasConflict = conflict?.forAction === action;
- return (
-
-
- {t(`actions.${action}`)}
- {
- setConflict(null);
- setCaptureFor(isCapturing ? null : action);
- }}
- title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
- className={[
- "px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
- isCapturing
- ? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
- : hasConflict
- ? "bg-amber-500/10 border-amber-500/50 text-amber-400"
- : "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
- ].join(" ")}
- >
- {isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
-
-
- {hasConflict && conflict?.conflictWith.type === "configurable" && (
-
-
- ⚠{" "}
- {t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })}
-
-
-
- {t("swap")}
-
-
- {tc("actions.cancel")}
-
-
+
+
+
+ {t("configurable")}
+
+ {SHORTCUT_ACTIONS.map((action) => {
+ const isCapturing = captureFor === action;
+ const hasConflict = conflict?.forAction === action;
+ return (
+
+
+ {t(`actions.${action}`)}
+ {
+ setConflict(null);
+ setCaptureFor(isCapturing ? null : action);
+ }}
+ title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
+ className={[
+ "px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
+ isCapturing
+ ? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
+ : hasConflict
+ ? "bg-amber-500/10 border-amber-500/50 text-amber-400"
+ : "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
+ ].join(" ")}
+ >
+ {isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
+
- )}
+ {hasConflict && conflict?.conflictWith.type === "configurable" && (
+
+
+ ⚠{" "}
+ {t("alreadyUsedBy", {
+ action: t(`actions.${conflict.conflictWith.action}`),
+ })}
+
+
+
+ {t("swap")}
+
+
+ {tc("actions.cancel")}
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+
+ {t("fixed")}
+
+ {FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
+
+
+ {t(`fixedActions.${i18nKey}`, { defaultValue: label })}
+
+
+ {display}
+
- );
- })}
+ ))}
+
+
+
{t("helpText")}
-
-
- {t("fixed")}
-
- {FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
-
-
- {t(`fixedActions.${i18nKey}`, { defaultValue: label })}
-
-
- {display}
-
-
- ))}
-
-
-
{t("helpText")}
-
-
+
Date: Sat, 2 May 2026 17:49:40 -0700
Subject: [PATCH 14/31] fix save prompt despite being saved
---
src/components/video-editor/VideoEditor.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 7adc558..a89c436 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -430,7 +430,7 @@ export default function VideoEditor() {
return false;
}
- const projectData = createProjectData(currentProjectMedia, {
+ const editorState = {
wallpaper,
shadowIntensity,
showBlur,
@@ -452,14 +452,17 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
- });
+ };
+ const projectData = createProjectData(currentProjectMedia, editorState);
const fileNameBase =
currentProjectMedia.screenVideoPath
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
- const projectSnapshot = JSON.stringify(projectData);
+ // Match the normalization path used by `currentProjectSnapshot` so the
+ // post-save baseline compares equal and `hasUnsavedChanges` clears.
+ const projectSnapshot = createProjectSnapshot(currentProjectMedia, editorState);
const result = await window.electronAPI.saveProjectFile(
projectData,
fileNameBase,
From c8d4e867b23452a8f184a5b3a7be3a1b2b7851a3 Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sat, 2 May 2026 17:53:43 -0700
Subject: [PATCH 15/31] fix recording inception error
---
electron/ipc/handlers.ts | 27 ++++++++++++++++++++-------
1 file changed, 20 insertions(+), 7 deletions(-)
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 95ed797..e067f59 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -526,14 +526,27 @@ export function registerIpcHandlers(
});
ipcMain.handle("get-sources", async (_, opts) => {
+ const ownWindowSourceIds = new Set(
+ BrowserWindow.getAllWindows()
+ .map((win) => {
+ try {
+ return win.getMediaSourceId();
+ } catch {
+ return null;
+ }
+ })
+ .filter((id): id is string => Boolean(id)),
+ );
const sources = await desktopCapturer.getSources(opts);
- return sources.map((source) => ({
- id: source.id,
- name: source.name,
- display_id: source.display_id,
- thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
- appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
- }));
+ return sources
+ .filter((source) => !ownWindowSourceIds.has(source.id))
+ .map((source) => ({
+ id: source.id,
+ name: source.name,
+ display_id: source.display_id,
+ thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
+ appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
+ }));
});
ipcMain.handle("select-source", (_, source: SelectedSource) => {
From e4eeff0ea34ed2bcf14396e52f21802f8744a34b Mon Sep 17 00:00:00 2001
From: hiroppelx <66677513+hiroppelx@users.noreply.github.com>
Date: Sun, 3 May 2026 11:03:20 +0900
Subject: [PATCH 16/31] =?UTF-8?q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E8=A8=B3?=
=?UTF-8?q?=E3=82=92=E6=94=B9=E5=96=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/i18n/locales/ja-JP/common.json | 4 +--
src/i18n/locales/ja-JP/dialogs.json | 24 ++++++++--------
src/i18n/locales/ja-JP/editor.json | 16 +++++------
src/i18n/locales/ja-JP/launch.json | 6 ++--
src/i18n/locales/ja-JP/settings.json | 43 ++++++++++++----------------
src/i18n/locales/ja-JP/timeline.json | 18 ++++++------
6 files changed, 52 insertions(+), 59 deletions(-)
diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json
index ee2205a..ee804f0 100644
--- a/src/i18n/locales/ja-JP/common.json
+++ b/src/i18n/locales/ja-JP/common.json
@@ -7,7 +7,7 @@
"share": "共有",
"done": "完了",
"open": "開く",
- "upload": "アップロード",
+ "upload": "読み込む",
"export": "エクスポート",
"showInFolder": "フォルダに表示",
"file": "ファイル",
@@ -15,7 +15,7 @@
"view": "表示",
"window": "ウィンドウ",
"quit": "終了",
- "stopRecording": "録画停止"
+ "stopRecording": "録画を停止"
},
"playback": {
"play": "再生",
diff --git a/src/i18n/locales/ja-JP/dialogs.json b/src/i18n/locales/ja-JP/dialogs.json
index 3c3fce5..a59cde7 100644
--- a/src/i18n/locales/ja-JP/dialogs.json
+++ b/src/i18n/locales/ja-JP/dialogs.json
@@ -1,22 +1,22 @@
{
"export": {
"complete": "エクスポート完了",
- "yourFormatReady": "あなたの{{format}}が準備できました",
+ "yourFormatReady": "{{format}}の準備ができました",
"showInFolder": "フォルダで表示",
- "finalizingVideo": "ビデオのエクスポートを最終処理中...",
- "compilingGifProgress": "GIFをコンパイル中... {{progress}}%",
- "compilingGifWait": "GIFをコンパイル中... しばらくお待ちください",
+ "finalizingVideo": "動画のエクスポートを仕上げています...",
+ "compilingGifProgress": "GIFを生成中... {{progress}}%",
+ "compilingGifWait": "GIFを生成中... しばらくお待ちください",
"takeMoment": "少々お待ちください...",
"failed": "エクスポートに失敗しました",
"tryAgain": "もう一度お試しください",
- "finalizingVideoTitle": "ビデオの最終処理",
- "compilingGif": "GIFをコンパイル中",
+ "finalizingVideoTitle": "動画の仕上げ",
+ "compilingGif": "GIFを生成中",
"exportingFormat": "{{format}}をエクスポート中",
- "compiling": "コンパイル中",
+ "compiling": "生成中",
"renderingFrames": "フレームをレンダリング中",
"processing": "処理中...",
"finalizing": "最終処理中...",
- "compilingStatus": "コンパイル中...",
+ "compilingStatus": "生成中...",
"status": "ステータス",
"format": "フォーマット",
"frames": "フレーム",
@@ -58,13 +58,13 @@
},
"fileDialogs": {
"saveGif": "エクスポートしたGIFを保存",
- "saveVideo": "エクスポートしたビデオを保存",
- "selectVideo": "ビデオファイルを選択",
+ "saveVideo": "エクスポートした動画を保存",
+ "selectVideo": "動画ファイルを選択",
"saveProject": "OpenScreen プロジェクトを保存",
"openProject": "OpenScreen プロジェクトを開く",
"gifImage": "GIF 画像",
- "mp4Video": "MP4 ビデオ",
- "videoFiles": "ビデオファイル",
+ "mp4Video": "MP4 動画",
+ "videoFiles": "動画ファイル",
"openscreenProject": "OpenScreen プロジェクト",
"allFiles": "すべてのファイル"
}
diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json
index 401dbc7..051335f 100644
--- a/src/i18n/locales/ja-JP/editor.json
+++ b/src/i18n/locales/ja-JP/editor.json
@@ -5,18 +5,18 @@
"cancel": "キャンセル",
"confirm": "確認"
},
- "loadingVideo": "ビデオを読み込み中...",
+ "loadingVideo": "動画を読み込み中...",
"errors": {
- "noVideoLoaded": "ビデオが読み込まれていません",
- "videoNotReady": "ビデオが準備できていません",
- "unableToDetermineSourcePath": "ソースビデオのパスを特定できません",
+ "noVideoLoaded": "動画が読み込まれていません",
+ "videoNotReady": "動画の準備ができていません",
+ "unableToDetermineSourcePath": "元動画のパスを特定できません",
"failedToSaveGif": "GIFの保存に失敗しました",
"gifExportFailed": "GIFのエクスポートに失敗しました",
- "failedToSaveVideo": "ビデオの保存に失敗しました",
+ "failedToSaveVideo": "動画の保存に失敗しました",
"exportFailed": "エクスポートに失敗しました",
"exportFailedWithError": "エクスポートに失敗しました: {{error}}",
"failedToSaveExport": "エクスポートの保存に失敗しました",
- "failedToSaveExportedVideo": "エクスポートしたビデオの保存に失敗しました",
+ "failedToSaveExportedVideo": "エクスポートした動画の保存に失敗しました",
"failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}",
"exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})"
},
@@ -35,8 +35,8 @@
"recording": {
"failedCameraAccess": "カメラのアクセス要求に失敗しました。",
"cameraBlocked": "カメラのアクセスがブロックされています。システム設定で有効にして、ウェブカメラを使用してください。",
- "systemAudioUnavailable": "システムオーディオが利用できません。システムオーディオなしで録画します。",
- "microphoneDenied": "マイクのアクセスが拒否されました。オーディオなしで録画を続行します。",
+ "systemAudioUnavailable": "システム音声を利用できません。システム音声なしで録画します。",
+ "microphoneDenied": "マイクへのアクセスが拒否されました。音声なしで録画を続行します。",
"cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。",
"permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。",
"cameraDisconnected": "ウェブカメラが切断されました。",
diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json
index 4504b00..51e3833 100644
--- a/src/i18n/locales/ja-JP/launch.json
+++ b/src/i18n/locales/ja-JP/launch.json
@@ -6,12 +6,12 @@
"cancelRecording": "録画をキャンセル",
"pauseRecording": "録画を一時停止",
"resumeRecording": "録画を再開",
- "openVideoFile": "ビデオファイルを開く",
+ "openVideoFile": "動画ファイルを開く",
"openProject": "プロジェクトを開く"
},
"audio": {
- "enableSystemAudio": "システムオーディオを有効にする",
- "disableSystemAudio": "システムオーディオを無効にする",
+ "enableSystemAudio": "システム音声を有効にする",
+ "disableSystemAudio": "システム音声を無効にする",
"enableMicrophone": "マイクを有効にする",
"disableMicrophone": "マイクを無効にする",
"defaultMicrophone": "デフォルトのマイク"
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 129217c..800d078 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -7,20 +7,13 @@
"title": "フォーカスモード",
"manual": "手動",
"auto": "自動",
- "autoDescription": "カメラが録画中のカーソル位置に追従します"
- },
- "speed": {
- "title": "ズーム速度",
- "instant": "即時",
- "fast": "高速",
- "smooth": "滑らか",
- "lazy": "遅延"
+ "autoDescription": "表示範囲が録画中のカーソル位置に追従します"
}
},
"speed": {
"playbackSpeed": "再生速度",
- "selectRegion": "速度範囲を選択して調整",
- "deleteRegion": "速度範囲を削除",
+ "selectRegion": "再生速度の範囲を選択して調整",
+ "deleteRegion": "再生速度の範囲を削除",
"customPlaybackSpeed": "カスタム再生速度",
"maxSpeedError": "速度は16×を超えることはできません"
},
@@ -31,14 +24,14 @@
"title": "レイアウト",
"preset": "プリセット",
"selectPreset": "プリセットを選択",
- "pictureInPicture": "ピクチャーインピクチャー",
- "verticalStack": "縦積み",
+ "pictureInPicture": "ピクチャーインピクチャ",
+ "verticalStack": "縦並び",
"dualFrame": "デュアルフレーム",
"webcamShape": "カメラの形状",
"webcamSize": "カメラのサイズ"
},
"effects": {
- "title": "ビデオ効果",
+ "title": "動画効果",
"blurBg": "背景をぼかす",
"motionBlur": "モーションブラー",
"off": "オフ",
@@ -51,14 +44,14 @@
"image": "画像",
"color": "色",
"gradient": "グラデーション",
- "uploadCustom": "カスタムをアップロード",
+ "uploadCustom": "カスタム画像を読み込む",
"gradientLabel": "グラデーション {{index}}",
"colorWheel": "カラーホイール",
"colorPalette": "カラーパレット"
},
"crop": {
"title": "クロップ",
- "cropVideo": "ビデオをクロップ",
+ "cropVideo": "動画をクロップ",
"dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
"ratio": "比率",
"free": "自由",
@@ -69,8 +62,8 @@
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
- "mp4Video": "MP4 ビデオ",
- "mp4Description": "高品質のビデオファイル",
+ "mp4Video": "MP4 動画",
+ "mp4Description": "高品質の動画ファイル",
"gifAnimation": "GIF アニメーション",
"gifDescription": "共有用のアニメーション画像"
},
@@ -90,7 +83,7 @@
"load": "プロジェクトを読み込む"
},
"export": {
- "videoButton": "ビデオをエクスポート",
+ "videoButton": "動画をエクスポート",
"gifButton": "GIF をエクスポート",
"chooseSaveLocation": "保存場所を選択"
},
@@ -100,9 +93,9 @@
},
"imageUpload": {
"invalidFileType": "無効なファイル形式",
- "jpgOnly": "JPG または JPEG 画像ファイルをアップロードしてください。",
- "uploadSuccess": "カスタム画像が正常にアップロードされました!",
- "failedToUpload": "画像のアップロードに失敗しました",
+ "jpgOnly": "JPG または JPEG 画像ファイルを選択してください。",
+ "uploadSuccess": "カスタム画像を読み込みました。",
+ "failedToUpload": "画像の読み込みに失敗しました",
"errorReading": "ファイルの読み取り中にエラーが発生しました。"
},
"annotation": {
@@ -125,7 +118,7 @@
"colorWheel": "カラーホイール",
"colorPalette": "カラーパレット",
"clearBackground": "背景をクリア",
- "uploadImage": "画像をアップロード",
+ "uploadImage": "画像を読み込む",
"supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
"arrowDirection": "矢印の方向",
"strokeWidth": "線の太さ: {{width}}px",
@@ -148,9 +141,9 @@
"tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
"tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
"invalidImageType": "無効なファイル形式",
- "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルをアップロードしてください。",
- "imageUploadSuccess": "画像が正常にアップロードされました!",
- "failedImageUpload": "画像のアップロードに失敗しました"
+ "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
+ "imageUploadSuccess": "画像を読み込みました。",
+ "failedImageUpload": "画像の読み込みに失敗しました"
},
"fontStyles": {
"classic": "クラシック",
diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json
index e0507f6..ec9472a 100644
--- a/src/i18n/locales/ja-JP/timeline.json
+++ b/src/i18n/locales/ja-JP/timeline.json
@@ -5,23 +5,23 @@
"addTrim": "トリムを追加 (T)",
"addAnnotation": "注釈を追加 (A)",
"addBlur": "ぼかしを追加 (B)",
- "addSpeed": "速度を追加 (S)"
+ "addSpeed": "再生速度を追加 (S)"
},
"hints": {
"pressZoom": "Zキーを押してズームを追加",
"pressTrim": "Tキーを押してトリムを追加",
"pressAnnotation": "Aキーを押して注釈を追加",
"pressBlur": "Bキーを押してぼかしを追加",
- "pressSpeed": "Sキーを押して速度を追加"
+ "pressSpeed": "Sキーを押して再生速度を追加"
},
"labels": {
"pan": "移動",
"zoom": "ズーム",
"trim": "トリム",
- "speed": "速度",
+ "speed": "再生速度",
"zoomItem": "ズーム {{index}}",
"trimItem": "トリム {{index}}",
- "speedItem": "速度 {{index}}",
+ "speedItem": "再生速度 {{index}}",
"annotationItem": "注釈",
"blurItem": "ぼかし {{index}}",
"imageItem": "画像",
@@ -36,17 +36,17 @@
"zoomExistsAtLocation": "この場所にはすでにズームが存在するか、十分なスペースがありません。",
"zoomSuggestionUnavailable": "ズームの自動提案機能が利用できません",
"noCursorTelemetry": "カーソルの動きが記録されていません",
- "noCursorTelemetryDescription": "まず画面収録を行い、カーソルに基づく提案を生成してください。",
+ "noCursorTelemetryDescription": "まず画面録画を行い、カーソルに基づく提案を生成してください。",
"noUsableTelemetry": "使用可能なカーソルの動きデータがありません",
"noUsableTelemetryDescription": "録画には十分なカーソルの動きデータが含まれていません。",
"noDwellMoments": "カーソルが静止したポイントが見つかりません",
"noDwellMomentsDescription": "強調したい操作の際に、カーソルを一時停止させて録画してみてください。",
"noAutoZoomSlots": "自動ズームを適用できる箇所がありません",
"noAutoZoomSlotsDescription": "検出された滞留ポイントが既存のズーム領域と重なっています。",
- "cannotPlaceTrim": "ここに切り取りを配置できません",
- "trimExistsAtLocation": "この場所にはすでに切り取りが存在するか、十分なスペースがありません。",
- "cannotPlaceSpeed": "ここに速度を配置できません",
- "speedExistsAtLocation": "この場所にはすでに速度が存在するか、十分なスペースがありません。"
+ "cannotPlaceTrim": "ここにトリムを配置できません",
+ "trimExistsAtLocation": "この場所にはすでにトリムが存在するか、十分なスペースがありません。",
+ "cannotPlaceSpeed": "ここに再生速度を配置できません",
+ "speedExistsAtLocation": "この場所にはすでに再生速度の範囲が存在するか、十分なスペースがありません。"
},
"success": {
"addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました",
From 8d79a14e3bba0a195df71ec57cd5c6540a06c04c Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sat, 2 May 2026 23:03:14 -0700
Subject: [PATCH 17/31] cursor highlighting and clicks
---
electron-builder.json5 | 5 +
electron/electron-env.d.ts | 6 +
electron/ipc/handlers.ts | 145 ++-
electron/preload.ts | 3 +
package-lock.json | 1054 ++---------------
package.json | 6 +-
src/components/video-editor/SettingsPanel.tsx | 186 +++
src/components/video-editor/VideoEditor.tsx | 24 +
src/components/video-editor/VideoPlayback.tsx | 74 +-
.../video-editor/projectPersistence.ts | 47 +
.../videoPlayback/cursorHighlight.ts | 125 ++
src/hooks/useEditorHistory.ts | 6 +
src/lib/exporter/frameRenderer.ts | 48 +
src/lib/exporter/gifExporter.ts | 4 +
src/lib/exporter/videoExporter.ts | 4 +
15 files changed, 769 insertions(+), 968 deletions(-)
create mode 100644 src/components/video-editor/videoPlayback/cursorHighlight.ts
diff --git a/electron-builder.json5 b/electron-builder.json5
index ca053ef..1770238 100644
--- a/electron-builder.json5
+++ b/electron-builder.json5
@@ -3,6 +3,11 @@
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.siddharthvaddem.openscreen",
"asar": true,
+ // .node binaries can't be dlopen'd from inside an asar — must live unpacked.
+ "asarUnpack": [
+ "node_modules/uiohook-napi/**/*",
+ "**/*.node"
+ ],
"productName": "Openscreen",
"npmRebuild": true,
"buildDependenciesFromSource": true,
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 85d8294..d9ebab2 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -37,6 +37,11 @@ interface Window {
status: string;
error?: string;
}>;
+ requestAccessibilityAccess: () => Promise<{
+ success: boolean;
+ granted: boolean;
+ error?: string;
+ }>;
assetBaseUrl: string;
storeRecordedVideo: (
videoData: ArrayBuffer,
@@ -68,6 +73,7 @@ interface Window {
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
+ clicks: number[];
message?: string;
error?: string;
}>;
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index e067f59..30fbf23 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -1,6 +1,10 @@
import fs from "node:fs/promises";
+import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
+
+const nodeRequire = createRequire(import.meta.url);
+
import {
app,
BrowserWindow,
@@ -280,19 +284,24 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
const telemetryPath = `${screenVideoPath}.cursor.json`;
const pendingBatch = cursorTelemetryBuffer.takeNextBatch();
- if (pendingBatch && pendingBatch.samples.length > 0) {
+ const pendingClicks = takeCursorClickTimestamps();
+ if ((pendingBatch && pendingBatch.samples.length > 0) || pendingClicks.length > 0) {
try {
await fs.writeFile(
telemetryPath,
JSON.stringify(
- { version: CURSOR_TELEMETRY_VERSION, samples: pendingBatch.samples },
+ {
+ version: CURSOR_TELEMETRY_VERSION,
+ samples: pendingBatch?.samples ?? [],
+ clicks: pendingClicks,
+ },
null,
2,
),
"utf-8",
);
} catch (err) {
- cursorTelemetryBuffer.prependBatch(pendingBatch);
+ if (pendingBatch) cursorTelemetryBuffer.prependBatch(pendingBatch);
throw err;
}
}
@@ -321,15 +330,114 @@ const cursorTelemetryBuffer = createCursorTelemetryBuffer({
maxActiveSamples: MAX_CURSOR_SAMPLES,
});
+// Mouse click timestamps (macOS only — uiohook-napi behind Accessibility).
+const MAX_CURSOR_CLICKS = 60 * 60 * 60; // ~1 click/sec for an hour
+let cursorClickTimestampsMs: number[] = [];
+let uioHookInstance: {
+ start: () => void;
+ stop: () => void;
+ on: (...a: unknown[]) => void;
+ off?: (...a: unknown[]) => void;
+ removeListener?: (...a: unknown[]) => void;
+} | null = null;
+let uioHookMouseDownHandler: ((event: { time?: number }) => void) | null = null;
+let uioHookFailureLogged = false;
+
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
+function loadUioHookForClicks(): typeof uioHookInstance {
+ try {
+ // Dynamic require + try/catch so a broken native binary doesn't crash startup.
+ const mod = nodeRequire("uiohook-napi");
+ const candidate = mod.uIOhook ?? mod.default?.uIOhook ?? mod.uiohook ?? mod.default;
+ if (candidate && typeof candidate.start === "function" && typeof candidate.on === "function") {
+ return candidate;
+ }
+ return null;
+ } catch (error) {
+ if (!uioHookFailureLogged) {
+ uioHookFailureLogged = true;
+ console.warn("[clickCapture] uiohook-napi unavailable:", error);
+ }
+ return null;
+ }
+}
+
+function startClickCapture() {
+ if (process.platform !== "darwin") return;
+ if (uioHookInstance) return;
+
+ // Passive check — the prompt fires from the renderer when the user toggles
+ // "Only on clicks" so it doesn't stack with the screen-recording prompt.
+ try {
+ if (!systemPreferences.isTrustedAccessibilityClient(false)) {
+ if (!uioHookFailureLogged) {
+ uioHookFailureLogged = true;
+ console.warn(
+ "[clickCapture] Accessibility permission not granted — click capture disabled.",
+ );
+ }
+ return;
+ }
+ } catch {
+ // fall through; uiohook will fail defensively below
+ }
+
+ const hook = loadUioHookForClicks();
+ if (!hook) return;
+
+ uioHookMouseDownHandler = (event) => {
+ const elapsed = Math.max(0, Date.now() - cursorCaptureStartTimeMs);
+ void event;
+ if (cursorClickTimestampsMs.length >= MAX_CURSOR_CLICKS) return;
+ cursorClickTimestampsMs.push(elapsed);
+ };
+
+ try {
+ hook.on("mousedown", uioHookMouseDownHandler);
+ hook.start();
+ uioHookInstance = hook;
+ } catch (error) {
+ if (!uioHookFailureLogged) {
+ uioHookFailureLogged = true;
+ console.warn("[clickCapture] failed to start uiohook:", error);
+ }
+ uioHookMouseDownHandler = null;
+ }
+}
+
+function stopClickCapture() {
+ if (!uioHookInstance) return;
+ try {
+ if (uioHookMouseDownHandler) {
+ if (typeof uioHookInstance.off === "function") {
+ uioHookInstance.off("mousedown", uioHookMouseDownHandler);
+ } else if (typeof uioHookInstance.removeListener === "function") {
+ uioHookInstance.removeListener("mousedown", uioHookMouseDownHandler);
+ }
+ }
+ uioHookInstance.stop();
+ } catch (error) {
+ console.warn("[clickCapture] failed to stop uiohook:", error);
+ }
+ uioHookInstance = null;
+ uioHookMouseDownHandler = null;
+}
+
+function takeCursorClickTimestamps(): number[] {
+ const out = cursorClickTimestampsMs;
+ cursorClickTimestampsMs = [];
+ return out;
+}
+
function stopCursorCapture() {
if (cursorCaptureInterval) {
clearInterval(cursorCaptureInterval);
cursorCaptureInterval = null;
}
+ stopClickCapture();
}
function sampleCursorPoint() {
@@ -594,6 +702,22 @@ export function registerIpcHandlers(
}
});
+ // macOS Accessibility prompt for global click capture. First call shows the
+ // system dialog; the user has to toggle the app in System Settings (no
+ // programmatic grant exists for Accessibility).
+ ipcMain.handle("request-accessibility-access", () => {
+ if (process.platform !== "darwin") {
+ return { success: true, granted: true };
+ }
+ try {
+ const granted = systemPreferences.isTrustedAccessibilityClient(true);
+ return { success: true, granted };
+ } catch (error) {
+ console.error("Failed to request accessibility access:", error);
+ return { success: false, granted: false, error: String(error) };
+ }
+ });
+
ipcMain.handle("open-source-selector", () => {
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
@@ -723,6 +847,8 @@ export function registerIpcHandlers(
const id = typeof recordingId === "number" ? recordingId : Date.now();
cursorTelemetryBuffer.startSession(id);
cursorCaptureStartTimeMs = Date.now();
+ cursorClickTimestampsMs = [];
+ startClickCapture();
sampleCursorPoint();
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
} else {
@@ -787,11 +913,19 @@ export function registerIpcHandlers(
})
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs);
- return { success: true, samples };
+ const rawClicks = Array.isArray(parsed?.clicks) ? parsed.clicks : [];
+ const clicks: number[] = rawClicks
+ .map((value: unknown) =>
+ typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null,
+ )
+ .filter((v: number | null): v is number => v !== null)
+ .sort((a: number, b: number) => a - b);
+
+ return { success: true, samples, clicks };
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
- return { success: true, samples: [] };
+ return { success: true, samples: [], clicks: [] };
}
console.error("Failed to load cursor telemetry:", error);
return {
@@ -799,6 +933,7 @@ export function registerIpcHandlers(
message: "Failed to load cursor telemetry",
error: String(error),
samples: [],
+ clicks: [],
};
}
});
diff --git a/electron/preload.ts b/electron/preload.ts
index 46e16f0..6c705d7 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -40,6 +40,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
requestCameraAccess: () => {
return ipcRenderer.invoke("request-camera-access");
},
+ requestAccessibilityAccess: () => {
+ return ipcRenderer.invoke("request-accessibility-access");
+ },
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
diff --git a/package-lock.json b/package-lock.json
index a449101..e823ad1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -47,11 +47,13 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
+ "uiohook-napi": "^1.5.5",
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.12",
+ "@electron/rebuild": "^4.0.4",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -1078,25 +1080,18 @@
}
},
"node_modules/@electron/rebuild": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz",
- "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz",
+ "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@malept/cross-spawn-promise": "^2.0.0",
"debug": "^4.1.1",
- "detect-libc": "^2.0.1",
- "got": "^11.7.0",
- "graceful-fs": "^4.2.11",
"node-abi": "^4.2.0",
"node-api-version": "^0.2.1",
- "node-gyp": "^11.2.0",
- "ora": "^5.1.0",
- "read-binary-file-arch": "^1.0.6",
- "semver": "^7.3.5",
- "tar": "^7.5.6",
- "yargs": "^17.0.1"
+ "node-gyp": "^12.2.0",
+ "read-binary-file-arch": "^1.0.6"
},
"bin": {
"electron-rebuild": "lib/cli.js"
@@ -1105,19 +1100,6 @@
"node": ">=22.12.0"
}
},
- "node_modules/@electron/rebuild/node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/@electron/universal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz",
@@ -1791,80 +1773,6 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
- "node_modules/@isaacs/cliui": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
- "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "string-width": "^5.1.2",
- "string-width-cjs": "npm:string-width@^4.2.0",
- "strip-ansi": "^7.0.1",
- "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
- "wrap-ansi": "^8.1.0",
- "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
- "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@isaacs/cliui/node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2048,56 +1956,6 @@
"node": ">= 8"
}
},
- "node_modules/@npmcli/agent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz",
- "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "agent-base": "^7.1.0",
- "http-proxy-agent": "^7.0.0",
- "https-proxy-agent": "^7.0.1",
- "lru-cache": "^10.0.1",
- "socks-proxy-agent": "^8.0.3"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
- "node_modules/@npmcli/agent/node_modules/lru-cache": {
- "version": "10.4.3",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
- "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/@npmcli/fs": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz",
- "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "semver": "^7.3.5"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
- "node_modules/@npmcli/fs/node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/@pixi/color": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
@@ -2224,17 +2082,6 @@
"url": "^0.11.0"
}
},
- "node_modules/@pkgjs/parseargs": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=14"
- }
- },
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
@@ -4464,13 +4311,13 @@
"license": "MIT"
},
"node_modules/abbrev": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
- "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
+ "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==",
"dev": true,
"license": "ISC",
"engines": {
- "node": "^18.17.0 || >=20.5.0"
+ "node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/acorn": {
@@ -4973,18 +4820,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/bl": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "buffer": "^5.5.0",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -5073,6 +4908,7 @@
}
],
"license": "MIT",
+ "optional": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
@@ -5172,92 +5008,6 @@
"node": ">= 10.0.0"
}
},
- "node_modules/cacache": {
- "version": "19.0.1",
- "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
- "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "@npmcli/fs": "^4.0.0",
- "fs-minipass": "^3.0.0",
- "glob": "^10.2.2",
- "lru-cache": "^10.0.1",
- "minipass": "^7.0.3",
- "minipass-collect": "^2.0.1",
- "minipass-flush": "^1.0.5",
- "minipass-pipeline": "^1.2.4",
- "p-map": "^7.0.2",
- "ssri": "^12.0.0",
- "tar": "^7.4.3",
- "unique-filename": "^4.0.0"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
- "node_modules/cacache/node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/cacache/node_modules/brace-expansion": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
- "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/cacache/node_modules/glob": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
- "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
- "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
- },
- "bin": {
- "glob": "dist/esm/bin.mjs"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/cacache/node_modules/lru-cache": {
- "version": "10.4.3",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
- "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/cacache/node_modules/minimatch": {
- "version": "9.0.9",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
- "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.2"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
@@ -5471,19 +5221,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/cli-spinners": {
- "version": "2.9.2",
- "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
- "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/cli-truncate": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
@@ -5548,16 +5285,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
- "node_modules/clone": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
- "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8"
- }
- },
"node_modules/clone-response": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
@@ -5828,19 +5555,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/defaults": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
- "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "clone": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -5909,16 +5623,6 @@
"node": ">=6"
}
},
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -6138,13 +5842,6 @@
"license": "ISC",
"peer": true
},
- "node_modules/eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -6396,17 +6093,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/encoding": {
- "version": "0.1.13",
- "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
- "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "iconv-lite": "^0.6.2"
- }
- },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -6818,36 +6504,6 @@
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
"license": "MIT"
},
- "node_modules/foreground-child": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
- "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "cross-spawn": "^7.0.6",
- "signal-exit": "^4.0.1"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/foreground-child/node_modules/signal-exit": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -6921,19 +6577,6 @@
"node": ">=6 <7 || >=8"
}
},
- "node_modules/fs-minipass": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
- "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "minipass": "^7.0.3"
- },
- "engines": {
- "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
- }
- },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -7466,17 +7109,8 @@
"url": "https://feross.org/support"
}
],
- "license": "BSD-3-Clause"
- },
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
- "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
+ "license": "BSD-3-Clause",
+ "optional": true
},
"node_modules/indent-string": {
"version": "4.0.0",
@@ -7507,16 +7141,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/ip-address": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
- "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 12"
- }
- },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -7575,16 +7199,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-interactive": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
- "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -7601,19 +7215,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/is-unicode-supported": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
- "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/isbinaryfile": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
@@ -7643,22 +7244,6 @@
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
"license": "MIT"
},
- "node_modules/jackspeak": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
- "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "@isaacs/cliui": "^8.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- },
- "optionalDependencies": {
- "@pkgjs/parseargs": "^0.11.0"
- }
- },
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
@@ -8002,23 +7587,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/log-symbols": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
- "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chalk": "^4.1.0",
- "is-unicode-supported": "^0.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
@@ -8147,29 +7715,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/make-fetch-happen": {
- "version": "14.0.3",
- "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz",
- "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "@npmcli/agent": "^3.0.0",
- "cacache": "^19.0.1",
- "http-cache-semantics": "^4.1.1",
- "minipass": "^7.0.2",
- "minipass-fetch": "^4.0.0",
- "minipass-flush": "^1.0.5",
- "minipass-pipeline": "^1.2.4",
- "negotiator": "^1.0.0",
- "proc-log": "^5.0.0",
- "promise-retry": "^2.0.1",
- "ssri": "^12.0.0"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@@ -8287,16 +7832,6 @@
"node": ">= 0.6"
}
},
- "node_modules/mimic-fn": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
- "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
@@ -8366,136 +7901,6 @@
"node": ">=16 || 14 >=14.17"
}
},
- "node_modules/minipass-collect": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz",
- "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "minipass": "^7.0.3"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
- "node_modules/minipass-fetch": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz",
- "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "minipass": "^7.0.3",
- "minipass-sized": "^1.0.3",
- "minizlib": "^3.0.1"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- },
- "optionalDependencies": {
- "encoding": "^0.1.13"
- }
- },
- "node_modules/minipass-flush": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz",
- "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "minipass": "^3.0.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/minipass-flush/node_modules/minipass": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
- "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/minipass-flush/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/minipass-pipeline": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
- "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "minipass": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/minipass-pipeline/node_modules/minipass": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
- "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/minipass-pipeline/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/minipass-sized": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz",
- "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "minipass": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/minipass-sized/node_modules/minipass": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
- "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/minipass-sized/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
@@ -8619,16 +8024,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/negotiator": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
- "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/node-abi": {
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz",
@@ -8687,28 +8082,49 @@
}
},
"node_modules/node-gyp": {
- "version": "11.5.0",
- "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz",
- "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==",
+ "version": "12.3.0",
+ "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz",
+ "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
- "make-fetch-happen": "^14.0.3",
- "nopt": "^8.0.0",
- "proc-log": "^5.0.0",
+ "nopt": "^9.0.0",
+ "proc-log": "^6.0.0",
"semver": "^7.3.5",
- "tar": "^7.4.3",
+ "tar": "^7.5.4",
"tinyglobby": "^0.2.12",
- "which": "^5.0.0"
+ "undici": "^6.25.0",
+ "which": "^6.0.0"
},
"bin": {
"node-gyp": "bin/node-gyp.js"
},
"engines": {
- "node": "^18.17.0 || >=20.5.0"
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
+ "node_modules/node-gyp/node_modules/isexe": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
+ "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=20"
}
},
"node_modules/node-gyp/node_modules/semver": {
@@ -8724,6 +8140,32 @@
"node": ">=10"
}
},
+ "node_modules/node-gyp/node_modules/undici": {
+ "version": "6.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
+ "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/node-gyp/node_modules/which": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz",
+ "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^4.0.0"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
@@ -8732,19 +8174,19 @@
"license": "MIT"
},
"node_modules/nopt": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
- "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
+ "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==",
"dev": true,
"license": "ISC",
"dependencies": {
- "abbrev": "^3.0.0"
+ "abbrev": "^4.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
- "node": "^18.17.0 || >=20.5.0"
+ "node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/normalize-path": {
@@ -8848,86 +8290,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/ora": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
- "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "bl": "^4.1.0",
- "chalk": "^4.1.0",
- "cli-cursor": "^3.1.0",
- "cli-spinners": "^2.5.0",
- "is-interactive": "^1.0.0",
- "is-unicode-supported": "^0.1.0",
- "log-symbols": "^4.1.0",
- "strip-ansi": "^6.0.0",
- "wcwidth": "^1.0.1"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/ora/node_modules/cli-cursor": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
- "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "restore-cursor": "^3.1.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ora/node_modules/onetime": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
- "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mimic-fn": "^2.1.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/ora/node_modules/restore-cursor": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
- "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "onetime": "^5.1.0",
- "signal-exit": "^3.0.2"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ora/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -8954,26 +8316,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/p-map": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
- "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/package-json-from-dist": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
- "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
- "dev": true,
- "license": "BlueOak-1.0.0"
- },
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
@@ -9019,30 +8361,6 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
- "node_modules/path-scurry": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
- "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "lru-cache": "^10.2.0",
- "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
- },
- "engines": {
- "node": ">=16 || 14 >=14.18"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/path-scurry/node_modules/lru-cache": {
- "version": "10.4.3",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
- "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -9438,13 +8756,13 @@
}
},
"node_modules/proc-log": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
- "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==",
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
+ "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
"dev": true,
"license": "ISC",
"engines": {
- "node": "^18.17.0 || >=20.5.0"
+ "node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/progress": {
@@ -9785,21 +9103,6 @@
"pify": "^2.3.0"
}
},
- "node_modules/readable-stream": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -10082,27 +9385,6 @@
"queue-microtask": "^1.2.2"
}
},
- "node_modules/safe-buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -10363,41 +9645,12 @@
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
- "node_modules/socks": {
- "version": "2.8.7",
- "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
- "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ip-address": "^10.0.1",
- "smart-buffer": "^4.2.0"
- },
- "engines": {
- "node": ">= 10.0.0",
- "npm": ">= 3.0.0"
- }
- },
- "node_modules/socks-proxy-agent": {
- "version": "8.0.5",
- "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
- "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "agent-base": "^7.1.2",
- "debug": "^4.3.4",
- "socks": "^2.8.3"
- },
- "engines": {
- "node": ">= 14"
- }
- },
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@@ -10446,19 +9699,6 @@
"license": "BSD-3-Clause",
"optional": true
},
- "node_modules/ssri": {
- "version": "12.0.0",
- "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
- "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "minipass": "^7.0.3"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -10483,16 +9723,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/string_decoder": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "safe-buffer": "~5.2.0"
- }
- },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
@@ -10518,35 +9748,6 @@
"node": ">=8"
}
},
- "node_modules/string-width-cjs": {
- "name": "string-width",
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width-cjs/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/string-width/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -10576,20 +9777,6 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
- "node_modules/strip-ansi-cjs": {
- "name": "strip-ansi",
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-ansi/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -11105,6 +10292,19 @@
"node": ">=14.17"
}
},
+ "node_modules/uiohook-napi": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.5.tgz",
+ "integrity": "sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
@@ -11122,32 +10322,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/unique-filename": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz",
- "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "unique-slug": "^5.0.0"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
- "node_modules/unique-slug": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz",
- "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "imurmurhash": "^0.1.4"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -11520,16 +10694,6 @@
"node": ">=18"
}
},
- "node_modules/wcwidth": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
- "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "defaults": "^1.0.3"
- }
- },
"node_modules/web-demuxer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/web-demuxer/-/web-demuxer-4.0.0.tgz",
@@ -11625,38 +10789,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
- "node_modules/wrap-ansi-cjs": {
- "name": "wrap-ansi",
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
diff --git a/package.json b/package.json
index 102e97c..2709d3e 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,9 @@
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
"test:e2e": "playwright test",
- "prepare": "husky"
+ "prepare": "husky",
+ "rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi",
+ "postinstall": "npm run rebuild:native"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
@@ -71,11 +73,13 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
+ "uiohook-napi": "^1.5.5",
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.12",
+ "@electron/rebuild": "^4.0.4",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 82e106c..5cac573 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1,5 +1,6 @@
import {
Bug,
+ ChevronDown,
Crop,
Download,
Film,
@@ -22,6 +23,7 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -151,6 +153,12 @@ const GRADIENTS = [
];
interface SettingsPanelProps {
+ cursorHighlight?: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
+ onCursorHighlightChange?: (
+ next: import("./videoPlayback/cursorHighlight").CursorHighlightConfig,
+ ) => void;
+ // macOS only — gates the "Only on clicks" toggle (needs uiohook).
+ cursorHighlightSupportsClicks?: boolean;
selected: string;
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
@@ -238,6 +246,9 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
];
export function SettingsPanel({
+ cursorHighlight,
+ onCursorHighlightChange,
+ cursorHighlightSupportsClicks = false,
selected,
onWallpaperChange,
selectedZoomDepth,
@@ -991,6 +1002,181 @@ export function SettingsPanel({
+ {cursorHighlight && onCursorHighlightChange && (
+
+
+
Cursor highlight
+
+ onCursorHighlightChange({
+ ...cursorHighlight,
+ enabled: !cursorHighlight.enabled,
+ })
+ }
+ className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
+ cursorHighlight.enabled
+ ? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
+ : "bg-white/5 border-white/10 text-slate-400"
+ }`}
+ >
+ {cursorHighlight.enabled ? "On" : "Off"}
+
+
+
+ {(["dot", "ring"] as const).map((style) => (
+ onCursorHighlightChange({ ...cursorHighlight, style })}
+ className={`text-[10px] px-2 py-1 rounded border capitalize transition-colors ${
+ cursorHighlight.style === style
+ ? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
+ : "bg-white/5 border-white/10 text-slate-300 hover:border-white/20"
+ }`}
+ >
+ {style}
+
+ ))}
+
+
+
+
Size
+
+ {cursorHighlight.sizePx}px
+
+
+
+ onCursorHighlightChange({ ...cursorHighlight, sizePx: values[0] })
+ }
+ min={10}
+ max={36}
+ step={1}
+ className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+ {cursorHighlightSupportsClicks && (
+
+
Only on clicks
+
{
+ const turningOn = !cursorHighlight.onlyOnClicks;
+ if (turningOn) {
+ try {
+ const result = await window.electronAPI.requestAccessibilityAccess();
+ if (!result.granted) {
+ toast.message("Accessibility permission needed", {
+ description:
+ "Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app.",
+ });
+ }
+ } catch (err) {
+ console.warn("Accessibility request failed:", err);
+ }
+ }
+ onCursorHighlightChange({
+ ...cursorHighlight,
+ onlyOnClicks: turningOn,
+ });
+ }}
+ className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
+ cursorHighlight.onlyOnClicks
+ ? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
+ : "bg-white/5 border-white/10 text-slate-400"
+ }`}
+ >
+ {cursorHighlight.onlyOnClicks ? "On" : "Off"}
+
+
+ )}
+
+
Color
+
+
+
+
+
+ {cursorHighlight.color}
+
+
+
+
+
+
+ onCursorHighlightChange({ ...cursorHighlight, color })
+ }
+ />
+
+
+
+
+
+
Offset X (window recordings)
+
+ {(cursorHighlight.offsetXNorm * 100).toFixed(1)}%
+
+
+
+ onCursorHighlightChange({
+ ...cursorHighlight,
+ offsetXNorm: values[0],
+ })
+ }
+ min={-0.25}
+ max={0.25}
+ step={0.005}
+ className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+
+
+
Offset Y
+
+ {(cursorHighlight.offsetYNorm * 100).toFixed(1)}%
+
+
+
+ onCursorHighlightChange({
+ ...cursorHighlight,
+ offsetYNorm: values[0],
+ })
+ }
+ min={-0.25}
+ max={0.25}
+ step={0.005}
+ className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+
+ )}
+
([]);
+ const [cursorClickTimestamps, setCursorClickTimestamps] = useState([]);
const [selectedZoomId, setSelectedZoomId] = useState(null);
const [selectedTrimId, setSelectedTrimId] = useState(null);
const [selectedSpeedId, setSelectedSpeedId] = useState(null);
@@ -153,6 +155,12 @@ export default function VideoEditor() {
const nextSpeedIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
+ // Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for
+ // renderers while keeping the persisted value intact for round-tripping.
+ const effectiveCursorHighlight = useMemo(
+ () => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }),
+ [cursorHighlight, isMac],
+ );
const { locale, setLocale, t: rawT } = useI18n();
const t = useScopedT("editor");
const ts = useScopedT("settings");
@@ -452,6 +460,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
+ cursorHighlight,
};
const projectData = createProjectData(currentProjectMedia, editorState);
@@ -513,6 +522,7 @@ export default function VideoEditor() {
videoPath,
t,
webcamSizePreset,
+ cursorHighlight,
],
);
@@ -587,6 +597,7 @@ export default function VideoEditor() {
if (!sourcePath) {
if (mounted) {
setCursorTelemetry([]);
+ setCursorClickTimestamps([]);
}
return;
}
@@ -595,11 +606,13 @@ export default function VideoEditor() {
const result = await window.electronAPI.getCursorTelemetry(sourcePath);
if (mounted) {
setCursorTelemetry(result.success ? result.samples : []);
+ setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []);
}
} catch (telemetryError) {
console.warn("Unable to load cursor telemetry:", telemetryError);
if (mounted) {
setCursorTelemetry([]);
+ setCursorClickTimestamps([]);
}
}
}
@@ -1394,6 +1407,8 @@ export default function VideoEditor() {
previewWidth,
previewHeight,
cursorTelemetry,
+ cursorClickTimestamps,
+ cursorHighlight: effectiveCursorHighlight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1534,6 +1549,8 @@ export default function VideoEditor() {
previewWidth,
previewHeight,
cursorTelemetry,
+ cursorClickTimestamps,
+ cursorHighlight: effectiveCursorHighlight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1617,6 +1634,8 @@ export default function VideoEditor() {
exportQuality,
handleExportSaved,
cursorTelemetry,
+ cursorClickTimestamps,
+ effectiveCursorHighlight,
t,
],
);
@@ -1874,6 +1893,8 @@ export default function VideoEditor() {
onBlurDataChange={handleBlurDataPreviewChange}
onBlurDataCommit={commitState}
cursorTelemetry={cursorTelemetry}
+ cursorHighlight={effectiveCursorHighlight}
+ cursorClickTimestamps={cursorClickTimestamps}
/>
@@ -1957,6 +1978,9 @@ export default function VideoEditor() {
{/* Right section: settings panel */}
pushState({ cursorHighlight: next })}
+ cursorHighlightSupportsClicks={isMac}
selected={wallpaper}
onWallpaperChange={(w) => pushState({ wallpaper: w })}
selectedZoomDepth={
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 35e0077..a69c8d7 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -51,7 +51,17 @@ import {
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
-import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
+import {
+ adaptiveSmoothFactor,
+ interpolateCursorAt,
+ smoothCursorFocus,
+} from "./videoPlayback/cursorFollowUtils";
+import {
+ type CursorHighlightConfig,
+ clickEmphasisAlpha,
+ DEFAULT_CURSOR_HIGHLIGHT,
+ drawCursorHighlightGraphics,
+} from "./videoPlayback/cursorHighlight";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -110,6 +120,8 @@ interface VideoPlaybackProps {
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
+ cursorHighlight?: CursorHighlightConfig;
+ cursorClickTimestamps?: number[];
}
export interface VideoPlaybackRef {
@@ -168,6 +180,8 @@ const VideoPlayback = forwardRef(
onBlurDataChange,
onBlurDataCommit,
cursorTelemetry = [],
+ cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
+ cursorClickTimestamps = [],
},
ref,
) => {
@@ -191,6 +205,9 @@ const VideoPlayback = forwardRef(
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef([]);
const cursorTelemetryRef = useRef([]);
+ const cursorHighlightRef = useRef(DEFAULT_CURSOR_HIGHLIGHT);
+ const cursorClickTimestampsRef = useRef([]);
+ const cursorHighlightGraphicsRef = useRef(null);
const selectedZoomIdRef = useRef(null);
const animationStateRef = useRef({
scale: 1,
@@ -515,6 +532,17 @@ const VideoPlayback = forwardRef(
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
+ useEffect(() => {
+ cursorHighlightRef.current = cursorHighlight;
+ if (cursorHighlightGraphicsRef.current) {
+ drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight);
+ }
+ }, [cursorHighlight]);
+
+ useEffect(() => {
+ cursorClickTimestampsRef.current = cursorClickTimestamps;
+ }, [cursorClickTimestamps]);
+
useEffect(() => {
selectedZoomIdRef.current = selectedZoomId;
}, [selectedZoomId]);
@@ -738,6 +766,12 @@ const VideoPlayback = forwardRef(
videoContainer.mask = maskGraphics;
maskGraphicsRef.current = maskGraphics;
+ const cursorHighlightGraphics = new Graphics();
+ cursorHighlightGraphics.visible = false;
+ videoContainer.addChild(cursorHighlightGraphics);
+ cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
+ drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
+
animationStateRef.current = {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
@@ -797,6 +831,11 @@ const VideoPlayback = forwardRef(
videoContainer.removeChild(maskGraphics);
maskGraphics.destroy();
}
+ if (cursorHighlightGraphicsRef.current) {
+ videoContainer.removeChild(cursorHighlightGraphicsRef.current);
+ cursorHighlightGraphicsRef.current.destroy();
+ cursorHighlightGraphicsRef.current = null;
+ }
videoContainer.mask = null;
maskGraphicsRef.current = null;
if (blurFilterRef.current) {
@@ -1016,6 +1055,39 @@ const VideoPlayback = forwardRef(
motionVector,
);
+ const cursorGraphics = cursorHighlightGraphicsRef.current;
+ const cursorConfig = cursorHighlightRef.current;
+ const lockedDims = lockedVideoDimensionsRef.current;
+ if (cursorGraphics) {
+ if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) {
+ const emphasisAlpha = clickEmphasisAlpha(
+ currentTimeRef.current,
+ cursorClickTimestampsRef.current,
+ cursorConfig,
+ );
+ const cursorPoint =
+ emphasisAlpha > 0
+ ? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current)
+ : null;
+ if (cursorPoint) {
+ const baseScale = baseScaleRef.current;
+ const baseOffset = baseOffsetRef.current;
+ const cx = cursorPoint.cx + cursorConfig.offsetXNorm;
+ const cy = cursorPoint.cy + cursorConfig.offsetYNorm;
+ cursorGraphics.position.set(
+ baseOffset.x + cx * lockedDims.width * baseScale,
+ baseOffset.y + cy * lockedDims.height * baseScale,
+ );
+ cursorGraphics.alpha = emphasisAlpha;
+ cursorGraphics.visible = true;
+ } else {
+ cursorGraphics.visible = false;
+ }
+ } else {
+ cursorGraphics.visible = false;
+ }
+ }
+
const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current;
if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) {
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index 7259c1e..beabbe4 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -80,6 +80,7 @@ export interface ProjectEditorState {
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
+ cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
}
export interface EditorProjectData {
@@ -494,6 +495,52 @@ export function normalizeProjectEditor(editor: Partial): Pro
editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
+ cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
+ };
+}
+
+function normalizeCursorHighlight(
+ value: unknown,
+): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
+ const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
+ enabled: false,
+ style: "ring",
+ sizePx: 24,
+ color: "#FFD700",
+ opacity: 0.9,
+ onlyOnClicks: false,
+ clickEmphasisDurationMs: 350,
+ offsetXNorm: 0,
+ offsetYNorm: 0,
+ };
+ if (!value || typeof value !== "object") return fallback;
+ const v = value as Partial;
+ return {
+ enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
+ style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
+ sizePx:
+ typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
+ color:
+ typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
+ ? v.color
+ : fallback.color,
+ opacity:
+ typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
+ ? v.opacity
+ : fallback.opacity,
+ onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
+ clickEmphasisDurationMs:
+ typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
+ ? v.clickEmphasisDurationMs
+ : fallback.clickEmphasisDurationMs,
+ offsetXNorm:
+ typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
+ ? Math.max(-1, Math.min(1, v.offsetXNorm))
+ : fallback.offsetXNorm,
+ offsetYNorm:
+ typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
+ ? Math.max(-1, Math.min(1, v.offsetYNorm))
+ : fallback.offsetYNorm,
};
}
diff --git a/src/components/video-editor/videoPlayback/cursorHighlight.ts b/src/components/video-editor/videoPlayback/cursorHighlight.ts
new file mode 100644
index 0000000..273e7b2
--- /dev/null
+++ b/src/components/video-editor/videoPlayback/cursorHighlight.ts
@@ -0,0 +1,125 @@
+import type { Graphics } from "pixi.js";
+
+export type CursorHighlightStyle = "dot" | "ring";
+
+export interface CursorHighlightConfig {
+ enabled: boolean;
+ style: CursorHighlightStyle;
+ sizePx: number;
+ color: string;
+ opacity: number;
+ // Show only on clicks (macOS — depends on click telemetry from uiohook).
+ onlyOnClicks: boolean;
+ clickEmphasisDurationMs: number;
+ // Per-recording manual nudge. Cursor telemetry is normalized to the display,
+ // but window recordings frame a subset of the display so the highlight
+ // lands offset. Users dial these in once to align with the actual cursor.
+ offsetXNorm: number;
+ offsetYNorm: number;
+}
+
+export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10;
+export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36;
+
+export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = {
+ enabled: false,
+ style: "ring",
+ sizePx: 24,
+ color: "#FFD700",
+ opacity: 0.9,
+ onlyOnClicks: false,
+ clickEmphasisDurationMs: 350,
+ offsetXNorm: 0,
+ offsetYNorm: 0,
+};
+
+export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface
+
+// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in
+// click-only mode; in click-only mode fades 1→0 across each click's window.
+export function clickEmphasisAlpha(
+ timeMs: number,
+ clickTimestampsMs: number[] | undefined,
+ config: CursorHighlightConfig,
+): number {
+ if (!config.onlyOnClicks) return 1;
+ if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0;
+ const window = Math.max(1, config.clickEmphasisDurationMs);
+ for (let i = 0; i < clickTimestampsMs.length; i++) {
+ const dt = timeMs - clickTimestampsMs[i];
+ if (dt >= 0 && dt <= window) {
+ return 1 - dt / window;
+ }
+ }
+ return 0;
+}
+
+function parseHexColor(hex: string): number {
+ const cleaned = hex.replace("#", "");
+ if (cleaned.length === 3) {
+ const r = cleaned[0];
+ const g = cleaned[1];
+ const b = cleaned[2];
+ return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16);
+ }
+ return Number.parseInt(cleaned.slice(0, 6), 16);
+}
+
+export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void {
+ g.clear();
+ if (!config.enabled) return;
+
+ const color = parseHexColor(config.color);
+ const radius = Math.max(1, config.sizePx / 2);
+ const alpha = Math.max(0, Math.min(1, config.opacity));
+
+ switch (config.style) {
+ case "dot": {
+ g.circle(0, 0, radius);
+ g.fill({ color, alpha });
+ break;
+ }
+ case "ring": {
+ g.circle(0, 0, radius);
+ g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) });
+ break;
+ }
+ }
+}
+
+export function drawCursorHighlightCanvas(
+ ctx: CanvasRenderingContext2D,
+ cx: number,
+ cy: number,
+ config: CursorHighlightConfig,
+ pixelScale = 1,
+): void {
+ if (!config.enabled) return;
+
+ const radius = Math.max(1, (config.sizePx / 2) * pixelScale);
+ const alpha = Math.max(0, Math.min(1, config.opacity));
+ const color = config.color;
+
+ ctx.save();
+ ctx.globalAlpha = alpha;
+
+ switch (config.style) {
+ case "dot": {
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fill();
+ break;
+ }
+ case "ring": {
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = Math.max(2, radius * 0.18);
+ ctx.stroke();
+ break;
+ }
+ }
+
+ ctx.restore();
+}
diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts
index bd410da..a655137 100644
--- a/src/hooks/useEditorHistory.ts
+++ b/src/hooks/useEditorHistory.ts
@@ -17,6 +17,10 @@ import {
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
} from "@/components/video-editor/types";
+import {
+ type CursorHighlightConfig,
+ DEFAULT_CURSOR_HIGHLIGHT,
+} from "@/components/video-editor/videoPlayback/cursorHighlight";
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
@@ -39,6 +43,7 @@ export interface EditorState {
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
+ cursorHighlight: CursorHighlightConfig;
}
export const INITIAL_EDITOR_STATE: EditorState = {
@@ -58,6 +63,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition: DEFAULT_WEBCAM_POSITION,
+ cursorHighlight: DEFAULT_CURSOR_HIGHLIGHT,
};
type StateUpdate = Partial | ((prev: EditorState) => Partial);
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index ad65a08..0a151b0 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -28,8 +28,14 @@ import {
} from "@/components/video-editor/videoPlayback/constants";
import {
adaptiveSmoothFactor,
+ interpolateCursorAt,
smoothCursorFocus,
} from "@/components/video-editor/videoPlayback/cursorFollowUtils";
+import {
+ type CursorHighlightConfig,
+ clickEmphasisAlpha,
+ drawCursorHighlightCanvas,
+} from "@/components/video-editor/videoPlayback/cursorHighlight";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
@@ -79,6 +85,8 @@ interface FrameRenderConfig {
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
+ cursorHighlight?: CursorHighlightConfig;
+ cursorClickTimestamps?: number[];
platform: string;
}
@@ -387,6 +395,46 @@ export class FrameRenderer {
// Composite with shadows to final output canvas
this.compositeWithShadows(webcamFrame);
+ // Cursor highlight overlay (rendered above video, below annotations)
+ if (
+ this.config.cursorHighlight?.enabled &&
+ this.config.cursorTelemetry &&
+ this.config.cursorTelemetry.length > 0 &&
+ this.compositeCtx
+ ) {
+ const emphasisAlpha = clickEmphasisAlpha(
+ timeMs,
+ this.config.cursorClickTimestamps,
+ this.config.cursorHighlight,
+ );
+ const cursorPoint =
+ emphasisAlpha > 0 ? interpolateCursorAt(this.config.cursorTelemetry, timeMs) : null;
+ if (cursorPoint) {
+ const cx = cursorPoint.cx + this.config.cursorHighlight.offsetXNorm;
+ const cy = cursorPoint.cy + this.config.cursorHighlight.offsetYNorm;
+ const stageX =
+ layoutCache.baseOffset.x + cx * this.config.videoWidth * layoutCache.baseScale;
+ const stageY =
+ layoutCache.baseOffset.y + cy * this.config.videoHeight * layoutCache.baseScale;
+ const appliedScale = this.animationState.appliedScale;
+ const canvasX = stageX * appliedScale + this.animationState.x;
+ const canvasY = stageY * appliedScale + this.animationState.y;
+ const previewW = this.config.previewWidth ?? this.config.width;
+ const previewH = this.config.previewHeight ?? this.config.height;
+ const cursorScale = (this.config.width / previewW + this.config.height / previewH) / 2;
+ drawCursorHighlightCanvas(
+ this.compositeCtx,
+ canvasX,
+ canvasY,
+ {
+ ...this.config.cursorHighlight,
+ opacity: this.config.cursorHighlight.opacity * emphasisAlpha,
+ },
+ appliedScale * cursorScale,
+ );
+ }
+ }
+
// Render annotations on top if present
if (
this.config.annotationRegions &&
diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts
index f41b58d..0d7a432 100644
--- a/src/lib/exporter/gifExporter.ts
+++ b/src/lib/exporter/gifExporter.ts
@@ -51,6 +51,8 @@ interface GifExporterConfig {
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
+ cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig;
+ cursorClickTimestamps?: number[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -161,6 +163,8 @@ export class GifExporter {
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
+ cursorClickTimestamps: this.config.cursorClickTimestamps,
+ cursorHighlight: this.config.cursorHighlight,
platform,
});
await this.renderer.initialize();
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index d44bf40..e064ba7 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -42,6 +42,8 @@ interface VideoExporterConfig extends ExportConfig {
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
+ cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig;
+ cursorClickTimestamps?: number[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -156,6 +158,8 @@ export class VideoExporter {
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
+ cursorClickTimestamps: this.config.cursorClickTimestamps,
+ cursorHighlight: this.config.cursorHighlight,
platform,
});
this.renderer = renderer;
From 78f57970e96c107c91448085a87c1e2b9c159a71 Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sat, 2 May 2026 23:27:38 -0700
Subject: [PATCH 18/31] fix ci checks
---
package.json | 2 +-
scripts/rebuild-native.mjs | 21 +++++++++++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
create mode 100644 scripts/rebuild-native.mjs
diff --git a/package.json b/package.json
index 2709d3e..855160f 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,7 @@
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
"test:e2e": "playwright test",
"prepare": "husky",
- "rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi",
+ "rebuild:native": "node ./scripts/rebuild-native.mjs",
"postinstall": "npm run rebuild:native"
},
"dependencies": {
diff --git a/scripts/rebuild-native.mjs b/scripts/rebuild-native.mjs
new file mode 100644
index 0000000..e028602
--- /dev/null
+++ b/scripts/rebuild-native.mjs
@@ -0,0 +1,21 @@
+import { spawnSync } from "node:child_process";
+import process from "node:process";
+
+// uiohook-napi click capture is macOS-only at runtime (gated in
+// electron/ipc/handlers.ts). Skip the rebuild on other platforms so CI runners
+// without X11 dev headers don't fail npm install. The library's prebuilt
+// .node binaries are still bundled and loadable; we just don't need a fresh
+// build against Electron's ABI on platforms where we don't load it.
+if (process.platform !== "darwin") {
+ console.log(
+ `[rebuild:native] Skipping uiohook-napi rebuild on ${process.platform} (macOS-only).`,
+ );
+ process.exit(0);
+}
+
+const result = spawnSync(
+ process.execPath,
+ ["./node_modules/@electron/rebuild/lib/cli.js", "--force", "--only", "uiohook-napi"],
+ { stdio: "inherit" },
+);
+process.exit(result.status ?? 0);
From b7d356327259c6befd698d5fa2132cb66f93185f Mon Sep 17 00:00:00 2001
From: psychosomat
Date: Sun, 3 May 2026 12:10:00 +0300
Subject: [PATCH 19/31] Upload pacman package in Linux CI artifacts
---
.github/workflows/build.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f42a92d..35177bc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -250,4 +250,5 @@ jobs:
release/**/*.AppImage
release/**/*.zsync
release/**/*.deb
+ release/**/*.pacman
retention-days: 30
From 679e306d31415ebc370ccf1e2a83ef27bd79d290 Mon Sep 17 00:00:00 2001
From: i1Zeus
Date: Sun, 3 May 2026 19:49:35 +0300
Subject: [PATCH 20/31] feat: add Arabic localization support for editor,
launch, settings, shortcuts, timeline, common, and dialogs modules
---
src/i18n/locales/ar/common.json | 30 +++++
src/i18n/locales/ar/dialogs.json | 70 +++++++++++
src/i18n/locales/ar/editor.json | 45 ++++++++
src/i18n/locales/ar/launch.json | 43 +++++++
src/i18n/locales/ar/settings.json | 180 +++++++++++++++++++++++++++++
src/i18n/locales/ar/shortcuts.json | 37 ++++++
src/i18n/locales/ar/timeline.json | 55 +++++++++
7 files changed, 460 insertions(+)
create mode 100644 src/i18n/locales/ar/common.json
create mode 100644 src/i18n/locales/ar/dialogs.json
create mode 100644 src/i18n/locales/ar/editor.json
create mode 100644 src/i18n/locales/ar/launch.json
create mode 100644 src/i18n/locales/ar/settings.json
create mode 100644 src/i18n/locales/ar/shortcuts.json
create mode 100644 src/i18n/locales/ar/timeline.json
diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json
new file mode 100644
index 0000000..e4f17fe
--- /dev/null
+++ b/src/i18n/locales/ar/common.json
@@ -0,0 +1,30 @@
+{
+ "actions": {
+ "cancel": "الغاء",
+ "save": "حفظ",
+ "delete": "حذف",
+ "close": "اغلاق",
+ "share": "مشاركة",
+ "done": "تم",
+ "open": "فتح",
+ "upload": "رفع",
+ "export": "تصدير",
+ "showInFolder": "عرض في المجلد",
+ "file": "ملف",
+ "edit": "تعديل",
+ "view": "عرض",
+ "window": "نافذة",
+ "quit": "خروج",
+ "stopRecording": "ايقاف التسجيل"
+ },
+ "playback": {
+ "play": "تشغيل",
+ "pause": "ايقاف مؤقت",
+ "fullscreen": "ملء الشاشة",
+ "exitFullscreen": "خروج من ملء الشاشة"
+ },
+ "locale": {
+ "name": "عربي",
+ "short": "AR"
+ }
+}
diff --git a/src/i18n/locales/ar/dialogs.json b/src/i18n/locales/ar/dialogs.json
new file mode 100644
index 0000000..2263f60
--- /dev/null
+++ b/src/i18n/locales/ar/dialogs.json
@@ -0,0 +1,70 @@
+{
+ "export": {
+ "complete": "اكتمل التصدير",
+ "yourFormatReady": "{{format}} الخاص بك جاهز",
+ "showInFolder": "عرض في المجلد",
+ "finalizingVideo": "جاري إنهاء تصدير الفيديو...",
+ "compilingGifProgress": "جاري تجميع GIF... {{progress}}%",
+ "compilingGifWait": "جاري تجميع GIF... قد يستغرق هذا بعض الوقت",
+ "takeMoment": "قد يستغرق هذا لحظة...",
+ "failed": "فشل التصدير",
+ "tryAgain": "يرجى المحاولة مرة أخرى",
+ "finalizingVideoTitle": "إنهاء الفيديو",
+ "compilingGif": "تجميع GIF",
+ "exportingFormat": "تصدير {{format}}",
+ "compiling": "تجميع",
+ "renderingFrames": "تصيير الإطارات",
+ "processing": "جاري المعالجة...",
+ "finalizing": "جاري الإنهاء...",
+ "compilingStatus": "جاري التجميع...",
+ "status": "الحالة",
+ "format": "الصيغة",
+ "frames": "الإطارات",
+ "cancelExport": "إلغاء التصدير",
+ "savedSuccessfully": "تم حفظ {{format}} بنجاح!"
+ },
+ "tutorial": {
+ "triggerLabel": "كيف يعمل القص",
+ "title": "كيف يعمل القص",
+ "description": "فهم كيفية قص الأجزاء غير المرغوب فيها من الفيديو الخاص بك.",
+ "explanationBefore": "تعمل أداة القص من خلال تحديد المقاطع التي تريد",
+ "remove": "إزالتها",
+ "explanationMiddle": " — أي شيء",
+ "covered": "مغطى",
+ "explanationAfter": "بمقطع قص أحمر سيتم قصه عند التصدير.",
+ "visualExample": "مثال مرئي",
+ "removed": "مُزال",
+ "kept": "مُحتفظ به",
+ "part1": "الجزء 1",
+ "part2": "الجزء 2",
+ "part3": "الجزء 3",
+ "finalVideo": "الفيديو النهائي",
+ "step1Title": "1. إضافة قص",
+ "step1DescriptionBefore": "اضغط على ",
+ "step1DescriptionAfter": " أو انقر على أيقونة المقص لتحديد قسم لإزالته.",
+ "step2Title": "2. تعديل",
+ "step2Description": "اسحب حواف المنطقة الحمراء لتغطي بالضبط ما تريد قصه."
+ },
+ "unsavedChanges": {
+ "title": "تغييرات غير محفوظة",
+ "message": "لديك تغييرات غير محفوظة.",
+ "detail": "هل تريد حفظ مشروعك قبل الإغلاق؟",
+ "saveAndClose": "حفظ وإغلاق",
+ "discardAndClose": "تجاهل وإغلاق",
+ "loadProject": "تحميل مشروع...",
+ "saveProject": "حفظ المشروع...",
+ "saveProjectAs": "حفظ المشروع باسم..."
+ },
+ "fileDialogs": {
+ "saveGif": "حفظ GIF المصدر",
+ "saveVideo": "حفظ الفيديو المصدر",
+ "selectVideo": "حدد ملف فيديو",
+ "saveProject": "حفظ مشروع OpenScreen",
+ "openProject": "فتح مشروع OpenScreen",
+ "gifImage": "صورة GIF",
+ "mp4Video": "فيديو MP4",
+ "videoFiles": "ملفات فيديو",
+ "openscreenProject": "مشروع OpenScreen",
+ "allFiles": "جميع الملفات"
+ }
+}
diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json
new file mode 100644
index 0000000..0d293d7
--- /dev/null
+++ b/src/i18n/locales/ar/editor.json
@@ -0,0 +1,45 @@
+{
+ "newRecording": {
+ "title": "العودة إلى المسجل",
+ "description": "تم حفظ جلستك الحالية.",
+ "cancel": "إلغاء",
+ "confirm": "تأكيد"
+ },
+ "loadingVideo": "جاري تحميل الفيديو...",
+ "errors": {
+ "noVideoLoaded": "لم يتم تحميل أي فيديو",
+ "videoNotReady": "الفيديو غير جاهز",
+ "unableToDetermineSourcePath": "تعذر تحديد مسار الفيديو المصدر",
+ "failedToSaveGif": "فشل حفظ GIF",
+ "gifExportFailed": "فشل تصدير GIF",
+ "failedToSaveVideo": "فشل حفظ الفيديو",
+ "exportFailed": "فشل التصدير",
+ "exportFailedWithError": "فشل التصدير: {{error}}",
+ "exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})",
+ "failedToSaveExport": "فشل حفظ التصدير",
+ "failedToSaveExportedVideo": "فشل حفظ الفيديو المصدر",
+ "failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}"
+ },
+ "export": {
+ "canceled": "تم إلغاء التصدير",
+ "exportedSuccessfully": "تم تصدير {{format}} بنجاح"
+ },
+ "project": {
+ "saveCanceled": "تم إلغاء حفظ المشروع",
+ "failedToSave": "فشل حفظ المشروع",
+ "savedTo": "تم حفظ المشروع في {{path}}",
+ "failedToLoad": "فشل تحميل المشروع",
+ "invalidFormat": "تنسيق ملف المشروع غير صالح",
+ "loadedFrom": "تم تحميل المشروع من {{path}}"
+ },
+ "recording": {
+ "failedCameraAccess": "فشل طلب الوصول إلى الكاميرا.",
+ "cameraBlocked": "الوصول إلى الكاميرا محظور. قم بتمكينه في إعدادات النظام لاستخدام كاميرا الويب.",
+ "systemAudioUnavailable": "صوت النظام غير متوفر. يتم التسجيل بدون صوت النظام.",
+ "microphoneDenied": "تم رفض الوصول إلى الميكروفون. سيستمر التسجيل بدون صوت.",
+ "cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.",
+ "cameraDisconnected": "تم فصل كاميرا الويب.",
+ "cameraNotFound": "لم يتم العثور على كاميرا.",
+ "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة."
+ }
+}
diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json
new file mode 100644
index 0000000..19da8fb
--- /dev/null
+++ b/src/i18n/locales/ar/launch.json
@@ -0,0 +1,43 @@
+{
+ "tooltips": {
+ "hideHUD": "إخفاء واجهة العرض",
+ "closeApp": "إغلاق التطبيق",
+ "restartRecording": "إعادة تشغيل التسجيل",
+ "cancelRecording": "إلغاء التسجيل",
+ "pauseRecording": "إيقاف التسجيل مؤقتاً",
+ "resumeRecording": "استئناف التسجيل",
+ "openVideoFile": "فتح ملف فيديو",
+ "openProject": "فتح مشروع"
+ },
+ "audio": {
+ "enableSystemAudio": "تفعيل صوت النظام",
+ "disableSystemAudio": "تعطيل صوت النظام",
+ "enableMicrophone": "تفعيل الميكروفون",
+ "disableMicrophone": "تعطيل الميكروفون",
+ "defaultMicrophone": "الميكروفون الافتراضي"
+ },
+ "webcam": {
+ "enableWebcam": "تفعيل كاميرا الويب",
+ "disableWebcam": "تعطيل كاميرا الويب",
+ "defaultCamera": "الكاميرا الافتراضية",
+ "searching": "جاري البحث...",
+ "noneFound": "لم يتم العثور على كاميرا",
+ "unavailable": "الكاميرا غير متوفرة"
+ },
+ "sourceSelector": {
+ "loading": "جاري تحميل المصادر...",
+ "screens": "الشاشات ({{count}})",
+ "windows": "النوافذ ({{count}})",
+ "defaultSourceName": "الشاشة"
+ },
+ "recording": {
+ "selectSource": "يرجى تحديد مصدر للتسجيل"
+ },
+ "language": "اللغة",
+ "systemLanguagePrompt": {
+ "title": "هل تريد استخدام لغة نظامك؟",
+ "description": "اكتشفنا أن {{language}} هي لغة نظامك. هل تريد تبديل OpenScreen إلى {{language}}؟",
+ "switch": "التبديل إلى {{language}}",
+ "keepDefault": "الاحتفاظ باللغة الحالية"
+ }
+}
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
new file mode 100644
index 0000000..e0510c9
--- /dev/null
+++ b/src/i18n/locales/ar/settings.json
@@ -0,0 +1,180 @@
+{
+ "zoom": {
+ "level": "مستوى التكبير",
+ "selectRegion": "حدد منطقة التكبير للتعديل",
+ "deleteZoom": "حذف التكبير",
+ "focusMode": {
+ "title": "وضع التركيز",
+ "manual": "يدوي",
+ "auto": "تلقائي",
+ "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل"
+ }
+ },
+ "speed": {
+ "playbackSpeed": "سرعة التشغيل",
+ "selectRegion": "حدد منطقة السرعة للتعديل",
+ "deleteRegion": "حذف منطقة السرعة",
+ "customPlaybackSpeed": "سرعة تشغيل مخصصة",
+ "maxSpeedError": "لا يمكن للسرعة أن تتجاوز 16×"
+ },
+ "trim": {
+ "deleteRegion": "حذف منطقة القص"
+ },
+ "layout": {
+ "title": "التخطيط",
+ "preset": "الإعداد المسبق",
+ "selectPreset": "حدد إعدادًا مسبقًا",
+ "pictureInPicture": "صورة داخل صورة",
+ "verticalStack": "تكدس عمودي",
+ "dualFrame": "إطار مزدوج",
+ "webcamShape": "شكل الكاميرا",
+ "webcamSize": "حجم كاميرا الويب"
+ },
+ "effects": {
+ "title": "تأثيرات الفيديو",
+ "blurBg": "تمويه الخلفية",
+ "motionBlur": "ضبابية الحركة",
+ "off": "إيقاف",
+ "shadow": "ظل",
+ "roundness": "الاستدارة",
+ "padding": "المسافة البادئة"
+ },
+ "background": {
+ "title": "الخلفية",
+ "image": "صورة",
+ "color": "لون",
+ "gradient": "تدرج لوني",
+ "uploadCustom": "رفع صورة مخصصة",
+ "gradientLabel": "تدرج لوني {{index}}",
+ "colorWheel": "عجلة الألوان",
+ "colorPalette": "لوحة الألوان"
+ },
+ "crop": {
+ "title": "اقتصاص",
+ "cropVideo": "اقتصاص الفيديو",
+ "dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
+ "ratio": "النسبة",
+ "free": "حر",
+ "done": "تم",
+ "lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
+ "unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع"
+ },
+ "exportFormat": {
+ "mp4": "MP4",
+ "gif": "GIF",
+ "mp4Video": "فيديو MP4",
+ "mp4Description": "ملف فيديو عالي الجودة",
+ "gifAnimation": "صورة GIF متحركة",
+ "gifDescription": "صورة متحركة للمشاركة"
+ },
+ "exportQuality": {
+ "title": "جودة التصدير",
+ "low": "منخفضة",
+ "medium": "متوسطة",
+ "high": "عالية"
+ },
+ "gifSettings": {
+ "frameRate": "معدل إطارات GIF",
+ "size": "حجم GIF",
+ "loop": "تكرار GIF"
+ },
+ "project": {
+ "save": "حفظ المشروع",
+ "load": "تحميل المشروع"
+ },
+ "export": {
+ "videoButton": "تصدير الفيديو",
+ "gifButton": "تصدير GIF",
+ "chooseSaveLocation": "اختيار موقع الحفظ"
+ },
+ "links": {
+ "reportBug": "الإبلاغ عن خطأ",
+ "starOnGithub": "إعطاء نجمة على GitHub"
+ },
+ "imageUpload": {
+ "invalidFileType": "نوع ملف غير صالح",
+ "jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
+ "uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
+ "failedToUpload": "فشل رفع الصورة",
+ "errorReading": "حدث خطأ أثناء قراءة الملف."
+ },
+ "annotation": {
+ "title": "إعدادات الشروح",
+ "active": "نشط",
+ "typeText": "نص",
+ "typeImage": "صورة",
+ "typeArrow": "سهم",
+ "typeBlur": "تمويه",
+ "textContent": "محتوى النص",
+ "textPlaceholder": "أدخل النص هنا...",
+ "fontStyle": "نمط الخط",
+ "selectStyle": "حدد النمط",
+ "size": "الحجم",
+ "customFonts": "خطوط مخصصة",
+ "textColor": "لون النص",
+ "background": "الخلفية",
+ "none": "بدون",
+ "color": "لون",
+ "colorWheel": "عجلة الألوان",
+ "colorPalette": "لوحة الألوان",
+ "clearBackground": "مسح الخلفية",
+ "uploadImage": "رفع صورة",
+ "supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
+ "arrowDirection": "اتجاه السهم",
+ "strokeWidth": "عرض الخط: {{width}}px",
+ "arrowColor": "لون السهم",
+ "blurType": "نوع التمويه",
+ "blurTypeBlur": "تمويه",
+ "blurTypeMosaic": "فسيفساء",
+ "blurColor": "لون التمويه",
+ "blurColorWhite": "أبيض",
+ "blurColorBlack": "أسود",
+ "blurShape": "شكل التمويه",
+ "blurIntensity": "كثافة التمويه",
+ "mosaicBlockSize": "حجم كتلة الفسيفساء",
+ "blurShapeRectangle": "مستطيل",
+ "blurShapeOval": "بيضاوي",
+ "blurShapeFreehand": "رسم حر",
+ "deleteAnnotation": "حذف الشرح",
+ "shortcutsAndTips": "اختصارات ونصائح",
+ "tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
+ "tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
+ "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
+ "invalidImageType": "نوع ملف غير صالح",
+ "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
+ "imageUploadSuccess": "تم رفع الصورة بنجاح!",
+ "failedImageUpload": "فشل في رفع الصورة"
+ },
+ "fontStyles": {
+ "classic": "كلاسيكي",
+ "editor": "محرر",
+ "strong": "قوي",
+ "typewriter": "آلة كاتبة",
+ "deco": "ديكو",
+ "simple": "بسيط",
+ "modern": "حديث",
+ "clean": "نظيف"
+ },
+ "customFont": {
+ "dialogTitle": "إضافة خط Google",
+ "urlLabel": "رابط استيراد خطوط Google",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"Get font\" → انسخ رابط @import",
+ "nameLabel": "اسم العرض",
+ "namePlaceholder": "خطي المخصص",
+ "nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
+ "addButton": "إضافة خط",
+ "addingButton": "جاري الإضافة...",
+ "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
+ "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
+ "errorEmptyName": "يرجى إدخال اسم الخط",
+ "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
+ "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
+ "failedToAdd": "فشل في إضافة الخط",
+ "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
+ "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google."
+ },
+ "language": {
+ "title": "اللغة"
+ }
+}
diff --git a/src/i18n/locales/ar/shortcuts.json b/src/i18n/locales/ar/shortcuts.json
new file mode 100644
index 0000000..a560c06
--- /dev/null
+++ b/src/i18n/locales/ar/shortcuts.json
@@ -0,0 +1,37 @@
+{
+ "title": "اختصارات لوحة المفاتيح",
+ "customize": "تخصيص",
+ "configurable": "قابل للتكوين",
+ "fixed": "ثابت",
+ "pressKey": "اضغط على مفتاح...",
+ "clickToChange": "انقر للتغيير",
+ "pressEscToCancel": "اضغط على Esc للإلغاء",
+ "helpText": "انقر على اختصار ثم اضغط على مجموعة المفاتيح الجديدة. اضغط على Esc للإلغاء.",
+ "resetToDefaults": "إعادة تعيين إلى الافتراضيات",
+ "alreadyUsedBy": "مستخدم بالفعل بواسطة {{action}}",
+ "swap": "تبديل",
+ "reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.",
+ "savedToast": "تم حفظ اختصارات لوحة المفاتيح",
+ "resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق",
+ "actions": {
+ "addZoom": "إضافة تكبير",
+ "addTrim": "إضافة قص",
+ "addSpeed": "إضافة سرعة",
+ "addAnnotation": "إضافة شرح",
+ "addBlur": "إضافة تمويه",
+ "addKeyframe": "إضافة إطار رئيسي",
+ "deleteSelected": "حذف المحدد",
+ "playPause": "تشغيل / إيقاف مؤقت"
+ },
+ "fixedActions": {
+ "undo": "تراجع",
+ "redo": "إعادة",
+ "cycleAnnotationsForward": "التنقل بين الشروح للأمام",
+ "cycleAnnotationsBackward": "التنقل بين الشروح للخلف",
+ "deleteSelectedAlt": "حذف المحدد (alt)",
+ "panTimeline": "تحريك المخطط الزمني",
+ "zoomTimeline": "تكبير المخطط الزمني",
+ "frameBack": "إطار للخلف",
+ "frameForward": "إطار للأمام"
+ }
+}
diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json
new file mode 100644
index 0000000..09d55c4
--- /dev/null
+++ b/src/i18n/locales/ar/timeline.json
@@ -0,0 +1,55 @@
+{
+ "buttons": {
+ "addZoom": "إضافة تكبير (Z)",
+ "suggestZooms": "اقتراح تكبير من المؤشر",
+ "addTrim": "إضافة قص (T)",
+ "addAnnotation": "إضافة شرح (A)",
+ "addBlur": "إضافة تمويه (B)",
+ "addSpeed": "إضافة سرعة (S)"
+ },
+ "hints": {
+ "pressZoom": "اضغط Z لإضافة تكبير",
+ "pressTrim": "اضغط T لإضافة قص",
+ "pressAnnotation": "اضغط A لإضافة شرح",
+ "pressBlur": "اضغط B لإضافة منطقة تمويه",
+ "pressSpeed": "اضغط S لإضافة سرعة"
+ },
+ "labels": {
+ "pan": "تحريك",
+ "zoom": "تكبير",
+ "trim": "قص",
+ "speed": "سرعة",
+ "zoomItem": "تكبير {{index}}",
+ "trimItem": "قص {{index}}",
+ "speedItem": "سرعة {{index}}",
+ "annotationItem": "شرح",
+ "blurItem": "تمويه {{index}}",
+ "imageItem": "صورة",
+ "emptyText": "نص فارغ"
+ },
+ "emptyState": {
+ "noVideo": "لم يتم تحميل أي فيديو",
+ "dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل"
+ },
+ "errors": {
+ "cannotPlaceZoom": "لا يمكن وضع التكبير هنا",
+ "zoomExistsAtLocation": "يوجد تكبير بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
+ "zoomSuggestionUnavailable": "معالج اقتراح التكبير غير متوفر",
+ "noCursorTelemetry": "لا تتوفر بيانات قياس المؤشر",
+ "noCursorTelemetryDescription": "قم بتسجيل الشاشة أولاً لإنشاء اقتراحات بناءً على المؤشر.",
+ "noUsableTelemetry": "لا توجد بيانات قياس مؤشر قابلة للاستخدام",
+ "noUsableTelemetryDescription": "التسجيل لا يتضمن بيانات حركة مؤشر كافية.",
+ "noDwellMoments": "لم يتم العثور على لحظات توقف واضحة للمؤشر",
+ "noDwellMomentsDescription": "جرب تسجيلاً مع توقفات مؤشر أبطأ عند الإجراءات المهمة.",
+ "noAutoZoomSlots": "لا تتوفر خانات تكبير تلقائي",
+ "noAutoZoomSlotsDescription": "نقاط التوقف المكتشفة تتداخل مع مناطق التكبير الحالية.",
+ "cannotPlaceTrim": "لا يمكن وضع القص هنا",
+ "trimExistsAtLocation": "يوجد قص بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
+ "cannotPlaceSpeed": "لا يمكن وضع السرعة هنا",
+ "speedExistsAtLocation": "توجد منطقة سرعة بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة."
+ },
+ "success": {
+ "addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر",
+ "addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر"
+ }
+}
From b5d37c427098a38f5b23b9dba3efb27df69b75b2 Mon Sep 17 00:00:00 2001
From: i1Zeus
Date: Sun, 3 May 2026 20:03:01 +0300
Subject: [PATCH 21/31] feat: implement video editor SettingsPanel and add
Arabic and English localization files
---
src/components/video-editor/SettingsPanel.tsx | 52 +++++++++++++------
src/i18n/locales/ar/editor.json | 2 +-
src/i18n/locales/ar/settings.json | 16 +++++-
src/i18n/locales/en/settings.json | 16 +++++-
4 files changed, 68 insertions(+), 18 deletions(-)
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 5cac573..343c4cf 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1005,7 +1005,9 @@ export function SettingsPanel({
{cursorHighlight && onCursorHighlightChange && (
-
Cursor highlight
+
+ {t("effects.cursorHighlight.title")}
+
@@ -1020,7 +1022,7 @@ export function SettingsPanel({
: "bg-white/5 border-white/10 text-slate-400"
}`}
>
- {cursorHighlight.enabled ? "On" : "Off"}
+ {cursorHighlight.enabled ? t("effects.on") : t("effects.off")}
- {style}
+ {t(`effects.cursorHighlight.${style}`)}
))}
-
Size
+
+ {t("effects.cursorHighlight.size")}
+
{cursorHighlight.sizePx}px
@@ -1051,7 +1055,10 @@ export function SettingsPanel({
- onCursorHighlightChange({ ...cursorHighlight, sizePx: values[0] })
+ onCursorHighlightChange({
+ ...cursorHighlight,
+ sizePx: values[0],
+ })
}
min={10}
max={36}
@@ -1063,7 +1070,9 @@ export function SettingsPanel({
-
Only on clicks
+
+ {t("effects.cursorHighlight.onlyOnClicks")}
+
{
@@ -1072,10 +1081,14 @@ export function SettingsPanel({
try {
const result = await window.electronAPI.requestAccessibilityAccess();
if (!result.granted) {
- toast.message("Accessibility permission needed", {
- description:
- "Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app.",
- });
+ toast.message(
+ t("effects.cursorHighlight.accessibilityPermissionTitle"),
+ {
+ description: t(
+ "effects.cursorHighlight.accessibilityPermissionDescription",
+ ),
+ },
+ );
}
} catch (err) {
console.warn("Accessibility request failed:", err);
@@ -1092,12 +1105,14 @@ export function SettingsPanel({
: "bg-white/5 border-white/10 text-slate-400"
}`}
>
- {cursorHighlight.onlyOnClicks ? "On" : "Off"}
+ {cursorHighlight.onlyOnClicks ? t("effects.on") : t("effects.off")}
)}
-
Color
+
+ {t("effects.cursorHighlight.color")}
+
- onCursorHighlightChange({ ...cursorHighlight, color })
+ onCursorHighlightChange({
+ ...cursorHighlight,
+ color,
+ })
}
/>
@@ -1134,7 +1152,9 @@ export function SettingsPanel({
-
Offset X (window recordings)
+
+ {t("effects.cursorHighlight.offsetX")}
+
{(cursorHighlight.offsetXNorm * 100).toFixed(1)}%
@@ -1155,7 +1175,9 @@ export function SettingsPanel({
-
Offset Y
+
+ {t("effects.cursorHighlight.offsetY")}
+
{(cursorHighlight.offsetYNorm * 100).toFixed(1)}%
diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json
index 0d293d7..a246f01 100644
--- a/src/i18n/locales/ar/editor.json
+++ b/src/i18n/locales/ar/editor.json
@@ -17,7 +17,7 @@
"exportFailedWithError": "فشل التصدير: {{error}}",
"exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})",
"failedToSaveExport": "فشل حفظ التصدير",
- "failedToSaveExportedVideo": "فشل حفظ الفيديو المصدر",
+ "failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر",
"failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}"
},
"export": {
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index e0510c9..e21976d 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -35,9 +35,23 @@
"blurBg": "تمويه الخلفية",
"motionBlur": "ضبابية الحركة",
"off": "إيقاف",
+ "on": "تشغيل",
"shadow": "ظل",
"roundness": "الاستدارة",
- "padding": "المسافة البادئة"
+ "padding": "المسافة البادئة",
+ "cursorHighlight": {
+ "title": "تمييز المؤشر",
+ "style": "النمط",
+ "dot": "نقطة",
+ "ring": "حلقة",
+ "size": "الحجم",
+ "onlyOnClicks": "عند النقر فقط",
+ "color": "اللون",
+ "offsetX": "إزاحة X (لتسجيلات النوافذ)",
+ "offsetY": "إزاحة Y",
+ "accessibilityPermissionTitle": "مطلوب إذن الوصول",
+ "accessibilityPermissionDescription": "افتح إعدادات النظام ← الخصوصية والأمان ← إمكانية الوصول، وقم بتفعيل Openscreen، ثم أعد تشغيل التطبيق."
+ }
},
"background": {
"title": "الخلفية",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 9b85c2b..aaa5be4 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -35,9 +35,23 @@
"blurBg": "Blur BG",
"motionBlur": "Motion Blur",
"off": "off",
+ "on": "on",
"shadow": "Shadow",
"roundness": "Roundness",
- "padding": "Padding"
+ "padding": "Padding",
+ "cursorHighlight": {
+ "title": "Cursor highlight",
+ "style": "Style",
+ "dot": "Dot",
+ "ring": "Ring",
+ "size": "Size",
+ "onlyOnClicks": "Only on clicks",
+ "color": "Color",
+ "offsetX": "Offset X (window recordings)",
+ "offsetY": "Offset Y",
+ "accessibilityPermissionTitle": "Accessibility permission needed",
+ "accessibilityPermissionDescription": "Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app."
+ }
},
"background": {
"title": "Background",
From bb30e20df7f70213937628b9fd4a3bcb9697d775 Mon Sep 17 00:00:00 2001
From: i1Zeus
Date: Sun, 3 May 2026 20:05:06 +0300
Subject: [PATCH 22/31] implement lightweight i18n support for electron main
process
---
electron/i18n.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/electron/i18n.ts b/electron/i18n.ts
index 4222741..7856357 100644
--- a/electron/i18n.ts
+++ b/electron/i18n.ts
@@ -1,6 +1,8 @@
// Lightweight i18n for the Electron main process.
// Imports the same JSON translation files used by the renderer.
+import commonAr from "../src/i18n/locales/ar/common.json";
+import dialogsAr from "../src/i18n/locales/ar/dialogs.json";
import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
@@ -18,7 +20,7 @@ import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
-type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr";
+type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar";
type Namespace = "common" | "dialogs";
type MessageMap = Record;
@@ -31,6 +33,7 @@ const messages: Record> = {
"ja-JP": { common: commonJa, dialogs: dialogsJa },
"ko-KR": { common: commonKo, dialogs: dialogsKo },
tr: { common: commonTr, dialogs: dialogsTr },
+ ar: { common: commonAr, dialogs: dialogsAr },
};
let currentLocale: Locale = "en";
@@ -44,7 +47,8 @@ export function setMainLocale(locale: string) {
locale === "fr" ||
locale === "ja-JP" ||
locale === "ko-KR" ||
- locale === "tr"
+ locale === "tr" ||
+ locale === "ar"
) {
currentLocale = locale;
}
From 59ecedb0ac10e4e1d82abae8f3e7fc7662a99fe7 Mon Sep 17 00:00:00 2001
From: i1Zeus
Date: Sun, 3 May 2026 20:21:42 +0300
Subject: [PATCH 23/31] implement i18n support and dynamic application menu in
electron main process
---
electron/main.ts | 112 +++++++++++++++++++++++++-------
src/i18n/locales/ar/common.json | 22 ++++++-
src/i18n/locales/en/common.json | 22 ++++++-
3 files changed, 131 insertions(+), 25 deletions(-)
diff --git a/electron/main.ts b/electron/main.ts
index ad0a33f..030a8cf 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -124,15 +124,30 @@ function setupApplicationMenu() {
template.push({
label: app.name,
submenu: [
- { role: "about" },
+ {
+ role: "about",
+ label: mainT("common", "actions.about") || "About OpenScreen",
+ },
{ type: "separator" },
- { role: "services" },
+ {
+ role: "services",
+ label: mainT("common", "actions.services") || "Services",
+ },
{ type: "separator" },
- { role: "hide" },
- { role: "hideOthers" },
- { role: "unhide" },
+ {
+ role: "hide",
+ label: mainT("common", "actions.hide") || "Hide OpenScreen",
+ },
+ {
+ role: "hideOthers",
+ label: mainT("common", "actions.hideOthers") || "Hide Others",
+ },
+ {
+ role: "unhide",
+ label: mainT("common", "actions.unhide") || "Show All",
+ },
{ type: "separator" },
- { role: "quit" },
+ { role: "quit", label: mainT("common", "actions.quit") || "Quit" },
],
});
}
@@ -156,40 +171,89 @@ function setupApplicationMenu() {
accelerator: "CmdOrCtrl+Shift+S",
click: () => sendEditorMenuAction("menu-save-project-as"),
},
- ...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
+ ...(isMac
+ ? []
+ : [
+ { type: "separator" as const },
+ {
+ role: "quit" as const,
+ label: mainT("common", "actions.quit") || "Quit",
+ },
+ ]),
],
},
{
label: mainT("common", "actions.edit") || "Edit",
submenu: [
- { role: "undo" },
- { role: "redo" },
+ { role: "undo", label: mainT("common", "actions.undo") || "Undo" },
+ { role: "redo", label: mainT("common", "actions.redo") || "Redo" },
{ type: "separator" },
- { role: "cut" },
- { role: "copy" },
- { role: "paste" },
- { role: "selectAll" },
+ { role: "cut", label: mainT("common", "actions.cut") || "Cut" },
+ { role: "copy", label: mainT("common", "actions.copy") || "Copy" },
+ { role: "paste", label: mainT("common", "actions.paste") || "Paste" },
+ {
+ role: "selectAll",
+ label: mainT("common", "actions.selectAll") || "Select All",
+ },
],
},
{
label: mainT("common", "actions.view") || "View",
submenu: [
- { role: "reload" },
- { role: "forceReload" },
- { role: "toggleDevTools" },
+ {
+ role: "reload",
+ label: mainT("common", "actions.reload") || "Reload",
+ },
+ {
+ role: "forceReload",
+ label: mainT("common", "actions.forceReload") || "Force Reload",
+ },
+ {
+ role: "toggleDevTools",
+ label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools",
+ },
{ type: "separator" },
- { role: "resetZoom" },
- { role: "zoomIn" },
- { role: "zoomOut" },
+ {
+ role: "resetZoom",
+ label: mainT("common", "actions.actualSize") || "Actual Size",
+ },
+ {
+ role: "zoomIn",
+ label: mainT("common", "actions.zoomIn") || "Zoom In",
+ },
+ {
+ role: "zoomOut",
+ label: mainT("common", "actions.zoomOut") || "Zoom Out",
+ },
{ type: "separator" },
- { role: "togglefullscreen" },
+ {
+ role: "togglefullscreen",
+ label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen",
+ },
],
},
{
label: mainT("common", "actions.window") || "Window",
submenu: isMac
- ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
- : [{ role: "minimize" }, { role: "close" }],
+ ? [
+ {
+ role: "minimize",
+ label: mainT("common", "actions.minimize") || "Minimize",
+ },
+ { role: "zoom" },
+ { type: "separator" },
+ { role: "front" },
+ ]
+ : [
+ {
+ role: "minimize",
+ label: mainT("common", "actions.minimize") || "Minimize",
+ },
+ {
+ role: "close",
+ label: mainT("common", "actions.close") || "Close",
+ },
+ ],
},
);
@@ -220,7 +284,9 @@ function getTrayIcon(filename: string, size: number) {
function updateTrayMenu(recording: boolean = false) {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
- const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
+ const trayToolTip = recording
+ ? mainT("common", "actions.recordingStatus", { source: selectedSourceName })
+ : "OpenScreen";
const menuTemplate = recording
? [
{
diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json
index e4f17fe..3591a29 100644
--- a/src/i18n/locales/ar/common.json
+++ b/src/i18n/locales/ar/common.json
@@ -15,7 +15,27 @@
"view": "عرض",
"window": "نافذة",
"quit": "خروج",
- "stopRecording": "ايقاف التسجيل"
+ "stopRecording": "إيقاف التسجيل",
+ "undo": "تراجع",
+ "redo": "إعادة",
+ "cut": "قص",
+ "copy": "نسخ",
+ "paste": "لصق",
+ "selectAll": "تحديد الكل",
+ "minimize": "تصغير",
+ "reload": "إعادة تحميل",
+ "forceReload": "إعادة تحميل إجبارية",
+ "toggleDevTools": "أدوات المطور",
+ "actualSize": "الحجم الفعلي",
+ "zoomIn": "تكبير",
+ "zoomOut": "تصغير",
+ "toggleFullScreen": "ملء الشاشة",
+ "recordingStatus": "جاري التسجيل: {{source}}",
+ "about": "حول OpenScreen",
+ "services": "خدمات",
+ "hide": "إخفاء OpenScreen",
+ "hideOthers": "إخفاء الآخرين",
+ "unhide": "إظهار الكل"
},
"playback": {
"play": "تشغيل",
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index cdefe84..f60a402 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -15,7 +15,27 @@
"view": "View",
"window": "Window",
"quit": "Quit",
- "stopRecording": "Stop Recording"
+ "stopRecording": "Stop Recording",
+ "undo": "Undo",
+ "redo": "Redo",
+ "cut": "Cut",
+ "copy": "Copy",
+ "paste": "Paste",
+ "selectAll": "Select All",
+ "minimize": "Minimize",
+ "reload": "Reload",
+ "forceReload": "Force Reload",
+ "toggleDevTools": "Toggle Developer Tools",
+ "actualSize": "Actual Size",
+ "zoomIn": "Zoom In",
+ "zoomOut": "Zoom Out",
+ "toggleFullScreen": "Toggle Full Screen",
+ "recordingStatus": "Recording: {{source}}",
+ "about": "About OpenScreen",
+ "services": "Services",
+ "hide": "Hide OpenScreen",
+ "hideOthers": "Hide Others",
+ "unhide": "Show All"
},
"playback": {
"play": "Play",
From a0d1cfe8c8003537115152349cca8d8a0677248e Mon Sep 17 00:00:00 2001
From: i1Zeus
Date: Sun, 3 May 2026 20:55:11 +0300
Subject: [PATCH 24/31] added ar to config and added fallback to the main.ts
recordingStatus
---
electron/main.ts | 4 +++-
src/components/video-editor/SettingsPanel.tsx | 6 ++++--
src/i18n/config.ts | 1 +
src/i18n/locales/ar/settings.json | 2 +-
4 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/electron/main.ts b/electron/main.ts
index 030a8cf..bace434 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -285,7 +285,9 @@ function updateTrayMenu(recording: boolean = false) {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
const trayToolTip = recording
- ? mainT("common", "actions.recordingStatus", { source: selectedSourceName })
+ ? mainT("common", "actions.recordingStatus", {
+ source: selectedSourceName,
+ }) || `Recording: ${selectedSourceName}`
: "OpenScreen";
const menuTemplate = recording
? [
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 343c4cf..a99a644 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1079,8 +1079,9 @@ export function SettingsPanel({
const turningOn = !cursorHighlight.onlyOnClicks;
if (turningOn) {
try {
- const result = await window.electronAPI.requestAccessibilityAccess();
- if (!result.granted) {
+ const result =
+ await window.electronAPI?.requestAccessibilityAccess?.();
+ if (!result?.granted) {
toast.message(
t("effects.cursorHighlight.accessibilityPermissionTitle"),
{
@@ -1089,6 +1090,7 @@ export function SettingsPanel({
),
},
);
+ return;
}
} catch (err) {
console.warn("Accessibility request failed:", err);
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
index 788a315..cf0b34c 100644
--- a/src/i18n/config.ts
+++ b/src/i18n/config.ts
@@ -8,6 +8,7 @@ export const SUPPORTED_LOCALES = [
"tr",
"ko-KR",
"ja-JP",
+ "ar",
] as const;
export const I18N_NAMESPACES = [
"common",
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index e21976d..2d250b1 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -173,7 +173,7 @@
"dialogTitle": "إضافة خط Google",
"urlLabel": "رابط استيراد خطوط Google",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
- "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"Get font\" → انسخ رابط @import",
+ "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
"nameLabel": "اسم العرض",
"namePlaceholder": "خطي المخصص",
"nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
From 7e00cdb1a9eb9da5fb9637921fa1dc4bd6dce54a Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sun, 3 May 2026 11:41:03 -0700
Subject: [PATCH 25/31] preview intentional perf optimizations
---
src/components/video-editor/VideoPlayback.tsx | 31 ++++++-
.../videoPlayback/videoEventHandlers.ts | 35 ++++++++
.../videoPlayback/zoomRegionUtils.ts | 87 ++++++++++++++-----
3 files changed, 131 insertions(+), 22 deletions(-)
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index a69c8d7..c35c0c7 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -232,6 +232,9 @@ const VideoPlayback = forwardRef(
const maskGraphicsRef = useRef(null);
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
+ const isScrubbingRef = useRef(false);
+ const scrubEndTimerRef = useRef(null);
+ const [isScrubbing, setIsScrubbing] = useState(false);
const allowPlaybackRef = useRef(false);
const lockedVideoDimensionsRef = useRef<{
width: number;
@@ -611,6 +614,24 @@ const VideoPlayback = forwardRef(
};
}, [pixiReady, videoReady, layoutVideoContent]);
+ // Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is
+ // navigating, not previewing) and restore native DPR on play/idle so the
+ // preview stays faithful. Mutating renderer.resolution per-frame would
+ // thrash texture uploads; we only do it on scrub-state transitions.
+ useEffect(() => {
+ if (!pixiReady) return;
+ const app = appRef.current;
+ const container = containerRef.current;
+ if (!app || !container) return;
+
+ const targetResolution = isScrubbing ? 1 : window.devicePixelRatio || 1;
+ if (app.renderer.resolution === targetResolution) return;
+
+ app.renderer.resolution = targetResolution;
+ app.renderer.resize(container.clientWidth, container.clientHeight);
+ layoutVideoContentRef.current?.();
+ }, [isScrubbing, pixiReady]);
+
useEffect(() => {
if (!pixiReady || !videoReady) return;
updateOverlayForRegion(selectedZoom);
@@ -804,6 +825,9 @@ const VideoPlayback = forwardRef(
onTimeUpdate: (time) => onTimeUpdateRef.current(time),
trimRegionsRef,
speedRegionsRef,
+ isScrubbingRef,
+ scrubEndTimerRef,
+ onScrubChange: (scrubbing) => setIsScrubbing(scrubbing),
});
video.addEventListener("play", handlePlay);
@@ -1088,7 +1112,8 @@ const VideoPlayback = forwardRef(
}
}
- const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current;
+ const isMotionBlurActive =
+ (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current;
if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) {
if (isMotionBlurActive) {
@@ -1225,6 +1250,10 @@ const VideoPlayback = forwardRef(
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
+ if (scrubEndTimerRef.current !== null) {
+ window.clearTimeout(scrubEndTimerRef.current);
+ scrubEndTimerRef.current = null;
+ }
};
}, []);
diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts
index 5542d67..a26107d 100644
--- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts
+++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts
@@ -1,6 +1,11 @@
import type React from "react";
import type { SpeedRegion, TrimRegion } from "../types";
+// Keep "scrub mode" on for a brief tail after `seeked` — rapid drag-scrubbing
+// fires `seeking`/`seeked` dozens of times per second, and toggling effects
+// each time would flicker.
+const SCRUB_END_DEBOUNCE_MS = 150;
+
interface VideoEventHandlersParams {
video: HTMLVideoElement;
isSeekingRef: React.MutableRefObject;
@@ -12,6 +17,9 @@ interface VideoEventHandlersParams {
onTimeUpdate: (time: number) => void;
trimRegionsRef: React.MutableRefObject;
speedRegionsRef: React.MutableRefObject;
+ isScrubbingRef?: React.MutableRefObject;
+ scrubEndTimerRef?: React.MutableRefObject;
+ onScrubChange?: (scrubbing: boolean) => void;
}
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
@@ -26,8 +34,18 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
onTimeUpdate,
trimRegionsRef,
speedRegionsRef,
+ isScrubbingRef,
+ scrubEndTimerRef,
+ onScrubChange,
} = params;
+ const clearScrubEndTimer = () => {
+ if (scrubEndTimerRef && scrubEndTimerRef.current !== null) {
+ window.clearTimeout(scrubEndTimerRef.current);
+ scrubEndTimerRef.current = null;
+ }
+ };
+
const emitTime = (timeValue: number) => {
currentTimeRef.current = timeValue * 1000;
onTimeUpdate(timeValue);
@@ -113,6 +131,15 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const handleSeeked = () => {
isSeekingRef.current = false;
+ if (isScrubbingRef && scrubEndTimerRef) {
+ clearScrubEndTimer();
+ scrubEndTimerRef.current = window.setTimeout(() => {
+ isScrubbingRef.current = false;
+ scrubEndTimerRef.current = null;
+ onScrubChange?.(false);
+ }, SCRUB_END_DEBOUNCE_MS);
+ }
+
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
@@ -137,6 +164,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const handleSeeking = () => {
isSeekingRef.current = true;
+ if (isScrubbingRef) {
+ clearScrubEndTimer();
+ if (!isScrubbingRef.current) {
+ isScrubbingRef.current = true;
+ onScrubChange?.(true);
+ }
+ }
+
if (!isPlayingRef.current && !video.paused) {
video.pause();
}
diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
index e5c16e1..ce31e0e 100644
--- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
+++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
@@ -254,34 +254,79 @@ function getConnectedRegionTransition(
return null;
}
-export function findDominantRegion(
- regions: ZoomRegion[],
- timeMs: number,
- options: DominantRegionOptions = {},
-): {
+type DominantRegionResult = {
region: ZoomRegion | null;
strength: number;
blendedScale: number | null;
transition: ConnectedPanTransition | null;
-} {
- const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
+};
+
+// Single-slot cache: the ticker calls findDominantRegion at 60fps with mostly
+// unchanged inputs (especially while paused). Reusing the previous result when
+// inputs match avoids the per-frame O(N) region scan + allocations.
+let dominantRegionCache: {
+ regions: ZoomRegion[];
+ timeMsKey: number;
+ telemetry: CursorTelemetryPoint[] | undefined;
+ connectZooms: boolean;
+ viewportRatio: ViewportRatio | undefined;
+ result: DominantRegionResult;
+} | null = null;
+
+export function findDominantRegion(
+ regions: ZoomRegion[],
+ timeMs: number,
+ options: DominantRegionOptions = {},
+): DominantRegionResult {
+ const connectZooms = !!options.connectZooms;
const telemetry = options.cursorTelemetry;
const vr = options.viewportRatio;
+ const timeMsKey = Math.round(timeMs);
- if (options.connectZooms) {
- const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
- if (connectedTransition) {
- return connectedTransition;
- }
-
- const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
- if (connectedHold) {
- return { ...connectedHold, transition: null };
- }
+ if (
+ dominantRegionCache &&
+ dominantRegionCache.regions === regions &&
+ dominantRegionCache.timeMsKey === timeMsKey &&
+ dominantRegionCache.telemetry === telemetry &&
+ dominantRegionCache.connectZooms === connectZooms &&
+ dominantRegionCache.viewportRatio === vr
+ ) {
+ return dominantRegionCache.result;
}
- const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
- return activeRegion
- ? { ...activeRegion, transition: null }
- : { region: null, strength: 0, blendedScale: null, transition: null };
+ const connectedPairs = connectZooms ? getConnectedRegionPairs(regions) : [];
+
+ let result: DominantRegionResult;
+ if (connectZooms) {
+ const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
+ if (connectedTransition) {
+ result = connectedTransition;
+ } else {
+ const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
+ if (connectedHold) {
+ result = { ...connectedHold, transition: null };
+ } else {
+ const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
+ result = activeRegion
+ ? { ...activeRegion, transition: null }
+ : { region: null, strength: 0, blendedScale: null, transition: null };
+ }
+ }
+ } else {
+ const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
+ result = activeRegion
+ ? { ...activeRegion, transition: null }
+ : { region: null, strength: 0, blendedScale: null, transition: null };
+ }
+
+ dominantRegionCache = {
+ regions,
+ timeMsKey,
+ telemetry,
+ connectZooms,
+ viewportRatio: vr,
+ result,
+ };
+
+ return result;
}
From 6fc19314ddfc3619c04090ead56fe0890857c3dd Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sun, 3 May 2026 12:03:23 -0700
Subject: [PATCH 26/31] fix dock macos lifecycle
---
electron/main.ts | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/electron/main.ts b/electron/main.ts
index 1da3603..0b90b89 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -352,10 +352,11 @@ function createCountdownOverlayWindowWrapper() {
return countdownOverlayWindow;
}
-// On macOS, applications and their menu bar stay active until the user quits
-// explicitly with Cmd + Q.
+// Closing every window quits the app entirely (tray icon goes too).
+// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
+// so closing the last window is an explicit "I'm done" signal.
app.on("window-all-closed", () => {
- // Keep app running (macOS behavior)
+ app.quit();
});
app.on("activate", () => {
@@ -377,6 +378,13 @@ app.on("activate", () => {
// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
+ // Force the app into "regular" activation policy so the Dock icon appears.
+ // The HUD overlay (transparent + frameless + skipTaskbar) is the first
+ // window we open, and AppKit otherwise classifies us as an accessory app.
+ if (process.platform === "darwin") {
+ app.dock?.show();
+ }
+
// Allow microphone/media permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
From 190d5d8ecb2006c355c4c6b907599784b375bf52 Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sun, 3 May 2026 17:54:21 -0700
Subject: [PATCH 27/31] 3d iso,tilt
---
src/components/video-editor/SettingsPanel.tsx | 42 +-
src/components/video-editor/VideoEditor.tsx | 24 +
src/components/video-editor/VideoPlayback.tsx | 421 ++++++++++--------
.../video-editor/projectPersistence.ts | 7 +
src/components/video-editor/types.ts | 129 ++++++
.../videoPlayback/zoomRegionUtils.ts | 29 +-
src/i18n/locales/en/settings.json | 8 +
src/lib/exporter/frameRenderer.ts | 185 ++++++--
src/lib/exporter/threeDPass.ts | 356 +++++++++++++++
9 files changed, 979 insertions(+), 222 deletions(-)
create mode 100644 src/lib/exporter/threeDPass.ts
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 5cac573..1ffa0f4 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -54,13 +54,19 @@ import type {
CropRegion,
FigureData,
PlaybackSpeed,
+ Rotation3DPreset,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamSizePreset,
ZoomDepth,
ZoomFocusMode,
} from "./types";
-import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
+import {
+ DEFAULT_WEBCAM_SIZE_PRESET,
+ MAX_PLAYBACK_SPEED,
+ ROTATION_3D_PRESET_ORDER,
+ SPEED_OPTIONS,
+} from "./types";
function CustomSpeedInput({
value,
@@ -168,6 +174,8 @@ interface SettingsPanelProps {
hasCursorTelemetry?: boolean;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
+ selectedZoomRotationPreset?: Rotation3DPreset | null;
+ onZoomRotationPresetChange?: (preset: Rotation3DPreset | null) => void;
selectedTrimId?: string | null;
onTrimDelete?: (id: string) => void;
shadowIntensity?: number;
@@ -258,6 +266,8 @@ export function SettingsPanel({
hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
+ selectedZoomRotationPreset,
+ onZoomRotationPresetChange,
selectedTrimId,
onTrimDelete,
shadowIntensity = 0,
@@ -647,6 +657,36 @@ export function SettingsPanel({
)}
)}
+ {zoomEnabled && (
+
+
+ {t("zoom.threeD.title")}
+
+
+ {ROTATION_3D_PRESET_ORDER.map((preset) => {
+ const isActive = selectedZoomRotationPreset === preset;
+ return (
+ onZoomRotationPresetChange?.(isActive ? null : preset)}
+ className={cn(
+ "h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all duration-200 ease-out cursor-pointer",
+ isActive
+ ? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
+ : "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
+ )}
+ >
+
+ {t(`zoom.threeD.preset.${preset}`)}
+
+
+ );
+ })}
+
+
+ )}
+
{zoomEnabled && (
{
+ if (!selectedZoomId) return;
+ pushState((prev) => ({
+ zoomRegions: prev.zoomRegions.map((region) => {
+ if (region.id !== selectedZoomId) return region;
+ if (preset === null) {
+ const { rotationPreset: _p, ...rest } = region;
+ return rest;
+ }
+ return { ...region, rotationPreset: preset };
+ }),
+ }));
+ },
+ [selectedZoomId, pushState],
+ );
+
const handleTrimDelete = useCallback(
(id: string) => {
pushState((prev) => ({
@@ -1996,6 +2014,12 @@ export default function VideoEditor() {
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
+ selectedZoomRotationPreset={
+ selectedZoomId
+ ? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
+ : null
+ }
+ onZoomRotationPresetChange={handleZoomRotationPresetChange}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index c35c0c7..ee52bd9 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -36,6 +36,11 @@ import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
type BlurData,
+ computeRotation3DContainScale,
+ DEFAULT_ROTATION_3D,
+ isRotation3DIdentity,
+ lerpRotation3D,
+ rotation3DPerspective,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
@@ -200,6 +205,8 @@ const VideoPlayback = forwardRef(
const overlayRef = useRef(null);
const focusIndicatorRef = useRef(null);
+ const composite3DRef = useRef(null);
+ const outerWrapperRef = useRef(null);
const [webcamLayout, setWebcamLayout] = useState(null);
const [webcamDimensions, setWebcamDimensions] = useState(null);
const currentTimeRef = useRef(0);
@@ -921,8 +928,10 @@ const VideoPlayback = forwardRef(
};
let lastMotionBlurActive: boolean | null = null;
+ let lastTransformIsIdentity = true;
+ let lastPerspectiveValue = 0;
const ticker = () => {
- const { region, strength, blendedScale, transition } = findDominantRegion(
+ const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{
@@ -1129,6 +1138,44 @@ const VideoPlayback = forwardRef(
lastMotionBlurActive = false;
}
}
+
+ const composite3D = composite3DRef.current;
+ const outerWrapper = outerWrapperRef.current;
+ if (composite3D && outerWrapper) {
+ const effectiveRotation =
+ region && targetProgress > 0 && !shouldShowUnzoomedView
+ ? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, targetProgress)
+ : DEFAULT_ROTATION_3D;
+ const isIdentity = isRotation3DIdentity(effectiveRotation);
+ if (isIdentity) {
+ if (!lastTransformIsIdentity) {
+ composite3D.style.transform = "";
+ composite3D.style.willChange = "auto";
+ lastTransformIsIdentity = true;
+ }
+ if (lastPerspectiveValue !== 0) {
+ outerWrapper.style.perspective = "";
+ lastPerspectiveValue = 0;
+ }
+ } else {
+ const wrapperW = outerWrapper.clientWidth || 1;
+ const wrapperH = outerWrapper.clientHeight || 1;
+ const persp = rotation3DPerspective(wrapperW, wrapperH);
+ const containScale = computeRotation3DContainScale(
+ effectiveRotation,
+ wrapperW,
+ wrapperH,
+ persp,
+ );
+ composite3D.style.transform = `scale(${containScale}) rotateX(${effectiveRotation.rotationX}deg) rotateY(${effectiveRotation.rotationY}deg) rotateZ(${effectiveRotation.rotationZ}deg)`;
+ composite3D.style.willChange = "transform";
+ lastTransformIsIdentity = false;
+ if (persp !== lastPerspectiveValue) {
+ outerWrapper.style.perspective = `${persp}px`;
+ lastPerspectiveValue = persp;
+ }
+ }
+ }
};
app.ticker.add(ticker);
@@ -1270,6 +1317,7 @@ const VideoPlayback = forwardRef(
return (
(
}}
/>
0
- ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
- : "none",
+ transformStyle: "preserve-3d",
+ transformOrigin: "center center",
}}
- />
- {webcamVideoPath &&
- (() => {
- const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
- const useClipPath = !!clipPath;
- return (
-
-
-
- );
- })()}
- {/* Only render overlay after PIXI and video are fully initialized */}
- {pixiReady && videoReady && (
+ >
-
- {(() => {
- const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
- if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
- return false;
-
- if (annotation.id === selectedAnnotationId) return true;
-
- const timeMs = Math.round(currentTime * 1000);
- return timeMs >= annotation.startMs && timeMs < annotation.endMs;
- });
-
- const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
- if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
- return false;
-
- if (blurRegion.id === selectedBlurId) return true;
-
- const timeMs = Math.round(currentTime * 1000);
- return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
- });
-
- const sorted = [
- ...filteredAnnotations.map((annotation) => ({
- kind: "annotation" as const,
- region: annotation,
- })),
- ...filteredBlurRegions.map((blurRegion) => ({
- kind: "blur" as const,
- region: blurRegion,
- })),
- ].sort((a, b) => a.region.zIndex - b.region.zIndex);
- const previewSnapshotCanvas =
- filteredBlurRegions.length > 0
- ? (() => {
- const app = appRef.current;
- if (!app?.renderer?.extract) return null;
- try {
- return app.renderer.extract.canvas(app.stage);
- } catch {
- return null;
- }
- })()
- : null;
-
- // Handle click-through cycling: when clicking same annotation, cycle to next
- const handleAnnotationClick = (clickedId: string) => {
- if (!onSelectAnnotation) return;
-
- // If clicking on already selected annotation and there are multiple overlapping
- if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
- // Find current index and cycle to next
- const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
- const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
- onSelectAnnotation(filteredAnnotations[nextIndex].id);
- } else {
- // First click or clicking different annotation
- onSelectAnnotation(clickedId);
- }
- };
-
- const handleBlurClick = (clickedId: string) => {
- if (!onSelectBlur) return;
-
- if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
- const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
- const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
- onSelectBlur(filteredBlurRegions[nextIndex].id);
- } else {
- onSelectBlur(clickedId);
- }
- };
-
- return sorted.map((item) => (
-
`${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
- : `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
- }
- annotation={item.region}
- isSelected={
- item.kind === "blur"
- ? item.region.id === selectedBlurId
- : item.region.id === selectedAnnotationId
- }
- containerWidth={overlaySize.width}
- containerHeight={overlaySize.height}
- onPositionChange={(id, position) =>
- item.kind === "blur"
- ? onBlurPositionChange?.(id, position)
- : onAnnotationPositionChange?.(id, position)
- }
- onSizeChange={(id, size) =>
- item.kind === "blur"
- ? onBlurSizeChange?.(id, size)
- : onAnnotationSizeChange?.(id, size)
- }
- onBlurDataChange={
- item.kind === "blur"
- ? (id, blurData) => onBlurDataChange?.(id, blurData)
- : undefined
- }
- onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
- onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
- zIndex={item.region.zIndex}
- isSelectedBoost={
- item.kind === "blur"
- ? item.region.id === selectedBlurId
- : item.region.id === selectedAnnotationId
- }
- previewSourceCanvas={previewSnapshotCanvas}
- previewFrameVersion={Math.round(currentTime * 1000)}
- />
- ));
+ ref={containerRef}
+ className="absolute inset-0"
+ style={{
+ filter:
+ showShadow && shadowIntensity > 0
+ ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
+ : "none",
+ }}
+ />
+ {webcamVideoPath &&
+ (() => {
+ const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
+ const useClipPath = !!clipPath;
+ return (
+
+
+
+ );
})()}
-
- )}
+ {/* Only render overlay after PIXI and video are fully initialized */}
+ {pixiReady && videoReady && (
+
+
+ {(() => {
+ const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
+ if (
+ typeof annotation.startMs !== "number" ||
+ typeof annotation.endMs !== "number"
+ )
+ return false;
+
+ if (annotation.id === selectedAnnotationId) return true;
+
+ const timeMs = Math.round(currentTime * 1000);
+ return timeMs >= annotation.startMs && timeMs < annotation.endMs;
+ });
+
+ const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
+ if (
+ typeof blurRegion.startMs !== "number" ||
+ typeof blurRegion.endMs !== "number"
+ )
+ return false;
+
+ if (blurRegion.id === selectedBlurId) return true;
+
+ const timeMs = Math.round(currentTime * 1000);
+ return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
+ });
+
+ const sorted = [
+ ...filteredAnnotations.map((annotation) => ({
+ kind: "annotation" as const,
+ region: annotation,
+ })),
+ ...filteredBlurRegions.map((blurRegion) => ({
+ kind: "blur" as const,
+ region: blurRegion,
+ })),
+ ].sort((a, b) => a.region.zIndex - b.region.zIndex);
+ const previewSnapshotCanvas =
+ filteredBlurRegions.length > 0
+ ? (() => {
+ const app = appRef.current;
+ if (!app?.renderer?.extract) return null;
+ try {
+ return app.renderer.extract.canvas(app.stage);
+ } catch {
+ return null;
+ }
+ })()
+ : null;
+
+ // Handle click-through cycling: when clicking same annotation, cycle to next
+ const handleAnnotationClick = (clickedId: string) => {
+ if (!onSelectAnnotation) return;
+
+ // If clicking on already selected annotation and there are multiple overlapping
+ if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
+ // Find current index and cycle to next
+ const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
+ const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
+ onSelectAnnotation(filteredAnnotations[nextIndex].id);
+ } else {
+ // First click or clicking different annotation
+ onSelectAnnotation(clickedId);
+ }
+ };
+
+ const handleBlurClick = (clickedId: string) => {
+ if (!onSelectBlur) return;
+
+ if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
+ const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
+ const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
+ onSelectBlur(filteredBlurRegions[nextIndex].id);
+ } else {
+ onSelectBlur(clickedId);
+ }
+ };
+
+ return sorted.map((item) => (
+
`${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
+ : `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
+ }
+ annotation={item.region}
+ isSelected={
+ item.kind === "blur"
+ ? item.region.id === selectedBlurId
+ : item.region.id === selectedAnnotationId
+ }
+ containerWidth={overlaySize.width}
+ containerHeight={overlaySize.height}
+ onPositionChange={(id, position) =>
+ item.kind === "blur"
+ ? onBlurPositionChange?.(id, position)
+ : onAnnotationPositionChange?.(id, position)
+ }
+ onSizeChange={(id, size) =>
+ item.kind === "blur"
+ ? onBlurSizeChange?.(id, size)
+ : onAnnotationSizeChange?.(id, size)
+ }
+ onBlurDataChange={
+ item.kind === "blur"
+ ? (id, blurData) => onBlurDataChange?.(id, blurData)
+ : undefined
+ }
+ onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
+ onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
+ zIndex={item.region.zIndex}
+ isSelectedBoost={
+ item.kind === "blur"
+ ? item.region.id === selectedBlurId
+ : item.region.id === selectedAnnotationId
+ }
+ previewSourceCanvas={previewSnapshotCanvas}
+ previewFrameVersion={Math.round(currentTime * 1000)}
+ />
+ ));
+ })()}
+
+ )}
+
): Pro
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
+ const validPreset =
+ region.rotationPreset === "iso" ||
+ region.rotationPreset === "left" ||
+ region.rotationPreset === "right"
+ ? region.rotationPreset
+ : undefined;
return {
id: region.id,
startMs,
@@ -261,6 +267,7 @@ export function normalizeProjectEditor(editor: Partial): Pro
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
focusMode: region.focusMode === "auto" ? "auto" : "manual",
+ ...(validPreset ? { rotationPreset: validPreset } : {}),
};
})
: [];
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index 046f428..f976efc 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -26,6 +26,37 @@ export interface ZoomFocus {
cy: number; // normalized vertical center (0-1)
}
+export interface Rotation3D {
+ rotationX: number;
+ rotationY: number;
+ rotationZ: number;
+}
+
+export const DEFAULT_ROTATION_3D: Rotation3D = {
+ rotationX: 0,
+ rotationY: 0,
+ rotationZ: 0,
+};
+
+export type Rotation3DPreset = "iso" | "left" | "right";
+
+export const ROTATION_3D_PRESETS: Record = {
+ iso: { rotationX: -10, rotationY: -16, rotationZ: 0 },
+ left: { rotationX: 0, rotationY: -22, rotationZ: 0 },
+ right: { rotationX: 0, rotationY: 22, rotationZ: 0 },
+};
+
+export const ROTATION_3D_PRESET_ORDER: Rotation3DPreset[] = ["iso", "left", "right"];
+
+/** Perspective distance in CSS px is computed at render-time as this factor times
+ * min(viewport width, viewport height). Same factor used in preview and export so
+ * the visual look is identical regardless of canvas resolution. */
+export const ROTATION_3D_PERSPECTIVE_FACTOR = 2.6;
+
+export function rotation3DPerspective(width: number, height: number): number {
+ return Math.min(width, height) * ROTATION_3D_PERSPECTIVE_FACTOR;
+}
+
export interface ZoomRegion {
id: string;
startMs: number;
@@ -33,6 +64,104 @@ export interface ZoomRegion {
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
+ rotationPreset?: Rotation3DPreset;
+}
+
+export function getRotation3D(region: Pick): Rotation3D {
+ if (!region.rotationPreset) return DEFAULT_ROTATION_3D;
+ return ROTATION_3D_PRESETS[region.rotationPreset];
+}
+
+export function isRotation3DIdentity(r: Rotation3D, eps = 0.01): boolean {
+ return Math.abs(r.rotationX) < eps && Math.abs(r.rotationY) < eps && Math.abs(r.rotationZ) < eps;
+}
+
+export function lerpRotation3D(a: Rotation3D, b: Rotation3D, t: number): Rotation3D {
+ return {
+ rotationX: a.rotationX + (b.rotationX - a.rotationX) * t,
+ rotationY: a.rotationY + (b.rotationY - a.rotationY) * t,
+ rotationZ: a.rotationZ + (b.rotationZ - a.rotationZ) * t,
+ };
+}
+
+/**
+ * Compute the maximum uniform scale that, when applied alongside `rot` and a perspective
+ * of `perspective` CSS px, keeps the projected bounding box of a `width × height` element
+ * inside its original `width × height` rectangle. Returns 1 when no scaling is needed.
+ *
+ * Math: project each rotated corner onto the screen via x' = x·P/(P−z); take the worst-case
+ * |x'|/|y'| against the half-extents and return the limiting ratio. This makes the rotated
+ * recording sit *inside* the zoom window instead of bleeding past it.
+ */
+export function computeRotation3DContainScale(
+ rot: Rotation3D,
+ width: number,
+ height: number,
+ perspective: number,
+): number {
+ const a = (rot.rotationX * Math.PI) / 180;
+ const b = (rot.rotationY * Math.PI) / 180;
+ const g = (rot.rotationZ * Math.PI) / 180;
+ const ca = Math.cos(a);
+ const sa = Math.sin(a);
+ const cb = Math.cos(b);
+ const sb = Math.sin(b);
+ const cg = Math.cos(g);
+ const sg = Math.sin(g);
+ const halfW = width / 2;
+ const halfH = height / 2;
+ const corners: Array<[number, number]> = [
+ [-halfW, -halfH],
+ [halfW, -halfH],
+ [halfW, halfH],
+ [-halfW, halfH],
+ ];
+
+ let maxAbsX = 0;
+ let maxAbsY = 0;
+
+ for (const [x0, y0] of corners) {
+ // CSS "rotateX(α) rotateY(β) rotateZ(γ)" reads right-to-left: Z first, then Y, then X.
+ let px = x0;
+ let py = y0;
+ let pz = 0;
+
+ // rotateZ
+ const zx = px * cg - py * sg;
+ const zy = px * sg + py * cg;
+ px = zx;
+ py = zy;
+
+ // rotateY
+ const yx = px * cb + pz * sb;
+ const yz = -px * sb + pz * cb;
+ px = yx;
+ pz = yz;
+
+ // rotateX
+ const xy = py * ca - pz * sa;
+ const xz = py * sa + pz * ca;
+ py = xy;
+ pz = xz;
+
+ // Perspective projection: viewer at (0, 0, P), looking toward −z. A point at z=pz
+ // is scaled by P / (P − pz). When perspective ≤ 0 we treat as orthographic.
+ if (perspective > 0) {
+ const denom = perspective - pz;
+ if (denom <= 0) return 1; // pathological — skip scaling rather than crash
+ const f = perspective / denom;
+ px *= f;
+ py *= f;
+ }
+
+ if (Math.abs(px) > maxAbsX) maxAbsX = Math.abs(px);
+ if (Math.abs(py) > maxAbsY) maxAbsY = Math.abs(py);
+ }
+
+ if (maxAbsX === 0 || maxAbsY === 0) return 1;
+ const sx = halfW / maxAbsX;
+ const sy = halfH / maxAbsY;
+ return Math.min(sx, sy, 1);
}
export interface CursorTelemetryPoint {
diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
index ce31e0e..5647054 100644
--- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
+++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
@@ -1,5 +1,5 @@
-import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
-import { ZOOM_DEPTH_SCALES } from "../types";
+import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
+import { DEFAULT_ROTATION_3D, getRotation3D, lerpRotation3D, ZOOM_DEPTH_SCALES } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { interpolateCursorAt } from "./cursorFollowUtils";
import { clampFocusToScale } from "./focusUtils";
@@ -164,6 +164,7 @@ function getActiveRegion(
},
strength: activeRegions[0].strength,
blendedScale: null,
+ rotation3D: getRotation3D(activeRegion),
};
}
@@ -189,6 +190,7 @@ function getConnectedRegionHold(
},
strength: 1,
blendedScale: null,
+ rotation3D: getRotation3D(pair.nextRegion),
};
}
}
@@ -233,6 +235,11 @@ function getConnectedRegionTransition(
viewportRatio,
);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
+ const transitionRotation = lerpRotation3D(
+ getRotation3D(currentRegion),
+ getRotation3D(nextRegion),
+ transitionProgress,
+ );
return {
region: {
@@ -241,6 +248,7 @@ function getConnectedRegionTransition(
},
strength: 1,
blendedScale: transitionScale,
+ rotation3D: transitionRotation,
transition: {
progress: transitionProgress,
startFocus: currentFocus,
@@ -258,6 +266,7 @@ type DominantRegionResult = {
region: ZoomRegion | null;
strength: number;
blendedScale: number | null;
+ rotation3D: Rotation3D;
transition: ConnectedPanTransition | null;
};
@@ -309,14 +318,26 @@ export function findDominantRegion(
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
result = activeRegion
? { ...activeRegion, transition: null }
- : { region: null, strength: 0, blendedScale: null, transition: null };
+ : {
+ region: null,
+ strength: 0,
+ blendedScale: null,
+ rotation3D: DEFAULT_ROTATION_3D,
+ transition: null,
+ };
}
}
} else {
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
result = activeRegion
? { ...activeRegion, transition: null }
- : { region: null, strength: 0, blendedScale: null, transition: null };
+ : {
+ region: null,
+ strength: 0,
+ blendedScale: null,
+ rotation3D: DEFAULT_ROTATION_3D,
+ transition: null,
+ };
}
dominantRegionCache = {
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 9b85c2b..d926709 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -8,6 +8,14 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
+ },
+ "threeD": {
+ "title": "3D Rotation",
+ "preset": {
+ "iso": "Iso",
+ "left": "Left",
+ "right": "Right"
+ }
}
},
"speed": {
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index 0a151b0..5906c70 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -11,13 +11,19 @@ import { MotionBlurFilter } from "pixi-filters/motion-blur";
import type {
AnnotationRegion,
CropRegion,
+ Rotation3D,
SpeedRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
-import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
+import {
+ DEFAULT_ROTATION_3D,
+ isRotation3DIdentity,
+ lerpRotation3D,
+ ZOOM_DEPTH_SCALES,
+} from "@/components/video-editor/types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
@@ -60,6 +66,7 @@ import {
parseCssGradient,
resolveLinearGradientAngle,
} from "./gradientParser";
+import { createThreeDPass, type ThreeDPass } from "./threeDPass";
interface FrameRenderConfig {
width: number;
@@ -124,8 +131,12 @@ export class FrameRenderer {
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
private compositeCtx: CanvasRenderingContext2D | null = null;
+ private foregroundCanvas: HTMLCanvasElement | null = null;
+ private foregroundCtx: CanvasRenderingContext2D | null = null;
private rasterCanvas: HTMLCanvasElement | null = null;
private rasterCtx: CanvasRenderingContext2D | null = null;
+ private threeDPass: ThreeDPass | null = null;
+ private currentRotation3D: Rotation3D = { ...DEFAULT_ROTATION_3D };
private config: FrameRenderConfig;
private animationState: AnimationState;
private layoutCache: LayoutCache | null = null;
@@ -217,6 +228,19 @@ export class FrameRenderer {
throw new Error("Failed to get 2D context for raster canvas");
}
+ // Foreground canvas: holds recording + shadow + webcam + cursor + annotations,
+ // transparent background. The 3D rotation pass operates only on this layer so
+ // the wallpaper stays flat behind the rotated content (matching preview).
+ this.foregroundCanvas = document.createElement("canvas");
+ this.foregroundCanvas.width = this.config.width;
+ this.foregroundCanvas.height = this.config.height;
+ this.foregroundCtx = this.foregroundCanvas.getContext("2d", {
+ willReadFrequently: this.isLinux,
+ });
+ if (!this.foregroundCtx) {
+ throw new Error("Failed to get 2D context for foreground canvas");
+ }
+
// Setup shadow canvas if needed
if (this.config.showShadow) {
this.shadowCanvas = document.createElement("canvas");
@@ -235,6 +259,13 @@ export class FrameRenderer {
this.maskGraphics = new Graphics();
this.videoContainer.addChild(this.maskGraphics);
this.videoContainer.mask = this.maskGraphics;
+
+ try {
+ this.threeDPass = createThreeDPass(this.config.width, this.config.height);
+ } catch (error) {
+ console.warn("[FrameRenderer] 3D pass unavailable, rotation fields will be ignored:", error);
+ this.threeDPass = null;
+ }
}
private async setupBackground(): Promise {
@@ -392,15 +423,18 @@ export class FrameRenderer {
// Render the PixiJS stage to its canvas (video only, transparent background)
this.app.renderer.render(this.app.stage);
- // Composite with shadows to final output canvas
- this.compositeWithShadows(webcamFrame);
+ // Skip baking the shadow when the WebGL rotation pass will run — it'd alias to
+ // a hard edge through bilinear sampling. We re-apply shadow fresh after rotation.
+ const willRotate = !isRotation3DIdentity(this.currentRotation3D);
+ this.compositeWithShadows(webcamFrame, !willRotate);
// Cursor highlight overlay (rendered above video, below annotations)
+ // Drawn onto foreground so it rotates with the recording.
if (
this.config.cursorHighlight?.enabled &&
this.config.cursorTelemetry &&
this.config.cursorTelemetry.length > 0 &&
- this.compositeCtx
+ this.foregroundCtx
) {
const emphasisAlpha = clickEmphasisAlpha(
timeMs,
@@ -423,7 +457,7 @@ export class FrameRenderer {
const previewH = this.config.previewHeight ?? this.config.height;
const cursorScale = (this.config.width / previewW + this.config.height / previewH) / 2;
drawCursorHighlightCanvas(
- this.compositeCtx,
+ this.foregroundCtx,
canvasX,
canvasY,
{
@@ -435,13 +469,12 @@ export class FrameRenderer {
}
}
- // Render annotations on top if present
+ // Render annotations on top of foreground (so they rotate with recording).
if (
this.config.annotationRegions &&
this.config.annotationRegions.length > 0 &&
- this.compositeCtx
+ this.foregroundCtx
) {
- // Calculate scale factor based on export vs preview dimensions
const previewWidth = this.config.previewWidth ?? this.config.width;
const previewHeight = this.config.previewHeight ?? this.config.height;
const scaleX = this.config.width / previewWidth;
@@ -449,7 +482,7 @@ export class FrameRenderer {
const scaleFactor = (scaleX + scaleY) / 2;
await renderAnnotations(
- this.compositeCtx,
+ this.foregroundCtx,
this.config.annotationRegions,
this.config.width,
this.config.height,
@@ -457,6 +490,58 @@ export class FrameRenderer {
scaleFactor,
);
}
+
+ // Apply 3D rotation to foreground only. Wallpaper (on compositeCanvas) is untouched.
+ if (willRotate && this.threeDPass && this.foregroundCanvas && this.foregroundCtx) {
+ const passCanvas = this.threeDPass.apply(this.foregroundCanvas, this.currentRotation3D);
+ const w = this.foregroundCanvas.width;
+ const h = this.foregroundCanvas.height;
+ this.foregroundCtx.clearRect(0, 0, w, h);
+ if (this.isLinux) {
+ // drawImage(webglCanvas) is unreliable on Linux/Wayland — use readPixels.
+ const pixels = this.threeDPass.readPixels();
+ const imageData = this.foregroundCtx.createImageData(w, h);
+ imageData.data.set(pixels);
+ this.foregroundCtx.putImageData(imageData, 0, 0);
+ } else {
+ this.foregroundCtx.drawImage(passCanvas, 0, 0);
+ }
+ }
+
+ // Apply shadow fresh on the rotated silhouette (flat path already baked it
+ // in compositeWithShadows, so guard on willRotate to avoid doubling).
+ // Same 3-layer filter chain as `main` — keeps the soft Gaussian intact.
+ if (
+ willRotate &&
+ this.config.showShadow &&
+ this.config.shadowIntensity > 0 &&
+ this.shadowCanvas &&
+ this.shadowCtx &&
+ this.foregroundCanvas
+ ) {
+ const shadowCtx = this.shadowCtx;
+ const w = this.foregroundCanvas.width;
+ const h = this.foregroundCanvas.height;
+ shadowCtx.clearRect(0, 0, w, h);
+ shadowCtx.save();
+ const intensity = this.config.shadowIntensity;
+ const baseBlur1 = 48 * intensity;
+ const baseBlur2 = 16 * intensity;
+ const baseBlur3 = 8 * intensity;
+ const baseAlpha1 = 0.7 * intensity;
+ const baseAlpha2 = 0.5 * intensity;
+ const baseAlpha3 = 0.3 * intensity;
+ const baseOffset = 12 * intensity;
+ shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
+ shadowCtx.drawImage(this.foregroundCanvas, 0, 0, w, h);
+ shadowCtx.restore();
+ if (this.compositeCtx) {
+ this.compositeCtx.drawImage(this.shadowCanvas, 0, 0);
+ }
+ } else if (this.compositeCtx && this.foregroundCanvas) {
+ // Flat path or 3D-without-shadow: stamp foreground directly.
+ this.compositeCtx.drawImage(this.foregroundCanvas, 0, 0);
+ }
}
private updateLayout(webcamFrame?: VideoFrame | null): void {
@@ -564,7 +649,7 @@ export class FrameRenderer {
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
- const { region, strength, blendedScale, transition } = findDominantRegion(
+ const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion(
this.config.zoomRegions,
timeMs,
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry },
@@ -575,6 +660,11 @@ export class FrameRenderer {
let targetFocus = { ...defaultFocus };
let targetProgress = 0;
+ this.currentRotation3D =
+ region && strength > 0
+ ? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, strength)
+ : { ...DEFAULT_ROTATION_3D };
+
if (region && strength > 0) {
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
@@ -747,38 +837,52 @@ export class FrameRenderer {
return this.rasterCanvas;
}
- private compositeWithShadows(webcamFrame?: VideoFrame | null): void {
- if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
+ // `applyShadowToRecording` is false when the 3D pass will rotate this canvas
+ // next — the shadow gets re-applied after rotation to avoid aliasing.
+ private compositeWithShadows(
+ webcamFrame: VideoFrame | null | undefined,
+ applyShadowToRecording: boolean,
+ ): void {
+ if (
+ !this.compositeCanvas ||
+ !this.compositeCtx ||
+ !this.foregroundCanvas ||
+ !this.foregroundCtx ||
+ !this.app
+ )
+ return;
const videoCanvas = this.isLinux
? this.readbackVideoCanvas()
: (this.app.canvas as HTMLCanvasElement);
- const ctx = this.compositeCtx;
+ const bgCtx = this.compositeCtx;
+ const fgCtx = this.foregroundCtx;
const w = this.compositeCanvas.width;
const h = this.compositeCanvas.height;
- // Clear composite canvas
- ctx.clearRect(0, 0, w, h);
-
- // Step 1: Draw background layer (with optional blur, not affected by zoom)
+ // Background layer (compositeCanvas): wallpaper only. Stays flat — never
+ // touched by the 3D rotation pass, matching preview behavior.
+ bgCtx.clearRect(0, 0, w, h);
if (this.backgroundSprite) {
const bgCanvas = this.backgroundSprite;
-
if (this.config.showBlur) {
- ctx.save();
- ctx.filter = "blur(6px)"; // Canvas blur is weaker than CSS
- ctx.drawImage(bgCanvas, 0, 0, w, h);
- ctx.restore();
+ bgCtx.save();
+ bgCtx.filter = "blur(6px)"; // Canvas blur is weaker than CSS
+ bgCtx.drawImage(bgCanvas, 0, 0, w, h);
+ bgCtx.restore();
} else {
- ctx.drawImage(bgCanvas, 0, 0, w, h);
+ bgCtx.drawImage(bgCanvas, 0, 0, w, h);
}
} else {
console.warn("[FrameRenderer] No background sprite found during compositing!");
}
- // Draw video layer with shadows on top of background
+ // Foreground (transparent): recording + webcam. Shadow only baked here on
+ // the flat path; the 3D path applies it after rotation (see renderFrame).
+ fgCtx.clearRect(0, 0, w, h);
if (
+ applyShadowToRecording &&
this.config.showShadow &&
this.config.shadowIntensity > 0 &&
this.shadowCanvas &&
@@ -788,7 +892,6 @@ export class FrameRenderer {
shadowCtx.clearRect(0, 0, w, h);
shadowCtx.save();
- // Calculate shadow parameters based on intensity (0-1)
const intensity = this.config.shadowIntensity;
const baseBlur1 = 48 * intensity;
const baseBlur2 = 16 * intensity;
@@ -801,9 +904,9 @@ export class FrameRenderer {
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
shadowCtx.drawImage(videoCanvas, 0, 0, w, h);
shadowCtx.restore();
- ctx.drawImage(this.shadowCanvas, 0, 0, w, h);
+ fgCtx.drawImage(this.shadowCanvas, 0, 0, w, h);
} else {
- ctx.drawImage(videoCanvas, 0, 0, w, h);
+ fgCtx.drawImage(videoCanvas, 0, 0, w, h);
}
const webcamRect = this.layoutCache?.webcamRect ?? null;
@@ -826,9 +929,9 @@ export class FrameRenderer {
sourceAspect > targetAspect ? sourceHeight : Math.round(sourceWidth / targetAspect);
const sourceCropX = Math.max(0, Math.round((sourceWidth - sourceCropWidth) / 2));
const sourceCropY = Math.max(0, Math.round((sourceHeight - sourceCropHeight) / 2));
- ctx.save();
+ fgCtx.save();
drawCanvasClipPath(
- ctx,
+ fgCtx,
webcamRect.x,
webcamRect.y,
webcamRect.width,
@@ -837,15 +940,15 @@ export class FrameRenderer {
webcamRect.borderRadius,
);
if (preset.shadow) {
- ctx.shadowColor = preset.shadow.color;
- ctx.shadowBlur = preset.shadow.blur;
- ctx.shadowOffsetX = preset.shadow.offsetX;
- ctx.shadowOffsetY = preset.shadow.offsetY;
+ fgCtx.shadowColor = preset.shadow.color;
+ fgCtx.shadowBlur = preset.shadow.blur;
+ fgCtx.shadowOffsetX = preset.shadow.offsetX;
+ fgCtx.shadowOffsetY = preset.shadow.offsetY;
}
- ctx.fillStyle = "#000000";
- ctx.fill();
- ctx.clip();
- ctx.drawImage(
+ fgCtx.fillStyle = "#000000";
+ fgCtx.fill();
+ fgCtx.clip();
+ fgCtx.drawImage(
webcamFrame as unknown as CanvasImageSource,
sourceCropX,
sourceCropY,
@@ -856,7 +959,7 @@ export class FrameRenderer {
webcamRect.width,
webcamRect.height,
);
- ctx.restore();
+ fgCtx.restore();
}
}
@@ -890,7 +993,13 @@ export class FrameRenderer {
this.shadowCtx = null;
this.compositeCanvas = null;
this.compositeCtx = null;
+ this.foregroundCanvas = null;
+ this.foregroundCtx = null;
this.rasterCanvas = null;
this.rasterCtx = null;
+ if (this.threeDPass) {
+ this.threeDPass.destroy();
+ this.threeDPass = null;
+ }
}
}
diff --git a/src/lib/exporter/threeDPass.ts b/src/lib/exporter/threeDPass.ts
new file mode 100644
index 0000000..5733bd0
--- /dev/null
+++ b/src/lib/exporter/threeDPass.ts
@@ -0,0 +1,356 @@
+import type { Rotation3D } from "@/components/video-editor/types";
+import {
+ computeRotation3DContainScale,
+ isRotation3DIdentity,
+ rotation3DPerspective,
+} from "@/components/video-editor/types";
+
+// CSS uses +y down, WebGL clip space uses +y up. We do all rotation math in CSS
+// convention (top-left origin, +y down) to match the preview, then flip
+// gl_Position.y at the end so WebGL's clip space lands the input's top edge at
+// the top of the output viewport.
+const VERTEX_SHADER = `#version 300 es
+in vec2 aPos;
+in vec2 aUV;
+out vec2 vUV;
+uniform mat4 uMvp;
+uniform vec2 uSize;
+void main() {
+ vUV = aUV;
+ vec2 px = (aPos - 0.5) * uSize;
+ vec4 clip = uMvp * vec4(px, 0.0, 1.0);
+ clip.y = -clip.y;
+ gl_Position = clip;
+}
+`;
+
+const FRAGMENT_SHADER = `#version 300 es
+precision highp float;
+in vec2 vUV;
+out vec4 fragColor;
+uniform sampler2D uTex;
+void main() {
+ fragColor = texture(uTex, vUV);
+}
+`;
+
+function deg2rad(deg: number): number {
+ return (deg * Math.PI) / 180;
+}
+
+function multiplyMat4(a: Float32Array, b: Float32Array): Float32Array {
+ const out = new Float32Array(16);
+ for (let i = 0; i < 4; i += 1) {
+ for (let j = 0; j < 4; j += 1) {
+ let s = 0;
+ for (let k = 0; k < 4; k += 1) {
+ s += a[k * 4 + j] * b[i * 4 + k];
+ }
+ out[i * 4 + j] = s;
+ }
+ }
+ return out;
+}
+
+function rotationXMat(rad: number): Float32Array {
+ const c = Math.cos(rad);
+ const s = Math.sin(rad);
+ return new Float32Array([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]);
+}
+
+function rotationYMat(rad: number): Float32Array {
+ const c = Math.cos(rad);
+ const s = Math.sin(rad);
+ return new Float32Array([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]);
+}
+
+function rotationZMat(rad: number): Float32Array {
+ const c = Math.cos(rad);
+ const s = Math.sin(rad);
+ return new Float32Array([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
+}
+
+function translationMat(x: number, y: number, z: number): Float32Array {
+ return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]);
+}
+
+function perspectiveMat(fovY: number, aspect: number, near: number, far: number): Float32Array {
+ const f = 1 / Math.tan(fovY / 2);
+ const nf = 1 / (near - far);
+ return new Float32Array([
+ f / aspect,
+ 0,
+ 0,
+ 0,
+ 0,
+ f,
+ 0,
+ 0,
+ 0,
+ 0,
+ (far + near) * nf,
+ -1,
+ 0,
+ 0,
+ 2 * far * near * nf,
+ 0,
+ ]);
+}
+
+function scaleMat(s: number): Float32Array {
+ return new Float32Array([s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
+}
+
+export function buildMvpMatrix(rot: Rotation3D, w: number, h: number): Float32Array {
+ const rx = rotationXMat(deg2rad(rot.rotationX));
+ const ry = rotationYMat(deg2rad(rot.rotationY));
+ const rz = rotationZMat(deg2rad(rot.rotationZ));
+ const rotMat = multiplyMat4(rz, multiplyMat4(ry, rx));
+
+ const perspective = rotation3DPerspective(w, h);
+ const containScale = computeRotation3DContainScale(rot, w, h, perspective);
+ const rotScaled = multiplyMat4(rotMat, scaleMat(containScale));
+
+ const d = perspective;
+ const fovY = 2 * Math.atan2(h / 2, d);
+ const proj = perspectiveMat(fovY, w / h, 0.1, d * 4 + Math.max(w, h));
+ const view = translationMat(0, 0, -d);
+ return multiplyMat4(proj, multiplyMat4(view, rotScaled));
+}
+
+function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
+ const shader = gl.createShader(type);
+ if (!shader) throw new Error("Failed to create shader");
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ const info = gl.getShaderInfoLog(shader);
+ gl.deleteShader(shader);
+ throw new Error(`Shader compile failed: ${info}`);
+ }
+ return shader;
+}
+
+function createProgram(gl: WebGL2RenderingContext): WebGLProgram {
+ const vs = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
+ const fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
+ const program = gl.createProgram();
+ if (!program) throw new Error("Failed to create program");
+ gl.attachShader(program, vs);
+ gl.attachShader(program, fs);
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ const info = gl.getProgramInfoLog(program);
+ gl.deleteProgram(program);
+ throw new Error(`Program link failed: ${info}`);
+ }
+ gl.deleteShader(vs);
+ gl.deleteShader(fs);
+ return program;
+}
+
+export interface ThreeDPass {
+ apply(srcCanvas: HTMLCanvasElement | OffscreenCanvas, rot: Rotation3D): HTMLCanvasElement;
+ /**
+ * Reads back the most recent apply() result into a Uint8ClampedArray suitable
+ * for ImageData. Use this on platforms where drawImage(webglCanvas) is unreliable.
+ */
+ readPixels(): Uint8ClampedArray;
+ resize(width: number, height: number): void;
+ destroy(): void;
+}
+
+export function createThreeDPass(width: number, height: number): ThreeDPass {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const gl = canvas.getContext("webgl2", { premultipliedAlpha: true, alpha: true });
+ if (!gl) throw new Error("WebGL2 not available for 3D pass");
+
+ const program = createProgram(gl);
+ // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API, not a React hook
+ gl.useProgram(program);
+
+ const aPos = gl.getAttribLocation(program, "aPos");
+ const aUV = gl.getAttribLocation(program, "aUV");
+ const uMvp = gl.getUniformLocation(program, "uMvp");
+ const uSize = gl.getUniformLocation(program, "uSize");
+ const uTex = gl.getUniformLocation(program, "uTex");
+
+ const vao = gl.createVertexArray();
+ gl.bindVertexArray(vao);
+
+ // Quad: two triangles sharing UVs consistently per corner.
+ // pos.y ranges 0 (top of input) → 1 (bottom of input) following CSS convention.
+ // UV.y is inverted (1 - pos.y) so that with UNPACK_FLIP_Y_WEBGL the texture
+ // sample at the top of the input lands at the top of the rendered quad.
+ // TL: pos(0,0) uv(0,1) TR: pos(1,0) uv(1,1)
+ // BL: pos(0,1) uv(0,0) BR: pos(1,1) uv(1,0)
+ const verts = new Float32Array([
+ // aPos.x, aPos.y, aUV.x, aUV.y
+ 0,
+ 0,
+ 0,
+ 1, // TL
+ 1,
+ 0,
+ 1,
+ 1, // TR
+ 0,
+ 1,
+ 0,
+ 0, // BL
+ 0,
+ 1,
+ 0,
+ 0, // BL
+ 1,
+ 0,
+ 1,
+ 1, // TR (was 1,0,1,0 — broken)
+ 1,
+ 1,
+ 1,
+ 0, // BR
+ ]);
+ const vbo = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
+ gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
+ gl.enableVertexAttribArray(aPos);
+ gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0);
+ gl.enableVertexAttribArray(aUV);
+ gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 16, 8);
+
+ const texture = gl.createTexture();
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ // Plain bilinear, NO mipmaps. Mipmaps pre-blur the texture for downsampling, but
+ // at our moderate rotation angles (≤22°) the receding edge would still pick a
+ // smaller mipmap level, which softens fine details — specifically the few-pixel
+ // rounded-corner anti-alias ramp and the shadow's Gaussian falloff. The result
+ // is "rounding looks like a hard corner / shadow looks grimy". Sampling level 0
+ // directly preserves the source crispness.
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+
+ // Anisotropic filtering still helps without mipmaps: at oblique viewing angles
+ // it samples multiple texels along the gradient direction at level 0, recovering
+ // detail that plain bilinear would lose. Cap to the device max (16× typical).
+ const anisoExt =
+ gl.getExtension("EXT_texture_filter_anisotropic") ||
+ gl.getExtension("MOZ_EXT_texture_filter_anisotropic") ||
+ gl.getExtension("WEBKIT_EXT_texture_filter_anisotropic");
+ if (anisoExt) {
+ const maxAniso = gl.getParameter(anisoExt.MAX_TEXTURE_MAX_ANISOTROPY_EXT) as number;
+ gl.texParameterf(gl.TEXTURE_2D, anisoExt.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(16, maxAniso));
+ }
+ gl.uniform1i(uTex, 0);
+
+ let currentSize = { width, height };
+
+ const apply = (
+ srcCanvas: HTMLCanvasElement | OffscreenCanvas,
+ rot: Rotation3D,
+ ): HTMLCanvasElement => {
+ gl.viewport(0, 0, currentSize.width, currentSize.height);
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.useProgram(program);
+ gl.bindVertexArray(vao);
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
+ // CRITICAL: premultiply on upload. The source 2D canvas stores non-premultiplied
+ // RGBA (alpha=0 areas have RGB=0). Bilinear filtering between an inside-the-shape
+ // texel (alpha=1, RGB=color) and an outside texel (alpha=0, RGB=0) in
+ // non-premultiplied space yields (color/2, alpha=0.5), which the
+ // premultipliedAlpha:true canvas then interprets as half-strength color — visible
+ // as a dark halo around rounded corners and softened/grimy shadows. Premultiplying
+ // at upload time makes the bilinear math operate in linear-light premultiplied
+ // space, which is exactly the math used for compositing. Edges and shadows then
+ // reproduce the source crisply.
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ srcCanvas as TexImageSource,
+ );
+
+ const mvp = isRotation3DIdentity(rot)
+ ? buildMvpMatrix(
+ { rotationX: 0, rotationY: 0, rotationZ: 0 },
+ currentSize.width,
+ currentSize.height,
+ )
+ : buildMvpMatrix(rot, currentSize.width, currentSize.height);
+ gl.uniformMatrix4fv(uMvp, false, mvp);
+ gl.uniform2f(uSize, currentSize.width, currentSize.height);
+
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ return canvas;
+ };
+
+ const resize = (w: number, h: number) => {
+ if (w === currentSize.width && h === currentSize.height) return;
+ canvas.width = w;
+ canvas.height = h;
+ currentSize = { width: w, height: h };
+ };
+
+ const readPixels = (): Uint8ClampedArray => {
+ const w = currentSize.width;
+ const h = currentSize.height;
+ const buf = new Uint8Array(w * h * 4);
+ gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
+ // gl.readPixels is bottom-up; flip to top-down for ImageData. We also need
+ // to un-premultiply the alpha here: the framebuffer holds premultiplied RGBA
+ // (we set UNPACK_PREMULTIPLY_ALPHA_WEBGL=true on upload), but ImageData /
+ // putImageData expect non-premultiplied. Without this divide, semi-transparent
+ // pixels get interpreted as darker than they should be.
+ const rowSize = w * 4;
+ const out = new Uint8ClampedArray(buf.length);
+ for (let row = 0; row < h; row += 1) {
+ const src = (h - 1 - row) * rowSize;
+ const dst = row * rowSize;
+ for (let col = 0; col < rowSize; col += 4) {
+ const r = buf[src + col];
+ const g = buf[src + col + 1];
+ const b = buf[src + col + 2];
+ const a = buf[src + col + 3];
+ if (a === 0) {
+ out[dst + col] = 0;
+ out[dst + col + 1] = 0;
+ out[dst + col + 2] = 0;
+ out[dst + col + 3] = 0;
+ } else if (a === 255) {
+ out[dst + col] = r;
+ out[dst + col + 1] = g;
+ out[dst + col + 2] = b;
+ out[dst + col + 3] = 255;
+ } else {
+ const inv = 255 / a;
+ out[dst + col] = Math.min(255, Math.round(r * inv));
+ out[dst + col + 1] = Math.min(255, Math.round(g * inv));
+ out[dst + col + 2] = Math.min(255, Math.round(b * inv));
+ out[dst + col + 3] = a;
+ }
+ }
+ }
+ return out;
+ };
+
+ const destroy = () => {
+ gl.deleteProgram(program);
+ gl.deleteBuffer(vbo);
+ gl.deleteVertexArray(vao);
+ gl.deleteTexture(texture);
+ };
+
+ return { apply, readPixels, resize, destroy };
+}
From 81b1eb3e8a0819b1b1cf003f0a1f1dc58f594056 Mon Sep 17 00:00:00 2001
From: auberginewly <3127221787@qq.com>
Date: Tue, 5 May 2026 06:37:21 +0800
Subject: [PATCH 28/31] =?UTF-8?q?fix(i18n):=20=E8=A1=A5=E5=85=85=207=20?=
=?UTF-8?q?=E4=B8=AA=E8=AF=AD=E8=A8=80=E7=BC=BA=E5=A4=B1=E7=9A=84=20zoom.t?=
=?UTF-8?q?hreeD=20=E7=BF=BB=E8=AF=91=E9=94=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
es/fr/ja-JP/ko-KR/tr/zh-CN/zh-TW 的 settings.json 均缺少
zoom.threeD.title 和 zoom.threeD.preset.{iso,left,right},
导致 npm run i18n:check 报告 MISSING。
---
src/i18n/locales/es/settings.json | 8 ++++++++
src/i18n/locales/fr/settings.json | 8 ++++++++
src/i18n/locales/ja-JP/settings.json | 8 ++++++++
src/i18n/locales/ko-KR/settings.json | 8 ++++++++
src/i18n/locales/tr/settings.json | 8 ++++++++
src/i18n/locales/zh-CN/settings.json | 8 ++++++++
src/i18n/locales/zh-TW/settings.json | 8 ++++++++
7 files changed, 56 insertions(+)
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 423b158..20a9ec3 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -8,6 +8,14 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
+ },
+ "threeD": {
+ "title": "Rotación 3D",
+ "preset": {
+ "iso": "Iso",
+ "left": "Izquierda",
+ "right": "Derecha"
+ }
}
},
"speed": {
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 66df1ba..66a6d74 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -15,6 +15,14 @@
"fast": "Rapide",
"smooth": "Fluide",
"lazy": "Lent"
+ },
+ "threeD": {
+ "title": "Rotation 3D",
+ "preset": {
+ "iso": "Iso",
+ "left": "Gauche",
+ "right": "Droite"
+ }
}
},
"speed": {
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 800d078..1ccc381 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -8,6 +8,14 @@
"manual": "手動",
"auto": "自動",
"autoDescription": "表示範囲が録画中のカーソル位置に追従します"
+ },
+ "threeD": {
+ "title": "3D回転",
+ "preset": {
+ "iso": "Iso",
+ "left": "左",
+ "right": "右"
+ }
}
},
"speed": {
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 08fdb3c..268ba57 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -8,6 +8,14 @@
"manual": "수동",
"auto": "자동",
"autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다"
+ },
+ "threeD": {
+ "title": "3D 회전",
+ "preset": {
+ "iso": "Iso",
+ "left": "왼쪽",
+ "right": "오른쪽"
+ }
}
},
"speed": {
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 143c467..f639558 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -8,6 +8,14 @@
"manual": "Manuel",
"auto": "Otomatik",
"autoDescription": "Kamera kaydedilen imleç konumunu takip eder"
+ },
+ "threeD": {
+ "title": "3D Döndürme",
+ "preset": {
+ "iso": "Iso",
+ "left": "Sol",
+ "right": "Sağ"
+ }
}
},
"speed": {
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 708ae5e..ff157dc 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -8,6 +8,14 @@
"manual": "手动",
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
+ },
+ "threeD": {
+ "title": "3D 旋转",
+ "preset": {
+ "iso": "Iso",
+ "left": "左",
+ "right": "右"
+ }
}
},
"speed": {
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index c93e8d5..50ca00c 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -15,6 +15,14 @@
"fast": "快速",
"smooth": "平滑",
"lazy": "緩慢"
+ },
+ "threeD": {
+ "title": "3D 旋轉",
+ "preset": {
+ "iso": "Iso",
+ "left": "左",
+ "right": "右"
+ }
}
},
"speed": {
From c13ec0df7dc6cb8a10a93e3288df64395d67b54d Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Mon, 4 May 2026 19:48:30 -0700
Subject: [PATCH 29/31] fix build to exclude uiohook
---
.github/workflows/build.yml | 12 +++---------
package.json | 4 ++--
2 files changed, 5 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 35177bc..d586686 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -20,18 +20,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
-
+
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
-
+
- name: Install dependencies
run: npm ci
-
- - name: Install app dependencies
- run: npx electron-builder install-app-deps
-
+
- name: Build Windows app
run: npm run build:win
env:
@@ -234,9 +231,6 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Install app dependencies
- run: npx electron-builder install-app-deps
-
- name: Build Linux app
run: npm run build:linux
env:
diff --git a/package.json b/package.json
index 2e8421e..546108c 100644
--- a/package.json
+++ b/package.json
@@ -21,8 +21,8 @@
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
- "build:win": "tsc && vite build && electron-builder --win",
- "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman",
+ "build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
+ "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
From 6a6caf618bc9033fa8c1d3c0c3b22e5065c38931 Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Tue, 5 May 2026 20:29:53 -0700
Subject: [PATCH 30/31] fix build
---
.github/workflows/build.yml | 5 +++++
nix/package.nix | 2 +-
package.json | 2 +-
3 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d586686..1f85736 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -231,6 +231,11 @@ jobs:
- name: Install dependencies
run: npm ci
+ # bsdtar (from libarchive-tools) is required by fpm to build pacman
+ # packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it.
+ - name: Install pacman build dependencies
+ run: sudo apt-get update && sudo apt-get install -y libarchive-tools
+
- name: Build Linux app
run: npm run build:linux
env:
diff --git a/nix/package.nix b/nix/package.nix
index 13a8658..95018b2 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -11,7 +11,7 @@
buildNpmPackage {
nodejs = nodejs_22;
pname = "openscreen";
- version = "1.3.0";
+ version = "1.4.0";
src =
let
diff --git a/package.json b/package.json
index 546108c..2ccb0b3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "openscreen",
"private": true,
- "version": "1.3.0",
+ "version": "1.4.0",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
From 899504f8e2090fe6d14f647e4f7154da36cfbd2e Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Tue, 5 May 2026 22:02:21 -0700
Subject: [PATCH 31/31] fix export mouse overlay
---
src/lib/exporter/frameRenderer.ts | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index 5906c70..1e5138c 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -627,12 +627,19 @@ export class FrameRenderer {
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
this.maskGraphics.fill({ color: 0xffffff });
- // Cache layout info
+ // Cache layout info. baseOffset is the stage position of the FULL
+ // (uncropped) video sprite's top-left — matches preview semantics so
+ // downstream consumers (e.g. cursor highlight) can map normalized
+ // recording-space coordinates to stage coordinates uniformly:
+ // stagePos = baseOffset + (cx, cy) * (videoWidth, videoHeight) * baseScale
this.layoutCache = {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
- baseOffset: { x: compositeLayout.screenRect.x, y: compositeLayout.screenRect.y },
+ baseOffset: {
+ x: compositeLayout.screenRect.x + coverOffsetX - cropPixelX,
+ y: compositeLayout.screenRect.y + coverOffsetY - cropPixelY,
+ },
maskRect: compositeLayout.screenRect,
webcamRect: compositeLayout.webcamRect,
};