memoir-node 0.1.5
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/LICENSE +27 -0
- package/README.md +224 -0
- package/build/Release/avcodec-62.dll +0 -0
- package/build/Release/avdevice-62.dll +0 -0
- package/build/Release/avfilter-11.dll +0 -0
- package/build/Release/avformat-62.dll +0 -0
- package/build/Release/avutil-60.dll +0 -0
- package/build/Release/libx265.dll +0 -0
- package/build/Release/memoir_node.node +0 -0
- package/build/Release/swresample-6.dll +0 -0
- package/build/Release/swscale-9.dll +0 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +16 -0
- package/dist/meta.d.ts +10 -0
- package/dist/meta.js +119 -0
- package/dist/native.d.ts +29 -0
- package/dist/native.js +37 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +3 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lunavod
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This software links against FFmpeg, which is licensed under the
|
|
26
|
+
GNU Lesser General Public License (LGPL) version 2.1 or later.
|
|
27
|
+
See https://www.ffmpeg.org/legal.html for details.
|
package/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# memoir-capture
|
|
2
|
+
|
|
3
|
+
Windows-native screen capture module with Python bindings for real-time frame analysis and deterministic replay recording.
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
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
|
|
12
|
+
- **Binary metadata** — `.meta` sidecar with per-frame keyboard state, timestamps, and frame IDs
|
|
13
|
+
- **Dynamic recording** — start/stop recording without restarting capture
|
|
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
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
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)
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
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
|
+
```
|
|
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
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
### Capture frames
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import memoir_capture
|
|
64
|
+
|
|
65
|
+
engine = memoir_capture.CaptureEngine(
|
|
66
|
+
memoir_capture.MonitorTarget(0), # primary monitor
|
|
67
|
+
max_fps=10.0,
|
|
68
|
+
)
|
|
69
|
+
engine.start()
|
|
70
|
+
|
|
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
|
|
77
|
+
|
|
78
|
+
engine.stop()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Capture a specific window
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
engine = memoir_capture.CaptureEngine(
|
|
85
|
+
memoir_capture.WindowTitleTarget(r"(?i)notepad"),
|
|
86
|
+
max_fps=30.0,
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Record to MP4
|
|
91
|
+
|
|
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
|
+
)
|
|
100
|
+
engine.start()
|
|
101
|
+
|
|
102
|
+
info = engine.start_recording("session_001")
|
|
103
|
+
print(f"Recording to {info.video_path}") # session_001.mp4
|
|
104
|
+
|
|
105
|
+
for i, packet in enumerate(engine.frames()):
|
|
106
|
+
packet.release()
|
|
107
|
+
if i >= 99:
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
engine.stop_recording() # finalizes .mp4 + .meta
|
|
111
|
+
engine.stop()
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Read metadata
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
meta = memoir_capture.MetaReader.read("session_001.meta")
|
|
118
|
+
|
|
119
|
+
print(f"Keys tracked: {[k.name for k in meta.keys]}")
|
|
120
|
+
|
|
121
|
+
for row in meta.rows:
|
|
122
|
+
print(f"Frame {row.frame_id}: keyboard=0x{row.keyboard_mask:016x}")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Write metadata (for synthetic replays)
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from memoir_capture import MetaWriter, MetaKeyEntry, MetaRow
|
|
129
|
+
|
|
130
|
+
keys = [MetaKeyEntry(0, 0x57, "W"), MetaKeyEntry(1, 0x41, "A")]
|
|
131
|
+
|
|
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
|
+
))
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Context manager
|
|
142
|
+
|
|
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
|
+
```
|
|
150
|
+
|
|
151
|
+
## API Reference
|
|
152
|
+
|
|
153
|
+
### `CaptureEngine(target, *, max_fps, ...)`
|
|
154
|
+
|
|
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) |
|
|
164
|
+
|
|
165
|
+
**Methods**: `start()`, `stop()`, `get_next_frame(timeout_ms)`, `frames()`, `start_recording(base_path, *, encoder=None)`, `stop_recording()`, `is_recording()`, `stats()`
|
|
166
|
+
|
|
167
|
+
### `FramePacket`
|
|
168
|
+
|
|
169
|
+
| Property | Type | Description |
|
|
170
|
+
|----------|------|-------------|
|
|
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 |
|
|
176
|
+
|
|
177
|
+
Supports `with packet:` (auto-release) and explicit `packet.release()`.
|
|
178
|
+
|
|
179
|
+
### Recording
|
|
180
|
+
|
|
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:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
info = engine.start_recording("session_001", encoder="libx265")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
| Setting | File size (10s, 1080p) |
|
|
188
|
+
|---------|----------------------|
|
|
189
|
+
| GOP=1 (all-intra) | ~52 MB |
|
|
190
|
+
| GOP=10 | ~17 MB |
|
|
191
|
+
| GOP=30 | ~14 MB |
|
|
192
|
+
|
|
193
|
+
## Architecture
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
WGC FrameArrived (thread pool)
|
|
197
|
+
│
|
|
198
|
+
├─ FPS limiter → drop if too soon
|
|
199
|
+
├─ Queue check → drop if full (drop-new policy)
|
|
200
|
+
│
|
|
201
|
+
├─ Accept: assign frame_id, snapshot keyboard
|
|
202
|
+
├─ GPU→CPU: CopyResource → staging → Map → memcpy
|
|
203
|
+
├─ Recording: swscale (BGRA→YUV444) → HEVC encoder → MP4
|
|
204
|
+
└─ Enqueue → Python consumer
|
|
205
|
+
```
|
|
206
|
+
|
|
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
|
+
```
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
MIT. Links against FFmpeg (LGPL 2.1+).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type { CaptureTarget, MonitorTarget, WindowTitleTarget, WindowExeTarget, KeySpec, EngineOptions, FramePacket, RecordingInfo, EngineStats, MetaHeader, MetaKeyEntry, MetaRow, MetaFile, } from './types';
|
|
2
|
+
export { readMeta, writeMeta, isPressed, pressedKeys, synthesizeKeyEvents } from './meta';
|
|
3
|
+
export { CaptureEngine, ping, version, grab } from './native';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.grab = exports.version = exports.ping = exports.CaptureEngine = exports.synthesizeKeyEvents = exports.pressedKeys = exports.isPressed = exports.writeMeta = exports.readMeta = void 0;
|
|
4
|
+
// Pure TypeScript meta reader/writer
|
|
5
|
+
var meta_1 = require("./meta");
|
|
6
|
+
Object.defineProperty(exports, "readMeta", { enumerable: true, get: function () { return meta_1.readMeta; } });
|
|
7
|
+
Object.defineProperty(exports, "writeMeta", { enumerable: true, get: function () { return meta_1.writeMeta; } });
|
|
8
|
+
Object.defineProperty(exports, "isPressed", { enumerable: true, get: function () { return meta_1.isPressed; } });
|
|
9
|
+
Object.defineProperty(exports, "pressedKeys", { enumerable: true, get: function () { return meta_1.pressedKeys; } });
|
|
10
|
+
Object.defineProperty(exports, "synthesizeKeyEvents", { enumerable: true, get: function () { return meta_1.synthesizeKeyEvents; } });
|
|
11
|
+
// Native addon
|
|
12
|
+
var native_1 = require("./native");
|
|
13
|
+
Object.defineProperty(exports, "CaptureEngine", { enumerable: true, get: function () { return native_1.CaptureEngine; } });
|
|
14
|
+
Object.defineProperty(exports, "ping", { enumerable: true, get: function () { return native_1.ping; } });
|
|
15
|
+
Object.defineProperty(exports, "version", { enumerable: true, get: function () { return native_1.version; } });
|
|
16
|
+
Object.defineProperty(exports, "grab", { enumerable: true, get: function () { return native_1.grab; } });
|
package/dist/meta.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MetaFile, MetaKeyEntry, MetaRow } from './types';
|
|
2
|
+
export declare function readMeta(path: string): MetaFile;
|
|
3
|
+
export declare function writeMeta(path: string, keys: MetaKeyEntry[], rows: MetaRow[], createdUnixNs?: bigint): void;
|
|
4
|
+
export declare function isPressed(row: MetaRow, keyName: string, keys: MetaKeyEntry[]): boolean;
|
|
5
|
+
export declare function pressedKeys(row: MetaRow, keys: MetaKeyEntry[]): string[];
|
|
6
|
+
export declare function synthesizeKeyEvents(rows: MetaRow[], keys: MetaKeyEntry[]): Array<{
|
|
7
|
+
frame: bigint;
|
|
8
|
+
type: 'keyDown' | 'keyUp';
|
|
9
|
+
key: string;
|
|
10
|
+
}>;
|
package/dist/meta.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readMeta = readMeta;
|
|
4
|
+
exports.writeMeta = writeMeta;
|
|
5
|
+
exports.isPressed = isPressed;
|
|
6
|
+
exports.pressedKeys = pressedKeys;
|
|
7
|
+
exports.synthesizeKeyEvents = synthesizeKeyEvents;
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const HEADER_SIZE = 32;
|
|
10
|
+
const KEY_SIZE = 40;
|
|
11
|
+
const ROW_SIZE = 56;
|
|
12
|
+
const MAGIC = Buffer.from('RCMETA1\x00', 'ascii');
|
|
13
|
+
function readMeta(path) {
|
|
14
|
+
const buf = (0, fs_1.readFileSync)(path);
|
|
15
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
16
|
+
// Header
|
|
17
|
+
const magic = buf.subarray(0, 8);
|
|
18
|
+
if (!magic.equals(MAGIC))
|
|
19
|
+
throw new Error(`Bad magic: ${magic.toString('hex')}`);
|
|
20
|
+
const version = view.getUint32(8, true);
|
|
21
|
+
const createdUnixNs = view.getBigUint64(16, true);
|
|
22
|
+
const keyCount = view.getUint32(24, true);
|
|
23
|
+
// Keys
|
|
24
|
+
let offset = HEADER_SIZE;
|
|
25
|
+
const keys = [];
|
|
26
|
+
for (let i = 0; i < keyCount; i++) {
|
|
27
|
+
const bit = view.getUint32(offset, true);
|
|
28
|
+
const vk = view.getUint32(offset + 4, true);
|
|
29
|
+
const nameBytes = buf.subarray(offset + 8, offset + 40);
|
|
30
|
+
const nullIdx = nameBytes.indexOf(0);
|
|
31
|
+
const name = nameBytes.subarray(0, nullIdx === -1 ? 32 : nullIdx).toString('ascii');
|
|
32
|
+
keys.push({ bit, vk, name });
|
|
33
|
+
offset += KEY_SIZE;
|
|
34
|
+
}
|
|
35
|
+
// Rows
|
|
36
|
+
const rows = [];
|
|
37
|
+
while (offset + ROW_SIZE <= buf.length) {
|
|
38
|
+
rows.push({
|
|
39
|
+
frameId: view.getBigUint64(offset, true),
|
|
40
|
+
recordFrameIndex: view.getBigUint64(offset + 8, true),
|
|
41
|
+
captureQpc: view.getBigInt64(offset + 16, true),
|
|
42
|
+
hostAcceptQpc: view.getBigInt64(offset + 24, true),
|
|
43
|
+
keyboardMask: view.getBigUint64(offset + 32, true),
|
|
44
|
+
width: view.getUint32(offset + 40, true),
|
|
45
|
+
height: view.getUint32(offset + 44, true),
|
|
46
|
+
analysisStride: view.getUint32(offset + 48, true),
|
|
47
|
+
flags: view.getUint32(offset + 52, true),
|
|
48
|
+
});
|
|
49
|
+
offset += ROW_SIZE;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
header: { version, createdUnixNs, keyCount },
|
|
53
|
+
keys,
|
|
54
|
+
rows,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function writeMeta(path, keys, rows, createdUnixNs) {
|
|
58
|
+
const size = HEADER_SIZE + keys.length * KEY_SIZE + rows.length * ROW_SIZE;
|
|
59
|
+
const buf = Buffer.alloc(size);
|
|
60
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
61
|
+
// Header
|
|
62
|
+
MAGIC.copy(buf, 0);
|
|
63
|
+
view.setUint32(8, 1, true); // version
|
|
64
|
+
view.setBigUint64(16, createdUnixNs ?? BigInt(Date.now()) * 1000000n, true);
|
|
65
|
+
view.setUint32(24, keys.length, true);
|
|
66
|
+
// Keys
|
|
67
|
+
let offset = HEADER_SIZE;
|
|
68
|
+
for (const k of keys) {
|
|
69
|
+
view.setUint32(offset, k.bit, true);
|
|
70
|
+
view.setUint32(offset + 4, k.vk, true);
|
|
71
|
+
buf.write(k.name.substring(0, 31), offset + 8, 'ascii');
|
|
72
|
+
offset += KEY_SIZE;
|
|
73
|
+
}
|
|
74
|
+
// Rows
|
|
75
|
+
for (const r of rows) {
|
|
76
|
+
view.setBigUint64(offset, r.frameId, true);
|
|
77
|
+
view.setBigUint64(offset + 8, r.recordFrameIndex, true);
|
|
78
|
+
view.setBigInt64(offset + 16, r.captureQpc, true);
|
|
79
|
+
view.setBigInt64(offset + 24, r.hostAcceptQpc, true);
|
|
80
|
+
view.setBigUint64(offset + 32, r.keyboardMask, true);
|
|
81
|
+
view.setUint32(offset + 40, r.width, true);
|
|
82
|
+
view.setUint32(offset + 44, r.height, true);
|
|
83
|
+
view.setUint32(offset + 48, r.analysisStride, true);
|
|
84
|
+
view.setUint32(offset + 52, r.flags, true);
|
|
85
|
+
offset += ROW_SIZE;
|
|
86
|
+
}
|
|
87
|
+
(0, fs_1.writeFileSync)(path, buf);
|
|
88
|
+
}
|
|
89
|
+
// ─── Helpers ────────────────────────────────────────────────────
|
|
90
|
+
function isPressed(row, keyName, keys) {
|
|
91
|
+
const key = keys.find(k => k.name === keyName);
|
|
92
|
+
if (!key)
|
|
93
|
+
throw new Error(`Key "${keyName}" not in key map`);
|
|
94
|
+
return (row.keyboardMask & (1n << BigInt(key.bit))) !== 0n;
|
|
95
|
+
}
|
|
96
|
+
function pressedKeys(row, keys) {
|
|
97
|
+
return keys.filter(k => (row.keyboardMask & (1n << BigInt(k.bit))) !== 0n).map(k => k.name);
|
|
98
|
+
}
|
|
99
|
+
function synthesizeKeyEvents(rows, keys) {
|
|
100
|
+
const events = [];
|
|
101
|
+
let prevMask = 0n;
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
const diff = row.keyboardMask ^ prevMask;
|
|
104
|
+
if (diff !== 0n) {
|
|
105
|
+
for (const k of keys) {
|
|
106
|
+
const bit = 1n << BigInt(k.bit);
|
|
107
|
+
if ((diff & bit) !== 0n) {
|
|
108
|
+
events.push({
|
|
109
|
+
frame: row.recordFrameIndex,
|
|
110
|
+
type: (row.keyboardMask & bit) !== 0n ? 'keyDown' : 'keyUp',
|
|
111
|
+
key: k.name,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
prevMask = row.keyboardMask;
|
|
117
|
+
}
|
|
118
|
+
return events;
|
|
119
|
+
}
|
package/dist/native.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CaptureTarget, EngineOptions, FramePacket } from './types';
|
|
2
|
+
export declare const CaptureEngine: {
|
|
3
|
+
new (options: EngineOptions): {
|
|
4
|
+
start(): void;
|
|
5
|
+
stop(): void;
|
|
6
|
+
getNextFrame(timeoutMs?: number): FramePacket | null;
|
|
7
|
+
startRecording(basePath: string, encoder?: string): import('./types').RecordingInfo;
|
|
8
|
+
startRecording(opts: {
|
|
9
|
+
path: string;
|
|
10
|
+
videoName: string;
|
|
11
|
+
metaName: string;
|
|
12
|
+
encoder?: string;
|
|
13
|
+
}): import('./types').RecordingInfo;
|
|
14
|
+
stopRecording(): void;
|
|
15
|
+
isRecording(): boolean;
|
|
16
|
+
stats(): import('./types').EngineStats;
|
|
17
|
+
lastError(): string | null;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export declare const ping: () => string;
|
|
21
|
+
export declare const version: string;
|
|
22
|
+
/**
|
|
23
|
+
* Capture a single frame and return it.
|
|
24
|
+
* Creates a temporary engine, grabs one frame, stops.
|
|
25
|
+
*/
|
|
26
|
+
export declare function grab(target: CaptureTarget, opts?: {
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
maxFps?: number;
|
|
29
|
+
}): FramePacket;
|
package/dist/native.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.version = exports.ping = exports.CaptureEngine = void 0;
|
|
7
|
+
exports.grab = grab;
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
// Load the compiled N-API addon
|
|
10
|
+
let addon;
|
|
11
|
+
try {
|
|
12
|
+
addon = require(path_1.default.resolve(__dirname, '../build/Release/memoir_node.node'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Fallback for different build configurations
|
|
16
|
+
addon = require(path_1.default.resolve(__dirname, '../build/Debug/memoir_node.node'));
|
|
17
|
+
}
|
|
18
|
+
exports.CaptureEngine = addon.CaptureEngine;
|
|
19
|
+
exports.ping = addon.ping;
|
|
20
|
+
exports.version = addon.version;
|
|
21
|
+
/**
|
|
22
|
+
* Capture a single frame and return it.
|
|
23
|
+
* Creates a temporary engine, grabs one frame, stops.
|
|
24
|
+
*/
|
|
25
|
+
function grab(target, opts) {
|
|
26
|
+
const engine = new exports.CaptureEngine({ target, maxFps: opts?.maxFps ?? 60 });
|
|
27
|
+
engine.start();
|
|
28
|
+
try {
|
|
29
|
+
const frame = engine.getNextFrame(opts?.timeoutMs ?? 5000);
|
|
30
|
+
if (!frame)
|
|
31
|
+
throw new Error('No frame captured within timeout');
|
|
32
|
+
return frame;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
engine.stop();
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface MonitorTarget {
|
|
2
|
+
type: 'monitor';
|
|
3
|
+
index: number;
|
|
4
|
+
}
|
|
5
|
+
export interface WindowTitleTarget {
|
|
6
|
+
type: 'windowTitle';
|
|
7
|
+
pattern: string;
|
|
8
|
+
}
|
|
9
|
+
export interface WindowExeTarget {
|
|
10
|
+
type: 'windowExe';
|
|
11
|
+
pattern: string;
|
|
12
|
+
}
|
|
13
|
+
export type CaptureTarget = MonitorTarget | WindowTitleTarget | WindowExeTarget;
|
|
14
|
+
export interface KeySpec {
|
|
15
|
+
bit: number;
|
|
16
|
+
vk: number;
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
export interface EngineOptions {
|
|
20
|
+
target: CaptureTarget;
|
|
21
|
+
maxFps?: number;
|
|
22
|
+
queueCapacity?: number;
|
|
23
|
+
captureCursor?: boolean;
|
|
24
|
+
keys?: KeySpec[];
|
|
25
|
+
recordWidth?: number;
|
|
26
|
+
recordHeight?: number;
|
|
27
|
+
recordGop?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface FramePacket {
|
|
30
|
+
readonly frameId: number;
|
|
31
|
+
readonly width: number;
|
|
32
|
+
readonly height: number;
|
|
33
|
+
readonly stride: number;
|
|
34
|
+
readonly captureQpc: bigint;
|
|
35
|
+
readonly hostAcceptQpc: bigint;
|
|
36
|
+
readonly keyboardMask: bigint;
|
|
37
|
+
readonly data: Buffer;
|
|
38
|
+
readonly released: boolean;
|
|
39
|
+
release(): void;
|
|
40
|
+
}
|
|
41
|
+
export interface RecordingInfo {
|
|
42
|
+
basePath: string;
|
|
43
|
+
videoPath: string;
|
|
44
|
+
metaPath: string;
|
|
45
|
+
codec: string;
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
}
|
|
49
|
+
export interface EngineStats {
|
|
50
|
+
framesSeen: number;
|
|
51
|
+
framesAccepted: number;
|
|
52
|
+
framesDroppedQueueFull: number;
|
|
53
|
+
framesDroppedError: number;
|
|
54
|
+
framesRecorded: number;
|
|
55
|
+
queueDepth: number;
|
|
56
|
+
recording: boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface MetaHeader {
|
|
59
|
+
version: number;
|
|
60
|
+
createdUnixNs: bigint;
|
|
61
|
+
keyCount: number;
|
|
62
|
+
}
|
|
63
|
+
export interface MetaKeyEntry {
|
|
64
|
+
bit: number;
|
|
65
|
+
vk: number;
|
|
66
|
+
name: string;
|
|
67
|
+
}
|
|
68
|
+
export interface MetaRow {
|
|
69
|
+
frameId: bigint;
|
|
70
|
+
recordFrameIndex: bigint;
|
|
71
|
+
captureQpc: bigint;
|
|
72
|
+
hostAcceptQpc: bigint;
|
|
73
|
+
keyboardMask: bigint;
|
|
74
|
+
width: number;
|
|
75
|
+
height: number;
|
|
76
|
+
analysisStride: number;
|
|
77
|
+
flags: number;
|
|
78
|
+
}
|
|
79
|
+
export interface MetaFile {
|
|
80
|
+
header: MetaHeader;
|
|
81
|
+
keys: MetaKeyEntry[];
|
|
82
|
+
rows: MetaRow[];
|
|
83
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "memoir-node",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Windows-native screen capture with Node.js bindings",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/lunavod/memoir-capture.git"
|
|
9
|
+
},
|
|
10
|
+
"os": ["win32"],
|
|
11
|
+
"cpu": ["x64"],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["screen-capture", "windows", "native", "napi", "wgc", "ffmpeg", "recording"],
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build:native": "cmake-js build --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",
|
|
20
|
+
"build:ts": "tsc",
|
|
21
|
+
"build": "npm run build:native && tsc",
|
|
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"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"cmake-js": "^7.0.0",
|
|
29
|
+
"node-addon-api": "^7.0.0",
|
|
30
|
+
"typescript": "^5.5.0",
|
|
31
|
+
"vitest": "^3.0.0"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist/*.js",
|
|
35
|
+
"dist/*.d.ts",
|
|
36
|
+
"build/Release/memoir_node.node",
|
|
37
|
+
"build/Release/*.dll",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
]
|
|
40
|
+
}
|