@energy8platform/golem 0.5.0 → 0.7.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 (72) hide show
  1. package/README.md +18 -0
  2. package/dist/editor.css +3 -4
  3. package/dist/editor.js +177 -75
  4. package/dist/lib/cli.js +302 -126
  5. package/dist/lib/cli.js.map +4 -4
  6. package/dist/lib/e8/agent.d.ts +35 -0
  7. package/dist/lib/e8/host.d.ts +22 -0
  8. package/dist/lib/e8/node.d.ts +66 -0
  9. package/dist/lib/e8/runtime.d.ts +10 -0
  10. package/dist/lib/e8/schema.d.ts +3 -0
  11. package/dist/lib/e8/types.d.ts +80 -0
  12. package/dist/lib/e8-agent.js +6781 -0
  13. package/dist/lib/e8-agent.js.map +7 -0
  14. package/dist/lib/e8-client.js +116 -0
  15. package/dist/lib/e8-host.js +6929 -0
  16. package/dist/lib/e8-host.js.map +7 -0
  17. package/dist/lib/e8-runtime.js +2024 -0
  18. package/dist/lib/e8-runtime.js.map +7 -0
  19. package/dist/lib/e8-schema.js +7 -0
  20. package/dist/lib/e8-schema.js.map +7 -0
  21. package/dist/lib/editor/api.d.ts +1 -0
  22. package/dist/lib/editor/embed.d.ts +9 -0
  23. package/dist/lib/editor/server.d.ts +23 -1
  24. package/dist/lib/editor/store.d.ts +107 -0
  25. package/dist/lib/editor/timeline.d.ts +15 -0
  26. package/dist/lib/editor-entry.d.ts +1 -1
  27. package/dist/lib/editor-entry.js +303 -126
  28. package/dist/lib/editor-entry.js.map +4 -4
  29. package/dist/lib/harness.js +69 -14
  30. package/dist/lib/interactive-editor.js.map +1 -1
  31. package/dist/lib/rig-anim.d.ts +2 -0
  32. package/dist/lib/rig-control-qa.d.ts +27 -0
  33. package/dist/lib/rig-format.d.ts +989 -0
  34. package/dist/lib/rig-import-layers.d.ts +2 -0
  35. package/dist/lib/rig-version.d.ts +12 -0
  36. package/dist/lib/runtime.js +69 -14
  37. package/dist/lib/runtime.js.map +3 -3
  38. package/dist/lib/spine-import.d.ts +4 -0
  39. package/dist/lib/tool-schema.d.ts +3 -0
  40. package/dist/lib/tools.d.ts +2 -0
  41. package/dist/lib/tools.js +257 -73
  42. package/dist/lib/tools.js.map +4 -4
  43. package/editor.html +2 -2
  44. package/package.json +38 -3
  45. package/skills/e8-golem/SKILL.md +71 -0
  46. package/skills/golem-symbol-animation/SKILL.md +87 -0
  47. package/skills/golem-symbol-animation/agents/openai.yaml +4 -0
  48. package/skills/golem-symbol-animation/references/facial-controls.md +48 -0
  49. package/skills/golem-symbol-animation/references/game-engine-integration.md +133 -0
  50. package/skills/golem-symbol-animation/references/golem-authoring.md +138 -0
  51. package/skills/golem-symbol-animation/references/interactive-editor.md +17 -0
  52. package/skills/golem-symbol-animation/references/motion-craft.md +113 -0
  53. package/skills/golem-symbol-animation/references/packed-delivery.md +88 -0
  54. package/skills/golem-symbol-animation/references/quantitative-qa.md +106 -0
  55. package/skills/golem-symbol-animation/references/spine-rive-study.md +87 -0
  56. package/skills/golem-symbol-animation/scripts/atlas_parts.py +87 -0
  57. package/skills/golem-symbol-animation/scripts/check_package.py +71 -0
  58. package/skills/golem-symbol-animation/scripts/image_gates.py +143 -0
  59. package/skills/golem-symbol-cutting/SKILL.md +70 -0
  60. package/skills/golem-symbol-cutting/agents/openai.yaml +4 -0
  61. package/skills/golem-symbol-cutting/assets/h1/h1.atlas.png +0 -0
  62. package/skills/golem-symbol-cutting/assets/h1/h1.png +0 -0
  63. package/skills/golem-symbol-cutting/references/cutting-workflow.md +92 -0
  64. package/skills/golem-symbol-cutting/references/facial-layers.md +44 -0
  65. package/skills/golem-symbol-cutting/references/generation-workflow.md +109 -0
  66. package/skills/golem-symbol-cutting/references/golem-handoff.md +62 -0
  67. package/skills/golem-symbol-cutting/references/h1-example.md +46 -0
  68. package/skills/golem-symbol-cutting/references/hybrid-workflow.md +119 -0
  69. package/skills/golem-symbol-cutting/references/registration-and-motion.md +89 -0
  70. package/skills/golem-symbol-cutting/references/spine-rive-construction.md +73 -0
  71. package/skills/golem-symbol-cutting/scripts/atlas_parts.py +87 -0
  72. package/skills/golem-symbol-cutting/scripts/hybrid_parts.py +279 -0
package/dist/lib/cli.js CHANGED
@@ -3,9 +3,9 @@ import { resolve as resolve3 } from "node:path";
3
3
 
4
4
  // src/editor/server.ts
5
5
  import { createServer } from "node:http";
6
- import { readFile as readFile10, stat as stat3, readdir as readdir5, mkdir as mkdir7, open } from "node:fs/promises";
6
+ import { readFile as readFile10, stat as stat4, readdir as readdir5, mkdir as mkdir7, open } from "node:fs/promises";
7
7
  import { watch as fsWatch } from "node:fs";
8
- import { resolve as resolve2, dirname as dirname9, basename as basename5, extname as extname2, sep, join as join10, relative as relative3 } from "node:path";
8
+ import { resolve as resolve2, dirname as dirname9, basename as basename5, extname as extname2, sep as sep2, join as join10, relative as relative4 } from "node:path";
9
9
  import { fileURLToPath as fileURLToPath3 } from "node:url";
10
10
 
11
11
  // src/rig-format.ts
@@ -176,7 +176,9 @@ var PathAttachment = z.object({
176
176
  /** curve lengths for constant-speed positioning */
177
177
  lengths: z.array(z.number()).default([]),
178
178
  closed: z.boolean().default(false),
179
- constantSpeed: z.boolean().default(true)
179
+ constantSpeed: z.boolean().default(true),
180
+ /** Spine-style active clipping span in the posed draw order; end slot is inclusive. */
181
+ clipping: z.object({ end: Id.optional() }).optional()
180
182
  });
181
183
  var Attachment = z.discriminatedUnion("type", [RegionAttachment, MeshAttachment, PathAttachment]);
