@ai-ecoverse/slicc-capture 1.2.0 → 1.3.1
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 +29 -3
- package/bin/screencapture +2 -2
- package/package.json +11 -4
- package/page/index.mjs +209 -0
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ Short options may be grouped (`-xc`), an option's value may be attached or separ
|
|
|
76
76
|
|
|
77
77
|
- **The file:** written as PNG, JPEG or WebP: `-t` decides, else the file's extension (`.png`, `.jpg` or `.jpeg`, `.webp`), else PNG. JPEG and WebP use quality 0.92. On success it prints `captured <n> KB to <file>`.
|
|
78
78
|
- **`-c`:** captures a PNG and copies it to the clipboard as `image/png`, with a `clipboard.write` request on `/dev/embedder`, which seven's embedder (slicc-bios) answers; [slicc-clipboard](https://github.com/ai-ecoverse/slicc-clipboard)'s `/dev/clipboard` stays text only. It prints `captured <n> KB to clipboard`. A file named with `-c` is ignored, as with macOS's `-c`, and so is `-t`. The `clipboard.write` request is sent before the prompt, so a page that doesn't answer it fails at once, without a capture.
|
|
79
|
-
- **`-v`:** shows the capture inline in the agent's tool result, by printing one line `<img:<absolute path>>` on stdout, the marker
|
|
79
|
+
- **`-v`:** shows the capture inline in the agent's tool result, by printing one line `<img:<absolute path>>` on stdout, the marker of [ai-ecoverse/slicc-agent#713](https://github.com/ai-ecoverse/slicc-agent/issues/713), which needs `@ai-ecoverse/slicc-agent` 7.7.0 or later (see its README's ["Images from command output"](https://github.com/ai-ecoverse/slicc-agent#readme)): the agent reads that file with the caller's own permissions, detects PNG, JPEG, GIF or WebP by its magic bytes, fits the image to 1600 px wide and at most 1 MB of base64, and replaces the line with `<path> (<mime>, <bytes> bytes)` and the image in the tool result, at most 4 images per command. It doesn't honour relative paths, markers on stderr, lines over 4 KB, or markers from runs that a guest started (or that inherited guest or lick taint); each of those gets a one-line note instead of the image. With a file, `-v` writes it and prints only the marker for it; without one, it writes the capture to a new `${TMPDIR:-/tmp}/screencapture-XXXXXX.<type>`, which it leaves there for the agent, and prints the marker for that; with `-c`, it copies, prints `captured <n> KB to clipboard`, and then the marker of such a file. slicc 6's `<img:data:<type>;base64,<data>>` is gone: base64 on stdout is scrubbed and cut by the output tail. With an older agent, the marker shows as the line itself. macOS's `-v` (record a video) is out of scope everywhere.
|
|
80
80
|
- **No file:** without a file, `-c` or `-v`: `screencapture: output file required (or use -c for clipboard)` and the usage to stderr, exit 2.
|
|
81
81
|
|
|
82
82
|
Errors, on stderr, from slicc 6 where they still apply:
|
|
@@ -92,6 +92,8 @@ Errors, on stderr, from slicc 6 where they still apply:
|
|
|
92
92
|
| `screencapture: the page sent no image` (an empty reply) | 1 |
|
|
93
93
|
| `screencapture: interrupted` (`SIGHUP`, `SIGINT` or `SIGTERM` during the request: the command stops the reader, so the page's `signal` aborts with `interrupted`, removes its temporary files and writes no file) | `128 + n` |
|
|
94
94
|
| `screencapture: failed to write file: <file>: <message>` | 1 |
|
|
95
|
+
| `screencapture: failed to create a temporary directory in <dir>` (before the request: `mktemp -d` failed in `$TMPDIR`, else `/tmp`; nothing is sent or written) | 1 |
|
|
96
|
+
| `screencapture: failed to create a temporary file in <dir>` (`-v` without a file, after the capture: `mktemp` failed; the image is dropped and nothing is written) | 1 |
|
|
95
97
|
| `screencapture: failed to copy to clipboard: this page does not copy images (no clipboard.write)` (`ENOTSUP`: no page answers `clipboard.write`) | 1 |
|
|
96
98
|
| `screencapture: failed to copy to clipboard: <message>` (the page's message, else the errno's text) | 1 |
|
|
97
99
|
| `screencapture: /dev/embedder not available (needs slicc-kernel >= 1.153.0)` (a kernel that runs the commands without the device) | 1 |
|
|
@@ -105,7 +107,7 @@ As `sccapture`, the messages start with `sccapture:`. A line of progress from th
|
|
|
105
107
|
|
|
106
108
|
### The request
|
|
107
109
|
|
|
108
|
-
The commands run in a kernel worker, which has no `getDisplayMedia`, so they ask seven's page over [`/dev/embedder`](https://github.com/ai-ecoverse/slicc-kernel#devembedder) (slicc-kernel 1.153.0, [#1527](https://github.com/ai-ecoverse/slicc-kernel/issues/1527)): one open file description per request, a JSON header line `{ kind, …options }`, then the body, and the reply read to end-of-file. The page side, the prompt, `getDisplayMedia`, the frame, the crop and the encoding, is a page module that slicc-bios registers for `capture.screen` and wraps with its own prompt, user activation, `selfBrowserSurface: 'exclude'` and a check of the picked surface against protected origins; it answers this contract:
|
|
110
|
+
The commands run in a kernel worker, which has no `getDisplayMedia`, so they ask seven's page over [`/dev/embedder`](https://github.com/ai-ecoverse/slicc-kernel#devembedder) (slicc-kernel 1.153.0, [#1527](https://github.com/ai-ecoverse/slicc-kernel/issues/1527)): one open file description per request, a JSON header line `{ kind, …options }`, then the body, and the reply read to end-of-file. The page side, the prompt, `getDisplayMedia`, the frame, the crop and the encoding, is [a page module](#the-page-module) that slicc-bios registers for `capture.screen` and wraps with its own prompt, user activation, `selfBrowserSurface: 'exclude'` and a check of the picked surface against protected origins; it answers this contract:
|
|
109
111
|
|
|
110
112
|
- **`capture.screen`:** the header is plain JSON, with no body, and the reply is the encoded image's bytes, nothing else. The command opens the device read-write (`exec 3<>/dev/embedder`), writes the header from a child `sh` that stays until the reply is read (so the page's progress reaches stderr), and reads the reply with a `cat <&3` that it starts in the background, so that a signal to the command can stop it.
|
|
111
113
|
|
|
@@ -122,6 +124,29 @@ The commands run in a kernel worker, which has no `getDisplayMedia`, so they ask
|
|
|
122
124
|
For example, `screencapture -C -T 2 -R 0,0,800,600 shot.jpg` sends `{"kind":"capture.screen","type":"image/jpeg","quality":0.92,"cursor":"always","delay":2,"rect":{"x":0,"y":0,"width":800,"height":600}}`. The page rejects with a `NotAllowedError` or `AbortError` for **Cancel**, a closed picker, a refused permission or a protected surface (`EACCES`); a `NotSupportedError` without `getDisplayMedia` (`ENOTSUP`); and any other error, such as a `TimeoutError` `timed out waiting for the picker` after 5 minutes, with the message the command prints (`EIO`). It aborts the capture when its `signal` does.
|
|
123
125
|
- **`clipboard.write`:** `-c` sends `{"kind":"clipboard.write","type":"image/png"}` and the PNG as the body, and reads the reply, which is empty, to end-of-file. slicc-bios answers it; with no page that does, the header write fails with `ENOTSUP`. The device is opened read-write and the reply read only after the whole body is written, so a page that answers without reading the body ([slicc-kernel#1536](https://github.com/ai-ecoverse/slicc-kernel/issues/1536)) is the only way a body over 1 MiB could block; the embedder's handler reads it.
|
|
124
126
|
|
|
127
|
+
### The page module
|
|
128
|
+
|
|
129
|
+
`@ai-ecoverse/slicc-capture/page` is that page module: one plain ES module (`page/index.mjs`), with no build step and no dependencies, which runs in Chrome and loads in Node. It answers `capture.screen` and nothing else; `clipboard.write` is the embedder's own.
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
import capture, { create, handle, kinds } from '@ai-ecoverse/slicc-capture/page';
|
|
133
|
+
|
|
134
|
+
createKernel({
|
|
135
|
+
requests: {
|
|
136
|
+
kinds: capture.kinds,
|
|
137
|
+
handle: (request) => capture.handle({ ...request, display: guardedGetDisplayMedia }),
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **The exports:** `kinds` is `{ 'capture.screen': {} }`, with no policy; `handle(request)` is a [`/dev/embedder` hook](https://github.com/ai-ecoverse/slicc-kernel#devembedder)'s `handle`; the default export is `{ kinds, handle }`. `create({ ImageCapture, OffscreenCanvas, document, timeout })` returns another `{ kinds, handle }` that uses those instead of the page's globals, and `timeout` (in ms, 5 minutes by default) for the picker; the tests inject fakes through it.
|
|
143
|
+
- **`display` is required:** the request is the kernel's (`{ kind, options, body, stamp, signal, progress }`) plus `display(constraints)`, the embedder's `getDisplayMedia`, which resolves to a `MediaStream`. The module never calls `navigator.mediaDevices.getDisplayMedia` itself; without `display`, or without `OffscreenCanvas` and a way to grab a frame (`ImageCapture`, else `document`), it rejects with a `NotSupportedError` `screen capture is not supported in this browser` (`ENOTSUP`).
|
|
144
|
+
- **The header:** `type` (PNG by default), `quality`, `cursor`, `surface`, `delay` and `rect` as in [the table above](#the-request); other keys are ignored. A `type` it doesn't encode rejects with a `NotSupportedError` `unsupported type: <type>`, and a value of the wrong shape with a `TypeError` `invalid <field>: <json>` (`EIO`), before `display` is called.
|
|
145
|
+
- **The capture:** it calls `display({ video: { cursor, displaySurface }, audio: false })` (each field only when the header has it: `cursor` as is, `surface` as `displaySurface`) after `progress('waiting for the picker…')`. No stream within the timeout rejects with a `TimeoutError` `timed out waiting for the picker`, one without a video track with a `NotFoundError` `the capture has no video track` (`ENOENT`), and one whose track has ended, or ends before the frame (the user's **Stop sharing**), with an `AbortError` `the capture was stopped` (`EACCES`). With a `delay`, it says `waiting <delay> s…` and waits that long. It grabs one frame with `ImageCapture.grabFrame()`, else from a muted `<video>` once it has data, crops it to `rect` (outside the frame: a `RangeError` `-R: rectangle outside the capture`), draws it on an `OffscreenCanvas` and encodes it with `convertToBlob({ type, quality })`. A browser that encodes another type than asked rejects with a `NotSupportedError` `<type> is not supported by this browser`. It resolves with the bytes, a `Uint8Array`.
|
|
146
|
+
- **Stopping:** every track of the stream is stopped when `handle` settles, whether it resolved, failed or was aborted, and so is a stream that `display` delivers after the timeout or an abort. When `signal` aborts, `handle` rejects with an `AbortError` carrying the reason's message (`interrupted`, `closed`, or the embedder's own).
|
|
147
|
+
|
|
148
|
+
**What slicc-bios does around it:** bios registers its own `kinds` table, of which the module's is a subset, and ignores the module's policies. It owns the prompt (`screencapture wants to capture your screen`, **Choose…**, **Cancel**), the user activation `getDisplayMedia` needs, the session tag and the protected origins, and calls `handle` only after its checks pass. Its `display` is a guarded `getDisplayMedia`: it adds `selfBrowserSurface: 'exclude'`, checks the picked surface's capture handle against the protected origins, and rejects with a `NotAllowedError` on a match. Its `signal` also aborts when its prompt is withdrawn.
|
|
149
|
+
|
|
125
150
|
### Options
|
|
126
151
|
|
|
127
152
|
Every option of macOS's `screencapture`, and what it does in SLICC and with `sccapture` on each host. On macOS, `sccapture` passes the options through to `screencapture` unchanged, except the ones the macOS column names. "No-op" means the option is accepted and changes nothing. "Not supported" means `<cmd>: <opt>: not supported in SLICC` (or `on Linux`, `on Windows`, `on macOS`) on stderr, exit 2, before anything is captured; "not with `<tool>`" means `sccapture: <opt>: not supported with <tool>`, exit 2, when no tool on `PATH` supports every option given (it names the first one tried).
|
|
@@ -154,7 +179,7 @@ Every option of macOS's `screencapture`, and what it does in SLICC and with `scc
|
|
|
154
179
|
| `-T <seconds>` | wait before capturing | waits after the picker | passed | `sccapture` waits, then runs the tool | `sccapture` waits, then captures |
|
|
155
180
|
| `-u` | show the UI afterwards, file ignored | not supported | passed | not supported | not supported |
|
|
156
181
|
| `-U` | the interactive toolbar | not supported | passed | not supported | not supported |
|
|
157
|
-
| `-v` | record a video | inline in the tool result, as an `<img:<path>>` line ([slicc-agent#713](https://github.com/ai-ecoverse/slicc-agent/issues/713)) | not supported | not supported | not supported |
|
|
182
|
+
| `-v` | record a video | inline in the tool result, as an `<img:<path>>` line ([slicc-agent#713](https://github.com/ai-ecoverse/slicc-agent/issues/713), slicc-agent 7.7.0 or later) | not supported | not supported | not supported |
|
|
158
183
|
| `-V <seconds>` | record a video for that long | not supported | not supported | not supported | not supported |
|
|
159
184
|
| `-w` | window selection only | prefers `displaySurface: 'window'` | passed | see [Linux](#linux) | not supported |
|
|
160
185
|
| `-W` | start in window selection | prefers `displaySurface: 'window'` | passed | as `-w` | not supported |
|
|
@@ -225,6 +250,7 @@ npm run test:unit
|
|
|
225
250
|
- `npm install` also installs the git hooks into this worktree's own `node_modules/` through slicc-shared-web's `slicc-install-hooks` and the committed `.githooks/`.
|
|
226
251
|
- `npm run lint`: Biome, the no-comment check and the other [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web) checks, then `shellcheck -s sh` and `dash -n` on `install.sh` and `bin/*`.
|
|
227
252
|
- `npm test`: the integration tests in `test/integration/`. `commands.test.mjs` runs `screencapture` and `sccapture` on `@ai-ecoverse/slicc-kernel/node` (the version `engines.slicc-kernel` names, pinned) with wasm-bash and wasm-coreutils, and a fake `capture.screen` and `clipboard.write` handler (`createNodeKernel({ requests })`): every option's header, the bytes written unchanged, the errnos and their messages, `-c`, `-v`, and slicc-kernel 1.152.2, which has no `/dev/embedder`. `test/integration/repo.test.mjs` checks the workflow, the release config, the pinned devDependencies and what `npm pack` ships. The host tests run `host/` against fake `screencapture`, `grim`, `slurp`, `gnome-screenshot`, `import`, `scrot`, `wl-copy`, `xclip`, `xdg-open`, `powershell.exe` and `pwsh.exe` on a temporary `PATH`, and `--upgrade` against installers served from a local HTTP server, with the platform forced, so each branch is checked on any machine; only CI touches a real screen. `install.test.mjs` runs `install.sh` on a tarball packed from the tree, with the Node on `PATH` and with a Node download from a fake `nodejs.org`, and `--upgrade` against a release served from a local directory; `install.ps1` is checked statically, and run when `pwsh` is on `PATH`.
|
|
253
|
+
- `page.test.mjs` registers [the page module](#the-page-module) through `createNodeKernel({ requests })`, with a fake `display` and fake `ImageCapture`, `OffscreenCanvas` and `<video>` injected through `create`, and runs the real `screencapture`: the constraints, the crop, the type and quality, the delay and its progress, an interrupt in the picker and in the delay, **Stop sharing**, the picker's timeout, the errnos and their messages end to end, and that every track is stopped. `browser.test.mjs` runs the module in Chrome, through slicc-shared-web's harness, with a `display` that returns a canvas's `captureStream()`: PNG, JPEG and WebP, the crop, the `<video>` fallback, an abort and the stopped track. It runs on CI only, and is skipped without `CI`.
|
|
228
254
|
- `npm run test:unit`: the unit tests in the gitignored `test/unit/`, with coverage in `coverage/unit/lcov.info`. `npm run test:sh` runs the `test/unit/*.sh.mjs` ones, which trace `bin/` with `set -x` in the node kernel, and writes `coverage/sh/lcov.info`. The pre-commit hook wants 100% coverage of the changed lines of both, and runs `diff-cover` through `uvx`, so install [uv](https://docs.astral.sh/uv/) once.
|
|
229
255
|
|
|
230
256
|
CI is `.github/workflows/ci.yml`, the only workflow, which runs [slicc-shared-web's pipeline](https://github.com/ai-ecoverse/slicc-shared-web#pipeline) at the devDependency's version: `npm run lint`, then `npm test`, then on a pull request the Claude and Codex reviews. The merge queue requires `ci / integration` and `ci / review`. On a push to `main`, semantic-release releases and publishes to npm through trusted publishing, with provenance from `ci.yml`, and attaches the installers.
|
package/bin/screencapture
CHANGED
|
@@ -268,7 +268,7 @@ request="$request,\"cursor\":\"$cursor\""
|
|
|
268
268
|
[ -z "$wait" ] || request="$request,\"delay\":$(num "${wait%%.*}")${wait#"${wait%%.*}"}"
|
|
269
269
|
[ -z "$area" ] || request="$request,\"rect\":{\"x\":$(num "$rx"),\"y\":$(num "$ry"),\"width\":$(num "$rw"),\"height\":$(num "$rh")}"
|
|
270
270
|
request="$request}"
|
|
271
|
-
work=$(mktemp -d)
|
|
271
|
+
work=$(mktemp -d 2>/dev/null) || die "failed to create a temporary directory in ${TMPDIR:-/tmp}"
|
|
272
272
|
trap cleanup EXIT
|
|
273
273
|
trap 'stop 1' HUP
|
|
274
274
|
trap 'stop 2' INT
|
|
@@ -300,7 +300,7 @@ if [ -n "$clip" ]; then
|
|
|
300
300
|
fi
|
|
301
301
|
if [ -z "$file" ]; then
|
|
302
302
|
[ -n "$view" ] || exit 0
|
|
303
|
-
file=$(mktemp "${TMPDIR:-/tmp}/screencapture-XXXXXX.$mime")
|
|
303
|
+
file=$(mktemp "${TMPDIR:-/tmp}/screencapture-XXXXXX.$mime" 2>/dev/null) || die "failed to create a temporary file in ${TMPDIR:-/tmp}"
|
|
304
304
|
fi
|
|
305
305
|
err=$(tee -- "$file" <"$work/image" 2>&1 >/dev/null) || die "failed to write file: ${err#tee: }"
|
|
306
306
|
if [ -n "$view" ]; then
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-capture",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "screencapture for SLICC, and sccapture for SLICC and any host",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"bin": {
|
|
16
16
|
"sccapture": "host/sccapture.mjs"
|
|
17
17
|
},
|
|
18
|
+
"exports": {
|
|
19
|
+
"./page": "./page/index.mjs",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
18
22
|
"fledgling": {
|
|
19
23
|
"provider": "github",
|
|
20
24
|
"workflow": "ci.yml",
|
|
@@ -35,12 +39,14 @@
|
|
|
35
39
|
}
|
|
36
40
|
},
|
|
37
41
|
"unitSources": [
|
|
38
|
-
"host"
|
|
42
|
+
"host",
|
|
43
|
+
"page"
|
|
39
44
|
]
|
|
40
45
|
},
|
|
41
46
|
"files": [
|
|
42
47
|
"bin",
|
|
43
|
-
"host"
|
|
48
|
+
"host",
|
|
49
|
+
"page"
|
|
44
50
|
],
|
|
45
51
|
"scripts": {
|
|
46
52
|
"prepare": "slicc-install-hooks",
|
|
@@ -55,8 +61,9 @@
|
|
|
55
61
|
"release": "semantic-release"
|
|
56
62
|
},
|
|
57
63
|
"devDependencies": {
|
|
64
|
+
"@ai-ecoverse/slicc-cdp": "1.40.0",
|
|
58
65
|
"@ai-ecoverse/slicc-kernel": "1.153.0",
|
|
59
|
-
"@ai-ecoverse/slicc-shared-web": "2.
|
|
66
|
+
"@ai-ecoverse/slicc-shared-web": "2.12.0",
|
|
60
67
|
"@ai-ecoverse/wasm-bash": "5.3.0-23",
|
|
61
68
|
"@ai-ecoverse/wasm-coreutils": "9.12.0-15",
|
|
62
69
|
"@biomejs/biome": "2.5.15",
|
package/page/index.mjs
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
const types = ['image/png', 'image/jpeg', 'image/webp'];
|
|
2
|
+
const cursors = ['always', 'motion', 'never'];
|
|
3
|
+
const surfaces = ['monitor', 'window', 'browser'];
|
|
4
|
+
const unsupported = 'screen capture is not supported in this browser';
|
|
5
|
+
|
|
6
|
+
export const kinds = { 'capture.screen': {} };
|
|
7
|
+
|
|
8
|
+
function fail(name, message) {
|
|
9
|
+
return new DOMException(message, name);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function aborted(signal) {
|
|
13
|
+
const reason = signal.reason;
|
|
14
|
+
if (reason?.name === 'AbortError') return reason;
|
|
15
|
+
return fail('AbortError', String(reason?.message ?? reason));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function race(promise, signal) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const stop = () => reject(aborted(signal));
|
|
21
|
+
if (signal.aborted) stop();
|
|
22
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
23
|
+
Promise.resolve(promise)
|
|
24
|
+
.then(resolve, reject)
|
|
25
|
+
.finally(() => signal.removeEventListener('abort', stop));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sleep(ms, signal) {
|
|
30
|
+
let timer;
|
|
31
|
+
const wait = new Promise((resolve) => {
|
|
32
|
+
timer = setTimeout(resolve, ms);
|
|
33
|
+
});
|
|
34
|
+
return race(wait, signal).finally(() => clearTimeout(timer));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stopAll(stream) {
|
|
38
|
+
for (const track of stream?.getTracks?.() ?? []) track.stop();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function invalid(field, value) {
|
|
42
|
+
return new TypeError(`invalid ${field}: ${JSON.stringify(value)}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const whole = (value, least) => Number.isInteger(value) && value >= least;
|
|
46
|
+
|
|
47
|
+
function parse(options = {}) {
|
|
48
|
+
const { type = 'image/png', quality, cursor, surface, delay, rect } = options;
|
|
49
|
+
if (!types.includes(type)) throw fail('NotSupportedError', `unsupported type: ${type}`);
|
|
50
|
+
if (quality !== undefined && !(typeof quality === 'number' && quality >= 0 && quality <= 1)) {
|
|
51
|
+
throw invalid('quality', quality);
|
|
52
|
+
}
|
|
53
|
+
if (cursor !== undefined && !cursors.includes(cursor)) throw invalid('cursor', cursor);
|
|
54
|
+
if (surface !== undefined && !surfaces.includes(surface)) throw invalid('surface', surface);
|
|
55
|
+
if (delay !== undefined && !(Number.isFinite(delay) && delay >= 0)) throw invalid('delay', delay);
|
|
56
|
+
if (
|
|
57
|
+
rect !== undefined &&
|
|
58
|
+
!(whole(rect?.x, 0) && whole(rect?.y, 0) && whole(rect?.width, 1) && whole(rect?.height, 1))
|
|
59
|
+
) {
|
|
60
|
+
throw invalid('rect', rect);
|
|
61
|
+
}
|
|
62
|
+
const video = {};
|
|
63
|
+
if (cursor !== undefined) video.cursor = cursor;
|
|
64
|
+
if (surface !== undefined) video.displaySurface = surface;
|
|
65
|
+
return {
|
|
66
|
+
constraints: { video, audio: false },
|
|
67
|
+
type,
|
|
68
|
+
quality,
|
|
69
|
+
delay: delay ?? 0,
|
|
70
|
+
rect,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function pick(display, constraints, signal, timeout) {
|
|
75
|
+
const pending = Promise.resolve().then(() => display(constraints));
|
|
76
|
+
let timer;
|
|
77
|
+
const late = new Promise((_, reject) => {
|
|
78
|
+
timer = setTimeout(
|
|
79
|
+
() => reject(fail('TimeoutError', 'timed out waiting for the picker')),
|
|
80
|
+
timeout
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
return await race(Promise.race([pending, late]), signal);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
pending.then(stopAll, () => {});
|
|
87
|
+
throw error;
|
|
88
|
+
} finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function grab(stream, track, env, signal) {
|
|
94
|
+
const ImageCapture = env('ImageCapture');
|
|
95
|
+
if (ImageCapture) {
|
|
96
|
+
const pending = Promise.resolve(new ImageCapture(track).grabFrame());
|
|
97
|
+
let bitmap;
|
|
98
|
+
try {
|
|
99
|
+
bitmap = await race(pending, signal);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
pending.then(
|
|
102
|
+
(late) => late?.close?.(),
|
|
103
|
+
() => {}
|
|
104
|
+
);
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
source: bitmap,
|
|
109
|
+
width: bitmap.width,
|
|
110
|
+
height: bitmap.height,
|
|
111
|
+
close: () => bitmap.close?.(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const video = env('document').createElement('video');
|
|
115
|
+
const close = () => {
|
|
116
|
+
video.pause();
|
|
117
|
+
video.srcObject = null;
|
|
118
|
+
};
|
|
119
|
+
const ready = new Promise((resolve, reject) => {
|
|
120
|
+
video.onloadedmetadata = () => video.play().catch(reject);
|
|
121
|
+
video.onloadeddata = resolve;
|
|
122
|
+
video.onerror = () => reject(new Error('the capture could not be played'));
|
|
123
|
+
});
|
|
124
|
+
video.muted = true;
|
|
125
|
+
video.playsInline = true;
|
|
126
|
+
video.srcObject = stream;
|
|
127
|
+
try {
|
|
128
|
+
await race(ready, signal);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
close();
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
return { source: video, width: video.videoWidth, height: video.videoHeight, close };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function encode(frame, request, env, signal) {
|
|
137
|
+
const { type, quality, rect } = request;
|
|
138
|
+
const { x, y, width, height } = rect ?? { x: 0, y: 0, width: frame.width, height: frame.height };
|
|
139
|
+
if (!(frame.width > 0 && frame.height > 0)) throw new Error('the capture has no frame');
|
|
140
|
+
if (x + width > frame.width || y + height > frame.height) {
|
|
141
|
+
throw new RangeError('-R: rectangle outside the capture');
|
|
142
|
+
}
|
|
143
|
+
const canvas = new (env('OffscreenCanvas'))(width, height);
|
|
144
|
+
canvas.getContext('2d').drawImage(frame.source, x, y, width, height, 0, 0, width, height);
|
|
145
|
+
const blob = await race(
|
|
146
|
+
canvas.convertToBlob(quality === undefined ? { type } : { type, quality }),
|
|
147
|
+
signal
|
|
148
|
+
);
|
|
149
|
+
if (blob.type !== type)
|
|
150
|
+
throw fail('NotSupportedError', `${type} is not supported by this browser`);
|
|
151
|
+
return new Uint8Array(await race(blob.arrayBuffer(), signal));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function create(overrides = {}) {
|
|
155
|
+
const env = (name) => (name in overrides ? overrides[name] : globalThis[name]);
|
|
156
|
+
const timeout = overrides.timeout ?? 300000;
|
|
157
|
+
|
|
158
|
+
async function handle({
|
|
159
|
+
options,
|
|
160
|
+
signal = new AbortController().signal,
|
|
161
|
+
progress = () => {},
|
|
162
|
+
display,
|
|
163
|
+
}) {
|
|
164
|
+
const request = parse(options);
|
|
165
|
+
if (typeof display !== 'function') throw fail('NotSupportedError', unsupported);
|
|
166
|
+
if (!env('OffscreenCanvas') || !(env('ImageCapture') || env('document'))) {
|
|
167
|
+
throw fail('NotSupportedError', unsupported);
|
|
168
|
+
}
|
|
169
|
+
const local = new AbortController();
|
|
170
|
+
const forward = () => local.abort(signal.reason);
|
|
171
|
+
if (signal.aborted) forward();
|
|
172
|
+
signal.addEventListener('abort', forward, { once: true });
|
|
173
|
+
let stream;
|
|
174
|
+
let frame;
|
|
175
|
+
try {
|
|
176
|
+
if (local.signal.aborted) throw aborted(local.signal);
|
|
177
|
+
progress('waiting for the picker…');
|
|
178
|
+
stream = await pick(display, request.constraints, local.signal, timeout);
|
|
179
|
+
const track = stream?.getVideoTracks?.()[0];
|
|
180
|
+
if (!track) throw fail('NotFoundError', 'the capture has no video track');
|
|
181
|
+
if (track.readyState === 'ended') throw fail('AbortError', 'the capture was stopped');
|
|
182
|
+
track.addEventListener(
|
|
183
|
+
'ended',
|
|
184
|
+
() => local.abort(fail('AbortError', 'the capture was stopped')),
|
|
185
|
+
{
|
|
186
|
+
once: true,
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
if (request.delay > 0) {
|
|
190
|
+
progress(`waiting ${request.delay} s…`);
|
|
191
|
+
await sleep(request.delay * 1000, local.signal);
|
|
192
|
+
}
|
|
193
|
+
frame = await grab(stream, track, env, local.signal);
|
|
194
|
+
return await encode(frame, request, env, local.signal);
|
|
195
|
+
} finally {
|
|
196
|
+
signal.removeEventListener('abort', forward);
|
|
197
|
+
frame?.close();
|
|
198
|
+
stopAll(stream);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { kinds, handle };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const page = create();
|
|
206
|
+
|
|
207
|
+
export const handle = page.handle;
|
|
208
|
+
|
|
209
|
+
export default page;
|