Merge pull request #246 from calesthio/feat/ink-theater
feat: Ink Theater — hand-drawn doodle animation engine + mocap puppet + cross-agent commands
This commit is contained in:
commit
169124d0fd
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
description: Animate a SUPPLIED drawing/photo of a character with real motion capture (Meta AnimatedDrawings) → raster GIF/MP4 of that drawing moving. To CREATE a vector doodle from scratch, use /ink-art.
|
||||
argument-hint: [path to drawing] [motion: dance|walk|jump|wave]
|
||||
---
|
||||
|
||||
Read `skills/creative/animated-drawing.md`, then set up and run Meta's open-source **AnimatedDrawings** to animate the supplied drawing with the requested motion.
|
||||
|
||||
- Use this only when the user *has* a humanoid drawing/photo to animate. To create a vector doodle from scratch that draws itself → use `/ink-art` instead.
|
||||
- Output is **raster** (the original drawing warped) — no vector, no draw-on reveal. Confirm the input is a single humanoid on a plain light background.
|
||||
- Prefer the turnkey bundled-character path first; the auto-rig path needs Docker + ~670 MB models.
|
||||
|
||||
Drawing + motion: $ARGUMENTS
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
description: Hand-drawn ink-on-white doodle animation — a character that draws itself then walks/dances/waves, or a deadpan contraption explainer. Vector, deterministic, rendered via HyperFrames to MP4.
|
||||
argument-hint: [what to animate]
|
||||
---
|
||||
|
||||
Read `skills/creative/ink-theater.md` (the metaphor method, color grammar, mined archetypes) and `ink-theater/README.md` (engine API, determinism, the SVG-text font gotcha), then build the piece described below.
|
||||
|
||||
- Use the **Ink Theater** engine (`ink-theater/ink-theater.js`): variable-width ink strokes, seek-safe boil, closed-form spring eases, FABRIK IK, the contraption grammar.
|
||||
- For a **character that walks / dances / waves / jumps**, use the **Ink Puppet** mocap system: `InkPuppet.create(...)` → `p.drawIn(tl, ...)` for the self-drawing reveal → `InkPuppet.choreograph(tl, p, [{clip:'wave'},{clip:'twist'},{clip:'walk'},...])`. Clip names come from `ink-theater/mocap/catalog.json` (12 CMU-sourced moves). **Never hand-tune motion** — add moves with `node ink-theater/mocap/add-motion.mjs <name> <cmu-id> …` (free CMU mocap has walk/run/dance/…).
|
||||
- Captions = **HTML overlay `<div>`s** (HyperFrames does not apply webfonts to SVG `<text>`).
|
||||
- Validate with `npx hyperframes lint` + `snapshot` (eyeball the contact sheet), then render.
|
||||
|
||||
In OpenMontage this runs on the `animation` pipeline (illustration) or `character-animation` pipeline (mocap puppet) — it is a style + engine, not a separate pipeline.
|
||||
|
||||
Request: $ARGUMENTS
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Codex custom prompts
|
||||
|
||||
Codex only scans **`~/.codex/prompts/`** (user home) for custom prompts — it does **not** read project-level `.codex/prompts/`. These files are versioned here as the source of truth; to use them in Codex, copy or symlink them into your home dir:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.codex/prompts
|
||||
cp .codex/prompts/ink-art.md .codex/prompts/animated-drawing.md ~/.codex/prompts/
|
||||
# or symlink so repo edits propagate:
|
||||
# ln -s "$PWD/.codex/prompts/ink-art.md" ~/.codex/prompts/ink-art.md
|
||||
```
|
||||
|
||||
Then restart Codex; `/ink-art` and `/animated-drawing` appear in the `/` menu. (OpenAI is deprecating custom prompts in favor of Codex "skills" — migrate when that stabilizes.)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
description: Animate a SUPPLIED drawing/photo with real mocap (Meta AnimatedDrawings) → raster GIF/MP4. To create a vector doodle from scratch, use /ink-art.
|
||||
argument-hint: [path to drawing] [motion: dance|walk|jump|wave]
|
||||
---
|
||||
|
||||
Read `skills/creative/animated-drawing.md`, then set up and run Meta's open-source AnimatedDrawings to animate the supplied drawing with the requested motion.
|
||||
|
||||
- Only for animating an *existing* humanoid drawing/photo. To create a vector doodle from scratch → use `/ink-art`.
|
||||
- Output is **raster** (original drawing warped) — no vector, no draw-on reveal.
|
||||
- Prefer the turnkey bundled-character path; auto-rig needs Docker + ~670 MB models.
|
||||
|
||||
Drawing + motion: $ARGUMENTS
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
description: Hand-drawn ink doodle animation — a character that draws itself then walks/dances/waves, or a contraption explainer (vector → HyperFrames MP4).
|
||||
argument-hint: [what to animate]
|
||||
---
|
||||
|
||||
Read `skills/creative/ink-theater.md` and `ink-theater/README.md`, then build the hand-drawn ink animation described below.
|
||||
|
||||
- **Ink Theater** engine (`ink-theater/ink-theater.js`): variable-width ink strokes, seek-safe boil, closed-form spring eases, FABRIK IK, contraption grammar.
|
||||
- For a **character that walks / dances / waves / jumps**, use the **Ink Puppet** mocap system: `InkPuppet.create` → `p.drawIn` (self-drawing reveal) → `InkPuppet.choreograph([{clip:'wave'},{clip:'twist'},…])` (names from `ink-theater/mocap/catalog.json`, 12 CMU-sourced moves). **Never hand-tune motion**; add moves via `node ink-theater/mocap/add-motion.mjs`.
|
||||
- Captions = **HTML overlay `<div>`s** (HyperFrames doesn't apply webfonts to SVG `<text>`). Validate (`npx hyperframes lint` + `snapshot`) before rendering.
|
||||
|
||||
Request: $ARGUMENTS
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# /animated-drawing — animate a supplied drawing with real mocap
|
||||
|
||||
Turn a user-supplied drawing/photo of a humanoid into a raster GIF/MP4 of that drawing moving (dance / walk / jump / wave), via Meta's open-source AnimatedDrawings. To *create* a vector doodle from scratch that draws itself, use `/ink-art`.
|
||||
|
||||
Read `skills/creative/animated-drawing.md`, then set up and run AnimatedDrawings on the user's drawing with the requested motion.
|
||||
|
||||
- Only for animating an *existing* humanoid drawing/photo (single figure, plain light background).
|
||||
- Output is **raster** (original drawing warped) — no vector, no draw-on reveal.
|
||||
- Prefer the turnkey bundled-character path; auto-rig needs Docker + ~670 MB models.
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# /ink-art — hand-drawn ink doodle animation
|
||||
|
||||
A character that draws itself then walks/dances/waves, or a deadpan contraption explainer. Vector, deterministic, rendered via HyperFrames to MP4.
|
||||
|
||||
Read `skills/creative/ink-theater.md` and `ink-theater/README.md`, then build what the user asks:
|
||||
|
||||
- **Ink Theater** engine (`ink-theater/ink-theater.js`): variable-width ink strokes, seek-safe boil, closed-form spring eases, FABRIK IK, contraption grammar.
|
||||
- For a **character that walks / dances / waves / jumps**, use the **Ink Puppet** mocap system: `InkPuppet.create` → `p.drawIn` (self-drawing reveal) → `InkPuppet.choreograph([{clip:'wave'},{clip:'twist'},…])` (names from `ink-theater/mocap/catalog.json`, 12 CMU-sourced moves). **Never hand-tune motion**; add moves via `node ink-theater/mocap/add-motion.mjs`.
|
||||
- Captions = **HTML overlay `<div>`s** (HyperFrames doesn't apply webfonts to SVG `<text>`).
|
||||
- Validate (`npx hyperframes lint` + `snapshot`) before rendering.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
description: Animate a SUPPLIED drawing/photo of a character with real mocap (Meta AnimatedDrawings) → raster GIF/MP4. To create a vector doodle from scratch, use /ink-art.
|
||||
---
|
||||
|
||||
Read `skills/creative/animated-drawing.md`, then set up and run Meta's open-source **AnimatedDrawings** to animate the user's supplied drawing with the requested motion (dance / walk / jump / wave).
|
||||
|
||||
- Only for animating an *existing* humanoid drawing/photo. To create a vector doodle from scratch that draws itself → use `/ink-art`.
|
||||
- Output is **raster** (the original drawing warped) — no vector, no draw-on reveal.
|
||||
- Prefer the turnkey bundled-character path; the auto-rig path needs Docker + ~670 MB models.
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
description: Hand-drawn ink-on-white doodle animation — a character that draws itself then walks/dances/waves, or a deadpan contraption explainer. Vector, deterministic, HyperFrames → MP4.
|
||||
---
|
||||
|
||||
Read `skills/creative/ink-theater.md` and `ink-theater/README.md`, then build the hand-drawn ink animation the user asks for.
|
||||
|
||||
- Use the **Ink Theater** engine (`ink-theater/ink-theater.js`): variable-width ink strokes, seek-safe boil, closed-form spring eases, FABRIK IK, contraption grammar.
|
||||
- For a **character that walks / dances / waves / jumps**, use the **Ink Puppet** mocap system: `InkPuppet.create` → `p.drawIn` (self-drawing reveal) → `InkPuppet.choreograph([{clip:'wave'},{clip:'twist'},…])` (names from `ink-theater/mocap/catalog.json`, 12 CMU-sourced moves). **Never hand-tune motion**; add moves via `node ink-theater/mocap/add-motion.mjs`.
|
||||
- Captions = **HTML overlay `<div>`s** (HyperFrames doesn't apply webfonts to SVG `<text>`).
|
||||
- Validate (`npx hyperframes lint` + `snapshot`) before rendering.
|
||||
|
|
@ -616,6 +616,11 @@ Tool rules:
|
|||
| `clean-professional` | Corporate, educational, SaaS |
|
||||
| `flat-motion-graphics` | Social media, TikTok, startups |
|
||||
| `minimalist-diagram` | Technical deep-dives, architecture |
|
||||
| `ink-sketch` (Ink Theater) | Hand-drawn ink-on-white doodle animation; a character that draws itself, walks, dances; contraption explainers |
|
||||
|
||||
### Hand-drawn "doodle" animation → Ink Theater / Ink Puppet
|
||||
|
||||
For any brief that wants a **hand-drawn ink doodle** look — "a sketch that comes to life", "a pencil / stick figure that walks or dances", "a little character that acts out the idea", whiteboard-doodle explainers — use the **Ink Theater** engine + **Ink Puppet** mocap system (`skills/creative/ink-theater.md`, `ink-theater/README.md`). It is a **style + reusable engine, not a new pipeline**: illustration / contraption pieces run on the `animation` pipeline; a mocap character (draws itself → walks / dances / waves via `InkPuppet.choreograph([...])`) runs on `character-animation`. Cross-tool entry points: **`/ink-art`** (create a vector doodle from scratch) and **`/animated-drawing`** (animate a *supplied* drawing with mocap — raster; `skills/creative/animated-drawing.md`). Never hand-tune character motion — the agent only chooses named mocap clips.
|
||||
|
||||
## Layer Map
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
# Ink Theater
|
||||
|
||||
A deterministic, seek-safe engine for hand-drawn **"moving art"** — a minimalist black-ink-on-white world where a deadpan mascot physically *performs* an abstract idea by operating absurd low-tech contraptions. Built for OpenMontage's **atelier** path and rendered through **HyperFrames** (HTML/SVG/CSS + one paused GSAP timeline → MP4).
|
||||
|
||||
Inspired by Ian's `小黑 / Xiaohei` illustration skill (MIT — credit Ian for the technique); this is an original, generic, English, motion-first engine, not a copy.
|
||||
|
||||
## Why it exists
|
||||
|
||||
The illustration style is simple enough that **the illustration IS the animation** — no diffusion model needed. Vector shapes + math give you the whole thing: free, deterministic, infinitely editable, and the character genuinely acts out the concept. This engine turns the research findings (`memory: project_ink_atelier_animation`, deep-research on vector/physics/metaphor foundations) into reusable primitives.
|
||||
|
||||
## The five capabilities (`ink-theater.js`, global `InkTheater`)
|
||||
|
||||
| Module | What it does | Key API |
|
||||
|---|---|---|
|
||||
| **ink strokes** | Confident hand-drawn lines — variable-width brush ribbons + wobbled centerlines | `inkPath(pts, opt)`, `inkRibbon(pts, {width,taper,seed})` |
|
||||
| **boil** | Seek-safe hand-drawn line "boil" — steps a `feTurbulence` seed off the timeline (~9fps), NOT SMIL | `boil(turbEl, tl, {duration,fps})` |
|
||||
| **spring physics** | Closed-form damped-spring eases (anticipation/overshoot/settle) — pure functions of progress, seek-safe | `springEase({stiffness,damping,mass})`, `ease.{settle,overshoot,bouncy,soft}` |
|
||||
| **rig / IK** | 2D FABRIK inverse kinematics + a riggable mascot whose arms reach a target | `fabrik(lengths,origin,target)`, `mascot({x,y,scale})` → `.reachL/.reachR([x,y])` |
|
||||
| **contraption grammar** | Parametric composable machine parts | `parts.{crank,gauge,hopper,slot,lever,box}` |
|
||||
|
||||
## Determinism (HyperFrames render contract)
|
||||
|
||||
Every frame must be reproducible from time alone. This engine obeys that:
|
||||
|
||||
- **Closed-form springs** — `springEase` evaluates an analytic damped-oscillator step response, so any progress `p` maps deterministically (no numeric integration, no accumulated state).
|
||||
- **Seek-safe boil** — driven by a GSAP stepped-seed tween on the timeline, never SMIL / render-time clocks.
|
||||
- **IK-follow via `onUpdate`** — pose the arm from a target whose position is set by the timeline; GSAP fires `onUpdate` on seek, so it's pure-function-of-time.
|
||||
- Seeded PRNG (`rng`) for all "random-looking" wobble — no runtime `Math.random`.
|
||||
- No `repeat:-1` (finite counts only), animate only transforms/opacity/attrs.
|
||||
|
||||
## ⚠ The font gotcha (the REAL root cause)
|
||||
|
||||
Custom handwriting rendered as **serif** in every render for a long time. The cause was **not** SVG-vs-HTML — it was a **font-subset trap**: grabbing one woff2 from the Google Fonts `css2` API (`grep … | head -1`) returns a single *unicode-range subset* (often cyrillic / vietnamese / latin-ext) that is **missing basic-latin (ASCII)**. So every English word silently falls back to serif — while the renderer still logs `Fonts: 1 loaded`. (This means earlier demos whose captions "looked handwritten" were actually serif.)
|
||||
|
||||
**Fix (verified):** embed the **full font file** — the TrueType, or a woff2 that actually covers basic-latin:
|
||||
|
||||
```html
|
||||
@font-face { font-family: "InkHand"; src: url("assets/patrickhand.ttf") format("truetype"); font-display: block; }
|
||||
```
|
||||
|
||||
A working Patrick Hand TTF ships at **`ink-theater/assets/patrickhand.ttf`** (SIL Open Font License — the license ships beside it as `assets/OFL.txt`; see `THIRD_PARTY_NOTICES.md`) — copy it into your project's `assets/` and use `font-family: "InkHand"`. It renders real handwriting on normal **HTML overlay `<div>`s** (verified — put caption divs over the SVG scene). Don't hot-link Google Fonts (a render-time network fetch breaks determinism); a local `@font-face` file is auto-inlined by the compiler at build time.
|
||||
|
||||
> Note: HyperFrames also pre-bundles ~18 fonts (none are handwriting) — see `hyperframes-creative/references/typography.md`. For handwriting you must embed your own full font as above.
|
||||
|
||||
## Usage in a HyperFrames project
|
||||
|
||||
1. Copy `ink-theater.js` into the project root; `<script src="ink-theater.js">` after gsap.
|
||||
2. Build the scene programmatically into a mount `<g>`, keep node refs.
|
||||
3. Apply `filter="url(#boil)"` to ink groups; call `InkTheater.boil(...)` once.
|
||||
4. Captions = HTML overlay divs (see gotcha).
|
||||
5. Register one `gsap.timeline({paused:true})` on `window.__timelines["<id>"]`.
|
||||
|
||||
## Ink Puppet — real mocap on a hand-drawn figure (recommended for characters)
|
||||
|
||||
The right way to animate a doodle *character* (walk / dance / wave / jump) is **not** hand-tuned math — it is **real motion-capture retargeted onto a stick figure**. An agent should only choose the character and choreograph named moves; it must never hand-tune motion. Two pieces:
|
||||
|
||||
- **`mocap/bvh2clip.mjs`** — offline converter: a 3D BVH mocap file → a compact 2D "clip" (per-frame joint tracks, hips-relative pose + root motion, scaled to a fixed figure height). Run once per motion; bundle clips with `clips.js`.
|
||||
- **`ink-puppet.js`** — runtime: builds the stick figure, plays clips, exposes a **declarative choreography API**:
|
||||
|
||||
```js
|
||||
var p = InkPuppet.create(mount, { cx: 960, ground: 902, boil: "boil" });
|
||||
p.drawIn(tl, { start: 0.4 }); // pencil sketches the figure limb-by-limb
|
||||
InkPuppet.choreograph(tl, p, [ // then plays named mocap clips — zero hand-tuning
|
||||
{ clip: "walk" }, { clip: "dance_spin" }, { clip: "kick" }, { clip: "wave" }
|
||||
], { start: 3.7 });
|
||||
|
||||
// speak — comic balloon tethered to the mouth (HTML text = webfont works)
|
||||
InkTheater.balloon(tl, { into: fxGroup, overlay: htmlOverlay, at: 5, dur: 2, text: "hello!", boil: "boil" });
|
||||
```
|
||||
|
||||
Deterministic + seek-safe (pose is a pure function of each segment's local time).
|
||||
|
||||
**The action library (`mocap/catalog.json`)** ships 12 varied moves the agent picks by name — locomotion (`walk`, `run`, `climb`, `march`, `shuffle`), action (`jump`, `kick`), posture (`sit`), gesture (`wave`), dance (`dance_spin`, `dance_glide`, `twist`). Every clip is CMU-sourced (free for any use). **Read the catalog and pick moves that fit the story — don't loop one clip.**
|
||||
|
||||
**Extend it in one command** (self-extending, no code changes) — the converter auto-maps fair1 / CMU / Mixamo skeletons:
|
||||
```
|
||||
node mocap/add-motion.mjs backflip 05_20 dance "a backflip" # CMU id, or a URL, or a local .bvh
|
||||
```
|
||||
Free **CMU mocap** (`una-dinosauria/cmu-mocap`) has thousands. This is what Meta's *Animated Drawings* does, but here it stays **vector, white-ink, with a draw-on reveal** (AD is raster, humanoid-only, no reveal). Provenance (all clips CMU, free for any use): `mocap/NOTE.md` · `THIRD_PARTY_NOTICES.md`.
|
||||
|
||||
### Speech balloons — `InkTheater.balloon(tl, opts)`
|
||||
Comic balloon that grows from the mouth, with HTML overlay text (so the webfont applies). `opts`: `into` (an SVG `<g>`), `overlay` (an HTML div), `at`, `dur`, `text`, `mouth:[x,y]`, `center:[x,y]`, `w`, `size`, `boil`.
|
||||
|
||||
## Demos
|
||||
|
||||
- `examples/mocap-figure/` — the pencil figure draws itself, then walks / runs / dances / kicks / sits / waves via **real CMU mocap**. Self-contained and lintable (`npx hyperframes lint ink-theater/examples/mocap-figure`); see `examples/README.md`.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Third-party notices — Ink Theater
|
||||
|
||||
Ink Theater bundles two third-party assets. Both are free for any use, including
|
||||
commercial, subject only to attribution.
|
||||
|
||||
## Patrick Hand (handwriting font) — SIL Open Font License 1.1
|
||||
|
||||
`assets/patrickhand.ttf`
|
||||
|
||||
Copyright (c) 2010-2012 Patrick Wagesreiter (mail@patrickwagesreiter.at).
|
||||
Licensed under the SIL Open Font License, Version 1.1. The OFL permits bundling,
|
||||
embedding, and redistribution (including commercially); it only forbids selling
|
||||
the font by itself and requires that this notice and the license text ship with
|
||||
it. Full license: `assets/OFL.txt`.
|
||||
|
||||
## Motion-capture clips — CMU Graphics Lab Motion Capture Database
|
||||
|
||||
`mocap/clips/*.json`, bundled into `mocap/clips.js`
|
||||
|
||||
The 2D motion clips are derived (via `mocap/bvh2clip.mjs`) from BVH files in the
|
||||
**CMU Graphics Lab Motion Capture Database** (http://mocap.cs.cmu.edu), obtained
|
||||
through the `una-dinosauria/cmu-mocap` mirror. The CMU database is **free for all
|
||||
uses** ("This data is free for use in research and commercial projects
|
||||
worldwide"). Per-clip source trial IDs are recorded in `mocap/catalog.json`
|
||||
(e.g. `walk` = CMU 02_01, `wave` = CMU 141_16).
|
||||
|
||||
No Meta / FAIR (AnimatedDrawings) mocap is bundled. The separate `/animated-drawing`
|
||||
capability documents Meta's AnimatedDrawings tool, but this engine's clip library
|
||||
is entirely CMU-sourced.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
Copyright (c) 2010-2012 Patrick Wagesreiter (mail@patrickwagesreiter.at)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
|
|
@ -0,0 +1,22 @@
|
|||
# Ink Theater examples
|
||||
|
||||
## `mocap-figure/` — the canonical runnable example
|
||||
|
||||
A self-contained HyperFrames composition: a pencil line **draws itself into a
|
||||
stick figure**, which then walks / runs / dances / kicks / sits / waves using
|
||||
**real CMU motion capture** (zero hand-tuning) via `InkPuppet.choreograph(...)`.
|
||||
|
||||
It bundles everything it needs (`ink-theater.js`, `ink-puppet.js`, `clips.js`,
|
||||
`assets/patrickhand.ttf`), so lint and render it directly:
|
||||
|
||||
```bash
|
||||
npx hyperframes lint ink-theater/examples/mocap-figure
|
||||
```
|
||||
|
||||
> Point the linter at a **composition directory** (one that contains an
|
||||
> `index.html`), not at `examples/` itself — the linter looks for `index.html`
|
||||
> and there is none at the `examples/` root.
|
||||
|
||||
To reuse the engine in your own project, copy `ink-theater.js` (+ `ink-puppet.js`
|
||||
and `mocap/clips.js` for characters, `assets/patrickhand.ttf` for handwriting)
|
||||
into the project root — see `../README.md`.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
Copyright (c) 2010-2012 Patrick Wagesreiter (mail@patrickwagesreiter.at)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,96 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-resolution="landscape">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="ink-theater.js"></script>
|
||||
<script src="ink-puppet.js"></script>
|
||||
<script src="clips.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@font-face { font-family: "InkHand"; src: url("assets/patrickhand.ttf") format("truetype"); font-display: block; }
|
||||
html, body { width: 1920px; height: 1080px; overflow: hidden; background: #fcfbf8; }
|
||||
#root { position: relative; width: 1920px; height: 1080px; overflow: hidden; }
|
||||
.stage { position: absolute; inset: 0; background: #fcfbf8; }
|
||||
.stage svg { position: absolute; inset: 0; display: block; width: 1920px; height: 1080px; }
|
||||
.cap { position: absolute; left: 0; width: 1920px; text-align: center; font-family: "InkHand", cursive; opacity: 0; }
|
||||
#opening { top: 200px; font-size: 66px; color: #333; }
|
||||
#action { top: 210px; font-size: 92px; color: #333; }
|
||||
#credit { bottom: 70px; font-size: 40px; color: #8a857a; }
|
||||
#endcap { top: 205px; font-size: 70px; color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="21" data-width="1920" data-height="1080">
|
||||
<section id="scene" class="clip" data-start="0" data-duration="21" data-track-index="1">
|
||||
<div class="stage">
|
||||
<svg viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="boil" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feTurbulence id="boilTurb" type="fractalNoise" baseFrequency="0.013" numOctaves="1" seed="1" result="n"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.8"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<path id="ground" d="" fill="none" stroke="#c3bdb0" stroke-width="3" stroke-linecap="round" opacity="0"/>
|
||||
<g id="mount"></g>
|
||||
</svg>
|
||||
<div id="opening" class="cap">watch a line become a character</div>
|
||||
<div id="action" class="cap">wave</div>
|
||||
<div id="endcap" class="cap">real motion capture · zero hand-tuning</div>
|
||||
<div id="credit" class="cap">ink puppet · the agent just chooses the moves</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
if (document.fonts && document.fonts.load) { document.fonts.load("90px InkHand"); }
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var GROUND = 902, CX = 960;
|
||||
|
||||
document.getElementById("ground").setAttribute("d",
|
||||
window.InkTheater.inkPath([[640, GROUND], [960, GROUND + 4], [1280, GROUND]], { wobble: 1.6, step: 60 }));
|
||||
|
||||
var pup = window.InkPuppet.create(document.getElementById("mount"), { cx: CX, ground: GROUND, boil: "boil", headR: 48, strokeWidth: 6 });
|
||||
|
||||
// 1) the pencil draws the figure
|
||||
pup.drawIn(tl, { start: 0.4, each: 0.5 });
|
||||
tl.fromTo("#ground", { opacity: 0 }, { opacity: 1, duration: 0.6 }, 2.4);
|
||||
tl.fromTo("#opening", { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: 0.6 }, 1.0);
|
||||
tl.to("#opening", { opacity: 0, duration: 0.4 }, 3.2);
|
||||
|
||||
// 2) it comes alive and performs — real mocap, chosen by name
|
||||
var segs = [
|
||||
{ clip: "walk", dur: 2.6, label: "it walks" },
|
||||
{ clip: "run", dur: 2.6, label: "it runs" },
|
||||
{ clip: "dance_spin", dur: 3.4, label: "it dances" },
|
||||
{ clip: "kick", dur: 2.6, label: "it kicks" },
|
||||
{ clip: "sit", dur: 3.4, label: "it sits" },
|
||||
{ clip: "wave", dur: 2.6, label: "it waves" }
|
||||
];
|
||||
window.InkPuppet.choreograph(tl, pup, segs, { start: 3.7 });
|
||||
|
||||
// action labels (one HTML div per move) + persistent credit
|
||||
tl.fromTo("#credit", { opacity: 0 }, { opacity: 1, duration: 0.6 }, 3.7);
|
||||
var stage = document.querySelector(".stage");
|
||||
var t = 3.7;
|
||||
segs.forEach(function (s) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "cap"; d.textContent = s.label;
|
||||
d.style.top = "208px"; d.style.fontSize = "94px"; d.style.color = "#333";
|
||||
stage.appendChild(d);
|
||||
tl.fromTo(d, { opacity: 0 }, { opacity: 1, duration: 0.4 }, t + 0.2);
|
||||
tl.to(d, { opacity: 0, duration: 0.35 }, t + s.dur - 0.4);
|
||||
t += s.dur;
|
||||
});
|
||||
|
||||
// 3) close
|
||||
tl.to("#credit", { opacity: 0, duration: 0.4 }, t + 0.1);
|
||||
tl.fromTo("#endcap", { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: 0.7 }, t + 0.2);
|
||||
|
||||
window.InkTheater.boil(document.getElementById("boilTurb"), tl, { duration: 18, fps: 10 });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Ink Puppet — plays real motion-capture "clips" (baked by mocap/bvh2clip.mjs)
|
||||
* onto a hand-drawn stick figure. Deterministic + seek-safe for HyperFrames.
|
||||
*
|
||||
* The agent never hand-tunes motion. It just:
|
||||
* var p = InkPuppet.create(mount, {cx, ground});
|
||||
* p.drawIn(tl, {start:0.3}); // pencil sketches the figure
|
||||
* InkPuppet.choreograph(tl, p, [ // then plays named mocap clips
|
||||
* {clip:'wave', dur:3},
|
||||
* {clip:'twist', dur:3},
|
||||
* {clip:'jump', dur:3.5},
|
||||
* {clip:'walk', dur:4}
|
||||
* ], {start:2.6});
|
||||
*
|
||||
* Clip names must exist in mocap/catalog.json (unknown names warn + hold).
|
||||
*
|
||||
* Clips come from window.INK_CLIPS (load clips.js). Motion = professional mocap.
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
var NS = "http://www.w3.org/2000/svg";
|
||||
var GR = "#333";
|
||||
function el(tag, a) { var n = document.createElementNS(NS, tag); if (a) for (var k in a) n.setAttribute(k, a[k]); return n; }
|
||||
function P(pt) { return (Math.round(pt[0] * 10) / 10) + " " + (Math.round(pt[1] * 10) / 10); }
|
||||
|
||||
var STAND = {
|
||||
hips: [0, 0], chest: [0, -88], neck: [0, -150], head: [0, -182],
|
||||
shR: [52, -140], elR: [64, -58], haR: [72, 26], shL: [-52, -140], elL: [-64, -58], haL: [-72, 26],
|
||||
hipR: [26, -6], knR: [32, 120], ftR: [40, 262], hipL: [-26, -6], knL: [-32, 120], ftL: [-40, 262],
|
||||
rootY: 0, groundY: 262
|
||||
};
|
||||
|
||||
function create(mount, opts) {
|
||||
opts = opts || {};
|
||||
var cx = opts.cx != null ? opts.cx : 960, ground = opts.ground != null ? opts.ground : 900;
|
||||
var sw = opts.strokeWidth || 6, headR = opts.headR || 46;
|
||||
var outer = el("g", {});
|
||||
var ink = el("g", opts.boil ? { filter: "url(#" + opts.boil + ")" } : {});
|
||||
var head = el("circle", { fill: "none", stroke: GR, "stroke-width": sw });
|
||||
function limb() { return el("path", { fill: "none", stroke: GR, "stroke-width": sw, "stroke-linecap": "round", "stroke-linejoin": "round" }); }
|
||||
var spine = limb(), armL = limb(), armR = limb(), legL = limb(), legR = limb();
|
||||
[spine, armL, armR, legL, legR, head].forEach(function (e) { ink.appendChild(e); });
|
||||
outer.appendChild(ink); mount.appendChild(outer);
|
||||
var parts = { head: head, spine: spine, armL: armL, armR: armR, legL: legL, legR: legR };
|
||||
var pup = {
|
||||
outer: outer, ink: ink, parts: parts, cx: cx, ground: ground, headR: headR,
|
||||
setPose: function (po) {
|
||||
head.setAttribute("cx", po.head[0]); head.setAttribute("cy", po.head[1] - headR * 0.32); head.setAttribute("r", headR);
|
||||
spine.setAttribute("d", "M " + P(po.hips) + " L " + P(po.chest) + " L " + P(po.neck) + " L " + P([po.head[0], po.head[1] + headR * 0.5]));
|
||||
armR.setAttribute("d", "M " + P(po.shR) + " L " + P(po.elR) + " L " + P(po.haR));
|
||||
armL.setAttribute("d", "M " + P(po.shL) + " L " + P(po.elL) + " L " + P(po.haL));
|
||||
legR.setAttribute("d", "M " + P(po.hipR) + " L " + P(po.knR) + " L " + P(po.ftR));
|
||||
legL.setAttribute("d", "M " + P(po.hipL) + " L " + P(po.knL) + " L " + P(po.ftL));
|
||||
},
|
||||
place: function (groundY, rootY) { outer.setAttribute("transform", "translate(" + cx + "," + (ground - groundY + (rootY || 0)) + ")"); },
|
||||
// pencil sketches the figure limb-by-limb, then holds
|
||||
drawIn: function (tl, o) {
|
||||
o = o || {}; var t0 = o.start != null ? o.start : 0.3, each = o.each || 0.55;
|
||||
pup.setPose(STAND); pup.place(STAND.groundY, 0);
|
||||
var seq = [head, spine, armL, armR, legL, legR], done = t0 + seq.length * each * 0.6 + each;
|
||||
seq.forEach(function (e, i) {
|
||||
var L = e.getTotalLength(); e.style.strokeDasharray = L; e.style.strokeDashoffset = L;
|
||||
tl.to(e, { strokeDashoffset: 0, duration: each, ease: "power1.inOut" }, t0 + i * each * 0.6);
|
||||
});
|
||||
seq.forEach(function (e) { tl.set(e, { strokeDasharray: "none" }, done); });
|
||||
pup._revealEnd = done;
|
||||
return done;
|
||||
}
|
||||
};
|
||||
pup.setPose(STAND); pup.place(STAND.groundY, 0);
|
||||
return pup;
|
||||
}
|
||||
|
||||
// Sequence named mocap clips on the timeline. Each clip loops at native fps to
|
||||
// fill its duration. Seek-safe: pose is a pure function of the segment's local time.
|
||||
function choreograph(tl, pup, segments, opts) {
|
||||
opts = opts || {};
|
||||
var t = opts.start != null ? opts.start : 0;
|
||||
var CLIPS = root.INK_CLIPS || {};
|
||||
segments.forEach(function (seg) {
|
||||
var clip = CLIPS[seg.clip];
|
||||
if (!clip) {
|
||||
console.warn('[InkPuppet] unknown clip "' + seg.clip + '" — skipping ' + seg.dur + 's (holds pose). Known clips: ' + Object.keys(CLIPS).join(", "));
|
||||
t += seg.dur; return;
|
||||
}
|
||||
var proxy = { u: 0 };
|
||||
tl.to(proxy, {
|
||||
u: 1, duration: seg.dur, ease: "none",
|
||||
onUpdate: function () {
|
||||
var lt = proxy.u * seg.dur;
|
||||
var idx = Math.floor(lt * clip.fps);
|
||||
idx = seg.loop === false ? Math.min(idx, clip.frames.length - 1) : idx % clip.frames.length;
|
||||
var fr = clip.frames[idx];
|
||||
pup.setPose(fr);
|
||||
pup.place(clip.groundY, fr.rootY || 0);
|
||||
}
|
||||
}, t);
|
||||
t += seg.dur;
|
||||
});
|
||||
return t;
|
||||
}
|
||||
|
||||
root.InkPuppet = { create: create, choreograph: choreograph, STAND: STAND };
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
/*
|
||||
* Ink Theater — a deterministic, seek-safe engine for hand-drawn "moving art".
|
||||
* Browser global `InkTheater`. Designed for HyperFrames: every primitive is a pure
|
||||
* function of geometry or of the GSAP timeline playhead, so frame N is reproducible
|
||||
* from time alone. No render-time clocks, no Math.random at runtime.
|
||||
*
|
||||
* Modules:
|
||||
* rng / noise — seeded determinism
|
||||
* inkPath/inkRibbon — hand-drawn variable-width strokes (the "look")
|
||||
* boil — seek-safe stepped line-boil driven off the timeline
|
||||
* springEase / ease — closed-form damped-spring eases (anticipation/overshoot/settle)
|
||||
* fabrik — 2D inverse kinematics for limb rigs
|
||||
* parts — parametric, composable low-tech contraption pieces (the grammar)
|
||||
* mascot — a riggable deadpan ink character
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
var SVGNS = "http://www.w3.org/2000/svg";
|
||||
|
||||
// ---- DOM helper ------------------------------------------------------------
|
||||
function el(tag, attrs, kids) {
|
||||
var n = document.createElementNS(SVGNS, tag);
|
||||
if (attrs) for (var k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
|
||||
if (kids) (Array.isArray(kids) ? kids : [kids]).forEach(function (c) { if (c) n.appendChild(c); });
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- Seeded determinism ----------------------------------------------------
|
||||
function rng(seed) { // mulberry32
|
||||
var a = (seed >>> 0) || 1;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
var t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Path math -------------------------------------------------------------
|
||||
function resample(pts, step) {
|
||||
if (pts.length < 2) return pts.slice();
|
||||
var out = [pts[0]], acc = 0, i, prev = pts[0];
|
||||
for (i = 1; i < pts.length; i++) {
|
||||
var p = pts[i], dx = p[0] - prev[0], dy = p[1] - prev[1], d = Math.hypot(dx, dy);
|
||||
while (acc + d >= step) {
|
||||
var t = (step - acc) / d;
|
||||
prev = [prev[0] + dx * t, prev[1] + dy * t];
|
||||
out.push(prev);
|
||||
dx = p[0] - prev[0]; dy = p[1] - prev[1]; d = Math.hypot(dx, dy); acc = 0;
|
||||
}
|
||||
acc += d; prev = p;
|
||||
}
|
||||
out.push(pts[pts.length - 1]);
|
||||
return out;
|
||||
}
|
||||
function normals(pts) {
|
||||
var ns = [], i;
|
||||
for (i = 0; i < pts.length; i++) {
|
||||
var a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
|
||||
var tx = b[0] - a[0], ty = b[1] - a[1], L = Math.hypot(tx, ty) || 1;
|
||||
ns.push([-ty / L, tx / L]);
|
||||
}
|
||||
return ns;
|
||||
}
|
||||
// Catmull-Rom -> cubic Bézier 'd' (smooth curve through points)
|
||||
function smoothD(pts, closed) {
|
||||
if (pts.length < 2) return "";
|
||||
var d = "M" + r2(pts[0][0]) + " " + r2(pts[0][1]), i, n = pts.length;
|
||||
for (i = 0; i < n - 1; i++) {
|
||||
var p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
|
||||
var c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
|
||||
var c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
|
||||
d += "C" + r2(c1x) + " " + r2(c1y) + " " + r2(c2x) + " " + r2(c2y) + " " + r2(p2[0]) + " " + r2(p2[1]);
|
||||
}
|
||||
return d + (closed ? "Z" : "");
|
||||
}
|
||||
function r2(x) { return Math.round(x * 100) / 100; }
|
||||
|
||||
// Bake a small deterministic hand-wobble into a polyline (perpendicular jitter).
|
||||
function wobblePts(pts, amp, seed) {
|
||||
var rand = rng(seed || 1), ns = normals(pts);
|
||||
return pts.map(function (p, i) {
|
||||
var j = (rand() - 0.5) * 2 * amp;
|
||||
return [p[0] + ns[i][0] * j, p[1] + ns[i][1] * j];
|
||||
});
|
||||
}
|
||||
|
||||
// A confident hand-drawn centerline (use with stroke + round caps).
|
||||
function inkPath(pts, opt) {
|
||||
opt = opt || {};
|
||||
var P = wobblePts(resample(pts, opt.step || 26), opt.wobble != null ? opt.wobble : 1.6, opt.seed || 7);
|
||||
return smoothD(P, opt.closed);
|
||||
}
|
||||
|
||||
// A variable-width brush ribbon (returns a CLOSED outline, fill it black).
|
||||
// Width tapers at the ends and swells in the middle — the difference between
|
||||
// a drawn line and a traced one.
|
||||
function inkRibbon(pts, opt) {
|
||||
opt = opt || {};
|
||||
var w = opt.width || 12, taper = opt.taper != null ? opt.taper : 0.55, seed = opt.seed || 5;
|
||||
var P = wobblePts(resample(pts, opt.step || 22), opt.wobble != null ? opt.wobble : 1.2, seed);
|
||||
var ns = normals(P), n = P.length, rand = rng(seed + 99);
|
||||
var left = [], right = [], i;
|
||||
for (i = 0; i < n; i++) {
|
||||
var t = n > 1 ? i / (n - 1) : 0;
|
||||
// width profile: 0 at ends, 1 in the belly; taper controls end thinness
|
||||
var prof = Math.pow(Math.sin(Math.PI * t), taper) * (0.85 + 0.3 * rand());
|
||||
var hw = (w / 2) * prof + 0.6;
|
||||
left.push([P[i][0] + ns[i][0] * hw, P[i][1] + ns[i][1] * hw]);
|
||||
right.push([P[i][0] - ns[i][0] * hw, P[i][1] - ns[i][1] * hw]);
|
||||
}
|
||||
return smoothD(left, false) + " " + smoothD(right.reverse(), false).replace(/^M/, "L") + "Z";
|
||||
}
|
||||
|
||||
// ---- Seek-safe line boil ---------------------------------------------------
|
||||
// Steps a feTurbulence seed across the timeline so the inked lines "boil" at a
|
||||
// low frame-rate (hand-drawn feel) while staying 100% deterministic under seek.
|
||||
function boil(turbulenceEl, tl, opt) {
|
||||
opt = opt || {};
|
||||
var dur = opt.duration || tl.duration() || 8;
|
||||
var fps = opt.fps || 9;
|
||||
var steps = Math.max(1, Math.round(dur * fps));
|
||||
tl.to(turbulenceEl, { attr: { seed: steps }, duration: dur, ease: "steps(" + steps + ")" }, 0);
|
||||
return turbulenceEl;
|
||||
}
|
||||
|
||||
// ---- Closed-form damped spring eases --------------------------------------
|
||||
// Step response of a damped harmonic oscillator, normalized to settle at 1.
|
||||
// Underdamped => overshoot + settle (the juicy follow-through). Pure fn of p.
|
||||
function springEase(opt) {
|
||||
opt = opt || {};
|
||||
var stiffness = opt.stiffness || 170, damping = opt.damping || 14, mass = opt.mass || 1;
|
||||
var w = Math.sqrt(stiffness / mass); // natural angular frequency
|
||||
var zeta = damping / (2 * Math.sqrt(stiffness * mass)); // damping ratio
|
||||
var horizon = opt.horizon || 1; // p maps to t in [0, horizon*settle]
|
||||
// choose a time-scale so motion is essentially settled at p=1
|
||||
var settle = 8 / (zeta * w || 1);
|
||||
function y(t) {
|
||||
if (zeta < 1) {
|
||||
var wd = w * Math.sqrt(1 - zeta * zeta);
|
||||
return 1 - Math.exp(-zeta * w * t) * (Math.cos(wd * t) + (zeta * w / wd) * Math.sin(wd * t));
|
||||
} else if (zeta === 1) {
|
||||
return 1 - Math.exp(-w * t) * (1 + w * t);
|
||||
} else {
|
||||
var a = -w * (zeta - Math.sqrt(zeta * zeta - 1)), b = -w * (zeta + Math.sqrt(zeta * zeta - 1));
|
||||
var A = b / (b - a), B = -a / (b - a);
|
||||
return 1 - (A * Math.exp(a * t) + B * Math.exp(b * t));
|
||||
}
|
||||
}
|
||||
var f = function (p) {
|
||||
if (p <= 0) return 0; if (p >= 1) return 1; // pin endpoints
|
||||
return y(p * settle * horizon);
|
||||
};
|
||||
return f;
|
||||
}
|
||||
var ease = {
|
||||
settle: springEase({ stiffness: 180, damping: 18 }), // minimal overshoot
|
||||
overshoot: springEase({ stiffness: 200, damping: 11 }),// clear overshoot + settle
|
||||
bouncy: springEase({ stiffness: 260, damping: 8 }), // springy
|
||||
soft: springEase({ stiffness: 120, damping: 20 }) // gentle, no overshoot
|
||||
};
|
||||
|
||||
// ---- FABRIK 2D inverse kinematics -----------------------------------------
|
||||
// lengths: array of segment lengths (n segments -> n+1 joints). Returns joint pts.
|
||||
function fabrik(lengths, origin, target, opt) {
|
||||
opt = opt || {};
|
||||
var iters = opt.iterations || 12, tol = opt.tol || 0.4;
|
||||
var n = lengths.length, total = lengths.reduce(function (a, b) { return a + b; }, 0);
|
||||
var pts = [origin.slice()], i;
|
||||
for (i = 0; i < n; i++) pts.push([origin[0] + lengths[i] * (i + 1), origin[1]]); // seed straight
|
||||
var dist = Math.hypot(target[0] - origin[0], target[1] - origin[1]);
|
||||
if (dist > total) { // unreachable: stretch straight
|
||||
var ux = (target[0] - origin[0]) / dist, uy = (target[1] - origin[1]) / dist, acc = origin.slice();
|
||||
pts[0] = origin.slice();
|
||||
for (i = 0; i < n; i++) { acc = [acc[0] + ux * lengths[i], acc[1] + uy * lengths[i]]; pts[i + 1] = acc.slice(); }
|
||||
return pts;
|
||||
}
|
||||
for (var it = 0; it < iters; it++) {
|
||||
pts[n] = target.slice(); // backward
|
||||
for (i = n - 1; i >= 0; i--) pts[i] = lerpTo(pts[i + 1], pts[i], lengths[i]);
|
||||
pts[0] = origin.slice(); // forward
|
||||
for (i = 0; i < n; i++) pts[i + 1] = lerpTo(pts[i], pts[i + 1], lengths[i]);
|
||||
if (Math.hypot(pts[n][0] - target[0], pts[n][1] - target[1]) < tol) break;
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
function lerpTo(from, to, len) {
|
||||
var dx = to[0] - from[0], dy = to[1] - from[1], d = Math.hypot(dx, dy) || 1;
|
||||
return [from[0] + dx / d * len, from[1] + dy / d * len];
|
||||
}
|
||||
|
||||
// ---- Contraption grammar — parametric composable parts --------------------
|
||||
// Each returns { g: <g>, ... } with named handles the author wires to motion.
|
||||
var INK = "#1a1a1a";
|
||||
function strokeEl(d, sw) { return el("path", { d: d, fill: "none", stroke: INK, "stroke-width": sw || 4, "stroke-linecap": "round", "stroke-linejoin": "round" }); }
|
||||
var parts = {
|
||||
// crank wheel with a handle on the rim — spin the returned `wheel` group
|
||||
crank: function (cx, cy, r) {
|
||||
var wheel = el("g", { transform: "" });
|
||||
wheel.appendChild(el("circle", { cx: cx, cy: cy, r: r, fill: "#fff", stroke: INK, "stroke-width": 4 }));
|
||||
wheel.appendChild(el("line", { x1: cx, y1: cy, x2: cx, y2: cy - r + 4, stroke: INK, "stroke-width": 2.4 }));
|
||||
wheel.appendChild(el("circle", { cx: cx, cy: cy - r + 8, r: r * 0.16, fill: INK })); // handle
|
||||
var g = el("g", null, [wheel, el("circle", { cx: cx, cy: cy, r: 3, fill: INK })]);
|
||||
return { g: g, wheel: wheel, pivot: [cx, cy] };
|
||||
},
|
||||
// gauge with a needle — swing `needle` (pivot at center)
|
||||
gauge: function (cx, cy, r) {
|
||||
var needle = el("line", { x1: cx, y1: cy, x2: cx, y2: cy - r + 6, stroke: INK, "stroke-width": 4 });
|
||||
var g = el("g", null, [
|
||||
el("circle", { cx: cx, cy: cy, r: r, fill: "#fff", stroke: INK, "stroke-width": 4 }),
|
||||
strokeEl("M" + (cx - r * 0.7) + " " + (cy - r * 0.2) + " A " + (r * 0.7) + " " + (r * 0.7) + " 0 0 1 " + (cx + r * 0.7) + " " + (cy - r * 0.2), 2),
|
||||
needle, el("circle", { cx: cx, cy: cy, r: 3.5, fill: INK })
|
||||
]);
|
||||
return { g: g, needle: needle, pivot: [cx, cy] };
|
||||
},
|
||||
hopper: function (cx, topY, mouthW, depth) {
|
||||
var hw = mouthW / 2;
|
||||
return { g: strokeEl("M" + (cx - hw * 0.5) + " " + (topY + depth) + " L" + (cx - hw) + " " + topY + " L" + (cx + hw) + " " + topY + " L" + (cx + hw * 0.5) + " " + (topY + depth)), mouth: [cx, topY] };
|
||||
},
|
||||
slot: function (x, y, w, h) { return { g: el("rect", { x: x, y: y, width: w, height: h, rx: 2, fill: INK }), out: [x + w, y + h / 2] }; },
|
||||
lever: function (cx, cy, len) {
|
||||
var arm = el("line", { x1: cx, y1: cy, x2: cx + len, y2: cy - len * 0.2, stroke: INK, "stroke-width": 6, "stroke-linecap": "round" });
|
||||
var knob = el("circle", { cx: cx + len, cy: cy - len * 0.2, r: 9, fill: INK });
|
||||
return { g: el("g", null, [el("circle", { cx: cx, cy: cy, r: 5, fill: INK }), arm, knob]), arm: arm, knob: knob, pivot: [cx, cy] };
|
||||
},
|
||||
box: function (x, y, w, h, label) {
|
||||
var g = el("g", null, [el("rect", { x: x, y: y, width: w, height: h, rx: 6, fill: "#fff", stroke: INK, "stroke-width": 4 })]);
|
||||
if (label) { var t = el("text", { x: x + w / 2, y: y + h / 2 + 6, "text-anchor": "middle", fill: INK, "font-size": 22, "font-family": "Caveat, cursive" }); t.textContent = label; g.appendChild(t); }
|
||||
return { g: g };
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Mascot ----------------------------------------------------------------
|
||||
// A deadpan ink blob with two FABRIK-posable arms.
|
||||
function mascot(opt) {
|
||||
opt = opt || {};
|
||||
var x = opt.x || 0, y = opt.y || 0, s = opt.scale || 1;
|
||||
var bw = 40 * s, bh = 50 * s;
|
||||
var body = el("ellipse", { cx: x, cy: y, rx: bw, ry: bh, fill: INK });
|
||||
var legL = el("path", { d: "M" + (x - 12 * s) + " " + (y + bh * 0.92) + " l" + (-4 * s) + " " + (24 * s), stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round" });
|
||||
var legR = el("path", { d: "M" + (x + 12 * s) + " " + (y + bh * 0.92) + " l" + (4 * s) + " " + (24 * s), stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round" });
|
||||
var eyeL = el("circle", { cx: x - 12 * s, cy: y - 16 * s, r: 7 * s, fill: "#fff" });
|
||||
var eyeR = el("circle", { cx: x + 12 * s, cy: y - 16 * s, r: 7 * s, fill: "#fff" });
|
||||
var armL = el("path", { d: "", stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" });
|
||||
var armR = el("path", { d: "", stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" });
|
||||
var shoulderL = [x - bw * 0.7, y - 2 * s], shoulderR = [x + bw * 0.7, y - 2 * s];
|
||||
var seg = [32 * s, 30 * s];
|
||||
function poseArm(pathEl, shoulder, target) {
|
||||
var j = fabrik(seg, shoulder, target);
|
||||
pathEl.setAttribute("d", "M" + r2(j[0][0]) + " " + r2(j[0][1]) + " L" + r2(j[1][0]) + " " + r2(j[1][1]) + " L" + r2(j[2][0]) + " " + r2(j[2][1]));
|
||||
}
|
||||
poseArm(armL, shoulderL, [shoulderL[0] - 18 * s, shoulderL[1] + 30 * s]);
|
||||
poseArm(armR, shoulderR, [shoulderR[0] + 18 * s, shoulderR[1] + 30 * s]);
|
||||
var g = el("g", null, [armL, armR, body, legL, legR, eyeL, eyeR]);
|
||||
return {
|
||||
g: g, body: body, eyeL: eyeL, eyeR: eyeR, armL: armL, armR: armR,
|
||||
origin: [x, y], shoulderL: shoulderL, shoulderR: shoulderR,
|
||||
reachL: function (t) { poseArm(armL, shoulderL, t); },
|
||||
reachR: function (t) { poseArm(armR, shoulderR, t); }
|
||||
};
|
||||
}
|
||||
|
||||
// ---- comic speech balloon (the "characters talking" primitive) ----
|
||||
// Builds an ink bubble + tail (grows from the mouth) and an HTML overlay text,
|
||||
// and animates it on the timeline. Text is HTML so the webfont applies.
|
||||
// balloon(tl, { into:<g>, overlay:<div>, at, dur, text, mouth:[x,y],
|
||||
// center:[x,y], w, size, font, boil })
|
||||
function balloon(tl, o) {
|
||||
o = o || {};
|
||||
var g = root.gsap;
|
||||
var bx = (o.center && o.center[0]) || 1030, by = (o.center && o.center[1]) || 320;
|
||||
var w = o.w || Math.max(200, String(o.text || "").length * 26), h = o.h || 116;
|
||||
var mouth = o.mouth || [bx - 260, by + 130];
|
||||
var grp = el("g", o.boil ? { filter: "url(#" + o.boil + ")" } : {});
|
||||
grp.appendChild(el("rect", { x: bx - w / 2, y: by - h / 2, width: w, height: h, rx: h / 2, fill: "#fff", stroke: INK, "stroke-width": 3.4 }));
|
||||
grp.appendChild(el("path", { d: "M " + (bx - 40) + " " + (by + h / 2 - 6) + " L " + mouth[0] + " " + mouth[1] + " L " + (bx - 4) + " " + (by + h / 2 - 2) + " Z", fill: "#fff", stroke: INK, "stroke-width": 3.4, "stroke-linejoin": "round" }));
|
||||
(o.into || document.body).appendChild(grp);
|
||||
var d = document.createElement("div");
|
||||
d.textContent = o.text;
|
||||
d.style.cssText = "position:absolute;display:flex;align-items:center;justify-content:center;text-align:center;opacity:0;color:" + INK +
|
||||
";font-family:'" + (o.font || "InkHand") + "',cursive;left:" + (bx - w / 2) + "px;top:" + (by - h / 2) + "px;width:" + w + "px;height:" + h + "px;font-size:" + (o.size || 50) + "px";
|
||||
(o.overlay || document.body).appendChild(d);
|
||||
if (tl && g) {
|
||||
g.set(grp, { svgOrigin: mouth[0] + " " + mouth[1], scale: 0 });
|
||||
tl.to(grp, { scale: 1, duration: 0.4, ease: ease.overshoot }, o.at || 0);
|
||||
tl.to(d, { opacity: 1, duration: 0.25 }, (o.at || 0) + 0.18);
|
||||
tl.to([grp, d], { opacity: 0, duration: 0.3 }, (o.at || 0) + (o.dur || 2) - 0.3);
|
||||
}
|
||||
return { group: grp, text: d };
|
||||
}
|
||||
|
||||
root.InkTheater = {
|
||||
el: el, rng: rng, resample: resample, smoothD: smoothD, wobblePts: wobblePts,
|
||||
inkPath: inkPath, inkRibbon: inkRibbon, boil: boil,
|
||||
springEase: springEase, ease: ease, fabrik: fabrik,
|
||||
parts: parts, mascot: mascot, balloon: balloon, INK: INK
|
||||
};
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Ink Puppet — plays real motion-capture "clips" (baked by mocap/bvh2clip.mjs)
|
||||
* onto a hand-drawn stick figure. Deterministic + seek-safe for HyperFrames.
|
||||
*
|
||||
* The agent never hand-tunes motion. It just:
|
||||
* var p = InkPuppet.create(mount, {cx, ground});
|
||||
* p.drawIn(tl, {start:0.3}); // pencil sketches the figure
|
||||
* InkPuppet.choreograph(tl, p, [ // then plays named mocap clips
|
||||
* {clip:'wave', dur:3},
|
||||
* {clip:'twist', dur:3},
|
||||
* {clip:'jump', dur:3.5},
|
||||
* {clip:'walk', dur:4}
|
||||
* ], {start:2.6});
|
||||
*
|
||||
* Clip names must exist in mocap/catalog.json (unknown names warn + hold).
|
||||
*
|
||||
* Clips come from window.INK_CLIPS (load clips.js). Motion = professional mocap.
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
var NS = "http://www.w3.org/2000/svg";
|
||||
var GR = "#333";
|
||||
function el(tag, a) { var n = document.createElementNS(NS, tag); if (a) for (var k in a) n.setAttribute(k, a[k]); return n; }
|
||||
function P(pt) { return (Math.round(pt[0] * 10) / 10) + " " + (Math.round(pt[1] * 10) / 10); }
|
||||
|
||||
var STAND = {
|
||||
hips: [0, 0], chest: [0, -88], neck: [0, -150], head: [0, -182],
|
||||
shR: [52, -140], elR: [64, -58], haR: [72, 26], shL: [-52, -140], elL: [-64, -58], haL: [-72, 26],
|
||||
hipR: [26, -6], knR: [32, 120], ftR: [40, 262], hipL: [-26, -6], knL: [-32, 120], ftL: [-40, 262],
|
||||
rootY: 0, groundY: 262
|
||||
};
|
||||
|
||||
function create(mount, opts) {
|
||||
opts = opts || {};
|
||||
var cx = opts.cx != null ? opts.cx : 960, ground = opts.ground != null ? opts.ground : 900;
|
||||
var sw = opts.strokeWidth || 6, headR = opts.headR || 46;
|
||||
var outer = el("g", {});
|
||||
var ink = el("g", opts.boil ? { filter: "url(#" + opts.boil + ")" } : {});
|
||||
var head = el("circle", { fill: "none", stroke: GR, "stroke-width": sw });
|
||||
function limb() { return el("path", { fill: "none", stroke: GR, "stroke-width": sw, "stroke-linecap": "round", "stroke-linejoin": "round" }); }
|
||||
var spine = limb(), armL = limb(), armR = limb(), legL = limb(), legR = limb();
|
||||
[spine, armL, armR, legL, legR, head].forEach(function (e) { ink.appendChild(e); });
|
||||
outer.appendChild(ink); mount.appendChild(outer);
|
||||
var parts = { head: head, spine: spine, armL: armL, armR: armR, legL: legL, legR: legR };
|
||||
var pup = {
|
||||
outer: outer, ink: ink, parts: parts, cx: cx, ground: ground, headR: headR,
|
||||
setPose: function (po) {
|
||||
head.setAttribute("cx", po.head[0]); head.setAttribute("cy", po.head[1] - headR * 0.32); head.setAttribute("r", headR);
|
||||
spine.setAttribute("d", "M " + P(po.hips) + " L " + P(po.chest) + " L " + P(po.neck) + " L " + P([po.head[0], po.head[1] + headR * 0.5]));
|
||||
armR.setAttribute("d", "M " + P(po.shR) + " L " + P(po.elR) + " L " + P(po.haR));
|
||||
armL.setAttribute("d", "M " + P(po.shL) + " L " + P(po.elL) + " L " + P(po.haL));
|
||||
legR.setAttribute("d", "M " + P(po.hipR) + " L " + P(po.knR) + " L " + P(po.ftR));
|
||||
legL.setAttribute("d", "M " + P(po.hipL) + " L " + P(po.knL) + " L " + P(po.ftL));
|
||||
},
|
||||
place: function (groundY, rootY) { outer.setAttribute("transform", "translate(" + cx + "," + (ground - groundY + (rootY || 0)) + ")"); },
|
||||
// pencil sketches the figure limb-by-limb, then holds
|
||||
drawIn: function (tl, o) {
|
||||
o = o || {}; var t0 = o.start != null ? o.start : 0.3, each = o.each || 0.55;
|
||||
pup.setPose(STAND); pup.place(STAND.groundY, 0);
|
||||
var seq = [head, spine, armL, armR, legL, legR], done = t0 + seq.length * each * 0.6 + each;
|
||||
seq.forEach(function (e, i) {
|
||||
var L = e.getTotalLength(); e.style.strokeDasharray = L; e.style.strokeDashoffset = L;
|
||||
tl.to(e, { strokeDashoffset: 0, duration: each, ease: "power1.inOut" }, t0 + i * each * 0.6);
|
||||
});
|
||||
seq.forEach(function (e) { tl.set(e, { strokeDasharray: "none" }, done); });
|
||||
pup._revealEnd = done;
|
||||
return done;
|
||||
}
|
||||
};
|
||||
pup.setPose(STAND); pup.place(STAND.groundY, 0);
|
||||
return pup;
|
||||
}
|
||||
|
||||
// Sequence named mocap clips on the timeline. Each clip loops at native fps to
|
||||
// fill its duration. Seek-safe: pose is a pure function of the segment's local time.
|
||||
function choreograph(tl, pup, segments, opts) {
|
||||
opts = opts || {};
|
||||
var t = opts.start != null ? opts.start : 0;
|
||||
var CLIPS = root.INK_CLIPS || {};
|
||||
segments.forEach(function (seg) {
|
||||
var clip = CLIPS[seg.clip];
|
||||
if (!clip) {
|
||||
console.warn('[InkPuppet] unknown clip "' + seg.clip + '" — skipping ' + seg.dur + 's (holds pose). Known clips: ' + Object.keys(CLIPS).join(", "));
|
||||
t += seg.dur; return;
|
||||
}
|
||||
var proxy = { u: 0 };
|
||||
tl.to(proxy, {
|
||||
u: 1, duration: seg.dur, ease: "none",
|
||||
onUpdate: function () {
|
||||
var lt = proxy.u * seg.dur;
|
||||
var idx = Math.floor(lt * clip.fps);
|
||||
idx = seg.loop === false ? Math.min(idx, clip.frames.length - 1) : idx % clip.frames.length;
|
||||
var fr = clip.frames[idx];
|
||||
pup.setPose(fr);
|
||||
pup.place(clip.groundY, fr.rootY || 0);
|
||||
}
|
||||
}, t);
|
||||
t += seg.dur;
|
||||
});
|
||||
return t;
|
||||
}
|
||||
|
||||
root.InkPuppet = { create: create, choreograph: choreograph, STAND: STAND };
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
/*
|
||||
* Ink Theater — a deterministic, seek-safe engine for hand-drawn "moving art".
|
||||
* Browser global `InkTheater`. Designed for HyperFrames: every primitive is a pure
|
||||
* function of geometry or of the GSAP timeline playhead, so frame N is reproducible
|
||||
* from time alone. No render-time clocks, no Math.random at runtime.
|
||||
*
|
||||
* Modules:
|
||||
* rng / noise — seeded determinism
|
||||
* inkPath/inkRibbon — hand-drawn variable-width strokes (the "look")
|
||||
* boil — seek-safe stepped line-boil driven off the timeline
|
||||
* springEase / ease — closed-form damped-spring eases (anticipation/overshoot/settle)
|
||||
* fabrik — 2D inverse kinematics for limb rigs
|
||||
* parts — parametric, composable low-tech contraption pieces (the grammar)
|
||||
* mascot — a riggable deadpan ink character
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
var SVGNS = "http://www.w3.org/2000/svg";
|
||||
|
||||
// ---- DOM helper ------------------------------------------------------------
|
||||
function el(tag, attrs, kids) {
|
||||
var n = document.createElementNS(SVGNS, tag);
|
||||
if (attrs) for (var k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
|
||||
if (kids) (Array.isArray(kids) ? kids : [kids]).forEach(function (c) { if (c) n.appendChild(c); });
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- Seeded determinism ----------------------------------------------------
|
||||
function rng(seed) { // mulberry32
|
||||
var a = (seed >>> 0) || 1;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
var t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Path math -------------------------------------------------------------
|
||||
function resample(pts, step) {
|
||||
if (pts.length < 2) return pts.slice();
|
||||
var out = [pts[0]], acc = 0, i, prev = pts[0];
|
||||
for (i = 1; i < pts.length; i++) {
|
||||
var p = pts[i], dx = p[0] - prev[0], dy = p[1] - prev[1], d = Math.hypot(dx, dy);
|
||||
while (acc + d >= step) {
|
||||
var t = (step - acc) / d;
|
||||
prev = [prev[0] + dx * t, prev[1] + dy * t];
|
||||
out.push(prev);
|
||||
dx = p[0] - prev[0]; dy = p[1] - prev[1]; d = Math.hypot(dx, dy); acc = 0;
|
||||
}
|
||||
acc += d; prev = p;
|
||||
}
|
||||
out.push(pts[pts.length - 1]);
|
||||
return out;
|
||||
}
|
||||
function normals(pts) {
|
||||
var ns = [], i;
|
||||
for (i = 0; i < pts.length; i++) {
|
||||
var a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
|
||||
var tx = b[0] - a[0], ty = b[1] - a[1], L = Math.hypot(tx, ty) || 1;
|
||||
ns.push([-ty / L, tx / L]);
|
||||
}
|
||||
return ns;
|
||||
}
|
||||
// Catmull-Rom -> cubic Bézier 'd' (smooth curve through points)
|
||||
function smoothD(pts, closed) {
|
||||
if (pts.length < 2) return "";
|
||||
var d = "M" + r2(pts[0][0]) + " " + r2(pts[0][1]), i, n = pts.length;
|
||||
for (i = 0; i < n - 1; i++) {
|
||||
var p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
|
||||
var c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
|
||||
var c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
|
||||
d += "C" + r2(c1x) + " " + r2(c1y) + " " + r2(c2x) + " " + r2(c2y) + " " + r2(p2[0]) + " " + r2(p2[1]);
|
||||
}
|
||||
return d + (closed ? "Z" : "");
|
||||
}
|
||||
function r2(x) { return Math.round(x * 100) / 100; }
|
||||
|
||||
// Bake a small deterministic hand-wobble into a polyline (perpendicular jitter).
|
||||
function wobblePts(pts, amp, seed) {
|
||||
var rand = rng(seed || 1), ns = normals(pts);
|
||||
return pts.map(function (p, i) {
|
||||
var j = (rand() - 0.5) * 2 * amp;
|
||||
return [p[0] + ns[i][0] * j, p[1] + ns[i][1] * j];
|
||||
});
|
||||
}
|
||||
|
||||
// A confident hand-drawn centerline (use with stroke + round caps).
|
||||
function inkPath(pts, opt) {
|
||||
opt = opt || {};
|
||||
var P = wobblePts(resample(pts, opt.step || 26), opt.wobble != null ? opt.wobble : 1.6, opt.seed || 7);
|
||||
return smoothD(P, opt.closed);
|
||||
}
|
||||
|
||||
// A variable-width brush ribbon (returns a CLOSED outline, fill it black).
|
||||
// Width tapers at the ends and swells in the middle — the difference between
|
||||
// a drawn line and a traced one.
|
||||
function inkRibbon(pts, opt) {
|
||||
opt = opt || {};
|
||||
var w = opt.width || 12, taper = opt.taper != null ? opt.taper : 0.55, seed = opt.seed || 5;
|
||||
var P = wobblePts(resample(pts, opt.step || 22), opt.wobble != null ? opt.wobble : 1.2, seed);
|
||||
var ns = normals(P), n = P.length, rand = rng(seed + 99);
|
||||
var left = [], right = [], i;
|
||||
for (i = 0; i < n; i++) {
|
||||
var t = n > 1 ? i / (n - 1) : 0;
|
||||
// width profile: 0 at ends, 1 in the belly; taper controls end thinness
|
||||
var prof = Math.pow(Math.sin(Math.PI * t), taper) * (0.85 + 0.3 * rand());
|
||||
var hw = (w / 2) * prof + 0.6;
|
||||
left.push([P[i][0] + ns[i][0] * hw, P[i][1] + ns[i][1] * hw]);
|
||||
right.push([P[i][0] - ns[i][0] * hw, P[i][1] - ns[i][1] * hw]);
|
||||
}
|
||||
return smoothD(left, false) + " " + smoothD(right.reverse(), false).replace(/^M/, "L") + "Z";
|
||||
}
|
||||
|
||||
// ---- Seek-safe line boil ---------------------------------------------------
|
||||
// Steps a feTurbulence seed across the timeline so the inked lines "boil" at a
|
||||
// low frame-rate (hand-drawn feel) while staying 100% deterministic under seek.
|
||||
function boil(turbulenceEl, tl, opt) {
|
||||
opt = opt || {};
|
||||
var dur = opt.duration || tl.duration() || 8;
|
||||
var fps = opt.fps || 9;
|
||||
var steps = Math.max(1, Math.round(dur * fps));
|
||||
tl.to(turbulenceEl, { attr: { seed: steps }, duration: dur, ease: "steps(" + steps + ")" }, 0);
|
||||
return turbulenceEl;
|
||||
}
|
||||
|
||||
// ---- Closed-form damped spring eases --------------------------------------
|
||||
// Step response of a damped harmonic oscillator, normalized to settle at 1.
|
||||
// Underdamped => overshoot + settle (the juicy follow-through). Pure fn of p.
|
||||
function springEase(opt) {
|
||||
opt = opt || {};
|
||||
var stiffness = opt.stiffness || 170, damping = opt.damping || 14, mass = opt.mass || 1;
|
||||
var w = Math.sqrt(stiffness / mass); // natural angular frequency
|
||||
var zeta = damping / (2 * Math.sqrt(stiffness * mass)); // damping ratio
|
||||
var horizon = opt.horizon || 1; // p maps to t in [0, horizon*settle]
|
||||
// choose a time-scale so motion is essentially settled at p=1
|
||||
var settle = 8 / (zeta * w || 1);
|
||||
function y(t) {
|
||||
if (zeta < 1) {
|
||||
var wd = w * Math.sqrt(1 - zeta * zeta);
|
||||
return 1 - Math.exp(-zeta * w * t) * (Math.cos(wd * t) + (zeta * w / wd) * Math.sin(wd * t));
|
||||
} else if (zeta === 1) {
|
||||
return 1 - Math.exp(-w * t) * (1 + w * t);
|
||||
} else {
|
||||
var a = -w * (zeta - Math.sqrt(zeta * zeta - 1)), b = -w * (zeta + Math.sqrt(zeta * zeta - 1));
|
||||
var A = b / (b - a), B = -a / (b - a);
|
||||
return 1 - (A * Math.exp(a * t) + B * Math.exp(b * t));
|
||||
}
|
||||
}
|
||||
var f = function (p) {
|
||||
if (p <= 0) return 0; if (p >= 1) return 1; // pin endpoints
|
||||
return y(p * settle * horizon);
|
||||
};
|
||||
return f;
|
||||
}
|
||||
var ease = {
|
||||
settle: springEase({ stiffness: 180, damping: 18 }), // minimal overshoot
|
||||
overshoot: springEase({ stiffness: 200, damping: 11 }),// clear overshoot + settle
|
||||
bouncy: springEase({ stiffness: 260, damping: 8 }), // springy
|
||||
soft: springEase({ stiffness: 120, damping: 20 }) // gentle, no overshoot
|
||||
};
|
||||
|
||||
// ---- FABRIK 2D inverse kinematics -----------------------------------------
|
||||
// lengths: array of segment lengths (n segments -> n+1 joints). Returns joint pts.
|
||||
function fabrik(lengths, origin, target, opt) {
|
||||
opt = opt || {};
|
||||
var iters = opt.iterations || 12, tol = opt.tol || 0.4;
|
||||
var n = lengths.length, total = lengths.reduce(function (a, b) { return a + b; }, 0);
|
||||
var pts = [origin.slice()], i;
|
||||
for (i = 0; i < n; i++) pts.push([origin[0] + lengths[i] * (i + 1), origin[1]]); // seed straight
|
||||
var dist = Math.hypot(target[0] - origin[0], target[1] - origin[1]);
|
||||
if (dist > total) { // unreachable: stretch straight
|
||||
var ux = (target[0] - origin[0]) / dist, uy = (target[1] - origin[1]) / dist, acc = origin.slice();
|
||||
pts[0] = origin.slice();
|
||||
for (i = 0; i < n; i++) { acc = [acc[0] + ux * lengths[i], acc[1] + uy * lengths[i]]; pts[i + 1] = acc.slice(); }
|
||||
return pts;
|
||||
}
|
||||
for (var it = 0; it < iters; it++) {
|
||||
pts[n] = target.slice(); // backward
|
||||
for (i = n - 1; i >= 0; i--) pts[i] = lerpTo(pts[i + 1], pts[i], lengths[i]);
|
||||
pts[0] = origin.slice(); // forward
|
||||
for (i = 0; i < n; i++) pts[i + 1] = lerpTo(pts[i], pts[i + 1], lengths[i]);
|
||||
if (Math.hypot(pts[n][0] - target[0], pts[n][1] - target[1]) < tol) break;
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
function lerpTo(from, to, len) {
|
||||
var dx = to[0] - from[0], dy = to[1] - from[1], d = Math.hypot(dx, dy) || 1;
|
||||
return [from[0] + dx / d * len, from[1] + dy / d * len];
|
||||
}
|
||||
|
||||
// ---- Contraption grammar — parametric composable parts --------------------
|
||||
// Each returns { g: <g>, ... } with named handles the author wires to motion.
|
||||
var INK = "#1a1a1a";
|
||||
function strokeEl(d, sw) { return el("path", { d: d, fill: "none", stroke: INK, "stroke-width": sw || 4, "stroke-linecap": "round", "stroke-linejoin": "round" }); }
|
||||
var parts = {
|
||||
// crank wheel with a handle on the rim — spin the returned `wheel` group
|
||||
crank: function (cx, cy, r) {
|
||||
var wheel = el("g", { transform: "" });
|
||||
wheel.appendChild(el("circle", { cx: cx, cy: cy, r: r, fill: "#fff", stroke: INK, "stroke-width": 4 }));
|
||||
wheel.appendChild(el("line", { x1: cx, y1: cy, x2: cx, y2: cy - r + 4, stroke: INK, "stroke-width": 2.4 }));
|
||||
wheel.appendChild(el("circle", { cx: cx, cy: cy - r + 8, r: r * 0.16, fill: INK })); // handle
|
||||
var g = el("g", null, [wheel, el("circle", { cx: cx, cy: cy, r: 3, fill: INK })]);
|
||||
return { g: g, wheel: wheel, pivot: [cx, cy] };
|
||||
},
|
||||
// gauge with a needle — swing `needle` (pivot at center)
|
||||
gauge: function (cx, cy, r) {
|
||||
var needle = el("line", { x1: cx, y1: cy, x2: cx, y2: cy - r + 6, stroke: INK, "stroke-width": 4 });
|
||||
var g = el("g", null, [
|
||||
el("circle", { cx: cx, cy: cy, r: r, fill: "#fff", stroke: INK, "stroke-width": 4 }),
|
||||
strokeEl("M" + (cx - r * 0.7) + " " + (cy - r * 0.2) + " A " + (r * 0.7) + " " + (r * 0.7) + " 0 0 1 " + (cx + r * 0.7) + " " + (cy - r * 0.2), 2),
|
||||
needle, el("circle", { cx: cx, cy: cy, r: 3.5, fill: INK })
|
||||
]);
|
||||
return { g: g, needle: needle, pivot: [cx, cy] };
|
||||
},
|
||||
hopper: function (cx, topY, mouthW, depth) {
|
||||
var hw = mouthW / 2;
|
||||
return { g: strokeEl("M" + (cx - hw * 0.5) + " " + (topY + depth) + " L" + (cx - hw) + " " + topY + " L" + (cx + hw) + " " + topY + " L" + (cx + hw * 0.5) + " " + (topY + depth)), mouth: [cx, topY] };
|
||||
},
|
||||
slot: function (x, y, w, h) { return { g: el("rect", { x: x, y: y, width: w, height: h, rx: 2, fill: INK }), out: [x + w, y + h / 2] }; },
|
||||
lever: function (cx, cy, len) {
|
||||
var arm = el("line", { x1: cx, y1: cy, x2: cx + len, y2: cy - len * 0.2, stroke: INK, "stroke-width": 6, "stroke-linecap": "round" });
|
||||
var knob = el("circle", { cx: cx + len, cy: cy - len * 0.2, r: 9, fill: INK });
|
||||
return { g: el("g", null, [el("circle", { cx: cx, cy: cy, r: 5, fill: INK }), arm, knob]), arm: arm, knob: knob, pivot: [cx, cy] };
|
||||
},
|
||||
box: function (x, y, w, h, label) {
|
||||
var g = el("g", null, [el("rect", { x: x, y: y, width: w, height: h, rx: 6, fill: "#fff", stroke: INK, "stroke-width": 4 })]);
|
||||
if (label) { var t = el("text", { x: x + w / 2, y: y + h / 2 + 6, "text-anchor": "middle", fill: INK, "font-size": 22, "font-family": "Caveat, cursive" }); t.textContent = label; g.appendChild(t); }
|
||||
return { g: g };
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Mascot ----------------------------------------------------------------
|
||||
// A deadpan ink blob with two FABRIK-posable arms.
|
||||
function mascot(opt) {
|
||||
opt = opt || {};
|
||||
var x = opt.x || 0, y = opt.y || 0, s = opt.scale || 1;
|
||||
var bw = 40 * s, bh = 50 * s;
|
||||
var body = el("ellipse", { cx: x, cy: y, rx: bw, ry: bh, fill: INK });
|
||||
var legL = el("path", { d: "M" + (x - 12 * s) + " " + (y + bh * 0.92) + " l" + (-4 * s) + " " + (24 * s), stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round" });
|
||||
var legR = el("path", { d: "M" + (x + 12 * s) + " " + (y + bh * 0.92) + " l" + (4 * s) + " " + (24 * s), stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round" });
|
||||
var eyeL = el("circle", { cx: x - 12 * s, cy: y - 16 * s, r: 7 * s, fill: "#fff" });
|
||||
var eyeR = el("circle", { cx: x + 12 * s, cy: y - 16 * s, r: 7 * s, fill: "#fff" });
|
||||
var armL = el("path", { d: "", stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" });
|
||||
var armR = el("path", { d: "", stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" });
|
||||
var shoulderL = [x - bw * 0.7, y - 2 * s], shoulderR = [x + bw * 0.7, y - 2 * s];
|
||||
var seg = [32 * s, 30 * s];
|
||||
function poseArm(pathEl, shoulder, target) {
|
||||
var j = fabrik(seg, shoulder, target);
|
||||
pathEl.setAttribute("d", "M" + r2(j[0][0]) + " " + r2(j[0][1]) + " L" + r2(j[1][0]) + " " + r2(j[1][1]) + " L" + r2(j[2][0]) + " " + r2(j[2][1]));
|
||||
}
|
||||
poseArm(armL, shoulderL, [shoulderL[0] - 18 * s, shoulderL[1] + 30 * s]);
|
||||
poseArm(armR, shoulderR, [shoulderR[0] + 18 * s, shoulderR[1] + 30 * s]);
|
||||
var g = el("g", null, [armL, armR, body, legL, legR, eyeL, eyeR]);
|
||||
return {
|
||||
g: g, body: body, eyeL: eyeL, eyeR: eyeR, armL: armL, armR: armR,
|
||||
origin: [x, y], shoulderL: shoulderL, shoulderR: shoulderR,
|
||||
reachL: function (t) { poseArm(armL, shoulderL, t); },
|
||||
reachR: function (t) { poseArm(armR, shoulderR, t); }
|
||||
};
|
||||
}
|
||||
|
||||
// ---- comic speech balloon (the "characters talking" primitive) ----
|
||||
// Builds an ink bubble + tail (grows from the mouth) and an HTML overlay text,
|
||||
// and animates it on the timeline. Text is HTML so the webfont applies.
|
||||
// balloon(tl, { into:<g>, overlay:<div>, at, dur, text, mouth:[x,y],
|
||||
// center:[x,y], w, size, font, boil })
|
||||
function balloon(tl, o) {
|
||||
o = o || {};
|
||||
var g = root.gsap;
|
||||
var bx = (o.center && o.center[0]) || 1030, by = (o.center && o.center[1]) || 320;
|
||||
var w = o.w || Math.max(200, String(o.text || "").length * 26), h = o.h || 116;
|
||||
var mouth = o.mouth || [bx - 260, by + 130];
|
||||
var grp = el("g", o.boil ? { filter: "url(#" + o.boil + ")" } : {});
|
||||
grp.appendChild(el("rect", { x: bx - w / 2, y: by - h / 2, width: w, height: h, rx: h / 2, fill: "#fff", stroke: INK, "stroke-width": 3.4 }));
|
||||
grp.appendChild(el("path", { d: "M " + (bx - 40) + " " + (by + h / 2 - 6) + " L " + mouth[0] + " " + mouth[1] + " L " + (bx - 4) + " " + (by + h / 2 - 2) + " Z", fill: "#fff", stroke: INK, "stroke-width": 3.4, "stroke-linejoin": "round" }));
|
||||
(o.into || document.body).appendChild(grp);
|
||||
var d = document.createElement("div");
|
||||
d.textContent = o.text;
|
||||
d.style.cssText = "position:absolute;display:flex;align-items:center;justify-content:center;text-align:center;opacity:0;color:" + INK +
|
||||
";font-family:'" + (o.font || "InkHand") + "',cursive;left:" + (bx - w / 2) + "px;top:" + (by - h / 2) + "px;width:" + w + "px;height:" + h + "px;font-size:" + (o.size || 50) + "px";
|
||||
(o.overlay || document.body).appendChild(d);
|
||||
if (tl && g) {
|
||||
g.set(grp, { svgOrigin: mouth[0] + " " + mouth[1], scale: 0 });
|
||||
tl.to(grp, { scale: 1, duration: 0.4, ease: ease.overshoot }, o.at || 0);
|
||||
tl.to(d, { opacity: 1, duration: 0.25 }, (o.at || 0) + 0.18);
|
||||
tl.to([grp, d], { opacity: 0, duration: 0.3 }, (o.at || 0) + (o.dur || 2) - 0.3);
|
||||
}
|
||||
return { group: grp, text: d };
|
||||
}
|
||||
|
||||
root.InkTheater = {
|
||||
el: el, rng: rng, resample: resample, smoothD: smoothD, wobblePts: wobblePts,
|
||||
inkPath: inkPath, inkRibbon: inkRibbon, boil: boil,
|
||||
springEase: springEase, ease: ease, fabrik: fabrik,
|
||||
parts: parts, mascot: mascot, balloon: balloon, INK: INK
|
||||
};
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# Mocap provenance & licensing
|
||||
|
||||
All bundled clips derive from the **CMU Graphics Lab Motion Capture Database**
|
||||
(http://mocap.cs.cmu.edu), which is **free for all uses, research and commercial**.
|
||||
BVH files are fetched from the `una-dinosauria/cmu-mocap` mirror; `clips.js` /
|
||||
`clips/*.json` are 2D derivations produced by `bvh2clip.mjs`. Per-clip source trial
|
||||
IDs live in `catalog.json` (e.g. `wave` = CMU 141_16, `shuffle` = CMU 77_29).
|
||||
See `../THIRD_PARTY_NOTICES.md` for the full attribution.
|
||||
|
||||
## Add a motion (self-extending — no code changes)
|
||||
|
||||
```
|
||||
node add-motion.mjs <name> <cmu-id|url|path> [category] "[description]"
|
||||
# e.g. node add-motion.mjs backflip 90_01 dance "a backflip"
|
||||
```
|
||||
|
||||
This fetches the BVH, converts it (auto-mapping fair1 / CMU / Mixamo skeletons),
|
||||
rebundles `clips.js`, and updates `catalog.json`. CMU has thousands of clips
|
||||
(walk, run, dance, wave, jump, …). Prefer CMU for anything shipped — it keeps the
|
||||
library license-clean. Different skeletons may need an alias added to the `ALIAS`
|
||||
table in `bvh2clip.mjs`, and `--axis xy|zy` to pick the projection plane.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// add-motion.mjs — fetch a BVH, convert it, and add it to the shared Ink Puppet
|
||||
// library (self-extending: no code changes, just data). Then copy clips.js into
|
||||
// your project.
|
||||
//
|
||||
// node add-motion.mjs <name> <source> [category] [description]
|
||||
//
|
||||
// <source> is one of:
|
||||
// • a CMU id "05_02" or "005/05_02" → fetched from una-dinosauria/cmu-mocap
|
||||
// • a URL "https://…/foo.bvh"
|
||||
// • a local path "./something.bvh"
|
||||
//
|
||||
// Skeleton is auto-mapped (fair1 / CMU / Mixamo) by bvh2clip.mjs. A new skeleton
|
||||
// only needs an alias added to bvh2clip.mjs's ALIAS table.
|
||||
import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const [, , name, source, category = "action", desc = ""] = process.argv;
|
||||
if (!name || !source) { console.error("usage: node add-motion.mjs <name> <source> [category] [description]"); process.exit(1); }
|
||||
|
||||
async function resolveBvh(src) {
|
||||
const tmp = join(here, "src"); mkdirSync(tmp, { recursive: true });
|
||||
const out = join(tmp, name + ".bvh");
|
||||
if (existsSync(src)) { writeFileSync(out, readFileSync(src)); return out; }
|
||||
let url = src;
|
||||
const CMU = "https://raw.githubusercontent.com/una-dinosauria/cmu-mocap/master/data";
|
||||
if (/^\d{2,3}_\d+$/.test(src)) { const subj = src.split("_")[0].padStart(3, "0"); url = `${CMU}/${subj}/${src}.bvh`; }
|
||||
else if (/^\d+\/\d{2,3}_\d+$/.test(src)) { url = `${CMU}/${src}.bvh`; }
|
||||
const r = await fetch(url); if (!r.ok) throw new Error(`fetch failed ${r.status}: ${url}`);
|
||||
writeFileSync(out, Buffer.from(await r.arrayBuffer())); return out;
|
||||
}
|
||||
|
||||
const bvh = await resolveBvh(source);
|
||||
execFileSync("node", [join(here, "bvh2clip.mjs"), bvh, join(here, "clips", name + ".json"), "--fps", "30", "--max", "180", "--name", name], { stdio: "inherit" });
|
||||
|
||||
const clipsDir = join(here, "clips");
|
||||
const clips = {};
|
||||
for (const f of readdirSync(clipsDir).filter((f) => f.endsWith(".json"))) clips[f.replace(/\.json$/, "")] = JSON.parse(readFileSync(join(clipsDir, f), "utf8"));
|
||||
writeFileSync(join(here, "clips.js"), "window.INK_CLIPS=" + JSON.stringify(clips) + ";");
|
||||
|
||||
const catPath = join(here, "catalog.json");
|
||||
const cat = existsSync(catPath) ? JSON.parse(readFileSync(catPath, "utf8")) : [];
|
||||
const entry = { name, category, desc: desc || name, frames: clips[name].frameCount, source };
|
||||
const i = cat.findIndex((c) => c.name === name);
|
||||
if (i >= 0) cat[i] = entry; else cat.push(entry);
|
||||
cat.sort((a, b) => a.name.localeCompare(b.name));
|
||||
writeFileSync(catPath, JSON.stringify(cat, null, 2));
|
||||
console.log(`✓ added "${name}" (${category}) · ${clips[name].frameCount}f · library now ${cat.length} actions. Copy clips.js into your project.`);
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
// bvh2clip.mjs — convert a 3D BVH mocap file into a compact 2D "clip" the
|
||||
// Ink Theater puppet can play. Offline, run once per motion.
|
||||
//
|
||||
// node bvh2clip.mjs <in.bvh> <out.json> [--axis auto|xy|zy] [--fps 30] [--lockx]
|
||||
//
|
||||
// Output: { name, fps, height, groundY, frames:[ {hips,chest,neck,head,
|
||||
// shR,elR,haR, shL,elL,haL, hipR,knR,ftR, hipL,knL,ftL, rootY} ] } in px,
|
||||
// hips-relative pose + separate rootY for vertical root motion (jumps).
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const [, , inPath, outPath, ...rest] = process.argv;
|
||||
const opt = { axis: "auto", fps: 30, lockx: true };
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (rest[i] === "--axis") opt.axis = rest[++i];
|
||||
else if (rest[i] === "--fps") opt.fps = +rest[++i];
|
||||
else if (rest[i] === "--nolockx") opt.lockx = false;
|
||||
else if (rest[i] === "--max") opt.max = +rest[++i];
|
||||
else if (rest[i] === "--name") opt.name = rest[++i];
|
||||
}
|
||||
|
||||
// ---- parse BVH ----
|
||||
const text = readFileSync(inPath, "utf8");
|
||||
const tokens = text.replace(/\r/g, "").split("\n");
|
||||
let li = 0;
|
||||
const joints = [];
|
||||
function parseJoint(parent) {
|
||||
// current line: "ROOT name" or "JOINT name" or "End Site"
|
||||
const line = tokens[li++].trim();
|
||||
const isEnd = line.startsWith("End");
|
||||
const name = isEnd ? parent.name + "_end" : line.split(/\s+/)[1];
|
||||
const j = { name, parent, offset: [0, 0, 0], channels: [], children: [], isEnd };
|
||||
joints.push(j);
|
||||
tokens[li++]; // {
|
||||
while (li < tokens.length) {
|
||||
const t = tokens[li].trim();
|
||||
if (t.startsWith("OFFSET")) { j.offset = t.split(/\s+/).slice(1, 4).map(Number); li++; }
|
||||
else if (t.startsWith("CHANNELS")) { j.channels = t.split(/\s+/).slice(2); li++; }
|
||||
else if (t.startsWith("JOINT") || t.startsWith("End")) { j.children.push(parseJoint(j)); }
|
||||
else if (t.startsWith("}")) { li++; break; }
|
||||
else li++;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
while (tokens[li] !== undefined && !tokens[li].trim().startsWith("ROOT")) li++;
|
||||
const root = parseJoint(null);
|
||||
// motion
|
||||
while (tokens[li] !== undefined && !tokens[li].trim().startsWith("Frames:")) li++;
|
||||
const nFrames = +tokens[li++].split(":")[1];
|
||||
const frameTime = +tokens[li++].split(":")[1];
|
||||
const motion = [];
|
||||
for (let f = 0; f < nFrames && li < tokens.length; f++) {
|
||||
const vals = tokens[li++].trim().split(/\s+/).map(Number);
|
||||
if (vals.length > 1) motion.push(vals);
|
||||
}
|
||||
|
||||
// channel layout: DFS order == joints[] order (excluding End Sites)
|
||||
const chanJoints = joints.filter((j) => !j.isEnd);
|
||||
|
||||
// ---- math ----
|
||||
const d2r = Math.PI / 180;
|
||||
function rotAxis(ax, deg) {
|
||||
const a = deg * d2r, c = Math.cos(a), s = Math.sin(a);
|
||||
if (ax === "X") return [[1, 0, 0], [0, c, -s], [0, s, c]];
|
||||
if (ax === "Y") return [[c, 0, s], [0, 1, 0], [-s, 0, c]];
|
||||
return [[c, -s, 0], [s, c, 0], [0, 0, 1]];
|
||||
}
|
||||
function mm(a, b) {
|
||||
const r = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++)
|
||||
r[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
|
||||
return r;
|
||||
}
|
||||
function mv(m, v) {
|
||||
return [m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]];
|
||||
}
|
||||
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
|
||||
function fk(frame) {
|
||||
const pos = {};
|
||||
let ci = 0;
|
||||
const chanVals = chanJoints.map((j) => { const n = j.channels.length; const v = frame.slice(ci, ci + n); ci += n; return v; });
|
||||
function recur(j, pR, pT) {
|
||||
const worldPos = add(mv(pR, j.offset), pT);
|
||||
pos[j.name] = worldPos;
|
||||
const idx = chanJoints.indexOf(j);
|
||||
let localR = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
||||
let rootT = null;
|
||||
if (idx >= 0) {
|
||||
const ch = j.channels, v = chanVals[idx];
|
||||
const rotPart = [];
|
||||
for (let k = 0; k < ch.length; k++) {
|
||||
if (ch[k].endsWith("position")) { if (!rootT) rootT = [0, 0, 0]; rootT[ch[k][0] === "X" ? 0 : ch[k][0] === "Y" ? 1 : 2] = v[k]; }
|
||||
else rotPart.push([ch[k][0], v[k]]);
|
||||
}
|
||||
for (const [ax, deg] of rotPart) localR = mm(localR, rotAxis(ax, deg));
|
||||
}
|
||||
const myT = rootT ? add(rootT, j.offset) : worldPos; // root uses its position channels
|
||||
const myPos = rootT ? add(rootT, j.offset) : worldPos;
|
||||
if (rootT) pos[j.name] = myPos;
|
||||
const gR = mm(pR, localR);
|
||||
for (const c of j.children) recur(c, gR, rootT ? myPos : worldPos);
|
||||
}
|
||||
recur(root, [[1, 0, 0], [0, 1, 0], [0, 0, 1]], [0, 0, 0]);
|
||||
return pos;
|
||||
}
|
||||
|
||||
// ---- joint-name resolver (skeleton-agnostic via aliases: fair1 / CMU / Mixamo) ----
|
||||
const present = new Set(joints.map((j) => j.name));
|
||||
const ALIAS = {
|
||||
hips: ["Hips", "mixamorig:Hips", "Hip"],
|
||||
chest: ["Spine3", "Spine2", "Spine1", "Chest", "Spine", "mixamorig:Spine2", "mixamorig:Spine1"],
|
||||
neck: ["Neck", "Neck1", "mixamorig:Neck"], head: ["Head", "mixamorig:Head"],
|
||||
shR: ["RightArm", "RightShoulder", "mixamorig:RightArm"], elR: ["RightForeArm", "mixamorig:RightForeArm"], haR: ["RightHand", "mixamorig:RightHand"],
|
||||
shL: ["LeftArm", "LeftShoulder", "mixamorig:LeftArm"], elL: ["LeftForeArm", "mixamorig:LeftForeArm"], haL: ["LeftHand", "mixamorig:LeftHand"],
|
||||
hipR: ["RightUpLeg", "RightHip", "mixamorig:RightUpLeg"], knR: ["RightLeg", "mixamorig:RightLeg"], ftR: ["RightFoot", "mixamorig:RightFoot"],
|
||||
hipL: ["LeftUpLeg", "LeftHip", "mixamorig:LeftUpLeg"], knL: ["LeftLeg", "mixamorig:LeftLeg"], ftL: ["LeftFoot", "mixamorig:LeftFoot"]
|
||||
};
|
||||
const JN = {};
|
||||
for (const k in ALIAS) JN[k] = ALIAS[k].find((n) => present.has(n)) || ALIAS[k][0];
|
||||
const unmapped = Object.keys(ALIAS).filter((k) => !present.has(JN[k]));
|
||||
if (unmapped.length) console.error("WARN " + inPath + ": unmapped joints " + unmapped.join(",") + " (skeleton not recognized — extend ALIAS)");
|
||||
|
||||
// ---- pick 2D axes ----
|
||||
const p0 = fk(motion[0]);
|
||||
const spread = [0, 1, 2].map((a) => Math.abs(p0[JN.head][a] - p0[JN.ftR][a]));
|
||||
let upAxis = spread[1] >= spread[2] ? 1 : 2; // Y or Z, whichever spans head→foot most
|
||||
if (opt.axis === "xy") upAxis = 1; if (opt.axis === "zy") upAxis = 2;
|
||||
const horizAxis = 0; // X = left/right (arms)
|
||||
const upSign = (p0[JN.head][upAxis] - p0[JN.ftR][upAxis]) > 0 ? 1 : -1;
|
||||
function proj(P) { return [P[horizAxis], -upSign * P[upAxis]]; }
|
||||
|
||||
// scale from the SKELETON rest height (rotation-free FK) so every clip of the
|
||||
// same skeleton renders the figure at an identical size.
|
||||
function fkRest() {
|
||||
const pos = {};
|
||||
(function recur(j, pT) { const wp = add(pT, j.offset); pos[j.name] = wp; for (const c of j.children) recur(c, wp); })(root, [0, 0, 0]);
|
||||
return pos;
|
||||
}
|
||||
const restPos = fkRest();
|
||||
const restUp = Object.values(restPos).map((P) => -upSign * P[upAxis]);
|
||||
const heightUnits = Math.max(...restUp) - Math.min(...restUp);
|
||||
const scale = 520 / heightUnits;
|
||||
|
||||
const step = Math.max(1, Math.round((1 / frameTime) / opt.fps));
|
||||
const hips0 = proj(p0[JN.hips]);
|
||||
let frames = [];
|
||||
for (let f = 0; f < motion.length; f += step) {
|
||||
const P = fk(motion[f]);
|
||||
const h = proj(P[JN.hips]);
|
||||
const rootY = (h[1] - hips0[1]) * scale; // vertical root motion (jumps)
|
||||
const rootX = opt.lockx ? h[0] : hips0[0]; // in-place unless drift wanted
|
||||
const out = { rootY: Math.round(rootY * 10) / 10 };
|
||||
for (const key in JN) {
|
||||
const j = P[JN[key]]; if (!j) continue;
|
||||
const q = proj(j);
|
||||
out[key] = [Math.round((q[0] - rootX) * scale * 10) / 10, Math.round((q[1] - h[1]) * scale * 10) / 10];
|
||||
}
|
||||
frames.push(out);
|
||||
}
|
||||
if (opt.max && frames.length > opt.max) frames = frames.slice(0, opt.max); // cap length (loops the take)
|
||||
// ground = lowest foot at rest (frame 0), figure height
|
||||
const f0 = frames[0];
|
||||
const groundY = Math.max(f0.ftR ? f0.ftR[1] : 0, f0.ftL ? f0.ftL[1] : 0);
|
||||
const name = opt.name || inPath.split(/[\\/]/).pop().replace(/\.bvh$/i, "");
|
||||
const clip = { name, fps: opt.fps, height: Math.round(520), groundY: Math.round(groundY), frameCount: frames.length, frames };
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, JSON.stringify(clip));
|
||||
console.log(`${name}: ${frames.length} frames @${opt.fps}fps · upAxis=${upAxis}(${upSign}) · scale=${scale.toFixed(2)} · groundY=${clip.groundY}`);
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
[
|
||||
{
|
||||
"name": "climb",
|
||||
"category": "locomotion",
|
||||
"desc": "clambers up",
|
||||
"frames": 180,
|
||||
"source": "CMU 01_02"
|
||||
},
|
||||
{
|
||||
"name": "dance_glide",
|
||||
"category": "dance",
|
||||
"desc": "gliding ballet dance",
|
||||
"frames": 180,
|
||||
"source": "CMU 05_10"
|
||||
},
|
||||
{
|
||||
"name": "dance_spin",
|
||||
"category": "dance",
|
||||
"desc": "expressive dance w/ a pirouette",
|
||||
"frames": 180,
|
||||
"source": "CMU 05_02"
|
||||
},
|
||||
{
|
||||
"name": "jump",
|
||||
"category": "action",
|
||||
"desc": "a forward jump",
|
||||
"frames": 104,
|
||||
"source": "CMU 13_11"
|
||||
},
|
||||
{
|
||||
"name": "kick",
|
||||
"category": "action",
|
||||
"desc": "kicks",
|
||||
"frames": 180,
|
||||
"source": "CMU 10_01"
|
||||
},
|
||||
{
|
||||
"name": "march",
|
||||
"category": "locomotion",
|
||||
"desc": "marches on the spot",
|
||||
"frames": 180,
|
||||
"source": "CMU 15_01"
|
||||
},
|
||||
{
|
||||
"name": "run",
|
||||
"category": "locomotion",
|
||||
"desc": "runs / jogs",
|
||||
"frames": 38,
|
||||
"source": "CMU 09_01"
|
||||
},
|
||||
{
|
||||
"name": "shuffle",
|
||||
"category": "locomotion",
|
||||
"desc": "a slow sneaking creep",
|
||||
"frames": 180,
|
||||
"source": "CMU 77_29"
|
||||
},
|
||||
{
|
||||
"name": "sit",
|
||||
"category": "posture",
|
||||
"desc": "sits then stands",
|
||||
"frames": 180,
|
||||
"source": "CMU 13_01"
|
||||
},
|
||||
{
|
||||
"name": "twist",
|
||||
"category": "dance",
|
||||
"desc": "a twisting dance",
|
||||
"frames": 143,
|
||||
"source": "CMU 141_12"
|
||||
},
|
||||
{
|
||||
"name": "walk",
|
||||
"category": "locomotion",
|
||||
"desc": "walks",
|
||||
"frames": 86,
|
||||
"source": "CMU 02_01"
|
||||
},
|
||||
{
|
||||
"name": "wave",
|
||||
"category": "gesture",
|
||||
"desc": "waves hello",
|
||||
"frames": 75,
|
||||
"source": "CMU 141_16"
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,71 @@
|
|||
# Animated Drawing — animate a supplied drawing/photo with real mocap
|
||||
|
||||
> Command: `/animated-drawing` · **Path A (raster).** Sibling: `/ink-art` (vector, from scratch).
|
||||
> Tool: Meta open-source **AnimatedDrawings** (github.com/facebookresearch/AnimatedDrawings — code MIT, repo archived 2025).
|
||||
|
||||
**When to use:** the user *has* a drawing or photo of a **humanoid** character and wants **that image** to move (dance / walk / jump / wave). Output = a raster **GIF (transparent) or MP4** of the original drawing *warped* to the motion. To **create** a vector doodle from scratch that draws itself and moves → use **`/ink-art`** instead.
|
||||
|
||||
**What it does:** auto-rigs the drawing (predicts a 16-joint skeleton), then retargets a BVH mocap clip onto it via As-Rigid-As-Possible mesh warp of the flat texture. It only *moves an already-complete drawing* — there is **no draw-on / self-sketching reveal** (that's `/ink-art`).
|
||||
|
||||
## Choose a character source — ASK FIRST (never silently reuse a bundled char)
|
||||
|
||||
The tool animates *whatever image you give it*, and it needs a **drawn humanoid** (head, 2 arms, 2 legs, limbs separated, plain light bg). Before building, **present the user these options** and pick one (default to *generate* when they have nothing) — this is also what stops every video looking like the same mascot:
|
||||
|
||||
1. **User uploads a drawing** — their own humanoid doodle. Best result; it's their character.
|
||||
2. **User uploads a photo / any image** — first **doodle-ify it** (`image_selector` img2img, FLUX/Recraft: "turn this into a simple child's crayon doodle of a humanoid, full body, plain white bg") → then rig. (The "hand-drawn" look comes from this step; a raw photo warps badly.)
|
||||
3. **Generate a fresh character** ⭐ (recommended default) — `image_selector` (FLUX/Imagen): *"a child's crayon drawing of a character, full body, front-facing, A-pose with arms and legs separated, plain white background, no shadow, no text."* Unique every video.
|
||||
4. **Grab a stock character** — `pixabay_image` / `pexels_image`, filtered to a *drawn humanoid on a plain bg*. Hit-or-miss (most stock is photos) — prefer #3.
|
||||
|
||||
Bundled example characters are **demo-only** — do not ship them as the user's character. Options 1–4 all need the **auto-rig Docker service** (below); if it isn't up, say so.
|
||||
|
||||
## Two run modes
|
||||
|
||||
**A · Bundled character + preset motion — turnkey, no Docker (verified on Windows):**
|
||||
```bash
|
||||
git clone --depth 1 https://github.com/facebookresearch/AnimatedDrawings.git && cd AnimatedDrawings
|
||||
# the repo pins Python 3.8 + old wheels; get 3.8 via uv:
|
||||
uv python install 3.8 && uv venv --python 3.8 .venv
|
||||
uv pip install --python .venv -e .
|
||||
uv pip install --python .venv "setuptools<81" # repo imports pkg_resources but doesn't declare it
|
||||
.venv/Scripts/python -c "from animated_drawings import render; render.start('./examples/config/mvc/export_gif_example.yaml')"
|
||||
```
|
||||
~10–12 s/clip on CPU, no GPU/Docker/model download.
|
||||
|
||||
**B · Auto-rig a NEW drawing — heavy (Docker + ~670 MB models, ~16 GB RAM):**
|
||||
```bash
|
||||
python image_to_animation.py drawing.png out_dir # detect → segment → rig → retarget → render
|
||||
```
|
||||
Needs the repo's TorchServe container (`docker/`) which downloads `drawn_humanoid_detector.mar` (311 MB) + `drawn_humanoid_pose_estimator.mar` (357 MB). Windows: run the rig stack only via that container (OpenMMLab is Linux-only in practice).
|
||||
|
||||
## Input requirements (auto-rig)
|
||||
One clearly-drawn **humanoid**, roughly T/A-pose (limbs separated, not overlapping), on a **plain light background** (segmentation is threshold + floodfill), exactly one figure.
|
||||
|
||||
## Config the agent generates (all YAML)
|
||||
`char_cfg.yaml` (+ `texture.png`, `mask.png`; auto-produced by `image_to_annotations.py`) · a **motion** config (bvh + frames + groundplane) · a **retarget** config (BVH-joint → rig-joint; reuse bundled `fair1_ppf` / `cmu1_pfp` unless the skeleton differs) · an **MVC** config (`controller.MODE: video_render`, `OUTPUT_VIDEO_PATH`, optional `WINDOW_DIMENSIONS` / `CLEAR_COLOR` / `BACKGROUND_IMAGE` / `CAMERA_POS`).
|
||||
|
||||
## Preset motions → retarget config (MUST match the BVH skeleton)
|
||||
Each bundled BVH is a different skeleton family; using the wrong retarget config **crashes** (`ValueError: 'RightArm' is not in list`). Pair them:
|
||||
|
||||
| Motion | BVH folder | retarget config |
|
||||
|---|---|---|
|
||||
| `dab`, `wave_hello`, `jumping`, `zombie` | `bvh/fair1/` | `fair1_ppf` |
|
||||
| `jumping_jacks` | `bvh/cmu1/` | `cmu1_pfp` |
|
||||
| `jesse_dance` | `bvh/rokoko/` | `mixamo_fff` |
|
||||
|
||||
Any other BVH → match its skeleton (or write a retarget config). **Cap long clips** with `end_frame_idx` (`wave_hello` is 839 frames) or the render runs for minutes; ground-contact clips (`dab`, `wave_hello`) render ~8× slower.
|
||||
|
||||
## Character variety — animate the USER's drawing (fixes "always the same character")
|
||||
Bundled characters are **demo-only**. In real use the character is whatever the user **supplies** — unique per video. For a "just make me a video" request with no drawing, **generate a fresh character** (image-gen: "a child's crayon drawing of a …" → PNG on plain light bg) and **auto-rig it** (Docker path) → a different character every time. **Never reuse a bundled char across videos**, or every output looks like the same mascot.
|
||||
|
||||
## Compositing into HyperFrames (the second half — required for a real video)
|
||||
AnimatedDrawings only outputs the moving character. To make a *video* (background, balloons, music), composite in HyperFrames:
|
||||
- **Transparent output:** `view.CLEAR_COLOR: [0,0,0,0]` in the MVC config → transparent frames.
|
||||
- **A GIF FREEZES in a deterministic HyperFrames render.** Convert to **VP9 WebM w/ alpha**: `ffmpeg -i char.gif -c:v libvpx-vp9 -pix_fmt yuva420p char.webm`. (`ffprobe` misreports `yuv420p` — alpha is intact.)
|
||||
- **Video contract (the linter enforces — run `npm run check`):** `<video>` must be a **direct stage child with its own `id`**, NOT nested in a timed `<div>` (nesting **freezes** it); each clip needs its own `data-track-index`; fades need a trailing hard-kill `tl.set(el,{opacity:0})`.
|
||||
- Text/**balloons** = HTML overlay divs with the full `ink-theater/assets/patrickhand.ttf` (see the ink-theater font gotcha).
|
||||
- **Pipeline-exempt:** `/animated-drawing` + `/ink-art` are creative entry points, not Rule-Zero pipelines — no `.yaml` manifest.
|
||||
|
||||
## Output & honest limits
|
||||
GIF (transparent) / MP4 (H.264, `avc1`), resolution from `WINDOW_DIMENSIONS` (examples 500×500). **Raster only** (warps the drawing's pixels — zoom shows stretched texture), **humanoid-only**, **no draw-on reveal**, **crude background**. A delightful "your doodle comes to life" novelty; behind a Docker service for the auto-rig path. Not a general vector engine — for white-ink vector doodles that draw themselves, use `/ink-art`.
|
||||
|
||||
Sample renders from the session eval: `.tmp/animated-drawings/out/` (`char3_dab.gif`, `char1_zombie.mp4`).
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# Ink Theater — hand-drawn "moving art" (creative skill)
|
||||
|
||||
> Style id: `ink-sketch` · Engine: `ink-theater/ink-theater.js` · Runtime: HyperFrames (atelier)
|
||||
> Technique credit: inspired by Ian's `小黑/Xiaohei` MIT skill. Generic, English, motion-first.
|
||||
|
||||
**What it is:** a minimalist **black-ink-on-white** world where a deadpan mascot *physically performs* an abstract idea by operating an absurd **low-tech contraption**. Not a fixed catalog of scenes — a generic method + a parametric engine. Runs on the `animation` pipeline in atelier mode; it is NOT its own pipeline.
|
||||
|
||||
## The generic method (concept → moving scene)
|
||||
|
||||
The "unlock" from research + the Xiaohei composition rules is a 3-step metaphor generator — do this in the agent, not in code:
|
||||
|
||||
1. **Abstract concept → one physical ACTION** — stuck, leaking, compounding, sorting, fermenting, pushing, sinking, unraveling.
|
||||
2. **System → one low-tech OBJECT** — press, funnel, well, jar, conveyor, ladder, bridge, cabinet, boulder, gate, scale.
|
||||
3. **Mascot PERFORMS the action on the object** — it cranks / pushes / pumps / stamps / fishes-out. *If you can delete the mascot and the metaphor survives, it's decoration — redo it.*
|
||||
|
||||
Then stage as beats on one continuous white page with a camera (pan / push).
|
||||
|
||||
## Mined archetypes (pick one, invent fresh specifics)
|
||||
|
||||
| Archetype | Mascot action | Motion recipe |
|
||||
|---|---|---|
|
||||
| **Contraption** | operates a machine (crank/lever/pump) | feed in → crank → machine works (steam/gauge) → output pops |
|
||||
| **Before/after load-shift** | crushed by chaos → relaxed, one key act | left chaos → orange sweep → right structure assembles |
|
||||
| **Journey / pitfalls** | walks a path; falls in pits or hits nodes | path draws on → walk → pit swallows / node pops → blue return loop |
|
||||
| **One → many fan** | splits/cuts one source | source splits → arrows draw to N branches → each branch acts |
|
||||
| **Push / momentum** | shoves a boulder up, it rolls away | slow strained up (power1) → crest → fast roll (power2.in) |
|
||||
| **Well / extract** | lowers a bucket into noise, scoops the gem | bucket descends → scoop → pull up the one good bit |
|
||||
|
||||
## Color grammar (strict)
|
||||
|
||||
- **black** = structure & mascot · **orange** = flow / arrows ONLY · **red** = the problem / warning · **blue** = the good end-state.
|
||||
- Pure white paper, ≥35% negative space, subject ~40–60%. Deadpan mascot (white-dot eyes), never cute.
|
||||
|
||||
## Engine cheat-sheet (`InkTheater`)
|
||||
|
||||
- Lines: `inkPath(pts)`, `inkRibbon(pts,{width,taper})` (brush). Boil: `boil(turbEl, tl, {duration})`.
|
||||
- Motion: `ease.{settle,overshoot,bouncy,soft}` (seek-safe springs) — use overshoot for pops, settle for arrivals, bounce for landings.
|
||||
- Character: `mascot({x,y,scale})` → `.reachL/.reachR([x,y])` (FABRIK). Follow a moving target via GSAP `onUpdate`.
|
||||
- Machines: `parts.{crank,gauge,hopper,slot,lever,box}` — compose them.
|
||||
- Full API + determinism rules: `ink-theater/README.md`.
|
||||
|
||||
## Characters — Ink Puppet + real mocap (never hand-tune motion)
|
||||
|
||||
For a character that walks / dances / etc., do NOT hand-author motion (sine curves, hand-posed frames). Use the puppet + the **mocap action library**:
|
||||
|
||||
- `InkPuppet.create(mount,{cx,ground,boil})` → `p.drawIn(tl,{start})` (self-drawing reveal) → `InkPuppet.choreograph(tl, p, [{clip:'walk'},{clip:'dance_spin'},{clip:'wave'}], {start})`.
|
||||
- **Read `ink-theater/mocap/catalog.json` and pick moves that fit each beat — vary them, and NEVER loop one clip** (looping is what makes videos feel repetitive). 12 today (all CMU-sourced): walk, run, climb, march, shuffle, jump, kick, sit, wave, twist, dance_spin, dance_glide.
|
||||
- **Move not in the catalog?** `node ink-theater/mocap/add-motion.mjs <name> <cmu-id|url|path> <category> "<desc>"` — fetches, converts (auto-maps fair1 / CMU / Mixamo skeletons), rebundles + updates the catalog. Free CMU mocap (`una-dinosauria/cmu-mocap`) has thousands. Then copy `mocap/clips.js` into the project.
|
||||
- **Speech balloons** (characters "talking"): `InkTheater.balloon(tl, {into, overlay, at, dur, text, mouth:[x,y], center:[x,y], boil})` — HTML text so the webfont applies.
|
||||
|
||||
## ⚠ Non-negotiables
|
||||
|
||||
- **Handwriting font: embed the FULL font, not a Google-Fonts subset woff2** (a `css2`-API subset is missing basic-latin → silent serif fallback everywhere). Use the bundled `ink-theater/assets/patrickhand.ttf` (`@font-face … format("truetype")`) on **HTML overlay `<div>`s** for captions/speech-balloons. No Google hot-link (breaks determinism). See `ink-theater/README.md` → "font gotcha".
|
||||
- Determinism: closed-form springs, seed-stepped boil off the timeline, no `repeat:-1`, seeded PRNG only.
|
||||
- One paused `gsap.timeline` on `window.__timelines`. Validate with `lint` + `snapshot` (read the contact-sheet) before render.
|
||||
- **Pipeline-exempt**: this is a style + engine on the `animation` / `character-animation` pipelines — NOT a Rule-Zero pipeline. Don't stall looking for an `.yaml` manifest.
|
||||
|
||||
## Reference builds
|
||||
|
||||
- `projects/ink-theater-reel/` — capabilities reel. · `projects/ink-theater-momentum/` — "Momentum" story (handwriting via HTML divs).
|
||||
|
|
@ -11,6 +11,7 @@ Do not use this pipeline when the project is really footage-led with a few overl
|
|||
- `docs/animation-best-practices.md`
|
||||
- `skills/creative/animation-pipeline.md`
|
||||
- `skills/creative/storytelling.md`
|
||||
- `skills/creative/ink-theater.md` — for hand-drawn **ink "doodle"** briefs: a character that draws itself and performs, or a deadpan contraption explainer (Ink Theater engine + Ink Puppet mocap; command `/ink-art`). A stick/pencil character that walks or dances belongs in the `character-animation` pipeline.
|
||||
|
||||
## Process
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
Produce `character_design`: a small cast with clear silhouettes, roles,
|
||||
emotions, actions, and style anchors.
|
||||
|
||||
> **Hand-drawn ink "doodle" character?** (a stick/pencil figure that draws itself, then walks / dances / waves / jumps) → use the **Ink Puppet** system: `skills/creative/ink-theater.md` + `ink-theater/README.md`. The rig is proportion-agnostic and plays **real mocap** clips via `InkPuppet.choreograph([{clip:'wave'},{clip:'twist'},…])` — clip names come from `ink-theater/mocap/catalog.json` (12 CMU-sourced moves); the agent only chooses named moves, **never hand-tunes motion**; add moves with `node ink-theater/mocap/add-motion.mjs`. Command: `/ink-art`.
|
||||
|
||||
## Process
|
||||
|
||||
1. List every character with `id`, role, body type, and style.
|
||||
|
|
|
|||
Loading…
Reference in New Issue