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/index.cjs CHANGED
@@ -284,6 +284,7 @@ __export(index_exports, {
284
284
  readVfxConversation: () => readVfxConversation,
285
285
  readVfxRecipe: () => readVfxRecipe,
286
286
  recipeDigest: () => recipeDigest,
287
+ recordedAnimationGroup: () => recordedAnimationGroup,
287
288
  referencedHashes: () => referencedHashes,
288
289
  refineAsset: () => refineAsset,
289
290
  refineFrameSet: () => refineFrameSet,
@@ -692,16 +693,30 @@ var CharacterAnimationSchema = import_zod.z.object({
692
693
  * `src/loop-directions.ts`.
693
694
  */
694
695
  directions: import_zod.z.array(CharacterDirectionSchema).min(1).optional(),
695
- /** Frames to generate without a template (v3). Even, 4 to 16. */
696
- frames: import_zod.z.number().int().min(4).max(16).optional(),
696
+ /**
697
+ * A sentence loop (`mode: "skeleton-v3"`, no `template`) of another
698
+ * direction whose motion this one shares: the asset id of the loop that
699
+ * leads. This loop is then submitted after it, into its animation group,
700
+ * so PixelLab draws it from the same 3D motion. Written for you when
701
+ * `directions` expands (the first direction leads); set by hand only when
702
+ * declaring the loops explicitly.
703
+ */
704
+ motionFrom: import_zod.z.string().min(1).optional(),
705
+ /**
706
+ * Frames to generate without a template. A v3 loop takes an even 4 to 16;
707
+ * a sentence loop (`mode: "skeleton-v3"` with no `template`) takes 3 to 15.
708
+ */
709
+ frames: import_zod.z.number().int().min(3).max(16).optional(),
697
710
  /** Playback rate recorded with the frames; PixelLab does not store one. */
698
711
  fps: import_zod.z.number().int().min(1).max(60).default(8),
699
712
  /** v3 only: keep the resting pose as frame 0, so `frames` generated frames land as `frames + 1` files. */
700
713
  keepFirstFrame: import_zod.z.boolean().default(true),
701
714
  /**
702
715
  * `template` when a template is named, otherwise `v3`; `skeleton-v3`
703
- * poses a template with PixelLab's skeleton model instead; `pro` for the
704
- * sequential high-quality engine.
716
+ * poses a template with PixelLab's skeleton model instead, or, with no
717
+ * template, animates the asset's prompt as a sentence written into one 3D
718
+ * motion (a v3 base; 3-15 `frames`); `pro` for the sequential
719
+ * high-quality engine.
705
720
  */
706
721
  mode: CharacterAnimationModeSchema.optional(),
707
722
  /**
@@ -731,15 +746,23 @@ var CharacterAnimationSchema = import_zod.z.object({
731
746
  path: ["directions"]
732
747
  });
733
748
  }
734
- if (animation.frames !== void 0 && animation.frames % 2 !== 0) {
735
- context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
749
+ const sentence = animation.mode === "skeleton-v3" && !animation.template;
750
+ if (animation.frames !== void 0 && !sentence) {
751
+ if (animation.frames % 2 !== 0) {
752
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
753
+ } else if (animation.frames < 4) {
754
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "frames must be 4 to 16", path: ["frames"] });
755
+ }
756
+ }
757
+ if (animation.frames !== void 0 && sentence && animation.frames > 15) {
758
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "a skeleton-v3 loop from a sentence takes 3 to 15 frames", path: ["frames"] });
736
759
  }
737
760
  const mode2 = animation.mode ?? (animation.template ? "template" : "v3");
738
761
  for (const key of ["startFrame", "endFrame", "enhancePrompt"]) {
739
762
  if (animation[key] !== void 0 && mode2 !== "v3") {
740
763
  context.addIssue({
741
764
  code: import_zod.z.ZodIssueCode.custom,
742
- 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"}`,
765
+ 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"}`,
743
766
  path: [key]
744
767
  });
745
768
  }
@@ -763,8 +786,14 @@ var CharacterAnimationSchema = import_zod.z.object({
763
786
  path: ["frames"]
764
787
  });
765
788
  }
766
- if (!animation.template && (animation.mode === "template" || animation.mode === "skeleton-v3")) {
767
- context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: `${animation.mode} mode needs a template`, path: ["template"] });
789
+ if (!animation.template && animation.mode === "template") {
790
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "template mode needs a template", path: ["template"] });
791
+ }
792
+ if (animation.motionFrom !== void 0 && !sentence) {
793
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "motionFrom applies to a skeleton-v3 loop from a sentence (no template)", path: ["motionFrom"] });
794
+ }
795
+ if (sentence && animation.subject !== void 0) {
796
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "subject does not apply to a skeleton-v3 loop from a sentence; describe the motion in the prompt", path: ["subject"] });
768
797
  }
769
798
  });
770
799
  var RevisionDitheringSchema = import_zod.z.enum(["none", "2x2", "4x4", "8x8"]);
@@ -1695,6 +1724,7 @@ var LockTerrainConnectSchema = import_zod.z.record(
1695
1724
  TerrainSideSchema,
1696
1725
  import_zod.z.object({ sourceAssetId: import_zod.z.string().min(1), sourceObjectId: import_zod.z.string().min(1) }).strict()
1697
1726
  );
1727
+ var LockMotionShareSchema = import_zod.z.object({ sourceAssetId: import_zod.z.string().min(1), animationGroupId: import_zod.z.string().min(1).nullable() }).strict();
1698
1728
  var LockTerrainSettingsSchema = import_zod.z.object({
1699
1729
  terrainMode: import_zod.z.string().optional(),
1700
1730
  terrainShapeStyle: import_zod.z.string().optional(),
@@ -1731,6 +1761,8 @@ var LockHistoryEntrySchema = import_zod.z.object({
1731
1761
  postprocess: PostprocessSchema.optional(),
1732
1762
  /** A connected terrain set's other sets; see LockEntrySchema.terrainConnect. */
1733
1763
  terrainConnect: LockTerrainConnectSchema.optional(),
1764
+ /** A sentence loop that shares another direction's motion; see LockEntrySchema.motionShare. */
1765
+ motionShare: LockMotionShareSchema.optional(),
1734
1766
  /** A terrain set's settings outside specHash; see LockEntrySchema.terrainSettings. */
1735
1767
  terrainSettings: LockTerrainSettingsSchema.optional(),
1736
1768
  /** When the replacement was submitted. */
@@ -1807,6 +1839,16 @@ var LockEntrySchema = import_zod.z.object({
1807
1839
  * Absent (not null) otherwise, so other entries do not grow the key.
1808
1840
  */
1809
1841
  terrainConnect: LockTerrainConnectSchema.optional(),
1842
+ /**
1843
+ * For a sentence loop of a later direction: the loop whose motion it
1844
+ * shares and the animation group of that loop's generation this was
1845
+ * drawn into when submitted. Tracked the way `terrainConnect` is, not
1846
+ * through `specHash`: the group lives in the lockfile. A regenerated leader
1847
+ * has a new group, so the plan marks this stale. `animationGroupId` is
1848
+ * null when the leader had no group recorded and this was drawn on its own.
1849
+ * Absent otherwise.
1850
+ */
1851
+ motionShare: LockMotionShareSchema.optional(),
1810
1852
  /**
1811
1853
  * For a terrain set: the settings it was submitted with that `specHash`
1812
1854
  * leaves out (see LockTerrainSettingsSchema). The plan marks the entry
@@ -2272,6 +2314,8 @@ var CharacterSubmitSchema = import_zod3.z.object({
2272
2314
  var AnimateSubmitSchema = import_zod3.z.object({
2273
2315
  background_job_ids: import_zod3.z.array(import_zod3.z.string()),
2274
2316
  directions: import_zod3.z.array(import_zod3.z.string()),
2317
+ /** skeleton-v3 only: the group the directions were drawn into. */
2318
+ animation_group_id: import_zod3.z.string().nullable().optional(),
2275
2319
  status: import_zod3.z.string().default("processing"),
2276
2320
  usage: UsageSchema
2277
2321
  }).passthrough();
@@ -2992,7 +3036,13 @@ var PixelLabClient = class {
2992
3036
  * Every mode but skeleton-v3 goes to `/animate-character`, the path whose
2993
3037
  * shapes were measured live. skeleton-v3 is documented only on
2994
3038
  * `/characters/animations`, which takes the same request and answers with
2995
- * the same job list (plus an `animation_group_id`).
3039
+ * the same job list (plus an `animation_group_id`). With no `template` it
3040
+ * animates `actionDescription` as a sentence, 3 to 15 `frameCount` frames
3041
+ * written into one 3D motion; measured on a v3 character (8 frames, 64px:
3042
+ * 3 generations, frames back at 96x96). `animationGroupId` (skeleton-v3
3043
+ * only) adds the directions to an existing animation of the character,
3044
+ * drawn from its motion; the group's own name must be sent again as
3045
+ * `animationName`, or the group appears renamed.
2996
3046
  */
2997
3047
  async animateCharacter(args) {
2998
3048
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
@@ -3004,10 +3054,12 @@ var PixelLabClient = class {
3004
3054
  animation_name: args.animationName,
3005
3055
  mode: args.mode,
3006
3056
  directions: args.directions,
3057
+ ...args.mode === "skeleton-v3" && args.animationGroupId ? { animation_group_id: args.animationGroupId } : {},
3007
3058
  ...args.template ? { template_animation_id: args.template } : {},
3008
3059
  ...args.actionDescription ? { action_description: args.actionDescription } : {},
3009
3060
  ...args.description ? { description: args.description } : {},
3010
- ...args.mode === "v3" && args.frameCount ? { frame_count: args.frameCount } : {},
3061
+ // A template fixes its own frame count; v3 and a skeleton-v3 sentence take one.
3062
+ ...(args.mode === "v3" || args.mode === "skeleton-v3" && !args.template) && args.frameCount ? { frame_count: args.frameCount } : {},
3011
3063
  ...args.mode === "v3" && args.keepFirstFrame === false ? { keep_first_frame: false } : {},
3012
3064
  ...args.mode === "v3" && args.startFrame ? { custom_start_frame: encode(args.startFrame) } : {},
3013
3065
  ...args.mode === "v3" && args.endFrame ? { end_frame: encode(args.endFrame) } : {},
@@ -4375,6 +4427,10 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
4375
4427
  template: spec.character.template,
4376
4428
  parent: spec.character.parentAssetId,
4377
4429
  parentSha256: spec.character.parentSha256 ?? null,
4430
+ // Which loop leads, not its group: the group is in the lockfile,
4431
+ // and the plan compares it there. Absent for every loop that
4432
+ // shares nothing, so no existing hash moves.
4433
+ motionFrom: spec.character.motionLeader?.assetId,
4378
4434
  state: spec.character.state,
4379
4435
  // Pose images enter as their hashes, never their paths.
4380
4436
  animation: spec.character.animation ? {
@@ -5419,7 +5475,13 @@ function decodeAnimationJob(jobId) {
5419
5475
  try {
5420
5476
  const value = JSON.parse(Buffer.from(jobId.slice("character-animation:".length), "base64url").toString("utf8"));
5421
5477
  if (typeof value?.characterId === "string" && typeof value.name === "string" && typeof value.direction === "string") {
5422
- return { characterId: value.characterId, name: value.name, direction: value.direction, jobIds: Array.isArray(value.jobIds) ? value.jobIds : [] };
5478
+ return {
5479
+ characterId: value.characterId,
5480
+ name: value.name,
5481
+ direction: value.direction,
5482
+ jobIds: Array.isArray(value.jobIds) ? value.jobIds : [],
5483
+ ...typeof value.groupId === "string" && value.groupId ? { groupId: value.groupId } : {}
5484
+ };
5423
5485
  }
5424
5486
  } catch {
5425
5487
  }
@@ -5659,6 +5721,16 @@ var PixelLabProvider = class _PixelLabProvider {
5659
5721
  if (skeletonTemplate && !CHARACTER_TEMPLATES.includes(skeletonTemplate)) {
5660
5722
  throw new Error(`PixelLab animate-skeleton template "${skeletonTemplate}" is not one of ${CHARACTER_TEMPLATES.join(", ")}`);
5661
5723
  }
5724
+ if (!spec.revision.description?.trim()) {
5725
+ throw new Error(
5726
+ `${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`
5727
+ );
5728
+ }
5729
+ if (!spec.prompt.trim()) {
5730
+ throw new Error(
5731
+ `${spec.styleId}/${spec.assetId}: an animate-skeleton revision needs a prompt, a short motion label (for example "swinging the sword")`
5732
+ );
5733
+ }
5662
5734
  }
5663
5735
  if (spec.revision?.mode === "animate" || spec.revision?.mode === "animate-pixminimax") {
5664
5736
  const { sourceWidth: width, sourceHeight: height, mode: mode2, frames, lastFrameWidth, lastFrameHeight } = spec.revision;
@@ -6062,12 +6134,18 @@ var PixelLabProvider = class _PixelLabProvider {
6062
6134
  `${spec.styleId}/${spec.assetId} animates "${character.animation.direction}", but its character has ${character.directions} directions: ${allowed.join(", ")}`
6063
6135
  );
6064
6136
  }
6065
- if (character.animation.mode !== "template" && character.animation.mode !== "skeleton-v3" && !spec.prompt.trim()) {
6066
- throw new Error(`${spec.styleId}/${spec.assetId} needs a prompt describing the motion, or a template`);
6137
+ const sentence = character.animation.mode === "skeleton-v3" && !character.animation.template;
6138
+ if (character.animation.mode !== "template" && (character.animation.mode !== "skeleton-v3" || sentence) && !spec.prompt.trim()) {
6139
+ throw new Error(
6140
+ `${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")' : "")
6141
+ );
6142
+ }
6143
+ if (sentence && (character.animation.frames < 3 || character.animation.frames > 15)) {
6144
+ 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`);
6067
6145
  }
6068
6146
  if (character.animation.mode === "skeleton-v3" && character.mode === "standard") {
6069
6147
  throw new Error(
6070
- `${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`
6148
+ 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`
6071
6149
  );
6072
6150
  }
6073
6151
  }
@@ -6241,11 +6319,18 @@ var PixelLabProvider = class _PixelLabProvider {
6241
6319
  };
6242
6320
  }
6243
6321
  const animation = character.animation;
6244
- const name = characterAnimationName(spec);
6322
+ const ownName = characterAnimationName(spec);
6323
+ const shared = context?.motionGroup;
6324
+ const name = shared?.animationName ?? ownName;
6245
6325
  const replaced = context?.replacedMetadata?.character ?? {};
6246
6326
  const existing = await this.client.getCharacter(parentId);
6327
+ if (shared && !existing.animations.some((group) => group.animation_group_id === shared.animationGroupId && group.directions.some((d) => d.direction !== animation.direction))) {
6328
+ throw new Error(
6329
+ `${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`
6330
+ );
6331
+ }
6247
6332
  for (const group of existing.animations) {
6248
- 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}/`))));
6333
+ 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}/`))));
6249
6334
  if (!ours || !group.animation_group_id) continue;
6250
6335
  if (group.directions.some((d) => d.direction === animation.direction)) {
6251
6336
  await this.client.deleteCharacterAnimations(parentId, { animationGroupId: group.animation_group_id }, animation.direction);
@@ -6260,6 +6345,7 @@ var PixelLabProvider = class _PixelLabProvider {
6260
6345
  frameCount: animation.frames,
6261
6346
  keepFirstFrame: animation.keepFirstFrame,
6262
6347
  directions: [animation.direction],
6348
+ ...shared ? { animationGroupId: shared.animationGroupId } : {},
6263
6349
  seed: spec.seed,
6264
6350
  textGuidanceScale: character.textGuidanceScale,
6265
6351
  isometric: character.isometric,
@@ -6272,10 +6358,11 @@ var PixelLabProvider = class _PixelLabProvider {
6272
6358
  enhancePrompt: animation.enhancePrompt,
6273
6359
  paletteSwatchBase64: spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0
6274
6360
  });
6275
- const job = { characterId: parentId, name, direction: animation.direction, jobIds: res.background_job_ids };
6361
+ const groupId = res.animation_group_id ?? shared?.animationGroupId;
6362
+ const job = { characterId: parentId, name, direction: animation.direction, jobIds: res.background_job_ids, ...groupId ? { groupId } : {} };
6276
6363
  return {
6277
6364
  jobId: encodeAnimationJob(job),
6278
- metadata: { character: { kind: "animation", characterId: parentId, animationName: name, direction: animation.direction, mode: animation.mode, backgroundJobIds: res.background_job_ids } }
6365
+ metadata: { character: { kind: "animation", characterId: parentId, animationName: name, direction: animation.direction, mode: animation.mode, backgroundJobIds: res.background_job_ids, ...groupId ? { animationGroupId: groupId } : {} } }
6279
6366
  };
6280
6367
  }
6281
6368
  /**
@@ -6490,13 +6577,13 @@ var PixelLabProvider = class _PixelLabProvider {
6490
6577
  const animationId = typeof done.animation_id === "string" ? done.animation_id : null;
6491
6578
  if (Array.isArray(frames) && frames.length && frames.every((f) => typeof f === "string")) {
6492
6579
  const character2 = await this.client.getCharacter(job.characterId);
6493
- const group = findAnimation(character2, job.name, job.direction, animationId);
6494
- return review(frames, animationId, group?.groupId ?? null, billedFromUsage(status.usage));
6580
+ const group = findAnimation(character2, job.name, job.direction, animationId, job.groupId);
6581
+ return review(frames, animationId, group?.groupId ?? job.groupId ?? null, billedFromUsage(status.usage));
6495
6582
  }
6496
6583
  }
6497
6584
  const recorded = context?.metadata?.character?.animationId ?? null;
6498
6585
  const character = await this.client.getCharacter(job.characterId);
6499
- const found = findAnimation(character, job.name, job.direction, recorded);
6586
+ const found = findAnimation(character, job.name, job.direction, recorded, job.groupId);
6500
6587
  if (found) return review(found.frames, found.animationId, found.groupId, null);
6501
6588
  return cleanedUp ? { status: "failed", error: `animation job is gone upstream and no "${job.name}" ${job.direction} animation exists on the character` } : { status: "processing" };
6502
6589
  }
@@ -7930,10 +8017,11 @@ function rotationSources(character) {
7930
8017
  }
7931
8018
  return sources;
7932
8019
  }
7933
- function findAnimation(character, name, direction, animationId) {
8020
+ function findAnimation(character, name, direction, animationId, groupId) {
7934
8021
  for (const group of character.animations) {
7935
8022
  const match = group.directions.find((d) => d.direction === direction && d.frames.length > 0);
7936
8023
  if (!match) continue;
8024
+ if (groupId && group.animation_group_id !== groupId) continue;
7937
8025
  const byName = group.display_name === name || group.animation_type === name;
7938
8026
  const byId = animationId !== null && match.frames.some((url) => url.includes(`/animations/${animationId}/`));
7939
8027
  if (!byName && !byId) continue;
@@ -10167,8 +10255,16 @@ function expandLoopDirections(raw) {
10167
10255
  continue;
10168
10256
  }
10169
10257
  for (const expandedId of ids) claimed.add(expandedId);
10170
- for (const loop of loops) {
10171
- expanded[loop.id] = { ...asset, animation: { ...animation, direction: loop.direction } };
10258
+ const sentence = animation.mode === "skeleton-v3" && !animation.template;
10259
+ for (const [index, loop] of loops.entries()) {
10260
+ expanded[loop.id] = {
10261
+ ...asset,
10262
+ animation: {
10263
+ ...animation,
10264
+ direction: loop.direction,
10265
+ ...sentence && index > 0 ? { motionFrom: loops[0].id } : {}
10266
+ }
10267
+ };
10172
10268
  }
10173
10269
  for (const mirror of mirrors) {
10174
10270
  const inherited = {};
@@ -11103,7 +11199,8 @@ async function resolveSpecs(loaded, filter) {
11103
11199
  `assets.${assetId}: mirroring ${asset.mirror} gives another ${facing}-facing loop; mirror a loop facing east, west, or a diagonal`
11104
11200
  );
11105
11201
  }
11106
- resolved.character = { ...sourceSpec.character, animation: { ...sourceSpec.character.animation, direction: mirrored } };
11202
+ const { motionLeader: _motionLeader, ...flipped } = sourceSpec.character;
11203
+ resolved.character = { ...flipped, animation: { ...sourceSpec.character.animation, direction: mirrored } };
11107
11204
  } else if (sourceSpec.character) {
11108
11205
  resolved.character = { ...sourceSpec.character };
11109
11206
  }
@@ -11160,6 +11257,26 @@ async function resolveSpecs(loaded, filter) {
11160
11257
  directions: resolved.character.kind === "base" ? resolved.character.directions : parentSpec.character?.directions ?? resolved.character.directions
11161
11258
  };
11162
11259
  }
11260
+ if (resolved.character?.animation && asset.animation?.motionFrom) {
11261
+ const leaderId = asset.animation.motionFrom;
11262
+ const where = `assets.${assetId}.animation.motionFrom`;
11263
+ if (leaderId === assetId) throw new Error(`${where}: an asset cannot share its own motion`);
11264
+ const leader = await finalize(leaderId);
11265
+ const led = leader.character?.animation;
11266
+ if (leader.character?.kind !== "animation" || leader.mirror || !led || led.mode !== "skeleton-v3" || led.template) {
11267
+ throw new Error(`${where}: ${leaderId} is not a skeleton-v3 loop from a sentence`);
11268
+ }
11269
+ if (leader.character.parentAssetId !== resolved.character.parentAssetId) {
11270
+ throw new Error(`${where}: ${leaderId} animates ${leader.character.parentAssetId}, not ${resolved.character.parentAssetId}`);
11271
+ }
11272
+ if (leader.character.motionLeader) {
11273
+ throw new Error(`${where}: ${leaderId} itself shares ${leader.character.motionLeader.assetId}'s motion; point at the loop that leads`);
11274
+ }
11275
+ if (led.direction === resolved.character.animation.direction) {
11276
+ throw new Error(`${where}: ${leaderId} is drawn in the same direction; sharing a motion needs another direction`);
11277
+ }
11278
+ resolved.character = { ...resolved.character, motionLeader: { assetId: leaderId, spec: leader } };
11279
+ }
11163
11280
  if (resolved.objectPro && characterParent) {
11164
11281
  const parentSpec = await finalize(characterParent);
11165
11282
  if (!parentSpec.objectPro) {
@@ -13154,7 +13271,10 @@ var import_node_path14 = __toESM(require("path"), 1);
13154
13271
  async function inspectRevisionReadiness(spec, lock) {
13155
13272
  if (spec.mirror) return inspectMirror(spec, lock, /* @__PURE__ */ new Set());
13156
13273
  if (spec.character?.kind === "outfit") return inspectOutfit(spec, lock, /* @__PURE__ */ new Set());
13157
- if (spec.character?.parentSpec) return inspectCharacterParent(spec, lock, /* @__PURE__ */ new Set());
13274
+ if (spec.character?.parentSpec) {
13275
+ const parent = await inspectCharacterParent(spec, lock, /* @__PURE__ */ new Set());
13276
+ return parent.ready && spec.character.motionLeader ? inspectMotionLeader(spec, lock, /* @__PURE__ */ new Set()) : parent;
13277
+ }
13158
13278
  if (spec.character?.styleAnchor) return inspectStyleAnchor(spec, lock, /* @__PURE__ */ new Set());
13159
13279
  if (spec.objectPro?.parentSpec) return inspectObjectProParent(spec, lock, /* @__PURE__ */ new Set());
13160
13280
  if (spec.scene) return inspectScene(spec);
@@ -13189,6 +13309,17 @@ async function inspectTerrainConnect(spec, lock, seen) {
13189
13309
  }
13190
13310
  return { ready: true, reason: "connected sets are current" };
13191
13311
  }
13312
+ function recordedAnimationGroup(entry) {
13313
+ const character = entry?.providerMetadata?.[entry.provider]?.character;
13314
+ const text = (value) => typeof value === "string" && value ? value : null;
13315
+ return { animationGroupId: text(character?.animationGroupId), animationName: text(character?.animationName) };
13316
+ }
13317
+ async function inspectMotionLeader(spec, lock, seen) {
13318
+ const leader = spec.character.motionLeader;
13319
+ const label = `motion leader ${spec.styleId}/${leader.assetId}`;
13320
+ const dependency = await inspectParent(leader.spec, lock, seen);
13321
+ return dependency.ready ? { ready: true, reason: `${label} is current` } : { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
13322
+ }
13192
13323
  async function inspectScene(spec) {
13193
13324
  const scene = spec.scene;
13194
13325
  const files = [{ label: "scene image", file: scene.file, sha256: scene.sha256 }];
@@ -13347,6 +13478,10 @@ async function inspectParent(spec, lock, seen) {
13347
13478
  const inputs = await inspectCharacterParent(spec, lock, nextSeen);
13348
13479
  if (!inputs.ready) return inputs;
13349
13480
  }
13481
+ if (spec.character?.motionLeader) {
13482
+ const inputs = await inspectMotionLeader(spec, lock, nextSeen);
13483
+ if (!inputs.ready) return inputs;
13484
+ }
13350
13485
  if (spec.character?.styleAnchor) {
13351
13486
  const inputs = await inspectStyleAnchor(spec, lock, nextSeen);
13352
13487
  if (!inputs.ready) return inputs;
@@ -13403,6 +13538,7 @@ function dependencies(spec) {
13403
13538
  spec.mirror?.sourceSpec,
13404
13539
  spec.revision?.sourceSpec,
13405
13540
  spec.character?.parentSpec,
13541
+ spec.character?.motionLeader?.spec,
13406
13542
  spec.character?.styleAnchor?.spec,
13407
13543
  spec.objectPro?.parentSpec,
13408
13544
  spec.terrainConnect?.lower?.spec,
@@ -13419,6 +13555,12 @@ function changedTerrainConnection(spec, entry, lock) {
13419
13555
  }
13420
13556
  return null;
13421
13557
  }
13558
+ function changedMotionLeader(spec, entry, lock) {
13559
+ const leader = spec.character?.motionLeader;
13560
+ if (!leader) return null;
13561
+ const current = recordedAnimationGroup(lock.entries[lockKey(leader.spec.styleId, leader.spec.assetId)]).animationGroupId;
13562
+ return (entry.motionShare?.animationGroupId ?? null) === current ? null : leader.assetId;
13563
+ }
13422
13564
  function changedTerrainSettings(spec, entry) {
13423
13565
  const recorded = entry.terrainSettings;
13424
13566
  const current = terrainSettings(spec);
@@ -13460,6 +13602,7 @@ async function buildPlan(specs, lock, opts = {}) {
13460
13602
  let state;
13461
13603
  let reason;
13462
13604
  const changedConnection = entry ? changedTerrainConnection(spec, entry, lock) : null;
13605
+ const changedMotion = entry ? changedMotionLeader(spec, entry, lock) : null;
13463
13606
  const changedSettings = entry ? changedTerrainSettings(spec, entry) : null;
13464
13607
  const revision = await inspectRevisionReadiness(spec, lock);
13465
13608
  if (revision && !revision.ready) {
@@ -13499,6 +13642,9 @@ async function buildPlan(specs, lock, opts = {}) {
13499
13642
  } else if (changedConnection) {
13500
13643
  state = "stale";
13501
13644
  reason = `connected set ${changedConnection} was regenerated; draw this set from its new terrain`;
13645
+ } else if (changedMotion) {
13646
+ state = "stale";
13647
+ reason = `shares motion with ${changedMotion}, which was regenerated; draw this direction into its new motion`;
13502
13648
  } else if (changedSettings) {
13503
13649
  state = "stale";
13504
13650
  reason = changedSettings;
@@ -13562,6 +13708,14 @@ async function buildPlan(specs, lock, opts = {}) {
13562
13708
  }
13563
13709
  }
13564
13710
  }
13711
+ for (const item of items) {
13712
+ const leader = item.spec.character?.motionLeader;
13713
+ if (!leader || !isActionable(item)) continue;
13714
+ const leaderEntry = lock.entries[lockKey(leader.spec.styleId, leader.spec.assetId)];
13715
+ if (leaderEntry?.status === "downloaded" && !recordedAnimationGroup(leaderEntry).animationGroupId) {
13716
+ item.reason += `; ${leader.assetId} has no animation group recorded, so this is drawn on its own and its motion will differ`;
13717
+ }
13718
+ }
13565
13719
  const actionable = items.filter(isActionable);
13566
13720
  const groupsByProvider = /* @__PURE__ */ new Map();
13567
13721
  for (const item of actionable) {
@@ -14971,6 +15125,7 @@ function retireGeneration(entry, retiredAt) {
14971
15125
  provider: entry.provider,
14972
15126
  postprocess: entry.postprocess,
14973
15127
  ...entry.terrainConnect ? { terrainConnect: entry.terrainConnect } : {},
15128
+ ...entry.motionShare ? { motionShare: entry.motionShare } : {},
14974
15129
  ...entry.terrainSettings ? { terrainSettings: entry.terrainSettings } : {},
14975
15130
  retiredAt
14976
15131
  };
@@ -15066,6 +15221,8 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
15066
15221
  // A connected set comes back with the connection it was drawn from, so
15067
15222
  // the plan compares the restored generation, not the one it replaced.
15068
15223
  terrainConnect: generation.terrainConnect,
15224
+ // Likewise a loop that shared another direction's motion.
15225
+ motionShare: generation.motionShare,
15069
15226
  // Likewise its settings; a generation from before they were recorded
15070
15227
  // comes back with none and is taken as current.
15071
15228
  terrainSettings: generation.terrainSettings,
@@ -16105,6 +16262,12 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
16105
16262
  };
16106
16263
  terrainConnect[side] = { sourceAssetId: link.assetId, sourceObjectId: objectId };
16107
16264
  }
16265
+ const motionLeader = spec.character?.motionLeader;
16266
+ const leaderGroup = motionLeader ? recordedAnimationGroup(lock.entries[lockKey(motionLeader.spec.styleId, motionLeader.spec.assetId)]) : void 0;
16267
+ const motionGroup = leaderGroup?.animationGroupId && leaderGroup.animationName ? { animationGroupId: leaderGroup.animationGroupId, animationName: leaderGroup.animationName } : void 0;
16268
+ if (motionLeader && !motionGroup) {
16269
+ log(` ${key}: ${motionLeader.assetId} has no animation group recorded; drawing this direction on its own`);
16270
+ }
16108
16271
  upsert(lock, key, {
16109
16272
  styleId: spec.styleId,
16110
16273
  assetId: spec.assetId,
@@ -16146,6 +16309,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
16146
16309
  referenceSha256: spec.character.outfit.reference.sha256
16147
16310
  } : null,
16148
16311
  terrainConnect: spec.terrainConnect ? terrainConnect : void 0,
16312
+ motionShare: motionLeader ? { sourceAssetId: motionLeader.assetId, animationGroupId: motionGroup?.animationGroupId ?? null } : void 0,
16149
16313
  // The terrain knobs specHash leaves out, so the plan can tell a
16150
16314
  // changed one; undefined (no key) for every other generator.
16151
16315
  terrainSettings: terrainSettings(spec),
@@ -16196,6 +16360,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
16196
16360
  ...anchorEntry?.objectId ? { styleObjectId: anchorEntry.objectId } : {},
16197
16361
  ...parentOutputs ? { parentOutputs } : {},
16198
16362
  ...spec.terrainConnect ? { terrainLinks } : {},
16363
+ ...motionGroup ? { motionGroup } : {},
16199
16364
  ...replacedMetadata ? { replacedMetadata } : {},
16200
16365
  ...lastGenerationMetadata ? { lastGenerationMetadata } : {},
16201
16366
  ...previousMetadata ? { previousMetadata } : {},
@@ -18448,7 +18613,8 @@ var SkeletonAnimationRequestSchema = import_zod12.z.object({
18448
18613
  /** The motion, sent as PixelLab's `action`. */
18449
18614
  prompt: import_zod12.z.string().min(1),
18450
18615
  direction: CharacterDirectionSchema,
18451
- description: import_zod12.z.string().min(1).optional(),
18616
+ /** What the character looks like; PixelLab refuses the request without it. */
18617
+ description: import_zod12.z.string().min(1),
18452
18618
  skeletonTemplate: import_zod12.z.string().min(1).optional(),
18453
18619
  /** Manifest-relative JSON path inside the project. */
18454
18620
  keypointsFile: import_zod12.z.string().min(1),
@@ -18498,7 +18664,7 @@ async function createSkeletonAnimation(manifestPath, request) {
18498
18664
  from: request.from,
18499
18665
  keypointsFile: relative.split(import_node_path33.default.sep).join("/"),
18500
18666
  direction: request.direction,
18501
- ...request.description ? { description: request.description } : {},
18667
+ description: request.description,
18502
18668
  ...request.skeletonTemplate ? { skeletonTemplate: request.skeletonTemplate } : {}
18503
18669
  }
18504
18670
  }
@@ -21387,6 +21553,7 @@ async function workspaceStatus(ws, dir) {
21387
21553
  readVfxConversation,
21388
21554
  readVfxRecipe,
21389
21555
  recipeDigest,
21556
+ recordedAnimationGroup,
21390
21557
  referencedHashes,
21391
21558
  refineAsset,
21392
21559
  refineFrameSet,