From 1b980d626434bf117cf0a360bf304572ae59c2ba Mon Sep 17 00:00:00 2001 From: Amir Yunus <54809019+AmirYunus@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:29:46 +0800 Subject: [PATCH 01/55] fix(hud): avoid horizontal scrollbar when recording on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use full-size layout and overflow clipping instead of 100vw/100vh on the HUD shell so the fixed 600×160 overlay does not gain a horizontal scrollbar when recording widens the toolbar. Fixes #305 --- src/App.tsx | 16 ++++++++++++++++ src/components/launch/LaunchWindow.tsx | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 9772ef8..985ecc7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,22 @@ export default function App() { document.getElementById("root")?.style.setProperty("background", "transparent"); } + // HUD window is a small fixed-size BrowserWindow (`electron/windows.ts`), not a full-screen + // surface. Pin the document shell to that viewport and hide overflow so the renderer cannot + // introduce scrollbars. Without this, `h-full` in `LaunchWindow` has no definite height chain + // from `html`/`body`, and stray overflow can still appear on some hosts (see issue #305). + if (type === "hud-overlay") { + document.documentElement.style.height = "100%"; + document.documentElement.style.overflow = "hidden"; + document.body.style.height = "100%"; + document.body.style.margin = "0"; + document.body.style.overflow = "hidden"; + const root = document.getElementById("root"); + root?.style.setProperty("height", "100%"); + root?.style.setProperty("min-height", "0"); + root?.style.setProperty("overflow", "hidden"); + } + // Load custom fonts on app initialization loadAllCustomFonts().catch((error) => { console.error("Failed to load custom fonts:", error); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f1b66b8..5c5ec92 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -241,7 +241,12 @@ export function LaunchWindow() { }; return ( -
+ // Root fills the HUD window only. Avoid `w-screen`/`h-screen` (`100vw`/`100vh`): `100vw` can + // exceed the inner layout width when scrollbars affect the viewport (notably on Windows), which + // showed up as a horizontal scrollbar once recording widened the toolbar (issue #305). +
{/* Language switcher — top-left, beside traffic lights */}
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} + /> + + )} +
+ + +
+
@@ -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} + /> + + )} +
+ + +
+
+ +
+ {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")}
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 ( +
+
+ + +
+ {colorMode === "wheel" && ( + <> +
+ {selectedColor} +
+ { + onUpdateColor(color.hex); + }} + style={{ + borderRadius: "8px", + }} + disableAlpha={true} + /> + + + )} + {colorMode === "palette" && ( + { + onUpdateColor(color.hex); + }} + style={{ + width: "100%", + borderRadius: "8px", + }} + /> + )} + {clearBackgroundOption && ( + + )} +
+ ); +} 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} - /> - - )} -
- - -
-
+ { + 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} - /> - - )} -
- - -
-
- + 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({ -
-
- - -
- {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 && ( )}
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 ( + <> +
+
+
+ OpenScreen +

+ {td("unsavedChanges.title")} +

+ +
+ +

{td("unsavedChanges.message")}

+

{td("unsavedChanges.detail")}

+ +
+ + + +
+
+ + ); +} 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 ( - <> -
-
-
- OpenScreen -

- {td("unsavedChanges.title")} -

- -
+ !open && onCancel()}> + + +
+ + + {td("unsavedChanges.title")} + +
+

{td("unsavedChanges.message")}

-

{td("unsavedChanges.detail")}

+ + {td("unsavedChanges.detail")} +
-
- + + ); } 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}`)} - -
- {hasConflict && conflict?.conflictWith.type === "configurable" && ( -
- - ⚠{" "} - {t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })} - -
- - -
+
+
+

+ {t("configurable")} +

+ {SHORTCUT_ACTIONS.map((action) => { + const isCapturing = captureFor === action; + const hasConflict = conflict?.forAction === action; + return ( +
+
+ {t(`actions.${action}`)} +
- )} + {hasConflict && conflict?.conflictWith.type === "configurable" && ( +
+ + ⚠{" "} + {t("alreadyUsedBy", { + action: t(`actions.${conflict.conflictWith.action}`), + })} + +
+ + +
+
+ )} +
+ ); + })} +
+ +
+

+ {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")}

- - +
+ {cursorHighlight && onCursorHighlightChange && ( +
+
+
Cursor highlight
+ +
+
+ {(["dot", "ring"] as const).map((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
+ +
+ )} +
+
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" + /> +
+
+ )} +
@@ -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")} +
- {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")} +
)}
-
Color
+
+ {t("effects.cursorHighlight.color")} +
-
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 ( + + ); + })} +
+
+ )} + {zoomEnabled && ( + {onSaveDiagnostic && ( + + )}
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 && + 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")} + +
+
+ + + onZoomFocusCoordinateChange({ + cx: percentToFocusX(p), + cy: selectedZoomFocus.cy, + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+
+ + + 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": {