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
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,23 @@ echo "ELEVENLABS_API_KEY=..." > .env # read from the environment or ./.env
## Command line

```sh
npx avatarscript make --avatar avatars/ani \
--script examples/hello-ja.avs --tts elevenlabs -o out/hello-ja.mp4
npx avatarscript make --avatar https://raw.githubusercontent.com/receptron/mulmocast-media/main/avatars/ani \
--script example:hello-ja --tts elevenlabs -o out/hello-ja.mp4
```

`--script example:<name>` reads a sample script shipped with the package
([`examples/*.avs`](examples): `hello-ja`, `hello-en`, `subtitles-ja`); `--script` otherwise takes
your own `.avs` or plain `.txt` file.

> **`npx avatarscript` does not work inside a clone of this repository.** npx finds the project
> itself instead of the published package, and a package is never linked into its own
> `node_modules/.bin`, so it fails with `sh: avatarscript: command not found`, even after
> `npm run build`. Run it from any other folder, or in the clone use one of:
>
> - `npm run avatarscript -- make ...`: the CLI from source
> - `node dist/cli.js make ...`: the built CLI, after `npm run build`
> - `npx avatarscript@latest make ...`: the published package, fetched from npm

This writes `out/hello-ja.wav`, `out/hello-ja.score.json` and `out/hello-ja.mp4`. Synthesized
speech is cached per segment in `out/.tts-cache/`, so changing motions or one sentence does not
request the whole script again. `--tts mock` runs the pipeline offline with a buzzing stand-in
Expand All @@ -44,14 +57,9 @@ voice. The steps can also be run separately with `compile` and `render`; run

