diff --git a/Plugins/Screenshot/Resources/Localizable.xcstrings b/Plugins/Screenshot/Resources/Localizable.xcstrings index c03019a5c..3e044df5e 100644 --- a/Plugins/Screenshot/Resources/Localizable.xcstrings +++ b/Plugins/Screenshot/Resources/Localizable.xcstrings @@ -309,6 +309,930 @@ } } }, + "overlay.record.autoZoom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Zoom" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動縮放" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تكبير تلقائي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatischer Zoom" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Zoom automático" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Zoom automatique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動ズーム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 확대/축소" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Zoom automático" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автомасштаб" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik Yakınlaştırma" + } + } + } + }, + "overlay.tool.fill": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fill" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "填充" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "填滿" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعبئة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Füllen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Relleno" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remplissage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "塗りつぶし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "채우기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Preencher" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заливка" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Doldur" + } + } + } + }, + "pin.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close (Esc)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭 (Esc)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉 (Esc)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق (Esc)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen (Esc)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar (Esc)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer (Esc)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる (Esc)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫기 (Esc)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fechar (Esc)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть (Esc)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapat (Esc)" + } + } + } + }, + "pin.copy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy (⌘C)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制 (⌘C)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拷貝 (⌘C)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ (⌘C)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopieren (⌘C)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar (⌘C)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier (⌘C)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー (⌘C)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복사 (⌘C)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Copiar (⌘C)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопировать (⌘C)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kopyala (⌘C)" + } + } + } + }, + "pin.save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save As (⌘S)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "另存为 (⌘S)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "另存新檔 (⌘S)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حفظ باسم (⌘S)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sichern unter (⌘S)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Guardar como (⌘S)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer sous (⌘S)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "別名で保存 (⌘S)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다른 이름으로 저장 (⌘S)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Salvar como (⌘S)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранить как (⌘S)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Farklı Kaydet (⌘S)" + } + } + } + }, + "record.keepOriginal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Original" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保留原片" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保留原片" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاحتفاظ بالأصل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original behalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conservar original" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Garder l’original" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナルを残す" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본 유지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Manter original" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Оставить исходник" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Orijinali Koru" + } + } + } + }, + "record.originalSuffix": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原始" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原始" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أصلي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исходник" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Orijinal" + } + } + } + }, + "record.pointerMonitorUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pointer activity is unavailable. Try again or turn off Auto Zoom." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取鼠标操作,请重试或关闭自动缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法讀取滑鼠操作,請重試或關閉自動縮放" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر قراءة نشاط المؤشر. أعد المحاولة أو أوقف التكبير التلقائي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigeraktivität ist nicht verfügbar. Versuche es erneut oder deaktiviere den automatischen Zoom." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La actividad del puntero no está disponible. Inténtalo de nuevo o desactiva el zoom automático." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’activité du pointeur est indisponible. Réessayez ou désactivez le zoom automatique." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポインタの操作を読み取れません。もう一度試すか、自動ズームをオフにしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포인터 활동을 읽을 수 없습니다. 다시 시도하거나 자동 확대/축소를 끄세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A atividade do ponteiro não está disponível. Tente novamente ou desative o zoom automático." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удаётся получить действия указателя. Повторите попытку или отключите автомасштаб." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İşaretçi etkinliği okunamıyor. Yeniden deneyin veya Otomatik Yakınlaştırma'yı kapatın." + } + } + } + }, + "record.processing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Creating auto-zoom video %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在生成自动缩放视频 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在產生自動縮放影片 %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إنشاء فيديو التكبير التلقائي %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Video mit automatischem Zoom wird erstellt %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Creando vídeo con zoom automático %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Création de la vidéo avec zoom automatique %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動ズームビデオを作成中 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 확대/축소 비디오 생성 중 %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Criando vídeo com zoom automático %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Создание видео с автомасштабом %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik yakınlaştırmalı video oluşturuluyor %@" + } + } + } + }, + "record.processingBusy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An auto-zoom video is still being created. Try recording again when it finishes." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在生成自动缩放视频,请稍后再录制" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在產生自動縮放影片,請稍後再錄製" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يزال فيديو التكبير التلقائي قيد الإنشاء. أعد التسجيل بعد اكتماله." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Video mit automatischem Zoom wird noch erstellt. Starte die Aufnahme danach erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún se está creando un vídeo con zoom automático. Vuelve a grabar cuando termine." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Une vidéo avec zoom automatique est encore en cours de création. Réessayez une fois terminée." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動ズームビデオを作成中です。完了してから収録してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 확대/축소 비디오를 아직 생성 중입니다. 완료된 후 다시 녹화하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Um vídeo com zoom automático ainda está sendo criado. Grave novamente quando terminar." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Видео с автомасштабом ещё создаётся. Начните запись после завершения." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik yakınlaştırmalı video hâlâ oluşturuluyor. Bittiğinde yeniden kaydedin." + } + } + } + }, + "record.processingCancelled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Original recording kept: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保留原始录像:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已保留原始錄影:%@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم الاحتفاظ بالتسجيل الأصلي: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Originalaufnahme behalten: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Grabación original conservada: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrement original conservé : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナルの収録を残しました:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본 녹화를 유지했습니다: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gravação original mantida: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исходная запись сохранена: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Orijinal kayıt korundu: %@" + } + } + } + }, + "record.processingFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not create the auto-zoom video. Original recording kept: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动缩放生成失败,已保留原始录像:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動縮放產生失敗,已保留原始錄影:%@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر إنشاء فيديو التكبير التلقائي. تم الاحتفاظ بالتسجيل الأصلي: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Video mit automatischem Zoom konnte nicht erstellt werden. Originalaufnahme behalten: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo crear el vídeo con zoom automático. Grabación original conservada: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de créer la vidéo avec zoom automatique. Enregistrement original conservé : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動ズームビデオを作成できませんでした。オリジナルの収録を残しました:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 확대/축소 비디오를 생성할 수 없습니다. 원본 녹화를 유지했습니다: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível criar o vídeo com zoom automático. Gravação original mantida: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось создать видео с автомасштабом. Исходная запись сохранена: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik yakınlaştırmalı video oluşturulamadı. Orijinal kayıt korundu: %@" + } + } + } + }, "record.saving": { "extractionState": "manual", "localizations": { @@ -6161,618 +7085,618 @@ } } }, - "overlay.tool.blur": { + "overlay.tool.effects": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Blur (B)" + "value": "Corners and shadow ([ ] corners, { } shadow)" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Bulanıklaştır (B)" + "value": "Köşeler ve gölge ([ ] köşeler, { } gölge)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "模糊 (B)" + "value": "圆角与阴影([ ] 圆角,{ } 阴影)" } }, "ar": { "stringUnit": { "state": "translated", - "value": "طمس (B)" + "value": "الزوايا والظل ([ ] الزوايا، { } الظل)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Weichzeichnen (B)" + "value": "Ecken und Schatten ([ ] Ecken, { } Schatten)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Desenfoque (B)" + "value": "Esquinas y sombra ([ ] esquinas, { } sombra)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Flou (B)" + "value": "Coins et ombre ([ ] coins, { } ombre)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ぼかし (B)" + "value": "角丸と影([ ] 角丸、{ } 影)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "블러(B)" + "value": "모서리와 그림자([ ] 모서리, { } 그림자)" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Desfoque (B)" + "value": "Cantos e sombra ([ ] cantos, { } sombra)" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Размытие (B)" + "value": "Скругление и тень ([ ] скругление, { } тень)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "模糊 (B)" + "value": "圓角與陰影([ ] 圓角,{ } 陰影)" } } } }, - "overlay.tool.effects": { + "overlay.tool.ellipse": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Corners and shadow ([ ] corners, { } shadow)" + "value": "Ellipse (O)" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Köşeler ve gölge ([ ] köşeler, { } gölge)" + "value": "Elips (O)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "圆角与阴影([ ] 圆角,{ } 阴影)" + "value": "椭圆 (O)" } }, "ar": { "stringUnit": { "state": "translated", - "value": "الزوايا والظل ([ ] الزوايا، { } الظل)" + "value": "القطع الناقص (O)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Ecken und Schatten ([ ] Ecken, { } Schatten)" + "value": "Ellipse (O)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Esquinas y sombra ([ ] esquinas, { } sombra)" + "value": "Elipse (O)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Coins et ombre ([ ] coins, { } ombre)" + "value": "Ellipse (O)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "角丸と影([ ] 角丸、{ } 影)" + "value": "楕円(O)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "모서리와 그림자([ ] 모서리, { } 그림자)" + "value": "타원(O)" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Cantos e sombra ([ ] cantos, { } sombra)" + "value": "Elipse (O)" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Скругление и тень ([ ] скругление, { } тень)" + "value": "Эллипс (O)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "圓角與陰影([ ] 圓角,{ } 陰影)" + "value": "橢圓 (O)" } } } }, - "overlay.tool.ellipse": { + "overlay.tool.line": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ellipse (O)" + "value": "Line (L)" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Elips (O)" + "value": "Çizgi (L)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "椭圆 (O)" + "value": "直线 (L)" } }, "ar": { "stringUnit": { "state": "translated", - "value": "القطع الناقص (O)" + "value": "الخط (L)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Ellipse (O)" + "value": "Linie (L)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Elipse (O)" + "value": "Línea (L)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ellipse (O)" + "value": "Ligne (L)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "楕円(O)" + "value": "直線 (L)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "타원(O)" + "value": "선(L)" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Elipse (O)" + "value": "Linha (L)" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Эллипс (O)" + "value": "Линия (L)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "橢圓 (O)" + "value": "直線 (L)" } } } }, - "overlay.tool.line": { + "overlay.tool.mosaic": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Line (L)" + "value": "Pixelate (M)" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Çizgi (L)" + "value": "Mozaik (M)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "直线 (L)" + "value": "马赛克 (M)" } }, "ar": { "stringUnit": { "state": "translated", - "value": "الخط (L)" + "value": "فسيفساء (M)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Linie (L)" + "value": "Mosaik (M)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Línea (L)" + "value": "Mosaico (M)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ligne (L)" + "value": "Mosaïque (M)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "直線 (L)" + "value": "モザイク(M)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "선(L)" + "value": "모자이크(M)" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Linha (L)" + "value": "Mosaico (M)" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Линия (L)" + "value": "Мозаика (M)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "直線 (L)" + "value": "馬賽克 (M)" } } } }, - "overlay.tool.mosaic": { + "overlay.tool.number": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pixelate (M)" + "value": "Number (N)" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Mozaik (M)" + "value": "Numara (N)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "马赛克 (M)" + "value": "序号 (N)" } }, "ar": { "stringUnit": { "state": "translated", - "value": "فسيفساء (M)" + "value": "الرقم (N)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Mosaik (M)" + "value": "Nummer (N)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Mosaico (M)" + "value": "Número (N)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Mosaïque (M)" + "value": "Numéro (N)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "モザイク(M)" + "value": "連番 (N)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "모자이크(M)" + "value": "번호(N)" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Mosaico (M)" + "value": "Número (N)" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Мозаика (M)" + "value": "Номер (N)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "馬賽克 (M)" + "value": "序號 (N)" } } } }, - "overlay.tool.number": { + "overlay.tool.pen": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Number (N)" + "value": "Pen (P)" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Numara (N)" + "value": "Kalem (P)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "序号 (N)" + "value": "画笔 (P)" } }, "ar": { "stringUnit": { "state": "translated", - "value": "الرقم (N)" + "value": "القلم (P)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Nummer (N)" + "value": "Stift (P)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Número (N)" + "value": "Bolígrafo (P)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Numéro (N)" + "value": "Stylo (P)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "連番 (N)" + "value": "ペン(P)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "번호(N)" + "value": "펜(P)" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Número (N)" + "value": "Caneta (P)" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Номер (N)" + "value": "Перо (P)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "序號 (N)" + "value": "畫筆 (P)" } } } }, - "overlay.tool.pen": { + "overlay.tool.rectangle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pen (P)" + "value": "Rectangle (R)" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Kalem (P)" + "value": "Dikdörtgen (R)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "画笔 (P)" + "value": "矩形 (R)" } }, "ar": { "stringUnit": { "state": "translated", - "value": "القلم (P)" + "value": "المستطيل (R)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Stift (P)" + "value": "Rechteck (R)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Bolígrafo (P)" + "value": "Rectángulo (R)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Stylo (P)" + "value": "Rectangle (R)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペン(P)" + "value": "四角形 (R)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "펜(P)" + "value": "직사각형(R)" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Caneta (P)" + "value": "Retângulo (R)" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Перо (P)" + "value": "Прямоугольник (R)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "畫筆 (P)" + "value": "矩形 (R)" } } } }, - "overlay.tool.rectangle": { + "overlay.tool.spotlight": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rectangle (R)" + "value": "Spotlight" } }, - "tr": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Dikdörtgen (R)" + "value": "聚焦" } }, - "zh-Hans": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "矩形 (R)" + "value": "聚焦" } }, "ar": { "stringUnit": { "state": "translated", - "value": "المستطيل (R)" + "value": "تسليط الضوء" } }, "de": { "stringUnit": { "state": "translated", - "value": "Rechteck (R)" + "value": "Hervorheben" } }, "es": { "stringUnit": { "state": "translated", - "value": "Rectángulo (R)" + "value": "Resaltar" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Rectangle (R)" + "value": "Projecteur" } }, "ja": { "stringUnit": { "state": "translated", - "value": "四角形 (R)" + "value": "スポットライト" } }, "ko": { "stringUnit": { "state": "translated", - "value": "직사각형(R)" + "value": "스포트라이트" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Retângulo (R)" + "value": "Destacar" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Прямоугольник (R)" + "value": "Подсветка" } }, - "zh-Hant": { + "tr": { "stringUnit": { "state": "translated", - "value": "矩形 (R)" + "value": "Vurgula" } } } @@ -8240,6 +9164,160 @@ } } }, + "settings.autoZoom.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zooms in where you click or scroll, then returns to the full view. Applied to the saved video; the desktop does not change while recording." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击或滚动时放大该位置,随后恢复全景。在保存的视频中生成,录制时不改变桌面。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點按或捲動時放大該位置,隨後恢復全景。在儲存的影片中產生,錄製時不改變桌面。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يكبّر موضع النقر أو التمرير ثم يعود إلى العرض الكامل. يُطبَّق على الفيديو المحفوظ ولا يتغير سطح المكتب أثناء التسجيل." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vergrößert die Stelle, an der du klickst oder scrollst, und kehrt dann zur Gesamtansicht zurück. Wird im gesicherten Video erzeugt; der Schreibtisch ändert sich während der Aufnahme nicht." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Amplía donde haces clic o te desplazas y luego vuelve a la vista completa. Se aplica al vídeo guardado; el escritorio no cambia durante la grabación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Agrandit l’endroit où vous cliquez ou faites défiler, puis revient à la vue complète. Appliqué à la vidéo enregistrée ; le bureau ne change pas pendant l’enregistrement." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリックやスクロールした位置を拡大し、その後全体表示に戻します。保存したビデオに適用され、収録中のデスクトップは変わりません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클릭하거나 스크롤한 위치를 확대한 다음 전체 보기로 돌아갑니다. 저장된 비디오에 적용되며 녹화 중에는 데스크탑이 변하지 않습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Amplia onde você clica ou rola e depois volta à visão completa. Aplicado ao vídeo salvo; a mesa não muda durante a gravação." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Увеличивает место щелчка или прокрутки, затем возвращает полный вид. Применяется к сохранённому видео; рабочий стол во время записи не меняется." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tıkladığınız veya kaydırdığınız yeri yakınlaştırır, ardından tam görünüme döner. Kaydedilen videoya uygulanır; kayıt sırasında masaüstü değişmez." + } + } + } + }, + "settings.autoZoom.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Zoom" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動縮放" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تكبير تلقائي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatischer Zoom" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Zoom automático" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Zoom automatique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動ズーム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 확대/축소" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Zoom automático" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автомасштаб" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik Yakınlaştırma" + } + } + } + }, "settings.folder.choose": { "extractionState": "manual", "localizations": { @@ -8548,6 +9626,83 @@ } } }, + "settings.recording.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recording" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "录屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "螢幕錄製" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التسجيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufnahme" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Grabación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画面収録" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화면 기록" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gravação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запись" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kayıt" + } + } + } + }, "settings.shortcuts.description": { "extractionState": "manual", "localizations": { diff --git a/Plugins/Screenshot/Sources/AnnotationRenderer.swift b/Plugins/Screenshot/Sources/AnnotationRenderer.swift index 8795df345..e094c0c53 100644 --- a/Plugins/Screenshot/Sources/AnnotationRenderer.swift +++ b/Plugins/Screenshot/Sources/AnnotationRenderer.swift @@ -1,5 +1,4 @@ import AppKit -import CoreImage enum Shape { case rect(NSRect) @@ -11,12 +10,13 @@ enum Shape { case tag(Int, at: NSPoint) case redaction(NSRect) case mosaic(NSRect) - case blur(NSRect) + case spotlight(NSRect) } struct Stroke { var color: NSColor var width: CGFloat + var filled = false /// Stroke presets also select text sizes, matching the annotation toolbar. var fontSize: CGFloat { @@ -32,9 +32,44 @@ struct Item { let shape: Shape let stroke: Stroke + var editableRect: NSRect? { + switch shape { + case .rect(let rect), .ellipse(let rect), .spotlight(let rect): return rect + default: return nil + } + } + + func replacingEditableRect(_ rect: NSRect) -> Item? { + switch shape { + case .rect: return Item(shape: .rect(rect), stroke: stroke) + case .ellipse: return Item(shape: .ellipse(rect), stroke: stroke) + case .spotlight: return Item(shape: .spotlight(rect), stroke: stroke) + default: return nil + } + } + static func qrMask(_ rect: NSRect) -> Item { Item(shape: .redaction(rect.insetBy(dx: -6, dy: -6)), stroke: Stroke(color: .black, width: 0)) } + + var supportsFill: Bool { + switch shape { + case .rect, .ellipse: return true + default: return false + } + } + + /// Opaque covers draw after source-sampling effects so nothing beneath them can show through. + var isMask: Bool { + if case .redaction = shape { return true } + return supportsFill && stroke.filled + } + + /// Ellipses hit-test their outline path; other editable shapes use their bounds. + func contains(_ point: NSPoint) -> Bool { + if case .ellipse(let rect) = shape { return NSBezierPath(ovalIn: rect).contains(point) } + return editableRect?.contains(point) == true + } } /// Draw annotations and export the frozen image without depending on overlay interaction state. @@ -44,8 +79,10 @@ final class AnnotationRenderer { private let scale: CGFloat private let bounds: NSRect private let frozen: NSImage - private lazy var ciContext = CIContext() - private var blurCache: [String: CGImage] = [:] + private var mosaicCache: [String: CGImage] = [:] + /// One random seed per capture keeps preview and export identical while varying between captures. + private let mosaicSeed = UInt64.random(in: .min ... .max) + private static let mosaicBlock: CGFloat = 14 init(image: CGImage, scale: CGFloat, size: NSSize) { cgImage = image @@ -54,7 +91,7 @@ final class AnnotationRenderer { frozen = NSImage(cgImage: image, size: size) } - func clearCache() { blurCache.removeAll() } + func clearCache() { mosaicCache.removeAll() } static func shadowMargin(for shadowSize: CGFloat) -> CGFloat { shadowSize > 0 ? (shadowSize * 2 + shadowOffset(for: shadowSize)).rounded(.up) : 0 @@ -80,16 +117,17 @@ final class AnnotationRenderer { } func drawItems(_ items: [Item], draft: Item? = nil, selection: NSRect) { - for item in items { - if case .redaction = item.shape { continue } + for item in items where !item.isMask { draw(item, cache: true, selection: selection) } - // Draft geometry changes every frame, so it must not grow the blur cache. - if let draft { draw(draft, cache: false, selection: selection) } - // Blur and mosaic sample the frozen source; keep masks above those effects. - for item in items { - if case .redaction = item.shape { draw(item, cache: true, selection: selection) } + // Draft geometry changes every frame, so it must not grow the mosaic cache. + if let draft, !draft.isMask { draw(draft, cache: false, selection: selection) } + drawSpotlights(items + (draft.map { [$0] } ?? []), selection: selection) + // Mosaic samples the frozen source; keep masks above it. + for item in items where item.isMask { + draw(item, cache: true, selection: selection) } + if let draft, draft.isMask { draw(draft, cache: false, selection: selection) } } private func draw(_ item: Item, cache: Bool, selection: NSRect) { @@ -97,9 +135,10 @@ final class AnnotationRenderer { let width = item.stroke.width switch item.shape { case .rect(let r): - stroke(NSBezierPath(rect: r), width: width) + drawClosedShape(NSBezierPath(rect: item.stroke.filled ? deviceAligned(r) : r), + style: item.stroke, selection: selection) case .ellipse(let r): - stroke(NSBezierPath(ovalIn: r), width: width) + drawClosedShape(NSBezierPath(ovalIn: r), style: item.stroke, selection: selection) case .line(let from, let to): let path = NSBezierPath() path.move(to: from) @@ -144,56 +183,127 @@ final class AnnotationRenderer { context.saveGState() context.setBlendMode(.copy) context.setFillColor(NSColor.black.cgColor) - context.fill(r.intersection(selection).intersection(bounds)) + context.fill(deviceAligned(r).intersection(selection).intersection(bounds)) context.restoreGState() case .mosaic(let r): - drawMosaic(in: r, selection: selection) - case .blur(let r): - drawBlur(in: r, cache: cache, selection: selection) + drawMosaic(in: r, cache: cache, selection: selection) + case .spotlight: + break // All spotlight regions share one dimming layer. } } + /// Expands a cover to whole device pixels. A half-pixel edge would blend the source beneath it. + private func deviceAligned(_ rect: NSRect) -> NSRect { + guard let cg = NSGraphicsContext.current?.cgContext else { return rect } + return cg.convertToUserSpace(cg.convertToDeviceSpace(rect).integral) + } + + private func drawClosedShape(_ path: NSBezierPath, style: Stroke, selection: NSRect) { + guard style.filled else { stroke(path, width: style.width); return } + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + NSBezierPath(rect: selection.intersection(bounds)).addClip() + style.color.withAlphaComponent(1).setFill() + path.fill() + } + + private func drawSpotlights(_ items: [Item], selection: NSRect) { + let regions = items.compactMap { item -> NSRect? in + guard case .spotlight(let rect) = item.shape else { return nil } + let clipped = rect.intersection(selection).intersection(bounds) + return clipped.width >= 2 && clipped.height >= 2 ? clipped : nil + } + guard !regions.isEmpty else { return } + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + // Intersect the outside of each region so overlapping highlights stay clear. + for rect in regions { + let mask = NSBezierPath(rect: selection.intersection(bounds)) + let radius = min(8, min(rect.width, rect.height) / 2) + mask.append(NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)) + mask.windingRule = .evenOdd + mask.addClip() + } + NSColor.black.withAlphaComponent(0.5).setFill() + NSBezierPath(rect: selection.intersection(bounds)).fill() + } + private func stroke(_ path: NSBezierPath, width: CGFloat) { path.lineWidth = width path.stroke() } - private func drawMosaic(in rect: NSRect, selection: NSRect) { - guard let (sub, r) = crop(rect, selection: selection), let cg = NSGraphicsContext.current?.cgContext else { return } - let block: CGFloat = 10 - let w = max(1, Int(r.width / block)), h = max(1, Int(r.height / block)) - guard let tiny = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) - else { return } - tiny.interpolationQuality = .high - tiny.draw(sub, in: CGRect(x: 0, y: 0, width: w, height: h)) - guard let small = tiny.makeImage() else { return } - + /// Pixelation keeps only a quantized, seeded-jitter average per block. Exact block averages + /// can be matched against rendered candidates (as Depix does), so blocks stay coarse and noisy. + private func drawMosaic(in rect: NSRect, cache: Bool, selection: NSRect) { + guard let cg = NSGraphicsContext.current?.cgContext, + let (sub, r) = crop(deviceAligned(rect), selection: selection) else { return } + let key = "\(r)" + let image: CGImage + if let hit = mosaicCache[key] { + image = hit + } else { + guard let made = mosaicImage(from: sub, size: r.size) else { return } + if cache { mosaicCache[key] = made } + image = made + } cg.saveGState() cg.interpolationQuality = .none - cg.draw(small, in: r) + cg.draw(image, in: r) cg.restoreGState() } - private func drawBlur(in rect: NSRect, cache: Bool, selection: NSRect) { - guard let (sub, r) = crop(rect, selection: selection), let cg = NSGraphicsContext.current?.cgContext else { return } - let key = "\(r)" - let out: CGImage - if let hit = blurCache[key] { - out = hit - } else { - let extent = CGRect(x: 0, y: 0, width: sub.width, height: sub.height) - - // Extend edge pixels before blurring to avoid a dark border. - let blurred = CIImage(cgImage: sub).clampedToExtent() - .applyingGaussianBlur(sigma: 6 * scale) - .cropped(to: extent) - guard let made = ciContext.createCGImage(blurred, from: extent) else { return } - out = made - if cache { blurCache[key] = made } + private func mosaicImage(from source: CGImage, size: NSSize) -> CGImage? { + let cols = max(1, Int((size.width / Self.mosaicBlock).rounded())) + let rows = max(1, Int((size.height / Self.mosaicBlock).rounded())) + // Downsample to k×k samples per block, then average them: close to an area average + // without visiting every source pixel of a large region. + let k = 4, w = cols * k, h = rows * k + let space = CGColorSpaceCreateDeviceRGB() + let info = CGImageAlphaInfo.premultipliedLast.rawValue + var samples = [UInt8](repeating: 0, count: w * h * 4) + let drawn = samples.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext(data: buffer.baseAddress, width: w, height: h, bitsPerComponent: 8, + bytesPerRow: w * 4, space: space, bitmapInfo: info) else { return false } + context.interpolationQuality = .high + context.draw(source, in: CGRect(x: 0, y: 0, width: w, height: h)) + return true } - cg.draw(out, in: r) + guard drawn else { return nil } + + // Alpha stays 255: a mosaic must never let the source show through. + var blocks = [UInt8](repeating: 255, count: cols * rows * 4) + for by in 0.. Int { + var z = mosaicSeed &+ UInt64(truncatingIfNeeded: x) &* 0x9E37_79B9_7F4A_7C15 + &+ UInt64(truncatingIfNeeded: y) &* 0xD1B5_4A32_D192_ED03 + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + z ^= z >> 31 + return Int(z % 25) - 12 } /// Convert bottom-left view points to top-left frozen-image pixels. diff --git a/Plugins/Screenshot/Sources/CaptureController.swift b/Plugins/Screenshot/Sources/CaptureController.swift index a1dcd1ea6..82d4ec1bf 100644 --- a/Plugins/Screenshot/Sources/CaptureController.swift +++ b/Plugins/Screenshot/Sources/CaptureController.swift @@ -89,8 +89,25 @@ final class CaptureController { private func configure(_ overlay: OverlayWindow, display: CaptureDisplay) { overlay.onComplete = { [weak self] png, mode in guard let self, !finished else { return } - dismiss() - ScreenshotOutput.save(png, mode: mode, environment: environment) + guard mode == .ask else { + dismiss() + ScreenshotOutput.save(png, mode: mode, environment: environment) + return + } + // Keep the selection and annotations until the file is written; cancelling or a write + // failure returns to them. + suspend() + let chosen = ScreenshotOutput.saveAs(png, suggestedName: ScreenshotOutput.suggestedScreenshotName(environment), + environment: environment) { [weak self] written in + guard let self, !finished else { return } + if written { + ScreenshotOutput.copyToPasteboard(png) + dismiss() + } else { + resume() + } + } + if !chosen, !finished { resume() } } overlay.onCancel = { [weak self] in self?.dismiss() } overlay.onRecord = { [weak self] rect in @@ -114,6 +131,27 @@ final class CaptureController { } } + /// Hides the capture surfaces without discarding their session state. + private func suspend() { + pointerTracker?.stop() + for overlay in overlays { overlay.orderOut(nil) } + if cursorPushed { + NSCursor.pop() + cursorPushed = false + } + } + + private func resume() { + NSCursor.crosshair.push() + cursorPushed = true + PluginPresentationSafety.prepareForWindowOrdering() + for overlay in overlays { overlay.orderFrontRegardless() } + let tracker = CapturePointerTracker(overlays: overlays) + pointerTracker = tracker + // Keyboard focus follows the pointer's display, as when capture first appears. + tracker.update() + } + func dismiss() { guard !finished else { return } finished = true diff --git a/Plugins/Screenshot/Sources/CapturePipeline.swift b/Plugins/Screenshot/Sources/CapturePipeline.swift index 746373335..c3e789056 100644 --- a/Plugins/Screenshot/Sources/CapturePipeline.swift +++ b/Plugins/Screenshot/Sources/CapturePipeline.swift @@ -148,6 +148,8 @@ enum CapturePipeline { config.shouldBeOpaque = true config.pixelFormat = kCVPixelFormatType_32BGRA config.scalesToFit = false + // The default also strips shadows and framing from menus and popovers in display captures. + config.ignoreShadowsSingleWindow = false return config } diff --git a/Plugins/Screenshot/Sources/CaptureStatusPanel.swift b/Plugins/Screenshot/Sources/CaptureStatusPanel.swift index 2b3677cfb..a6c2f85fe 100644 --- a/Plugins/Screenshot/Sources/CaptureStatusPanel.swift +++ b/Plugins/Screenshot/Sources/CaptureStatusPanel.swift @@ -8,6 +8,7 @@ final class CaptureStatusPanel: NSPanel { var onCancel: (() -> Void)? private let label = NSTextField(labelWithString: "00:00") private let primary = NSButton() + private var indicator: NSImageView? init(primaryTitle: String, cancelTitle: String? = nil, indicatorColor: NSColor? = nil) { super.init(contentRect: NSRect(x: 0, y: 0, width: 10, height: 10), @@ -23,6 +24,7 @@ final class CaptureStatusPanel: NSPanel { var views: [NSView] = [] if let indicatorColor, let image = NSImage(systemSymbolName: "circle.fill", accessibilityDescription: nil) { let indicator = NSImageView(image: image) + self.indicator = indicator indicator.contentTintColor = indicatorColor indicator.widthAnchor.constraint(equalToConstant: 10).isActive = true indicator.heightAnchor.constraint(equalToConstant: 10).isActive = true @@ -59,6 +61,12 @@ final class CaptureStatusPanel: NSPanel { } } + /// Reuses the visible panel for a follow-up phase, such as processing a finished recording. + func setPrimary(title: String, indicatorColor: NSColor? = nil) { + primary.title = title + if let indicatorColor { indicator?.contentTintColor = indicatorColor } + } + func show(near region: CGRect, displayID: CGDirectDisplayID) { guard let screen = NSScreen.screens.first(where: { ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == displayID diff --git a/Plugins/Screenshot/Sources/Overlay.swift b/Plugins/Screenshot/Sources/Overlay.swift index 0e7ea5803..96ec86154 100644 --- a/Plugins/Screenshot/Sources/Overlay.swift +++ b/Plugins/Screenshot/Sources/Overlay.swift @@ -123,9 +123,9 @@ final class OverlayWindow: NSPanel { } private enum Tool: Int { - case none, rect, ellipse, line, arrow, pen, text, tag, mosaic, blur + case none, rect, ellipse, line, arrow, pen, text, tag, mosaic, spotlight - var usesStroke: Bool { self != .none && self != .mosaic && self != .blur } + var usesStroke: Bool { self != .none && self != .mosaic && self != .spotlight } } private enum Mode: Int { case shot = 0, ocr = 1, scroll = 2, record = 3 } @@ -143,6 +143,8 @@ private enum Drag { case move case resize(Int) case draw + case moveAnnotation(Int) + case resizeAnnotation(item: Int, handle: Int) } enum MagnifierColorFormat: Equatable { @@ -321,6 +323,15 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { private var tool: Tool = .none private var stroke = Stroke(color: .systemRed, width: 2) private var items: [Item] = [] + private var selectedItemIndex: Int? + private var undoHistory: [[Item]] = [] + private var dragItems: [Item]? + private var origAnnotationRect: NSRect = .zero + + private var selectedAnnotationRect: NSRect? { + guard mode == .shot, let selectedItemIndex, items.indices.contains(selectedItemIndex) else { return nil } + return items[selectedItemIndex].editableRect + } private var anchor: NSPoint? private var dragMode: Drag? @@ -339,8 +350,12 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { private var toolButtons: [BarButton] = [] private var colorButtons: [BarButton] = [] private var widthButtons: [BarButton] = [] + private var widthControls: [NSView] = [] + private var fillButton: BarButton! private var mode: Mode = .shot private var recordBar: NSView! + private lazy var autoZoomToggle = NSButton(checkboxWithTitle: environment.string("overlay.record.autoZoom", "自动缩放"), + target: self, action: #selector(toggleAutoZoom)) private var scrollBar: NSView! private var ocrPanel: NSView? private var ocrTextView: NSTextView? @@ -415,6 +430,7 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { radiusRow.isHidden = cornerRadius == 0 && shadowSize == 0 refreshShadowButtons() refreshStyleButtons() + autoZoomToggle.state = environment.recordingAutoZoom ? .on : .off for button in modeButtons { button.isSelectedLook = button.tag == 0 } for button in toolButtons { button.isSelectedLook = false } backdrop?.prepare(image: image, bounds: bounds, scale: scale) @@ -449,6 +465,8 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { [modeBar, recordBar, scrollBar, tipView].forEach { $0?.isHidden = true } toolbar.isHidden = true styleRow.isHidden = true + fillButton.isHidden = true + widthControls.forEach { $0.isHidden = false } setHover(nil) tipButton = nil pointerInside = false @@ -461,6 +479,10 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { penPoints.removeAll() selection = .zero items.removeAll() + selectedItemIndex = nil + undoHistory.removeAll() + dragItems = nil + origAnnotationRect = .zero editing = false toolbarPinned = false modeBarPreferredX = nil @@ -660,7 +682,11 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { let bars: [NSView] = [modeBar, toolbar, recordBar, scrollBar] + (ocrPanel.map { [$0] } ?? []) if bars.contains(where: { !$0.isHidden && $0.frame.contains(p) }) { return .arrow } guard editing, tool == .none else { return .crosshair } - if let i = handleIndex(at: p) { return resizeCursor(for: handleSpots[i]) } + if let rect = selectedAnnotationRect, let i = handleIndex(at: p, in: rect) { + return resizeCursor(for: handleSpots[i]) + } + if editableItemIndex(at: p) != nil { return .openHand } + if let i = handleIndex(at: p, in: selection) { return resizeCursor(for: handleSpots[i]) } return selection.contains(p) ? .openHand : .crosshair } @@ -692,19 +718,28 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { drawItems() NSGraphicsContext.restoreGraphicsState() } - if editing, tool == .none, selectionDirty.intersects(dirtyRect) { drawHandles() } + if editing, tool == .none, selectionDirty.intersects(dirtyRect) { + drawHandles(for: selection) + if let rect = selectedAnnotationRect { + NSColor.controlAccentColor.setStroke() + let outline = NSBezierPath(rect: rect) + outline.lineWidth = 1 + outline.stroke() + drawHandles(for: rect) + } + } } private func pixelColor(at point: NSPoint) -> MagnifierColorValue? { magnifier.color(at: point) } - private func drawHandles() { + private func drawHandles(for rect: NSRect) { let shadow = NSShadow() shadow.shadowBlurRadius = 3 shadow.shadowOffset = NSSize(width: 0, height: -1) shadow.shadowColor = NSColor.black.withAlphaComponent(0.4) - for p in handlePoints() { + for p in handlePoints(for: rect) { let dot = NSBezierPath(ovalIn: NSRect(x: p.x - 5, y: p.y - 5, width: 10, height: 10)) NSGraphicsContext.saveGraphicsState() shadow.set() @@ -746,9 +781,9 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { } } - private func handlePoints() -> [NSPoint] { - handleSpots.map { NSPoint(x: coord($0.x, selection.minX, selection.maxX), - y: coord($0.y, selection.minY, selection.maxY)) } + private func handlePoints(for rect: NSRect) -> [NSPoint] { + handleSpots.map { NSPoint(x: coord($0.x, rect.minX, rect.maxX), + y: coord($0.y, rect.minY, rect.maxY)) } } private func coord(_ edge: Edge, _ lo: CGFloat, _ hi: CGFloat) -> CGFloat { @@ -759,8 +794,8 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { } } - private func handleIndex(at p: NSPoint) -> Int? { - handlePoints().firstIndex { hypot($0.x - p.x, $0.y - p.y) <= 8 } + private func handleIndex(at p: NSPoint, in rect: NSRect) -> Int? { + handlePoints(for: rect).firstIndex { hypot($0.x - p.x, $0.y - p.y) <= 8 } } private func resized(_ r: NSRect, handle: (x: Edge, y: Edge), dx: CGFloat, dy: CGFloat) -> NSRect { @@ -778,13 +813,33 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { return NSRect(x: min(left, right), y: min(bottom, top), width: abs(right - left), height: abs(top - bottom)) } - private func clamped(_ r: NSRect) -> NSRect { + private func clamped(_ r: NSRect, to limits: NSRect) -> NSRect { var r = r - r.origin.x = min(max(0, r.minX), bounds.maxX - r.width) - r.origin.y = min(max(0, r.minY), bounds.maxY - r.height) + r.size.width = min(r.width, limits.width) + r.size.height = min(r.height, limits.height) + r.origin.x = min(max(limits.minX, r.minX), limits.maxX - r.width) + r.origin.y = min(max(limits.minY, r.minY), limits.maxY - r.height) return r } + private func editableItemIndex(at point: NSPoint) -> Int? { + guard mode == .shot, selection.contains(point) else { return nil } + return items.indices.reversed().first { items[$0].contains(point) } + } + + private func appendItem(_ item: Item) { + undoHistory.append(items) + items.append(item) + } + + private func beginAnnotationDrag(_ index: Int, mode: Drag) { + guard let rect = items[index].editableRect else { return } + selectedItemIndex = index + origAnnotationRect = rect + dragItems = items + dragMode = mode + } + override func mouseDown(with event: NSEvent) { onPointerActivity?() commitText() @@ -793,26 +848,38 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { lastMouse = p anchor = p origSelection = selection + dragItems = nil penPoints = [] guard editing else { dragMode = .select; return } switch tool { case .none: - if let i = handleIndex(at: p) { + if let index = selectedItemIndex, let rect = selectedAnnotationRect, + let handle = handleIndex(at: p, in: rect) { + beginAnnotationDrag(index, mode: .resizeAnnotation(item: index, handle: handle)) + } else if let index = editableItemIndex(at: p) { + beginAnnotationDrag(index, mode: .moveAnnotation(index)) + NSCursor.closedHand.set() + } else if let i = handleIndex(at: p, in: selection) { + selectedItemIndex = nil dragMode = .resize(i) } else if selection.contains(p) { + selectedItemIndex = nil dragMode = .move NSCursor.closedHand.set() } else { + selectedItemIndex = nil dragMode = .select } + refreshSelectionStyle() + invalidateAnnotations() case .text: dragMode = nil placeTextField(at: p) case .tag: dragMode = nil let count = items.filter { if case .tag = $0.shape { return true } else { return false } }.count - items.append(Item(shape: .tag(count + 1, at: p), stroke: stroke)) + appendItem(Item(shape: .tag(count + 1, at: p), stroke: stroke)) invalidateAnnotations() default: dragMode = .draw @@ -833,8 +900,18 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { let hadAnnotations = editing || !items.isEmpty || draft != nil switch mode { + case .moveAnnotation(let index): + let rect = clamped(origAnnotationRect.offsetBy(dx: dx, dy: dy), to: selection.intersection(bounds)) + if let item = items[index].replacingEditableRect(rect) { items[index] = item } + case .resizeAnnotation(let index, let handle): + let rect = resized(origAnnotationRect, handle: handleSpots[handle], dx: dx, dy: dy) + .intersection(selection).intersection(bounds) + if rect.width >= 2, rect.height >= 2, let item = items[index].replacingEditableRect(rect) { + items[index] = item + } case .draw: if tool == .pen { + draft = nil // Release the draft's reference so the append can grow in place. penPoints.append(p) draft = Item(shape: .pen(penPoints), stroke: stroke) } else if let shape = makeShape(from: a, to: p) { @@ -843,7 +920,7 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { draft = nil } case .move: - selection = clamped(origSelection.offsetBy(dx: dx, dy: dy)) + selection = clamped(origSelection.offsetBy(dx: dx, dy: dy), to: bounds) layoutBars() case .resize(let i): let r = resized(origSelection, handle: handleSpots[i], dx: dx, dy: dy) @@ -859,6 +936,8 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { toolbarPinned = false hideOcrPanel() items.removeAll() + selectedItemIndex = nil + undoHistory.removeAll() renderer?.clearCache() placeModeBar() } @@ -872,6 +951,7 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { override func mouseUp(with event: NSEvent) { defer { anchor = nil; draft = nil; dragMode = nil + dragItems = nil onPointerActivity?() } let p = convert(event.locationInWindow, from: nil) @@ -880,8 +960,19 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { if lastMouse != p { updateDrag(at: p) } switch mode { + case .moveAnnotation(let index), .resizeAnnotation(let index, _): + if let dragItems, items.indices.contains(index), items[index].editableRect != dragItems[index].editableRect { + undoHistory.append(dragItems) + } case .draw: - if let d = draft { items.append(d) } + if let d = draft { + appendItem(d) + if d.editableRect != nil { + select(tool: .none) + selectedItemIndex = items.count - 1 + refreshSelectionStyle() + } + } case .move: if event.clickCount == 2 { primaryAction() } else if ocrShowing { rerunRecognition() } @@ -934,19 +1025,21 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { switch mode { case .record: place(recordBar) case .scroll: place(scrollBar) - case .shot, .ocr: layoutToolbar() + case .shot: layoutToolbar() + case .ocr: break // The recognition panel follows the selection; the annotation toolbar stays hidden. } } private func makeShape(from a: NSPoint, to p: NSPoint) -> Shape? { - let r = NSRect(x: min(a.x, p.x), y: min(a.y, p.y), width: abs(p.x - a.x), height: abs(p.y - a.y)) + var r = NSRect(x: min(a.x, p.x), y: min(a.y, p.y), width: abs(p.x - a.x), height: abs(p.y - a.y)) + if tool == .rect || tool == .spotlight { r = r.intersection(selection).intersection(bounds) } let big = r.width >= 2 && r.height >= 2 let far = hypot(p.x - a.x, p.y - a.y) >= 4 switch tool { case .rect: return big ? .rect(r) : nil case .ellipse: return big ? .ellipse(r) : nil case .mosaic: return big ? .mosaic(r) : nil - case .blur: return big ? .blur(r) : nil + case .spotlight: return big ? .spotlight(r) : nil case .line: return far ? .line(from: a, to: p) : nil case .arrow: return far ? .arrow(from: a, to: p) : nil case .none, .pen, .text, .tag: return nil @@ -968,11 +1061,20 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { updateMagnifier() return case 123, 124, 125, 126: - guard editing else { return } + guard editing, dragMode == nil else { return } let step: CGFloat = flags.contains(.shift) ? 10 : 1 let delta: [UInt16: (CGFloat, CGFloat)] = [123: (-step, 0), 124: (step, 0), 125: (0, -step), 126: (0, step)] let (dx, dy) = delta[event.keyCode]! - selection = clamped(selection.offsetBy(dx: dx, dy: dy)) + if let index = selectedItemIndex, let rect = selectedAnnotationRect { + let moved = clamped(rect.offsetBy(dx: dx, dy: dy), to: selection.intersection(bounds)) + if moved != rect, let item = items[index].replacingEditableRect(moved) { + undoHistory.append(items) + items[index] = item + } + invalidateAnnotations() + return + } + selection = clamped(selection.offsetBy(dx: dx, dy: dy), to: bounds) layoutBars() if ocrShowing { rerunRecognition() } invalidateAnnotations() @@ -993,7 +1095,7 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { if editing, key == "{" { setShadow(shadowSize - 4); return } if editing, key == "}" { setShadow(shadowSize + 4); return } let shortcuts: [String: Tool] = ["r": .rect, "o": .ellipse, "l": .line, "a": .arrow, "p": .pen, - "t": .text, "n": .tag, "m": .mosaic, "b": .blur] + "t": .text, "n": .tag, "m": .mosaic] if editing, let t = shortcuts[key] { select(tool: t) } else { super.keyDown(with: event) } } @@ -1133,7 +1235,10 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { title: environment.string("overlay.action.cancel", "取消"), action: #selector(cancel) ) - recordBar = CaptureActionBar.make([start, cancel]) + autoZoomToggle.font = PluginTypography.body.nsFont + autoZoomToggle.toolTip = environment.string("settings.autoZoom.description", "点击或滚动时放大该位置,随后恢复全景。在保存的视频中生成,录制时不改变桌面。") + autoZoomToggle.setAccessibilityIdentifier("screenshot.record.autoZoom") + recordBar = CaptureActionBar.make([autoZoomToggle, start, cancel]) recordBar.isHidden = true addSubview(recordBar) } @@ -1157,6 +1262,10 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { return button } + @objc private func toggleAutoZoom() { + environment.recordingAutoZoom = autoZoomToggle.state == .on + } + @objc private func startRecord() { guard #available(macOS 15, *) else { environment.showToast(environment.string("overlay.record.unavailable", "录屏需要 macOS 15 或更高版本")) @@ -1198,13 +1307,15 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { ] for (symbol, t, tip) in drawTools { let button = iconButton(symbol, tip: tip, action: #selector(pickTool(_:)), tag: t.rawValue) + button.setAccessibilityIdentifier("screenshot.tool.\(t)") toolButtons.append(button) annotate.append(button) } annotate.append(separator()) for (symbol, t, tip) in [("checkerboard.rectangle", Tool.mosaic, environment.string("overlay.tool.mosaic", "马赛克 (M)")), - ("aqi.medium", .blur, environment.string("overlay.tool.blur", "模糊 (B)"))] { + ("light.max", Tool.spotlight, environment.string("overlay.tool.spotlight", "聚焦"))] { let button = iconButton(symbol, tip: tip, action: #selector(pickTool(_:)), tag: t.rawValue) + button.setAccessibilityIdentifier("screenshot.tool.\(t)") toolButtons.append(button) annotate.append(button) } @@ -1244,15 +1355,23 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { colorButtons.append(button) style.append(button) } - style.append(separator()) + let widthDivider = separator() + style.append(widthDivider) + widthControls.append(widthDivider) for (i, width) in Self.widths.enumerated() { let button = styledButton(image: dotImage(color: .labelColor, diameter: 4 + width * 1.5), tip: [environment.string("overlay.stroke.thin", "细"), environment.string("overlay.stroke.medium", "中"), environment.string("overlay.stroke.thick", "粗")][i], action: #selector(pickWidth(_:)), tag: i) widthButtons.append(button) + widthControls.append(button) style.append(button) } + fillButton = iconButton("paintbrush.pointed.fill", tip: environment.string("overlay.tool.fill", "填充"), + action: #selector(toggleFill)) + fillButton.setAccessibilityIdentifier("screenshot.style.fill") + fillButton.isHidden = true + style.append(fillButton) styleRow = capsule(style) styleRow.isHidden = true toolbar.addArrangedSubview(styleRow) @@ -1419,8 +1538,9 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { private func select(tool picked: Tool) { commitText() tool = tool == picked ? .none : picked + selectedItemIndex = nil for b in toolButtons { b.isSelectedLook = b.tag == tool.rawValue } - styleRow.isHidden = !tool.usesStroke + refreshStyleControls() layoutToolbar() invalidateAnnotations() } @@ -1432,6 +1552,47 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { textField?.textColor = stroke.color } + private var selectedShape: Item? { + guard mode == .shot, let index = selectedItemIndex, items.indices.contains(index), + items[index].supportsFill else { return nil } + return items[index] + } + + /// The style row edits the next shape, or the selected rectangle or ellipse. + private func refreshStyleControls() { + let canFill = tool == .rect || tool == .ellipse || selectedShape != nil + styleRow.isHidden = !tool.usesStroke && selectedShape == nil + fillButton.isHidden = !canFill + fillButton.isSelectedLook = canFill && stroke.filled + fillButton.setAccessibilityValue(stroke.filled ? "1" : "0") + // A filled shape has no outline width. + for control in widthControls { control.isHidden = canFill && stroke.filled } + } + + private func refreshSelectionStyle() { + if let item = selectedShape { stroke = item.stroke } + refreshStyleButtons() + refreshStyleControls() + if editing, !toolbar.isHidden { layoutToolbar() } + } + + private func applySelectedStyle() { + if let item = selectedShape, let index = selectedItemIndex { + undoHistory.append(items) + items[index] = Item(shape: item.shape, stroke: stroke) + } + refreshStyleButtons() + refreshStyleControls() + layoutToolbar() + invalidateAnnotations() + } + + @objc private func toggleFill() { + guard tool == .rect || tool == .ellipse || selectedShape != nil else { return } + stroke.filled.toggle() + applySelectedStyle() + } + @objc private func pickTool(_ sender: NSButton) { select(tool: Tool(rawValue: sender.tag) ?? .none) } @objc private func toggleRadiusRow() { @@ -1471,17 +1632,24 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { @objc private func pickColor(_ sender: NSButton) { stroke.color = Self.palette[sender.tag] - refreshStyleButtons() + applySelectedStyle() } @objc private func pickWidth(_ sender: NSButton) { stroke.width = Self.widths[sender.tag] - refreshStyleButtons() + applySelectedStyle() } @objc private func undoLast() { + guard dragMode == nil else { return } commitText() - _ = items.popLast() + guard let previous = undoHistory.popLast() else { return } + items = previous + if let index = selectedItemIndex, + !items.indices.contains(index) || items[index].editableRect == nil { + selectedItemIndex = nil + } + refreshSelectionStyle() invalidateAnnotations() } @@ -1568,7 +1736,9 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { pasteboard.clearContents() pasteboard.setString(text, forType: .string) showOcrPanel(title: title, text: text, - status: environment.format("overlay.recognition.copiedCount", "%d %@ · 已复制到剪贴板", lines.count, unit), + // Blank lines inserted to preserve layout are not recognized lines. + status: environment.format("overlay.recognition.copiedCount", "%d %@ · 已复制到剪贴板", + lines.lazy.filter { !$0.isEmpty }.count, unit), busy: false) } @@ -1579,7 +1749,7 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { } @objc private func maskQR() { - items.append(contentsOf: qrRects.map(Item.qrMask)) + for rect in qrRects { appendItem(Item.qrMask(rect)) } qrRects = [] hideOcrPanel() layoutToolbar() @@ -1604,7 +1774,7 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { maskButton.isHidden = qrRects.isEmpty openButton.isHidden = resultURL == nil - ocrScrollWidth?.constant = min(max(selection.width, 320), 640) + ocrScrollWidth?.constant = min(max(selection.width, 320), 640, bounds.width - 48) panel.layoutSubtreeIfNeeded() if let manager = textView.layoutManager, let container = textView.textContainer { manager.ensureLayout(for: container) @@ -1715,7 +1885,9 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { @objc private func closeOcr() { hideOcrPanel() - layoutToolbar() + // Text recognition chosen from the mode bar ends with its panel. Recognition started from + // the screenshot toolbar returns to annotation. + if mode == .ocr { onCancel?() } else { layoutToolbar() } } @objc private func finishOcr() { @@ -1746,7 +1918,7 @@ final class OverlayView: NSView, NSTextFieldDelegate, NSTextViewDelegate { textField = nil let string = field.stringValue.trimmingCharacters(in: .whitespaces) if !string.isEmpty { - items.append(Item(shape: .text(string, at: field.frame.origin), stroke: stroke)) + appendItem(Item(shape: .text(string, at: field.frame.origin), stroke: stroke)) } field.removeFromSuperview() window?.makeFirstResponder(self) diff --git a/Plugins/Screenshot/Sources/Pin.swift b/Plugins/Screenshot/Sources/Pin.swift index f724296fe..1951b7ea5 100644 --- a/Plugins/Screenshot/Sources/Pin.swift +++ b/Plugins/Screenshot/Sources/Pin.swift @@ -5,7 +5,9 @@ import MacToolsPluginKit /// A movable image window retained by the plugin environment until closed or disabled. @MainActor final class PinWindow: NSWindow { - init(png: Data, at frame: NSRect, name: String, environment: ScreenshotEnvironment) { + /// A result pin, such as an assembled scrolling screenshot, closes after it is copied or saved. + /// A deliberately pinned image stays on screen after either action. + init(png: Data, at frame: NSRect, name: String, isResult: Bool = false, environment: ScreenshotEnvironment) { super.init(contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false) level = .floating hasShadow = true @@ -13,45 +15,224 @@ final class PinWindow: NSWindow { backgroundColor = .clear isReleasedWhenClosed = false collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - let view = PinView(png: png, size: frame.size, name: name, environment: environment) + let view = PinView(png: png, size: frame.size, name: name, isResult: isResult, environment: environment) contentView = view makeFirstResponder(view) } override var canBecomeKey: Bool { true } + + override func close() { + (contentView as? PinView)?.dismissControls() + super.close() + } } @MainActor -private final class PinView: NSImageView { +final class PinView: NSImageView { private let png: Data private let name: String + private let isResult: Bool private let baseSize: NSSize private weak var environment: ScreenshotEnvironment? private var zoom: CGFloat = 1 private var localeSubscription: AnyCancellable? + private var controls: NSView! + private var controlButtons: [(button: BarButton, key: String, fallback: String)] = [] + private var controlsPanel: PinControlsPanel? + private var controlsTrackingArea: NSTrackingArea? + private var hideTimer: Timer? - init(png: Data, size: NSSize, name: String, environment: ScreenshotEnvironment) { + init(png: Data, size: NSSize, name: String, isResult: Bool, environment: ScreenshotEnvironment) { self.png = png self.name = name + self.isResult = isResult self.environment = environment baseSize = size super.init(frame: NSRect(origin: .zero, size: size)) image = NSImage(data: png) imageScaling = .scaleAxesIndependently autoresizingMask = [.width, .height] - refreshTooltip() + buildControls() + refreshLocalizedText() localeSubscription = PluginRuntimeLocalization.source.$revision.dropFirst().sink { [weak self] _ in - DispatchQueue.main.async { [weak self] in self?.refreshTooltip() } + DispatchQueue.main.async { [weak self] in self?.refreshLocalizedText() } } } required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } override var acceptsFirstResponder: Bool { true } - private func refreshTooltip() { + private func refreshLocalizedText() { toolTip = environment?.string("pin.tooltip", "拖动移动 · 滚轮缩放 · 双击/Esc 关闭 · ⌘C 复制 · ⌘S 保存") + for control in controlButtons { + let text = environment?.string(control.key, control.fallback) ?? control.fallback + control.button.toolTip = text + control.button.setAccessibilityLabel(text) + } + } + + // MARK: Hover controls + + /// Copy, save, and close actions appear on hover so they do not cover the image otherwise. + private func buildControls() { + let specs: [(String, String, String, Selector)] = [ + ("doc.on.doc", "pin.copy", "复制 (⌘C)", #selector(copyImage)), + ("arrow.down.to.line", "pin.save", "另存为 (⌘S)", #selector(saveImage)), + ("xmark", "pin.close", "关闭 (Esc)", #selector(closeWindow)), + ] + var buttons: [NSView] = [] + for (symbol, key, fallback, action) in specs { + let image = (NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + ?? NSImage(systemSymbolName: "questionmark", accessibilityDescription: nil)!) + .withSymbolConfiguration(.init(pointSize: 15, weight: .medium))! + let button = BarButton(image: image, target: self, action: action) + button.isBordered = false + (button.cell as? NSButtonCell)?.highlightsBy = [] + button.imagePosition = .imageOnly + button.usesCircularBackground = true + button.backgroundVerticalInset = 6 + button.setAccessibilityIdentifier(key) + button.widthAnchor.constraint(equalToConstant: 32).isActive = true + button.heightAnchor.constraint(equalToConstant: 36).isActive = true + button.refresh() + button.addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, userInfo: ["button": button])) + controlButtons.append((button, key, fallback)) + buttons.append(button) + } + let stack = NSStackView(views: buttons) + stack.orientation = .horizontal + stack.spacing = 2 + stack.edgeInsets = NSEdgeInsets(top: 4, left: 2, bottom: 4, right: 2) + controls = Glass.wrap(stack, radius: stack.fittingSize.height / 2, blending: .behindWindow) + controls.isHidden = true + addSubview(controls) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + NotificationCenter.default.removeObserver(self) + guard let window else { dismissControls(); return } + for name in [NSWindow.didMoveNotification, NSWindow.didResizeNotification, NSWindow.didChangeScreenNotification] { + NotificationCenter.default.addObserver(self, selector: #selector(positionControls), name: name, object: window) + } + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + for area in trackingAreas where area.userInfo == nil { removeTrackingArea(area) } + addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, userInfo: nil)) + } + + override func layout() { + super.layout() + positionControls() + } + + /// Controls sit inside the image when they fit; small images show them in an adjacent panel instead. + @objc private func positionControls() { + let size = controls.fittingSize + if bounds.width >= size.width, bounds.height >= size.height + 10 { + if controls.superview !== self { + if let tracking = controlsTrackingArea { controls.removeTrackingArea(tracking) } + controlsTrackingArea = nil + controlsPanel?.contentView = nil + controls.removeFromSuperview() + addSubview(controls) + } + hideControlsPanel() + controls.frame = NSRect(x: (bounds.width - size.width) / 2, y: 10, width: size.width, height: size.height) + } else if let window { + let panel = controlsPanel ?? PinControlsPanel() + controlsPanel = panel + if panel.contentView !== controls { + controls.removeFromSuperview() + panel.contentView = controls + let tracking = NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, userInfo: nil) + controls.addTrackingArea(tracking) + controlsTrackingArea = tracking + } + let screen = window.screen ?? NSScreen.screens.first { $0.frame.intersects(window.frame) } + let area = screen?.visibleFrame ?? window.frame.insetBy(dx: -size.width, dy: -size.height) + panel.setFrame(Self.controlsFrame(adjacentTo: window.frame, size: size, within: area), display: true) + if !controls.isHidden, window.isVisible, panel.parent !== window { + window.addChildWindow(panel, ordered: .above) + } + } + } + + /// Chooses the screen-clamped side position that overlaps the image least. + static func controlsFrame(adjacentTo image: NSRect, size: NSSize, within screen: NSRect) -> NSRect { + let origins = [NSPoint(x: image.midX - size.width / 2, y: image.minY - size.height), + NSPoint(x: image.midX - size.width / 2, y: image.maxY), + NSPoint(x: image.maxX, y: image.midY - size.height / 2), + NSPoint(x: image.minX - size.width, y: image.midY - size.height / 2)] + let candidates = origins.map { point in + NSRect(x: max(screen.minX, min(point.x, screen.maxX - size.width)), + y: max(screen.minY, min(point.y, screen.maxY - size.height)), + width: size.width, height: size.height) + } + return candidates.min { lhs, rhs in + let left = lhs.intersection(image), right = rhs.intersection(image) + return left.width * left.height < right.width * right.height + }! } + private func hideControlsPanel() { + guard let panel = controlsPanel else { return } + panel.parent?.removeChildWindow(panel) + panel.orderOut(nil) + } + + func dismissControls() { + hideTimer?.invalidate() + hideTimer = nil + controls.isHidden = true + for control in controlButtons { control.button.isHovered = false } + hideControlsPanel() + } + + override func mouseEntered(with event: NSEvent) { + if let button = event.trackingArea?.userInfo?["button"] as? BarButton { + button.isHovered = true + return + } + setControls(visible: true) + } + + override func mouseExited(with event: NSEvent) { + if let button = event.trackingArea?.userInfo?["button"] as? BarButton { + button.isHovered = false + return + } + setControls(visible: false) + } + + private func setControls(visible: Bool) { + hideTimer?.invalidate() + hideTimer = nil + if visible { + controls.isHidden = false + positionControls() + return + } + // Give the pointer time to cross into the adjacent control panel. + hideTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + let pointer = NSEvent.mouseLocation + if self.window?.frame.contains(pointer) == true { return } + if let panel = self.controlsPanel, panel.isVisible, panel.frame.contains(pointer) { return } + self.dismissControls() + } + } + } + + // MARK: Actions + override func mouseDown(with event: NSEvent) { if event.clickCount == 2 { window?.close(); return } window?.performDrag(with: event) @@ -66,15 +247,46 @@ private final class PinView: NSImageView { width: size.width, height: size.height), display: true) } + @objc private func copyImage() { + ScreenshotOutput.copyToPasteboard(png) + if let environment { environment.showToast(environment.string("output.copied", "已复制到剪贴板")) } + if isResult { closeWindow() } + } + + @objc private func saveImage() { + guard let environment else { return } + // A result closes only after its file is written; cancellation or a write failure keeps it for another try. + ScreenshotOutput.saveAs(png, suggestedName: name, environment: environment) { [weak self] written in + guard let self, written, isResult else { return } + closeWindow() + } + } + + @objc private func closeWindow() { window?.close() } + override func keyDown(with event: NSEvent) { switch (event.keyCode, event.modifierFlags.contains(.command), event.charactersIgnoringModifiers) { - case (53, _, _): window?.close() - case (_, true, "c"): - ScreenshotOutput.copyToPasteboard(png) - if let environment { environment.showToast(environment.string("output.copied", "已复制到剪贴板")) } - case (_, true, "s"): - if let environment { ScreenshotOutput.saveAs(png, suggestedName: name, environment: environment) } + case (53, _, _): closeWindow() + case (_, true, "c"): copyImage() + case (_, true, "s"): saveImage() default: super.keyDown(with: event) } } } + +/// Hosts pin controls beside an image that is too small to contain them. +private final class PinControlsPanel: NSPanel { + init() { + super.init(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + isOpaque = false + backgroundColor = .clear + hasShadow = true + isReleasedWhenClosed = false + hidesOnDeactivate = false + level = .floating + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + } + + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} diff --git a/Plugins/Screenshot/Sources/Recognition.swift b/Plugins/Screenshot/Sources/Recognition.swift index 9e27ca741..a0961f16b 100644 --- a/Plugins/Screenshot/Sources/Recognition.swift +++ b/Plugins/Screenshot/Sources/Recognition.swift @@ -81,10 +81,11 @@ actor VisionRecognition { request.usesLanguageCorrection = true request.recognitionLanguages = Self.languages try handler.perform([request]) - let lines = (request.results ?? []) - .sorted { $0.boundingBox.minY > $1.boundingBox.minY } - .compactMap { $0.topCandidates(1).first?.string } - result = RecognitionResult(lines: lines) + let fragments = (request.results ?? []).compactMap { observation -> TextLayout.Fragment? in + guard let text = observation.topCandidates(1).first?.string else { return nil } + return TextLayout.Fragment(text: text, box: observation.boundingBox) + } + result = RecognitionResult(lines: TextLayout.lines(from: fragments)) case .barcode: let request = VNDetectBarcodesRequest() try handler.perform([request]) @@ -97,13 +98,14 @@ actor VisionRecognition { } func warmUp() async { - guard let ctx = CGContext(data: nil, width: 120, height: 40, bitsPerComponent: 8, bytesPerRow: 0, + guard let ctx = CGContext(data: nil, width: 200, height: 40, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return } ctx.setFillColor(CGColor.white) - ctx.fill(CGRect(x: 0, y: 0, width: 120, height: 40)) - let line = CTLineCreateWithAttributedString(NSAttributedString(string: "Warm up", attributes: [ + ctx.fill(CGRect(x: 0, y: 0, width: 200, height: 40)) + // Include Chinese so the first Chinese recognition does not load its model on demand. + let line = CTLineCreateWithAttributedString(NSAttributedString(string: "预热 Warm up", attributes: [ .font: NSFont.systemFont(ofSize: 20), .foregroundColor: NSColor.black, ])) ctx.textPosition = CGPoint(x: 6, y: 12) diff --git a/Plugins/Screenshot/Sources/Record.swift b/Plugins/Screenshot/Sources/Record.swift index 71c64e243..752c9a70f 100644 --- a/Plugins/Screenshot/Sources/Record.swift +++ b/Plugins/Screenshot/Sources/Record.swift @@ -6,12 +6,22 @@ import VideoToolbox @MainActor final class Recorder: NSObject, SCRecordingOutputDelegate { var onFinish: ((Result) -> Void)? + /// Capture has ended and the auto-zoom movie is being rendered; other capture modes may run. + var onProcessing: (() -> Void)? let region: CaptureRegion + let autoZoom: Bool + private(set) var isProcessing = false private let environment: ScreenshotEnvironment private let panel: CaptureStatusPanel private let outline: CaptureRegionOutlineWindow private let controls: CaptureControls + /// The file written by capture. With auto zoom, this is the recoverable original. private let url: URL + /// The published movie. + private let movieURL: URL + private let clock = RecordingClock() + private var activity: RecordingActivity? + private var processingTask: Task? private var session: CaptureStreamSession? private var output: SCRecordingOutput? private var startupTask: Task? @@ -19,10 +29,27 @@ final class Recorder: NSObject, SCRecordingOutputDelegate { private var startedAt: ContinuousClock.Instant? private lazy var lifecycle = RecordingLifecycle { [weak self] in self?.session?.stop() } - init(region: CaptureRegion, environment: ScreenshotEnvironment) { + init(region: CaptureRegion, environment: ScreenshotEnvironment, autoZoom: Bool? = nil) { self.region = region self.environment = environment - url = environment.fileURL(prefix: environment.string("record.filename", "录屏"), ext: "mov") + let autoZoom = autoZoom ?? environment.recordingAutoZoom + self.autoZoom = autoZoom + // Use a name whose movie and original are both unused, so neither can replace an existing file. + let preferred = environment.fileURL(prefix: environment.string("record.filename", "录屏"), ext: "mov") + let suffix = environment.string("record.originalSuffix", "原始") + func original(_ movie: URL) -> URL { + movie.deletingLastPathComponent() + .appendingPathComponent("\(movie.deletingPathExtension().lastPathComponent) \(suffix).mov") + } + let files = FileManager.default + var index = 1 + var candidate = preferred + while files.fileExists(atPath: candidate.path) || files.fileExists(atPath: original(candidate).path) { + index += 1 + candidate = ScreenshotImageEncoder.numbered(preferred, index) + } + movieURL = candidate + url = autoZoom ? original(candidate) : candidate panel = CaptureStatusPanel(primaryTitle: environment.string("record.stop", "停止"), indicatorColor: .systemRed) outline = CaptureRegionOutlineWindow(region: region) controls = CaptureControls([outline, panel]) @@ -74,6 +101,15 @@ final class Recorder: NSObject, SCRecordingOutputDelegate { let output = SCRecordingOutput(configuration: recording, delegate: self) self.output = output try session.stream.addRecordingOutput(output) + if autoZoom { + // The first encoded frame's clock aligns pointer events with the movie timeline. + try session.stream.addStreamOutput(clock, type: .screen, + sampleHandlerQueue: DispatchQueue(label: "cc.ggbond.mactools.screenshot.recording-clock")) + let activity = RecordingActivity(displayBounds: CGDisplayBounds(region.display.id), + sourceRect: region.sourceRect) + self.activity = activity + guard activity.start() else { throw RecordingZoomError.pointerMonitorUnavailable } + } outline.show() panel.show(near: region.globalRect, displayID: region.display.id) session.start() @@ -92,6 +128,8 @@ final class Recorder: NSObject, SCRecordingOutputDelegate { } func cancel() { + // Cancelling processing keeps the finished original. + processingTask?.cancel() startupTask?.cancel() if session == nil { lifecycle.failBeforeCapture(CancellationError()) } else { lifecycle.cancel() } @@ -137,10 +175,73 @@ final class Recorder: NSObject, SCRecordingOutputDelegate { private func finish(_ result: Result) { timer?.invalidate() timer = nil - controls.hide() - if let output, let session { try? session.stream.removeRecordingOutput(output) } + activity?.stop() + if let output, let session { + try? session.stream.removeRecordingOutput(output) + if autoZoom { try? session.stream.removeStreamOutput(clock, type: .screen) } + } output = nil session = nil + let activity = activity + self.activity = nil + guard case .success(let source) = result, let activity else { + controls.hide() + deliver(result) + return + } + // Without a frame clock no event can be aligned, so the original becomes the movie unchanged. + let events = clock.startTime.map { activity.snapshot(startTime: $0) } ?? [] + process(source, events: events) + } + + private func process(_ source: URL, events: [RecordingZoomEvent]) { + isProcessing = true + outline.orderOut(nil) + panel.setPrimary(title: environment.string("record.keepOriginal", "保留原片"), indicatorColor: .controlAccentColor) + panel.onPrimary = { [weak self] in self?.processingTask?.cancel() } + updateProcessing(0) + onProcessing?() + let destination = movieURL + let report: @Sendable (Double) -> Void = { [weak self] progress in + Task { @MainActor [weak self] in self?.updateProcessing(progress) } + } + processingTask = Task { [weak self] in + let result: Result + do { + result = .success(try await Self.render(source: source, destination: destination, events: events, + progress: report)) + } catch { + result = .failure(RecordingProcessingError(original: source, cause: error)) + } + guard let self else { return } + processingTask = nil + isProcessing = false + controls.hide() + deliver(result) + } + } + + private nonisolated static func render(source: URL, destination: URL, events: [RecordingZoomEvent], + progress: @escaping @Sendable (Double) -> Void) async throws -> URL { + // A clip without a deliberate interaction keeps the original bytes without re-encoding. + guard events.contains(where: { $0.kind == .click || $0.kind == .scroll }) else { + try FileManager.default.moveItem(at: source, to: destination) + return destination + } + try await RecordingZoomExporter.export(source: source, destination: destination, events: events, progress: progress) + try Task.checkCancellation() + // The complete movie exists before the recoverable original is removed. + try? FileManager.default.removeItem(at: source) + return destination + } + + private func updateProcessing(_ progress: Double) { + guard isProcessing else { return } + let percent = Int(min(1, max(0, progress)) * 100) + panel.update(environment.format("record.processing", "正在生成自动缩放视频 %@", "\(percent)%")) + } + + private func deliver(_ result: Result) { let completion = onFinish onFinish = nil completion?(result) @@ -161,6 +262,14 @@ final class Recorder: NSObject, SCRecordingOutputDelegate { enum RecordingError: Error { case unsupportedResolution } +enum RecordingZoomError: Error { case pointerMonitorUnavailable } + +/// Processing failed or was cancelled after capture; the original movie remains at `original`. +struct RecordingProcessingError: Error { + let original: URL + let cause: Error +} + enum RecordingEncoding { static func codec(width: Int, height: Int) throws -> AVVideoCodecType { if supports(kCMVideoCodecType_H264, width: width, height: height) { return .h264 } diff --git a/Plugins/Screenshot/Sources/RecordingActivity.swift b/Plugins/Screenshot/Sources/RecordingActivity.swift new file mode 100644 index 000000000..923e98701 --- /dev/null +++ b/Plugins/Screenshot/Sources/RecordingActivity.swift @@ -0,0 +1,187 @@ +import AppKit +import ScreenCaptureKit + +/// Observes the first encoded frame's clock without retaining any captured pixels. +final class RecordingClock: NSObject, SCStreamOutput, @unchecked Sendable { + private let lock = NSLock() + private var firstTime: Double? + + var startTime: Double? { lock.withLock { firstTime } } + + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType) { + guard type == .screen, sampleBuffer.isValid, + let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) + as? [[SCStreamFrameInfo: Any]], + let status = attachments.first?[.status] as? Int, + status == SCFrameStatus.complete.rawValue else { return } + let host = CMClockGetHostTimeClock() + let time: Double + if let captureClock = stream.synchronizationClock { + time = CMSyncConvertTime(sampleBuffer.presentationTimeStamp, from: captureClock, to: host).seconds + } else if let ticks = attachments.first?[.displayTime] as? UInt64 { + time = CMClockMakeHostTimeFromSystemUnits(ticks).seconds + } else { + return + } + guard time.isFinite else { return } + lock.withLock { if firstTime == nil { firstTime = time } } + } +} + +/// Converts global Quartz points to recording-relative points, independent of Retina scale. +struct RecordingCoordinates { + let rect: CGRect + + init(displayBounds: CGRect, sourceRect: CGRect) { + rect = sourceRect.offsetBy(dx: displayBounds.minX, dy: displayBounds.minY) + } + + func normalize(_ point: CGPoint) -> CGPoint? { + guard rect.width > 0, rect.height > 0, point.x.isFinite, point.y.isFinite, + rect.contains(point) else { return nil } + return CGPoint(x: (point.x - rect.minX) / rect.width, + y: (point.y - rect.minY) / rect.height) + } +} + +/// Stores sparse mouse metadata only. It never intercepts input or records keyboard text. +@MainActor +final class RecordingActivity { + private let coordinates: RecordingCoordinates + private var monitor: Any? + private var localMonitor: Any? + private var activationObserver: NSObjectProtocol? + private var events: [RecordingZoomEvent] = [] + private var lastMoveTime = -Double.infinity + private var lastScrollTime = -Double.infinity + private var lastPoint: CGPoint? + private var lastWindow: CGWindowID? + private var wasInside = false + private var suppressClickUntil = -Double.infinity + + init(displayBounds: CGRect, sourceRect: CGRect) { + coordinates = RecordingCoordinates(displayBounds: displayBounds, sourceRect: sourceRect) + } + + func start() -> Bool { + let mask: NSEvent.EventTypeMask = [.leftMouseDown, .leftMouseUp, .rightMouseDown, + .rightMouseUp, .otherMouseDown, .otherMouseUp, .mouseMoved, + .leftMouseDragged, .rightMouseDragged, .otherMouseDragged, .scrollWheel] + // AppKit delivers both monitors on the main thread. + monitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in + MainActor.assumeIsolated { self?.receive(event) } + } + // MacTools controls are excluded from the video and must not create zoom targets. + localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in + MainActor.assumeIsolated { self?.reset(at: event.timestamp) } + return event + } + activationObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + let time = CMClockGetTime(CMClockGetHostTimeClock()).seconds + self?.reset(at: time) + self?.suppressClickUntil = time + 0.3 + self?.lastWindow = nil + } + } + return monitor != nil + } + + func stop() { + if let monitor { NSEvent.removeMonitor(monitor) } + if let localMonitor { NSEvent.removeMonitor(localMonitor) } + if let activationObserver { NSWorkspace.shared.notificationCenter.removeObserver(activationObserver) } + monitor = nil + localMonitor = nil + activationObserver = nil + } + + func snapshot(startTime: Double) -> [RecordingZoomEvent] { + Self.relativeEvents(events, startTime: startTime) + } + + static func relativeEvents(_ events: [RecordingZoomEvent], startTime: Double) -> [RecordingZoomEvent] { + guard startTime.isFinite else { return [] } + return events.compactMap { event in + let time = event.time - startTime + guard time.isFinite, time >= 0 else { return nil } + return RecordingZoomEvent(time: time, kind: event.kind, point: event.point) + } + } + + func receive(_ event: NSEvent) { + guard let cgEvent = event.cgEvent else { return } + let time = event.timestamp + guard time.isFinite else { return } + guard let point = coordinates.normalize(cgEvent.location) else { + if wasInside { reset(at: time) } + wasInside = false + return + } + wasInside = true + let kind: RecordingZoomEvent.Kind + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + receiveClick(at: time, point: point, window: Self.window(for: cgEvent)) + return + case .leftMouseUp, .rightMouseUp, .otherMouseUp: kind = .release + case .scrollWheel: + guard abs(event.scrollingDeltaX) + abs(event.scrollingDeltaY) > 0 else { return } + // Momentum does not count as another deliberate operation. + if event.momentumPhase != [] { return } + guard time - lastScrollTime >= 0.05 else { return } + lastScrollTime = time + kind = .scroll + default: + guard time - lastMoveTime >= 0.05 else { return } + if let lastPoint, hypot(point.x - lastPoint.x, point.y - lastPoint.y) < 0.003 { return } + lastMoveTime = time + lastPoint = point + kind = .move + } + events.append(RecordingZoomEvent(time: time, kind: kind, point: point)) + } + + func receiveClick(at time: Double, point: CGPoint, window: CGWindowID?) { + let changedWindow = lastWindow != nil && window != nil && window != lastWindow + if let window { lastWindow = window } + guard time >= suppressClickUntil, !changedWindow else { reset(at: time); return } + events.append(RecordingZoomEvent(time: time, kind: .click, point: point)) + } + + private func reset(at time: Double) { + guard events.last?.kind != .reset else { return } + events.append(RecordingZoomEvent(time: time, kind: .reset, point: nil)) + } + + private static func window(for event: CGEvent) -> CGWindowID? { + let handling = event.getIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent) + let underPointer = event.getIntegerValueField(.mouseEventWindowUnderMousePointer) + let eventWindow = CGWindowID(exactly: handling > 0 ? handling : underPointer) ?? 0 + // 事件已带窗口号时只查这一个窗口,不必在每次点击时枚举全部窗口 + let windows = (eventWindow != 0 + ? CGWindowListCopyWindowInfo(.optionIncludingWindow, eventWindow) + : CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID)) + as? [[String: Any]] ?? [] + return ordinaryWindow(at: event.location, eventWindow: eventWindow, windows: windows) + } + + static func ordinaryWindow(at point: CGPoint, eventWindow: CGWindowID = 0, + windows: [[String: Any]]) -> CGWindowID? { + // Do not look through menus or popovers to an unrelated window behind them. + let hit = windows.first { window in + if eventWindow != 0 { + return (window[kCGWindowNumber as String] as? NSNumber)?.uint32Value == eventWindow + } + guard ((window[kCGWindowAlpha as String] as? Double) ?? 1) > 0, + let bounds = window[kCGWindowBounds as String] as? [String: Any], + let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary) else { return false } + return rect.contains(point) + } + guard let hit, (hit[kCGWindowLayer as String] as? Int) == 0 else { return nil } + return (hit[kCGWindowNumber as String] as? NSNumber)?.uint32Value + } +} diff --git a/Plugins/Screenshot/Sources/RecordingZoom.swift b/Plugins/Screenshot/Sources/RecordingZoom.swift new file mode 100644 index 000000000..9ef95f884 --- /dev/null +++ b/Plugins/Screenshot/Sources/RecordingZoom.swift @@ -0,0 +1,212 @@ +import CoreGraphics +import Foundation + +struct RecordingZoomEvent: Sendable { + enum Kind: Sendable { case click, move, release, scroll, reset } + + let time: Double + let kind: Kind + /// Normalized coordinates with a top-left origin, relative to the recorded area. + let point: CGPoint? + + init(time: Double, kind: Kind, point: CGPoint? = nil) { + self.time = time + self.kind = kind + self.point = point + } +} + +/// An immutable camera path, independent of capture and export queues. +struct RecordingZoomPlan: Sendable { + private static let full = CGRect(x: 0, y: 0, width: 1, height: 1) + private static let zoomIn = 0.35 + private static let zoomOut = 0.45 + private static let idle = 2.0 + private static let focusedSize = 1.0 / 1.6 + private static let smoothingStep = 0.025 + private static let settlingTime = smoothingStep * 4 + + private struct Keyframe: Sendable { + let time: Double + let end: Double + let source: CGRect + let target: CGRect + let followsCursor: Bool + } + + private var keyframes: [Keyframe] = [] + private let duration: Double + + var hasZoom: Bool { + keyframes.contains { $0.source != Self.full || $0.target != Self.full } + } + + init(events: [RecordingZoomEvent], duration: Double) { + self.duration = duration.isFinite ? max(0, duration) : 0 + guard self.duration > 0 else { return } + + // Preserve input order for a press/release or reset occurring at the same timestamp. + let ordered = events.enumerated().filter { + $0.element.time.isFinite && $0.element.time >= 0 && $0.element.time < self.duration + }.sorted { + $0.element.time == $1.element.time ? $0.offset < $1.offset : $0.element.time < $1.element.time + } + var focus: CGRect? + var pressed = false + var idleAt: Double? + var lastInteraction = 0.0 + var followAfter = 0.0 + var focusReadyAt = 0.0 + + for (_, event) in ordered { + let active = focus != nil && (pressed || event.time < (idleAt ?? .infinity)) + switch event.kind { + case .click, .scroll: + guard let point = Self.validPoint(event.point) else { continue } + // Avoid a zoom pulse with no time to enter and leave near the end of a clip. + guard active || self.duration - event.time >= Self.zoomIn + Self.zoomOut + Self.settlingTime else { continue } + let next = Self.focused(around: point) + let current = plannedCrop(at: event.time) + if active, let idleAt { removeTransitions(from: idleAt) } + let far = active && current.width < 0.95 && + max(abs(next.midX - current.midX), abs(next.midY - current.midY)) > 0.3 + if active, next == focus { + // Repeated scrolling/clicks refresh dwell without restarting the camera's entry. + } else if far { + transition(to: Self.full, at: event.time, over: Self.zoomOut) + transition(to: next, at: event.time + Self.zoomOut, over: Self.zoomIn) + followAfter = event.time + Self.zoomOut + focusReadyAt = followAfter + Self.zoomIn + } else { + if !active || event.time >= focusReadyAt { focusReadyAt = event.time + Self.zoomIn } + transition(to: next, at: event.time, over: focusReadyAt - event.time) + followAfter = event.time + } + focus = next + pressed = pressed || event.kind == .click + lastInteraction = event.time + idleAt = pressed ? nil : event.time + Self.idle + if let idleAt { transition(to: Self.full, at: idleAt, over: Self.zoomOut) } + + case .move: + guard active, event.time >= followAfter, let point = Self.validPoint(event.point), + let previous = focus else { continue } + let tolerance = previous.width * 0.12 + let dx = point.x - previous.midX + let dy = point.y - previous.midY + let center = CGPoint( + x: previous.midX + Self.outsideTolerance(dx, tolerance), + y: previous.midY + Self.outsideTolerance(dy, tolerance)) + let next = Self.focused(around: center) + guard next != previous else { continue } + let length = event.time < focusReadyAt ? focusReadyAt - event.time : 0.15 + transition(to: next, at: event.time, over: length, followsCursor: true) + focus = next + // Passive motion follows the camera but cannot postpone the deliberate-activity timeout. + if let idleAt { transition(to: Self.full, at: idleAt, over: Self.zoomOut) } + + case .release: + guard active, pressed else { continue } + pressed = false + lastInteraction = event.time + idleAt = event.time + Self.idle + transition(to: Self.full, at: event.time + Self.idle, over: Self.zoomOut) + + case .reset: + transition(to: Self.full, at: event.time, over: Self.zoomOut) + focus = nil + pressed = false + idleAt = nil + } + } + + // Use a quiet tail to finish at overview, without zooming out during a held drag. + if focus != nil, !pressed, self.duration - lastInteraction >= Self.zoomOut + Self.settlingTime + 0.15 { + let start = self.duration - Self.zoomOut - Self.settlingTime + if plannedCrop(at: start) != Self.full { + transition(to: Self.full, at: start, over: Self.zoomOut) + } + } + } + + func crop(at time: Double) -> CGRect { + guard time.isFinite, time > 0, !keyframes.isEmpty else { return Self.full } + let position = min(time, duration) / Self.smoothingStep + let index = floor(position) + let u = CGFloat(position - index) + let u2 = u * u + let u3 = u2 * u + // A causal cubic B-spline keeps velocity and acceleration continuous across input events. + // Its positive weights preserve the capture boundary, with only 50 ms of camera delay. + let weights: [CGFloat] = [pow(1 - u, 3) / 6, (3 * u3 - 6 * u2 + 4) / 6, + (-3 * u3 + 3 * u2 + 3 * u + 1) / 6, u3 / 6] + let samples = (0..<4).map { plannedCrop(at: (index - 3 + Double($0)) * Self.smoothingStep) } + let base = samples[0] + var x = base.minX + var y = base.minY + var size = base.width + for i in 1..<4 { + x += (samples[i].minX - base.minX) * weights[i] + y += (samples[i].minY - base.minY) * weights[i] + size += (samples[i].width - base.width) * weights[i] + } + size = min(1, max(Self.focusedSize, size)) + return CGRect(x: min(1 - size, max(0, x)), y: min(1 - size, max(0, y)), width: size, height: size) + } + + private func plannedCrop(at time: Double) -> CGRect { + guard time.isFinite, time > 0, !keyframes.isEmpty else { return Self.full } + let time = min(time, duration) + var lower = 0 + var upper = keyframes.count + while lower < upper { + let middle = (lower + upper) / 2 + if keyframes[middle].time <= time { lower = middle + 1 } + else { upper = middle } + } + guard lower > 0 else { return Self.full } + let frame = keyframes[lower - 1] + guard time < frame.end else { return frame.target } + let progress = CGFloat(max(0, min(1, (time - frame.time) / (frame.end - frame.time)))) + // Cursor following needs an immediate response; restarting an ease-in at 20 Hz loses the cursor. + let weight = frame.followsCursor ? progress * (2 - progress) : progress * progress * (3 - 2 * progress) + return CGRect( + x: frame.source.minX + (frame.target.minX - frame.source.minX) * weight, + y: frame.source.minY + (frame.target.minY - frame.source.minY) * weight, + width: frame.source.width + (frame.target.width - frame.source.width) * weight, + height: frame.source.height + (frame.target.height - frame.source.height) * weight) + } + + private mutating func transition(to target: CGRect, at time: Double, over length: Double, + followsCursor: Bool = false) { + guard time < duration else { return } + let source = plannedCrop(at: time) + // Keep the previous transition intact before its interruption, including its original easing. + removeTransitions(from: time) + // A stop/reset near the last frame must not compress the normal animation into a flash. + keyframes.append(Keyframe(time: time, end: time + length, + source: source, target: target, followsCursor: followsCursor)) + } + + private mutating func removeTransitions(from time: Double) { + while let last = keyframes.last, last.time >= time { keyframes.removeLast() } + } + + private static func validPoint(_ point: CGPoint?) -> CGPoint? { + guard let point, point.x.isFinite, point.y.isFinite, + (0...1).contains(point.x), (0...1).contains(point.y) else { return nil } + return point + } + + private static func focused(around point: CGPoint) -> CGRect { + let size = focusedSize + return CGRect(x: min(1 - size, max(0, point.x - size / 2)), + y: min(1 - size, max(0, point.y - size / 2)), width: size, height: size) + } + + private static func outsideTolerance(_ distance: CGFloat, _ tolerance: CGFloat) -> CGFloat { + if distance > tolerance { return distance - tolerance } + if distance < -tolerance { return distance + tolerance } + return 0 + } +} diff --git a/Plugins/Screenshot/Sources/RecordingZoomExporter.swift b/Plugins/Screenshot/Sources/RecordingZoomExporter.swift new file mode 100644 index 000000000..368306054 --- /dev/null +++ b/Plugins/Screenshot/Sources/RecordingZoomExporter.swift @@ -0,0 +1,83 @@ +import AVFoundation +import CoreImage +import Foundation + +/// Render a second movie without modifying the captured source or an existing destination. +@available(macOS 15, *) +enum RecordingZoomExporter { + static func export(source: URL, destination: URL, events: [RecordingZoomEvent], + progress: (@Sendable (Double) -> Void)? = nil) async throws { + try Task.checkCancellation() + let files = FileManager.default + guard source.isFileURL, destination.isFileURL, + source.standardizedFileURL.resolvingSymlinksInPath() != destination.standardizedFileURL.resolvingSymlinksInPath() + else { throw RecordingZoomExportError.invalidDestination } + guard !files.fileExists(atPath: destination.path) else { throw RecordingZoomExportError.destinationExists } + + let asset = AVURLAsset(url: source) + let duration = try await asset.load(.duration).seconds + guard duration.isFinite, duration > 0, + try await !asset.loadTracks(withMediaType: .video).isEmpty + else { throw RecordingZoomExportError.noVideo } + let plan = RecordingZoomPlan(events: events, duration: duration) + let partial = destination.deletingLastPathComponent() + .appendingPathComponent(".mactools-zoom-\(UUID().uuidString).mov") + guard !files.fileExists(atPath: partial.path) else { throw RecordingZoomExportError.destinationExists } + defer { try? files.removeItem(at: partial) } + if !plan.hasZoom { + progress?(0) + try Task.checkCancellation() + try files.copyItem(at: source, to: partial) + try Task.checkCancellation() + try files.moveItem(at: partial, to: destination) + progress?(1) + return + } + let composition = try await AVMutableVideoComposition.videoComposition(with: asset, applyingCIFiltersWithHandler: { request in + let frame = CGRect(origin: .zero, size: request.renderSize) + let normalized = plan.crop(at: request.compositionTime.seconds) + let crop = CGRect(x: normalized.minX * frame.width, + y: (1 - normalized.maxY) * frame.height, + width: normalized.width * frame.width, + height: normalized.height * frame.height) + // AVFoundation has already applied the track's display transform. + // Clamp before scaling so fractional edge samples cannot expose transparent borders. + let image = request.sourceImage.clampedToExtent() + .transformed(by: CGAffineTransform(translationX: -crop.minX, y: -crop.minY)) + .transformed(by: CGAffineTransform(scaleX: frame.width / crop.width, y: frame.height / crop.height)) + .cropped(to: frame) + request.finish(with: image, context: nil) + }) + // Static screens can have sparse source frames. Keep camera motion independent of those updates. + composition.sourceTrackIDForFrameTiming = kCMPersistentTrackID_Invalid + composition.frameDuration = CMTime(value: 1, timescale: 60) + guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) + else { throw RecordingZoomExportError.unsupportedMovie } + session.videoComposition = composition + + let updates = session.states(updateInterval: 0.1) + let observer = progress.map { report in + Task { + for await state in updates { + if Task.isCancelled { break } + if case .exporting(let value) = state { report(value.fractionCompleted) } + } + } + } + defer { observer?.cancel() } + progress?(0) + try Task.checkCancellation() + try await session.export(to: partial, as: .mov) + try Task.checkCancellation() + // Same-directory publication is atomic; moveItem refuses to replace an existing file. + try files.moveItem(at: partial, to: destination) + progress?(1) + } +} + +enum RecordingZoomExportError: Error { + case invalidDestination + case destinationExists + case noVideo + case unsupportedMovie +} diff --git a/Plugins/Screenshot/Sources/ScreenshotCoordinator.swift b/Plugins/Screenshot/Sources/ScreenshotCoordinator.swift index fd3feb4f8..87ae1a446 100644 --- a/Plugins/Screenshot/Sources/ScreenshotCoordinator.swift +++ b/Plugins/Screenshot/Sources/ScreenshotCoordinator.swift @@ -13,9 +13,14 @@ final class ScreenshotCoordinator { private let overlayPool: CaptureOverlayPool private var controller: CaptureController? private var recorder: AnyObject? + /// A recorder whose capture has ended while its auto-zoom movie renders. It does not block other modes. + private var processingRecorder: AnyObject? private var scrollSession: ScrollSession? private var exportTask: Task? private var generation: UInt64 = 0 + /// Changes only when the plugin cancels all work. A recording can finish while a later screenshot + /// is in progress, so its messages must not depend on `generation`. + private(set) var sessionToken: UInt64 = 0 init(environment: ScreenshotEnvironment) { self.environment = environment @@ -62,13 +67,17 @@ final class ScreenshotCoordinator { func cancel() { generation &+= 1 + sessionToken &+= 1 exportTask?.cancel() exportTask = nil controller?.onFinish = nil controller?.dismiss() controller = nil scrollSession?.cancel() - if #available(macOS 15, *), let recorder = recorder as? Recorder { recorder.cancel() } + if #available(macOS 15, *) { + (recorder as? Recorder)?.cancel() + (processingRecorder as? Recorder)?.cancel() + } environment.closeAll() overlayPool.release() onStateChange?() @@ -79,30 +88,58 @@ final class ScreenshotCoordinator { report(environment.string("record.requiresNewerSystem", "录屏需要 macOS 15 或更高版本")) return } - let requestGeneration = generation + guard processingRecorder == nil else { + report(environment.string("record.processingBusy", "正在生成自动缩放视频,请稍后再录制")) + return + } + let requestSession = sessionToken let session = Recorder(region: request, environment: environment) recorder = session - session.onFinish = { [weak self, weak session] result in + session.onProcessing = { [weak self, weak session] in guard let self, let session, recorder === session else { return } recorder = nil + processingRecorder = session onStateChange?() - guard generation == requestGeneration else { return } - switch result { - case .success(let url): - let folderName = FileManager.default.displayName(atPath: url.deletingLastPathComponent().path) - environment.showToast(environment.format("record.saved", "录屏已保存到「%@」", folderName)) - case .failure(let error): - guard !(error is CancellationError) else { return } - let message = error is RecordingError - ? environment.string("record.unsupportedResolution", "当前区域超出硬件编码能力,请缩小录制范围") - : environment.captureErrorDescription(error) - report(environment.format("record.failed", "录屏失败:%@", message)) - } + } + session.onFinish = { [weak self, weak session] result in + guard let self, let session else { return } + if recorder === session { recorder = nil } + else if processingRecorder === session { processingRecorder = nil } + else { return } + onStateChange?() + recordingFinished(result, session: requestSession) } onStateChange?() session.start() } + /// Reports a finished recording unless the plugin cancelled its session. + func recordingFinished(_ result: Result, session: UInt64) { + guard session == sessionToken else { return } + switch result { + case .success(let url): + let folderName = FileManager.default.displayName(atPath: url.deletingLastPathComponent().path) + environment.showToast(environment.format("record.saved", "录屏已保存到「%@」", folderName)) + case .failure(let error as RecordingProcessingError): + let name = error.original.lastPathComponent + environment.showToast(error.cause is CancellationError + ? environment.format("record.processingCancelled", "已保留原始录像:%@", name) + : environment.format("record.processingFailed", "自动缩放生成失败,已保留原始录像:%@", name)) + case .failure(let error): + guard !(error is CancellationError) else { return } + let message: String + switch error { + case is RecordingError: + message = environment.string("record.unsupportedResolution", "当前区域超出硬件编码能力,请缩小录制范围") + case is RecordingZoomError: + message = environment.string("record.pointerMonitorUnavailable", "无法读取鼠标操作,请重试或关闭自动缩放") + default: + message = environment.captureErrorDescription(error) + } + report(environment.format("record.failed", "录屏失败:%@", message)) + } + } + private func startScroll(_ request: CaptureRegion) { let requestGeneration = generation let session = ScrollSession(region: request, environment: environment) diff --git a/Plugins/Screenshot/Sources/ScreenshotEnvironment.swift b/Plugins/Screenshot/Sources/ScreenshotEnvironment.swift index ef2e866c4..a5b20624d 100644 --- a/Plugins/Screenshot/Sources/ScreenshotEnvironment.swift +++ b/Plugins/Screenshot/Sources/ScreenshotEnvironment.swift @@ -37,6 +37,12 @@ final class ScreenshotEnvironment { } } + /// Recording auto zoom is on by default; the record bar and settings share this preference. + var recordingAutoZoom: Bool { + get { storage.object(forKey: "recordingAutoZoom") as? Bool ?? true } + set { storage.set(newValue, forKey: "recordingAutoZoom") } + } + var saveFolderDisplayName: String { FileManager.default.displayName(atPath: saveFolder.path) } func string(_ key: String, _ defaultValue: String) -> String { @@ -74,9 +80,9 @@ final class ScreenshotEnvironment { saveFolder.appendingPathComponent("\(prefix) \(stamp()).\(ext)") } - func pin(_ png: Data, at frame: NSRect, bakedShadow: Bool, name: String) { + func pin(_ png: Data, at frame: NSRect, bakedShadow: Bool, name: String, isResult: Bool = false) { pins.removeAll { !$0.isVisible } - let window = PinWindow(png: png, at: frame, name: name, environment: self) + let window = PinWindow(png: png, at: frame, name: name, isResult: isResult, environment: self) window.hasShadow = !bakedShadow pins.append(window) PluginPresentationSafety.prepareForWindowOrdering(window) diff --git a/Plugins/Screenshot/Sources/ScreenshotImageEncoder.swift b/Plugins/Screenshot/Sources/ScreenshotImageEncoder.swift index c96a6099b..d5630b352 100644 --- a/Plugins/Screenshot/Sources/ScreenshotImageEncoder.swift +++ b/Plugins/Screenshot/Sources/ScreenshotImageEncoder.swift @@ -49,6 +49,37 @@ enum ScreenshotImageEncoder { try data.write(to: url, options: .atomic) }.value } + + /// Publishes a complete file under an unused name instead of replacing an existing file. + static func writeNew(_ data: Data, preferred url: URL) async throws -> URL { + try await Task.detached(priority: .userInitiated) { + try Task.checkCancellation() + let files = FileManager.default + let folder = url.deletingLastPathComponent() + try files.createDirectory(at: folder, withIntermediateDirectories: true) + let partial = folder.appendingPathComponent(".mactools-screenshot-\(UUID().uuidString).tmp") + defer { try? files.removeItem(at: partial) } + try data.write(to: partial, options: .withoutOverwriting) + var index = 1 + while true { + let candidate = numbered(url, index) + do { + // moveItem never replaces an existing destination, so a concurrent writer cannot be lost. + try files.moveItem(at: partial, to: candidate) + return candidate + } catch CocoaError.fileWriteFileExists { + index += 1 + } + } + }.value + } + + /// Candidate names for an occupied file: the original name, then " 2", " 3", and so on. + static func numbered(_ url: URL, _ index: Int) -> URL { + guard index > 1 else { return url } + return url.deletingLastPathComponent().appendingPathComponent( + "\(url.deletingPathExtension().lastPathComponent) \(index).\(url.pathExtension)") + } } /// Bound concurrent full-image encodings, including rapid clipboard replacements. diff --git a/Plugins/Screenshot/Sources/ScreenshotOutput.swift b/Plugins/Screenshot/Sources/ScreenshotOutput.swift index 1762fbabe..15c1fd702 100644 --- a/Plugins/Screenshot/Sources/ScreenshotOutput.swift +++ b/Plugins/Screenshot/Sources/ScreenshotOutput.swift @@ -26,16 +26,17 @@ enum ScreenshotOutput { } static func save(_ png: Data, mode: SaveMode, environment: ScreenshotEnvironment) { - copyToPasteboard(png) switch mode { case .clipboard: + copyToPasteboard(png) environment.showToast(environment.string("output.copied", "已复制到剪贴板")) case .folder: + copyToPasteboard(png) let url = environment.fileURL(prefix: environment.string("output.screenshotName", "截图"), ext: "png") let generation = environment.presentationGeneration Task { do { - try await ScreenshotImageEncoder.write(png, to: url) + _ = try await ScreenshotImageEncoder.writeNew(png, preferred: url) guard generation == environment.presentationGeneration else { return } environment.showToast(environment.format("output.savedAndCopied", "已保存到「%@」,并复制到剪贴板", environment.saveFolderDisplayName)) } catch { @@ -44,11 +45,22 @@ enum ScreenshotOutput { } } case .ask: - saveAs(png, suggestedName: "\(environment.string("output.screenshotName", "截图")) \(environment.stamp()).png", environment: environment) + if saveAs(png, suggestedName: suggestedScreenshotName(environment), environment: environment) { + copyToPasteboard(png) + } } } - static func saveAs(_ png: Data, suggestedName: String, environment: ScreenshotEnvironment) { + static func suggestedScreenshotName(_ environment: ScreenshotEnvironment) -> String { + "\(environment.string("output.screenshotName", "截图")) \(environment.stamp()).png" + } + + /// Returns whether the user chose a destination; cancelling must not end the caller's workflow. + /// `completion` reports whether the file was actually written, so callers can keep their content + /// available for another attempt after a write failure. + @discardableResult + static func saveAs(_ png: Data, suggestedName: String, environment: ScreenshotEnvironment, + completion: (@MainActor (Bool) -> Void)? = nil) -> Bool { let panel = NSSavePanel() panel.directoryURL = environment.saveFolder panel.nameFieldStringValue = suggestedName @@ -57,18 +69,25 @@ enum ScreenshotOutput { defer { if environment.savePanel === panel { environment.savePanel = nil } } PluginPresentationSafety.prepareForWindowOrdering(panel) NSApp.activate(ignoringOtherApps: true) - guard panel.runModal() == .OK, let url = panel.url else { return } + guard panel.runModal() == .OK, let url = panel.url else { return false } let generation = environment.presentationGeneration Task { + let written: Bool do { try await ScreenshotImageEncoder.write(png, to: url) - guard generation == environment.presentationGeneration else { return } - environment.showToast(environment.format("output.saved", "已保存到「%@」", url.deletingLastPathComponent().lastPathComponent)) + written = true + if generation == environment.presentationGeneration { + environment.showToast(environment.format("output.saved", "已保存到「%@」", url.deletingLastPathComponent().lastPathComponent)) + } } catch { - guard generation == environment.presentationGeneration else { return } - environment.showToast(environment.format("output.saveFailed", "保存失败:%@", error.localizedDescription)) + written = false + if generation == environment.presentationGeneration { + environment.showToast(environment.format("output.saveFailed", "保存失败:%@", error.localizedDescription)) + } } + completion?(written) } + return true } static func finishLong(_ image: CGImage, scale: CGFloat, environment: ScreenshotEnvironment) async { @@ -87,7 +106,8 @@ enum ScreenshotOutput { environment.pin(png, at: NSRect(x: room.midX - size.width / 2, y: room.midY - size.height / 2, width: size.width, height: size.height), bakedShadow: false, - name: "\(environment.string("output.longScreenshotName", "长截图")) \(environment.stamp()).png") + name: "\(environment.string("output.longScreenshotName", "长截图")) \(environment.stamp()).png", + isResult: true) environment.showToast(environment.string("output.longCopiedAndPinned", "已复制到剪贴板,并钉在屏幕上 · ⌘S 保存")) } } diff --git a/Plugins/Screenshot/Sources/ScreenshotPlugin.swift b/Plugins/Screenshot/Sources/ScreenshotPlugin.swift index 2d133a2a4..06b2276a9 100644 --- a/Plugins/Screenshot/Sources/ScreenshotPlugin.swift +++ b/Plugins/Screenshot/Sources/ScreenshotPlugin.swift @@ -35,6 +35,7 @@ final class ScreenshotPlugin: MacToolsPlugin, PluginActionProviding, PluginActio static let execute = "execute" static let permission = "screen-recording" static let folder = "save-folder" + static let autoZoom = "recording-auto-zoom" } var metadata: PluginMetadata { @@ -173,6 +174,18 @@ final class ScreenshotPlugin: MacToolsPlugin, PluginActionProviding, PluginActio control: .action(title: environment.string("settings.folder.choose", "选择文件夹…"), role: .normal) )] ), + PluginSettingsSection( + id: "recording", + title: environment.string("settings.recording.title", "录屏"), + systemImage: "record.circle", + rows: [PluginSettingsRow( + id: ID.autoZoom, + title: environment.string("settings.autoZoom.title", "自动缩放"), + description: environment.string("settings.autoZoom.description", "点击或滚动时放大该位置,随后恢复全景。在保存的视频中生成,录制时不改变桌面。"), + systemImage: "plus.magnifyingglass", + control: .toggle(isOn: environment.recordingAutoZoom) + )] + ), ]) } @@ -287,6 +300,11 @@ final class ScreenshotPlugin: MacToolsPlugin, PluginActionProviding, PluginActio } func handleSettingsAction(_ action: PluginSettingsAction) { + if case .setBoolean(ID.autoZoom, let isOn) = action { + environment.recordingAutoZoom = isOn + onStateChange?() + return + } guard isActive, case .invoke(ID.folder) = action, let folder = folderPicker(environment.saveFolder), isActive, folder.isFileURL else { return } environment.saveFolder = folder diff --git a/Plugins/Screenshot/Sources/ScrollStitcher.swift b/Plugins/Screenshot/Sources/ScrollStitcher.swift index a2f022b0b..e4681413b 100644 --- a/Plugins/Screenshot/Sources/ScrollStitcher.swift +++ b/Plugins/Screenshot/Sources/ScrollStitcher.swift @@ -154,7 +154,13 @@ final class Stitcher { let e = error(dy, step: 1) if e < bestErr { bestErr = e; best = dy } } - guard bestErr <= 6 else { return nil } + // Capture noise raises every score, so an absolute limit alone rejects real matches. + // Also accept a match that is clearly better than a typical unrelated alignment. + let finite = scores.filter(\.isFinite).sorted() + let baseline = finite.isEmpty ? 0 : finite[finite.count / 2] + guard bestErr <= 6 || (baseline > 1 && bestErr < baseline * 0.7) else { return nil } + // The footer check scales with the matched noise floor, staying at 6 for clean frames. + let tolerance = max(6, bestErr * 2) if best > 0 { // Repeated or featureless content cannot establish a unique displacement. if scores.enumerated().contains(where: { @@ -169,7 +175,7 @@ final class Stitcher { samples += 1 } } - guard samples > 0, Double(tailError) / Double(samples) <= 6 else { return nil } + guard samples > 0, Double(tailError) / Double(samples) <= tolerance else { return nil } } return best } diff --git a/Plugins/Screenshot/Sources/TextLayout.swift b/Plugins/Screenshot/Sources/TextLayout.swift new file mode 100644 index 000000000..c8122d9a8 --- /dev/null +++ b/Plugins/Screenshot/Sources/TextLayout.swift @@ -0,0 +1,114 @@ +import CoreGraphics + +/// Rebuilds reading layout from Vision's scattered text fragments. Joining fragments by vertical order alone +/// loses indentation, side-by-side columns, and paragraph gaps. A clear vertical gutter splits columns first; +/// each column then groups fragments into visual rows and places them at absolute character columns. +enum TextLayout { + struct Fragment { + let text: String + /// Vision's normalized rectangle with a bottom-left origin. + let box: CGRect + } + + static func lines(from fragments: [Fragment]) -> [String] { + guard !fragments.isEmpty else { return [] } + let (rows, bands) = group(fragments) + // The median glyph width falls between half- and full-width characters in mixed CJK and Latin text. + let charWidth = max(median(fragments.compactMap { + $0.text.isEmpty ? nil : $0.box.width / CGFloat($0.text.count) + }), 1e-6) + // Each column recurses with its own glyph width and left edge, so the right column is not indented + // by the left column's offset. + if let (left, right) = columnSplit(fragments, bands: bands, charWidth: charWidth) { + return lines(from: left) + [""] + lines(from: right) + } + + // Calibrate blank lines against the median row pitch. Vision boxes hug glyphs, so box height would + // misread normal line spacing as paragraph breaks. + let pitch = max(median(zip(bands.dropLast(), bands.dropFirst()).map { $0.midY - $1.midY }), 1e-6) + let left = bands.map(\.minX).min() ?? 0 + + var out: [String] = [] + for (i, row) in rows.enumerated() { + // A clearly larger step is a blank line or paragraph gap; cap large whitespace at two lines. + if i > 0 { + let step = bands[i - 1].midY - bands[i].midY + if step > pitch * 1.6 { + out += Array(repeating: "", count: min(2, max(1, Int((step / pitch).rounded()) - 1))) + } + } + // Place each fragment at its absolute column instead of accumulating gaps, + // so table cells that share a left edge line up. + var text = "" + for fragment in row.sorted(by: { $0.box.minX < $1.box.minX }) { + let column = spaces(fragment.box.minX - left, charWidth) + if text.count < column { + text += String(repeating: " ", count: column - text.count) + } else if !text.isEmpty { + text += " " + } + text += fragment.text + } + out.append(text) + } + return out + } + + /// Fragments overlapping vertically by at least half belong to one visual row. + private static func group(_ fragments: [Fragment]) -> ([[Fragment]], [CGRect]) { + var rows: [[Fragment]] = [] + var bands: [CGRect] = [] + for fragment in fragments.sorted(by: { $0.box.midY > $1.box.midY }) { + if let band = bands.last, overlaps(band, fragment.box) { + rows[rows.count - 1].append(fragment) + bands[bands.count - 1] = band.union(fragment.box) + } else { + rows.append([fragment]) + bands.append(fragment.box) + } + } + return (rows, bands) + } + + private static func overlaps(_ a: CGRect, _ b: CGRect) -> Bool { + max(0, min(a.maxY, b.maxY) - max(a.minY, b.minY)) >= 0.5 * min(a.height, b.height) + } + + /// Finds a vertical gutter separating two columns. All three conditions must hold, preferring no split + /// over reading a table column by column: the gutter is wider than word spacing, each side has enough + /// rows, and the rows on both sides do not line up. Table cells share rows, so the last check rejects them. + private static func columnSplit(_ fragments: [Fragment], bands: [CGRect], + charWidth: CGFloat) -> ([Fragment], [Fragment])? { + guard fragments.count >= 8 else { return nil } + let bounds = bands.dropFirst().reduce(bands[0]) { $0.union($1) } + let minWidth = max(3 * charWidth, 0.04 * bounds.width) + + var cursor = bounds.minX + var candidates: [(CGFloat, CGFloat)] = [] + for box in fragments.map(\.box).sorted(by: { $0.minX < $1.minX }) { + if box.minX - cursor >= minWidth { candidates.append((cursor, box.minX)) } + cursor = max(cursor, box.maxX) + } + + for (lo, hi) in candidates.sorted(by: { $1.1 - $1.0 < $0.1 - $0.0 }) { + let left = fragments.filter { $0.box.maxX <= lo } + let right = fragments.filter { $0.box.minX >= hi } + guard left.count + right.count == fragments.count else { continue } + let leftBands = group(left).1, rightBands = group(right).1 + guard leftBands.count >= 4, rightBands.count >= 4 else { continue } + let paired = leftBands.filter { l in rightBands.contains { overlaps(l, $0) } }.count + if Double(paired) / Double(leftBands.count) < 0.8 { return (left, right) } + } + return nil + } + + private static func spaces(_ gap: CGFloat, _ charWidth: CGFloat) -> Int { + // Cap malformed boxes so they cannot produce thousands of spaces. + min(200, max(0, Int((gap / charWidth).rounded()))) + } + + private static func median(_ values: [CGFloat]) -> CGFloat { + guard !values.isEmpty else { return 0 } + return values.sorted()[values.count / 2] + } +} diff --git a/Plugins/Screenshot/Tests/AnnotationEditingTests.swift b/Plugins/Screenshot/Tests/AnnotationEditingTests.swift new file mode 100644 index 000000000..5447382cd --- /dev/null +++ b/Plugins/Screenshot/Tests/AnnotationEditingTests.swift @@ -0,0 +1,221 @@ +import AppKit +import MacToolsPluginKit +import XCTest +@testable import ScreenshotPlugin + +@MainActor +final class ScreenshotAnnotationEditingTests: XCTestCase { + func testRectangleCanBeReselectedMovedResizedAndUndoneWithoutExportingHandles() async throws { + let editor = try Editor() + defer { editor.close() } + let original = NSRect(x: 200, y: 200, width: 120, height: 80) + try editor.draw("rect", original) + try editor.drag(from: NSPoint(x: 260, y: 240), to: NSPoint(x: 300, y: 260), attemptUndoDuringDrag: true) + let moved = original.offsetBy(dx: 40, dy: 20) + try await assertExport(editor, items: [item(.rect(moved))]) + + try editor.drag(from: NSPoint(x: moved.maxX, y: moved.maxY), to: NSPoint(x: 400, y: 340)) + try await assertExport(editor, items: [item(.rect(NSRect(x: 240, y: 220, width: 160, height: 120)))]) + editor.view.undo(nil) + try await assertExport(editor, items: [item(.rect(moved))]) + editor.view.undo(nil) + try await assertExport(editor, items: [item(.rect(original))]) + editor.view.undo(nil) + try await assertExport(editor, items: []) + } + + func testDrawingCreatesNestedItemsAndSelectionTargetsTheTopmostEditableItem() async throws { + let editor = try Editor() + defer { editor.close() } + let outer = NSRect(x: 180, y: 180, width: 220, height: 180) + let light = NSRect(x: 230, y: 210, width: 100, height: 80) + try editor.draw("rect", outer) + try editor.draw("spotlight", light) + try editor.drag(from: NSPoint(x: 280, y: 250), to: NSPoint(x: 320, y: 270)) + let movedLight = light.offsetBy(dx: 40, dy: 20) + let nested = NSRect(x: 290, y: 250, width: 30, height: 30) + try editor.draw("rect", nested) + + try await assertExport(editor, items: [item(.rect(outer)), item(.spotlight(movedLight)), item(.rect(nested))]) + editor.view.undo(nil) + editor.view.undo(nil) + try await assertExport(editor, items: [item(.rect(outer)), item(.spotlight(light))]) + } + + func testSpotlightClampsToCropAndArrowMovementAndNoOpDragsHaveCorrectUndo() async throws { + let editor = try Editor() + defer { editor.close() } + let original = NSRect(x: 200, y: 200, width: 120, height: 80) + try editor.draw("spotlight", original) + try editor.drag(from: NSPoint(x: 260, y: 240), to: NSPoint(x: -100, y: -100)) + let corner = NSRect(x: 100, y: 100, width: 120, height: 80) + try await assertExport(editor, items: [item(.spotlight(corner))]) + try editor.drag(from: NSPoint(x: 160, y: 140), to: NSPoint(x: -100, y: -100)) + editor.view.undo(nil) + try await assertExport(editor, items: [item(.spotlight(original))]) + + try editor.key(124, flags: .shift) + try await assertExport(editor, items: [item(.spotlight(original.offsetBy(dx: 10, dy: 0)))]) + editor.view.undo(nil) + try editor.drag(from: NSPoint(x: original.maxX, y: original.maxY), to: NSPoint(x: 1200, y: 900)) + let expanded = NSRect(x: 200, y: 200, width: editor.crop.maxX - 200, height: editor.crop.maxY - 200) + try await assertExport(editor, items: [item(.spotlight(expanded))]) + } + + func testRecroppingAndReusingOverlayDiscardAnnotationHistory() async throws { + let editor = try Editor() + defer { editor.close() } + try editor.draw("rect", NSRect(x: 200, y: 200, width: 120, height: 80)) + try editor.drag(from: NSPoint(x: 50, y: 50), to: NSPoint(x: 700, y: 500)) + editor.view.undo(nil) + try await assertExport(editor, items: [], crop: NSRect(x: 50, y: 50, width: 650, height: 450)) + + editor.view.prepare(frozen: editor.image, windows: [], quick: false) + try editor.drag(from: editor.crop.origin, to: NSPoint(x: editor.crop.maxX, y: editor.crop.maxY)) + editor.view.undo(nil) + try await assertExport(editor, items: []) + } + + func testFillCanBeSetBeforeDrawingAndToggledOnTheSelectedShapeWithUndo() async throws { + let editor = try Editor() + defer { editor.close() } + let oval = NSRect(x: 200, y: 200, width: 120, height: 80) + try editor.pick("ellipse") + try editor.click("screenshot.style.fill") + try editor.drag(from: oval.origin, to: NSPoint(x: oval.maxX, y: oval.maxY)) + try await assertExport(editor, items: [item(.ellipse(oval), filled: true)]) + + // The new ellipse stays selected, so the same control turns it back into an outline. + try editor.click("screenshot.style.fill") + try await assertExport(editor, items: [item(.ellipse(oval))]) + editor.view.undo(nil) + try await assertExport(editor, items: [item(.ellipse(oval), filled: true)]) + + try editor.drag(from: NSPoint(x: oval.midX, y: oval.midY), to: NSPoint(x: oval.midX + 30, y: oval.midY)) + try await assertExport(editor, items: [item(.ellipse(oval.offsetBy(dx: 30, dy: 0)), filled: true)]) + } + + func testTextRecognitionFromTheModeBarNeverRevealsTheAnnotationToolbar() throws { + let editor = try Editor() + defer { editor.close() } + var cancelled = false + editor.view.onCancel = { cancelled = true } + let modeBar = try XCTUnwrap(editor.descendants().first { $0.identifier?.rawValue == "screenshot.modeBar" }) + // Mode-bar order: screenshot, scrolling, recording, text recognition. + try XCTUnwrap(editor.descendants(of: modeBar).compactMap { $0 as? NSButton }.first { $0.tag == 3 }).performClick(nil) + let annotationTool = try XCTUnwrap(editor.descendants().compactMap { $0 as? NSButton } + .first { $0.accessibilityIdentifier() == "screenshot.tool.rect" }) + XCTAssertTrue(annotationTool.isHiddenOrHasHiddenAncestor) + + let close = try XCTUnwrap(editor.descendants().compactMap { $0 as? NSButton } + .first { $0.action == NSSelectorFromString("closeOcr") }) + try editor.drag(from: NSPoint(x: 500, y: 400), to: NSPoint(x: 520, y: 410)) + XCTAssertTrue(annotationTool.isHiddenOrHasHiddenAncestor, "Moving a text-recognition selection must not show annotation tools") + close.performClick(nil) + XCTAssertTrue(cancelled, "Closing recognition chosen from the mode bar ends the capture") + XCTAssertTrue(annotationTool.isHiddenOrHasHiddenAncestor) + } + + private func item(_ shape: Shape, filled: Bool = false) -> Item { + Item(shape: shape, stroke: Stroke(color: .systemRed, width: 2, filled: filled)) + } + + private func assertExport(_ editor: Editor, items: [Item], crop: NSRect? = nil, + file: StaticString = #filePath, line: UInt = #line) async throws { + let finished = expectation(description: "Screenshot encoded") + var result: Data? + editor.view.onComplete = { png, _ in result = png; finished.fulfill() } + editor.view.copy(nil) + await fulfillment(of: [finished], timeout: 3) + let image = try XCTUnwrap(NSBitmapImageRep(data: XCTUnwrap(result))?.cgImage) + let renderer = AnnotationRenderer(image: editor.image, scale: 1, size: editor.view.bounds.size) + let expected = try XCTUnwrap(renderer.render(selection: crop ?? editor.crop, items: items, + radius: 0, shadowSize: 0, shadowColor: .black)) + XCTAssertEqual(image.width, expected.image.width, file: file, line: line) + XCTAssertEqual(image.height, expected.image.height, file: file, line: line) + let actualPixels = try pixels(image) + let expectedPixels = try pixels(expected.image) + XCTAssertTrue(actualPixels == expectedPixels, "Export changed geometry, stroke, crop, or included editing handles", + file: file, line: line) + } + + private func pixels(_ image: CGImage) throws -> Data { + let context = try XCTUnwrap(CGContext(data: nil, width: image.width, height: image.height, + bitsPerComponent: 8, bytesPerRow: image.width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return Data(bytes: try XCTUnwrap(context.data), count: context.bytesPerRow * context.height) + } +} + +@MainActor +private final class Editor { + let view: OverlayView + let window: NSWindow + let image: CGImage + let crop = NSRect(x: 100, y: 100, width: 800, height: 560) + + init() throws { + _ = NSApplication.shared + let bounds = NSRect(x: 0, y: 0, width: 1000, height: 760) + let context = try XCTUnwrap(CGContext(data: nil, width: 1000, height: 760, bitsPerComponent: 8, + bytesPerRow: 4000, space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.setFillColor(CGColor.white) + context.fill(bounds) + image = try XCTUnwrap(context.makeImage()) + let environment = ScreenshotEnvironment(context: PluginRuntimeContext(pluginID: "screenshot", storage: ScreenshotTestStorage())) + view = OverlayView(frame: bounds, environment: environment) + window = NSWindow(contentRect: bounds, styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + view.prepare(frozen: image, windows: [], quick: false) + try drag(from: crop.origin, to: NSPoint(x: crop.maxX, y: crop.maxY)) + } + + func close() { view.stop(); window.close() } + + func draw(_ tool: String, _ rect: NSRect) throws { + try pick(tool) + try drag(from: rect.origin, to: NSPoint(x: rect.maxX, y: rect.maxY)) + } + + func pick(_ tool: String) throws { + try click("screenshot.tool.\(tool)") + } + + func descendants(of parent: NSView? = nil) -> [NSView] { + (parent ?? view).subviews.flatMap { [$0] + descendants(of: $0) } + } + + func click(_ identifier: String) throws { + let button = try XCTUnwrap(descendants().compactMap { $0 as? NSButton } + .first { $0.accessibilityIdentifier() == identifier }) + button.performClick(nil) + } + + func drag(from start: NSPoint, to end: NSPoint, attemptUndoDuringDrag: Bool = false) throws { + view.mouseDown(with: try mouse(.leftMouseDown, at: start)) + if attemptUndoDuringDrag { + view.mouseDragged(with: try mouse(.leftMouseDragged, at: NSPoint(x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2))) + view.undo(nil) + try key(124) + } + // Final mouse-up coordinates must apply even when AppKit coalesces drag samples. + view.mouseUp(with: try mouse(.leftMouseUp, at: end)) + } + + func key(_ code: UInt16, flags: NSEvent.ModifierFlags = []) throws { + view.keyDown(with: try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, + timestamp: 0, windowNumber: window.windowNumber, context: nil, + characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: code))) + } + + private func mouse(_ type: NSEvent.EventType, at point: NSPoint) throws -> NSEvent { + try XCTUnwrap(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: 0)) + } +} diff --git a/Plugins/Screenshot/Tests/AnnotationRendererTests.swift b/Plugins/Screenshot/Tests/AnnotationRendererTests.swift index ae6b2f2e2..6c20b664b 100644 --- a/Plugins/Screenshot/Tests/AnnotationRendererTests.swift +++ b/Plugins/Screenshot/Tests/AnnotationRendererTests.swift @@ -6,6 +6,64 @@ import XCTest @MainActor final class AnnotationRendererTests: XCTestCase { + func testSpotlightKeepsOverlappingRegionsClearThroughDraftCommitAndUndoAtBothScales() throws { + let selection = NSRect(x: 20, y: 10, width: 120, height: 100) + let stroke = Stroke(color: .red, width: 4) + let first = Item(shape: .spotlight(NSRect(x: 35, y: 25, width: 40, height: 40)), stroke: stroke) + let second = Item(shape: .spotlight(NSRect(x: 55, y: 45, width: 40, height: 40)), stroke: stroke) + + for scale: CGFloat in [1, 2] { + let renderer = try whiteRenderer(scale: scale) + func render(_ items: [Item], draft: Item? = nil) throws -> NSBitmapImageRep { + let raster = try XCTUnwrap(renderer.render( + selection: selection, items: items, draft: draft, + radius: 0, shadowSize: 0, shadowColor: .black + )) + return NSBitmapImageRep(cgImage: raster.image) + } + func brightness(_ bitmap: NSBitmapImageRep, _ x: CGFloat, _ y: CGFloat) throws -> CGFloat { + let color = try XCTUnwrap(bitmap.colorAt( + x: Int((x - selection.minX) * scale), y: Int((selection.maxY - y) * scale) + )?.usingColorSpace(.deviceRGB)) + XCTAssertEqual(color.alphaComponent, 1, accuracy: 0.01) + return color.redComponent + } + + let draft = try render([first], draft: second) + let committed = try render([first, second]) + XCTAssertEqual(committed.pixelsWide, Int(selection.width * scale)) + XCTAssertEqual(committed.pixelsHigh, Int(selection.height * scale)) + for bitmap in [draft, committed] { + // Each region and their overlap remain clear, with a single dim layer outside. + for (x, y): (CGFloat, CGFloat) in [(45, 35), (85, 75), (65, 55)] { + XCTAssertEqual(try brightness(bitmap, x, y), 1, accuracy: 0.01) + } + XCTAssertEqual(try brightness(bitmap, 125, 95), 0.5, accuracy: 0.02) + XCTAssertLessThan(try brightness(bitmap, 36, 26), 0.8, "The highlight has rounded corners") + } + let undone = try render([first]) + XCTAssertEqual(try brightness(undone, 45, 35), 1, accuracy: 0.01) + XCTAssertEqual(try brightness(undone, 85, 75), 0.5, accuracy: 0.02) + XCTAssertEqual(try brightness(render([]), 125, 95), 1, accuracy: 0.01) + } + } + + func testSpotlightOutsideSelectionOrTooSmallDoesNotDimExport() throws { + let renderer = try whiteRenderer(scale: 1) + let selection = NSRect(x: 20, y: 10, width: 120, height: 100) + let raster = try XCTUnwrap(renderer.render( + selection: selection, + items: [NSRect(x: 0, y: 0, width: 10, height: 10), + NSRect(x: 40, y: 40, width: 1, height: 1)].map { + Item(shape: .spotlight($0), stroke: Stroke(color: .red, width: 2)) + }, + radius: 0, shadowSize: 0, shadowColor: .black + )) + let bitmap = NSBitmapImageRep(cgImage: raster.image) + let color = try XCTUnwrap(bitmap.colorAt(x: 60, y: 50)?.usingColorSpace(.deviceRGB)) + XCTAssertEqual(color.redComponent, 1, accuracy: 0.01) + } + func testExportKeepsQRCodeRedactedUnderOtherAnnotationsAtBothDisplayScales() throws { let payload = "mactools-private-token" let filter = try XCTUnwrap(CIFilter(name: "CIQRCodeGenerator")) @@ -38,8 +96,9 @@ final class AnnotationRendererTests: XCTestCase { // These effects sample the unredacted source, so neither may uncover the mask. let raster = try XCTUnwrap(renderer.render( selection: NSRect(origin: .zero, size: size), - items: [.qrMask(region), Item(shape: .mosaic(region), stroke: stroke)], - draft: Item(shape: .blur(region), stroke: stroke), + items: [.qrMask(region), Item(shape: .mosaic(region), stroke: stroke), + Item(shape: .spotlight(region), stroke: stroke)], + draft: Item(shape: .mosaic(region), stroke: stroke), radius: 0, shadowSize: 0, shadowColor: .black )) let bitmap = try XCTUnwrap(NSBitmapImageRep(data: ScreenshotImageEncoder.encodePNG(raster))) @@ -50,10 +109,135 @@ final class AnnotationRendererTests: XCTestCase { XCTAssertEqual(center.greenComponent, 0) XCTAssertEqual(center.blueComponent, 0) let outside = try XCTUnwrap(bitmap.colorAt(x: 0, y: 0)?.usingColorSpace(.deviceRGB)) - XCTAssertEqual(outside.redComponent, 1) + XCTAssertGreaterThan(outside.redComponent, 0) + XCTAssertLessThan(outside.redComponent, 1) + } + } + + func testMosaicAndFillHideTextFromRecognition() throws { + let secret = "4829 1735 6604" + let (source, size, textRect) = try textImage(secret, scale: 2) + XCTAssertTrue(try recognizedText(source).contains("4829"), "The source fixture must be readable") + + for filled in [false, true] { + let renderer = AnnotationRenderer(image: source, scale: 2, size: size) + let shape: Shape = filled ? .rect(textRect) : .mosaic(textRect) + let raster = try XCTUnwrap(renderer.render( + selection: NSRect(origin: .zero, size: size), + items: [Item(shape: shape, stroke: Stroke(color: .systemRed, width: 2, filled: filled))], + radius: 0, shadowSize: 0, shadowColor: .black)) + let text = try recognizedText(raster.image) + for group in secret.split(separator: " ") { + XCTAssertFalse(text.contains(group), "filled: \(filled), recognized: \(text)") + } + } + } + + func testMaskEdgesOnHalfPixelsDoNotBlendWithTheSource() throws { + let source = try checkerboard(width: 200, height: 120) + let size = NSSize(width: 100, height: 60) + // The left edge falls on device pixel 20.5; an unaligned cover would blend column 20. + let region = NSRect(x: 10.25, y: 10.25, width: 60, height: 40) + for filled in [false, true] { + let renderer = AnnotationRenderer(image: source, scale: 2, size: size) + let shape: Shape = filled ? .rect(region) : .mosaic(region) + let raster = try XCTUnwrap(renderer.render( + selection: NSRect(origin: .zero, size: size), + items: [Item(shape: shape, stroke: Stroke(color: .systemRed, width: 2, filled: filled))], + radius: 0, shadowSize: 0, shadowColor: .black)) + let bitmap = NSBitmapImageRep(cgImage: raster.image) + for row in [30, 60, 90] { + XCTAssertEqual(bitmap.colorAt(x: 20, y: row), bitmap.colorAt(x: 21, y: row), + "filled: \(filled), row: \(row)") + XCTAssertEqual(bitmap.colorAt(x: 20, y: row)?.alphaComponent, 1) + } + } + } + + func testMosaicIsOpaqueStableWithinACaptureAndVariesBetweenCaptures() throws { + let source = try checkerboard(width: 120, height: 120, transparentLeftHalf: true) + let size = NSSize(width: 120, height: 120) + let selection = NSRect(origin: .zero, size: size) + let items = [Item(shape: .mosaic(selection), stroke: Stroke(color: .red, width: 2))] + func export(_ renderer: AnnotationRenderer) throws -> Data { + let raster = try XCTUnwrap(renderer.render(selection: selection, items: items, + radius: 0, shadowSize: 0, shadowColor: .black)) + return try ScreenshotImageEncoder.encodePNG(raster) + } + let renderer = AnnotationRenderer(image: source, scale: 1, size: size) + let png = try export(renderer) + XCTAssertEqual(try export(renderer), png, "Preview and export must match within one capture") + XCTAssertNotEqual(try export(AnnotationRenderer(image: source, scale: 1, size: size)), png, + "A fixed block pattern could be matched against rendered candidates") + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: png)) + for (x, y) in [(0, 0), (60, 60), (119, 119)] { + XCTAssertEqual(bitmap.colorAt(x: x, y: y)?.alphaComponent, 1) } } + private func textImage(_ text: String, scale: CGFloat) throws -> (CGImage, NSSize, NSRect) { + let size = NSSize(width: 360, height: 90) + let rep = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(size.width * scale), pixelsHigh: Int(size.height * scale), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + rep.size = size + let context = try XCTUnwrap(NSGraphicsContext(bitmapImageRep: rep)) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + NSColor.white.setFill() + NSRect(origin: .zero, size: size).fill() + let string = NSAttributedString(string: text, attributes: [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 24, weight: .regular), + .foregroundColor: NSColor.black, + ]) + let origin = NSPoint(x: 20, y: 30) + string.draw(at: origin) + NSGraphicsContext.restoreGraphicsState() + let textRect = NSRect(origin: origin, size: string.size()).insetBy(dx: -4, dy: -4) + return (try XCTUnwrap(rep.cgImage), size, textRect) + } + + private func recognizedText(_ image: CGImage) throws -> String { + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + try VNImageRequestHandler(cgImage: image).perform([request]) + return (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") + } + + private func checkerboard(width: Int, height: Int, transparentLeftHalf: Bool = false) throws -> CGImage { + var pixels = [UInt8](repeating: 0, count: width * height * 4) + for y in 0.. AnnotationRenderer { + let size = NSSize(width: 160, height: 120) + let context = try XCTUnwrap(CGContext( + data: nil, width: Int(size.width * scale), height: Int(size.height * scale), + bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.setFillColor(CGColor.white) + context.fill(CGRect(x: 0, y: 0, width: context.width, height: context.height)) + return AnnotationRenderer(image: try XCTUnwrap(context.makeImage()), scale: scale, size: size) + } + private func decodedPayloads(_ image: CGImage) throws -> [String] { let request = VNDetectBarcodesRequest() request.symbologies = [.qr] diff --git a/Plugins/Screenshot/Tests/PinViewTests.swift b/Plugins/Screenshot/Tests/PinViewTests.swift new file mode 100644 index 000000000..71093530c --- /dev/null +++ b/Plugins/Screenshot/Tests/PinViewTests.swift @@ -0,0 +1,18 @@ +import AppKit +import XCTest +@testable import ScreenshotPlugin + +@MainActor +final class PinViewTests: XCTestCase { + func testControlsForASmallImageStayOnScreenWithoutCoveringIt() { + let screen = NSRect(x: 0, y: 0, width: 1440, height: 900) + let size = NSSize(width: 104, height: 44) + for image in [NSRect(x: 600, y: 400, width: 60, height: 30), // free space below + NSRect(x: 600, y: 0, width: 60, height: 30), // at the bottom edge + NSRect(x: 0, y: 870, width: 40, height: 30)] { // in the top-left corner + let frame = PinView.controlsFrame(adjacentTo: image, size: size, within: screen) + XCTAssertTrue(screen.contains(frame), "\(image)") + XCTAssertTrue(frame.intersection(image).isEmpty, "\(image)") + } + } +} diff --git a/Plugins/Screenshot/Tests/RecordingActivityTests.swift b/Plugins/Screenshot/Tests/RecordingActivityTests.swift new file mode 100644 index 000000000..e0fc0ed1e --- /dev/null +++ b/Plugins/Screenshot/Tests/RecordingActivityTests.swift @@ -0,0 +1,70 @@ +import AppKit +import XCTest +@testable import ScreenshotPlugin + +@MainActor +final class RecordingActivityTests: XCTestCase { + func testQuartzCoordinatesHandleCropsAndDisplaysAboveAndLeftOfPrimary() { + for origin in [CGPoint.zero, CGPoint(x: -1920, y: -1080), CGPoint(x: 1512, y: 200)] { + let coordinates = RecordingCoordinates( + displayBounds: CGRect(origin: origin, size: CGSize(width: 1920, height: 1080)), + sourceRect: CGRect(x: 100, y: 50, width: 800, height: 600)) + XCTAssertEqual(coordinates.normalize(CGPoint(x: origin.x + 100, y: origin.y + 50)), .zero) + XCTAssertEqual(coordinates.normalize(CGPoint(x: origin.x + 500, y: origin.y + 350)), CGPoint(x: 0.5, y: 0.5)) + XCTAssertNil(coordinates.normalize(CGPoint(x: origin.x + 99, y: origin.y + 50))) + XCTAssertNil(coordinates.normalize(CGPoint(x: CGFloat.nan, y: 0))) + } + } + + func testPointerMovementDoesNotSuppressDeliberateScrolling() throws { + let activity = RecordingActivity(displayBounds: CGRect(x: 0, y: 0, width: 800, height: 600), + sourceRect: CGRect(x: 0, y: 0, width: 800, height: 600)) + for index in 0..<10 { + let time = 1 + Double(index) * 0.06 + let point = CGPoint(x: 100 + index * 8, y: 100) + let move = try XCTUnwrap(CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, + mouseCursorPosition: point, mouseButton: .left)) + move.timestamp = UInt64(time * 1_000_000_000) + activity.receive(try XCTUnwrap(NSEvent(cgEvent: move))) + let scroll = try XCTUnwrap(CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 1, + wheel1: 10, wheel2: 0, wheel3: 0)) + scroll.location = point + scroll.timestamp = UInt64((time + 0.01) * 1_000_000_000) + activity.receive(try XCTUnwrap(NSEvent(cgEvent: scroll))) + } + let events = activity.snapshot(startTime: 0) + XCTAssertEqual(events.filter { $0.kind == .scroll }.count, 10) + XCTAssertTrue(RecordingZoomPlan(events: events, duration: 5).hasZoom) + } + + func testEventsAreRelativeToTheFirstVideoFrame() { + let events: [RecordingZoomEvent] = [ + .init(time: 100, kind: .click, point: .zero), + .init(time: 102, kind: .click, point: CGPoint(x: 0.2, y: 0.3)), + .init(time: .nan, kind: .reset), + ] + let result = RecordingActivity.relativeEvents(events, startTime: 101.5) + XCTAssertEqual(result.map(\.time), [0.5]) + XCTAssertTrue(RecordingActivity.relativeEvents(events, startTime: .nan).isEmpty) + } + + func testSwitchingWindowsResetsButMenusKeepTheCurrentInteraction() { + let activity = RecordingActivity(displayBounds: CGRect(x: 0, y: 0, width: 800, height: 600), + sourceRect: CGRect(x: 0, y: 0, width: 800, height: 600)) + let point = CGPoint(x: 0.5, y: 0.5) + activity.receiveClick(at: 1, point: point, window: 1) + let menu = RecordingActivity.ordinaryWindow(at: CGPoint(x: 100, y: 100), + windows: [window(id: 3, layer: 101), window(id: 1)]) + XCTAssertNil(menu) + activity.receiveClick(at: 2, point: point, window: menu) + let other = RecordingActivity.ordinaryWindow(at: CGPoint(x: 100, y: 100), eventWindow: 2, + windows: [window(id: 1), window(id: 2)]) + activity.receiveClick(at: 3, point: point, window: other) + XCTAssertEqual(activity.snapshot(startTime: 0).map(\.kind), [.click, .click, .reset]) + } + + private func window(id: CGWindowID, layer: Int = 0) -> [String: Any] { + [kCGWindowNumber as String: NSNumber(value: id), kCGWindowLayer as String: layer, + kCGWindowBounds as String: CGRect(x: 0, y: 0, width: 200, height: 200).dictionaryRepresentation] + } +} diff --git a/Plugins/Screenshot/Tests/RecordingZoomExporterTests.swift b/Plugins/Screenshot/Tests/RecordingZoomExporterTests.swift new file mode 100644 index 000000000..3ac794c79 --- /dev/null +++ b/Plugins/Screenshot/Tests/RecordingZoomExporterTests.swift @@ -0,0 +1,229 @@ +import AppKit +import AVFoundation +import XCTest +@testable import ScreenshotPlugin + +@available(macOS 15, *) +final class RecordingZoomExporterTests: XCTestCase { + func testZoomedMovieKeepsDurationSizeOrientationAndAudio() async throws { + for rotated in [false, true] { + let folder = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: folder) } + let source = folder.appendingPathComponent("original.mov") + let destination = folder.appendingPathComponent("zoom.mov") + try await makeMovie(at: source, rotated: rotated) + let originalData = try Data(contentsOf: source) + let events = [RecordingZoomEvent(time: 0.3, kind: .click, point: CGPoint(x: 0.8, y: 0.2))] + try await RecordingZoomExporter.export(source: source, destination: destination, events: events) + XCTAssertEqual(try Data(contentsOf: source), originalData, "The original stays recoverable") + + let input = AVURLAsset(url: source), output = AVURLAsset(url: destination) + let inputDuration = try await input.load(.duration).seconds + let outputDuration = try await output.load(.duration).seconds + XCTAssertEqual(outputDuration, inputDuration, accuracy: 1.0 / 30) + let videoTracks = try await output.loadTracks(withMediaType: .video) + let track = try XCTUnwrap(videoTracks.first) + let frameRate = try await track.load(.nominalFrameRate) + XCTAssertEqual(frameRate, 60, accuracy: 0.01) + let audioTracks = try await output.loadTracks(withMediaType: .audio) + XCTAssertEqual(audioTracks.count, 1) + + let original = try await frame(from: input, at: 1) + let result = try await frame(from: output, at: 1) + XCTAssertEqual(result.pixelsWide, rotated ? 240 : 320) + XCTAssertEqual(result.pixelsHigh, rotated ? 320 : 240) + let crop = RecordingZoomPlan(events: events, duration: inputDuration).crop(at: 1) + XCTAssertLessThan(crop.width, 1) + // Compare decoded pixels in display orientation; this catches vertical flips and a lost track transform. + for point in [CGPoint(x: 0.1, y: 0.1), CGPoint(x: 0.5, y: 0.5), CGPoint(x: 0.9, y: 0.9)] { + let expected = try XCTUnwrap(original.colorAt( + x: Int((crop.minX + crop.width * point.x) * CGFloat(original.pixelsWide)), + y: Int((crop.minY + crop.height * point.y) * CGFloat(original.pixelsHigh)))?.usingColorSpace(.sRGB)) + let actual = try XCTUnwrap(result.colorAt(x: Int(point.x * CGFloat(result.pixelsWide)), + y: Int(point.y * CGFloat(result.pixelsHigh)))?.usingColorSpace(.sRGB)) + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.06, "\(point)") + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.06, "\(point)") + } + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted(), ["original.mov", "zoom.mov"]) + } + } + + func testSparseScreenUpdatesStillProduceSixtyFramesPerSecond() async throws { + let folder = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: folder) } + let source = folder.appendingPathComponent("original.mov") + let destination = folder.appendingPathComponent("zoom.mov") + try await makeMovie(at: source, rotated: false, frameStride: 30) + try await RecordingZoomExporter.export(source: source, destination: destination, events: [ + .init(time: 0.3, kind: .click, point: CGPoint(x: 0.8, y: 0.2)), .init(time: 0.4, kind: .release), + ]) + let asset = AVURLAsset(url: destination) + let videoTracks = try await asset.loadTracks(withMediaType: .video) + let track = try XCTUnwrap(videoTracks.first) + let reader = try AVAssetReader(asset: asset) + let output = AVAssetReaderTrackOutput(track: track, outputSettings: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + ]) + reader.add(output) + XCTAssertTrue(reader.startReading()) + var times: [Double] = [] + while let sample = output.copyNextSampleBuffer() { + if CMSampleBufferGetImageBuffer(sample) != nil { times.append(sample.presentationTimeStamp.seconds) } + } + XCTAssertGreaterThanOrEqual(times.count, 179, "The camera keeps animating while the screen is unchanged") + XCTAssertLessThanOrEqual(zip(times.dropFirst(), times).map { $0 - $1 }.max() ?? .infinity, 1.0 / 60 + 0.00001) + } + + func testWithoutZoomTheOriginalBytesArePublishedUnchanged() async throws { + let folder = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: folder) } + let source = folder.appendingPathComponent("original.mov") + let destination = folder.appendingPathComponent("unchanged.mov") + try await makeMovie(at: source, rotated: true) + let originalData = try Data(contentsOf: source) + // A click too close to the end has no room to zoom in and out. + try await RecordingZoomExporter.export(source: source, destination: destination, events: [ + .init(time: 2.8, kind: .click, point: CGPoint(x: 0.8, y: 0.2)), + ]) + XCTAssertEqual(try Data(contentsOf: destination), originalData) + XCTAssertEqual(try Data(contentsOf: source), originalData) + } + + func testCancellationAndCollisionsLeaveExistingFilesUntouched() async throws { + let folder = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: folder) } + let source = folder.appendingPathComponent("original.mov") + let destination = folder.appendingPathComponent("zoom.mov") + try await makeMovie(at: source, rotated: false) + let originalData = try Data(contentsOf: source) + let events = [RecordingZoomEvent(time: 0.3, kind: .click, point: CGPoint(x: 0.8, y: 0.2))] + let (updates, continuation) = AsyncStream.makeStream() + let gate = DispatchSemaphore(value: 0) + let task = Task { + try await RecordingZoomExporter.export(source: source, destination: destination, events: events) { progress in + if progress == 0 { + continuation.yield(()) + gate.wait() + } + } + } + var iterator = updates.makeAsyncIterator() + await iterator.next() + task.cancel() + gate.signal() + do { + try await task.value + XCTFail("Cancelled export must throw") + } catch { + XCTAssertTrue(error is CancellationError, "\(error)") + } + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: folder.path), ["original.mov"]) + + let sentinel = Data("keep existing destination".utf8) + try sentinel.write(to: destination) + for target in [destination, source] { + do { + try await RecordingZoomExporter.export(source: source, destination: target, events: events) + XCTFail("An occupied destination must not be replaced") + } catch {} + } + XCTAssertEqual(try Data(contentsOf: destination), sentinel) + XCTAssertEqual(try Data(contentsOf: source), originalData) + } + + private func temporaryFolder() throws -> URL { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + return folder + } + + private func frame(from asset: AVAsset, at seconds: Double) async throws -> NSBitmapImageRep { + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.requestedTimeToleranceBefore = .zero + generator.requestedTimeToleranceAfter = .zero + let result = try await generator.image(at: CMTime(seconds: seconds, preferredTimescale: 600)) + return NSBitmapImageRep(cgImage: result.image) + } + + private func makeMovie(at url: URL, rotated: Bool, frameStride: Int = 1) async throws { + let writer = try AVAssetWriter(outputURL: url, fileType: .mov) + let video = AVAssetWriterInput(mediaType: .video, outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: 320, AVVideoHeightKey: 240, + AVVideoColorPropertiesKey: [AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2, + AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2, + AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2], + ]) + if rotated { video.transform = CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 240, ty: 0) } + let adaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: video, sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: 320, kCVPixelBufferHeightKey as String: 240, + ]) + let audio = AVAssetWriterInput(mediaType: .audio, outputSettings: [ + AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48000, AVNumberOfChannelsKey: 1, + ]) + writer.add(video) + writer.add(audio) + XCTAssertTrue(writer.startWriting()) + writer.startSession(atSourceTime: .zero) + let samples = try silentAudio() + XCTAssertTrue(audio.append(samples)) + audio.markAsFinished() + + var created: CVPixelBuffer? + XCTAssertTrue(CVPixelBufferCreate(kCFAllocatorDefault, 320, 240, kCVPixelFormatType_32BGRA, nil, &created) == kCVReturnSuccess) + let pixels = try XCTUnwrap(created) + CVPixelBufferLockBaseAddress(pixels, []) + let base = try XCTUnwrap(CVPixelBufferGetBaseAddress(pixels)).assumingMemoryBound(to: UInt8.self) + let stride = CVPixelBufferGetBytesPerRow(pixels) + for y in 0..<240 { + for x in 0..<320 { + let offset = y * stride + x * 4 + base[offset] = 180 + base[offset + 1] = UInt8(y * 255 / 239) + base[offset + 2] = UInt8(x * 255 / 319) + base[offset + 3] = 255 + } + } + CVPixelBufferUnlockBaseAddress(pixels, []) + for index in Swift.stride(from: 0, to: 90, by: frameStride) { + while !video.isReadyForMoreMediaData { + if let error = writer.error { throw error } + try await Task.sleep(for: .milliseconds(1)) + } + XCTAssertTrue(adaptor.append(pixels, withPresentationTime: CMTime(value: Int64(index), timescale: 30))) + } + video.markAsFinished() + writer.endSession(atSourceTime: CMTime(value: 3, timescale: 1)) + await writer.finishWriting() + XCTAssertTrue(writer.status == .completed) + if let error = writer.error { throw error } + } + + private func silentAudio() throws -> CMSampleBuffer { + var description = AudioStreamBasicDescription(mSampleRate: 48000, mFormatID: kAudioFormatLinearPCM, + mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, + mBytesPerPacket: 2, mFramesPerPacket: 1, mBytesPerFrame: 2, mChannelsPerFrame: 1, + mBitsPerChannel: 16, mReserved: 0) + var format: CMAudioFormatDescription? + XCTAssertTrue(CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault, asbd: &description, + layoutSize: 0, layout: nil, magicCookieSize: 0, magicCookie: nil, extensions: nil, + formatDescriptionOut: &format) == noErr) + var block: CMBlockBuffer? + let count = 3 * 48000 + XCTAssertTrue(CMBlockBufferCreateWithMemoryBlock(allocator: kCFAllocatorDefault, memoryBlock: nil, + blockLength: count * 2, blockAllocator: kCFAllocatorDefault, customBlockSource: nil, + offsetToData: 0, dataLength: count * 2, flags: 0, blockBufferOut: &block) == noErr) + let data = try XCTUnwrap(block) + XCTAssertTrue(CMBlockBufferFillDataBytes(with: 0, blockBuffer: data, offsetIntoDestination: 0, dataLength: count * 2) == noErr) + var timing = CMSampleTimingInfo(duration: CMTime(value: 1, timescale: 48000), presentationTimeStamp: .zero, + decodeTimeStamp: .invalid) + var sample: CMSampleBuffer? + let audioFormat = try XCTUnwrap(format) + XCTAssertTrue(CMSampleBufferCreateReady(allocator: kCFAllocatorDefault, dataBuffer: data, + formatDescription: audioFormat, sampleCount: count, sampleTimingEntryCount: 1, + sampleTimingArray: &timing, sampleSizeEntryCount: 0, sampleSizeArray: nil, + sampleBufferOut: &sample) == noErr) + return try XCTUnwrap(sample) + } +} diff --git a/Plugins/Screenshot/Tests/RecordingZoomTests.swift b/Plugins/Screenshot/Tests/RecordingZoomTests.swift new file mode 100644 index 000000000..fbc0bf894 --- /dev/null +++ b/Plugins/Screenshot/Tests/RecordingZoomTests.swift @@ -0,0 +1,92 @@ +import CoreGraphics +import XCTest +@testable import ScreenshotPlugin + +final class RecordingZoomTests: XCTestCase { + private let full = CGRect(x: 0, y: 0, width: 1, height: 1) + private let center = CGPoint(x: 0.5, y: 0.5) + + func testClickEntersSmoothlyAndReturnsToOverviewAfterInactivity() { + let plan = RecordingZoomPlan(events: [ + .init(time: 1, kind: .click, point: center), .init(time: 1.1, kind: .release) + ], duration: 5) + XCTAssertTrue(plan.hasZoom) + XCTAssertEqual(plan.crop(at: 1), full) + XCTAssertLessThan(plan.crop(at: 1.15).width, 1) + XCTAssertGreaterThan(plan.crop(at: 1.15).width, 0.625) + XCTAssertEqual(plan.crop(at: 1.5).width, 0.625, accuracy: 0.000001) + XCTAssertEqual(plan.crop(at: 3.7), full) + } + + func testInputEventsNeverRestartOrReverseCameraVelocity() { + let traces: [[RecordingZoomEvent]] = [ + [.init(time: 1, kind: .click, point: center), + .init(time: 1.15, kind: .move, point: CGPoint(x: 0.85, y: 0.5))], + [.init(time: 1, kind: .click, point: center), .init(time: 1.05, kind: .release), + .init(time: 1.18, kind: .click, point: CGPoint(x: 0.65, y: 0.5))], + [.init(time: 1, kind: .click, point: center), .init(time: 1.18, kind: .reset)], + ] + let epsilon = 0.00001 + for events in traces { + let plan = RecordingZoomPlan(events: events, duration: 6) + for time in events.flatMap({ event in (0...6).map { event.time + Double($0) / 60 } }) { + let before = plan.crop(at: time - epsilon), current = plan.crop(at: time), after = plan.crop(at: time + epsilon) + for (a, b, c) in [(before.midX, current.midX, after.midX), (before.width, current.width, after.width)] { + XCTAssertLessThan(abs((c - b) / epsilon - (b - a) / epsilon), 0.01) + } + } + } + } + + func testHeldDragOutlivesTheIdleTimeoutAndResetReturnsToOverview() { + let events: [RecordingZoomEvent] = [ + .init(time: 1, kind: .click, point: center), + .init(time: 4, kind: .move, point: CGPoint(x: 0.9, y: 0.1)), + .init(time: 5, kind: .release), + ] + let plan = RecordingZoomPlan(events: events, duration: 9) + XCTAssertEqual(plan.crop(at: 4.5).width, 0.625) + XCTAssertEqual(plan.crop(at: 6.5).width, 0.625) + XCTAssertEqual(plan.crop(at: 7.6), full) + + let reset = RecordingZoomPlan(events: events + [.init(time: 4.2, kind: .reset)], duration: 9) + XCTAssertEqual(reset.crop(at: 4.8), full) + } + + func testDenseDragKeepsTheCursorInsideTheCrop() { + var events: [RecordingZoomEvent] = [.init(time: 1, kind: .click, point: CGPoint(x: 0.1, y: 0.5))] + for index in 0...20 { + events.append(.init(time: 2 + Double(index) * 0.05, kind: .move, + point: CGPoint(x: 0.1 + Double(index) * 0.04, y: 0.5))) + } + let plan = RecordingZoomPlan(events: events, duration: 6) + for event in events.dropFirst() { + XCTAssertTrue(plan.crop(at: event.time).contains(event.point!)) + } + } + + func testInvalidEventsAreIgnoredAndCropsStayInsideTheRecording() { + let invalid: [RecordingZoomEvent] = [ + .init(time: .nan, kind: .click, point: center), + .init(time: -1, kind: .click, point: center), + .init(time: 1, kind: .click, point: CGPoint(x: 1.1, y: 0)), + .init(time: 1, kind: .click), + .init(time: 20, kind: .click, point: center), + ] + XCTAssertFalse(RecordingZoomPlan(events: invalid, duration: 5).hasZoom) + XCTAssertFalse(RecordingZoomPlan(events: [.init(time: 0, kind: .click, point: center)], duration: 0.2).hasZoom, + "A clip too short to enter and leave keeps the full view") + + let plan = RecordingZoomPlan(events: [ + .init(time: 0, kind: .click, point: CGPoint(x: 0, y: 1)), + .init(time: 0, kind: .release), + .init(time: 1, kind: .scroll, point: CGPoint(x: 1, y: 0)), + ] + invalid, duration: 5) + for step in 0...150 { + let crop = plan.crop(at: Double(step) / 30) + XCTAssertTrue(crop.minX >= 0 && crop.minY >= 0 && crop.maxX <= 1.000001 && crop.maxY <= 1.000001) + XCTAssertTrue(crop.width >= 0.625 && crop.width <= 1 && crop.width == crop.height) + } + XCTAssertEqual(plan.crop(at: 3.6), full, "A same-time release must not leave the button held") + } +} diff --git a/Plugins/Screenshot/Tests/ScreenshotCoordinatorTests.swift b/Plugins/Screenshot/Tests/ScreenshotCoordinatorTests.swift new file mode 100644 index 000000000..1eae1d4c2 --- /dev/null +++ b/Plugins/Screenshot/Tests/ScreenshotCoordinatorTests.swift @@ -0,0 +1,25 @@ +import MacToolsPluginKit +import XCTest +@testable import ScreenshotPlugin + +@MainActor +final class ScreenshotCoordinatorTests: XCTestCase { + /// A recording can finish while a later screenshot is in progress; only cancelling the plugin + /// may suppress its completion messages. + func testRecordingMessagesFollowTheSessionNotLaterScreenshots() { + let environment = ScreenshotEnvironment(context: PluginRuntimeContext(pluginID: "screenshot", storage: ScreenshotTestStorage())) + let coordinator = ScreenshotCoordinator(environment: environment) + var errors: [String] = [] + coordinator.onError = { errors.append($0) } + let session = coordinator.sessionToken + + coordinator.recordingFinished(.failure(TestError.failed), session: session) + XCTAssertEqual(errors.count, 1) + + coordinator.cancel() + coordinator.recordingFinished(.failure(TestError.failed), session: session) + XCTAssertEqual(errors.count, 1, "A disabled plugin must not report its old recording") + } + + private enum TestError: Error { case failed } +} diff --git a/Plugins/Screenshot/Tests/ScrollSessionTests.swift b/Plugins/Screenshot/Tests/ScrollSessionTests.swift index 40abec5b4..112df01d3 100644 --- a/Plugins/Screenshot/Tests/ScrollSessionTests.swift +++ b/Plugins/Screenshot/Tests/ScrollSessionTests.swift @@ -69,22 +69,8 @@ final class ScrollSessionTests: XCTestCase { } func testStitcherReconstructsADocumentAcrossDifferentScrollOffsets() throws { - let width = 400, height = 2400, viewport = 900 - let context = try XCTUnwrap(CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) - var seed: UInt32 = 12345 - func random() -> CGFloat { - seed = seed &* 1664525 &+ 1013904223 - return CGFloat((seed >> 8) & 0xFFFF) / 65535 - } - context.setFillColor(CGColor.white) - context.fill(CGRect(x: 0, y: 0, width: width, height: height)) - for _ in 0..<600 { - context.setFillColor(CGColor(red: random(), green: random(), blue: random(), alpha: 1)) - context.fill(CGRect(x: random() * 380, y: random() * 2380, width: 8 + random() * 60, height: 4 + random() * 20)) - } - let document = try XCTUnwrap(context.makeImage()) + let width = 400, viewport = 900 + let document = try makeDocument() let stitcher = Stitcher() for offset in [0, 300, 650, 650, 1100, 1500] { try stitcher.push(try XCTUnwrap(document.cropping(to: CGRect(x: 0, y: offset, width: width, height: viewport)))) @@ -147,6 +133,55 @@ final class ScrollSessionTests: XCTestCase { XCTAssertNil(try XCTUnwrap(results.first).get()) } + /// Real captures differ slightly between frames; noise must not reject every later frame as unmatched. + func testNoisyFramesStillMatchAndAppendEachRowOnce() throws { + let width = 400, viewport = 900 + let document = try makeDocument() + var seed: UInt32 = 777 + for amplitude in [10, 20, 40] { + let stitcher = Stitcher() + for offset in [0, 300, 650, 1100, 1500] { + let frame = try XCTUnwrap(document.cropping(to: CGRect(x: 0, y: offset, width: width, height: viewport))) + try stitcher.push(try jittered(frame, amplitude: amplitude, seed: &seed)) + } + let output = try XCTUnwrap(try stitcher.compose()) + XCTAssertEqual(output.height, document.height, "amplitude: \(amplitude)") + } + } + + private func makeDocument(width: Int = 400, height: Int = 2400) throws -> CGImage { + let context = try XCTUnwrap(CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + var seed: UInt32 = 12345 + func random() -> CGFloat { + seed = seed &* 1664525 &+ 1013904223 + return CGFloat((seed >> 8) & 0xFFFF) / 65535 + } + context.setFillColor(CGColor.white) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + for _ in 0..<600 { + context.setFillColor(CGColor(red: random(), green: random(), blue: random(), alpha: 1)) + context.fill(CGRect(x: random() * 380, y: random() * 2380, width: 8 + random() * 60, height: 4 + random() * 20)) + } + return try XCTUnwrap(context.makeImage()) + } + + private func jittered(_ image: CGImage, amplitude: Int, seed: inout UInt32) throws -> CGImage { + let bytesPerRow = image.width * 4 + let context = try XCTUnwrap(CGContext(data: nil, width: image.width, height: image.height, bitsPerComponent: 8, + bytesPerRow: bytesPerRow, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let pixels = try XCTUnwrap(context.data).assumingMemoryBound(to: UInt8.self) + for i in Swift.stride(from: 0, to: bytesPerRow * image.height, by: 4) { + seed = seed &* 1664525 &+ 1013904223 + let delta = Int((seed >> 8) % UInt32(amplitude * 2 + 1)) - amplitude + for k in 0..<3 { pixels[i + k] = UInt8(clamping: Int(pixels[i + k]) + delta) } + } + return try XCTUnwrap(context.makeImage()) + } + private func makeFrame(height: Int = 128) throws -> CGImage { let context = try XCTUnwrap(CGContext(data: nil, width: 32, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), diff --git a/Plugins/Screenshot/Tests/TextLayoutTests.swift b/Plugins/Screenshot/Tests/TextLayoutTests.swift new file mode 100644 index 000000000..1e1da7991 --- /dev/null +++ b/Plugins/Screenshot/Tests/TextLayoutTests.swift @@ -0,0 +1,40 @@ +import CoreGraphics +import XCTest +@testable import ScreenshotPlugin + +final class TextLayoutTests: XCTestCase { + func testRestoresTableColumnsParagraphGapsAndIndentation() { + let lines = TextLayout.lines(from: [ + fragment("indented", x: 0.14, width: 0.16, y: 0.58), + fragment("Age", x: 0.30, width: 0.06, y: 0.90), + fragment("30", x: 0.30, width: 0.04, y: 0.82), + fragment("Name", x: 0.10, width: 0.08, y: 0.90), + fragment("Bob", x: 0.10, width: 0.06, y: 0.82), + fragment("Amy", x: 0.10, width: 0.06, y: 0.74), + fragment("28", x: 0.30, width: 0.04, y: 0.74), + ]) + XCTAssertEqual(lines, ["Name Age", "Bob 30", "Amy 28", "", " indented"]) + XCTAssertEqual(TextLayout.lines(from: [fragment("solo", x: 0.6, width: 0.08, y: 0.3)]), ["solo"]) + } + + func testKeepsAlignedTableRowsTogetherButSplitsOffsetSideBySidePanels() { + let rows = [0.90, 0.82, 0.74, 0.66, 0.58] + let table = rows.enumerated().flatMap { i, y in + [fragment("row\(i)", x: 0.05, width: 0.20, y: y), fragment("val\(i)", x: 0.60, width: 0.20, y: y)] + } + let asTable = TextLayout.lines(from: table) + XCTAssertEqual(asTable.count, 5, "A table must not be read column by column") + XCTAssertTrue(asTable[0].hasPrefix("row0") && asTable[0].hasSuffix("val0")) + + let panels = rows.enumerated().flatMap { i, y in + [fragment("Left row \(i)", x: 0.05, width: 0.30, y: y), + fragment("Right col \(i)", x: 0.55, width: 0.30, y: y - 0.04)] + } + XCTAssertEqual(TextLayout.lines(from: panels), + (0...4).map { "Left row \($0)" } + [""] + (0...4).map { "Right col \($0)" }) + } + + private func fragment(_ text: String, x: CGFloat, width: CGFloat, y: CGFloat) -> TextLayout.Fragment { + TextLayout.Fragment(text: text, box: CGRect(x: x, y: y, width: width, height: 0.06)) + } +} diff --git a/Plugins/Screenshot/plugin.json b/Plugins/Screenshot/plugin.json index 1cd0118df..1667e8fd2 100644 --- a/Plugins/Screenshot/plugin.json +++ b/Plugins/Screenshot/plugin.json @@ -78,18 +78,18 @@ "quick-capture-title": "@localizable.action.quickCapture.title", "quick-capture-description": "@localizable.action.quickCapture.description", "long-description": { - "ar": "أداة محلية بلقطات مناطق ونوافذ وتعليقات وإخفاء بالموزاييك أو التمويه والتعرّف على النصوص والرموز وتثبيت الصور ولقطات التمرير. تبدأ فقط بإجراء صريح في الواجهة. تسجيل المناطق بدون صوت يتطلب macOS 15 أو أحدث؛ الميزات الأخرى تعمل على macOS 14 أو أحدث.", - "de": "Lokale Bereichs- und Fensteraufnahmen mit Anmerkungen, Mosaik und Weichzeichnen, Text- und Codeerkennung, angehefteten Bildern und Scrollaufnahmen. Start nur durch eine ausdrückliche Vordergrundaktion. Bereichsvideos ohne Ton benötigen macOS 15; alle anderen Funktionen macOS 14.", - "en": "Local region and window capture with annotations, mosaic and blur, text and code recognition, pinned images, and scrolling screenshots. Starts only through an explicit foreground action. Silent region recording requires macOS 15 or later; other features support macOS 14 or later.", - "es": "Capturas locales de regiones y ventanas con anotaciones, mosaico, desenfoque, reconocimiento de texto y códigos, imágenes fijadas y capturas con desplazamiento. Solo comienza mediante una acción explícita en primer plano. La grabación de regiones sin audio requiere macOS 15; las otras funciones, macOS 14.", - "fr": "Captures locales de zones et fenêtres avec annotations, mosaïque et flou, reconnaissance de texte et de codes, images épinglées et captures défilantes. Démarrage uniquement par une action explicite au premier plan. L’enregistrement de zones sans audio nécessite macOS 15 ; les autres fonctions, macOS 14.", - "ja": "範囲・ウインドウ撮影、注釈、モザイク・ぼかし、文字・コード認識、画像のピン留め、スクロール撮影をローカルで実行します。明示的な前面操作でのみ開始します。音声なしの範囲収録には macOS 15 以降、その他の機能には macOS 14 以降が必要です。", - "ko": "영역·창 캡처, 주석, 모자이크·흐림, 텍스트·코드 인식, 이미지 고정, 스크롤 캡처를 로컬에서 처리합니다. 명시적인 전경 작업으로만 시작합니다. 소리 없는 영역 녹화는 macOS 15 이상, 나머지 기능은 macOS 14 이상이 필요합니다.", - "pt": "Capturas locais de áreas e janelas com anotações, mosaico, desfoque, reconhecimento de textos e códigos, imagens fixadas e rolagem. Inicia apenas por uma ação explícita em primeiro plano. A gravação de áreas sem áudio exige macOS 15; os demais recursos, macOS 14.", - "ru": "Локальные снимки областей и окон с пометками, мозаикой и размытием, распознаванием текста и кодов, закреплением изображений и прокруткой. Запуск только явным действием на переднем плане. Запись области без звука требует macOS 15; остальные функции — macOS 14.", - "tr": "Ek açıklamalar, mozaik ve bulanıklık, metin ve kod tanıma, sabitlenmiş görseller ve kaydırma ekran görüntüleriyle yerel bölge ve pencere yakalama. Yalnızca açık bir ön plan eylemiyle başlar. Sessiz bölge kaydı macOS 15 veya sonrasını gerektirir; diğer özellikler macOS 14 veya sonrasını destekler.", - "zh-Hans": "本地完成区域和窗口截图、标注、马赛克与模糊、文字与二维码识别、贴图和滚动截图。仅由明确的前台操作启动;无声区域录屏需要 macOS 15 或更高,其他功能支持 macOS 14 或更高。", - "zh-Hant": "在本機完成區域和視窗截圖、標註、馬賽克與模糊、文字與 QR 碼辨識、釘選圖片及捲動截圖。僅由明確的前景操作啟動;無聲區域錄製需要 macOS 15 或更新版本,其他功能支援 macOS 14 或更新版本。" + "ar": "أداة محلية بلقطات مناطق ونوافذ وتعليقات وإخفاء بالموزاييك والتعرّف على النصوص والرموز وتثبيت الصور ولقطات التمرير. تبدأ فقط بإجراء صريح في الواجهة. تسجيل المناطق بدون صوت يتطلب macOS 15 أو أحدث؛ الميزات الأخرى تعمل على macOS 14 أو أحدث.", + "de": "Lokale Bereichs- und Fensteraufnahmen mit Anmerkungen, Mosaik, Text- und Codeerkennung, angehefteten Bildern und Scrollaufnahmen. Start nur durch eine ausdrückliche Vordergrundaktion. Bereichsvideos ohne Ton benötigen macOS 15; alle anderen Funktionen macOS 14.", + "en": "Local region and window capture with annotations, mosaic, text and code recognition, pinned images, and scrolling screenshots. Starts only through an explicit foreground action. Silent region recording requires macOS 15 or later; other features support macOS 14 or later.", + "es": "Capturas locales de regiones y ventanas con anotaciones, mosaico, reconocimiento de texto y códigos, imágenes fijadas y capturas con desplazamiento. Solo comienza mediante una acción explícita en primer plano. La grabación de regiones sin audio requiere macOS 15; las otras funciones, macOS 14.", + "fr": "Captures locales de zones et fenêtres avec annotations, mosaïque, reconnaissance de texte et de codes, images épinglées et captures défilantes. Démarrage uniquement par une action explicite au premier plan. L’enregistrement de zones sans audio nécessite macOS 15 ; les autres fonctions, macOS 14.", + "ja": "範囲・ウインドウ撮影、注釈、モザイク、文字・コード認識、画像のピン留め、スクロール撮影をローカルで実行します。明示的な前面操作でのみ開始します。音声なしの範囲収録には macOS 15 以降、その他の機能には macOS 14 以降が必要です。", + "ko": "영역·창 캡처, 주석, 모자이크, 텍스트·코드 인식, 이미지 고정, 스크롤 캡처를 로컬에서 처리합니다. 명시적인 전경 작업으로만 시작합니다. 소리 없는 영역 녹화는 macOS 15 이상, 나머지 기능은 macOS 14 이상이 필요합니다.", + "pt": "Capturas locais de áreas e janelas com anotações, mosaico, reconhecimento de textos e códigos, imagens fixadas e rolagem. Inicia apenas por uma ação explícita em primeiro plano. A gravação de áreas sem áudio exige macOS 15; os demais recursos, macOS 14.", + "ru": "Локальные снимки областей и окон с пометками, мозаикой, распознаванием текста и кодов, закреплением изображений и прокруткой. Запуск только явным действием на переднем плане. Запись области без звука требует macOS 15; остальные функции — macOS 14.", + "tr": "Ek açıklamalar, mozaik, metin ve kod tanıma, sabitlenmiş görseller ve kaydırma ekran görüntüleriyle yerel bölge ve pencere yakalama. Yalnızca açık bir ön plan eylemiyle başlar. Sessiz bölge kaydı macOS 15 veya sonrasını gerektirir; diğer özellikler macOS 14 veya sonrasını destekler.", + "zh-Hans": "本地完成区域和窗口截图、标注、马赛克、文字与二维码识别、贴图和滚动截图。仅由明确的前台操作启动;无声区域录屏需要 macOS 15 或更高,其他功能支持 macOS 14 或更高。", + "zh-Hant": "在本機完成區域和視窗截圖、標註、馬賽克、文字與 QR 碼辨識、釘選圖片及捲動截圖。僅由明確的前景操作啟動;無聲區域錄製需要 macOS 15 或更新版本,其他功能支援 macOS 14 或更新版本。" }, "retention": { "ar": "تبقى الملفات المصدّرة ومحتويات الحافظة تحت تحكم المستخدم. تُزال إعدادات الإضافة وملفاتها الخاصة عند إلغاء تثبيتها؛ ولا تُحذف الملفات التي صدّرها المستخدم.", @@ -107,7 +107,6 @@ }, "setup.requirements.title": "@standardSetup.requirements.title", "setup.requirements.description": "@standardSetup.requirements.description", - "discovery.blur": "@localizable.overlay.tool.blur", "discovery.pixelate": "@localizable.overlay.tool.mosaic", "discovery.scrolling-screenshot": "@localizable.overlay.mode.scroll", "discovery.pin-image": "@localizable.overlay.action.pin", @@ -183,7 +182,6 @@ "scrolling screenshot", "screen recording", "pin", - "blur", "pixelate" ], "localizedSynonyms": { @@ -229,7 +227,6 @@ "zh-Hans": [ "截图", "快速截图", - "模糊", "马赛克" ], "zh-Hant": [ @@ -253,7 +250,6 @@ "relatedPluginIDs": [], "alternativePluginIDs": [], "localizedSynonymRefs": [ - "@productStrings.discovery.blur", "@productStrings.discovery.pixelate", "@productStrings.discovery.scrolling-screenshot", "@productStrings.discovery.pin-image", @@ -282,7 +278,8 @@ "dataObserved": [ "screen-pixels", "window-geometry", - "recognized-text-and-codes" + "recognized-text-and-codes", + "pointer-events" ], "dataPersisted": [ "user-exported-screenshots", diff --git a/README.md b/README.md index 72b232143..cb6f4a7c2 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ Experimental [Trackpad Scale](docs/plugins/trackpad-scale.md) estimates small-ob Screenshot selection remains available on each display after switching desktops or entering and leaving full-screen apps. +The screenshot toolbar includes **Spotlight** for keeping a detail clear while dimming the rest, and opaque **Fill** for rectangles and ellipses; shapes stay selectable, movable, and resizable after drawing. Region recordings can **auto zoom** toward clicks and scrolling in the saved video. + **Take your setup with you:** export and import preferences, keep local backups, or sync supported settings through a cloud or shared folder. The app supports **12 languages** and follows your system language by default. ## `mactools` in your terminal diff --git a/changes/unreleased/screenshot-capture-polish.md b/changes/unreleased/screenshot-capture-polish.md new file mode 100644 index 000000000..a8d2fdd16 --- /dev/null +++ b/changes/unreleased/screenshot-capture-polish.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: changed +--- +Screenshot keeps recognized text layout, tolerates noisy scrolling frames, adds pinned-image controls, and never overwrites saved files. Cancelling Save As returns to the selection. diff --git a/changes/unreleased/screenshot-fill-redaction.md b/changes/unreleased/screenshot-fill-redaction.md new file mode 100644 index 000000000..eb181ae05 --- /dev/null +++ b/changes/unreleased/screenshot-fill-redaction.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: changed +--- +Screenshot adds opaque fills for rectangles and ellipses, hardens mosaic against text recovery, and removes blur. diff --git a/changes/unreleased/screenshot-legacy-shadows.md b/changes/unreleased/screenshot-legacy-shadows.md new file mode 100644 index 000000000..267e98cf4 --- /dev/null +++ b/changes/unreleased/screenshot-legacy-shadows.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: fixed +--- +On macOS 14 and 15, screenshots, scrolling capture, and recordings keep menu and popover shadows. diff --git a/changes/unreleased/screenshot-recording-auto-zoom.md b/changes/unreleased/screenshot-recording-auto-zoom.md new file mode 100644 index 000000000..22a785d8a --- /dev/null +++ b/changes/unreleased/screenshot-recording-auto-zoom.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: added +--- +Region recordings can zoom toward clicks and scrolling in the saved video. Processing shows progress and can keep the original movie instead. diff --git a/changes/unreleased/screenshot-spotlight.md b/changes/unreleased/screenshot-spotlight.md new file mode 100644 index 000000000..a02842204 --- /dev/null +++ b/changes/unreleased/screenshot-spotlight.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: added +--- +Screenshot adds Spotlight to highlight details in copied, saved, and pinned images. Rectangles, ellipses, and spotlight regions can be selected, moved, resized, and undone after drawing. diff --git a/changes/unreleased/screenshot-text-recognition-flow.md b/changes/unreleased/screenshot-text-recognition-flow.md new file mode 100644 index 000000000..a12339390 --- /dev/null +++ b/changes/unreleased/screenshot-text-recognition-flow.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: fixed +--- +Closing text recognition chosen from the mode bar now ends the capture instead of showing annotation tools. Recognized line counts exclude layout blank lines, and the result panel stays on screen. diff --git a/docs/plugins/screenshot.md b/docs/plugins/screenshot.md index 436115190..1342e285d 100644 --- a/docs/plugins/screenshot.md +++ b/docs/plugins/screenshot.md @@ -6,16 +6,22 @@ Adapted from the [Snap screenshot implementation](https://github.com/idev19/Snap ## Capture and edit -- **Capture Screenshot** opens a visible selection interface across connected displays. Only the display under the pointer previews a target window and shows the mode bar, including when moving between displays; stale hover highlights are cleared immediately. Other displays remain evenly dimmed unless they contain a selection; moving the pointer does not discard that selection. Window targeting includes visible menu bar popovers, menus, floating panels, and standard app windows while excluding system overlays; translucent menu materials keep their captured appearance in preview and export. Drag to select a region or click a highlighted target. Add rectangles, ellipses, lines, arrows, freehand marks, text, numbered tags, mosaic, or blur before copying, saving, or pinning the image. +- **Capture Screenshot** opens a visible selection interface across connected displays. Only the display under the pointer previews a target window and shows the mode bar, including when moving between displays; stale hover highlights are cleared immediately. Other displays remain evenly dimmed unless they contain a selection; moving the pointer does not discard that selection. Window targeting includes visible menu bar popovers, menus, floating panels, and standard app windows while excluding system overlays; translucent menu materials keep their captured appearance in preview and export. Drag to select a region or click a highlighted target. Add rectangles, ellipses, lines, arrows, freehand marks, text, numbered tags, mosaic, or spotlight regions before copying, saving, or pinning the image. +- **Spotlight** in the screenshot annotation toolbar keeps a dragged rounded rectangle clear and dims the surrounding selection. Choose the tool again to highlight another detail; overlapping regions stay clear without darkening the background again. Copy, save, and pin include the same effect, and QR masks remain opaque. +- **Edit annotations** without a separate selection tool. Rectangles, ellipses, and spotlight regions are selected after drawing: drag inside to move, drag an edge or corner handle to resize, or use arrow keys to nudge. Click an existing shape to select it again; overlapping shapes select the topmost one. Choose a drawing tool to add another shape, or choose the active tool again to return to editing. Command-Z undoes each completed edit or annotation, and selection handles are excluded from exports. +- **Fill** appears in the style row for rectangles and ellipses. Turn it on before drawing, or select an existing shape to fill it, change its color, or return it to an outline; the change can be undone. Fills are always opaque and draw above mosaic and spotlight effects. +- **Mosaic and fill redaction** expand their edges to whole device pixels so no partially blended source pixels remain at a fractional boundary. Mosaic uses coarse 14-point blocks, area-averaged, quantized, and offset by per-capture random jitter, and is always opaque. This resists matching pixelated text against rendered candidates, but an opaque fill remains the safer choice for passwords and identifiers. The earlier blur tool has been removed. - **Capture visibility** uses a clear dimmed backdrop, the system accent color for window and selection outlines, and balanced hover spacing. Start-confirmation bars and active scrolling/recording controls share the same glass capsule, regular-size native buttons, and padding. - **Mode bar** uses slightly larger controls, sits 4 points below the screen's top edge, and moves horizontally using its left-hand grip. Movement stays within screen edges and avoids the camera housing; each display retains its dragged position during the current capture. - **Magnifier** shows the pointer coordinates and current RGB or HEX color in separate left-aligned rows. Press Tab to switch formats; Command-C copies the displayed value and closes capture. - **Quick Capture** uses the same selection interface and immediately copies the selected image after selection. -- **Text and QR recognition** processes the frozen selection locally. Recognized content can be edited and copied. Closing the panel, changing the selection or mode, or ending capture prevents an earlier recognition request from publishing its result. The QR **Mask** action uses an opaque black fill in preview and export. Mosaic and blur remain visual effects and should not be used to reliably redact sensitive content. +- **Text and QR recognition** processes the frozen selection locally. Recognized content can be edited and copied. Closing the panel, changing the selection or mode, or ending capture prevents an earlier recognition request from publishing its result. When text recognition is chosen from the mode bar, closing its panel ends the capture; recognition started from the screenshot toolbar returns to annotation. Annotation tools stay hidden in text-recognition mode. Recognized text keeps its layout: indentation, table columns, and paragraph gaps are rebuilt from fragment geometry, and side-by-side panels with misaligned rows are read one column at a time. The QR **Mask** action uses an opaque black fill in preview and export. - **Recognition actions** use the same native capsule button style and sizing as scrolling and recording confirmation actions. The footer can show Close, Mask, Open Link, and Done according to the recognized content. -- **Pinned images** stay in a movable window for reference. Use Command-S to save, and Escape or a double-click to close. -- **Scrolling screenshots** continuously capture the selected region while you scroll it downward yourself. Select the scrolling content rather than fixed headers or footers. Finish to copy and pin the assembled image, or cancel to discard it. Ambiguous or unmatched content is not appended; the controls explain how to recover. Matching and composition run on a serial background worker, with only the newest pending frame retained. Output is limited to 128 MiB of RGBA pixels and 32,768 rows, with an additional working-pixel budget that can lower this limit for large viewports. Reaching a limit stops capture with guidance to use smaller sections. +- **Pinned images** stay in a movable window for reference. Hovering shows Copy, Save As, and Close controls; small images show them beside the image without enlarging it. Use Command-C to copy, Command-S to save, and Escape or a double-click to close. A pinned scrolling screenshot closes after it is copied or saved; a deliberately pinned image stays open. +- **Scrolling screenshots** continuously capture the selected region while you scroll it downward yourself. Select the scrolling content rather than fixed headers or footers. Finish to copy and pin the assembled image, or cancel to discard it. Ambiguous or unmatched content is not appended; the controls explain how to recover. Besides a strict absolute match, a frame also matches when its best offset is clearly better than a typical unrelated alignment, so normal capture noise does not stall stitching; the stationary-footer check scales with that matched noise level. Matching and composition run on a serial background worker, with only the newest pending frame retained. Output is limited to 128 MiB of RGBA pixels and 32,768 rows, with an additional working-pixel budget that can lower this limit for large viewports. Reaching a limit stops capture with guidance to use smaller sections. - **Region recording** is available on macOS 15 or later. Choose the recording mode and a region, then start and stop using the visible controls. It saves a MOV file without microphone or system audio, checking hardware codec support before starting the native recording output. A click-through accent-colored outline marks the selected region while capture is active and is excluded from the video. It stays within the target display at screen edges and hides when native capture stops, even while the file is still finalizing. The timer starts when recording actually starts. Completion requires both native capture termination and recording-file finalization; slow finalization keeps showing progress instead of reporting a false failure. Unsupported hardware encoding dimensions produce guidance to select a smaller region. +- **Recording auto zoom** is on by default and controlled by the record bar's **Auto Zoom** checkbox or the plugin's Recording settings; both share one preference. Recording still captures the original region. While capture runs, AppKit mouse monitors record pointer position, clicks, and scrolling inside the region; keyboard input is never observed, and no Accessibility permission is requested. After capture stops, the plugin renders a second movie at 60 fps that zooms to 1.6× around clicks and scrolling, follows the cursor with a dead zone, and returns to the full view after two seconds of inactivity or when the active window or app changes. Output dimensions and aspect ratio are unchanged. +- **Auto-zoom processing** shows progress in the recording panel. **Keep Original** cancels processing; cancellation or a failure keeps the original movie, named with an Original suffix, and reports its name. A clip without a click or scroll moves the original into place without re-encoding. The finished movie is published before the original is removed. Screenshots, recognition, and scrolling capture remain available while a movie renders; a new recording waits until processing ends. Escape closes the active selection interface. Screenshot and Quick Capture global shortcuts are unassigned by default and are configured above the output section in the plugin's settings. While scrolling capture or recording is active, the capture action finishes that session instead of opening another selection interface. @@ -27,11 +33,11 @@ MacTools owns the Screen Recording permission card (`screen-recording`, kind `sc ## Privacy and retention -Screen images, visible window geometry, recognized text, and codes may contain sensitive information. Capture starts only through an explicit foreground action with a visible selection interface. The two canonical actions do not support external Run Links, automatic rules, or App Intents. The plugin does not capture on startup or run unattended screenshot jobs. +Screen images, visible window geometry, recognized text, and codes may contain sensitive information. With recording auto zoom on, pointer positions, clicks, and scrolling inside the recorded region are kept in memory only for the current recording and discarded after its movie is rendered. Capture starts only through an explicit foreground action with a visible selection interface. The two canonical actions do not support external Run Links, automatic rules, or App Intents. The plugin does not capture on startup or run unattended screenshot jobs. Capture, recognition, editing, and image assembly use Apple frameworks locally. The plugin has no upload, network service, or telemetry. Choosing to open a recognized link explicitly hands that URL to the user's browser; screenshots and recognized text are not sent to a service by the plugin. -Screenshots are copied to the system clipboard unless you choose a save operation. Saved images use PNG; recordings use MOV. The default destination is `~/Desktop/screenshot`; the folder is created on the first save, and the plugin's settings can select another folder. The selected folder is stored in plugin-scoped preferences. Exported files remain until you manage or delete them yourself; clipboard content remains subject to macOS and other clipboard applications. +Screenshots are copied to the system clipboard unless you choose a save operation. Saving to the folder never replaces an existing file; a numbered name is used instead. Cancelling Save As, or a failed write, returns to the same selection and annotations; a pinned scrolling screenshot stays open until its file is written. Saved images use PNG; recordings use MOV. The default destination is `~/Desktop/screenshot`; the folder is created on the first save, and the plugin's settings can select another folder. The selected folder is stored in plugin-scoped preferences. Exported files remain until you manage or delete them yourself; clipboard content remains subject to macOS and other clipboard applications. Ending a selection hides its reusable capture surfaces, clears image references and annotation contents, and prevents pending work from publishing stale results. Idle surfaces do not acquire or retain desktop snapshots. Disabling the plugin destroys these surfaces and closes pinned images and auxiliary windows. Unsaved edits are discarded. If recording has already created a file, interruption or an error can leave an incomplete MOV file in the selected output folder; inspect or remove it yourself. @@ -62,7 +68,7 @@ The plugin prepares one hidden nonactivating `NSPanel` per display when activate Before each presentation, the pool checks each cached panel's public `isOnActiveSpace` property, including hidden panels. If AppKit reports an inactive Space, only that display's panel is closed and recreated; unaffected panels retain their controls. Panels also use `canJoinAllApplications` to join other apps' full-screen Spaces without participating in Stage Manager's window layout. Both APIs are available throughout the macOS 14+ support range. This check runs after image acquisition and adds no idle polling or Space-change observers. -On macOS 26 and later, screenshot configuration explicitly sets `ignoreShadows = false` to preserve system-drawn window framing, including glass edge highlights and shadows within the captured region. Relying on the default can remove these effects from the source image before either preview or export. This is separate from optional annotation shadows and does not reconstruct window decorations. +On macOS 26 and later, screenshot configuration explicitly sets `ignoreShadows = false` to preserve system-drawn window framing, including glass edge highlights and shadows within the captured region. Relying on the default can remove these effects from the source image before either preview or export. Earlier systems use stream configurations, which set `ignoreShadowsSingleWindow = false` for the same reason: its default also strips menu and popover shadows from display captures, including scrolling capture and recording. This is separate from optional annotation shadows and does not reconstruct window decorations. The original `CGImage` is assigned directly to a dedicated `CALayer`, with the source color space assigned to its window. Separate shape layers draw dimming and selection outlines, and an explicit shadow path keeps shadow composition independent of annotation drawing. The size badge and magnifier use small retained AppKit views; the magnifier reuses its source image, localized hints, and a one-pixel sRGB sampling context. The annotation view remains hidden during initial selection and is invalidated explicitly when marks or editing handles change. AppKit coalesces redraws; mouse release always commits the final position. Pointer movement does not redraw the full frozen image or create a full-screen bitmap sampler. The preview, recognition input, and PNG export all use the same frozen source. There is no live-window replacement, popover reconstruction, alpha workaround, startup capture, or continuous idle stream. Display-topology changes cancel selection and rebuild the pool through the host's `DisplayTopologyRefreshing` hook. diff --git a/docs/pr-assets/screenshot-editing-recording/pin-large-dark.png b/docs/pr-assets/screenshot-editing-recording/pin-large-dark.png new file mode 100644 index 000000000..ae0a1d38c Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/pin-large-dark.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/pin-large-light.png b/docs/pr-assets/screenshot-editing-recording/pin-large-light.png new file mode 100644 index 000000000..6419ec08e Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/pin-large-light.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/pin-small-dark.png b/docs/pr-assets/screenshot-editing-recording/pin-small-dark.png new file mode 100644 index 000000000..c87dcd9f4 Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/pin-small-dark.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/pin-small-light.png b/docs/pr-assets/screenshot-editing-recording/pin-small-light.png new file mode 100644 index 000000000..a04e4caef Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/pin-small-light.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/processing-panel-dark.png b/docs/pr-assets/screenshot-editing-recording/processing-panel-dark.png new file mode 100644 index 000000000..8c9bf4ac6 Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/processing-panel-dark.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/processing-panel-light.png b/docs/pr-assets/screenshot-editing-recording/processing-panel-light.png new file mode 100644 index 000000000..1a47294c7 Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/processing-panel-light.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/record-bar-dark.png b/docs/pr-assets/screenshot-editing-recording/record-bar-dark.png new file mode 100644 index 000000000..28c01ebc0 Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/record-bar-dark.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/record-bar-light.png b/docs/pr-assets/screenshot-editing-recording/record-bar-light.png new file mode 100644 index 000000000..1f9e872df Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/record-bar-light.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/settings-recording-light.png b/docs/pr-assets/screenshot-editing-recording/settings-recording-light.png new file mode 100644 index 000000000..c5109f59a Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/settings-recording-light.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/toolbar-fill-dark.png b/docs/pr-assets/screenshot-editing-recording/toolbar-fill-dark.png new file mode 100644 index 000000000..6d2a79185 Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/toolbar-fill-dark.png differ diff --git a/docs/pr-assets/screenshot-editing-recording/toolbar-fill-light.png b/docs/pr-assets/screenshot-editing-recording/toolbar-fill-light.png new file mode 100644 index 000000000..af8a37fbc Binary files /dev/null and b/docs/pr-assets/screenshot-editing-recording/toolbar-fill-light.png differ diff --git a/site/src/generated/plugins.json b/site/src/generated/plugins.json index f9debafe8..b7e7f12a8 100644 --- a/site/src/generated/plugins.json +++ b/site/src/generated/plugins.json @@ -19214,14 +19214,12 @@ "scrolling screenshot", "screen recording", "pin", - "blur", "pixelate" ], "localizedSynonyms": { "ar": [ "لقطة شاشة", "التقاط سريع", - "طمس (B)", "فسيفساء (M)", "لقطة شاشة بالتمرير", "تثبيت الصورة على الشاشة", @@ -19233,7 +19231,6 @@ "de": [ "Bildschirmfoto", "Schnellaufnahme", - "Weichzeichnen (B)", "Mosaik (M)", "Scroll-Screenshot", "Bild auf dem Bildschirm fixieren", @@ -19245,7 +19242,6 @@ "en": [ "Screenshot", "Quick Capture", - "Blur (B)", "Pixelate (M)", "Scrolling Screenshot", "Pin image on screen", @@ -19257,7 +19253,6 @@ "es": [ "Captura de pantalla", "Captura rápida", - "Desenfoque (B)", "Mosaico (M)", "Captura de pantalla con desplazamiento", "Fijar imagen en pantalla", @@ -19269,7 +19264,6 @@ "fr": [ "Capture d’écran", "Capture rapide", - "Flou (B)", "Mosaïque (M)", "Capture d'écran avec défilement", "Épingler l'image à l'écran", @@ -19281,7 +19275,6 @@ "ja": [ "スクリーンショット", "クイック撮影", - "ぼかし (B)", "モザイク(M)", "スクロールスクリーンショット", "画像を画面に固定", @@ -19293,7 +19286,6 @@ "ko": [ "스크린샷", "빠른 캡처", - "블러(B)", "모자이크(M)", "스크롤 스크린샷", "화면에 이미지 고정하기", @@ -19305,7 +19297,6 @@ "pt": [ "Captura de tela", "Captura rápida", - "Desfoque (B)", "Mosaico (M)", "Captura de tela de rolagem", "Fixar imagem na tela", @@ -19317,7 +19308,6 @@ "ru": [ "Снимок экрана", "Быстрый снимок", - "Размытие (B)", "Мозаика (M)", "Скриншот с прокруткой", "Закрепить изображение на экране", @@ -19328,7 +19318,6 @@ ], "tr": [ "Ekran Görüntüsü", - "Bulanıklaştır (B)", "Mozaik (M)", "Kaydırmalı Ekran Görüntüsü", "Görseli ekrana sabitle", @@ -19340,9 +19329,7 @@ "zh-Hans": [ "截图", "快速截图", - "模糊", "马赛克", - "模糊 (B)", "马赛克 (M)", "滚动截图", "贴图:钉在屏幕上", @@ -19354,7 +19341,6 @@ "zh-Hant": [ "截圖", "快速截圖", - "模糊 (B)", "馬賽克 (M)", "捲動截圖", "將圖片釘選在螢幕上", @@ -19442,18 +19428,18 @@ ], "license": "GPL-3.0-only", "longDescription": { - "ar": "أداة محلية بلقطات مناطق ونوافذ وتعليقات وإخفاء بالموزاييك أو التمويه والتعرّف على النصوص والرموز وتثبيت الصور ولقطات التمرير. تبدأ فقط بإجراء صريح في الواجهة. تسجيل المناطق بدون صوت يتطلب macOS 15 أو أحدث؛ الميزات الأخرى تعمل على macOS 14 أو أحدث.", - "de": "Lokale Bereichs- und Fensteraufnahmen mit Anmerkungen, Mosaik und Weichzeichnen, Text- und Codeerkennung, angehefteten Bildern und Scrollaufnahmen. Start nur durch eine ausdrückliche Vordergrundaktion. Bereichsvideos ohne Ton benötigen macOS 15; alle anderen Funktionen macOS 14.", - "en": "Local region and window capture with annotations, mosaic and blur, text and code recognition, pinned images, and scrolling screenshots. Starts only through an explicit foreground action. Silent region recording requires macOS 15 or later; other features support macOS 14 or later.", - "es": "Capturas locales de regiones y ventanas con anotaciones, mosaico, desenfoque, reconocimiento de texto y códigos, imágenes fijadas y capturas con desplazamiento. Solo comienza mediante una acción explícita en primer plano. La grabación de regiones sin audio requiere macOS 15; las otras funciones, macOS 14.", - "fr": "Captures locales de zones et fenêtres avec annotations, mosaïque et flou, reconnaissance de texte et de codes, images épinglées et captures défilantes. Démarrage uniquement par une action explicite au premier plan. L’enregistrement de zones sans audio nécessite macOS 15 ; les autres fonctions, macOS 14.", - "ja": "範囲・ウインドウ撮影、注釈、モザイク・ぼかし、文字・コード認識、画像のピン留め、スクロール撮影をローカルで実行します。明示的な前面操作でのみ開始します。音声なしの範囲収録には macOS 15 以降、その他の機能には macOS 14 以降が必要です。", - "ko": "영역·창 캡처, 주석, 모자이크·흐림, 텍스트·코드 인식, 이미지 고정, 스크롤 캡처를 로컬에서 처리합니다. 명시적인 전경 작업으로만 시작합니다. 소리 없는 영역 녹화는 macOS 15 이상, 나머지 기능은 macOS 14 이상이 필요합니다.", - "pt": "Capturas locais de áreas e janelas com anotações, mosaico, desfoque, reconhecimento de textos e códigos, imagens fixadas e rolagem. Inicia apenas por uma ação explícita em primeiro plano. A gravação de áreas sem áudio exige macOS 15; os demais recursos, macOS 14.", - "ru": "Локальные снимки областей и окон с пометками, мозаикой и размытием, распознаванием текста и кодов, закреплением изображений и прокруткой. Запуск только явным действием на переднем плане. Запись области без звука требует macOS 15; остальные функции — macOS 14.", - "tr": "Ek açıklamalar, mozaik ve bulanıklık, metin ve kod tanıma, sabitlenmiş görseller ve kaydırma ekran görüntüleriyle yerel bölge ve pencere yakalama. Yalnızca açık bir ön plan eylemiyle başlar. Sessiz bölge kaydı macOS 15 veya sonrasını gerektirir; diğer özellikler macOS 14 veya sonrasını destekler.", - "zh-Hans": "本地完成区域和窗口截图、标注、马赛克与模糊、文字与二维码识别、贴图和滚动截图。仅由明确的前台操作启动;无声区域录屏需要 macOS 15 或更高,其他功能支持 macOS 14 或更高。", - "zh-Hant": "在本機完成區域和視窗截圖、標註、馬賽克與模糊、文字與 QR 碼辨識、釘選圖片及捲動截圖。僅由明確的前景操作啟動;無聲區域錄製需要 macOS 15 或更新版本,其他功能支援 macOS 14 或更新版本。" + "ar": "أداة محلية بلقطات مناطق ونوافذ وتعليقات وإخفاء بالموزاييك والتعرّف على النصوص والرموز وتثبيت الصور ولقطات التمرير. تبدأ فقط بإجراء صريح في الواجهة. تسجيل المناطق بدون صوت يتطلب macOS 15 أو أحدث؛ الميزات الأخرى تعمل على macOS 14 أو أحدث.", + "de": "Lokale Bereichs- und Fensteraufnahmen mit Anmerkungen, Mosaik, Text- und Codeerkennung, angehefteten Bildern und Scrollaufnahmen. Start nur durch eine ausdrückliche Vordergrundaktion. Bereichsvideos ohne Ton benötigen macOS 15; alle anderen Funktionen macOS 14.", + "en": "Local region and window capture with annotations, mosaic, text and code recognition, pinned images, and scrolling screenshots. Starts only through an explicit foreground action. Silent region recording requires macOS 15 or later; other features support macOS 14 or later.", + "es": "Capturas locales de regiones y ventanas con anotaciones, mosaico, reconocimiento de texto y códigos, imágenes fijadas y capturas con desplazamiento. Solo comienza mediante una acción explícita en primer plano. La grabación de regiones sin audio requiere macOS 15; las otras funciones, macOS 14.", + "fr": "Captures locales de zones et fenêtres avec annotations, mosaïque, reconnaissance de texte et de codes, images épinglées et captures défilantes. Démarrage uniquement par une action explicite au premier plan. L’enregistrement de zones sans audio nécessite macOS 15 ; les autres fonctions, macOS 14.", + "ja": "範囲・ウインドウ撮影、注釈、モザイク、文字・コード認識、画像のピン留め、スクロール撮影をローカルで実行します。明示的な前面操作でのみ開始します。音声なしの範囲収録には macOS 15 以降、その他の機能には macOS 14 以降が必要です。", + "ko": "영역·창 캡처, 주석, 모자이크, 텍스트·코드 인식, 이미지 고정, 스크롤 캡처를 로컬에서 처리합니다. 명시적인 전경 작업으로만 시작합니다. 소리 없는 영역 녹화는 macOS 15 이상, 나머지 기능은 macOS 14 이상이 필요합니다.", + "pt": "Capturas locais de áreas e janelas com anotações, mosaico, reconhecimento de textos e códigos, imagens fixadas e rolagem. Inicia apenas por uma ação explícita em primeiro plano. A gravação de áreas sem áudio exige macOS 15; os demais recursos, macOS 14.", + "ru": "Локальные снимки областей и окон с пометками, мозаикой, распознаванием текста и кодов, закреплением изображений и прокруткой. Запуск только явным действием на переднем плане. Запись области без звука требует macOS 15; остальные функции — macOS 14.", + "tr": "Ek açıklamalar, mozaik, metin ve kod tanıma, sabitlenmiş görseller ve kaydırma ekran görüntüleriyle yerel bölge ve pencere yakalama. Yalnızca açık bir ön plan eylemiyle başlar. Sessiz bölge kaydı macOS 15 veya sonrasını gerektirir; diğer özellikler macOS 14 veya sonrasını destekler.", + "zh-Hans": "本地完成区域和窗口截图、标注、马赛克、文字与二维码识别、贴图和滚动截图。仅由明确的前台操作启动;无声区域录屏需要 macOS 15 或更高,其他功能支持 macOS 14 或更高。", + "zh-Hant": "在本機完成區域和視窗截圖、標註、馬賽克、文字與 QR 碼辨識、釘選圖片及捲動截圖。僅由明確的前景操作啟動;無聲區域錄製需要 macOS 15 或更新版本,其他功能支援 macOS 14 或更新版本。" }, "publisher": "MacTools", "screenshots": [], @@ -19464,7 +19450,8 @@ "dataObserved": [ "screen-pixels", "window-geometry", - "recognized-text-and-codes" + "recognized-text-and-codes", + "pointer-events" ], "dataPersisted": [ "user-exported-screenshots",