img2rig 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +26 -0
  2. package/package.json +13 -0
  3. package/rig-player.js +245 -0
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # Web reference player
2
+
3
+ A dependency-free ES module that renders a `rig.txt` layer pack on a 2D
4
+ canvas, with the same solver and motion machine as the C++ runtime.
5
+
6
+ ```bash
7
+ # from the repository root
8
+ python -m http.server 8000
9
+ # open http://localhost:8000/runtime/web/ (loads the bundled demo character)
10
+ # or point at any rig: .../runtime/web/?rig=/path/to/rig.txt
11
+ ```
12
+
13
+ Embedding:
14
+
15
+ ```js
16
+ import { RigPlayer } from "./rig-player.js";
17
+ const rig = await RigPlayer.load("assets/mychar/rig.txt");
18
+ // each animation frame:
19
+ rig.update(dt); // seconds; freeze time to freeze her
20
+ rig.draw(ctx, x, y, displayHeight); // (x, y) = character center
21
+ rig.trigger("stagger"); // hit | stagger | enrage | die | ...
22
+ ```
23
+
24
+ The solver is duplicated by design across `rig_math.h` (C++, unit-tested),
25
+ `preview.py` (offline GIF renderer) and `rig-player.js` (this file) so each
26
+ stays dependency-free. If you change one, change all three.
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "img2rig",
3
+ "version": "0.1.0",
4
+ "description": "Dependency-free Canvas player for img2rig character rigs - springs, breathing, blinks, hit/stagger/enrage motions from a single rig.txt layer pack.",
5
+ "type": "module",
6
+ "main": "rig-player.js",
7
+ "exports": "./rig-player.js",
8
+ "files": ["rig-player.js", "README.md"],
9
+ "keywords": ["stable-diffusion", "character-rig", "live2d-style", "animation", "canvas", "spring-physics"],
10
+ "repository": { "type": "git", "url": "git+https://github.com/SSnemo/img2rig.git", "directory": "runtime/web" },
11
+ "homepage": "https://github.com/SSnemo/img2rig",
12
+ "license": "Apache-2.0"
13
+ }
package/rig-player.js ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * img2rig web reference player - renders a rig.txt layer pack on a 2D canvas.
3
+ *
4
+ * A faithful JS port of the runtime solver (runtime/cpp/img2rig/rig_math.h)
5
+ * and the procedural motion machine: breath, blink, head sway, spring
6
+ * physics, hit/stagger/enrage/collapse/die impulses. No dependencies, no
7
+ * build step - plain ES module.
8
+ *
9
+ * If you change the solver here, change rig_math.h and preview.py too; the
10
+ * C++ unit tests pin the behavior.
11
+ *
12
+ * Usage:
13
+ * import { RigPlayer } from "./rig-player.js";
14
+ * const rig = await RigPlayer.load("path/to/rig.txt");
15
+ * function frame(t) { rig.update(dt); rig.draw(ctx, x, y, height); }
16
+ * rig.trigger("stagger");
17
+ */
18
+
19
+ // ---- rig.txt parsing (mirrors img2rig::parse) ----
20
+
21
+ function parseRig(text) {
22
+ const nodes = [];
23
+ let canvas = [0, 0];
24
+ for (const line of text.split(/\r?\n/)) {
25
+ const t = line.trim().split(/\s+/);
26
+ if (!t[0] || t[0].startsWith("#")) continue;
27
+ if (t[0] === "canvas") {
28
+ canvas = [parseFloat(t[1]), parseFloat(t[2])];
29
+ } else if (t[0] === "node") {
30
+ nodes.push({ name: t[1], parent: t[2], z: parseInt(t[3]),
31
+ px: parseFloat(t[4]), py: parseFloat(t[5]),
32
+ isLayer: false, spring: null });
33
+ } else if (t[0] === "layer") {
34
+ const n = { name: t[1], file: t[2],
35
+ x: parseFloat(t[3]), y: parseFloat(t[4]),
36
+ w: parseFloat(t[5]), h: parseFloat(t[6]),
37
+ px: parseFloat(t[7]), py: parseFloat(t[8]),
38
+ parent: t[9], z: parseInt(t[10]),
39
+ isLayer: true, spring: null, springA: 0, springV: 0 };
40
+ if (t[11] === "spring") n.spring = [parseFloat(t[12]), parseFloat(t[13])];
41
+ nodes.push(n);
42
+ }
43
+ }
44
+ const idx = new Map(nodes.map((n, i) => [n.name, i]));
45
+ for (const n of nodes) n.parentIdx = idx.has(n.parent) ? idx.get(n.parent) : -1;
46
+ return { canvas, nodes };
47
+ }
48
+
49
+ function rotAround(px, py, cx, cy, a) {
50
+ const s = Math.sin(a), c = Math.cos(a);
51
+ const ox = cx - px, oy = cy - py;
52
+ return [px + ox * c - oy * s, py + ox * s + oy * c];
53
+ }
54
+
55
+ // ---- solver (mirrors img2rig::solve) ----
56
+
57
+ function solve(rig, p, dt) {
58
+ const acc = [], out = [];
59
+ for (const n of rig.nodes) {
60
+ let [rot, dx, dy] = n.parentIdx >= 0 ? acc[n.parentIdx] : [0, 0, 0];
61
+ let local = 0;
62
+ if (!n.isLayer) {
63
+ if (n.name === "body_pivot") {
64
+ local = p.lean; dx += p.kickX; dy += p.sink - p.breath * 6.0;
65
+ } else if (n.name === "head_pivot") {
66
+ local = p.headAngle + p.breath * 0.012;
67
+ }
68
+ } else if (n.spring) {
69
+ if (dt > 0) {
70
+ const [k, c] = n.spring;
71
+ n.springV += (-k * n.springA - c * n.springV - rot * k * 0.6) * dt;
72
+ n.springA += n.springV * dt;
73
+ }
74
+ local = n.springA;
75
+ }
76
+ acc.push([rot + local, dx, dy]);
77
+ if (!n.isLayer) { out.push([0, 0, 0, 0]); continue; }
78
+
79
+ let cx = n.x + n.w * 0.5, cy = n.y + n.h * 0.5;
80
+ const arm = n.name === "arm_L" ? p.armL : n.name === "arm_R" ? p.armR : 0;
81
+ if (arm) [cx, cy] = rotAround(n.px, n.py, cx, cy, arm);
82
+ const chain = [];
83
+ for (let pi = n.parentIdx; pi >= 0 && chain.length < 4;
84
+ pi = rig.nodes[pi].parentIdx) chain.push(pi);
85
+ let chainRot = 0;
86
+ for (let ci = chain.length - 1; ci >= 0; ci--) {
87
+ const pn = rig.nodes[chain[ci]];
88
+ const pr = pn.name === "body_pivot" ? p.lean
89
+ : pn.name === "head_pivot" ? p.headAngle + p.breath * 0.012 : 0;
90
+ if (pr) [cx, cy] = rotAround(pn.px, pn.py, cx, cy, pr);
91
+ chainRot += pr;
92
+ }
93
+ out.push([cx + dx, cy + dy, chainRot + local + arm, p.fade]);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ // ---- player ----
99
+
100
+ export class RigPlayer {
101
+ /** Load rig.txt and all layer images. baseUrl defaults to rig.txt's dir. */
102
+ static async load(rigUrl) {
103
+ const text = await (await fetch(rigUrl)).text();
104
+ const rig = parseRig(text);
105
+ const base = rigUrl.slice(0, rigUrl.lastIndexOf("/") + 1);
106
+ const player = new RigPlayer(rig);
107
+ await Promise.all(rig.nodes.filter(n => n.isLayer).map(n =>
108
+ new Promise((res, rej) => {
109
+ const im = new Image();
110
+ im.onload = () => { player.images.set(n.name, im); res(); };
111
+ im.onerror = () => rej(new Error("failed to load " + n.file));
112
+ im.src = base + n.file;
113
+ })));
114
+ return player;
115
+ }
116
+
117
+ constructor(rig) {
118
+ this.rig = rig;
119
+ this.images = new Map();
120
+ this.order = rig.nodes.map((n, i) => i).filter(i => rig.nodes[i].isLayer)
121
+ .sort((a, b) => rig.nodes[a].z - rig.nodes[b].z);
122
+ this.reset();
123
+ }
124
+
125
+ reset() {
126
+ this.t = 0; this.blinkIn = 1.2; this.eyeOpen = 1; this.eyeShut = 0;
127
+ this.lean = 0; this.leanTarget = 0; this.leanReturn = 0;
128
+ this.kick = 0; this.kickV = 0; this.headImp = 0; this.headImpV = 0;
129
+ this.sink = 0; this.sinkTarget = 0; this.rage = 0; this.rageTarget = 0;
130
+ this.fade = 1; this.fadeTarget = 1;
131
+ this.armL = 0; this.armLT = 0; this.armR = 0; this.armRT = 0;
132
+ this.armRate = 5;
133
+ for (const n of this.rig.nodes) if (n.spring) { n.springA = 0; n.springV = 0; }
134
+ this.params = null;
135
+ this.xf = null;
136
+ }
137
+
138
+ excite(v) {
139
+ for (const n of this.rig.nodes)
140
+ if (n.spring) n.springV += v * (Math.random() > 0.5 ? 1 : -1);
141
+ }
142
+
143
+ /** hit | lunge | stagger | collapse | recover | enrage | calm | heal | windup | die */
144
+ trigger(motion) {
145
+ switch (motion) {
146
+ case "hit":
147
+ this.kickV += 340; this.headImpV += 5; this.eyeShut = 0.18;
148
+ this.excite(2.2); break;
149
+ case "lunge":
150
+ this.kickV -= 260; this.leanTo(-0.06, 0.35); break;
151
+ case "stagger":
152
+ this.leanTo(0.24, 1.0); this.kickV += 260; this.headImpV += 6;
153
+ this.eyeShut = 0.3; this.excite(3.5); break;
154
+ case "collapse":
155
+ this.sinkTarget = 26; this.leanTo(0.13, 0); break;
156
+ case "recover":
157
+ this.sinkTarget = 0; this.leanTo(0, 0); this.fadeTarget = 1;
158
+ this.eyeShut = 0; break;
159
+ case "enrage":
160
+ this.rageTarget = 1; this.headImpV += 6; this.excite(4.0); break;
161
+ case "calm": this.rageTarget = 0; break;
162
+ case "heal": this.headImpV -= 2.5; break;
163
+ case "windup": this.leanTo(-0.045, 0.4); break;
164
+ case "die":
165
+ this.fadeTarget = 0.22; this.sinkTarget = 44; this.leanTo(0.38, 0);
166
+ this.eyeShut = 9e9; break;
167
+ }
168
+ }
169
+
170
+ leanTo(target, ret) { this.leanTarget = target; this.leanReturn = ret; }
171
+
172
+ update(dt) {
173
+ if (dt <= 0) return;
174
+ dt = Math.min(dt, 0.05); // tab-switch guard: clamp runaway frame gaps
175
+ this.t += dt;
176
+ this.blinkIn -= dt;
177
+ if (this.eyeShut > 0) {
178
+ this.eyeShut -= dt;
179
+ this.eyeOpen = Math.max(0.08, this.eyeOpen - dt * 14);
180
+ } else if (this.blinkIn < 0) {
181
+ this.eyeOpen -= dt * 12;
182
+ if (this.eyeOpen <= 0.08) {
183
+ this.blinkIn = 2.2 + 2.6 * Math.random();
184
+ this.eyeOpen = 0.08;
185
+ }
186
+ } else {
187
+ this.eyeOpen = Math.min(1, this.eyeOpen + dt * 8);
188
+ }
189
+ if (this.leanReturn > 0) {
190
+ this.leanReturn -= dt;
191
+ if (this.leanReturn <= 0) this.leanTarget = 0;
192
+ }
193
+ this.lean += (this.leanTarget - this.lean) * Math.min(1, dt * 6);
194
+ this.kickV += (-90 * this.kick - 9 * this.kickV) * dt;
195
+ this.kick += this.kickV * dt;
196
+ this.headImpV += (-70 * this.headImp - 7.5 * this.headImpV) * dt;
197
+ this.headImp += this.headImpV * dt;
198
+ this.sink += (this.sinkTarget - this.sink) * Math.min(1, dt * 4);
199
+ this.rage += (this.rageTarget - this.rage) * Math.min(1, dt * 3);
200
+ this.fade += (this.fadeTarget - this.fade) * Math.min(1, dt * 2.5);
201
+ this.armL += (this.armLT - this.armL) * Math.min(1, dt * this.armRate);
202
+ this.armR += (this.armRT - this.armR) * Math.min(1, dt * this.armRate);
203
+
204
+ this.params = {
205
+ breath: 0.5 + 0.5 * Math.sin(this.t * 6.2832 / 3.8),
206
+ headAngle: 0.02 * Math.sin(this.t * 6.2832 / 7.3) + this.headImp * 0.05,
207
+ eyeOpen: this.eyeOpen, lean: this.lean, kickX: this.kick,
208
+ sink: this.sink, rage: this.rage, fade: this.fade,
209
+ armL: this.armL, armR: this.armR,
210
+ };
211
+ this.xf = solve(this.rig, this.params, dt);
212
+ }
213
+
214
+ /** Draw at (x, y) = character center, scaled to `height` display pixels. */
215
+ draw(ctx, x, y, height) {
216
+ if (!this.xf) return;
217
+ const [cw, ch] = this.rig.canvas;
218
+ const sc = height / ch;
219
+ const p = this.params;
220
+ for (const i of this.order) {
221
+ const n = this.rig.nodes[i];
222
+ const [wx, wy, rot, alphaBase] = this.xf[i];
223
+ const img = this.images.get(n.name);
224
+ if (!img) continue;
225
+ let alpha = alphaBase;
226
+ if (n.name === "sigil") {
227
+ alpha *= p.rage;
228
+ if (alpha < 0.01) continue;
229
+ }
230
+ let w = n.w * sc, h = n.h * sc, eyeDrop = 0;
231
+ if (n.name === "eye_L" || n.name === "eye_R") {
232
+ const open = Math.max(0.08, p.eyeOpen);
233
+ eyeDrop = h * (1 - open) * 0.5; // blink anchors the bottom edge
234
+ h *= open;
235
+ }
236
+ ctx.save();
237
+ ctx.globalAlpha = alpha;
238
+ if (p.fade < 0.999) ctx.filter = `brightness(${p.fade})`;
239
+ ctx.translate(x + (wx - cw * 0.5) * sc, y + (wy - ch * 0.5) * sc + eyeDrop);
240
+ if (rot) ctx.rotate(rot);
241
+ ctx.drawImage(img, -w / 2, -h / 2, w, h);
242
+ ctx.restore();
243
+ }
244
+ }
245
+ }