rigged-model-to-gif

Verified@netahandiness649/rigged-model-to-gif
Use it in Cohub

Paste this into your agent's chat — it fetches the skill and follows it.

Use the skill: rigged-model-to-gif. Run `npx -y @neta-art/neta-search-cli@latest skills use @netahandiness649/rigged-model-to-gif --via site` and follow what it prints. Read its complete output, redirecting it to a temporary file first if necessary, and resolve relative paths from the skill directory it prints.
Use in Neta Studio Open it in Neta Studio and see what people made with it.
Install from a terminal
npx -y @neta-art/neta-search-cli@latest skills use @netahandiness649/rigged-model-to-gif
Summary

Turn a rigged 3D model into a small looping gif of it walking, posing or turning around.

  • Makes a looping gif or still from a 3D character that already has a skeleton, using its own built-in animation, such as a walk or run.
  • If the model has no animation, you get a turnaround instead: the camera circles the model. It is labelled as a turnaround, not an animation.
  • Can place the model in front of a backdrop image, with a soft shadow under its feet. The background can also scroll to match a running stride.
  • Checks whether a model really moves before promising anything, and can tell you whether a four-legged walk is a true walk, trot or pace.
  • The finished gif is saved to your world as a card with a shareable link. Works best with a model you generated here or exported from a rigging tool.
SKILL.md
# Rigged model → gif

Render a rigged model by playing its own skeleton, headlessly, and deliver the result as a
Work in this world. No Blender, no game engine, no browser-automation library.

## Where the model comes from

Since Neta can generate, rig and animate a 3D model directly (`neta generate 3d`), this skill is
usually the **last mile** rather than the whole pipeline: the model already exists, it is already
rigged, and someone wants a small looping gif of it. Two routes in:

- **Generated here** — `neta generate 3d` hands back a Work whose media is a `.glb`. Render it
  straight away; do not re-generate the model to get motion.
- **Made elsewhere** — Meshy, Mixamo, Blender. Attached files land in `uploads/`.

What an outside service's export looks like, and what to do about it:

| What you get | What to do |
|---|---|
| Several clips (walk, run, idle, attack) | Choose one: `--clip <index\|name>`. Taking the first silently renders an idle when you wanted the walk. |
| Two clips with the same name (a real one and a 2-key stub) | Pick by **index**, not name — a name lookup returns the first match, which may be the stub. `--clip 0`. |
| Anonymous bones (`Bone_NNN`) | Nothing to name, so measure: `joints` and `gait`. Drive by position, or retarget. |
| 100,000+ triangles | Remesh *before* rigging — re-topologising a rigged mesh throws the skeleton away. A good colour map carries the surface; about 10,000 triangles is the safe working size. |
| Embedded textures | `render` externalises them itself. Doing it by hand and forgetting is the blank-frame failure. |
| `.fbx` with the animation only in the FBX | Pass the `.fbx`; the harness loads it with `FBXLoader` and copies any `*texture*` beside it. |

## Steps

1. **Find the model.** Look in `uploads/` first — that is where an attached file lands. If it
   belongs to the library, use `models/glb/`.
2. **Inspect before assuming.** Never claim a model animates until you have looked:
   `node tools/inspect.mjs <model.glb>`
   Prints joint count, clip names and durations, whether textures are embedded, the bounding
   box and the triangle count. **Check the triangle count against about 10,000.** That is the
   reliable working size, not a hard ceiling — heavier can work (a 30,550-triangle model rendered
   here, with finer physical detail), but it is where failures start, so recommend the safe number
   and explain why if asked: a good colour map carries the surface detail, and tools like Meshy
   default 100,000+. It cannot be fixed here: a heavy mesh has to be remeshed *before* it is
   rigged, because re-topologising a rigged mesh throws the skeleton away. The tool warns above
   15,000 triangles. **If there are no clips, say so** — then offer something useful
   rather than nothing: a turntable (`az=spin`, step 3), or authored motion if the bones are
   named (`Bone_NNN` names make that a guessing game — measure positions first).
