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.
- package/README.md +161 -136
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,194 +1,232 @@
|
|
|
1
|
-
# memoir-
|
|
1
|
+
# memoir-node
|
|
2
2
|
|
|
3
|
-
Windows-native screen capture module with
|
|
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
|
|
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
|
-
- **
|
|
11
|
-
- **Hardware-accelerated recording** — lossless HEVC encoding
|
|
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
|
-
- **
|
|
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+
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
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
|
-
```
|
|
63
|
-
import
|
|
35
|
+
```typescript
|
|
36
|
+
import { CaptureEngine } from 'memoir-node'
|
|
64
37
|
|
|
65
|
-
engine =
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
```
|
|
84
|
-
engine =
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
```
|
|
93
|
-
engine =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
103
|
-
|
|
84
|
+
const info = engine.startRecording('session_001')
|
|
85
|
+
console.log(`Recording to ${info.videoPath}`) // session_001.mp4
|
|
104
86
|
|
|
105
|
-
for i
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
87
|
+
for (let i = 0; i < 100; i++) {
|
|
88
|
+
const frame = engine.getNextFrame(5000)
|
|
89
|
+
if (frame) frame.release()
|
|
90
|
+
}
|
|
109
91
|
|
|
110
|
-
engine.
|
|
92
|
+
engine.stopRecording() // finalizes .mp4 + .meta
|
|
111
93
|
engine.stop()
|
|
112
94
|
```
|
|
113
95
|
|
|
114
96
|
### Read metadata
|
|
115
97
|
|
|
116
|
-
```
|
|
117
|
-
|
|
98
|
+
```typescript
|
|
99
|
+
import { readMeta, pressedKeys } from 'memoir-node'
|
|
118
100
|
|
|
119
|
-
|
|
101
|
+
const meta = readMeta('session_001.meta')
|
|
102
|
+
console.log(`Keys tracked: ${meta.keys.map(k => k.name)}`)
|
|
120
103
|
|
|
121
|
-
for row
|
|
122
|
-
|
|
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
|
-
```
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
129
|
+
### Worker thread usage (Electron)
|
|
142
130
|
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
136
|
+
const engine = new CaptureEngine({
|
|
137
|
+
target: { type: 'windowExe', pattern: 'myapp\\.exe' },
|
|
138
|
+
maxFps: 10,
|
|
139
|
+
})
|
|
140
|
+
engine.start()
|
|
152
141
|
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
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
|
-
|
|
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
|
|
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
|
-
```
|
|
184
|
-
info = engine.
|
|
209
|
+
```typescript
|
|
210
|
+
const info = engine.startRecording('session', 'libx265')
|
|
211
|
+
console.log(info.codec) // "libx265"
|
|
185
212
|
```
|
|
186
213
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 →
|
|
242
|
+
└─ Enqueue → Node.js consumer
|
|
205
243
|
```
|
|
206
244
|
|
|
207
|
-
|
|
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.
|
|
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": {
|