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.js CHANGED
@@ -310,16 +310,30 @@ var CharacterAnimationSchema = z.object({
310
310
  * `src/loop-directions.ts`.
311
311
  */
312
312
  directions: z.array(CharacterDirectionSchema).min(1).optional(),
313
- /** Frames to generate without a template (v3). Even, 4 to 16. */
314
- frames: z.number().int().min(4).max(16).optional(),
313
+ /**
314
+ * A sentence loop (`mode: "skeleton-v3"`, no `template`) of another
315
+ * direction whose motion this one shares: the asset id of the loop that
316
+ * leads. This loop is then submitted after it, into its animation group,
317
+ * so PixelLab draws it from the same 3D motion. Written for you when
318
+ * `directions` expands (the first direction leads); set by hand only when
319
+ * declaring the loops explicitly.
320
+ */
321
+ motionFrom: z.string().min(1).optional(),
322
+ /**
323
+ * Frames to generate without a template. A v3 loop takes an even 4 to 16;
324
+ * a sentence loop (`mode: "skeleton-v3"` with no `template`) takes 3 to 15.
325
+ */
326
+ frames: z.number().int().min(3).max(16).optional(),
315
327
  /** Playback rate recorded with the frames; PixelLab does not store one. */
316
328
  fps: z.number().int().min(1).max(60).default(8),
317
329
  /** v3 only: keep the resting pose as frame 0, so `frames` generated frames land as `frames + 1` files. */
318
330
  keepFirstFrame: z.boolean().default(true),
319
331
  /**
320
332
  * `template` when a template is named, otherwise `v3`; `skeleton-v3`
321
- * poses a template with PixelLab's skeleton model instead; `pro` for the
322
- * sequential high-quality engine.
333
+ * poses a template with PixelLab's skeleton model instead, or, with no
334
+ * template, animates the asset's prompt as a sentence written into one 3D
335
+ * motion (a v3 base; 3-15 `frames`); `pro` for the sequential
336
+ * high-quality engine.
323
337
  */
324
338
  mode: CharacterAnimationModeSchema.optional(),
325
339
  /**
@@ -349,15 +363,23 @@ var CharacterAnimationSchema = z.object({
349
363
  path: ["directions"]
350
364
  });
351
365
  }
352
- if (animation.frames !== void 0 && animation.frames % 2 !== 0) {
353
- context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
366
+ const sentence = animation.mode === "skeleton-v3" && !animation.template;
367
+ if (animation.frames !== void 0 && !sentence) {
368
+ if (animation.frames % 2 !== 0) {
369
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
370
+ } else if (animation.frames < 4) {
371
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be 4 to 16", path: ["frames"] });
372
+ }
373
+ }
374
+ if (animation.frames !== void 0 && sentence && animation.frames > 15) {
375
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "a skeleton-v3 loop from a sentence takes 3 to 15 frames", path: ["frames"] });
354
376
  }
355
377
  const mode2 = animation.mode ?? (animation.template ? "template" : "v3");
356
378
  for (const key of ["startFrame", "endFrame", "enhancePrompt"]) {
357
379
  if (animation[key] !== void 0 && mode2 !== "v3") {
358
380
  context.addIssue({
359
381
  code: z.ZodIssueCode.custom,
360
- 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"}`,
382
+ 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"}`,
361
383
  path: [key]
362
384
  });
363
385
  }
@@ -381,8 +403,14 @@ var CharacterAnimationSchema = z.object({
381
403
  path: ["frames"]
382
404
  });
383
405
  }
384
- if (!animation.template && (animation.mode === "template" || animation.mode === "skeleton-v3")) {
385
- context.addIssue({ code: z.ZodIssueCode.custom, message: `${animation.mode} mode needs a template`, path: ["template"] });
406
+ if (!animation.template && animation.mode === "template") {
407
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "template mode needs a template", path: ["template"] });
408
+ }
409
+ if (animation.motionFrom !== void 0 && !sentence) {
410
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "motionFrom applies to a skeleton-v3 loop from a sentence (no template)", path: ["motionFrom"] });
411
+ }
412
+ if (sentence && animation.subject !== void 0) {
413
+ 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"] });
386
414
  }
387
415
  });
388
416
  var RevisionDitheringSchema = z.enum(["none", "2x2", "4x4", "8x8"]);
@@ -1313,6 +1341,7 @@ var LockTerrainConnectSchema = z.record(
1313
1341
  TerrainSideSchema,
1314
1342
  z.object({ sourceAssetId: z.string().min(1), sourceObjectId: z.string().min(1) }).strict()
1315
1343
  );
1344
+ var LockMotionShareSchema = z.object({ sourceAssetId: z.string().min(1), animationGroupId: z.string().min(1).nullable() }).strict();
1316
1345
  var LockTerrainSettingsSchema = z.object({
1317
1346
  terrainMode: z.string().optional(),
1318
1347
  terrainShapeStyle: z.string().optional(),
@@ -1349,6 +1378,8 @@ var LockHistoryEntrySchema = z.object({
1349
1378
  postprocess: PostprocessSchema.optional(),
1350
1379
  /** A connected terrain set's other sets; see LockEntrySchema.terrainConnect. */
1351
1380
  terrainConnect: LockTerrainConnectSchema.optional(),
1381
+ /** A sentence loop that shares another direction's motion; see LockEntrySchema.motionShare. */
1382
+ motionShare: LockMotionShareSchema.optional(),
1352
1383
  /** A terrain set's settings outside specHash; see LockEntrySchema.terrainSettings. */
1353
1384
  terrainSettings: LockTerrainSettingsSchema.optional(),
1354
1385
  /** When the replacement was submitted. */
@@ -1425,6 +1456,16 @@ var LockEntrySchema = z.object({
1425
1456
  * Absent (not null) otherwise, so other entries do not grow the key.
1426
1457
  */
1427
1458
  terrainConnect: LockTerrainConnectSchema.optional(),
1459
+ /**
1460
+ * For a sentence loop of a later direction: the loop whose motion it
1461
+ * shares and the animation group of that loop's generation this was
1462
+ * drawn into when submitted. Tracked the way `terrainConnect` is, not
1463
+ * through `specHash`: the group lives in the lockfile. A regenerated leader
1464
+ * has a new group, so the plan marks this stale. `animationGroupId` is
1465
+ * null when the leader had no group recorded and this was drawn on its own.
1466
+ * Absent otherwise.
1467
+ */
1468
+ motionShare: LockMotionShareSchema.optional(),
1428
1469
  /**
1429
1470
  * For a terrain set: the settings it was submitted with that `specHash`
1430
1471
  * leaves out (see LockTerrainSettingsSchema). The plan marks the entry
@@ -1890,6 +1931,8 @@ var CharacterSubmitSchema = z3.object({
1890
1931
  var AnimateSubmitSchema = z3.object({
1891
1932
  background_job_ids: z3.array(z3.string()),
1892
1933
  directions: z3.array(z3.string()),
1934
+ /** skeleton-v3 only: the group the directions were drawn into. */
1935
+ animation_group_id: z3.string().nullable().optional(),
1893
1936
  status: z3.string().default("processing"),
1894
1937
  usage: UsageSchema
1895
1938
  }).passthrough();
@@ -2610,7 +2653,13 @@ var PixelLabClient = class {
2610
2653
  * Every mode but skeleton-v3 goes to `/animate-character`, the path whose
2611
2654
  * shapes were measured live. skeleton-v3 is documented only on
2612
2655
  * `/characters/animations`, which takes the same request and answers with
2613
- * the same job list (plus an `animation_group_id`).
2656
+ * the same job list (plus an `animation_group_id`). With no `template` it
2657
+ * animates `actionDescription` as a sentence, 3 to 15 `frameCount` frames
2658
+ * written into one 3D motion; measured on a v3 character (8 frames, 64px:
2659
+ * 3 generations, frames back at 96x96). `animationGroupId` (skeleton-v3
2660
+ * only) adds the directions to an existing animation of the character,
2661
+ * drawn from its motion; the group's own name must be sent again as
2662
+ * `animationName`, or the group appears renamed.
2614
2663
  */
2615
2664
  async animateCharacter(args) {
2616
2665
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
@@ -2622,10 +2671,12 @@ var PixelLabClient = class {
2622
2671
  animation_name: args.animationName,
2623
2672
  mode: args.mode,
2624
2673
  directions: args.directions,
2674
+ ...args.mode === "skeleton-v3" && args.animationGroupId ? { animation_group_id: args.animationGroupId } : {},
2625
2675
  ...args.template ? { template_animation_id: args.template } : {},
2626
2676
  ...args.actionDescription ? { action_description: args.actionDescription } : {},
2627
2677
  ...args.description ? { description: args.description } : {},
2628
- ...args.mode === "v3" && args.frameCount ? { frame_count: args.frameCount } : {},
2678
+ // A template fixes its own frame count; v3 and a skeleton-v3 sentence take one.
2679
+ ...(args.mode === "v3" || args.mode === "skeleton-v3" && !args.template) && args.frameCount ? { frame_count: args.frameCount } : {},
2629
2680
  ...args.mode === "v3" && args.keepFirstFrame === false ? { keep_first_frame: false } : {},
2630
2681
  ...args.mode === "v3" && args.startFrame ? { custom_start_frame: encode(args.startFrame) } : {},
2631
2682
  ...args.mode === "v3" && args.endFrame ? { end_frame: encode(args.endFrame) } : {},
@@ -3993,6 +4044,10 @@ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.provider
3993
4044
  template: spec.character.template,
3994
4045
  parent: spec.character.parentAssetId,
3995
4046
  parentSha256: spec.character.parentSha256 ?? null,
4047
+ // Which loop leads, not its group: the group is in the lockfile,
4048
+ // and the plan compares it there. Absent for every loop that
4049
+ // shares nothing, so no existing hash moves.
4050
+ motionFrom: spec.character.motionLeader?.assetId,
3996
4051
  state: spec.character.state,
3997
4052
  // Pose images enter as their hashes, never their paths.
3998
4053
  animation: spec.character.animation ? {
@@ -5037,7 +5092,13 @@ function decodeAnimationJob(jobId) {
5037
5092
  try {
5038
5093
  const value = JSON.parse(Buffer.from(jobId.slice("character-animation:".length), "base64url").toString("utf8"));
5039
5094
  if (typeof value?.characterId === "string" && typeof value.name === "string" && typeof value.direction === "string") {
5040
- return { characterId: value.characterId, name: value.name, direction: value.direction, jobIds: Array.isArray(value.jobIds) ? value.jobIds : [] };
5095
+ return {
5096
+ characterId: value.characterId,
5097
+ name: value.name,
5098
+ direction: value.direction,
5099
+ jobIds: Array.isArray(value.jobIds) ? value.jobIds : [],
5100
+ ...typeof value.groupId === "string" && value.groupId ? { groupId: value.groupId } : {}
5101
+ };
5041
5102
  }
5042
5103
  } catch {
5043
5104
  }
@@ -5277,6 +5338,16 @@ var PixelLabProvider = class _PixelLabProvider {
5277
5338
  if (skeletonTemplate && !CHARACTER_TEMPLATES.includes(skeletonTemplate)) {
5278
5339
  throw new Error(`PixelLab animate-skeleton template "${skeletonTemplate}" is not one of ${CHARACTER_TEMPLATES.join(", ")}`);
5279
5340
  }
5341
+ if (!spec.revision.description?.trim()) {
5342
+ throw new Error(
5343
+ `${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`
5344
+ );
5345
+ }
5346
+ if (!spec.prompt.trim()) {
5347
+ throw new Error(
5348
+ `${spec.styleId}/${spec.assetId}: an animate-skeleton revision needs a prompt, a short motion label (for example "swinging the sword")`
5349
+ );
5350
+ }
5280
5351
  }
5281
5352
  if (spec.revision?.mode === "animate" || spec.revision?.mode === "animate-pixminimax") {
5282
5353
  const { sourceWidth: width, sourceHeight: height, mode: mode2, frames, lastFrameWidth, lastFrameHeight } = spec.revision;
@@ -5680,12 +5751,18 @@ var PixelLabProvider = class _PixelLabProvider {
5680
5751
  `${spec.styleId}/${spec.assetId} animates "${character.animation.direction}", but its character has ${character.directions} directions: ${allowed.join(", ")}`
5681
5752
  );
5682
5753
  }
5683
- if (character.animation.mode !== "template" && character.animation.mode !== "skeleton-v3" && !spec.prompt.trim()) {
5684
- throw new Error(`${spec.styleId}/${spec.assetId} needs a prompt describing the motion, or a template`);
5754
+ const sentence = character.animation.mode === "skeleton-v3" && !character.animation.template;
5755
+ if (character.animation.mode !== "template" && (character.animation.mode !== "skeleton-v3" || sentence) && !spec.prompt.trim()) {
5756
+ throw new Error(
5757
+ `${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")' : "")
5758
+ );
5759
+ }
5760
+ if (sentence && (character.animation.frames < 3 || character.animation.frames > 15)) {
5761
+ 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`);
5685
5762
  }
5686
5763
  if (character.animation.mode === "skeleton-v3" && character.mode === "standard") {
5687
5764
  throw new Error(
5688
- `${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`
5765
+ 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`
5689
5766
  );
5690
5767
  }
5691
5768
  }
@@ -5859,11 +5936,18 @@ var PixelLabProvider = class _PixelLabProvider {
5859
5936
  };
5860
5937
  }
5861
5938
  const animation = character.animation;
5862
- const name = characterAnimationName(spec);
5939
+ const ownName = characterAnimationName(spec);
5940
+ const shared = context?.motionGroup;
5941
+ const name = shared?.animationName ?? ownName;
5863
5942
  const replaced = context?.replacedMetadata?.character ?? {};
5864
5943
  const existing = await this.client.getCharacter(parentId);
5944
+ if (shared && !existing.animations.some((group) => group.animation_group_id === shared.animationGroupId && group.directions.some((d) => d.direction !== animation.direction))) {
5945
+ throw new Error(
5946
+ `${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`
5947
+ );
5948
+ }
5865
5949
  for (const group of existing.animations) {
5866
- 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}/`))));
5950
+ 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}/`))));
5867
5951
  if (!ours || !group.animation_group_id) continue;
5868
5952
  if (group.directions.some((d) => d.direction === animation.direction)) {
5869
5953
  await this.client.deleteCharacterAnimations(parentId, { animationGroupId: group.animation_group_id }, animation.direction);
@@ -5878,6 +5962,7 @@ var PixelLabProvider = class _PixelLabProvider {
5878
5962
  frameCount: animation.frames,
5879
5963
  keepFirstFrame: animation.keepFirstFrame,
5880
5964
  directions: [animation.direction],
5965
+ ...shared ? { animationGroupId: shared.animationGroupId } : {},
5881
5966
  seed: spec.seed,
5882
5967
  textGuidanceScale: character.textGuidanceScale,
5883
5968
  isometric: character.isometric,
@@ -5890,10 +5975,11 @@ var PixelLabProvider = class _PixelLabProvider {
5890
5975
  enhancePrompt: animation.enhancePrompt,
5891
5976
  paletteSwatchBase64: spec.palette.length ? paletteSwatch(spec.palette).toString("base64") : void 0
5892
5977
  });
5893
- const job = { characterId: parentId, name, direction: animation.direction, jobIds: res.background_job_ids };
5978
+ const groupId = res.animation_group_id ?? shared?.animationGroupId;
5979
+ const job = { characterId: parentId, name, direction: animation.direction, jobIds: res.background_job_ids, ...groupId ? { groupId } : {} };
5894
5980
  return {
5895
5981
  jobId: encodeAnimationJob(job),
5896
- metadata: { character: { kind: "animation", characterId: parentId, animationName: name, direction: animation.direction, mode: animation.mode, backgroundJobIds: res.background_job_ids } }
5982
+ metadata: { character: { kind: "animation", characterId: parentId, animationName: name, direction: animation.direction, mode: animation.mode, backgroundJobIds: res.background_job_ids, ...groupId ? { animationGroupId: groupId } : {} } }
5897
5983
  };
5898
5984
  }
5899
5985
  /**
@@ -6108,13 +6194,13 @@ var PixelLabProvider = class _PixelLabProvider {
6108
6194
  const animationId = typeof done.animation_id === "string" ? done.animation_id : null;
6109
6195
  if (Array.isArray(frames) && frames.length && frames.every((f) => typeof f === "string")) {
6110
6196
  const character2 = await this.client.getCharacter(job.characterId);
6111
- const group = findAnimation(character2, job.name, job.direction, animationId);
6112
- return review(frames, animationId, group?.groupId ?? null, billedFromUsage(status.usage));
6197
+ const group = findAnimation(character2, job.name, job.direction, animationId, job.groupId);
6198
+ return review(frames, animationId, group?.groupId ?? job.groupId ?? null, billedFromUsage(status.usage));
6113
6199
  }
6114
6200
  }
6115
6201
  const recorded = context?.metadata?.character?.animationId ?? null;
6116
6202
  const character = await this.client.getCharacter(job.characterId);
6117
- const found = findAnimation(character, job.name, job.direction, recorded);
6203
+ const found = findAnimation(character, job.name, job.direction, recorded, job.groupId);
6118
6204
  if (found) return review(found.frames, found.animationId, found.groupId, null);
6119
6205
  return cleanedUp ? { status: "failed", error: `animation job is gone upstream and no "${job.name}" ${job.direction} animation exists on the character` } : { status: "processing" };
6120
6206
  }
@@ -7548,10 +7634,11 @@ function rotationSources(character) {
7548
7634
  }
7549
7635
  return sources;
7550
7636
  }
7551
- function findAnimation(character, name, direction, animationId) {
7637
+ function findAnimation(character, name, direction, animationId, groupId) {
7552
7638
  for (const group of character.animations) {
7553
7639
  const match = group.directions.find((d) => d.direction === direction && d.frames.length > 0);
7554
7640
  if (!match) continue;
7641
+ if (groupId && group.animation_group_id !== groupId) continue;
7555
7642
  const byName = group.display_name === name || group.animation_type === name;
7556
7643
  const byId = animationId !== null && match.frames.some((url) => url.includes(`/animations/${animationId}/`));
7557
7644
  if (!byName && !byId) continue;
@@ -9785,8 +9872,16 @@ function expandLoopDirections(raw) {
9785
9872
  continue;
9786
9873
  }
9787
9874
  for (const expandedId of ids) claimed.add(expandedId);
9788
- for (const loop of loops) {
9789
- expanded[loop.id] = { ...asset, animation: { ...animation, direction: loop.direction } };
9875
+ const sentence = animation.mode === "skeleton-v3" && !animation.template;
9876
+ for (const [index, loop] of loops.entries()) {
9877
+ expanded[loop.id] = {
9878
+ ...asset,
9879
+ animation: {
9880
+ ...animation,
9881
+ direction: loop.direction,
9882
+ ...sentence && index > 0 ? { motionFrom: loops[0].id } : {}
9883
+ }
9884
+ };
9790
9885
  }
9791
9886
  for (const mirror of mirrors) {
9792
9887
  const inherited = {};
@@ -10721,7 +10816,8 @@ async function resolveSpecs(loaded, filter) {
10721
10816
  `assets.${assetId}: mirroring ${asset.mirror} gives another ${facing}-facing loop; mirror a loop facing east, west, or a diagonal`
10722
10817
  );
10723
10818
  }
10724
- resolved.character = { ...sourceSpec.character, animation: { ...sourceSpec.character.animation, direction: mirrored } };
10819
+ const { motionLeader: _motionLeader, ...flipped } = sourceSpec.character;
10820
+ resolved.character = { ...flipped, animation: { ...sourceSpec.character.animation, direction: mirrored } };
10725
10821
  } else if (sourceSpec.character) {
10726
10822
  resolved.character = { ...sourceSpec.character };
10727
10823
  }
@@ -10778,6 +10874,26 @@ async function resolveSpecs(loaded, filter) {
10778
10874
  directions: resolved.character.kind === "base" ? resolved.character.directions : parentSpec.character?.directions ?? resolved.character.directions
10779
10875
  };
10780
10876
  }
10877
+ if (resolved.character?.animation && asset.animation?.motionFrom) {
10878
+ const leaderId = asset.animation.motionFrom;
10879
+ const where = `assets.${assetId}.animation.motionFrom`;
10880
+ if (leaderId === assetId) throw new Error(`${where}: an asset cannot share its own motion`);
10881
+ const leader = await finalize(leaderId);
10882
+ const led = leader.character?.animation;
10883
+ if (leader.character?.kind !== "animation" || leader.mirror || !led || led.mode !== "skeleton-v3" || led.template) {
10884
+ throw new Error(`${where}: ${leaderId} is not a skeleton-v3 loop from a sentence`);
10885
+ }
10886
+ if (leader.character.parentAssetId !== resolved.character.parentAssetId) {
10887
+ throw new Error(`${where}: ${leaderId} animates ${leader.character.parentAssetId}, not ${resolved.character.parentAssetId}`);
10888
+ }
10889
+ if (leader.character.motionLeader) {
10890
+ throw new Error(`${where}: ${leaderId} itself shares ${leader.character.motionLeader.assetId}'s motion; point at the loop that leads`);
10891
+ }
10892
+ if (led.direction === resolved.character.animation.direction) {
10893
+ throw new Error(`${where}: ${leaderId} is drawn in the same direction; sharing a motion needs another direction`);
10894
+ }
10895
+ resolved.character = { ...resolved.character, motionLeader: { assetId: leaderId, spec: leader } };
10896
+ }
10781
10897
  if (resolved.objectPro && characterParent) {
10782
10898
  const parentSpec = await finalize(characterParent);
10783
10899
  if (!parentSpec.objectPro) {
@@ -12772,7 +12888,10 @@ import path14 from "path";
12772
12888
  async function inspectRevisionReadiness(spec, lock) {
12773
12889
  if (spec.mirror) return inspectMirror(spec, lock, /* @__PURE__ */ new Set());
12774
12890
  if (spec.character?.kind === "outfit") return inspectOutfit(spec, lock, /* @__PURE__ */ new Set());
12775
- if (spec.character?.parentSpec) return inspectCharacterParent(spec, lock, /* @__PURE__ */ new Set());
12891
+ if (spec.character?.parentSpec) {
12892
+ const parent = await inspectCharacterParent(spec, lock, /* @__PURE__ */ new Set());
12893
+ return parent.ready && spec.character.motionLeader ? inspectMotionLeader(spec, lock, /* @__PURE__ */ new Set()) : parent;
12894
+ }
12776
12895
  if (spec.character?.styleAnchor) return inspectStyleAnchor(spec, lock, /* @__PURE__ */ new Set());
12777
12896
  if (spec.objectPro?.parentSpec) return inspectObjectProParent(spec, lock, /* @__PURE__ */ new Set());
12778
12897
  if (spec.scene) return inspectScene(spec);
@@ -12807,6 +12926,17 @@ async function inspectTerrainConnect(spec, lock, seen) {
12807
12926
  }
12808
12927
  return { ready: true, reason: "connected sets are current" };
12809
12928
  }
12929
+ function recordedAnimationGroup(entry) {
12930
+ const character = entry?.providerMetadata?.[entry.provider]?.character;
12931
+ const text = (value) => typeof value === "string" && value ? value : null;
12932
+ return { animationGroupId: text(character?.animationGroupId), animationName: text(character?.animationName) };
12933
+ }
12934
+ async function inspectMotionLeader(spec, lock, seen) {
12935
+ const leader = spec.character.motionLeader;
12936
+ const label = `motion leader ${spec.styleId}/${leader.assetId}`;
12937
+ const dependency = await inspectParent(leader.spec, lock, seen);
12938
+ return dependency.ready ? { ready: true, reason: `${label} is current` } : { ready: false, reason: `${label} is not ready: ${dependency.reason}` };
12939
+ }
12810
12940
  async function inspectScene(spec) {
12811
12941
  const scene = spec.scene;
12812
12942
  const files = [{ label: "scene image", file: scene.file, sha256: scene.sha256 }];
@@ -12965,6 +13095,10 @@ async function inspectParent(spec, lock, seen) {
12965
13095
  const inputs = await inspectCharacterParent(spec, lock, nextSeen);
12966
13096
  if (!inputs.ready) return inputs;
12967
13097
  }
13098
+ if (spec.character?.motionLeader) {
13099
+ const inputs = await inspectMotionLeader(spec, lock, nextSeen);
13100
+ if (!inputs.ready) return inputs;
13101
+ }
12968
13102
  if (spec.character?.styleAnchor) {
12969
13103
  const inputs = await inspectStyleAnchor(spec, lock, nextSeen);
12970
13104
  if (!inputs.ready) return inputs;
@@ -13021,6 +13155,7 @@ function dependencies(spec) {
13021
13155
  spec.mirror?.sourceSpec,
13022
13156
  spec.revision?.sourceSpec,
13023
13157
  spec.character?.parentSpec,
13158
+ spec.character?.motionLeader?.spec,
13024
13159
  spec.character?.styleAnchor?.spec,
13025
13160
  spec.objectPro?.parentSpec,
13026
13161
  spec.terrainConnect?.lower?.spec,
@@ -13037,6 +13172,12 @@ function changedTerrainConnection(spec, entry, lock) {
13037
13172
  }
13038
13173
  return null;
13039
13174
  }
13175
+ function changedMotionLeader(spec, entry, lock) {
13176
+ const leader = spec.character?.motionLeader;
13177
+ if (!leader) return null;
13178
+ const current = recordedAnimationGroup(lock.entries[lockKey(leader.spec.styleId, leader.spec.assetId)]).animationGroupId;
13179
+ return (entry.motionShare?.animationGroupId ?? null) === current ? null : leader.assetId;
13180
+ }
13040
13181
  function changedTerrainSettings(spec, entry) {
13041
13182
  const recorded = entry.terrainSettings;
13042
13183
  const current = terrainSettings(spec);
@@ -13078,6 +13219,7 @@ async function buildPlan(specs, lock, opts = {}) {
13078
13219
  let state;
13079
13220
  let reason;
13080
13221
  const changedConnection = entry ? changedTerrainConnection(spec, entry, lock) : null;
13222
+ const changedMotion = entry ? changedMotionLeader(spec, entry, lock) : null;
13081
13223
  const changedSettings = entry ? changedTerrainSettings(spec, entry) : null;
13082
13224
  const revision = await inspectRevisionReadiness(spec, lock);
13083
13225
  if (revision && !revision.ready) {
@@ -13117,6 +13259,9 @@ async function buildPlan(specs, lock, opts = {}) {
13117
13259
  } else if (changedConnection) {
13118
13260
  state = "stale";
13119
13261
  reason = `connected set ${changedConnection} was regenerated; draw this set from its new terrain`;
13262
+ } else if (changedMotion) {
13263
+ state = "stale";
13264
+ reason = `shares motion with ${changedMotion}, which was regenerated; draw this direction into its new motion`;
13120
13265
  } else if (changedSettings) {
13121
13266
  state = "stale";
13122
13267
  reason = changedSettings;
@@ -13180,6 +13325,14 @@ async function buildPlan(specs, lock, opts = {}) {
13180
13325
  }
13181
13326
  }
13182
13327
  }
13328
+ for (const item of items) {
13329
+ const leader = item.spec.character?.motionLeader;
13330
+ if (!leader || !isActionable(item)) continue;
13331
+ const leaderEntry = lock.entries[lockKey(leader.spec.styleId, leader.spec.assetId)];
13332
+ if (leaderEntry?.status === "downloaded" && !recordedAnimationGroup(leaderEntry).animationGroupId) {
13333
+ item.reason += `; ${leader.assetId} has no animation group recorded, so this is drawn on its own and its motion will differ`;
13334
+ }
13335
+ }
13183
13336
  const actionable = items.filter(isActionable);
13184
13337
  const groupsByProvider = /* @__PURE__ */ new Map();
13185
13338
  for (const item of actionable) {
@@ -14589,6 +14742,7 @@ function retireGeneration(entry, retiredAt) {
14589
14742
  provider: entry.provider,
14590
14743
  postprocess: entry.postprocess,
14591
14744
  ...entry.terrainConnect ? { terrainConnect: entry.terrainConnect } : {},
14745
+ ...entry.motionShare ? { motionShare: entry.motionShare } : {},
14592
14746
  ...entry.terrainSettings ? { terrainSettings: entry.terrainSettings } : {},
14593
14747
  retiredAt
14594
14748
  };
@@ -14684,6 +14838,8 @@ async function revertGeneration(provider, spec, lock, lockPath, opts) {
14684
14838
  // A connected set comes back with the connection it was drawn from, so
14685
14839
  // the plan compares the restored generation, not the one it replaced.
14686
14840
  terrainConnect: generation.terrainConnect,
14841
+ // Likewise a loop that shared another direction's motion.
14842
+ motionShare: generation.motionShare,
14687
14843
  // Likewise its settings; a generation from before they were recorded
14688
14844
  // comes back with none and is taken as current.
14689
14845
  terrainSettings: generation.terrainSettings,
@@ -15723,6 +15879,12 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
15723
15879
  };
15724
15880
  terrainConnect[side] = { sourceAssetId: link.assetId, sourceObjectId: objectId };
15725
15881
  }
15882
+ const motionLeader = spec.character?.motionLeader;
15883
+ const leaderGroup = motionLeader ? recordedAnimationGroup(lock.entries[lockKey(motionLeader.spec.styleId, motionLeader.spec.assetId)]) : void 0;
15884
+ const motionGroup = leaderGroup?.animationGroupId && leaderGroup.animationName ? { animationGroupId: leaderGroup.animationGroupId, animationName: leaderGroup.animationName } : void 0;
15885
+ if (motionLeader && !motionGroup) {
15886
+ log(` ${key}: ${motionLeader.assetId} has no animation group recorded; drawing this direction on its own`);
15887
+ }
15726
15888
  upsert(lock, key, {
15727
15889
  styleId: spec.styleId,
15728
15890
  assetId: spec.assetId,
@@ -15764,6 +15926,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
15764
15926
  referenceSha256: spec.character.outfit.reference.sha256
15765
15927
  } : null,
15766
15928
  terrainConnect: spec.terrainConnect ? terrainConnect : void 0,
15929
+ motionShare: motionLeader ? { sourceAssetId: motionLeader.assetId, animationGroupId: motionGroup?.animationGroupId ?? null } : void 0,
15767
15930
  // The terrain knobs specHash leaves out, so the plan can tell a
15768
15931
  // changed one; undefined (no key) for every other generator.
15769
15932
  terrainSettings: terrainSettings(spec),
@@ -15814,6 +15977,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
15814
15977
  ...anchorEntry?.objectId ? { styleObjectId: anchorEntry.objectId } : {},
15815
15978
  ...parentOutputs ? { parentOutputs } : {},
15816
15979
  ...spec.terrainConnect ? { terrainLinks } : {},
15980
+ ...motionGroup ? { motionGroup } : {},
15817
15981
  ...replacedMetadata ? { replacedMetadata } : {},
15818
15982
  ...lastGenerationMetadata ? { lastGenerationMetadata } : {},
15819
15983
  ...previousMetadata ? { previousMetadata } : {},
@@ -18066,7 +18230,8 @@ var SkeletonAnimationRequestSchema = z12.object({
18066
18230
  /** The motion, sent as PixelLab's `action`. */
18067
18231
  prompt: z12.string().min(1),
18068
18232
  direction: CharacterDirectionSchema,
18069
- description: z12.string().min(1).optional(),
18233
+ /** What the character looks like; PixelLab refuses the request without it. */
18234
+ description: z12.string().min(1),
18070
18235
  skeletonTemplate: z12.string().min(1).optional(),
18071
18236
  /** Manifest-relative JSON path inside the project. */
18072
18237
  keypointsFile: z12.string().min(1),
@@ -18116,7 +18281,7 @@ async function createSkeletonAnimation(manifestPath, request) {
18116
18281
  from: request.from,
18117
18282
  keypointsFile: relative.split(path32.sep).join("/"),
18118
18283
  direction: request.direction,
18119
- ...request.description ? { description: request.description } : {},
18284
+ description: request.description,
18120
18285
  ...request.skeletonTemplate ? { skeletonTemplate: request.skeletonTemplate } : {}
18121
18286
  }
18122
18287
  }
@@ -21004,6 +21169,7 @@ export {
21004
21169
  readVfxConversation,
21005
21170
  readVfxRecipe,
21006
21171
  recipeDigest,
21172
+ recordedAnimationGroup,
21007
21173
  referencedHashes,
21008
21174
  refineAsset,
21009
21175
  refineFrameSet,