3. **If the model has an embedded texture, externalise it first.** Textured models render
   COMPLETELY EMPTY in this pipeline unless you do — see the gotcha below. One command:
   `node tools/slim.mjs <in.glb> <dir>/model.glb --external-color=1024`
   That strips the embedded images (so the loader never decodes one) and writes the resized coat
   beside the model as `color.png`, which `capture.sh` picks up automatically. You keep the colour.
   The harness sets `flipY = false` on those maps — without it the coat lands in the wrong places
   and the model renders as a hodgepodge of colour while the geometry looks perfectly fine.
4. **Render the loop:**
   `bash tools/capture.sh <model.glb> [frames=24] [width=240] [az=90|spin] [pt=-4]`
   `az=90` is a true side view; `az=spin` orbits the camera across the frames, which is the
   honest fallback for a rig with no animation. Writes `out.gif` at the requested width.
   Match `FPS` to the clip: 16 frames from a 0.67 s clip want `FPS=24` to play at real speed.
5. **Look at two or three frames before delivering.** If the model is a speck, the scale did
   not normalise. If the frame is a bare background and the page reports no title, the model
   never finished loading — externalise the texture (step 3) or strip it.

## Putting a scene behind it

A loop on a flat ground reads as a studio test. To stand the model in a scene, supply a backdrop
image beside the model and the harness puts it behind the canvas:

```
SCENE=backdrop.webp BGZOOM=160 BGY=84 FPS=23 bash tools/capture.sh model.glb 24 400 90 -4
```

- `SCENE` — a path, or a filename sitting beside the model. The canvas clears to nothing and the
  page carries the image, so a single screenshot holds both.
- `BGZOOM` — backdrop size as a percentage. A square backdrop in a square viewport has no slack to
  move in, so zoom in to bring the ground line up or down.
- `BGY` — vertical position 0–100; higher shows the lower part of the image. Tune it until the
  model's feet stand on the scene's ground, not above or below it.
- `AMB` — ambient light multiplier. The default lighting is tuned for a dark ground; raise it to
  sit the model in a brighter scene instead of leaving it looking pasted on.
- `SHADOW=0` turns off the contact shadow, which is **on by default with a backdrop and is the
  single biggest tell of a fake composite.** Without it the model reads as floating. The shadow is
  a soft radial plane at the model's base, drawn by the renderer — not painted into the scene.
- Generate the backdrop **with nothing standing in it**, an open centre, and light that roughly
  matches the model's own key light (a dark model wants a low-key scene).
6. **Deliver it as a Work** — the gif becomes a card with a public URL:
   `node /mods/neta/neta work import image --file out.gif --work-title "<name>" --work-description "<what it is>" --aspect 1:1`
7. **Record it** if it is reusable: a line in the world's log, and the model filed in `models/`
   with its verified facts in `models.json`.

## Making the scene move

A still backdrop under a running figure reads as running in place. A pan fixes that, but two things
have to be right or it looks worse than still.

**1. The ground speed must match the stride.** The harness reports each foot's screen position in its
title (`--dump-dom`, grep `<title>`). Read the feet across the frames, find the planted foot — the
lower of the two — and measure how far it travels during its stance. That distance over the stance's
duration *is* the ground speed. Ginger's run measured **~800 px/s**, about **530 px per run cycle**.
A ground moving at any other speed makes the planted foot slide: the same foot-slide a rig cannot
prevent, arriving from the other direction.

**2. The backdrop must tile.** Pan an image and its edges come into view, and the loop has to close.
`capture.sh` handles both: with `BGX` set it builds a tile from the plate and its own mirror, so the
first and last columns are identical (verify with `compare -metric RMSE` — it should be 0) and one
tile width per loop lands the pan exactly back in phase.

```
SCENE=band.png BGZOOM=267 BGY=62 BGX=1 CYCLES=3 FPS=24 bash tools/capture.sh model.glb 48 400 90 -4
```

- `BGX=1` — pan one tile width per loop. Keep it a whole number or the loop jumps.
- `CYCLES` — how many times the clip plays across the loop. The pan distance is fixed by the tile, so
  the **loop length** is what sets the speed: cycles × clip duration × target speed = tile width.
