@motionscript/headless 0.0.0-stage → 0.1.0-alpha.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/index.js ADDED
@@ -0,0 +1,27 @@
1
+ // @motionscript/headless — render Motion Script projects from Node.
2
+ //
3
+ // It drives the **browser** engine in a headless page. That is not an
4
+ // implementation detail: the in-process CanvasKit rasterizer this replaced could
5
+ // not draw `Canvas3D` at all and threw on any audio, so a server export and a
6
+ // user's preview were different pictures with nothing to say so. Here they are
7
+ // the same code.
8
+ //
9
+ // const engine = createServerEngine({ projectRoot: './my-project' });
10
+ // const still = await engine.renderImage({ document, frame: 'last' });
11
+ // const video = await engine.renderVideo({ document });
12
+ //
13
+ // The entry is a **file**, not an object: `src/project.ts` (the path
14
+ // `motionscript.json` declares), exporting the `nodes` and `shaders` its
15
+ // documents name, and optionally a default project document for a request that
16
+ // brings none. Node types are classes, and a class does not survive a process
17
+ // boundary — so the entry is bundled into the page rather than passed to it.
18
+ // Documents are data, and arrive per request.
19
+ export { MotionScriptServerEngine, createServerEngine } from './server-engine.js';
20
+ export { bundlePage } from './page-bundle.js';
21
+ export { EngineError, isEngineError, } from './errors.js';
22
+ export { parseFrameSelector, toFrameSpec, } from './frame.js';
23
+ // Option parsers, for a service validating a request body at the edge: each
24
+ // throws INVALID_OPTION naming the value it refused. The engine itself does not
25
+ // call them.
26
+ export { parseBitrate, parseCodec, parseImageFormat, parseScale, parseSupersample, parseTimeout, } from './validate.js';
27
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,EAAE;AACF,sEAAsE;AACtE,iFAAiF;AACjF,8EAA8E;AAC9E,+EAA+E;AAC/E,iBAAiB;AACjB,EAAE;AACF,0EAA0E;AAC1E,2EAA2E;AAC3E,4DAA4D;AAC5D,EAAE;AACF,qEAAqE;AACrE,yEAAyE;AACzE,+EAA+E;AAC/E,8EAA8E;AAC9E,6EAA6E;AAC7E,8CAA8C;AAE9C,OAAO,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAGlF,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAK9C,OAAO,EACH,WAAW,EACX,aAAa,GAEhB,MAAM,aAAa,CAAC;AAErB,OAAO,EACH,kBAAkB,EAClB,WAAW,GAId,MAAM,YAAY,CAAC;AAEpB,4EAA4E;AAC5E,gFAAgF;AAChF,aAAa;AACb,OAAO,EACH,YAAY,EACZ,UAAU,EACV,gBAAgB,EAChB,UAAU,EACV,gBAAgB,EAChB,YAAY,GAIf,MAAM,eAAe,CAAC","sourcesContent":["// @motionscript/headless — render Motion Script projects from Node.\n//\n// It drives the **browser** engine in a headless page. That is not an\n// implementation detail: the in-process CanvasKit rasterizer this replaced could\n// not draw `Canvas3D` at all and threw on any audio, so a server export and a\n// user's preview were different pictures with nothing to say so. Here they are\n// the same code.\n//\n// const engine = createServerEngine({ projectRoot: './my-project' });\n// const still = await engine.renderImage({ document, frame: 'last' });\n// const video = await engine.renderVideo({ document });\n//\n// The entry is a **file**, not an object: `src/project.ts` (the path\n// `motionscript.json` declares), exporting the `nodes` and `shaders` its\n// documents name, and optionally a default project document for a request that\n// brings none. Node types are classes, and a class does not survive a process\n// boundary — so the entry is bundled into the page rather than passed to it.\n// Documents are data, and arrive per request.\n\nexport { MotionScriptServerEngine, createServerEngine } from './server-engine.js';\nexport type { ServerEngineOptions, ServerImageOptions, ServerImage } from './server-engine.js';\n\nexport { bundlePage } from './page-bundle.js';\nexport type { BundleOptions, BuiltBundle } from './page-bundle.js';\n\nexport type { Bridge, ScreenshotRequest, ScreenshotResult, ExportRequest } from './page-bridge.js';\n\nexport {\n EngineError,\n isEngineError,\n type EngineErrorCode,\n} from './errors.js';\n\nexport {\n parseFrameSelector,\n toFrameSpec,\n type FrameSelector,\n type FrameSpec,\n type ParsedFrame,\n} from './frame.js';\n\n// Option parsers, for a service validating a request body at the edge: each\n// throws INVALID_OPTION naming the value it refused. The engine itself does not\n// call them.\nexport {\n parseBitrate,\n parseCodec,\n parseImageFormat,\n parseScale,\n parseSupersample,\n parseTimeout,\n type ResolvedImageFormat,\n type VideoCodec,\n type ImageFormat,\n} from './validate.js';\n"]}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The contract between the Node driver and the page it drives.
3
+ *
4
+ * Types only, in a file neither half's tsconfig has to make an exception for:
5
+ * the harness compiles with the DOM lib and no Node types, the driver with Node
6
+ * types and no DOM, and a shared *type* needs neither.
7
+ */
8
+ export interface ScreenshotRequest {
9
+ /**
10
+ * The document to render, as JSON.
11
+ *
12
+ * The usual way in, and the reason the split between this and the entry is
13
+ * worth having: a **document is data**, so it crosses the process boundary
14
+ * freely and a caller can render anything at request time. Only *node types*
15
+ * are code, and code is what the entry is bundled for.
16
+ *
17
+ * Omit to render the entry's own default project.
18
+ */
19
+ document?: unknown;
20
+ frame: number | "first" | "last";
21
+ scale?: number;
22
+ format?: "png" | "jpeg";
23
+ }
24
+ export interface ScreenshotResult {
25
+ /** Encoded bytes, base64 — Playwright cannot round-trip a `Uint8Array`. */
26
+ base64: string;
27
+ frame: number;
28
+ totalFrames: number;
29
+ }
30
+ export interface ExportRequest {
31
+ /** See {@link ScreenshotRequest.document}. */
32
+ document?: unknown;
33
+ scale?: number;
34
+ }
35
+ /** What the page publishes. Checked by capability, never by version number. */
36
+ export interface Bridge {
37
+ projectName: string;
38
+ fps: number;
39
+ /** The default project's track ids, in paint order. */
40
+ listTracks(): string[];
41
+ /**
42
+ * Declare the media a scene may name.
43
+ *
44
+ * Fonts above all: `ManifestAssetCatalog` throws for a `src` it has never
45
+ * heard of, so an undeclared face is never fetched — and unlike Node, where
46
+ * a missing face renders nothing and is obvious, a browser quietly shapes
47
+ * against a fallback. Either way the frame is wrong; only one of them says so.
48
+ */
49
+ setManifest(manifest: unknown): void;
50
+ screenshot(request: ScreenshotRequest): Promise<ScreenshotResult>;
51
+ exportVideo(request: ExportRequest): Promise<string>;
52
+ }
53
+ //# sourceMappingURL=page-bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-bridge.d.ts","sourceRoot":"","sources":["../src/page-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,WAAW,iBAAiB;IAC9B;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC7B,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,aAAa;IAC1B,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,+EAA+E;AAC/E,MAAM,WAAW,MAAM;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,uDAAuD;IACvD,UAAU,IAAI,MAAM,EAAE,CAAC;IACvB;;;;;;;OAOG;IACH,WAAW,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI,CAAC;IACrC,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAClE,WAAW,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACxD"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The contract between the Node driver and the page it drives.
3
+ *
4
+ * Types only, in a file neither half's tsconfig has to make an exception for:
5
+ * the harness compiles with the DOM lib and no Node types, the driver with Node
6
+ * types and no DOM, and a shared *type* needs neither.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=page-bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-bridge.js","sourceRoot":"","sources":["../src/page-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG","sourcesContent":["/**\n * The contract between the Node driver and the page it drives.\n *\n * Types only, in a file neither half's tsconfig has to make an exception for:\n * the harness compiles with the DOM lib and no Node types, the driver with Node\n * types and no DOM, and a shared *type* needs neither.\n */\n\nexport interface ScreenshotRequest {\n /**\n * The document to render, as JSON.\n *\n * The usual way in, and the reason the split between this and the entry is\n * worth having: a **document is data**, so it crosses the process boundary\n * freely and a caller can render anything at request time. Only *node types*\n * are code, and code is what the entry is bundled for.\n *\n * Omit to render the entry's own default project.\n */\n document?: unknown;\n frame: number | \"first\" | \"last\";\n scale?: number;\n format?: \"png\" | \"jpeg\";\n}\n\nexport interface ScreenshotResult {\n /** Encoded bytes, base64 — Playwright cannot round-trip a `Uint8Array`. */\n base64: string;\n frame: number;\n totalFrames: number;\n}\n\nexport interface ExportRequest {\n /** See {@link ScreenshotRequest.document}. */\n document?: unknown;\n scale?: number;\n}\n\n/** What the page publishes. Checked by capability, never by version number. */\nexport interface Bridge {\n projectName: string;\n fps: number;\n /** The default project's track ids, in paint order. */\n listTracks(): string[];\n /**\n * Declare the media a scene may name.\n *\n * Fonts above all: `ManifestAssetCatalog` throws for a `src` it has never\n * heard of, so an undeclared face is never fetched — and unlike Node, where\n * a missing face renders nothing and is obvious, a browser quietly shapes\n * against a fallback. Either way the frame is wrong; only one of them says so.\n */\n setManifest(manifest: unknown): void;\n screenshot(request: ScreenshotRequest): Promise<ScreenshotResult>;\n exportVideo(request: ExportRequest): Promise<string>;\n}\n"]}
@@ -0,0 +1,31 @@
1
+ export interface BundleOptions {
2
+ /** The project entry, e.g. `<root>/src/project.ts`. */
3
+ entry: string;
4
+ /** Where the built bundle is cached. Defaults to `<entry dir>/../node_modules/.cache`. */
5
+ cacheDir?: string;
6
+ }
7
+ export interface BuiltBundle {
8
+ /** The bundled IIFE, ready to be served to the page. */
9
+ code: string;
10
+ /** Absolute paths esbuild resolved. The cache key is derived from these. */
11
+ inputs: string[];
12
+ /** Whether this came from the cache rather than a build. */
13
+ cached: boolean;
14
+ }
15
+ /**
16
+ * Bundle a project entry plus the page harness into one IIFE.
17
+ *
18
+ * **One file, not a module graph.** The previous Playwright driver served the
19
+ * project through a Vite dev server, which meant a dependency-optimizer scan and
20
+ * a per-module HTTP transform before anything drew — and a `warmOptimizer()`
21
+ * whose whole job was to pre-pay that, best-effort, and which still raced. A
22
+ * static bundle has none of it: the `three` and `mediabunny` dynamic imports that
23
+ * scan existed to discover are simply inlined.
24
+ *
25
+ * The entry is imported by path rather than having its exports passed in, because
26
+ * node types are **classes** — code, not JSON — and nothing survives a process
27
+ * boundary as a class. This is the one place the browser and server factories
28
+ * genuinely cannot share a signature.
29
+ */
30
+ export declare function bundlePage(options: BundleOptions): Promise<BuiltBundle>;
31
+ //# sourceMappingURL=page-bundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-bundle.d.ts","sourceRoot":"","sources":["../src/page-bundle.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,aAAa;IAC1B,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IACxB,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,4DAA4D;IAC5D,MAAM,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,WAAW,CAAC,CAiC7E"}
@@ -0,0 +1,142 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import * as esbuild from 'esbuild';
6
+ import { EngineError } from './errors.js';
7
+ /**
8
+ * Bundle a project entry plus the page harness into one IIFE.
9
+ *
10
+ * **One file, not a module graph.** The previous Playwright driver served the
11
+ * project through a Vite dev server, which meant a dependency-optimizer scan and
12
+ * a per-module HTTP transform before anything drew — and a `warmOptimizer()`
13
+ * whose whole job was to pre-pay that, best-effort, and which still raced. A
14
+ * static bundle has none of it: the `three` and `mediabunny` dynamic imports that
15
+ * scan existed to discover are simply inlined.
16
+ *
17
+ * The entry is imported by path rather than having its exports passed in, because
18
+ * node types are **classes** — code, not JSON — and nothing survives a process
19
+ * boundary as a class. This is the one place the browser and server factories
20
+ * genuinely cannot share a signature.
21
+ */
22
+ export async function bundlePage(options) {
23
+ const entry = path.resolve(options.entry);
24
+ if (!fs.existsSync(entry)) {
25
+ throw new EngineError('PROJECT_NOT_FOUND', `No project entry at ${entry}. \`createServerEngine\` renders a project ` +
26
+ `entry — the file \`motionscript.json\` declares as "project".`);
27
+ }
28
+ const cacheDir = options.cacheDir
29
+ ?? path.join(path.dirname(entry), '..', 'node_modules', '.cache', 'motionscript');
30
+ // Built once with `metafile` so the cache key can be derived from the exact
31
+ // files esbuild resolved, rather than from a guess at the dependency graph.
32
+ const result = await build(entry);
33
+ const inputs = Object.keys(result.metafile?.inputs ?? {})
34
+ .map(p => path.resolve(p))
35
+ .sort();
36
+ const key = cacheKey(inputs);
37
+ const cachePath = path.join(cacheDir, `${key}.js`);
38
+ if (fs.existsSync(cachePath)) {
39
+ return { code: fs.readFileSync(cachePath, 'utf8'), inputs, cached: true };
40
+ }
41
+ const code = result.outputFiles?.[0]?.text;
42
+ if (!code) {
43
+ throw new EngineError('START_FAILED', 'esbuild produced no output for the page bundle.');
44
+ }
45
+ fs.mkdirSync(cacheDir, { recursive: true });
46
+ fs.writeFileSync(cachePath, code);
47
+ return { code, inputs, cached: false };
48
+ }
49
+ /**
50
+ * Node built-ins CanvasKit's emscripten wrapper names but never reaches.
51
+ *
52
+ * `canvaskit.js` is a UMD build carrying both halves: a browser path and a Node
53
+ * path that `require("fs")`/`require("path")` to read the wasm off disk. Only one
54
+ * runs — the Node half is behind a `typeof process` guard — but a bundler must
55
+ * still resolve both, and in a browser target there is nothing to resolve them
56
+ * to. Stubbing with an empty module is what a browser bundler's `browser` field
57
+ * does for the same file; esbuild has no such convention for a UMD module, so it
58
+ * is written out here.
59
+ */
60
+ const NODE_BUILTIN_STUBS = ['fs', 'path', 'crypto', 'os', 'module', 'worker_threads'];
61
+ function stubNodeBuiltins() {
62
+ const filter = new RegExp(`^(node:)?(${NODE_BUILTIN_STUBS.join('|')})$`);
63
+ return {
64
+ name: 'motion-script:stub-node-builtins',
65
+ setup(build) {
66
+ build.onResolve({ filter }, (args) => ({ path: args.path, namespace: 'ms-stub' }));
67
+ build.onLoad({ filter: /.*/, namespace: 'ms-stub' }, () => ({
68
+ contents: 'export default {}; export const promises = {};',
69
+ loader: 'js',
70
+ }));
71
+ },
72
+ };
73
+ }
74
+ function build(entry) {
75
+ // A synthetic entry via `stdin`, so nothing is written next to the user's
76
+ // source. `resolveDir` is what lets its relative import of the project work.
77
+ return esbuild.build({
78
+ stdin: {
79
+ contents: [
80
+ `import * as project from ${JSON.stringify(entry)};`,
81
+ `import { installBridge } from ${JSON.stringify(harnessPath())};`,
82
+ `installBridge(project).catch((err) => {`,
83
+ ` document.documentElement.setAttribute('data-motion-script', 'failed');`,
84
+ ` document.documentElement.setAttribute('data-motion-script-error', String(err && err.stack || err));`,
85
+ `});`,
86
+ ].join('\n'),
87
+ resolveDir: path.dirname(entry),
88
+ loader: 'ts',
89
+ },
90
+ bundle: true,
91
+ write: false,
92
+ format: 'iife',
93
+ platform: 'browser',
94
+ target: 'chrome120',
95
+ metafile: true,
96
+ // The wasm is served separately rather than inlined: it is ~7MB, and a
97
+ // base64 data URI would be a third larger again on every page load.
98
+ external: ['*.wasm'],
99
+ loader: { '.json': 'json' },
100
+ plugins: [stubNodeBuiltins()],
101
+ logLevel: 'silent',
102
+ }).catch((err) => {
103
+ throw new EngineError('START_FAILED', `Could not bundle the project entry: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
104
+ });
105
+ }
106
+ /**
107
+ * Where the harness lives, as an absolute path esbuild can resolve.
108
+ *
109
+ * Always the `.ts` source: the harness is browser code and is excluded from this
110
+ * package's Node build (see `tsconfig.page.json`), so there is no `.js` beside
111
+ * it — esbuild compiles it as part of the bundle.
112
+ */
113
+ function harnessPath() {
114
+ const here = path.dirname(fileURLToPath(import.meta.url));
115
+ const source = path.resolve(here, '..', 'src', 'page', 'harness.ts');
116
+ return fs.existsSync(source) ? source : path.join(here, 'harness.ts');
117
+ }
118
+ /**
119
+ * A key over the resolved inputs' identity, not their content.
120
+ *
121
+ * `mtime` + `size` rather than a hash of every byte: the graph is thousands of
122
+ * files, hashing them costs more than the build it is meant to skip, and a
123
+ * source edit changes both.
124
+ */
125
+ function cacheKey(inputs) {
126
+ const hash = crypto.createHash('sha256');
127
+ for (const file of inputs) {
128
+ hash.update(file);
129
+ try {
130
+ const stat = fs.statSync(file);
131
+ hash.update(String(stat.mtimeMs));
132
+ hash.update(String(stat.size));
133
+ }
134
+ catch {
135
+ // A file esbuild resolved but that has since gone is itself a
136
+ // difference worth keying on.
137
+ hash.update('missing');
138
+ }
139
+ }
140
+ return hash.digest('hex').slice(0, 16);
141
+ }
142
+ //# sourceMappingURL=page-bundle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-bundle.js","sourceRoot":"","sources":["../src/page-bundle.ts"],"names":[],"mappings":"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","sourcesContent":["import crypto from 'node:crypto';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport * as esbuild from 'esbuild';\nimport { EngineError } from './errors.js';\n\nexport interface BundleOptions {\n /** The project entry, e.g. `<root>/src/project.ts`. */\n entry: string;\n /** Where the built bundle is cached. Defaults to `<entry dir>/../node_modules/.cache`. */\n cacheDir?: string;\n}\n\nexport interface BuiltBundle {\n /** The bundled IIFE, ready to be served to the page. */\n code: string;\n /** Absolute paths esbuild resolved. The cache key is derived from these. */\n inputs: string[];\n /** Whether this came from the cache rather than a build. */\n cached: boolean;\n}\n\n/**\n * Bundle a project entry plus the page harness into one IIFE.\n *\n * **One file, not a module graph.** The previous Playwright driver served the\n * project through a Vite dev server, which meant a dependency-optimizer scan and\n * a per-module HTTP transform before anything drew — and a `warmOptimizer()`\n * whose whole job was to pre-pay that, best-effort, and which still raced. A\n * static bundle has none of it: the `three` and `mediabunny` dynamic imports that\n * scan existed to discover are simply inlined.\n *\n * The entry is imported by path rather than having its exports passed in, because\n * node types are **classes** — code, not JSON — and nothing survives a process\n * boundary as a class. This is the one place the browser and server factories\n * genuinely cannot share a signature.\n */\nexport async function bundlePage(options: BundleOptions): Promise<BuiltBundle> {\n const entry = path.resolve(options.entry);\n if (!fs.existsSync(entry)) {\n throw new EngineError(\n 'PROJECT_NOT_FOUND',\n `No project entry at ${entry}. \\`createServerEngine\\` renders a project ` +\n `entry — the file \\`motionscript.json\\` declares as \"project\".`,\n );\n }\n\n const cacheDir = options.cacheDir\n ?? path.join(path.dirname(entry), '..', 'node_modules', '.cache', 'motionscript');\n\n // Built once with `metafile` so the cache key can be derived from the exact\n // files esbuild resolved, rather than from a guess at the dependency graph.\n const result = await build(entry);\n const inputs = Object.keys(result.metafile?.inputs ?? {})\n .map(p => path.resolve(p))\n .sort();\n const key = cacheKey(inputs);\n const cachePath = path.join(cacheDir, `${key}.js`);\n\n if (fs.existsSync(cachePath)) {\n return { code: fs.readFileSync(cachePath, 'utf8'), inputs, cached: true };\n }\n\n const code = result.outputFiles?.[0]?.text;\n if (!code) {\n throw new EngineError('START_FAILED', 'esbuild produced no output for the page bundle.');\n }\n fs.mkdirSync(cacheDir, { recursive: true });\n fs.writeFileSync(cachePath, code);\n return { code, inputs, cached: false };\n}\n\n/**\n * Node built-ins CanvasKit's emscripten wrapper names but never reaches.\n *\n * `canvaskit.js` is a UMD build carrying both halves: a browser path and a Node\n * path that `require(\"fs\")`/`require(\"path\")` to read the wasm off disk. Only one\n * runs — the Node half is behind a `typeof process` guard — but a bundler must\n * still resolve both, and in a browser target there is nothing to resolve them\n * to. Stubbing with an empty module is what a browser bundler's `browser` field\n * does for the same file; esbuild has no such convention for a UMD module, so it\n * is written out here.\n */\nconst NODE_BUILTIN_STUBS = ['fs', 'path', 'crypto', 'os', 'module', 'worker_threads'];\n\nfunction stubNodeBuiltins(): esbuild.Plugin {\n const filter = new RegExp(`^(node:)?(${NODE_BUILTIN_STUBS.join('|')})$`);\n return {\n name: 'motion-script:stub-node-builtins',\n setup(build) {\n build.onResolve({ filter }, (args) => ({ path: args.path, namespace: 'ms-stub' }));\n build.onLoad({ filter: /.*/, namespace: 'ms-stub' }, () => ({\n contents: 'export default {}; export const promises = {};',\n loader: 'js',\n }));\n },\n };\n}\n\nfunction build(entry: string): Promise<esbuild.BuildResult> {\n // A synthetic entry via `stdin`, so nothing is written next to the user's\n // source. `resolveDir` is what lets its relative import of the project work.\n return esbuild.build({\n stdin: {\n contents: [\n `import * as project from ${JSON.stringify(entry)};`,\n `import { installBridge } from ${JSON.stringify(harnessPath())};`,\n `installBridge(project).catch((err) => {`,\n ` document.documentElement.setAttribute('data-motion-script', 'failed');`,\n ` document.documentElement.setAttribute('data-motion-script-error', String(err && err.stack || err));`,\n `});`,\n ].join('\\n'),\n resolveDir: path.dirname(entry),\n loader: 'ts',\n },\n bundle: true,\n write: false,\n format: 'iife',\n platform: 'browser',\n target: 'chrome120',\n metafile: true,\n // The wasm is served separately rather than inlined: it is ~7MB, and a\n // base64 data URI would be a third larger again on every page load.\n external: ['*.wasm'],\n loader: { '.json': 'json' },\n plugins: [stubNodeBuiltins()],\n logLevel: 'silent',\n }).catch((err: unknown) => {\n throw new EngineError(\n 'START_FAILED',\n `Could not bundle the project entry: ${err instanceof Error ? err.message : String(err)}`,\n { cause: err },\n );\n });\n}\n\n/**\n * Where the harness lives, as an absolute path esbuild can resolve.\n *\n * Always the `.ts` source: the harness is browser code and is excluded from this\n * package's Node build (see `tsconfig.page.json`), so there is no `.js` beside\n * it — esbuild compiles it as part of the bundle.\n */\nfunction harnessPath(): string {\n const here = path.dirname(fileURLToPath(import.meta.url));\n const source = path.resolve(here, '..', 'src', 'page', 'harness.ts');\n return fs.existsSync(source) ? source : path.join(here, 'harness.ts');\n}\n\n/**\n * A key over the resolved inputs' identity, not their content.\n *\n * `mtime` + `size` rather than a hash of every byte: the graph is thousands of\n * files, hashing them costs more than the build it is meant to skip, and a\n * source edit changes both.\n */\nfunction cacheKey(inputs: string[]): string {\n const hash = crypto.createHash('sha256');\n for (const file of inputs) {\n hash.update(file);\n try {\n const stat = fs.statSync(file);\n hash.update(String(stat.mtimeMs));\n hash.update(String(stat.size));\n } catch {\n // A file esbuild resolved but that has since gone is itself a\n // difference worth keying on.\n hash.update('missing');\n }\n }\n return hash.digest('hex').slice(0, 16);\n}\n"]}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A counting semaphore with a FIFO queue and abortable waits.
3
+ *
4
+ * This is what bounds the engine's concurrency. It is a separate primitive
5
+ * from the session pool because the two answer different questions — "may
6
+ * another job run?" and "which page does it run on?" — and only the first one
7
+ * has interesting edge cases (queue order, cancelling from the queue, closing
8
+ * with jobs still waiting).
9
+ */
10
+ export declare class Semaphore {
11
+ private available;
12
+ private readonly waiters;
13
+ constructor(permits: number);
14
+ /** Jobs currently queued for a permit. */
15
+ get pending(): number;
16
+ /** Permits not currently held. */
17
+ get free(): number;
18
+ /** Take a permit, waiting in line if none is free. Always pair with {@link release}. */
19
+ acquire(signal?: AbortSignal): Promise<void>;
20
+ /**
21
+ * Give a permit back — to the longest-waiting job if there is one, rather
22
+ * than to the counter. Handing it over directly is what keeps the queue
23
+ * FIFO instead of letting a caller that arrives later barge in.
24
+ */
25
+ release(): void;
26
+ /** Reject everything still queued, e.g. because the engine is shutting down. */
27
+ drain(err: unknown): void;
28
+ }
29
+ //# sourceMappingURL=semaphore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semaphore.d.ts","sourceRoot":"","sources":["../src/semaphore.ts"],"names":[],"mappings":"AAQA;;;;;;;;GAQG;AACH,qBAAa,SAAS;IAClB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgB;gBAE5B,OAAO,EAAE,MAAM;IAI3B,0CAA0C;IAC1C,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,kCAAkC;IAClC,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,wFAAwF;IACxF,OAAO,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAyB5C;;;;OAIG;IACH,OAAO,IAAI,IAAI;IAUf,gFAAgF;IAChF,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI;CAO5B"}
@@ -0,0 +1,74 @@
1
+ import { EngineError } from './errors.js';
2
+ /**
3
+ * A counting semaphore with a FIFO queue and abortable waits.
4
+ *
5
+ * This is what bounds the engine's concurrency. It is a separate primitive
6
+ * from the session pool because the two answer different questions — "may
7
+ * another job run?" and "which page does it run on?" — and only the first one
8
+ * has interesting edge cases (queue order, cancelling from the queue, closing
9
+ * with jobs still waiting).
10
+ */
11
+ export class Semaphore {
12
+ available;
13
+ waiters = [];
14
+ constructor(permits) {
15
+ this.available = Math.max(1, Math.floor(permits));
16
+ }
17
+ /** Jobs currently queued for a permit. */
18
+ get pending() {
19
+ return this.waiters.length;
20
+ }
21
+ /** Permits not currently held. */
22
+ get free() {
23
+ return this.available;
24
+ }
25
+ /** Take a permit, waiting in line if none is free. Always pair with {@link release}. */
26
+ acquire(signal) {
27
+ if (signal?.aborted) {
28
+ return Promise.reject(new EngineError('ABORTED', 'The job was aborted.', { cause: signal.reason }));
29
+ }
30
+ if (this.available > 0) {
31
+ this.available -= 1;
32
+ return Promise.resolve();
33
+ }
34
+ return new Promise((resolve, reject) => {
35
+ const onAbort = () => {
36
+ const index = this.waiters.indexOf(waiter);
37
+ if (index >= 0)
38
+ this.waiters.splice(index, 1);
39
+ waiter.dispose();
40
+ reject(new EngineError('ABORTED', 'The job was aborted.', { cause: signal?.reason }));
41
+ };
42
+ const waiter = {
43
+ resolve,
44
+ reject,
45
+ dispose: () => signal?.removeEventListener('abort', onAbort),
46
+ };
47
+ signal?.addEventListener('abort', onAbort, { once: true });
48
+ this.waiters.push(waiter);
49
+ });
50
+ }
51
+ /**
52
+ * Give a permit back — to the longest-waiting job if there is one, rather
53
+ * than to the counter. Handing it over directly is what keeps the queue
54
+ * FIFO instead of letting a caller that arrives later barge in.
55
+ */
56
+ release() {
57
+ const waiter = this.waiters.shift();
58
+ if (waiter) {
59
+ waiter.dispose();
60
+ waiter.resolve();
61
+ return;
62
+ }
63
+ this.available += 1;
64
+ }
65
+ /** Reject everything still queued, e.g. because the engine is shutting down. */
66
+ drain(err) {
67
+ while (this.waiters.length > 0) {
68
+ const waiter = this.waiters.shift();
69
+ waiter.dispose();
70
+ waiter.reject(err);
71
+ }
72
+ }
73
+ }
74
+ //# sourceMappingURL=semaphore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semaphore.js","sourceRoot":"","sources":["../src/semaphore.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAQ1C;;;;;;;;GAQG;AACH,MAAM,OAAO,SAAS;IACV,SAAS,CAAS;IACT,OAAO,GAAa,EAAE,CAAC;IAExC,YAAY,OAAe;QACvB,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;IACtD,CAAC;IAED,0CAA0C;IAC1C,IAAI,OAAO;QACP,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC;IAC/B,CAAC;IAED,kCAAkC;IAClC,IAAI,IAAI;QACJ,OAAO,IAAI,CAAC,SAAS,CAAC;IAC1B,CAAC;IAED,wFAAwF;IACxF,OAAO,CAAC,MAAoB;QACxB,IAAI,MAAM,EAAE,OAAO,EAAE,CAAC;YAClB,OAAO,OAAO,CAAC,MAAM,CAAC,IAAI,WAAW,CAAC,SAAS,EAAE,sBAAsB,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACxG,CAAC;QACD,IAAI,IAAI,CAAC,SAAS,GAAG,CAAC,EAAE,CAAC;YACrB,IAAI,CAAC,SAAS,IAAI,CAAC,CAAC;YACpB,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;QAC7B,CAAC;QACD,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YACzC,MAAM,OAAO,GAAG,GAAS,EAAE;gBACvB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;gBAC3C,IAAI,KAAK,IAAI,CAAC;oBAAE,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;gBAC9C,MAAM,CAAC,OAAO,EAAE,CAAC;gBACjB,MAAM,CAAC,IAAI,WAAW,CAAC,SAAS,EAAE,sBAAsB,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;YAC1F,CAAC,CAAC;YACF,MAAM,MAAM,GAAW;gBACnB,OAAO;gBACP,MAAM;gBACN,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC;aAC/D,CAAC;YACF,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YAC3D,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC9B,CAAC,CAAC,CAAC;IACP,CAAC;IAED;;;;OAIG;IACH,OAAO;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;QACpC,IAAI,MAAM,EAAE,CAAC;YACT,MAAM,CAAC,OAAO,EAAE,CAAC;YACjB,MAAM,CAAC,OAAO,EAAE,CAAC;YACjB,OAAO;QACX,CAAC;QACD,IAAI,CAAC,SAAS,IAAI,CAAC,CAAC;IACxB,CAAC;IAED,gFAAgF;IAChF,KAAK,CAAC,GAAY;QACd,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC7B,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAG,CAAC;YACrC,MAAM,CAAC,OAAO,EAAE,CAAC;YACjB,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACvB,CAAC;IACL,CAAC;CACJ","sourcesContent":["import { EngineError } from './errors.js';\n\ntype Waiter = {\n resolve(): void;\n reject(err: unknown): void;\n dispose(): void;\n};\n\n/**\n * A counting semaphore with a FIFO queue and abortable waits.\n *\n * This is what bounds the engine's concurrency. It is a separate primitive\n * from the session pool because the two answer different questions — \"may\n * another job run?\" and \"which page does it run on?\" — and only the first one\n * has interesting edge cases (queue order, cancelling from the queue, closing\n * with jobs still waiting).\n */\nexport class Semaphore {\n private available: number;\n private readonly waiters: Waiter[] = [];\n\n constructor(permits: number) {\n this.available = Math.max(1, Math.floor(permits));\n }\n\n /** Jobs currently queued for a permit. */\n get pending(): number {\n return this.waiters.length;\n }\n\n /** Permits not currently held. */\n get free(): number {\n return this.available;\n }\n\n /** Take a permit, waiting in line if none is free. Always pair with {@link release}. */\n acquire(signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(new EngineError('ABORTED', 'The job was aborted.', { cause: signal.reason }));\n }\n if (this.available > 0) {\n this.available -= 1;\n return Promise.resolve();\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = (): void => {\n const index = this.waiters.indexOf(waiter);\n if (index >= 0) this.waiters.splice(index, 1);\n waiter.dispose();\n reject(new EngineError('ABORTED', 'The job was aborted.', { cause: signal?.reason }));\n };\n const waiter: Waiter = {\n resolve,\n reject,\n dispose: () => signal?.removeEventListener('abort', onAbort),\n };\n signal?.addEventListener('abort', onAbort, { once: true });\n this.waiters.push(waiter);\n });\n }\n\n /**\n * Give a permit back — to the longest-waiting job if there is one, rather\n * than to the counter. Handing it over directly is what keeps the queue\n * FIFO instead of letting a caller that arrives later barge in.\n */\n release(): void {\n const waiter = this.waiters.shift();\n if (waiter) {\n waiter.dispose();\n waiter.resolve();\n return;\n }\n this.available += 1;\n }\n\n /** Reject everything still queued, e.g. because the engine is shutting down. */\n drain(err: unknown): void {\n while (this.waiters.length > 0) {\n const waiter = this.waiters.shift()!;\n waiter.dispose();\n waiter.reject(err);\n }\n }\n}\n"]}
@@ -0,0 +1,117 @@
1
+ import type { ExportRequest } from './page-bridge.js';
2
+ export interface ServerEngineOptions {
3
+ /** The project entry to render. Defaults to `<projectRoot>/src/project.ts`. */
4
+ entry?: string;
5
+ /** Project root, used to find the entry and the assets directory. */
6
+ projectRoot?: string;
7
+ /** Directory served at the origin root, for the assets a scene names. */
8
+ assetsDir?: string;
9
+ /**
10
+ * Force software rasterization. Set for a deterministic pixel-diff; leave off
11
+ * for speed.
12
+ *
13
+ * A GPU frame and a SwiftShader frame **do not match**, so anything comparing
14
+ * renders must fix this and record which mode produced a baseline. Also
15
+ * honoured from `MS_SOFTWARE_RENDER=1`.
16
+ */
17
+ softwareRender?: boolean;
18
+ /** Where the built page bundle is cached. */
19
+ cacheDir?: string;
20
+ /**
21
+ * Asset metadata the page's catalog resolves `src` values against.
22
+ *
23
+ * Required for fonts: an undeclared family is never fetched, and the browser
24
+ * shapes against a fallback rather than failing.
25
+ */
26
+ manifest?: unknown;
27
+ /** Extra files to serve at the origin, by path. Overrides `assetsDir`. */
28
+ serve?: Record<string, string>;
29
+ /**
30
+ * Called for every warning and error the page logs.
31
+ *
32
+ * The reason this exists rather than the driver just writing them to stderr:
33
+ * a browser's failures are mostly **soft**. An undeclared font shapes against
34
+ * a fallback, an undeclared image draws nothing — each a warning and a wrong
35
+ * frame, where the Node path would have thrown. A harness that compares
36
+ * pixels has to be able to turn those back into failures, and only it knows
37
+ * which ones matter.
38
+ */
39
+ onPageMessage?: (message: {
40
+ type: string;
41
+ text: string;
42
+ }) => void;
43
+ }
44
+ export interface ServerImageOptions {
45
+ /** The document to render, as JSON. See {@link ScreenshotRequest.document}. */
46
+ document?: unknown;
47
+ frame?: number | 'first' | 'last';
48
+ scale?: number;
49
+ format?: 'png' | 'jpeg';
50
+ }
51
+ export interface ServerImage {
52
+ bytes: Uint8Array;
53
+ frame: number;
54
+ totalFrames: number;
55
+ }
56
+ /**
57
+ * Renders a project from Node by driving the **browser** engine in a headless
58
+ * page.
59
+ *
60
+ * The point is parity, not portability: `Canvas3D` draws nothing under the
61
+ * in-process CPU path and `loadAudio` throws there, so a server export and a
62
+ * user's preview are currently different pictures. Here they are the same code.
63
+ *
64
+ * ### It is an RPC client, not a `core.Engine`
65
+ *
66
+ * The five platform seams live *inside the page*; Node holds a proxy. So this
67
+ * deliberately does not extend `Engine` — it exposes the same operations with the
68
+ * same names, and every one of them is async because each is a round trip.
69
+ * Unifying the two behind one interface is worth doing and is not done here.
70
+ *
71
+ * ### Bytes cross base64-encoded
72
+ *
73
+ * Playwright cannot round-trip a `Uint8Array` through `evaluate`, so a frame
74
+ * comes back a third larger than it is. Fine per still; sized before trusting it
75
+ * for a long video.
76
+ */
77
+ export declare class MotionScriptServerEngine {
78
+ private readonly options;
79
+ private browser;
80
+ private page;
81
+ private starting;
82
+ private closed;
83
+ constructor(options?: ServerEngineOptions);
84
+ get started(): boolean;
85
+ /** Bundle the project, launch the browser, and wait for the bridge. Idempotent. */
86
+ start(): Promise<void>;
87
+ /** The default project's track ids, in paint order. */
88
+ listTracks(): Promise<string[]>;
89
+ projectName(): Promise<string>;
90
+ fps(): Promise<number>;
91
+ /** Render one frame and bring back its encoded bytes. */
92
+ renderImage(options?: ServerImageOptions): Promise<ServerImage>;
93
+ /** Render the timeline to an encoded video. */
94
+ renderVideo(options?: ExportRequest & {
95
+ scale?: number;
96
+ }): Promise<Uint8Array>;
97
+ close(): Promise<void>;
98
+ private ready;
99
+ private boot;
100
+ /**
101
+ * Chromium flags, and why each is load-bearing.
102
+ *
103
+ * `--headless=new` with `headless: false`: Playwright's `headless: true`
104
+ * selects the *old* headless mode, which always falls back to SwiftShader —
105
+ * so an export there is far slower than the same render in a real browser.
106
+ *
107
+ * The GPU trio is not about speed. `packages/browser/vitest.config.ts` documents
108
+ * that without them headless Chromium has no GPU process to encode video
109
+ * with, and the failure is disguised: `VideoEncoder.isConfigSupported` still
110
+ * answers `supported: true`, then the first real `encode()` takes the whole
111
+ * page down with an error nowhere near the cause.
112
+ */
113
+ private launchOptions;
114
+ }
115
+ /** Build a server engine. Call `start()` (or any render) to boot it. */
116
+ export declare function createServerEngine(options?: ServerEngineOptions): MotionScriptServerEngine;
117
+ //# sourceMappingURL=server-engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-engine.d.ts","sourceRoot":"","sources":["../src/server-engine.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAU,aAAa,EAAqB,MAAM,kBAAkB,CAAC;AAyBjF,MAAM,WAAW,mBAAmB;IAChC,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACrE;AAED,MAAM,WAAW,kBAAkB;IAC/B,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,UAAU,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,wBAAwB;IAMrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IALpC,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,IAAI,CAAqB;IACjC,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,MAAM,CAAS;gBAEM,OAAO,GAAE,mBAAwB;IAE9D,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,mFAAmF;IACnF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAUtB,uDAAuD;IACjD,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAK/B,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC;IAK9B,GAAG,IAAI,OAAO,CAAC,MAAM,CAAC;IAK5B,yDAAyD;IACnD,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,WAAW,CAAC;IAmBzE,+CAA+C;IACzC,WAAW,CAAC,OAAO,GAAE,aAAa,GAAG;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC,UAAU,CAAC;IASlF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAQd,KAAK;YAML,IAAI;IAoHlB;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,aAAa;CAQxB;AA+DD,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,wBAAwB,CAE9F"}