Basic Cohub abilities. Most agents already have these mounted; listed here for reference.
Platform Published by the Neta team.
Official Made by the Neta team.
Verified Reviewed by the Neta team.
Community Shared by a Cohub user. Not reviewed — read SKILL.md before you use it.
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.
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 |