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/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
+ }