- `BGZOOM` then sets the tile's drawn width, which is also the scene's scale — and it decides
  whether the mirror join sits off-screen or in the middle of the view.

**Three traps, each of which cost time here:**

- **Mirror only a scene with no focal feature.** A plate with a sun, a path or a horizon mirrors into
  an obvious kaleidoscope — mathematically perfect and still wrong. For panning, generate a separate
  *uniform* band: evenly spaced trunks, no sun, no horizon, no path. Keep the pretty plate for a
  static scene.
- **The page height must match the canvas.** The canvas is 600×600, but under headless Chrome the
  laid-out viewport measures less (about 513), so the body painted a shorter area than the canvas and
  a tiled band left bare page above and below it. The harness now pins the page to the canvas.
- **Keep the file's true extension.** Chrome sniffs an image's real format and decodes it at *that*
  size, so a PNG named `.webp` is drawn at half height and the band stands too short. Silent, and it
  looks exactly like a sizing bug in the CSS.
- **A panning loop weighs about twice a still one, and plan for it.** When the ground moves, no two
  frames share anything, so the format cannot cheat: the same run measured 2.2 MB with a still
  backdrop and 4.7 MB with a panning one at 400px. On a poor connection that difference is the whole
  ballgame — a heavy gif shows its first frame and then stalls, which reads as a broken file rather
  than a slow one. Offer a ladder of weights before assuming anything is corrupt: same loop at 300px
  and 96 colours (2.6 MB), then 24 frames at 280px (1.1 MB), then the still-backdrop version (~0.35 MB).
  Cutting the frame count coarsens the pan but keeps both the stride match and the closed loop.

## Reading a gait

`node tools/gait-lag.mjs <model.glb>` — edit the four bone names at the top for the rig first.
It prints the lag between each leg and the front-left leg, then names the gait:

| result | gait |
|---|---|
| same-side legs in step | **pace** (lateral couplets) |
| diagonal legs in step | **trot** |
| four legs roughly 90° apart | **walk** |

**A file called "Walking" may not be one.** One model here shipped as
`..._Animation_Walking_withSkin` and measured as a pace — the same-side legs 58° apart against
128° for the diagonal. Measure, then state.

`node tools/joint-travel.mjs <model.glb>` answers the prior question — does the clip animate at
all. It reports how far each joint rotates across the clip. If a couple of joints move and the
legs read near zero, the animation is not in the file, which is the usual reason feet — and
especially **back** feet — will not move.

## Gotchas

- **`capture.sh` must overwrite its output, and this bit once.** The ffmpeg calls carry `-y`.
  Without it ffmpeg asks before replacing an existing `out.gif` — and with no terminal attached it
exits instead, leaving the OLD gif in place. That looks exactly like a render that produced nothing,
or one that ignored your change, because the stale file is what you open.
- **A backdrop needs a contact shadow**, or the model reads as floating above the scene. The
  harness draws one automatically when `SCENE` is set.
- **A mesh, its skin weights and its animation must all come from the same skeleton.** Rigs are not
  interchangeable, and swapping one part is what tears a model apart. Real case: a mesh rigged by one
  tool, animated by another's clip, deformed its cloak wildly — because the weights were painted for
  *its* skeleton, not the one driving it. If a character tears, shreds or explodes when it moves, the
  rig is usually fine and the **weights belong to a different skeleton**. Fix it by keeping the whole
  chain in one tool: rig and animate in the same place, so the weights are always made for the bones
  that move them.
- **Rig quality is diagnosable, so diagnose it rather than guess.** `dump=1` writes the whole skeleton
  into the page as text — names, hierarchy and rest positions — and `--dump-dom` reads it back. That
  works for **FBX as well as glb**, which matters because a binary FBX cannot be parsed the way a glb
  can. What to look for:
  - **Rest positions that don't fit the mesh.** If a rig's head bone sits at 0.37 of a model that is
    1.0 tall, the skeleton and the skin are at different scales, and the geometry will explode. This
    is invisible in a still and obvious in a dump.
  - **Anonymous bones** (`Bone_NNN`) — harmless for *playing* a baked clip, since the clip was written
    against that same rig, but useless for authoring or retargeting, where you must address bones by
    name or find them by position.
  - **Extra chains beside the real ones.** A parallel set of unnamed chains running alongside the arms
    is a rigging artefact, and a sign the export should be checked before anything is built on it.
