From 3895ca985f845dd99c9f0af83af7d5aec3c9da20 Mon Sep 17 00:00:00 2001 From: Ishan Panta Date: Fri, 3 Apr 2026 08:37:16 +0545 Subject: [PATCH 001/115] [add] extend speed options with higher presets and custom speed input add 3x, 4x, 5x speed presets and a custom playback speed input field that accepts any integer value up to 16x. change PlaybackSpeed type from a fixed union to number with min/max constants and clamp utility. update project persistence to validate any speed in range instead of exact value matching. add i18n keys for en, es, zh-CN. closes #252 --- src/components/video-editor/SettingsPanel.tsx | 90 ++++++++++++++++++- .../video-editor/projectPersistence.ts | 15 ++-- src/components/video-editor/types.ts | 14 ++- src/i18n/locales/en/settings.json | 4 +- src/i18n/locales/es/settings.json | 4 +- src/i18n/locales/zh-CN/settings.json | 4 +- 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index f5afe35..cdb00ce 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,7 +53,70 @@ import type { WebcamLayoutPreset, ZoomDepth, } from "./types"; -import { SPEED_OPTIONS } from "./types"; +import { MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; + +function CustomSpeedInput({ + value, + onChange, + onError, +}: { + value: number; + onChange: (val: number) => void; + onError: () => void; +}) { + const isPreset = SPEED_OPTIONS.some((o) => o.speed === value); + const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value))); + const [isFocused, setIsFocused] = useState(false); + + const prevValue = useRef(value); + if (!isFocused && prevValue.current !== value) { + prevValue.current = value; + setDraft(isPreset ? "" : String(Math.round(value))); + } + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const digits = e.target.value.replace(/\D/g, ""); + if (digits === "") { + setDraft(""); + return; + } + const num = Number(digits); + if (num > MAX_PLAYBACK_SPEED) { + onError(); + return; + } + setDraft(digits); + if (num >= 1) onChange(num); + }, + [onChange, onError], + ); + + const handleBlur = useCallback(() => { + setIsFocused(false); + if (!draft || Number(draft) < 1) { + setDraft(isPreset ? "" : String(Math.round(value))); + } + }, [draft, isPreset, value]); + + return ( +
+ setIsFocused(true)} + onChange={handleChange} + onBlur={handleBlur} + onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} + className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40" + /> + × +
+ ); +} const WALLPAPER_COUNT = 18; const WALLPAPER_RELATIVE = Array.from( @@ -537,7 +600,7 @@ export function SettingsPanel({ )} -
+
{SPEED_OPTIONS.map((option) => { const isActive = selectedSpeedValue === option.speed; return ( @@ -562,6 +625,29 @@ export function SettingsPanel({ ); })}
+
+
+ + {t("speed.customPlaybackSpeed")} + + {selectedSpeedId ? ( + onSpeedChange?.(val)} + onError={() => toast.error(t("speed.maxSpeedError"))} + /> + ) : ( +
+
+ -- +
+ × +
+ )} +
+
{!selectedSpeedId && (

{t("speed.selectRegion")}

)} diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 99f1bba..bfe6972 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -5,6 +5,7 @@ import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils"; import { type AnnotationRegion, type CropRegion, + clampPlaybackSpeed, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, @@ -14,6 +15,8 @@ import { DEFAULT_WEBCAM_LAYOUT_PRESET, DEFAULT_WEBCAM_POSITION, DEFAULT_ZOOM_DEPTH, + MAX_PLAYBACK_SPEED, + MIN_PLAYBACK_SPEED, type SpeedRegion, type TrimRegion, type WebcamLayoutPreset, @@ -219,14 +222,10 @@ export function normalizeProjectEditor(editor: Partial): Pro const endMs = Math.max(startMs + 1, rawEnd); const speed = - region.speed === 0.25 || - region.speed === 0.5 || - region.speed === 0.75 || - region.speed === 1.25 || - region.speed === 1.5 || - region.speed === 1.75 || - region.speed === 2 - ? region.speed + isFiniteNumber(region.speed) && + region.speed >= MIN_PLAYBACK_SPEED && + region.speed <= MAX_PLAYBACK_SPEED + ? clampPlaybackSpeed(region.speed) : DEFAULT_PLAYBACK_SPEED; return { diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index ce49f8e..d52e60a 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -132,7 +132,16 @@ export const DEFAULT_CROP_REGION: CropRegion = { height: 1, }; -export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2; +export type PlaybackSpeed = number; + +export const MIN_PLAYBACK_SPEED = 0.1; +// Anything above 16x causes the playhead to stall during preview +// due to the video decoder not being able to keep up. +export const MAX_PLAYBACK_SPEED = 16; + +export function clampPlaybackSpeed(speed: number): PlaybackSpeed { + return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100; +} export interface SpeedRegion { id: string; @@ -149,6 +158,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [ { speed: 1.5, label: "1.5×" }, { speed: 1.75, label: "1.75×" }, { speed: 2, label: "2×" }, + { speed: 3, label: "3×" }, + { speed: 4, label: "4×" }, + { speed: 5, label: "5×" }, ]; export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5; diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 36b7462..ad5f308 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -7,7 +7,9 @@ "speed": { "playbackSpeed": "Playback Speed", "selectRegion": "Select a speed region to adjust", - "deleteRegion": "Delete Speed Region" + "deleteRegion": "Delete Speed Region", + "customPlaybackSpeed": "Custom Playback Speed", + "maxSpeedError": "Speed can't go higher than 16×" }, "trim": { "deleteRegion": "Delete Trim Region" diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 4674480..f4c2d42 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -7,7 +7,9 @@ "speed": { "playbackSpeed": "Velocidad de reproducción", "selectRegion": "Selecciona una región de velocidad para ajustar", - "deleteRegion": "Eliminar región de velocidad" + "deleteRegion": "Eliminar región de velocidad", + "customPlaybackSpeed": "Velocidad personalizada", + "maxSpeedError": "La velocidad no puede superar 16×" }, "trim": { "deleteRegion": "Eliminar región de recorte" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 41bf55b..d38291e 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -7,7 +7,9 @@ "speed": { "playbackSpeed": "播放速度", "selectRegion": "选择要调整的速度区域", - "deleteRegion": "删除速度区域" + "deleteRegion": "删除速度区域", + "customPlaybackSpeed": "自定义播放速度", + "maxSpeedError": "速度不能超过 16×" }, "trim": { "deleteRegion": "删除剪辑区域" From 2b471783c084800acd67c37e8a74439c1db864a1 Mon Sep 17 00:00:00 2001 From: Adam <69064669+abres33@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:00:36 -0500 Subject: [PATCH 002/115] feat: add Cancel Recording button to HUD --- src/components/launch/LaunchWindow.tsx | 15 +++++++++++++++ src/hooks/useScreenRecorder.ts | 13 +++++++++++++ src/i18n/locales/en/launch.json | 1 + src/i18n/locales/es/launch.json | 1 + src/i18n/locales/zh-CN/launch.json | 1 + 5 files changed, 31 insertions(+) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f1b66b8..d1185e8 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -5,6 +5,7 @@ import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; import { + MdCancel, MdMic, MdMicOff, MdMonitor, @@ -43,6 +44,7 @@ const ICON_CONFIG = { webcamOff: { icon: MdVideocamOff, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, restart: { icon: MdRestartAlt, size: ICON_SIZE }, + cancel: { icon: MdCancel, size: ICON_SIZE }, record: { icon: BsRecordCircle, size: ICON_SIZE }, videoFile: { icon: MdVideoFile, size: ICON_SIZE }, folder: { icon: FaFolderOpen, size: ICON_SIZE }, @@ -79,6 +81,7 @@ export function LaunchWindow() { recording, toggleRecording, restartRecording, + cancelRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, @@ -477,6 +480,18 @@ export function LaunchWindow() { )} + {/* Cancel recording */} + {recording && ( + + + + )} + {/* Open video file */} + + + + +
+ + {recording && ( + + + + )} + {/* Restart recording */} {recording && ( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 0c418c1..8e92962 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -41,7 +41,10 @@ const WEBCAM_TARGET_FRAME_RATE = 30; type UseScreenRecorderReturn = { recording: boolean; + paused: boolean; + elapsedSeconds: number; toggleRecording: () => void; + togglePaused: () => void; restartRecording: () => void; microphoneEnabled: boolean; setMicrophoneEnabled: (enabled: boolean) => void; @@ -85,6 +88,8 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); const [recording, setRecording] = useState(false); + const [paused, setPaused] = useState(false); + const [elapsedSeconds, setElapsedSeconds] = useState(0); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); @@ -97,13 +102,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const microphoneStream = useRef(null); const webcamStream = useRef(null); const mixingContext = useRef(null); - const startTime = useRef(0); const recordingId = useRef(0); + const accumulatedDurationMs = useRef(0); + const segmentStartedAt = useRef(null); const finalizingRecordingId = useRef(null); const allowAutoFinalize = useRef(false); const discardRecordingId = useRef(null); const restarting = useRef(false); + const getRecordingDurationMs = useCallback(() => { + const segmentDuration = + screenRecorder.current?.recorder.state === "recording" && segmentStartedAt.current + ? Date.now() - segmentStartedAt.current + : 0; + return accumulatedDurationMs.current + segmentDuration; + }, []); + const selectMimeType = () => { const preferred = [ "video/webm;codecs=av1", @@ -202,6 +216,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { teardownMedia(); setRecording(false); + setPaused(false); + setElapsedSeconds(0); + accumulatedDurationMs.current = 0; + segmentStartedAt.current = null; window.electronAPI?.setRecordingState(false); void (async () => { @@ -273,7 +291,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const activeWebcamRecorder = webcamRecorder.current; - const duration = Date.now() - startTime.current; + const duration = getRecordingDurationMs(); const activeRecordingId = recordingId.current; finalizeRecording( @@ -283,7 +301,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { activeRecordingId, ); - if (activeScreenRecorder.recorder.state === "recording") { + if ( + activeScreenRecorder.recorder.state === "recording" || + activeScreenRecorder.recorder.state === "paused" + ) { try { activeScreenRecorder.recorder.stop(); } catch { @@ -291,7 +312,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } if (activeWebcamRecorder) { - if (activeWebcamRecorder.recorder.state === "recording") { + if ( + activeWebcamRecorder.recorder.state === "recording" || + activeWebcamRecorder.recorder.state === "paused" + ) { try { activeWebcamRecorder.recorder.stop(); } catch { @@ -316,14 +340,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn { restarting.current = false; discardRecordingId.current = null; - if (screenRecorder.current?.recorder.state === "recording") { + if ( + screenRecorder.current?.recorder.state === "recording" || + screenRecorder.current?.recorder.state === "paused" + ) { try { screenRecorder.current.recorder.stop(); } catch { // Ignore recorder teardown errors during cleanup. } } - if (webcamRecorder.current?.recorder.state === "recording") { + if ( + webcamRecorder.current?.recorder.state === "recording" || + webcamRecorder.current?.recorder.state === "paused" + ) { try { webcamRecorder.current.recorder.stop(); } catch { @@ -518,9 +548,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } recordingId.current = Date.now(); - startTime.current = recordingId.current; + accumulatedDurationMs.current = 0; + segmentStartedAt.current = Date.now(); allowAutoFinalize.current = true; setRecording(true); + setPaused(false); + setElapsedSeconds(0); window.electronAPI?.setRecordingState(true); const activeScreenRecorder = screenRecorder.current; @@ -536,7 +569,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalizeRecording( activeScreenRecorder, activeWebcamRecorder ?? null, - Math.max(0, Date.now() - startTime.current), + Math.max(0, getRecordingDurationMs()), activeRecordingId, ); }, @@ -552,12 +585,56 @@ export function useScreenRecorder(): UseScreenRecorderReturn { toast.error(errorMsg); } setRecording(false); + setPaused(false); + setElapsedSeconds(0); + accumulatedDurationMs.current = 0; + segmentStartedAt.current = null; screenRecorder.current = null; webcamRecorder.current = null; teardownMedia(); } }; + const togglePaused = () => { + const activeScreenRecorder = screenRecorder.current?.recorder; + if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") { + return; + } + + const activeWebcamRecorder = webcamRecorder.current?.recorder; + + if (activeScreenRecorder.state === "paused") { + try { + activeScreenRecorder.resume(); + if (activeWebcamRecorder?.state === "paused") { + activeWebcamRecorder.resume(); + } + segmentStartedAt.current = Date.now(); + setPaused(false); + } catch (error) { + console.error("Failed to resume recording:", error); + } + return; + } + + if (activeScreenRecorder.state !== "recording") { + return; + } + + try { + accumulatedDurationMs.current = getRecordingDurationMs(); + segmentStartedAt.current = null; + setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); + activeScreenRecorder.pause(); + if (activeWebcamRecorder?.state === "recording") { + activeWebcamRecorder.pause(); + } + setPaused(true); + } catch (error) { + console.error("Failed to pause recording:", error); + } + }; + const toggleRecording = () => { recording ? stopRecording.current() : startRecording(); }; @@ -566,7 +643,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (restarting.current) return; const activeScreenRecorder = screenRecorder.current; - if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return; + if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return; const activeWebcamRecorder = webcamRecorder.current; const activeRecordingId = recordingId.current; @@ -581,7 +658,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }), ]; - if (activeWebcamRecorder?.recorder.state === "recording") { + if ( + activeWebcamRecorder?.recorder.state === "recording" || + activeWebcamRecorder?.recorder.state === "paused" + ) { stopPromises.push( new Promise((resolve) => { activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), { @@ -601,9 +681,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + useEffect(() => { + if (!recording) { + setElapsedSeconds(0); + return; + } + + setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000)); + if (paused) { + return; + } + + const interval = window.setInterval(() => { + setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000)); + }, 250); + + return () => window.clearInterval(interval); + }, [getRecordingDurationMs, paused, recording]); + return { recording, + paused, + elapsedSeconds, toggleRecording, + togglePaused, restartRecording, microphoneEnabled, setMicrophoneEnabled, From 3bfcd8576b43dcb5395d109feb135724cb6ca478 Mon Sep 17 00:00:00 2001 From: theaiagent Date: Fri, 3 Apr 2026 22:44:25 +0300 Subject: [PATCH 013/115] fix: read live video.currentTime for rapid frame steps and add JSDoc - Read currentTime directly from the video element instead of the React ref so rapid arrow key presses each advance by exactly one frame - Add JSDoc docstrings to frameStep.ts exports --- src/components/video-editor/VideoEditor.tsx | 10 +++++++--- src/lib/frameStep.ts | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 3efd9ce..e83f5b0 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -949,13 +949,17 @@ export default function VideoEditor() { return; } e.preventDefault(); + const video = videoPlaybackRef.current?.video; + if (!video) { + return; + } const direction = e.key === "ArrowLeft" ? "backward" : "forward"; const newTime = computeFrameStepTime( - currentTimeRef.current, - durationRef.current, + video.currentTime, + Number.isFinite(video.duration) ? video.duration : durationRef.current, direction, ); - handleSeek(newTime); + video.currentTime = newTime; return; } diff --git a/src/lib/frameStep.ts b/src/lib/frameStep.ts index 7eaaf6b..dc42d78 100644 --- a/src/lib/frameStep.ts +++ b/src/lib/frameStep.ts @@ -1,5 +1,10 @@ +/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */ export const FRAME_DURATION_SEC = 1 / 60; +/** + * Compute the new playhead time after stepping one frame forward or backward. + * The result is clamped to the range [0, duration]. + */ export function computeFrameStepTime( currentTime: number, duration: number, From 97c9a73578ce547356593f03e258ce1595415549 Mon Sep 17 00:00:00 2001 From: theaiagent Date: Fri, 3 Apr 2026 23:02:12 +0300 Subject: [PATCH 014/115] fix: skip frame-step on ARIA widgets that own arrow keys Expand the arrow key guard to also skip elements with role="separator" (PanelResizeHandle), role="slider", and role="spinbutton" so keyboard panel resizing is not intercepted. --- src/components/video-editor/VideoEditor.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e83f5b0..e3a30cf 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -944,7 +944,9 @@ export default function VideoEditor() { target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || - (target instanceof HTMLElement && target.isContentEditable) + (target instanceof HTMLElement && + (target.isContentEditable || + target.closest('[role="separator"], [role="slider"], [role="spinbutton"]'))) ) { return; } From f972556443ddbf5577fde31a5d6db93dfa188165 Mon Sep 17 00:00:00 2001 From: lueckpeter76-lgtm Date: Fri, 3 Apr 2026 18:33:54 -0600 Subject: [PATCH 015/115] Revert "fix: prevent double-finalize race condition in restartRecording on Windos" --- src/hooks/useScreenRecorder.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 0c418c1..01c3917 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -573,7 +573,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { restarting.current = true; discardRecordingId.current = activeRecordingId; - allowAutoFinalize.current = false; const stopPromises = [ new Promise((resolve) => { From 43ec6ee9cd74d921f5c12cfc43f0fa563862eb22 Mon Sep 17 00:00:00 2001 From: Ayush765-spec Date: Sat, 4 Apr 2026 11:51:05 +0530 Subject: [PATCH 016/115] fix(editor): localize new recording dialog and fix session clear behavior --- src/components/video-editor/VideoEditor.tsx | 24 +++++++++++---------- src/i18n/locales/en/editor.json | 6 ++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index dae009a..26bfd44 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -474,10 +474,14 @@ export default function VideoEditor() { }, [saveProject]); const handleNewRecordingConfirm = useCallback(async () => { - setShowNewRecordingDialog(false); - await window.electronAPI.clearCurrentVideoPath(); - await window.electronAPI.setCurrentRecordingSession(null); - await window.electronAPI.switchToHud(); + try { + await window.electronAPI.clearCurrentVideoPath(); + await window.electronAPI.switchToHud(); + setShowNewRecordingDialog(false); + } catch (err) { + console.error("Failed to start new recording:", err); + setError("Failed to start new recording: " + String(err)); + } }, []); const handleLoadProject = useCallback(async () => { @@ -1415,10 +1419,8 @@ export default function VideoEditor() { style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > - New Recording - - Start a new recording? Your current recording will be discarded. - + {t("newRecording.title")} + {t("newRecording.description")} @@ -1470,7 +1472,7 @@ export default function VideoEditor() { className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium" >
@@ -755,11 +829,16 @@ export function SettingsPanel({ )} - +
- {t("effects.title")} + + {t("effects.title")} +
@@ -783,7 +862,9 @@ export function SettingsPanel({ {t("effects.motionBlur")} - {motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)} + {motionBlurAmount === 0 + ? t("effects.off") + : motionBlurAmount.toFixed(2)} {t("effects.roundness")} - {borderRadius}px + + {borderRadius}px + onBorderRadiusChange?.(values[0])} + onValueChange={(values) => + onBorderRadiusChange?.(values[0]) + } onValueCommit={() => onBorderRadiusCommit?.()} min={0} max={16} @@ -840,11 +925,15 @@ export function SettingsPanel({ {t("effects.padding")} - {webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`} + {webcamLayoutPreset === "vertical-stack" + ? "—" + : `${padding}%`} onPaddingChange?.(values[0])} onValueCommit={() => onPaddingCommit?.()} min={0} @@ -874,7 +963,9 @@ export function SettingsPanel({
- {t("background.title")} + + {t("background.title")} +
@@ -939,7 +1030,9 @@ export function SettingsPanel({ role="button" > ))} @@ -1265,7 +1392,9 @@ export function SettingsPanel({ {gifOutputDimensions.width} × {gifOutputDimensions.height}px
- {t("gifSettings.loop")} + + {t("gifSettings.loop")} + - {exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")} + {exportFormat === "gif" + ? t("export.gifButton") + : t("export.videoButton")}
@@ -1314,7 +1445,9 @@ export function SettingsPanel({ ))}
)} + {webcamLayoutPreset === "picture-in-picture" && ( +
+
+
+ {t("layout.webcamSize")} +
+
+ {webcamSizePreset}% +
+
+ onWebcamSizePresetChange?.(values[0])} + onValueCommit={() => onWebcamSizePresetCommit?.()} + min={10} + max={50} + step={1} + className="w-full" + /> +
+ )}
)} - +
- - {t("effects.title")} - + {t("effects.title")}
@@ -862,9 +815,7 @@ export function SettingsPanel({ {t("effects.motionBlur")} - {motionBlurAmount === 0 - ? t("effects.off") - : motionBlurAmount.toFixed(2)} + {motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)} {t("effects.roundness")} - - {borderRadius}px - + {borderRadius}px - onBorderRadiusChange?.(values[0]) - } + onValueChange={(values) => onBorderRadiusChange?.(values[0])} onValueCommit={() => onBorderRadiusCommit?.()} min={0} max={16} @@ -925,15 +872,11 @@ export function SettingsPanel({ {t("effects.padding")} - {webcamLayoutPreset === "vertical-stack" - ? "—" - : `${padding}%`} + {webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`} onPaddingChange?.(values[0])} onValueCommit={() => onPaddingCommit?.()} min={0} @@ -963,9 +906,7 @@ export function SettingsPanel({
- - {t("background.title")} - + {t("background.title")}
@@ -1030,9 +971,7 @@ export function SettingsPanel({ role="button" > ))} @@ -1392,9 +1299,7 @@ export function SettingsPanel({ {gifOutputDimensions.width} × {gifOutputDimensions.height}px
- - {t("gifSettings.loop")} - + {t("gifSettings.loop")} - {exportFormat === "gif" - ? t("export.gifButton") - : t("export.videoButton")} + {exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")}
@@ -1445,9 +1348,7 @@ export function SettingsPanel({ {recording && ( - + diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 1b772a2..ea2ceaa 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -1,7 +1,7 @@ { "newRecording": { - "title": "New Recording", - "description": "Start a new recording? Your current recording will be discarded.", + "title": "Return to Recorder", + "description": "Your current session has been saved.", "cancel": "Cancel", "confirm": "Confirm" }, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index e3ed9b4..d76ee15 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -20,6 +20,7 @@ interface Window { getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; switchToHud: () => Promise; + startNewRecording: () => Promise<{ success: boolean; error?: string }>; openSourceSelector: () => Promise; selectSource: (source: ProcessedDesktopSource) => Promise; getSelectedSource: () => Promise; From 475cbcd76c673211208f4747db9ed6c6f4b6613c Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sun, 5 Apr 2026 10:05:04 -0700 Subject: [PATCH 031/115] revert: undo manual merge of PR #314 --- package-lock.json | 4 +- src/components/launch/LaunchWindow.tsx | 53 ++++++----- src/hooks/useScreenRecorder.ts | 119 +++---------------------- src/i18n/locales/en/launch.json | 2 - src/i18n/locales/es/launch.json | 2 - src/i18n/locales/zh-CN/launch.json | 2 - 6 files changed, 38 insertions(+), 144 deletions(-) diff --git a/package-lock.json b/package-lock.json index fdbd6b9..70e3395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.3.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.3.0", + "version": "1.2.0", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 249dd77..d1185e8 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,6 +1,6 @@ import { ChevronDown, Languages } from "lucide-react"; import { useEffect, useState } from "react"; -import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; +import { BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; @@ -42,8 +42,6 @@ const ICON_CONFIG = { micOff: { icon: MdMicOff, size: ICON_SIZE }, webcamOn: { icon: MdVideocam, size: ICON_SIZE }, webcamOff: { icon: MdVideocamOff, size: ICON_SIZE }, - pause: { icon: BsPauseCircle, size: ICON_SIZE }, - resume: { icon: BsPlayCircle, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, restart: { icon: MdRestartAlt, size: ICON_SIZE }, cancel: { icon: MdCancel, size: ICON_SIZE }, @@ -81,10 +79,7 @@ export function LaunchWindow() { const { recording, - paused, - elapsedSeconds, toggleRecording, - togglePaused, restartRecording, cancelRecording, microphoneEnabled, @@ -98,6 +93,8 @@ export function LaunchWindow() { webcamDeviceId, setWebcamDeviceId, } = useScreenRecorder(); + const [recordingStart, setRecordingStart] = useState(null); + const [elapsed, setElapsed] = useState(0); const showMicControls = microphoneEnabled && !recording; const showWebcamControls = webcamEnabled && !recording; @@ -152,6 +149,25 @@ export function LaunchWindow() { } }, [selectedCameraId, setWebcamDeviceId]); + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + if (recording) { + if (!recordingStart) setRecordingStart(Date.now()); + timer = setInterval(() => { + if (recordingStart) { + setElapsed(Math.floor((Date.now() - recordingStart) / 1000)); + } + }, 1000); + } else { + setRecordingStart(null); + setElapsed(0); + if (timer) clearInterval(timer); + } + return () => { + if (timer) clearInterval(timer); + }; + }, [recording, recordingStart]); + useEffect(() => { if (!import.meta.env.DEV) { return; @@ -434,11 +450,7 @@ export function LaunchWindow() { {/* Record/Stop group */} - {recording && ( - - - - )} - {/* Restart recording */} {recording && ( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index a676d66..25adf6d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -41,10 +41,7 @@ const WEBCAM_TARGET_FRAME_RATE = 30; type UseScreenRecorderReturn = { recording: boolean; - paused: boolean; - elapsedSeconds: number; toggleRecording: () => void; - togglePaused: () => void; restartRecording: () => void; cancelRecording: () => void; microphoneEnabled: boolean; @@ -89,8 +86,6 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); const [recording, setRecording] = useState(false); - const [paused, setPaused] = useState(false); - const [elapsedSeconds, setElapsedSeconds] = useState(0); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); @@ -103,20 +98,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const microphoneStream = useRef(null); const webcamStream = useRef(null); const mixingContext = useRef(null); + const startTime = useRef(0); const recordingId = useRef(0); - const accumulatedDurationMs = useRef(0); - const segmentStartedAt = useRef(null); const finalizingRecordingId = useRef(null); const allowAutoFinalize = useRef(false); const discardRecordingId = useRef(null); const restarting = useRef(false); - const getRecordingDurationMs = useCallback(() => { - const segmentDuration = - segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current; - return accumulatedDurationMs.current + segmentDuration; - }, []); - const selectMimeType = () => { const preferred = [ "video/webm;codecs=av1", @@ -215,10 +203,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { teardownMedia(); setRecording(false); - setPaused(false); - setElapsedSeconds(0); - accumulatedDurationMs.current = 0; - segmentStartedAt.current = null; window.electronAPI?.setRecordingState(false); void (async () => { @@ -290,7 +274,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const activeWebcamRecorder = webcamRecorder.current; - const duration = getRecordingDurationMs(); + const duration = Date.now() - startTime.current; const activeRecordingId = recordingId.current; finalizeRecording( @@ -300,10 +284,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { activeRecordingId, ); - if ( - activeScreenRecorder.recorder.state === "recording" || - activeScreenRecorder.recorder.state === "paused" - ) { + if (activeScreenRecorder.recorder.state === "recording") { try { activeScreenRecorder.recorder.stop(); } catch { @@ -311,10 +292,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } if (activeWebcamRecorder) { - if ( - activeWebcamRecorder.recorder.state === "recording" || - activeWebcamRecorder.recorder.state === "paused" - ) { + if (activeWebcamRecorder.recorder.state === "recording") { try { activeWebcamRecorder.recorder.stop(); } catch { @@ -339,20 +317,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { restarting.current = false; discardRecordingId.current = null; - if ( - screenRecorder.current?.recorder.state === "recording" || - screenRecorder.current?.recorder.state === "paused" - ) { + if (screenRecorder.current?.recorder.state === "recording") { try { screenRecorder.current.recorder.stop(); } catch { // Ignore recorder teardown errors during cleanup. } } - if ( - webcamRecorder.current?.recorder.state === "recording" || - webcamRecorder.current?.recorder.state === "paused" - ) { + if (webcamRecorder.current?.recorder.state === "recording") { try { webcamRecorder.current.recorder.stop(); } catch { @@ -547,12 +519,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } recordingId.current = Date.now(); - accumulatedDurationMs.current = 0; - segmentStartedAt.current = Date.now(); + startTime.current = recordingId.current; allowAutoFinalize.current = true; setRecording(true); - setPaused(false); - setElapsedSeconds(0); window.electronAPI?.setRecordingState(true); const activeScreenRecorder = screenRecorder.current; @@ -568,7 +537,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalizeRecording( activeScreenRecorder, activeWebcamRecorder ?? null, - Math.max(0, getRecordingDurationMs()), + Math.max(0, Date.now() - startTime.current), activeRecordingId, ); }, @@ -584,56 +553,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { toast.error(errorMsg); } setRecording(false); - setPaused(false); - setElapsedSeconds(0); - accumulatedDurationMs.current = 0; - segmentStartedAt.current = null; screenRecorder.current = null; webcamRecorder.current = null; teardownMedia(); } }; - const togglePaused = () => { - const activeScreenRecorder = screenRecorder.current?.recorder; - if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") { - return; - } - - const activeWebcamRecorder = webcamRecorder.current?.recorder; - - if (activeScreenRecorder.state === "paused") { - try { - activeScreenRecorder.resume(); - if (activeWebcamRecorder?.state === "paused") { - activeWebcamRecorder.resume(); - } - segmentStartedAt.current = Date.now(); - setPaused(false); - } catch (error) { - console.error("Failed to resume recording:", error); - } - return; - } - - if (activeScreenRecorder.state !== "recording") { - return; - } - - try { - accumulatedDurationMs.current = getRecordingDurationMs(); - segmentStartedAt.current = null; - setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); - activeScreenRecorder.pause(); - if (activeWebcamRecorder?.state === "recording") { - activeWebcamRecorder.pause(); - } - setPaused(true); - } catch (error) { - console.error("Failed to pause recording:", error); - } - }; - const toggleRecording = () => { recording ? stopRecording.current() : startRecording(); }; @@ -642,7 +567,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (restarting.current) return; const activeScreenRecorder = screenRecorder.current; - if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return; + if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return; const activeWebcamRecorder = webcamRecorder.current; const activeRecordingId = recordingId.current; @@ -657,10 +582,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }), ]; - if ( - activeWebcamRecorder?.recorder.state === "recording" || - activeWebcamRecorder?.recorder.state === "paused" - ) { + if (activeWebcamRecorder?.recorder.state === "recording") { stopPromises.push( new Promise((resolve) => { activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), { @@ -691,30 +613,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { stopRecording.current(); }; - useEffect(() => { - if (!recording) { - setElapsedSeconds(0); - return; - } - - setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000)); - if (paused) { - return; - } - - const interval = window.setInterval(() => { - setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000)); - }, 250); - - return () => window.clearInterval(interval); - }, [getRecordingDurationMs, paused, recording]); - return { recording, - paused, - elapsedSeconds, toggleRecording, - togglePaused, restartRecording, cancelRecording, microphoneEnabled, diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index cf111c4..c1229cc 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -4,8 +4,6 @@ "closeApp": "Close App", "restartRecording": "Restart recording", "cancelRecording": "Cancel recording", - "pauseRecording": "Pause recording", - "resumeRecording": "Resume recording", "openVideoFile": "Open video file", "openProject": "Open project" }, diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index f47bc81..f5be07c 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -4,8 +4,6 @@ "closeApp": "Cerrar aplicación", "restartRecording": "Reiniciar grabación", "cancelRecording": "Cancelar grabación", - "pauseRecording": "Pausar grabación", - "resumeRecording": "Reanudar grabación", "openVideoFile": "Abrir archivo de video", "openProject": "Abrir proyecto" }, diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 6b63df1..0c2b319 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -4,8 +4,6 @@ "closeApp": "关闭应用", "restartRecording": "重新开始录制", "cancelRecording": "取消录制", - "pauseRecording": "暂停录制", - "resumeRecording": "继续录制", "openVideoFile": "打开视频文件", "openProject": "打开项目" }, From c868469be57ef8abb961b15af55629d55fa4174c Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sun, 5 Apr 2026 10:17:35 -0700 Subject: [PATCH 032/115] fix: auto-finalize duration bug, restore cancelRecording, and add i18n for pause tooltips --- src/components/launch/LaunchWindow.tsx | 2 +- src/hooks/useScreenRecorder.ts | 15 ++++++++++++--- src/i18n/locales/en/launch.json | 2 ++ src/i18n/locales/es/launch.json | 2 ++ src/i18n/locales/zh-CN/launch.json | 2 ++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 83306ee..249dd77 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -459,7 +459,7 @@ export function LaunchWindow() { {recording && ( - + + +
+
+ )} {/* 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 056/115] 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 057/115] 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 90ba71332395ca989c12f5dff4ac09db0b9ae35d Mon Sep 17 00:00:00 2001 From: AmitwalaH Date: Mon, 6 Apr 2026 15:08:49 +0530 Subject: [PATCH 058/115] fix(i18n): update tutorial dialog translation keys for all locales --- 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..d1fb685 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..6d7fe7d 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 4e2a53b2004f88f4847c501b8e016d752c96ab21 Mon Sep 17 00:00:00 2001 From: AmitwalaH Date: Mon, 6 Apr 2026 15:19:24 +0530 Subject: [PATCH 059/115] fix: spacing issues in tutorial translations --- src/i18n/locales/en/dialogs.json | 6 +++--- src/i18n/locales/es/dialogs.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index d1fb685..a84b5fd 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -29,7 +29,7 @@ "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", + "explanationMiddle": " — anything", "covered": "covered", "explanationAfter": "by a red trim segment will be cut out when you export.", "visualExample": "Visual Example", @@ -40,8 +40,8 @@ "part3": "Part 3", "finalVideo": "Final Video", "step1Title": "1. Add Trim", - "step1DescriptionBefore": "Press", - "step1DescriptionAfter": "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 6d7fe7d..f8a5e63 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -29,7 +29,7 @@ "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", + "explanationMiddle": " — cualquier parte", "covered": "cubierta", "explanationAfter": "por un segmento rojo será eliminada al exportar.", "visualExample": "Ejemplo visual", @@ -40,8 +40,8 @@ "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.", + "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." }, From 112f02fe032de8a970cd9d022032bd959f5cbb9f Mon Sep 17 00:00:00 2001 From: moncef Date: Tue, 7 Apr 2026 00:30:23 +0100 Subject: [PATCH 060/115] feat: implement video editor timeline components with interactive zoom, trim, and speed region controls. --- src/components/video-editor/SettingsPanel.tsx | 47 ++++++++- src/components/video-editor/VideoEditor.tsx | 31 +++++- src/components/video-editor/timeline/Item.tsx | 99 ++++++++++++++++++- .../video-editor/timeline/TimelineEditor.tsx | 12 +++ src/components/video-editor/types.ts | 2 + .../videoPlayback/zoomRegionUtils.ts | 33 ++++--- src/i18n/locales/en/settings.json | 7 ++ src/i18n/locales/es/settings.json | 7 ++ src/i18n/locales/zh-CN/settings.json | 7 ++ 9 files changed, 230 insertions(+), 15 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 7e556b8..34adddd 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -150,6 +150,9 @@ interface SettingsPanelProps { onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; webcamMaskShape?: import("./types").WebcamMaskShape; onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void; + selectedZoomInDuration?: number; + selectedZoomOutDuration?: number; + onZoomDurationChange?: (zoomIn: number, zoomOut: number) => void; } export default SettingsPanel; @@ -163,6 +166,14 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; +// TODO: make this configurable +const ZOOM_SPEED_OPTIONS = [ + { label: "Instant", zoomIn: 0, zoomOut: 0 }, + { label: "Fast", zoomIn: 500, zoomOut: 350 }, + { label: "Smooth", zoomIn: 1522, zoomOut: 1015 }, + { label: "Lazy", zoomIn: 3000, zoomOut: 2000 }, +]; + export function SettingsPanel({ selected, onWallpaperChange, @@ -223,6 +234,9 @@ export function SettingsPanel({ onWebcamLayoutPresetChange, webcamMaskShape = "rectangle", onWebcamMaskShapeChange, + selectedZoomInDuration, + selectedZoomOutDuration, + onZoomDurationChange, }: SettingsPanelProps) { const t = useScopedT("settings"); const [wallpaperPaths, setWallpaperPaths] = useState([]); @@ -547,6 +561,37 @@ export function SettingsPanel({ )}
)} + + {zoomEnabled && ( +
+ + {t("zoom.speed.title") || "Zoom Speed"} + +
+ {ZOOM_SPEED_OPTIONS.map((opt) => { + const isActive = + selectedZoomInDuration === opt.zoomIn && + selectedZoomOutDuration === opt.zoomOut; + return ( + + ); + })} +
+
+ )} {zoomEnabled && (
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index f265fe4..db9ae1a 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,5 +1,5 @@ import type { Span } from "dnd-timeline"; -import { useItem } from "dnd-timeline"; +import { useItem, useTimelineContext } from "dnd-timeline"; import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react"; import { useMemo } from "react"; import { cn } from "@/lib/utils"; @@ -13,8 +13,11 @@ interface ItemProps { isSelected?: boolean; onSelect?: () => void; zoomDepth?: number; + zoomInDurationMs?: number; + zoomOutDurationMs?: number; speedValue?: number; variant?: "zoom" | "trim" | "annotation" | "speed"; + onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void; } // Map zoom depth to multiplier labels @@ -44,10 +47,14 @@ export default function Item({ isSelected = false, onSelect, zoomDepth = 1, + zoomInDurationMs, + zoomOutDurationMs, speedValue, variant = "zoom", children, + onZoomDurationChange, }: ItemProps) { + const { pixelsToValue } = useTimelineContext(); const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, span, @@ -101,6 +108,96 @@ export default function Item({ onSelect?.(); }} > + {isZoom && ( + <> + {/* Transition In Marker */} +
+ {/* Draggable handle for Transition In */} +
{ + e.stopPropagation(); + e.preventDefault(); + const target = e.currentTarget; + target.setPointerCapture(e.pointerId); + + const onPointerMove = (moveEvent: PointerEvent) => { + const deltaPx = moveEvent.clientX - e.clientX; + const deltaMs = pixelsToValue(deltaPx); + const newDuration = Math.max( + 0, + Math.min( + (zoomInDurationMs ?? 1522.575) + deltaMs, + span.end - span.start - (zoomOutDurationMs ?? 1015.05), + ), + ); + onZoomDurationChange?.(id, newDuration, zoomOutDurationMs ?? 1015.05); + }; + + const onPointerUp = () => { + target.releasePointerCapture(e.pointerId); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }} + /> + {/* Transition Out Marker */} +
+ {/* Draggable handle for Transition Out */} +
{ + e.stopPropagation(); + e.preventDefault(); + const target = e.currentTarget; + target.setPointerCapture(e.pointerId); + + const onPointerMove = (moveEvent: PointerEvent) => { + const deltaPx = e.clientX - moveEvent.clientX; // Inverted because right-anchored + const deltaMs = pixelsToValue(deltaPx); + const newDuration = Math.max( + 0, + Math.min( + (zoomOutDurationMs ?? 1015.05) + deltaMs, + span.end - span.start - (zoomInDurationMs ?? 1522.575), + ), + ); + onZoomDurationChange?.(id, zoomInDurationMs ?? 1522.575, newDuration); + }; + + const onPointerUp = () => { + target.releasePointerCapture(e.pointerId); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }} + /> + + )}
void; onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; onZoomSpanChange: (id: string, span: Span) => void; + onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void; onZoomDelete: (id: string) => void; selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; @@ -96,6 +97,8 @@ interface TimelineRenderItem { label: string; zoomDepth?: number; speedValue?: number; + zoomInDurationMs?: number; + zoomOutDurationMs?: number; variant: "zoom" | "trim" | "annotation" | "speed"; } @@ -530,6 +533,7 @@ function Timeline({ selectedTrimId, selectedAnnotationId, selectedSpeedId, + onZoomDurationChange, keyframes = [], }: { items: TimelineRenderItem[]; @@ -545,6 +549,7 @@ function Timeline({ selectedTrimId?: string | null; selectedAnnotationId?: string | null; selectedSpeedId?: string | null; + onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void; keyframes?: { id: string; time: number }[]; }) { const t = useScopedT("timeline"); @@ -668,6 +673,9 @@ function Timeline({ isSelected={item.id === selectedZoomId} onSelect={() => onSelectZoom?.(item.id)} zoomDepth={item.zoomDepth} + zoomInDurationMs={item.zoomInDurationMs} + zoomOutDurationMs={item.zoomOutDurationMs} + onZoomDurationChange={onZoomDurationChange} variant="zoom" > {item.label} @@ -740,6 +748,7 @@ export default function TimelineEditor({ onZoomAdded, onZoomSuggested, onZoomSpanChange, + onZoomDurationChange, onZoomDelete, selectedZoomId, onSelectZoom, @@ -1271,6 +1280,8 @@ export default function TimelineEditor({ span: { start: region.startMs, end: region.endMs }, label: t("labels.zoomItem", { index: String(index + 1) }), zoomDepth: region.depth, + zoomInDurationMs: region.zoomInDurationMs, + zoomOutDurationMs: region.zoomOutDurationMs, variant: "zoom", })); @@ -1494,6 +1505,7 @@ export default function TimelineEditor({ selectedTrimId={selectedTrimId} selectedAnnotationId={selectedAnnotationId} selectedSpeedId={selectedSpeedId} + onZoomDurationChange={onZoomDurationChange} keyframes={keyframes} /> diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index de06ba1..abad188 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -29,6 +29,8 @@ export interface ZoomRegion { depth: ZoomDepth; focus: ZoomFocus; focusMode?: ZoomFocusMode; + zoomInDurationMs?: number; + zoomOutDurationMs?: number; } export interface CursorTelemetryPoint { diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index e5c16e1..12acdbf 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -7,7 +7,6 @@ import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils"; const CHAINED_ZOOM_PAN_GAP_MS = 1500; const CONNECTED_ZOOM_PAN_DURATION_MS = 1000; -const ZOOM_IN_OVERLAP_MS = 500; type DominantRegionOptions = { connectZooms?: boolean; @@ -38,26 +37,36 @@ function easeConnectedPan(value: number) { return cubicBezier(0.1, 0.0, 0.2, 1.0, value); } -export function computeRegionStrength(region: ZoomRegion, timeMs: number) { - const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS; - const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS; - const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS; +const DEFAULT_ZOOM_OUT_MS = TRANSITION_WINDOW_MS; +const DEFAULT_ZOOM_IN_MS = ZOOM_IN_TRANSITION_WINDOW_MS; - if (timeMs < leadInStart || timeMs > leadOutEnd) { +function getDurations(region: ZoomRegion) { + const zoomIn = region.zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS; + const zoomOut = region.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS; + return { zoomIn, zoomOut }; +} + +export function computeRegionStrength(region: ZoomRegion, timeMs: number) { + const { zoomIn, zoomOut } = getDurations(region); + + if (timeMs < region.startMs || timeMs > region.endMs) { return 0; } - if (timeMs < zoomInEnd) { - const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS; + // Zooming in + if (timeMs < region.startMs + zoomIn) { + const progress = Math.max(0, Math.min(1, (timeMs - region.startMs) / zoomIn)); return easeOutScreenStudio(progress); } - if (timeMs <= region.endMs) { - return 1; + // Zooming out + if (timeMs > region.endMs - zoomOut) { + const progress = Math.max(0, Math.min(1, (region.endMs - timeMs) / zoomOut)); + return easeOutScreenStudio(progress); } - const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS); - return 1 - easeOutScreenStudio(progress); + // Full zoom + return 1; } function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus { diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 632a569..44ac16a 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -8,6 +8,13 @@ "manual": "Manual", "auto": "Auto", "autoDescription": "Camera follows the recorded cursor position" + }, + "speed": { + "title": "Zoom Speed", + "instant": "Instant", + "fast": "Fast", + "smooth": "Smooth", + "lazy": "Lazy" } }, "speed": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 586e840..9f70ee4 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -8,6 +8,13 @@ "manual": "Manual", "auto": "Auto", "autoDescription": "La cámara sigue la posición del cursor grabado" + }, + "speed": { + "title": "Velocidad de zoom", + "instant": "Instantáneo", + "fast": "Rápido", + "smooth": "Suave", + "lazy": "Lento" } }, "speed": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ab0d41b..2e69b80 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -8,6 +8,13 @@ "manual": "手动", "auto": "自动", "autoDescription": "摄像头跟随录制时的光标位置" + }, + "speed": { + "title": "缩放速度", + "instant": "即时", + "fast": "快速", + "smooth": "平滑", + "lazy": "缓慢" } }, "speed": { From c36349d950a7fcc95050accff61af89b9f1aadf7 Mon Sep 17 00:00:00 2001 From: "Nadir A." Date: Tue, 7 Apr 2026 03:05:21 +0300 Subject: [PATCH 061/115] feat(i18n): add Turkish (tr) locale support Add complete Turkish translation across all 7 i18n namespaces: - common: actions, playback controls, locale metadata - launch: HUD tooltips, audio/webcam controls, source selector - editor: error messages, export, project, recording permissions - dialogs: export progress, trim tutorial, unsaved changes, file dialogs - settings: all panels (zoom, speed, trim, layout, effects, background, crop, export, annotations, custom fonts, language, audio) - shortcuts: keyboard shortcuts panel and all actions - timeline: toolbar buttons, hints, labels, errors, success messages Also adds "tr" to SUPPORTED_LOCALES config and i18n validation script. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/i18n-check.mjs | 2 +- src/i18n/config.ts | 2 +- src/i18n/locales/tr/common.json | 29 +++++ src/i18n/locales/tr/dialogs.json | 68 ++++++++++++ src/i18n/locales/tr/editor.json | 35 ++++++ src/i18n/locales/tr/launch.json | 48 ++++++++ src/i18n/locales/tr/settings.json | 170 +++++++++++++++++++++++++++++ src/i18n/locales/tr/shortcuts.json | 36 ++++++ src/i18n/locales/tr/timeline.json | 50 +++++++++ 9 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 src/i18n/locales/tr/common.json create mode 100644 src/i18n/locales/tr/dialogs.json create mode 100644 src/i18n/locales/tr/editor.json create mode 100644 src/i18n/locales/tr/launch.json create mode 100644 src/i18n/locales/tr/settings.json create mode 100644 src/i18n/locales/tr/shortcuts.json create mode 100644 src/i18n/locales/tr/timeline.json diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs index 3fd0331..c320946 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"]; +const COMPARE_LOCALES = ["zh-CN", "es", "tr"]; function getKeys(obj, prefix = "") { const keys = []; diff --git a/src/i18n/config.ts b/src/i18n/config.ts index a96c7ef..063cc4f 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"] as const; +export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "tr"] as const; export const I18N_NAMESPACES = [ "common", "dialogs", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json new file mode 100644 index 0000000..3ec132c --- /dev/null +++ b/src/i18n/locales/tr/common.json @@ -0,0 +1,29 @@ +{ + "actions": { + "cancel": "İptal", + "save": "Kaydet", + "delete": "Sil", + "close": "Kapat", + "share": "Paylaş", + "done": "Tamam", + "open": "Aç", + "upload": "Yükle", + "export": "Dışa Aktar", + "file": "Dosya", + "edit": "Düzenle", + "view": "Görünüm", + "window": "Pencere", + "quit": "Çıkış", + "stopRecording": "Kaydı Durdur" + }, + "playback": { + "play": "Oynat", + "pause": "Duraklat", + "fullscreen": "Tam Ekran", + "exitFullscreen": "Tam Ekrandan Çık" + }, + "locale": { + "name": "Türkçe", + "short": "TR" + } +} diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json new file mode 100644 index 0000000..5661e45 --- /dev/null +++ b/src/i18n/locales/tr/dialogs.json @@ -0,0 +1,68 @@ +{ + "export": { + "complete": "Dışa Aktarım Tamamlandı", + "yourFormatReady": "{{format}} dosyanız hazır", + "showInFolder": "Klasörde Göster", + "finalizingVideo": "Video dışa aktarımı sonlandırılıyor...", + "compilingGifProgress": "GIF derleniyor... %{{progress}}", + "compilingGifWait": "GIF derleniyor... Bu biraz zaman alabilir", + "takeMoment": "Bu biraz zaman alabilir...", + "failed": "Dışa Aktarım Başarısız", + "tryAgain": "Lütfen tekrar deneyin", + "finalizingVideoTitle": "Video Sonlandırılıyor", + "compilingGif": "GIF Derleniyor", + "exportingFormat": "{{format}} Dışa Aktarılıyor", + "compiling": "Derleniyor", + "renderingFrames": "Kareler İşleniyor", + "processing": "İşleniyor...", + "finalizing": "Sonlandırılıyor...", + "compilingStatus": "Derleniyor...", + "status": "Durum", + "format": "Biçim", + "frames": "Kareler", + "cancelExport": "Dışa Aktarımı İptal Et", + "savedSuccessfully": "{{format}} başarıyla kaydedildi!" + }, + "tutorial": { + "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.", + "visualExample": "Görsel Örnek", + "removed": "KALDIRILDI", + "kept": "Korundu", + "part1": "Bölüm 1", + "part2": "Bölüm 2", + "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.", + "step2Title": "2. Ayarla", + "step2Description": "Kesmek istediğiniz kısmı tam olarak kaplamak için kırmızı bölgenin kenarlarını sürükleyin." + }, + "unsavedChanges": { + "title": "Kaydedilmemiş Değişiklikler", + "message": "Kaydedilmemiş değişiklikleriniz var.", + "detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?", + "saveAndClose": "Kaydet ve Kapat", + "discardAndClose": "Kaydetmeden Kapat", + "loadProject": "Proje Yükle…", + "saveProject": "Proje Kaydet…", + "saveProjectAs": "Farklı Kaydet…" + }, + "fileDialogs": { + "saveGif": "Dışa Aktarılan GIF'i Kaydet", + "saveVideo": "Dışa Aktarılan Videoyu Kaydet", + "selectVideo": "Video Dosyası Seç", + "saveProject": "OpenScreen Projesini Kaydet", + "openProject": "OpenScreen Projesini Aç", + "gifImage": "GIF Görüntüsü", + "mp4Video": "MP4 Video", + "videoFiles": "Video Dosyaları", + "openscreenProject": "OpenScreen Projesi", + "allFiles": "Tüm Dosyalar" + } +} diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json new file mode 100644 index 0000000..dfa4cb1 --- /dev/null +++ b/src/i18n/locales/tr/editor.json @@ -0,0 +1,35 @@ +{ + "errors": { + "noVideoLoaded": "Video yüklenmedi", + "videoNotReady": "Video hazır değil", + "unableToDetermineSourcePath": "Kaynak video yolu belirlenemiyor", + "failedToSaveGif": "GIF kaydedilemedi", + "gifExportFailed": "GIF dışa aktarımı başarısız oldu", + "failedToSaveVideo": "Video kaydedilemedi", + "exportFailed": "Dışa aktarım başarısız oldu", + "exportFailedWithError": "Dışa aktarım başarısız: {{error}}", + "failedToSaveExport": "Dışa aktarım kaydedilemedi", + "failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi", + "failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}" + }, + "export": { + "canceled": "Dışa aktarım iptal edildi", + "exportedSuccessfully": "{{format}} başarıyla dışa aktarıldı" + }, + "project": { + "saveCanceled": "Proje kaydetme iptal edildi", + "failedToSave": "Proje kaydedilemedi", + "savedTo": "Proje şuraya kaydedildi: {{path}}", + "failedToLoad": "Proje yüklenemedi", + "invalidFormat": "Geçersiz proje dosyası biçimi", + "loadedFrom": "Proje şuradan yüklendi: {{path}}" + }, + "recording": { + "failedCameraAccess": "Kamera erişimi istenemedi.", + "cameraBlocked": "Kamera erişimi engellendi. Kamerayı kullanmak için sistem ayarlarından izin verin.", + "systemAudioUnavailable": "Sistem sesi kullanılamıyor. Sistem sesi olmadan kaydediliyor.", + "microphoneDenied": "Mikrofon erişimi reddedildi. Kayıt ses olmadan devam edecek.", + "cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.", + "permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin." + } +} diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json new file mode 100644 index 0000000..f48d99d --- /dev/null +++ b/src/i18n/locales/tr/launch.json @@ -0,0 +1,48 @@ +{ + "tooltips": { + "hideHUD": "Kontrol panelini gizle", + "closeApp": "Uygulamayı kapat", + "restartRecording": "Kaydı yeniden başlat", + "cancelRecording": "Kaydı iptal et", + "pauseRecording": "Kaydı duraklat", + "resumeRecording": "Kayda devam et", + "openVideoFile": "Video dosyası aç", + "openProject": "Proje aç" + }, + "audio": { + "enableSystemAudio": "Sistem sesini etkinleştir", + "disableSystemAudio": "Sistem sesini devre dışı bırak", + "enableMicrophone": "Mikrofonu etkinleştir", + "disableMicrophone": "Mikrofonu devre dışı bırak", + "defaultMicrophone": "Varsayılan Mikrofon", + "enableNoiseReduction": "Gürültü azaltmayı etkinleştir (yapay zeka destekli)", + "disableNoiseReduction": "Gürültü azaltmayı devre dışı bırak", + "noiseReduction": "Gürültü azaltma", + "clickToCycle": "Seviye değiştirmek için tıklayın", + "nrLevel": { + "light": "Hafif", + "moderate": "Orta", + "aggressive": "Güçlü" + }, + "noiseReductionPrompt": "Daha net ses için yapay zeka destekli gürültü azaltmayı etkinleştirmek ister misiniz?", + "enableNoiseReductionShort": "Etkinleştir" + }, + "webcam": { + "enableWebcam": "Kamerayı etkinleştir", + "disableWebcam": "Kamerayı devre dışı bırak", + "defaultCamera": "Varsayılan Kamera", + "searching": "Aranıyor...", + "noneFound": "Kamera bulunamadı", + "unavailable": "Kamera kullanılamıyor" + }, + "sourceSelector": { + "loading": "Kaynaklar yükleniyor...", + "screens": "Ekranlar ({{count}})", + "windows": "Pencereler ({{count}})", + "defaultSourceName": "Ekran" + }, + "recording": { + "selectSource": "Lütfen kayıt için bir kaynak seçin" + }, + "language": "Dil" +} diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json new file mode 100644 index 0000000..1fa4668 --- /dev/null +++ b/src/i18n/locales/tr/settings.json @@ -0,0 +1,170 @@ +{ + "zoom": { + "level": "Yakınlaştırma Seviyesi", + "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin", + "deleteZoom": "Yakınlaştırmayı Sil", + "focusMode": { + "title": "Odak Modu", + "manual": "Manuel", + "auto": "Otomatik", + "autoDescription": "Kamera kaydedilen imleç konumunu takip eder" + } + }, + "speed": { + "playbackSpeed": "Oynatma Hızı", + "selectRegion": "Ayarlamak için bir hız bölgesi seçin", + "deleteRegion": "Hız Bölgesini Sil" + }, + "trim": { + "deleteRegion": "Kırpma Bölgesini Sil" + }, + "layout": { + "title": "Düzen", + "preset": "Ön Ayar", + "selectPreset": "Ön ayar seçin", + "pictureInPicture": "Resim İçinde Resim", + "verticalStack": "Dikey Yığın", + "webcamShape": "Kamera Şekli" + }, + "effects": { + "title": "Video Efektleri", + "blurBg": "Arka Planı Bulanıklaştır", + "motionBlur": "Hareket Bulanıklığı", + "off": "kapalı", + "shadow": "Gölge", + "roundness": "Yuvarlaklık", + "padding": "Dolgu" + }, + "background": { + "title": "Arka Plan", + "image": "Görüntü", + "color": "Renk", + "gradient": "Gradyan", + "uploadCustom": "Özel Yükle", + "gradientLabel": "Gradyan {{index}}" + }, + "crop": { + "title": "Kırpma", + "cropVideo": "Videoyu Kırp", + "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin", + "ratio": "Oran", + "free": "Serbest", + "done": "Tamam", + "lockAspectRatio": "En boy oranını kilitle", + "unlockAspectRatio": "En boy oranının kilidini aç" + }, + "exportFormat": { + "mp4": "MP4", + "gif": "GIF", + "mp4Video": "MP4 Video", + "mp4Description": "Yüksek kaliteli video dosyası", + "gifAnimation": "GIF Animasyon", + "gifDescription": "Paylaşım için hareketli görüntü" + }, + "exportQuality": { + "title": "Dışa Aktarım Kalitesi", + "low": "Düşük", + "medium": "Orta", + "high": "Yüksek" + }, + "gifSettings": { + "frameRate": "GIF Kare Hızı", + "size": "GIF Boyutu", + "loop": "GIF Döngüsü" + }, + "project": { + "save": "Projeyi Kaydet", + "load": "Proje Yükle" + }, + "export": { + "videoButton": "Videoyu Dışa Aktar", + "gifButton": "GIF Olarak Dışa Aktar", + "chooseSaveLocation": "Kayıt Konumu Seç" + }, + "links": { + "reportBug": "Hata Bildir", + "starOnGithub": "GitHub'da Yıldızla" + }, + "imageUpload": { + "invalidFileType": "Geçersiz dosya türü", + "jpgOnly": "Lütfen bir JPG veya JPEG görüntü dosyası yükleyin.", + "uploadSuccess": "Özel görüntü başarıyla yüklendi!", + "failedToUpload": "Görüntü yüklenemedi", + "errorReading": "Dosya okunurken bir hata oluştu." + }, + "annotation": { + "title": "Açıklama Ayarları", + "active": "Aktif", + "typeText": "Metin", + "typeImage": "Görüntü", + "typeArrow": "Ok", + "textContent": "Metin İçeriği", + "textPlaceholder": "Metninizi girin...", + "fontStyle": "Yazı Tipi Stili", + "selectStyle": "Stil seçin", + "size": "Boyut", + "customFonts": "Özel Yazı Tipleri", + "textColor": "Metin Rengi", + "background": "Arka Plan", + "none": "Yok", + "color": "Renk", + "clearBackground": "Arka Planı Temizle", + "uploadImage": "Görüntü Yükle", + "supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP", + "arrowDirection": "Ok Yönü", + "strokeWidth": "Çizgi Kalınlığı: {{width}}px", + "arrowColor": "Ok Rengi", + "deleteAnnotation": "Açıklamayı Sil", + "shortcutsAndTips": "Kısayollar ve İpuçları", + "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.", + "tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.", + "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.", + "invalidImageType": "Geçersiz dosya türü", + "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.", + "imageUploadSuccess": "Görüntü başarıyla yüklendi!", + "failedImageUpload": "Görüntü yüklenemedi" + }, + "fontStyles": { + "classic": "Klasik", + "editor": "Editör", + "strong": "Kalın", + "typewriter": "Daktilo", + "deco": "Dekoratif", + "simple": "Sade", + "modern": "Modern", + "clean": "Temiz" + }, + "customFont": { + "dialogTitle": "Google Yazı Tipi Ekle", + "urlLabel": "Google Fonts İçe Aktarım URL'si", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın", + "nameLabel": "Görünen Ad", + "namePlaceholder": "Özel Yazı Tipim", + "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler", + "addButton": "Yazı Tipi Ekle", + "addingButton": "Ekleniyor...", + "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin", + "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin", + "errorEmptyName": "Lütfen bir yazı tipi adı girin", + "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı", + "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi", + "failedToAdd": "Yazı tipi eklenemedi", + "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.", + "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin." + }, + "language": { + "title": "Dil" + }, + "audio": { + "title": "Ses", + "noiseReduction": "Gürültü Azaltma", + "level": "Seviye", + "nrLevel": { + "light": "Hafif", + "moderate": "Orta", + "aggressive": "Güçlü" + }, + "nrDescription": "Yapay zeka destekli gürültü azaltma arka plan gürültüsünü temizler. Daha yüksek seviyeler daha agresiftir ancak ses kalitesini etkileyebilir." + } +} diff --git a/src/i18n/locales/tr/shortcuts.json b/src/i18n/locales/tr/shortcuts.json new file mode 100644 index 0000000..8eb7931 --- /dev/null +++ b/src/i18n/locales/tr/shortcuts.json @@ -0,0 +1,36 @@ +{ + "title": "Klavye Kısayolları", + "customize": "Özelleştir", + "configurable": "Yapılandırılabilir", + "fixed": "Sabit", + "pressKey": "Bir tuşa basın…", + "clickToChange": "Değiştirmek için tıklayın", + "pressEscToCancel": "İptal etmek için Esc tuşuna basın", + "helpText": "Bir kısayola tıklayın, ardından yeni tuş kombinasyonuna basın. İptal etmek için Esc tuşuna basın.", + "resetToDefaults": "Varsayılanlara sıfırla", + "alreadyUsedBy": "\"{{action}}\" tarafından zaten kullanılıyor", + "swap": "Değiştir", + "reservedShortcut": "Bu kısayol \"{{label}}\" için ayrılmıştır ve yeniden atanamaz.", + "savedToast": "Klavye kısayolları kaydedildi", + "resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın", + "actions": { + "addZoom": "Yakınlaştırma Ekle", + "addTrim": "Kırpma Ekle", + "addSpeed": "Hız Ekle", + "addAnnotation": "Açıklama Ekle", + "addKeyframe": "Anahtar Kare Ekle", + "deleteSelected": "Seçileni Sil", + "playPause": "Oynat / Duraklat" + }, + "fixedActions": { + "undo": "Geri Al", + "redo": "Yinele", + "cycleAnnotationsForward": "Açıklamalar Arasında İleri Geç", + "cycleAnnotationsBackward": "Açıklamalar Arasında Geri Geç", + "deleteSelectedAlt": "Seçileni Sil (alternatif)", + "panTimeline": "Zaman Çizelgesini Kaydır", + "zoomTimeline": "Zaman Çizelgesini Yakınlaştır", + "frameBack": "Önceki Kare", + "frameForward": "Sonraki Kare" + } +} diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json new file mode 100644 index 0000000..b39a5d1 --- /dev/null +++ b/src/i18n/locales/tr/timeline.json @@ -0,0 +1,50 @@ +{ + "buttons": { + "addZoom": "Yakınlaştırma Ekle (Z)", + "suggestZooms": "İmleçten Yakınlaştırma Öner", + "addTrim": "Kırpma Ekle (T)", + "addAnnotation": "Açıklama Ekle (A)", + "addSpeed": "Hız Ekle (S)" + }, + "hints": { + "pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın", + "pressTrim": "Kırpma eklemek için T tuşuna basın", + "pressAnnotation": "Açıklama eklemek için A tuşuna basın", + "pressSpeed": "Hız eklemek için S tuşuna basın" + }, + "labels": { + "pan": "Kaydır", + "zoom": "Yakınlaştır", + "zoomItem": "Yakınlaştırma {{index}}", + "trimItem": "Kırpma {{index}}", + "speedItem": "Hız {{index}}", + "annotationItem": "Açıklama", + "imageItem": "Görüntü", + "emptyText": "Boş metin" + }, + "emptyState": { + "noVideo": "Video Yüklenmedi", + "dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın" + }, + "errors": { + "cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor", + "zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.", + "zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor", + "noCursorTelemetry": "İmleç telemetrisi mevcut değil", + "noCursorTelemetryDescription": "İmleç tabanlı öneriler oluşturmak için önce bir ekran kaydı yapın.", + "noUsableTelemetry": "Kullanılabilir imleç telemetrisi yok", + "noUsableTelemetryDescription": "Kayıt yeterli imleç hareketi verisi içermiyor.", + "noDwellMoments": "Belirgin imleç bekleme anları bulunamadı", + "noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.", + "noAutoZoomSlots": "Otomatik yakınlaştırma alanı yok", + "noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.", + "cannotPlaceTrim": "Buraya kırpma yerleştirilemiyor", + "trimExistsAtLocation": "Bu konumda zaten bir kırpma var veya yeterli alan yok.", + "cannotPlaceSpeed": "Buraya hız yerleştirilemiyor", + "speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok." + }, + "success": { + "addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi", + "addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi" + } +} From e739653b3fc97c4361561e71c563009a74c5f12d Mon Sep 17 00:00:00 2001 From: FabLrc Date: Tue, 7 Apr 2026 12:05:36 +0200 Subject: [PATCH 062/115] feat(i18n): add French translations for various application components --- src/i18n/config.ts | 2 +- src/i18n/locales/fr/common.json | 29 ++++++ src/i18n/locales/fr/dialogs.json | 68 ++++++++++++ src/i18n/locales/fr/editor.json | 35 +++++++ src/i18n/locales/fr/launch.json | 37 +++++++ src/i18n/locales/fr/settings.json | 159 +++++++++++++++++++++++++++++ src/i18n/locales/fr/shortcuts.json | 36 +++++++ src/i18n/locales/fr/timeline.json | 50 +++++++++ 8 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 src/i18n/locales/fr/common.json create mode 100644 src/i18n/locales/fr/dialogs.json create mode 100644 src/i18n/locales/fr/editor.json create mode 100644 src/i18n/locales/fr/launch.json create mode 100644 src/i18n/locales/fr/settings.json create mode 100644 src/i18n/locales/fr/shortcuts.json create mode 100644 src/i18n/locales/fr/timeline.json diff --git a/src/i18n/config.ts b/src/i18n/config.ts index a96c7ef..38680f9 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"] as const; +export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr"] as const; export const I18N_NAMESPACES = [ "common", "dialogs", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json new file mode 100644 index 0000000..7eb7f83 --- /dev/null +++ b/src/i18n/locales/fr/common.json @@ -0,0 +1,29 @@ +{ + "actions": { + "cancel": "Annuler", + "save": "Enregistrer", + "delete": "Supprimer", + "close": "Fermer", + "share": "Partager", + "done": "Terminer", + "open": "Ouvrir", + "upload": "Téléverser", + "export": "Exporter", + "file": "Fichier", + "edit": "Éditer", + "view": "Affichage", + "window": "Fenêtre", + "quit": "Quitter", + "stopRecording": "Arrêter l'enregistrement" + }, + "playback": { + "play": "Lecture", + "pause": "Pause", + "fullscreen": "Plein écran", + "exitFullscreen": "Quitter le plein écran" + }, + "locale": { + "name": "Français", + "short": "FR" + } +} diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json new file mode 100644 index 0000000..b4056a5 --- /dev/null +++ b/src/i18n/locales/fr/dialogs.json @@ -0,0 +1,68 @@ +{ + "export": { + "complete": "Export terminé", + "yourFormatReady": "Votre {{format}} est prêt", + "showInFolder": "Afficher dans le dossier", + "finalizingVideo": "Finalisation de l'export vidéo...", + "compilingGifProgress": "Compilation du GIF... {{progress}}%", + "compilingGifWait": "Compilation du GIF... Cela peut prendre un moment", + "takeMoment": "Cela peut prendre un moment...", + "failed": "Export échoué", + "tryAgain": "Veuillez réessayer", + "finalizingVideoTitle": "Finalisation de la vidéo", + "compilingGif": "Compilation du GIF", + "exportingFormat": "Export de {{format}}", + "compiling": "Compilation en cours", + "renderingFrames": "Rendu des images", + "processing": "Traitement en cours...", + "finalizing": "Finalisation...", + "compilingStatus": "Compilation...", + "status": "Statut", + "format": "Format", + "frames": "Images", + "cancelExport": "Annuler l'export", + "savedSuccessfully": "{{format}} enregistré avec succès !" + }, + "tutorial": { + "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.", + "visualExample": "Exemple visuel", + "removed": "SUPPRIMÉ", + "kept": "Conservé", + "part1": "Partie 1", + "part2": "Partie 2", + "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.", + "step2Title": "2. Ajuster", + "step2Description": "Faites glisser les bords de la région rouge pour couvrir exactement ce que vous souhaitez couper." + }, + "unsavedChanges": { + "title": "Modifications non enregistrées", + "message": "Vous avez des modifications non enregistrées.", + "detail": "Voulez-vous enregistrer votre projet avant de fermer ?", + "saveAndClose": "Enregistrer et fermer", + "discardAndClose": "Ignorer et fermer", + "loadProject": "Charger un projet…", + "saveProject": "Enregistrer le projet…", + "saveProjectAs": "Enregistrer le projet sous…" + }, + "fileDialogs": { + "saveGif": "Enregistrer le GIF exporté", + "saveVideo": "Enregistrer la vidéo exportée", + "selectVideo": "Sélectionner un fichier vidéo", + "saveProject": "Enregistrer le projet OpenScreen", + "openProject": "Ouvrir un projet OpenScreen", + "gifImage": "Image GIF", + "mp4Video": "Vidéo MP4", + "videoFiles": "Fichiers vidéo", + "openscreenProject": "Projet OpenScreen", + "allFiles": "Tous les fichiers" + } +} diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json new file mode 100644 index 0000000..779bcd7 --- /dev/null +++ b/src/i18n/locales/fr/editor.json @@ -0,0 +1,35 @@ +{ + "errors": { + "noVideoLoaded": "Aucune vidéo chargée", + "videoNotReady": "Vidéo non prête", + "unableToDetermineSourcePath": "Impossible de déterminer le chemin de la vidéo source", + "failedToSaveGif": "Échec de l'enregistrement du GIF", + "gifExportFailed": "L'export du GIF a échoué", + "failedToSaveVideo": "Échec de l'enregistrement de la vidéo", + "exportFailed": "L'export a échoué", + "exportFailedWithError": "L'export a échoué : {{error}}", + "failedToSaveExport": "Échec de l'enregistrement de l'export", + "failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée", + "failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}" + }, + "export": { + "canceled": "Export annulé", + "exportedSuccessfully": "{{format}} exporté avec succès" + }, + "project": { + "saveCanceled": "Enregistrement du projet annulé", + "failedToSave": "Échec de l'enregistrement du projet", + "savedTo": "Projet enregistré dans {{path}}", + "failedToLoad": "Échec du chargement du projet", + "invalidFormat": "Format de fichier projet invalide", + "loadedFrom": "Projet chargé depuis {{path}}" + }, + "recording": { + "failedCameraAccess": "Échec de la demande d'accès à la caméra.", + "cameraBlocked": "L'accès à la caméra est bloqué. Activez-le dans les paramètres système pour utiliser la webcam.", + "systemAudioUnavailable": "Audio système non disponible. Enregistrement sans audio système.", + "microphoneDenied": "Accès au microphone refusé. L'enregistrement continuera sans audio.", + "cameraDenied": "Accès à la caméra refusé. L'enregistrement continuera sans webcam.", + "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran." + } +} diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json new file mode 100644 index 0000000..f4bfb27 --- /dev/null +++ b/src/i18n/locales/fr/launch.json @@ -0,0 +1,37 @@ +{ + "tooltips": { + "hideHUD": "Masquer le HUD", + "closeApp": "Fermer l'application", + "restartRecording": "Redémarrer l'enregistrement", + "cancelRecording": "Annuler l'enregistrement", + "pauseRecording": "Mettre en pause l'enregistrement", + "resumeRecording": "Reprendre l'enregistrement", + "openVideoFile": "Ouvrir un fichier vidéo", + "openProject": "Ouvrir un projet" + }, + "audio": { + "enableSystemAudio": "Activer l'audio système", + "disableSystemAudio": "Désactiver l'audio système", + "enableMicrophone": "Activer le microphone", + "disableMicrophone": "Désactiver le microphone", + "defaultMicrophone": "Microphone par défaut" + }, + "webcam": { + "enableWebcam": "Activer la webcam", + "disableWebcam": "Désactiver la webcam", + "defaultCamera": "Caméra par défaut", + "searching": "Recherche en cours...", + "noneFound": "Aucune caméra trouvée", + "unavailable": "Caméra non disponible" + }, + "sourceSelector": { + "loading": "Chargement des sources...", + "screens": "Écrans ({{count}})", + "windows": "Fenêtres ({{count}})", + "defaultSourceName": "Écran" + }, + "recording": { + "selectSource": "Veuillez sélectionner une source à enregistrer" + }, + "language": "Langue" +} diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json new file mode 100644 index 0000000..dd7610f --- /dev/null +++ b/src/i18n/locales/fr/settings.json @@ -0,0 +1,159 @@ +{ + "zoom": { + "level": "Niveau de zoom", + "selectRegion": "Sélectionnez une région de zoom à ajuster", + "deleteZoom": "Supprimer le zoom", + "focusMode": { + "title": "Mode focus", + "manual": "Manuel", + "auto": "Auto", + "autoDescription": "La caméra suit la position du curseur enregistré" + } + }, + "speed": { + "playbackSpeed": "Vitesse de lecture", + "selectRegion": "Sélectionnez une région de vitesse à ajuster", + "deleteRegion": "Supprimer la région de vitesse" + }, + "trim": { + "deleteRegion": "Supprimer la région de coupe" + }, + "layout": { + "title": "Mise en page", + "preset": "Préréglage", + "selectPreset": "Choisir un préréglage", + "pictureInPicture": "Incrustation d'image", + "verticalStack": "Empilement vertical", + "webcamShape": "Forme de la caméra" + }, + "effects": { + "title": "Effets vidéo", + "blurBg": "Flou arrière-plan", + "motionBlur": "Flou de mouvement", + "off": "désactivé", + "shadow": "Ombre", + "roundness": "Arrondi", + "padding": "Marge" + }, + "background": { + "title": "Arrière-plan", + "image": "Image", + "color": "Couleur", + "gradient": "Dégradé", + "uploadCustom": "Téléverser une image", + "gradientLabel": "Dégradé {{index}}" + }, + "crop": { + "title": "Recadrage", + "cropVideo": "Recadrer la vidéo", + "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage", + "ratio": "Ratio", + "free": "Libre", + "done": "Terminer", + "lockAspectRatio": "Verrouiller le ratio", + "unlockAspectRatio": "Déverrouiller le ratio" + }, + "exportFormat": { + "mp4": "MP4", + "gif": "GIF", + "mp4Video": "Vidéo MP4", + "mp4Description": "Fichier vidéo haute qualité", + "gifAnimation": "Animation GIF", + "gifDescription": "Image animée pour le partage" + }, + "exportQuality": { + "title": "Qualité d'export", + "low": "Faible", + "medium": "Moyenne", + "high": "Haute" + }, + "gifSettings": { + "frameRate": "Fréquence d'images GIF", + "size": "Taille du GIF", + "loop": "GIF en boucle" + }, + "project": { + "save": "Enregistrer le projet", + "load": "Charger un projet" + }, + "export": { + "videoButton": "Exporter la vidéo", + "gifButton": "Exporter le GIF", + "chooseSaveLocation": "Choisir l'emplacement d'enregistrement" + }, + "links": { + "reportBug": "Signaler un bug", + "starOnGithub": "Étoile sur GitHub" + }, + "imageUpload": { + "invalidFileType": "Type de fichier invalide", + "jpgOnly": "Veuillez téléverser un fichier image JPG ou JPEG.", + "uploadSuccess": "Image personnalisée téléversée avec succès !", + "failedToUpload": "Échec du téléversement de l'image", + "errorReading": "Une erreur s'est produite lors de la lecture du fichier." + }, + "annotation": { + "title": "Paramètres d'annotation", + "active": "Actif", + "typeText": "Texte", + "typeImage": "Image", + "typeArrow": "Flèche", + "textContent": "Contenu du texte", + "textPlaceholder": "Saisissez votre texte...", + "fontStyle": "Style de police", + "selectStyle": "Choisir un style", + "size": "Taille", + "customFonts": "Polices personnalisées", + "textColor": "Couleur du texte", + "background": "Arrière-plan", + "none": "Aucun", + "color": "Couleur", + "clearBackground": "Supprimer l'arrière-plan", + "uploadImage": "Téléverser une image", + "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP", + "arrowDirection": "Direction de la flèche", + "strokeWidth": "Épaisseur du trait : {{width}}px", + "arrowColor": "Couleur de la flèche", + "deleteAnnotation": "Supprimer l'annotation", + "shortcutsAndTips": "Raccourcis & Astuces", + "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.", + "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.", + "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.", + "invalidImageType": "Type de fichier invalide", + "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.", + "imageUploadSuccess": "Image téléversée avec succès !", + "failedImageUpload": "Échec du téléversement de l'image" + }, + "fontStyles": { + "classic": "Classique", + "editor": "Éditeur", + "strong": "Gras", + "typewriter": "Machine à écrire", + "deco": "Déco", + "simple": "Simple", + "modern": "Moderne", + "clean": "Épuré" + }, + "customFont": { + "dialogTitle": "Ajouter une police Google", + "urlLabel": "URL d'import Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import", + "nameLabel": "Nom d'affichage", + "namePlaceholder": "Ma police personnalisée", + "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices", + "addButton": "Ajouter la police", + "addingButton": "Ajout en cours...", + "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts", + "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide", + "errorEmptyName": "Veuillez saisir un nom de police", + "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL", + "successMessage": "Police « {{fontName}} » ajoutée avec succès", + "failedToAdd": "Échec de l'ajout de la police", + "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.", + "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte." + }, + "language": { + "title": "Langue" + } +} diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json new file mode 100644 index 0000000..ebd2181 --- /dev/null +++ b/src/i18n/locales/fr/shortcuts.json @@ -0,0 +1,36 @@ +{ + "title": "Raccourcis clavier", + "customize": "Personnaliser", + "configurable": "Configurable", + "fixed": "Fixe", + "pressKey": "Appuyez sur une touche…", + "clickToChange": "Cliquez pour modifier", + "pressEscToCancel": "Appuyez sur Échap pour annuler", + "helpText": "Cliquez sur un raccourci puis appuyez sur la nouvelle combinaison de touches. Appuyez sur Échap pour annuler.", + "resetToDefaults": "Réinitialiser les valeurs par défaut", + "alreadyUsedBy": "Déjà utilisé par {{action}}", + "swap": "Échanger", + "reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.", + "savedToast": "Raccourcis clavier enregistrés", + "resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer", + "actions": { + "addZoom": "Ajouter un zoom", + "addTrim": "Ajouter une coupe", + "addSpeed": "Ajouter une vitesse", + "addAnnotation": "Ajouter une annotation", + "addKeyframe": "Ajouter une image-clé", + "deleteSelected": "Supprimer la sélection", + "playPause": "Lecture / Pause" + }, + "fixedActions": { + "undo": "Annuler", + "redo": "Rétablir", + "cycleAnnotationsForward": "Cycler les annotations en avant", + "cycleAnnotationsBackward": "Cycler les annotations en arrière", + "deleteSelectedAlt": "Supprimer la sélection (alt)", + "panTimeline": "Panoramique de la timeline", + "zoomTimeline": "Zoom de la timeline", + "frameBack": "Image précédente", + "frameForward": "Image suivante" + } +} diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json new file mode 100644 index 0000000..abee16c --- /dev/null +++ b/src/i18n/locales/fr/timeline.json @@ -0,0 +1,50 @@ +{ + "buttons": { + "addZoom": "Ajouter un zoom (Z)", + "suggestZooms": "Suggérer des zooms depuis le curseur", + "addTrim": "Ajouter une coupe (T)", + "addAnnotation": "Ajouter une annotation (A)", + "addSpeed": "Ajouter une vitesse (S)" + }, + "hints": { + "pressZoom": "Appuyez sur Z pour ajouter un zoom", + "pressTrim": "Appuyez sur T pour ajouter une coupe", + "pressAnnotation": "Appuyez sur A pour ajouter une annotation", + "pressSpeed": "Appuyez sur S pour ajouter une vitesse" + }, + "labels": { + "pan": "Panoramique", + "zoom": "Zoom", + "zoomItem": "Zoom {{index}}", + "trimItem": "Coupe {{index}}", + "speedItem": "Vitesse {{index}}", + "annotationItem": "Annotation", + "imageItem": "Image", + "emptyText": "Texte vide" + }, + "emptyState": { + "noVideo": "Aucune vidéo chargée", + "dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer" + }, + "errors": { + "cannotPlaceZoom": "Impossible de placer le zoom ici", + "zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.", + "zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible", + "noCursorTelemetry": "Aucune télémétrie de curseur disponible", + "noCursorTelemetryDescription": "Enregistrez d'abord un screencast pour générer des suggestions basées sur le curseur.", + "noUsableTelemetry": "Aucune télémétrie de curseur utilisable", + "noUsableTelemetryDescription": "L'enregistrement ne contient pas suffisamment de données de mouvement du curseur.", + "noDwellMoments": "Aucun moment de pause du curseur trouvé", + "noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.", + "noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible", + "noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.", + "cannotPlaceTrim": "Impossible de placer la coupe ici", + "trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l'espace disponible est insuffisant.", + "cannotPlaceSpeed": "Impossible de placer la vitesse ici", + "speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant." + }, + "success": { + "addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée", + "addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées" + } +} From 7a8fb807e6f2be2bf273f066d71aca071b51595e Mon Sep 17 00:00:00 2001 From: FabLrc Date: Tue, 7 Apr 2026 12:17:10 +0200 Subject: [PATCH 063/115] feat(i18n): add French translations for common and dialogs namespaces --- electron/i18n.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/electron/i18n.ts b/electron/i18n.ts index b385008..2dfb4d3 100644 --- a/electron/i18n.ts +++ b/electron/i18n.ts @@ -5,10 +5,12 @@ import commonEn from "../src/i18n/locales/en/common.json"; import dialogsEn from "../src/i18n/locales/en/dialogs.json"; import commonEs from "../src/i18n/locales/es/common.json"; import dialogsEs from "../src/i18n/locales/es/dialogs.json"; +import commonFr from "../src/i18n/locales/fr/common.json"; +import dialogsFr from "../src/i18n/locales/fr/dialogs.json"; import commonZh from "../src/i18n/locales/zh-CN/common.json"; import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json"; -type Locale = "en" | "zh-CN" | "es"; +type Locale = "en" | "zh-CN" | "es" | "fr"; type Namespace = "common" | "dialogs"; type MessageMap = Record; @@ -16,12 +18,13 @@ const messages: Record> = { en: { common: commonEn, dialogs: dialogsEn }, "zh-CN": { common: commonZh, dialogs: dialogsZh }, es: { common: commonEs, dialogs: dialogsEs }, + fr: { common: commonFr, dialogs: dialogsFr }, }; let currentLocale: Locale = "en"; export function setMainLocale(locale: string) { - if (locale === "en" || locale === "zh-CN" || locale === "es") { + if (locale === "en" || locale === "zh-CN" || locale === "es" || locale === "fr") { currentLocale = locale; } } From 1f56bb42c31ac02d96511545dcb199381df0b788 Mon Sep 17 00:00:00 2001 From: FabLrc Date: Tue, 7 Apr 2026 12:17:53 +0200 Subject: [PATCH 064/115] fix(i18n): update French translations for cycle annotations shortcuts --- src/i18n/locales/fr/shortcuts.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json index ebd2181..5c6e494 100644 --- a/src/i18n/locales/fr/shortcuts.json +++ b/src/i18n/locales/fr/shortcuts.json @@ -25,8 +25,8 @@ "fixedActions": { "undo": "Annuler", "redo": "Rétablir", - "cycleAnnotationsForward": "Cycler les annotations en avant", - "cycleAnnotationsBackward": "Cycler les annotations en arrière", + "cycleAnnotationsForward": "Parcourir les annotations en avant", + "cycleAnnotationsBackward": "Parcourir les annotations en arrière", "deleteSelectedAlt": "Supprimer la sélection (alt)", "panTimeline": "Panoramique de la timeline", "zoomTimeline": "Zoom de la timeline", From 8f35cf090c8fdbc06db62c247771fe4790c1fa8d Mon Sep 17 00:00:00 2001 From: moncef Date: Tue, 7 Apr 2026 11:40:39 +0100 Subject: [PATCH 065/115] feat: add zoomRegionUtils to calculate dominant zoom regions and handle smooth transitions between connected regions --- .../video-editor/videoPlayback/zoomRegionUtils.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 12acdbf..e9fd603 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -41,8 +41,16 @@ const DEFAULT_ZOOM_OUT_MS = TRANSITION_WINDOW_MS; const DEFAULT_ZOOM_IN_MS = ZOOM_IN_TRANSITION_WINDOW_MS; function getDurations(region: ZoomRegion) { - const zoomIn = region.zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS; - const zoomOut = region.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS; + let zoomIn = region.zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS; + let zoomOut = region.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS; + + const duration = region.endMs - region.startMs; + if (zoomIn + zoomOut > duration) { + const scale = duration / (zoomIn + zoomOut); + zoomIn *= scale; + zoomOut *= scale; + } + return { zoomIn, zoomOut }; } From 7409631207f6c58a21e98a37c34dbb5105bc2d97 Mon Sep 17 00:00:00 2001 From: moncef Date: Tue, 7 Apr 2026 11:43:20 +0100 Subject: [PATCH 066/115] Fix pr review SelecedSpeedId --- src/components/video-editor/VideoEditor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 79ac03a..e4de186 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1760,6 +1760,7 @@ export default function VideoEditor() { onAnnotationStyleChange={handleAnnotationStyleChange} onAnnotationFigureDataChange={handleAnnotationFigureDataChange} onAnnotationDelete={handleAnnotationDelete} + selectedSpeedId={selectedSpeedId} selectedSpeedValue={ selectedSpeedId ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) From 0cb298d20bd7a53672dc550298ba9014c80c0e3c Mon Sep 17 00:00:00 2001 From: moncef Date: Tue, 7 Apr 2026 11:58:45 +0100 Subject: [PATCH 067/115] Fix Pr reviews --- src/components/video-editor/SettingsPanel.tsx | 9 ++-- src/components/video-editor/VideoEditor.tsx | 4 +- src/components/video-editor/timeline/Item.tsx | 49 +++++++++++++------ .../videoPlayback/zoomRegionUtils.ts | 11 +++-- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 34adddd..e45f439 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -166,7 +166,6 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; -// TODO: make this configurable const ZOOM_SPEED_OPTIONS = [ { label: "Instant", zoomIn: 0, zoomOut: 0 }, { label: "Fast", zoomIn: 500, zoomOut: 350 }, @@ -570,8 +569,10 @@ export function SettingsPanel({
{ZOOM_SPEED_OPTIONS.map((opt) => { const isActive = - selectedZoomInDuration === opt.zoomIn && - selectedZoomOutDuration === opt.zoomOut; + selectedZoomInDuration !== undefined && + selectedZoomOutDuration !== undefined && + Math.round(selectedZoomInDuration) === Math.round(opt.zoomIn) && + Math.round(selectedZoomOutDuration) === Math.round(opt.zoomOut); return ( + ); + })} +
+ +
+
+ Blur intensity + + {Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px + +
+ { + onBlurDataChange({ + ...DEFAULT_BLUR_DATA, + ...blurRegion.blurData, + intensity: values[0], + }); + }} + onValueCommit={() => onBlurDataCommit?.()} + min={MIN_BLUR_INTENSITY} + max={MAX_BLUR_INTENSITY} + step={1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+ + + +
+
+ + {t("annotation.shortcutsAndTips")} +
+
    +
  • {t("annotation.tipMovePlayhead")}
  • +
+
+
+
+ ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index daf5f42..b1cd78d 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -42,11 +42,13 @@ import { cn } from "@/lib/utils"; import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { getTestId } from "@/utils/getTestId"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; +import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, AnnotationType, + BlurData, CropRegion, FigureData, PlaybackSpeed, @@ -209,6 +211,11 @@ interface SettingsPanelProps { onAnnotationStyleChange?: (id: string, style: Partial) => void; onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void; onAnnotationDelete?: (id: string) => void; + selectedBlurId?: string | null; + blurRegions?: AnnotationRegion[]; + onBlurDataChange?: (id: string, blurData: BlurData) => void; + onBlurDataCommit?: () => void; + onBlurDelete?: (id: string) => void; selectedSpeedId?: string | null; selectedSpeedValue?: PlaybackSpeed | null; onSpeedChange?: (speed: PlaybackSpeed) => void; @@ -285,6 +292,11 @@ export function SettingsPanel({ onAnnotationStyleChange, onAnnotationFigureDataChange, onAnnotationDelete, + selectedBlurId, + blurRegions = [], + onBlurDataChange, + onBlurDataCommit, + onBlurDelete, selectedSpeedId, selectedSpeedValue, onSpeedChange, @@ -520,6 +532,9 @@ export function SettingsPanel({ const selectedAnnotation = selectedAnnotationId ? annotationRegions.find((a) => a.id === selectedAnnotationId) : null; + const selectedBlur = selectedBlurId + ? blurRegions.find((region) => region.id === selectedBlurId) + : null; // If an annotation is selected, show annotation settings instead if ( @@ -545,6 +560,17 @@ export function SettingsPanel({ ); } + if (selectedBlur && onBlurDataChange && onBlurDelete) { + return ( + onBlurDataChange(selectedBlur.id, blurData)} + onBlurDataCommit={onBlurDataCommit} + onDelete={() => onBlurDelete(selectedBlur.id)} + /> + ); + } + return (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 88c3aae..a543da8 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -54,11 +54,13 @@ import { SettingsPanel } from "./SettingsPanel"; import TimelineEditor from "./timeline/TimelineEditor"; import { type AnnotationRegion, + type BlurData, type CursorTelemetryPoint, clampFocusToDepth, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, + DEFAULT_BLUR_DATA, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, @@ -122,6 +124,7 @@ export default function VideoEditor() { const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [selectedBlurId, setSelectedBlurId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); @@ -157,6 +160,15 @@ export default function VideoEditor() { const nextAnnotationZIndexRef = useRef(1); const exporterRef = useRef(null); + const annotationOnlyRegions = useMemo( + () => annotationRegions.filter((region) => region.type !== "blur"), + [annotationRegions], + ); + const blurRegions = useMemo( + () => annotationRegions.filter((region) => region.type === "blur"), + [annotationRegions], + ); + const currentProjectMedia = useMemo(() => { const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); if (!screenVideoPath) { @@ -229,6 +241,7 @@ export default function VideoEditor() { setSelectedTrimId(null); setSelectedSpeedId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); nextZoomIdRef.current = deriveNextId( "zoom", @@ -626,7 +639,11 @@ export default function VideoEditor() { const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); - if (id) setSelectedTrimId(null); + if (id) { + setSelectedTrimId(null); + setSelectedAnnotationId(null); + setSelectedBlurId(null); + } }, []); const handleSelectTrim = useCallback((id: string | null) => { @@ -634,6 +651,7 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); } }, []); @@ -642,6 +660,16 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedBlurId(null); + } + }, []); + + const handleSelectBlur = useCallback((id: string | null) => { + setSelectedBlurId(id); + if (id) { + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedAnnotationId(null); } }, []); @@ -659,6 +687,7 @@ export default function VideoEditor() { setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); }, [pushState], ); @@ -677,6 +706,7 @@ export default function VideoEditor() { setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); }, [pushState], ); @@ -693,6 +723,7 @@ export default function VideoEditor() { setSelectedTrimId(id); setSelectedZoomId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); }, [pushState], ); @@ -803,6 +834,7 @@ export default function VideoEditor() { setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); } }, []); @@ -822,6 +854,7 @@ export default function VideoEditor() { setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); }, [pushState], ); @@ -888,6 +921,35 @@ export default function VideoEditor() { setSelectedAnnotationId(id); setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedBlurId(null); + }, + [pushState], + ); + + const handleBlurAdded = useCallback( + (span: Span) => { + const id = `annotation-${nextAnnotationIdRef.current++}`; + const zIndex = nextAnnotationZIndexRef.current++; + const newRegion: AnnotationRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + type: "blur", + content: "", + position: { ...DEFAULT_ANNOTATION_POSITION }, + size: { ...DEFAULT_ANNOTATION_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex, + blurData: { ...DEFAULT_BLUR_DATA }, + }; + pushState((prev) => ({ + annotationRegions: [...prev.annotationRegions, newRegion], + })); + setSelectedBlurId(id); + setSelectedAnnotationId(null); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); }, [pushState], ); @@ -917,8 +979,11 @@ export default function VideoEditor() { if (selectedAnnotationId === id) { setSelectedAnnotationId(null); } + if (selectedBlurId === id) { + setSelectedBlurId(null); + } }, - [selectedAnnotationId, pushState], + [selectedAnnotationId, selectedBlurId, pushState], ); const handleAnnotationContentChange = useCallback( @@ -953,6 +1018,11 @@ export default function VideoEditor() { if (!region.figureData) { updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA }; } + } else if (type === "blur") { + updatedRegion.content = ""; + if (!region.blurData) { + updatedRegion.blurData = { ...DEFAULT_BLUR_DATA }; + } } return updatedRegion; }), @@ -983,6 +1053,51 @@ export default function VideoEditor() { [pushState], ); + const handleBlurDataPreviewChange = useCallback( + (id: string, blurData: BlurData) => { + updateState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id + ? { + ...region, + blurData, + // Freehand drawing area is the full video surface. + ...(blurData.shape === "freehand" + ? { + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + } + : {}), + } + : region, + ), + })); + }, + [updateState], + ); + + const handleBlurDataPanelChange = useCallback( + (id: string, blurData: BlurData) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id + ? { + ...region, + blurData, + ...(blurData.shape === "freehand" + ? { + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + } + : {}), + } + : region, + ), + })); + }, + [pushState], + ); + const handleAnnotationPositionChange = useCallback( (id: string, position: { x: number; y: number }) => { pushState((prev) => ({ @@ -1100,7 +1215,10 @@ export default function VideoEditor() { ) { setSelectedAnnotationId(null); } - }, [selectedAnnotationId, annotationRegions]); + if (selectedBlurId && !annotationRegions.some((region) => region.id === selectedBlurId)) { + setSelectedBlurId(null); + } + }, [selectedAnnotationId, selectedBlurId, annotationRegions]); useEffect(() => { if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) { @@ -1675,11 +1793,18 @@ export default function VideoEditor() { cropRegion={cropRegion} trimRegions={trimRegions} speedRegions={speedRegions} - annotationRegions={annotationRegions} + annotationRegions={annotationOnlyRegions} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} + blurRegions={blurRegions} + selectedBlurId={selectedBlurId} + onSelectBlur={handleSelectBlur} + onBlurPositionChange={handleAnnotationPositionChange} + onBlurSizeChange={handleAnnotationSizeChange} + onBlurDataChange={handleBlurDataPreviewChange} + onBlurDataCommit={commitState} cursorTelemetry={cursorTelemetry} />
@@ -1732,12 +1857,18 @@ export default function VideoEditor() { onSpeedDelete={handleSpeedDelete} selectedSpeedId={selectedSpeedId} onSelectSpeed={handleSelectSpeed} - annotationRegions={annotationRegions} + annotationRegions={annotationOnlyRegions} onAnnotationAdded={handleAnnotationAdded} onAnnotationSpanChange={handleAnnotationSpanChange} onAnnotationDelete={handleAnnotationDelete} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} + blurRegions={blurRegions} + onBlurAdded={handleBlurAdded} + onBlurSpanChange={handleAnnotationSpanChange} + onBlurDelete={handleAnnotationDelete} + selectedBlurId={selectedBlurId} + onSelectBlur={handleSelectBlur} aspectRatio={aspectRatio} onAspectRatioChange={(ar) => pushState({ @@ -1830,12 +1961,17 @@ export default function VideoEditor() { )} onExport={handleOpenExportDialog} selectedAnnotationId={selectedAnnotationId} - annotationRegions={annotationRegions} + annotationRegions={annotationOnlyRegions} onAnnotationContentChange={handleAnnotationContentChange} onAnnotationTypeChange={handleAnnotationTypeChange} onAnnotationStyleChange={handleAnnotationStyleChange} onAnnotationFigureDataChange={handleAnnotationFigureDataChange} onAnnotationDelete={handleAnnotationDelete} + selectedBlurId={selectedBlurId} + blurRegions={blurRegions} + onBlurDataChange={handleBlurDataPanelChange} + onBlurDataCommit={commitState} + onBlurDelete={handleAnnotationDelete} selectedSpeedId={selectedSpeedId} selectedSpeedValue={ selectedSpeedId diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 08c1c25..caebe36 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -35,6 +35,7 @@ import { import { AnnotationOverlay } from "./AnnotationOverlay"; import { type AnnotationRegion, + type BlurData, type SpeedRegion, type TrimRegion, ZOOM_DEPTH_SCALES, @@ -101,6 +102,13 @@ interface VideoPlaybackProps { onSelectAnnotation?: (id: string | null) => void; onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void; + blurRegions?: AnnotationRegion[]; + selectedBlurId?: string | null; + onSelectBlur?: (id: string | null) => void; + onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void; + onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void; + onBlurDataChange?: (id: string, blurData: BlurData) => void; + onBlurDataCommit?: () => void; cursorTelemetry?: import("./types").CursorTelemetryPoint[]; } @@ -152,6 +160,13 @@ const VideoPlayback = forwardRef( onSelectAnnotation, onAnnotationPositionChange, onAnnotationSizeChange, + blurRegions = [], + selectedBlurId, + onSelectBlur, + onBlurPositionChange, + onBlurSizeChange, + onBlurDataChange, + onBlurDataCommit, cursorTelemetry = [], }, ref, @@ -166,6 +181,8 @@ const VideoPlayback = forwardRef( const timeUpdateAnimationRef = useRef(null); const [pixiReady, setPixiReady] = useState(false); const [videoReady, setVideoReady] = useState(false); + const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 }); + const [overlayElement, setOverlayElement] = useState(null); const overlayRef = useRef(null); const focusIndicatorRef = useRef(null); const [webcamLayout, setWebcamLayout] = useState(null); @@ -330,6 +347,11 @@ const VideoPlayback = forwardRef( layoutVideoContentRef.current = layoutVideoContent; }, [layoutVideoContent]); + const setOverlayRefs = useCallback((node: HTMLDivElement | null) => { + overlayRef.current = node; + setOverlayElement(node); + }, []); + const selectedZoom = useMemo(() => { if (!selectedZoomId) return null; return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; @@ -623,7 +645,8 @@ const VideoPlayback = forwardRef( }, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]); useEffect(() => { - const overlayEl = overlayRef.current; + if (!pixiReady || !videoReady) return; + const overlayEl = overlayElement; if (!overlayEl) return; if (!selectedZoom) { overlayEl.style.cursor = "default"; @@ -632,7 +655,34 @@ const VideoPlayback = forwardRef( } overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab"; overlayEl.style.pointerEvents = isPlaying ? "none" : "auto"; - }, [selectedZoom, isPlaying]); + }, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]); + + useEffect(() => { + const overlayEl = overlayElement; + if (!overlayEl) return; + + const updateOverlaySize = () => { + const width = overlayEl.clientWidth || 800; + const height = overlayEl.clientHeight || 600; + setOverlaySize((prev) => { + if (prev.width === width && prev.height === height) return prev; + return { width, height }; + }); + }; + + updateOverlaySize(); + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(() => { + updateOverlaySize(); + }); + observer.observe(overlayEl); + return () => observer.disconnect(); + } + + window.addEventListener("resize", updateOverlaySize); + return () => window.removeEventListener("resize", updateOverlaySize); + }, [overlayElement]); useEffect(() => { const container = containerRef.current; @@ -1287,7 +1337,7 @@ const VideoPlayback = forwardRef( {/* Only render overlay after PIXI and video are fully initialized */} {pixiReady && videoReady && (
( return sorted.map((annotation) => ( onAnnotationPositionChange?.(id, position)} onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)} onClick={handleAnnotationClick} @@ -1345,6 +1395,39 @@ const VideoPlayback = forwardRef( /> )); })()} + {(() => { + const filtered = (blurRegions || []).filter((blurRegion) => { + if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number") + return false; + + if (blurRegion.id === selectedBlurId) return true; + + const timeMs = Math.round(currentTime * 1000); + return timeMs >= blurRegion.startMs && timeMs <= blurRegion.endMs; + }); + + const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex); + const handleBlurClick = (clickedId: string) => { + onSelectBlur?.(clickedId); + }; + + return sorted.map((blurRegion) => ( + `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`} + annotation={blurRegion} + isSelected={blurRegion.id === selectedBlurId} + containerWidth={overlaySize.width} + containerHeight={overlaySize.height} + onPositionChange={(id, position) => onBlurPositionChange?.(id, position)} + onSizeChange={(id, size) => onBlurSizeChange?.(id, size)} + onBlurDataChange={(id, blurData) => onBlurDataChange?.(id, blurData)} + onBlurDataCommit={onBlurDataCommit} + onClick={handleBlurClick} + zIndex={blurRegion.zIndex} + isSelectedBoost={blurRegion.id === selectedBlurId} + /> + )); + })()}
)}
+
+ )} {/* 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 100/115] 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 101/115] 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 102/115] 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 103/115] 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 104/115] 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 105/115] 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 106/115] 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 107/115] 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 108/115] 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 109/115] 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 110/115] 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 111/115] 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 112/115] 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 113/115] 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 114/115] 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}`); + } From 7264b9989ee71e7093f411b366276007d0b18557 Mon Sep 17 00:00:00 2001 From: Aaryash Khalkar <91302334+imAaryash@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:16:56 +0530 Subject: [PATCH 115/115] Refactor Discord webhook URL handling in workflow Updated Discord webhook handling to allow for a fallback to DISCORD_PR_FORUM_WEBHOOK if DISCORD_WEBHOOK_URL is not set. Added checks to ensure webhook URL is provided, especially for fork PR events. --- .github/workflows/discord.yaml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml index 3b07ad0..97d23e7 100644 --- a/.github/workflows/discord.yaml +++ b/.github/workflows/discord.yaml @@ -25,7 +25,8 @@ jobs: id: sync uses: actions/github-script@v7 env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + DISCORD_PR_FORUM_WEBHOOK: ${{ 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 }} @@ -37,7 +38,7 @@ jobs: const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim(); const THREAD_MARKER_REGEX = //i; - const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || "").trim(); + const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || process.env.DISCORD_PR_FORUM_WEBHOOK || "").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(); @@ -193,17 +194,24 @@ jobs: } 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 isForkPr = !!pr.head?.repo?.fork; + if (!webhookUrl) { + if (isForkPr) { + core.info("Skipping Discord sync: webhook secret is unavailable for fork PR events."); + return; + } + core.setFailed( + "Missing Discord webhook secret. Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets, or pass it explicitly if using reusable workflows." + ); + return; + } + const action = context.payload.action || ""; const owner = context.repo.owner; const repo = context.repo.repo;