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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Finding `scenic-prism` on disk, and serving it to the page.
3
+ *
4
+ * The trace itself is the library's own `render()`, running in the browser
5
+ * exactly as it does on a web page — this package never reimplements the render
6
+ * loop, and never compiles a shader of its own. What it does instead is hand
7
+ * the browser the very copy of `scenic-prism` the caller has installed, as ES
8
+ * modules over an intercepted origin, so the image a script saves and the image
9
+ * that same scene draws in an app come out of the same code.
10
+ *
11
+ * `scenic-prism` is not a dependency of this package: `scenic-prism-fluent` is
12
+ * the peer, and it carries the core at an exact version. So the copy to serve
13
+ * is the one *the fluent package* resolves, which is what `resolveLibraryRoot`
14
+ * goes looking for.
15
+ */
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { dirname, join, resolve, sep } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ /** The package whose modules are served into the page. */
20
+ const CORE = 'scenic-prism';
21
+ /** The peer that carries it, and through which it is found. */
22
+ const FLUENT = 'scenic-prism-fluent';
23
+ /**
24
+ * Walk `node_modules` upwards from a directory, the way Node's own resolver
25
+ * does, and return the root of the first `name` found. Deliberately not
26
+ * `require.resolve`: this package and the ones it looks for are ESM with an
27
+ * `exports` map that has no `require` condition, so CommonJS resolution cannot
28
+ * see them, and `import.meta.resolve` answers with an entry module rather than
29
+ * with the package root and its manifest — which is what has to be served.
30
+ */
31
+ export function findPackageRoot(from, name) {
32
+ let directory = resolve(from);
33
+ for (;;) {
34
+ const candidate = join(directory, 'node_modules', name, 'package.json');
35
+ if (existsSync(candidate))
36
+ return dirname(candidate);
37
+ const parent = dirname(directory);
38
+ if (parent === directory)
39
+ return null;
40
+ directory = parent;
41
+ }
42
+ }
43
+ /**
44
+ * Where the installed `scenic-prism` lives. Looked for beside the fluent
45
+ * package first — that is the copy the caller's scenes are built with, and the
46
+ * one whose version the fluent package pins — and only then beside this one,
47
+ * for an install flat enough to hoist it.
48
+ */
49
+ export function resolveLibraryRoot() {
50
+ const here = dirname(fileURLToPath(import.meta.url));
51
+ const starts = [here];
52
+ try {
53
+ starts.unshift(dirname(fileURLToPath(import.meta.resolve(FLUENT))));
54
+ }
55
+ catch {
56
+ // `scenic-prism-fluent` is a peer dependency, so a project that has this
57
+ // package without it is already broken — but a missing peer should be
58
+ // reported as the missing library below, not as a resolution stack trace.
59
+ }
60
+ for (const start of starts) {
61
+ const root = findPackageRoot(start, CORE);
62
+ if (root)
63
+ return root;
64
+ }
65
+ throw new Error(`scenic-prism-playwright: could not find '${CORE}' to serve to the browser. ` +
66
+ `It comes with '${FLUENT}', which is a peer dependency — install that, ` +
67
+ 'or pass `libraryRoot` pointing at the package root to use.');
68
+ }
69
+ /**
70
+ * The module a browser should import for a package root, as a path relative to
71
+ * it. Read off the manifest the way a bundler would: the `import` condition of
72
+ * the main export first, then the legacy fields.
73
+ */
74
+ export function browserEntry(root) {
75
+ const manifestPath = join(root, 'package.json');
76
+ if (!existsSync(manifestPath)) {
77
+ throw new Error(`scenic-prism-playwright: no package.json in '${root}'.`);
78
+ }
79
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
80
+ const main = manifest.exports?.['.'];
81
+ const conditions = typeof main === 'object' && main !== null ? main : {};
82
+ const entry = [
83
+ typeof main === 'string' ? main : undefined,
84
+ conditions.import,
85
+ conditions.default,
86
+ manifest.module,
87
+ manifest.main,
88
+ ].find((value) => typeof value === 'string');
89
+ if (!entry) {
90
+ throw new Error(`scenic-prism-playwright: '${root}' declares no ES module entry point. ` +
91
+ 'Point `libraryRoot` at an assembled package rather than at a source checkout.');
92
+ }
93
+ const relative = entry.replace(/^\.\//, '');
94
+ if (!existsSync(join(root, relative))) {
95
+ throw new Error(`scenic-prism-playwright: '${join(root, relative)}' does not exist.`);
96
+ }
97
+ return relative;
98
+ }
99
+ /** Content types for the handful of things a package's `dist` holds. */
100
+ export function contentType(path) {
101
+ if (path.endsWith('.js') || path.endsWith('.mjs'))
102
+ return 'text/javascript; charset=utf-8';
103
+ if (path.endsWith('.json') || path.endsWith('.map'))
104
+ return 'application/json; charset=utf-8';
105
+ if (path.endsWith('.css'))
106
+ return 'text/css; charset=utf-8';
107
+ return 'text/plain; charset=utf-8';
108
+ }
109
+ /**
110
+ * Resolve a request path against the served root, refusing anything that
111
+ * climbs out of it. Nothing here is reachable from outside the browser this
112
+ * package launched, but a scene is data and data comes from somewhere, so the
113
+ * one place a path could be influenced is still checked.
114
+ */
115
+ export function resolveServedFile(root, requestPath) {
116
+ const decoded = decodeURIComponent(requestPath);
117
+ const full = resolve(root, `.${decoded.startsWith('/') ? decoded : `/${decoded}`}`);
118
+ const base = resolve(root);
119
+ if (full !== base && !full.startsWith(base + sep))
120
+ return null;
121
+ return full;
122
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The margin: a border of flat colour around a finished image.
3
+ *
4
+ * It is deliberately not part of the render. The page is served, the canvas is
5
+ * sized and the scene is traced exactly as they are without a margin, and the
6
+ * border is added afterwards, here, to the bytes that came back — so a plate
7
+ * rendered at `size: 1200` with a margin holds the *same* 1200² of traced image
8
+ * as one rendered without, sitting in a larger file. Widening the canvas
9
+ * instead would have widened the field of view with it, which is a different
10
+ * picture rather than a mounted one.
11
+ *
12
+ * That is also why this is the one part of the package that does image work of
13
+ * its own: everything else hands pixels to the browser or takes them from it,
14
+ * and a border drawn after the encoding has no browser left to draw it in.
15
+ * `sharp` is the dependency that pays for it.
16
+ */
17
+ import type { Vec3 } from 'scenic-prism-fluent';
18
+ import type { ImageFormat } from './image.js';
19
+ /** The border a render can be mounted in. */
20
+ export interface MarginOptions {
21
+ /**
22
+ * How many pixels to add on each of the four sides. A whole number, and at
23
+ * least one — a margin of nothing is a margin nobody meant to ask for.
24
+ */
25
+ size: number;
26
+ /**
27
+ * What to fill it with, as the `[r, g, b]` triple colours are written as
28
+ * everywhere else in the library. White by default.
29
+ *
30
+ * Unlike a colour in a scene this one is never traced and never tonemapped:
31
+ * it is the colour of the file, so `[1, 1, 1]` is white, `[0, 0, 0]` is
32
+ * black, and `[0.5, 0.5, 0.5]` is the middle of the range the file can hold
33
+ * rather than the middle of a range of light. Components outside 0..1 have
34
+ * nowhere to go, so they are refused rather than clipped quietly.
35
+ */
36
+ colour?: Vec3;
37
+ }
38
+ /** What every generated file gets when no colour is named: paper. */
39
+ export declare const DEFAULT_MARGIN_COLOUR: Vec3;
40
+ /** A checked margin, in the terms the encoder wants: pixels, and 0..255. */
41
+ export interface Margin {
42
+ size: number;
43
+ colour: {
44
+ r: number;
45
+ g: number;
46
+ b: number;
47
+ };
48
+ }
49
+ /**
50
+ * Apply the default and refuse anything that cannot be drawn, before a browser
51
+ * is launched — a margin is checked at the top of a render for the same reason
52
+ * `quality` is, so a mistake in it costs a millisecond rather than the whole
53
+ * trace it would otherwise be found after.
54
+ */
55
+ export declare function resolveMargin(margin: MarginOptions | undefined): Margin | undefined;
56
+ /**
57
+ * Mount an encoded image in its margin, and hand back the encoded result.
58
+ *
59
+ * The border is opaque whatever the image is: a colour is something to stand
60
+ * the plate on, so an `alpha` render comes back as a cut-out on that colour
61
+ * rather than as a cut-out with a transparent frame nobody would see.
62
+ */
63
+ export declare function addMargin(image: Buffer, format: ImageFormat, margin: Margin, quality: number | undefined): Promise<{
64
+ data: Buffer;
65
+ width: number;
66
+ height: number;
67
+ }>;
package/dist/margin.js ADDED
@@ -0,0 +1,84 @@
1
+ import sharp from 'sharp';
2
+ /** What every generated file gets when no colour is named: paper. */
3
+ export const DEFAULT_MARGIN_COLOUR = [1, 1, 1];
4
+ /**
5
+ * The quality each lossy encoder is asked for when the caller named none.
6
+ *
7
+ * A margin means the image is encoded twice — once by the canvas, once here —
8
+ * and the second encode has to be told what the first one did or a `.jpg` would
9
+ * quietly change quality the moment a border was added to it. These are the
10
+ * defaults `canvas.toDataURL()` uses, so an unasked-for margin costs one
11
+ * generation of a lossy codec and nothing else.
12
+ */
13
+ const CANVAS_DEFAULT_QUALITY = {
14
+ png: undefined,
15
+ jpeg: 0.92,
16
+ webp: 0.8,
17
+ };
18
+ /** One colour component, as a byte. */
19
+ function byte(value, index, colour) {
20
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
21
+ throw new Error(`scenic-prism-playwright: \`margin.colour\` components must be between 0 and 1, ` +
22
+ `got ${value} at index ${index} of [${colour.join(', ')}].`);
23
+ }
24
+ return Math.round(value * 255);
25
+ }
26
+ /**
27
+ * Apply the default and refuse anything that cannot be drawn, before a browser
28
+ * is launched — a margin is checked at the top of a render for the same reason
29
+ * `quality` is, so a mistake in it costs a millisecond rather than the whole
30
+ * trace it would otherwise be found after.
31
+ */
32
+ export function resolveMargin(margin) {
33
+ if (margin === undefined)
34
+ return undefined;
35
+ const { size, colour = DEFAULT_MARGIN_COLOUR } = margin;
36
+ if (!Number.isSafeInteger(size) || size < 1) {
37
+ throw new Error(`scenic-prism-playwright: \`margin.size\` must be a whole number of pixels ` +
38
+ `greater than zero, got ${size}.`);
39
+ }
40
+ if (!Array.isArray(colour) || colour.length !== 3) {
41
+ throw new Error(`scenic-prism-playwright: \`margin.colour\` must be an [r, g, b] triple, ` +
42
+ `got ${JSON.stringify(colour)}.`);
43
+ }
44
+ return {
45
+ size,
46
+ colour: {
47
+ r: byte(colour[0], 0, colour),
48
+ g: byte(colour[1], 1, colour),
49
+ b: byte(colour[2], 2, colour),
50
+ },
51
+ };
52
+ }
53
+ /** The encoder settings for a re-encode that must not change the format. */
54
+ function encoderOptions(format, quality) {
55
+ const asked = quality ?? CANVAS_DEFAULT_QUALITY[format];
56
+ // PNG is lossless and `checkQuality` has already refused a quality alongside
57
+ // it, so there is nothing to carry across.
58
+ if (format === 'png' || asked === undefined)
59
+ return {};
60
+ // The canvas takes a fraction and every other encoder takes a percentage;
61
+ // 0 is not reachable, `checkQuality` having excluded it.
62
+ return { quality: Math.max(1, Math.round(asked * 100)) };
63
+ }
64
+ /**
65
+ * Mount an encoded image in its margin, and hand back the encoded result.
66
+ *
67
+ * The border is opaque whatever the image is: a colour is something to stand
68
+ * the plate on, so an `alpha` render comes back as a cut-out on that colour
69
+ * rather than as a cut-out with a transparent frame nobody would see.
70
+ */
71
+ export async function addMargin(image, format, margin, quality) {
72
+ const { size, colour } = margin;
73
+ const { data, info } = await sharp(image)
74
+ .extend({
75
+ top: size,
76
+ bottom: size,
77
+ left: size,
78
+ right: size,
79
+ background: { ...colour, alpha: 1 },
80
+ })
81
+ .toFormat(format, encoderOptions(format, quality))
82
+ .toBuffer({ resolveWithObject: true });
83
+ return { data, width: info.width, height: info.height };
84
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The one-call surface. `renderToFile(scene, 'out.png')` opens a browser,
3
+ * traces the scene, writes the file and shuts everything down again — which is
4
+ * all most scripts ever want, and is exactly `openStudio()`, one render, and
5
+ * `close()`.
6
+ *
7
+ * Reach for {@link openStudio} instead when there is more than one image: the
8
+ * browser launch is the expensive part, and a studio pays it once.
9
+ */
10
+ import type { FluentSceneSpec } from 'scenic-prism-fluent';
11
+ import type { RenderOptions, RenderedImageData, SavedImage, StudioOptions } from './studio.js';
12
+ /** Everything a standalone render takes: the render's options, and the browser's. */
13
+ export interface HeadlessOptions extends RenderOptions, StudioOptions {
14
+ }
15
+ /**
16
+ * Path-trace `spec` and save it to `output`.
17
+ *
18
+ * ```ts
19
+ * import { backgrounds, materials, sphere } from 'scenic-prism-fluent';
20
+ * import { renderToFile } from 'scenic-prism-playwright';
21
+ *
22
+ * await renderToFile(
23
+ * { scene: sphere(1).paint(materials.chrome), background: backgrounds.dusk },
24
+ * 'renders/sphere.png',
25
+ * { size: 1200, maxFrames: 900, seed: 7 },
26
+ * );
27
+ * ```
28
+ *
29
+ * The encoding comes from the file's extension — `.png`, `.jpg`, `.jpeg` or
30
+ * `.webp` — unless `format` says otherwise, and any directories in the path are
31
+ * created. Give `seed` to make the image reproducible; without one the sample
32
+ * sequence is random, so two runs of the same scene differ in their noise. The
33
+ * render takes the machine's GPU where there is one — add `gpu: false` for
34
+ * software rasterisation, which is what makes two *machines* agree as well.
35
+ */
36
+ export declare function renderToFile(spec: FluentSceneSpec, output: string, options?: HeadlessOptions): Promise<SavedImage>;
37
+ /**
38
+ * The same render, handed back as bytes rather than written. `format` defaults
39
+ * to `png` here, there being no filename to read it from.
40
+ */
41
+ export declare function renderToBuffer(spec: FluentSceneSpec, options?: HeadlessOptions): Promise<RenderedImageData>;
package/dist/render.js ADDED
@@ -0,0 +1,42 @@
1
+ import { openStudio } from "./studio.js";
2
+ /** Open a studio, run one job in it, and close it however the job ends. */
3
+ async function once(options, job) {
4
+ const studio = await openStudio(options);
5
+ try {
6
+ return await job(studio);
7
+ }
8
+ finally {
9
+ await studio.close();
10
+ }
11
+ }
12
+ /**
13
+ * Path-trace `spec` and save it to `output`.
14
+ *
15
+ * ```ts
16
+ * import { backgrounds, materials, sphere } from 'scenic-prism-fluent';
17
+ * import { renderToFile } from 'scenic-prism-playwright';
18
+ *
19
+ * await renderToFile(
20
+ * { scene: sphere(1).paint(materials.chrome), background: backgrounds.dusk },
21
+ * 'renders/sphere.png',
22
+ * { size: 1200, maxFrames: 900, seed: 7 },
23
+ * );
24
+ * ```
25
+ *
26
+ * The encoding comes from the file's extension — `.png`, `.jpg`, `.jpeg` or
27
+ * `.webp` — unless `format` says otherwise, and any directories in the path are
28
+ * created. Give `seed` to make the image reproducible; without one the sample
29
+ * sequence is random, so two runs of the same scene differ in their noise. The
30
+ * render takes the machine's GPU where there is one — add `gpu: false` for
31
+ * software rasterisation, which is what makes two *machines* agree as well.
32
+ */
33
+ export async function renderToFile(spec, output, options = {}) {
34
+ return once(options, (studio) => studio.renderToFile(spec, output, options));
35
+ }
36
+ /**
37
+ * The same render, handed back as bytes rather than written. `format` defaults
38
+ * to `png` here, there being no filename to read it from.
39
+ */
40
+ export async function renderToBuffer(spec, options = {}) {
41
+ return once(options, (studio) => studio.renderToBuffer(spec, options));
42
+ }
@@ -0,0 +1,251 @@
1
+ import type { Browser, LaunchOptions } from 'playwright';
2
+ import type { ColorSpace, FluentSceneSpec, SceneSpec, ToneCurve } from 'scenic-prism-fluent';
3
+ import type { ImageFormat } from './image.js';
4
+ import type { MarginOptions } from './margin.js';
5
+ /**
6
+ * How hard to try for the machine's GPU.
7
+ *
8
+ * - `'auto'` (the default) asks for hardware and takes SwiftShader when there
9
+ * is none, so a laptop is fast and a build container still works.
10
+ * - `true` insists on hardware: a machine without it fails rather than
11
+ * quietly spending twenty minutes on what should have taken one.
12
+ * - `false` is software everywhere, which is the reproducible one — the same
13
+ * scene and `seed` then give the same pixels on every machine.
14
+ */
15
+ export type GpuPreference = boolean | 'auto';
16
+ /**
17
+ * Software rendering, asked for explicitly: SwiftShader's WebGPU adapter and
18
+ * nothing else, whatever the machine has. This is what `gpu: false` launches
19
+ * with, and it is the only setting under which two machines agree pixel for
20
+ * pixel — a GPU and a software rasteriser round differently, so the same
21
+ * `seed` on each gives images that look identical and hash differently.
22
+ */
23
+ export declare const SOFTWARE_BROWSER_ARGS: readonly string[];
24
+ /**
25
+ * Hardware and no substitute: what `gpu: true` launches with. Chromium is asked
26
+ * to leave the software fallback out altogether — though that is not what
27
+ * enforces `true`, since a browser is entitled to hand out its fallback adapter
28
+ * anyway. What enforces it is {@link requireHardware}, which asks a page which
29
+ * adapter it would actually trace with.
30
+ */
31
+ export declare const GPU_BROWSER_ARGS: readonly string[];
32
+ /**
33
+ * What a launch takes by default: the hardware switches, and permission to fall
34
+ * back to SwiftShader rather than the instruction not to. A browser with no GPU
35
+ * to offer hands a page SwiftShader's adapter by itself, which is why `'auto'`
36
+ * needs no probe of its own — the browser answers the question by starting.
37
+ *
38
+ * Chromium takes the last occurrence of a switch, so anything passed in
39
+ * `launch.args` still overrides what is here.
40
+ */
41
+ export declare const DEFAULT_BROWSER_ARGS: readonly string[];
42
+ /**
43
+ * The launches to try, in order, for a preference — each one falling back to
44
+ * the next only if Chromium refuses to start at all.
45
+ *
46
+ * A caller who named a `channel` or an `executablePath` has said which binary
47
+ * to run, so that one is left alone and there is nothing to fall back to.
48
+ */
49
+ export declare function launchPlan(gpu: GpuPreference, launch?: LaunchOptions): LaunchOptions[];
50
+ /** What a page says about the adapter it traces with. */
51
+ export interface AdapterReport {
52
+ /** Vendor, architecture and whatever else the adapter will say, in a line. */
53
+ renderer: string;
54
+ /** Whether the browser itself calls it a fallback — a software one. */
55
+ fallback: boolean;
56
+ }
57
+ /** Whether a reported adapter is a software one. */
58
+ export declare function isSoftwareAdapter({ renderer, fallback }: AdapterReport): boolean;
59
+ /**
60
+ * How long a single render is given before it is abandoned. Generous on
61
+ * purpose: a thousand samples of a glassy scene at 1024² is minutes of software
62
+ * path tracing, and a timeout that fires on a render that was going to finish
63
+ * is worse than one that fires late.
64
+ */
65
+ export declare const DEFAULT_TIMEOUT = 300000;
66
+ /** The renderer's own knobs, as far as a still image cares about them. */
67
+ export interface TraceOptions {
68
+ /** Square backing resolution in pixels. 1024 by default. */
69
+ size?: number;
70
+ /** Backing-store width in pixels, when the image is not square. */
71
+ width?: number;
72
+ /** Backing-store height in pixels, when the image is not square. */
73
+ height?: number;
74
+ /** Samples per pixel to accumulate before saving. 1200 by default. */
75
+ maxFrames?: number;
76
+ /** Light-bounce budget. 6 by default; glass and metal reward more. */
77
+ bounces?: number;
78
+ /**
79
+ * The curve that brings the traced light into display range: `'aces'` (the
80
+ * default), `'reinhard'` or `'linear'`.
81
+ */
82
+ tone?: ToneCurve;
83
+ /**
84
+ * Exposure in stops, applied before the tone curve — `-1` is half as bright,
85
+ * `+2` four times. What to reach for when a plate comes out too bright, since
86
+ * it moves the whole image rather than the colours in the scene one by one.
87
+ */
88
+ exposure?: number;
89
+ /**
90
+ * Trace onto a transparent background instead of onto the environment, so
91
+ * the image saves with a cut-out rather than a sky behind it. Wants a format
92
+ * that has an alpha channel to save into — `png` or `webp`, not `jpeg`.
93
+ */
94
+ alpha?: boolean;
95
+ /**
96
+ * The colour space to trace in: `'srgb'` (the default) or `'display-p3'`,
97
+ * where the browser has it. A P3 image is saved tagged as one.
98
+ */
99
+ colorSpace?: ColorSpace;
100
+ /** Fix the sample sequence, for a byte-for-byte reproducible image. */
101
+ seed?: number;
102
+ }
103
+ /** Everything a single render takes, on top of the scene itself. */
104
+ export interface RenderOptions extends TraceOptions {
105
+ /** Encoding to save. Taken from the output file's extension by default. */
106
+ format?: ImageFormat;
107
+ /** Encoder quality in (0, 1], for `jpeg` and `webp`. */
108
+ quality?: number;
109
+ /**
110
+ * A border of flat colour around the finished image — `{ size }` in pixels,
111
+ * with an optional `colour` that is white if left out.
112
+ *
113
+ * It is added after the trace rather than before it: the page is served and
114
+ * the canvas is sized exactly as they would be without one, so the scene
115
+ * comes out identical and lands in a larger file. A margin on a `jpeg` or a
116
+ * `webp` costs one further generation of the encoder, at whatever `quality`
117
+ * the render was saved at.
118
+ */
119
+ margin?: MarginOptions;
120
+ /** Milliseconds before the render is abandoned. 300000 by default. */
121
+ timeout?: number;
122
+ /** Called in this process after every accumulated frame. */
123
+ onProgress?: (frames: number, total: number) => void;
124
+ }
125
+ /** What a finished render reports about itself. */
126
+ export interface RenderedImage {
127
+ /** The encoding actually written. */
128
+ format: ImageFormat;
129
+ /** Width of the saved image in pixels, including any `margin`. */
130
+ width: number;
131
+ /** Height of the saved image in pixels, including any `margin`. */
132
+ height: number;
133
+ /** Samples per pixel that were accumulated. */
134
+ frames: number;
135
+ /** Size of the encoded image in bytes. */
136
+ bytes: number;
137
+ /**
138
+ * What WebGPU reported as the adapter behind the trace — a graphics card's
139
+ * vendor and architecture (`apple metal-3`), or a software one
140
+ * (`google swiftshader`). Empty if the browser would not say.
141
+ */
142
+ renderer: string;
143
+ /** Whether that adapter was hardware. False for SwiftShader, and if unknown. */
144
+ accelerated: boolean;
145
+ /** The colour space the image was traced and saved in. */
146
+ colorSpace: ColorSpace;
147
+ }
148
+ /** A render that went to memory. */
149
+ export interface RenderedImageData extends RenderedImage {
150
+ /** The encoded image. */
151
+ data: Buffer;
152
+ }
153
+ /** A render that went to disk. */
154
+ export interface SavedImage extends RenderedImage {
155
+ /** The absolute path written. */
156
+ path: string;
157
+ }
158
+ /** How a studio is opened. */
159
+ export interface StudioOptions {
160
+ /**
161
+ * An already-running browser to draw in. Given one, the studio never launches
162
+ * or closes anything — useful inside a Playwright test that already has a
163
+ * browser, and the seam this package's own tests drive it through.
164
+ */
165
+ browser?: Browser;
166
+ /**
167
+ * Whether to render on the machine's GPU. `'auto'` by default: hardware when
168
+ * this machine has some, SwiftShader when it does not. Pass `false` for
169
+ * software everywhere, which is what makes an image reproducible across
170
+ * machines, or `true` to fail rather than fall back.
171
+ */
172
+ gpu?: GpuPreference;
173
+ /** Passed to `chromium.launch()`, on top of {@link DEFAULT_BROWSER_ARGS}. */
174
+ launch?: LaunchOptions;
175
+ /**
176
+ * The `scenic-prism` package root to serve to the page. Found beside the
177
+ * installed `scenic-prism-fluent` by default, which is the copy the scenes
178
+ * handed in were built with.
179
+ */
180
+ libraryRoot?: string;
181
+ /** Default timeout in milliseconds for the renders in this studio. */
182
+ timeout?: number;
183
+ }
184
+ /** A browser held open across several renders. */
185
+ export interface Studio {
186
+ /** Trace a scene and write it to `output`, creating directories as needed. */
187
+ renderToFile(spec: FluentSceneSpec, output: string, options?: RenderOptions): Promise<SavedImage>;
188
+ /** Trace a scene and return the encoded bytes. `format` defaults to png. */
189
+ renderToBuffer(spec: FluentSceneSpec, options?: RenderOptions): Promise<RenderedImageData>;
190
+ /** Close the browser, unless it was handed in. */
191
+ close(): Promise<void>;
192
+ }
193
+ /** What the page is asked to do, as plain data — it crosses a process. */
194
+ export interface TraceJob {
195
+ spec: SceneSpec;
196
+ trace: TraceOptions;
197
+ mimeType: string;
198
+ quality?: number;
199
+ reportProgress: boolean;
200
+ /** The globals the traced function reads, since it can close over nothing. */
201
+ library: string;
202
+ binding: string;
203
+ }
204
+ /** What it answers with. */
205
+ export interface TraceResult {
206
+ dataUrl: string;
207
+ width: number;
208
+ height: number;
209
+ frames: number;
210
+ /** What WebGPU said drew it, so this process can report what it got. */
211
+ adapter: AdapterReport;
212
+ colorSpace: ColorSpace;
213
+ }
214
+ /**
215
+ * The whole of the in-page work, and the only code here that runs in the
216
+ * browser. It takes the library the document loaded, traces until the
217
+ * accumulation finishes on its own, and reads the finished image back.
218
+ *
219
+ * Read back from the render rather than from the canvas: a WebGPU canvas does
220
+ * not keep what was drawn into it once that has been shown, so the render is
221
+ * started with `retain` and asked for its pixels, which are then encoded
222
+ * through a 2D canvas — the one thing in a browser that turns pixels into a
223
+ * PNG, a JPEG or a WebP. The render itself goes to an `OffscreenCanvas`, which
224
+ * the library draws into exactly as it would a canvas on a page.
225
+ *
226
+ * This function is sent across as source, so it can close over nothing: every
227
+ * value it needs, including the names of the two globals, arrives in `job`.
228
+ *
229
+ * Exported for `studio.test.ts` alone — `index.ts` does not re-export it, so it
230
+ * is no part of the package's surface. Being sent across as source is exactly
231
+ * why it is worth calling directly: nothing else here would catch a mistake in
232
+ * it before a browser did.
233
+ */
234
+ export declare function traceInPage(job: TraceJob): Promise<TraceResult>;
235
+ /**
236
+ * Which adapter a page would trace with, asked before anything is traced. Sent
237
+ * across as source like {@link traceInPage}, so it closes over nothing and
238
+ * reads no globals. `null` is a page with no WebGPU adapter at all.
239
+ *
240
+ * Exported for the tests on the same terms as {@link traceInPage}.
241
+ */
242
+ export declare function probeAdapterInPage(): Promise<AdapterReport | null>;
243
+ /**
244
+ * Open a browser and keep it open. Every scene rendered through the returned
245
+ * studio shares it, which is most of the cost of the first image and all of the
246
+ * cost of the rest. Close it when the batch is done.
247
+ *
248
+ * The browser is asked for the machine's GPU unless `gpu` says otherwise, and
249
+ * takes SwiftShader wherever there is none to have. See {@link GpuPreference}.
250
+ */
251
+ export declare function openStudio(options?: StudioOptions): Promise<Studio>;