182
184
  var IkConstraint = z.object({
@@ -245,7 +247,9 @@ var Key = z.object({
245
247
  t: z.number().nonnegative(),
246
248
  v: KeyValue,
247
249
  /** easing INTO the next key */
248
- ease: Ease.default("ease_in_out")
250
+ ease: Ease.default("ease_in_out"),
251
+ /** Optional independent easing for numeric-array components (for example Spine RGBA curves). */
252
+ componentEase: z.array(Ease).min(1).optional()
249
253
  });
250
254
  var Interpolation = { interpolation: z.enum(["segment", "pchip"]).optional() };
251
255
  var Track = z.discriminatedUnion("target", [
@@ -432,6 +436,10 @@ function validate(doc) {
432
436
  else if (a.slot !== s2.id) errs.push(`slot "${s2.id}": attachment "${s2.attachment}" belongs to slot "${a.slot}"`);
433
437
  }
434
438
  }
439
+ for (const a of doc.attachments) if (a.type === "path" && a.clipping) {
440
+ if (!a.closed) errs.push(`attachment "${a.id}": clipping path must be closed`);
441
+ if (a.clipping.end && !slots.has(a.clipping.end)) errs.push(`attachment "${a.id}": unknown clipping end slot "${a.clipping.end}"`);
442
+ }
435
443
  const vertexCount2 = (a) => {
436
444
  if (a.type === "region") return 4;
437
445
  if (a.type === "mesh") return a.uvs.length / 2;
@@ -515,6 +523,7 @@ function validate(doc) {
515
523
  const att = t.target === "deform" ? attachments.get(t.id) : void 0;
516
524
  let prev = -1;
517
525
  for (const k of t.keys) {
526
+ if (k.componentEase && (!Array.isArray(k.v) || !k.v.every((v) => typeof v === "number") || k.componentEase.length !== k.v.length)) errs.push(`animation "${a.id}" ${tid}: componentEase must match the numeric-array value length`);
518
527
  if (k.t <= prev && prev >= 0) errs.push(`animation "${a.id}" ${tid}: keys not strictly increasing`);
519
528
  if (k.t > a.duration) errs.push(`animation "${a.id}" ${tid}: key at ${k.t}s exceeds duration`);
520
529
  if (t.target === "control") {
@@ -562,7 +571,7 @@ function parse(input) {
562
571
  // src/rig-api.ts
563
572
  import { z as z3 } from "zod";
564
573
  import { readFile as readFile9, writeFile as writeFile7, stat as stat2, mkdir as mkdir6, readdir as readdir4 } from "node:fs/promises";
565
- import { dirname as dirname8, join as join9, relative as relative2, isAbsolute } from "node:path";
574
+ import { dirname as dirname8, join as join9, relative as relative3, isAbsolute as isAbsolute2 } from "node:path";
566
575
 
567
576
  // src/rig-io.ts
568
577
  import { mkdir, rename, rm, writeFile } from "node:fs/promises";
@@ -585,8 +594,10 @@ var saveJson = (path, value) => saveBytes(path, JSON.stringify(value, null, 2));
585
594
  // src/rig-queue.ts
586
595
  import { AsyncLocalStorage } from "node:async_hooks";
587
596
  import { resolve } from "node:path";
588
- var pending = /* @__PURE__ */ new Map();
589
- var active = new AsyncLocalStorage();
597
+ var { pending, active } = globalThis[Symbol.for("golem.rigQueue")] ??= {
598
+ pending: /* @__PURE__ */ new Map(),
599
+ active: new AsyncLocalStorage()
600
+ };
590
601
  async function withRigQueue(path, op) {
591
602
  const key = resolve(path);
592
603
  const held = active.getStore();
@@ -742,8 +753,8 @@ function sample(track, t, loop = false) {
742
753
  }
743
754
  if (Array.isArray(a.v) && Array.isArray(b.v) && typeof a.v[0] === "number") {
744
755
  if (track.interpolation === "pchip") return a.v.map((_, j2) => smoothValue(track, i, t, j2, loop));
745
- const u = ease(a.ease)((t - a.t) / (b.t - a.t)), av = a.v, bv = b.v;
746
- return av.map((v, k) => v + ((bv[k] ?? v) - v) * u);
756
+ const phase = (t - a.t) / (b.t - a.t), av = a.v, bv = b.v;
757
+ return av.map((v, k) => v + ((bv[k] ?? v) - v) * ease(a.componentEase?.[k] ?? a.ease)(phase));
747
758
  }
748
759
  return a.v;
749
760
  }
@@ -835,6 +846,13 @@ function boneWorldOf(bone, s2, parent) {
835
846
  if (!parent) return local;
836
847
  if (bone.inherit === "normal") return mul(parent, local);
837
848
  const [tx, ty] = apply(parent, s2.x, s2.y);
849
+ if (bone.inherit === "noScale" || bone.inherit === "noScaleOrReflection") {
850
+ const r = s2.rotation * DEG2, x = parent.a * Math.cos(r) + parent.c * Math.sin(r), y = parent.b * Math.cos(r) + parent.d * Math.sin(r);
851
+ const length = Math.hypot(x, y), a = length > 1e-5 ? x / length : x, b = length > 1e-5 ? y / length : y;
852
+ const sign = bone.inherit === "noScale" && parent.a * parent.d - parent.b * parent.c < 0 ? -1 : 1;
853
+ const basis3 = { a, b, c: -b * sign, d: a * sign, tx: 0, ty: 0 };
854
+ return { ...mul(basis3, affineOf(0, 0, 0, s2.scaleX, s2.scaleY, s2.shearX, s2.shearY)), tx, ty };
855
+ }
838
856
  return { ...mul(inheritedParentLinear(bone, parent), { ...local, tx: 0, ty: 0 }), tx, ty };
839
857
  }
840
858
  var orderCache = /* @__PURE__ */ new WeakMap();
@@ -867,11 +885,11 @@ var localOf = (doc, pose, b) => {
867
885
  shearY: num(pose, pk("bone", id, "shearY"), r.shearY)
868
886
  };
869
887
  };
870
- function updateSubtree(skel, root3) {
888
+ function updateSubtree(skel, root3, includeRoot = true) {
871
889
  const doc = skel.doc;
872
890
  const kids = /* @__PURE__ */ new Map();
873
891
  for (const b of doc.bones) if (b.parent) (kids.get(b.parent) ?? kids.set(b.parent, []).get(b.parent)).push(b);
874
- const stack = [root3];
892
+ const stack = includeRoot ? [root3] : (kids.get(root3) ?? []).map((b) => b.id);
875
893
  while (stack.length) {
876
894
  const id = stack.pop(), b = boneOf(doc, id), st = skel.bones.get(id);
877
895
  st.world = boneWorldOf(b, st, b.parent ? skel.bones.get(b.parent).world : void 0);
@@ -1114,7 +1132,8 @@ function applyTransformConstraint(skel, c) {
1114
1132
  if (!mixRotate && !mixX && !mixY && !mixScaleX && !mixScaleY && !mixShearY) return;
1115
1133
  c = { ...c, ...Object.fromEntries(["x", "y", "rotation", "scaleX", "scaleY", "shearY"].map((p) => [p, cnum(skel, "transform", c.id, p, c[p])])) };
1116
1134
  const target = skel.bones.get(c.target), tw = target.world;
1117
- const offsetRotation = c.rotation * DEG2, offsetShearY = c.shearY * DEG2;
1135
+ const handedness = tw.a * tw.d - tw.b * tw.c < 0 ? -1 : 1;
1136
+ const offsetRotation = c.rotation * DEG2 * (c.local ? 1 : handedness), offsetShearY = c.shearY * DEG2 * (c.local ? 1 : handedness);
1118
1137
  for (const id of c.bones) {
1119
1138
  const b = boneOf(skel.doc, id), st = skel.bones.get(id);
1120
1139
  if (c.local) {
@@ -1276,6 +1295,7 @@ function applyPathConstraint(skel, c) {
1276
1295
  const position = cnum(skel, "path", c.id, "position", c.position), spacing = cnum(skel, "path", c.id, "spacing", c.spacing);
1277
1296
  const tangents = c.rotateMode === "tangent", scale = c.rotateMode === "chainScale";
1278
1297
  const bones = c.bones.map((id) => ({ b: boneOf(skel.doc, id), st: skel.bones.get(id) }));
1298
+ const inputWorlds = bones.map(({ st }) => ({ ...st.world }));
1279
1299
  const spacesCount = tangents ? bones.length : bones.length + 1;
1280
1300
  const spaces = new Array(spacesCount).fill(0), worldLengths = [];
1281
1301
  let sum = 0;
@@ -1310,9 +1330,10 @@ function applyPathConstraint(skel, c) {
1310
1330
  if (tw.a * tw.d - tw.b * tw.c < 0) offsetRotation = -offsetRotation;
1311
1331
  }
1312
1332
  let boneX = samples[0].x, boneY = samples[0].y;
1333
+ const solvedWorlds = /* @__PURE__ */ new Map();
1313
1334
  for (let i = 0; i < bones.length; i++) {
1314
1335
  const { b, st } = bones[i];
1315
- let w = { ...st.world };
1336
+ let w = { ...inputWorlds[i] };
1316
1337
  w.tx += (boneX - w.tx) * mixX;
1317
1338
  w.ty += (boneY - w.ty) * mixY;
1318
1339
  const next = samples[i + 1] ?? { x: 0, y: 0, angle: 0 };
@@ -1342,7 +1363,11 @@ function applyPathConstraint(skel, c) {
1342
1363
  const cos = Math.cos(r), sin = Math.sin(r);
1343
1364
  w = { a: cos * a - sin * bb, b: sin * a + cos * bb, c: cos * cc - sin * d, d: sin * cc + cos * d, tx: w.tx, ty: w.ty };
1344
1365
  }
1345
- setEffectiveWorld(skel, b, st, w);
1366
+ solvedWorlds.set(b.id, w);
1367
+ }
1368
+ for (const b of boneOrder(skel.doc)) {
1369
+ const world = solvedWorlds.get(b.id);
1370
+ if (world) setEffectiveWorld(skel, b, skel.bones.get(b.id), world);
1346
1371
  }
1347
1372
  }
1348
1373
  function measuredPathLength(points, closed, lengths, constantSpeed) {
@@ -1371,9 +1396,14 @@ function inheritedParentLinear(bone, parent) {
1371
1396
  case "onlyTranslation":
1372
1397
  return { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
1373
1398
  case "noRotationOrReflection": {
1374
- const lenX = Math.hypot(parent.a, parent.b) || 1;
1399
+ const lenX = Math.hypot(parent.a, parent.b);
1375
1400
  const det = parent.a * parent.d - parent.b * parent.c;
1376
- return { a: lenX, b: 0, c: 0, d: Math.abs(det) / lenX, tx: 0, ty: 0 };
1401
+ if (lenX * lenX <= 1e-4) {
1402
+ const lenY = Math.hypot(parent.c, parent.d), ux2 = lenY ? parent.c / lenY : 0, uy2 = lenY ? parent.d / lenY : 0;
1403
+ return { a: lenY * ux2 * ux2, b: lenY * ux2 * uy2, c: lenY * ux2 * uy2, d: lenY * uy2 * uy2, tx: 0, ty: 0 };
1404
+ }
1405
+ const ux = parent.a / lenX, uy = parent.b / lenX, sy = Math.abs(det) / lenX;
1406
+ return { a: lenX * ux * ux + sy * uy * uy, b: (lenX - sy) * ux * uy, c: (lenX - sy) * ux * uy, d: lenX * uy * uy + sy * ux * ux, tx: 0, ty: 0 };
1377
1407
  }
1378
1408
  case "noScale":
1379
1409
  case "noScaleOrReflection": {
@@ -1399,7 +1429,16 @@ function setEffectiveWorld(skel, bone, st, world) {
1399
1429
  st.scaleY = Math.hypot(lin.c, lin.d);
1400
1430
  st.shearX = 0;
1401
1431
  st.shearY = shortest(Math.atan2(lin.d, lin.c) / DEG2 - st.rotation - 90);
1402
- updateSubtree(skel, bone.id);
1432
+ if (parent && (bone.inherit === "noScale" || bone.inherit === "noScaleOrReflection")) {
1433
+ const direction = mul(invert({ ...parent, tx: 0, ty: 0 }), { ...world, tx: 0, ty: 0 });
1434
+ st.rotation = Math.atan2(direction.b, direction.a) / DEG2;
1435
+ st.scaleX = Math.hypot(world.a, world.b);
1436
+ st.scaleY = Math.hypot(world.c, world.d);
1437
+ const sign = bone.inherit === "noScale" && parent.a * parent.d - parent.b * parent.c < 0 ? -1 : 1;
1438
+ st.shearY = shortest((Math.atan2(world.d, world.c) - Math.atan2(world.b, world.a)) / DEG2 * sign - 90);
1439
+ }
1440
+ st.world = world;
1441
+ updateSubtree(skel, bone.id, false);
1403
1442
  }
1404
1443
  function attachmentWorldVertices(skel, slotId, att) {
1405
1444
  const deform = skel.pose.get(pk("deform", att.id, "vertices"));
@@ -1974,8 +2013,9 @@ function createDoc(name, width, height, notes) {
1974
2013
  function createSymbol(name, asset, margin = 0.15, notes) {
1975
2014
  const mx = Math.ceil(asset.width * margin), my = Math.ceil(asset.height * margin);
1976
2015
  const width = asset.width + 2 * mx, height = asset.height + 2 * my;
1977
- const fromFile = asset.src.replace(/^.*\//, "").replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/^[^a-z]+/, "");
1978
- const id = asset.id ?? (fromFile || name);
2016
+ const stem = asset.src.replace(/^.*\//, "").replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9_]/g, "_");
2017
+ const fromFile = stem.replace(/^[^a-z]+/, "") || (stem ? `symbol_${stem}` : "");
2018
+ const id = asset.id ?? (ID_RE.test(fromFile) ? fromFile : ID_RE.test(name) ? name : "symbol");
1979
2019
  let doc = createDoc(name, width, height, notes);
1980
2020
  doc = { ...doc, bones: [{ ...doc.bones[0], transform: { ...doc.bones[0].transform, x: width / 2, y: height - my } }] };
1981
2021
  doc = { ...doc, assets: [RigSchema.shape.assets.element.parse({ id, src: asset.src, width: asset.width, height: asset.height })] };
@@ -2158,7 +2198,7 @@ function removeSlot(doc, id) {
2158
2198
  return patchDrawOrder({
2159
2199
  ...doc,
2160
2200
  slots: doc.slots.filter((s2) => s2.id !== id).map((s2) => gone.has(s2.clip ?? "") ? { ...s2, clip: void 0 } : s2),
2161
- attachments: doc.attachments.filter((a) => a.slot !== id),
2201
+ attachments: doc.attachments.filter((a) => a.slot !== id).map((a) => a.type === "path" && a.clipping?.end === id ? { ...a, clipping: {} } : a),
2162
2202
  animations: doc.animations.map((a) => ({ ...a, tracks: a.tracks.filter((t) => !(t.target === "slot" && t.id === id || t.target === "deform" && gone.has(t.id))) }))
2163
2203
  }, (o) => o.filter((s2) => s2 !== id));
2164
2204
  }
@@ -2554,7 +2594,7 @@ function rename2(doc, kind, id, to) {
2554
2594
  return {
2555
2595
  ...doc,
2556
2596
  slots: doc.slots.map((s2) => ({ ...s2, id: sub(s2.id) })),
2557
- attachments: doc.attachments.map((a) => ({ ...a, slot: sub(a.slot) })),
2597
+ attachments: doc.attachments.map((a) => ({ ...a, slot: sub(a.slot), ...a.type === "path" && a.clipping?.end ? { clipping: { end: sub(a.clipping.end) } } : {} })),
2558
2598
  path: doc.path.map((c) => ({ ...c, target: sub(c.target) }))
2559
2599
  };
2560
2600
  }
@@ -2731,7 +2771,7 @@ function setGoal(doc, bone, x, y, opts) {
2731
2771
 
2732
2772
  // src/rig-import-layers.ts
2733
2773
  import { readdir, readFile } from "node:fs/promises";
2734
- import { join as join2, basename as basename2 } from "node:path";
2774
+ import { join as join2, basename as basename2, isAbsolute, relative, sep } from "node:path";
2735
2775
  var DEFAULT_PARENT = {
2736
2776
  head: "neck",
2737
2777
  neck: "torso",
@@ -2757,6 +2797,10 @@ function pngSize(buf) {
2757
2797
  if (buf.readUInt32BE(12) !== 1229472850) throw new Error("not a PNG (IHDR missing)");
2758
2798
  return [buf.readUInt32BE(16), buf.readUInt32BE(20)];
2759
2799
  }
2800
+ function outsideDir(dir, file) {
2801
+ const r = relative(dir, join2(dir, file));
2802
+ return isAbsolute(file) || r === ".." || r.startsWith(".." + sep);
2803
+ }
2760
2804
  async function importLayers(doc, dir, relPrefix = "layers") {
2761
2805
  let specs;
2762
2806
  try {
@@ -2769,6 +2813,7 @@ async function importLayers(doc, dir, relPrefix = "layers") {
2769
2813
  let next = doc;
2770
2814
  const parsed = [];
2771
2815
  for (const s2 of specs) {
2816
+ if (outsideDir(dir, s2.file)) throw new Error(`layers.json file "${s2.file}" is outside the layers folder (${dir})`);
2772
2817
  const buf = await readFile(join2(dir, s2.file));
2773
2818
  const [w, h] = pngSize(buf);
2774
2819
  const m = basename2(s2.file, ".png").match(/^(?:(\d+)_)?(.+)$/);
@@ -4025,7 +4070,7 @@ async function buildAtlas(doc, assetsDir, outDir, opts = {}) {
4025
4070
 
4026
4071
  // src/spine-import.ts
4027
4072
  import { readFile as readFile7, stat, writeFile as writeFile5 } from "node:fs/promises";
4028
- import { basename as basename4, dirname as dirname6, extname, join as join8, relative } from "node:path";
4073
+ import { basename as basename4, dirname as dirname6, extname, join as join8, relative as relative2 } from "node:path";
4029
4074
 
4030
4075
  // src/ktx2.ts
4031
4076
  import { readFile as readFile6 } from "node:fs/promises";
@@ -4167,21 +4212,26 @@ var Ids = class {
4167
4212
  has(name) {
4168
4213
  return this.map.has(name);
4169
4214
  }
4215
+ reserve(id) {
4216
+ this.used.add(id);
4217
+ }
4170
4218
  };
4171
4219
  var spineToDoc = (x, y, originX, originY) => [x + originX, originY - y];
4172
4220
  var r4 = (v) => Math.round(v * 1e6) / 1e6 + 0;
4173
4221
  var hex8 = (c) => `#${(c ?? "ffffffff").toLowerCase().padEnd(8, "f").slice(0, 8)}`;
4174
- function importSpine(json, atlasText, opts = {}) {
4222
+ function importSpine(json2, atlasText, opts = {}) {
4175
4223
  const warnings = [];
4176
4224
  const atlas = parseAtlas(atlasText);
4177
- const version = String(json.skeleton?.spine ?? "4.0");
4225
+ const version = String(json2.skeleton?.spine ?? "4.0");
4226
+ if (json2.physics?.length) warnings.push(`${json2.physics.length} Spine physics constraints are not imported; bake their motion before delivery`);
4178
4227
  const major = parseFloat(version);
4179
- const bounds = { x: json.skeleton?.x ?? 0, y: json.skeleton?.y ?? 0, w: json.skeleton?.width ?? 0, h: json.skeleton?.height ?? 0 };
4228
+ const bounds = { x: json2.skeleton?.x ?? 0, y: json2.skeleton?.y ?? 0, w: json2.skeleton?.width ?? 0, h: json2.skeleton?.height ?? 0 };
4180
4229
  const margin = opts.margin ?? 0;
4181
4230
  let originX = -bounds.x + margin, originY = bounds.y + bounds.h + margin;
4182
4231
  const toDoc = (x, y) => spineToDoc(x, y, originX, originY);
4183
4232
  const boneIds = new Ids("b_"), slotIds = new Ids("s_"), assetIds = new Ids("a_"), attIds = new Ids("att_"), ikIds = new Ids("ik_"), tcIds = new Ids("tc_"), pcIds = new Ids("pc_"), animIds = new Ids("anim_");
4184
4233
  const ROOT = "origin";
4234
+ boneIds.reserve(ROOT);
4185
4235
  const attachmentIdMap = /* @__PURE__ */ new Map();
4186
4236
  const attachmentId = (slot, name) => {
4187
4237
  const key = `${slot} ${name}`;
@@ -4192,7 +4242,7 @@ function importSpine(json, atlasText, opts = {}) {
4192
4242
  }
4193
4243
  return id;
4194
4244
  };
4195
- const spineBones = json.bones ?? [];
4245
+ const spineBones = json2.bones ?? [];
4196
4246
  const boneIndexByName = new Map(spineBones.map((b, i) => [b.name, i]));
4197
4247
  const bones = [{ id: ROOT, transform: { x: r4(originX), y: r4(originY) }, role: "root" }];
4198
4248
  for (const b of spineBones) {
@@ -4205,14 +4255,18 @@ function importSpine(json, atlasText, opts = {}) {
4205
4255
  ...b.color ? { color: b.color } : {}
4206
4256
  });
4207
4257
  }
4208
- const skins = Array.isArray(json.skins) ? json.skins : Object.entries(json.skins ?? {}).map(([name, attachments2]) => ({ name, attachments: attachments2 }));
4258
+ const skins = Array.isArray(json2.skins) ? json2.skins : Object.entries(json2.skins ?? {}).map(([name, attachments2]) => ({ name, attachments: attachments2 }));
4259
+ const selectedSkin = opts.skin ? skins.find((s2) => s2.name === opts.skin) : skins.find((s2) => s2.name === "default") ?? skins[0];
4260
+ if (opts.skin && !selectedSkin) throw new Error(`Spine skin "${opts.skin}" not found; available: ${skins.map((s2) => s2.name).join(", ")}`);
4261
+ const lookupSkins = [selectedSkin, skins.find((s2) => s2.name === "default")].filter((s2, i, list) => s2 && list.indexOf(s2) === i);
4262
+ if (skins.length > 1) warnings.push(`Spine skin "${selectedSkin?.name}" selected; setup and attachment timelines are flattened to this skin with default fallback (no runtime skin switching)`);
4209
4263
  const frameSuffix = (seq, index) => {
4210
- const n = String((seq.start ?? 0) + index);
4264
+ const n = String((seq.start ?? 1) + index);
4211
4265
  return "0".repeat(Math.max(0, (seq.digits ?? 0) - n.length)) + n;
4212
4266
  };
4213
4267
  const sequences = /* @__PURE__ */ new Map();
4214
4268
  const skinnedName = /* @__PURE__ */ new Map();
4215
- for (const skin2 of skins) {
4269
+ for (const skin2 of lookupSkins) {
4216
4270
  const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4217
4271
  for (const [slotName, atts] of Object.entries(skin2.attachments ?? {}))
4218
4272
  for (const [name, a] of Object.entries(atts)) {
@@ -4221,19 +4275,23 @@ function importSpine(json, atlasText, opts = {}) {
4221
4275
  if (!skinnedName.has(key)) skinnedName.set(key, prefix + name + (seq ? frameSuffix(seq, seq.setupIndex ?? 0) : ""));
4222
4276
  }
4223
4277
  }
4224
- const attachmentRef = (slot, name) => attachmentId(slot, skinnedName.get(`${slot} ${name}`) ?? name);
4225
- const spineSlots = json.slots ?? [];
4278
+ const attachmentRef = (slot, name) => {
4279
+ const resolved = skinnedName.get(`${slot} ${name}`);
4280
+ return resolved === void 0 ? null : attachmentId(slot, resolved);
4281
+ };
4282
+ const spineSlots = json2.slots ?? [];
4226
4283
  const slotIndexByName = new Map(spineSlots.map((s2, i) => [s2.name, i]));
4227
4284
  const blendOf = (b) => b === "additive" ? "add" : b === "multiply" ? "multiply" : b === "screen" ? "screen" : "normal";
4228
4285
  const slots = spineSlots.map((s2, i) => ({
4229
4286
  id: slotIds.get(s2.name),
4230
4287
  bone: boneIds.get(s2.bone),
4231
4288
  z: i,
4232
- ...s2.attachment ? { attachment: attachmentRef(s2.name, s2.attachment) } : {},
4289
+ ...s2.attachment && attachmentRef(s2.name, s2.attachment) ? { attachment: attachmentRef(s2.name, s2.attachment) } : {},
4233
4290
  blend: blendOf(s2.blend),
4234
4291
  color: hex8(s2.color),
4235
4292
  alpha: 1
4236
4293
  }));
4294
+ if (spineSlots.some((s2) => s2.dark)) warnings.push("Spine setup dark two-color tint is not supported");
4237
4295
  const pageImage = (page2) => typeof opts.pageImage === "function" ? opts.pageImage(page2) : opts.pageImage ?? page2;
4238
4296
  const assets = /* @__PURE__ */ new Map();
4239
4297
  const regionByName = new Map(atlas.regions.map((r) => [r.name, r]));
@@ -4259,6 +4317,7 @@ function importSpine(json, atlasText, opts = {}) {
4259
4317
  return id;
4260
4318
  };
4261
4319
  const attachments = [];
4320
+ const clipDeforms = /* @__PURE__ */ new Map();
4262
4321
  const readVertices = (a, vertexCount2) => {
4263
4322
  const v = a.vertices ?? [];
4264
4323
  if (v.length === vertexCount2 * 2) {
@@ -4279,6 +4338,22 @@ function importSpine(json, atlasText, opts = {}) {
4279
4338
  }
4280
4339
  return { weights };
4281
4340
  };
4341
+ const meshGeometry = (skinName, slot, name, visiting = /* @__PURE__ */ new Set()) => {
4342
+ const key = JSON.stringify([skinName, slot, name]);
4343
+ if (visiting.has(key)) {
4344
+ warnings.push(`linked mesh cycle at "${skinName}/${slot}/${name}" skipped`);
4345
+ return void 0;
4346
+ }
4347
+ const raw = skins.find((s2) => s2.name === skinName)?.attachments?.[slot]?.[name];
4348
+ if (!raw) {
4349
+ warnings.push(`linked mesh parent "${skinName}/${slot}/${name}" not found`);
4350
+ return void 0;
4351
+ }
4352
+ if (!raw.parent) return raw;
4353
+ visiting.add(key);
4354
+ return meshGeometry(raw.skin ?? "default", slot, raw.parent, visiting);
4355
+ };
4356
+ const inheritedDeform = /* @__PURE__ */ new Map();
4282
4357
  for (const skin2 of skins) {
4283
4358
  const prefix = skin2.name === "default" ? "" : `${skin2.name}_`;
4284
4359
  for (const [slotName, atts] of Object.entries(skin2.attachments ?? {})) {
@@ -4290,7 +4365,7 @@ function importSpine(json, atlasText, opts = {}) {
4290
4365
  for (const [name, a] of Object.entries(atts)) {
4291
4366
  if (a.sequence) {
4292
4367
  sequences.set(`${slotName} ${prefix}${name}`, a.sequence);
4293
- const base = a.path ?? name;
4368
+ const base = a.path ?? a.name ?? name;
4294
4369
  for (let i = 0; i < (a.sequence.count ?? 0); i++) {
4295
4370
  const suffix = frameSuffix(a.sequence, i), asset = assetFor(base + suffix);
4296
4371
  if (!asset) continue;
@@ -4313,7 +4388,7 @@ function importSpine(json, atlasText, opts = {}) {
4313
4388
  }
4314
4389
  const type = a.type ?? "region", id = attachmentId(slotName, prefix + name);
4315
4390
  if (type === "region") {
4316
- const asset = assetFor(a.path ?? name);
4391
+ const asset = assetFor(a.path ?? a.name ?? name);
4317
4392
  if (!asset) continue;
4318
4393
  attachments.push({
4319
4394
  id,
@@ -4329,21 +4404,48 @@ function importSpine(json, atlasText, opts = {}) {
4329
4404
  ...a.height ? { height: a.height } : {}
4330
4405
  });
4331
4406
  } else if (type === "mesh" || type === "linkedmesh") {
4332
- if (type === "linkedmesh") {
4333
- warnings.push(`linked mesh "${slotName}/${name}" skipped`);
4334
- continue;
4335
- }
4336
- const asset = assetFor(a.path ?? name);
4407
+ const geometry = a.parent ? meshGeometry(skin2.name, slotName, name) : a;
4408
+ if (!geometry) continue;
4409
+ const asset = assetFor(a.path ?? a.name ?? name);
4337
4410
  if (!asset) continue;
4338
- const n = (a.uvs?.length ?? 0) / 2;
4339
- attachments.push({ id, slot, type: "mesh", asset, uvs: a.uvs, triangles: a.triangles, ...readVertices(a, n), hull: a.hull ?? 0, ...a.width ? { width: a.width } : {}, ...a.height ? { height: a.height } : {} });
4411
+ const n = (geometry.uvs?.length ?? 0) / 2;
4412
+ attachments.push({ id, slot, type: "mesh", asset, uvs: geometry.uvs, triangles: geometry.triangles, ...readVertices(geometry, n), hull: geometry.hull ?? 0, ...geometry.width ? { width: geometry.width } : {}, ...geometry.height ? { height: geometry.height } : {} });
4413
+ if (a.parent && (a.timelines ?? a.deform ?? true)) {
4414
+ const parentSkin = a.skin ?? "default";
4415
+ inheritedDeform.set(id, attachmentId(slotName, (parentSkin === "default" ? "" : `${parentSkin}_`) + a.parent));
4416
+ }
4340
4417
  } else if (type === "path") {
4341
4418
  attachments.push({ id, slot, type: "path", ...readVertices(a, a.vertexCount ?? 0), lengths: a.lengths ?? [], closed: !!a.closed, constantSpeed: a.constantSpeed ?? true });
4419
+ } else if (type === "clipping") {
4420
+ const data = readVertices(a, a.vertexCount), n = a.vertexCount;
4421
+ const recipes = Array.from({ length: n }, (_, i) => [
4422
+ [[(i + n - 1) % n, 1 / 3], [i, 2 / 3]],
4423
+ [[i, 1]],
4424
+ [[i, 2 / 3], [(i + 1) % n, 1 / 3]]
4425
+ ]).flat();
4426
+ let geometry;
4427
+ if (data.weights) {
4428
+ const starts = [];
4429
+ let offset = 0;
4430
+ for (const list of data.weights) {
4431
+ starts.push(offset);
4432
+ offset += list.length * 2;
4433
+ }
4434
+ const mapping = recipes.flatMap((recipe) => recipe.flatMap(([index]) => data.weights[index].map((_, k) => starts[index] + k * 2)));
4435
+ geometry = { weights: recipes.map((recipe) => recipe.flatMap(([index, factor]) => data.weights[index].map((inf) => ({ ...inf, w: inf.w * factor })))) };
4436
+ clipDeforms.set(id, (offsets) => mapping.flatMap((index) => [offsets[index] ?? 0, offsets[index + 1] ?? 0]));
4437
+ } else {
4438
+ const expand = (values2) => recipes.flatMap((recipe) => [0, 1].map((axis) => recipe.reduce((sum, [index, factor]) => sum + (values2[index * 2 + axis] ?? 0) * factor, 0)));
4439
+ geometry = { vertices: expand(data.vertices) };
4440
+ clipDeforms.set(id, expand);
4441
+ }
4442
+ attachments.push({ id, slot, type: "path", ...geometry, closed: true, clipping: { ...a.end ? { end: slotIds.get(a.end) } : {} } });
4342
4443
  } else warnings.push(`attachment "${slotName}/${name}" of type ${type} skipped`);
4343
4444
  }
4344
4445
  }
4345
4446
  }
4346
- const ik = (json.ik ?? []).map((c) => ({
4447
+ const activeConstraint = (kind, c) => !c.skin || lookupSkins.some((s2) => s2[kind]?.includes(c.name));
4448
+ const ik = (json2.ik ?? []).filter((c) => activeConstraint("ik", c)).map((c) => ({
4347
4449
  id: ikIds.get(c.name),
4348
4450
  order: c.order ?? 0,
4349
4451
  bones: c.bones.map((b) => boneIds.get(b)),
@@ -4356,7 +4458,7 @@ function importSpine(json, atlasText, opts = {}) {
4356
4458
  stretch: !!c.stretch,
4357
4459
  uniform: !!c.uniform
4358
4460
  }));
4359
- const transform = (json.transform ?? []).map((c) => ({
4461
+ const transform = (json2.transform ?? []).filter((c) => activeConstraint("transform", c)).map((c) => ({
4360
4462
  id: tcIds.get(c.name),
4361
4463
  order: c.order ?? 0,
4362
4464
  bones: c.bones.map((b) => boneIds.get(b)),
@@ -4376,7 +4478,7 @@ function importSpine(json, atlasText, opts = {}) {
4376
4478
  mixScaleY: c.mixScaleY ?? c.mixScaleX ?? c.scaleMix ?? 1,
4377
4479
  mixShearY: c.mixShearY ?? c.shearMix ?? 1
4378
4480
  }));
4379
- const path = (json.path ?? []).map((c) => ({
4481
+ const path = (json2.path ?? []).filter((c) => activeConstraint("path", c)).map((c) => ({
4380
4482
  id: pcIds.get(c.name),
4381
4483
  order: c.order ?? 0,
4382
4484
  bones: c.bones.map((b) => boneIds.get(b)),
@@ -4411,17 +4513,35 @@ function importSpine(json, atlasText, opts = {}) {
4411
4513
  for (let i = 0; i < frames.length; i++) {
4412
4514
  const f = frames[i], next = frames[i + 1], t = f.time ?? 0;
4413
4515
  const ease2 = next ? easeOf(f.curve, comp, t, next.time ?? 0, f[field] ?? def, next[field] ?? def) : "linear";
4414
- keys.push({ t: r4(t), v: r4(abs(f[field] ?? def)), ease: ease2 });
4516
+ const curve = major >= 4 && Array.isArray(f.curve) ? f.curve.slice(comp * 4, comp * 4 + 4) : [];
4517
+ const value = f[field] ?? def;
4518
+ if (next && value === (next[field] ?? def) && curve.length === 4 && (curve[1] !== value || curve[3] !== value)) {
4519
+ const cubic = (p0, p1, p2, p3, u) => (1 - u) ** 3 * p0 + 3 * (1 - u) ** 2 * u * p1 + 3 * (1 - u) * u * u * p2 + u ** 3 * p3;
4520
+ const inner = Array.from({ length: 9 }, (_, k) => {
4521
+ const u = (k + 1) / 10;
4522
+ return { t: r4(cubic(t, curve[0], curve[2], next.time ?? 0, u)), v: r4(abs(cubic(value, curve[1], curve[3], value, u))), ease: "linear" };
4523
+ });
4524
+ if (inner.every((k, i2) => k.t > (i2 ? inner[i2 - 1].t : t) && k.t < (next.time ?? 0))) {
4525
+ keys.push({ t: r4(t), v: r4(abs(value)), ease: "linear" }, ...inner);
4526
+ continue;
4527
+ }
4528
+ warnings.push(`equal-endpoint ${field} curve at ${t}s has non-monotonic control times and could not be lowered`);
4529
+ }
4530
+ keys.push({ t: r4(t), v: r4(abs(value)), ease: ease2 });
4415
4531
  }
4416
4532
  return keys;
4417
4533
  };
4418
4534
  const animations = [];
4419
- for (const [animName, a] of Object.entries(json.animations ?? {})) {
4535
+ for (const [animName, a] of Object.entries(json2.animations ?? {})) {
4420
4536
  const tracks = [], events = [];
4421
4537
  let duration = 0;
4422
4538
  const seen = (frames) => {
4423
4539
  for (const f of frames) duration = Math.max(duration, f.time ?? 0);
4424
4540
  };
4541
+ if (a.physics) {
4542
+ warnings.push(`${animName}: Spine physics timelines are not imported`);
4543
+ for (const timelines of Object.values(a.physics)) for (const frames of Object.values(timelines)) seen(frames);
4544
+ }
4425
4545
  for (const [boneName, tl] of Object.entries(a.bones ?? {})) {
4426
4546
  const sb = spineBones[boneIndexByName.get(boneName)];
4427
4547
  if (!sb) {
@@ -4462,19 +4582,22 @@ function importSpine(json, atlasText, opts = {}) {
4462
4582
  if (kind === "attachment") {
4463
4583
  attachmentFrames.set(slotName, frames);
4464
4584
  tracks.push(track("slot", id, "attachment", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.name ? attachmentRef(slotName, f.name) : null, ease: "step" }))));
4465
- } else if (kind === "rgba" || kind === "color") {
4585
+ } else if (["rgba", "color", "rgb", "rgba2", "rgb2"].includes(kind)) {
4586
+ const setupAlpha = parseInt(hex8(spineSlots[slotIndexByName.get(slotName)].color).slice(7), 16) / 255;
4587
+ const rgbOnly = kind === "rgb" || kind === "rgb2";
4466
4588
  const rgbaOf = (f) => {
4467
- const c = hex8(f.color);
4468
- return [0, 2, 4, 6].map((k) => parseInt(c.slice(1 + k, 3 + k), 16) / 255);
4589
+ const c = hex8(f.color ?? f.light);
4590
+ return [0, 2, 4, 6].map((k, i) => i === 3 && rgbOnly ? setupAlpha : parseInt(c.slice(1 + k, 3 + k), 16) / 255);
4469
4591
  };
4592
+ if (kind.endsWith("2")) warnings.push(`${animName}/${slotName}: ${kind} light color imported; dark two-color tint is not supported`);
4470
4593
  tracks.push(track("slot", id, "color", frames.map((f, i) => {
4471
4594
  const rgba = rgbaOf(f), next = frames[i + 1];
4472
- return { t: r4(f.time ?? 0), v: rgba.map(r4), ease: next ? easeOf(f.curve, 0, f.time ?? 0, next.time ?? 0, rgba[0], rgbaOf(next)[0]) : "linear" };
4473
- })));
4474
- } else if (kind === "rgb") {
4475
- tracks.push(track("slot", id, "color", frames.map((f) => {
4476
- const c = hex8((f.color ?? "ffffff") + "ff");
4477
- return { t: r4(f.time ?? 0), v: [0, 2, 4, 6].map((k) => r4(parseInt(c.slice(1 + k, 3 + k), 16) / 255)), ease: "linear" };
4595
+ return {
4596
+ t: r4(f.time ?? 0),
4597
+ v: rgba.map(r4),
4598
+ ease: "linear",
4599
+ ...next ? { componentEase: rgba.map((v, k) => rgbOnly && k === 3 ? "linear" : easeOf(f.curve, k, f.time ?? 0, next.time ?? 0, v, rgbaOf(next)[k])) } : {}
4600
+ };
4478
4601
  })));
4479
4602
  } else if (kind === "alpha") tracks.push(track("slot", id, "alpha", numeric(frames, "value", 1, (v) => v, 0)));
4480
4603
  else warnings.push(`${animName}/${slotName}: ${kind} timeline skipped`);
@@ -4513,15 +4636,30 @@ function importSpine(json, atlasText, opts = {}) {
4513
4636
  warnings.push(`${animName}: deform of unknown attachment ${slotName}/${attName}`);
4514
4637
  continue;
4515
4638
  }
4516
- const length = att.weights ? 2 * att.weights.reduce((s2, l) => s2 + l.length, 0) : att.vertices?.length ?? 0;
4639
+ const rawClip = skins.find((s2) => s2.name === skinName)?.attachments?.[slotName]?.[attName];
4640
+ const clipData = clipDeforms.has(id) ? readVertices(rawClip, rawClip.vertexCount) : void 0;
4641
+ const data = clipData ?? att;
4642
+ const length = data.weights ? 2 * data.weights.reduce((s2, l) => s2 + l.length, 0) : data.vertices?.length ?? 0;
4517
4643
  tracks.push(track("deform", id, "vertices", frames.map((f, i) => {
4518
4644
  const full = new Array(length).fill(0);
4519
4645
  const off = f.offset ?? 0, vs = f.vertices ?? [];
4520
4646
  for (let k = 0; k < vs.length; k++) full[off + k] = (k + off) % 2 === 1 ? -vs[k] : vs[k];
4521
4647
  const next = frames[i + 1];
4522
- return { t: r4(f.time ?? 0), v: full.map(r4), ease: next ? easeOf(f.curve, 0, f.time ?? 0, next.time ?? 0, 0, 1) : "linear" };
4648
+ return { t: r4(f.time ?? 0), v: (clipDeforms.get(id)?.(full) ?? full).map(r4), ease: next ? easeOf(f.curve, 0, f.time ?? 0, next.time ?? 0, 0, 1) : "linear" };
4523
4649
  })));
4524
4650
  }
4651
+ for (let pass = 0; pass < inheritedDeform.size; pass++) {
4652
+ let changed = false;
4653
+ for (const [child, parent] of inheritedDeform) {
4654
+ if (tracks.some((t) => t.target === "deform" && t.id === child)) continue;
4655
+ const source = tracks.find((t) => t.target === "deform" && t.id === parent);
4656
+ if (source) {
4657
+ tracks.push({ ...source, id: child, keys: structuredClone(source.keys) });
4658
+ changed = true;
4659
+ }
4660
+ }
4661
+ if (!changed) break;
4662
+ }
4525
4663
  const drawOrderFrames = a.drawOrder ?? a.draworder;
4526
4664
  if (Array.isArray(drawOrderFrames)) {
4527
4665
  seen(drawOrderFrames);
@@ -4544,20 +4682,25 @@ function importSpine(json, atlasText, opts = {}) {
4544
4682
  for (const [name, frames] of Object.entries(a.ik ?? {})) {
4545
4683
  seen(frames);
4546
4684
  const id = ikIds.get(name);
4685
+ if (!ik.some((c) => c.id === id)) continue;
4547
4686
  tracks.push(track("ik", id, "mix", numeric(frames, "mix", 1, (v) => v, 0)));
4548
4687
  tracks.push(track("ik", id, "softness", numeric(frames, "softness", 0, (v) => v, 1)));
4549
4688
  tracks.push(track("ik", id, "bendPositive", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.bendPositive ?? true ? 0 : 1, ease: "step" }))));
4550
4689
  tracks.push(track("ik", id, "compress", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.compress ? 1 : 0, ease: "step" }))));
4551
4690
  tracks.push(track("ik", id, "stretch", frames.map((f) => ({ t: r4(f.time ?? 0), v: f.stretch ? 1 : 0, ease: "step" }))));
4552
4691
  }
4553
- const mixTrack = (target, id, frames, prop, old, comp) => track(target, id, prop, frames.map((f, i) => ({
4554
- t: r4(f.time ?? 0),
4555
- v: r4(f[prop] ?? f[old] ?? 1),
4556
- ease: frames[i + 1] ? easeOf(f.curve, comp, f.time ?? 0, frames[i + 1].time ?? 0, f[prop] ?? f[old] ?? 1, frames[i + 1][prop] ?? frames[i + 1][old] ?? 1) : "linear"
4557
- })));
4692
+ const mixTrack = (target, id, frames, prop, old, comp) => {
4693
+ const value = (f) => f[prop] ?? (prop === "mixY" ? f.mixX : prop === "mixScaleY" ? f.mixScaleX : void 0) ?? f[old] ?? 1;
4694
+ return track(target, id, prop, frames.map((f, i) => ({
4695
+ t: r4(f.time ?? 0),
4696
+ v: r4(value(f)),
4697
+ ease: frames[i + 1] ? easeOf(f.curve, comp, f.time ?? 0, frames[i + 1].time ?? 0, value(f), value(frames[i + 1])) : "linear"
4698
+ })));
4699
+ };
4558
4700
  for (const [name, frames] of Object.entries(a.transform ?? {})) {
4559
4701
  seen(frames);
4560
4702
  const id = tcIds.get(name);
4703
+ if (!transform.some((c) => c.id === id)) continue;
4561
4704
  const props = [["mixRotate", "rotateMix", 0], ["mixX", "translateMix", 1], ["mixY", "translateMix", 2], ["mixScaleX", "scaleMix", 3], ["mixScaleY", "scaleMix", 4], ["mixShearY", "shearMix", 5]];
4562
4705
  for (const [prop, old, comp] of props) tracks.push(mixTrack("transform", id, frames, prop, old, comp));
4563
4706
  }
@@ -4565,6 +4708,7 @@ function importSpine(json, atlasText, opts = {}) {
4565
4708
  const id = pcIds.get(name);
4566
4709
  for (const [kind, frames] of Object.entries(tl)) {
4567
4710
  seen(frames);
4711
+ if (!path.some((c) => c.id === id)) continue;
4568
4712
  if (kind === "position" || kind === "spacing") tracks.push(track("path", id, kind, numeric(frames, "value", 0, (v) => v, 0)));
4569
4713
  else if (kind === "mix") for (const [prop, old, comp] of [["mixRotate", "rotateMix", 0], ["mixX", "translateMix", 1], ["mixY", "translateMix", 2]])
4570
4714
  tracks.push(mixTrack("path", id, frames, prop, old, comp));
@@ -4669,7 +4813,7 @@ function importSpine(json, atlasText, opts = {}) {
4669
4813
  const build = () => ({
4670
4814
  format: "energy8.rig",
4671
4815
  version: 2,
4672
- meta: { name: opts.name ?? json.skeleton?.hash ?? "spine", width, height, notes: opts.notes ?? `imported from Spine ${version}` },
4816
+ meta: { name: opts.name ?? json2.skeleton?.hash ?? "spine", width, height, notes: opts.notes ?? `imported from Spine ${version}` },
4673
4817
  assets: [...assets.values()],
4674
4818
  bones,
4675
4819
  slots,
@@ -4722,26 +4866,33 @@ async function firstExisting(candidates, what) {
4722
4866
  throw new Error(`${what} not found, tried: ${candidates.join(", ")}`);
4723
4867
  }
4724
4868
  async function importSpineProject(opts) {
4725
- const json = JSON.parse(await readFile7(opts.json, "utf8"));
4869
+ const json2 = JSON.parse(await readFile7(opts.json, "utf8"));
4726
4870
  const stem = opts.json.replace(/\.json$/i, "");
4727
4871
  const atlasPath = opts.atlas ?? await firstExisting([stem + ".atlas", stem + ".atlas.txt"], "atlas");
4728
4872
  const atlasText = await readFile7(atlasPath, "utf8");
4729
4873
  const { pages } = parseAtlas(atlasText);
4730
- if (pages.length !== 1) throw new Error(`expected one atlas page, got ${pages.length}`);
4731
- const pageStem = join8(dirname6(atlasPath), pages[0].name.replace(/\.[a-z0-9]+$/i, ""));
4732
- const imagePath = opts.image ?? await firstExisting([join8(dirname6(atlasPath), pages[0].name), pageStem + ".png", pageStem + ".ktx2", stem + ".png", stem + ".ktx2"], "atlas page image");
4874
+ if (!pages.length) throw new Error("atlas has no pages");
4875
+ if (opts.image && pages.length !== 1) throw new Error("image override requires a single-page atlas; multi-page atlases resolve each named page beside the atlas");
4733
4876
  const rigDir = dirname6(opts.rigPath);
4734
- let pngPath = imagePath;
4735
- if (extname(imagePath).toLowerCase() === ".ktx2") {
4736
- pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4737
- if (!await exists(pngPath)) {
4738
- const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
4739
- await writeFile5(pngPath, png);
4877
+ const pagePaths = /* @__PURE__ */ new Map();
4878
+ for (const page2 of pages) {
4879
+ if (!opts.image && outsideDir(dirname6(atlasPath), page2.name)) throw new Error(`atlas page "${page2.name}" is outside the atlas folder (${dirname6(atlasPath)})`);
4880
+ const pageStem = join8(dirname6(atlasPath), page2.name.replace(/\.[a-z0-9]+$/i, ""));
4881
+ const imagePath = opts.image ?? await firstExisting([join8(dirname6(atlasPath), page2.name), pageStem + ".png", pageStem + ".ktx2", ...pages.length === 1 ? [stem + ".png", stem + ".ktx2"] : []], `atlas page image ${page2.name}`);
4882
+ let pngPath = imagePath;
4883
+ if (extname(imagePath).toLowerCase() === ".ktx2") {
4884
+ pngPath = join8(rigDir, basename4(imagePath).replace(/\.ktx2$/i, ".png"));
4885
+ if (!await exists(pngPath)) {
4886
+ const { png } = await ktx2ToPng(new Uint8Array(await readFile7(imagePath)));
4887
+ await writeFile5(pngPath, png);
4888
+ }
4740
4889
  }
4890
+ pagePaths.set(page2.name, pngPath);
4741
4891
  }
4742
- const pageImage = relative(rigDir, pngPath).split("\\").join("/");
4892
+ const pageImage = (page2) => relative2(rigDir, pagePaths.get(page2)).split("\\").join("/");
4743
4893
  const name = opts.name ?? basename4(stem);
4744
- return { ...importSpine(json, atlasText, { name, pageImage, margin: opts.margin, notes: opts.notes }), imagePath: pngPath };
4894
+ const imagePaths = [...pagePaths.values()];
4895
+ return { ...importSpine(json2, atlasText, { name, skin: opts.skin, pageImage, margin: opts.margin, notes: opts.notes }), imagePath: imagePaths[0], imagePaths };
4745
4896
  }
4746
4897
 
4747
4898
  // src/rig-templates.ts
@@ -5274,13 +5425,14 @@ function attachToBone(doc, animation, bone, target, t) {
5274
5425
  if (Math.abs(to.a * to.d - to.b * to.c) < 1e-10) throw new Error("cannot attach to a singular target");
5275
5426
  const [x, y] = apply(invert(to), from.tx, from.ty);
5276
5427
  const id = `attach_${bone}_${target}`;
5428
+ const handedness = to.a * to.d - to.b * to.c < 0 ? -1 : 1;
5277
5429
  const offset = {
5278
5430
  x,
5279
5431
  y,
5280
- rotation: wrap2(rotationOf(from) - rotationOf(to)),
5432
+ rotation: wrap2(rotationOf(from) - rotationOf(to)) * handedness,
5281
5433
  scaleX: Math.hypot(from.a, from.b) - Math.hypot(to.a, to.b),
5282
5434
  scaleY: Math.hypot(from.c, from.d) - Math.hypot(to.c, to.d),
5283
- shearY: wrap2((Math.atan2(from.d, from.c) - Math.atan2(from.b, from.a) - Math.atan2(to.d, to.c) + Math.atan2(to.b, to.a)) * 180 / Math.PI)
5435
+ shearY: wrap2((Math.atan2(from.d, from.c) - Math.atan2(from.b, from.a) - Math.atan2(to.d, to.c) + Math.atan2(to.b, to.a)) * 180 / Math.PI) * handedness
5284
5436
  };
5285
5437
  const order = Math.max(0, ...doc.ik.map((c) => c.order), ...doc.transform.map((c) => c.order), ...doc.path.map((c) => c.order)) + 1;
5286
5438
  let next = setConstraint(doc, "transform", id, { bones: [bone], target, order, ...offset, mixX: 0, mixY: 0, mixRotate: 0, mixScaleX: 0, mixScaleY: 0, mixShearY: 0 });
@@ -5430,8 +5582,8 @@ async function load(path) {
5430
5582
  }
5431
5583
  var save = (path, doc) => saveJson(path, doc);
5432
5584
  var exists2 = (p) => stat2(p).then(() => true, () => false);
5433
- var resolveFrom = (rigPath, p) => isAbsolute(p) ? p : join9(dirname8(rigPath), p);
5434
- var srcFor = (rigPath, file) => relative2(dirname8(rigPath), file).split("\\").join("/");
5585
+ var resolveFrom = (rigPath, p) => isAbsolute2(p) ? p : join9(dirname8(rigPath), p);
5586
+ var srcFor = (rigPath, file) => relative3(dirname8(rigPath), file).split("\\").join("/");
5435
5587
  var libraryFor = (rigPath, lib) => lib ? resolveFrom(rigPath, lib) : join9(dirname8(rigPath), "presets.json");
5436
5588
  async function pngInfo(rigPath, file) {
5437
5589
  const abs = resolveFrom(rigPath, file);
@@ -5543,10 +5695,11 @@ tool(
5543
5695
  var KeyIn = z3.object({
5544
5696
  t: z3.number().describe("seconds"),
5545
5697
  v: z3.union([z3.number(), z3.string(), z3.null(), z3.array(z3.number()), z3.array(z3.string())]),
5698
+ componentEase: z3.array(Ease).min(1).optional().describe("optional independent easing per numeric-array component"),
5546
5699
  ease: Ease.optional().describe("into the next key: linear|step|ease_in|ease_out|ease_in_out|back_out|elastic_out|{bezier:[x1,y1,x2,y2]}; default ease_in_out; minimum_jerk for a rest-to-rest segment")
5547
5700
  });
5548
5701
  var roundKey = (k) => Array.isArray(k.v) && k.v.every((x) => typeof x === "number") ? { ...k, v: k.v.map(round6) } : k;
5549
- var KEY_DOC = "Keys are {t, v, ease?}. Bone values are ABSOLUTE local-transform values, not offsets (rest x=4 \u2192 key v=0 moves it 4px); deform values are relative (0 = rest). bone: x|y|rotation|scaleX|scaleY|shearX|shearY; slot: alpha|attachment(asset id or null)|z; deform (3\xD73 mesh on the slot's image): bulge|bend|sway|squash (fractions of the image size, |v|\u22640.9/1) or p<row><col>x|y (px, rows/cols 0..2 top-left first). Some values are arrays: deform `vertices` keys are number arrays (2\xB7n offsets, or 2\xB7\u03A3influences for weighted meshes); drawOrder keys are slot-id arrays; slot color keys are [r,g,b,a].";
5702
+ var KEY_DOC = "Keys are {t, v, ease?, componentEase?}; componentEase selects independent easing for numeric-array components. Bone values are ABSOLUTE local-transform values, not offsets (rest x=4 \u2192 key v=0 moves it 4px); deform values are relative (0 = rest). bone: x|y|rotation|scaleX|scaleY|shearX|shearY; slot: alpha|attachment(asset id or null)|z; deform (3\xD73 mesh on the slot's image): bulge|bend|sway|squash (fractions of the image size, |v|\u22640.9/1) or p<row><col>x|y (px, rows/cols 0..2 top-left first). Some values are arrays: deform `vertices` keys are number arrays (2\xB7n offsets, or 2\xB7\u03A3influences for weighted meshes); drawOrder keys are slot-id arrays; slot color keys are [r,g,b,a].";
5550
5703
  tool(
5551
5704
  "rig_create",
5552
5705
  "Create a new empty rig document with a root bone at bottom-center.",
@@ -5670,19 +5823,20 @@ tool(
5670
5823
  );
5671
5824
  tool(
5672
5825
  "rig_import_spine",
5673
- "Import a Spine project (skeleton .json 3.8/4.x + .atlas + page image) as a new rig: bones, slots, region/mesh/path attachments with weights, IK / transform / path constraints and every animation (deform, draw order, colors), converted to this document's y-down space. The atlas may be plain or base64 text; a KTX2 page is transcoded to PNG next to rig.json. Then use rig_get / rig_render_preview / rig_check as with any rig.",
5826
+ "Import a Spine project (skeleton .json 3.8/4.x + .atlas + page image) as a new rig: bones, slots, region/mesh/path attachments with weights, IK / transform / path constraints and every animation (deform, draw order, colors), converted to this document's y-down space. The atlas may be plain or base64 text; named pages resolve beside the atlas, and KTX2 pages transcode to PNG next to rig.json. Read warnings for unsupported physics and dark tint. Then use rig_get / rig_render_preview / rig_check as with any rig.",
5674
5827
  {
5675
5828
  path: Path,
5676
5829
  json: z3.string().describe("Spine skeleton .json, absolute or relative to rig.json dir"),
5677
5830
  atlas: z3.string().optional().describe("atlas file; default: <json>.atlas or <json>.atlas.txt"),
5678
5831
  image: z3.string().optional().describe("atlas page image (.png or .ktx2); default: the page named in the atlas, next to it"),
5832
+ skin: z3.string().optional().describe("skin to flatten; default: default skin, or first skin if no default exists; no runtime skin switching"),
5679
5833
  name: z3.string().optional().describe("default: the json file name"),
5680
5834
  margin: z3.number().optional().describe("canvas margin around the skeleton bounds, px"),
5681
5835
  notes: z3.string().optional()
5682
5836
  },
5683
- async ({ path, json, atlas, image, name, margin, notes }) => {
5837
+ async ({ path, json: json2, atlas, image, name, skin: skin2, margin, notes }) => {
5684
5838
  try {
5685
- const r = await importSpineProject({ json: resolveFrom(path, json), atlas: atlas && resolveFrom(path, atlas), image: image && resolveFrom(path, image), rigPath: path, name, margin, notes });
5839
+ const r = await importSpineProject({ json: resolveFrom(path, json2), atlas: atlas && resolveFrom(path, atlas), image: image && resolveFrom(path, image), rigPath: path, name, skin: skin2, margin, notes });
5686
5840
  const res = await create(path, parse(r.doc));
5687
5841
  if (r.warnings.length) res.text += `
5688
5842
  warnings:
@@ -6454,40 +6608,41 @@ tool(
6454
6608
  }
6455
6609
  );
6456
6610
 
6611
+ // src/rig-version.ts
6612
+ import { stat as stat3 } from "node:fs/promises";
6613
+ async function rigVersion(abs) {
6614
+ try {
6615
+ const s2 = await stat3(abs);
6616
+ return `${s2.mtimeMs}:${s2.size}`;
6617
+ } catch {
6618
+ return "missing";
6619
+ }
6620
+ }
6621
+
6457
6622
  // src/editor/server.ts
6458
6623
  var TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".map": "application/json", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp" };
6459
- async function startEditorServer(opts) {
6624
+ var json = (res, status, body) => {
6625
+ res.writeHead(status, { "content-type": "application/json" });
6626
+ res.end(JSON.stringify(body));
6627
+ };
6628
+ function createEditorHandler(opts) {
6460
6629
  const root3 = resolve2(opts.root);
6461
6630
  const assets = resolve2(opts.assets ?? fileURLToPath3(new URL("../..", import.meta.url)));
6462
6631
  const inside = (rel) => {
6463
6632
  const abs = resolve2(root3, rel);
6464
- return abs === root3 || abs.startsWith(root3 + sep) ? abs : void 0;
6465
- };
6466
- const version = async (abs) => {
6467
- try {
6468
- const s2 = await stat3(abs);
6469
- return `${s2.mtimeMs}:${s2.size}`;
6470
- } catch {
6471
- return "missing";
6472
- }
6633
+ return abs === root3 || abs.startsWith(root3 + sep2) ? abs : void 0;
6473
6634
  };
6474
6635
  const readDoc = async (abs) => RigDocument.parse(JSON.parse(await readFile10(abs, "utf8")));
6475
6636
  const readDocAt = async (abs) => {
6476
- let v = await version(abs);
6637
+ let v = await rigVersion(abs);
6477
6638
  for (let i = 0; i < 3; i++) {
6478
6639
  const doc = await readDoc(abs);
6479
- const after = await version(abs);
6640
+ const after = await rigVersion(abs);
6480
6641
  if (after === v) return { doc, version: v };
6481
6642
  v = after;
6482
6643
  }
6483
6644
  return { doc: await readDoc(abs), version: v };
6484
6645
  };
6485
- const json = (res, status, body2) => {
6486
- res.writeHead(status, { "content-type": "application/json" });
6487
- res.end(JSON.stringify(body2));
6488
- };
6489
- let allowedHosts = /* @__PURE__ */ new Set();
6490
- const hostOk = (req) => allowedHosts.has(String(req.headers.host ?? "").toLowerCase());
6491
6646
  const clients = /* @__PURE__ */ new Map();
6492
6647
  const watchers = /* @__PURE__ */ new Map();
6493
6648
  function ensureWatch(abs) {
@@ -6499,7 +6654,7 @@ async function startEditorServer(opts) {
6499
6654
  const schedule = () => {
6500
6655
  clearTimeout(entry.timer);
6501
6656
  entry.timer = setTimeout(async () => {
6502
- const v = await version(abs);
6657
+ const v = await rigVersion(abs);
6503
6658
  if (v === entry.last) return;
6504
6659
  entry.last = v;
6505
6660
  for (const res of clients.get(abs) ?? []) res.write(`event: changed
@@ -6508,7 +6663,7 @@ data: ${JSON.stringify({ version: v })}
6508
6663
  `);
6509
6664
  }, 50);
6510
6665
  };
6511
- version(abs).then((v) => {
6666
+ rigVersion(abs).then((v) => {
6512
6667
  entry.last = v;
6513
6668
  });
6514
6669
  watchers.set(abs, entry);
@@ -6534,7 +6689,7 @@ data: ${JSON.stringify({ version: v })}
6534
6689
  name = String(JSON.parse(await readFile10(abs, "utf8")).meta?.name ?? "?");
6535
6690
  } catch {
6536
6691
  }
6537
- out.push({ rig: relative3(root3, abs).split(sep).join("/"), name, mtimeMs: (await stat3(abs)).mtimeMs });
6692
+ out.push({ rig: relative4(root3, abs).split(sep2).join("/"), name, mtimeMs: (await stat4(abs)).mtimeMs });
6538
6693
  }
6539
6694
  };
6540
6695
  await walk(root3);
@@ -6568,20 +6723,22 @@ data: ${JSON.stringify({ version: v })}
6568
6723
  }
6569
6724
  return Buffer.concat(chunks);
6570
6725
  }
6571
- const server = createServer(async (req, res) => {
6726
+ async function handle(req, res, rawUrl = req.url ?? "/") {
6572
6727
  try {
6573
- if (!hostOk(req)) return json(res, 403, { isError: true, text: `ERROR: forbidden Host "${req.headers.host ?? ""}" \u2014 this server answers only on 127.0.0.1 / localhost` });
6574
- const url = new URL(req.url ?? "/", "http://x");
6728
+ const url = new URL(rawUrl, "http://x");
6575
6729
  if (url.pathname === "/") {
6576
- res.writeHead(302, { location: "/editor.html" + url.search });
6577
- return res.end();
6730
+ res.writeHead(302, { location: "editor.html" + url.search });
6731
+ return void res.end();
6732
+ }
6733
+ if (url.pathname.startsWith("/api/")) {
6734
+ await api(url, req, res);
6735
+ return;
6578
6736
  }
6579
- if (url.pathname.startsWith("/api/")) return await api(url, req, res);
6580
6737
  const STATIC = /* @__PURE__ */ new Set(["/editor.html", "/dist/editor.js", "/dist/editor.js.map", "/dist/editor.css", "/dist/editor.css.map"]);
6581
6738
  const abs = url.pathname.startsWith("/rig/") ? inside(decodeURIComponent(url.pathname.slice(5))) : STATIC.has(url.pathname) ? join10(assets, url.pathname) : void 0;
6582
6739
  if (!abs) {
6583
6740
  res.writeHead(403);
6584
- return res.end("forbidden");
6741
+ return void res.end("forbidden");
6585
6742
  }
6586
6743
  try {
6587
6744
  const data = await readFile10(abs);
@@ -6599,7 +6756,7 @@ data: ${JSON.stringify({ version: v })}
6599
6756
  }
6600
6757
  json(res, 500, { isError: true, text: `ERROR: ${e.message}` });
6601
6758
  }
6602
- });
6759
+ }
6603
6760
  async function api(url, req, res) {
6604
6761
  const rigOf = (rel) => {
6605
6762
  if (!rel) return void 0;
@@ -6616,7 +6773,7 @@ data: ${JSON.stringify({ version: v })}
6616
6773
  res.writeHead(403);
6617
6774
  return res.end();
6618
6775
  }
6619
- if (!await stat3(abs).then(() => true, () => false)) return json(res, 404, { isError: true, text: `ERROR: ${url.searchParams.get("rig")} does not exist under ${root3}` });
6776
+ if (!await stat4(abs).then(() => true, () => false)) return json(res, 404, { isError: true, text: `ERROR: ${url.searchParams.get("rig")} does not exist under ${root3}` });
6620
6777
  ensureWatch(abs);
6621
6778
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
6622
6779
  res.write(`: connected
@@ -6638,7 +6795,7 @@ data: ${JSON.stringify({ version: v })}
6638
6795
  if (!abs) return json(res, 403, { isError: true, text: "rig outside root" });
6639
6796
  ensureWatch(abs);
6640
6797
  return await withRigQueue(abs, async () => {
6641
- if (baseVersion !== void 0 && await version(abs) !== baseVersion) {
6798
+ if (baseVersion !== void 0 && await rigVersion(abs) !== baseVersion) {
6642
6799
  const at = await readDocAt(abs);
6643
6800
  return json(res, 409, {
6644
6801
  isError: true,
@@ -6647,8 +6804,15 @@ data: ${JSON.stringify({ version: v })}
6647
6804
  text: `ERROR: the rig changed on disk (expected ${baseVersion}, found ${at.version}); nothing was written`
6648
6805
  });
6649
6806
  }
6650
- const r = await callTool(url.pathname.slice(10), { ...args2 ?? {}, path: abs });
6651
- return json(res, 200, { text: r.text, doc: r.doc, image: r.image?.toString("base64"), isError: r.isError, version: await version(abs) });
6807
+ const tool2 = url.pathname.slice(10);
6808
+ let given = { ...args2 ?? {}, path: abs };
6809
+ try {
6810
+ if (opts.args) given = opts.args(tool2, given);
6811
+ } catch (e) {
6812
+ return json(res, 400, { isError: true, text: `ERROR: ${e instanceof Error ? e.message : String(e)}` });
6813
+ }
6814
+ const r = await callTool(tool2, given);
6815
+ return json(res, 200, { text: r.text, doc: r.doc, image: r.image?.toString("base64"), isError: r.isError, version: await rigVersion(abs) });
6652
6816
  });
6653
6817
  }
6654
6818
  if (url.pathname === "/api/rigs" && req.method === "GET") return json(res, 200, await listRigs());
@@ -6659,11 +6823,11 @@ data: ${JSON.stringify({ version: v })}
6659
6823
  if (typeof rig !== "string" || !/(^|\/)rig\.json$/.test(rig)) return json(res, 400, { isError: true, text: "ERROR: a project is a directory with a rig.json \u2014 the path must end with /rig.json" });
6660
6824
  const abs = inside(rig);
6661
6825
  if (!abs) return json(res, 403, { isError: true, text: "rig outside root" });
6662
- if (await stat3(abs).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${rig} already exists \u2014 open it, or pick another directory` });
6826
+ if (await stat4(abs).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${rig} already exists \u2014 open it, or pick another directory` });
6663
6827
  await mkdir7(dirname9(abs), { recursive: true });
6664
6828
  const r = await callTool("rig_create", { path: abs, name: name ?? basename5(dirname9(abs)), width: width ?? 640, height: height ?? 700 });
6665
6829
  ensureWatch(abs);
6666
- return json(res, r.isError ? 400 : 200, { text: r.text, doc: r.doc, isError: r.isError, version: await version(abs) });
6830
+ return json(res, r.isError ? 400 : 200, { text: r.text, doc: r.doc, isError: r.isError, version: await rigVersion(abs) });
6667
6831
  }
6668
6832
  if (url.pathname === "/api/files" && req.method === "GET") {
6669
6833
  const abs = rigOf(url.searchParams.get("rig"));
@@ -6677,8 +6841,8 @@ data: ${JSON.stringify({ version: v })}
6677
6841
  if (!/^[A-Za-z0-9._-]+\.png$/i.test(name) || name.startsWith(".")) return json(res, 400, { isError: true, text: `ERROR: bad file name "${name}" \u2014 letters, digits, . _ - and a .png extension` });
6678
6842
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("image/png")) return json(res, 415, { isError: true, text: "ERROR: upload needs content-type: image/png" });
6679
6843
  const target = join10(dirname9(abs), name);
6680
- if (!inside(relative3(root3, target))) return json(res, 403, { isError: true, text: "file outside root" });
6681
- if (await stat3(target).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${name} already exists next to the rig \u2014 rename the file` });
6844
+ if (!inside(relative4(root3, target))) return json(res, 403, { isError: true, text: "file outside root" });
6845
+ if (await stat4(target).then(() => true, () => false)) return json(res, 409, { isError: true, text: `ERROR: ${name} already exists next to the rig \u2014 rename the file` });
6682
6846
  const data = await bytes(req, UPLOAD_MAX);
6683
6847
  if (!data) return json(res, 413, { isError: true, text: `ERROR: ${name} is larger than ${UPLOAD_MAX / 1048576} MB` });
6684
6848
  let size;
@@ -6692,6 +6856,22 @@ data: ${JSON.stringify({ version: v })}
6692
6856
  }
6693
6857
  json(res, 404, { isError: true, text: "unknown api route" });
6694
6858
  }
6859
+ return { handle, close() {
6860
+ for (const w of watchers.values()) {
6861
+ clearTimeout(w.timer);
6862
+ w.w.close();
6863
+ }
6864
+ for (const set of clients.values()) for (const c of set) c.end();
6865
+ } };
6866
+ }
6867
+ async function startEditorServer(opts) {
6868
+ const editor = createEditorHandler(opts);
6869
+ let allowedHosts = /* @__PURE__ */ new Set();
6870
+ const server = createServer((req, res) => {
6871
+ if (!allowedHosts.has(String(req.headers.host ?? "").toLowerCase()))
6872
+ return json(res, 403, { isError: true, text: `ERROR: forbidden Host "${req.headers.host ?? ""}" \u2014 this server answers only on 127.0.0.1 / localhost` });
6873
+ void editor.handle(req, res);
6874
+ });
6695
6875
  await new Promise((ok, fail2) => {
6696
6876
  server.once("error", fail2);
6697
6877
  server.listen(opts.port ?? 8765, "127.0.0.1", () => {
@@ -6702,11 +6882,7 @@ data: ${JSON.stringify({ version: v })}
6702
6882
  const port2 = server.address().port;
6703
6883
  allowedHosts = /* @__PURE__ */ new Set([`127.0.0.1:${port2}`, `localhost:${port2}`, `[::1]:${port2}`]);
6704
6884
  return { port: port2, url: `http://127.0.0.1:${port2}`, close: async () => {
6705
- for (const w of watchers.values()) {
6706
- clearTimeout(w.timer);
6707
- w.w.close();
6708
- }
6709
- for (const set of clients.values()) for (const c of set) c.end();
6885
+ editor.close();
6710
6886
  await new Promise((r) => server.close(() => r()));
6711
6887
  } };
6712
6888
  }