Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 8 additions & 35 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -238,29 +238,32 @@ 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 <dir>` / `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 <dir>` / `AVATARSCRIPT_MESH_ENGINE` at a
[mesh-avatar-studio](https://github.com/shinshin86/mesh-avatar-studio) checkout.

```sh
npm install
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.
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 17 additions & 13 deletions scripts/build.ts
Original file line number Diff line number Diff line change
@@ -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}`);
6 changes: 3 additions & 3 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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] こんにちは!<nod> Hello from the package.\n' > story.avs
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Video (make, render):
--seed <n> 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 <dir> mesh-avatar-studio checkout (default ../mesh-avatar-studio)
--engine <dir> bundle the engine from a mesh-avatar-studio checkout instead

API keys are read from the environment or a .env file.`;

Expand Down
59 changes: 41 additions & 18 deletions src/engine.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,57 @@
// 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";

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 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(
() => true,
() => false,
);

export async function findEngineRoot(explicit?: string): Promise<string> {
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.`);
/** 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;
}

/** 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<EngineSource> {
for (const layout of CHECKOUT_LAYOUTS) {
const entry = resolve(root, layout.entry);
if (await exists(entry)) return { entry, root: resolve(root, layout.root) };
}
return 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 from a mesh-avatar-studio checkout (esbuild is a development dependency). */
export async function bundleEngine(root: string): Promise<string> {
/** Bundles the engine into one script that sets `window.MeshAvatarEngine` (esbuild is a development dependency). */
export async function bundleEngine(source: EngineSource): Promise<string> {
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",
Expand All @@ -40,12 +63,12 @@ export async function bundleEngine(root: string): Promise<string> {
}

/**
* 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<string> {
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());
}
2 changes: 1 addition & 1 deletion src/render-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading
Loading