memoir-node 0.1.5 → 0.1.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.
Files changed (2) hide show
  1. package/README.md +161 -136
  2. package/package.json +4 -2
package/README.md CHANGED
@@ -1,194 +1,232 @@
1
- # memoir-capture
1
+ # memoir-node
2
2
 
3
- Windows-native screen capture module with Python bindings for real-time frame analysis and deterministic replay recording.
3
+ Windows-native screen capture module with Node.js bindings for real-time frame analysis and deterministic replay recording.
4
4
 
5
- Memoir captures frames from a window or monitor using Windows Graphics Capture (WGC), delivers them to Python as NumPy arrays, and optionally records them to HEVC video with per-frame metadata — all without GPU-to-CPU roundtrips in the recording path.
5
+ Memoir captures frames from a window or monitor using Windows Graphics Capture (WGC), delivers them as Buffers, and optionally records them to HEVC video with per-frame metadata.
6
6
 
7
7
  ## Features
8
8
 
9
9
  - **WGC capture** — continuous frame capture from any window or monitor
10
- - **NumPy delivery** — BGRA frames as `(H, W, 4)` uint8 arrays via bounded queue
11
- - **Hardware-accelerated recording** — lossless HEVC encoding (YUV 4:4:4) via NVENC, AMF, or software x265 fallback
10
+ - **Zero-copy Buffers** — BGRA frames delivered as Node.js Buffers without copying
11
+ - **Hardware-accelerated recording** — lossless HEVC encoding via NVENC, AMF, or x265 fallback
12
12
  - **Binary metadata** — `.meta` sidecar with per-frame keyboard state, timestamps, and frame IDs
13
13
  - **Dynamic recording** — start/stop recording without restarting capture
14
14
  - **Frame-accurate keyboard** — key state snapshot at the exact moment each frame is accepted
15
- - **Typed Python API** — full type annotations, dataclasses, context managers
15
+ - **Pure TypeScript meta reader/writer** — read and write `.meta` files without the native addon
16
+ - **Synchronous blocking API** — designed for Worker threads running tick loops
16
17
 
17
18
  ## Requirements
18
19
 
19
- - Windows 10 1903+ (for WGC `CreateFreeThreaded`)
20
- - Python 3.10+
21
- - NVIDIA GPU (NVENC), AMD GPU (AMF), or CPU-only (x265 software fallback) for recording
22
- - Visual Studio 2022 (for building from source)
20
+ - Windows 10 1903+
21
+ - Node.js 18+
22
+ - x64 architecture
23
+ - NVIDIA GPU (NVENC), AMD GPU (AMF), or CPU-only (x265) for recording
23
24
 
24
25
  ## Installation
25
26
 
26
- ### From PyPI (prebuilt)
27
-
28
- ```
29
- pip install memoir-capture
30
- ```
31
-
32
- ### From source
33
-
34
- ```powershell
35
- # Clone with vcpkg
36
- git clone https://github.com/lunavod/memoir-capture.git
37
- cd Memoir
38
- git clone https://github.com/microsoft/vcpkg.git vcpkg --depth 1
39
- .\vcpkg\bootstrap-vcpkg.bat -disableMetrics
40
-
41
- # Build
42
- pip install build numpy
43
- python -m build --wheel
44
-
45
- # Install
46
- pip install dist\memoir_capture-*.whl
47
27
  ```
48
-
49
- The first build takes ~15 minutes (vcpkg builds FFmpeg). Subsequent builds use cached binaries.
50
-
51
- For local development without installing:
52
-
53
- ```powershell
54
- .\scripts\build.ps1
55
- # memoir_capture/ package is ready to import from the project root
28
+ npm install memoir-node
56
29
  ```
57
30
 
58
31
  ## Quick Start
59
32
 
60
33
  ### Capture frames
61
34
 
62
- ```python
63
- import memoir_capture
35
+ ```typescript
36
+ import { CaptureEngine } from 'memoir-node'
64
37
 
