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
9 changes: 9 additions & 0 deletions .changeset/harden-avatar-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"posecode-parser": minor
"posecode-render": minor
"posecode-embed": minor
---

Add an optional avatar selector separate from humanoid rig topology, safely hot-swap document-selected characters with procedural fallback, and add hosted avatar defaults.

Keep the renderer peer range compatible with the parser's additive language/IR update.
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,52 @@ The hosted playground currently uses an Adobe Mixamo character and one showcase

The renderer also includes a zero-asset procedural figure and accepts compatible humanoid GLB characters through `characterUrl`.

### Multiple character appearances (`avatar avatar1` / `avatar2` / `avatar3`)

All built-in characters use the same `rig humanoid` skeleton topology. An
optional `avatar` directive selects appearance without redefining that rig (see
[`spec/SPEC.md`](spec/SPEC.md)). Pass `characterUrls` (selector → GLB URL map)
to `createViewer` instead of a single `characterUrl`; `ir.avatar` is used when
present and `ir.rig` supplies the default selector otherwise. Switching
documents, or editing the `avatar` directive, swaps the visible character. A
selector with no entry in the map (or any load failure) falls back to the
procedural figure. See
[`packages/posecode-render/README.md`](packages/posecode-render/README.md#usage)
for the option, and `packages/posecode-embed`'s `character` attribute docs for
the same behavior in the web component (absent by default; set an explicit URL
to pin one character regardless of `avatar`).

### Bringing your own character rig

Pass a `characterUrl` (fixed) or `characterUrls` (per-selector, see above) pointing
to a skinned GLB to replace the bundled Mixamo character. Requirements:

- **Format:** glTF binary (`.glb`) containing a `THREE.SkinnedMesh`.
- **Rest pose:** T-pose.
- **Bone naming:** Mixamo convention. Names may carry the `mixamorig:` /
`mixamorigN:` namespace prefix — it's stripped automatically. These bones
must all be present:
- Torso/head: `Hips`, `Spine`, `Spine2`, `Neck`, `Head`
- Arms: `LeftArm`, `LeftForeArm`, `LeftHand`, `RightArm`, `RightForeArm`, `RightHand`
- Legs: `LeftUpLeg`, `LeftLeg`, `LeftFoot`, `RightUpLeg`, `RightLeg`, `RightFoot`
- Fingers (first phalanx only): `LeftHandThumb1`, `LeftHandIndex1`,
`LeftHandMiddle1`, `LeftHandRing1`, `LeftHandPinky1`, and the
`RightHand*1` equivalents

If any required bone is missing, loading the character rejects and the
viewer silently falls back to the zero-asset procedural figure — a bad rig
never breaks the scene.

The simplest way to source a compatible rig is [mixamo.com](https://www.mixamo.com):
export a character in T-pose with "skin with skeleton," then convert
FBX → GLB (e.g. with Blender's glTF exporter or `FBX2glTF`). Bone names come
out Mixamo-compatible automatically.

The bone map and retarget/calibration logic live in
[`packages/posecode-render/src/character.ts`](packages/posecode-render/src/character.ts).
Supporting a different naming convention (e.g. VRM humanoid bones) means
editing the `BONE_MAP` table and `plainName()` prefix-stripping there.

---

## Licensing
Expand Down
2 changes: 1 addition & 1 deletion docs/legal/LEGAL_REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ This is an engineering audit checklist, not legal advice. Resolve these points w

## Third-party materials

- Recheck the current Adobe Mixamo terms for redistribution of `xbot.glb` and `jumping-jacks.fbx`, especially for npm, source archives, hosted demos, white-label offerings, and customer redistribution.
- Recheck the current Adobe Mixamo terms for redistribution of `xbot.glb`, `avatar2.glb`, `avatar3.glb`, and `jumping-jacks.fbx`, especially for npm, source archives, hosted demos, white-label offerings, and customer redistribution.
- Replace the remaining Mixamo-derived files with owned or clearly redistributable assets if the intended distribution is not covered.
- Run a dependency and source-provenance audit for every release artifact, including bundled JavaScript, model files, fonts, images, and generated content. Preserve all required notices.
6 changes: 6 additions & 0 deletions docs/legal/THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ Posecode npm packages use dependencies under permissive licenses including MIT,
The following committed files originate from Adobe Mixamo character or animation exports and are governed by the applicable Adobe terms, not Apache-2.0 or AGPL-3.0:

- `playground/public/models/xbot.glb`
- `playground/public/models/avatar2.glb` (Mixamo "Beta" character)
- `playground/public/models/avatar3.glb` (Mixamo "Remy" character)
- `playground/public/clips/jumping-jacks.fbx`

Provenance for the two avatar characters is established by their glTF contents:
both carry the `mixamorig:` skeleton, a `mixamo.com` animation track, and Mixamo
mesh and texture names (`Beta_Joints`/`Beta_Surface` and `Remy_Body_*`).

The software licenses in this repository do not grant independent rights to extract, redistribute, or relicense these assets. Review the current Adobe Mixamo terms before redistribution. Posecode also provides a code-generated procedural figure that does not require these assets.

`playground/public/models/character.glb`, an older unused Mixamo-derived character, was removed before the 0.3.0 release. The hosted embed default now uses the same active XBot asset as the playground.
4 changes: 2 additions & 2 deletions editors/vscode/syntaxes/posecode.tmLanguage.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"keywords": {
"name": "keyword.control.posecode",
"match": "\\b(posecode|rig|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b"
"match": "\\b(posecode|rig|avatar|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b"
},
"kinds": {
"name": "storage.type.posecode",
Expand All @@ -45,7 +45,7 @@
},
"constants": {
"name": "constant.language.posecode",
"match": "\\b(flow|settle|drive|snap|linear|ease-in-out|ease-in|ease-out|neutral|standing|plank|hands|feet|humanoid)\\b"
"match": "\\b(flow|settle|drive|snap|linear|ease-in-out|ease-in|ease-out|neutral|standing|plank|hands|feet|humanoid|avatar1|avatar2|avatar3)\\b"
},
"numbers": {
"name": "constant.numeric.posecode",
Expand Down
34 changes: 17 additions & 17 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion packages/posecode-embed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ definePosecodePlayer(); // idempotent
| `controls` | `true` | Show the play/pause bar. |
| `autorotate` | `true` | Slowly orbit the camera when idle. |
| `speed` | `1` | Playback multiplier (`0.1`–`4`). |
| `character` | *(hosted default)* | Realistic figure: a GLB URL (Mixamo rig), or `off` for the procedural mannequin. Load failures fall back to the mannequin. |
| `character` | *(document-driven)* | Realistic figure. Absent: optional `avatar avatar1|avatar2|avatar3` selects a hosted appearance; documents without it use the humanoid XBot default. Set to a GLB URL to pin one character regardless of `avatar`, or `off` for the procedural mannequin. Load failures fall back to the mannequin. |
| `playground` | `https://posecode.org/play` | Base URL for the "Edit ↗" link. |

Boolean attributes accept `false` / `0` / `no` / `off` to turn them off, so
Expand Down
6 changes: 5 additions & 1 deletion packages/posecode-embed/src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,11 @@ export class PosecodePlayerElement extends HTMLElement {
const { createViewer } = await import("posecode-render");
const viewer = createViewer(this.#canvas, {
autoRotate: opts.autoRotate && !reduceMotion,
...(opts.characterUrl ? { characterUrl: opts.characterUrl } : {}),
...(opts.characterDisabled
? {}
: opts.characterUrl
? { characterUrl: opts.characterUrl }
: { characterUrls: opts.characterUrls }),
});
this.#viewer = viewer;
viewer.onPhase(({ phaseName }) => {
Expand Down
45 changes: 32 additions & 13 deletions packages/posecode-embed/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,43 @@ export interface PlayerOptions {
/** Playback speed multiplier (0.1–4). */
speed: number;
/**
* Realistic skinned figure: a GLB URL, the default hosted character when
* absent, or `""` (attribute `character="off"`) for the procedural figure.
* Load failures fall back to the procedural figure, so an offline page
* degrades instead of blanking.
* Realistic skinned figure pinned to one GLB URL, from an explicit
* `character="<url>"` attribute. `""` when the attribute is absent (the host
* picks the character from `characterUrls` instead) or the character is
* disabled. Load failures fall back to the procedural figure, so an offline
* page degrades instead of blanking.
*/
characterUrl: string;
/** True when `character="off"` (or another falsey word) explicitly disables any skinned character. */
characterDisabled: boolean;
/**
* Document selector (`avatar` when present, otherwise `rig`) → GLB URL,
* applied when `characterUrl` is unset and the character isn't disabled.
* Defaults to the hosted character choices and the humanoid default.
*/
characterUrls: Record<string, string>;
}

/** The character the hosted playground uses, served from the same origin. */
export const DEFAULT_CHARACTER_URL = "https://posecode.org/models/xbot.glb";

/** Hosted character per built-in selector. Avatar1 intentionally reuses XBot. */
export const DEFAULT_CHARACTER_URLS: Record<string, string> = {
humanoid: DEFAULT_CHARACTER_URL,
avatar1: DEFAULT_CHARACTER_URL,
avatar2: "https://posecode.org/models/avatar2.glb",
avatar3: "https://posecode.org/models/avatar3.glb",
};

export const DEFAULT_OPTIONS: PlayerOptions = {
autoplay: true,
loop: true,
controls: true,
autoRotate: true,
speed: 1,
characterUrl: DEFAULT_CHARACTER_URL,
characterUrl: "",
characterDisabled: false,
characterUrls: DEFAULT_CHARACTER_URLS,
};

const SPEED_MIN = 0.1;
Expand Down Expand Up @@ -66,15 +85,13 @@ function clamp(n: number, lo: number, hi: number): number {

export function parseOptions(attrs: RawAttributes): PlayerOptions {
const speedRaw = attrs.speed != null ? Number(attrs.speed) : NaN;
// `character` accepts a GLB URL, a falsey word to opt out, or absent for
// the hosted default.
// `character` accepts a GLB URL (pinned regardless of the document's rig),
// a falsey word to disable any skinned character, or absent to let the
// document's optional `avatar` directive pick from characterUrls.
const characterRaw = attrs.character?.trim();
const characterUrl =
characterRaw === undefined || characterRaw === null
? DEFAULT_OPTIONS.characterUrl
: FALSEY.has(characterRaw.toLowerCase())
? ""
: characterRaw;
const characterDisabled =
characterRaw !== undefined && characterRaw !== null && FALSEY.has(characterRaw.toLowerCase());
const characterUrl = characterRaw && !characterDisabled ? characterRaw : "";
return {
autoplay: boolAttr(attrs.autoplay, DEFAULT_OPTIONS.autoplay),
loop: boolAttr(attrs.loop, DEFAULT_OPTIONS.loop),
Expand All @@ -84,5 +101,7 @@ export function parseOptions(attrs: RawAttributes): PlayerOptions {
? clamp(speedRaw, SPEED_MIN, SPEED_MAX)
: DEFAULT_OPTIONS.speed,
characterUrl,
characterDisabled,
characterUrls: DEFAULT_OPTIONS.characterUrls,
};
}
2 changes: 1 addition & 1 deletion packages/posecode-embed/test/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ describe("embed compatibility contract", () => {
readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8"),
) as { version: string };
expect(version).toBe(pkg.version);
expect(languageVersion).toBe("0.3");
expect(languageVersion).toBe("0.4");
});
});
25 changes: 24 additions & 1 deletion packages/posecode-embed/test/options.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
import { describe, it, expect } from "vitest";
import { parseOptions, DEFAULT_CHARACTER_URL, DEFAULT_OPTIONS } from "../src/options.js";
import {
parseOptions,
DEFAULT_CHARACTER_URL,
DEFAULT_CHARACTER_URLS,
DEFAULT_OPTIONS,
} from "../src/options.js";

describe("parseOptions", () => {
it("returns sensible defaults for an element with no attributes", () => {
expect(parseOptions({})).toEqual(DEFAULT_OPTIONS);
expect(DEFAULT_CHARACTER_URL).toBe("https://posecode.org/models/xbot.glb");
// No explicit `character` attribute: document-driven, not pinned to one URL.
expect(DEFAULT_OPTIONS.characterUrl).toBe("");
expect(DEFAULT_OPTIONS.characterDisabled).toBe(false);
expect(DEFAULT_OPTIONS.characterUrls).toBe(DEFAULT_CHARACTER_URLS);
expect(DEFAULT_CHARACTER_URLS.humanoid).toBe(DEFAULT_CHARACTER_URL);
expect(DEFAULT_CHARACTER_URLS.avatar1).toBe(DEFAULT_CHARACTER_URL);
});

it("pins an explicit character URL and disables document-driven selection", () => {
const o = parseOptions({ character: "https://example.com/me.glb" });
expect(o.characterUrl).toBe("https://example.com/me.glb");
expect(o.characterDisabled).toBe(false);
});

it("disables the character entirely on a falsey word", () => {
const o = parseOptions({ character: "off" });
expect(o.characterUrl).toBe("");
expect(o.characterDisabled).toBe(true);
});

it("treats boolean attributes as present-means-true", () => {
Expand Down
Loading