@meltemi/diagrams-capture 0.0.0-stage → 1.9.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 +37 -2
- package/dist/browser.d.mts +7 -0
- package/dist/browser.mjs +54 -0
- package/dist/capture.d.mts +29 -0
- package/dist/capture.mjs +106 -0
- package/dist/cdp.d.mts +15 -0
- package/dist/cdp.mjs +173 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/page.d.mts +5 -0
- package/dist/page.mjs +34 -0
- package/dist/video.d.mts +37 -0
- package/dist/video.mjs +201 -0
- package/dist/worker.mjs +36 -0
- package/package.json +36 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Architecture Studio contributors
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @meltemi/diagrams-capture
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
PNG, PDF and video captures of rendered SVG with the installed Edge, Chrome or Chromium, driven
|
|
4
|
+
over the DevTools protocol, and video encoding with the installed FFmpeg. Nothing is downloaded:
|
|
5
|
+
`MELTEMI_BROWSER` and `MELTEMI_FFMPEG` name other executables.
|
|
6
|
+
|
|
7
|
+
It is part of [Meltemi Diagrams](https://github.com/askenaz-dev/meltemi-diagrams#readme), diagrams as code with a canvas, motion and
|
|
8
|
+
exports. Every package of the product has the same version.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @meltemi/diagrams-capture
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Node 22.12.0 or later. The package is an ES module with type declarations.
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { captureSync } from "@meltemi/diagrams-capture";
|
|
22
|
+
|
|
23
|
+
// Writes a PNG at twice the pixels and a vector PDF page of the same SVG.
|
|
24
|
+
captureSync([
|
|
25
|
+
{ kind: "png", svg, width: 640, height: 360, scale: 2, out: "picture.png" },
|
|
26
|
+
{ kind: "pdf", svg, width: 640, height: 360, out: "picture.pdf" },
|
|
27
|
+
]);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Documentation
|
|
31
|
+
|
|
32
|
+
- [Export](https://github.com/askenaz-dev/meltemi-diagrams/blob/main/docs/language/export.md)
|
|
33
|
+
- [Compatibility](https://github.com/askenaz-dev/meltemi-diagrams/blob/main/docs/compatibility.md)
|
|
34
|
+
- [All documentation](https://github.com/askenaz-dev/meltemi-diagrams/blob/main/docs/README.md)
|
|
35
|
+
|
|
36
|
+
## Licence
|
|
37
|
+
|
|
38
|
+
MIT. Third-party components and their licences are listed in [THIRD_PARTY_LICENSES.md](https://github.com/askenaz-dev/meltemi-diagrams/blob/main/THIRD_PARTY_LICENSES.md).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The executable of an installed Chromium browser, or null when there is none. */
|
|
2
|
+
export function findBrowser(
|
|
3
|
+
env?: Record<string, string | undefined>,
|
|
4
|
+
platform?: string,
|
|
5
|
+
): string | null;
|
|
6
|
+
/** What to say when no browser is found. */
|
|
7
|
+
export const NO_BROWSER: string;
|
package/dist/browser.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Finds an installed Edge, Chrome or Chromium. Plain JavaScript, so a plain `node` can run it.
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Where the usual Chromium browsers install, by platform. */
|
|
6
|
+
function candidates(env, platform) {
|
|
7
|
+
const local = env.LOCALAPPDATA ?? "";
|
|
8
|
+
const programs = [
|
|
9
|
+
env["PROGRAMFILES(X86)"],
|
|
10
|
+
env.PROGRAMFILES,
|
|
11
|
+
"C:/Program Files (x86)",
|
|
12
|
+
"C:/Program Files",
|
|
13
|
+
]
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.map((p) => p.replace(/\\/g, "/"));
|
|
16
|
+
if (platform === "win32") {
|
|
17
|
+
return [
|
|
18
|
+
...programs.map((p) => join(p, "Microsoft/Edge/Application/msedge.exe")),
|
|
19
|
+
...programs.map((p) => join(p, "Google/Chrome/Application/chrome.exe")),
|
|
20
|
+
...(local ? [join(local, "Google/Chrome/Application/chrome.exe")] : []),
|
|
21
|
+
...(local ? [join(local, "Chromium/Application/chrome.exe")] : []),
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
if (platform === "darwin") {
|
|
25
|
+
return [
|
|
26
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
27
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
28
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
return [
|
|
32
|
+
"/usr/bin/microsoft-edge",
|
|
33
|
+
"/usr/bin/microsoft-edge-stable",
|
|
34
|
+
"/usr/bin/google-chrome",
|
|
35
|
+
"/usr/bin/google-chrome-stable",
|
|
36
|
+
"/usr/bin/chromium",
|
|
37
|
+
"/usr/bin/chromium-browser",
|
|
38
|
+
"/snap/bin/chromium",
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The executable of an installed Chromium browser: `MELTEMI_BROWSER` when it is set, otherwise the
|
|
44
|
+
* first of the usual Edge, Chrome and Chromium locations that exists; null when there is none.
|
|
45
|
+
*/
|
|
46
|
+
export function findBrowser(env = process.env, platform = process.platform) {
|
|
47
|
+
const chosen = env.MELTEMI_BROWSER;
|
|
48
|
+
if (chosen) return existsSync(chosen) ? chosen : null;
|
|
49
|
+
return candidates(env, platform).find((path) => existsSync(path)) ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What to say when no browser is found. */
|
|
53
|
+
export const NO_BROWSER =
|
|
54
|
+
"PNG and PDF exports need Edge, Chrome or Chromium installed; set MELTEMI_BROWSER to the browser's executable if it is somewhere else.";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** One picture to capture. */
|
|
2
|
+
export interface CaptureJob {
|
|
3
|
+
readonly kind: "png" | "pdf";
|
|
4
|
+
/** The SVG of the picture. */
|
|
5
|
+
readonly svg: string;
|
|
6
|
+
/** The size of the output in CSS pixels. */
|
|
7
|
+
readonly width: number;
|
|
8
|
+
readonly height: number;
|
|
9
|
+
/** PNG: pixels per CSS pixel. */
|
|
10
|
+
readonly scale?: number;
|
|
11
|
+
/** A CSS colour, or `transparent` (the default). */
|
|
12
|
+
readonly background?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CaptureOptions {
|
|
16
|
+
/** The browser's executable; found when absent. */
|
|
17
|
+
readonly browser?: string;
|
|
18
|
+
/** How long the browser may take to answer, in milliseconds. */
|
|
19
|
+
readonly timeout?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The page a job is drawn on. */
|
|
23
|
+
export function capturePage(job: CaptureJob): string;
|
|
24
|
+
|
|
25
|
+
/** Captures the jobs in one browser session. */
|
|
26
|
+
export function capture(
|
|
27
|
+
jobs: readonly CaptureJob[],
|
|
28
|
+
options?: CaptureOptions,
|
|
29
|
+
): Promise<{ browser: string; results: Uint8Array[] }>;
|
package/dist/capture.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// PNG and PDF captures of SVG pictures with a headless Chromium browser. Plain JavaScript, so a
|
|
2
|
+
// plain `node` (the worker of `captureSync`) can run it.
|
|
3
|
+
import { findBrowser, NO_BROWSER } from "./browser.mjs";
|
|
4
|
+
import { PipeSession } from "./cdp.mjs";
|
|
5
|
+
import { capturePage, cssColor } from "./page.mjs";
|
|
6
|
+
|
|
7
|
+
export { capturePage };
|
|
8
|
+
|
|
9
|
+
/** Waits until the fonts and images of the page are ready and two frames have been drawn. */
|
|
10
|
+
const READY = `(async () => {
|
|
11
|
+
await document.fonts.ready;
|
|
12
|
+
await Promise.all([...document.querySelectorAll("image")].map((image) => {
|
|
13
|
+
const href = image.getAttribute("href") || image.getAttribute("xlink:href");
|
|
14
|
+
if (!href) return null;
|
|
15
|
+
const img = new Image();
|
|
16
|
+
img.src = href;
|
|
17
|
+
return img.decode().catch(() => null);
|
|
18
|
+
}));
|
|
19
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
20
|
+
return true;
|
|
21
|
+
})()`;
|
|
22
|
+
|
|
23
|
+
async function captureOne(session, job) {
|
|
24
|
+
const { targetId } = await session.send("Target.createTarget", { url: "about:blank" });
|
|
25
|
+
const { sessionId } = await session.send("Target.attachToTarget", { targetId, flatten: true });
|
|
26
|
+
const transparent = cssColor(job.background) === "transparent";
|
|
27
|
+
try {
|
|
28
|
+
await session.send("Page.enable", {}, sessionId);
|
|
29
|
+
await session.send(
|
|
30
|
+
"Emulation.setDeviceMetricsOverride",
|
|
31
|
+
{
|
|
32
|
+
width: Math.ceil(job.width),
|
|
33
|
+
height: Math.ceil(job.height),
|
|
34
|
+
deviceScaleFactor: job.kind === "png" ? (job.scale ?? 1) : 1,
|
|
35
|
+
mobile: false,
|
|
36
|
+
},
|
|
37
|
+
sessionId,
|
|
38
|
+
);
|
|
39
|
+
if (transparent) {
|
|
40
|
+
await session.send(
|
|
41
|
+
"Emulation.setDefaultBackgroundColorOverride",
|
|
42
|
+
{ color: { r: 0, g: 0, b: 0, a: 0 } },
|
|
43
|
+
sessionId,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const { frameTree } = await session.send("Page.getFrameTree", {}, sessionId);
|
|
47
|
+
await session.send(
|
|
48
|
+
"Page.setDocumentContent",
|
|
49
|
+
{ frameId: frameTree.frame.id, html: capturePage(job) },
|
|
50
|
+
sessionId,
|
|
51
|
+
);
|
|
52
|
+
await session.send(
|
|
53
|
+
"Runtime.evaluate",
|
|
54
|
+
{ expression: READY, awaitPromise: true, returnByValue: true },
|
|
55
|
+
sessionId,
|
|
56
|
+
);
|
|
57
|
+
if (job.kind === "png") {
|
|
58
|
+
const { data } = await session.send(
|
|
59
|
+
"Page.captureScreenshot",
|
|
60
|
+
{
|
|
61
|
+
format: "png",
|
|
62
|
+
clip: { x: 0, y: 0, width: job.width, height: job.height, scale: 1 },
|
|
63
|
+
fromSurface: true,
|
|
64
|
+
captureBeyondViewport: false,
|
|
65
|
+
},
|
|
66
|
+
sessionId,
|
|
67
|
+
);
|
|
68
|
+
return Buffer.from(data, "base64");
|
|
69
|
+
}
|
|
70
|
+
const { data } = await session.send(
|
|
71
|
+
"Page.printToPDF",
|
|
72
|
+
{
|
|
73
|
+
printBackground: !transparent,
|
|
74
|
+
preferCSSPageSize: true,
|
|
75
|
+
marginTop: 0,
|
|
76
|
+
marginBottom: 0,
|
|
77
|
+
marginLeft: 0,
|
|
78
|
+
marginRight: 0,
|
|
79
|
+
displayHeaderFooter: false,
|
|
80
|
+
generateTaggedPDF: true,
|
|
81
|
+
},
|
|
82
|
+
sessionId,
|
|
83
|
+
);
|
|
84
|
+
return Buffer.from(data, "base64");
|
|
85
|
+
} finally {
|
|
86
|
+
await session.send("Target.closeTarget", { targetId }).catch(() => undefined);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Captures each job (`{ kind: "png" | "pdf", svg, width, height, scale?, background? }`) in one
|
|
92
|
+
* browser session; resolves to the browser's name and version and the files' bytes.
|
|
93
|
+
*/
|
|
94
|
+
export async function capture(jobs, options = {}) {
|
|
95
|
+
const executable = options.browser ?? findBrowser();
|
|
96
|
+
if (!executable) throw new Error(NO_BROWSER);
|
|
97
|
+
const session = await PipeSession.launch(executable, { timeout: options.timeout ?? 30_000 });
|
|
98
|
+
try {
|
|
99
|
+
const { product } = await session.send("Browser.getVersion");
|
|
100
|
+
const results = [];
|
|
101
|
+
for (const job of jobs) results.push(await captureOne(session, job));
|
|
102
|
+
return { browser: product, results };
|
|
103
|
+
} finally {
|
|
104
|
+
await session.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/cdp.d.mts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Arguments of the headless browser. */
|
|
2
|
+
export function browserArgs(profile: string): string[];
|
|
3
|
+
|
|
4
|
+
/** A DevTools protocol session with a browser over a pipe. */
|
|
5
|
+
export class PipeSession {
|
|
6
|
+
static launch(executable: string, options?: { timeout?: number }): Promise<PipeSession>;
|
|
7
|
+
// Replies and events are plain JSON objects shaped by the DevTools protocol.
|
|
8
|
+
send(
|
|
9
|
+
method: string,
|
|
10
|
+
params?: Record<string, unknown>,
|
|
11
|
+
sessionId?: string,
|
|
12
|
+
): Promise<Record<string, unknown>>;
|
|
13
|
+
waitFor(method: string, sessionId?: string): Promise<Record<string, unknown>>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
package/dist/cdp.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// A small client of the Chrome DevTools protocol over a pipe (`--remote-debugging-pipe`): the
|
|
2
|
+
// browser reads commands on its fd 3 and writes replies and events on its fd 4, each message a JSON
|
|
3
|
+
// text ended by a NUL. Plain JavaScript, so a plain `node` can run it.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** Arguments of a headless browser that does nothing but render what it is given. */
|
|
10
|
+
export function browserArgs(profile) {
|
|
11
|
+
// Features that make the browser relaunch itself (and lose the pipe), call home or show screens.
|
|
12
|
+
const disabled = [
|
|
13
|
+
"AutoDeElevate",
|
|
14
|
+
"msForceBrowserSignIn",
|
|
15
|
+
"msEdgeUpdateLaunchServicesPreferredVersion",
|
|
16
|
+
"Translate",
|
|
17
|
+
"MediaRouter",
|
|
18
|
+
"OptimizationHints",
|
|
19
|
+
"DialMediaRouteProvider",
|
|
20
|
+
"GlobalMediaControls",
|
|
21
|
+
"PaintHolding",
|
|
22
|
+
];
|
|
23
|
+
return [
|
|
24
|
+
"--headless",
|
|
25
|
+
"--remote-debugging-pipe",
|
|
26
|
+
`--user-data-dir=${profile}`,
|
|
27
|
+
// Edge relaunches itself on Windows without its file descriptors unless told not to.
|
|
28
|
+
"--edge-skip-compat-layer-relaunch",
|
|
29
|
+
"--no-first-run",
|
|
30
|
+
"--no-default-browser-check",
|
|
31
|
+
"--no-service-autorun",
|
|
32
|
+
"--disable-extensions",
|
|
33
|
+
"--disable-sync",
|
|
34
|
+
"--disable-background-networking",
|
|
35
|
+
"--disable-component-update",
|
|
36
|
+
"--disable-default-apps",
|
|
37
|
+
"--disable-breakpad",
|
|
38
|
+
"--disable-edgeupdater",
|
|
39
|
+
"--disable-updater-scheduler",
|
|
40
|
+
"--disable-search-engine-choice-screen",
|
|
41
|
+
"--disable-field-trial-config",
|
|
42
|
+
"--metrics-recording-only",
|
|
43
|
+
"--password-store=basic",
|
|
44
|
+
"--use-mock-keychain",
|
|
45
|
+
`--disable-features=${disabled.join(",")}`,
|
|
46
|
+
"--hide-scrollbars",
|
|
47
|
+
"--mute-audio",
|
|
48
|
+
"--force-color-profile=srgb",
|
|
49
|
+
"--font-render-hinting=none",
|
|
50
|
+
"--export-tagged-pdf",
|
|
51
|
+
...(process.platform === "linux" && process.getuid?.() === 0 ? ["--no-sandbox"] : []),
|
|
52
|
+
"--no-startup-window",
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class PipeSession {
|
|
57
|
+
/** Starts the browser. */
|
|
58
|
+
static async launch(executable, { timeout = 30_000 } = {}) {
|
|
59
|
+
const profile = mkdtempSync(join(tmpdir(), "meltemi-capture-"));
|
|
60
|
+
const child = spawn(executable, browserArgs(profile), {
|
|
61
|
+
stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"],
|
|
62
|
+
windowsHide: true,
|
|
63
|
+
});
|
|
64
|
+
const session = new PipeSession(child, profile, timeout);
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
child.once("spawn", resolve);
|
|
67
|
+
child.once("error", reject);
|
|
68
|
+
});
|
|
69
|
+
return session;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
constructor(child, profile, timeout) {
|
|
73
|
+
this.child = child;
|
|
74
|
+
this.profile = profile;
|
|
75
|
+
this.timeout = timeout;
|
|
76
|
+
this.nextId = 1;
|
|
77
|
+
this.pending = new Map();
|
|
78
|
+
this.listeners = [];
|
|
79
|
+
this.buffer = "";
|
|
80
|
+
this.stderr = "";
|
|
81
|
+
this.closed = false;
|
|
82
|
+
child.stdio[2].setEncoding("utf8");
|
|
83
|
+
child.stdio[2].on("data", (text) => {
|
|
84
|
+
// Kept for error messages only; the browser logs little in headless mode.
|
|
85
|
+
this.stderr = (this.stderr + text).slice(-4000);
|
|
86
|
+
});
|
|
87
|
+
child.stdio[4].setEncoding("utf8");
|
|
88
|
+
child.stdio[4].on("data", (text) => this.receive(text));
|
|
89
|
+
child.once("exit", (code) => {
|
|
90
|
+
this.closed = true;
|
|
91
|
+
const error = new Error(
|
|
92
|
+
`The browser stopped (exit code ${code}).${this.stderr ? ` ${this.stderr.trim().split("\n").pop()}` : ""}`,
|
|
93
|
+
);
|
|
94
|
+
for (const { reject } of this.pending.values()) reject(error);
|
|
95
|
+
this.pending.clear();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
receive(text) {
|
|
100
|
+
this.buffer += text;
|
|
101
|
+
let end;
|
|
102
|
+
while ((end = this.buffer.indexOf("\0")) >= 0) {
|
|
103
|
+
const raw = this.buffer.slice(0, end);
|
|
104
|
+
this.buffer = this.buffer.slice(end + 1);
|
|
105
|
+
let message;
|
|
106
|
+
try {
|
|
107
|
+
message = JSON.parse(raw);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (message.id !== undefined && this.pending.has(message.id)) {
|
|
112
|
+
const { resolve, reject, timer } = this.pending.get(message.id);
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
this.pending.delete(message.id);
|
|
115
|
+
if (message.error) reject(new Error(`${message.error.message} (${message.error.code})`));
|
|
116
|
+
else resolve(message.result ?? {});
|
|
117
|
+
} else if (message.method) {
|
|
118
|
+
for (const listener of [...this.listeners]) listener(message);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Sends a command and waits for its result. */
|
|
124
|
+
send(method, params = {}, sessionId) {
|
|
125
|
+
if (this.closed) return Promise.reject(new Error("The browser is closed."));
|
|
126
|
+
const id = this.nextId++;
|
|
127
|
+
const message = { id, method, params, ...(sessionId ? { sessionId } : {}) };
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
this.pending.delete(id);
|
|
131
|
+
reject(new Error(`The browser did not answer ${method} within ${this.timeout} ms.`));
|
|
132
|
+
}, this.timeout);
|
|
133
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
134
|
+
this.child.stdio[3].write(`${JSON.stringify(message)}\0`);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Waits for an event of a session. */
|
|
139
|
+
waitFor(method, sessionId) {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
143
|
+
reject(new Error(`The browser did not send ${method} within ${this.timeout} ms.`));
|
|
144
|
+
}, this.timeout);
|
|
145
|
+
const listener = (message) => {
|
|
146
|
+
if (message.method !== method || message.sessionId !== sessionId) return;
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
149
|
+
resolve(message.params);
|
|
150
|
+
};
|
|
151
|
+
this.listeners.push(listener);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Closes the browser and removes its temporary profile. */
|
|
156
|
+
async close() {
|
|
157
|
+
if (!this.closed) {
|
|
158
|
+
const exited = new Promise((resolve) => this.child.once("exit", resolve));
|
|
159
|
+
await this.send("Browser.close").catch(() => undefined);
|
|
160
|
+
const timer = new Promise((resolve) => setTimeout(resolve, 5000));
|
|
161
|
+
await Promise.race([exited, timer]);
|
|
162
|
+
if (!this.closed) this.child.kill();
|
|
163
|
+
}
|
|
164
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
165
|
+
try {
|
|
166
|
+
rmSync(this.profile, { recursive: true, force: true });
|
|
167
|
+
return;
|
|
168
|
+
} catch {
|
|
169
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { findBrowser, NO_BROWSER } from "./browser.mjs";
|
|
2
|
+
import { capture, capturePage, type CaptureJob, type CaptureOptions } from "./capture.mjs";
|
|
3
|
+
import { NO_FFMPEG, captureVideo, encoderArgs, ffmpegVersion, findFfmpeg, frameTimes, type VideoJob, type VideoOptions } from "./video.mjs";
|
|
4
|
+
export declare const PACKAGE_NAME = "@meltemi/diagrams-capture";
|
|
5
|
+
export { capture, capturePage, findBrowser, NO_BROWSER, type CaptureJob, type CaptureOptions };
|
|
6
|
+
export { NO_FFMPEG, captureVideo, encoderArgs, ffmpegVersion, findFfmpeg, frameTimes, type VideoJob, type VideoOptions, };
|
|
7
|
+
/** A job of `captureSync`: where its file goes. */
|
|
8
|
+
export interface FileJob extends CaptureJob {
|
|
9
|
+
readonly out: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Captures the pictures and videos and writes their files, waiting for the browser and FFmpeg: the
|
|
13
|
+
* same captures run in a child `node` process, for synchronous callers such as the command line.
|
|
14
|
+
* Returns the browser's and FFmpeg's versions and each video's frame count; throws with their
|
|
15
|
+
* message when one fails.
|
|
16
|
+
*/
|
|
17
|
+
export declare function captureSync(jobs: readonly FileJob[], options?: CaptureOptions & {
|
|
18
|
+
readonly videos?: readonly VideoJob[];
|
|
19
|
+
readonly ffmpeg?: string;
|
|
20
|
+
}): {
|
|
21
|
+
browser: string;
|
|
22
|
+
ffmpeg: string;
|
|
23
|
+
frames: number[];
|
|
24
|
+
};
|
|
25
|
+
/** The size and colour type of a PNG file, from its header. */
|
|
26
|
+
export declare function pngInfo(bytes: Uint8Array): {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
alpha: boolean;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The output size for a picture: `size` when given (the picture is fitted and centred in it), or
|
|
33
|
+
* the picture's own size.
|
|
34
|
+
*/
|
|
35
|
+
export declare function outputSize(picture: {
|
|
36
|
+
readonly width: number;
|
|
37
|
+
readonly height: number;
|
|
38
|
+
}, size: {
|
|
39
|
+
readonly width: number;
|
|
40
|
+
readonly height: number;
|
|
41
|
+
} | null): {
|
|
42
|
+
width: number;
|
|
43
|
+
height: number;
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AACxD,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAC3F,OAAO,EACL,SAAS,EACT,YAAY,EACZ,WAAW,EACX,aAAa,EACb,UAAU,EACV,UAAU,EACV,KAAK,QAAQ,EACb,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AAErB,eAAO,MAAM,YAAY,8BAA8B,CAAC;AAExD,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,CAAC;AAC/F,OAAO,EACL,SAAS,EACT,YAAY,EACZ,WAAW,EACX,aAAa,EACb,UAAU,EACV,UAAU,EACV,KAAK,QAAQ,EACb,KAAK,YAAY,GAClB,CAAC;AAEF,mDAAmD;AACnD,MAAM,WAAW,OAAQ,SAAQ,UAAU;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,SAAS,OAAO,EAAE,EACxB,OAAO,GAAE,cAAc,GAAG;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,QAAQ,EAAE,CAAC;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACrB,GACL;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAiBvD;AAED,+DAA+D;AAC/D,wBAAgB,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAY5F;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,EAC5D,IAAI,EAAE;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAC/D;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAInC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { findBrowser, NO_BROWSER } from "./browser.mjs";
|
|
4
|
+
import { capture, capturePage } from "./capture.mjs";
|
|
5
|
+
import { NO_FFMPEG, captureVideo, encoderArgs, ffmpegVersion, findFfmpeg, frameTimes, } from "./video.mjs";
|
|
6
|
+
export const PACKAGE_NAME = "@meltemi/diagrams-capture";
|
|
7
|
+
export { capture, capturePage, findBrowser, NO_BROWSER };
|
|
8
|
+
export { NO_FFMPEG, captureVideo, encoderArgs, ffmpegVersion, findFfmpeg, frameTimes, };
|
|
9
|
+
/**
|
|
10
|
+
* Captures the pictures and videos and writes their files, waiting for the browser and FFmpeg: the
|
|
11
|
+
* same captures run in a child `node` process, for synchronous callers such as the command line.
|
|
12
|
+
* Returns the browser's and FFmpeg's versions and each video's frame count; throws with their
|
|
13
|
+
* message when one fails.
|
|
14
|
+
*/
|
|
15
|
+
export function captureSync(jobs, options = {}) {
|
|
16
|
+
const browser = options.browser ?? findBrowser();
|
|
17
|
+
if (!browser)
|
|
18
|
+
throw new Error(NO_BROWSER);
|
|
19
|
+
const videos = options.videos ?? [];
|
|
20
|
+
const ffmpeg = videos.length > 0 ? (options.ffmpeg ?? findFfmpeg()) : null;
|
|
21
|
+
if (videos.length > 0 && !ffmpeg)
|
|
22
|
+
throw new Error(NO_FFMPEG);
|
|
23
|
+
const worker = fileURLToPath(new URL("./worker.mjs", import.meta.url));
|
|
24
|
+
const result = spawnSync(process.execPath, [worker], {
|
|
25
|
+
input: JSON.stringify({ jobs, videos, browser, ffmpeg, timeout: options.timeout ?? null }),
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
28
|
+
windowsHide: true,
|
|
29
|
+
});
|
|
30
|
+
if (result.error)
|
|
31
|
+
throw result.error;
|
|
32
|
+
if (result.status !== 0)
|
|
33
|
+
throw new Error(result.stderr.trim() || "The browser could not capture the picture.");
|
|
34
|
+
return JSON.parse(result.stdout);
|
|
35
|
+
}
|
|
36
|
+
/** The size and colour type of a PNG file, from its header. */
|
|
37
|
+
export function pngInfo(bytes) {
|
|
38
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
39
|
+
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
40
|
+
if (bytes.length < 33 || signature.some((b, i) => bytes[i] !== b)) {
|
|
41
|
+
throw new Error("Not a PNG file.");
|
|
42
|
+
}
|
|
43
|
+
const colorType = bytes[25];
|
|
44
|
+
return {
|
|
45
|
+
width: view.getUint32(16),
|
|
46
|
+
height: view.getUint32(20),
|
|
47
|
+
alpha: colorType === 4 || colorType === 6,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The output size for a picture: `size` when given (the picture is fitted and centred in it), or
|
|
52
|
+
* the picture's own size.
|
|
53
|
+
*/
|
|
54
|
+
export function outputSize(picture, size) {
|
|
55
|
+
return size
|
|
56
|
+
? { width: size.width, height: size.height }
|
|
57
|
+
: { width: picture.width, height: picture.height };
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/page.d.mts
ADDED
package/dist/page.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// What a capture draws, without the browser that draws it: the page of a picture and the times of
|
|
2
|
+
// a video's frames. Plain JavaScript with no Node module, so pages in a browser use it too.
|
|
3
|
+
|
|
4
|
+
/** A CSS colour as written by a profile, or transparent when it is not a plain colour. */
|
|
5
|
+
export function cssColor(color) {
|
|
6
|
+
if (!color || color === "transparent") return "transparent";
|
|
7
|
+
return /^[#\w(),.%\s-]+$/.test(color) ? color : "transparent";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The page a job is drawn on: the SVG at the job's size (its view box keeps the picture's ratio,
|
|
12
|
+
* centred), on the job's background, and a printed page of the same size.
|
|
13
|
+
*/
|
|
14
|
+
export function capturePage(job) {
|
|
15
|
+
const width = Math.round(job.width * 100) / 100;
|
|
16
|
+
const height = Math.round(job.height * 100) / 100;
|
|
17
|
+
const background = cssColor(job.background);
|
|
18
|
+
return [
|
|
19
|
+
"<!doctype html>",
|
|
20
|
+
'<html><head><meta charset="utf-8"><style>',
|
|
21
|
+
`@page { size: ${width}px ${height}px; margin: 0; }`,
|
|
22
|
+
`html, body { margin: 0; padding: 0; width: ${width}px; height: ${height}px; overflow: hidden; background: ${background}; }`,
|
|
23
|
+
`body > svg { display: block; width: ${width}px; height: ${height}px; }`,
|
|
24
|
+
"</style></head><body>",
|
|
25
|
+
job.svg,
|
|
26
|
+
"</body></html>",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The times of the frames of a video of `durationMs` at `fps`, in milliseconds. */
|
|
31
|
+
export function frameTimes(durationMs, fps) {
|
|
32
|
+
const count = Math.max(1, Math.round((durationMs * fps) / 1000));
|
|
33
|
+
return Array.from({ length: count }, (_, i) => (i * 1000) / fps);
|
|
34
|
+
}
|
package/dist/video.d.mts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** One video to capture and encode. */
|
|
2
|
+
export interface VideoJob {
|
|
3
|
+
readonly format: "mp4" | "webm" | "gif";
|
|
4
|
+
/** The frames page: it defines `__draw(t)`. */
|
|
5
|
+
readonly html: string;
|
|
6
|
+
readonly width: number;
|
|
7
|
+
readonly height: number;
|
|
8
|
+
readonly fps: number;
|
|
9
|
+
readonly durationMs: number;
|
|
10
|
+
/** `transparent`, or a CSS colour. */
|
|
11
|
+
readonly background: string;
|
|
12
|
+
/** Where FFmpeg writes the video. */
|
|
13
|
+
readonly out: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface VideoOptions {
|
|
17
|
+
readonly browser?: string;
|
|
18
|
+
readonly ffmpeg?: string;
|
|
19
|
+
readonly timeout?: number;
|
|
20
|
+
readonly onFrame?: (done: number, total: number) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const NO_FFMPEG: string;
|
|
24
|
+
export function findFfmpeg(
|
|
25
|
+
env?: Record<string, string | undefined>,
|
|
26
|
+
platform?: string,
|
|
27
|
+
): string | null;
|
|
28
|
+
export function ffmpegVersion(ffmpeg: string): string;
|
|
29
|
+
export function encoderArgs(
|
|
30
|
+
format: "mp4" | "webm" | "gif",
|
|
31
|
+
options: { fps: number; transparent: boolean; out: string },
|
|
32
|
+
): string[];
|
|
33
|
+
export function frameTimes(durationMs: number, fps: number): number[];
|
|
34
|
+
export function captureVideo(
|
|
35
|
+
job: VideoJob,
|
|
36
|
+
options?: VideoOptions,
|
|
37
|
+
): Promise<{ browser: string; ffmpeg: string; frames: number }>;
|
package/dist/video.mjs
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Videos of player documents: frames drawn in one browser page and encoded by the system FFmpeg.
|
|
2
|
+
// Plain JavaScript, so a plain `node` (the worker of `captureSync`) can run it.
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { delimiter, join } from "node:path";
|
|
6
|
+
import { findBrowser, NO_BROWSER } from "./browser.mjs";
|
|
7
|
+
import { PipeSession } from "./cdp.mjs";
|
|
8
|
+
import { frameTimes } from "./page.mjs";
|
|
9
|
+
|
|
10
|
+
export { frameTimes };
|
|
11
|
+
|
|
12
|
+
/** What to say when FFmpeg is not found. */
|
|
13
|
+
export const NO_FFMPEG =
|
|
14
|
+
"Video exports need FFmpeg; install it (it is not bundled) or set MELTEMI_FFMPEG to the ffmpeg executable.";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The FFmpeg executable: `MELTEMI_FFMPEG` when set, otherwise `ffmpeg` on the path or in the usual
|
|
18
|
+
* install folders; null when there is none.
|
|
19
|
+
*/
|
|
20
|
+
export function findFfmpeg(env = process.env, platform = process.platform) {
|
|
21
|
+
const chosen = env.MELTEMI_FFMPEG;
|
|
22
|
+
if (chosen) return existsSync(chosen) ? chosen : null;
|
|
23
|
+
const name = platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
|
|
24
|
+
const path = env.PATH ?? env.Path ?? "";
|
|
25
|
+
for (const folder of path.split(delimiter)) {
|
|
26
|
+
if (folder && existsSync(join(folder, name))) return join(folder, name);
|
|
27
|
+
}
|
|
28
|
+
const usual =
|
|
29
|
+
platform === "win32"
|
|
30
|
+
? ["C:/ProgramData/chocolatey/bin/ffmpeg.exe", "C:/ffmpeg/bin/ffmpeg.exe"]
|
|
31
|
+
: ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"];
|
|
32
|
+
return usual.find((p) => existsSync(p)) ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The version FFmpeg reports, such as `6.1.1`. */
|
|
36
|
+
export function ffmpegVersion(ffmpeg) {
|
|
37
|
+
const result = spawnSync(ffmpeg, ["-hide_banner", "-version"], {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
windowsHide: true,
|
|
40
|
+
});
|
|
41
|
+
const match = /ffmpeg version (\S+)/.exec(result.stdout ?? "");
|
|
42
|
+
return match ? match[1].replace(/-.*$/, "") : "unknown";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The FFmpeg arguments that encode PNG frames read from standard input into `out` (decision
|
|
47
|
+
* 0026): H.264 MP4, VP9 WebM (with alpha when transparent) or a looping GIF with its own palette.
|
|
48
|
+
*/
|
|
49
|
+
export function encoderArgs(format, { fps, transparent, out }) {
|
|
50
|
+
const input = [
|
|
51
|
+
"-hide_banner",
|
|
52
|
+
"-loglevel",
|
|
53
|
+
"error",
|
|
54
|
+
"-y",
|
|
55
|
+
"-f",
|
|
56
|
+
"image2pipe",
|
|
57
|
+
"-framerate",
|
|
58
|
+
String(fps),
|
|
59
|
+
"-i",
|
|
60
|
+
"-",
|
|
61
|
+
];
|
|
62
|
+
if (format === "mp4") {
|
|
63
|
+
return [
|
|
64
|
+
...input,
|
|
65
|
+
"-vf",
|
|
66
|
+
"pad=ceil(iw/2)*2:ceil(ih/2)*2",
|
|
67
|
+
"-c:v",
|
|
68
|
+
"libx264",
|
|
69
|
+
"-preset",
|
|
70
|
+
"medium",
|
|
71
|
+
"-crf",
|
|
72
|
+
"18",
|
|
73
|
+
"-pix_fmt",
|
|
74
|
+
"yuv420p",
|
|
75
|
+
"-movflags",
|
|
76
|
+
"+faststart",
|
|
77
|
+
"-r",
|
|
78
|
+
String(fps),
|
|
79
|
+
out,
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
if (format === "webm") {
|
|
83
|
+
return [
|
|
84
|
+
...input,
|
|
85
|
+
"-c:v",
|
|
86
|
+
"libvpx-vp9",
|
|
87
|
+
"-b:v",
|
|
88
|
+
"0",
|
|
89
|
+
"-crf",
|
|
90
|
+
"32",
|
|
91
|
+
"-row-mt",
|
|
92
|
+
"1",
|
|
93
|
+
...(transparent ? ["-pix_fmt", "yuva420p", "-auto-alt-ref", "0"] : ["-pix_fmt", "yuv420p"]),
|
|
94
|
+
"-r",
|
|
95
|
+
String(fps),
|
|
96
|
+
out,
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
return [
|
|
100
|
+
...input,
|
|
101
|
+
"-filter_complex",
|
|
102
|
+
"[0:v]split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
|
|
103
|
+
"-loop",
|
|
104
|
+
"0",
|
|
105
|
+
out,
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Writes a buffer to a stream, waiting when it asks to. */
|
|
110
|
+
function write(stream, buffer) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const ok = stream.write(buffer, (error) => (error ? reject(error) : undefined));
|
|
113
|
+
if (ok) resolve();
|
|
114
|
+
else stream.once("drain", resolve);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Captures a video: the frames page (`html`, which defines `__draw(t)`) at the output size, one
|
|
120
|
+
* screenshot per frame time, piped to FFmpeg. `onFrame(done, total)` reports progress. Resolves to
|
|
121
|
+
* the browser's and FFmpeg's versions and the number of frames.
|
|
122
|
+
*/
|
|
123
|
+
export async function captureVideo(job, options = {}) {
|
|
124
|
+
const browser = options.browser ?? findBrowser();
|
|
125
|
+
if (!browser) throw new Error(NO_BROWSER);
|
|
126
|
+
const ffmpeg = options.ffmpeg ?? findFfmpeg();
|
|
127
|
+
if (!ffmpeg) throw new Error(NO_FFMPEG);
|
|
128
|
+
const transparent = job.background === "transparent";
|
|
129
|
+
const times = frameTimes(job.durationMs, job.fps);
|
|
130
|
+
const encoder = spawn(
|
|
131
|
+
ffmpeg,
|
|
132
|
+
encoderArgs(job.format, { fps: job.fps, transparent, out: job.out }),
|
|
133
|
+
{
|
|
134
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
let errors = "";
|
|
139
|
+
encoder.stderr.setEncoding("utf8");
|
|
140
|
+
encoder.stderr.on("data", (text) => (errors = (errors + text).slice(-4000)));
|
|
141
|
+
const finished = new Promise((resolve, reject) => {
|
|
142
|
+
encoder.once("error", reject);
|
|
143
|
+
encoder.once("exit", (code) =>
|
|
144
|
+
code === 0
|
|
145
|
+
? resolve()
|
|
146
|
+
: reject(new Error(`FFmpeg failed (exit code ${code}): ${errors.trim()}`)),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
const session = await PipeSession.launch(browser, { timeout: options.timeout ?? 30_000 });
|
|
150
|
+
try {
|
|
151
|
+
const { product } = await session.send("Browser.getVersion");
|
|
152
|
+
const { targetId } = await session.send("Target.createTarget", { url: "about:blank" });
|
|
153
|
+
const { sessionId } = await session.send("Target.attachToTarget", { targetId, flatten: true });
|
|
154
|
+
await session.send("Page.enable", {}, sessionId);
|
|
155
|
+
await session.send(
|
|
156
|
+
"Emulation.setDeviceMetricsOverride",
|
|
157
|
+
{ width: job.width, height: job.height, deviceScaleFactor: 1, mobile: false },
|
|
158
|
+
sessionId,
|
|
159
|
+
);
|
|
160
|
+
if (transparent) {
|
|
161
|
+
await session.send(
|
|
162
|
+
"Emulation.setDefaultBackgroundColorOverride",
|
|
163
|
+
{ color: { r: 0, g: 0, b: 0, a: 0 } },
|
|
164
|
+
sessionId,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const { frameTree } = await session.send("Page.getFrameTree", {}, sessionId);
|
|
168
|
+
await session.send(
|
|
169
|
+
"Page.setDocumentContent",
|
|
170
|
+
{ frameId: frameTree.frame.id, html: job.html },
|
|
171
|
+
sessionId,
|
|
172
|
+
);
|
|
173
|
+
for (const [i, t] of times.entries()) {
|
|
174
|
+
await session.send(
|
|
175
|
+
"Runtime.evaluate",
|
|
176
|
+
{ expression: `__draw(${t})`, awaitPromise: true, returnByValue: true },
|
|
177
|
+
sessionId,
|
|
178
|
+
);
|
|
179
|
+
const { data } = await session.send(
|
|
180
|
+
"Page.captureScreenshot",
|
|
181
|
+
{
|
|
182
|
+
format: "png",
|
|
183
|
+
clip: { x: 0, y: 0, width: job.width, height: job.height, scale: 1 },
|
|
184
|
+
fromSurface: true,
|
|
185
|
+
captureBeyondViewport: false,
|
|
186
|
+
},
|
|
187
|
+
sessionId,
|
|
188
|
+
);
|
|
189
|
+
await write(encoder.stdin, Buffer.from(data, "base64"));
|
|
190
|
+
options.onFrame?.(i + 1, times.length);
|
|
191
|
+
}
|
|
192
|
+
encoder.stdin.end();
|
|
193
|
+
await finished;
|
|
194
|
+
return { browser: product, ffmpeg: ffmpegVersion(ffmpeg), frames: times.length };
|
|
195
|
+
} catch (error) {
|
|
196
|
+
encoder.kill();
|
|
197
|
+
throw error;
|
|
198
|
+
} finally {
|
|
199
|
+
await session.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
package/dist/worker.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Runs captures for `captureSync`: reads `{ jobs, videos, browser, ffmpeg, timeout }` as JSON on
|
|
2
|
+
// stdin, writes each picture to its `out` path (videos are written by FFmpeg), and prints
|
|
3
|
+
// `{ browser, ffmpeg, frames }` as JSON. Plain JavaScript for a plain `node`.
|
|
4
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { capture } from "./capture.mjs";
|
|
6
|
+
import { captureVideo } from "./video.mjs";
|
|
7
|
+
|
|
8
|
+
const input = JSON.parse(readFileSync(0, "utf8"));
|
|
9
|
+
const options = {
|
|
10
|
+
browser: input.browser ?? undefined,
|
|
11
|
+
ffmpeg: input.ffmpeg ?? undefined,
|
|
12
|
+
timeout: input.timeout ?? undefined,
|
|
13
|
+
};
|
|
14
|
+
try {
|
|
15
|
+
let browser = "";
|
|
16
|
+
let ffmpeg = "";
|
|
17
|
+
const frames = [];
|
|
18
|
+
if (input.jobs?.length > 0) {
|
|
19
|
+
const result = await capture(
|
|
20
|
+
input.jobs.map(({ out: _out, ...job }) => job),
|
|
21
|
+
options,
|
|
22
|
+
);
|
|
23
|
+
browser = result.browser;
|
|
24
|
+
input.jobs.forEach((job, i) => writeFileSync(job.out, result.results[i]));
|
|
25
|
+
}
|
|
26
|
+
for (const video of input.videos ?? []) {
|
|
27
|
+
const result = await captureVideo(video, options);
|
|
28
|
+
browser = result.browser;
|
|
29
|
+
ffmpeg = result.ffmpeg;
|
|
30
|
+
frames.push(result.frames);
|
|
31
|
+
}
|
|
32
|
+
process.stdout.write(JSON.stringify({ browser, ffmpeg, frames }));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
process.stderr.write(error instanceof Error ? error.message : String(error));
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meltemi/diagrams-capture",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.9.0",
|
|
4
|
+
"description": "PNG, PDF and video captures of rendered SVG with the installed Edge, Chrome or Chromium over the DevTools protocol, and video encoding with the installed FFmpeg.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/askenaz-dev/meltemi-diagrams.git",
|
|
9
|
+
"directory": "packages/capture"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/askenaz-dev/meltemi-diagrams/tree/main/packages/capture#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/askenaz-dev/meltemi-diagrams/issues"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.12.0"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./page": {
|
|
25
|
+
"types": "./dist/page.d.mts",
|
|
26
|
+
"default": "./dist/page.mjs"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json && node build.mjs",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
38
|
+
}
|
|
6
39
|
}
|