65
- engine = memoir_capture.CaptureEngine(
66
- memoir_capture.MonitorTarget(0), # primary monitor
67
- max_fps=10.0,
68
- )
38
+ const engine = new CaptureEngine({
39
+ target: { type: 'monitor', index: 0 }, // primary monitor
40
+ maxFps: 10,
41
+ })
69
42
  engine.start()
70
43
 
71
- for packet in engine.frames():
72
- with packet:
73
- img = packet.cpu_bgra # numpy (H, W, 4) uint8
74
- print(f"Frame {packet.frame_id}: {img.shape}")
75
- print(f"Keys: 0x{packet.keyboard_mask:016x}")
76
- break
44
+ const frame = engine.getNextFrame(5000)
45
+ if (frame) {
46
+ console.log(`Frame ${frame.frameId}: ${frame.width}x${frame.height}`)
47
+ console.log(`Keys: ${frame.keyboardMask.toString(16)}`)
48
+ // frame.data is a Buffer with BGRA pixels
49
+ frame.release()
50
+ }
77
51
 
78
52
  engine.stop()
79
53
  ```
80
54
 
81
55
  ### Capture a specific window
82
56
 
83
- ```python
84
- engine = memoir_capture.CaptureEngine(
85
- memoir_capture.WindowTitleTarget(r"(?i)notepad"),
86
- max_fps=30.0,
87
- )
57
+ ```typescript
58
+ const engine = new CaptureEngine({
59
+ target: { type: 'windowTitle', pattern: '(?i)notepad' },
60
+ maxFps: 30,
61
+ })
62
+ ```
63
+
64
+ Or by executable name:
65
+
66
+ ```typescript
67
+ const engine = new CaptureEngine({
68
+ target: { type: 'windowExe', pattern: 'notepad\\.exe' },
69
+ maxFps: 30,
70
+ })
88
71
  ```
89
72
 
90
73
  ### Record to MP4
91
74
 
92
- ```python
93
- engine = memoir_capture.CaptureEngine(
94
- memoir_capture.MonitorTarget(0),
95
- max_fps=10.0,
96
- record_width=1920,
97
- record_height=1080,
98
- record_gop=1, # 1 = all-intra (frame-accurate seeking)
99
- )
75
+ ```typescript
76
+ const engine = new CaptureEngine({
77
+ target: { type: 'monitor', index: 0 },
78
+ maxFps: 10,
79
+ recordWidth: 1920,
80
+ recordHeight: 1080,
81
+ })
100
82
  engine.start()
101
83
 
102
- info = engine.start_recording("session_001")
103
- print(f"Recording to {info.video_path}") # session_001.mp4
84
+ const info = engine.startRecording('session_001')
85
+ console.log(`Recording to ${info.videoPath}`) // session_001.mp4
104
86
 
105
- for i, packet in enumerate(engine.frames()):
106
- packet.release()
107
- if i >= 99:
108
- break
87
+ for (let i = 0; i < 100; i++) {
88
+ const frame = engine.getNextFrame(5000)
89
+ if (frame) frame.release()
90
+ }
109
91
 
110
- engine.stop_recording() # finalizes .mp4 + .meta
92
+ engine.stopRecording() // finalizes .mp4 + .meta
111
93
  engine.stop()
112
94
  ```
113
95
 
114
96
  ### Read metadata
115
97
 
116
- ```python
117
- meta = memoir_capture.MetaReader.read("session_001.meta")
98
+ ```typescript
99
+ import { readMeta, pressedKeys } from 'memoir-node'
118
100
 
119
- print(f"Keys tracked: {[k.name for k in meta.keys]}")
101
+ const meta = readMeta('session_001.meta')
102
+ console.log(`Keys tracked: ${meta.keys.map(k => k.name)}`)
120
103
 
121
- for row in meta.rows:
122
- print(f"Frame {row.frame_id}: keyboard=0x{row.keyboard_mask:016x}")
104
+ for (const row of meta.rows) {
105
+ console.log(`Frame ${row.frameId}: ${pressedKeys(row, meta.keys).join(', ')}`)
106
+ }
123
107
  ```
