node-mac-recorder 2.24.4 → 2.24.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +101 -68
- package/package.json +1 -1
- package/src/mac_recorder.mm +57 -18
- package/src/screen_capture_kit.mm +281 -62
- package/src/window_selector.mm +211 -97
package/index.js
CHANGED
|
@@ -835,8 +835,12 @@ class MacRecorder extends EventEmitter {
|
|
|
835
835
|
};
|
|
836
836
|
}
|
|
837
837
|
|
|
838
|
-
//
|
|
839
|
-
|
|
838
|
+
// Gerçek alan kaydında crop'u native tarafa ilet. Pencere kaydında
|
|
839
|
+
// captureArea yalnızca cursor koordinat metadata'sıdır; ScreenCaptureKit
|
|
840
|
+
// desktopIndependentWindow zaten doğru pencereyi filtreler. Bunu ayrıca
|
|
841
|
+
// crop olarak göndermek native tarafta Retina 2x boyutu tekrar 1x
|
|
842
|
+
// logical pencere ölçüsüne indiriyordu.
|
|
843
|
+
if (this.options.captureArea && !this.options.windowId) {
|
|
840
844
|
recordingOptions.captureArea = {
|
|
841
845
|
x: this.options.captureArea.x,
|
|
842
846
|
y: this.options.captureArea.y,
|
|
@@ -899,79 +903,58 @@ class MacRecorder extends EventEmitter {
|
|
|
899
903
|
}
|
|
900
904
|
};
|
|
901
905
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
`✅ SYNC: Video ilk karesi ${Date.now() - waitStart}ms'de yakalandi`
|
|
911
|
-
);
|
|
912
|
-
break;
|
|
913
|
-
}
|
|
914
|
-
await new Promise(r => setTimeout(r, VIDEO_START_POLL_MS));
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
if (!videoStartTimestamp) {
|
|
918
|
-
console.warn(
|
|
919
|
-
`⚠️ SYNC: Video baslangici ${VIDEO_START_TIMEOUT_MS}ms icinde okunamadi — heuristik hizalamaya dusuluyor`
|
|
920
|
-
);
|
|
921
|
-
}
|
|
922
|
-
} else {
|
|
923
|
-
// AVFoundation yolunda video-start damgasi yok; eski
|
|
924
|
-
// hazir-olma beklemesi korunuyor.
|
|
925
|
-
while (Date.now() - waitStart < 600) {
|
|
926
|
-
try {
|
|
927
|
-
if (
|
|
928
|
-
nativeBinding &&
|
|
929
|
-
nativeBinding.getRecordingStatus &&
|
|
930
|
-
nativeBinding.getRecordingStatus()
|
|
931
|
-
) {
|
|
932
|
-
break;
|
|
933
|
-
}
|
|
934
|
-
} catch (_) {}
|
|
935
|
-
await new Promise(r => setTimeout(r, 30));
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
906
|
+
// ONEMLI: Bu bekleme BLOKLAMAZ.
|
|
907
|
+
// Ilk kare, yakalandigi andan ~200ms+ sonra islenip bize ulasiyor
|
|
908
|
+
// (clamshell'de saniyeler). startRecording bunu beklerse kayit
|
|
909
|
+
// coktan basladigi halde UI "Starting..." gostergesinde takili
|
|
910
|
+
// kaliyor. Bu yuzden cursor/klavye HEMEN baslatiliyor (veri kaybi
|
|
911
|
+
// olmasin), video baslangici ARKA PLANDA yakalanip saklaniyor;
|
|
912
|
+
// stop sirasinda cursor JSON'una gercek offset yaziliyor ve
|
|
913
|
+
// editor bunu birebir telafi ediyor.
|
|
939
914
|
this.sessionTimestamp = sessionTimestamp;
|
|
940
|
-
this.videoStartTimestamp =
|
|
941
|
-
// Onceki kayittan kalan referans sizmasin
|
|
942
|
-
this.timelineStartTimestamp = 0;
|
|
915
|
+
this.videoStartTimestamp = 0;
|
|
943
916
|
|
|
944
917
|
const syncTimestamp = Date.now();
|
|
945
918
|
this.syncTimestamp = syncTimestamp;
|
|
946
919
|
this.recordingStartTime = syncTimestamp;
|
|
920
|
+
this.timelineStartTimestamp = syncTimestamp;
|
|
947
921
|
|
|
948
|
-
if (
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
)
|
|
922
|
+
if (usesScreenCaptureKit) {
|
|
923
|
+
const watchStart = Date.now();
|
|
924
|
+
this._videoStartWatcherActive = true;
|
|
925
|
+
this._videoStartWatcher = (async () => {
|
|
926
|
+
while (
|
|
927
|
+
this._videoStartWatcherActive &&
|
|
928
|
+
Date.now() - watchStart < VIDEO_START_TIMEOUT_MS
|
|
929
|
+
) {
|
|
930
|
+
const value = readVideoStart();
|
|
931
|
+
if (value > 0) {
|
|
932
|
+
this.videoStartTimestamp = value;
|
|
933
|
+
console.log(
|
|
934
|
+
`✅ SYNC: Video ilk karesi ${Date.now() - watchStart}ms'de yakalandi (cursor'a gore ${(syncTimestamp - value).toFixed(0)}ms once)`
|
|
935
|
+
);
|
|
936
|
+
return value;
|
|
937
|
+
}
|
|
938
|
+
await new Promise(r => setTimeout(r, VIDEO_START_POLL_MS));
|
|
939
|
+
}
|
|
940
|
+
if (this._videoStartWatcherActive) {
|
|
941
|
+
console.warn(
|
|
942
|
+
`⚠️ SYNC: Video baslangici ${VIDEO_START_TIMEOUT_MS}ms icinde okunamadi — heuristik hizalamaya dusuluyor`
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
return 0;
|
|
946
|
+
})();
|
|
947
|
+
// Kayit akisini etkilemesin
|
|
948
|
+
this._videoStartWatcher.catch(() => {});
|
|
952
949
|
}
|
|
953
950
|
|
|
954
|
-
// ZAMAN REFERANSI: cursor/klavye timeline'i
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
|
|
961
|
-
// farkli bir kayma olusuyor ve editordeki telafi 1sn limitine
|
|
962
|
-
// takilabiliyor. Videonun kendi baslangicini referans alinca fark
|
|
963
|
-
// yapisal olarak sifir olur; hizlanma senkronu bozmaz.
|
|
964
|
-
const timelineStartTimestamp =
|
|
965
|
-
this.videoStartTimestamp > 0
|
|
966
|
-
? this.videoStartTimestamp
|
|
967
|
-
: syncTimestamp;
|
|
968
|
-
this.timelineStartTimestamp = timelineStartTimestamp;
|
|
969
|
-
|
|
970
|
-
if (timelineStartTimestamp !== syncTimestamp) {
|
|
971
|
-
console.log(
|
|
972
|
-
`🎯 SYNC: Timeline referansi video ilk karesine cekildi (${(syncTimestamp - timelineStartTimestamp).toFixed(0)}ms geri)`
|
|
973
|
-
);
|
|
974
|
-
}
|
|
951
|
+
// ZAMAN REFERANSI: cursor/klavye timeline'i SIMDI baslar
|
|
952
|
+
// (syncTimestamp). Videonun gercek t=0'i genelde bundan biraz
|
|
953
|
+
// oncedir; aradaki fark stop'ta cursor JSON'una yazilir ve
|
|
954
|
+
// editor birebir telafi eder. Bekleyip "sifir offset" elde
|
|
955
|
+
// etmek yerine bu yol tercih ediliyor cunku bekleme, kayit
|
|
956
|
+
// coktan basladigi halde UI'i "Starting..." halinde tutuyordu.
|
|
957
|
+
const timelineStartTimestamp = syncTimestamp;
|
|
975
958
|
|
|
976
959
|
const standardCursorOptions = {
|
|
977
960
|
videoRelative: true,
|
|
@@ -1085,11 +1068,43 @@ class MacRecorder extends EventEmitter {
|
|
|
1085
1068
|
}, 1000);
|
|
1086
1069
|
|
|
1087
1070
|
// Native kayıt gerçekten başladığını kontrol etmek için polling başlat
|
|
1071
|
+
//
|
|
1072
|
+
// KRITIK: getRecordingStatus() TEK BASINA KULLANILAMAZ.
|
|
1073
|
+
// ScreenCaptureKit yolunda `isFullyInitialized` = 10 KARE sarti
|
|
1074
|
+
// arıyor. Ama video writer ILK KAREDE basliyor — yani kayit
|
|
1075
|
+
// coktan basladigi halde bu bayrak false kalabiliyor.
|
|
1076
|
+
// ScreenCaptureKit statik ekranda yeni kare URETMEDIGI icin
|
|
1077
|
+
// (sadece degisiklik oldugunda kare gonderir) kullanici kayda
|
|
1078
|
+
// basip bekledigi anda 10 kare hic dolmuyor ve UI saniyelerce
|
|
1079
|
+
// "Starting recorder..." gosteriyordu; bu sure de videoya giriyordu.
|
|
1080
|
+
//
|
|
1081
|
+
// Dogru sinyal ilk karedir: videoStartTimestamp > 0 (arka plandaki
|
|
1082
|
+
// _videoStartWatcher set eder). Boylece UI, videonun gercek
|
|
1083
|
+
// baslangici ile ayni ana kapanir.
|
|
1088
1084
|
let recordingStartedEmitted = false;
|
|
1089
1085
|
let checkRecordingStatus = null;
|
|
1086
|
+
const isNativeRecordingLive = () => {
|
|
1087
|
+
if (this.options.preferScreenCaptureKit === true) {
|
|
1088
|
+
// SCK yolunda TEK dogru sinyal ilk karedir.
|
|
1089
|
+
// getRecordingStatus() burada iki yonlu yaniltiyor:
|
|
1090
|
+
// - SCK stream'i henuz baslamadiysa g_isRecording
|
|
1091
|
+
// fallback'ine dusup ERKEN true doner (video yokken
|
|
1092
|
+
// "kayit basladi" denir)
|
|
1093
|
+
// - SCK basladiysa isFullyInitialized = 10 kare bekler,
|
|
1094
|
+
// statik ekranda bu hic dolmaz ve GEC true doner
|
|
1095
|
+
// Hangisinin kazandigi yarisa bagli; ilk kare ise kesin.
|
|
1096
|
+
return Number(this.videoStartTimestamp) > 0;
|
|
1097
|
+
}
|
|
1098
|
+
// AVFoundation yolu: video-start damgasi yok, eski sinyal
|
|
1099
|
+
try {
|
|
1100
|
+
return nativeBinding.getRecordingStatus();
|
|
1101
|
+
} catch (_) {
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1090
1105
|
const pollRecordingStatus = () => {
|
|
1091
1106
|
try {
|
|
1092
|
-
const nativeStatus =
|
|
1107
|
+
const nativeStatus = isNativeRecordingLive();
|
|
1093
1108
|
if (nativeStatus && !recordingStartedEmitted) {
|
|
1094
1109
|
recordingStartedEmitted = true;
|
|
1095
1110
|
clearInterval(checkRecordingStatus);
|
|
@@ -1229,6 +1244,11 @@ class MacRecorder extends EventEmitter {
|
|
|
1229
1244
|
try {
|
|
1230
1245
|
console.log('🛑 SYNC: Stopping all recording components simultaneously');
|
|
1231
1246
|
|
|
1247
|
+
// Video baslangicini bekleyen arka plan izleyicisini sonlandir.
|
|
1248
|
+
// Deger geldiyse zaten this.videoStartTimestamp'e yazildi ve
|
|
1249
|
+
// cursor metadata'sinda kullanilacak.
|
|
1250
|
+
this._videoStartWatcherActive = false;
|
|
1251
|
+
|
|
1232
1252
|
// SYNC FIX: Stop ALL components at the same time for perfect sync
|
|
1233
1253
|
// 1. Stop cursor tracking FIRST (it's instant)
|
|
1234
1254
|
if (this.cursorCaptureInterval) {
|
|
@@ -1396,6 +1416,19 @@ class MacRecorder extends EventEmitter {
|
|
|
1396
1416
|
};
|
|
1397
1417
|
}
|
|
1398
1418
|
|
|
1419
|
+
/**
|
|
1420
|
+
* Encoder'ın gerçek zamanlı yetişip yetişmediğini gösteren kare sayaçları.
|
|
1421
|
+
* Kayıt sırasında canlı, bittikten sonra son oturumun değerlerini döndürür.
|
|
1422
|
+
* dropRatio > 0.02 ise çözünürlük x FPS x bitrate bu makine için ağırdır ve
|
|
1423
|
+
* görüntü kalitesi sessizce düşüyor demektir.
|
|
1424
|
+
*/
|
|
1425
|
+
getCaptureFrameStats() {
|
|
1426
|
+
if (typeof nativeBinding.getCaptureFrameStats !== "function") {
|
|
1427
|
+
return { appendedFrames: 0, droppedFrames: 0, targetFps: 0, dropRatio: 0 };
|
|
1428
|
+
}
|
|
1429
|
+
return nativeBinding.getCaptureFrameStats();
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1399
1432
|
/**
|
|
1400
1433
|
* macOS'ta kayıt izinlerini kontrol eder
|
|
1401
1434
|
*/
|
package/package.json
CHANGED
package/src/mac_recorder.mm
CHANGED
|
@@ -43,6 +43,7 @@ extern "C" {
|
|
|
43
43
|
bool hasAudioPermission();
|
|
44
44
|
|
|
45
45
|
NSString *ScreenCaptureKitCurrentAudioPath(void);
|
|
46
|
+
void ScreenCaptureKitGetFrameStats(long *appendedOut, long *droppedOut, long *targetFPSOut);
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
// Cursor tracker function declarations
|
|
@@ -627,14 +628,39 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
|
|
|
627
628
|
// Use ScreenCaptureKit with window exclusion and timeout protection
|
|
628
629
|
NSError *sckError = nil;
|
|
629
630
|
|
|
630
|
-
// A/V SYNC:
|
|
631
|
-
//
|
|
631
|
+
// A/V SYNC: Kamera SCK'dan ONCE baslatilir VE onayi burada beklenir.
|
|
632
|
+
//
|
|
633
|
+
// ESKIDEN: kamera non-blocking baslatilip SCK hemen schedule
|
|
634
|
+
// ediliyor, kamera onayi SCK'dan SONRA bekleniyordu. Sorun:
|
|
635
|
+
// SCK schedule edilir edilmez video ilk karesini yakalayip
|
|
636
|
+
// KAYDA BASLIYOR, ama JS tarafi hala kamera onayini bekledigi
|
|
637
|
+
// icin (startRecording senkron bir N-API cagrisi, event loop
|
|
638
|
+
// bloke) "kayit basladi" sinyali ~1sn gec gidiyordu.
|
|
639
|
+
// Olculdu: kamerali startRecording 1319ms / kamerasiz 508ms.
|
|
640
|
+
// Bu fark dogrudan videonun basina fazlalik olarak yaziliyor
|
|
641
|
+
// ve UI o sure boyunca "Starting recorder..." gosteriyordu.
|
|
642
|
+
//
|
|
643
|
+
// SIMDI: kamera hazir olduktan SONRA video baslar. Basta
|
|
644
|
+
// fazlalik olusmaz ve kamera ile ekran ayni ana hizalanir.
|
|
632
645
|
if (captureCamera) {
|
|
633
|
-
MRLog(@"🎥 Starting camera recording
|
|
646
|
+
MRLog(@"🎥 Starting camera recording (before SCK, blocking until ready)");
|
|
634
647
|
if (!startCameraIfRequested(true, &cameraOutputPath, cameraDeviceId, outputPath, sessionTimestamp)) {
|
|
635
648
|
MRLog(@"❌ Camera failed to start - aborting recording");
|
|
636
649
|
return Napi::Boolean::New(env, false);
|
|
637
650
|
}
|
|
651
|
+
|
|
652
|
+
if (!waitForCameraRecordingStart(8.0)) {
|
|
653
|
+
double cameraStartTs = currentCameraRecordingStartTime();
|
|
654
|
+
if (cameraStartTs > 0 || isCameraRecording()) {
|
|
655
|
+
MRLog(@"⚠️ Camera did not confirm start within 8.0s but appears running; continuing");
|
|
656
|
+
} else {
|
|
657
|
+
MRLog(@"❌ Camera did not signal recording start within 8.0s");
|
|
658
|
+
stopCameraRecording();
|
|
659
|
+
return Napi::Boolean::New(env, false);
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
MRLog(@"✅ Camera recording confirmed (before SCK start)");
|
|
663
|
+
}
|
|
638
664
|
}
|
|
639
665
|
|
|
640
666
|
// Start SCK immediately - don't wait for camera confirmation
|
|
@@ -647,21 +673,10 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
|
|
|
647
673
|
MRLog(@"🎬 RECORDING METHOD: ScreenCaptureKit");
|
|
648
674
|
MRLog(@"✅ SYNC: ScreenCaptureKit recording started successfully");
|
|
649
675
|
|
|
650
|
-
//
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (cameraStartTs > 0 || isCameraRecording()) {
|
|
655
|
-
MRLog(@"⚠️ Camera did not confirm start within 8.0s but appears running; continuing");
|
|
656
|
-
} else {
|
|
657
|
-
MRLog(@"❌ Camera did not signal recording start within 8.0s");
|
|
658
|
-
stopCameraRecording();
|
|
659
|
-
[ScreenCaptureKitRecorder stopRecording];
|
|
660
|
-
return Napi::Boolean::New(env, false);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
MRLog(@"✅ Camera recording confirmed (started in parallel with SCK)");
|
|
664
|
-
}
|
|
676
|
+
// NOT: Kamera onayi artik SCK'dan ONCE bekleniyor
|
|
677
|
+
// (yukariya alindi) — burada blocking bir is kalmadi,
|
|
678
|
+
// boylece SCK schedule edildikten sonra JS'e donus
|
|
679
|
+
// gecikmiyor ve videonun basinda fazlalik olusmuyor.
|
|
665
680
|
|
|
666
681
|
g_isRecording = true;
|
|
667
682
|
MRMarkRecordingStartTimestamp();
|
|
@@ -1386,6 +1401,29 @@ Napi::Value GetRecordingStatus(const Napi::CallbackInfo& info) {
|
|
|
1386
1401
|
return Napi::Boolean::New(env, g_isRecording);
|
|
1387
1402
|
}
|
|
1388
1403
|
|
|
1404
|
+
// NAPI Function: Yakalama sirasinda encoder'in dusurdugu kare istatistikleri.
|
|
1405
|
+
// dropRatio > ~2 ise cozunurluk x FPS x bitrate bu makine icin surdurulebilir
|
|
1406
|
+
// degildir ve "video kalitesiz" sikayetinin olculebilir kanitidir.
|
|
1407
|
+
Napi::Value GetCaptureFrameStats(const Napi::CallbackInfo& info) {
|
|
1408
|
+
Napi::Env env = info.Env();
|
|
1409
|
+
Napi::Object result = Napi::Object::New(env);
|
|
1410
|
+
|
|
1411
|
+
long appended = 0;
|
|
1412
|
+
long dropped = 0;
|
|
1413
|
+
long targetFPS = 0;
|
|
1414
|
+
if (@available(macOS 12.3, *)) {
|
|
1415
|
+
ScreenCaptureKitGetFrameStats(&appended, &dropped, &targetFPS);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
long total = appended + dropped;
|
|
1419
|
+
result.Set("appendedFrames", Napi::Number::New(env, (double)appended));
|
|
1420
|
+
result.Set("droppedFrames", Napi::Number::New(env, (double)dropped));
|
|
1421
|
+
result.Set("targetFps", Napi::Number::New(env, (double)targetFPS));
|
|
1422
|
+
result.Set("dropRatio",
|
|
1423
|
+
Napi::Number::New(env, total > 0 ? (double)dropped / (double)total : 0.0));
|
|
1424
|
+
return result;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1389
1427
|
// NAPI Function: Get Window Thumbnail
|
|
1390
1428
|
Napi::Value GetWindowThumbnail(const Napi::CallbackInfo& info) {
|
|
1391
1429
|
Napi::Env env = info.Env();
|
|
@@ -1724,6 +1762,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
|
1724
1762
|
exports.Set(Napi::String::New(env, "getDisplays"), Napi::Function::New(env, GetDisplays));
|
|
1725
1763
|
exports.Set(Napi::String::New(env, "getWindows"), Napi::Function::New(env, GetWindows));
|
|
1726
1764
|
exports.Set(Napi::String::New(env, "getRecordingStatus"), Napi::Function::New(env, GetRecordingStatus));
|
|
1765
|
+
exports.Set(Napi::String::New(env, "getCaptureFrameStats"), Napi::Function::New(env, GetCaptureFrameStats));
|
|
1727
1766
|
exports.Set(Napi::String::New(env, "prewarmScreenCapture"), Napi::Function::New(env, PrewarmScreenCapture));
|
|
1728
1767
|
exports.Set(Napi::String::New(env, "getVideoStartTimestamp"), Napi::Function::New(env, GetVideoStartTimestamp));
|
|
1729
1768
|
exports.Set(Napi::String::New(env, "checkPermissions"), Napi::Function::New(env, CheckPermissions));
|
|
@@ -165,7 +165,56 @@ static NSInteger g_targetFPS = 60;
|
|
|
165
165
|
static NSString *g_qualityPreset = @"high";
|
|
166
166
|
static NSInteger g_frameCount = 0;
|
|
167
167
|
static CFAbsoluteTime g_firstFrameTime = 0;
|
|
168
|
-
|
|
168
|
+
|
|
169
|
+
// Encoder gercek zamanli yetisemezse kare SESSIZCE dusuyordu (readyForMoreMediaData
|
|
170
|
+
// NO -> return). Fansiz makinelerde (MacBook Air) 60 FPS Retina yakalamada bu
|
|
171
|
+
// gorunmez kalite kaybinin ana kaynagi; sayilmadan teshis edilemiyor.
|
|
172
|
+
static NSInteger g_videoFramesAppended = 0;
|
|
173
|
+
static NSInteger g_videoFramesDroppedNotReady = 0;
|
|
174
|
+
// CleanupWriters sayaclari sifirladigi icin JS tarafi kayit bittikten SONRA
|
|
175
|
+
// okuyabilsin diye son oturumun degerleri ayrica saklanir.
|
|
176
|
+
static NSInteger g_lastVideoFramesAppended = 0;
|
|
177
|
+
static NSInteger g_lastVideoFramesDropped = 0;
|
|
178
|
+
static NSInteger g_lastVideoTargetFPS = 0;
|
|
179
|
+
|
|
180
|
+
// ---- Prewarm edilmis SCShareableContent onbellegi ----
|
|
181
|
+
// Kayit baslatilirken envanter cagrisini tamamen atlayabilmek icin saklanir.
|
|
182
|
+
// ARC KAPALI: retain/release elle yonetiliyor.
|
|
183
|
+
static id g_cachedShareableContent = nil;
|
|
184
|
+
static CFAbsoluteTime g_cachedShareableContentTime = 0;
|
|
185
|
+
// Bayat envanter yanlis pencere/ekran secimine yol acabilir; kisa tutuluyor.
|
|
186
|
+
static const CFAbsoluteTime kShareableContentCacheTTLSeconds = 30.0;
|
|
187
|
+
|
|
188
|
+
static void SCKStoreShareableContent(id content) {
|
|
189
|
+
if (!content) return;
|
|
190
|
+
@synchronized([ScreenCaptureKitRecorder class]) {
|
|
191
|
+
if (g_cachedShareableContent != content) {
|
|
192
|
+
[g_cachedShareableContent release];
|
|
193
|
+
g_cachedShareableContent = [content retain];
|
|
194
|
+
}
|
|
195
|
+
g_cachedShareableContentTime = CFAbsoluteTimeGetCurrent();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Taze onbellek varsa doner ve onbellegi bosaltir (her kayit taze envanterle
|
|
200
|
+
// baslasin diye tek kullanimlik). Yoksa nil.
|
|
201
|
+
static id SCKTakeCachedShareableContent(void) {
|
|
202
|
+
@synchronized([ScreenCaptureKitRecorder class]) {
|
|
203
|
+
if (!g_cachedShareableContent) {
|
|
204
|
+
return nil;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
CFAbsoluteTime age = CFAbsoluteTimeGetCurrent() - g_cachedShareableContentTime;
|
|
208
|
+
id content = [[g_cachedShareableContent retain] autorelease];
|
|
209
|
+
[g_cachedShareableContent release];
|
|
210
|
+
g_cachedShareableContent = nil;
|
|
211
|
+
|
|
212
|
+
if (age > kShareableContentCacheTTLSeconds) {
|
|
213
|
+
return nil;
|
|
214
|
+
}
|
|
215
|
+
return content;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
169
218
|
|
|
170
219
|
// Quality helpers
|
|
171
220
|
static NSString *SCKNormalizeQualityPreset(id preset) {
|
|
@@ -193,42 +242,88 @@ static CGFloat SCKQualityScaleForPreset(NSString *preset) {
|
|
|
193
242
|
return 1.0; // High = full resolution
|
|
194
243
|
}
|
|
195
244
|
|
|
245
|
+
// SCDisplay.width/height ve CGDisplayPixelsWide/High ölçekli Retina modlarında
|
|
246
|
+
// mantıksal (Quartz) çözünürlüğü döndürebiliyor. Zoom sırasında gerçek piksel
|
|
247
|
+
// detayını korumak için aktif display mode'un backing pixel ölçüsünü, global
|
|
248
|
+
// point-space frame'e böl. ScreenCaptureKit filter scale'i bazı bağımsız pencere
|
|
249
|
+
// filtrelerinde 1.0 raporladığından bu değer güvenilir alt sınırdır.
|
|
250
|
+
static CGFloat SCKBackingScaleForDisplay(SCDisplay *display) API_AVAILABLE(macos(12.3)) {
|
|
251
|
+
if (!display) return 1.0;
|
|
252
|
+
|
|
253
|
+
CGFloat logicalWidth = display.frame.size.width;
|
|
254
|
+
CGFloat logicalHeight = display.frame.size.height;
|
|
255
|
+
NSInteger backingWidth = 0;
|
|
256
|
+
NSInteger backingHeight = 0;
|
|
257
|
+
|
|
258
|
+
CGDisplayModeRef mode = CGDisplayCopyDisplayMode(display.displayID);
|
|
259
|
+
if (mode) {
|
|
260
|
+
backingWidth = (NSInteger)CGDisplayModeGetPixelWidth(mode);
|
|
261
|
+
backingHeight = (NSInteger)CGDisplayModeGetPixelHeight(mode);
|
|
262
|
+
CGDisplayModeRelease(mode);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Eski/alışılmadık display mode'larda pixel ölçüsü yoksa Quartz değerine
|
|
266
|
+
// düş; bu en azından non-Retina ekranlarda doğru 1x sonucu verir.
|
|
267
|
+
if (backingWidth <= 0) {
|
|
268
|
+
backingWidth = (NSInteger)CGDisplayPixelsWide(display.displayID);
|
|
269
|
+
}
|
|
270
|
+
if (backingHeight <= 0) {
|
|
271
|
+
backingHeight = (NSInteger)CGDisplayPixelsHigh(display.displayID);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
CGFloat scaleX = (logicalWidth > 0 && backingWidth > 0)
|
|
275
|
+
? (CGFloat)backingWidth / logicalWidth
|
|
276
|
+
: 1.0;
|
|
277
|
+
CGFloat scaleY = (logicalHeight > 0 && backingHeight > 0)
|
|
278
|
+
? (CGFloat)backingHeight / logicalHeight
|
|
279
|
+
: 1.0;
|
|
280
|
+
CGFloat scale = MAX(scaleX, scaleY);
|
|
281
|
+
if (!isfinite(scale)) scale = 1.0;
|
|
282
|
+
return MIN(4.0, MAX(1.0, scale));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Bitrate hesabi FPS'e DUYARLI olmali. Eski formul `w*h*multiplier` idi ve fps'i
|
|
286
|
+
// hic gormuyordu: kayit 30 -> 60 FPS'e cikarildiginda toplam bitrate ayni kaldigi
|
|
287
|
+
// icin kare basina bit yariya dustu, ustelik 180+ Mbps CABAC akisi fansiz bir
|
|
288
|
+
// makinede gercek zamanli encoder'i doyurup kare dusurmeye basladi. Artik hedef
|
|
289
|
+
// "kare basina piksel basina bit" (bpp) olarak ifade edilir; fps degisince toplam
|
|
290
|
+
// bitrate onunla birlikte olceklenir ve kare basina kalite SABIT kalir.
|
|
196
291
|
static void SCKQualityBitrateForDimensions(NSString *preset,
|
|
197
292
|
NSInteger width,
|
|
198
293
|
NSInteger height,
|
|
294
|
+
NSInteger fps,
|
|
199
295
|
NSInteger *bitrateOut,
|
|
200
|
-
|
|
296
|
+
double *bppOut,
|
|
201
297
|
NSInteger *minOut,
|
|
202
298
|
NSInteger *maxOut) {
|
|
203
299
|
NSString *normalized = SCKNormalizeQualityPreset(preset);
|
|
204
300
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
301
|
+
// H.264 High/CABAC 4:2:0 ekran icerigi icin 0.28 bpp zaten gorsel olarak
|
|
302
|
+
// doygunluk bolgesidir; asil kalite tavani bitrate degil 4:2:0 kroma alt
|
|
303
|
+
// ornekleme. Eski 0.53 bpp'nin ikinci yarisi kalite getirmiyor, sadece
|
|
304
|
+
// entropy coding + disk yazma yuku olarak realtime encoder'i zorluyordu.
|
|
305
|
+
double bpp = 0.28;
|
|
306
|
+
NSInteger minBitrate = 40 * 1000 * 1000;
|
|
307
|
+
NSInteger maxBitrate = 200 * 1000 * 1000;
|
|
208
308
|
|
|
209
309
|
if ([normalized isEqualToString:@"low"]) {
|
|
210
|
-
|
|
310
|
+
bpp = 0.09;
|
|
211
311
|
minBitrate = 10 * 1000 * 1000;
|
|
212
312
|
maxBitrate = 45 * 1000 * 1000;
|
|
213
313
|
} else if ([normalized isEqualToString:@"medium"]) {
|
|
214
|
-
|
|
215
|
-
minBitrate =
|
|
216
|
-
maxBitrate =
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
minBitrate = kSCKHighQualityVideoBitrate; // 100 Mbps taban
|
|
222
|
-
maxBitrate = 200 * 1000 * 1000; // 200 Mbps tavan (realtime güvenli)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
double base = ((double)MAX(1, width)) * ((double)MAX(1, height)) * (double)multiplier;
|
|
314
|
+
bpp = 0.16;
|
|
315
|
+
minBitrate = 20 * 1000 * 1000;
|
|
316
|
+
maxBitrate = 90 * 1000 * 1000;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
double base = ((double)MAX(1, width)) * ((double)MAX(1, height)) *
|
|
320
|
+
((double)MAX(1, fps)) * bpp;
|
|
226
321
|
NSInteger bitrate = (NSInteger)base;
|
|
227
322
|
if (bitrate < minBitrate) bitrate = minBitrate;
|
|
228
323
|
if (bitrate > maxBitrate) bitrate = maxBitrate;
|
|
229
324
|
|
|
230
325
|
if (bitrateOut) *bitrateOut = bitrate;
|
|
231
|
-
if (
|
|
326
|
+
if (bppOut) *bppOut = bpp;
|
|
232
327
|
if (minOut) *minOut = minBitrate;
|
|
233
328
|
if (maxOut) *maxOut = maxBitrate;
|
|
234
329
|
}
|
|
@@ -370,9 +465,31 @@ static void CleanupWriters(void) {
|
|
|
370
465
|
g_videoWriterStarted = NO;
|
|
371
466
|
g_videoStartTime = kCMTimeInvalid;
|
|
372
467
|
|
|
468
|
+
// Kayit sonu ozeti: kare dusme orani gorunur kalsin, aksi halde "video
|
|
469
|
+
// kalitesiz" sikayeti olcusuz kaliyor.
|
|
470
|
+
NSInteger totalSeen = g_videoFramesAppended + g_videoFramesDroppedNotReady;
|
|
471
|
+
if (totalSeen > 0) {
|
|
472
|
+
g_lastVideoFramesAppended = g_videoFramesAppended;
|
|
473
|
+
g_lastVideoFramesDropped = g_videoFramesDroppedNotReady;
|
|
474
|
+
g_lastVideoTargetFPS = MAX(1, g_targetFPS);
|
|
475
|
+
double dropRatio = 100.0 * g_videoFramesDroppedNotReady / totalSeen;
|
|
476
|
+
MRLog(@"📊 Kayit ozeti: %ld kare yazildi, %ld dusuruldu (%%%.1f) @%ldfps hedef",
|
|
477
|
+
(long)g_videoFramesAppended,
|
|
478
|
+
(long)g_videoFramesDroppedNotReady,
|
|
479
|
+
dropRatio,
|
|
480
|
+
(long)MAX(1, g_targetFPS));
|
|
481
|
+
if (dropRatio > 2.0) {
|
|
482
|
+
NSLog(@"⚠️ Encoder yuku surdurulebilir degil: kare dusme orani %%%.1f. "
|
|
483
|
+
@"Cozunurluk/FPS/bitrate kombinasyonu bu makine icin agir.",
|
|
484
|
+
dropRatio);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
373
488
|
// Reset frame counting
|
|
374
489
|
g_frameCount = 0;
|
|
375
490
|
g_firstFrameTime = 0;
|
|
491
|
+
g_videoFramesAppended = 0;
|
|
492
|
+
g_videoFramesDroppedNotReady = 0;
|
|
376
493
|
}
|
|
377
494
|
|
|
378
495
|
if (g_audioWriter) {
|
|
@@ -409,6 +526,23 @@ extern "C" BOOL MRMixAudioToSingleTrackWithGains(NSString *primaryAudioPath,
|
|
|
409
526
|
float systemGain);
|
|
410
527
|
extern "C" BOOL MRMuxAudioIntoVideo(NSString *videoPath, NSString *audioPath);
|
|
411
528
|
|
|
529
|
+
// Encoder'in gercek zamanli yetisip yetismedigini JS tarafina tasir. Kayit
|
|
530
|
+
// suruyorsa canli sayaclar, bittiyse son oturumun degerleri dondurulur.
|
|
531
|
+
extern "C" void ScreenCaptureKitGetFrameStats(long *appendedOut,
|
|
532
|
+
long *droppedOut,
|
|
533
|
+
long *targetFPSOut) {
|
|
534
|
+
BOOL live = (g_videoFramesAppended + g_videoFramesDroppedNotReady) > 0;
|
|
535
|
+
if (appendedOut) {
|
|
536
|
+
*appendedOut = (long)(live ? g_videoFramesAppended : g_lastVideoFramesAppended);
|
|
537
|
+
}
|
|
538
|
+
if (droppedOut) {
|
|
539
|
+
*droppedOut = (long)(live ? g_videoFramesDroppedNotReady : g_lastVideoFramesDropped);
|
|
540
|
+
}
|
|
541
|
+
if (targetFPSOut) {
|
|
542
|
+
*targetFPSOut = (long)(live ? MAX(1, g_targetFPS) : g_lastVideoTargetFPS);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
412
546
|
extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
|
|
413
547
|
if (!g_audioOutputPath) {
|
|
414
548
|
return nil;
|
|
@@ -506,9 +640,21 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
|
|
|
506
640
|
}
|
|
507
641
|
|
|
508
642
|
if (!g_videoInput.readyForMoreMediaData) {
|
|
643
|
+
// Encoder geride kaldi: bu kare kalici olarak kayboluyor. Sessizce
|
|
644
|
+
// dusurmek kalite sikayetlerini teshis edilemez hale getiriyordu; oran
|
|
645
|
+
// %2'yi asarsa encoder yuku (cozunurluk x fps x bitrate) surdurulebilir
|
|
646
|
+
// degil demektir.
|
|
647
|
+
g_videoFramesDroppedNotReady++;
|
|
648
|
+
NSInteger totalSeen = g_videoFramesAppended + g_videoFramesDroppedNotReady;
|
|
649
|
+
if (g_videoFramesDroppedNotReady == 1 || (g_videoFramesDroppedNotReady % 60) == 0) {
|
|
650
|
+
MRLog(@"⚠️ Encoder yetisemedi, kare dusuruldu: %ld/%ld (%.1f%%)",
|
|
651
|
+
(long)g_videoFramesDroppedNotReady,
|
|
652
|
+
(long)totalSeen,
|
|
653
|
+
totalSeen > 0 ? (100.0 * g_videoFramesDroppedNotReady / totalSeen) : 0.0);
|
|
654
|
+
}
|
|
509
655
|
return;
|
|
510
656
|
}
|
|
511
|
-
|
|
657
|
+
|
|
512
658
|
CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
|
|
513
659
|
if (!pixelBuffer) {
|
|
514
660
|
return;
|
|
@@ -557,6 +703,8 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
|
|
|
557
703
|
BOOL appended = [adaptor appendPixelBuffer:pixelBuffer withPresentationTime:relativePresentation];
|
|
558
704
|
if (!appended) {
|
|
559
705
|
NSLog(@"⚠️ Failed appending pixel buffer: %@", g_videoWriter.error);
|
|
706
|
+
} else {
|
|
707
|
+
g_videoFramesAppended++;
|
|
560
708
|
}
|
|
561
709
|
|
|
562
710
|
// Frame rate debugging
|
|
@@ -780,30 +928,39 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
|
|
|
780
928
|
}
|
|
781
929
|
|
|
782
930
|
NSString *normalizedQuality = SCKNormalizeQualityPreset(g_qualityPreset);
|
|
931
|
+
NSInteger encoderFPS = MAX(1, g_targetFPS);
|
|
783
932
|
NSInteger bitrate = 0;
|
|
933
|
+
double bpp = 0.0;
|
|
784
934
|
NSInteger minBitrate = 0;
|
|
785
935
|
NSInteger maxBitrate = 0;
|
|
786
|
-
SCKQualityBitrateForDimensions(normalizedQuality, width, height,
|
|
936
|
+
SCKQualityBitrateForDimensions(normalizedQuality, width, height, encoderFPS,
|
|
937
|
+
&bitrate, &bpp, &minBitrate, &maxBitrate);
|
|
787
938
|
|
|
788
939
|
NSNumber *qualityHint = [normalizedQuality isEqualToString:@"high"] ? @1.0 : ([normalizedQuality isEqualToString:@"medium"] ? @0.9 : @0.85);
|
|
789
940
|
|
|
790
|
-
MRLog(@"🎬 Screen encoder (%@): %ldx%ld, codec=H.264 High Profile, bitrate=%.2fMbps (min=%ldMbps max=%ldMbps)",
|
|
941
|
+
MRLog(@"🎬 Screen encoder (%@): %ldx%ld@%ldfps, codec=H.264 High Profile, bitrate=%.2fMbps (%.2f bpp, min=%ldMbps max=%ldMbps)",
|
|
791
942
|
normalizedQuality,
|
|
792
943
|
(long)width,
|
|
793
944
|
(long)height,
|
|
945
|
+
(long)encoderFPS,
|
|
794
946
|
bitrate / (1000.0 * 1000.0),
|
|
947
|
+
bpp,
|
|
795
948
|
(long)(minBitrate / (1000 * 1000)),
|
|
796
949
|
(long)(maxBitrate / (1000 * 1000)));
|
|
797
950
|
|
|
798
951
|
NSDictionary *compressionProps = @{
|
|
799
952
|
AVVideoAverageBitRateKey: @(bitrate),
|
|
800
|
-
AVVideoMaxKeyFrameIntervalKey: @(
|
|
801
|
-
|
|
802
|
-
|
|
953
|
+
AVVideoMaxKeyFrameIntervalKey: @(encoderFPS),
|
|
954
|
+
// B-frame'ler (kare yeniden siralama) gercek zamanli yakalamada encoder'a
|
|
955
|
+
// ek gecikme + is yukleyip readyForMoreMediaData'yi geciktiriyor; ekran
|
|
956
|
+
// icerigi zaten neredeyse tamamen statik/kesme oldugu icin kazanci ihmal
|
|
957
|
+
// edilebilir. Kapatmak fansiz makinelerde kare dusmesini azaltir.
|
|
958
|
+
AVVideoAllowFrameReorderingKey: @NO,
|
|
959
|
+
AVVideoExpectedSourceFrameRateKey: @(encoderFPS),
|
|
803
960
|
AVVideoQualityKey: qualityHint,
|
|
804
961
|
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
|
|
805
962
|
AVVideoH264EntropyModeKey: AVVideoH264EntropyModeCABAC,
|
|
806
|
-
AVVideoAverageNonDroppableFrameRateKey: @(
|
|
963
|
+
AVVideoAverageNonDroppableFrameRateKey: @(encoderFPS),
|
|
807
964
|
AVVideoMaxKeyFrameIntervalDurationKey: @(1.0)
|
|
808
965
|
};
|
|
809
966
|
|
|
@@ -1041,16 +1198,24 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
|
|
|
1041
1198
|
|
|
1042
1199
|
+ (void)prewarmShareableContent {
|
|
1043
1200
|
if (@available(macOS 15.0, *)) {
|
|
1044
|
-
// Kayit sirasindaki
|
|
1045
|
-
// (TCC kontrolu + pencere
|
|
1046
|
-
//
|
|
1201
|
+
// Kayit sirasindaki SCShareableContent cagrisi yavas olabiliyor
|
|
1202
|
+
// (TCC kontrolu + ekran/pencere envanteri). Ozellikle ekran
|
|
1203
|
+
// konfigurasyonu degistikten sonra (clamshell) bu envanter sifirdan
|
|
1204
|
+
// kuruluyor ve saniyeler surebiliyor.
|
|
1205
|
+
//
|
|
1206
|
+
// Sadece "isitmak" yetmiyordu: kayit aninda cagri YINE bastan
|
|
1207
|
+
// yapiliyordu. Artik sonucu saklayip kayitta yeniden kullaniyoruz,
|
|
1208
|
+
// yani bu adim tamamen atlanabiliyor.
|
|
1047
1209
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
|
|
1210
|
+
CFAbsoluteTime fetchStart = CFAbsoluteTimeGetCurrent();
|
|
1048
1211
|
[SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *contentError) {
|
|
1049
|
-
if (contentError) {
|
|
1212
|
+
if (contentError || !content) {
|
|
1050
1213
|
MRLog(@"⚠️ Prewarm shareable content failed: %@", contentError.localizedDescription);
|
|
1051
1214
|
return;
|
|
1052
1215
|
}
|
|
1053
|
-
|
|
1216
|
+
SCKStoreShareableContent(content);
|
|
1217
|
+
NSLog(@"🔥 Prewarm: shareable content hazir ve saklandi (%.0fms, %lu ekran, %lu pencere)",
|
|
1218
|
+
(CFAbsoluteTimeGetCurrent() - fetchStart) * 1000.0,
|
|
1054
1219
|
(unsigned long)content.displays.count,
|
|
1055
1220
|
(unsigned long)content.windows.count);
|
|
1056
1221
|
}];
|
|
@@ -1082,9 +1247,29 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
|
|
|
1082
1247
|
return NO;
|
|
1083
1248
|
}
|
|
1084
1249
|
|
|
1250
|
+
// Prewarm ile alinmis taze envanter varsa cagriyi TAMAMEN atla.
|
|
1251
|
+
// Bu cagri clamshell'de saniyeler surebiliyor ve kayit baslatmanin
|
|
1252
|
+
// en buyuk gecikme kalemi.
|
|
1253
|
+
if (@available(macOS 15.0, *)) {
|
|
1254
|
+
SCShareableContent *prewarmed = (SCShareableContent *)SCKTakeCachedShareableContent();
|
|
1255
|
+
if (prewarmed) {
|
|
1256
|
+
NSLog(@"⚡ Prewarmed shareable content kullanildi — envanter cagrisi atlandi");
|
|
1257
|
+
dispatch_async(controlQueue, ^{
|
|
1258
|
+
SCKPerformRecordingSetup(configCopy, prewarmed);
|
|
1259
|
+
});
|
|
1260
|
+
// NOT: Burada onbellegi tazelemek YOK.
|
|
1261
|
+
// Kayit setup'i calisirken yeni bir SCShareableContent istegi
|
|
1262
|
+
// baslatmak sistem envanterini kayitla ayni anda sorgular ve
|
|
1263
|
+
// kamera/stream baslatmayla yarisir. Tazeleme, kayit bittikten
|
|
1264
|
+
// sonra desktop tarafindaki periyodik isitmaya birakiliyor.
|
|
1265
|
+
return YES;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1085
1269
|
// CRITICAL FIX: Use dispatch_get_global_queue instead of main_queue
|
|
1086
1270
|
// because Node.js standalone doesn't run macOS main event loop (only Electron does)
|
|
1087
1271
|
NSLog(@"🚀 Requesting shareable content...");
|
|
1272
|
+
CFAbsoluteTime contentFetchStart = CFAbsoluteTimeGetCurrent();
|
|
1088
1273
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
|
|
1089
1274
|
[SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *contentError) {
|
|
1090
1275
|
if (contentError || !content) {
|
|
@@ -1092,7 +1277,8 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
|
|
|
1092
1277
|
SCKFailScheduling();
|
|
1093
1278
|
return;
|
|
1094
1279
|
}
|
|
1095
|
-
NSLog(@"✅ Got shareable content, starting recording setup..."
|
|
1280
|
+
NSLog(@"✅ Got shareable content in %.0fms, starting recording setup...",
|
|
1281
|
+
(CFAbsoluteTimeGetCurrent() - contentFetchStart) * 1000.0);
|
|
1096
1282
|
dispatch_async(controlQueue, ^{
|
|
1097
1283
|
SCKPerformRecordingSetup(configCopy, content);
|
|
1098
1284
|
});
|
|
@@ -1422,31 +1608,41 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
|
|
|
1422
1608
|
CGRect dispBounds = CGRectMake(disp.frame.origin.x, disp.frame.origin.y,
|
|
1423
1609
|
disp.frame.size.width, disp.frame.size.height);
|
|
1424
1610
|
if (CGRectContainsPoint(dispBounds, windowCenter)) {
|
|
1425
|
-
|
|
1426
|
-
NSInteger physH = (NSInteger)CGDisplayPixelsHigh(disp.displayID);
|
|
1427
|
-
CGFloat scX = (disp.width > 0) ? (CGFloat)physW / (CGFloat)disp.width : 1.0;
|
|
1428
|
-
CGFloat scY = (disp.height > 0) ? (CGFloat)physH / (CGFloat)disp.height : 1.0;
|
|
1429
|
-
scaleFactor = MAX(scX, scY);
|
|
1611
|
+
scaleFactor = SCKBackingScaleForDisplay(disp);
|
|
1430
1612
|
break;
|
|
1431
1613
|
}
|
|
1432
1614
|
}
|
|
1433
1615
|
// Fallback: use main display scale factor
|
|
1434
1616
|
if (scaleFactor == 1.0 && content.displays.count > 0) {
|
|
1435
1617
|
SCDisplay *mainDisp = content.displays.firstObject;
|
|
1436
|
-
|
|
1437
|
-
NSInteger physH = (NSInteger)CGDisplayPixelsHigh(mainDisp.displayID);
|
|
1438
|
-
CGFloat scX = (mainDisp.width > 0) ? (CGFloat)physW / (CGFloat)mainDisp.width : 1.0;
|
|
1439
|
-
CGFloat scY = (mainDisp.height > 0) ? (CGFloat)physH / (CGFloat)mainDisp.height : 1.0;
|
|
1440
|
-
scaleFactor = MAX(scX, scY);
|
|
1618
|
+
scaleFactor = SCKBackingScaleForDisplay(mainDisp);
|
|
1441
1619
|
}
|
|
1442
1620
|
|
|
1443
|
-
|
|
1444
|
-
|
|
1621
|
+
filter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:targetWindow];
|
|
1622
|
+
if (@available(macOS 14.0, *)) {
|
|
1623
|
+
// ScreenCaptureKit exposes the authoritative point→pixel
|
|
1624
|
+
// conversion for this exact filter. Prefer it over display
|
|
1625
|
+
// heuristics (scaled modes and windows spanning displays).
|
|
1626
|
+
CGFloat filterScale = filter.pointPixelScale;
|
|
1627
|
+
if (isfinite(filterScale) && filterScale >= 1.0 && filterScale <= 4.0) {
|
|
1628
|
+
// desktopIndependentWindow filtresi bazı macOS/sürüm
|
|
1629
|
+
// kombinasyonlarında Retina pencerede bile 1.0 dönebiliyor.
|
|
1630
|
+
// Display geometrisinden bulunan 2x değeri 1x'e indirirsek
|
|
1631
|
+
// 1500x960 pencere gerçek 3000x1920 yerine logical boyutta
|
|
1632
|
+
// kaydediliyor ve zoom'da metin detayı geri dönülemez
|
|
1633
|
+
// biçimde kayboluyor. Filter yalnızca daha yüksek/güvenli
|
|
1634
|
+
// bir backing scale bildiriyorsa heuristiği yükseltsin.
|
|
1635
|
+
scaleFactor = MAX(scaleFactor, filterScale);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
scaleFactor = MIN(4.0, MAX(1.0, scaleFactor));
|
|
1639
|
+
|
|
1640
|
+
NSInteger physicalWindowWidth = (NSInteger)llround(windowLogicalWidth * scaleFactor);
|
|
1641
|
+
NSInteger physicalWindowHeight = (NSInteger)llround(windowLogicalHeight * scaleFactor);
|
|
1445
1642
|
|
|
1446
1643
|
MRLog(@"🪟 Recording window: %@ logical=%ux%u, physical=%ldx%ld, scale=%.2fx",
|
|
1447
1644
|
targetWindow.title, (unsigned)windowLogicalWidth, (unsigned)windowLogicalHeight,
|
|
1448
1645
|
(long)physicalWindowWidth, (long)physicalWindowHeight, scaleFactor);
|
|
1449
|
-
filter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:targetWindow];
|
|
1450
1646
|
recordingWidth = physicalWindowWidth;
|
|
1451
1647
|
recordingHeight = physicalWindowHeight;
|
|
1452
1648
|
} else {
|
|
@@ -1482,22 +1678,36 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
|
|
|
1482
1678
|
}
|
|
1483
1679
|
|
|
1484
1680
|
if (targetDisplay) {
|
|
1485
|
-
// Use physical pixel dimensions for Retina displays (not logical points)
|
|
1486
1681
|
CGDirectDisplayID displayID = targetDisplay.displayID;
|
|
1487
|
-
|
|
1488
|
-
NSInteger physicalHeight = (NSInteger)CGDisplayPixelsHigh(displayID);
|
|
1489
|
-
NSInteger logicalWidth = targetDisplay.width;
|
|
1490
|
-
NSInteger logicalHeight = targetDisplay.height;
|
|
1682
|
+
filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:@[]];
|
|
1491
1683
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1684
|
+
// CGDisplayPixelsWide/High reports the current Quartz coordinate
|
|
1685
|
+
// resolution on scaled Retina modes (for example 1800x1169), not
|
|
1686
|
+
// necessarily the backing pixels ScreenCaptureKit can deliver.
|
|
1687
|
+
// The filter exposes the authoritative point -> pixel scale and
|
|
1688
|
+
// content rect, so derive the requested stream size from those.
|
|
1689
|
+
CGFloat logicalWidth = targetDisplay.frame.size.width;
|
|
1690
|
+
CGFloat logicalHeight = targetDisplay.frame.size.height;
|
|
1691
|
+
CGFloat scaleFactor = SCKBackingScaleForDisplay(targetDisplay);
|
|
1692
|
+
if (@available(macOS 14.0, *)) {
|
|
1693
|
+
CGRect filterRect = filter.contentRect;
|
|
1694
|
+
CGFloat filterScale = filter.pointPixelScale;
|
|
1695
|
+
if (filterRect.size.width > 0 && filterRect.size.height > 0) {
|
|
1696
|
+
logicalWidth = filterRect.size.width;
|
|
1697
|
+
logicalHeight = filterRect.size.height;
|
|
1698
|
+
}
|
|
1699
|
+
if (isfinite(filterScale) && filterScale >= 1.0 && filterScale <= 4.0) {
|
|
1700
|
+
scaleFactor = MAX(scaleFactor, filterScale);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
scaleFactor = MIN(4.0, MAX(1.0, scaleFactor));
|
|
1495
1704
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1705
|
+
NSInteger physicalWidth = (NSInteger)llround(logicalWidth * scaleFactor);
|
|
1706
|
+
NSInteger physicalHeight = (NSInteger)llround(logicalHeight * scaleFactor);
|
|
1707
|
+
MRLog(@"🖥️ Recording display %u: logical=%.0fx%.0f, physical=%ldx%ld, scale=%.2fx (filter-backed)",
|
|
1708
|
+
displayID, logicalWidth, logicalHeight,
|
|
1498
1709
|
(long)physicalWidth, (long)physicalHeight, scaleFactor);
|
|
1499
1710
|
|
|
1500
|
-
filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:@[]];
|
|
1501
1711
|
recordingWidth = physicalWidth;
|
|
1502
1712
|
recordingHeight = physicalHeight;
|
|
1503
1713
|
} else {
|
|
@@ -1507,19 +1717,28 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
|
|
|
1507
1717
|
}
|
|
1508
1718
|
}
|
|
1509
1719
|
|
|
1510
|
-
|
|
1720
|
+
// desktopIndependentWindow kendi içerik sınırını zaten uygular. JS
|
|
1721
|
+
// katmanındaki window captureArea cursor metadata'sı olarak tutulabilir;
|
|
1722
|
+
// burada tekrar crop boyutu gibi kullanılırsa yukarıda hesaplanan Retina
|
|
1723
|
+
// physicalWindowWidth/Height logical 1x ölçülerle ezilir. Bu blok yalnızca
|
|
1724
|
+
// gerçek display/area kayıtlarında çıktı ölçüsünü değiştirmeli.
|
|
1725
|
+
BOOL isWindowCapture = windowId && [windowId integerValue] != 0;
|
|
1726
|
+
if (!isWindowCapture && captureRect && captureRect[@"width"] && captureRect[@"height"]) {
|
|
1511
1727
|
CGFloat cropWidth = [captureRect[@"width"] doubleValue];
|
|
1512
1728
|
CGFloat cropHeight = [captureRect[@"height"] doubleValue];
|
|
1513
1729
|
if (cropWidth > 0 && cropHeight > 0) {
|
|
1514
1730
|
// Scale crop dimensions for Retina displays
|
|
1515
1731
|
CGFloat cropScaleFactor = 1.0;
|
|
1516
1732
|
if (targetDisplay) {
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1733
|
+
cropScaleFactor = SCKBackingScaleForDisplay(targetDisplay);
|
|
1734
|
+
if (@available(macOS 14.0, *)) {
|
|
1735
|
+
CGFloat filterScale = filter.pointPixelScale;
|
|
1736
|
+
if (isfinite(filterScale) && filterScale >= 1.0 && filterScale <= 4.0) {
|
|
1737
|
+
cropScaleFactor = MAX(cropScaleFactor, filterScale);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1522
1740
|
}
|
|
1741
|
+
cropScaleFactor = MIN(4.0, MAX(1.0, cropScaleFactor));
|
|
1523
1742
|
NSInteger physicalCropWidth = (NSInteger)(cropWidth * cropScaleFactor);
|
|
1524
1743
|
NSInteger physicalCropHeight = (NSInteger)(cropHeight * cropScaleFactor);
|
|
1525
1744
|
MRLog(@"🔲 Crop area: logical=%.0fx%.0f, physical=%ldx%ld, scale=%.2fx at (%.0f,%.0f)",
|
package/src/window_selector.mm
CHANGED
|
@@ -85,11 +85,31 @@ static bool shouldSkipSelectableWindowOwner(NSString *windowOwner) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
// Record icon helpers
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Marka butonu metrikleri — electron/main.cjs içindeki alan seçici `button.primary`
|
|
90
|
+
// ile bire bir: padding 14px 26px | min-width 220px | radius 18px | font 15px/650
|
|
91
|
+
// gap 10px | ikon 20px halka + 8px nokta | hover: translateY(-1px)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
static const CGFloat kBrandFontSize = 15.0;
|
|
94
|
+
static const CGFloat kBrandCornerRadius = 18.0;
|
|
95
|
+
static const CGFloat kBrandPaddingX = 26.0;
|
|
96
|
+
static const CGFloat kBrandPaddingY = 16.0; // web 14px; NSButton'da metin
|
|
97
|
+
// biraz daha sıkışık durduğu için +2
|
|
98
|
+
static const CGFloat kBrandMinWidth = 220.0;
|
|
99
|
+
static const CGFloat kBrandIconDiameter = 20.0;
|
|
100
|
+
static const CGFloat kBrandIconSpacing = 10.0;
|
|
101
|
+
// Hover: web'de renk DEĞİŞMİYOR, buton 1px yukarı kalkıyor.
|
|
102
|
+
static const CGFloat kBrandHoverLift = 1.0;
|
|
103
|
+
|
|
104
|
+
// CSS açısı (0° = yukarı, saat yönü) -> NSGradient açısı (0° = sağa, saat yönü tersi)
|
|
105
|
+
static const CGFloat kBrandBaseAngle = 259.0; // CSS 190.98deg
|
|
106
|
+
static const CGFloat kBrandGlossAngle = 249.25; // CSS 200.71deg
|
|
107
|
+
|
|
108
|
+
// Alan seçicideki .primary-icon ile aynı: 20x20 halka, 2px beyaz kenarlık
|
|
109
|
+
// (%90 alfa), ortasında 8px beyaz nokta. Sağdaki boşluk CSS'teki gap:10px.
|
|
110
|
+
static NSImage *CreateRecordIconImage(CGFloat diameter, CGFloat trailingGap) {
|
|
111
|
+
CGFloat width = diameter + trailingGap;
|
|
112
|
+
NSImage *image = [[[NSImage alloc] initWithSize:NSMakeSize(width, diameter)] autorelease];
|
|
93
113
|
if (!image) {
|
|
94
114
|
return nil;
|
|
95
115
|
}
|
|
@@ -97,26 +117,25 @@ static NSImage *CreateRecordIconImage(CGFloat size) {
|
|
|
97
117
|
[image lockFocus];
|
|
98
118
|
|
|
99
119
|
[[NSColor clearColor] setFill];
|
|
100
|
-
NSRectFill(NSMakeRect(0, 0, width,
|
|
120
|
+
NSRectFill(NSMakeRect(0, 0, width, diameter));
|
|
101
121
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
size);
|
|
122
|
+
NSColor *iconColor = [NSColor colorWithSRGBRed:1.0 green:1.0 blue:1.0 alpha:0.9];
|
|
123
|
+
|
|
124
|
+
const CGFloat strokeWidth = 2.0;
|
|
125
|
+
NSRect iconRect = NSMakeRect(0, 0, diameter, diameter);
|
|
107
126
|
NSRect outerRect = NSInsetRect(iconRect, strokeWidth / 2.0, strokeWidth / 2.0);
|
|
108
127
|
NSBezierPath *outerPath = [NSBezierPath bezierPathWithOvalInRect:outerRect];
|
|
109
128
|
[outerPath setLineWidth:strokeWidth];
|
|
110
|
-
[
|
|
129
|
+
[iconColor setStroke];
|
|
111
130
|
[outerPath stroke];
|
|
112
131
|
|
|
113
|
-
CGFloat innerDiameter =
|
|
132
|
+
const CGFloat innerDiameter = 8.0;
|
|
114
133
|
NSRect innerRect = NSMakeRect(NSMidX(iconRect) - innerDiameter / 2.0,
|
|
115
134
|
NSMidY(iconRect) - innerDiameter / 2.0,
|
|
116
135
|
innerDiameter,
|
|
117
136
|
innerDiameter);
|
|
118
137
|
NSBezierPath *innerPath = [NSBezierPath bezierPathWithOvalInRect:innerRect];
|
|
119
|
-
[
|
|
138
|
+
[iconColor setFill];
|
|
120
139
|
[innerPath fill];
|
|
121
140
|
|
|
122
141
|
[image unlockFocus];
|
|
@@ -128,7 +147,7 @@ static NSImage *CreateRecordIconImage(CGFloat size) {
|
|
|
128
147
|
static NSImage *GetStartRecordIcon(void) {
|
|
129
148
|
static NSImage *recordIcon = nil;
|
|
130
149
|
if (!recordIcon) {
|
|
131
|
-
recordIcon = [CreateRecordIconImage(
|
|
150
|
+
recordIcon = [CreateRecordIconImage(kBrandIconDiameter, kBrandIconSpacing) retain];
|
|
132
151
|
}
|
|
133
152
|
return recordIcon;
|
|
134
153
|
}
|
|
@@ -146,15 +165,54 @@ static void ApplyStartRecordButtonIcon(NSButton *button) {
|
|
|
146
165
|
[button setImage:icon];
|
|
147
166
|
[button setImageScaling:NSImageScaleNone];
|
|
148
167
|
[button setImagePosition:NSImageLeft];
|
|
168
|
+
// İç boşluk ApplyBrandButtonStyle'da ayarlanıyor (padding 14px 26px).
|
|
169
|
+
}
|
|
149
170
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Marka butonu — pages/selection.vue içindeki .confirm-button ile aynı görünüm
|
|
173
|
+
//
|
|
174
|
+
// background: linear-gradient(200.71deg, rgba(255,255,255,.57) -0.3%,
|
|
175
|
+
// rgba(255,255,255,0) 70.49%),
|
|
176
|
+
// linear-gradient(190.98deg, #006aff 26.77%, #0030ff);
|
|
177
|
+
// color: #fff | padding: 8px 16px | border-radius: 6px | font-size: 14px
|
|
178
|
+
// hover: filter: brightness(0.92) | transition: 0.2s
|
|
179
|
+
//
|
|
180
|
+
// Arka plan drawRect'te çizilir: CAGradientLayer sublayer olarak eklenince
|
|
181
|
+
// butonun başlığı ve ikonu gradient'in ALTINDA kalıyor.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
static NSFont *BrandButtonFont(void) {
|
|
184
|
+
// CSS: font-size 15px, font-weight 650 (semibold ile bire bir yakın).
|
|
185
|
+
NSFont *inter = [NSFont fontWithName:@"Inter-SemiBold" size:kBrandFontSize];
|
|
186
|
+
if (inter) return inter;
|
|
187
|
+
return [NSFont systemFontOfSize:kBrandFontSize weight:NSFontWeightSemibold];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
static NSGradient *BrandBaseGradient(void) {
|
|
191
|
+
static NSGradient *gradient = nil;
|
|
192
|
+
if (!gradient) {
|
|
193
|
+
NSColor *from = [NSColor colorWithSRGBRed:0.0 green:106.0 / 255.0 blue:1.0 alpha:1.0];
|
|
194
|
+
NSColor *to = [NSColor colorWithSRGBRed:0.0 green:48.0 / 255.0 blue:1.0 alpha:1.0];
|
|
195
|
+
gradient = [[NSGradient alloc] initWithColorsAndLocations:from, 0.2677, to, 1.0, nil];
|
|
153
196
|
}
|
|
197
|
+
return gradient;
|
|
198
|
+
}
|
|
154
199
|
|
|
155
|
-
|
|
156
|
-
|
|
200
|
+
static NSGradient *BrandGlossGradient(void) {
|
|
201
|
+
static NSGradient *gradient = nil;
|
|
202
|
+
if (!gradient) {
|
|
203
|
+
NSColor *from = [NSColor colorWithSRGBRed:1.0 green:1.0 blue:1.0 alpha:0.57];
|
|
204
|
+
NSColor *to = [NSColor colorWithSRGBRed:1.0 green:1.0 blue:1.0 alpha:0.0];
|
|
205
|
+
gradient = [[NSGradient alloc] initWithColorsAndLocations:from, 0.0, to, 0.7049, nil];
|
|
157
206
|
}
|
|
207
|
+
return gradient;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
static void DrawBrandBackground(NSRect bounds) {
|
|
211
|
+
NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:bounds
|
|
212
|
+
xRadius:kBrandCornerRadius
|
|
213
|
+
yRadius:kBrandCornerRadius];
|
|
214
|
+
[BrandBaseGradient() drawInBezierPath:path angle:kBrandBaseAngle];
|
|
215
|
+
[BrandGlossGradient() drawInBezierPath:path angle:kBrandGlossAngle];
|
|
158
216
|
}
|
|
159
217
|
|
|
160
218
|
// CGWindow (sol-üst orijin) <-> Cocoa (sol-alt orijin) dönüşümünde referans yükseklik
|
|
@@ -205,6 +263,10 @@ void updateScreenOverlays();
|
|
|
205
263
|
// Custom button with hover effects
|
|
206
264
|
@interface HoverButton : NSButton
|
|
207
265
|
@property (nonatomic) BOOL isHovered;
|
|
266
|
+
// Marka butonu (alan seçimindeki mavi buton) görünümü: arka plan drawRect'te
|
|
267
|
+
// çizilir. CAGradientLayer sublayer olarak eklenirse butonun kendi çizimi
|
|
268
|
+
// (başlık + ikon) ALTINDA kalıyor, o yüzden katman değil çizim kullanılıyor.
|
|
269
|
+
@property (nonatomic) BOOL usesBrandStyle;
|
|
208
270
|
- (void)setupHoverTracking;
|
|
209
271
|
@end
|
|
210
272
|
|
|
@@ -368,7 +430,19 @@ void updateScreenOverlays();
|
|
|
368
430
|
- (void)mouseEntered:(NSEvent *)event {
|
|
369
431
|
self.isHovered = YES;
|
|
370
432
|
[[NSCursor pointingHandCursor] set];
|
|
371
|
-
|
|
433
|
+
|
|
434
|
+
// Marka butonu: hover'da web ile aynı şekilde HAFİF KOYULAŞIR
|
|
435
|
+
// (filter: brightness(0.92)). Gradient katmanlı olduğu için backgroundColor
|
|
436
|
+
// ile oynanamaz, örtü katmanının opacity'si kullanılır.
|
|
437
|
+
if (self.usesBrandStyle) {
|
|
438
|
+
// CSS: transform: translateY(-1px) — renk değişmiyor, buton kalkıyor.
|
|
439
|
+
[CATransaction begin];
|
|
440
|
+
[CATransaction setAnimationDuration:0.11];
|
|
441
|
+
self.layer.transform = CATransform3DMakeTranslation(0, kBrandHoverLift, 0);
|
|
442
|
+
[CATransaction commit];
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
372
446
|
// Brighten background on hover
|
|
373
447
|
if (self.layer.backgroundColor) {
|
|
374
448
|
CGFloat red, green, blue, alpha;
|
|
@@ -388,7 +462,15 @@ void updateScreenOverlays();
|
|
|
388
462
|
- (void)mouseExited:(NSEvent *)event {
|
|
389
463
|
self.isHovered = NO;
|
|
390
464
|
[[NSCursor arrowCursor] set];
|
|
391
|
-
|
|
465
|
+
|
|
466
|
+
if (self.usesBrandStyle) {
|
|
467
|
+
[CATransaction begin];
|
|
468
|
+
[CATransaction setAnimationDuration:0.11];
|
|
469
|
+
self.layer.transform = CATransform3DIdentity;
|
|
470
|
+
[CATransaction commit];
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
392
474
|
// Restore original background color
|
|
393
475
|
NSString *title = [self title];
|
|
394
476
|
if ([title isEqualToString:@"Start Record"]) {
|
|
@@ -412,18 +494,100 @@ void updateScreenOverlays();
|
|
|
412
494
|
|
|
413
495
|
- (void)updateTrackingAreas {
|
|
414
496
|
[super updateTrackingAreas];
|
|
415
|
-
|
|
497
|
+
|
|
416
498
|
// Remove old tracking areas
|
|
417
499
|
for (NSTrackingArea *area in self.trackingAreas) {
|
|
418
500
|
[self removeTrackingArea:area];
|
|
419
501
|
}
|
|
420
|
-
|
|
502
|
+
|
|
421
503
|
// Add new tracking area
|
|
422
504
|
[self setupHoverTracking];
|
|
423
505
|
}
|
|
424
506
|
|
|
507
|
+
// Marka arka planı drawRect'te çizildiği için başlık ve ikon her zaman ÜSTTE kalır.
|
|
508
|
+
- (void)drawRect:(NSRect)dirtyRect {
|
|
509
|
+
if (self.usesBrandStyle) {
|
|
510
|
+
DrawBrandBackground([self bounds]);
|
|
511
|
+
}
|
|
512
|
+
[super drawRect:dirtyRect];
|
|
513
|
+
}
|
|
514
|
+
|
|
425
515
|
@end
|
|
426
516
|
|
|
517
|
+
// Marka butonu stilini uygular: alan seçimindeki .confirm-button ile aynı
|
|
518
|
+
// tipografi, köşe ve iç boşluk. Arka planı HoverButton.drawRect çiziyor.
|
|
519
|
+
static void ApplyBrandButtonStyle(NSButton *button) {
|
|
520
|
+
if (!button) return;
|
|
521
|
+
|
|
522
|
+
[button setWantsLayer:YES];
|
|
523
|
+
[button setBordered:NO];
|
|
524
|
+
[button setFocusRingType:NSFocusRingTypeNone];
|
|
525
|
+
[button setShowsBorderOnlyWhileMouseInside:NO];
|
|
526
|
+
|
|
527
|
+
if ([button isKindOfClass:[HoverButton class]]) {
|
|
528
|
+
[(HoverButton *)button setUsesBrandStyle:YES];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
CALayer *root = [button layer];
|
|
532
|
+
if (root) {
|
|
533
|
+
// Arka plan drawRect'te; katman tarafında hiçbir dolgu/kenar kalmasın.
|
|
534
|
+
root.backgroundColor = [[NSColor clearColor] CGColor];
|
|
535
|
+
root.borderWidth = 0.0;
|
|
536
|
+
root.borderColor = [[NSColor clearColor] CGColor];
|
|
537
|
+
root.shadowOpacity = 0.0;
|
|
538
|
+
root.shadowRadius = 0.0;
|
|
539
|
+
root.shadowOffset = CGSizeMake(0, 0);
|
|
540
|
+
root.masksToBounds = NO;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
NSFont *font = BrandButtonFont();
|
|
544
|
+
[button setFont:font];
|
|
545
|
+
|
|
546
|
+
// İç boşluk web ile aynı: padding 14px 26px.
|
|
547
|
+
if ([button respondsToSelector:@selector(setContentInsets:)]) {
|
|
548
|
+
[button setContentInsets:NSEdgeInsetsMake(kBrandPaddingY, kBrandPaddingX,
|
|
549
|
+
kBrandPaddingY, kBrandPaddingX)];
|
|
550
|
+
}
|
|
551
|
+
// Web'de ikon ve metin bir arada ortalanıyor (inline-flex + center).
|
|
552
|
+
// Bu olmadan NSButton ikonu sol kenara dayar, metni ortada bırakır.
|
|
553
|
+
if ([button respondsToSelector:@selector(setImageHugsTitle:)]) {
|
|
554
|
+
[button setImageHugsTitle:YES];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
NSString *title = [button title] ?: @"";
|
|
558
|
+
NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init];
|
|
559
|
+
[paragraph setAlignment:NSTextAlignmentCenter];
|
|
560
|
+
NSDictionary *attributes = @{
|
|
561
|
+
NSForegroundColorAttributeName : [NSColor whiteColor],
|
|
562
|
+
NSParagraphStyleAttributeName : paragraph,
|
|
563
|
+
NSFontAttributeName : font
|
|
564
|
+
};
|
|
565
|
+
NSMutableAttributedString *attributed =
|
|
566
|
+
[[NSMutableAttributedString alloc] initWithString:title];
|
|
567
|
+
[attributed addAttributes:attributes range:NSMakeRange(0, [attributed length])];
|
|
568
|
+
[button setAttributedTitle:attributed];
|
|
569
|
+
|
|
570
|
+
// Boyut: içerik + padding, en az min-width 220px (web ile aynı).
|
|
571
|
+
NSSize textSize = [title sizeWithAttributes:attributes];
|
|
572
|
+
CGFloat contentWidth = ceil(textSize.width);
|
|
573
|
+
CGFloat contentHeight = ceil(textSize.height);
|
|
574
|
+
NSImage *icon = [button image];
|
|
575
|
+
if (icon) {
|
|
576
|
+
// İkon görüntüsü sağındaki gap'i (10px) zaten içeriyor.
|
|
577
|
+
contentWidth += [icon size].width;
|
|
578
|
+
contentHeight = MAX(contentHeight, [icon size].height);
|
|
579
|
+
}
|
|
580
|
+
NSRect frame = [button frame];
|
|
581
|
+
frame.size.width = MAX(kBrandMinWidth, contentWidth + kBrandPaddingX * 2.0);
|
|
582
|
+
frame.size.height = contentHeight + kBrandPaddingY * 2.0;
|
|
583
|
+
[button setFrame:frame];
|
|
584
|
+
[button setNeedsDisplay:YES];
|
|
585
|
+
|
|
586
|
+
[attributed release];
|
|
587
|
+
[paragraph release];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
427
591
|
@implementation NoFocusWindow
|
|
428
592
|
|
|
429
593
|
- (BOOL)canBecomeKeyWindow {
|
|
@@ -1207,28 +1371,10 @@ void updateOverlay() {
|
|
|
1207
1371
|
[targetSelectButton setBordered:NO];
|
|
1208
1372
|
[targetSelectButton setFont:[NSFont systemFontOfSize:16 weight:NSFontWeightRegular]];
|
|
1209
1373
|
|
|
1210
|
-
//
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
[targetSelectButton.layer setBorderWidth:3.0]; // THICK border
|
|
1215
|
-
[targetSelectButton.layer setBorderColor:[[NSColor yellowColor] CGColor]]; // YELLOW border
|
|
1216
|
-
[targetSelectButton.layer setMasksToBounds:YES];
|
|
1217
|
-
|
|
1218
|
-
// Force very visible styling
|
|
1219
|
-
[targetSelectButton.layer setShadowColor:[[NSColor blackColor] CGColor]];
|
|
1220
|
-
[targetSelectButton.layer setShadowOffset:CGSizeMake(5, 5)];
|
|
1221
|
-
[targetSelectButton.layer setShadowRadius:10.0];
|
|
1222
|
-
[targetSelectButton.layer setShadowOpacity:1.0];
|
|
1223
|
-
|
|
1224
|
-
// Clean white text - normal weight
|
|
1225
|
-
NSMutableAttributedString *titleString = [[NSMutableAttributedString alloc]
|
|
1226
|
-
initWithString:[targetSelectButton title]];
|
|
1227
|
-
[titleString addAttribute:NSForegroundColorAttributeName
|
|
1228
|
-
value:[NSColor whiteColor]
|
|
1229
|
-
range:NSMakeRange(0, [titleString length])];
|
|
1230
|
-
[targetSelectButton setAttributedTitle:titleString];
|
|
1231
|
-
|
|
1374
|
+
// Alan seçimindeki mavi buton ile aynı marka görünümü
|
|
1375
|
+
ApplyStartRecordButtonIcon(targetSelectButton);
|
|
1376
|
+
ApplyBrandButtonStyle(targetSelectButton);
|
|
1377
|
+
|
|
1232
1378
|
// Set target and action
|
|
1233
1379
|
if (!g_delegate) {
|
|
1234
1380
|
g_delegate = [[WindowSelectorDelegate alloc] init];
|
|
@@ -1244,7 +1390,10 @@ void updateOverlay() {
|
|
|
1244
1390
|
NSLog(@"🆕 Added new button to Screen %ld overlay - total subviews now: %lu", targetScreenIndex, [targetOverlay.contentView subviews].count);
|
|
1245
1391
|
}
|
|
1246
1392
|
|
|
1393
|
+
// Butonun her gösterimde marka metriklerinde kalmasını garanti et
|
|
1394
|
+
// (mevcut buton yeniden kullanılıyor olabilir).
|
|
1247
1395
|
ApplyStartRecordButtonIcon(targetSelectButton);
|
|
1396
|
+
ApplyBrandButtonStyle(targetSelectButton);
|
|
1248
1397
|
|
|
1249
1398
|
// Position buttons - Start Record in center of selected window
|
|
1250
1399
|
if (targetSelectButton) {
|
|
@@ -1614,15 +1763,17 @@ void updateScreenOverlays() {
|
|
|
1614
1763
|
if ([subview isKindOfClass:[NSButton class]]) {
|
|
1615
1764
|
NSButton *button = (NSButton *)subview;
|
|
1616
1765
|
if ([button.title isEqualToString:@"Start Record"]) {
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
[button.layer setBackgroundColor:
|
|
1624
|
-
|
|
1766
|
+
// Marka butonunun arka planı drawRect'te çiziliyor;
|
|
1767
|
+
// layer.backgroundColor set edilirse hapın altında
|
|
1768
|
+
// köşeli bir dikdörtgen olarak görünüyor.
|
|
1769
|
+
BOOL usesBrand = [button isKindOfClass:[HoverButton class]] &&
|
|
1770
|
+
[(HoverButton *)button usesBrandStyle];
|
|
1771
|
+
if (!usesBrand) {
|
|
1772
|
+
[button.layer setBackgroundColor:
|
|
1773
|
+
[[NSColor colorWithRed:77.0/255.0 green:30.0/255.0 blue:231.0/255.0
|
|
1774
|
+
alpha:(isActiveScreen ? 1.0 : 0.6)] CGColor]];
|
|
1625
1775
|
}
|
|
1776
|
+
[button setAlphaValue:isActiveScreen ? 1.0 : 0.7];
|
|
1626
1777
|
}
|
|
1627
1778
|
}
|
|
1628
1779
|
if ([subview isKindOfClass:[NSTextField class]]) {
|
|
@@ -1827,29 +1978,11 @@ bool startScreenSelection() {
|
|
|
1827
1978
|
[selectButton setFont:[NSFont systemFontOfSize:16 weight:NSFontWeightRegular]];
|
|
1828
1979
|
[selectButton setTag:i]; // Set screen index as tag
|
|
1829
1980
|
|
|
1830
|
-
//
|
|
1831
|
-
[selectButton setWantsLayer:YES];
|
|
1832
|
-
[selectButton.layer setBackgroundColor:[[NSColor colorWithRed:90.0/255.0 green:50.0/255.0 blue:250.0/255.0 alpha:1.0] CGColor]];
|
|
1833
|
-
[selectButton.layer setCornerRadius:8.0];
|
|
1834
|
-
[selectButton.layer setBorderWidth:0.0];
|
|
1835
|
-
|
|
1836
|
-
// Remove all button borders and decorations
|
|
1837
|
-
[selectButton.layer setShadowOpacity:0.0];
|
|
1838
|
-
[selectButton.layer setShadowRadius:0.0];
|
|
1839
|
-
[selectButton.layer setShadowOffset:NSMakeSize(0, 0)];
|
|
1840
|
-
[selectButton.layer setMasksToBounds:YES];
|
|
1841
|
-
|
|
1842
|
-
// Clean white text - normal weight
|
|
1843
|
-
[selectButton setFont:[NSFont systemFontOfSize:16 weight:NSFontWeightRegular]];
|
|
1981
|
+
// Alan seçimindeki mavi buton ile aynı marka görünümü
|
|
1844
1982
|
[selectButton setTitle:@"Start Record"];
|
|
1845
|
-
NSMutableAttributedString *titleString = [[NSMutableAttributedString alloc]
|
|
1846
|
-
initWithString:[selectButton title]];
|
|
1847
|
-
[titleString addAttribute:NSForegroundColorAttributeName
|
|
1848
|
-
value:[NSColor whiteColor]
|
|
1849
|
-
range:NSMakeRange(0, [titleString length])];
|
|
1850
|
-
[selectButton setAttributedTitle:titleString];
|
|
1851
|
-
|
|
1852
1983
|
ApplyStartRecordButtonIcon(selectButton);
|
|
1984
|
+
ApplyBrandButtonStyle(selectButton);
|
|
1985
|
+
|
|
1853
1986
|
|
|
1854
1987
|
// Clean button - no shadows or highlights
|
|
1855
1988
|
|
|
@@ -2314,29 +2447,10 @@ Napi::Value StartWindowSelection(const Napi::CallbackInfo& info) {
|
|
|
2314
2447
|
[g_selectButton setBordered:NO];
|
|
2315
2448
|
[g_selectButton setFont:[NSFont systemFontOfSize:16 weight:NSFontWeightRegular]];
|
|
2316
2449
|
|
|
2317
|
-
//
|
|
2318
|
-
[g_selectButton setWantsLayer:YES];
|
|
2319
|
-
[g_selectButton.layer setBackgroundColor:[[NSColor colorWithRed:90.0/255.0 green:50.0/255.0 blue:250.0/255.0 alpha:1.0] CGColor]];
|
|
2320
|
-
[g_selectButton.layer setCornerRadius:8.0];
|
|
2321
|
-
[g_selectButton.layer setBorderWidth:0.0];
|
|
2322
|
-
|
|
2323
|
-
// Remove all button borders and decorations
|
|
2324
|
-
[g_selectButton.layer setShadowOpacity:0.0];
|
|
2325
|
-
[g_selectButton.layer setShadowRadius:0.0];
|
|
2326
|
-
[g_selectButton.layer setShadowOffset:NSMakeSize(0, 0)];
|
|
2327
|
-
[g_selectButton.layer setMasksToBounds:YES];
|
|
2328
|
-
[g_selectButton.layer setBorderWidth:0.0];
|
|
2329
|
-
[g_selectButton.layer setBorderColor:[[NSColor clearColor] CGColor]];
|
|
2330
|
-
|
|
2331
|
-
// Clean white text - normal weight
|
|
2332
|
-
NSMutableAttributedString *titleString = [[NSMutableAttributedString alloc]
|
|
2333
|
-
initWithString:[g_selectButton title]];
|
|
2334
|
-
[titleString addAttribute:NSForegroundColorAttributeName
|
|
2335
|
-
value:[NSColor whiteColor]
|
|
2336
|
-
range:NSMakeRange(0, [titleString length])];
|
|
2337
|
-
[g_selectButton setAttributedTitle:titleString];
|
|
2338
|
-
|
|
2450
|
+
// Alan seçimindeki mavi buton ile aynı marka görünümü
|
|
2339
2451
|
ApplyStartRecordButtonIcon(g_selectButton);
|
|
2452
|
+
ApplyBrandButtonStyle(g_selectButton);
|
|
2453
|
+
|
|
2340
2454
|
|
|
2341
2455
|
// Create delegate for button action and timer
|
|
2342
2456
|
g_delegate = [[WindowSelectorDelegate alloc] init];
|