node-mac-recorder 2.24.15 → 2.24.17

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.
@@ -0,0 +1,67 @@
1
+ // Run inside Electron. This uses Chromium's actual CSS cursors, which may differ
2
+ // from rendering the raw HIServices PDF into an NSImage.
3
+ const { app, BrowserWindow, screen } = require('electron');
4
+ const addon = require('../build/Release/mac_recorder.node');
5
+ const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
6
+ let window;
7
+
8
+ app.whenReady().then(async () => {
9
+ const point = screen.getCursorScreenPoint();
10
+ window = new BrowserWindow({
11
+ x: point.x - 100, y: point.y - 60, width: 200, height: 120,
12
+ frame: false, alwaysOnTop: true, skipTaskbar: true,
13
+ webPreferences: { nodeIntegration: false, contextIsolation: true },
14
+ });
15
+ await window.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(
16
+ '<html><body style="margin:0;width:100vw;height:100vh;background:#eee;font:14px sans-serif;display:grid;place-items:center">Cursor detection test</body></html>'
17
+ ));
18
+ window.focus();
19
+ await wait(200);
20
+ const results = [];
21
+ const check = (label, expected) => {
22
+ const actual = addon.getCursorPosition(false)?.cursorType;
23
+ results.push({ label, expected, actual });
24
+ };
25
+ // Repeated transitions catch stale seed/image caches as well as wrong shapes.
26
+ for (const [css, expected] of [
27
+ ['default', 'default'], ['move', 'all-scroll'], ['all-scroll', 'all-scroll'],
28
+ ['grab', 'grab'], ['grabbing', 'grabbing'], ['grab', 'grab'], ['move', 'all-scroll'],
29
+ ['-webkit-grab', 'grab'], ['-webkit-grabbing', 'grabbing'],
30
+ ['help', 'help'], ['progress', 'progress'], ['cell', 'crosshair'],
31
+ ['ns-resize', 'ns-resize'], ['row-resize', 'row-resize'],
32
+ ['nesw-resize', 'nesw-resize'], ['nwse-resize', 'nwse-resize'],
33
+ ['zoom-in', 'zoom-in'], ['zoom-out', 'zoom-out'],
34
+ ]) {
35
+ await window.webContents.executeJavaScript(`document.body.style.cursor=${JSON.stringify(css)}; document.body.textContent=${JSON.stringify(css)}`);
36
+ window.webContents.sendInputEvent({ type: 'mouseMove', x: 100, y: 60 });
37
+ await wait(60);
38
+ check(css, expected);
39
+ }
40
+
41
+ // A real renderer switches the displayed hand in response to press/release.
42
+ await window.webContents.executeJavaScript(`
43
+ document.body.style.cursor='grab';
44
+ document.body.onpointerdown=()=>document.body.style.cursor='grabbing';
45
+ document.body.onpointerup=()=>document.body.style.cursor='grab';
46
+ void 0;
47
+ `);
48
+ window.webContents.sendInputEvent({ type: 'mouseMove', x: 100, y: 60 });
49
+ await wait(60);
50
+ check('before press', 'grab');
51
+ window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, x: 100, y: 60 });
52
+ await wait(60);
53
+ check('during press', 'grabbing');
54
+ window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, x: 100, y: 60 });
55
+ await wait(60);
56
+ check('after release', 'grab');
57
+ console.log('CURSOR_RESULTS=' + JSON.stringify(results));
58
+ if (results.some(({ expected, actual }) => expected !== actual)) process.exitCode = 1;
59
+ }).catch(error => {
60
+ console.error(error);
61
+ process.exitCode = 1;
62
+ }).finally(() => {
63
+ if (window && !window.isDestroyed()) window.destroy();
64
+ app.exit(process.exitCode || 0);
65
+ });
66
+
67
+ setTimeout(() => app.exit(1), 15000).unref();
@@ -0,0 +1,249 @@
1
+ // Exercise the production delegates and MOV writers without opening devices.
2
+ #import "../src/sync_timeline.mm"
3
+ #import "../src/audio_recorder.mm"
4
+ #import "../src/camera_recorder.mm"
5
+ #import "../src/ios_device_recorder.mm"
6
+ #include <unistd.h>
7
+
8
+ @interface MRProbeSession : AVCaptureSession
9
+ @property(nonatomic, assign) CMClockRef probeClock;
10
+ @end
11
+ @implementation MRProbeSession
12
+ - (CMClockRef)masterClock { return self.probeClock ?: CMClockGetHostTimeClock(); }
13
+ - (BOOL)isRunning { return NO; }
14
+ @end
15
+
16
+ @interface MRProbeMovieOutput : AVCaptureMovieFileOutput
17
+ @property NSUInteger starts;
18
+ @end
19
+ @implementation MRProbeMovieOutput
20
+ - (void)startRecordingToOutputFileURL:(NSURL *)url recordingDelegate:(id<AVCaptureFileOutputRecordingDelegate>)delegate {
21
+ self.starts += 1;
22
+ }
23
+ @end
24
+
25
+ static CMTime T(double seconds) { return CMTimeMakeWithSeconds(seconds, 48000); }
26
+
27
+ static CMSampleBufferRef Video(double seconds) {
28
+ CVPixelBufferRef pixels = NULL;
29
+ CVPixelBufferCreate(kCFAllocatorDefault, 64, 64, kCVPixelFormatType_32BGRA,
30
+ (CFDictionaryRef)@{(id)kCVPixelBufferIOSurfacePropertiesKey: @{}}, &pixels);
31
+ CVPixelBufferLockBaseAddress(pixels, 0);
32
+ memset(CVPixelBufferGetBaseAddress(pixels), 128, CVPixelBufferGetDataSize(pixels));
33
+ CVPixelBufferUnlockBaseAddress(pixels, 0);
34
+ CMVideoFormatDescriptionRef format = NULL;
35
+ CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixels, &format);
36
+ CMSampleTimingInfo timing = { T(1.0 / 30), T(seconds), kCMTimeInvalid };
37
+ CMSampleBufferRef sample = NULL;
38
+ CMSampleBufferCreateReadyWithImageBuffer(kCFAllocatorDefault, pixels, format, &timing, &sample);
39
+ CFRelease(format);
40
+ CFRelease(pixels);
41
+ return sample;
42
+ }
43
+
44
+ static CMSampleBufferRef Audio(double seconds) {
45
+ AudioStreamBasicDescription asbd = {};
46
+ asbd.mSampleRate = 48000;
47
+ asbd.mFormatID = kAudioFormatLinearPCM;
48
+ asbd.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
49
+ asbd.mBytesPerPacket = asbd.mBytesPerFrame = 2;
50
+ asbd.mFramesPerPacket = asbd.mChannelsPerFrame = 1;
51
+ asbd.mBitsPerChannel = 16;
52
+ CMAudioFormatDescriptionRef format = NULL;
53
+ CMAudioFormatDescriptionCreate(kCFAllocatorDefault, &asbd, 0, NULL, 0, NULL, NULL, &format);
54
+ CMBlockBufferRef block = NULL;
55
+ CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault, NULL, 1920,
56
+ kCFAllocatorDefault, NULL, 0, 1920, 0, &block);
57
+ CMBlockBufferFillDataBytes(0, block, 0, 1920);
58
+ char *data = NULL;
59
+ CMBlockBufferGetDataPointer(block, 0, NULL, NULL, &data);
60
+ for (int i = 0; i < 960; i++) ((int16_t *)data)[i] = (int16_t)(8000 * sin(i * 2 * M_PI / 48));
61
+ CMSampleTimingInfo timing = { T(1.0 / 48000), T(seconds), kCMTimeInvalid };
62
+ size_t size = 2;
63
+ CMSampleBufferRef sample = NULL;
64
+ CMSampleBufferCreateReady(kCFAllocatorDefault, block, format, 960, 1, &timing, 1, &size, &sample);
65
+ CFRelease(format);
66
+ CFRelease(block);
67
+ return sample;
68
+ }
69
+
70
+ static void WaitForInput(AVAssetWriterInput *input) {
71
+ for (int i = 0; input && !input.readyForMoreMediaData && i < 2000; i++) usleep(1000);
72
+ }
73
+
74
+ static Napi::Value Run(const Napi::CallbackInfo &info) {
75
+ @autoreleasepool {
76
+ NSString *directory = [NSString stringWithUTF8String:info[0].As<Napi::String>().Utf8Value().c_str()];
77
+ NSMutableArray *failures = [NSMutableArray array];
78
+ NSUInteger checks = 0;
79
+ auto check = [&](BOOL ok, NSString *message) { checks++; if (!ok) [failures addObject:message]; };
80
+ auto near = [&](CMTime actual, double expected, NSString *message) {
81
+ check(CMTIME_IS_NUMERIC(actual) && fabs(CMTimeGetSeconds(actual) - expected) < 0.0001, message);
82
+ };
83
+
84
+ MRSyncConfigure(YES);
85
+ MRSyncConfigureCamera(YES);
86
+ MRSyncConfigurePrimaryStart(YES);
87
+ check(!CMTIME_IS_NUMERIC(MRSyncPrimaryMediaTime(T(100))), @"warmup must not enter a writer");
88
+ MRIOSDeviceRecorder *phone = [[[MRIOSDeviceRecorder alloc] init] autorelease];
89
+ phone.session = [[[MRProbeSession alloc] init] autorelease];
90
+ MRProbeMovieOutput *movie = [[[MRProbeMovieOutput alloc] init] autorelease];
91
+ phone.movieOutput = movie;
92
+ phone.currentSegmentPath = [directory stringByAppendingPathComponent:@"phone.mov"];
93
+ phone.primaryStartHostTime = kCMTimeInvalid;
94
+ phone.segmentStartPending = YES;
95
+ CMSampleBufferRef first = Video(100);
96
+ [phone captureOutput:movie didOutputSampleBuffer:first fromConnection:nil];
97
+ [phone captureOutput:movie didOutputSampleBuffer:first fromConnection:nil];
98
+ CFRelease(first);
99
+ check(movie.starts == 1, @"sample-accurate USB start must happen exactly once");
100
+ near(MRSyncPrimaryStartTimestamp(), 100, @"origin must come from the first USB sample");
101
+ MRIOSMarkSegmentStarted(phone, NO);
102
+ MRIOSMarkSegmentStarted(phone, YES);
103
+ near(MRSyncPrimaryStartTimestamp(), 100, @"delayed progress/delegate must not move the origin");
104
+ check(!CMTIME_IS_NUMERIC(MRSyncPrimaryMediaTime(T(99.9))), @"pre-origin samples are discarded");
105
+ near(MRSyncPrimaryMediaTime(T(100.6)), 0.6, @"late camera keeps its actual offset");
106
+ check(!CMTIME_IS_NUMERIC(MRSyncHostTimestamp(T(100), NULL)), @"missing capture clock is rejected");
107
+
108
+ // Distinct session clock epochs must not become an A/V offset.
109
+ CMTimebaseRef cameraClock = NULL, audioClock = NULL;
110
+ CMTimebaseCreateWithSourceClock(kCFAllocatorDefault, CMClockGetHostTimeClock(), &cameraClock);
111
+ CMTimebaseCreateWithSourceClock(kCFAllocatorDefault, CMClockGetHostTimeClock(), &audioClock);
112
+ CMTimebaseSetTime(cameraClock, T(2000));
113
+ CMTimebaseSetRate(cameraClock, 1);
114
+ CMTimebaseSetTime(audioClock, T(500));
115
+ CMTimebaseSetRate(audioClock, 1);
116
+ MRProbeSession *cameraSession = [[[MRProbeSession alloc] init] autorelease];
117
+ cameraSession.probeClock = (CMClockRef)cameraClock;
118
+ MRProbeSession *audioSession = [[[MRProbeSession alloc] init] autorelease];
119
+ audioSession.probeClock = (CMClockRef)audioClock;
120
+
121
+ CameraRecorder *camera = [[[CameraRecorder alloc] init] autorelease];
122
+ camera.session = cameraSession;
123
+ camera.videoOutput = [[[AVCaptureVideoDataOutput alloc] init] autorelease];
124
+ camera.outputPath = [directory stringByAppendingPathComponent:@"camera.mov"];
125
+ camera.isRecording = YES;
126
+ NativeAudioRecorder *microphone = [[[NativeAudioRecorder alloc] init] autorelease];
127
+ microphone.session = audioSession;
128
+ microphone.audioOutput = [[[AVCaptureAudioDataOutput alloc] init] autorelease];
129
+ microphone.outputPath = [directory stringByAppendingPathComponent:@"microphone.mov"];
130
+ auto sendVideo = [&](double time) {
131
+ WaitForInput(camera.writerInput);
132
+ double sourceTime = CMTimeGetSeconds(CMSyncConvertTime(T(time), CMClockGetHostTimeClock(), cameraClock));
133
+ CMSampleBufferRef sample = Video(sourceTime);
134
+ [camera captureOutput:camera.videoOutput didOutputSampleBuffer:sample fromConnection:nil];
135
+ CFRelease(sample);
136
+ };
137
+ auto sendAudio = [&](double time) {
138
+ WaitForInput(microphone.writerInput);
139
+ double sourceTime = CMTimeGetSeconds(CMSyncConvertTime(T(time), CMClockGetHostTimeClock(), audioClock));
140
+ CMSampleBufferRef sample = Audio(sourceTime);
141
+ [microphone captureOutput:microphone.audioOutput didOutputSampleBuffer:sample fromConnection:nil];
142
+ CFRelease(sample);
143
+ };
144
+ // Deliberately start the mic 400 ms before the camera. Both must retain
145
+ // their offsets to the phone, without waiting for the other device.
146
+ for (int i = 0; i < 40; i++) sendAudio(100.2 + i * 0.02);
147
+ for (int i = 0; i < 12; i++) sendVideo(100.6 + i / 30.0);
148
+ near(microphone.startTime, 100, @"mic writer uses USB origin");
149
+ near(camera.startTime, 100, @"camera writer uses USB origin");
150
+ MRSyncPauseAtHostTime(T(101));
151
+ phone.pauseRanges = [NSMutableArray array];
152
+ MRIOSBeginPauseRange(phone, T(101));
153
+ near(MRSyncPrimaryMediaTime(T(100.99)), 0.99, @"buffer queued before pause retains its timestamp");
154
+ check(!CMTIME_IS_NUMERIC(MRSyncPrimaryMediaTime(T(101.1))), @"paused samples are dropped");
155
+ sendAudio(102);
156
+ sendVideo(102);
157
+ MRSyncResumeAtHostTime(T(104));
158
+ MRIOSEndPauseRange(phone, T(104));
159
+ check(phone.pauseRanges.count == 1 &&
160
+ fabs(phone.pauseRanges[0][@"start"].doubleValue - 1) < 0.0001 &&
161
+ fabs(phone.pauseRanges[0][@"end"].doubleValue - 4) < 0.0001,
162
+ @"phone trims the exact same 1-to-4-second range as camera and microphone");
163
+ check(!CMTIME_IS_NUMERIC(MRSyncPrimaryMediaTime(T(102))), @"late delivery of a paused sample is still dropped");
164
+ near(MRSyncPrimaryMediaTime(T(104.5)), 1.5, @"resume removes exactly the phone's pause range");
165
+ for (int i = 0; i < 50; i++) sendAudio(104 + i * 0.02);
166
+ for (int i = 0; i < 30; i++) sendVideo(104 + i / 30.0);
167
+ MRSyncSetStopLimitSeconds(2);
168
+ check(MRFinishAssetWriterSafely(camera.writer, 5, 2), @"camera MOV finalizes");
169
+ check(MRFinishAssetWriterSafely(microphone.writer, 5, 2), @"microphone MOV finalizes");
170
+ for (NSString *kind in @[@"camera", @"microphone"]) {
171
+ NSURL *url = [NSURL fileURLWithPath:[directory stringByAppendingPathComponent:[kind stringByAppendingString:@".mov"]]];
172
+ AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
173
+ BOOL isCamera = [kind isEqualToString:@"camera"];
174
+ AVAssetTrack *track = [asset tracksWithMediaType:isCamera ? AVMediaTypeVideo : AVMediaTypeAudio].firstObject;
175
+ check(track != nil, [kind stringByAppendingString:@" track exists"]);
176
+ if (!track) continue;
177
+ check(fabs(CMTimeGetSeconds(asset.duration) - 2) < 0.04, [kind stringByAppendingString:@" has the shared stop boundary"]);
178
+ NSError *error = nil;
179
+ AVAssetReader *reader = [AVAssetReader assetReaderWithAsset:asset error:&error];
180
+ AVAssetReaderOutput *output = isCamera
181
+ ? [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:track outputSettings:nil]
182
+ : [AVAssetReaderAudioMixOutput assetReaderAudioMixOutputWithAudioTracks:@[track]
183
+ audioSettings:@{AVFormatIDKey: @(kAudioFormatLinearPCM), AVLinearPCMBitDepthKey: @16,
184
+ AVLinearPCMIsFloatKey: @NO, AVLinearPCMIsNonInterleaved: @NO}];
185
+ [reader addOutput:output];
186
+ [reader startReading];
187
+ CMTime previous = kCMTimeInvalid;
188
+ CMTime firstPTS = kCMTimeInvalid;
189
+ CMTime lastPTS = kCMTimeInvalid;
190
+ double firstSound = -1;
191
+ double secondVideoPTS = INFINITY;
192
+ NSUInteger count = 0;
193
+ while (CMSampleBufferRef sample = [output copyNextSampleBuffer]) {
194
+ CMTime pts = CMSampleBufferGetPresentationTimeStamp(sample);
195
+ if (CMSampleBufferGetTotalSampleSize(sample) == 0) { CFRelease(sample); continue; }
196
+ CMTime dts = CMSampleBufferGetDecodeTimeStamp(sample);
197
+ if (!CMTIME_IS_NUMERIC(dts)) dts = pts;
198
+ check(!CMTIME_IS_NUMERIC(previous) || CMTimeCompare(dts, previous) >= 0,
199
+ [kind stringByAppendingString:@" has monotonic decode timestamps after pause"]);
200
+ previous = dts;
201
+ firstPTS = CMTIME_IS_NUMERIC(firstPTS) ? CMTimeMinimum(firstPTS, pts) : pts;
202
+ lastPTS = CMTIME_IS_NUMERIC(lastPTS) ? CMTimeMaximum(lastPTS, pts) : pts;
203
+ if (isCamera && CMTimeGetSeconds(pts) > 0.001) secondVideoPTS = MIN(secondVideoPTS, CMTimeGetSeconds(pts));
204
+ if (!isCamera && firstSound < 0) {
205
+ CMBlockBufferRef block = CMSampleBufferGetDataBuffer(sample);
206
+ size_t length = CMBlockBufferGetDataLength(block);
207
+ std::vector<int16_t> pcm(length / sizeof(int16_t));
208
+ CMBlockBufferCopyDataBytes(block, 0, length, pcm.data());
209
+ for (size_t i = 0; i < pcm.size(); i++) {
210
+ if (abs(pcm[i]) > 500) { firstSound = CMTimeGetSeconds(pts) + i / 48000.0; break; }
211
+ }
212
+ }
213
+ count++;
214
+ CFRelease(sample);
215
+ }
216
+ check(fabs(CMTimeGetSeconds(firstPTS)) < 0.04,
217
+ [NSString stringWithFormat:@"%@ starts at zero even when a reader ignores empty edits (%.3f)", kind, CMTimeGetSeconds(firstPTS)]);
218
+ if (isCamera) check(fabs(secondVideoPTS - 0.6) < 0.04, @"camera holds its first frame until the actual capture time");
219
+ else check(fabs(firstSound - 0.2) < 0.04,
220
+ [NSString stringWithFormat:@"microphone leading silence preserves sound onset (%.3f)", firstSound]);
221
+ check(count > 1 && CMTimeGetSeconds(lastPTS) > 1.5,
222
+ [NSString stringWithFormat:@"%@ contains media on both sides of pause (count %lu, last PTS %.3f)", kind, count, CMTimeGetSeconds(lastPTS)]);
223
+ }
224
+ // Repeated pauses across a long session: no callback-time accumulation.
225
+ for (int i = 0; i < 100; i++) {
226
+ MRSyncPauseAtHostTime(T(110 + i * 10));
227
+ MRSyncResumeAtHostTime(T(113 + i * 10));
228
+ }
229
+ near(MRSyncPrimaryMediaTime(T(1110)), 707, @"100 pauses preserve the common long-recording timeline");
230
+ MRSyncConfigure(NO);
231
+ check(!MRSyncUsesPrimaryTimeline(), @"desktop recording does not inherit the iPhone timeline");
232
+ check(!MRSyncShouldHoldVideoFrame(T(200)), @"desktop camera barrier remains unchanged");
233
+ near(MRSyncAdjustForPauses(T(5)), 5, @"desktop pause duration resets between recordings");
234
+ cameraSession.probeClock = nil;
235
+ audioSession.probeClock = nil;
236
+ CFRelease(cameraClock);
237
+ CFRelease(audioClock);
238
+ Napi::Object result = Napi::Object::New(info.Env());
239
+ result.Set("checks", Napi::Number::New(info.Env(), checks));
240
+ result.Set("failures", Napi::String::New(info.Env(), [[failures componentsJoinedByString:@"\n"] UTF8String]));
241
+ return result;
242
+ }
243
+ }
244
+
245
+ static Napi::Object InitProbe(Napi::Env env, Napi::Object exports) {
246
+ exports.Set("run", Napi::Function::New(env, Run));
247
+ return exports;
248
+ }
249
+ NODE_API_MODULE(ios_sync_probe, InitProbe)
@@ -0,0 +1,30 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { execFileSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ test('iPhone, camera and microphone preserve one media timeline through delayed startup and pauses', {
9
+ skip: process.platform !== 'darwin', timeout: 120000,
10
+ }, (t) => {
11
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'recorder-ios-sync-'));
12
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
13
+ const headers = [
14
+ process.env.npm_config_nodedir && path.join(process.env.npm_config_nodedir, 'include/node'),
15
+ path.join(os.homedir(), 'Library/Caches/node-gyp', process.versions.node, 'include/node'),
16
+ path.resolve(path.dirname(process.execPath), '../include/node'),
17
+ ].filter(Boolean).find((p) => fs.existsSync(path.join(p, 'node_api.h')));
18
+ assert.ok(headers, 'Node headers required; run npm run rebuild first');
19
+ const binary = path.join(directory, 'ios-sync-probe.node');
20
+ execFileSync('xcrun', ['clang++', '-std=c++17', '-bundle', '-undefined', 'dynamic_lookup',
21
+ '-DNAPI_DISABLE_CPP_EXCEPTIONS', '-Wno-deprecated-declarations',
22
+ '-I', headers, '-I', path.dirname(require.resolve('node-addon-api')),
23
+ path.join(__dirname, 'ios-sync-probe.mm'), '-o', binary,
24
+ ...['Foundation', 'AVFoundation', 'CoreMedia', 'CoreVideo', 'CoreMediaIO', 'IOKit', 'CoreAudio']
25
+ .flatMap((framework) => ['-framework', framework]),
26
+ ], { timeout: 60000, stdio: 'pipe' });
27
+ const result = require(binary).run(directory);
28
+ t.diagnostic(`${result.checks} native timing and MOV checks`);
29
+ assert.equal(result.failures, '');
30
+ });