124
108
 
125
109
  ### Write metadata (for synthetic replays)
126
110
 
127
- ```python
128
- from memoir_capture import MetaWriter, MetaKeyEntry, MetaRow
111
+ ```typescript
112
+ import { writeMeta, type MetaKeyEntry, type MetaRow } from 'memoir-node'
113
+
114
+ const keys: MetaKeyEntry[] = [
115
+ { bit: 0, vk: 0x57, name: 'W' },
116
+ { bit: 1, vk: 0x41, name: 'A' },
117
+ ]
129
118
 
130
- keys = [MetaKeyEntry(0, 0x57, "W"), MetaKeyEntry(1, 0x41, "A")]
119
+ const rows: MetaRow[] = [{
120
+ frameId: 0n, recordFrameIndex: 0n,
121
+ captureQpc: 0n, hostAcceptQpc: 0n,
122
+ keyboardMask: 0b01n,
123
+ width: 1920, height: 1080, analysisStride: 7680, flags: 0,
124
+ }]
131
125
 
132
- with MetaWriter("synthetic.meta", keys) as w:
133
- w.write_row(MetaRow(
134
- frame_id=0, record_frame_index=0,
135
- capture_qpc=0, host_accept_qpc=0,
136
- keyboard_mask=0b01,
137
- width=1920, height=1080, analysis_stride=7680,
138
- ))
126
+ writeMeta('synthetic.meta', keys, rows)
139
127
  ```
140
128
 
141
- ### Context manager
129
+ ### Worker thread usage (Electron)
142
130
 
143
- ```python
144
- with memoir_capture.CaptureEngine(memoir_capture.MonitorTarget(0)) as engine:
145
- packet = engine.get_next_frame(timeout_ms=2000)
146
- if packet:
147
- with packet:
148
- process(packet.cpu_bgra)
149
- ```
131
+ ```typescript
132
+ // tick-worker.ts runs in Worker thread
133
+ import { CaptureEngine } from 'memoir-node'
134
+ import { parentPort } from 'worker_threads'
150
135
 
151
- ## API Reference
136
+ const engine = new CaptureEngine({
137
+ target: { type: 'windowExe', pattern: 'myapp\\.exe' },
138
+ maxFps: 10,
139
+ })
140
+ engine.start()
152
141
 
