Date: Mon, 6 Apr 2026 20:37:05 +0200
Subject: [PATCH 02/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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 dc7259ba0944ed71ec7a263572d2c9e9e13faa84 Mon Sep 17 00:00:00 2001
From: david
Date: Wed, 29 Apr 2026 10:31:08 +0200
Subject: [PATCH 11/55] fix: bumped npmDepsHash on package.nix
---
nix/package.nix | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nix/package.nix b/nix/package.nix
index 13a8658..6ece133 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -33,7 +33,7 @@ buildNpmPackage {
);
};
- npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
+ npmDepsHash = "sha256-i8QMhvd/ydFPww7qTG3Bz2LOAIFyp65n1NXakr3MTk8=";
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
From 8e8b194454e48f7216e3fed21cf4882bd5d373df Mon Sep 17 00:00:00 2001
From: BaptisteAuscher
Date: Thu, 30 Apr 2026 22:22:46 +0200
Subject: [PATCH 12/55] 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 a38454a7fb03a5e736c52abf58a6ed5c280bb63a Mon Sep 17 00:00:00 2001
From: AbhinRustagi
Date: Sat, 2 May 2026 01:02:42 +0530
Subject: [PATCH 13/55] feat: update saveExportedVideo fn signature
---
electron/electron-env.d.ts | 1 +
electron/preload.ts | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 85d8294..f04b7c3 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -76,6 +76,7 @@ interface Window {
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
+ exportFolder?: string,
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
diff --git a/electron/preload.ts b/electron/preload.ts
index 46e16f0..ec221b0 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -68,8 +68,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
- saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
- return ipcRenderer.invoke("save-exported-video", videoData, fileName);
+ saveExportedVideo: (videoData: ArrayBuffer, fileName: string, exportFolder?: string) => {
+ return ipcRenderer.invoke("save-exported-video", videoData, fileName, exportFolder);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
From c40727672ffa206d7f57f38a2913906f03dfcbe0 Mon Sep 17 00:00:00 2001
From: AbhinRustagi
Date: Sat, 2 May 2026 01:05:17 +0530
Subject: [PATCH 14/55] feat: implement handlers to store last export location
---
electron/ipc/handlers.ts | 102 ++++++++++++--------
src/components/video-editor/VideoEditor.tsx | 19 +++-
src/lib/userPreferences.ts | 18 ++++
3 files changed, 94 insertions(+), 45 deletions(-)
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 95ed797..9a8e9ca 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -822,54 +822,72 @@ export function registerIpcHandlers(
* @returns Object with success status, optional file path, and error details.
*/
- ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
- try {
- // Determine file type from extension
- const isGif = fileName.toLowerCase().endsWith(".gif");
- const filters = isGif
- ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
- : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
+ ipcMain.handle(
+ "save-exported-video",
+ async (_, videoData: ArrayBuffer, fileName: string, exportFolder?: string) => {
+ try {
+ // Determine file type from extension
+ const isGif = fileName.toLowerCase().endsWith(".gif");
+ const filters = isGif
+ ? [{ 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"],
- });
+ // Prefer the user's last export folder if it still exists, otherwise fall
+ // back to ~/Downloads. Validation must happen here because the renderer
+ // can't stat the filesystem.
+ let defaultDir = app.getPath("downloads");
+ if (exportFolder) {
+ try {
+ const stats = await fs.stat(exportFolder);
+ if (stats.isDirectory()) {
+ defaultDir = exportFolder;
+ }
+ } catch {
+ // Folder was moved or deleted since the last export; keep Downloads.
+ }
+ }
- if (result.canceled || !result.filePath) {
+ const result = await dialog.showSaveDialog({
+ title: isGif
+ ? mainT("dialogs", "fileDialogs.saveGif")
+ : mainT("dialogs", "fileDialogs.saveVideo"),
+ defaultPath: path.join(defaultDir, fileName),
+ filters,
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ });
+
+ if (result.canceled || !result.filePath) {
+ return {
+ success: false,
+ canceled: true,
+ message: "Export canceled",
+ };
+ }
+
+ // --- FIX: Normalize the path for Windows compatibility ---
+ const normalizedPath = path.normalize(result.filePath);
+
+ // Ensure the parent directory exists (Windows may fail if the folder is missing)
+ await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
+ // --- END FIX ---
+
+ await fs.writeFile(normalizedPath, Buffer.from(videoData));
+
+ return {
+ success: true,
+ path: normalizedPath,
+ message: "Video exported successfully",
+ };
+ } catch (error) {
+ console.error("Failed to save exported video:", error);
return {
success: false,
- canceled: true,
- message: "Export canceled",
+ message: "Failed to save exported video",
+ error: String(error),
};
}
-
- // --- FIX: Normalize the path for Windows compatibility ---
- const normalizedPath = path.normalize(result.filePath);
-
- // Ensure the parent directory exists (Windows may fail if the folder is missing)
- await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
- // --- END FIX ---
-
- await fs.writeFile(normalizedPath, Buffer.from(videoData));
-
- return {
- success: true,
- path: normalizedPath,
- message: "Video exported successfully",
- };
- } catch (error) {
- console.error("Failed to save exported video:", error);
- return {
- success: false,
- message: "Failed to save exported video",
- error: String(error),
- };
- }
- });
+ },
+ );
ipcMain.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 7adc558..cf174fa 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -31,7 +31,7 @@ import {
import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
-import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
+import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences";
import { BackgroundLoadError } from "@/lib/wallpaper";
import {
getAspectRatioValue,
@@ -1285,6 +1285,10 @@ export default function VideoEditor() {
const handleExportSaved = useCallback(
(formatLabel: "GIF" | "Video", filePath: string) => {
setExportedFilePath(filePath);
+ const folder = parentDirectoryOf(filePath);
+ if (folder) {
+ saveUserPreferences({ exportFolder: folder });
+ }
toast.success(
t("export.exportedSuccessfully", {
format: formatLabel,
@@ -1309,6 +1313,7 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(
unsavedExport.arrayBuffer,
unsavedExport.fileName,
+ loadUserPreferences().exportFolder ?? undefined,
);
if (saveResult.canceled) {
toast.info("Export canceled");
@@ -1410,7 +1415,11 @@ export default function VideoEditor() {
}
}
- const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
+ const saveResult = await window.electronAPI.saveExportedVideo(
+ arrayBuffer,
+ fileName,
+ loadUserPreferences().exportFolder ?? undefined,
+ );
if (saveResult.canceled) {
setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
@@ -1550,7 +1559,11 @@ export default function VideoEditor() {
}
}
- const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
+ const saveResult = await window.electronAPI.saveExportedVideo(
+ arrayBuffer,
+ fileName,
+ loadUserPreferences().exportFolder ?? undefined,
+ );
if (saveResult.canceled) {
setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts
index e060788..6947da5 100644
--- a/src/lib/userPreferences.ts
+++ b/src/lib/userPreferences.ts
@@ -23,6 +23,8 @@ export interface UserPreferences {
exportQuality: ExportQuality;
/** Default export format */
exportFormat: ExportFormat;
+ /** Folder used for the most recent successful export, if any */
+ exportFolder: string | null;
}
const DEFAULT_PREFS: UserPreferences = {
@@ -30,6 +32,7 @@ const DEFAULT_PREFS: UserPreferences = {
aspectRatio: "16:9",
exportQuality: "good",
exportFormat: "mp4",
+ exportFolder: null,
};
function safeJsonParse(text: string | null): Record | null {
@@ -76,9 +79,24 @@ export function loadUserPreferences(): UserPreferences {
raw.exportFormat === "gif" || raw.exportFormat === "mp4"
? (raw.exportFormat as ExportFormat)
: DEFAULT_PREFS.exportFormat,
+ exportFolder:
+ typeof raw.exportFolder === "string" && raw.exportFolder.length > 0
+ ? raw.exportFolder
+ : DEFAULT_PREFS.exportFolder,
};
}
+/**
+ * Extracts the parent directory from a saved file path. Handles both POSIX
+ * and Windows separators since the path comes from the OS save dialog.
+ * Returns null if no separator is found.
+ */
+export function parentDirectoryOf(filePath: string): string | null {
+ const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
+ if (lastSep <= 0) return null;
+ return filePath.slice(0, lastSep);
+}
+
/**
* Persist user preferences to localStorage.
* Only the explicitly provided fields are updated.
From b801c1ccea42522e752fc9b72a733d492e262400 Mon Sep 17 00:00:00 2001
From: AbhinRustagi
Date: Sat, 2 May 2026 01:19:44 +0530
Subject: [PATCH 15/55] fix: resolve comments
---
src/lib/userPreferences.test.ts | 26 ++++++++++++++++++++++++++
src/lib/userPreferences.ts | 17 ++++++++++++++++-
2 files changed, 42 insertions(+), 1 deletion(-)
create mode 100644 src/lib/userPreferences.test.ts
diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts
new file mode 100644
index 0000000..5ba9fce
--- /dev/null
+++ b/src/lib/userPreferences.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { parentDirectoryOf } from "./userPreferences";
+
+describe("parentDirectoryOf", () => {
+ it("returns the directory for a POSIX path", () => {
+ expect(parentDirectoryOf("/Users/me/Movies/clip.mp4")).toBe("/Users/me/Movies");
+ });
+
+ it("returns the directory for a Windows path", () => {
+ expect(parentDirectoryOf("C:\\Users\\me\\Movies\\clip.mp4")).toBe("C:\\Users\\me\\Movies");
+ });
+
+ it("preserves the POSIX root when the file is at /", () => {
+ expect(parentDirectoryOf("/video.mp4")).toBe("/");
+ });
+
+ it("preserves the Windows drive root with its trailing separator", () => {
+ expect(parentDirectoryOf("C:\\video.mp4")).toBe("C:\\");
+ expect(parentDirectoryOf("D:/video.mp4")).toBe("D:/");
+ });
+
+ it("returns null when no separator is present", () => {
+ expect(parentDirectoryOf("video.mp4")).toBeNull();
+ expect(parentDirectoryOf("")).toBeNull();
+ });
+});
diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts
index 6947da5..2c9db6f 100644
--- a/src/lib/userPreferences.ts
+++ b/src/lib/userPreferences.ts
@@ -89,11 +89,26 @@ export function loadUserPreferences(): UserPreferences {
/**
* Extracts the parent directory from a saved file path. Handles both POSIX
* and Windows separators since the path comes from the OS save dialog.
+ *
+ * Root directories are preserved with their trailing separator so that the
+ * value is still a valid directory path:
+ * "/video.mp4" -> "/"
+ * "C:\\video.mp4" -> "C:\\"
+ *
* Returns null if no separator is found.
*/
export function parentDirectoryOf(filePath: string): string | null {
const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
- if (lastSep <= 0) return null;
+ if (lastSep < 0) return null;
+
+ // POSIX root, e.g. "/video.mp4" -> "/"
+ if (lastSep === 0) return filePath[0];
+
+ // Windows drive root, e.g. "C:\\video.mp4" -> "C:\\"
+ if (lastSep === 2 && /^[A-Za-z]:[/\\]/.test(filePath)) {
+ return filePath.slice(0, lastSep + 1);
+ }
+
return filePath.slice(0, lastSep);
}
From b3469c469b474a3a3aa32b909088b8611ee96f58 Mon Sep 17 00:00:00 2001
From: makaradam
Date: Sat, 2 May 2026 12:28:04 +0200
Subject: [PATCH 16/55] feat: replace native OS close dialog with custom in-app
dialog
---
electron/electron-env.d.ts | 2 +
electron/main.ts | 43 ++++------
electron/preload.ts | 8 ++
.../video-editor/UnsavedChangesDialog.tsx | 78 +++++++++++++++++++
src/components/video-editor/VideoEditor.tsx | 31 ++++++++
5 files changed, 136 insertions(+), 26 deletions(-)
create mode 100644 src/components/video-editor/UnsavedChangesDialog.tsx
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 85d8294..f4b379f 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -143,6 +143,8 @@ interface Window {
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void;
+ onRequestCloseConfirm: (callback: () => void) => () => void;
+ sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void;
setLocale: (locale: string) => Promise;
};
}
diff --git a/electron/main.ts b/electron/main.ts
index ad0a33f..5540419 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
- dialog,
ipcMain,
Menu,
nativeImage,
@@ -288,35 +287,27 @@ function createEditorWindowWrapper() {
event.preventDefault();
- const choice = dialog.showMessageBoxSync(mainWindow!, {
- type: "warning",
- buttons: [
- mainT("dialogs", "unsavedChanges.saveAndClose"),
- mainT("dialogs", "unsavedChanges.discardAndClose"),
- mainT("common", "actions.cancel"),
- ],
- defaultId: 0,
- cancelId: 2,
- title: mainT("dialogs", "unsavedChanges.title"),
- message: mainT("dialogs", "unsavedChanges.message"),
- detail: mainT("dialogs", "unsavedChanges.detail"),
- });
-
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
- if (choice === 0) {
- // Save & Close — tell renderer to save, then close
- windowToClose.webContents.send("request-save-before-close");
- ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
- if (!shouldClose) return;
+ // Ask renderer to show the custom in-app dialog
+ windowToClose.webContents.send("request-close-confirm");
+
+ ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => {
+ if (!windowToClose || windowToClose.isDestroyed()) return;
+
+ if (choice === "save") {
+ // Tell renderer to save the project, then close when done
+ windowToClose.webContents.send("request-save-before-close");
+ ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
+ if (!shouldClose) return;
+ forceCloseEditorWindow(windowToClose);
+ });
+ } else if (choice === "discard") {
forceCloseEditorWindow(windowToClose);
- });
- } else if (choice === 1) {
- // Discard & Close
- forceCloseEditorWindow(windowToClose);
- }
- // choice === 2: Cancel — do nothing, window stays open
+ }
+ // "cancel": do nothing, window stays open
+ });
});
}
diff --git a/electron/preload.ts b/electron/preload.ts
index 46e16f0..2e065bd 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -163,4 +163,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
},
+ onRequestCloseConfirm: (callback: () => void) => {
+ const listener = () => callback();
+ ipcRenderer.on("request-close-confirm", listener);
+ return () => ipcRenderer.removeListener("request-close-confirm", listener);
+ },
+ sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => {
+ ipcRenderer.send("close-confirm-response", choice);
+ },
});
diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx
new file mode 100644
index 0000000..9b8ee03
--- /dev/null
+++ b/src/components/video-editor/UnsavedChangesDialog.tsx
@@ -0,0 +1,78 @@
+import { Save, Trash2, X } from "lucide-react";
+import { useScopedT } from "@/contexts/I18nContext";
+
+interface UnsavedChangesDialogProps {
+ isOpen: boolean;
+ onSaveAndClose: () => void;
+ onDiscardAndClose: () => void;
+ onCancel: () => void;
+}
+
+export function UnsavedChangesDialog({
+ isOpen,
+ onSaveAndClose,
+ onDiscardAndClose,
+ onCancel,
+}: UnsavedChangesDialogProps) {
+ const td = useScopedT("dialogs");
+ const tc = useScopedT("common");
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+
+
+
+
+
+ {td("unsavedChanges.title")}
+
+
+
+
+
+
+
{td("unsavedChanges.message")}
+
{td("unsavedChanges.detail")}
+
+
+
+
+ {td("unsavedChanges.saveAndClose")}
+
+
+
+ {td("unsavedChanges.discardAndClose")}
+
+
+ {tc("actions.cancel")}
+
+
+
+ >
+ );
+}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 7adc558..14c695a 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -74,6 +74,7 @@ import {
type ZoomFocusMode,
type ZoomRegion,
} from "./types";
+import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
export default function VideoEditor() {
@@ -144,6 +145,7 @@ export default function VideoEditor() {
format: string;
} | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
+ const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
const playerContainerRef = useRef(null);
const videoPlaybackRef = useRef(null);
@@ -524,6 +526,28 @@ export default function VideoEditor() {
return () => cleanup();
}, [saveProject]);
+ useEffect(() => {
+ const cleanup = window.electronAPI.onRequestCloseConfirm(() => {
+ setShowCloseConfirmDialog(true);
+ });
+ return () => cleanup();
+ }, []);
+
+ const handleCloseConfirmSave = useCallback(() => {
+ setShowCloseConfirmDialog(false);
+ window.electronAPI.sendCloseConfirmResponse("save");
+ }, []);
+
+ const handleCloseConfirmDiscard = useCallback(() => {
+ setShowCloseConfirmDialog(false);
+ window.electronAPI.sendCloseConfirmResponse("discard");
+ }, []);
+
+ const handleCloseConfirmCancel = useCallback(() => {
+ setShowCloseConfirmDialog(false);
+ window.electronAPI.sendCloseConfirmResponse("cancel");
+ }, []);
+
const handleSaveProject = useCallback(async () => {
await saveProject(false);
}, [saveProject]);
@@ -2066,6 +2090,13 @@ export default function VideoEditor() {
exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined
}
/>
+
+
);
}
From 36076aaf2a3efd77213d11474c38d81177e1e7be Mon Sep 17 00:00:00 2001
From: makaradam
Date: Sat, 2 May 2026 13:08:52 +0200
Subject: [PATCH 17/55] fix: address code review feedback on custom close
dialog
---
electron/main.ts | 7 ++-
.../video-editor/UnsavedChangesDialog.tsx | 63 +++++++++----------
2 files changed, 36 insertions(+), 34 deletions(-)
diff --git a/electron/main.ts b/electron/main.ts
index 5540419..94f0a42 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -252,6 +252,7 @@ function updateTrayMenu(recording: boolean = false) {
let editorHasUnsavedChanges = false;
let isForceClosing = false;
+let isCloseConfirmInFlight = false;
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
@@ -283,9 +284,10 @@ function createEditorWindowWrapper() {
editorHasUnsavedChanges = false;
mainWindow.on("close", (event) => {
- if (isForceClosing || !editorHasUnsavedChanges) return;
+ if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return;
event.preventDefault();
+ isCloseConfirmInFlight = true;
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
@@ -294,6 +296,7 @@ function createEditorWindowWrapper() {
windowToClose.webContents.send("request-close-confirm");
ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => {
+ isCloseConfirmInFlight = false;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === "save") {
@@ -306,7 +309,7 @@ function createEditorWindowWrapper() {
} else if (choice === "discard") {
forceCloseEditorWindow(windowToClose);
}
- // "cancel": do nothing, window stays open
+ // "cancel": flag reset, window stays open
});
});
}
diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx
index 9b8ee03..a0623ba 100644
--- a/src/components/video-editor/UnsavedChangesDialog.tsx
+++ b/src/components/video-editor/UnsavedChangesDialog.tsx
@@ -1,4 +1,11 @@
-import { Save, Trash2, X } from "lucide-react";
+import { Save, Trash2 } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
interface UnsavedChangesDialogProps {
@@ -17,41 +24,33 @@ export function UnsavedChangesDialog({
const td = useScopedT("dialogs");
const tc = useScopedT("common");
- if (!isOpen) return null;
-
return (
- <>
-
-
-
-
-
- {td("unsavedChanges.title")}
-
-
-
-
-
+
!open && onCancel()}>
+
+
+
+
+
+ {td("unsavedChanges.title")}
+
+
+
{td("unsavedChanges.message")}
- {td("unsavedChanges.detail")}
+
+ {td("unsavedChanges.detail")}
+
{td("unsavedChanges.saveAndClose")}
@@ -59,7 +58,7 @@ export function UnsavedChangesDialog({
{td("unsavedChanges.discardAndClose")}
@@ -67,12 +66,12 @@ export function UnsavedChangesDialog({
{tc("actions.cancel")}
-
- >
+
+
);
}
From b2cc7226135117165e0b0fc539a913b5e4246d54 Mon Sep 17 00:00:00 2001
From: makaradam
Date: Sat, 2 May 2026 13:43:20 +0200
Subject: [PATCH 18/55] fix: use getAssetPath for logo so it resolves correctly
in packaged app
---
src/components/video-editor/UnsavedChangesDialog.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx
index a0623ba..f3f88dc 100644
--- a/src/components/video-editor/UnsavedChangesDialog.tsx
+++ b/src/components/video-editor/UnsavedChangesDialog.tsx
@@ -7,6 +7,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
+import getAssetPath from "@/lib/assetPath";
interface UnsavedChangesDialogProps {
isOpen: boolean;
@@ -30,7 +31,7 @@ export function UnsavedChangesDialog({
Date: Sat, 2 May 2026 14:33:14 +0200
Subject: [PATCH 19/55] fix: use relative path for logo so it resolves in
packaged app
./openscreen.png resolves correctly both in dev (Vite serves public/)
and in production (loadFile sets base to dist/, where public assets land
inside the asar). getAssetPath points to extraResources, which is the
wrong location for bundled dist assets.
---
src/components/video-editor/UnsavedChangesDialog.tsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx
index f3f88dc..902b142 100644
--- a/src/components/video-editor/UnsavedChangesDialog.tsx
+++ b/src/components/video-editor/UnsavedChangesDialog.tsx
@@ -7,7 +7,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
-import getAssetPath from "@/lib/assetPath";
interface UnsavedChangesDialogProps {
isOpen: boolean;
@@ -31,7 +30,7 @@ export function UnsavedChangesDialog({
Date: Sat, 2 May 2026 14:36:59 +0200
Subject: [PATCH 20/55] fix: scope IPC close-confirm responses to the
originating window
Both ipcMain.once handlers now check event.sender.id against
windowToClose.webContents.id and ignore messages from any other
renderer, preventing cross-window response mix-ups if multiple editor
windows are ever open simultaneously.
---
electron/main.ts | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/electron/main.ts b/electron/main.ts
index 94f0a42..3e0b232 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -295,14 +295,16 @@ function createEditorWindowWrapper() {
// Ask renderer to show the custom in-app dialog
windowToClose.webContents.send("request-close-confirm");
- ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => {
+ ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
+ if (event.sender.id !== windowToClose?.webContents.id) return;
isCloseConfirmInFlight = false;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === "save") {
// Tell renderer to save the project, then close when done
windowToClose.webContents.send("request-save-before-close");
- ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
+ ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
+ if (event.sender.id !== windowToClose?.webContents.id) return;
if (!shouldClose) return;
forceCloseEditorWindow(windowToClose);
});
From d59db3d8392ba6de30fcfaa817881beee7d11079 Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sat, 2 May 2026 17:34:47 -0700
Subject: [PATCH 21/55] 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 22/55] 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 23/55] 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 24/55] 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 25/55] =?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 26/55] 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 27/55] 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 28/55] 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 29/55] 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 30/55] 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 31/55] 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 32/55] 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 33/55] 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 34/55] 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 35/55] 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 36/55] 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 37/55] =?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 38/55] 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 39/55] 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 40/55] 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,
};
From 6130c66be6e12c68314657ea772fcf6084f9b08b Mon Sep 17 00:00:00 2001
From: psychosomat
Date: Wed, 6 May 2026 12:55:01 +0300
Subject: [PATCH 41/55] Add Russian localization
---
.../tutorialHelpTranslations.test.ts | 2 +
src/i18n/config.ts | 1 +
src/i18n/locales/ru/common.json | 50 +++++
src/i18n/locales/ru/dialogs.json | 70 ++++++
src/i18n/locales/ru/editor.json | 45 ++++
src/i18n/locales/ru/launch.json | 43 ++++
src/i18n/locales/ru/settings.json | 202 ++++++++++++++++++
src/i18n/locales/ru/shortcuts.json | 37 ++++
src/i18n/locales/ru/timeline.json | 55 +++++
9 files changed, 505 insertions(+)
create mode 100644 src/i18n/locales/ru/common.json
create mode 100644 src/i18n/locales/ru/dialogs.json
create mode 100644 src/i18n/locales/ru/editor.json
create mode 100644 src/i18n/locales/ru/launch.json
create mode 100644 src/i18n/locales/ru/settings.json
create mode 100644 src/i18n/locales/ru/shortcuts.json
create mode 100644 src/i18n/locales/ru/timeline.json
diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts
index fcfa9d3..54979d5 100644
--- a/src/i18n/__tests__/tutorialHelpTranslations.test.ts
+++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts
@@ -6,6 +6,7 @@ import frDialogs from "@/i18n/locales/fr/dialogs.json";
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
import trDialogs from "@/i18n/locales/tr/dialogs.json";
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
+import ruDialogs from "@/i18n/locales/ru/dialogs.json";
const tutorialHelpKeys = [
"triggerLabel",
@@ -39,6 +40,7 @@ const dialogsByLocale = {
fr: frDialogs,
tr: trDialogs,
"ko-KR": koKRDialogs,
+ ru: ruDialogs,
} satisfies Record }>;
describe("TutorialHelp translations", () => {
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
index cf0b34c..9b0d6b9 100644
--- a/src/i18n/config.ts
+++ b/src/i18n/config.ts
@@ -9,6 +9,7 @@ export const SUPPORTED_LOCALES = [
"ko-KR",
"ja-JP",
"ar",
+ "ru",
] as const;
export const I18N_NAMESPACES = [
"common",
diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json
new file mode 100644
index 0000000..e001a5f
--- /dev/null
+++ b/src/i18n/locales/ru/common.json
@@ -0,0 +1,50 @@
+{
+ "actions": {
+ "cancel": "Отмена",
+ "save": "Сохранить",
+ "delete": "Удалить",
+ "close": "Закрыть",
+ "share": "Поделиться",
+ "done": "Готово",
+ "open": "Открыть",
+ "upload": "Загрузить",
+ "export": "Экспорт",
+ "showInFolder": "Показать в папке",
+ "file": "Файл",
+ "edit": "Редактировать",
+ "view": "Вид",
+ "window": "Окно",
+ "quit": "Выход",
+ "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": "Воспроизвести",
+ "pause": "Пауза",
+ "fullscreen": "Полный экран",
+ "exitFullscreen": "Выход из полного экрана"
+ },
+ "locale": {
+ "name": "Русский",
+ "short": "RU"
+ }
+}
diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json
new file mode 100644
index 0000000..40b4113
--- /dev/null
+++ b/src/i18n/locales/ru/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/ru/editor.json b/src/i18n/locales/ru/editor.json
new file mode 100644
index 0000000..098ca7a
--- /dev/null
+++ b/src/i18n/locales/ru/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/ru/launch.json b/src/i18n/locales/ru/launch.json
new file mode 100644
index 0000000..e4373e3
--- /dev/null
+++ b/src/i18n/locales/ru/launch.json
@@ -0,0 +1,43 @@
+{
+ "tooltips": {
+ "hideHUD": "Скрыть HUD",
+ "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/ru/settings.json b/src/i18n/locales/ru/settings.json
new file mode 100644
index 0000000..e3be5ed
--- /dev/null
+++ b/src/i18n/locales/ru/settings.json
@@ -0,0 +1,202 @@
+{
+ "zoom": {
+ "level": "Уровень масштабирования",
+ "selectRegion": "Выберите область масштабирования для настройки",
+ "deleteZoom": "Удалить масштабирование",
+ "focusMode": {
+ "title": "Режим фокуса",
+ "manual": "Ручной",
+ "auto": "Авто",
+ "autoDescription": "Камера следует за записанной позицией курсора"
+ },
+ "threeD": {
+ "title": "3D вращение",
+ "preset": {
+ "iso": "Изометрия",
+ "left": "Слева",
+ "right": "Справа"
+ }
+ }
+ },
+ "speed": {
+ "playbackSpeed": "Скорость воспроизведения",
+ "selectRegion": "Выберите область скорости для настройки",
+ "deleteRegion": "Удалить область скорости",
+ "customPlaybackSpeed": "Пользовательская скорость воспроизведения",
+ "maxSpeedError": "Скорость не может быть выше 16×"
+ },
+ "trim": {
+ "deleteRegion": "Удалить область обрезки"
+ },
+ "layout": {
+ "title": "Макет",
+ "preset": "Пресет",
+ "selectPreset": "Выбрать пресет",
+ "pictureInPicture": "Картинка в картинке",
+ "verticalStack": "Вертикальный стек",
+ "dualFrame": "Двойной кадр",
+ "webcamShape": "Форма камеры",
+ "webcamSize": "Размер веб-камеры"
+ },
+ "effects": {
+ "title": "Видеоэффекты",
+ "blurBg": "Размытие фона",
+ "motionBlur": "Размытие движения",
+ "off": "выкл",
+ "on": "вкл",
+ "shadow": "Тень",
+ "roundness": "Скругление",
+ "padding": "Отступ",
+ "cursorHighlight": {
+ "title": "Подсветка курсора",
+ "style": "Стиль",
+ "dot": "Точка",
+ "ring": "Кольцо",
+ "size": "Размер",
+ "onlyOnClicks": "Только при кликах",
+ "color": "Цвет",
+ "offsetX": "Смещение X (записи окон)",
+ "offsetY": "Смещение Y",
+ "accessibilityPermissionTitle": "Требуется разрешение на доступность",
+ "accessibilityPermissionDescription": "Откройте Системные настройки → Конфиденциальность и безопасность → Универсальный доступ, включите Openscreen, затем перезапустите приложение."
+ }
+ },
+ "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": "URL импорта Google Fonts",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
+ "nameLabel": "Отображаемое имя",
+ "namePlaceholder": "Мой пользовательский шрифт",
+ "nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
+ "addButton": "Добавить шрифт",
+ "addingButton": "Добавление...",
+ "errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
+ "errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
+ "errorEmptyName": "Пожалуйста, введите имя шрифта",
+ "errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
+ "successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
+ "failedToAdd": "Не удалось добавить шрифт",
+ "errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
+ "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts."
+ },
+ "language": {
+ "title": "Язык"
+ }
+}
diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json
new file mode 100644
index 0000000..ab6eae8
--- /dev/null
+++ b/src/i18n/locales/ru/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": "Удалить выбранное (альт)",
+ "panTimeline": "Панорамирование таймлайна",
+ "zoomTimeline": "Масштабирование таймлайна",
+ "frameBack": "Кадр назад",
+ "frameForward": "Кадр вперёд"
+ }
+}
diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json
new file mode 100644
index 0000000..fe9ce46
--- /dev/null
+++ b/src/i18n/locales/ru/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": "Сначала запишите screencast для генерации предложений на основе курсора.",
+ "noUsableTelemetry": "Нет пригодной телеметрии курсора",
+ "noUsableTelemetryDescription": "Запись не содержит достаточно данных о движении курсора.",
+ "noDwellMoments": "Не найдено чётких моментов задержки курсора",
+ "noDwellMomentsDescription": "Попробуйте запись с более медленными паузами курсора на важных действиях.",
+ "noAutoZoomSlots": "Нет доступных слотов авто-масштабирования",
+ "noAutoZoomSlotsDescription": "Обнаруженные точки задержки перекрывают существующие области масштабирования.",
+ "cannotPlaceTrim": "Невозможно разместить обрезку здесь",
+ "trimExistsAtLocation": "Обрезка уже существует в этом месте или недостаточно свободного места.",
+ "cannotPlaceSpeed": "Невозможно разместить изменение скорости здесь",
+ "speedExistsAtLocation": "Область изменения скорости уже существует в этом месте или недостаточно свободного места."
+ },
+ "success": {
+ "addedZoomSuggestions": "Добавлено {{count}} предложение масштабирования на основе курсора",
+ "addedZoomSuggestionsPlural": "Добавлено {{count}} предложений масштабирования на основе курсора"
+ }
+}
From 9336e3d3c6a1bcef7a1f33f2f06ca273751b1077 Mon Sep 17 00:00:00 2001
From: psychosomat
Date: Wed, 6 May 2026 13:16:21 +0300
Subject: [PATCH 42/55] Fix Russian translation typo and reorder imports
---
src/i18n/__tests__/tutorialHelpTranslations.test.ts | 2 +-
src/i18n/locales/ru/editor.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts
index 54979d5..a7bc726 100644
--- a/src/i18n/__tests__/tutorialHelpTranslations.test.ts
+++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts
@@ -4,9 +4,9 @@ import enDialogs from "@/i18n/locales/en/dialogs.json";
import esDialogs from "@/i18n/locales/es/dialogs.json";
import frDialogs from "@/i18n/locales/fr/dialogs.json";
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
+import ruDialogs from "@/i18n/locales/ru/dialogs.json";
import trDialogs from "@/i18n/locales/tr/dialogs.json";
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
-import ruDialogs from "@/i18n/locales/ru/dialogs.json";
const tutorialHelpKeys = [
"triggerLabel",
diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json
index 098ca7a..c5616d2 100644
--- a/src/i18n/locales/ru/editor.json
+++ b/src/i18n/locales/ru/editor.json
@@ -35,7 +35,7 @@
"recording": {
"failedCameraAccess": "Не удалось запросить доступ к камере.",
"cameraBlocked": "Доступ к камере заблокирован. Включите его в системных настройках для использования веб-камеры.",
- "systemAudioUnavailable": "Системный аудио недоступен. Запись без системного аудио.",
+ "systemAudioUnavailable": "Системное аудио недоступно. Запись без системного аудио.",
"microphoneDenied": "Доступ к микрофону запрещён. Запись продолжится без аудио.",
"cameraDenied": "Доступ к камере запрещён. Запись продолжится без веб-камеры.",
"cameraDisconnected": "Веб-камера отключена.",
From ada1f434f708f2b718bfa52e3df7d93e16901132 Mon Sep 17 00:00:00 2001
From: Ayusman Singhal
Date: Thu, 7 May 2026 12:19:48 +0530
Subject: [PATCH 43/55] feat: add 'No Webcam' layout preset to hide webcam in
final recording
Adds a new 'No Webcam' option to the webcam layout preset dropdown in the editor. When selected, the webcam feed is completely hidden from both the preview and the exported video, allowing users who recorded with a webcam to exclude it from the final output.
- Add 'no-webcam' to WebcamLayoutPreset type union and preset map
- Handle 'no-webcam' in computeCompositeLayout (returns webcamRect: null)
- Add 'no-webcam' case in project persistence normalization
- Add 'No Webcam' option to the layout preset dropdown in SettingsPanel
- Add 'noWebcam' i18n translation key (en)
---
src/components/video-editor/SettingsPanel.tsx | 5 ++-
.../video-editor/projectPersistence.ts | 1 +
src/i18n/locales/en/settings.json | 1 +
src/lib/compositeLayout.ts | 32 ++++++++++++++++++-
4 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 76ff762..6cadead 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -821,6 +821,7 @@ export function SettingsPanel({
{WEBCAM_LAYOUT_PRESETS.filter((preset) => {
if (preset.value === "picture-in-picture") return true;
+ if (preset.value === "no-webcam") return true;
if (preset.value === "vertical-stack") return isPortraitCanvas;
return !isPortraitCanvas;
}).map((preset) => (
@@ -829,7 +830,9 @@ export function SettingsPanel({
? t("layout.pictureInPicture")
: preset.value === "vertical-stack"
? t("layout.verticalStack")
- : t("layout.dualFrame")}
+ : preset.value === "no-webcam"
+ ? t("layout.noWebcam")
+ : t("layout.dualFrame")}
))}
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index 8d13428..aec570f 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -100,6 +100,7 @@ function computeNormalizedWebcamLayoutPreset(
): WebcamLayoutPreset {
switch (webcamLayoutPreset) {
case "picture-in-picture":
+ case "no-webcam":
return webcamLayoutPreset;
case "vertical-stack":
return isPortraitAspectRatio(normalizedAspectRatio)
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 557fb14..aeac04f 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -35,6 +35,7 @@
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"dualFrame": "Dual Frame",
+ "noWebcam": "No Webcam",
"webcamShape": "Camera Shape",
"webcamSize": "Webcam Size"
},
diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts
index e6db733..93161c0 100644
--- a/src/lib/compositeLayout.ts
+++ b/src/lib/compositeLayout.ts
@@ -15,7 +15,11 @@ export interface Size {
height: number;
}
-export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack" | "dual-frame";
+export type WebcamLayoutPreset =
+ | "picture-in-picture"
+ | "vertical-stack"
+ | "dual-frame"
+ | "no-webcam";
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
export type WebcamSizePreset = number;
@@ -126,6 +130,21 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record
Date: Fri, 8 May 2026 05:24:40 +0530
Subject: [PATCH 44/55] fix: resolve comments
---
electron/electron-env.d.ts | 8 +++++++-
electron/ipc/handlers.ts | 10 ++++++++--
src/components/video-editor/VideoEditor.tsx | 13 +++++++++----
src/lib/userPreferences.ts | 8 ++++++++
4 files changed, 32 insertions(+), 7 deletions(-)
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index f04b7c3..5e26c44 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -77,7 +77,13 @@ interface Window {
videoData: ArrayBuffer,
fileName: string,
exportFolder?: string,
- ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
+ ) => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 9a8e9ca..cb8d217 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -842,8 +842,14 @@ export function registerIpcHandlers(
if (stats.isDirectory()) {
defaultDir = exportFolder;
}
- } catch {
- // Folder was moved or deleted since the last export; keep Downloads.
+ } catch (err) {
+ // Stat can fail because the folder was moved/deleted (expected) or
+ // because of a permission error (worth surfacing). Either way we
+ // fall back to Downloads, but log so debugging isn't blind.
+ console.warn(
+ `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`,
+ err,
+ );
}
}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index cf174fa..46957a3 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -31,7 +31,12 @@ import {
import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
-import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences";
+import {
+ getExportFolder,
+ loadUserPreferences,
+ parentDirectoryOf,
+ saveUserPreferences,
+} from "@/lib/userPreferences";
import { BackgroundLoadError } from "@/lib/wallpaper";
import {
getAspectRatioValue,
@@ -1313,7 +1318,7 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(
unsavedExport.arrayBuffer,
unsavedExport.fileName,
- loadUserPreferences().exportFolder ?? undefined,
+ getExportFolder(),
);
if (saveResult.canceled) {
toast.info("Export canceled");
@@ -1418,7 +1423,7 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(
arrayBuffer,
fileName,
- loadUserPreferences().exportFolder ?? undefined,
+ getExportFolder(),
);
if (saveResult.canceled) {
@@ -1562,7 +1567,7 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(
arrayBuffer,
fileName,
- loadUserPreferences().exportFolder ?? undefined,
+ getExportFolder(),
);
if (saveResult.canceled) {
diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts
index 2c9db6f..5b7bc86 100644
--- a/src/lib/userPreferences.ts
+++ b/src/lib/userPreferences.ts
@@ -112,6 +112,14 @@ export function parentDirectoryOf(filePath: string): string | null {
return filePath.slice(0, lastSep);
}
+/**
+ * Returns the remembered export folder as `string | undefined`, suitable for
+ * passing directly to IPC handlers that treat absence as "use the default".
+ */
+export function getExportFolder(): string | undefined {
+ return loadUserPreferences().exportFolder ?? undefined;
+}
+
/**
* Persist user preferences to localStorage.
* Only the explicitly provided fields are updated.
From c9980b0dca53ae6bfc46fd8f6544d333b8458a4d Mon Sep 17 00:00:00 2001
From: Marc Diaz
Date: Thu, 7 May 2026 23:22:32 -0400
Subject: [PATCH 45/55] fix: tests + how to write them
---
.github/workflows/ci.yml | 1 +
docs/tests/writing-tests.md | 149 ++++++++++++++++++
package-lock.json | 91 +++++------
.../tutorialHelpTranslations.test.ts | 6 +
src/lib/blurEffects.test.ts | 2 +-
vitest.config.ts | 1 +
6 files changed, 194 insertions(+), 56 deletions(-)
create mode 100644 docs/tests/writing-tests.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4194797..3c9e8ef 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,6 +41,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
+ - run: npm run test
- run: npm run test:browser:install
- run: npm run test:browser
diff --git a/docs/tests/writing-tests.md b/docs/tests/writing-tests.md
new file mode 100644
index 0000000..09ede7e
--- /dev/null
+++ b/docs/tests/writing-tests.md
@@ -0,0 +1,149 @@
+# Writing Tests
+
+This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files.
+
+## Unit tests
+
+**Config:** `vitest.config.ts`
+**Runs in:** jsdom (simulated DOM, no real browser)
+**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts`
+**CI command:** `npm run test`
+
+Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.).
+
+### File placement
+
+Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory.
+
+```
+src/lib/compositeLayout.ts
+src/lib/compositeLayout.test.ts # co-located
+
+src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped
+```
+
+### Example
+
+```ts
+import { describe, expect, it } from "vitest";
+import { computeCompositeLayout } from "./compositeLayout";
+
+describe("computeCompositeLayout", () => {
+ it("anchors the overlay in the lower-right corner", () => {
+ const layout = computeCompositeLayout({
+ canvasSize: { width: 1920, height: 1080 },
+ screenSize: { width: 1920, height: 1080 },
+ webcamSize: { width: 1280, height: 720 },
+ });
+
+ expect(layout).not.toBeNull();
+ expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
+ expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
+ });
+});
+```
+
+### Path aliases
+
+The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths.
+
+```ts
+import { SUPPORTED_LOCALES } from "@/i18n/config";
+```
+
+### Running locally
+
+```bash
+npm run test # run once
+npm run test:watch # watch mode
+```
+
+---
+
+## Browser tests
+
+**Config:** `vitest.browser.config.ts`
+**Runs in:** real Chromium via Playwright (headless)
+**File pattern:** `src/**/*.browser.test.ts`
+**CI commands:** `npm run test:browser:install` then `npm run test:browser`
+
+Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc.
+
+### File placement
+
+Name the file `.browser.test.ts` and place it next to the source file.
+
+```
+src/lib/exporter/videoExporter.ts
+src/lib/exporter/videoExporter.browser.test.ts
+```
+
+### Loading fixture assets
+
+Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server.
+
+```ts
+import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
+```
+
+### Example
+
+```ts
+import { describe, expect, it } from "vitest";
+import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
+import { VideoExporter } from "./videoExporter";
+
+describe("VideoExporter (real browser)", () => {
+ it("exports a valid MP4 blob from a real video", async () => {
+ const exporter = new VideoExporter({
+ videoUrl: sampleVideoUrl,
+ width: 320,
+ height: 180,
+ frameRate: 15,
+ bitrate: 1_000_000,
+ wallpaper: "#1a1a2e",
+ zoomRegions: [],
+ showShadow: false,
+ shadowIntensity: 0,
+ showBlur: false,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
+ });
+
+ const result = await exporter.export();
+
+ expect(result.success, result.error).toBe(true);
+ expect(result.blob).toBeInstanceOf(Blob);
+ });
+});
+```
+
+### Timeouts
+
+Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast.
+
+### Running locally
+
+First install the browser (one-time):
+
+```bash
+npm run test:browser:install
+```
+
+Then run the tests:
+
+```bash
+npm run test:browser
+```
+
+---
+
+## Choosing the right type
+
+| Situation | Use |
+|---|---|
+| Pure function / data transformation | Unit test |
+| i18n key coverage | Unit test |
+| React hook logic (no real browser APIs) | Unit test |
+| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test |
+| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test |
+| File export producing a real `Blob` | Browser test |
diff --git a/package-lock.json b/package-lock.json
index e823ad1..afe2091 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,13 @@
{
"name": "openscreen",
- "version": "1.3.0",
+ "version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
- "version": "1.3.0",
+ "version": "1.4.0",
+ "hasInstallScript": true,
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
@@ -187,6 +188,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -395,6 +397,7 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -718,6 +721,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -766,6 +770,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1197,7 +1202,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
- "peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1219,7 +1223,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1236,7 +1239,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1251,7 +1253,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -1961,7 +1962,6 @@
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
"integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@pixi/colord": "^2.9.6"
}
@@ -1976,8 +1976,7 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz",
"integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@pixi/core": {
"version": "7.4.3",
@@ -2004,8 +2003,7 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz",
"integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@pixi/filter-drop-shadow": {
"version": "5.2.0",
@@ -2032,22 +2030,19 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz",
"integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@pixi/runner": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz",
"integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@pixi/settings": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz",
"integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@pixi/constants": "7.4.3",
"@types/css-font-loading-module": "^0.0.12",
@@ -2059,7 +2054,6 @@
"resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz",
"integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@pixi/extensions": "7.4.3",
"@pixi/settings": "7.4.3",
@@ -2071,7 +2065,6 @@
"resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz",
"integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@pixi/color": "7.4.3",
"@pixi/constants": "7.4.3",
@@ -3650,8 +3643,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -3726,8 +3718,7 @@
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
"integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
@@ -3765,8 +3756,7 @@
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -3865,6 +3855,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -3876,6 +3867,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -4157,6 +4149,7 @@
"integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/browser": "4.1.5",
"@vitest/mocker": "4.1.5",
@@ -4349,6 +4342,7 @@
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -4874,6 +4868,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5055,7 +5050,6 @@
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -5406,8 +5400,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -5697,6 +5690,7 @@
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"app-builder-lib": "26.8.1",
"builder-util": "26.8.1",
@@ -5789,8 +5783,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
@@ -5839,8 +5832,7 @@
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/ejs": {
"version": "3.1.10",
@@ -6023,7 +6015,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -6044,7 +6035,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -6287,8 +6277,7 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/expect-type": {
"version": "1.3.0",
@@ -7700,7 +7689,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -7920,7 +7908,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -8234,7 +8221,6 @@
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">= 0.4"
},
@@ -8443,6 +8429,7 @@
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz",
"integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==",
"license": "MIT",
+ "peer": true,
"workspaces": [
"examples",
"playground"
@@ -8488,6 +8475,7 @@
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"playwright-core": "1.59.1"
},
@@ -8558,6 +8546,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8702,7 +8691,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -8720,7 +8708,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -8731,7 +8718,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -8747,7 +8733,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -8861,7 +8846,6 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
- "peer": true,
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -8920,6 +8904,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8932,6 +8917,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8968,8 +8954,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.18.0",
@@ -9290,7 +9275,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -9497,7 +9481,6 @@
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
@@ -9517,7 +9500,6 @@
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
@@ -9534,7 +9516,6 @@
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -9553,7 +9534,6 @@
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -9894,6 +9874,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -9977,7 +9958,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -10378,7 +10358,6 @@
"resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
"integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"punycode": "^1.4.1",
"qs": "^6.12.3"
@@ -10391,8 +10370,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
@@ -10485,6 +10463,7 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -10574,7 +10553,8 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
@@ -10597,6 +10577,7 @@
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts
index fcfa9d3..b391a12 100644
--- a/src/i18n/__tests__/tutorialHelpTranslations.test.ts
+++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts
@@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
+import arDialogs from "@/i18n/locales/ar/dialogs.json";
import enDialogs from "@/i18n/locales/en/dialogs.json";
import esDialogs from "@/i18n/locales/es/dialogs.json";
import frDialogs from "@/i18n/locales/fr/dialogs.json";
+import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json";
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
import trDialogs from "@/i18n/locales/tr/dialogs.json";
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
+import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json";
const tutorialHelpKeys = [
"triggerLabel",
@@ -35,10 +38,13 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des
const dialogsByLocale = {
en: enDialogs,
"zh-CN": zhCNDialogs,
+ "zh-TW": zhTWDialogs,
es: esDialogs,
fr: frDialogs,
tr: trDialogs,
"ko-KR": koKRDialogs,
+ "ja-JP": jaJPDialogs,
+ ar: arDialogs,
} satisfies Record }>;
describe("TutorialHelp translations", () => {
diff --git a/src/lib/blurEffects.test.ts b/src/lib/blurEffects.test.ts
index 4797e69..1a6a9c9 100644
--- a/src/lib/blurEffects.test.ts
+++ b/src/lib/blurEffects.test.ts
@@ -75,6 +75,6 @@ describe("blur color helpers", () => {
intensity: 12,
blockSize: 12,
}),
- ).toBe("rgba(0, 0, 0, 0.18)");
+ ).toBe("rgba(0, 0, 0, 0.56)");
});
});
diff --git a/vitest.config.ts b/vitest.config.ts
index ea60216..9108f69 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,6 +6,7 @@ export default defineConfig({
globals: true,
environment: "jsdom",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
+ exclude: ["src/**/*.browser.test.{ts,tsx}"],
},
resolve: {
alias: {
From a0c423de677f46a2d04fbf64b7fc5c226b355c29 Mon Sep 17 00:00:00 2001
From: Marc Diaz
Date: Fri, 8 May 2026 00:00:30 -0400
Subject: [PATCH 46/55] add diagnostics report
---
electron/electron-env.d.ts | 6 +++
electron/ipc/handlers.ts | 42 +++++++++++++++++++
electron/preload.ts | 8 ++++
src/components/video-editor/SettingsPanel.tsx | 13 ++++++
src/components/video-editor/VideoEditor.tsx | 14 +++++++
5 files changed, 83 insertions(+)
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index d9ebab2..6d3d9d5 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -150,6 +150,12 @@ interface Window {
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void;
setLocale: (locale: string) => Promise;
+ saveDiagnostic: (payload: {
+ error: string;
+ stack?: string;
+ projectState: unknown;
+ logs: string[];
+ }) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>;
};
}
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 7361b26..20154bc 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
+import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -1317,4 +1318,45 @@ export function registerIpcHandlers(
return { success: false, error: String(error) };
}
});
+
+ ipcMain.handle(
+ "save-diagnostic",
+ async (
+ _,
+ payload: { error: string; stack?: string; projectState: unknown; logs: string[] },
+ ) => {
+ const { filePath, canceled } = await dialog.showSaveDialog({
+ title: "Save Diagnostic File",
+ defaultPath: `openscreen-diagnostic-${Date.now()}.json`,
+ filters: [{ name: "JSON", extensions: ["json"] }],
+ });
+
+ if (canceled || !filePath) return { success: false, canceled: true };
+
+ const diagnostic = {
+ timestamp: new Date().toISOString(),
+ appVersion: app.getVersion(),
+ platform: process.platform,
+ arch: process.arch,
+ osRelease: os.release(),
+ osVersion: os.version(),
+ totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
+ nodeVersion: process.versions.node,
+ electronVersion: process.versions.electron,
+ chromeVersion: process.versions.chrome,
+ error: payload.error,
+ stack: payload.stack,
+ projectState: payload.projectState,
+ recentLogs: payload.logs,
+ };
+
+ try {
+ await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8");
+ return { success: true, path: filePath };
+ } catch (error) {
+ console.error("Failed to write diagnostic file:", error);
+ return { success: false, error: String(error) };
+ }
+ },
+ );
}
diff --git a/electron/preload.ts b/electron/preload.ts
index 6c705d7..9149756 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -134,6 +134,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
setLocale: (locale: string) => {
return ipcRenderer.invoke("set-locale", locale);
},
+ saveDiagnostic: (payload: {
+ error: string;
+ stack?: string;
+ projectState: unknown;
+ logs: string[];
+ }) => {
+ return ipcRenderer.invoke("save-diagnostic", payload);
+ },
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 76ff762..377cbbe 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -3,6 +3,7 @@ import {
ChevronDown,
Crop,
Download,
+ FileDown,
Film,
Image,
Lock,
@@ -240,6 +241,7 @@ interface SettingsPanelProps {
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
+ onSaveDiagnostic?: () => Promise;
}
export default SettingsPanel;
@@ -327,6 +329,7 @@ export function SettingsPanel({
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
+ onSaveDiagnostic,
}: SettingsPanelProps) {
const t = useScopedT("settings");
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
@@ -1682,6 +1685,16 @@ export function SettingsPanel({
{t("links.reportBug")}
+ {onSaveDiagnostic && (
+
+
+ Save Diagnostics
+
+ )}
{
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index fd21888..bdcd57b 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1730,6 +1730,19 @@ export default function VideoEditor() {
}
}, []);
+ const handleSaveDiagnostic = useCallback(async () => {
+ const result = await window.electronAPI.saveDiagnostic({
+ error: exportError ?? "Manual diagnostic export",
+ projectState: editorState,
+ logs: [],
+ });
+ if (result.success) {
+ toast.success("Diagnostic file saved");
+ } else if (!result.canceled) {
+ toast.error("Failed to save diagnostic file");
+ }
+ }, [exportError, editorState]);
+
if (loading) {
return (
@@ -2100,6 +2113,7 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
+ onSaveDiagnostic={handleSaveDiagnostic}
/>
From f47fa6bdca465de34d67bf2105e3b7e918e5a5f7 Mon Sep 17 00:00:00 2001
From: Trivenzaa-Admin
Date: Fri, 8 May 2026 01:48:52 -0700
Subject: [PATCH 47/55] fix(macos): add NSScreenCaptureUsageDescription and
screen-capture entitlement
Without NSScreenCaptureUsageDescription in Info.plist, macOS silently
blocks desktopCapturer.getSources(), breaking window detection on macOS
10.15+. Also adds the com.apple.security.device.screen-capture entitlement
to macos.entitlements alongside the existing camera and audio-input entries.
Fixes #548
---
electron-builder.json5 | 1 +
macos.entitlements | 4 ++++
2 files changed, 5 insertions(+)
diff --git a/electron-builder.json5 b/electron-builder.json5
index ad6cd18..d9fee6b 100644
--- a/electron-builder.json5
+++ b/electron-builder.json5
@@ -51,6 +51,7 @@
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
+ "NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
diff --git a/macos.entitlements b/macos.entitlements
index 5c6ddcf..38d8b29 100644
--- a/macos.entitlements
+++ b/macos.entitlements
@@ -21,5 +21,9 @@
com.apple.security.device.camera
+
+
+ com.apple.security.device.screen-capture
+
From 37215531c2484acb06dc39f35dec5af6a85b910c Mon Sep 17 00:00:00 2001
From: makaradam
Date: Sat, 2 May 2026 10:24:04 +0200
Subject: [PATCH 48/55] feat: add custom zoom slider with continuous scale
control (#513)
Adds a Radix UI slider below the zoom preset buttons allowing any scale
between 1.0x and 5.0x. When the slider value matches a preset exactly,
that preset button also shows as active.
- Add `customScale?: number` to `ZoomRegion` and `getZoomScale()` helper
that returns customScale when set, falling back to ZOOM_DEPTH_SCALES[depth]
- Overlay indicator, playback renderer, and frame exporter all use
getZoomScale() so preview, playback, and export are consistent
- Fix focus clamping in zoomRegionUtils and frameRenderer to use actual
scale instead of depth-based preset scale, preventing zoom drift with
custom values
- Fix drag boundary in VideoPlayback to use clampFocusToScale with the
actual scale so the full canvas is clickable at high custom zoom levels
- Timeline item label shows custom scale value when set
- Slider styled dark with green thumb/fill when a custom (non-preset) value is active
Co-Authored-By: Claude Sonnet 4.6
---
src/components/video-editor/SettingsPanel.tsx | 78 ++++++++++++++++++-
src/components/video-editor/VideoEditor.tsx | 26 +++++++
src/components/video-editor/VideoPlayback.tsx | 13 +---
src/components/video-editor/timeline/Item.tsx | 6 +-
.../video-editor/timeline/TimelineEditor.tsx | 3 +
src/components/video-editor/types.ts | 10 +++
.../videoPlayback/overlayUtils.ts | 11 +--
.../videoPlayback/zoomRegionUtils.ts | 10 +--
src/i18n/locales/en/settings.json | 1 +
src/lib/exporter/frameRenderer.ts | 17 +---
10 files changed, 138 insertions(+), 37 deletions(-)
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 377cbbe..36fa255 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1,3 +1,4 @@
+import * as SliderPrimitive from "@radix-ui/react-slider";
import {
Bug,
ChevronDown,
@@ -65,8 +66,11 @@ import type {
import {
DEFAULT_WEBCAM_SIZE_PRESET,
MAX_PLAYBACK_SPEED,
+ MAX_ZOOM_SCALE,
+ MIN_ZOOM_SCALE,
ROTATION_3D_PRESET_ORDER,
SPEED_OPTIONS,
+ ZOOM_DEPTH_SCALES,
} from "./types";
function CustomSpeedInput({
@@ -170,6 +174,9 @@ interface SettingsPanelProps {
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
+ selectedZoomCustomScale?: number | null;
+ onZoomCustomScaleChange?: (scale: number) => void;
+ onZoomCustomScaleCommit?: () => void;
selectedZoomFocusMode?: ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
hasCursorTelemetry?: boolean;
@@ -263,6 +270,9 @@ export function SettingsPanel({
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
+ selectedZoomCustomScale,
+ onZoomCustomScaleChange,
+ onZoomCustomScaleCommit,
selectedZoomFocusMode,
onZoomFocusModeChange,
hasCursorTelemetry = false,
@@ -593,7 +603,9 @@ export function SettingsPanel({
{zoomEnabled && selectedZoomDepth && (
- {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}
+ {selectedZoomCustomScale != null
+ ? `${selectedZoomCustomScale.toFixed(2)}×`
+ : ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}
)}
@@ -601,7 +613,10 @@ export function SettingsPanel({
{ZOOM_DEPTH_OPTIONS.map((option) => {
- const isActive = selectedZoomDepth === option.depth;
+ const effectiveScale =
+ selectedZoomCustomScale ??
+ (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null);
+ const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth];
return (
+ {zoomEnabled && (
+
+
+ {t("zoom.customScale")}
+
+ {(
+ selectedZoomCustomScale ??
+ (selectedZoomDepth != null
+ ? ZOOM_DEPTH_SCALES[selectedZoomDepth]
+ : MIN_ZOOM_SCALE)
+ ).toFixed(2)}
+ ×
+
+
+
onZoomCustomScaleChange?.(values[0])}
+ onValueCommit={() => onZoomCustomScaleCommit?.()}
+ disabled={!zoomEnabled}
+ className="relative flex w-full touch-none select-none items-center py-1"
+ >
+
+
+
+
+
+
+ {MIN_ZOOM_SCALE.toFixed(1)}×
+ {MAX_ZOOM_SCALE.toFixed(1)}×
+
+
+ )}
{!zoomEnabled && (
{t("zoom.selectRegion")}
)}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index a1c7f1c..7e0393e 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -834,6 +834,7 @@ export default function VideoEditor() {
? {
...region,
depth,
+ customScale: undefined,
focus: clampFocusToDepth(region.focus, depth),
}
: region,
@@ -843,6 +844,24 @@ export default function VideoEditor() {
[selectedZoomId, pushState],
);
+ const handleZoomCustomScaleChange = useCallback(
+ (scale: number) => {
+ if (!selectedZoomId) return;
+ updateState((prev) => ({
+ zoomRegions: prev.zoomRegions.map((region) =>
+ region.id === selectedZoomId
+ ? { ...region, customScale: Math.round(scale * 100) / 100 }
+ : region,
+ ),
+ }));
+ },
+ [selectedZoomId, updateState],
+ );
+
+ const handleZoomCustomScaleCommit = useCallback(() => {
+ commitState();
+ }, [commitState]);
+
const handleZoomFocusModeChange = useCallback(
(focusMode: ZoomFocusMode) => {
if (!selectedZoomId) return;
@@ -2060,6 +2079,13 @@ export default function VideoEditor() {
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
+ selectedZoomCustomScale={
+ selectedZoomId
+ ? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null)
+ : null
+ }
+ onZoomCustomScaleChange={handleZoomCustomScaleChange}
+ onZoomCustomScaleCommit={handleZoomCustomScaleCommit}
selectedZoomFocusMode={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index ee52bd9..f863f83 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -38,13 +38,12 @@ import {
type BlurData,
computeRotation3DContainScale,
DEFAULT_ROTATION_3D,
+ getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
rotation3DPerspective,
type SpeedRegion,
type TrimRegion,
- ZOOM_DEPTH_SCALES,
- type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
} from "./types";
@@ -67,7 +66,7 @@ import {
DEFAULT_CURSOR_HIGHLIGHT,
drawCursorHighlightGraphics,
} from "./videoPlayback/cursorHighlight";
-import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
+import { clampFocusToScale } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
@@ -258,10 +257,6 @@ const VideoPlayback = forwardRef(
const smoothedAutoFocusRef = useRef(null);
const prevTargetProgressRef = useRef(0);
- const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
- return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
- }, []);
-
const updateOverlayForRegion = useCallback(
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
const overlayEl = overlayRef.current;
@@ -442,7 +437,7 @@ const VideoPlayback = forwardRef(
cx: clamp01(localX / stageWidth),
cy: clamp01(localY / stageHeight),
};
- const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
+ const clampedFocus = clampFocusToScale(unclampedFocus, getZoomScale(region));
onZoomFocusChange(region.id, clampedFocus);
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
@@ -951,7 +946,7 @@ const VideoPlayback = forwardRef(
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
if (region && strength > 0 && !shouldShowUnzoomedView) {
- const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
+ const zoomScale = blendedScale ?? getZoomScale(region);
const regionFocus = region.focus;
targetScaleFactor = zoomScale;
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index d2b80c7..3ae5670 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -14,6 +14,7 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
+ zoomCustomScale?: number;
speedValue?: number;
isAutoFocus?: boolean;
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
@@ -46,6 +47,7 @@ export default function Item({
isSelected = false,
onSelect,
zoomDepth = 1,
+ zoomCustomScale,
speedValue,
isAutoFocus = false,
variant = "zoom",
@@ -134,7 +136,9 @@ export default function Item({
<>
- {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
+ {zoomCustomScale != null
+ ? `${zoomCustomScale.toFixed(2)}×`
+ : ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
{isAutoFocus && (
onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
+ zoomCustomScale={item.zoomCustomScale}
isAutoFocus={item.isAutoFocus}
variant="zoom"
>
@@ -1339,6 +1341,7 @@ export default function TimelineEditor({
span: { start: region.startMs, end: region.endMs },
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
+ zoomCustomScale: region.customScale,
isAutoFocus: region.focusMode === "auto",
variant: "zoom",
}));
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index f976efc..9fc03e6 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -65,6 +65,8 @@ export interface ZoomRegion {
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
rotationPreset?: Rotation3DPreset;
+ /** Custom scale overriding the preset depth (1.0–5.0, two decimal precision). */
+ customScale?: number;
}
export function getRotation3D(region: Pick): Rotation3D {
@@ -356,8 +358,16 @@ export const ZOOM_DEPTH_SCALES: Record = {
6: 5.0,
};
+export const MIN_ZOOM_SCALE = 1.0;
+export const MAX_ZOOM_SCALE = 5.0;
+
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
+/** Returns the effective zoom scale for a region, preferring customScale over the preset. */
+export function getZoomScale(region: ZoomRegion): number {
+ return region.customScale ?? ZOOM_DEPTH_SCALES[region.depth];
+}
+
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
return {
cx: clamp(focus.cx, 0, 1),
diff --git a/src/components/video-editor/videoPlayback/overlayUtils.ts b/src/components/video-editor/videoPlayback/overlayUtils.ts
index 0426a23..0859934 100644
--- a/src/components/video-editor/videoPlayback/overlayUtils.ts
+++ b/src/components/video-editor/videoPlayback/overlayUtils.ts
@@ -1,5 +1,5 @@
-import { ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion } from "../types";
-import { clampFocusToStage } from "./focusUtils";
+import { getZoomScale, type ZoomFocus, type ZoomRegion } from "../types";
+import { clampFocusToScale } from "./focusUtils";
interface OverlayUpdateParams {
overlayEl: HTMLDivElement;
@@ -35,11 +35,8 @@ export function updateOverlayIndicator(params: OverlayUpdateParams) {
return;
}
- const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
- const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth, {
- width: stageWidth,
- height: stageHeight,
- });
+ const zoomScale = getZoomScale(region);
+ const focus = clampFocusToScale(focusOverride ?? region.focus, zoomScale);
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
const indicatorWidth = stageWidth / zoomScale;
diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
index 5647054..1bfa465 100644
--- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
+++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
@@ -1,5 +1,5 @@
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
-import { DEFAULT_ROTATION_3D, getRotation3D, lerpRotation3D, ZOOM_DEPTH_SCALES } from "../types";
+import { DEFAULT_ROTATION_3D, getRotation3D, getZoomScale, lerpRotation3D } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { interpolateCursorAt } from "./cursorFollowUtils";
import { clampFocusToScale } from "./focusUtils";
@@ -155,7 +155,7 @@ function getActiveRegion(
}
const activeRegion = activeRegions[0].region;
- const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth];
+ const activeScale = getZoomScale(activeRegion);
return {
region: {
@@ -176,7 +176,7 @@ function getConnectedRegionHold(
) {
for (const pair of connectedPairs) {
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
- const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
+ const nextScale = getZoomScale(pair.nextRegion);
return {
region: {
...pair.nextRegion,
@@ -214,8 +214,8 @@ function getConnectedRegionTransition(
const transitionProgress = easeConnectedPan(
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
);
- const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
- const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
+ const currentScale = getZoomScale(currentRegion);
+ const nextScale = getZoomScale(nextRegion);
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
// Both regions share the same timeMs, so interpolate cursor once and reuse.
const sharedCursorFocus =
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 557fb14..104c1b1 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -1,6 +1,7 @@
{
"zoom": {
"level": "Zoom Level",
+ "customScale": "Custom Zoom",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom",
"focusMode": {
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index 1e5138c..017af83 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -15,14 +15,13 @@ import type {
SpeedRegion,
WebcamLayoutPreset,
WebcamSizePreset,
- ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
import {
DEFAULT_ROTATION_3D,
+ getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
- ZOOM_DEPTH_SCALES,
} from "@/components/video-editor/types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
@@ -42,7 +41,7 @@ import {
clickEmphasisAlpha,
drawCursorHighlightCanvas,
} from "@/components/video-editor/videoPlayback/cursorHighlight";
-import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
+import { clampFocusToScale } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
@@ -645,14 +644,6 @@ export class FrameRenderer {
};
}
- private clampFocusToStage(
- focus: { cx: number; cy: number },
- depth: ZoomDepth,
- ): { cx: number; cy: number } {
- if (!this.layoutCache) return focus;
- return clampFocusToStageUtil(focus, depth, this.layoutCache.stageSize);
- }
-
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
@@ -673,8 +664,8 @@ export class FrameRenderer {
: { ...DEFAULT_ROTATION_3D };
if (region && strength > 0) {
- const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
- const regionFocus = this.clampFocusToStage(region.focus, region.depth);
+ const zoomScale = blendedScale ?? getZoomScale(region);
+ const regionFocus = clampFocusToScale(region.focus, zoomScale);
targetScaleFactor = zoomScale;
targetFocus = regionFocus;
From f30090bf88df399c328ce95250bc8f9ed9d682e6 Mon Sep 17 00:00:00 2001
From: makaradam
Date: Sat, 2 May 2026 11:05:48 +0200
Subject: [PATCH 49/55] fix: sanitize customScale in getZoomScale and fix
isCustomActive styling
---
src/components/video-editor/SettingsPanel.tsx | 9 ++++++---
src/components/video-editor/types.ts | 6 +++++-
2 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 36fa255..b6aff5f 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -473,6 +473,9 @@ export function SettingsPanel({
);
const zoomEnabled = Boolean(selectedZoomDepth);
+ const isCustomActive =
+ selectedZoomCustomScale != null &&
+ !Object.values(ZOOM_DEPTH_SCALES).some((s) => s === selectedZoomCustomScale);
const trimEnabled = Boolean(selectedTrimId);
const handleDeleteClick = () => {
@@ -644,7 +647,7 @@ export function SettingsPanel({
{(
@@ -675,7 +678,7 @@ export function SettingsPanel({
@@ -684,7 +687,7 @@ export function SettingsPanel({
"block h-3.5 w-3.5 rounded-full border-2 shadow transition-all duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50",
"disabled:pointer-events-none disabled:opacity-50 cursor-grab active:cursor-grabbing",
- selectedZoomCustomScale != null
+ isCustomActive
? "border-[#34B27B] bg-[#34B27B] shadow-[0_0_6px_rgba(52,178,123,0.4)]"
: "border-white/20 bg-[#2a2a30] hover:border-white/40",
)}
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index 9fc03e6..cbae93c 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -365,7 +365,11 @@ export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
/** Returns the effective zoom scale for a region, preferring customScale over the preset. */
export function getZoomScale(region: ZoomRegion): number {
- return region.customScale ?? ZOOM_DEPTH_SCALES[region.depth];
+ if (region.customScale != null) {
+ const clamped = Math.max(MIN_ZOOM_SCALE, Math.min(MAX_ZOOM_SCALE, region.customScale));
+ if (Number.isFinite(clamped)) return clamped;
+ }
+ return ZOOM_DEPTH_SCALES[region.depth];
}
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
From f3dcbf2867cde8ce9dfca6e58883e216bb3e4dc9 Mon Sep 17 00:00:00 2001
From: makaradam
Date: Sat, 2 May 2026 11:21:59 +0200
Subject: [PATCH 50/55] fix: address code review feedback on custom zoom slider
- Clamp and NaN-guard customScale in getZoomScale (defensive sanitization)
- Set customScale on preset button click so slider stays green
- Set customScale on new zoom region creation so slider lights up immediately
---
src/components/video-editor/SettingsPanel.tsx | 9 +++------
src/components/video-editor/VideoEditor.tsx | 5 ++++-
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index b6aff5f..36fa255 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -473,9 +473,6 @@ export function SettingsPanel({
);
const zoomEnabled = Boolean(selectedZoomDepth);
- const isCustomActive =
- selectedZoomCustomScale != null &&
- !Object.values(ZOOM_DEPTH_SCALES).some((s) => s === selectedZoomCustomScale);
const trimEnabled = Boolean(selectedTrimId);
const handleDeleteClick = () => {
@@ -647,7 +644,7 @@ export function SettingsPanel({
{(
@@ -678,7 +675,7 @@ export function SettingsPanel({
@@ -687,7 +684,7 @@ export function SettingsPanel({
"block h-3.5 w-3.5 rounded-full border-2 shadow transition-all duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50",
"disabled:pointer-events-none disabled:opacity-50 cursor-grab active:cursor-grabbing",
- isCustomActive
+ selectedZoomCustomScale != null
? "border-[#34B27B] bg-[#34B27B] shadow-[0_0_6px_rgba(52,178,123,0.4)]"
: "border-white/20 bg-[#2a2a30] hover:border-white/40",
)}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 7e0393e..4f27eb2 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -75,6 +75,7 @@ import {
type Rotation3DPreset,
type SpeedRegion,
type TrimRegion,
+ ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomFocusMode,
@@ -732,6 +733,7 @@ export default function VideoEditor() {
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
+ customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
focus: { cx: 0.5, cy: 0.5 },
};
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
@@ -751,6 +753,7 @@ export default function VideoEditor() {
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
+ customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
};
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
@@ -834,7 +837,7 @@ export default function VideoEditor() {
? {
...region,
depth,
- customScale: undefined,
+ customScale: ZOOM_DEPTH_SCALES[depth],
focus: clampFocusToDepth(region.focus, depth),
}
: region,
From 42127e647f03dfb0fef26071723462f1b262b120 Mon Sep 17 00:00:00 2001
From: makaradam
Date: Sat, 2 May 2026 11:32:31 +0200
Subject: [PATCH 51/55] fix: add NaN guard in handleZoomCustomScaleChange
before state update
---
src/components/video-editor/VideoEditor.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 4f27eb2..2ccd6eb 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -850,11 +850,11 @@ export default function VideoEditor() {
const handleZoomCustomScaleChange = useCallback(
(scale: number) => {
if (!selectedZoomId) return;
+ const rounded = Math.round(scale * 100) / 100;
+ if (!Number.isFinite(rounded)) return;
updateState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
- region.id === selectedZoomId
- ? { ...region, customScale: Math.round(scale * 100) / 100 }
- : region,
+ region.id === selectedZoomId ? { ...region, customScale: rounded } : region,
),
}));
},
From c771bf8bb988d712fb9390b97f3d524c4b0f75b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Murat=20=C3=87elik?=
Date: Thu, 9 Apr 2026 02:02:30 +0300
Subject: [PATCH 52/55] fix: clamp trim handle end position to timeline
boundary
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The right-side trim handle could be dragged past the end of the
timeline because clampSpanToBounds did not cap the computed end
value at totalMs. This adds Math.min(…, totalMs) so the handle
snaps to the timeline edge.
Fixes #393
---
src/components/video-editor/timeline/TimelineWrapper.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx
index 3616f18..4c668f0 100644
--- a/src/components/video-editor/timeline/TimelineWrapper.tsx
+++ b/src/components/video-editor/timeline/TimelineWrapper.tsx
@@ -57,7 +57,7 @@ export default function TimelineWrapper({
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
- const end = start + duration;
+ const end = Math.min(start + duration, totalMs);
return { start, end };
},
From 5bd17f4346d53b56b94f6c18d327ec205781fe3f Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sat, 9 May 2026 11:46:09 -0700
Subject: [PATCH 53/55] fix layout
---
src/components/video-editor/VideoEditor.tsx | 29 ++++++++++++---------
1 file changed, 16 insertions(+), 13 deletions(-)
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 2ccd6eb..d6b363d 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1530,18 +1530,19 @@ export default function VideoEditor() {
let bitrate: number;
if (quality === "source") {
- // Use source resolution
exportWidth = sourceWidth;
exportHeight = sourceHeight;
+ // Use the source's longer dimension as the long axis of the export so
+ // a landscape recording can still fill a portrait target (and vice versa).
+ const sourceLongDim = Math.max(sourceWidth, sourceHeight);
+
if (aspectRatioValue === 1) {
- // Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
- // Landscape: find largest even dimensions that exactly match aspect ratio
- const baseWidth = Math.floor(sourceWidth / 2) * 2;
+ const baseWidth = Math.floor(sourceLongDim / 2) * 2;
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
@@ -1556,8 +1557,7 @@ export default function VideoEditor() {
exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2;
}
} else {
- // Portrait: find largest even dimensions that exactly match aspect ratio
- const baseHeight = Math.floor(sourceHeight / 2) * 2;
+ const baseHeight = Math.floor(sourceLongDim / 2) * 2;
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
@@ -1573,7 +1573,6 @@ export default function VideoEditor() {
}
}
- // Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = exportWidth * exportHeight;
bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
@@ -1582,14 +1581,18 @@ export default function VideoEditor() {
bitrate = 80_000_000;
}
} else {
- // Use quality-based target resolution
- const targetHeight = quality === "medium" ? 720 : 1080;
+ // Quality presets target the SHORT side; the long side derives from the
+ // aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080.
+ const targetShortDim = quality === "medium" ? 720 : 1080;
- // Calculate dimensions maintaining aspect ratio
- exportHeight = Math.floor(targetHeight / 2) * 2;
- exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
+ if (aspectRatioValue >= 1) {
+ exportHeight = Math.floor(targetShortDim / 2) * 2;
+ exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
+ } else {
+ exportWidth = Math.floor(targetShortDim / 2) * 2;
+ exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2;
+ }
- // Adjust bitrate for lower resolutions
const totalPixels = exportWidth * exportHeight;
if (totalPixels <= 1280 * 720) {
bitrate = 10_000_000;
From c1f6cf67b2b8e43df761932c88aebbe7b564e345 Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sat, 9 May 2026 11:59:52 -0700
Subject: [PATCH 54/55] loc first and then export processing
---
electron/electron-env.d.ts | 12 +-
electron/ipc/handlers.ts | 149 ++++++++++----------
electron/preload.ts | 7 +-
src/components/video-editor/VideoEditor.tsx | 56 ++++----
4 files changed, 123 insertions(+), 101 deletions(-)
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index ab2b8cf..744c2c7 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -79,8 +79,7 @@ interface Window {
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
- saveExportedVideo: (
- videoData: ArrayBuffer,
+ pickExportSavePath: (
fileName: string,
exportFolder?: string,
) => Promise<{
@@ -90,6 +89,15 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
+ writeExportToPath: (
+ videoData: ArrayBuffer,
+ filePath: string,
+ ) => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ }>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index bf0bc97..82acdf9 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -986,81 +986,88 @@ export function registerIpcHandlers(
* @returns Object with success status, optional file path, and error details.
*/
- ipcMain.handle(
- "save-exported-video",
- async (_, videoData: ArrayBuffer, fileName: string, exportFolder?: string) => {
- try {
- // Determine file type from extension
- const isGif = fileName.toLowerCase().endsWith(".gif");
- const filters = isGif
- ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
- : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
+ ipcMain.handle("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => {
+ try {
+ const isGif = fileName.toLowerCase().endsWith(".gif");
+ const filters = isGif
+ ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
+ : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
- // Prefer the user's last export folder if it still exists, otherwise fall
- // back to ~/Downloads. Validation must happen here because the renderer
- // can't stat the filesystem.
- let defaultDir = app.getPath("downloads");
- if (exportFolder) {
- try {
- const stats = await fs.stat(exportFolder);
- if (stats.isDirectory()) {
- defaultDir = exportFolder;
- }
- } catch (err) {
- // Stat can fail because the folder was moved/deleted (expected) or
- // because of a permission error (worth surfacing). Either way we
- // fall back to Downloads, but log so debugging isn't blind.
- console.warn(
- `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`,
- err,
- );
+ // Prefer the user's last export folder if it still exists, otherwise fall
+ // back to ~/Downloads. Validation must happen here because the renderer
+ // can't stat the filesystem.
+ let defaultDir = app.getPath("downloads");
+ if (exportFolder) {
+ try {
+ const stats = await fs.stat(exportFolder);
+ if (stats.isDirectory()) {
+ defaultDir = exportFolder;
}
+ } catch (err) {
+ console.warn(
+ `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`,
+ err,
+ );
}
- const dialogOptions = buildDialogOptions(
- {
- title: isGif
- ? mainT("dialogs", "fileDialogs.saveGif")
- : mainT("dialogs", "fileDialogs.saveVideo"),
- defaultPath: path.join(defaultDir, fileName),
- filters,
- properties: ["createDirectory", "showOverwriteConfirmation"],
- },
- getMainWindow(),
- );
- const result = await dialog.showSaveDialog(dialogOptions);
-
- if (result.canceled || !result.filePath) {
- return {
- success: false,
- canceled: true,
- message: "Export canceled",
- };
- }
-
- // --- FIX: Normalize the path for Windows compatibility ---
- const normalizedPath = path.normalize(result.filePath);
-
- // Ensure the parent directory exists (Windows may fail if the folder is missing)
- await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
- // --- END FIX ---
-
- await fs.writeFile(normalizedPath, Buffer.from(videoData));
-
- return {
- success: true,
- path: normalizedPath,
- message: "Video exported successfully",
- };
- } catch (error) {
- console.error("Failed to save exported video:", error);
- return {
- success: false,
- message: "Failed to save exported video",
- error: String(error),
- };
}
- },
- );
+ const dialogOptions = buildDialogOptions(
+ {
+ title: isGif
+ ? mainT("dialogs", "fileDialogs.saveGif")
+ : mainT("dialogs", "fileDialogs.saveVideo"),
+ defaultPath: path.join(defaultDir, fileName),
+ filters,
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ },
+ getMainWindow(),
+ );
+ const result = await dialog.showSaveDialog(dialogOptions);
+
+ if (result.canceled || !result.filePath) {
+ return { success: false, canceled: true, message: "Export canceled" };
+ }
+
+ return { success: true, path: path.normalize(result.filePath) };
+ } catch (error) {
+ console.error("Failed to show save dialog:", error);
+ return {
+ success: false,
+ message: "Failed to show save dialog",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => {
+ try {
+ // Sanity-check the path. The renderer is trusted (contextIsolation is on),
+ // but a stale state bug shouldn't be able to clobber arbitrary files.
+ if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
+ return { success: false, message: "Invalid path" };
+ }
+ const lower = filePath.toLowerCase();
+ if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) {
+ return { success: false, message: "Invalid file type" };
+ }
+
+ const normalizedPath = path.normalize(filePath);
+ await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
+ await fs.writeFile(normalizedPath, Buffer.from(videoData));
+
+ return {
+ success: true,
+ path: normalizedPath,
+ message: "Video exported successfully",
+ };
+ } catch (error) {
+ console.error("Failed to write exported video:", error);
+ return {
+ success: false,
+ message: "Failed to save exported video",
+ error: String(error),
+ };
+ }
+ });
ipcMain.handle("open-video-file-picker", async () => {
try {
const dialogOptions = buildDialogOptions(
diff --git a/electron/preload.ts b/electron/preload.ts
index 6d729b9..5334a00 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -71,8 +71,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
- saveExportedVideo: (videoData: ArrayBuffer, fileName: string, exportFolder?: string) => {
- return ipcRenderer.invoke("save-exported-video", videoData, fileName, exportFolder);
+ pickExportSavePath: (fileName: string, exportFolder?: string) => {
+ return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder);
+ },
+ writeExportToPath: (videoData: ArrayBuffer, filePath: string) => {
+ return ipcRenderer.invoke("write-export-to-path", videoData, filePath);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index d6b363d..2e04a83 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1395,14 +1395,19 @@ export default function VideoEditor() {
const handleSaveUnsavedExport = useCallback(async () => {
if (!unsavedExport) return;
try {
- const saveResult = await window.electronAPI.saveExportedVideo(
- unsavedExport.arrayBuffer,
+ const pickResult = await window.electronAPI.pickExportSavePath(
unsavedExport.fileName,
getExportFolder(),
);
- if (saveResult.canceled) {
+ if (pickResult.canceled || !pickResult.success || !pickResult.path) {
toast.info("Export canceled");
- } else if (saveResult.success && saveResult.path) {
+ return;
+ }
+ const saveResult = await window.electronAPI.writeExportToPath(
+ unsavedExport.arrayBuffer,
+ pickResult.path,
+ );
+ if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
} else {
@@ -1427,6 +1432,21 @@ export default function VideoEditor() {
return;
}
+ // Ask the user where to save BEFORE starting the export. This avoids the
+ // post-export save dialog getting hidden behind other windows after a
+ // long-running export.
+ const isGifFormat = settings.format === "gif";
+ const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`;
+ const pickResult = await window.electronAPI.pickExportSavePath(
+ targetFileName,
+ getExportFolder(),
+ );
+ if (pickResult.canceled || !pickResult.success || !pickResult.path) {
+ setShowExportDialog(false);
+ return;
+ }
+ const targetPath = pickResult.path;
+
setIsExporting(true);
setExportProgress(null);
setExportError(null);
@@ -1493,8 +1513,6 @@ export default function VideoEditor() {
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
- const timestamp = Date.now();
- const fileName = `export-${timestamp}.gif`;
if (result.warnings) {
for (const warning of result.warnings) {
@@ -1502,19 +1520,13 @@ export default function VideoEditor() {
}
}
- const saveResult = await window.electronAPI.saveExportedVideo(
- arrayBuffer,
- fileName,
- getExportFolder(),
- );
+ const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
- if (saveResult.canceled) {
- setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
- toast.info("Export canceled");
- } else if (saveResult.success && saveResult.path) {
+ if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("GIF", saveResult.path);
} else {
+ setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" });
setExportError(saveResult.message || "Failed to save GIF");
toast.error(saveResult.message || "Failed to save GIF");
}
@@ -1642,8 +1654,6 @@ export default function VideoEditor() {
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
- const timestamp = Date.now();
- const fileName = `export-${timestamp}.mp4`;
if (result.warnings) {
for (const warning of result.warnings) {
@@ -1651,19 +1661,13 @@ export default function VideoEditor() {
}
}
- const saveResult = await window.electronAPI.saveExportedVideo(
- arrayBuffer,
- fileName,
- getExportFolder(),
- );
+ const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
- if (saveResult.canceled) {
- setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
- toast.info("Export canceled");
- } else if (saveResult.success && saveResult.path) {
+ if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("Video", saveResult.path);
} else {
+ setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" });
setExportError(saveResult.message || "Failed to save video");
toast.error(saveResult.message || "Failed to save video");
}
From 68c35ff01c12a921bf2cb5e8fa2b56b9ba11bf4a Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Sat, 9 May 2026 14:32:50 -0700
Subject: [PATCH 55/55] zoom precision position
---
src/components/video-editor/SettingsPanel.tsx | 124 ++++++++++++++++++
src/components/video-editor/VideoEditor.tsx | 9 ++
.../video-editor/videoPlayback/focusUtils.ts | 2 +-
src/i18n/locales/en/settings.json | 6 +
4 files changed, 140 insertions(+), 1 deletion(-)
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index d37190a..c625f87 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -61,6 +61,7 @@ import type {
WebcamMaskShape,
WebcamSizePreset,
ZoomDepth,
+ ZoomFocus,
ZoomFocusMode,
} from "./types";
import {
@@ -72,6 +73,7 @@ import {
SPEED_OPTIONS,
ZOOM_DEPTH_SCALES,
} from "./types";
+import { getFocusBoundsForScale } from "./videoPlayback/focusUtils";
function CustomSpeedInput({
value,
@@ -136,6 +138,58 @@ function CustomSpeedInput({
);
}
+function ZoomFocusCoordInput({
+ percent,
+ onChange,
+ onCommit,
+ disabled,
+ ariaLabel,
+}: {
+ percent: number;
+ onChange: (nextPercent: number) => void;
+ onCommit?: () => void;
+ disabled?: boolean;
+ ariaLabel: string;
+}) {
+ // While the input is focused (user is editing), show their draft text
+ // so partial entries like "5" or "" don't get overwritten by re-renders.
+ // When not focused, mirror the live prop value so external changes
+ // (dragging the overlay on the preview) update the displayed number in real time.
+ const [draft, setDraft] = useState(null);
+ const display = percent.toFixed(1);
+
+ return (
+ setDraft(display)}
+ onChange={(e) => {
+ const next = e.target.value;
+ setDraft(next);
+ const parsed = Number(next);
+ if (next !== "" && Number.isFinite(parsed)) {
+ const clamped = Math.min(100, Math.max(0, parsed));
+ onChange(clamped);
+ }
+ }}
+ onBlur={() => {
+ setDraft(null);
+ onCommit?.();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") (e.target as HTMLInputElement).blur();
+ }}
+ className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed"
+ />
+ );
+}
+
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
@@ -179,6 +233,9 @@ interface SettingsPanelProps {
onZoomCustomScaleCommit?: () => void;
selectedZoomFocusMode?: ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
+ selectedZoomFocus?: ZoomFocus | null;
+ onZoomFocusCoordinateChange?: (focus: ZoomFocus) => void;
+ onZoomFocusCoordinateCommit?: () => void;
hasCursorTelemetry?: boolean;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
@@ -275,6 +332,9 @@ export function SettingsPanel({
onZoomCustomScaleCommit,
selectedZoomFocusMode,
onZoomFocusModeChange,
+ selectedZoomFocus,
+ onZoomFocusCoordinateChange,
+ onZoomFocusCoordinateCommit,
hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
@@ -734,6 +794,70 @@ export function SettingsPanel({
)}
)}
+ {zoomEnabled &&
+ selectedZoomFocusMode !== "auto" &&
+ selectedZoomFocus &&
+ onZoomFocusCoordinateChange &&
+ (() => {
+ const effectiveZoomScale =
+ selectedZoomCustomScale ??
+ (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : MIN_ZOOM_SCALE);
+ const bounds = getFocusBoundsForScale(effectiveZoomScale);
+ const xRange = bounds.maxX - bounds.minX;
+ const yRange = bounds.maxY - bounds.minY;
+ const focusToPercentX = (cx: number) =>
+ xRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100));
+ const focusToPercentY = (cy: number) =>
+ yRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100));
+ const percentToFocusX = (p: number) =>
+ xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange;
+ const percentToFocusY = (p: number) =>
+ yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange;
+ return (
+
+
+ {t("zoom.position.title")}
+
+
+
+
+ {t("zoom.position.x")}
+
+
+ onZoomFocusCoordinateChange({
+ cx: percentToFocusX(p),
+ cy: selectedZoomFocus.cy,
+ })
+ }
+ onCommit={onZoomFocusCoordinateCommit}
+ />
+
+
+
+ {t("zoom.position.y")}
+
+
+ onZoomFocusCoordinateChange({
+ cx: selectedZoomFocus.cx,
+ cy: percentToFocusY(p),
+ })
+ }
+ onCommit={onZoomFocusCoordinateCommit}
+ />
+
+
+ {t("zoom.position.hint")}
+
+
+
+ );
+ })()}
{zoomEnabled && (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 2e04a83..12832ad 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -2102,6 +2102,15 @@ export default function VideoEditor() {
: null
}
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
+ selectedZoomFocus={
+ selectedZoomId
+ ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null)
+ : null
+ }
+ onZoomFocusCoordinateChange={(focus) =>
+ selectedZoomId && handleZoomFocusChange(selectedZoomId, focus)
+ }
+ onZoomFocusCoordinateCommit={commitState}
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts
index f893935..a0973ec 100644
--- a/src/components/video-editor/videoPlayback/focusUtils.ts
+++ b/src/components/video-editor/videoPlayback/focusUtils.ts
@@ -44,7 +44,7 @@ interface ViewportRatio {
heightRatio: number;
}
-function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
+export function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
const wr = viewportRatio?.widthRatio ?? 1;
const hr = viewportRatio?.heightRatio ?? 1;
const marginX = Math.min(0.5, wr / (2 * zoomScale));
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 6620b75..3ec0819 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -17,6 +17,12 @@
"left": "Left",
"right": "Right"
}
+ },
+ "position": {
+ "title": "Focus Position",
+ "x": "X (%)",
+ "y": "Y (%)",
+ "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost"
}
},
"speed": {