scenic-prism-playwright 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 +133 -0
- package/README.md +240 -0
- package/dist/image.d.ts +31 -0
- package/dist/image.js +68 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +49 -0
- package/dist/library.d.ts +31 -0
- package/dist/library.js +122 -0
- package/dist/margin.d.ts +67 -0
- package/dist/margin.js +84 -0
- package/dist/render.d.ts +41 -0
- package/dist/render.js +42 -0
- package/dist/studio.d.ts +251 -0
- package/dist/studio.js +593 -0
- package/package.json +38 -0
package/dist/studio.js
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser side: a Chromium instance, and one page per image.
|
|
3
|
+
*
|
|
4
|
+
* A studio is the expensive half — launching a browser — held open so a batch
|
|
5
|
+
* of scenes can share it. Each render gets a page of its own, and with it a
|
|
6
|
+
* GPU device of its own: the library shares one device per page, and closing
|
|
7
|
+
* the page is what gives it back, whether the render finished or not.
|
|
8
|
+
*
|
|
9
|
+
* The page itself is served entirely out of this process. There is no HTTP
|
|
10
|
+
* server and no temporary directory: `page.route` intercepts an origin that
|
|
11
|
+
* does not exist, answers `/` with a canvas and `/library/*` with the files of
|
|
12
|
+
* the installed `scenic-prism`, and the page then imports the library exactly
|
|
13
|
+
* as a browser would over the network. That origin is a `.localhost` one,
|
|
14
|
+
* which a browser counts as a secure context — and WebGPU is offered to secure
|
|
15
|
+
* contexts only.
|
|
16
|
+
*
|
|
17
|
+
* The one thing WebGPU changes here is how the image comes back. A WebGL canvas can be kept readable after it is
|
|
18
|
+
* drawn; a WebGPU canvas cannot — what was drawn into it is gone once it has
|
|
19
|
+
* been shown — so reading the finished canvas finds nothing. The page instead
|
|
20
|
+
* renders with `retain`, asks the handle for the pixels with `readPixels()`,
|
|
21
|
+
* and encodes those through a 2D canvas of its own.
|
|
22
|
+
*/
|
|
23
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
24
|
+
import { dirname, resolve } from 'node:path';
|
|
25
|
+
import { chromium } from 'playwright';
|
|
26
|
+
import { toSceneSpec } from 'scenic-prism-fluent';
|
|
27
|
+
import { MIME_TYPES, checkQuality, decodeDataUrl, formatForPath } from "./image.js";
|
|
28
|
+
import { addMargin, resolveMargin } from "./margin.js";
|
|
29
|
+
import { browserEntry, contentType, resolveLibraryRoot, resolveServedFile } from "./library.js";
|
|
30
|
+
/**
|
|
31
|
+
* The origin the page is served on. Nothing is listening there and nothing ever
|
|
32
|
+
* will be: every request for it is fulfilled by the route handler below, before
|
|
33
|
+
* the browser looks for a host. `.localhost` is reserved for exactly this, so
|
|
34
|
+
* the name can never start resolving to somebody else's machine.
|
|
35
|
+
*/
|
|
36
|
+
const ORIGIN = 'http://scenic-prism.localhost';
|
|
37
|
+
/** Where the served copy of `scenic-prism` is mounted under that origin. */
|
|
38
|
+
const LIBRARY_PREFIX = '/library/';
|
|
39
|
+
/** Where the loaded library is left for the traced function to find. */
|
|
40
|
+
const LIBRARY_GLOBAL = '__scenicPrismLibrary';
|
|
41
|
+
/**
|
|
42
|
+
* The whole page: an import of the library, and nothing to lay out.
|
|
43
|
+
*
|
|
44
|
+
* The import is a module script in the markup rather than a dynamic `import()`
|
|
45
|
+
* inside the traced function, and deliberately so. That function is sent to the
|
|
46
|
+
* browser as source, and TypeScript compiles `import(someVariable)` into a call
|
|
47
|
+
* to a helper of its own that exists in this process and not in the page — so
|
|
48
|
+
* the one thing this file must never do is write a dynamic import in the code
|
|
49
|
+
* that crosses. A `<script type="module">` cannot be rewritten by anybody.
|
|
50
|
+
*/
|
|
51
|
+
function pageHtml(entryUrl) {
|
|
52
|
+
return `<!doctype html>
|
|
53
|
+
<html lang="en">
|
|
54
|
+
<head>
|
|
55
|
+
<meta charset="utf-8" />
|
|
56
|
+
<title>scenic-prism-playwright</title>
|
|
57
|
+
<style>
|
|
58
|
+
html, body { margin: 0; padding: 0; background: #000; }
|
|
59
|
+
canvas { display: block; }
|
|
60
|
+
</style>
|
|
61
|
+
<script type="module">
|
|
62
|
+
import * as library from '${entryUrl}';
|
|
63
|
+
globalThis.${LIBRARY_GLOBAL} = library;
|
|
64
|
+
</script>
|
|
65
|
+
</head>
|
|
66
|
+
<body></body>
|
|
67
|
+
</html>
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Switches every launch takes, whichever adapter is behind it. WebGPU is still
|
|
72
|
+
* behind a switch in the Chromium builds Playwright manages; the rest matter
|
|
73
|
+
* because the render loop is `requestAnimationFrame`, and a page nobody is
|
|
74
|
+
* looking at is entitled to be slowed right down.
|
|
75
|
+
*/
|
|
76
|
+
const SHARED_BROWSER_ARGS = [
|
|
77
|
+
'--enable-unsafe-webgpu',
|
|
78
|
+
'--disable-background-timer-throttling',
|
|
79
|
+
'--disable-backgrounding-occluded-windows',
|
|
80
|
+
'--disable-renderer-backgrounding',
|
|
81
|
+
];
|
|
82
|
+
/**
|
|
83
|
+
* Software rendering, asked for explicitly: SwiftShader's WebGPU adapter and
|
|
84
|
+
* nothing else, whatever the machine has. This is what `gpu: false` launches
|
|
85
|
+
* with, and it is the only setting under which two machines agree pixel for
|
|
86
|
+
* pixel — a GPU and a software rasteriser round differently, so the same
|
|
87
|
+
* `seed` on each gives images that look identical and hash differently.
|
|
88
|
+
*/
|
|
89
|
+
export const SOFTWARE_BROWSER_ARGS = [
|
|
90
|
+
...SHARED_BROWSER_ARGS,
|
|
91
|
+
'--use-webgpu-adapter=swiftshader',
|
|
92
|
+
'--enable-unsafe-swiftshader',
|
|
93
|
+
];
|
|
94
|
+
/**
|
|
95
|
+
* Asking for hardware, as plainly as Chromium allows: the GPU switched on for
|
|
96
|
+
* headless, and the blocklist ignored so a driver Chrome merely distrusts is
|
|
97
|
+
* still used for a render nobody is browsing with. On Linux, Chromium's WebGPU
|
|
98
|
+
* runs on Vulkan, which it does not turn on by itself.
|
|
99
|
+
*/
|
|
100
|
+
const HARDWARE_BROWSER_ARGS = [
|
|
101
|
+
'--enable-gpu',
|
|
102
|
+
'--ignore-gpu-blocklist',
|
|
103
|
+
...(process.platform === 'linux' ? ['--enable-features=Vulkan'] : []),
|
|
104
|
+
];
|
|
105
|
+
/**
|
|
106
|
+
* Hardware and no substitute: what `gpu: true` launches with. Chromium is asked
|
|
107
|
+
* to leave the software fallback out altogether — though that is not what
|
|
108
|
+
* enforces `true`, since a browser is entitled to hand out its fallback adapter
|
|
109
|
+
* anyway. What enforces it is {@link requireHardware}, which asks a page which
|
|
110
|
+
* adapter it would actually trace with.
|
|
111
|
+
*/
|
|
112
|
+
export const GPU_BROWSER_ARGS = [
|
|
113
|
+
...SHARED_BROWSER_ARGS,
|
|
114
|
+
...HARDWARE_BROWSER_ARGS,
|
|
115
|
+
'--disable-software-rasterizer',
|
|
116
|
+
];
|
|
117
|
+
/**
|
|
118
|
+
* What a launch takes by default: the hardware switches, and permission to fall
|
|
119
|
+
* back to SwiftShader rather than the instruction not to. A browser with no GPU
|
|
120
|
+
* to offer hands a page SwiftShader's adapter by itself, which is why `'auto'`
|
|
121
|
+
* needs no probe of its own — the browser answers the question by starting.
|
|
122
|
+
*
|
|
123
|
+
* Chromium takes the last occurrence of a switch, so anything passed in
|
|
124
|
+
* `launch.args` still overrides what is here.
|
|
125
|
+
*/
|
|
126
|
+
export const DEFAULT_BROWSER_ARGS = [
|
|
127
|
+
...SHARED_BROWSER_ARGS,
|
|
128
|
+
...HARDWARE_BROWSER_ARGS,
|
|
129
|
+
'--enable-unsafe-swiftshader',
|
|
130
|
+
];
|
|
131
|
+
/**
|
|
132
|
+
* The channel a hardware launch asks for. Playwright's default headless
|
|
133
|
+
* Chromium is the *headless shell*, a build with no GPU stack in it at all, so
|
|
134
|
+
* asking for a GPU means asking for the full browser as well. A Playwright too
|
|
135
|
+
* old to know the channel, or an install without that browser, is why the
|
|
136
|
+
* launch plan below has a second attempt in it.
|
|
137
|
+
*/
|
|
138
|
+
const GPU_CHANNEL = 'chromium';
|
|
139
|
+
/** The args for a preference, before the caller's own are appended. */
|
|
140
|
+
function browserArgsFor(gpu) {
|
|
141
|
+
if (gpu === false)
|
|
142
|
+
return SOFTWARE_BROWSER_ARGS;
|
|
143
|
+
if (gpu === true)
|
|
144
|
+
return GPU_BROWSER_ARGS;
|
|
145
|
+
return DEFAULT_BROWSER_ARGS;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The launches to try, in order, for a preference — each one falling back to
|
|
149
|
+
* the next only if Chromium refuses to start at all.
|
|
150
|
+
*
|
|
151
|
+
* A caller who named a `channel` or an `executablePath` has said which binary
|
|
152
|
+
* to run, so that one is left alone and there is nothing to fall back to.
|
|
153
|
+
*/
|
|
154
|
+
export function launchPlan(gpu, launch = {}) {
|
|
155
|
+
const attempt = (args, extra = {}) => ({
|
|
156
|
+
...extra,
|
|
157
|
+
...launch,
|
|
158
|
+
args: [...args, ...(launch.args ?? [])],
|
|
159
|
+
});
|
|
160
|
+
const software = attempt(SOFTWARE_BROWSER_ARGS);
|
|
161
|
+
if (gpu === false)
|
|
162
|
+
return [software];
|
|
163
|
+
const args = browserArgsFor(gpu);
|
|
164
|
+
const binaryChosen = launch.channel !== undefined || launch.executablePath !== undefined;
|
|
165
|
+
const hardware = binaryChosen
|
|
166
|
+
? [attempt(args)]
|
|
167
|
+
: // The full browser first, then whatever `chromium` is on an installation
|
|
168
|
+
// that has no such channel — which on Playwright before 1.49 is the full
|
|
169
|
+
// browser anyway, and so is worth asking.
|
|
170
|
+
[attempt(args, { channel: GPU_CHANNEL }), attempt(args)];
|
|
171
|
+
return gpu === true ? hardware : [...hardware, software];
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Adapter names that mean nobody's silicon was involved. The name is read off
|
|
175
|
+
* the page's WebGPU adapter, which is the only honest place to read it: what
|
|
176
|
+
* Chromium was *asked* for and what it ended up doing are different questions.
|
|
177
|
+
*/
|
|
178
|
+
const SOFTWARE_RENDERERS = /swiftshader|llvmpipe|lavapipe|softpipe|software|basic render/i;
|
|
179
|
+
/** Whether a reported adapter is a software one. */
|
|
180
|
+
export function isSoftwareAdapter({ renderer, fallback }) {
|
|
181
|
+
return fallback || SOFTWARE_RENDERERS.test(renderer);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Whether a failed render looks like it failed for want of a working GPU,
|
|
185
|
+
* rather than for a reason a different browser would fail for too. No adapter
|
|
186
|
+
* is what a page reports when Chromium's GPU process never came up and
|
|
187
|
+
* SwiftShader was not permitted behind it.
|
|
188
|
+
*/
|
|
189
|
+
function looksLikeGraphicsFailure(error) {
|
|
190
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
191
|
+
return /webgpu|gpu|adapter|device|graphics|swiftshader|vulkan|metal/i.test(message);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* How long a single render is given before it is abandoned. Generous on
|
|
195
|
+
* purpose: a thousand samples of a glassy scene at 1024² is minutes of software
|
|
196
|
+
* path tracing, and a timeout that fires on a render that was going to finish
|
|
197
|
+
* is worse than one that fires late.
|
|
198
|
+
*/
|
|
199
|
+
export const DEFAULT_TIMEOUT = 300_000;
|
|
200
|
+
/** The name the page calls back on. Bound only when there is a listener. */
|
|
201
|
+
const PROGRESS_BINDING = '__scenicPrismProgress';
|
|
202
|
+
/**
|
|
203
|
+
* The whole of the in-page work, and the only code here that runs in the
|
|
204
|
+
* browser. It takes the library the document loaded, traces until the
|
|
205
|
+
* accumulation finishes on its own, and reads the finished image back.
|
|
206
|
+
*
|
|
207
|
+
* Read back from the render rather than from the canvas: a WebGPU canvas does
|
|
208
|
+
* not keep what was drawn into it once that has been shown, so the render is
|
|
209
|
+
* started with `retain` and asked for its pixels, which are then encoded
|
|
210
|
+
* through a 2D canvas — the one thing in a browser that turns pixels into a
|
|
211
|
+
* PNG, a JPEG or a WebP. The render itself goes to an `OffscreenCanvas`, which
|
|
212
|
+
* the library draws into exactly as it would a canvas on a page.
|
|
213
|
+
*
|
|
214
|
+
* This function is sent across as source, so it can close over nothing: every
|
|
215
|
+
* value it needs, including the names of the two globals, arrives in `job`.
|
|
216
|
+
*
|
|
217
|
+
* Exported for `studio.test.ts` alone — `index.ts` does not re-export it, so it
|
|
218
|
+
* is no part of the package's surface. Being sent across as source is exactly
|
|
219
|
+
* why it is worth calling directly: nothing else here would catch a mistake in
|
|
220
|
+
* it before a browser did.
|
|
221
|
+
*/
|
|
222
|
+
export async function traceInPage(job) {
|
|
223
|
+
const globals = globalThis;
|
|
224
|
+
const library = globals[job.library];
|
|
225
|
+
if (!library)
|
|
226
|
+
throw new Error('the page did not load scenic-prism');
|
|
227
|
+
// Offscreen: nothing here is ever shown, and Playwright's headless shell —
|
|
228
|
+
// the Chromium a software render launches — loses the device a few frames
|
|
229
|
+
// into presenting WebGPU to a canvas in the document. The render sizes it.
|
|
230
|
+
const canvas = new OffscreenCanvas(1, 1);
|
|
231
|
+
const report = globals[job.binding];
|
|
232
|
+
const onProgress = job.reportProgress && typeof report === 'function'
|
|
233
|
+
? (frames, total) => {
|
|
234
|
+
// Fire and forget: awaiting a binding round-trip on every frame would
|
|
235
|
+
// pace the render against the process driving it.
|
|
236
|
+
void report(frames, total);
|
|
237
|
+
}
|
|
238
|
+
: undefined;
|
|
239
|
+
// A device lost mid-render ends it early, and `done` resolves as though it
|
|
240
|
+
// had merely stopped: kept here so the image is not saved half-traced.
|
|
241
|
+
let lost;
|
|
242
|
+
const handle = library.render(canvas, job.spec, {
|
|
243
|
+
...job.trace,
|
|
244
|
+
onProgress,
|
|
245
|
+
// A method rather than an arrow in a property: a TypeScript runner that
|
|
246
|
+
// keeps function names — tsx does — wraps a named arrow in a helper that
|
|
247
|
+
// exists in Node and not in the page this function is sent to.
|
|
248
|
+
onDeviceLost(info) {
|
|
249
|
+
lost = info;
|
|
250
|
+
},
|
|
251
|
+
retain: true,
|
|
252
|
+
});
|
|
253
|
+
try {
|
|
254
|
+
// `ready` first: a render that could not start settles `done` too, with
|
|
255
|
+
// nothing, and it is the failure that is worth reporting.
|
|
256
|
+
await handle.ready;
|
|
257
|
+
const frames = await handle.done;
|
|
258
|
+
if (lost) {
|
|
259
|
+
throw new Error(`the GPU device was lost after ${frames} samples (${lost.reason}): ${lost.message}`);
|
|
260
|
+
}
|
|
261
|
+
const pixels = await handle.readPixels();
|
|
262
|
+
const colorSpace = handle.colorSpace;
|
|
263
|
+
const out = document.createElement('canvas');
|
|
264
|
+
out.width = pixels.width;
|
|
265
|
+
out.height = pixels.height;
|
|
266
|
+
const context = out.getContext('2d', { colorSpace });
|
|
267
|
+
if (!context)
|
|
268
|
+
throw new Error('the page could not make a 2D canvas to encode the image with');
|
|
269
|
+
context.putImageData(new ImageData(pixels.data, pixels.width, pixels.height, {
|
|
270
|
+
colorSpace,
|
|
271
|
+
}), 0, 0);
|
|
272
|
+
// The adapter the library took, asked for again: the same browser flags
|
|
273
|
+
// give the same answer. Which card drew the image is not worth failing a
|
|
274
|
+
// render over, so a browser that will not say reports an empty name.
|
|
275
|
+
let adapter = { renderer: '', fallback: false };
|
|
276
|
+
try {
|
|
277
|
+
const found = await navigator.gpu.requestAdapter();
|
|
278
|
+
if (found) {
|
|
279
|
+
const info = found.info;
|
|
280
|
+
adapter = {
|
|
281
|
+
renderer: [info.vendor, info.architecture, info.device, info.description]
|
|
282
|
+
.filter(Boolean)
|
|
283
|
+
.join(' '),
|
|
284
|
+
fallback: info.isFallbackAdapter === true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// As above.
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
dataUrl: out.toDataURL(job.mimeType, job.quality),
|
|
293
|
+
width: pixels.width,
|
|
294
|
+
height: pixels.height,
|
|
295
|
+
frames,
|
|
296
|
+
adapter,
|
|
297
|
+
colorSpace,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
handle.stop();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Which adapter a page would trace with, asked before anything is traced. Sent
|
|
306
|
+
* across as source like {@link traceInPage}, so it closes over nothing and
|
|
307
|
+
* reads no globals. `null` is a page with no WebGPU adapter at all.
|
|
308
|
+
*
|
|
309
|
+
* Exported for the tests on the same terms as {@link traceInPage}.
|
|
310
|
+
*/
|
|
311
|
+
export async function probeAdapterInPage() {
|
|
312
|
+
if (typeof navigator === 'undefined' || !navigator.gpu)
|
|
313
|
+
return null;
|
|
314
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
315
|
+
if (!adapter)
|
|
316
|
+
return null;
|
|
317
|
+
const info = adapter.info;
|
|
318
|
+
return {
|
|
319
|
+
renderer: [info.vendor, info.architecture, info.device, info.description]
|
|
320
|
+
.filter(Boolean)
|
|
321
|
+
.join(' '),
|
|
322
|
+
fallback: info.isFallbackAdapter === true,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Hold `gpu: true` to what it asked for, before a scene is traced rather than
|
|
327
|
+
* after. A browser that fell back to SwiftShader renders perfectly well and
|
|
328
|
+
* perhaps a hundred times slower, which is exactly the outcome somebody writing
|
|
329
|
+
* `true` wanted to hear about instead of wait out.
|
|
330
|
+
*/
|
|
331
|
+
async function requireHardware(browser, ownsBrowser, serve) {
|
|
332
|
+
const page = await browser.newPage();
|
|
333
|
+
let adapter = null;
|
|
334
|
+
try {
|
|
335
|
+
// Asked on the served origin, since WebGPU is only offered to a secure
|
|
336
|
+
// context and a blank page is not one.
|
|
337
|
+
await serve(page);
|
|
338
|
+
await page.goto(`${ORIGIN}/`);
|
|
339
|
+
adapter = await page.evaluate(probeAdapterInPage);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// A page that cannot answer has no WebGPU to answer about.
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
await page.close();
|
|
346
|
+
}
|
|
347
|
+
if (adapter && !isSoftwareAdapter(adapter))
|
|
348
|
+
return;
|
|
349
|
+
if (ownsBrowser) {
|
|
350
|
+
try {
|
|
351
|
+
await browser.close();
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Nothing to do about a browser that has already gone.
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
throw new Error('scenic-prism-playwright: `gpu: true` asked for hardware rendering, and this browser ' +
|
|
358
|
+
`${adapter ? `traces with ${adapter.renderer || 'a fallback adapter'}` : 'has no WebGPU adapter at all'}. ` +
|
|
359
|
+
"Pass `gpu: 'auto'` to render in software wherever there is no GPU to use.");
|
|
360
|
+
}
|
|
361
|
+
/** Reject if a promise has not settled in time, naming what was waited on. */
|
|
362
|
+
async function withTimeout(work, ms, what) {
|
|
363
|
+
let timer;
|
|
364
|
+
try {
|
|
365
|
+
return await Promise.race([
|
|
366
|
+
work,
|
|
367
|
+
new Promise((_, reject) => {
|
|
368
|
+
timer = setTimeout(() => reject(new Error(`scenic-prism-playwright: ${what} after ${ms}ms.`)), ms);
|
|
369
|
+
}),
|
|
370
|
+
]);
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Launch the first attempt in a plan that Chromium will actually start, so a
|
|
378
|
+
* missing full-browser build is a slower render rather than a failed script.
|
|
379
|
+
*/
|
|
380
|
+
async function launchFirstThatStarts(plan, gpu) {
|
|
381
|
+
let failure;
|
|
382
|
+
for (const attempt of plan) {
|
|
383
|
+
try {
|
|
384
|
+
return await chromium.launch(attempt);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
failure = error;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const detail = failure instanceof Error ? failure.message : String(failure);
|
|
391
|
+
throw new Error(gpu === true
|
|
392
|
+
? 'scenic-prism-playwright: could not launch Chromium for GPU rendering. Install the ' +
|
|
393
|
+
'full browser with `npx playwright install chromium`, or pass `gpu: false` to render ' +
|
|
394
|
+
`in software instead. (${detail})`
|
|
395
|
+
: `scenic-prism-playwright: could not launch Chromium. (${detail})`, { cause: failure });
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Open a browser and keep it open. Every scene rendered through the returned
|
|
399
|
+
* studio shares it, which is most of the cost of the first image and all of the
|
|
400
|
+
* cost of the rest. Close it when the batch is done.
|
|
401
|
+
*
|
|
402
|
+
* The browser is asked for the machine's GPU unless `gpu` says otherwise, and
|
|
403
|
+
* takes SwiftShader wherever there is none to have. See {@link GpuPreference}.
|
|
404
|
+
*/
|
|
405
|
+
export async function openStudio(options = {}) {
|
|
406
|
+
// Resolved before anything is launched, so a missing library is an error
|
|
407
|
+
// about the library rather than a browser that starts and finds nothing.
|
|
408
|
+
const libraryRoot = resolve(options.libraryRoot ?? resolveLibraryRoot());
|
|
409
|
+
const html = pageHtml(`${LIBRARY_PREFIX}${browserEntry(libraryRoot)}`);
|
|
410
|
+
const defaultTimeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
411
|
+
const gpu = options.gpu ?? 'auto';
|
|
412
|
+
const ownsBrowser = options.browser === undefined;
|
|
413
|
+
let browser = options.browser ?? (await launchFirstThatStarts(launchPlan(gpu, options.launch), gpu));
|
|
414
|
+
/** Whether the one software retry below has already been spent. */
|
|
415
|
+
let fellBack = false;
|
|
416
|
+
/** Serve the page, and the library it imports, to one page. */
|
|
417
|
+
async function serve(page) {
|
|
418
|
+
await page.route(`${ORIGIN}/**`, async (route) => {
|
|
419
|
+
const { pathname } = new URL(route.request().url());
|
|
420
|
+
const notFound = () => route.fulfill({ status: 404, contentType: 'text/plain; charset=utf-8', body: 'Not found' });
|
|
421
|
+
if (pathname === '/') {
|
|
422
|
+
await route.fulfill({
|
|
423
|
+
status: 200,
|
|
424
|
+
contentType: 'text/html; charset=utf-8',
|
|
425
|
+
body: html,
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (!pathname.startsWith(LIBRARY_PREFIX)) {
|
|
430
|
+
await notFound();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const file = resolveServedFile(libraryRoot, pathname.slice(LIBRARY_PREFIX.length - 1));
|
|
434
|
+
if (!file) {
|
|
435
|
+
await notFound();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
await route.fulfill({
|
|
440
|
+
status: 200,
|
|
441
|
+
contentType: contentType(file),
|
|
442
|
+
body: await readFile(file),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
await notFound();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
// Only `true` insists, so only `true` pays for a page to ask. `'auto'` takes
|
|
451
|
+
// whatever it is given, and `false` asked for the software renderer.
|
|
452
|
+
if (gpu === true)
|
|
453
|
+
await requireHardware(browser, ownsBrowser, serve);
|
|
454
|
+
/**
|
|
455
|
+
* Swap a hardware browser for a software one, once, when a render failed in a
|
|
456
|
+
* way that points at the graphics stack. Chromium falls back to SwiftShader
|
|
457
|
+
* by itself when the GPU process never comes up, so this is for the machine
|
|
458
|
+
* where it comes up and then cannot give a page a WebGPU adapter or device —
|
|
459
|
+
* rare, and cheaper to recover from than to explain.
|
|
460
|
+
*/
|
|
461
|
+
async function fallBackToSoftware(error) {
|
|
462
|
+
if (gpu !== 'auto' || !ownsBrowser || fellBack)
|
|
463
|
+
return false;
|
|
464
|
+
if (!looksLikeGraphicsFailure(error))
|
|
465
|
+
return false;
|
|
466
|
+
fellBack = true;
|
|
467
|
+
try {
|
|
468
|
+
await browser.close();
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// Already gone, most likely with the GPU process that took it down.
|
|
472
|
+
}
|
|
473
|
+
browser = await launchFirstThatStarts(launchPlan(false, options.launch), false);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
/** One render, on the browser this studio currently holds. */
|
|
477
|
+
async function traceOnce(spec, format,
|
|
478
|
+
// `margin` is pulled out here with `format` and the rest: what is left is
|
|
479
|
+
// handed to the page as its trace options, and a margin is not one — it is
|
|
480
|
+
// added to the encoded image in this process, after the render.
|
|
481
|
+
{ format: _format, margin: _margin, quality, timeout, onProgress, ...trace }) {
|
|
482
|
+
const page = await browser.newPage();
|
|
483
|
+
// Anything the page throws on its own — the library failing to parse, a
|
|
484
|
+
// module 404 — never reaches `evaluate`, so it is kept here to be added to
|
|
485
|
+
// whatever the trace ends up complaining about.
|
|
486
|
+
const pageErrors = [];
|
|
487
|
+
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
488
|
+
try {
|
|
489
|
+
if (onProgress)
|
|
490
|
+
await page.exposeFunction(PROGRESS_BINDING, onProgress);
|
|
491
|
+
await serve(page);
|
|
492
|
+
await page.goto(`${ORIGIN}/`);
|
|
493
|
+
const job = {
|
|
494
|
+
// A `Draft` carries its chaining methods on a prototype and may hold a
|
|
495
|
+
// fluent camera; `toSceneSpec` is what turns either dialect into the
|
|
496
|
+
// plain data that can cross into the browser.
|
|
497
|
+
spec: toSceneSpec(spec),
|
|
498
|
+
trace,
|
|
499
|
+
mimeType: MIME_TYPES[format],
|
|
500
|
+
quality,
|
|
501
|
+
reportProgress: onProgress !== undefined,
|
|
502
|
+
library: LIBRARY_GLOBAL,
|
|
503
|
+
binding: PROGRESS_BINDING,
|
|
504
|
+
};
|
|
505
|
+
const result = await withTimeout(page.evaluate(traceInPage, job), timeout ?? defaultTimeout, 'the render did not finish').catch((cause) => {
|
|
506
|
+
const detail = pageErrors.length ? ` (the page reported: ${pageErrors.join('; ')})` : '';
|
|
507
|
+
throw new Error(`${cause instanceof Error ? cause.message : String(cause)}${detail}`, {
|
|
508
|
+
cause,
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
const { mimeType, bytes } = decodeDataUrl(result.dataUrl);
|
|
512
|
+
if (mimeType !== MIME_TYPES[format]) {
|
|
513
|
+
// A canvas asked for an encoding it does not have quietly hands back a
|
|
514
|
+
// PNG. Saying so beats writing PNG bytes into the file the caller named.
|
|
515
|
+
throw new Error(`scenic-prism-playwright: this browser cannot encode ${format} — it returned ${mimeType}.`);
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
format,
|
|
519
|
+
width: result.width,
|
|
520
|
+
height: result.height,
|
|
521
|
+
frames: result.frames,
|
|
522
|
+
bytes: bytes.length,
|
|
523
|
+
renderer: result.adapter.renderer,
|
|
524
|
+
accelerated: result.adapter.renderer !== '' && !isSoftwareAdapter(result.adapter),
|
|
525
|
+
colorSpace: result.colorSpace,
|
|
526
|
+
data: bytes,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
// Closing the page is what frees its GPU device, whether the render
|
|
531
|
+
// finished, threw, or ran out of time.
|
|
532
|
+
await page.close();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Mount a finished render in its margin. The trace is over by the time this
|
|
537
|
+
* runs and knows nothing about it, so what comes back is the same image in a
|
|
538
|
+
* larger file — re-encoded in the format it arrived in, and re-measured.
|
|
539
|
+
*/
|
|
540
|
+
async function mount(image, format, margin, quality) {
|
|
541
|
+
const { data, width, height } = await addMargin(image.data, format, margin, quality);
|
|
542
|
+
return { ...image, data, width, height, bytes: data.length };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* A render, and — under `gpu: 'auto'` — a second attempt in software if the
|
|
546
|
+
* first one failed for want of working graphics.
|
|
547
|
+
*/
|
|
548
|
+
async function trace(spec, format, renderOptions) {
|
|
549
|
+
try {
|
|
550
|
+
return await traceOnce(spec, format, renderOptions);
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
if (await fallBackToSoftware(error))
|
|
554
|
+
return traceOnce(spec, format, renderOptions);
|
|
555
|
+
if (gpu === true && looksLikeGraphicsFailure(error)) {
|
|
556
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} — \`gpu: true\` asked ` +
|
|
557
|
+
"for hardware rendering and this machine did not provide it. `gpu: 'auto'` " +
|
|
558
|
+
'renders in software when there is no GPU to use.', { cause: error });
|
|
559
|
+
}
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/** One image, start to finish: what the page traced, mounted if asked. */
|
|
564
|
+
async function draw(spec, format, renderOptions) {
|
|
565
|
+
// Both checked before the browser is asked for anything, so a quality or a
|
|
566
|
+
// margin this package cannot use is an error in the first millisecond
|
|
567
|
+
// rather than after the minutes of tracing it would otherwise follow.
|
|
568
|
+
checkQuality(format, renderOptions.quality);
|
|
569
|
+
const margin = resolveMargin(renderOptions.margin);
|
|
570
|
+
const image = await trace(spec, format, renderOptions);
|
|
571
|
+
return margin ? mount(image, format, margin, renderOptions.quality) : image;
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
async renderToBuffer(spec, renderOptions = {}) {
|
|
575
|
+
return draw(spec, renderOptions.format ?? 'png', renderOptions);
|
|
576
|
+
},
|
|
577
|
+
async renderToFile(spec, output, renderOptions = {}) {
|
|
578
|
+
const path = resolve(output);
|
|
579
|
+
// The extension is read before the browser does any work, so a name this
|
|
580
|
+
// package cannot save is an error in the first millisecond, not the last.
|
|
581
|
+
const format = renderOptions.format ?? formatForPath(output);
|
|
582
|
+
const image = await draw(spec, format, renderOptions);
|
|
583
|
+
await mkdir(dirname(path), { recursive: true });
|
|
584
|
+
await writeFile(path, image.data);
|
|
585
|
+
const { data: _data, ...rest } = image;
|
|
586
|
+
return { ...rest, path };
|
|
587
|
+
},
|
|
588
|
+
async close() {
|
|
589
|
+
if (ownsBrowser)
|
|
590
|
+
await browser.close();
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scenic-prism-playwright",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Render a scenic-prism scene to an image file from Node, by path-tracing it in a headless browser driven by Playwright.",
|
|
5
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"sharp": "^0.35.4"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"playwright": ">=1.40",
|
|
21
|
+
"scenic-prism-fluent": ">=0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"keywords": [
|
|
29
|
+
"playwright",
|
|
30
|
+
"headless",
|
|
31
|
+
"screenshot",
|
|
32
|
+
"csg",
|
|
33
|
+
"path-tracing",
|
|
34
|
+
"webgpu",
|
|
35
|
+
"sdf",
|
|
36
|
+
"generative-art"
|
|
37
|
+
]
|
|
38
|
+
}
|