@fortemate/vega-vvd-driver 0.1.0
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 +21 -0
- package/README.md +157 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +13 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +242 -0
- package/dist/console.d.ts +9 -0
- package/dist/console.js +87 -0
- package/dist/device.d.ts +48 -0
- package/dist/device.js +208 -0
- package/dist/discovery.d.ts +27 -0
- package/dist/discovery.js +164 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +10 -0
- package/dist/keys.d.ts +13 -0
- package/dist/keys.js +85 -0
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +273 -0
- package/dist/png.d.ts +1 -0
- package/dist/png.js +49 -0
- package/dist/record.d.ts +19 -0
- package/dist/record.js +254 -0
- package/dist/safearea.d.ts +17 -0
- package/dist/safearea.js +70 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/package.json +63 -0
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// An MCP server over stdio, so that an AI coding agent can drive the VVD and
|
|
2
|
+
// see what it did: press remote keys, look at the screen, wait for it to
|
|
3
|
+
// change, record a video and check the TV safe area.
|
|
4
|
+
//
|
|
5
|
+
// A model chooses the arguments, so they are bounded: how many keys a call
|
|
6
|
+
// presses, how long it waits or records, and which files it may write. A call
|
|
7
|
+
// that the client cancels stops.
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { enableGrpc } from "./console.js";
|
|
14
|
+
import { Device } from "./device.js";
|
|
15
|
+
import { findEmulators } from "./discovery.js";
|
|
16
|
+
import { parseKeys } from "./keys.js";
|
|
17
|
+
import { record } from "./record.js";
|
|
18
|
+
import { checkSafeArea, formatColour, parseColour } from "./safearea.js";
|
|
19
|
+
import { VERSION } from "./version.js";
|
|
20
|
+
// Presses per call: 100 at the default gap take about 54 seconds.
|
|
21
|
+
export const MAX_PRESSES = 100;
|
|
22
|
+
const text = (value) => ({
|
|
23
|
+
content: [{ type: 'text', text: value }],
|
|
24
|
+
});
|
|
25
|
+
const failure = (error) => ({
|
|
26
|
+
isError: true,
|
|
27
|
+
content: [{ type: 'text', text: error.message ?? String(error) }],
|
|
28
|
+
});
|
|
29
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
// A path a model may record to: a local file with a video extension, and not
|
|
31
|
+
// one that exists unless it asks to replace it. Relative paths resolve against
|
|
32
|
+
// the server's working directory.
|
|
33
|
+
export const videoPath = (file, overwrite = false) => {
|
|
34
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(file))
|
|
35
|
+
throw new Error('file must be a local path, not a URL');
|
|
36
|
+
const path = resolve(file);
|
|
37
|
+
if (!/\.(mp4|mov|mkv)$/i.test(path))
|
|
38
|
+
throw new Error('file must end in .mp4, .mov or .mkv');
|
|
39
|
+
if (!overwrite && existsSync(path))
|
|
40
|
+
throw new Error(`${path} exists; set overwrite to replace it`);
|
|
41
|
+
return path;
|
|
42
|
+
};
|
|
43
|
+
export const createServer = (options = {}) => {
|
|
44
|
+
const server = new McpServer({ name: 'vega-vvd-driver', version: VERSION });
|
|
45
|
+
const find = { pid: options.pid, directories: options.directories };
|
|
46
|
+
// One connection, kept while the same emulator serves the same endpoint; a
|
|
47
|
+
// restarted VVD, or gRPC turned on again, gets a new one.
|
|
48
|
+
let device;
|
|
49
|
+
const connected = () => {
|
|
50
|
+
const [newest] = findEmulators(find);
|
|
51
|
+
const current = device?.emulator;
|
|
52
|
+
if (device &&
|
|
53
|
+
current &&
|
|
54
|
+
newest &&
|
|
55
|
+
current.pid === newest.pid &&
|
|
56
|
+
current.grpcPort === newest.grpcPort &&
|
|
57
|
+
current.grpcToken === newest.grpcToken)
|
|
58
|
+
return device;
|
|
59
|
+
device?.close();
|
|
60
|
+
device = undefined; // a failed connect below must not leave it cached
|
|
61
|
+
device = Device.connect({
|
|
62
|
+
...find,
|
|
63
|
+
protoDirectory: options.protoDirectory,
|
|
64
|
+
});
|
|
65
|
+
return device;
|
|
66
|
+
};
|
|
67
|
+
const screen = async (d, signal) => {
|
|
68
|
+
const frame = await d.screenshot('png', { signal });
|
|
69
|
+
return {
|
|
70
|
+
type: 'image',
|
|
71
|
+
data: frame.data.toString('base64'),
|
|
72
|
+
mimeType: 'image/png',
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
server.registerTool('list_devices', {
|
|
76
|
+
title: 'List running Vega Virtual Devices',
|
|
77
|
+
description: 'Lists the running Vega Virtual Devices whose gRPC endpoint is on. An empty list usually means gRPC is off: call enable_grpc.',
|
|
78
|
+
annotations: { readOnlyHint: true },
|
|
79
|
+
}, async () => {
|
|
80
|
+
const devices = findEmulators({ directories: options.directories }).map(({ pid, grpcPort, consolePort, avdName }) => ({
|
|
81
|
+
pid,
|
|
82
|
+
grpcPort,
|
|
83
|
+
consolePort,
|
|
84
|
+
avdName,
|
|
85
|
+
}));
|
|
86
|
+
return text(JSON.stringify(devices, null, 2));
|
|
87
|
+
});
|
|
88
|
+
server.registerTool('enable_grpc', {
|
|
89
|
+
title: 'Turn on the emulator gRPC endpoint',
|
|
90
|
+
description: 'Turns on the gRPC endpoint of a running Vega Virtual Device through its emulator console. It is off after every start of the device, and nothing else here works until it is on.',
|
|
91
|
+
inputSchema: {
|
|
92
|
+
grpc_port: z
|
|
93
|
+
.number()
|
|
94
|
+
.int()
|
|
95
|
+
.min(1024)
|
|
96
|
+
.max(65535)
|
|
97
|
+
.optional()
|
|
98
|
+
.describe('Default 8554'),
|
|
99
|
+
console_port: z
|
|
100
|
+
.number()
|
|
101
|
+
.int()
|
|
102
|
+
.min(5554)
|
|
103
|
+
.max(5682)
|
|
104
|
+
.multipleOf(2)
|
|
105
|
+
.optional()
|
|
106
|
+
.describe('The emulator console, an even port; default 5554'),
|
|
107
|
+
},
|
|
108
|
+
}, async ({ grpc_port, console_port }) => {
|
|
109
|
+
try {
|
|
110
|
+
await enableGrpc(grpc_port ?? 8554, { port: console_port });
|
|
111
|
+
const found = () => findEmulators({ directories: options.directories }).length;
|
|
112
|
+
for (let i = 0; i < 20 && found() === 0; i++)
|
|
113
|
+
await sleep(250);
|
|
114
|
+
return text(found()
|
|
115
|
+
? `gRPC is on at port ${grpc_port ?? 8554}.`
|
|
116
|
+
: 'The console accepted the command, but no discovery file appeared yet.');
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return failure(error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
server.registerTool('press_keys', {
|
|
123
|
+
title: 'Press remote keys',
|
|
124
|
+
description: `Presses TV remote keys in order: up, down, left, right, ok, back, menu, playpause, rewind, fastforward, a KEY_* name or an evdev code. Append *N to repeat (down*3), :down or :up to hold or release. At most ${MAX_PRESSES} presses per call. Home cannot be pressed on the Virtual Device. Set screenshot_after to see the result.`,
|
|
125
|
+
inputSchema: {
|
|
126
|
+
keys: z
|
|
127
|
+
.array(z.string().min(1).max(40))
|
|
128
|
+
.min(1)
|
|
129
|
+
.max(50)
|
|
130
|
+
.describe('For example ["down", "down", "ok"]'),
|
|
131
|
+
gap_ms: z
|
|
132
|
+
.number()
|
|
133
|
+
.int()
|
|
134
|
+
.min(0)
|
|
135
|
+
.max(10000)
|
|
136
|
+
.optional()
|
|
137
|
+
.describe('Pause after each key, default 450'),
|
|
138
|
+
screenshot_after: z
|
|
139
|
+
.boolean()
|
|
140
|
+
.optional()
|
|
141
|
+
.describe('Return a screenshot once the keys are pressed'),
|
|
142
|
+
},
|
|
143
|
+
}, async ({ keys, gap_ms, screenshot_after }, { signal }) => {
|
|
144
|
+
try {
|
|
145
|
+
const steps = parseKeys(keys); // a typo fails before any device is touched
|
|
146
|
+
if (steps.length > MAX_PRESSES)
|
|
147
|
+
throw new Error(`${steps.length} presses asked for; at most ${MAX_PRESSES} per call`);
|
|
148
|
+
const d = connected();
|
|
149
|
+
await d.press(steps, { gapMs: gap_ms, signal });
|
|
150
|
+
const done = text(`Pressed ${keys.join(' ')}.`);
|
|
151
|
+
if (screenshot_after)
|
|
152
|
+
done.content.push(await screen(d, signal));
|
|
153
|
+
return done;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return failure(error);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
server.registerTool('screenshot', {
|
|
160
|
+
title: 'Take a screenshot',
|
|
161
|
+
description: 'Returns the current screen of the Vega Virtual Device as a PNG image (1920x1080).',
|
|
162
|
+
annotations: { readOnlyHint: true },
|
|
163
|
+
}, async ({ signal }) => {
|
|
164
|
+
try {
|
|
165
|
+
return { content: [await screen(connected(), signal)] };
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return failure(error);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
server.registerTool('wait_for_change', {
|
|
172
|
+
title: 'Wait until the screen changes',
|
|
173
|
+
description: 'Waits until the screen differs from how it looked when the call began, for example after starting an app or while the app is thinking. Returns whether it changed, with a screenshot.',
|
|
174
|
+
inputSchema: {
|
|
175
|
+
timeout_ms: z
|
|
176
|
+
.number()
|
|
177
|
+
.int()
|
|
178
|
+
.min(100)
|
|
179
|
+
.max(120000)
|
|
180
|
+
.optional()
|
|
181
|
+
.describe('Default 5000'),
|
|
182
|
+
},
|
|
183
|
+
annotations: { readOnlyHint: true },
|
|
184
|
+
}, async ({ timeout_ms }, { signal }) => {
|
|
185
|
+
try {
|
|
186
|
+
const d = connected();
|
|
187
|
+
const changed = await d.waitForChange({
|
|
188
|
+
timeoutMs: timeout_ms,
|
|
189
|
+
signal,
|
|
190
|
+
});
|
|
191
|
+
const result = text(changed
|
|
192
|
+
? 'The screen changed.'
|
|
193
|
+
: 'The screen did not change before the timeout.');
|
|
194
|
+
result.content.push(await screen(d, signal));
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
return failure(error);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
server.registerTool('record_video', {
|
|
202
|
+
title: 'Record a video with sound',
|
|
203
|
+
description: 'Records the screen and the sound of the Vega Virtual Device to a video file, at 1080p. Needs ffmpeg on the PATH. Blocks for the whole duration; cancelling the call stops the recording.',
|
|
204
|
+
inputSchema: {
|
|
205
|
+
file: z
|
|
206
|
+
.string()
|
|
207
|
+
.min(1)
|
|
208
|
+
.max(1024)
|
|
209
|
+
.describe("A local .mp4, .mov or .mkv path, absolute or relative to the server's working directory"),
|
|
210
|
+
seconds: z.number().min(1).max(600),
|
|
211
|
+
fps: z.number().int().min(1).max(60).optional().describe('Default 30'),
|
|
212
|
+
audio: z.boolean().optional().describe('Default true'),
|
|
213
|
+
overwrite: z
|
|
214
|
+
.boolean()
|
|
215
|
+
.optional()
|
|
216
|
+
.describe('Replace an existing file; default false'),
|
|
217
|
+
},
|
|
218
|
+
}, async ({ file, seconds, fps, audio, overwrite }, { signal }) => {
|
|
219
|
+
try {
|
|
220
|
+
const path = videoPath(file, overwrite);
|
|
221
|
+
const result = await record(connected(), {
|
|
222
|
+
file: path,
|
|
223
|
+
seconds,
|
|
224
|
+
fps,
|
|
225
|
+
audio,
|
|
226
|
+
signal,
|
|
227
|
+
});
|
|
228
|
+
return text(`Recorded ${result.file}: ${result.frames} frames, ${result.audioSeconds.toFixed(1)} s of audio.`);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
return failure(error);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
server.registerTool('check_safe_area', {
|
|
235
|
+
title: 'Check the TV safe area',
|
|
236
|
+
description: 'Counts the pixels in the outer margin of the screen (5% of each edge by default) that differ from the background. Fire TV guidance keeps content out of that margin, which a television may crop.',
|
|
237
|
+
inputSchema: {
|
|
238
|
+
background: z
|
|
239
|
+
.string()
|
|
240
|
+
.max(7)
|
|
241
|
+
.optional()
|
|
242
|
+
.describe('#rrggbb; the most common margin colour by default'),
|
|
243
|
+
margin: z
|
|
244
|
+
.number()
|
|
245
|
+
.min(0.01)
|
|
246
|
+
.max(0.25)
|
|
247
|
+
.optional()
|
|
248
|
+
.describe('Fraction of each edge, default 0.05'),
|
|
249
|
+
},
|
|
250
|
+
annotations: { readOnlyHint: true },
|
|
251
|
+
}, async ({ background, margin }, { signal }) => {
|
|
252
|
+
try {
|
|
253
|
+
const frame = await connected().screenshot('rgb', { signal });
|
|
254
|
+
const report = checkSafeArea(frame.data, frame.width, frame.height, {
|
|
255
|
+
background: background ? parseColour(background) : undefined,
|
|
256
|
+
margin,
|
|
257
|
+
});
|
|
258
|
+
return text(`${report.clear ? 'Clear' : 'Not clear'}: left ${report.left}, right ${report.right}, top ${report.top}, bottom ${report.bottom} pixels differ from ${formatColour(report.background)}.`);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
return failure(error);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
// Let go of the device when the client goes away.
|
|
265
|
+
server.server.onclose = () => {
|
|
266
|
+
device?.close();
|
|
267
|
+
device = undefined;
|
|
268
|
+
};
|
|
269
|
+
return server;
|
|
270
|
+
};
|
|
271
|
+
export const serveStdio = async (options = {}) => {
|
|
272
|
+
await createServer(options).connect(new StdioServerTransport());
|
|
273
|
+
};
|
package/dist/png.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const encodePng: (width: number, height: number, rgb: Buffer) => Buffer;
|
package/dist/png.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A minimal PNG encoder for RGB888 frames, so that frames can be saved
|
|
2
|
+
// without an image library: one IHDR chunk, the rows deflated with no filter,
|
|
3
|
+
// and IEND.
|
|
4
|
+
import { deflateSync } from 'node:zlib';
|
|
5
|
+
const CRC_TABLE = (() => {
|
|
6
|
+
const table = new Uint32Array(256);
|
|
7
|
+
for (let n = 0; n < 256; n++) {
|
|
8
|
+
let c = n;
|
|
9
|
+
for (let k = 0; k < 8; k++)
|
|
10
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
11
|
+
table[n] = c >>> 0;
|
|
12
|
+
}
|
|
13
|
+
return table;
|
|
14
|
+
})();
|
|
15
|
+
const crc32 = (bytes) => {
|
|
16
|
+
let crc = 0xffffffff;
|
|
17
|
+
for (const byte of bytes)
|
|
18
|
+
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
19
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
20
|
+
};
|
|
21
|
+
const chunk = (type, data) => {
|
|
22
|
+
const length = Buffer.alloc(4);
|
|
23
|
+
length.writeUInt32BE(data.length);
|
|
24
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
25
|
+
const crc = Buffer.alloc(4);
|
|
26
|
+
crc.writeUInt32BE(crc32(body));
|
|
27
|
+
return Buffer.concat([length, body, crc]);
|
|
28
|
+
};
|
|
29
|
+
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
30
|
+
export const encodePng = (width, height, rgb) => {
|
|
31
|
+
const stride = width * 3;
|
|
32
|
+
if (rgb.length < stride * height)
|
|
33
|
+
throw new Error(`expected ${width}x${height} RGB data, got ${rgb.length} bytes`);
|
|
34
|
+
const header = Buffer.alloc(13);
|
|
35
|
+
header.writeUInt32BE(width, 0);
|
|
36
|
+
header.writeUInt32BE(height, 4);
|
|
37
|
+
header[8] = 8; // bits per channel
|
|
38
|
+
header[9] = 2; // colour type: RGB
|
|
39
|
+
// compression, filter and interlace methods stay 0
|
|
40
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
41
|
+
for (let y = 0; y < height; y++)
|
|
42
|
+
rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
43
|
+
return Buffer.concat([
|
|
44
|
+
SIGNATURE,
|
|
45
|
+
chunk('IHDR', header),
|
|
46
|
+
chunk('IDAT', deflateSync(raw, { level: 6 })),
|
|
47
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
48
|
+
]);
|
|
49
|
+
};
|
package/dist/record.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AudioPacket, type Device } from './device.ts';
|
|
2
|
+
export type RecordOptions = {
|
|
3
|
+
file: string;
|
|
4
|
+
seconds: number;
|
|
5
|
+
fps?: number;
|
|
6
|
+
audio?: boolean;
|
|
7
|
+
onStart?: () => void | Promise<void>;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
};
|
|
10
|
+
export type RecordResult = {
|
|
11
|
+
file: string;
|
|
12
|
+
frames: number;
|
|
13
|
+
screenshots: number;
|
|
14
|
+
audioSeconds: number;
|
|
15
|
+
};
|
|
16
|
+
export declare const assembleAudio: (packets: readonly AudioPacket[], startUs: number, seconds: number) => Buffer;
|
|
17
|
+
export declare const audioWithin: (packets: readonly AudioPacket[], startUs: number, seconds: number) => number;
|
|
18
|
+
export declare const hasFfmpeg: () => Promise<boolean>;
|
|
19
|
+
export declare const record: (device: Device, options: RecordOptions) => Promise<RecordResult>;
|
package/dist/record.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Recording the VVD's screen, with its sound, to a video file. Needs ffmpeg on
|
|
2
|
+
// the PATH.
|
|
3
|
+
//
|
|
4
|
+
// Frames are polled with getScreenshot and written to ffmpeg at a fixed frame
|
|
5
|
+
// rate, repeating the latest frame when the screen is still. The emulator's
|
|
6
|
+
// streamScreenshot was tried first and can stop delivering frames while the
|
|
7
|
+
// screen keeps changing; polling keeps up at about 90 screenshots a second at
|
|
8
|
+
// 1080p. The audio comes from streamAudio. The emulator sends nothing while
|
|
9
|
+
// the device is silent, so the track is rebuilt on the video's clock from each
|
|
10
|
+
// packet's capture time, with silence in the gaps.
|
|
11
|
+
//
|
|
12
|
+
// However a recording ends, it cleans up after itself: ffmpeg is stopped, the
|
|
13
|
+
// audio stream is cancelled and the working directory is removed.
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { once } from 'node:events';
|
|
16
|
+
import { accessSync, constants, mkdtempSync, rmSync, writeFileSync, } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
20
|
+
import { CALL_TIMEOUT_MS } from "./device.js";
|
|
21
|
+
// Settles when the process has ended: resolves on exit code 0, and otherwise
|
|
22
|
+
// rejects with the end of what it wrote to stderr.
|
|
23
|
+
const ended = (child, name) => new Promise((resolve, reject) => {
|
|
24
|
+
let stderr = '';
|
|
25
|
+
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
|
26
|
+
child.on('error', reject);
|
|
27
|
+
child.on('close', (code, signal) => code === 0
|
|
28
|
+
? resolve()
|
|
29
|
+
: reject(new Error(`${name} exited with ${code ?? signal}: ${stderr.trim().slice(-500)}`)));
|
|
30
|
+
});
|
|
31
|
+
const run = (command, args, signal) => ended(spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'], signal }), command);
|
|
32
|
+
// Waits for a promise, or rejects as soon as the signal fires.
|
|
33
|
+
const abortable = (promise, signal) => {
|
|
34
|
+
if (!signal)
|
|
35
|
+
return promise;
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const onAbort = () => reject(signal.reason);
|
|
39
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
40
|
+
promise.then((value) => {
|
|
41
|
+
signal.removeEventListener('abort', onAbort);
|
|
42
|
+
resolve(value);
|
|
43
|
+
}, (error) => {
|
|
44
|
+
signal.removeEventListener('abort', onAbort);
|
|
45
|
+
reject(error);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
const SAMPLE_RATE = 44100;
|
|
50
|
+
const FRAME_BYTES = 4; // 16-bit stereo
|
|
51
|
+
// Lays audio packets on a timeline that starts at `startUs` and lasts
|
|
52
|
+
// `seconds`, as 16-bit stereo PCM: silence where nothing arrived, and a packet
|
|
53
|
+
// that starts earlier than the audio before it overwrites the overlap. Packets
|
|
54
|
+
// captured before the start are cut; the result is exactly as long as asked.
|
|
55
|
+
export const assembleAudio = (packets, startUs, seconds) => {
|
|
56
|
+
const track = Buffer.alloc(Math.round(seconds * SAMPLE_RATE) * FRAME_BYTES);
|
|
57
|
+
for (const packet of packets) {
|
|
58
|
+
const frames = Math.floor(packet.pcm.length / FRAME_BYTES);
|
|
59
|
+
let offset = Math.round(((packet.timestampUs - startUs) / 1e6) * SAMPLE_RATE);
|
|
60
|
+
let from = 0;
|
|
61
|
+
if (offset < 0) {
|
|
62
|
+
from = -offset;
|
|
63
|
+
offset = 0;
|
|
64
|
+
}
|
|
65
|
+
if (from >= frames || offset * FRAME_BYTES >= track.length)
|
|
66
|
+
continue;
|
|
67
|
+
packet.pcm.copy(track, offset * FRAME_BYTES, from * FRAME_BYTES, frames * FRAME_BYTES);
|
|
68
|
+
}
|
|
69
|
+
return track;
|
|
70
|
+
};
|
|
71
|
+
// How many seconds of the window from `startUs` the packets cover.
|
|
72
|
+
export const audioWithin = (packets, startUs, seconds) => {
|
|
73
|
+
const endUs = startUs + seconds * 1e6;
|
|
74
|
+
let covered = 0;
|
|
75
|
+
for (const packet of packets) {
|
|
76
|
+
const lengthUs = (Math.floor(packet.pcm.length / FRAME_BYTES) / SAMPLE_RATE) * 1e6;
|
|
77
|
+
const from = Math.max(packet.timestampUs, startUs);
|
|
78
|
+
const to = Math.min(packet.timestampUs + lengthUs, endUs);
|
|
79
|
+
if (to > from)
|
|
80
|
+
covered += to - from;
|
|
81
|
+
}
|
|
82
|
+
return covered / 1e6;
|
|
83
|
+
};
|
|
84
|
+
export const hasFfmpeg = () => run('ffmpeg', ['-version']).then(() => true, () => false);
|
|
85
|
+
// The output as an absolute path, in a directory that can be written: checked
|
|
86
|
+
// before anything is recorded, not after.
|
|
87
|
+
const outputPath = (file) => {
|
|
88
|
+
const path = resolve(file);
|
|
89
|
+
try {
|
|
90
|
+
accessSync(dirname(path), constants.W_OK);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error(`cannot write to ${dirname(path)}`);
|
|
94
|
+
}
|
|
95
|
+
return path;
|
|
96
|
+
};
|
|
97
|
+
export const record = async (device, options) => {
|
|
98
|
+
const { signal } = options;
|
|
99
|
+
const output = outputPath(options.file);
|
|
100
|
+
// "file:" keeps ffmpeg from reading a name such as "http://…" as a URL.
|
|
101
|
+
const target = `file:${output}`;
|
|
102
|
+
if (!(await hasFfmpeg()))
|
|
103
|
+
throw new Error('recording needs ffmpeg on the PATH (for example: brew install ffmpeg)');
|
|
104
|
+
const fps = options.fps ?? 30;
|
|
105
|
+
const withAudio = options.audio ?? true;
|
|
106
|
+
const work = mkdtempSync(join(tmpdir(), 'vvd-record-'));
|
|
107
|
+
let encoder;
|
|
108
|
+
let encoded;
|
|
109
|
+
let stopAudio;
|
|
110
|
+
try {
|
|
111
|
+
const first = await device.screenshot('rgb', { signal });
|
|
112
|
+
const video = join(work, 'video.mp4');
|
|
113
|
+
encoder = spawn('ffmpeg', [
|
|
114
|
+
['-loglevel', 'error', '-y'],
|
|
115
|
+
[
|
|
116
|
+
'-f',
|
|
117
|
+
'rawvideo',
|
|
118
|
+
'-pix_fmt',
|
|
119
|
+
'rgb24',
|
|
120
|
+
'-s',
|
|
121
|
+
`${first.width}x${first.height}`,
|
|
122
|
+
'-r',
|
|
123
|
+
String(fps),
|
|
124
|
+
],
|
|
125
|
+
['-i', 'pipe:0'],
|
|
126
|
+
[
|
|
127
|
+
'-c:v',
|
|
128
|
+
'libx264',
|
|
129
|
+
'-preset',
|
|
130
|
+
'veryfast',
|
|
131
|
+
'-crf',
|
|
132
|
+
'18',
|
|
133
|
+
'-pix_fmt',
|
|
134
|
+
'yuv420p',
|
|
135
|
+
],
|
|
136
|
+
[video],
|
|
137
|
+
].flat(), { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
138
|
+
const stdin = encoder.stdin;
|
|
139
|
+
encoded = ended(encoder, 'ffmpeg');
|
|
140
|
+
// Awaited below. Until then, an early exit must not crash the process.
|
|
141
|
+
encoded.catch(() => { });
|
|
142
|
+
// A broken pipe means ffmpeg has gone, and `encoded` says why.
|
|
143
|
+
stdin.on('error', () => { });
|
|
144
|
+
let finishing = false;
|
|
145
|
+
// Rejects when ffmpeg stops before it has been asked to.
|
|
146
|
+
const died = encoded.then(() => {
|
|
147
|
+
if (!finishing)
|
|
148
|
+
throw new Error('ffmpeg stopped before the recording ended');
|
|
149
|
+
});
|
|
150
|
+
died.catch(() => { });
|
|
151
|
+
const write = async (chunk) => {
|
|
152
|
+
if (stdin.write(chunk))
|
|
153
|
+
return;
|
|
154
|
+
try {
|
|
155
|
+
await Promise.race([once(stdin, 'drain', { signal }), died]);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
// A broken pipe: ffmpeg's own exit, if it comes soon, says more.
|
|
159
|
+
if (!signal?.aborted)
|
|
160
|
+
await Promise.race([died, sleep(1000)]);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
stopAudio = withAudio ? device.listen() : undefined;
|
|
165
|
+
const startMs = Date.now();
|
|
166
|
+
const endMs = startMs + options.seconds * 1000;
|
|
167
|
+
let latest = first.data;
|
|
168
|
+
let written = 1;
|
|
169
|
+
let screenshots = 1;
|
|
170
|
+
await write(latest);
|
|
171
|
+
// Writes the latest frame until the video has caught up with the clock.
|
|
172
|
+
const catchUp = async (untilMs) => {
|
|
173
|
+
const due = Math.floor(((untilMs - startMs) / 1000) * fps);
|
|
174
|
+
while (written < due) {
|
|
175
|
+
written += 1;
|
|
176
|
+
await write(latest);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
// The caller's actions run alongside the recording, not before it.
|
|
180
|
+
let startFailure;
|
|
181
|
+
const started = (async () => options.onStart?.())();
|
|
182
|
+
started.catch((error) => (startFailure = { error }));
|
|
183
|
+
while (Date.now() < endMs) {
|
|
184
|
+
signal?.throwIfAborted();
|
|
185
|
+
if (startFailure)
|
|
186
|
+
throw startFailure.error;
|
|
187
|
+
const frame = await device
|
|
188
|
+
.screenshot('rgb', {
|
|
189
|
+
signal,
|
|
190
|
+
deadline: Math.min(Date.now() + CALL_TIMEOUT_MS, endMs + 1000),
|
|
191
|
+
})
|
|
192
|
+
.catch((error) => {
|
|
193
|
+
// A screenshot that runs out of time after the end ends the loop.
|
|
194
|
+
if (error.name === 'TimeoutError' && Date.now() >= endMs)
|
|
195
|
+
return undefined;
|
|
196
|
+
throw error;
|
|
197
|
+
});
|
|
198
|
+
if (!frame)
|
|
199
|
+
break;
|
|
200
|
+
screenshots += 1;
|
|
201
|
+
if (frame.width === first.width && frame.height === first.height)
|
|
202
|
+
latest = frame.data;
|
|
203
|
+
await catchUp(Math.min(Date.now(), endMs));
|
|
204
|
+
}
|
|
205
|
+
await catchUp(endMs);
|
|
206
|
+
const packets = stopAudio?.() ?? [];
|
|
207
|
+
stopAudio = undefined;
|
|
208
|
+
finishing = true;
|
|
209
|
+
stdin.end();
|
|
210
|
+
await abortable(encoded, signal);
|
|
211
|
+
await abortable(started, signal);
|
|
212
|
+
if (!withAudio) {
|
|
213
|
+
await run('ffmpeg', [
|
|
214
|
+
['-loglevel', 'error', '-y', '-i', video],
|
|
215
|
+
['-c', 'copy', '-movflags', '+faststart', target],
|
|
216
|
+
].flat(), signal);
|
|
217
|
+
return { file: output, frames: written, screenshots, audioSeconds: 0 };
|
|
218
|
+
}
|
|
219
|
+
const raw = join(work, 'audio.raw');
|
|
220
|
+
writeFileSync(raw, assembleAudio(packets, startMs * 1000, options.seconds));
|
|
221
|
+
await run('ffmpeg', [
|
|
222
|
+
['-loglevel', 'error', '-y', '-i', video],
|
|
223
|
+
['-f', 's16le', '-ar', String(SAMPLE_RATE), '-ac', '2', '-i', raw],
|
|
224
|
+
[
|
|
225
|
+
'-c:v',
|
|
226
|
+
'copy',
|
|
227
|
+
'-c:a',
|
|
228
|
+
'aac',
|
|
229
|
+
'-b:a',
|
|
230
|
+
'192k',
|
|
231
|
+
'-t',
|
|
232
|
+
String(options.seconds),
|
|
233
|
+
'-movflags',
|
|
234
|
+
'+faststart',
|
|
235
|
+
],
|
|
236
|
+
[target],
|
|
237
|
+
].flat(), signal);
|
|
238
|
+
return {
|
|
239
|
+
file: output,
|
|
240
|
+
frames: written,
|
|
241
|
+
screenshots,
|
|
242
|
+
audioSeconds: audioWithin(packets, startMs * 1000, options.seconds),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
stopAudio?.();
|
|
247
|
+
if (encoder && encoder.exitCode === null && encoder.signalCode === null) {
|
|
248
|
+
encoder.stdin.destroy();
|
|
249
|
+
encoder.kill('SIGKILL');
|
|
250
|
+
}
|
|
251
|
+
await encoded?.catch(() => { });
|
|
252
|
+
rmSync(work, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Rgb = readonly [number, number, number];
|
|
2
|
+
export type SafeAreaReport = {
|
|
3
|
+
background: Rgb;
|
|
4
|
+
left: number;
|
|
5
|
+
right: number;
|
|
6
|
+
top: number;
|
|
7
|
+
bottom: number;
|
|
8
|
+
clear: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type SafeAreaOptions = {
|
|
11
|
+
background?: Rgb;
|
|
12
|
+
margin?: number;
|
|
13
|
+
tolerance?: number;
|
|
14
|
+
};
|
|
15
|
+
export declare const parseColour: (text: string) => Rgb;
|
|
16
|
+
export declare const formatColour: ([r, g, b]: Rgb) => string;
|
|
17
|
+
export declare const checkSafeArea: (rgb: Buffer, width: number, height: number, options?: SafeAreaOptions) => SafeAreaReport;
|
package/dist/safearea.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Checking a screen against the television's safe area. Amazon's Fire TV
|
|
2
|
+
// guidance keeps content out of the outer 5% of each edge, which a television
|
|
3
|
+
// may crop (overscan). This counts, per edge, the pixels in that margin that
|
|
4
|
+
// are not the background.
|
|
5
|
+
export const parseColour = (text) => {
|
|
6
|
+
const match = /^#?([0-9a-f]{6})$/i.exec(text.trim());
|
|
7
|
+
if (!match)
|
|
8
|
+
throw new Error(`cannot read colour "${text}": use #rrggbb`);
|
|
9
|
+
const value = Number.parseInt(match[1], 16);
|
|
10
|
+
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
|
11
|
+
};
|
|
12
|
+
export const formatColour = ([r, g, b]) => `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
|
|
13
|
+
const bands = (width, height, margin) => {
|
|
14
|
+
const across = Math.round(width * margin);
|
|
15
|
+
const down = Math.round(height * margin);
|
|
16
|
+
return {
|
|
17
|
+
left: { x: 0, y: 0, width: across, height },
|
|
18
|
+
right: { x: width - across, y: 0, width: across, height },
|
|
19
|
+
top: { x: 0, y: 0, width, height: down },
|
|
20
|
+
bottom: { x: 0, y: height - down, width, height: down },
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const mostCommon = (rgb, width, all) => {
|
|
24
|
+
const counts = new Map();
|
|
25
|
+
for (const band of all)
|
|
26
|
+
for (let y = band.y; y < band.y + band.height; y += 2)
|
|
27
|
+
for (let x = band.x; x < band.x + band.width; x += 2) {
|
|
28
|
+
const at = (y * width + x) * 3;
|
|
29
|
+
const key = (rgb[at] << 16) | (rgb[at + 1] << 8) | rgb[at + 2];
|
|
30
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
let best = 0;
|
|
33
|
+
let bestCount = -1;
|
|
34
|
+
for (const [key, count] of counts)
|
|
35
|
+
if (count > bestCount)
|
|
36
|
+
[best, bestCount] = [key, count];
|
|
37
|
+
return [(best >> 16) & 255, (best >> 8) & 255, best & 255];
|
|
38
|
+
};
|
|
39
|
+
// `rgb` is RGB888 rows, top to bottom, as Device.screenshot('rgb') returns.
|
|
40
|
+
export const checkSafeArea = (rgb, width, height, options = {}) => {
|
|
41
|
+
if (rgb.length < width * height * 3)
|
|
42
|
+
throw new Error(`expected ${width}x${height} RGB data, got ${rgb.length} bytes`);
|
|
43
|
+
const edges = bands(width, height, options.margin ?? 0.05);
|
|
44
|
+
const background = options.background ?? mostCommon(rgb, width, Object.values(edges));
|
|
45
|
+
const tolerance = options.tolerance ?? 6;
|
|
46
|
+
const count = (band) => {
|
|
47
|
+
let outside = 0;
|
|
48
|
+
for (let y = band.y; y < band.y + band.height; y++)
|
|
49
|
+
for (let x = band.x; x < band.x + band.width; x++) {
|
|
50
|
+
const at = (y * width + x) * 3;
|
|
51
|
+
if (Math.abs(rgb[at] - background[0]) > tolerance ||
|
|
52
|
+
Math.abs(rgb[at + 1] - background[1]) > tolerance ||
|
|
53
|
+
Math.abs(rgb[at + 2] - background[2]) > tolerance)
|
|
54
|
+
outside += 1;
|
|
55
|
+
}
|
|
56
|
+
return outside;
|
|
57
|
+
};
|
|
58
|
+
const left = count(edges.left);
|
|
59
|
+
const right = count(edges.right);
|
|
60
|
+
const top = count(edges.top);
|
|
61
|
+
const bottom = count(edges.bottom);
|
|
62
|
+
return {
|
|
63
|
+
background,
|
|
64
|
+
left,
|
|
65
|
+
right,
|
|
66
|
+
top,
|
|
67
|
+
bottom,
|
|
68
|
+
clear: left + right + top + bottom === 0,
|
|
69
|
+
};
|
|
70
|
+
};
|