- **A retarget must cover every part that MOVES, or the parts left still look broken.** Mapping a run
  across pelvis, spine, neck, head, both arms and both legs left the tail at rest — and a static tail
  beside running legs reads as a **third leg**. Nothing was wrong with the mesh; the omission was in the
  map. So before blaming an asset, check that every part that should move is actually being driven.
  Slowing a loop is what exposes this: at speed the eye forgives motion it cannot follow.
- **An untextured FBX renders BLACK**, not grey — its material arrives unlit. `flat=1` forces the
  neutral material. Same failure the untextured glb has, arrived at differently.
- **A rig's forward axis is not standard.** One Mixamo character gave a true side view at `az=90`;
  another needed `az=180`. Never carry an azimuth from one model to the next.
- **Aim for about 10,000 faces, and remesh *before* rigging.** That is a working size rather than
  a ceiling: heavier can work — a model here at 30,550 triangles rendered — but it is where
  failures begin, and a conservative number means nobody reports "I tried 100k and it broke". A
  good colour map carries the surface detail, so remeshing down costs little visually; Meshy and
  similar default far above this (100,000+), so it is a deliberate step. It is a modelling-stage
  rule: re-topologising a mesh that already carries a skeleton throws the skeleton away.
  `inspect.mjs` warns above 15,000.
- **Unit scale is the number one trap.** Models are often authored in millimetres. Un-normalised,
  the camera's near plane clips the model and you render a speck. `harness.html` normalises the
  scale and centres the model; keep that code if you rewrite it.
- **A texture that has not decoded renders black**, not missing. Await textures and the glb
  together, then render.
- **Textures are used as they come.** If the glb carries an embedded base-colour map, the harness
  keeps the model's own material and renders it textured. It only builds a material of its own when
  you supply external maps — so a textured model never renders as flat grey. Put separate maps
  **beside the model** named `color.png` and `normal.png` and `capture.sh` passes them in.
- **Heavy textures slow the render.** A 4K map can push a single frame past the default budget.
  Raise it per-frame with `BUDGET=90000 bash tools/capture.sh …`, and expect a long run: time one
  frame first before committing to a big loop.
- **`frustumCulled = false`** on skinned meshes, or the model vanishes mid-animation: three
  computes their bounds from the bind pose, not the animated one.
- **Headless WebGL needs the swiftshader flags** (`--enable-unsafe-swiftshader`). A heavy
  texture decode can stall a large model past the virtual-time budget; light models are fine.
- **Count the clips before planning around them.** A file named `..._All_Animations.glb` may
  contain exactly one.
- **Discord, and anything else that shows images inline, never enlarges an image** — it only
  shrinks it to fit the column, roughly 400–500 px on desktop. So the only lever on displayed
  size is the file's own pixel width. 240 px posts small. A wide contact sheet can never display
  small and has to be redesigned narrow, not rescaled.
- **An external map needs `flipY = false`.** glTF puts the UV origin at the top-left; `GLTFLoader`
  sets this for embedded textures, but a texture loaded by `TextureLoader` defaults to `flipY = true`.
  The result is not a missing coat but a WRONG one: the geometry looks perfect while the colours sit
  in the wrong places. If a textured render looks like a hodgepodge, check this before anything else.
- **An embedded texture makes the render come back EMPTY — and it is not a size problem.**
  `GLTFLoader` decodes embedded images with `createImageBitmap`, which in a software-GL sandbox can
  simply never finish: the loader's callback does not fire, so the canvas holds only the background.
  Measured here: 1.5 MB with no textures renders; 4.5 MB with one 2K map renders empty; even 2.9 MB
  with a 1K map renders empty. Shrinking the map does NOT fix it — **removing it from the glb does**.
  Use `slim.mjs --external-color=1024`, which strips the embedded images and writes the coat beside
  the model as `color.png`; the harness loads that through an ordinary `<img>`, which works, so you
  keep the colour. `--strip-images` alone also works but loses the coat — say which you are delivering.