`--avatar` takes a mesh-avatar-studio project folder (`rig.json` + `built/`) or a folder with an
`avatar.json` manifest, on disk or at an http(s) URL. The repository includes one sample avatar,
[`avatars/ani`](avatars/ani) (not in the npm package). It is also published in
[`avatars/ani`](avatars/ani) (not in the npm package); the URL above is the same avatar, published in
[mulmocast-media](https://github.com/receptron/mulmocast-media/tree/main/avatars), so it works
without a clone:

```sh
npx avatarscript make --avatar https://raw.githubusercontent.com/receptron/mulmocast-media/main/avatars/ani \
--script hello.avs --tts elevenlabs -o out/hello.mp4
```
without a clone.

From a URL, the package's small JSON files are fetched each time and its images are cached in
`~/.cache/avatarscript/avatars/` (or `$AVATARSCRIPT_AVATAR_DIR`), per build of the avatar, so an
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"files": [
"dist",
"README.md",
"LICENSE"
"LICENSE",
"examples/*.avs"
],
"engines": {
"node": ">=22.18"
Expand Down
33 changes: 33 additions & 0 deletions plans/feat-bundled-example-scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Bundled example scripts (`--script example:<name>`)

Issue: #7

## Problem

`npx avatarscript make --avatar avatars/ani --script examples/hello-ja.avs ...` (the README's first
example) fails inside a clone (npx resolves to the project itself, which has no `.bin` entry) and
outside one (the relative paths are not in the npm package). Nothing lets a user try the CLI with
npx alone.

## Change

- `package.json` `files`: add `examples/*.avs`, so the sample scripts ship with the package.
- `src/examples.ts` (pure, no fs): `exampleNames(files)` lists bundled names from a directory
listing; `scriptSource(arg, examplesDir, names)` maps a `--script` value to the file to read —
`example:<name>` → `<examplesDir>/<name>.avs`, anything else unchanged; an unknown name throws
with the list.
- `src/cli.ts`: read the `examples/` folder next to `dist/` (or `src/`), route `--script` through
`scriptSource`, list the examples in `--help`, and record `example:<name>` (not a path into the
npx cache) as the score's script provenance.
- README: the first command-line example becomes npx-only (avatar URL + `example:hello-ja`), followed
by a prominent note that `npx avatarscript` fails inside a clone, even after `npm run build`, and
the three commands that work there instead.

## Not in scope

A default `--avatar`; fetching scripts from URLs (see the issue for why).

## Verification

Unit tests for `src/examples.ts`; `scripts/smoke.sh avatars/ani` extended to compile
`--script example:hello-en` from the installed tarball, so the packaging is checked, not assumed.
8 changes: 7 additions & 1 deletion scripts/smoke.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/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 package and no esbuild beside it,
# and make a video through both the CLI and the library with the offline mock voice.
# and make a video through both the CLI and the library with the offline mock voice, and compile
# a bundled example script (--script example:<name>).
# 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 @@ -27,6 +28,11 @@ fi
printf '[emotion:happy] こんにちは!<nod> Hello from the package.\n' > story.avs
node_modules/.bin/avatarscript make --avatar "$AVATAR" --script story.avs --tts mock \
--size 640x360 -o out/cli.mp4
# compile only: enough to show the bundled script shipped and resolves from the installed package
node_modules/.bin/avatarscript compile --avatar "$AVATAR" --script example:hello-en --tts mock \
-o out/example.score.json
grep -q '"script": "example:hello-en"' out/example.score.json \
|| { echo "bundled example: provenance missing from out/example.score.json"; exit 1; }

cat > lib.mjs <<EOF
import { loadAvatar, parseScript, compile, render, createTts, toWav } from 'avatarscript';
Expand Down
25 changes: 19 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { existsSync, readdirSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { toWav } from "./audio.ts";
import { loadAvatar, type Avatar } from "./avatar.ts";
import { compile, detectLang } from "./compile.ts";
import { EXAMPLE_PREFIX, exampleNames, scriptSource } from "./examples.ts";
import { render } from "./render.ts";
import { parseJson } from "./json.ts";
import { ScoreSchema, type Score } from "./score.ts";
import { parseScript, plainScript, type Script } from "./script.ts";
import { createTts, isTtsProvider, TTS_PROVIDERS, type TextToSpeech } from "./tts/index.ts";

const USAGE = `Usage:
avatarscript make --avatar <dir|url> --script <file.avs|file.txt> -o <video.mp4|.webm|.mov> [options]
avatarscript compile --avatar <dir|url> --script <file.avs|file.txt> -o <name.score.json> [options]
avatarscript make --avatar <dir|url> --script <file.avs|file.txt|example:name> -o <video.mp4|.webm|.mov> [options]
avatarscript compile --avatar <dir|url> --script <file.avs|file.txt|example:name> -o <name.score.json> [options]
avatarscript render --avatar <dir|url> --score <name.score.json> -o <video.mp4> [options]

Speech (make, compile):
Expand All @@ -42,6 +43,16 @@ Video (make, render):
API keys are read from the environment or a .env file.`;

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const examplesDir = join(repoRoot, "examples");

function bundledExamples(): string[] {
try {
return exampleNames(readdirSync(examplesDir));
} catch {
return [];
}
}

for (const file of [resolve(".env"), join(repoRoot, ".env")]) {
if (existsSync(file)) {
process.loadEnvFile(file);
Expand Down Expand Up @@ -102,15 +113,16 @@ async function loadScript(path: string): Promise<Script> {
}

async function compileStep(avatar: Avatar, scorePath: string): Promise<Score> {
const scriptPath = need("script");
const scriptArg = need("script");
const scriptPath = scriptSource(scriptArg, examplesDir, bundledExamples());
const script = await loadScript(scriptPath);
const lang = args.lang ?? script.meta.lang ?? detectLang(script.plain);
const tts = textToSpeech(avatar, join(dirname(scorePath), ".tts-cache"));
const audioPath = scorePath.replace(/(\.score)?\.json$/, "") + ".wav";
const { score, audio } = await compile(script, tts, {
lang,
subtitles: args.subtitles,
scriptName: relative(dirname(scorePath), scriptPath),
scriptName: scriptArg.startsWith(EXAMPLE_PREFIX) ? scriptArg : relative(dirname(scorePath), scriptPath),
baseDir: dirname(resolve(scriptPath)),
audioName: basename(audioPath),
onSegment: (i, n, text) => console.log(`speech ${i + 1}/${n}: ${text.length > 40 ? text.slice(0, 40) + "…" : text}`),
Expand Down Expand Up @@ -158,7 +170,8 @@ async function renderStep(avatar: Avatar, score: Score, scorePath: string, out:
async function main() {
const command = positionals[0];
if (args.help || !command) {
console.log(USAGE);
const examples = bundledExamples();
console.log(examples.length ? `${USAGE}\n\nBundled scripts: ${examples.map((n) => EXAMPLE_PREFIX + n).join(", ")}` : USAGE);
return;
}
const avatar = await loadAvatar(need("avatar"));
Expand Down
24 changes: 24 additions & 0 deletions src/examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { join } from "node:path";

/** `--script example:<name>` reads the sample script `examples/<name>.avs` shipped with the package. */
export const EXAMPLE_PREFIX = "example:";
const EXAMPLE_EXTENSION = ".avs";

/** The bundled example names in a listing of the examples folder. */
export function exampleNames(files: readonly string[]): string[] {
return files
.filter((file) => file.endsWith(EXAMPLE_EXTENSION) && file.length > EXAMPLE_EXTENSION.length)
.map((file) => file.slice(0, -EXAMPLE_EXTENSION.length))
.sort((a, b) => a.localeCompare(b));
}

/** The file a `--script` value refers to: a bundled example's path, or the value itself. */
export function scriptSource(arg: string, examplesDir: string, names: readonly string[]): string {
if (!arg.startsWith(EXAMPLE_PREFIX)) return arg;
const name = arg.slice(EXAMPLE_PREFIX.length);
if (!names.includes(name)) {
const known = names.length ? names.map((n) => EXAMPLE_PREFIX + n).join(", ") : "none are bundled";
throw new Error(`unknown example "${name}" (examples: ${known})`);
}
return join(examplesDir, name + EXAMPLE_EXTENSION);
}
38 changes: 38 additions & 0 deletions tests/examples.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { exampleNames, scriptSource } from "../src/examples.ts";

describe("exampleNames", () => {
it("lists the .avs files by name, sorted, and skips everything else", () => {
const files = ["subtitles-ja.avs", "make-video.ts", "hello-en.avs", "hello-ja.avs", "notes.avs.bak", ".avs"];
expect(exampleNames(files)).toEqual(["hello-en", "hello-ja", "subtitles-ja"]);
});

it("is empty for an empty folder", () => {
expect(exampleNames([])).toEqual([]);
});
});

describe("scriptSource", () => {
const names = ["hello-en", "hello-ja"];

it("maps example:<name> to the bundled file", () => {
expect(scriptSource("example:hello-ja", "/pkg/examples", names)).toBe(join("/pkg/examples", "hello-ja.avs"));
});

it("passes any other value through unchanged", () => {
for (const arg of ["story.avs", "examples/hello-ja.avs", "/abs/notes.txt", "hello-ja", "Example:hello-ja"]) {
expect(scriptSource(arg, "/pkg/examples", names)).toBe(arg);
}
});

it("rejects an unknown, empty or path-like name and lists the bundled ones", () => {
for (const arg of ["example:nope", "example:", "example:../package", "example:hello-ja.avs"]) {
expect(() => scriptSource(arg, "/pkg/examples", names)).toThrow(/examples: example:hello-en, example:hello-ja/);
}
});

it("says when nothing is bundled", () => {
expect(() => scriptSource("example:hello-ja", "/pkg/examples", [])).toThrow(/none are bundled/);
});
});
Loading