153
- ### `CaptureEngine(target, *, max_fps, ...)`
142
+ while (true) {
143
+ const frame = engine.getNextFrame(2000)
144
+ if (!frame) {
145
+ const err = engine.lastError()
146
+ if (err) { parentPort!.postMessage({ type: 'error', error: err }); break }
147
+ continue
148
+ }
149
+
150
+ // frame.data is a Buffer — process it here, don't post it to main thread
151
+ parentPort!.postMessage({
152
+ type: 'tick',
153
+ frameId: frame.frameId,
154
+ width: frame.width,
155
+ height: frame.height,
156
+ })
157
+
158
+ frame.release()
159
+ }
160
+ ```
154
161
 
155
- | Parameter | Default | Description |
156
- |-----------|---------|-------------|
157
- | `target` | required | `MonitorTarget(index)`, `WindowTitleTarget(regex)`, or `WindowExeTarget(regex)` |
158
- | `max_fps` | `10.0` | Maximum accepted frame rate |
159
- | `analysis_queue_capacity` | `1` | Bounded queue size for Python delivery |
160
- | `capture_cursor` | `False` | Include cursor in capture |
161
- | `record_width` | `1920` | Recording output width |
162
- | `record_height` | `1080` | Recording output height |
163
- | `record_gop` | `1` | GOP size (1 = all-intra, higher = smaller files) |
162
+ ## API Reference
164
163
 
165
- **Methods**: `start()`, `stop()`, `get_next_frame(timeout_ms)`, `frames()`, `start_recording(base_path, *, encoder=None)`, `stop_recording()`, `is_recording()`, `stats()`
164
+ ### `new CaptureEngine(options)`
165
+
166
+ | Option | Default | Description |
167
+ |--------|---------|-------------|
168
+ | `target` | required | `{ type: 'monitor', index }`, `{ type: 'windowTitle', pattern }`, or `{ type: 'windowExe', pattern }` |
169
+ | `maxFps` | `10` | Maximum accepted frame rate |
170
+ | `queueCapacity` | `1` | Bounded queue size |
171
+ | `captureCursor` | `false` | Include cursor in capture |
172
+ | `keys` | 40-key gaming set | Array of `{ bit, vk, name }` for keyboard tracking |
173
+ | `recordWidth` | `1920` | Recording output width |
174
+ | `recordHeight` | `1080` | Recording output height |
175
+ | `recordGop` | `1` | GOP size (1 = all-intra) |
176
+
177
+ ### Methods
178
+
179
+ | Method | Returns | Description |
180
+ |--------|---------|-------------|
181
+ | `start()` | `void` | Initialize D3D11, create WGC session, begin capturing |
182
+ | `stop()` | `void` | Stop capture and any active recording |
183
+ | `getNextFrame(timeoutMs?)` | `FramePacket \| null` | Block until frame available. -1 = forever, 0 = poll |
184
+ | `startRecording(basePath, encoder?)` | `RecordingInfo` | Start recording to `basePath.mp4` + `basePath.meta` |
185
+ | `startRecording(opts)` | `RecordingInfo` | Start recording with `{ path, videoName, metaName, encoder? }` |
186
+ | `stopRecording()` | `void` | Finalize recording |
187
+ | `isRecording()` | `boolean` | Whether a recording session is active |
188
+ | `stats()` | `EngineStats` | Live counters |
189
+ | `lastError()` | `string \| null` | Last non-fatal error |
166
190
 
167
191
  ### `FramePacket`
168
192
 
169
193
  | Property | Type | Description |
170
194
  |----------|------|-------------|
171
- | `frame_id` | `int` | Monotonic ID (only accepted frames get IDs) |
172
- | `cpu_bgra` | `np.ndarray` | `(H, W, 4)` uint8 BGRA pixel data |
173
- | `keyboard_mask` | `int` | 64-bit bitmask of tracked keys |
174
- | `capture_qpc` | `int` | WGC capture timestamp (100ns units) |
175
- | `width`, `height`, `stride` | `int` | Frame dimensions |
195
+ | `frameId` | `number` | Monotonic frame ID |
196
+ | `data` | `Buffer` | BGRA pixels, length = stride * height |
197
+ | `keyboardMask` | `bigint` | 64-bit key state bitmask |
198
+ | `captureQpc` | `bigint` | WGC timestamp (100ns units) |
199
+ | `hostAcceptQpc` | `bigint` | Host QPC when frame was accepted |
200
+ | `width`, `height`, `stride` | `number` | Frame dimensions |
201
+ | `released` | `boolean` | Whether pixel memory has been freed |
176
202
 
177
- Supports `with packet:` (auto-release) and explicit `packet.release()`.
203
+ Call `frame.release()` when done to free pixel memory.
178
204
 
179
205
  ### Recording
180
206
 
181
- Encoding: lossless HEVC (QP=0), YUV 4:4:4, full color range. The encoder is selected automatically in priority order: `hevc_nvenc` (NVIDIA) → `hevc_amf` (AMD) → `libx265` (software). You can force a specific encoder:
207
+ Encoding: lossless HEVC (QP=0), YUV 4:4:4. Encoder selected automatically: `hevc_nvenc` (NVIDIA) → `hevc_amf` (AMD) → `libx265` (software). Force a specific encoder:
182
208
 
183
- ```python
184
- info = engine.start_recording("session_001", encoder="libx265")
209
+ ```typescript
210
+ const info = engine.startRecording('session', 'libx265')
211
+ console.log(info.codec) // "libx265"
185
212
  ```
