# toonstrip documentation
---
# Getting started
> Drop in a script tag and a custom element, render your first strip.
Source: https://toonstrip.danielkimdev.com/docs/getting-started/
toonstrip renders a comic strip from data you supply. There is nothing to generate and no
image pipeline: you write a small JSON document, the page draws it on canvases. This page
gets you from an empty HTML file to a rendered panel, no install and no build step.
## 1. Add the element
`@toonstrip/element` is a custom element with no framework dependency.
[esm.sh](https://esm.sh) resolves it and its dependencies for you, straight from a
`
```
Pin the exact version, same as any other dependency.
## 2. Point it at a pack
The default pack is the fifteen-character Comic Chat roster with three backdrops. unpkg
serves a pack's files (`pack.json`, sprite sheets, backdrops) as-is, with CORS enabled, so
there is nothing to copy anywhere:
```js
strip.packs = ["https://unpkg.com/@toonstrip/pack-comic-chat@0.1.1/"];
```
## 3. Render a strip
Put a `` tag in the page and set its `document` property.
```html
```
(Example strip `firstStrip` — see https://toonstrip.danielkimdev.com/docs/getting-started/ for the rendered version.)
That is the whole embed: a script tag, a pack URL, and a document. Save it as `index.html`
and open it, no server required.
`npm install @toonstrip/element @toonstrip/pack-comic-chat` works too, but a bare
`import "@toonstrip/element"` only resolves in a browser through a bundler or an import
map — a plain `
```
## API
| Member | Type | Meaning |
|---|---|---|
| `packs` | `string[]` property | Base URLs of asset packs, each serving `pack.json` and its files. Searched in order; duplicate ids across packs throw. |
| `document` | `ComicStripDocument` property | The strip. Validated on set. Setting it again re-renders. |
| `columns` | `number` getter | Column count from the last layout pass. |
Setting `document` before `packs` is fine; the element rebuilds on either change and the
last set wins.
Validation errors and unknown ids are reported by replacing the strip with a short red
notice and logging to the console. Nothing throws out of a property setter.
## From a framework
React, Vue, Svelte, and the rest can all render a custom element and set properties on it.
The one thing to remember is that `packs` and `document` are **properties**, not
attributes: set them from a ref after mount, or use your framework's property-binding
syntax (`.document={doc}` in Lit, `prop:document={doc}` in Solid, `:document.prop` in Vue).
For React and Vue specifically, `@toonstrip/react` and `@toonstrip/vue` package exactly
this pattern as a `` component. See [React reference](/docs/react-reference/)
and [Vue reference](/docs/vue-reference/).
## Bundling
The element imports `@toonstrip/core` and `@toonstrip/schema`; any ESM bundler handles it.
The schema is embedded at build time as plain data, and validation runs as ordinary JS
with no `eval`, so a strict Content Security Policy does not need `unsafe-eval`.
## No build step at all
The `install`/`npm`/bundler steps above assume *something* resolves the bare
`import "@toonstrip/element"` specifier. With no bundler and no import map, use
[esm.sh](https://esm.sh) instead, which resolves a package's own dependencies for you:
```html
```
Point `packs` at a CDN too: unpkg serves a pack's static files (`pack.json`, sprite
sheets, backdrops) as-is, with CORS enabled:
```js
strip.packs = ["https://unpkg.com/@toonstrip/pack-comic-chat@0.1.1/"];
```
The full worked example is `examples/vanilla-html/index.html` in the repo: one file, no
`package.json`, opens from `file://` or any static file server. Pin exact versions in
both URLs, same as any other dependency. See the repo's `docs/vanilla-html.md` for the
esm.sh-vs-unpkg reasoning in full.
---
# Astro reference
> Every option of the integration and the component, and the current limitations.
Source: https://toonstrip.danielkimdev.com/docs/astro-reference/
## `toonstrip(options)`
The integration, added to `integrations` in `astro.config.mjs`.
| Option | Type | Meaning |
|---|---|---|
| `packs` | `string[]` | npm specifiers of asset packs to serve. Each is resolved, and its `pack.json`, `characters/`, and `backdrops/` are copied to `public/_toonstrip//` during `astro:config:setup`. Default: none. |
The integration also registers a renderer for ``. The server half emits an
inert `` tag; the client half imports `@toonstrip/element` and sets the
properties. Nothing else touches your Vite config.
## ``
```astro
---
---
```
| Prop | Type | Meaning |
|---|---|---|
| `document` | `ComicStripDocument` | The strip. Any object of the right shape; usually an imported JSON file. |
| `packs` | `string[]` | Pack **names** (the `name` field of each `pack.json`), resolved to `/_toonstrip//`. Default `[]`, which renders nothing useful, so always pass at least one. |
Any Astro client directive works. `client:visible` is the documented default; use
`client:load` above the fold and `client:idle` for a strip near the top that is not the
first thing the reader needs.
## What renders on the server
Only the tag. There is no server-side canvas in the component, so there is no content
without JavaScript beyond the element's `aria-label`s, which are generated client-side too.
For a static image (Open Graph, RSS, a `` fallback) render the same document at
build time with `npx -p @toonstrip/cli toonstrip render strip.json --out strip.png` from `@toonstrip/cli`; it
uses the same layout policy and figure selection as the element.
## Limitations
- Tested against Astro 7. Astro 5 and 6 should work (nothing version-specific is used) but
are not in the test matrix.
- The renderer is hand-written, not `@astrojs/lit`, because the element is a plain
`HTMLElement` with no framework dependency. This is deliberate.
- `packs` in the integration and `packs` in the component take different things
(specifiers vs names). The names come from each pack's `pack.json`.
The `public/_toonstrip/` directory is generated on every build and dev start. Add it to
`.gitignore`.
---
# React reference
> The component's props, and why prop updates don't remount the element.
Source: https://toonstrip.danielkimdev.com/docs/react-reference/
## ``
```tsx
```
| Prop | Type | Meaning |
|---|---|---|
| `document` | `ComicStripDocument` | The strip. Any object of the right shape; usually an imported JSON file. Pushed onto the element whenever it changes. |
| `packs` | `string[]` | Base URLs of asset packs (same shape as `packages/element`'s own `.packs`, not the pack **names** `@toonstrip/astro`'s component takes, since there's no integration step resolving names to URLs here). Default `[]`. |
| `placeholder` | `{ src, width, height }` | An SSR-rendered PNG to show, as a light-DOM ` `, until the live canvas paints. See [Next.js reference](/docs/next-reference/): `@toonstrip/cli`'s `renderPlaceholder` produces this shape. Optional; most apps don't set it directly. |
| `className` / `style` | n/a | Forwarded straight to the underlying `` element. |
The component has no rendering logic of its own: it mounts `` via a ref and
sets `document`/`packs` as DOM properties in a `useEffect`, the same way
`@toonstrip/astro`'s client renderer does.
## Updating without a remount
`` always renders the same host tag in the same position, so React's
reconciliation reuses the existing `` DOM node across re-renders. A new
`document` value updates that node in place rather than tearing it down and rebuilding
it. Verified during M10 by tagging the live element with a scratch property from
devtools' console, then confirming it survived a `document` change.
## Worked example
`examples/react-vite` in the repo is a Vite + React app: a toggle button swaps between
two hand-written documents, and `scripts/copy-pack-assets.mjs` copies
`@toonstrip/pack-comic-chat`'s files into `public/packs/comic-chat/` before `dev`/`build`
(Vite has no integration-hook system the way Astro does, so this is a plain Node script
rather than a config-time hook). Run it locally:
```
pnpm --filter react-vite-example dev
```
## Current limitations
- Tested against React 18. The `peerDependencies` range also allows React 19, but 19
isn't in the test matrix yet.
Importing `@toonstrip/element` no longer throws on the server (it guards `HTMLElement`/
`customElements` access), so this package is safe to render server-side. ``
also carries its own `'use client'` directive, so it's directly usable from a Next.js App
Router Server Component with no extra wrapper. See
[Next.js reference](/docs/next-reference/) for the SSR-placeholder helper
(`@toonstrip/cli`'s `renderPlaceholder`) that goes with it.
Vue is also published. See [Vue reference](/docs/vue-reference/). Svelte isn't yet;
until then, [Plain HTML](/docs/plain-html/)'s "From a framework" section covers using the
custom element directly with any framework's property-binding syntax.
---
# Vue reference
> The component's props, and why prop updates don't remount the element.
Source: https://toonstrip.danielkimdev.com/docs/vue-reference/
## ``
```vue
```
| Prop | Type | Meaning |
|---|---|---|
| `document` | `ComicStripDocument` | The strip. Any object of the right shape; usually an imported JSON file. Pushed onto the element whenever it changes. |
| `packs` | `string[]` | Base URLs of asset packs (same shape as `packages/element`'s own `.packs`, not the pack **names** `@toonstrip/astro`'s component takes, since there's no integration step resolving names to URLs here). Default `[]`. |
| `class` / `style` | n/a | Fall through to the underlying `` element automatically (Vue's default single-root attribute inheritance), not declared as props. |
The component has no rendering logic of its own: it mounts `` via a template
ref and sets `document`/`packs` as DOM properties from a `watch`, the same way
`@toonstrip/astro`'s client renderer does.
## Updating without a remount
`` always renders the same host tag (`h("comic-strip", { ref })`, a render
function rather than an SFC `` for this one tag, which sidesteps needing
`compilerOptions.isCustomElement` just for `comic-strip`), so Vue's patching reuses the
existing `` DOM node across re-renders. A new `document` value updates that
node in place rather than tearing it down and rebuilding it. Verified during M11 by
tagging the live element with a scratch property from devtools' console, then confirming
it survived a `document` change.
## Worked example
`examples/vue-vite` in the repo is a Vite + Vue app: a toggle button swaps between two
hand-written documents, and `scripts/copy-pack-assets.mjs` copies
`@toonstrip/pack-comic-chat`'s files into `public/packs/comic-chat/` before `dev`/`build`
(Vite has no integration-hook system the way Astro does, so this is a plain Node script
rather than a config-time hook, the same script `examples/react-vite` uses). Run it
locally:
```
pnpm --filter vue-vite-example dev
```
## Current limitations
- Tested against Vue 3.5. No Nuxt-specific wrapper yet. `@toonstrip/element`'s module no
longer throws when imported on a server (it guards `HTMLElement`/`customElements`
access, dev plan §13.4), so this package is safe to import in a Nuxt server render pass.
Per [Next.js reference](/docs/next-reference/)'s deprecation of `@toonstrip/next`,
a dedicated Nuxt package probably isn't the answer either: a client-boundary marker
(Nuxt's own equivalent of `'use client'`) belongs directly on this package's component,
and an SSR-placeholder helper already exists on `@toonstrip/cli`.
Svelte isn't published yet. Until then, [Plain HTML](/docs/plain-html/)'s "From a
framework" section covers using the custom element directly with any framework's
property-binding syntax.
---
# Next.js (deprecated)
> behind an App-Router-safe client boundary, plus an SSR placeholder helper, now built into @toonstrip/react and @toonstrip/cli directly.
Source: https://toonstrip.danielkimdev.com/docs/next-reference/
`@toonstrip/next` used to wrap two things Next.js App Router needs: a `'use client'`
boundary and a server-only PNG-placeholder helper. Both now live directly on the
packages that do the work, so the wrapper adds nothing on top of them. Existing installs
keep working (it's a re-export shim, not removed); new code should import from
[`@toonstrip/react`](/docs/react-reference/) and `@toonstrip/cli` instead, as shown below.
## ``
```tsx
// app/page.tsx: a Server Component; no "use client" needed here
```
Same props as [`@toonstrip/react`](/docs/react-reference/)'s ``. The
component itself carries a `'use client'` directive, the boundary React Server
Components require around anything using `useRef`/`useEffect`. You can import it directly
from a Server Component; you don't need to mark your own page `'use client'`.
## SSR placeholder
```tsx
// app/page.tsx
export default async function Page() {
// Dynamic + webpackIgnore, not a static import; see the callout below.
const { renderPlaceholder } = await import(/* webpackIgnore: true */ "@toonstrip/cli");
const placeholder = await renderPlaceholder(doc, { width: 640 });
return ;
}
```
`renderPlaceholder` reuses `@toonstrip/cli`'s headless-canvas render path (the same one
behind [`toonstrip render`](/docs/astro-reference/)) to produce a PNG on the server,
sized in CSS pixels to match the `width` you give it. `` renders that as a
light-DOM ` ` inside ``, present in the raw server
HTML with no client JS, so it shows with JavaScript disabled and in "view source". Once
client JS hydrates and the live canvas is ready, `@toonstrip/element` hides the placeholder
and shows the live strip itself; no code drives that swap directly.
Match the `width` you pass to `renderPlaceholder` to the CSS width your `` is
actually laid out at. A mismatch produces a placeholder sized differently than the live
strip, which shows up as a layout shift on hydration.
`renderPlaceholder` pulls in `@toonstrip/cli`, which loads `@napi-rs/canvas`, a native
Node addon that must never end up in a client bundle. Only call it from a Server
Component or other server-only code.
`@toonstrip/cli`'s native dependency needs two things in your app's own `next.config`:
list `@napi-rs/canvas` and `@toonstrip/cli` under `serverExternalPackages`, and add
`@toonstrip/cli` as your app's own direct dependency (not only a transitive one). Even
with both, a **static** import of `renderPlaceholder` still makes webpack try to bundle
the native binaries when `@toonstrip/cli` is a workspace-symlinked local package (as in
this monorepo's own example), hence the `webpackIgnore`d dynamic `import()` above. See
`examples/next-app`'s `next.config.mjs` and `app/page.tsx` for a working setup.
## Worked example
`examples/next-app` in the repo is an App Router app: a Server Component page renders a
strip with a placeholder, `scripts/copy-pack-assets.mjs` copies
`@toonstrip/pack-comic-chat`'s files into `public/packs/comic-chat/` for the client-side
cast fetch (same script `examples/react-vite` and `examples/vue-vite` use), and
`renderPlaceholder` reads the pack straight from `node_modules` for the server-side render.
Run it locally:
```
pnpm --filter next-app-example build && pnpm --filter next-app-example start
```
## Current limitations
- Tested against Next.js 15 (App Router) and React 18. Pages Router isn't covered.
- No Nuxt-equivalent wrapper yet. See [Vue reference](/docs/vue-reference/)'s note on
that.
`@toonstrip/element`'s module no longer throws when imported on a server. That guard
(dev plan §13.4) is what makes ``'s client boundary safe to import from a
Server Component in the first place.
---
# Schema reference
> Every field of the Comic Strip Document, generated from the published JSON Schema.
Source: https://toonstrip.danielkimdev.com/docs/schema-reference/
The tables below are generated at build time from `@toonstrip/schema`'s `schema.json`
(draft-07, `additionalProperties: false` throughout). Field names, types, and required-ness
come from the schema; the one-line descriptions are maintained on this site.
The TypeScript types are exported from `@toonstrip/schema` as `ComicStripDocument`,
`Panel`, `Body`, `Line`, `Emotion`, `CameraPreset`, and `BalloonKind`. The JSON Schema is
the authoritative machine-checkable version for non-TypeScript producers.
---
# Accessibility
> What the element emits for assistive technology, and how to write lines that read well aloud.
Source: https://toonstrip.danielkimdev.com/docs/accessibility/
A canvas is a bitmap; on its own it says nothing to a screen reader. The element fixes
that per panel, from the document itself, so the same data that draws the strip describes
it.
## What the element emits
Every panel's `` carries `role="img"` and an `aria-label` generated from the panel:
the camera shot, the room title if any, then each line in order with its speaker and
balloon kind. The three panels on the home page read as:
```
establishing shot; caption: toonstrip draws comic strips from data.; dan says "You write the panels as JSON."
medium shot; anna says "And it draws them right here, on a canvas?"; dan says "Live. Resize the page and watch it reflow."
reaction shot; anna thinks "No build step for the art. No image files to keep in sync."
```
Thought balloons read as "thinks", speech as "says", captions as "caption:". A dark panel
is announced as such.
The generator is `describePanel` in `@toonstrip/element`, exported so a site can reuse it
for a visible transcript or a ``.
## Writing lines that read well
- Use the character's display name in the text when it matters; the label uses the id
(`dan`), which is what the document has.
- Put the beat in words. A `reaction` panel with no line is announced only as "reaction
shot"; a caption gives it content.
- Keep punctuation conventional. Screen readers pause on periods and commas, not on
ellipses or dashes.
- Do not encode meaning in emotion alone. `SAD` changes the face, not the label.
## Around the element
- Wrap a strip in `` with a `` when it illustrates surrounding text.
- Do not rely on colour in captions or titles; the art is monochrome anyway.
- Motion: the element animates nothing. Resizes repaint instantly.
## Known gaps
- There is no visible-text fallback when JavaScript is off, because the labels are
generated client-side. Pair a `` image from `toonstrip render` with a written
transcript if that matters for your page.
- Focus: canvases are not focusable and there is nothing to operate, so the strip is
skipped in the tab order, which is correct for an image.
---
# FAQ
> No AI, no game engine, the licence and the trademark, browser support, and server rendering.
Source: https://toonstrip.danielkimdev.com/docs/faq/
## Does toonstrip generate the comic for me?
No, and it never will. toonstrip renders a document you give it. Writing the dialogue,
choosing emotions and cameras, deciding what is funny: that is the author's job, or a
script's, or a language model's if you choose to call one. Whatever produces the JSON, it
lives outside this library. The playground on this site is an editor, not a generator.
## Is there text-to-emotion inference?
No. Comic Chat guessed a mood from the words you typed; toonstrip does not read your text.
You set `emotion` per body. If you want inference, build it in front of toonstrip and
write the result into the document.
## Is this a game engine or a chat client?
No. There is no session, no turn, no event stream. A panel is authored data. toonstrip
was extracted from a project that did have a game behind it, and the game stayed there.
## What is the licence, and can I use the characters commercially?
The code is MIT. The default pack's art is Microsoft Comic Chat art redistributed under
MIT with Microsoft's copyright notice preserved; see the repository's `NOTICE`. MIT permits
commercial use. COMIC CHAT is a Microsoft trademark and this project is not affiliated
with Microsoft, so do not name your product after it. Your own packs carry whatever
licence you put in their `pack.json`.
## Which browsers?
Anything with Canvas 2D, custom elements, `ResizeObserver`, and `IntersectionObserver`:
every current browser. No polyfills are shipped.
## Does it render on the server?
Not in the Astro component: the server emits an inert tag and the browser draws. For a
static PNG (Open Graph images, RSS, `` fallbacks) use the CLI, which runs the
same renderer in Node:
```
npx -p @toonstrip/cli toonstrip render strip.json --out strip.png --width 1200
```
## Can I use it outside Astro?
Yes. The custom element has no framework dependency; see [plain HTML](/docs/plain-html/).
Astro is the first-class wrapper because it is what the author uses. Other wrappers would
be a few lines each.
## How big is it?
The element plus core plus the schema validator is about 36 KB gzipped (measured on the
published builds).
Sprite sheets are fetched per character actually used in a document, so a three-character
strip loads three sheets and one backdrop, not the whole pack.
## Why does Korean (or other non-Latin) text look wrong?
Balloon text is drawn on a canvas in Comic Sans, which has no Hangul, so those glyphs
fall through the font stack: Noto Sans KR first, then the platform's Korean sans face
(Apple SD Gothic Neo, Malgun Gothic). Before `@toonstrip/core` 0.1.7 the stack named no Korean face at all
and Windows landed on Gungseo, a brush script. To get Noto Sans KR everywhere, declare it
on the page (this site uses Astro's fonts API with the `korean` subset); the element
waits for `document.fonts` before painting and repaints if a face arrives late. For
`toonstrip render`, pass `--font NotoSansKR-Regular.ttf`.
## Why is the art black and white?
Because the original Comic Chat art is. A pack can be in colour; nothing in the renderer
assumes monochrome.
## Can an AI agent write documents for me?
toonstrip still won't generate content (see the first question above), but there's an
[agent skill](/docs/agent-skill/) that teaches a coding agent the document shape, the
emotion-wheel angles, and the real character/backdrop ids, so it stops guessing and
validates what it writes before handing it back.
## Where do I report a bug or contribute a character?
[GitHub issues](https://github.com/dkdev24/toonstrip/issues). For a character, read
[asset packs](/docs/asset-packs/) and open a pull request against `pack-example` or your
own pack repository.
---
# Agent skill
> A packaged SKILL.md that teaches an AI coding agent the document shape, so it stops guessing.
Source: https://toonstrip.danielkimdev.com/docs/agent-skill/
toonstrip does not generate strips (see the [FAQ](/docs/faq/)), but if you're asking an
AI coding agent to author a `ComicStripDocument` for you, it should not have to guess the
shape, the emotion-wheel angles, or which character ids actually exist. `skills/toonstrip/SKILL.md`
in the repository packages exactly that knowledge into one file an agent can load.
## What it teaches
- The full [`ComicStripDocument`](/docs/schema-reference/) v1 shape (`Panel`, `Body`,
`Line`), including that every object is `additionalProperties: false`, so a typo'd key
fails validation instead of being silently ignored.
- The exact [emotion wheel](/docs/emotions-and-poses/) angles (`HAPPY` at `0`, `SAD` at
`Math.PI`, and so on) as a lookup table, so the agent hits a named point instead of
inventing a number.
- The default `@toonstrip/pack-comic-chat` pack's real 15 character ids and 3 backdrop
ids. An agent using this skill will not fabricate a `character` or `backdrop` that
doesn't exist, which is otherwise a hard error at render time, not a silent fallback.
- The `toonstrip render` workflow as the required last step: never hand back a document
without actually running it through the CLI first.
## Using it
Drop the file into wherever your agent looks for skills. For [Claude Code](https://claude.com/claude-code),
that's a `SKILL.md` under `.claude/skills/`:
```
mkdir -p .claude/skills/toonstrip
curl -o .claude/skills/toonstrip/SKILL.md \
https://raw.githubusercontent.com/dkdev24/toonstrip/main/skills/toonstrip/SKILL.md
```
Other agents that support a similar convention can point at the same file directly.
It's plain Markdown with a YAML frontmatter header, so no toonstrip-specific tooling
is required to read it.
No skill support in your agent? Paste the file's contents into the prompt directly, or
point the agent at /llms-full.txt , the whole docs site as
one Markdown file, which covers the same schema and workflow in more depth.
## Verifying it worked
The skill's own instructions end with "run `toonstrip render`, don't just hand back
JSON." If an agent using this skill gives you a document, the fast check is the same one
the skill teaches:
```
npx @toonstrip/cli render doc.json --out strip.png
```
A schema error or an unresolved character/backdrop id fails loudly (`FAIL:`, exit 1)
with every problem listed, not just the first. A clean run means the document is both
structurally valid and actually resolves against a real pack.
## Source
[`skills/toonstrip/SKILL.md`](https://github.com/dkdev24/toonstrip/blob/main/skills/toonstrip/SKILL.md)
in the repository. It has no dependency on any other milestone in this project's plan.
The schema and CLI it teaches are already stable and published.
---
# Asset packager
> Turn a folder of PNGs, or one labelled sheet, into a valid toonstrip asset pack.
Source: https://toonstrip.danielkimdev.com/docs/asset-packager/
Writing a [pack](/docs/asset-packs/) by hand means writing `pack.json`, a manifest per
character, sprite-sheet rectangles and a face/torso socket for every pose. `@toonstrip/asset-packager`
does that part: you supply finished drawings, it validates them, packs the sheets, derives
the join and writes the pack.
It is offline and deterministic. No model call, no network: it consumes PNGs, whether they
came out of an image model, Procreate or Aseprite. toonstrip still generates no art (see the
[FAQ](/docs/faq/)); this is the step *after* you have some.
```
npm install -D @toonstrip/asset-packager
```
## The whole loop
```
# 1. scaffold a source pack
npx -p @toonstrip/asset-packager toonstrip-pack init ./my-source --name my-pack
# 2. draw or generate the poses into ./my-source/characters//, then check them
npx -p @toonstrip/asset-packager toonstrip-pack validate ./my-source
# 3. build, and look at the join preview before shipping
npx -p @toonstrip/asset-packager toonstrip-pack build ./my-source --out ./my-pack --preview joins.png
# 4. the output is a normal pack
npx -p @toonstrip/cli toonstrip pack validate ./my-pack
npx -p @toonstrip/cli toonstrip render strip.json --out strip.png --pack ./my-pack
```
`--force` builds despite errors, for when you want to look at a broken pack rather than read
about it.
`toonstrip-pack` (the packager, source in) and `toonstrip` (the [CLI](/docs/getting-started/),
pack out) are separate commands. Step 4 above runs the second one against the first one's
output as an independent check.
## Two ways in
### One PNG per pose
```
my-source/
source.json # name, version, license, spec overrides
characters//
face-NEUTRAL.png # required
torso-NEUTRAL.png # required
face-HAPPY.png
torso-WAVE.png
face-SAD@0.5-2.png # optional intensity and variant
backdrops/.png
```
Filenames are `-[@][-].png`, with `kind` either `face` or
`torso` and `EMOTION` one of the nine face names (`NEUTRAL`, `HAPPY`, `COY`, `BORED`,
`SCARED`, `SAD`, `ANGRY`, `SHOUT`, `LAUGH`) or nine torso names (`NEUTRAL`, `WAVE`,
`POINTOTHER`, `POINTSELF`, `DOUBLEPOINT`, `SHRUG`, `3QRWALK`, `SIDEWALK`, `3QFWALK`). Case
matters, and anything unrecognised in the directory is ignored rather than rejected, so
reference sketches can live alongside the art.
Every part is a **square, transparent PNG, one pose per file**, at least 256×256 and ideally
1024×1024. A face is a head with a short neck stub, ending at the join; a torso is shoulders
to waist with the neck line at its top edge and the bottom running off the canvas. The full
rules are in [`docs/asset-image-spec.md`](https://github.com/dkdev24/toonstrip/blob/main/docs/asset-image-spec.md).
### One sheet, many assets
When the art comes from an image model, generate a grid in one pass instead of a file per
pose: identity cannot drift between cells, and the packager registers every pose against the
character's NEUTRAL cell. Backdrops work the same way: one image, many scenes.
```
# a reference sheet to prompt with; also prints the source.json fragment and a prompt
npx -p @toonstrip/asset-packager toonstrip-pack template --out ref.png
npx -p @toonstrip/asset-packager toonstrip-pack template --out scenes.png --kind backdrop --ids hall,cellar,attic --columns 2
```
The grid shape is yours to declare. Name the cells in `source.json`:
```json
{
"characters": [
{
"id": "pim",
"sheet": {
"file": "characters/pim/sheet.png",
"layout": [
["face-NEUTRAL", "face-HAPPY"],
["torso-NEUTRAL", "torso-WAVE"]
],
"labelBand": 0.1
}
}
],
"backdropSheets": [
{ "file": "backdrops/interiors.png", "ids": ["hall", "cellar", "attic", "kitchen"], "columns": 2 }
]
}
```
Cells are sliced on the gutters actually present rather than on an even division, so a
model's imprecision is absorbed. Label bands are cropped first, and character cells are keyed
by flood-filling in from the edges: a flat white background is expected here, because few
models emit transparency. A white shirt stays white; it is not connected to the edge.
A background that is not flat (a gradient, a painted room, a checkerboard standing in for
transparency) cannot be keyed, and you get `background is not transparent`. Ask for a plain
single-colour background.
A sheet costs detail. A model capping at 1536px gives roughly 512px cells on a 3×3 and about
300px on a 5×4; against the default 256px `cell`, a 3×3 is comfortable and a 5×4 is marginal.
The packager errors when the grid cannot carry the pack's cell size. Options and diagnostics
in full: [`docs/asset-sheets.md`](https://github.com/dkdev24/toonstrip/blob/main/docs/asset-sheets.md).
### Mixing the two
A per-pose file **overrides** a sheet cell of the same name. That is the fix for one weak
cell: regenerate that pose on its own with the sheet as reference, drop it in as
`characters//face-LAUGH.png`, rebuild. No edit to `source.json`.
## The join
A face and a torso are separate drawings composited at render time, so the packager has to
know where the head meets the neck. Three routes, in priority order:
1. `anchors` in `source.json`: explicit input-canvas pixel coordinates.
2. A single opaque magenta (`#ff00ff`) marker pixel in the art, erased before packing.
3. The convention: a face joins at the bottom-centre of its drawing, a torso at the
top-centre of its.
The join is derived once per character from its **NEUTRAL** pose and reused for every other
pose. Keep the head and body in the same place across poses and change only the expression or
the arms.
A wrong join still produces a *valid* pack, which is what `--preview` is for: it composites
each character's NEUTRAL pair and crosses the join, so a floating or buried head is visible
at a glance. Look at it before shipping.
## Errors vs warnings
`validate` and `build` both report the same two-tier diagnostics, and list every problem
rather than stopping at the first.
**Errors** block the build: a non-square canvas, an opaque background on the per-pose path, a
missing NEUTRAL face or torso, an unknown emotion name, a blank or misnamed sheet cell, a
grid too dense for the pack's cell size.
**Warnings** build anyway and are worth a look: a soft or feathered outline, a drawing running
into a gutter, a pose more than 12% off the NEUTRAL cell's scale, a torso floating above the
bottom of its canvas, a character with only one pose, two near-identical backdrop cells.
Sheet diagnostics name the cell they are about, as `sheet.png[column,row] -> name`.
## As a library
The CLI is a thin wrapper; both stages are importable.
```ts
const { report } = await analyze("./my-source"); // errors + warnings
const result = await buildPack({ source: "./my-source", out: "./my-pack" });
```
## Licensing what you build
Say what the art is. If any of it is not wholly yours (including anything a model produced
from a reference you do not own), ship a `NOTICE` crediting the source and its licence. The
default pack's `NOTICE` (Microsoft Comic Chat art, MIT, with a trademark disclaimer) is the
worked example. `source.json`'s `license` field lands in the built `pack.json`.