From 08b5580ca2d73b90f08af1fd778b314ea496f4d2 Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 09:41:42 +0530 Subject: [PATCH 01/22] feat(launch): refine recording HUD and language switching UX --- src/components/launch/LaunchWindow.tsx | 278 ++++++++++++++++--------- src/components/ui/select.tsx | 64 +++--- src/contexts/I18nContext.tsx | 102 ++++++++- src/i18n/locales/en/launch.json | 8 +- src/i18n/locales/es/launch.json | 8 +- src/i18n/locales/zh-CN/launch.json | 8 +- 6 files changed, 341 insertions(+), 127 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 249dd77..79a32d5 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,5 @@ import { ChevronDown, Languages } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -18,9 +18,8 @@ import { } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; -import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; import { getLocaleName } from "@/i18n/loader"; -import { isMac as getIsMac } from "@/utils/platformUtils"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -28,6 +27,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; +import { Button } from "../ui/button"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -67,17 +67,24 @@ const hudGroupClasses = const hudIconBtnClasses = "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95"; +const hudAuxIconBtnClasses = + "flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; + const windowBtnClasses = "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; +const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; + export function LaunchWindow() { const t = useScopedT("launch"); - const { locale, setLocale } = useI18n(); - const [isMac, setIsMac] = useState(false); - - useEffect(() => { - getIsMac().then(setIsMac); - }, []); + const { + locale, + setLocale, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + } = useI18n(); + const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; const { recording, @@ -164,6 +171,8 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); + const languageMenuRef = useRef(null); useEffect(() => { const checkSelectedSource = async () => { @@ -185,6 +194,31 @@ export function LaunchWindow() { return () => clearInterval(interval); }, []); + useEffect(() => { + if (!isLanguageMenuOpen) return; + + const onPointerDown = (event: MouseEvent) => { + if (!languageMenuRef.current) return; + if (!languageMenuRef.current.contains(event.target as Node)) { + setIsLanguageMenuOpen(false); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsLanguageMenuOpen(false); + } + }; + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [isLanguageMenuOpen]); + const openSourceSelector = () => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); @@ -228,25 +262,42 @@ export function LaunchWindow() { }; return ( -
- {/* Language switcher — top-left, beside traffic lights */} -
- - -
+
+ {t("systemLanguagePrompt.title")} +
+
+ {t("systemLanguagePrompt.description", { + language: suggestedLanguageName, + })} +
+
+ + +
+
+ )} {/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */} {(showMicControls || showWebcamControls) && ( @@ -433,104 +484,133 @@ export function LaunchWindow() { {/* Record/Stop group */} {recording && ( - - - + + + + + + + + + )} - {/* Restart recording */} - {recording && ( - - - + {!recording && ( + <> + {/* Open video file */} + + + + + {/* Open project */} + + + + )} - {/* Cancel recording */} - {recording && ( - + {/* Right sidebar controls */} +
+
- - )} - {/* Open video file */} - - - + {isLanguageMenuOpen && ( +
+ {SUPPORTED_LOCALES.map((loc) => ( + + ))} +
+ )} +
- {/* Open project */} - - - - - {/* Window controls */} -
- - + {/* Window controls */} +
+ + +
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 53e21e6..3326ee9 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam const SelectContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( - - - - & { + showScrollButtons?: boolean; + viewportClassName?: string; + } +>( + ( + { + className, + children, + position = "popper", + showScrollButtons = true, + viewportClassName, + ...props + }, + ref, + ) => ( + + - {children} - - - - -)); + {showScrollButtons ? : null} + + {children} + + {showScrollButtons ? : null} + + + ), +); SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 0b75212..405d5c3 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -22,8 +22,13 @@ interface I18nContextValue { locale: Locale; setLocale: (locale: Locale) => void; t: (qualifiedKey: string, vars?: TranslateVars) => string; + systemLocaleSuggestion: Locale | null; + acceptSystemLocaleSuggestion: () => void; + dismissSystemLocaleSuggestion: () => void; } +const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen"; + const I18nContext = createContext(null); export function useI18n(): I18nContextValue { @@ -44,6 +49,35 @@ function isSupportedLocale(value: string): value is Locale { return (SUPPORTED_LOCALES as readonly string[]).includes(value); } +function getSupportedSystemLocale(): Locale | null { + if (typeof navigator === "undefined") return null; + + const candidates = + Array.isArray(navigator.languages) && navigator.languages.length > 0 + ? navigator.languages + : [navigator.language]; + + for (const candidate of candidates) { + if (!candidate) continue; + if (isSupportedLocale(candidate)) return candidate; + + const exactMatch = SUPPORTED_LOCALES.find( + (locale) => locale.toLowerCase() === candidate.toLowerCase(), + ); + if (exactMatch) return exactMatch; + + const baseLanguage = candidate.split("-")[0]?.toLowerCase(); + if (!baseLanguage) continue; + + if (baseLanguage === "zh") return "zh-CN"; + + const baseMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === baseLanguage); + if (baseMatch) return baseMatch; + } + + return null; +} + function getInitialLocale(): Locale { try { const stored = localStorage.getItem(LOCALE_STORAGE_KEY); @@ -56,6 +90,15 @@ function getInitialLocale(): Locale { export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(getInitialLocale); + const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState(null); + + const markPromptAsHandled = useCallback(() => { + try { + localStorage.setItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY, "1"); + } catch { + // localStorage may be unavailable + } + }, []); const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale); @@ -73,6 +116,46 @@ export function I18nProvider({ children }: { children: ReactNode }) { document.documentElement.lang = locale; }, [locale]); + useEffect(() => { + let hasStoredLocale = false; + let hasHandledSystemPrompt = false; + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + hasStoredLocale = Boolean(stored && isSupportedLocale(stored)); + hasHandledSystemPrompt = localStorage.getItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY) === "1"; + } catch { + // localStorage may be unavailable + } + + if (hasStoredLocale || hasHandledSystemPrompt || systemLocaleSuggestion) return; + + const detectedSystemLocale = getSupportedSystemLocale(); + if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) { + markPromptAsHandled(); + return; + } + + setSystemLocaleSuggestion(detectedSystemLocale); + }, [markPromptAsHandled, systemLocaleSuggestion]); + + const acceptSystemLocaleSuggestion = useCallback(() => { + if (!systemLocaleSuggestion) return; + setLocale(systemLocaleSuggestion); + setSystemLocaleSuggestion(null); + markPromptAsHandled(); + }, [markPromptAsHandled, setLocale, systemLocaleSuggestion]); + + const dismissSystemLocaleSuggestion = useCallback(() => { + setSystemLocaleSuggestion(null); + try { + // Persisting default locale avoids showing this prompt again. + localStorage.setItem(LOCALE_STORAGE_KEY, DEFAULT_LOCALE); + } catch { + // localStorage may be unavailable + } + markPromptAsHandled(); + }, [markPromptAsHandled]); + const t = useCallback( (qualifiedKey: string, vars?: TranslateVars): string => { const dotIndex = qualifiedKey.indexOf("."); @@ -84,7 +167,24 @@ export function I18nProvider({ children }: { children: ReactNode }) { [locale], ); - const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]); + const value = useMemo( + () => ({ + locale, + setLocale, + t, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + }), + [ + locale, + setLocale, + t, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + ], + ); return {children}; } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index cf111c4..e959a54 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "Please select a source to record" }, - "language": "Language" + "language": "Language", + "systemLanguagePrompt": { + "title": "Use your system language?", + "description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?", + "switch": "Switch to {{language}}", + "keepDefault": "Keep current language" + } } diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index f47bc81..68919aa 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "Por favor selecciona una fuente para grabar" }, - "language": "Idioma" + "language": "Idioma", + "systemLanguagePrompt": { + "title": "¿Usar el idioma del sistema?", + "description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?", + "switch": "Cambiar a {{language}}", + "keepDefault": "Mantener idioma actual" + } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 6b63df1..a5c2a9d 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "请选择要录制的源" }, - "language": "语言" + "language": "语言", + "systemLanguagePrompt": { + "title": "使用系统语言吗?", + "description": "我们检测到你的系统语言是{{language}}。是否将 OpenScreen 切换为{{language}}?", + "switch": "切换到{{language}}", + "keepDefault": "保持当前语言" + } } From 4e43b59b42fbe52690eb0267a3fe2b0980461bd3 Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 10:11:07 +0530 Subject: [PATCH 02/22] fix(launch): polish language menu behavior --- src/components/launch/LaunchWindow.tsx | 103 ++++++++++--------------- src/contexts/I18nContext.tsx | 9 ++- 2 files changed, 46 insertions(+), 66 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 79a32d5..a430be0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,5 @@ -import { ChevronDown, Languages } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { Check, ChevronDown, Languages } from "lucide-react"; +import { useEffect, useState } from "react"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -28,6 +28,12 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -171,8 +177,6 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); - const languageMenuRef = useRef(null); useEffect(() => { const checkSelectedSource = async () => { @@ -194,31 +198,6 @@ export function LaunchWindow() { return () => clearInterval(interval); }, []); - useEffect(() => { - if (!isLanguageMenuOpen) return; - - const onPointerDown = (event: MouseEvent) => { - if (!languageMenuRef.current) return; - if (!languageMenuRef.current.contains(event.target as Node)) { - setIsLanguageMenuOpen(false); - } - }; - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setIsLanguageMenuOpen(false); - } - }; - - document.addEventListener("mousedown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - - return () => { - document.removeEventListener("mousedown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - }; - }, [isLanguageMenuOpen]); - const openSourceSelector = () => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); @@ -557,42 +536,38 @@ export function LaunchWindow() { {/* Right sidebar controls */}
-
- - - {isLanguageMenuOpen && ( -
+ + - ))} -
- )} -
+
+ +
+ + + + + {SUPPORTED_LOCALES.map((loc) => ( + setLocale(loc)} + className={`flex items-center justify-between rounded-sm px-2 py-1.5 text-[11px] transition-colors ${loc === locale ? "text-white" : "text-white/90"} focus:bg-white/10 focus:text-white ${styles.electronNoDrag}`} + > + {getLocaleName(loc)} + {loc === locale ? : null} + + ))} + + {/* Window controls */}
diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 405d5c3..f9c5ee5 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -5,6 +5,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { @@ -91,6 +92,7 @@ function getInitialLocale(): Locale { export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(getInitialLocale); const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState(null); + const hasRunSystemLocaleCheckRef = useRef(false); const markPromptAsHandled = useCallback(() => { try { @@ -117,6 +119,9 @@ export function I18nProvider({ children }: { children: ReactNode }) { }, [locale]); useEffect(() => { + if (hasRunSystemLocaleCheckRef.current) return; + hasRunSystemLocaleCheckRef.current = true; + let hasStoredLocale = false; let hasHandledSystemPrompt = false; try { @@ -127,7 +132,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { // localStorage may be unavailable } - if (hasStoredLocale || hasHandledSystemPrompt || systemLocaleSuggestion) return; + if (hasStoredLocale || hasHandledSystemPrompt) return; const detectedSystemLocale = getSupportedSystemLocale(); if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) { @@ -136,7 +141,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { } setSystemLocaleSuggestion(detectedSystemLocale); - }, [markPromptAsHandled, systemLocaleSuggestion]); + }, [markPromptAsHandled]); const acceptSystemLocaleSuggestion = useCallback(() => { if (!systemLocaleSuggestion) return; From 3d20c67c63dfe7c7ad58c79bcb1242a971bf6da9 Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 10:15:41 +0530 Subject: [PATCH 03/22] fix(i18n): resolve prompt persistence and language menu behavior --- src/components/launch/LaunchWindow.tsx | 8 ++++++-- src/contexts/I18nContext.tsx | 14 ++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index a430be0..137b28c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -89,6 +89,7 @@ export function LaunchWindow() { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, } = useI18n(); const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; @@ -554,12 +555,15 @@ export function LaunchWindow() { side="top" sideOffset={6} collisionPadding={6} - className={`w-36 min-w-0 max-h-none overflow-hidden border-white/15 bg-[rgba(24,24,34,0.98)] p-1 text-white shadow-2xl backdrop-blur-xl ${styles.electronNoDrag}`} + className={`w-36 min-w-0 max-h-none overflow-y-hidden overflow-x-hidden border-white/15 bg-[rgba(24,24,34,0.98)] p-1 text-white shadow-2xl backdrop-blur-xl ${styles.electronNoDrag}`} > {SUPPORTED_LOCALES.map((loc) => ( setLocale(loc)} + onSelect={() => { + setLocale(loc); + resolveSystemLocaleSuggestion(); + }} className={`flex items-center justify-between rounded-sm px-2 py-1.5 text-[11px] transition-colors ${loc === locale ? "text-white" : "text-white/90"} focus:bg-white/10 focus:text-white ${styles.electronNoDrag}`} > {getLocaleName(loc)} diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index f9c5ee5..84640ea 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -26,6 +26,7 @@ interface I18nContextValue { systemLocaleSuggestion: Locale | null; acceptSystemLocaleSuggestion: () => void; dismissSystemLocaleSuggestion: () => void; + resolveSystemLocaleSuggestion: () => void; } const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen"; @@ -152,12 +153,11 @@ export function I18nProvider({ children }: { children: ReactNode }) { const dismissSystemLocaleSuggestion = useCallback(() => { setSystemLocaleSuggestion(null); - try { - // Persisting default locale avoids showing this prompt again. - localStorage.setItem(LOCALE_STORAGE_KEY, DEFAULT_LOCALE); - } catch { - // localStorage may be unavailable - } + markPromptAsHandled(); + }, [markPromptAsHandled]); + + const resolveSystemLocaleSuggestion = useCallback(() => { + setSystemLocaleSuggestion(null); markPromptAsHandled(); }, [markPromptAsHandled]); @@ -180,6 +180,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, }), [ locale, @@ -188,6 +189,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, ], ); From 5494acb5bafa303bdc54d521d7cf913d07edfb08 Mon Sep 17 00:00:00 2001 From: Sid <70214527+siddharthvaddem@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:21:02 -0700 Subject: [PATCH 04/22] Merge pull request #365 from AmitwalaH/fix-tutorial-translations fix(i18n): add missing tutorial dialog translation keys --- src/i18n/locales/en/dialogs.json | 13 ++++++++----- src/i18n/locales/es/dialogs.json | 12 +++++++----- src/i18n/locales/zh-CN/dialogs.json | 12 +++++++----- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index 66a33c2..a84b5fd 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -27,10 +27,11 @@ "triggerLabel": "How trimming works", "title": "How Trimming Works", "description": "Understanding how to cut out unwanted parts of your video.", - "explanation": "The Trim tool works by defining the segments you want to", - "explanationRemove": "remove", - "explanationCovered": "covered", - "explanationEnd": "by a red trim segment will be cut out when you export.", + "explanationBefore": "The Trim tool works by defining the segments you want to", + "remove": "remove", + "explanationMiddle": " — anything", + "covered": "covered", + "explanationAfter": "by a red trim segment will be cut out when you export.", "visualExample": "Visual Example", "removed": "REMOVED", "kept": "Kept", @@ -39,7 +40,9 @@ "part3": "Part 3", "finalVideo": "Final Video", "step1Title": "1. Add Trim", - "step1Description": "Press T or click the scissors icon to mark a section for removal.", + "step1DescriptionBefore": "Press ", + "step1DescriptionAfter": " or click the scissors icon to mark a section for removal.", + "step2Title": "2. Adjust", "step2Description": "Drag the edges of the red region to cover exactly what you want to cut out." }, diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index acf2a04..f8a5e63 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -27,10 +27,11 @@ "triggerLabel": "Cómo funciona el recorte", "title": "Cómo funciona el recorte", "description": "Aprende a eliminar las partes no deseadas de tu video.", - "explanation": "La herramienta de recorte funciona definiendo los segmentos que deseas", - "explanationRemove": "eliminar", - "explanationCovered": "cubierto", - "explanationEnd": "por un segmento rojo de recorte será eliminado al exportar.", + "explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas", + "remove": "eliminar", + "explanationMiddle": " — cualquier parte", + "covered": "cubierta", + "explanationAfter": "por un segmento rojo será eliminada al exportar.", "visualExample": "Ejemplo visual", "removed": "ELIMINADO", "kept": "Conservado", @@ -39,7 +40,8 @@ "part3": "Parte 3", "finalVideo": "Video final", "step1Title": "1. Agregar recorte", - "step1Description": "Presiona T o haz clic en el ícono de tijeras para marcar una sección a eliminar.", + "step1DescriptionBefore": "Presiona ", + "step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.", "step2Title": "2. Ajustar", "step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar." }, diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 3f181bc..0385b36 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -27,10 +27,11 @@ "triggerLabel": "剪辑功能说明", "title": "剪辑功能说明", "description": "了解如何剪掉视频中不需要的部分。", - "explanation": "剪辑工具通过定义您要", - "explanationRemove": "移除", - "explanationCovered": "覆盖", - "explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。", + "explanationBefore": "剪辑工具通过定义您要", + "remove": "移除", + "explanationMiddle": "——任何被", + "covered": "覆盖", + "explanationAfter": "的红色剪辑区域部分将在导出时被剪掉。", "visualExample": "示例演示", "removed": "已移除", "kept": "保留", @@ -39,7 +40,8 @@ "part3": "第 3 部分", "finalVideo": "最终视频", "step1Title": "1. 添加剪辑", - "step1Description": "按 T 或点击剪刀图标来标记要移除的片段。", + "step1DescriptionBefore": "按", + "step1DescriptionAfter": "键或点击剪刀图标来标记要移除的片段。", "step2Title": "2. 调整", "step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。" }, From d21dd1cbf1e4861e296fe88f66601ac74c3afa15 Mon Sep 17 00:00:00 2001 From: Theodor Peifer Date: Fri, 10 Apr 2026 22:24:37 +0200 Subject: [PATCH 05/22] fix: export frame counter exceeding total frames --- src/lib/exporter/gifExporter.ts | 4 ++-- src/lib/exporter/streamingDecoder.ts | 19 ++++++++++++++++--- src/lib/exporter/videoExporter.ts | 4 ++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 747e34e..58ed693 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -174,11 +174,11 @@ export class GifExporter { }); // Calculate effective duration and frame count (excluding trim regions) - const effectiveDuration = this.streamingDecoder.getEffectiveDuration( + const { effectiveDuration, totalFrames } = this.streamingDecoder.getExportMetrics( + this.config.frameRate, this.config.trimRegions, this.config.speedRegions, ); - const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); // Calculate frame delay in milliseconds (gif.js uses ms) const frameDelay = Math.round(1000 / this.config.frameRate); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index ee67576..c028832 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -536,11 +536,24 @@ export class StreamingVideoDecoder { return segments; } - getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number { + getExportMetrics( + targetFrameRate: number, + trimRegions?: TrimRegion[], + speedRegions?: SpeedRegion[], + ): { effectiveDuration: number; totalFrames: number } { if (!this.metadata) throw new Error("Must call loadMetadata() first"); const trimSegments = this.computeSegments(this.metadata.duration, trimRegions); - const speedSegments = this.splitBySpeed(trimSegments, speedRegions); - return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0); + const segments = this.splitBySpeed(trimSegments, speedRegions); + return { + effectiveDuration: segments.reduce( + (sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, + 0, + ), + totalFrames: segments.reduce( + (sum, seg) => sum + Math.ceil(((seg.endSec - seg.startSec) / seg.speed) * targetFrameRate), + 0, + ), + }; } private splitBySpeed( diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index d0affd1..dcfcc3e 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -157,11 +157,11 @@ export class VideoExporter { this.muxer = muxer; await muxer.initialize(); - const effectiveDuration = streamingDecoder.getEffectiveDuration( + const { effectiveDuration, totalFrames } = streamingDecoder.getExportMetrics( + this.config.frameRate, this.config.trimRegions, this.config.speedRegions, ); - const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); const readEndSec = Math.max(videoInfo.duration, videoInfo.streamDuration ?? 0) + 0.5; console.log("[VideoExporter] Original duration:", videoInfo.duration, "s"); From 363683d2886e7b67d25440ba38e4e6e2199f6435 Mon Sep 17 00:00:00 2001 From: Kendrick <137743645+pufferfish3e@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:57:46 +0800 Subject: [PATCH 06/22] Add documentation section to README Added a documentation section with a link to OpenScreen Docs. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index b42355e..074eaa7 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,11 @@ System audio capture relies on Electron's [desktopCapturer](https://www.electron _I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_ +## Documentation + +See the documentation here: +[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen) + ## Contributing Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute. From 0c627da22c27968c52fb626bc84a721551fa075d Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 09:41:42 +0530 Subject: [PATCH 07/22] feat(launch): refine recording HUD and language switching UX --- src/components/launch/LaunchWindow.tsx | 278 ++++++++++++++++--------- src/components/ui/select.tsx | 64 +++--- src/contexts/I18nContext.tsx | 102 ++++++++- src/i18n/locales/en/launch.json | 8 +- src/i18n/locales/es/launch.json | 8 +- src/i18n/locales/zh-CN/launch.json | 8 +- 6 files changed, 341 insertions(+), 127 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 249dd77..79a32d5 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,5 @@ import { ChevronDown, Languages } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -18,9 +18,8 @@ import { } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; -import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; import { getLocaleName } from "@/i18n/loader"; -import { isMac as getIsMac } from "@/utils/platformUtils"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -28,6 +27,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; +import { Button } from "../ui/button"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -67,17 +67,24 @@ const hudGroupClasses = const hudIconBtnClasses = "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95"; +const hudAuxIconBtnClasses = + "flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; + const windowBtnClasses = "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; +const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; + export function LaunchWindow() { const t = useScopedT("launch"); - const { locale, setLocale } = useI18n(); - const [isMac, setIsMac] = useState(false); - - useEffect(() => { - getIsMac().then(setIsMac); - }, []); + const { + locale, + setLocale, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + } = useI18n(); + const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; const { recording, @@ -164,6 +171,8 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); + const languageMenuRef = useRef(null); useEffect(() => { const checkSelectedSource = async () => { @@ -185,6 +194,31 @@ export function LaunchWindow() { return () => clearInterval(interval); }, []); + useEffect(() => { + if (!isLanguageMenuOpen) return; + + const onPointerDown = (event: MouseEvent) => { + if (!languageMenuRef.current) return; + if (!languageMenuRef.current.contains(event.target as Node)) { + setIsLanguageMenuOpen(false); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsLanguageMenuOpen(false); + } + }; + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [isLanguageMenuOpen]); + const openSourceSelector = () => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); @@ -228,25 +262,42 @@ export function LaunchWindow() { }; return ( -
- {/* Language switcher — top-left, beside traffic lights */} -
- - -
+
+ {t("systemLanguagePrompt.title")} +
+
+ {t("systemLanguagePrompt.description", { + language: suggestedLanguageName, + })} +
+
+ + +
+
+ )} {/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */} {(showMicControls || showWebcamControls) && ( @@ -433,104 +484,133 @@ export function LaunchWindow() { {/* Record/Stop group */} {recording && ( - - - + + + + + + + + +
)} - {/* Restart recording */} - {recording && ( - - - + {!recording && ( + <> + {/* Open video file */} + + + + + {/* Open project */} + + + + )} - {/* Cancel recording */} - {recording && ( - + {/* Right sidebar controls */} +
+
- - )} - {/* Open video file */} - - - + {isLanguageMenuOpen && ( +
+ {SUPPORTED_LOCALES.map((loc) => ( + + ))} +
+ )} +
- {/* Open project */} - - - - - {/* Window controls */} -
- - + {/* Window controls */} +
+ + +
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 53e21e6..3326ee9 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam const SelectContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( - - - - & { + showScrollButtons?: boolean; + viewportClassName?: string; + } +>( + ( + { + className, + children, + position = "popper", + showScrollButtons = true, + viewportClassName, + ...props + }, + ref, + ) => ( + + - {children} - - - - -)); + {showScrollButtons ? : null} + + {children} + + {showScrollButtons ? : null} + + + ), +); SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 0b75212..405d5c3 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -22,8 +22,13 @@ interface I18nContextValue { locale: Locale; setLocale: (locale: Locale) => void; t: (qualifiedKey: string, vars?: TranslateVars) => string; + systemLocaleSuggestion: Locale | null; + acceptSystemLocaleSuggestion: () => void; + dismissSystemLocaleSuggestion: () => void; } +const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen"; + const I18nContext = createContext(null); export function useI18n(): I18nContextValue { @@ -44,6 +49,35 @@ function isSupportedLocale(value: string): value is Locale { return (SUPPORTED_LOCALES as readonly string[]).includes(value); } +function getSupportedSystemLocale(): Locale | null { + if (typeof navigator === "undefined") return null; + + const candidates = + Array.isArray(navigator.languages) && navigator.languages.length > 0 + ? navigator.languages + : [navigator.language]; + + for (const candidate of candidates) { + if (!candidate) continue; + if (isSupportedLocale(candidate)) return candidate; + + const exactMatch = SUPPORTED_LOCALES.find( + (locale) => locale.toLowerCase() === candidate.toLowerCase(), + ); + if (exactMatch) return exactMatch; + + const baseLanguage = candidate.split("-")[0]?.toLowerCase(); + if (!baseLanguage) continue; + + if (baseLanguage === "zh") return "zh-CN"; + + const baseMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === baseLanguage); + if (baseMatch) return baseMatch; + } + + return null; +} + function getInitialLocale(): Locale { try { const stored = localStorage.getItem(LOCALE_STORAGE_KEY); @@ -56,6 +90,15 @@ function getInitialLocale(): Locale { export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(getInitialLocale); + const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState(null); + + const markPromptAsHandled = useCallback(() => { + try { + localStorage.setItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY, "1"); + } catch { + // localStorage may be unavailable + } + }, []); const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale); @@ -73,6 +116,46 @@ export function I18nProvider({ children }: { children: ReactNode }) { document.documentElement.lang = locale; }, [locale]); + useEffect(() => { + let hasStoredLocale = false; + let hasHandledSystemPrompt = false; + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + hasStoredLocale = Boolean(stored && isSupportedLocale(stored)); + hasHandledSystemPrompt = localStorage.getItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY) === "1"; + } catch { + // localStorage may be unavailable + } + + if (hasStoredLocale || hasHandledSystemPrompt || systemLocaleSuggestion) return; + + const detectedSystemLocale = getSupportedSystemLocale(); + if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) { + markPromptAsHandled(); + return; + } + + setSystemLocaleSuggestion(detectedSystemLocale); + }, [markPromptAsHandled, systemLocaleSuggestion]); + + const acceptSystemLocaleSuggestion = useCallback(() => { + if (!systemLocaleSuggestion) return; + setLocale(systemLocaleSuggestion); + setSystemLocaleSuggestion(null); + markPromptAsHandled(); + }, [markPromptAsHandled, setLocale, systemLocaleSuggestion]); + + const dismissSystemLocaleSuggestion = useCallback(() => { + setSystemLocaleSuggestion(null); + try { + // Persisting default locale avoids showing this prompt again. + localStorage.setItem(LOCALE_STORAGE_KEY, DEFAULT_LOCALE); + } catch { + // localStorage may be unavailable + } + markPromptAsHandled(); + }, [markPromptAsHandled]); + const t = useCallback( (qualifiedKey: string, vars?: TranslateVars): string => { const dotIndex = qualifiedKey.indexOf("."); @@ -84,7 +167,24 @@ export function I18nProvider({ children }: { children: ReactNode }) { [locale], ); - const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]); + const value = useMemo( + () => ({ + locale, + setLocale, + t, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + }), + [ + locale, + setLocale, + t, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + ], + ); return {children}; } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index cf111c4..e959a54 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "Please select a source to record" }, - "language": "Language" + "language": "Language", + "systemLanguagePrompt": { + "title": "Use your system language?", + "description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?", + "switch": "Switch to {{language}}", + "keepDefault": "Keep current language" + } } diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index f47bc81..68919aa 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "Por favor selecciona una fuente para grabar" }, - "language": "Idioma" + "language": "Idioma", + "systemLanguagePrompt": { + "title": "¿Usar el idioma del sistema?", + "description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?", + "switch": "Cambiar a {{language}}", + "keepDefault": "Mantener idioma actual" + } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 6b63df1..a5c2a9d 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "请选择要录制的源" }, - "language": "语言" + "language": "语言", + "systemLanguagePrompt": { + "title": "使用系统语言吗?", + "description": "我们检测到你的系统语言是{{language}}。是否将 OpenScreen 切换为{{language}}?", + "switch": "切换到{{language}}", + "keepDefault": "保持当前语言" + } } From c9c2634db4b6bba20333ac96969f6c16a666706c Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 10:11:07 +0530 Subject: [PATCH 08/22] fix(launch): polish language menu behavior --- src/components/launch/LaunchWindow.tsx | 103 ++++++++++--------------- src/contexts/I18nContext.tsx | 9 ++- 2 files changed, 46 insertions(+), 66 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 79a32d5..a430be0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,5 @@ -import { ChevronDown, Languages } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { Check, ChevronDown, Languages } from "lucide-react"; +import { useEffect, useState } from "react"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -28,6 +28,12 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -171,8 +177,6 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); - const languageMenuRef = useRef(null); useEffect(() => { const checkSelectedSource = async () => { @@ -194,31 +198,6 @@ export function LaunchWindow() { return () => clearInterval(interval); }, []); - useEffect(() => { - if (!isLanguageMenuOpen) return; - - const onPointerDown = (event: MouseEvent) => { - if (!languageMenuRef.current) return; - if (!languageMenuRef.current.contains(event.target as Node)) { - setIsLanguageMenuOpen(false); - } - }; - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setIsLanguageMenuOpen(false); - } - }; - - document.addEventListener("mousedown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - - return () => { - document.removeEventListener("mousedown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - }; - }, [isLanguageMenuOpen]); - const openSourceSelector = () => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); @@ -557,42 +536,38 @@ export function LaunchWindow() { {/* Right sidebar controls */}
-
- - - {isLanguageMenuOpen && ( -
+ + - ))} -
- )} -
+
+ +
+ + + + + {SUPPORTED_LOCALES.map((loc) => ( + setLocale(loc)} + className={`flex items-center justify-between rounded-sm px-2 py-1.5 text-[11px] transition-colors ${loc === locale ? "text-white" : "text-white/90"} focus:bg-white/10 focus:text-white ${styles.electronNoDrag}`} + > + {getLocaleName(loc)} + {loc === locale ? : null} + + ))} + + {/* Window controls */}
diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 405d5c3..f9c5ee5 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -5,6 +5,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { @@ -91,6 +92,7 @@ function getInitialLocale(): Locale { export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(getInitialLocale); const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState(null); + const hasRunSystemLocaleCheckRef = useRef(false); const markPromptAsHandled = useCallback(() => { try { @@ -117,6 +119,9 @@ export function I18nProvider({ children }: { children: ReactNode }) { }, [locale]); useEffect(() => { + if (hasRunSystemLocaleCheckRef.current) return; + hasRunSystemLocaleCheckRef.current = true; + let hasStoredLocale = false; let hasHandledSystemPrompt = false; try { @@ -127,7 +132,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { // localStorage may be unavailable } - if (hasStoredLocale || hasHandledSystemPrompt || systemLocaleSuggestion) return; + if (hasStoredLocale || hasHandledSystemPrompt) return; const detectedSystemLocale = getSupportedSystemLocale(); if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) { @@ -136,7 +141,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { } setSystemLocaleSuggestion(detectedSystemLocale); - }, [markPromptAsHandled, systemLocaleSuggestion]); + }, [markPromptAsHandled]); const acceptSystemLocaleSuggestion = useCallback(() => { if (!systemLocaleSuggestion) return; From 97fbb01801ab85f6db072afc841de1bb830e2efa Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 10:15:41 +0530 Subject: [PATCH 09/22] fix(i18n): resolve prompt persistence and language menu behavior --- src/components/launch/LaunchWindow.tsx | 8 ++++++-- src/contexts/I18nContext.tsx | 14 ++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index a430be0..137b28c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -89,6 +89,7 @@ export function LaunchWindow() { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, } = useI18n(); const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; @@ -554,12 +555,15 @@ export function LaunchWindow() { side="top" sideOffset={6} collisionPadding={6} - className={`w-36 min-w-0 max-h-none overflow-hidden border-white/15 bg-[rgba(24,24,34,0.98)] p-1 text-white shadow-2xl backdrop-blur-xl ${styles.electronNoDrag}`} + className={`w-36 min-w-0 max-h-none overflow-y-hidden overflow-x-hidden border-white/15 bg-[rgba(24,24,34,0.98)] p-1 text-white shadow-2xl backdrop-blur-xl ${styles.electronNoDrag}`} > {SUPPORTED_LOCALES.map((loc) => ( setLocale(loc)} + onSelect={() => { + setLocale(loc); + resolveSystemLocaleSuggestion(); + }} className={`flex items-center justify-between rounded-sm px-2 py-1.5 text-[11px] transition-colors ${loc === locale ? "text-white" : "text-white/90"} focus:bg-white/10 focus:text-white ${styles.electronNoDrag}`} > {getLocaleName(loc)} diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index f9c5ee5..84640ea 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -26,6 +26,7 @@ interface I18nContextValue { systemLocaleSuggestion: Locale | null; acceptSystemLocaleSuggestion: () => void; dismissSystemLocaleSuggestion: () => void; + resolveSystemLocaleSuggestion: () => void; } const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen"; @@ -152,12 +153,11 @@ export function I18nProvider({ children }: { children: ReactNode }) { const dismissSystemLocaleSuggestion = useCallback(() => { setSystemLocaleSuggestion(null); - try { - // Persisting default locale avoids showing this prompt again. - localStorage.setItem(LOCALE_STORAGE_KEY, DEFAULT_LOCALE); - } catch { - // localStorage may be unavailable - } + markPromptAsHandled(); + }, [markPromptAsHandled]); + + const resolveSystemLocaleSuggestion = useCallback(() => { + setSystemLocaleSuggestion(null); markPromptAsHandled(); }, [markPromptAsHandled]); @@ -180,6 +180,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, }), [ locale, @@ -188,6 +189,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, ], ); From e96478e8130b6d0bb59ce3dcd67f25791f62e7a9 Mon Sep 17 00:00:00 2001 From: imAaryash Date: Sun, 12 Apr 2026 04:22:08 +0530 Subject: [PATCH 10/22] Revert "Merge pull request #365 from AmitwalaH/fix-tutorial-translations" This reverts commit 5494acb5bafa303bdc54d521d7cf913d07edfb08. --- src/i18n/locales/en/dialogs.json | 13 +++++-------- src/i18n/locales/es/dialogs.json | 12 +++++------- src/i18n/locales/zh-CN/dialogs.json | 12 +++++------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index a84b5fd..66a33c2 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -27,11 +27,10 @@ "triggerLabel": "How trimming works", "title": "How Trimming Works", "description": "Understanding how to cut out unwanted parts of your video.", - "explanationBefore": "The Trim tool works by defining the segments you want to", - "remove": "remove", - "explanationMiddle": " — anything", - "covered": "covered", - "explanationAfter": "by a red trim segment will be cut out when you export.", + "explanation": "The Trim tool works by defining the segments you want to", + "explanationRemove": "remove", + "explanationCovered": "covered", + "explanationEnd": "by a red trim segment will be cut out when you export.", "visualExample": "Visual Example", "removed": "REMOVED", "kept": "Kept", @@ -40,9 +39,7 @@ "part3": "Part 3", "finalVideo": "Final Video", "step1Title": "1. Add Trim", - "step1DescriptionBefore": "Press ", - "step1DescriptionAfter": " or click the scissors icon to mark a section for removal.", - + "step1Description": "Press T or click the scissors icon to mark a section for removal.", "step2Title": "2. Adjust", "step2Description": "Drag the edges of the red region to cover exactly what you want to cut out." }, diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index f8a5e63..acf2a04 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -27,11 +27,10 @@ "triggerLabel": "Cómo funciona el recorte", "title": "Cómo funciona el recorte", "description": "Aprende a eliminar las partes no deseadas de tu video.", - "explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas", - "remove": "eliminar", - "explanationMiddle": " — cualquier parte", - "covered": "cubierta", - "explanationAfter": "por un segmento rojo será eliminada al exportar.", + "explanation": "La herramienta de recorte funciona definiendo los segmentos que deseas", + "explanationRemove": "eliminar", + "explanationCovered": "cubierto", + "explanationEnd": "por un segmento rojo de recorte será eliminado al exportar.", "visualExample": "Ejemplo visual", "removed": "ELIMINADO", "kept": "Conservado", @@ -40,8 +39,7 @@ "part3": "Parte 3", "finalVideo": "Video final", "step1Title": "1. Agregar recorte", - "step1DescriptionBefore": "Presiona ", - "step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.", + "step1Description": "Presiona T o haz clic en el ícono de tijeras para marcar una sección a eliminar.", "step2Title": "2. Ajustar", "step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar." }, diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 0385b36..3f181bc 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -27,11 +27,10 @@ "triggerLabel": "剪辑功能说明", "title": "剪辑功能说明", "description": "了解如何剪掉视频中不需要的部分。", - "explanationBefore": "剪辑工具通过定义您要", - "remove": "移除", - "explanationMiddle": "——任何被", - "covered": "覆盖", - "explanationAfter": "的红色剪辑区域部分将在导出时被剪掉。", + "explanation": "剪辑工具通过定义您要", + "explanationRemove": "移除", + "explanationCovered": "覆盖", + "explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。", "visualExample": "示例演示", "removed": "已移除", "kept": "保留", @@ -40,8 +39,7 @@ "part3": "第 3 部分", "finalVideo": "最终视频", "step1Title": "1. 添加剪辑", - "step1DescriptionBefore": "按", - "step1DescriptionAfter": "键或点击剪刀图标来标记要移除的片段。", + "step1Description": "按 T 或点击剪刀图标来标记要移除的片段。", "step2Title": "2. 调整", "step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。" }, From d1c9555464ecc59a70972c95bf60462386847cde Mon Sep 17 00:00:00 2001 From: imAaryash Date: Sun, 12 Apr 2026 05:13:31 +0530 Subject: [PATCH 11/22] feat(i18n): auto-discover valid locales and harden language menu - derive available locales from locale folders with required namespace validation - exclude incomplete locales and report missing namespace files - align system-language suggestion and selectors with discovered locales - improve launch HUD language menu interaction, scrolling, and viewport clipping - make i18n-check discover locale folders automatically --- scripts/i18n-check.mjs | 14 +- src/components/launch/LaunchWindow.module.css | 75 ++++++++ src/components/launch/LaunchWindow.tsx | 175 +++++++++++++----- src/components/ui/dropdown-menu.tsx | 18 +- src/components/video-editor/VideoEditor.tsx | 7 +- src/contexts/I18nContext.tsx | 19 +- src/i18n/config.ts | 3 +- src/i18n/loader.ts | 77 +++++++- 8 files changed, 314 insertions(+), 74 deletions(-) diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs index ca73b23..699ae9e 100644 --- a/scripts/i18n-check.mjs +++ b/scripts/i18n-check.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Validates that all locale translation files have identical key structures. - * Compares zh-CN and es against the en baseline for every namespace. + * Compares all locale folders (except en) against the en baseline for every namespace. * * Usage: node scripts/i18n-check.mjs */ @@ -11,7 +11,6 @@ import path from "node:path"; const LOCALES_DIR = path.resolve("src/i18n/locales"); const BASE_LOCALE = "en"; -const COMPARE_LOCALES = ["zh-CN", "es", "tr", "ko-KR"]; function getKeys(obj, prefix = "") { const keys = []; @@ -34,12 +33,19 @@ const namespaces = fs .filter((f) => f.endsWith(".json")) .map((f) => f.replace(".json", "")); +const compareLocales = fs + .readdirSync(LOCALES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((locale) => locale !== BASE_LOCALE) + .sort((a, b) => a.localeCompare(b)); + for (const namespace of namespaces) { const basePath = path.join(baseDir, `${namespace}.json`); const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8")); const baseKeys = getKeys(baseData); - for (const locale of COMPARE_LOCALES) { + for (const locale of compareLocales) { const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`); if (!fs.existsSync(localePath)) { @@ -77,6 +83,6 @@ if (hasErrors) { process.exit(1); } else { console.log( - `i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`, + `i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`, ); } diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index ff68c3d..132fa0a 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -6,3 +6,78 @@ .electronNoDrag { -webkit-app-region: no-drag; } + +.languageMenuScroll { + max-height: 16rem; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; +} + +.languageMenuScroll::-webkit-scrollbar { + width: 8px; +} + +.languageMenuScroll::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.04); + border-radius: 999px; +} + +.languageMenuScroll::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2)); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.languageMenuScroll::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3)); +} + +.languageMenuContainer { + position: relative; + z-index: 20; +} + +.languageMenuPanel { + position: fixed; + right: 0; + top: 0; + width: 12rem; + padding: 0.375rem; + border-radius: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.14); + background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98)); + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55); + backdrop-filter: blur(14px); + pointer-events: auto; + box-sizing: border-box; +} + +.languageMenuItem { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.625rem; + border-radius: 0.5rem; + font-size: 11px; + color: rgba(255, 255, 255, 0.88); + background: transparent; + border: 0; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.languageMenuItem:hover, +.languageMenuItem:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + outline: none; +} + +.languageMenuItemActive { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 137b28c..2914584 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,6 @@ import { Check, ChevronDown, Languages } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -18,8 +19,7 @@ import { } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; -import { getLocaleName } from "@/i18n/loader"; +import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -28,12 +28,6 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -83,6 +77,7 @@ const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-cen export function LaunchWindow() { const t = useScopedT("launch"); + const availableLocales = getAvailableLocales(); const { locale, setLocale, @@ -123,6 +118,18 @@ export function LaunchWindow() { const [isWebcamHovered, setIsWebcamHovered] = useState(false); const [isWebcamFocused, setIsWebcamFocused] = useState(false); const webcamExpanded = isWebcamHovered || isWebcamFocused; + const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); + const languageTriggerRef = useRef(null); + const languageMenuPanelRef = useRef(null); + const [languageMenuStyle, setLanguageMenuStyle] = useState<{ + right: number; + top: number; + maxHeight: number; + }>({ + right: 12, + top: 12, + maxHeight: 240, + }); const { devices: micDevices, @@ -176,6 +183,71 @@ export function LaunchWindow() { }); }, []); + useEffect(() => { + if (!isLanguageMenuOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + const clickedTrigger = languageTriggerRef.current?.contains(target); + const clickedMenu = languageMenuPanelRef.current?.contains(target); + if (!clickedTrigger && !clickedMenu) { + setIsLanguageMenuOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsLanguageMenuOpen(false); + } + }; + + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleEscape); + + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleEscape); + }; + }, [isLanguageMenuOpen]); + + useEffect(() => { + if (!isLanguageMenuOpen || !languageTriggerRef.current) return; + + const updatePosition = () => { + if (!languageTriggerRef.current) return; + const rect = languageTriggerRef.current.getBoundingClientRect(); + const gap = 8; + const viewportPadding = 8; + const availableHeight = Math.max(80, rect.top - viewportPadding - gap); + const top = Math.max(viewportPadding, rect.top - gap - availableHeight); + + setLanguageMenuStyle({ + right: Math.max(viewportPadding, window.innerWidth - rect.right), + top, + maxHeight: availableHeight, + }); + }; + + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [isLanguageMenuOpen]); + + useEffect(() => { + if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return; + const id = requestAnimationFrame(() => { + if (languageMenuPanelRef.current) { + languageMenuPanelRef.current.scrollTop = 0; + } + }); + return () => cancelAnimationFrame(id); + }, [isLanguageMenuOpen]); + const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); @@ -537,41 +609,60 @@ export function LaunchWindow() { {/* Right sidebar controls */}
- - - - - - + +
+ + {isLanguageMenuOpen + ? createPortal( +
event.stopPropagation()} > - {getLocaleName(loc)} - {loc === locale ? : null} - - ))} - - + {availableLocales.map((loc) => ( + + ))} +
, + document.body, + ) + : null} {/* Window controls */}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index c15187d..f4dd29f 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam const DropdownMenuContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { + portalled?: boolean; + } +>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => { + const content = ( - -)); + ); + + if (!portalled) { + return content; + } + + return {content}; +}); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0321f43..47c5668 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -14,8 +14,8 @@ import { import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; -import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; -import { getLocaleName } from "@/i18n/loader"; +import { type Locale } from "@/i18n/config"; +import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { calculateOutputDimensions, type ExportFormat, @@ -154,6 +154,7 @@ export default function VideoEditor() { const { shortcuts, isMac } = useShortcuts(); const t = useScopedT("editor"); const ts = useScopedT("settings"); + const availableLocales = getAvailableLocales(); const { locale, setLocale } = useI18n(); const nextAnnotationIdRef = useRef(1); @@ -1707,7 +1708,7 @@ export default function VideoEditor() { className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1" style={{ color: "inherit" }} > - {SUPPORTED_LOCALES.map((loc) => ( + {availableLocales.map((loc) => ( diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 84640ea..1056749 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -8,14 +8,8 @@ import { useRef, useState, } from "react"; -import { - DEFAULT_LOCALE, - type I18nNamespace, - LOCALE_STORAGE_KEY, - type Locale, - SUPPORTED_LOCALES, -} from "@/i18n/config"; -import { translate } from "@/i18n/loader"; +import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config"; +import { getAvailableLocales, translate } from "@/i18n/loader"; type TranslateVars = Record; @@ -48,11 +42,12 @@ export function useScopedT(namespace: I18nNamespace) { } function isSupportedLocale(value: string): value is Locale { - return (SUPPORTED_LOCALES as readonly string[]).includes(value); + return getAvailableLocales().includes(value); } function getSupportedSystemLocale(): Locale | null { if (typeof navigator === "undefined") return null; + const availableLocales = getAvailableLocales(); const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0 @@ -63,7 +58,7 @@ function getSupportedSystemLocale(): Locale | null { if (!candidate) continue; if (isSupportedLocale(candidate)) return candidate; - const exactMatch = SUPPORTED_LOCALES.find( + const exactMatch = availableLocales.find( (locale) => locale.toLowerCase() === candidate.toLowerCase(), ); if (exactMatch) return exactMatch; @@ -71,9 +66,9 @@ function getSupportedSystemLocale(): Locale | null { const baseLanguage = candidate.split("-")[0]?.toLowerCase(); if (!baseLanguage) continue; - if (baseLanguage === "zh") return "zh-CN"; + if (baseLanguage === "zh" && availableLocales.includes("zh-CN")) return "zh-CN"; - const baseMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === baseLanguage); + const baseMatch = availableLocales.find((locale) => locale.toLowerCase() === baseLanguage); if (baseMatch) return baseMatch; } diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 0933569..507aa4d 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -1,5 +1,4 @@ export const DEFAULT_LOCALE = "en" as const; -export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr", "tr", "ko-KR"] as const; export const I18N_NAMESPACES = [ "common", "dialogs", @@ -10,7 +9,7 @@ export const I18N_NAMESPACES = [ "timeline", ] as const; -export type Locale = (typeof SUPPORTED_LOCALES)[number]; +export type Locale = string; export type I18nNamespace = (typeof I18N_NAMESPACES)[number]; export const LOCALE_STORAGE_KEY = "openscreen-locale"; diff --git a/src/i18n/loader.ts b/src/i18n/loader.ts index 4736db8..36d8eb6 100644 --- a/src/i18n/loader.ts +++ b/src/i18n/loader.ts @@ -1,6 +1,10 @@ -import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config"; +import { DEFAULT_LOCALE, I18N_NAMESPACES, type I18nNamespace, type Locale } from "./config"; type MessageMap = Record; +type LocaleValidationError = { + locale: string; + missingNamespaces: I18nNamespace[]; +}; const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record< string, @@ -18,6 +22,62 @@ for (const [path, mod] of Object.entries(modules)) { messages[locale][namespace] = mod.default; } +const REQUIRED_NAMESPACES = new Set(I18N_NAMESPACES); + +const localeValidationErrors: LocaleValidationError[] = Object.keys(messages) + .map((locale) => { + const localeMessages = messages[locale] ?? {}; + const missingNamespaces = I18N_NAMESPACES.filter((namespace) => !localeMessages[namespace]); + return { + locale, + missingNamespaces, + }; + }) + .filter((entry) => entry.missingNamespaces.length > 0); + +const invalidLocales = new Set(localeValidationErrors.map((entry) => entry.locale)); + +const availableLocales = Object.keys(messages) + .filter((locale) => REQUIRED_NAMESPACES.size > 0 && hasRequiredNamespaces(messages[locale])) + .filter((locale) => !invalidLocales.has(locale)) + .sort((a, b) => { + if (a === DEFAULT_LOCALE) return -1; + if (b === DEFAULT_LOCALE) return 1; + return a.localeCompare(b); + }); + +if (localeValidationErrors.length > 0) { + console.error("[i18n] Incomplete locale folders were excluded:"); + for (const entry of localeValidationErrors) { + console.error( + `[i18n] ${entry.locale}: missing ${entry.missingNamespaces.map((ns) => `${ns}.json`).join(", ")}`, + ); + } +} + +function hasRequiredNamespaces(localeMessages: Record | undefined): boolean { + if (!localeMessages) return false; + for (const namespace of REQUIRED_NAMESPACES) { + if (!localeMessages[namespace]) return false; + } + return true; +} + +function isAvailableLocale(locale: string): locale is Locale { + return availableLocales.includes(locale); +} + +export function getAvailableLocales(): Locale[] { + if (availableLocales.length === 0) { + return [DEFAULT_LOCALE]; + } + return availableLocales; +} + +export function getLocaleValidationErrors(): LocaleValidationError[] { + return localeValidationErrors; +} + function getMessageValue(obj: unknown, dotPath: string): string | undefined { const keys = dotPath.split("."); let current: unknown = obj; @@ -34,15 +94,18 @@ function interpolate(str: string, vars?: Record): strin } export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap { - return messages[locale]?.[namespace] ?? {}; + const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE; + return messages[resolvedLocale]?.[namespace] ?? {}; } export function getLocaleName(locale: Locale): string { - return getMessageValue(messages[locale]?.common, "locale.name") ?? locale; + const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE; + return getMessageValue(messages[resolvedLocale]?.common, "locale.name") ?? locale; } export function getLocaleShort(locale: Locale): string { - return getMessageValue(messages[locale]?.common, "locale.short") ?? locale; + const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE; + return getMessageValue(messages[resolvedLocale]?.common, "locale.short") ?? locale; } export function translate( @@ -52,8 +115,10 @@ export function translate( vars?: Record, ): string { const value = - getMessageValue(messages[locale]?.[namespace], key) ?? - getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key); + getMessageValue( + messages[isAvailableLocale(locale) ? locale : DEFAULT_LOCALE]?.[namespace], + key, + ) ?? getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key); if (value == null) return `${namespace}.${key}`; return interpolate(value, vars); From 0efd2d64ed0dd2bad26e50c500888cfec2fc13b3 Mon Sep 17 00:00:00 2001 From: SimulAffect <248947347+SimulAffect@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:26:45 +0800 Subject: [PATCH 12/22] fix(i18n): sync tutorial help translations --- .../tutorialHelpTranslations.test.ts | 59 +++++++++++++++++++ src/i18n/locales/fr/dialogs.json | 12 ++-- src/i18n/locales/tr/dialogs.json | 12 ++-- 3 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 src/i18n/__tests__/tutorialHelpTranslations.test.ts diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts new file mode 100644 index 0000000..fcfa9d3 --- /dev/null +++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; +import enDialogs from "@/i18n/locales/en/dialogs.json"; +import esDialogs from "@/i18n/locales/es/dialogs.json"; +import frDialogs from "@/i18n/locales/fr/dialogs.json"; +import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json"; +import trDialogs from "@/i18n/locales/tr/dialogs.json"; +import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json"; + +const tutorialHelpKeys = [ + "triggerLabel", + "title", + "description", + "explanationBefore", + "remove", + "explanationMiddle", + "covered", + "explanationAfter", + "visualExample", + "removed", + "kept", + "part1", + "part2", + "part3", + "finalVideo", + "step1Title", + "step1DescriptionBefore", + "step1DescriptionAfter", + "step2Title", + "step2Description", +] as const; + +const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1DescriptionBefore"]); + +const dialogsByLocale = { + en: enDialogs, + "zh-CN": zhCNDialogs, + es: esDialogs, + fr: frDialogs, + tr: trDialogs, + "ko-KR": koKRDialogs, +} satisfies Record }>; + +describe("TutorialHelp translations", () => { + it("defines every tutorial help key for each supported locale", () => { + for (const locale of SUPPORTED_LOCALES) { + const tutorial = dialogsByLocale[locale].tutorial; + + for (const key of tutorialHelpKeys) { + const message = tutorial[key]; + const label = `${locale} dialogs.tutorial.${key}`; + expect(message, label).toEqual(expect.any(String)); + if (!keysThatMayBeEmpty.has(key)) { + expect((message as string).trim().length, label).toBeGreaterThan(0); + } + } + } + }); +}); diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json index b4056a5..fc32e6b 100644 --- a/src/i18n/locales/fr/dialogs.json +++ b/src/i18n/locales/fr/dialogs.json @@ -27,10 +27,11 @@ "triggerLabel": "Comment fonctionne la coupe", "title": "Comment fonctionne la coupe", "description": "Comprendre comment supprimer les parties indésirables de votre vidéo.", - "explanation": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez", - "explanationRemove": "supprimer", - "explanationCovered": "couvert", - "explanationEnd": "par un segment de coupe rouge sera coupé lors de l'export.", + "explanationBefore": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez", + "remove": "supprimer", + "explanationMiddle": " — tout élément", + "covered": "couvert", + "explanationAfter": "par un segment de coupe rouge sera coupé lors de l'export.", "visualExample": "Exemple visuel", "removed": "SUPPRIMÉ", "kept": "Conservé", @@ -39,7 +40,8 @@ "part3": "Partie 3", "finalVideo": "Vidéo finale", "step1Title": "1. Ajouter une coupe", - "step1Description": "Appuyez sur T ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.", + "step1DescriptionBefore": "Appuyez sur ", + "step1DescriptionAfter": " ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.", "step2Title": "2. Ajuster", "step2Description": "Faites glisser les bords de la région rouge pour couvrir exactement ce que vous souhaitez couper." }, diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json index 5661e45..9fab50d 100644 --- a/src/i18n/locales/tr/dialogs.json +++ b/src/i18n/locales/tr/dialogs.json @@ -27,10 +27,11 @@ "triggerLabel": "Kırpma nasıl çalışır", "title": "Kırpma Nasıl Çalışır", "description": "Videonuzun istenmeyen bölümlerini nasıl keseceğinizi anlayın.", - "explanation": "Kırpma aracı, kaldırmak istediğiniz bölümleri tanımlayarak çalışır.", - "explanationRemove": "kaldırmak", - "explanationCovered": "kaplanan", - "explanationEnd": "kırmızı kırpma bölgesi ile işaretlenen kısımlar dışa aktarımda kesilecektir.", + "explanationBefore": "Kırpma aracı, istediğiniz bölümleri", + "remove": "kaldırmak", + "explanationMiddle": " için kullanılır; kırmızı kırpma bölgesiyle", + "covered": "kaplanan", + "explanationAfter": "her şey dışa aktarımda kesilecektir.", "visualExample": "Görsel Örnek", "removed": "KALDIRILDI", "kept": "Korundu", @@ -39,7 +40,8 @@ "part3": "Bölüm 3", "finalVideo": "Son Video", "step1Title": "1. Kırpma Ekle", - "step1Description": "Kaldırılacak bölümü işaretlemek için T tuşuna basın veya makas simgesine tıklayın.", + "step1DescriptionBefore": "Kaldırılacak bölümü işaretlemek için ", + "step1DescriptionAfter": " tuşuna basın veya makas simgesine tıklayın.", "step2Title": "2. Ayarla", "step2Description": "Kesmek istediğiniz kısmı tam olarak kaplamak için kırmızı bölgenin kenarlarını sürükleyin." }, From 8bcce473d5bcef718d8baa3b8c801c9f5df2b87c Mon Sep 17 00:00:00 2001 From: LorenzoLancia Date: Sun, 12 Apr 2026 18:04:43 +0200 Subject: [PATCH 13/22] feat: add mosaic blur with black shading --- .../video-editor/AnnotationOverlay.tsx | 195 +++++++++++++++++- .../video-editor/BlurSettingsPanel.tsx | 117 ++++++++++- src/components/video-editor/VideoPlayback.tsx | 17 +- .../video-editor/projectPersistence.test.ts | 69 +++++++ .../video-editor/projectPersistence.ts | 11 + src/components/video-editor/types.ts | 11 + src/i18n/locales/en/settings.json | 7 + src/i18n/locales/es/settings.json | 7 + src/i18n/locales/fr/settings.json | 7 + src/lib/blurEffects.test.ts | 80 +++++++ src/lib/blurEffects.ts | 113 ++++++++++ src/lib/exporter/annotationRenderer.ts | 45 ++-- 12 files changed, 644 insertions(+), 35 deletions(-) create mode 100644 src/lib/blurEffects.test.ts create mode 100644 src/lib/blurEffects.ts diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 3120f0b..f416c32 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -1,15 +1,27 @@ -import { type CSSProperties, type PointerEvent, useRef, useState } from "react"; +import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react"; import { Rnd } from "react-rnd"; +import { + getBlurOverlayColor, + getMosaicGridOverlayColor, + getNormalizedMosaicBlockSize, +} from "@/lib/blurEffects"; import { cn } from "@/lib/utils"; import { getArrowComponent } from "./ArrowSvgs"; import { type AnnotationRegion, type BlurData, + DEFAULT_BLUR_BLOCK_SIZE, DEFAULT_BLUR_DATA, DEFAULT_BLUR_INTENSITY, } from "./types"; const FREEHAND_POINT_THRESHOLD = 1; +type PreviewCanvasSource = { + width: number; + height: number; + clientWidth?: number; + clientHeight?: number; +}; function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) { if (points.length < 3) return undefined; @@ -36,6 +48,8 @@ interface AnnotationOverlayProps { onClick: (id: string) => void; zIndex: number; isSelectedBoost: boolean; // Boost z-index when selected for easy editing + previewSourceCanvas?: PreviewCanvasSource | null; + previewFrameVersion?: number; } export function AnnotationOverlay({ @@ -50,11 +64,13 @@ export function AnnotationOverlay({ onClick, zIndex, isSelectedBoost, + previewSourceCanvas, + previewFrameVersion, }: AnnotationOverlayProps) { - const x = (annotation.position.x / 100) * containerWidth; - const y = (annotation.position.y / 100) * containerHeight; - const width = (annotation.size.width / 100) * containerWidth; - const height = (annotation.size.height / 100) * containerHeight; + const committedX = (annotation.position.x / 100) * containerWidth; + const committedY = (annotation.position.y / 100) * containerHeight; + const committedWidth = (annotation.size.width / 100) * containerWidth; + const committedHeight = (annotation.size.height / 100) * containerHeight; const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null; const isSelectedFreehandBlur = isSelected && blurShape === "freehand"; const isDraggingRef = useRef(false); @@ -65,6 +81,108 @@ export function AnnotationOverlay({ [], ); const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null); + const mosaicCanvasRef = useRef(null); + const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur"; + const blurOverlayColor = + annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : ""; + const mosaicGridOverlayColor = + annotation.type === "blur" ? getMosaicGridOverlayColor(annotation.blurData) : ""; + const [liveRect, setLiveRect] = useState({ + x: committedX, + y: committedY, + width: committedWidth, + height: committedHeight, + }); + + useEffect(() => { + setLiveRect({ + x: committedX, + y: committedY, + width: committedWidth, + height: committedHeight, + }); + }, [committedHeight, committedWidth, committedX, committedY]); + + const { x, y, width, height } = liveRect; + + useEffect(() => { + if (annotation.type !== "blur" || blurType !== "mosaic") { + return; + } + void previewFrameVersion; + + const canvas = mosaicCanvasRef.current; + const sourceCanvas = previewSourceCanvas; + if (!canvas || !sourceCanvas) { + return; + } + + const sourceWidth = sourceCanvas.width; + const sourceHeight = sourceCanvas.height; + const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth; + const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight; + if ( + sourceWidth <= 0 || + sourceHeight <= 0 || + sourceClientWidth <= 0 || + sourceClientHeight <= 0 + ) { + return; + } + + const drawWidth = Math.max(1, Math.round(width)); + const drawHeight = Math.max(1, Math.round(height)); + if (drawWidth <= 0 || drawHeight <= 0) { + return; + } + + canvas.width = drawWidth; + canvas.height = drawHeight; + + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + return; + } + + const scaleX = sourceWidth / sourceClientWidth; + const scaleY = sourceHeight / sourceClientHeight; + const sourceX = Math.max(0, Math.floor(x * scaleX)); + const sourceY = Math.max(0, Math.floor(y * scaleY)); + const sourceSampleWidth = Math.max(1, Math.ceil(drawWidth * scaleX)); + const sourceSampleHeight = Math.max(1, Math.ceil(drawHeight * scaleY)); + const clampedSampleWidth = Math.max(1, Math.min(sourceSampleWidth, sourceWidth - sourceX)); + const clampedSampleHeight = Math.max(1, Math.min(sourceSampleHeight, sourceHeight - sourceY)); + const blockSize = getNormalizedMosaicBlockSize(annotation.blurData); + const downscaledWidth = Math.max(1, Math.round(drawWidth / blockSize)); + const downscaledHeight = Math.max(1, Math.round(drawHeight / blockSize)); + canvas.width = downscaledWidth; + canvas.height = downscaledHeight; + + context.clearRect(0, 0, downscaledWidth, downscaledHeight); + context.imageSmoothingEnabled = true; + context.drawImage( + sourceCanvas as CanvasImageSource, + sourceX, + sourceY, + clampedSampleWidth, + clampedSampleHeight, + 0, + 0, + downscaledWidth, + downscaledHeight, + ); + }, [ + annotation, + blurType, + containerHeight, + containerWidth, + height, + previewFrameVersion, + previewSourceCanvas, + width, + x, + y, + ]); const renderArrow = () => { const direction = annotation.figureData?.arrowDirection || "right"; @@ -240,6 +358,10 @@ export function AnnotationOverlay({ 1, Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY), ); + const blockSize = Math.max( + 1, + Math.round(annotation.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE), + ); const activeFreehandPoints = shape === "freehand" ? isFreehandDrawing @@ -292,12 +414,43 @@ export function AnnotationOverlay({ className="absolute inset-0" style={{ ...shapeMaskStyle, - backdropFilter: `blur(${blurIntensity}px)`, - WebkitBackdropFilter: `blur(${blurIntensity}px)`, - backgroundColor: "rgba(255, 255, 255, 0.02)", + backdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`, + WebkitBackdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`, + backgroundColor: blurOverlayColor, opacity: shouldShowFreehandBlurFill ? 1 : 0, }} /> + {blurType === "mosaic" && shouldShowFreehandBlurFill && ( + + )} + {blurType === "mosaic" && shouldShowFreehandBlurFill && ( +
+ )} + {blurType === "mosaic" && ( +
+ )} {isSelected && shape !== "freehand" && (
{ isDraggingRef.current = true; }} + onDrag={(_e, d) => { + setLiveRect((prev) => ({ + ...prev, + x: d.x, + y: d.y, + })); + }} onDragStop={(_e, d) => { + setLiveRect((prev) => ({ + ...prev, + x: d.x, + y: d.y, + })); const xPercent = (d.x / containerWidth) * 100; const yPercent = (d.y / containerHeight) * 100; onPositionChange(annotation.id, { x: xPercent, y: yPercent }); @@ -364,7 +529,21 @@ export function AnnotationOverlay({ isDraggingRef.current = false; }, 100); }} + onResize={(_e, _direction, ref, _delta, position) => { + setLiveRect({ + x: position.x, + y: position.y, + width: ref.offsetWidth, + height: ref.offsetHeight, + }); + }} onResizeStop={(_e, _direction, ref, _delta, position) => { + setLiveRect({ + x: position.x, + y: position.y, + width: ref.offsetWidth, + height: ref.offsetHeight, + }); const xPercent = (position.x / containerWidth) * 100; const yPercent = (position.y / containerHeight) * 100; const widthPercent = (ref.offsetWidth / containerWidth) * 100; diff --git a/src/components/video-editor/BlurSettingsPanel.tsx b/src/components/video-editor/BlurSettingsPanel.tsx index 382cd80..09bfe3a 100644 --- a/src/components/video-editor/BlurSettingsPanel.tsx +++ b/src/components/video-editor/BlurSettingsPanel.tsx @@ -1,14 +1,26 @@ import { Info, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { useScopedT } from "@/contexts/I18nContext"; +import { getBlurOverlayColor } from "@/lib/blurEffects"; import { cn } from "@/lib/utils"; import { type AnnotationRegion, + type BlurColor, type BlurData, type BlurShape, + DEFAULT_BLUR_BLOCK_SIZE, DEFAULT_BLUR_DATA, + MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, + MIN_BLUR_BLOCK_SIZE, MIN_BLUR_INTENSITY, } from "./types"; @@ -31,6 +43,10 @@ export function BlurSettingsPanel({ { value: "rectangle", labelKey: "blurShapeRectangle" }, { value: "oval", labelKey: "blurShapeOval" }, ]; + const blurColorOptions: Array<{ value: BlurColor; labelKey: string }> = [ + { value: "white", labelKey: "blurColorWhite" }, + { value: "black", labelKey: "blurColorBlack" }, + ]; return (
@@ -91,27 +107,116 @@ export function BlurSettingsPanel({ })}
+
+ + +
+ +
+ +
+ {blurColorOptions.map((option) => { + const activeColor = blurRegion.blurData?.color ?? DEFAULT_BLUR_DATA.color; + const isActive = activeColor === option.value; + return ( + + ); + })} +
+
+
- {t("annotation.blurIntensity")} + {blurRegion.blurData?.type === "mosaic" + ? t("annotation.mosaicBlockSize") + : t("annotation.blurIntensity")} - {Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px + {Math.round( + blurRegion.blurData?.type === "mosaic" + ? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE) + : (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity), + )} + px
{ onBlurDataChange({ ...DEFAULT_BLUR_DATA, ...blurRegion.blurData, - intensity: values[0], + ...(blurRegion.blurData?.type === "mosaic" + ? { blockSize: values[0] } + : { intensity: values[0] }), }); }} onValueCommit={() => onBlurDataCommit?.()} - min={MIN_BLUR_INTENSITY} - max={MAX_BLUR_INTENSITY} + min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY} + max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY} step={1} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" /> diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index ea477c8..b798641 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1348,7 +1348,7 @@ const VideoPlayback = forwardRef( if (annotation.id === selectedAnnotationId) return true; const timeMs = Math.round(currentTime * 1000); - return timeMs >= annotation.startMs && timeMs <= annotation.endMs; + return timeMs >= annotation.startMs && timeMs < annotation.endMs; }); const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => { @@ -1358,7 +1358,7 @@ const VideoPlayback = forwardRef( if (blurRegion.id === selectedBlurId) return true; const timeMs = Math.round(currentTime * 1000); - return timeMs >= blurRegion.startMs && timeMs <= blurRegion.endMs; + return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs; }); const sorted = [ @@ -1371,6 +1371,15 @@ const VideoPlayback = forwardRef( region: blurRegion, })), ].sort((a, b) => a.region.zIndex - b.region.zIndex); + const previewSnapshotCanvas = (() => { + const app = appRef.current; + if (!app?.renderer?.extract) return null; + try { + return app.renderer.extract.canvas(app.stage); + } catch { + return null; + } + })(); // Handle click-through cycling: when clicking same annotation, cycle to next const handleAnnotationClick = (clickedId: string) => { @@ -1404,7 +1413,7 @@ const VideoPlayback = forwardRef( `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}` + ? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}` : `${item.region.id}-${overlaySize.width}-${overlaySize.height}` } annotation={item.region} @@ -1438,6 +1447,8 @@ const VideoPlayback = forwardRef( ? item.region.id === selectedBlurId : item.region.id === selectedAnnotationId } + previewSourceCanvas={previewSnapshotCanvas} + previewFrameVersion={Math.round(currentTime * 1000)} /> )); })()} diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 9a99ef7..14dc240 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -68,6 +68,75 @@ describe("projectPersistence media compatibility", () => { ).toBe("rectangle"); }); + it("normalizes blur region type and mosaic block size safely", () => { + const editor = normalizeProjectEditor({ + annotationRegions: [ + { + id: "annotation-1", + startMs: 0, + endMs: 500, + type: "blur", + content: "", + position: { x: 10, y: 10 }, + size: { width: 20, height: 20 }, + style: { + color: "#fff", + backgroundColor: "transparent", + fontSize: 32, + fontFamily: "Inter", + fontWeight: "bold", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", + }, + zIndex: 1, + blurData: { + type: "mosaic", + shape: "rectangle", + color: "black", + intensity: 999, + blockSize: 999, + }, + }, + { + id: "annotation-2", + startMs: 0, + endMs: 500, + type: "blur", + content: "", + position: { x: 10, y: 10 }, + size: { width: 20, height: 20 }, + style: { + color: "#fff", + backgroundColor: "transparent", + fontSize: 32, + fontFamily: "Inter", + fontWeight: "bold", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", + }, + zIndex: 2, + blurData: { + type: "invalid" as never, + shape: "rectangle", + color: "invalid" as never, + intensity: 10, + blockSize: 0, + }, + }, + ], + }); + + expect(editor.annotationRegions[0].blurData?.type).toBe("mosaic"); + expect(editor.annotationRegions[0].blurData?.color).toBe("black"); + expect(editor.annotationRegions[0].blurData?.intensity).toBe(40); + expect(editor.annotationRegions[0].blurData?.blockSize).toBe(48); + expect(editor.annotationRegions[1].blurData?.type).toBe("blur"); + expect(editor.annotationRegions[1].blurData?.color).toBe("white"); + expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4); + }); + it("accepts the dual frame webcam layout preset", () => { expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe( "dual-frame", diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index a8362c8..c085e0d 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,3 +1,4 @@ +import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; @@ -9,6 +10,7 @@ import { DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, + DEFAULT_BLUR_BLOCK_SIZE, DEFAULT_BLUR_DATA, DEFAULT_BLUR_FREEHAND_POINTS, DEFAULT_BLUR_INTENSITY, @@ -20,8 +22,10 @@ import { DEFAULT_WEBCAM_POSITION, DEFAULT_WEBCAM_SIZE_PRESET, DEFAULT_ZOOM_DEPTH, + MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, MAX_PLAYBACK_SPEED, + MIN_BLUR_BLOCK_SIZE, MIN_BLUR_INTENSITY, MIN_PLAYBACK_SPEED, type SpeedRegion, @@ -305,6 +309,8 @@ export function normalizeProjectEditor(editor: Partial): Pro VALID_BLUR_SHAPES.has(region.blurData.shape) ? region.blurData.shape : DEFAULT_BLUR_DATA.shape; + const blurType = normalizeBlurType(region.blurData?.type); + const blurColor = normalizeBlurColor(region.blurData?.color); return { id: region.id, @@ -365,10 +371,15 @@ export function normalizeProjectEditor(editor: Partial): Pro ? { ...DEFAULT_BLUR_DATA, ...region.blurData, + type: blurType, shape: blurShape, + color: blurColor, intensity: isFiniteNumber(region.blurData.intensity) ? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) : DEFAULT_BLUR_INTENSITY, + blockSize: isFiniteNumber(region.blurData.blockSize) + ? clamp(region.blurData.blockSize, MIN_BLUR_BLOCK_SIZE, MAX_BLUR_BLOCK_SIZE) + : DEFAULT_BLUR_BLOCK_SIZE, freehandPoints: Array.isArray(region.blurData.freehandPoints) ? region.blurData.freehandPoints .filter( diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 609d38b..87e4331 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -68,14 +68,22 @@ export interface FigureData { } export type BlurShape = "rectangle" | "oval" | "freehand"; +export type BlurType = "blur" | "mosaic"; +export type BlurColor = "white" | "black"; export const MIN_BLUR_INTENSITY = 2; export const MAX_BLUR_INTENSITY = 40; export const DEFAULT_BLUR_INTENSITY = 12; +export const MIN_BLUR_BLOCK_SIZE = 4; +export const MAX_BLUR_BLOCK_SIZE = 48; +export const DEFAULT_BLUR_BLOCK_SIZE = 12; export interface BlurData { + type: BlurType; shape: BlurShape; + color: BlurColor; intensity: number; + blockSize: number; // Points are normalized (0-100) within the annotation bounds. freehandPoints?: Array<{ x: number; y: number }>; } @@ -157,8 +165,11 @@ export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [ ]; export const DEFAULT_BLUR_DATA: BlurData = { + type: "blur", shape: "rectangle", + color: "white", intensity: DEFAULT_BLUR_INTENSITY, + blockSize: DEFAULT_BLUR_BLOCK_SIZE, freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS, }; diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 7703d12..00e7c08 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -126,8 +126,15 @@ "arrowDirection": "Arrow Direction", "strokeWidth": "Stroke Width: {{width}}px", "arrowColor": "Arrow Color", + "blurType": "Blur Type", + "blurTypeBlur": "Blur", + "blurTypeMosaic": "Mosaic Blur", + "blurColor": "Blur Color", + "blurColorWhite": "White", + "blurColorBlack": "Black", "blurShape": "Blur Shape", "blurIntensity": "Blur Intensity", + "mosaicBlockSize": "Mosaic Block Size", "blurShapeRectangle": "Rectangle", "blurShapeOval": "Oval", "blurShapeFreehand": "Freehand", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 8dffa2e..92160bd 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -126,8 +126,15 @@ "arrowDirection": "Dirección de la flecha", "strokeWidth": "Grosor del trazo: {{width}}px", "arrowColor": "Color de la flecha", + "blurType": "Tipo de desenfoque", + "blurTypeBlur": "Desenfoque", + "blurTypeMosaic": "Desenfoque mosaico", + "blurColor": "Color del desenfoque", + "blurColorWhite": "Blanco", + "blurColorBlack": "Negro", "blurShape": "Forma del desenfoque", "blurIntensity": "Intensidad del desenfoque", + "mosaicBlockSize": "Tamano del bloque mosaico", "blurShapeRectangle": "Rectángulo", "blurShapeOval": "Óvalo", "blurShapeFreehand": "Mano alzada", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 381094f..ae98a59 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -115,8 +115,15 @@ "arrowDirection": "Direction de la flèche", "strokeWidth": "Épaisseur du trait : {{width}}px", "arrowColor": "Couleur de la flèche", + "blurType": "Type de flou", + "blurTypeBlur": "Flou", + "blurTypeMosaic": "Flou mosaique", + "blurColor": "Couleur du flou", + "blurColorWhite": "Blanc", + "blurColorBlack": "Noir", "blurShape": "Forme du flou", "blurIntensity": "Intensité du flou", + "mosaicBlockSize": "Taille des blocs de mosaique", "blurShapeRectangle": "Rectangle", "blurShapeOval": "Ovale", "blurShapeFreehand": "Main levée", diff --git a/src/lib/blurEffects.test.ts b/src/lib/blurEffects.test.ts new file mode 100644 index 0000000..4797e69 --- /dev/null +++ b/src/lib/blurEffects.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { applyMosaicToImageData, getBlurOverlayColor, normalizeBlurColor } from "./blurEffects"; + +function createTestImageData(width: number, height: number) { + const data = new Uint8ClampedArray(width * height * 4); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const offset = (y * width + x) * 4; + data[offset] = x * 20 + y; + data[offset + 1] = y * 20 + x; + data[offset + 2] = (x + y) * 10; + data[offset + 3] = 255; + } + } + + return { + data, + width, + height, + } as ImageData; +} + +describe("applyMosaicToImageData", () => { + it("collapses each block to a single representative color", () => { + const imageData = createTestImageData(4, 4); + const original = new Uint8ClampedArray(imageData.data); + + applyMosaicToImageData(imageData, 2); + + const topLeft = Array.from(imageData.data.slice(0, 4)); + const topRightOffset = (1 * 4 + 1) * 4; + const topRight = Array.from(imageData.data.slice(topRightOffset, topRightOffset + 4)); + expect(topLeft).toEqual(topRight); + + expect(Array.from(original.slice(0, 4))).not.toEqual(topLeft); + }); + + it("reduces unique pixel colors, making the transform information-lossy", () => { + const imageData = createTestImageData(8, 8); + const before = new Set(); + const after = new Set(); + + for (let i = 0; i < imageData.data.length; i += 4) { + before.add( + `${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`, + ); + } + + applyMosaicToImageData(imageData, 4); + + for (let i = 0; i < imageData.data.length; i += 4) { + after.add( + `${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`, + ); + } + + expect(after.size).toBeLessThan(before.size); + expect(after.size).toBe(4); + }); +}); + +describe("blur color helpers", () => { + it("normalizes invalid blur colors to white", () => { + expect(normalizeBlurColor("black")).toBe("black"); + expect(normalizeBlurColor("invalid")).toBe("white"); + }); + + it("returns a dark overlay when black blur color is selected", () => { + expect( + getBlurOverlayColor({ + type: "blur", + shape: "rectangle", + color: "black", + intensity: 12, + blockSize: 12, + }), + ).toBe("rgba(0, 0, 0, 0.18)"); + }); +}); diff --git a/src/lib/blurEffects.ts b/src/lib/blurEffects.ts new file mode 100644 index 0000000..6933924 --- /dev/null +++ b/src/lib/blurEffects.ts @@ -0,0 +1,113 @@ +import { + type BlurColor, + type BlurData, + type BlurType, + DEFAULT_BLUR_BLOCK_SIZE, + DEFAULT_BLUR_INTENSITY, + MAX_BLUR_BLOCK_SIZE, + MAX_BLUR_INTENSITY, + MIN_BLUR_BLOCK_SIZE, + MIN_BLUR_INTENSITY, +} from "@/components/video-editor/types"; + +function clamp(value: number, min: number, max: number) { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, value)); +} + +export function normalizeBlurType(value: unknown): BlurType { + return value === "mosaic" ? "mosaic" : "blur"; +} + +export function normalizeBlurColor(value: unknown): BlurColor { + return value === "black" ? "black" : "white"; +} + +export function getNormalizedBlurIntensity(blurData?: BlurData | null): number { + return clamp( + blurData?.intensity ?? DEFAULT_BLUR_INTENSITY, + MIN_BLUR_INTENSITY, + MAX_BLUR_INTENSITY, + ); +} + +export function getNormalizedMosaicBlockSize(blurData?: BlurData | null, scaleFactor = 1): number { + const rawBlockSize = clamp( + blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE, + MIN_BLUR_BLOCK_SIZE, + MAX_BLUR_BLOCK_SIZE, + ); + return Math.max(1, Math.round(rawBlockSize * Math.max(scaleFactor, 0.01))); +} + +export function getBlurOverlayColor(blurData?: BlurData | null): string { + const blurColor = normalizeBlurColor(blurData?.color); + const blurType = normalizeBlurType(blurData?.type); + + if (blurColor === "black") { + return blurType === "mosaic" ? "rgba(0, 0, 0, 0.72)" : "rgba(0, 0, 0, 0.56)"; + } + + return blurType === "mosaic" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.02)"; +} + +export function getMosaicGridOverlayColor(blurData?: BlurData | null): string { + return normalizeBlurColor(blurData?.color) === "black" + ? "rgba(255,255,255,0.05)" + : "rgba(255,255,255,0.04)"; +} + +export function applyMosaicToImageData(imageData: ImageData, blockSize: number): ImageData { + const width = imageData.width; + const height = imageData.height; + const data = imageData.data; + const normalizedBlockSize = Math.max(1, Math.floor(blockSize)); + + if (width <= 0 || height <= 0 || normalizedBlockSize <= 1) { + return imageData; + } + + for (let blockY = 0; blockY < height; blockY += normalizedBlockSize) { + for (let blockX = 0; blockX < width; blockX += normalizedBlockSize) { + const blockWidth = Math.min(normalizedBlockSize, width - blockX); + const blockHeight = Math.min(normalizedBlockSize, height - blockY); + const pixelCount = blockWidth * blockHeight; + + if (pixelCount <= 0) { + continue; + } + + let redTotal = 0; + let greenTotal = 0; + let blueTotal = 0; + let alphaTotal = 0; + + for (let y = blockY; y < blockY + blockHeight; y++) { + for (let x = blockX; x < blockX + blockWidth; x++) { + const offset = (y * width + x) * 4; + redTotal += data[offset]; + greenTotal += data[offset + 1]; + blueTotal += data[offset + 2]; + alphaTotal += data[offset + 3]; + } + } + + const averageRed = Math.round(redTotal / pixelCount); + const averageGreen = Math.round(greenTotal / pixelCount); + const averageBlue = Math.round(blueTotal / pixelCount); + const averageAlpha = Math.round(alphaTotal / pixelCount); + + for (let y = blockY; y < blockY + blockHeight; y++) { + for (let x = blockX; x < blockX + blockWidth; x++) { + const offset = (y * width + x) * 4; + data[offset] = averageRed; + data[offset + 1] = averageGreen; + data[offset + 2] = averageBlue; + data[offset + 3] = averageAlpha; + } + } + } + } + + return imageData; +} diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index ec663e8..b0c4948 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -1,10 +1,11 @@ +import { type AnnotationRegion, type ArrowDirection } from "@/components/video-editor/types"; import { - type AnnotationRegion, - type ArrowDirection, - DEFAULT_BLUR_INTENSITY, - MAX_BLUR_INTENSITY, - MIN_BLUR_INTENSITY, -} from "@/components/video-editor/types"; + applyMosaicToImageData, + getBlurOverlayColor, + getNormalizedBlurIntensity, + getNormalizedMosaicBlockSize, + normalizeBlurType, +} from "@/lib/blurEffects"; let blurScratchCanvas: HTMLCanvasElement | null = null; let blurScratchCtx: CanvasRenderingContext2D | null = null; @@ -151,15 +152,16 @@ function renderBlur( scaleFactor: number, ) { const canvas = ctx.canvas; - const configuredIntensity = annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY; + const blurType = normalizeBlurType(annotation.blurData?.type); + const blurRadius = Math.max( 1, - Math.round(clamp(configuredIntensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) * scaleFactor), + Math.round(getNormalizedBlurIntensity(annotation.blurData) * scaleFactor), ); - - // Sample pixels around the target shape too; without this padding, small blur regions - // lose intensity because the filter has no neighboring pixels to blend with. - const samplePadding = Math.max(2, Math.ceil(blurRadius * 2)); + const samplePadding = + blurType === "mosaic" + ? Math.max(0, Math.ceil(getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor))) + : Math.max(2, Math.ceil(blurRadius * 2)); const sx = Math.max(0, Math.floor(x) - samplePadding); const sy = Math.max(0, Math.floor(y) - samplePadding); const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding); @@ -179,19 +181,26 @@ function renderBlur( blurScratchCtx.clearRect(0, 0, sw, sh); blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh); + if (blurType === "mosaic") { + const imageData = blurScratchCtx.getImageData(0, 0, sw, sh); + applyMosaicToImageData( + imageData, + getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor), + ); + blurScratchCtx.putImageData(imageData, 0, 0); + } + ctx.save(); drawBlurPath(ctx, annotation, x, y, width, height); ctx.clip(); - ctx.filter = `blur(${blurRadius}px)`; + ctx.filter = blurType === "mosaic" ? "none" : `blur(${blurRadius}px)`; ctx.drawImage(blurScratchCanvas, sx, sy); ctx.filter = "none"; + ctx.fillStyle = getBlurOverlayColor(annotation.blurData); + ctx.fillRect(sx, sy, sw, sh); ctx.restore(); } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - function renderText( ctx: CanvasRenderingContext2D, annotation: AnnotationRegion, @@ -364,7 +373,7 @@ export async function renderAnnotations( ): Promise { // Filter active annotations at current time const activeAnnotations = annotations.filter( - (ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs, + (ann) => currentTimeMs >= ann.startMs && currentTimeMs < ann.endMs, ); // Sort by z-index (lower first, so higher z-index draws on top) From 64cdc0dd3c20dce9afa116e7d52f0967ea1554c1 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Sun, 12 Apr 2026 13:33:13 -0500 Subject: [PATCH 14/22] feat: add Nix flake with dev shell, package, and NixOS/Home Manager modules Reproducible development environment for NixOS/Nix contributors: - Dev shell with Node 22, system Electron, Playwright, LD_LIBRARY_PATH for X11/Wayland/audio libs, activated automatically via direnv - buildNpmPackage derivation wrapping system Electron with desktop file and hicolor icons - NixOS module (programs.openscreen.enable) with xdg-desktop-portal - Home Manager module for per-user installation - Overlay for composing with other flakes Tested: nix flake show, nix develop, nix build, nixos-rebuild switch --- .envrc | 1 + .gitignore | 7 ++- flake.lock | 27 ++++++++++ flake.nix | 122 +++++++++++++++++++++++++++++++++++++++++++ nix/hm-module.nix | 36 +++++++++++++ nix/module.nix | 42 +++++++++++++++ nix/package.nix | 130 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/hm-module.nix create mode 100644 nix/module.nix create mode 100644 nix/package.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 1f895bd..040cada 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,9 @@ test-results playwright-report/ # Vitest browser mode screenshots -__screenshots__/ \ No newline at end of file +__screenshots__/ + +# Nix +result +result-* +.direnv/ \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..77972fb --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1775710090, + "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a44e9c7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,122 @@ +{ + description = "OpenScreen — desktop screen recorder with built-in editor"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + # -- Per-system outputs (packages, dev shells) -- + + packages = forAllSystems (pkgs: { + openscreen = pkgs.callPackage ./nix/package.nix { }; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; + }); + + devShells = forAllSystems ( + pkgs: + let + electron = pkgs.electron; + + # Libraries Electron needs at runtime on Linux + runtimeLibs = with pkgs; [ + # X11 + libx11 + libxcomposite + libxdamage + libxext + libxfixes + libxrandr + libxtst + libxcb + libxshmfence + + # Wayland + wayland + + # GTK / UI toolkit + gtk3 + glib + pango + cairo + gdk-pixbuf + atk + at-spi2-atk + at-spi2-core + + # Graphics + mesa + libGL + libdrm + vulkan-loader + + # Networking / crypto (NSS for Chromium) + nss + nspr + + # Audio + alsa-lib + pipewire + pulseaudio + + # System + dbus + cups + expat + libnotify + libsecret + util-linux # libuuid + ]; + in + { + default = pkgs.mkShell { + packages = with pkgs; [ + nodejs_22 + electron + + # Native module compilation + python3 + pkg-config + gcc + + # Playwright browser tests + playwright-driver.browsers + ]; + + # Electron's prebuilt binary needs these at runtime + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs; + + # Tell the npm `electron` package to use the Nix-provided binary + # instead of downloading its own. vite-plugin-electron respects this. + ELECTRON_OVERRIDE_DIST_PATH = "${electron}/lib/electron"; + + # Playwright browser path for test:browser / test:e2e + PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; + + shellHook = '' + echo "OpenScreen dev shell — node $(node --version), electron v$(electron --version 2>/dev/null | tr -d 'v')" + ''; + }; + } + ); + + # -- System-wide outputs (modules, overlay) -- + + overlays.default = final: _prev: { + openscreen = self.packages.${final.stdenv.hostPlatform.system}.openscreen; + }; + + nixosModules.default = import ./nix/module.nix self; + homeManagerModules.default = import ./nix/hm-module.nix self; + }; +} diff --git a/nix/hm-module.nix b/nix/hm-module.nix new file mode 100644 index 0000000..b04f827 --- /dev/null +++ b/nix/hm-module.nix @@ -0,0 +1,36 @@ +# Home Manager module for OpenScreen +# Usage in flake-based Home Manager config: +# +# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# +# { inputs, ... }: { +# imports = [ inputs.openscreen.homeManagerModules.default ]; +# programs.openscreen.enable = true; +# } +self: +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.programs.openscreen; +in +{ + options.programs.openscreen = { + enable = lib.mkEnableOption "OpenScreen screen recorder"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; + defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen"; + description = "The OpenScreen package to use."; + }; + }; + + config = lib.mkIf cfg.enable { + home.packages = [ cfg.package ]; + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..3282d2d --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,42 @@ +# NixOS module for OpenScreen +# Usage in flake-based NixOS config: +# +# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# +# { inputs, ... }: { +# imports = [ inputs.openscreen.nixosModules.default ]; +# programs.openscreen.enable = true; +# } +self: +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.programs.openscreen; +in +{ + options.programs.openscreen = { + enable = lib.mkEnableOption "OpenScreen screen recorder"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; + defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen"; + description = "The OpenScreen package to use."; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + + # Screen capture on Wayland requires xdg-desktop-portal. + # We enable the base portal; users should also enable a + # desktop-specific portal (e.g. xdg-desktop-portal-gtk, + # xdg-desktop-portal-hyprland) in their DE config. + xdg.portal.enable = lib.mkDefault true; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..489fa13 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,130 @@ +{ + lib, + buildNpmPackage, + nodejs_22, + electron, + makeWrapper, + makeDesktopItem, + copyDesktopItems, +}: + +buildNpmPackage { + nodejs = nodejs_22; + pname = "openscreen"; + version = "1.3.0"; + + src = + let + fs = lib.fileset; + maybe = fs.maybeMissing; + in + fs.toSource { + root = ../.; + fileset = fs.difference ../. ( + fs.unions [ + ../nix + ../flake.nix + ../flake.lock + (maybe ../release) + (maybe ../test-results) + (maybe ../playwright-report) + (maybe ../.github) + (maybe ../.vscode) + (maybe ../.idea) + (maybe ../.kiro) + (maybe ../.envrc) + (maybe ../.direnv) + (fs.fileFilter (file: file.hasExt "md") ../.) + ] + ); + }; + + npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U="; + + env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; + + # electron-builder is not needed — we wrap system electron directly + npmFlags = [ "--ignore-scripts" ]; + makeCacheWritable = true; + + # vite-plugin-electron compiles electron/ sources into dist-electron/ + # tsconfig has noEmit — tsc is type-check only + buildPhase = '' + runHook preBuild + npx vite build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p "$out/lib/openscreen" + + # Renderer build output (index.html, JS chunks, copied public/ assets) + cp -r dist "$out/lib/openscreen/" + + # Main process + preload (compiled by vite-plugin-electron) + cp -r dist-electron "$out/lib/openscreen/" + + # Package manifest (electron reads "main" field to find entry point) + cp package.json "$out/lib/openscreen/" + + # Strip devDependencies (electron, vitest, biome, playwright, etc.) + npm prune --omit=dev --no-save + cp -r node_modules "$out/lib/openscreen/" + + # Asset resolution: when app.isPackaged is false, the main process resolves + # assets at /public/assets/. Mirror the electron-builder + # extraResources layout so wallpapers load correctly. + mkdir -p "$out/lib/openscreen/public/assets" + cp -r public/wallpapers "$out/lib/openscreen/public/assets/wallpapers" + + # Wrap system electron with the app directory + mkdir -p "$out/bin" + makeWrapper "${electron}/bin/electron" "$out/bin/openscreen" \ + --add-flags "$out/lib/openscreen" \ + --set ELECTRON_IS_DEV 0 + + # Install icons to hicolor theme + for size in 16 24 32 48 64 128 256 512 1024; do + icon="icons/icons/png/''${size}x''${size}.png" + if [ -f "$icon" ]; then + install -Dm644 "$icon" \ + "$out/share/icons/hicolor/''${size}x''${size}/apps/openscreen.png" + fi + done + + runHook postInstall + ''; + + nativeBuildInputs = [ + makeWrapper + copyDesktopItems + ]; + + desktopItems = [ + (makeDesktopItem { + name = "openscreen"; + desktopName = "OpenScreen"; + genericName = "Screen Recorder"; + exec = "openscreen %U"; + icon = "openscreen"; + comment = "Desktop screen recorder with built-in editor"; + categories = [ + "AudioVideo" + "Video" + "Recorder" + ]; + startupWMClass = "Openscreen"; + terminal = false; + }) + ]; + + meta = { + description = "Desktop screen recorder with built-in editor"; + homepage = "https://github.com/siddharthvaddem/openscreen"; + license = lib.licenses.mit; + mainProgram = "openscreen"; + platforms = lib.platforms.linux; + }; +} From 456816ab2ef655447f71b9584c75772e8b41c602 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Sun, 12 Apr 2026 17:55:43 -0500 Subject: [PATCH 15/22] fix(nix): correct Electron binary path to libexec/electron Electron 41.x in nixpkgs places the binary at libexec/electron/, not lib/electron/. Without this fix, npm run dev fails with ENOENT. --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index a44e9c7..7b2d328 100644 --- a/flake.nix +++ b/flake.nix @@ -97,7 +97,7 @@ # Tell the npm `electron` package to use the Nix-provided binary # instead of downloading its own. vite-plugin-electron respects this. - ELECTRON_OVERRIDE_DIST_PATH = "${electron}/lib/electron"; + ELECTRON_OVERRIDE_DIST_PATH = "${electron}/libexec/electron"; # Playwright browser path for test:browser / test:e2e PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; From f106cc683544d26ff42da21c425bb645733c554a Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Sun, 12 Apr 2026 18:14:44 -0500 Subject: [PATCH 16/22] fix(nix): restrict package source to git-tracked files Replace denylist approach with gitTracked to exclude node_modules, dist, .git, and any other untracked artifacts from the derivation. Keeps the nix/flake/md exclusions as they are nix-only or non-source. --- nix/package.nix | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 489fa13..198d68c 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -16,24 +16,14 @@ buildNpmPackage { src = let fs = lib.fileset; - maybe = fs.maybeMissing; in fs.toSource { root = ../.; - fileset = fs.difference ../. ( + fileset = fs.difference (fs.gitTracked ../.) ( fs.unions [ ../nix ../flake.nix ../flake.lock - (maybe ../release) - (maybe ../test-results) - (maybe ../playwright-report) - (maybe ../.github) - (maybe ../.vscode) - (maybe ../.idea) - (maybe ../.kiro) - (maybe ../.envrc) - (maybe ../.direnv) (fs.fileFilter (file: file.hasExt "md") ../.) ] ); From 515baf1d84aba617f92b6ef4af6ce24909307224 Mon Sep 17 00:00:00 2001 From: Dopiz Date: Mon, 13 Apr 2026 17:19:45 +0800 Subject: [PATCH 17/22] feat: add zh-TW locale --- scripts/i18n-check.mjs | 2 +- src/i18n/config.ts | 2 +- src/i18n/locales/zh-CN/common.json | 4 +- src/i18n/locales/zh-TW/common.json | 29 +++++ src/i18n/locales/zh-TW/dialogs.json | 70 ++++++++++ src/i18n/locales/zh-TW/editor.json | 41 ++++++ src/i18n/locales/zh-TW/launch.json | 37 ++++++ src/i18n/locales/zh-TW/settings.json | 176 ++++++++++++++++++++++++++ src/i18n/locales/zh-TW/shortcuts.json | 37 ++++++ src/i18n/locales/zh-TW/timeline.json | 53 ++++++++ 10 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 src/i18n/locales/zh-TW/common.json create mode 100644 src/i18n/locales/zh-TW/dialogs.json create mode 100644 src/i18n/locales/zh-TW/editor.json create mode 100644 src/i18n/locales/zh-TW/launch.json create mode 100644 src/i18n/locales/zh-TW/settings.json create mode 100644 src/i18n/locales/zh-TW/shortcuts.json create mode 100644 src/i18n/locales/zh-TW/timeline.json diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs index ca73b23..476e0ed 100644 --- a/scripts/i18n-check.mjs +++ b/scripts/i18n-check.mjs @@ -11,7 +11,7 @@ import path from "node:path"; const LOCALES_DIR = path.resolve("src/i18n/locales"); const BASE_LOCALE = "en"; -const COMPARE_LOCALES = ["zh-CN", "es", "tr", "ko-KR"]; +const COMPARE_LOCALES = ["zh-CN", "zh-TW", "es", "tr", "ko-KR"]; function getKeys(obj, prefix = "") { const keys = []; diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 0933569..c352c9a 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -1,5 +1,5 @@ export const DEFAULT_LOCALE = "en" as const; -export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr", "tr", "ko-KR"] as const; +export const SUPPORTED_LOCALES = ["en", "zh-CN", "zh-TW", "es", "fr", "tr", "ko-KR"] as const; export const I18N_NAMESPACES = [ "common", "dialogs", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 9a3cc1c..d8bff69 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -23,7 +23,7 @@ "exitFullscreen": "退出全屏" }, "locale": { - "name": "中文", - "short": "中文" + "name": "简体中文", + "short": "简中" } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json new file mode 100644 index 0000000..971d9ab --- /dev/null +++ b/src/i18n/locales/zh-TW/common.json @@ -0,0 +1,29 @@ +{ + "actions": { + "cancel": "取消", + "save": "儲存", + "delete": "刪除", + "close": "關閉", + "share": "分享", + "done": "完成", + "open": "開啟", + "upload": "上傳", + "export": "匯出", + "file": "檔案", + "edit": "編輯", + "view": "檢視", + "window": "視窗", + "quit": "退出", + "stopRecording": "停止錄製" + }, + "playback": { + "play": "播放", + "pause": "暫停", + "fullscreen": "全螢幕", + "exitFullscreen": "退出全螢幕" + }, + "locale": { + "name": "繁體中文", + "short": "繁中" + } +} diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json new file mode 100644 index 0000000..b582aba --- /dev/null +++ b/src/i18n/locales/zh-TW/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/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json new file mode 100644 index 0000000..73a3f4e --- /dev/null +++ b/src/i18n/locales/zh-TW/editor.json @@ -0,0 +1,41 @@ +{ + "newRecording": { + "title": "返回錄影", + "description": "目前工作階段已儲存。", + "cancel": "取消", + "confirm": "確認" + }, + "errors": { + "noVideoLoaded": "未載入影片", + "videoNotReady": "影片未就緒", + "unableToDetermineSourcePath": "無法確定來源影片路徑", + "failedToSaveGif": "儲存 GIF 失敗", + "gifExportFailed": "GIF 匯出失敗", + "failedToSaveVideo": "儲存影片失敗", + "exportFailed": "匯出失敗", + "exportFailedWithError": "匯出失敗:{{error}}", + "failedToSaveExport": "儲存匯出檔案失敗", + "failedToSaveExportedVideo": "儲存匯出的影片失敗", + "failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}" + }, + "export": { + "canceled": "匯出已取消", + "exportedSuccessfully": "{{format}} 匯出成功" + }, + "project": { + "saveCanceled": "專案儲存已取消", + "failedToSave": "儲存專案失敗", + "savedTo": "專案已儲存至 {{path}}", + "failedToLoad": "載入專案失敗", + "invalidFormat": "無效的專案檔案格式", + "loadedFrom": "專案已從 {{path}} 載入" + }, + "recording": { + "failedCameraAccess": "請求攝影機權限失敗。", + "cameraBlocked": "攝影機權限已被封鎖。請在系統設定中啟用以使用攝影機。", + "systemAudioUnavailable": "系統音訊不可用。將在無系統音訊的情況下錄製。", + "microphoneDenied": "麥克風權限被拒絕。錄製將繼續,但不包含音訊。", + "cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。", + "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。" + } +} diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json new file mode 100644 index 0000000..e8b723f --- /dev/null +++ b/src/i18n/locales/zh-TW/launch.json @@ -0,0 +1,37 @@ +{ + "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": "語言" +} diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json new file mode 100644 index 0000000..6344a99 --- /dev/null +++ b/src/i18n/locales/zh-TW/settings.json @@ -0,0 +1,176 @@ +{ + "zoom": { + "level": "縮放級別", + "selectRegion": "選擇要調整的縮放區域", + "deleteZoom": "刪除縮放", + "focusMode": { + "title": "對焦模式", + "manual": "手動", + "auto": "自動", + "autoDescription": "攝影機跟隨錄製時的游標位置" + }, + "speed": { + "title": "縮放速度", + "instant": "即時", + "fast": "快速", + "smooth": "平滑", + "lazy": "緩慢" + } + }, + "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}}" + }, + "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": "顏色", + "clearBackground": "清除背景", + "uploadImage": "上傳圖片", + "supportedFormats": "支援的格式:JPG、PNG、GIF、WebP", + "arrowDirection": "箭頭方向", + "strokeWidth": "描邊寬度:{{width}}px", + "arrowColor": "箭頭顏色", + "blurShape": "模糊形狀", + "blurIntensity": "模糊強度", + "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 Fonts 匯入 URL", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL", + "nameLabel": "顯示名稱", + "namePlaceholder": "我的自訂字體", + "nameHelp": "這是字體在字體選擇器中顯示的名稱", + "addButton": "新增字體", + "addingButton": "新增中...", + "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL", + "errorInvalidUrl": "請輸入有效的 Google Fonts URL", + "errorEmptyName": "請輸入字體名稱", + "errorExtractFailed": "無法從 URL 中提取字體系列", + "successMessage": "字體 \"{{fontName}}\" 新增成功", + "failedToAdd": "新增字體失敗", + "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。", + "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。" + }, + "language": { + "title": "語言" + } +} diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json new file mode 100644 index 0000000..54c0cfc --- /dev/null +++ b/src/i18n/locales/zh-TW/shortcuts.json @@ -0,0 +1,37 @@ +{ + "title": "鍵盤快捷鍵", + "customize": "自訂", + "configurable": "可設定", + "fixed": "固定", + "pressKey": "請按下按鍵…", + "clickToChange": "點擊以變更", + "pressEscToCancel": "按 Esc 取消", + "helpText": "點擊一個快捷鍵,然後按下新的組合鍵。按 Esc 取消。", + "resetToDefaults": "還原預設設定", + "alreadyUsedBy": "已被 \"{{action}}\" 使用", + "swap": "交換", + "reservedShortcut": "此快捷鍵已保留給 \"{{label}}\",無法重新指定。", + "savedToast": "鍵盤快捷鍵已儲存", + "resetToast": "已還原預設快捷鍵 — 點擊儲存以套用", + "actions": { + "addZoom": "新增縮放", + "addTrim": "新增剪輯", + "addSpeed": "新增速度", + "addAnnotation": "新增標註", + "addBlur": "新增模糊", + "addKeyframe": "新增關鍵影格", + "deleteSelected": "刪除所選", + "playPause": "播放 / 暫停" + }, + "fixedActions": { + "undo": "復原", + "redo": "重做", + "cycleAnnotationsForward": "向前切換標註", + "cycleAnnotationsBackward": "向後切換標註", + "deleteSelectedAlt": "刪除所選(替代)", + "panTimeline": "平移時間軸", + "zoomTimeline": "縮放時間軸", + "frameBack": "上一影格", + "frameForward": "下一影格" + } +} diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json new file mode 100644 index 0000000..52457d6 --- /dev/null +++ b/src/i18n/locales/zh-TW/timeline.json @@ -0,0 +1,53 @@ +{ + "buttons": { + "addZoom": "新增縮放 (Z)", + "suggestZooms": "根據游標建議縮放", + "addTrim": "新增剪輯 (T)", + "addAnnotation": "新增標註 (A)", + "addSpeed": "新增速度 (S)", + "addBlur": "新增模糊 (B)" + }, + "hints": { + "pressZoom": "按 Z 新增縮放", + "pressTrim": "按 T 新增剪輯", + "pressAnnotation": "按 A 新增標註", + "pressSpeed": "按 S 新增速度", + "pressBlur": "按 B 新增模糊區域" + }, + "labels": { + "pan": "平移", + "zoom": "縮放", + "zoomItem": "縮放 {{index}}", + "trimItem": "剪輯 {{index}}", + "speedItem": "速度 {{index}}", + "annotationItem": "標註", + "imageItem": "圖片", + "emptyText": "空文字", + "blurItem": "模糊 {{index}}" + }, + "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 d20a062150f3520b25233875b9b73a70d51c6723 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 13 Apr 2026 06:17:07 -0500 Subject: [PATCH 18/22] fix(nix): handle store path sources for path: flake inputs gitTracked uses builtins.fetchGit which fails when the source is already a store path (happens with path: flake inputs from consuming flakes). Detect store paths at eval time and fall back to cleanSource. --- nix/package.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 198d68c..195043f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -16,10 +16,14 @@ buildNpmPackage { src = let fs = lib.fileset; + # gitTracked fails when source is already a store path (path: flake inputs). + # Detect this and fall back to cleanSource which handles both cases. + isStorePath = builtins.storeDir == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.); + baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.; in fs.toSource { root = ../.; - fileset = fs.difference (fs.gitTracked ../.) ( + fileset = fs.difference baseFiles ( fs.unions [ ../nix ../flake.nix From 46c611bd3fc34e26a013d7171d67aa32edd133b8 Mon Sep 17 00:00:00 2001 From: Theodor Peifer Date: Mon, 13 Apr 2026 17:30:16 +0200 Subject: [PATCH 19/22] fix: include epsilon subtration in totalFrame calculation --- src/lib/exporter/streamingDecoder.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index c028832..25a6aa2 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -2,7 +2,7 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; const SOURCE_LOAD_TIMEOUT_MS = 60_000; - +const EPSILON_SEC = 0.001; /** * Build a full WebCodecs-compatible AV1 codec string from the AV1CodecConfigurationRecord. * web-demuxer may return a bare "av01" when the WASM-side parser fails to read @@ -249,7 +249,6 @@ export class StreamingVideoDecoder { Math.ceil(((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate), ); const frameDurationUs = 1_000_000 / targetFrameRate; - const epsilonSec = 0.001; // Async frame queue — decoder pushes, consumer pulls const pendingFrames: VideoFrame[] = []; @@ -360,7 +359,7 @@ export class StreamingVideoDecoder { const sourceTimeSec = segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed; - if (sourceTimeSec >= segment.endSec - epsilonSec) return false; + if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false; const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); @@ -379,7 +378,7 @@ export class StreamingVideoDecoder { // Finalize completed segments before handling this frame. while ( segmentIdx < segments.length && - frameTimeSec >= segments[segmentIdx].endSec - epsilonSec + frameTimeSec >= segments[segmentIdx].endSec - EPSILON_SEC ) { const segment = segments[segmentIdx]; while (!this.cancelled && (await emitHeldFrameForTarget(segment))) { @@ -391,7 +390,7 @@ export class StreamingVideoDecoder { if ( heldFrame && segmentIdx < segments.length && - heldFrameSec < segments[segmentIdx].startSec - epsilonSec + heldFrameSec < segments[segmentIdx].startSec - EPSILON_SEC ) { heldFrame.close(); heldFrame = null; @@ -406,7 +405,7 @@ export class StreamingVideoDecoder { const currentSegment = segments[segmentIdx]; // Before current segment (trimmed region or pre-roll). - if (frameTimeSec < currentSegment.startSec - epsilonSec) { + if (frameTimeSec < currentSegment.startSec - EPSILON_SEC) { frame.close(); continue; } @@ -427,7 +426,7 @@ export class StreamingVideoDecoder { const sourceTimeSec = currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed; - if (sourceTimeSec >= currentSegment.endSec - epsilonSec) { + if (sourceTimeSec >= currentSegment.endSec - EPSILON_SEC) { break; } if (sourceTimeSec > handoffBoundarySec) { @@ -449,7 +448,7 @@ export class StreamingVideoDecoder { if (heldFrame && segmentIdx < segments.length) { while (!this.cancelled && segmentIdx < segments.length) { const segment = segments[segmentIdx]; - if (heldFrameSec < segment.startSec - epsilonSec) { + if (heldFrameSec < segment.startSec - EPSILON_SEC) { break; } @@ -461,7 +460,7 @@ export class StreamingVideoDecoder { segmentFrameIndex = 0; if ( segmentIdx < segments.length && - heldFrameSec < segments[segmentIdx].startSec - epsilonSec + heldFrameSec < segments[segmentIdx].startSec - EPSILON_SEC ) { break; } @@ -549,10 +548,10 @@ export class StreamingVideoDecoder { (sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0, ), - totalFrames: segments.reduce( - (sum, seg) => sum + Math.ceil(((seg.endSec - seg.startSec) / seg.speed) * targetFrameRate), - 0, - ), + totalFrames: segments.reduce((sum, seg) => { + const segDur = seg.endSec - seg.startSec - EPSILON_SEC; + return sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)); + }, 0), }; } From 6441e96035cd4355d757ac85dd01628b8969675a Mon Sep 17 00:00:00 2001 From: AmitwalaH Date: Tue, 14 Apr 2026 12:45:02 +0530 Subject: [PATCH 20/22] fix: prevent crash in read-binary-file handler and improve error debugging --- electron/ipc/handlers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 4cb4875..d0b42a3 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -501,8 +501,9 @@ export function registerIpcHandlers( }); ipcMain.handle("read-binary-file", async (_, inputPath: string) => { + let normalizedPath: string | null = null; try { - const normalizedPath = normalizeVideoSourcePath(inputPath); + normalizedPath = normalizeVideoSourcePath(inputPath); if (!normalizedPath) { return { success: false, message: "Invalid file path" }; } @@ -527,6 +528,7 @@ export function registerIpcHandlers( success: false, message: "Failed to read binary file", error: String(error), + path: normalizedPath, }; } }); From 14bbe8f18348a56a6c6373bbb4818f0c05c18874 Mon Sep 17 00:00:00 2001 From: Theodor Peifer Date: Tue, 14 Apr 2026 20:26:21 +0200 Subject: [PATCH 21/22] fix: algin frame cap with epsilon boundary to prevent frame count mismatch --- src/lib/exporter/streamingDecoder.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 25a6aa2..651a557 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -246,7 +246,9 @@ export class StreamingVideoDecoder { speedRegions, ); const segmentOutputFrameCounts = segments.map((segment) => - Math.ceil(((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate), + Math.ceil( + ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * targetFrameRate, + ), ); const frameDurationUs = 1_000_000 / targetFrameRate; From ee395b789650c08f2d62f387c9076a13371d22ed Mon Sep 17 00:00:00 2001 From: imAaryash Date: Wed, 15 Apr 2026 22:01:28 +0530 Subject: [PATCH 22/22] added discord.yaml --- .github/workflows/discord.yaml | 501 +++++++++++++++++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 .github/workflows/discord.yaml diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml new file mode 100644 index 0000000..3b07ad0 --- /dev/null +++ b/.github/workflows/discord.yaml @@ -0,0 +1,501 @@ +name: PR to Discord Forum + +on: + pull_request: + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed] + pull_request_review: + types: [submitted] + issue_comment: + types: [created] + schedule: + - cron: "0 12 * * 1" + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + notify: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - name: Sync PR activity to Discord forum thread + id: sync + uses: actions/github-script@v7 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }} + DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }} + DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }} + DISCORD_ALERT_WEBHOOK_URL: ${{ secrets.DISCORD_ALERT_WEBHOOK_URL }} + with: + script: | + const WEBHOOK_USERNAME = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim(); + const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim(); + + const THREAD_MARKER_REGEX = //i; + const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || "").trim(); + const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); + const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim(); + const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim(); + + const TAGS = { + open: "1493976692967080096", + draft: "1493976782028935279", + ready: "1493976833626996756", + changes: "1493976909875515564", + approved: "1493976951038152764", + merged: "1493977049709281320", + closed: "1493977108102516786", + }; + + const labelTagMap = { + bug: "1493977562773458975", + enhancement: "1493977619216207993", + documentation: "1493978565153394830", + }; + + function cleanDescription(text, maxLen = 3500) { + if (!text) return "No description provided."; + const normalized = text + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (normalized.length <= maxLen) return normalized; + return `${normalized.slice(0, maxLen - 1)}…`; + } + + function trimThreadName(name) { + return name.length > 95 ? name.slice(0, 95) : name; + } + + function extractThreadId(body) { + if (!body) return null; + const match = body.match(THREAD_MARKER_REGEX); + return match ? match[1] : null; + } + + function upsertThreadMarker(body, threadId) { + const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim(); + return `${cleaned}\n\n`.trim(); + } + + async function discordPost(payload, options = {}) { + const endpoint = new URL(webhookUrl); + endpoint.searchParams.set("wait", "true"); + if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId)); + + const response = await fetch(endpoint.toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: WEBHOOK_USERNAME, + avatar_url: WEBHOOK_AVATAR, + allowed_mentions: { parse: [] }, + ...payload, + }) + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Discord API error ${response.status}: ${text}`); + } + + const text = await response.text(); + return text ? JSON.parse(text) : {}; + } + + async function patchDiscordThread(threadId, patchBody) { + if (!botToken || !threadId) return; + const response = await fetch(`https://discord.com/api/v10/channels/${threadId}`, { + method: "PATCH", + headers: { + "Authorization": `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(patchBody), + }); + if (!response.ok) { + const text = await response.text(); + core.warning(`Discord thread patch failed (${response.status}): ${text}`); + } + } + + function desiredStatusTag(prState) { + if (prState.merged && TAGS.merged) return TAGS.merged; + if (prState.closed && !prState.merged && TAGS.closed) return TAGS.closed; + if (prState.reviewState === "CHANGES_REQUESTED" && TAGS.changes) return TAGS.changes; + if (prState.reviewState === "APPROVED" && TAGS.approved) return TAGS.approved; + if (prState.draft && TAGS.draft) return TAGS.draft; + if (!prState.draft && TAGS.ready) return TAGS.ready; + return TAGS.open || null; + } + + function tagIdsFromLabels(labels) { + const out = []; + for (const label of labels) { + const mapped = labelTagMap[label.toLowerCase()] || labelTagMap[label]; + if (mapped) out.push(String(mapped)); + } + return out; + } + + async function getPullRequest() { + if (context.eventName === "pull_request" || context.eventName === "pull_request_review") { + return context.payload.pull_request || null; + } + if (context.eventName === "issue_comment") { + const issue = context.payload.issue; + if (!issue?.pull_request) return null; + const { data } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issue.number, + }); + return data; + } + return null; + } + + async function getReviewState(owner, repo, pullNumber) { + const { data } = await github.rest.pulls.listReviews({ owner, repo, pull_number: pullNumber, per_page: 100 }); + let hasChanges = false; + let hasApproved = false; + for (const r of data) { + const s = (r.state || "").toUpperCase(); + if (s === "CHANGES_REQUESTED") hasChanges = true; + if (s === "APPROVED") hasApproved = true; + } + if (hasChanges) return "CHANGES_REQUESTED"; + if (hasApproved) return "APPROVED"; + return "NONE"; + } + + async function sendFailureAlert(message) { + if (!alertWebhookUrl) return; + try { + await fetch(alertWebhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "OpenScreen", + avatar_url: WEBHOOK_AVATAR, + content: `⚠️ PR Discord sync failed\n${message}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + allowed_mentions: { parse: [] } + }) + }); + } catch { + core.warning("Failed to send failure alert webhook."); + } + } + + try { + if (!webhookUrl) { + core.setFailed("Missing webhook URL (DISCORD_PR_FORUM_WEBHOOK)."); + return; + } + + const pr = await getPullRequest(); + if (!pr) { + core.info("No PR context found. Skipping."); + return; + } + + const action = context.payload.action || ""; + const owner = context.repo.owner; + const repo = context.repo.repo; + const number = pr.number; + const title = pr.title; + const author = pr.user?.login || "unknown"; + const url = pr.html_url; + const authorUrl = pr.user?.html_url || ""; + const authorAvatar = pr.user?.avatar_url || ""; + const base = pr.base?.ref || ""; + const head = pr.head?.ref || ""; + const repoFullName = pr.base?.repo?.full_name || `${owner}/${repo}`; + const labels = (pr.labels || []).map((l) => l.name); + const body = (pr.body || "").trim(); + const reviewState = await getReviewState(owner, repo, number); + + let threadId = extractThreadId(body); + const shouldCreateThread = + context.eventName === "pull_request" && + ["opened", "reopened", "ready_for_review"].includes(action) && + !threadId; + + if (shouldCreateThread) { + const fields = [ + { name: "PR", value: `[#${number}](${url})`, inline: true }, + { name: "Author", value: `[${author}](${authorUrl || url})`, inline: true }, + { name: "Status", value: pr.draft ? "Draft" : "Open", inline: true }, + { name: "Branches", value: `\`${head}\` -> \`${base}\``, inline: true }, + { name: "Changes", value: `+${pr.additions} / -${pr.deletions}`, inline: true }, + { name: "Files Changed", value: String(pr.changed_files), inline: true } + ]; + + if (labels.length) { + fields.push({ + name: "Labels", + value: labels.map((l) => `\`${l}\``).join(" "), + inline: false, + }); + } + + const statusTag = desiredStatusTag({ draft: pr.draft, reviewState, merged: false, closed: false }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; + + const createPayload = { + content: action === "ready_for_review" ? "🔔 PR is now ready for review" : "🔔 New pull request opened", + thread_name: trimThreadName(`PR #${number} - ${title}`), + applied_tags: appliedTags, + embeds: [ + { + title: `PR #${number}: ${title}`, + url, + description: cleanDescription(body), + color: pr.draft ? 15105570 : 1998671, + author: { + name: author, + url: authorUrl || undefined, + icon_url: authorAvatar || undefined, + }, + fields, + footer: { text: repoFullName }, + timestamp: new Date().toISOString(), + }, + ], + }; + + const result = await discordPost(createPayload); + const createdThreadId = result.channel_id || null; + if (createdThreadId) { + const updatedBody = upsertThreadMarker(body, createdThreadId); + await github.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody }); + core.info(`Created Discord thread ${createdThreadId} and stored mapping.`); + } else { + core.warning("Discord thread created but channel_id missing in response."); + } + return; + } + + if (!threadId) { + core.info("No mapped Discord thread ID found; skipping update event."); + return; + } + + if (context.eventName === "pull_request" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action)) { + const statusTag = desiredStatusTag({ + draft: action === "converted_to_draft" ? true : pr.draft, + reviewState, + merged: false, + closed: false, + }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; + await patchDiscordThread(threadId, { + name: trimThreadName(`PR #${number} - ${title}`), + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + }); + } + + let updateMessage = null; + let updateEmbed = null; + + if (context.eventName === "pull_request") { + if (action === "synchronize") { + const { data: commits } = await github.rest.pulls.listCommits({ owner, repo, pull_number: number, per_page: 5 }); + const list = commits.map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`).join("\n") || "- No commit details"; + updateMessage = `🧩 New commits pushed to PR #${number}`; + updateEmbed = { + title: `Commit Update • PR #${number}`, + url: `${url}/files`, + description: `${list}`, + color: 1998671, + footer: { text: repoFullName }, + timestamp: new Date().toISOString(), + }; + } else if (action === "edited") { + updateMessage = `✏️ PR #${number} details were edited`; + updateEmbed = { + title: `PR Updated • #${number}`, + url, + description: cleanDescription(body, 1200), + color: 1998671, + timestamp: new Date().toISOString(), + }; + } else if (action === "closed") { + const isMerged = !!pr.merged; + const statusTag = desiredStatusTag({ draft: false, reviewState, merged: isMerged, closed: true }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; + await patchDiscordThread(threadId, { + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + ...(isMerged ? { archived: true, locked: true } : {}), + }); + + updateMessage = isMerged + ? `✅ PR #${number} was merged` + : `🛑 PR #${number} was closed without merge`; + updateEmbed = { + title: isMerged ? `Merged • PR #${number}` : `Closed • PR #${number}`, + url, + description: isMerged ? "This PR has been merged into the base branch." : "This PR was closed before merge.", + color: isMerged ? 5763719 : 15158332, + timestamp: new Date().toISOString(), + }; + } else if (action === "ready_for_review") { + updateMessage = `🚀 PR #${number} moved from draft to ready for review`; + if (reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`; + } else if (action === "converted_to_draft") { + updateMessage = `📝 PR #${number} converted to draft`; + } + } else if (context.eventName === "pull_request_review") { + const review = context.payload.review; + if (review) { + const state = (review.state || "commented").toUpperCase(); + const reviewer = review.user?.login || "reviewer"; + updateMessage = `🧪 Review ${state} by **${reviewer}** on PR #${number}`; + if (state === "CHANGES_REQUESTED" && reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`; + updateEmbed = { + title: `Review ${state} • PR #${number}`, + url: review.html_url || url, + description: cleanDescription(review.body || "No review note.", 1000), + color: state === "APPROVED" ? 5763719 : state === "CHANGES_REQUESTED" ? 15158332 : 1998671, + timestamp: new Date().toISOString(), + }; + + if (state === "CHANGES_REQUESTED" || state === "APPROVED") { + const statusTag = desiredStatusTag({ draft: pr.draft, reviewState: state, merged: false, closed: false }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; + await patchDiscordThread(threadId, { + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + }); + } + } + } else if (context.eventName === "issue_comment") { + const comment = context.payload.comment; + if (comment) { + const commenter = comment.user?.login || "user"; + updateMessage = `💬 New comment by **${commenter}** on PR #${number}`; + updateEmbed = { + title: `New PR Comment • #${number}`, + url: comment.html_url || url, + description: cleanDescription(comment.body || "No comment body.", 1000), + color: 1998671, + timestamp: new Date().toISOString(), + }; + } + } + + if (!updateMessage && !updateEmbed) { + core.info("No Discord update message for this event/action. Skipping."); + return; + } + + const payload = { content: updateMessage || "" }; + if (updateEmbed) payload.embeds = [updateEmbed]; + await discordPost(payload, { threadId }); + core.info(`Posted update to Discord thread ${threadId}.`); + } catch (err) { + const msg = err && err.message ? err.message : String(err); + core.setFailed(msg); + + const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL; + if (alertWebhook) { + try { + await fetch(alertWebhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "OpenScreen", + avatar_url: WEBHOOK_AVATAR, + content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + allowed_mentions: { parse: [] } + }) + }); + } catch { + core.warning("Failed to send alert webhook."); + } + } + } + + weekly-contributor-leaderboard: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Post weekly contributor leaderboard + uses: actions/github-script@v7 + env: + DISCORD_SPOTLIGHT_WEBHOOK_URL: ${{ secrets.DISCORD_SPOTLIGHT_WEBHOOK_URL }} + DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }} + DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }} + with: + script: | + const spotlightWebhook = (process.env.DISCORD_SPOTLIGHT_WEBHOOK_URL || "").trim(); + const webhookUsername = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim(); + const webhookAvatar = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim(); + if (!spotlightWebhook) { + core.info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post."); + return; + } + + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const owner = context.repo.owner; + const repo = context.repo.repo; + + const q = `repo:${owner}/${repo} is:pr is:merged merged:>=${since.substring(0, 10)}`; + const search = await github.rest.search.issuesAndPullRequests({ + q, + per_page: 100, + }); + + const counter = new Map(); + for (const item of search.data.items) { + const login = item.user?.login; + if (!login) continue; + counter.set(login, (counter.get(login) || 0) + 1); + } + + const ranked = [...counter.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + const totalMerged = search.data.items.length; + const lines = ranked.length + ? ranked.map(([user, count], idx) => `${idx + 1}. **${user}** - ${count} merged PR(s)`).join("\n") + : "No merged PRs this week."; + + const payload = { + username: webhookUsername, + ...(webhookAvatar ? { avatar_url: webhookAvatar } : {}), + embeds: [ + { + title: "🌟 Weekly Contributor Leaderboard", + description: lines, + color: 1998671, + fields: [ + { name: "Merged PRs (7d)", value: String(totalMerged), inline: true }, + { name: "Repository", value: `${owner}/${repo}`, inline: true }, + { name: "Period", value: "Last 7 days", inline: true } + ], + timestamp: new Date().toISOString() + } + ], + allowed_mentions: { parse: [] } + }; + + const res = await fetch(`${spotlightWebhook}?wait=true`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const txt = await res.text(); + core.setFailed(`Leaderboard post failed ${res.status}: ${txt}`); + }