186
213
 
187
- | Setting | File size (10s, 1080p) |
188
- |---------|----------------------|
189
- | GOP=1 (all-intra) | ~52 MB |
190
- | GOP=10 | ~17 MB |
191
- | GOP=30 | ~14 MB |
214
+ ### Meta utilities
215
+
216
+ ```typescript
217
+ import { readMeta, writeMeta, isPressed, pressedKeys, synthesizeKeyEvents } from 'memoir-node'
218
+
219
+ // Read
220
+ const meta = readMeta('session.meta')
221
+
222
+ // Check keys
223
+ isPressed(meta.rows[0], 'W', meta.keys) // boolean
224
+ pressedKeys(meta.rows[0], meta.keys) // string[]
225
+
226
+ // Generate key events for replay
227
+ const events = synthesizeKeyEvents(meta.rows, meta.keys)
228
+ // [{ frame: 0n, type: 'keyDown', key: 'W' }, ...]
229
+ ```
192
230
 
193
231
  ## Architecture
194
232
 
@@ -199,25 +237,12 @@ WGC FrameArrived (thread pool)
199
237
  ├─ Queue check → drop if full (drop-new policy)
200
238
 
201
239
  ├─ Accept: assign frame_id, snapshot keyboard
202
- ├─ GPU→CPU: CopyResource → staging → Map → memcpy
240
+ ├─ GPU→CPU: CopyResource → staging → Map → memcpy → Buffer (zero-copy)
203
241
  ├─ Recording: swscale (BGRA→YUV444) → HEVC encoder → MP4
204
- └─ Enqueue → Python consumer
242
+ └─ Enqueue → Node.js consumer
205
243
  ```
206
244
 
207
- - Single `ID3D11DeviceContext` + mutex for all GPU ops
208
- - WGC `CreateFreeThreaded` — no DispatcherQueue needed
209
- - Recording runs on the callback thread (well within budget at 10fps)
210
- - `FramePacket` owns its pixel buffer; `release()` frees it
211
-
212
- ## Testing
213
-
214
- ```powershell
215
- # Full suite (requires display + supported GPU or x265)
216
- pytest -v
217
-
218
- # Headless (CI-safe)
219
- pytest --headless -v
220
- ```
245
+ `getNextFrame()` is a synchronous blocking call that waits on a condition variable. No GIL concerns (unlike Python) — the calling thread simply sleeps until a frame arrives. Use in a Worker thread to avoid blocking the main V8 thread.
221
246
 
222
247
  ## License
223
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-node",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Windows-native screen capture with Node.js bindings",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,7 +20,9 @@
20
20
  "build:ts": "tsc",
21
21
  "build": "npm run build:native && tsc",
22
22
  "rebuild": "cmake-js rebuild --CDMEMOIR_BUILD_NODE=ON --CDMEMOIR_BUILD_PYTHON=OFF --CDCMAKE_TOOLCHAIN_FILE=vcpkg/scripts/buildsystems/vcpkg.cmake --CDVCPKG_OVERLAY_TRIPLETS=cmake/triplets --CDVCPKG_TARGET_TRIPLET=x64-windows-release",
23
- "test": "vitest run"
23
+ "test": "vitest run",
24
+ "prepack": "node -e \"const fs=require('fs'); fs.copyFileSync('README.md','README-python.md'); fs.copyFileSync('README-node.md','README.md')\"",
25
+ "postpack": "node -e \"const fs=require('fs'); fs.copyFileSync('README-python.md','README.md'); fs.unlinkSync('README-python.md')\""
24
26
  },
25
27
  "dependencies": {},
26
28
  "devDependencies": {