- **An empty render means the model is too heavy, not that the harness is broken.** If the frames
  come back as flat background — or worse, as 404 pages — check in this order: does the model load at
  all (read `document.title` with `--dump-dom`), is the file too big to finish inside the window
  (a 15 MB glb with a 4K map will not; 1.5 MB will), and is a stale server holding the port. Slim the
  model with `tools/slim.mjs`, and let `capture.sh` pick its own port.
- **One Chrome launch per frame is slow** — about four seconds each, so a 24-frame loop takes
  roughly a minute and a half. Plan the loop length.
- **Verify, then claim.** Read the numbers back — joint travel, leg lags, clip names — instead of
  describing what you think you saw.

## A rig holds; generation drifts

Worth saying out loud when a user is trying to fix sliding feet by tuning an animation.

A rigged model playing a baked clip **cannot** drift frame to frame: every pose is computed from
the same bone curves, so it is identical every time you play it. That is why the feet hold.

Generate frames instead — an AI clip, or one image per frame — and nothing ties frame 24 to
frame 25. Shapes boil, and the feet are the first thing to slip, because a paw needs contact with
the ground and one invented frame cannot know where the previous one put it. The fix lives in the
asset, not the animation: rig it, or author the clip so it keys the feet.

One caveat, so a rig is not oversold: a rig stops *drift*, but it does not by itself stop
*foot-slide*. That is a separate quality — the cycle has to plant the paws.

## Deliver

The user gets a gif (or a still) as a Work in this world — and, when it was made to show
something, that same Work handed over in the reply on its own line as an interaction-components
card, so they can open it from the chat rather than hunting the board. Keep it light: chat media
lands at roughly 1 MB or less unless a heavier copy is asked for, and a heavier version is
*offered* as a choice, never delivered as the only copy.

Mention what the render actually shows in one line — the clip that was played, how many frames,
the width, the file size. If the model had no clip and `az=spin` was used, say so plainly: a
turnaround is not an animation.

Reply in the user's language. Text the user sees in the deliverable follows the user's language.

## When to ask

Required inputs, and when each counts as given:

| input | counts as given when |
|---|---|
| the model | a path is named, or exactly one plausible model sits in `uploads/` / `models/glb/` |
| the clip | the model has one clip; with several, ask once which action they want rather than rendering the first |
| the size | a width fits what they asked for; otherwise default 240px and say what was used |

All three given → render and deliver without asking. One missing → ask once, with the options you
found (clip names and durations), then build. If the model cannot animate and no bones are named,
say what *is* possible (a turnaround, or a retarget from another file) instead of returning nothing.

## Out of scope

No changes to world settings, atoms or existing Works. No deleting or overwriting a model. No
re-generating a model to get motion — render the rig that exists. Nothing beyond the gif or still
that was asked for.

## Files

| file | what it is |
|---|---|
| `bin/cli.mjs` | the `npx rigged-model-to-gif` entry point: `inspect` / `render` / `slim` / `joints` / `gait`. `render` externalises embedded textures itself, lists the clips, and takes `--clip` |
| `tools/harness.html` | the render page — one frame per URL, plays the model's clip (`clip=<index\|name>`), normalises scale |
| `tools/capture.sh` | serve + render every frame + assemble the gif. `az=spin` makes a turntable; applies `color.png` / `normal.png` if they sit beside the model |
| `tools/inspect.mjs` | what is in the glb: joints, clips, textures, bounds |
| `tools/joint-travel.mjs` | does the clip animate, and which joints move |
| `tools/gait-lag.mjs` | which gait a four-legged clip is |
| `tools/slim.mjs` | make a textured model renderable. `--external-color=1024` keeps the coat (recommended); `--strip-images` loses it |