pixelkiln 0.89.6 → 0.91.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.
package/dist/cli.js CHANGED
@@ -807,6 +807,10 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
807
807
  template: spec.character.template,
808
808
  parent: spec.character.parentAssetId,
809
809
  parentSha256: spec.character.parentSha256 ?? null,
810
+ // Which loop leads, not its group: the group is in the lockfile,
811
+ // and the plan compares it there. Absent for every loop that
812
+ // shares nothing, so no existing hash moves.
813
+ motionFrom: spec.character.motionLeader?.assetId,
810
814
  state: spec.character.state,
811
815
  // Pose images enter as their hashes, never their paths.
812
816
  animation: spec.character.animation ? {
@@ -1238,16 +1242,30 @@ var CharacterAnimationSchema = z.object({
1238
1242
  * `src/loop-directions.ts`.
1239
1243
  */
1240
1244
  directions: z.array(CharacterDirectionSchema).min(1).optional(),
1241
- /** Frames to generate without a template (v3). Even, 4 to 16. */
1242
- frames: z.number().int().min(4).max(16).optional(),
1245
+ /**
1246
+ * A sentence loop (`mode: "skeleton-v3"`, no `template`) of another
1247
+ * direction whose motion this one shares: the asset id of the loop that
1248
+ * leads. This loop is then submitted after it, into its animation group,
1249
+ * so PixelLab draws it from the same 3D motion. Written for you when
1250
+ * `directions` expands (the first direction leads); set by hand only when
1251
+ * declaring the loops explicitly.
1252
+ */
1253
+ motionFrom: z.string().min(1).optional(),
1254
+ /**
1255
+ * Frames to generate without a template. A v3 loop takes an even 4 to 16;
1256
+ * a sentence loop (`mode: "skeleton-v3"` with no `template`) takes 3 to 15.
1257
+ */
1258
+ frames: z.number().int().min(3).max(16).optional(),
1243
1259
  /** Playback rate recorded with the frames; PixelLab does not store one. */
1244
1260
  fps: z.number().int().min(1).max(60).default(8),
1245
1261
  /** v3 only: keep the resting pose as frame 0, so `frames` generated frames land as `frames + 1` files. */
1246
1262
  keepFirstFrame: z.boolean().default(true),
1247
1263
  /**
1248
1264
  * `template` when a template is named, otherwise `v3`; `skeleton-v3`
1249
- * poses a template with PixelLab's skeleton model instead; `pro` for the
1250
- * sequential high-quality engine.
1265
+ * poses a template with PixelLab's skeleton model instead, or, with no
1266
+ * template, animates the asset's prompt as a sentence written into one 3D
1267
+ * motion (a v3 base; 3-15 `frames`); `pro` for the sequential
1268
+ * high-quality engine.
1251
1269
  */
1252
1270
  mode: CharacterAnimationModeSchema.optional(),
1253
1271
  /**
@@ -1277,15 +1295,23 @@ var CharacterAnimationSchema = z.object({
1277
1295
  path: ["directions"]
1278
1296
  });
1279
1297
  }
1280
- if (animation.frames !== void 0 && animation.frames % 2 !== 0) {
1281
- context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
1298
+ const sentence = animation.mode === "skeleton-v3" && !animation.template;
1299
+ if (animation.frames !== void 0 && !sentence) {
1300
+ if (animation.frames % 2 !== 0) {
1301
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
1302
+ } else if (animation.frames < 4) {
1303
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be 4 to 16", path: ["frames"] });
1304
+ }
1305
+ }
1306
+ if (animation.frames !== void 0 && sentence && animation.frames > 15) {
1307
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "a skeleton-v3 loop from a sentence takes 3 to 15 frames", path: ["frames"] });
1282
1308
  }
1283
1309
  const mode2 = animation.mode ?? (animation.template ? "template" : "v3");
1284
1310
  for (const key of ["startFrame", "endFrame", "enhancePrompt"]) {
1285
1311
  if (animation[key] !== void 0 && mode2 !== "v3") {
1286
1312
  context.addIssue({
1287
1313
  code: z.ZodIssueCode.custom,
1288
- message: `${key} is for v3 loops; a ${mode2} loop ${mode2 === "template" || mode2 === "skeleton-v3" ? "starts from the character's rotation and follows its template" : "takes neither"}`,
1314
+ message: `${key} is for v3 loops; a ${mode2} loop ${mode2 === "template" || mode2 === "skeleton-v3" ? `starts from the character's rotation and ${animation.template ? "follows its template" : "animates its sentence"}` : "takes neither"}`,
1289
1315
  path: [key]
1290
1316
  });
1291
1317
  }
@@ -1309,8 +1335,14 @@ var CharacterAnimationSchema = z.object({
1309
1335
  path: ["frames"]
1310
1336
  });
1311
1337
  }
1312
- if (!animation.template && (animation.mode === "template" || animation.mode === "skeleton-v3")) {
1313
- context.addIssue({ code: z.ZodIssueCode.custom, message: `${animation.mode} mode needs a template`, path: ["template"] });
1338
+ if (!animation.template && animation.mode === "template") {
1339
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "template mode needs a template", path: ["template"] });
1340
+ }
1341
+ if (animation.motionFrom !== void 0 && !sentence) {
1342
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "motionFrom applies to a skeleton-v3 loop from a sentence (no template)", path: ["motionFrom"] });
1343
+ }
1344
+ if (sentence && animation.subject !== void 0) {
1345
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "subject does not apply to a skeleton-v3 loop from a sentence; describe the motion in the prompt", path: ["subject"] });
1314
1346
  }
1315
1347
  });
1316
1348
  var RevisionDitheringSchema = z.enum(["none", "2x2", "4x4", "8x8"]);
@@ -2241,6 +2273,7 @@ var LockTerrainConnectSchema = z.record(
2241
2273
  TerrainSideSchema,
2242
2274
  z.object({ sourceAssetId: z.string().min(1), sourceObjectId: z.string().min(1) }).strict()
2243
2275
  );
2276
+ var LockMotionShareSchema = z.object({ sourceAssetId: z.string().min(1), animationGroupId: z.string().min(1).nullable() }).strict();
2244
2277
  var LockTerrainSettingsSchema = z.object({
2245
2278
  terrainMode: z.string().optional(),
2246
2279
  terrainShapeStyle: z.string().optional(),
@@ -2277,6 +2310,8 @@ var LockHistoryEntrySchema = z.object({
2277
2310
  postprocess: PostprocessSchema.optional(),
2278
2311
  /** A connected terrain set's other sets; see LockEntrySchema.terrainConnect. */
2279
2312
  terrainConnect: LockTerrainConnectSchema.optional(),
2313
+ /** A sentence loop that shares another direction's motion; see LockEntrySchema.motionShare. */
2314
+ motionShare: LockMotionShareSchema.optional(),
2280
2315
  /** A terrain set's settings outside specHash; see LockEntrySchema.terrainSettings. */
2281
2316
  terrainSettings: LockTerrainSettingsSchema.optional(),
2282
2317
  /** When the replacement was submitted. */
@@ -2353,6 +2388,16 @@ var LockEntrySchema = z.object({
2353
2388
  * Absent (not null) otherwise, so other entries do not grow the key.
2354
2389
  */
2355
2390
  terrainConnect: LockTerrainConnectSchema.optional(),
2391
+ /**
2392
+ * For a sentence loop of a later direction: the loop whose motion it
2393
+ * shares and the animation group of that loop's generation this was
2394
+ * drawn into when submitted. Tracked the way `terrainConnect` is, not
2395
+ * through `specHash`: the group lives in the lockfile. A regenerated leader
2396
+ * has a new group, so the plan marks this stale. `animationGroupId` is
2397
+ * null when the leader had no group recorded and this was drawn on its own.
2398
+ * Absent otherwise.
2399
+ */
2400
+ motionShare: LockMotionShareSchema.optional(),
2356
2401
  /**
2357
2402
  * For a terrain set: the settings it was submitted with that `specHash`
2358
2403
  * leaves out (see LockTerrainSettingsSchema). The plan marks the entry
@@ -4679,7 +4724,10 @@ import path8 from "path";
4679
4724
  async function inspectRevisionReadiness(spec, lock) {
4680
4725
  if (spec.mirror) return inspectMirror(spec, lock, /* @__PURE__ */ new Set());
4681
4726
  if (spec.character?.kind === "outfit") return inspectOutfit(spec, lock, /* @__PURE__ */ new Set());
4682
- if (spec.character?.parentSpec) return inspectCharacterParent(spec, lock, /* @__PURE__ */ new Set());
4727
+ if (spec.character?.parentSpec) {
4728
+ const parent = await inspectCharacterParent(spec, lock, /* @__PURE__ */ new Set());
4729
+ return parent.ready && spec.character.motionLeader ? inspectMotionLeader(spec, lock, /* @__PURE__ */ new Set()) : parent;
4730
+ }
4683
4731
  if (spec.character?.styleAnchor) return inspectStyleAnchor(spec, lock, /* @__PURE__ */ new Set());
4684
4732
  if (spec.objectPro?.parentSpec) return inspectObjectProParent(spec, lock, /* @__PURE__ */ new Set());
4685
4733
  if (spec.scene) return inspectScene(spec);
@@ -4714,6 +4762,17 @@ async function inspectTerrainConnect(spec, lock, seen) {
4714
4762
  }
4715
4763
  return { ready: true, reason: "connected sets are current" };
4716
4764
  }
4765
+ function recordedAnimationGroup(entry) {
4766
+ const character = entry?.providerMetadata?.[entry.provider]?.character;
4767
+ const text = (value) => typeof value === "string" && value ? value : null;
4768
+ return { animationGroupId: text(character?.animationGroupId), animationName: text(character?.animationName) };
4769
+ }
4770
+ async function inspectMotionLeader(spec, lock, seen) {
4771
+ const leader = spec.character.motionLeader;
4772
+ const label = `motion leader ${spec.styleId}/${leader.assetId}`;
4773
+ const dependency = await inspectParent(leader.spec, lock, seen);
4774
+ return dependency.ready ? { ready: true, reason: `${label} is current` } : { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
4775
+ }
4717
4776
  async function inspectScene(spec) {
4718
4777
  const scene = spec.scene;
4719
4778
  const files = [{ label: "scene image", file: scene.file, sha256: scene.sha256 }];
@@ -4872,6 +4931,10 @@ async function inspectParent(spec, lock, seen) {
4872
4931
  const inputs = await inspectCharacterParent(spec, lock, nextSeen);
4873
4932
  if (!inputs.ready) return inputs;
4874
4933
  }
4934
+ if (spec.character?.motionLeader) {
4935
+ const inputs = await inspectMotionLeader(spec, lock, nextSeen);
4936
+ if (!inputs.ready) return inputs;
4937
+ }
4875
4938
  if (spec.character?.styleAnchor) {
4876
4939
  const inputs = await inspectStyleAnchor(spec, lock, nextSeen);
4877
4940
  if (!inputs.ready) return inputs;
@@ -4928,6 +4991,7 @@ function dependencies(spec) {
4928
4991
  spec.mirror?.sourceSpec,
4929
4992
  spec.revision?.sourceSpec,
4930
4993
  spec.character?.parentSpec,
4994
+ spec.character?.motionLeader?.spec,
4931
4995
  spec.character?.styleAnchor?.spec,
4932
4996
  spec.objectPro?.parentSpec,
4933
4997
  spec.terrainConnect?.lower?.spec,
@@ -4944,6 +5008,12 @@ function changedTerrainConnection(spec, entry, lock) {
4944
5008
  }
4945
5009
  return null;
4946
5010
  }
5011
+ function changedMotionLeader(spec, entry, lock) {
5012
+ const leader = spec.character?.motionLeader;
5013
+ if (!leader) return null;
5014
+ const current = recordedAnimationGroup(lock.entries[lockKey(leader.spec.styleId, leader.spec.assetId)]).animationGroupId;
5015
+ return (entry.motionShare?.animationGroupId ?? null) === current ? null : leader.assetId;
5016
+ }
4947
5017
  function changedTerrainSettings(spec, entry) {
4948
5018
  const recorded = entry.terrainSettings;
4949
5019
  const current = terrainSettings(spec);
@@ -4985,6 +5055,7 @@ async function buildPlan(specs, lock, opts = {}) {
4985
5055
  let state;
4986
5056
  let reason;
4987
5057
  const changedConnection = entry ? changedTerrainConnection(spec, entry, lock) : null;
5058
+ const changedMotion = entry ? changedMotionLeader(spec, entry, lock) : null;
4988
5059
  const changedSettings = entry ? changedTerrainSettings(spec, entry) : null;
4989
5060
  const revision = await inspectRevisionReadiness(spec, lock);
4990
5061
  if (revision && !revision.ready) {
@@ -5024,6 +5095,9 @@ async function buildPlan(specs, lock, opts = {}) {
5024
5095
  } else if (changedConnection) {
5025
5096
  state = "stale";
5026
5097
  reason = `connected set ${changedConnection} was regenerated; draw this set from its new terrain`;
5098
+ } else if (changedMotion) {
5099
+ state = "stale";
5100
+ reason = `shares motion with ${changedMotion}, which was regenerated; draw this direction into its new motion`;
5027
5101
  } else if (changedSettings) {
5028
5102
  state = "stale";
5029
5103
  reason = changedSettings;
@@ -5087,6 +5161,14 @@ async function buildPlan(specs, lock, opts = {}) {
5087
5161
  }
5088
5162
  }
5089
5163
  }
5164
+ for (const item of items) {
5165
+ const leader = item.spec.character?.motionLeader;
5166
+ if (!leader || !isActionable(item)) continue;
5167
+ const leaderEntry = lock.entries[lockKey(leader.spec.styleId, leader.spec.assetId)];
5168
+ if (leaderEntry?.status === "downloaded" && !recordedAnimationGroup(leaderEntry).animationGroupId) {
5169
+ item.reason += `; ${leader.assetId} has no animation group recorded, so this is drawn on its own and its motion will differ`;
5170
+ }
5171
+ }
5090
5172
  const actionable = items.filter(isActionable);
5091
5173
  const groupsByProvider = /* @__PURE__ */ new Map();
5092
5174
  for (const item of actionable) {
@@ -6538,6 +6620,8 @@ var CharacterSubmitSchema = z3.object({
6538
6620
  var AnimateSubmitSchema = z3.object({
6539
6621
  background_job_ids: z3.array(z3.string()),
6540
6622
  directions: z3.array(z3.string()),
6623
+ /** skeleton-v3 only: the group the directions were drawn into. */
6624
+ animation_group_id: z3.string().nullable().optional(),
6541
6625
  status: z3.string().default("processing"),
6542
6626
  usage: UsageSchema
6543
6627
  }).passthrough();
@@ -7258,7 +7342,13 @@ var PixelLabClient = class {
7258
7342
  * Every mode but skeleton-v3 goes to `/animate-character`, the path whose
7259
7343
  * shapes were measured live. skeleton-v3 is documented only on
7260
7344
  * `/characters/animations`, which takes the same request and answers with
7261
- * the same job list (plus an `animation_group_id`).
7345
+ * the same job list (plus an `animation_group_id`). With no `template` it
7346
+ * animates `actionDescription` as a sentence, 3 to 15 `frameCount` frames
7347
+ * written into one 3D motion; measured on a v3 character (8 frames, 64px:
7348
+ * 3 generations, frames back at 96x96). `animationGroupId` (skeleton-v3
7349
+ * only) adds the directions to an existing animation of the character,
7350
+ * drawn from its motion; the group's own name must be sent again as
7351
+ * `animationName`, or the group appears renamed.
7262
7352
  */
7263
7353
  async animateCharacter(args) {
7264
7354
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
@@ -7270,10 +7360,12 @@ var PixelLabClient = class {
7270
7360
  animation_name: args.animationName,
7271
7361
  mode: args.mode,
7272
7362
  directions: args.directions,
7363
+ ...args.mode === "skeleton-v3" && args.animationGroupId ? { animation_group_id: args.animationGroupId } : {},
7273
7364
  ...args.template ? { template_animation_id: args.template } : {},
7274
7365
  ...args.actionDescription ? { action_description: args.actionDescription } : {},
7275
7366
  ...args.description ? { description: args.description } : {},
7276
- ...args.mode === "v3" && args.frameCount ? { frame_count: args.frameCount } : {},
7367
+ // A template fixes its own frame count; v3 and a skeleton-v3 sentence take one.
7368
+ ...(args.mode === "v3" || args.mode === "skeleton-v3" && !args.template) && args.frameCount ? { frame_count: args.frameCount } : {},
7277
7369
  ...args.mode === "v3" && args.keepFirstFrame === false ? { keep_first_frame: false } : {},
7278
7370
  ...args.mode === "v3" && args.startFrame ? { custom_start_frame: encode(args.startFrame) } : {},
7279
7371
  ...args.mode === "v3" && args.endFrame ? { end_frame: encode(args.endFrame) } : {},
@@ -8953,7 +9045,13 @@ function decodeAnimationJob(jobId) {
8953
9045
  try {
8954
9046
  const value = JSON.parse(Buffer.from(jobId.slice("character-animation:".length), "base64url").toString("utf8"));
8955
9047
  if (typeof value?.characterId === "string" && typeof value.name === "string" && typeof value.direction === "string") {
8956
- return { characterId: value.characterId, name: value.name, direction: value.direction, jobIds: Array.isArray(value.jobIds) ? value.jobIds : [] };
9048
+ return {
9049
+ characterId: value.characterId,
9050
+ name: value.name,
9051
+ direction: value.direction,
9052
+ jobIds: Array.isArray(value.jobIds) ? value.jobIds : [],
9053
+ ...typeof value.groupId === "string" && value.groupId ? { groupId: value.groupId } : {}
9054
+ };
8957
9055
  }
8958
9056
  } catch {
8959
9057
  }
@@ -9193,6 +9291,16 @@ var PixelLabProvider = class _PixelLabProvider {
9193
9291
  if (skeletonTemplate && !CHARACTER_TEMPLATES.includes(skeletonTemplate)) {
9194
9292
  throw new Error(`PixelLab animate-skeleton template "${skeletonTemplate}" is not one of ${CHARACTER_TEMPLATES.join(", ")}`);
9195
9293
  }
9294
+ if (!spec.revision.description?.trim()) {
9295
+ throw new Error(
9296
+ `${spec.styleId}/${spec.assetId}: an animate-skeleton revision needs revision.description, what the character looks like (for example "a knight in steel armour holding a sword"); PixelLab refuses the request without it`
9297
+ );
9298
+ }
9299
+ if (!spec.prompt.trim()) {
9300
+ throw new Error(
9301
+ `${spec.styleId}/${spec.assetId}: an animate-skeleton revision needs a prompt, a short motion label (for example "swinging the sword")`
9302
+ );
9303
+ }
9196
9304
  }
9197
9305
  if (spec.revision?.mode === "animate" || spec.revision?.mode === "animate-pixminimax") {
9198
9306
  const { sourceWidth: width, sourceHeight: height, mode: mode2, frames, lastFrameWidth, lastFrameHeight } = spec.revision;
@@ -9596,12 +9704,18 @@ var PixelLabProvider = class _PixelLabProvider {
9596
9704
  `${spec.styleId}/${spec.assetId} animates "${character.animation.direction}", but its character has ${character.directions} directions: ${allowed.join(", ")}`
9597
9705
  );
9598
9706
  }
9599
- if (character.animation.mode !== "template" && character.animation.mode !== "skeleton-v3" && !spec.prompt.trim()) {
9600
- throw new Error(`${spec.styleId}/${spec.assetId} needs a prompt describing the motion, or a template`);
9707
+ const sentence = character.animation.mode === "skeleton-v3" && !character.animation.template;
9708
+ if (character.animation.mode !== "template" && (character.animation.mode !== "skeleton-v3" || sentence) && !spec.prompt.trim()) {
9709
+ throw new Error(
9710
+ `${spec.styleId}/${spec.assetId} needs a prompt describing the motion, or a template` + (sentence ? ' (a skeleton-v3 loop with no template animates its prompt, e.g. "swinging a sword in a wide arc")' : "")
9711
+ );
9712
+ }
9713
+ if (sentence && (character.animation.frames < 3 || character.animation.frames > 15)) {
9714
+ throw new Error(`${spec.styleId}/${spec.assetId}: a skeleton-v3 loop from a sentence takes 3 to 15 frames; ${character.animation.frames} is out of range`);
9601
9715
  }
9602
9716
  if (character.animation.mode === "skeleton-v3" && character.mode === "standard") {
9603
9717
  throw new Error(
9604
- `${spec.styleId}/${spec.assetId}: skeleton-v3 poses a template onto a 3D skeleton, which a ${character.mode} character has none of; use a v3 base (style mode: "v3"), or mode: "template" for this loop`
9718
+ sentence ? `${spec.styleId}/${spec.assetId}: a skeleton-v3 loop from a sentence writes the motion onto a 3D skeleton, which a ${character.mode} character has none of; use a v3 base (style mode: "v3"), or mode: "template" with a template for this loop` : `${spec.styleId}/${spec.assetId}: skeleton-v3 poses a template onto a 3D skeleton, which a ${character.mode} character has none of; use a v3 base (style mode: "v3"), or mode: "template" for this loop`
9605
9719
  );
9606
9720
  }
9607
9721
  }
@@ -9775,11 +9889,18 @@ var PixelLabProvider = class _PixelLabProvider {
9775
9889
  };
9776
9890
  }
9777
9891
  const animation = character.animation;
9778
- const name = characterAnimationName(spec);
9892
+ const ownName = characterAnimationName(spec);
9893
+ const shared = context?.motionGroup;
9894
+ const name = shared?.animationName ?? ownName;
9779
9895
  const replaced = context?.replacedMetadata?.character ?? {};
9780
9896
  const existing = await this.client.getCharacter(parentId);
9897
+ if (shared && !existing.animations.some((group) => group.animation_group_id === shared.animationGroupId && group.directions.some((d) => d.direction !== animation.direction))) {
9898
+ throw new Error(
9899
+ `${spec.styleId}/${spec.assetId} shares the motion of an animation group (${shared.animationGroupId}) that is no longer on the character; regenerate the leading direction first`
9900
+ );
9901
+ }
9781
9902
  for (const group of existing.animations) {
9782
- const ours = group.animation_group_id && (group.animation_group_id === replaced.animationGroupId || group.display_name === name || replaced.animationId && group.directions.some((d) => d.frames.some((url) => url.includes(`/animations/${replaced.animationId}/`))));
9903
+ const ours = group.animation_group_id && (group.animation_group_id === replaced.animationGroupId || group.display_name === name || group.display_name === ownName || replaced.animationId && group.directions.some((d) => d.frames.some((url) => url.includes(`/animations/${replaced.animationId}/`))));
9783
9904
  if (!ours || !group.animation_group_id) continue;
9784
9905
  if (group.directions.some((d) => d.direction === animation.direction)) {
9785
9906
  await this.client.deleteCharacterAnimations(parentId, { animationGroupId: group.animation_group_id }, animation.direction);
@@ -9794,6 +9915,7 @@ var PixelLabProvider = class _PixelLabProvider {
9794
9915
  frameCount: animation.frames,
9795
9916
  keepFirstFrame: animation.keepFirstFrame,
9796
9917
  directions: [animation.direction],
9918
+ ...shared ? { animationGroupId: shared.animationGroupId } : {},
9797
9919
  seed: spec.seed,
9798
9920
  textGuidanceScale: character.textGuidanceScale,
9799
9921
  isometric: character.isometric,
@@ -9806,10 +9928,11 @@ var PixelLabProvider = class _PixelLabProvider {
9806
9928
  enhancePrompt: animation.enhancePrompt,
9807
9929
  paletteSwatchBase64: spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0
9808
9930
  });
9809
- const job = { characterId: parentId, name, direction: animation.direction, jobIds: res.background_job_ids };
9931
+ const groupId = res.animation_group_id ?? shared?.animationGroupId;
9932
+ const job = { characterId: parentId, name, direction: animation.direction, jobIds: res.background_job_ids, ...groupId ? { groupId } : {} };
9810
9933
  return {
9811
9934
  jobId: encodeAnimationJob(job),
9812
- metadata: { character: { kind: "animation", characterId: parentId, animationName: name, direction: animation.direction, mode: animation.mode, backgroundJobIds: res.background_job_ids } }
9935
+ metadata: { character: { kind: "animation", characterId: parentId, animationName: name, direction: animation.direction, mode: animation.mode, backgroundJobIds: res.background_job_ids, ...groupId ? { animationGroupId: groupId } : {} } }
9813
9936
  };
9814
9937
  }
9815
9938
  /**
@@ -10024,13 +10147,13 @@ var PixelLabProvider = class _PixelLabProvider {
10024
10147
  const animationId = typeof done.animation_id === "string" ? done.animation_id : null;
10025
10148
  if (Array.isArray(frames) && frames.length && frames.every((f) => typeof f === "string")) {
10026
10149
  const character2 = await this.client.getCharacter(job.characterId);
10027
- const group = findAnimation(character2, job.name, job.direction, animationId);
10028
- return review(frames, animationId, group?.groupId ?? null, billedFromUsage(status.usage));
10150
+ const group = findAnimation(character2, job.name, job.direction, animationId, job.groupId);
10151
+ return review(frames, animationId, group?.groupId ?? job.groupId ?? null, billedFromUsage(status.usage));
10029
10152
  }
10030
10153
  }
10031
10154
  const recorded = context?.metadata?.character?.animationId ?? null;
10032
10155
  const character = await this.client.getCharacter(job.characterId);
10033
- const found = findAnimation(character, job.name, job.direction, recorded);
10156
+ const found = findAnimation(character, job.name, job.direction, recorded, job.groupId);
10034
10157
  if (found) return review(found.frames, found.animationId, found.groupId, null);
10035
10158
  return cleanedUp ? { status: "failed", error: `animation job is gone upstream and no "${job.name}" ${job.direction} animation exists on the character` } : { status: "processing" };
10036
10159
  }
@@ -11464,10 +11587,11 @@ function rotationSources(character) {
11464
11587
  }
11465
11588
  return sources;
11466
11589
  }
11467
- function findAnimation(character, name, direction, animationId) {
11590
+ function findAnimation(character, name, direction, animationId, groupId) {
11468
11591
  for (const group of character.animations) {
11469
11592
  const match = group.directions.find((d) => d.direction === direction && d.frames.length > 0);
11470
11593
  if (!match) continue;
11594
+ if (groupId && group.animation_group_id !== groupId) continue;
11471
11595
  const byName = group.display_name === name || group.animation_type === name;
11472
11596
  const byId = animationId !== null && match.frames.some((url) => url.includes(`/animations/${animationId}/`));
11473
11597
  if (!byName && !byId) continue;
@@ -12602,8 +12726,16 @@ function expandLoopDirections(raw) {
12602
12726
  continue;
12603
12727
  }
12604
12728
  for (const expandedId of ids) claimed.add(expandedId);
12605
- for (const loop of loops) {
12606
- expanded[loop.id] = { ...asset, animation: { ...animation, direction: loop.direction } };
12729
+ const sentence = animation.mode === "skeleton-v3" && !animation.template;
12730
+ for (const [index, loop] of loops.entries()) {
12731
+ expanded[loop.id] = {
12732
+ ...asset,
12733
+ animation: {
12734
+ ...animation,
12735
+ direction: loop.direction,
12736
+ ...sentence && index > 0 ? { motionFrom: loops[0].id } : {}
12737
+ }
12738
+ };
12607
12739
  }
12608
12740
  for (const mirror of mirrors) {
12609
12741
  const inherited = {};
@@ -13219,7 +13351,8 @@ async function resolveSpecs(loaded, filter) {
13219
13351
  `assets.${assetId}: mirroring ${asset.mirror} gives another ${facing}-facing loop; mirror a loop facing east, west, or a diagonal`
13220
13352
  );
13221
13353
  }
13222
- resolved.character = { ...sourceSpec.character, animation: { ...sourceSpec.character.animation, direction: mirrored } };
13354
+ const { motionLeader: _motionLeader, ...flipped } = sourceSpec.character;
13355
+ resolved.character = { ...flipped, animation: { ...sourceSpec.character.animation, direction: mirrored } };
13223
13356
  } else if (sourceSpec.character) {
13224
13357
  resolved.character = { ...sourceSpec.character };
13225
13358
  }
@@ -13276,6 +13409,26 @@ async function resolveSpecs(loaded, filter) {
13276
13409
  directions: resolved.character.kind === "base" ? resolved.character.directions : parentSpec.character?.directions ?? resolved.character.directions
13277
13410
  };
13278
13411
  }
13412
+ if (resolved.character?.animation && asset.animation?.motionFrom) {
13413
+ const leaderId = asset.animation.motionFrom;
13414
+ const where = `assets.${assetId}.animation.motionFrom`;
13415
+ if (leaderId === assetId) throw new Error(`${where}: an asset cannot share its own motion`);
13416
+ const leader = await finalize(leaderId);
13417
+ const led = leader.character?.animation;
13418
+ if (leader.character?.kind !== "animation" || leader.mirror || !led || led.mode !== "skeleton-v3" || led.template) {
13419
+ throw new Error(`${where}: ${leaderId} is not a skeleton-v3 loop from a sentence`);
13420
+ }
13421
+ if (leader.character.parentAssetId !== resolved.character.parentAssetId) {
13422
+ throw new Error(`${where}: ${leaderId} animates ${leader.character.parentAssetId}, not ${resolved.character.parentAssetId}`);
13423
+ }
13424
+ if (leader.character.motionLeader) {
13425
+ throw new Error(`${where}: ${leaderId} itself shares ${leader.character.motionLeader.assetId}'s motion; point at the loop that leads`);
13426
+ }
13427
+ if (led.direction === resolved.character.animation.direction) {
13428
+ throw new Error(`${where}: ${leaderId} is drawn in the same direction; sharing a motion needs another direction`);
13429
+ }
13430
+ resolved.character = { ...resolved.character, motionLeader: { assetId: leaderId, spec: leader } };
13431
+ }
13279
13432
  if (resolved.objectPro && characterParent) {
13280
13433
  const parentSpec = await finalize(characterParent);
13281
13434
  if (!parentSpec.objectPro) {
@@ -17029,7 +17182,8 @@ var SkeletonAnimationRequestSchema = z10.object({
17029
17182
  /** The motion, sent as PixelLab's `action`. */
17030
17183
  prompt: z10.string().min(1),
17031
17184
  direction: CharacterDirectionSchema,
17032
- description: z10.string().min(1).optional(),
17185
+ /** What the character looks like; PixelLab refuses the request without it. */
17186
+ description: z10.string().min(1),
17033
17187
  skeletonTemplate: z10.string().min(1).optional(),
17034
17188
  /** Manifest-relative JSON path inside the project. */
17035
17189
  keypointsFile: z10.string().min(1),
@@ -17136,7 +17290,7 @@ async function createSkeletonAnimation(manifestPath, request) {
17136
17290
  from: request.from,
17137
17291
  keypointsFile: relative.split(path31.sep).join("/"),
17138
17292
  direction: request.direction,
17139
- ...request.description ? { description: request.description } : {},
17293
+ description: request.description,
17140
17294
  ...request.skeletonTemplate ? { skeletonTemplate: request.skeletonTemplate } : {}
17141
17295
  }
17142
17296
  }
@@ -17460,6 +17614,7 @@ function retireGeneration(entry, retiredAt) {
17460
17614
  provider: entry.provider,
17461
17615
  postprocess: entry.postprocess,
17462
17616
  ...entry.terrainConnect ? { terrainConnect: entry.terrainConnect } : {},
17617
+ ...entry.motionShare ? { motionShare: entry.motionShare } : {},
17463
17618
  ...entry.terrainSettings ? { terrainSettings: entry.terrainSettings } : {},
17464
17619
  retiredAt
17465
17620
  };
@@ -17555,6 +17710,8 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
17555
17710
  // A connected set comes back with the connection it was drawn from, so
17556
17711
  // the plan compares the restored generation, not the one it replaced.
17557
17712
  terrainConnect: generation.terrainConnect,
17713
+ // Likewise a loop that shared another direction's motion.
17714
+ motionShare: generation.motionShare,
17558
17715
  // Likewise its settings; a generation from before they were recorded
17559
17716
  // comes back with none and is taken as current.
17560
17717
  terrainSettings: generation.terrainSettings,
@@ -18919,6 +19076,12 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
18919
19076
  };
18920
19077
  terrainConnect[side] = { sourceAssetId: link.assetId, sourceObjectId: objectId };
18921
19078
  }
19079
+ const motionLeader = spec.character?.motionLeader;
19080
+ const leaderGroup = motionLeader ? recordedAnimationGroup(lock.entries[lockKey(motionLeader.spec.styleId, motionLeader.spec.assetId)]) : void 0;
19081
+ const motionGroup = leaderGroup?.animationGroupId && leaderGroup.animationName ? { animationGroupId: leaderGroup.animationGroupId, animationName: leaderGroup.animationName } : void 0;
19082
+ if (motionLeader && !motionGroup) {
19083
+ log2(` ${key}: ${motionLeader.assetId} has no animation group recorded; drawing this direction on its own`);
19084
+ }
18922
19085
  upsert(lock, key, {
18923
19086
  styleId: spec.styleId,
18924
19087
  assetId: spec.assetId,
@@ -18960,6 +19123,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
18960
19123
  referenceSha256: spec.character.outfit.reference.sha256
18961
19124
  } : null,
18962
19125
  terrainConnect: spec.terrainConnect ? terrainConnect : void 0,
19126
+ motionShare: motionLeader ? { sourceAssetId: motionLeader.assetId, animationGroupId: motionGroup?.animationGroupId ?? null } : void 0,
18963
19127
  // The terrain knobs specHash leaves out, so the plan can tell a
18964
19128
  // changed one; undefined (no key) for every other generator.
18965
19129
  terrainSettings: terrainSettings(spec),
@@ -19010,6 +19174,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
19010
19174
  ...anchorEntry?.objectId ? { styleObjectId: anchorEntry.objectId } : {},
19011
19175
  ...parentOutputs ? { parentOutputs } : {},
19012
19176
  ...spec.terrainConnect ? { terrainLinks } : {},
19177
+ ...motionGroup ? { motionGroup } : {},
19013
19178
  ...replacedMetadata ? { replacedMetadata } : {},
19014
19179
  ...lastGenerationMetadata ? { lastGenerationMetadata } : {},
19015
19180
  ...previousMetadata ? { previousMetadata } : {},