From 5ff33026200af16e4f88d95d0b1b14a37c8f90e0 Mon Sep 17 00:00:00 2001 From: snakajima Date: Mon, 12 Oct 2026 15:23:25 -0700 Subject: [PATCH 1/3] Bundle the mesh engine from the mesh-avatar npm package mesh-avatar-studio now publishes its runtime as mesh-avatar on npm. Build it from that development dependency, pinned to exactly 0.1.1, instead of a sibling ../mesh-avatar-studio checkout (whose src/engine/ has moved to packages/runtime/ upstream). The package still ships the engine prebuilt in dist/engine/, so installs need neither mesh-avatar nor esbuild. - src/engine.ts: bundle from the installed package; --engine / AVATARSCRIPT_MESH_ENGINE name a checkout's packages/runtime instead - scripts/build.ts: record package name and version in source.json; license as LICENSE.mesh-avatar - CI: drop the mesh-avatar-studio checkouts and commit pin; check the bundled version against package.json - smoke.sh: fail if mesh-avatar is installed with the package Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 43 ++++++-------------------------- README.md | 21 +++++++++------- package-lock.json | 18 ++++++++++++++ package.json | 1 + scripts/build.ts | 30 +++++++++++++---------- scripts/smoke.sh | 6 ++--- src/cli.ts | 2 +- src/engine.ts | 53 ++++++++++++++++++++++++++-------------- src/render-page.ts | 2 +- src/render.ts | 2 +- 10 files changed, 96 insertions(+), 82 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6d6fc3..7725caf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,13 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -# The mesh avatar engine is bundled from this mesh-avatar-studio commit at build time. Pinned so -# an upstream change cannot silently change what CI builds and ships; move it on purpose, in a -# commit of its own, after rendering the examples with the new engine. +# The mesh avatar engine is bundled at build time from the mesh-avatar development dependency, +# pinned to an exact version in package.json so an upstream release cannot silently change what CI +# builds and ships; move it on purpose, in a commit of its own, after rendering the examples with it. env: # onnxruntime-node's install script fetches its CUDA binaries from NuGet on Linux x64 (hundreds of # MB, per install). Alignment runs on the CPU binaries the package already contains. ONNXRUNTIME_NODE_INSTALL: skip - MESH_AVATAR_STUDIO_REF: 8713e85a00e50f5cdf8fe33294b961d1d6506fc9 # Timeouts are generous: here they also count time spent queued for a runner, and jobs that take # a minute were cancelled after 15 minutes of waiting when runners were busy. @@ -36,28 +35,15 @@ jobs: name: Format, lint, typecheck, build runs-on: ubuntu-latest timeout-minutes: 30 - defaults: - run: - working-directory: avatarscript steps: - # Side by side, as in development: the build bundles the engine from ../mesh-avatar-studio. - # Not nested inside this checkout, where vitest and tsc would pick up its files. - uses: actions/checkout@v6 with: - path: avatarscript - persist-credentials: false - - uses: actions/checkout@v6 - with: - repository: shinshin86/mesh-avatar-studio - ref: ${{ env.MESH_AVATAR_STUDIO_REF }} - path: mesh-avatar-studio persist-credentials: false - uses: actions/setup-node@v6 with: node-version: 22 cache: npm - cache-dependency-path: avatarscript/package-lock.json # PUPPETEER_SKIP_DOWNLOAD: nothing here starts a browser, and Chrome is most of the install. - run: npm ci @@ -80,15 +66,14 @@ jobs: - name: Build run: npm run build - # The build must have bundled the pinned engine, not whatever happened to be checked out. + # The build must have bundled the version package.json pins, not whatever was installed. - name: Check the bundled engine run: | node -e " const s = JSON.parse(require('fs').readFileSync('dist/engine/source.json', 'utf8')); - if (s.commit !== process.env.MESH_AVATAR_STUDIO_REF || s.modified) { - console.error('bundled engine', s); process.exit(1); - } - console.log('bundled engine', s.commit);" + const pinned = JSON.parse(require('fs').readFileSync('package.json', 'utf8')).devDependencies['mesh-avatar']; + if (s.version !== pinned) { console.error('bundled engine', s, 'pinned', pinned); process.exit(1); } + console.log('bundled engine', s.package + '@' + s.version);" # ubuntu + macOS: the unit tests need no browser and no API key (CLAUDE.md), but kuromoji's # dictionary loading and path handling are worth running on both platforms the CLI is used on. @@ -123,34 +108,22 @@ jobs: # Verifies the published package actually works: scripts/smoke.sh packs the exact tarball # (prepack builds dist/ and bundles the engine), installs it into an empty folder with no - # mesh-avatar-studio and no esbuild beside it, and renders videos through the CLI and the + # mesh-avatar package and no esbuild beside it, and renders videos through the CLI and the # library with the offline mock voice, checking codec, length and that the frames are not # blank. Run the same script locally before changing packaging or rendering. package-smoke: name: Package smoke (pack, install, render) runs-on: ubuntu-latest timeout-minutes: 45 - defaults: - run: - working-directory: avatarscript steps: - uses: actions/checkout@v6 with: - path: avatarscript - persist-credentials: false - # Also the source of the sample avatar the smoke renders. - - uses: actions/checkout@v6 - with: - repository: shinshin86/mesh-avatar-studio - ref: ${{ env.MESH_AVATAR_STUDIO_REF }} - path: mesh-avatar-studio persist-credentials: false - uses: actions/setup-node@v6 with: node-version: 22 cache: npm - cache-dependency-path: avatarscript/package-lock.json - name: Install ffmpeg run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg diff --git a/README.md b/README.md index f824246..52fcd2e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ avatar package + text ──► AvatarScript (.avs) ──► compiler + TTS ─ AvatarScript defines a standard avatar package, a script language for directing an avatar's speech, emotion and gestures, and a compiler and renderer that produce the video. The first -runtime is the mesh avatar engine from +runtime is the mesh avatar engine, [mesh-avatar](https://www.npmjs.com/package/mesh-avatar) from [mesh-avatar-studio](https://github.com/shinshin86/mesh-avatar-studio). See [PLAN.md](PLAN.md) for the design and milestones. @@ -238,9 +238,11 @@ a font file always works. ## Develop -Clone [mesh-avatar-studio](https://github.com/shinshin86/mesh-avatar-studio) next to this -repository: running from source (`node src/cli.ts …`) bundles the engine from that checkout -(or from `--engine ` / `AVATARSCRIPT_MESH_ENGINE`). +Running from source (`node src/cli.ts …`) bundles the engine from the +[mesh-avatar](https://www.npmjs.com/package/mesh-avatar) development dependency, pinned to an +exact version in package.json. To try engine changes before they are published, point +`--engine ` / `AVATARSCRIPT_MESH_ENGINE` at a +[mesh-avatar-studio](https://github.com/shinshin86/mesh-avatar-studio) checkout. ```sh npm install @@ -248,19 +250,20 @@ npm run format # Prettier npm run lint # ESLint, including type-aware rules npm test npm run typecheck -npm run build # dist/, including the engine bundled from ../mesh-avatar-studio +npm run build # dist/, including the engine bundled from mesh-avatar scripts/smoke.sh avatars/ani # pack, install, render (as CI does) ``` CI (`.github/workflows/ci.yml`) runs Prettier, ESLint, the typecheck and build, the tests on Ubuntu and macOS, -and the package smoke above, against a pinned mesh-avatar-studio commit. +and the package smoke above. -`npm run build` records the engine's source commit in `dist/engine/source.json`. +`npm run build` records the engine's package version in `dist/engine/source.json`. ## License -[MIT](LICENSE). The bundled mesh avatar engine (`dist/engine/`) is from +[MIT](LICENSE). The bundled mesh avatar engine (`dist/engine/`) is +[mesh-avatar](https://www.npmjs.com/package/mesh-avatar) from [mesh-avatar-studio](https://github.com/shinshin86/mesh-avatar-studio), MIT License, -© Yuki Shindo; its license is included as `dist/engine/LICENSE.mesh-avatar-studio`. The +© Yuki Shindo; its license is included as `dist/engine/LICENSE.mesh-avatar`. The sample avatar `avatars/ani` (illustration and built layers) is © 2026 receptron, under the same MIT License. diff --git a/package-lock.json b/package-lock.json index 2b554a1..decb9d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "eslint-plugin-security": "^4.2.0", "eslint-plugin-sonarjs": "^4.2.2", "globals": "^17.13.0", + "mesh-avatar": "0.1.1", "onnxruntime-node": "^1.30.0", "prettier": "^3.9.9", "typescript": "~6.0.2", @@ -2171,6 +2172,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", @@ -2827,6 +2835,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mesh-avatar": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/mesh-avatar/-/mesh-avatar-0.1.1.tgz", + "integrity": "sha512-RVSizpqYjVX6rbGfdSgwS0/bDcADn5tcDDSyctDi9cPeSdb5cCep8Y2AAzj3WRw258PHDsJ8nTmkREyDLZOF/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fflate": "^0.8.3" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", diff --git a/package.json b/package.json index 29449b5..b99fefe 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "eslint-plugin-security": "^4.2.0", "eslint-plugin-sonarjs": "^4.2.2", "globals": "^17.13.0", + "mesh-avatar": "0.1.1", "onnxruntime-node": "^1.30.0", "prettier": "^3.9.9", "typescript": "~6.0.2", diff --git a/scripts/build.ts b/scripts/build.ts index cc11f0f..9393004 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,24 +1,28 @@ -// npm run build: compiles src/ to dist/ and bundles the mesh avatar engine into -// dist/engine/ with its license, so the package runs without a mesh-avatar-studio checkout. +// npm run build: compiles src/ to dist/ and bundles the mesh avatar engine (the mesh-avatar +// package) into dist/engine/ with its license, so the package runs without it or esbuild. import { execFileSync } from "node:child_process"; -import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { bundleEngine, findEngineRoot } from "../src/engine.ts"; +import { z } from "zod"; +import { parseJson } from "../src/json.ts"; +import { bundleEngine, packageEngine } from "../src/engine.ts"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const dist = join(root, "dist"); await rm(dist, { recursive: true, force: true }); execFileSync("npx", ["tsc", "-p", "tsconfig.build.json"], { cwd: root, stdio: "inherit" }); -const engineRoot = await findEngineRoot(process.argv[2]); -const git = (...args: string[]) => execFileSync("git", ["-C", engineRoot, ...args], { encoding: "utf8" }).trim(); -const commit = git("rev-parse", "HEAD"); -const dirty = git("status", "--porcelain", "--", "src/engine", "src/rig") !== ""; -const source = { repository: "https://github.com/shinshin86/mesh-avatar-studio", commit, modified: dirty }; +const engine = packageEngine(); +const { name, version } = parseJson( + z.looseObject({ name: z.string(), version: z.string() }), + await readFile(join(engine.root, "package.json"), "utf8"), + "mesh-avatar package.json", +); +const source = { package: name, version, repository: "https://github.com/shinshin86/mesh-avatar-studio" }; await mkdir(join(dist, "engine"), { recursive: true }); -const banner = `/*! Mesh avatar engine from mesh-avatar-studio (${source.repository}, ${commit.slice(0, 7)}${dirty ? ", modified" : ""}).\n * MIT License, see LICENSE.mesh-avatar-studio in this folder. */\n`; -await writeFile(join(dist, "engine/mesh-engine.js"), banner + (await bundleEngine(engineRoot))); -await copyFile(join(engineRoot, "LICENSE"), join(dist, "engine/LICENSE.mesh-avatar-studio")); +const banner = `/*! Mesh avatar engine: ${name}@${version} (${source.repository}).\n * MIT License, see LICENSE.mesh-avatar in this folder. */\n`; +await writeFile(join(dist, "engine/mesh-engine.js"), banner + (await bundleEngine(engine))); +await copyFile(join(engine.root, "LICENSE"), join(dist, "engine/LICENSE.mesh-avatar")); await writeFile(join(dist, "engine/source.json"), JSON.stringify(source, null, 2) + "\n"); -console.log(`built dist/ with the mesh engine from ${engineRoot} @ ${commit.slice(0, 7)}${dirty ? " (modified)" : ""}`); +console.log(`built dist/ with the mesh engine ${name}@${version}`); diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 476ed23..62176b8 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Package smoke test: pack the exact tarball (prepack builds dist/ and bundles the engine), -# install it into an empty folder with no mesh-avatar-studio checkout and no esbuild beside it, +# install it into an empty folder with no mesh-avatar package and no esbuild beside it, # and make a video through both the CLI and the library with the offline mock voice. # Then check the video is real: an H.264 + AAC MP4 of the expected length whose frames show # the avatar, not a blank canvas. @@ -20,8 +20,8 @@ npm init -y >/dev/null npm pkg set type=module npm install --no-audit --no-fund "$WORK/$TGZ" >/dev/null -if [ -e node_modules/esbuild ]; then - echo "esbuild was installed with the package; the engine must come prebuilt"; exit 1 +if [ -e node_modules/esbuild ] || [ -e node_modules/mesh-avatar ]; then + echo "esbuild or mesh-avatar was installed with the package; the engine must come prebuilt"; exit 1 fi printf '[emotion:happy] こんにちは! Hello from the package.\n' > story.avs diff --git a/src/cli.ts b/src/cli.ts index a744cff..5a908fd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,7 +37,7 @@ Video (make, render): --seed randomness of blinks and idle motion (default 1) --subtitles draw subtitles (style from the script's subtitle-* front matter) --no-subtitles do not draw subtitles, even if the script turns them on - --engine mesh-avatar-studio checkout (default ../mesh-avatar-studio) + --engine bundle the engine from a mesh-avatar-studio checkout instead API keys are read from the environment or a .env file.`; diff --git a/src/engine.ts b/src/engine.ts index dc951fb..343ee51 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,6 +1,6 @@ -// The mesh avatar engine (from mesh-avatar-studio) as one script for the render page. -// Packages ship it prebuilt in dist/engine/; a source checkout bundles it from a -// mesh-avatar-studio checkout next to this repository. +// The mesh avatar engine (the mesh-avatar package, from mesh-avatar-studio) as one script for the +// render page. Packages ship it prebuilt in dist/engine/; a source checkout bundles it from the +// mesh-avatar development dependency, or from a mesh-avatar-studio checkout when one is named. import { readFile, stat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,6 +8,8 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); /** Where `npm run build` writes the bundled engine (dist/engine/ next to dist/engine.js). */ export const BUNDLED_ENGINE = resolve(here, "engine/mesh-engine.js"); +/** The engine's entry in a mesh-avatar-studio checkout. */ +const CHECKOUT_ENTRY = "packages/runtime/src/index.ts"; const exists = (p: string) => stat(p).then( @@ -15,20 +17,33 @@ const exists = (p: string) => () => false, ); -export async function findEngineRoot(explicit?: string): Promise { - const root = resolve(explicit ?? process.env.AVATARSCRIPT_MESH_ENGINE ?? resolve(here, "../../mesh-avatar-studio")); - const entry = resolve(root, "src/engine/index.ts"); - if (!(await exists(entry))) { - throw new Error(`mesh engine not found at ${entry}. Clone mesh-avatar-studio next to this repository, or pass --engine / set AVATARSCRIPT_MESH_ENGINE.`); - } - return root; +/** Where the engine is bundled from: the installed mesh-avatar package, or a checkout's runtime source. */ +export interface EngineSource { + entry: string; + /** the folder holding the engine's LICENSE */ + root: string; } -/** Bundles the engine from a mesh-avatar-studio checkout (esbuild is a development dependency). */ -export async function bundleEngine(root: string): Promise { +/** The installed mesh-avatar package (a development dependency: packages ship the engine bundled). */ +export function packageEngine(): EngineSource { + // import.meta.resolve: the package exports only an `import` condition + const entry = fileURLToPath(import.meta.resolve("mesh-avatar")); + return { entry, root: resolve(dirname(entry), "..") }; +} + +/** A mesh-avatar-studio checkout, for trying engine changes before they are published. */ +export async function checkoutEngine(root: string): Promise { + const entry = resolve(root, CHECKOUT_ENTRY); + if (!(await exists(entry))) + throw new Error(`mesh engine not found at ${entry}: --engine / AVATARSCRIPT_MESH_ENGINE must name a mesh-avatar-studio checkout.`); + return { entry, root: resolve(root, "packages/runtime") }; +} + +/** Bundles the engine into one script that sets `window.MeshAvatarEngine` (esbuild is a development dependency). */ +export async function bundleEngine(source: EngineSource): Promise { const { build } = await import("esbuild"); const result = await build({ - entryPoints: [resolve(root, "src/engine/index.ts")], + entryPoints: [source.entry], bundle: true, write: false, format: "iife", @@ -40,12 +55,12 @@ export async function bundleEngine(root: string): Promise { } /** - * The engine script. An explicit checkout (option or AVATARSCRIPT_MESH_ENGINE) wins; otherwise - * the bundled copy, otherwise the checkout next to this repository. + * The engine script. A named checkout (option or AVATARSCRIPT_MESH_ENGINE) wins; otherwise the + * bundled copy, otherwise the installed mesh-avatar package. */ export async function loadEngine(explicitRoot?: string): Promise { - if (!explicitRoot && !process.env.AVATARSCRIPT_MESH_ENGINE && (await exists(BUNDLED_ENGINE))) { - return readFile(BUNDLED_ENGINE, "utf8"); - } - return bundleEngine(await findEngineRoot(explicitRoot)); + const root = explicitRoot ?? process.env.AVATARSCRIPT_MESH_ENGINE; + if (root) return bundleEngine(await checkoutEngine(root)); + if (await exists(BUNDLED_ENGINE)) return readFile(BUNDLED_ENGINE, "utf8"); + return bundleEngine(packageEngine()); } diff --git a/src/render-page.ts b/src/render-page.ts index a6311fc..4aad6b4 100644 --- a/src/render-page.ts +++ b/src/render-page.ts @@ -19,7 +19,7 @@ export interface FramePlan { caption: string | null; } -/** The part of the mesh engine's avatar API the renderer drives (mesh-avatar-studio src/engine/index.ts). */ +/** The part of the mesh engine's avatar API the renderer drives (the mesh-avatar package's MeshAvatar). */ interface MeshAvatarApi { readonly motions: { id: string }[]; setEmotion(tag: string, options: { playMotion: boolean }): void; diff --git a/src/render.ts b/src/render.ts index 690a254..b5343c9 100644 --- a/src/render.ts +++ b/src/render.ts @@ -26,7 +26,7 @@ export interface RenderOptions { height?: number; /** background and avatar placement over the score's */ view?: ViewInput; - /** mesh-avatar-studio checkout to bundle the engine from, instead of the bundled copy */ + /** mesh-avatar-studio checkout to bundle the engine from, instead of the bundled mesh-avatar package */ engineRoot?: string; seed?: number; /** mouth shapes lead the sound slightly so the smoothed mouth arrives on time, seconds */ From b168feb9db5223af437aa77b91d76f0792b08ba6 Mon Sep 17 00:00:00 2001 From: snakajima Date: Mon, 12 Oct 2026 15:24:23 -0700 Subject: [PATCH 2/3] yarn.lock: add mesh-avatar 0.1.1 Co-Authored-By: Claude Opus 5.5 --- yarn.lock | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/yarn.lock b/yarn.lock index 7dcca0f..07764be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -820,6 +820,11 @@ fdir@^6.5.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +fflate@^0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc" + integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA== + "file-entry-cache@11.1.5 || >11.1.6 <12": version "11.1.5" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-11.1.5.tgz#c8210eb055de63e68685ccfb6a017e386d4577d0" @@ -1030,6 +1035,13 @@ matcher@^4.0.0: dependencies: escape-string-regexp "^4.0.0" +mesh-avatar@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/mesh-avatar/-/mesh-avatar-0.1.1.tgz#2cf8a3500c61fa2df9484ab8d00b9fb2c875bd71" + integrity sha512-RVSizpqYjVX6rbGfdSgwS0/bDcADn5tcDDSyctDi9cPeSdb5cCep8Y2AAzj3WRw258PHDsJ8nTmkREyDLZOF/Q== + dependencies: + fflate "^0.8.3" + minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5, minimatch@^10.2.6: version "10.2.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef" From ff377ab4f406263063b77308007dbe2db5b78000 Mon Sep 17 00:00:00 2001 From: snakajima Date: Mon, 12 Oct 2026 15:55:55 -0700 Subject: [PATCH 3/3] --engine: also accept a checkout from before the runtime package A mesh-avatar-studio checkout older than packages/runtime keeps the engine at src/engine/index.ts (as at 8713e85, the commit 0.4 bundled). Accept both layouts so an existing --engine / AVATARSCRIPT_MESH_ENGINE keeps working. Found by Codex review. Co-Authored-By: Claude Opus 5.5 --- src/engine.ts | 20 ++++++++++++++------ tests/engine.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 tests/engine.test.ts diff --git a/src/engine.ts b/src/engine.ts index 343ee51..0f07b89 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -8,8 +8,14 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); /** Where `npm run build` writes the bundled engine (dist/engine/ next to dist/engine.js). */ export const BUNDLED_ENGINE = resolve(here, "engine/mesh-engine.js"); -/** The engine's entry in a mesh-avatar-studio checkout. */ -const CHECKOUT_ENTRY = "packages/runtime/src/index.ts"; +/** + * The engine in a mesh-avatar-studio checkout: the runtime package, or the src/engine/ it lived in + * before mesh-avatar was published (as at the commit avatarscript 0.4 bundled). `root` holds its LICENSE. + */ +const CHECKOUT_LAYOUTS = [ + { entry: "packages/runtime/src/index.ts", root: "packages/runtime" }, + { entry: "src/engine/index.ts", root: "." }, +]; const exists = (p: string) => stat(p).then( @@ -33,10 +39,12 @@ export function packageEngine(): EngineSource { /** A mesh-avatar-studio checkout, for trying engine changes before they are published. */ export async function checkoutEngine(root: string): Promise { - const entry = resolve(root, CHECKOUT_ENTRY); - if (!(await exists(entry))) - throw new Error(`mesh engine not found at ${entry}: --engine / AVATARSCRIPT_MESH_ENGINE must name a mesh-avatar-studio checkout.`); - return { entry, root: resolve(root, "packages/runtime") }; + for (const layout of CHECKOUT_LAYOUTS) { + const entry = resolve(root, layout.entry); + if (await exists(entry)) return { entry, root: resolve(root, layout.root) }; + } + const tried = CHECKOUT_LAYOUTS.map((l) => resolve(root, l.entry)).join(" or "); + throw new Error(`mesh engine not found at ${tried}: --engine / AVATARSCRIPT_MESH_ENGINE must name a mesh-avatar-studio checkout.`); } /** Bundles the engine into one script that sets `window.MeshAvatarEngine` (esbuild is a development dependency). */ diff --git a/tests/engine.test.ts b/tests/engine.test.ts new file mode 100644 index 0000000..086e115 --- /dev/null +++ b/tests/engine.test.ts @@ -0,0 +1,38 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { checkoutEngine } from "../src/engine.ts"; + +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); +}); + +/** A fake mesh-avatar-studio checkout with the given files. */ +async function checkout(...files: string[]): Promise { + const root = await mkdtemp(join(tmpdir(), "avatarscript-engine-")); + dirs.push(root); + for (const f of files) { + await mkdir(join(root, f, ".."), { recursive: true }); + await writeFile(join(root, f), ""); + } + return root; +} + +describe("checkoutEngine", () => { + it("bundles the runtime package of a current checkout", async () => { + const root = await checkout("packages/runtime/src/index.ts", "src/engine/index.ts"); + expect(await checkoutEngine(root)).toEqual({ entry: join(root, "packages/runtime/src/index.ts"), root: join(root, "packages/runtime") }); + }); + + it("accepts a checkout from before the runtime package (src/engine/)", async () => { + const root = await checkout("src/engine/index.ts"); + expect(await checkoutEngine(root)).toEqual({ entry: join(root, "src/engine/index.ts"), root }); + }); + + it("names both layouts when neither is there", async () => { + const root = await checkout("README.md"); + await expect(checkoutEngine(root)).rejects.toThrow(/packages\/runtime\/src\/index\.ts or .*src\/engine\/index\.ts/); + }); +});