pixelkiln 0.70.1 → 0.72.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.
@@ -326,3 +326,18 @@ form.edit .pal { margin-top:4px; }
326
326
  .poses svg.pose { width:112px; height:112px; background:var(--panel-deep); border:1px solid var(--line); image-rendering:pixelated; }
327
327
  .poses figcaption { font:10.5px/1 var(--mono); color:var(--dim); }
328
328
  textarea.mono { font-family:var(--mono); font-size:11.5px; }
329
+ .pose-editor { display:grid; grid-template-columns:minmax(200px, 320px) 1fr; gap:12px; align-items:start; margin-top:10px; }
330
+ .pose-stage svg.stage { width:100%; aspect-ratio:1; background:var(--panel-deep); border:1px solid var(--line); image-rendering:pixelated; touch-action:none; display:block; }
331
+ .pose-stage svg.stage:focus { outline:1px solid var(--accent); }
332
+ .pose-stage .joint { cursor:grab; }
333
+ .pose-stage .joint:active { cursor:grabbing; }
334
+ .pose-side { display:grid; gap:8px; align-content:start; }
335
+ .pose-tools { display:flex; flex-wrap:wrap; gap:6px; }
336
+ .pose-tools button { font:11.5px/1 var(--mono); padding:6px 8px; }
337
+ .pose-status { font:11.5px/1.45 var(--mono); color:var(--dim); }
338
+ .pose-strip { grid-column:1 / -1; }
339
+ .pose-strip button { flex:none; padding:3px; display:grid; gap:3px; justify-items:center; }
340
+ .pose-strip button.on { border-color:var(--accent); }
341
+ .pose-strip svg.pose { width:64px; height:64px; }
342
+ .pose-strip span { font:10.5px/1 var(--mono); color:var(--dim); }
343
+ @media (max-width: 640px) { .pose-editor { grid-template-columns:1fr; } }
@@ -363,13 +363,22 @@
363
363
  prompt.placeholder = 'What the loop does, e.g. "a slow idle sway".';
364
364
  prompt.required = true;
365
365
  const mode = el("select");
366
- if (isCharacter) mode.append(new Option("template", "template"));
366
+ if (isCharacter) {
367
+ mode.append(
368
+ new Option("skeleton-v3 (a template, posed by the skeleton model; steadiest)", "skeleton-v3"),
369
+ new Option("template (a template, redrawn; 1 generation)", "template")
370
+ );
371
+ }
367
372
  mode.append(new Option("v3 (text-described)", "v3"), new Option("pro (sequential, high quality)", "pro"));
368
- mode.value = "v3";
373
+ mode.value = isCharacter ? "skeleton-v3" : "v3";
369
374
  const template = el("input");
370
375
  template.type = "text";
371
376
  template.placeholder = "e.g. walk, breathing-idle";
372
- const templateField = field("template id", template, "A PixelLab template; costs 1 generation regardless of frame count.");
377
+ const templateHint = {
378
+ "skeleton-v3": "A PixelLab template posed onto the character by the skeleton model, which moves it instead of redrawing it: steadier identity and colours. 2 to 4 generations and 3 to 5 minutes per direction; beta, Tier 1 plans and up.",
379
+ template: "A PixelLab template, redrawn frame by frame; 1 generation regardless of frame count."
380
+ };
381
+ const templateField = field("template id", template, templateHint.template);
373
382
  const frames = el("input");
374
383
  frames.type = "number";
375
384
  frames.min = "4";
@@ -403,9 +412,11 @@
403
412
  form.append(row2);
404
413
  if (subject) form.append(field("subject override", subject));
405
414
  const syncMode = () => {
406
- const isTemplate = isCharacter && mode.value === "template";
415
+ const isTemplate = isCharacter && (mode.value === "template" || mode.value === "skeleton-v3");
407
416
  templateField.style.display = isTemplate ? "" : "none";
417
+ if (isTemplate) templateField.querySelector("small").textContent = templateHint[mode.value];
408
418
  template.required = isTemplate;
419
+ prompt.required = !isTemplate;
409
420
  row2.style.display = isTemplate ? "none" : "";
410
421
  };
411
422
  if (isCharacter) {
@@ -433,8 +444,9 @@
433
444
  msg.textContent = "saving…";
434
445
  const animation = { of: item.assetId };
435
446
  if (directionField) animation.direction = directionField.select.value;
436
- if (isCharacter && mode.value === "template" && template.value.trim()) {
447
+ if (isCharacter && (mode.value === "template" || mode.value === "skeleton-v3") && template.value.trim()) {
437
448
  animation.template = template.value.trim();
449
+ if (mode.value === "skeleton-v3") animation.mode = "skeleton-v3";
438
450
  } else {
439
451
  animation.mode = mode.value;
440
452
  if (frames.value.trim() !== "") animation.frames = Number(frames.value);
@@ -2107,6 +2119,291 @@
2107
2119
  return row2;
2108
2120
  }
2109
2121
 
2122
+ // src/gallery/client/src/pose-editor.ts
2123
+ var MIN_FRAMES = 3;
2124
+ var MAX_FRAMES = 15;
2125
+ var HISTORY = 100;
2126
+ var NUDGE = 1 / 128;
2127
+ var PLAY_MS = 125;
2128
+ var clone = (set) => JSON.parse(JSON.stringify(set));
2129
+ var clamp = (v) => Math.min(1, Math.max(0, Math.round(v * 1e3) / 1e3));
2130
+ var mirrorLabel = (label) => label.startsWith("RIGHT ") ? "LEFT " + label.slice(6) : label.startsWith("LEFT ") ? "RIGHT " + label.slice(5) : label;
2131
+ function poseEditor(initial, imageUrl, onChange) {
2132
+ let set = clone(initial);
2133
+ let index = set.frames.length ? 1 : 0;
2134
+ let selected = null;
2135
+ let playing = null;
2136
+ const past = [];
2137
+ const future = [];
2138
+ const root = el("div", "pose-editor");
2139
+ const stageWrap = el("div", "pose-stage");
2140
+ const side = el("div", "pose-side");
2141
+ const status = el("div", "pose-status");
2142
+ const strip = el("div", "poses pose-strip");
2143
+ const tools = el("div", "pose-tools");
2144
+ side.append(tools, status);
2145
+ root.append(stageWrap, side, strip);
2146
+ const poseAt = (i) => i === 0 ? set.firstFrameKeypoints : set.frames[i - 1];
2147
+ const count = () => set.frames.length + 1;
2148
+ const name = (i) => i === 0 ? "start" : "frame " + i;
2149
+ const remember = (before) => {
2150
+ past.push(before);
2151
+ if (past.length > HISTORY) past.shift();
2152
+ future.length = 0;
2153
+ };
2154
+ const commit = (before) => {
2155
+ if (JSON.stringify(set) === before) return;
2156
+ remember(before);
2157
+ onChange(clone(set));
2158
+ drawStrip();
2159
+ drawTools();
2160
+ };
2161
+ const edit = (change) => {
2162
+ const before = JSON.stringify(set);
2163
+ change();
2164
+ commit(before);
2165
+ drawStage();
2166
+ };
2167
+ let svg;
2168
+ let joints = /* @__PURE__ */ new Map();
2169
+ let bones = [];
2170
+ function drawStage() {
2171
+ stageWrap.textContent = "";
2172
+ svg = svgEl("svg", { viewBox: "0 0 1 1", class: "pose stage", tabindex: 0 });
2173
+ svg.setAttribute("aria-label", "Pose " + name(index) + ": drag joints, arrow keys nudge the selected one");
2174
+ if (imageUrl) svg.append(svgEl("image", { href: imageUrl, x: 0, y: 0, width: 1, height: 1, preserveAspectRatio: "none" }));
2175
+ if (index > 0) {
2176
+ const prev = new Map(poseAt(index - 1).map((j) => [j.label, j]));
2177
+ for (const [a, b] of SKELETON_BONES) {
2178
+ const p = prev.get(a), q = prev.get(b);
2179
+ if (p && q) svg.append(svgEl("line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, stroke: boneColor(b), "stroke-width": 0.012, opacity: 0.3, "stroke-linecap": "round" }));
2180
+ }
2181
+ }
2182
+ const at = new Map(poseAt(index).map((j) => [j.label, j]));
2183
+ joints = /* @__PURE__ */ new Map();
2184
+ bones = [];
2185
+ for (const [a, b] of SKELETON_BONES) {
2186
+ const p = at.get(a), q = at.get(b);
2187
+ if (!p || !q) continue;
2188
+ const lines = [
2189
+ svgEl("line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, stroke: "#000", "stroke-width": 0.03, "stroke-linecap": "round" }),
2190
+ svgEl("line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, stroke: boneColor(b), "stroke-width": 0.015, "stroke-linecap": "round" })
2191
+ ];
2192
+ svg.append(...lines);
2193
+ bones.push({ a, b, lines });
2194
+ }
2195
+ for (const j of poseAt(index)) {
2196
+ const on = j.label === selected;
2197
+ const ring = svgEl("circle", { cx: j.x, cy: j.y, r: on ? 0.034 : 0.026, fill: "#000" });
2198
+ const dot = svgEl("circle", { cx: j.x, cy: j.y, r: on ? 0.022 : 0.016, fill: on ? "#fff" : boneColor(j.label) });
2199
+ const hit = svgEl("circle", { cx: j.x, cy: j.y, r: 0.045, fill: "transparent", class: "joint" });
2200
+ const title = svgEl("title", {});
2201
+ title.textContent = j.label;
2202
+ hit.append(title);
2203
+ hit.addEventListener("pointerdown", (e) => startDrag(e, j.label));
2204
+ svg.append(ring, dot, hit);
2205
+ joints.set(j.label, [ring, dot, hit]);
2206
+ }
2207
+ svg.addEventListener("keydown", onKey);
2208
+ stageWrap.append(svg);
2209
+ drawStatus();
2210
+ }
2211
+ function place(label, x, y) {
2212
+ for (const c of joints.get(label) || []) {
2213
+ c.setAttribute("cx", String(x));
2214
+ c.setAttribute("cy", String(y));
2215
+ }
2216
+ for (const bone of bones) {
2217
+ for (const line of bone.lines) {
2218
+ if (bone.a === label) {
2219
+ line.setAttribute("x1", String(x));
2220
+ line.setAttribute("y1", String(y));
2221
+ }
2222
+ if (bone.b === label) {
2223
+ line.setAttribute("x2", String(x));
2224
+ line.setAttribute("y2", String(y));
2225
+ }
2226
+ }
2227
+ }
2228
+ }
2229
+ function toUnit(e) {
2230
+ const m = svg.getScreenCTM();
2231
+ if (!m) return { x: 0, y: 0 };
2232
+ const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse());
2233
+ return { x: clamp(p.x), y: clamp(p.y) };
2234
+ }
2235
+ function startDrag(e, label) {
2236
+ if (playing) return;
2237
+ e.preventDefault();
2238
+ const before = JSON.stringify(set);
2239
+ const joint = poseAt(index).find((j) => j.label === label);
2240
+ const target = e.target;
2241
+ target.setPointerCapture(e.pointerId);
2242
+ const wasSelected = selected;
2243
+ selected = label;
2244
+ if (wasSelected !== label) {
2245
+ for (const [l, [ring, dot]] of joints) {
2246
+ const on = l === label;
2247
+ ring.setAttribute("r", String(on ? 0.034 : 0.026));
2248
+ dot.setAttribute("r", String(on ? 0.022 : 0.016));
2249
+ dot.setAttribute("fill", on ? "#fff" : boneColor(l));
2250
+ }
2251
+ }
2252
+ const move = (ev) => {
2253
+ const { x, y } = toUnit(ev);
2254
+ joint.x = x;
2255
+ joint.y = y;
2256
+ place(label, x, y);
2257
+ drawStatus();
2258
+ };
2259
+ const end = () => {
2260
+ target.removeEventListener("pointermove", move);
2261
+ target.removeEventListener("pointerup", end);
2262
+ target.removeEventListener("pointercancel", end);
2263
+ commit(before);
2264
+ svg.focus({ preventScroll: true });
2265
+ };
2266
+ target.addEventListener("pointermove", move);
2267
+ target.addEventListener("pointerup", end);
2268
+ target.addEventListener("pointercancel", end);
2269
+ drawStatus();
2270
+ }
2271
+ function onKey(e) {
2272
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
2273
+ e.preventDefault();
2274
+ if (e.shiftKey) redo();
2275
+ else undo();
2276
+ return;
2277
+ }
2278
+ if (e.key === ",") {
2279
+ e.preventDefault();
2280
+ go(index - 1);
2281
+ return;
2282
+ }
2283
+ if (e.key === ".") {
2284
+ e.preventDefault();
2285
+ go(index + 1);
2286
+ return;
2287
+ }
2288
+ const step2 = (e.shiftKey ? 4 : 1) * NUDGE;
2289
+ const d = { ArrowLeft: [-step2, 0], ArrowRight: [step2, 0], ArrowUp: [0, -step2], ArrowDown: [0, step2] }[e.key];
2290
+ if (!d || !selected || playing) return;
2291
+ e.preventDefault();
2292
+ const label = selected;
2293
+ edit(() => {
2294
+ const joint = poseAt(index).find((j) => j.label === label);
2295
+ joint.x = clamp(joint.x + d[0]);
2296
+ joint.y = clamp(joint.y + d[1]);
2297
+ });
2298
+ svg.focus({ preventScroll: true });
2299
+ }
2300
+ function undo() {
2301
+ const before = past.pop();
2302
+ if (!before) return;
2303
+ future.push(JSON.stringify(set));
2304
+ set = JSON.parse(before);
2305
+ index = Math.min(index, count() - 1);
2306
+ onChange(clone(set));
2307
+ redraw();
2308
+ }
2309
+ function redo() {
2310
+ const next = future.pop();
2311
+ if (!next) return;
2312
+ past.push(JSON.stringify(set));
2313
+ set = JSON.parse(next);
2314
+ index = Math.min(index, count() - 1);
2315
+ onChange(clone(set));
2316
+ redraw();
2317
+ }
2318
+ function go(i) {
2319
+ if (i < 0 || i >= count()) return;
2320
+ index = i;
2321
+ redraw();
2322
+ svg.focus({ preventScroll: true });
2323
+ }
2324
+ const button = (label, title, onClick, disabled = false) => {
2325
+ const b = el("button", null, label);
2326
+ b.type = "button";
2327
+ b.title = title;
2328
+ b.disabled = disabled || !!playing && label !== "Stop";
2329
+ b.onclick = onClick;
2330
+ return b;
2331
+ };
2332
+ function drawTools() {
2333
+ tools.textContent = "";
2334
+ const frameOnly = index === 0;
2335
+ tools.append(
2336
+ button("◀", "Previous pose (,)", () => go(index - 1), index === 0),
2337
+ button("▶", "Next pose (.)", () => go(index + 1), index >= count() - 1),
2338
+ button(playing ? "Stop" : "Play", "Play the frames in a loop", () => togglePlay(), set.frames.length < 2 && !playing),
2339
+ button("Copy previous", "Replace this pose with the one before it", () => edit(() => {
2340
+ set.frames[index - 1] = poseAt(index - 1).map((j) => ({ ...j }));
2341
+ }), frameOnly),
2342
+ button("Mirror", "Flip this pose left to right, swapping left and right joints", () => edit(() => {
2343
+ const flipped = poseAt(index).map((j) => ({ ...j, label: mirrorLabel(j.label), x: clamp(1 - j.x) }));
2344
+ if (index === 0) set.firstFrameKeypoints = flipped;
2345
+ else set.frames[index - 1] = flipped;
2346
+ })),
2347
+ button("+ Frame", "Add a copy of this pose after it", () => edit(() => {
2348
+ set.frames.splice(index, 0, poseAt(index).map((j) => ({ ...j })));
2349
+ index += 1;
2350
+ }), set.frames.length >= MAX_FRAMES),
2351
+ button("− Frame", "Remove this frame", () => edit(() => {
2352
+ set.frames.splice(index - 1, 1);
2353
+ index = Math.min(index, count() - 1);
2354
+ }), frameOnly || set.frames.length <= MIN_FRAMES),
2355
+ button("Undo", "Undo (Ctrl+Z)", undo, !past.length),
2356
+ button("Redo", "Redo (Ctrl+Shift+Z)", redo, !future.length)
2357
+ );
2358
+ }
2359
+ function togglePlay() {
2360
+ if (playing) {
2361
+ clearInterval(playing);
2362
+ playing = null;
2363
+ redraw();
2364
+ return;
2365
+ }
2366
+ let i = index > 0 ? index : 1;
2367
+ playing = setInterval(() => {
2368
+ if (!root.isConnected) {
2369
+ clearInterval(playing);
2370
+ playing = null;
2371
+ return;
2372
+ }
2373
+ i = i >= count() - 1 ? 1 : i + 1;
2374
+ index = i;
2375
+ drawStage();
2376
+ drawStrip();
2377
+ }, PLAY_MS);
2378
+ drawTools();
2379
+ }
2380
+ function drawStatus() {
2381
+ const j = selected && poseAt(index).find((x) => x.label === selected);
2382
+ status.textContent = name(index) + " of " + set.frames.length + " frames" + (j ? " · " + j.label + " (" + j.x.toFixed(3) + ", " + j.y.toFixed(3) + ")" : " · drag a joint to move it") + (index === 0 ? " · the starting pose must match the sprite as drawn" : "");
2383
+ }
2384
+ function drawStrip() {
2385
+ strip.textContent = "";
2386
+ for (let i = 0; i < count(); i++) {
2387
+ const cell = el("button", i === index ? "on" : null);
2388
+ cell.type = "button";
2389
+ cell.title = name(i);
2390
+ cell.append(poseSvg(poseAt(i), imageUrl, i > 0), el("span", null, i === 0 ? "start" : String(i)));
2391
+ const target = i;
2392
+ cell.onclick = () => {
2393
+ if (!playing) go(target);
2394
+ };
2395
+ strip.append(cell);
2396
+ }
2397
+ }
2398
+ function redraw() {
2399
+ drawStage();
2400
+ drawStrip();
2401
+ drawTools();
2402
+ }
2403
+ redraw();
2404
+ return root;
2405
+ }
2406
+
2110
2407
  // src/gallery/client/src/skeleton.ts
2111
2408
  var SKELETON_BONES = [
2112
2409
  ["NOSE", "NECK"],
@@ -2260,7 +2557,7 @@
2260
2557
  const preview = el("div");
2261
2558
  const row2 = el("div", "row");
2262
2559
  row2.append(field("poses from", source), field("keypoints file", file, "Inside the project. The manifest records this path."));
2263
- form.append(row2, estimateRow, field("poses (JSON)", json, "Move joints frame by frame; x and y are fractions of the sprite."), overwriteField, status, preview);
2560
+ form.append(row2, estimateRow, field("poses (JSON)", json, "Drag joints in the editor below, or edit the JSON; x and y are fractions of the sprite."), overwriteField, status, preview);
2264
2561
  const imageUrl = displayUrl(item);
2265
2562
  let set = null;
2266
2563
  const showPoses = () => {
@@ -2286,7 +2583,10 @@
2286
2583
  set = null;
2287
2584
  return;
2288
2585
  }
2289
- preview.append(poseStrip(set, imageUrl));
2586
+ preview.append(poseEditor(set, imageUrl, (next) => {
2587
+ set = next;
2588
+ json.value = JSON.stringify(next, null, 2);
2589
+ }));
2290
2590
  };
2291
2591
  const sync = () => {
2292
2592
  const pasting = source.value !== "file";
@@ -2373,6 +2673,51 @@
2373
2673
  setTimeout(() => id.focus(), 0);
2374
2674
  return form;
2375
2675
  }
2676
+ function skeletonPoseForm(item, parentUrl) {
2677
+ const form = el("form", "edit");
2678
+ form.append(el("h3", null, "Edit poses of " + item.assetId));
2679
+ let draft = item.skeleton.set;
2680
+ let dirty = false;
2681
+ const msg = el("span", "msg");
2682
+ const save = el("button", "primary", "Save poses");
2683
+ save.type = "submit";
2684
+ save.disabled = true;
2685
+ form.append(poseEditor(draft, parentUrl, (next) => {
2686
+ draft = next;
2687
+ dirty = true;
2688
+ save.disabled = false;
2689
+ msg.className = "msg";
2690
+ msg.textContent = "unsaved";
2691
+ }));
2692
+ form.append(el("small", "state-dim", "Saving rewrites " + item.skeleton.keypointsFile + "; the animation is then stale until it is generated again."));
2693
+ const cancel = el("button", null, "Cancel");
2694
+ cancel.type = "button";
2695
+ cancel.onclick = () => {
2696
+ if (dirty && !confirm("Discard the pose changes?")) return;
2697
+ ui.editing = null;
2698
+ renderDrawer();
2699
+ };
2700
+ const actions = el("div", "actions");
2701
+ actions.append(save, cancel, msg);
2702
+ form.append(actions);
2703
+ form.onsubmit = async (e) => {
2704
+ e.preventDefault();
2705
+ save.disabled = true;
2706
+ msg.className = "msg";
2707
+ msg.textContent = "saving…";
2708
+ const body = { action: "update-skeleton-keypoints", styleId: item.styleId, assetId: item.assetId, expectedSha256: item.skeleton.sha256, set: draft };
2709
+ if (item.project) body.project = item.project;
2710
+ try {
2711
+ S.snap = await postEdit(body);
2712
+ ui.editing = null;
2713
+ ui.notice = { id: item.id, text: "Poses saved to " + item.skeleton.keypointsFile + ". " + nextStep().replace("it", "the animation") };
2714
+ render();
2715
+ } catch (err) {
2716
+ showSaveError(msg, save, err);
2717
+ }
2718
+ };
2719
+ return form;
2720
+ }
2376
2721
 
2377
2722
  // src/gallery/client/src/drawer.ts
2378
2723
  function row(dl, label, value, opts = {}) {
@@ -2737,8 +3082,23 @@
2737
3082
  row(dl, "keypoints", item.skeleton.keypointsFile, { mono: true });
2738
3083
  if (item.skeleton.set) {
2739
3084
  const parent = S.snap.items.find((i) => i.key === item.revisionParentKey && i.project === item.project);
2740
- s.append(poseStrip(item.skeleton.set, displayUrl(parent)));
2741
- s.append(el("small", "state-dim", "The starting pose must match the sprite; each frame is drawn over a dimmed copy. `pixelkiln skeleton-preview " + item.assetId + "` writes the same sheet as a PNG."));
3085
+ const posesKey = "poses:" + item.id;
3086
+ if (ui.editing === posesKey) {
3087
+ s.append(skeletonPoseForm(item, displayUrl(parent)));
3088
+ } else {
3089
+ s.append(poseStrip(item.skeleton.set, displayUrl(parent)));
3090
+ s.append(el("small", "state-dim", "The starting pose must match the sprite; each frame is drawn over a dimmed copy. `pixelkiln skeleton-preview " + item.assetId + "` writes the same sheet as a PNG."));
3091
+ if (canEdit && item.skeleton.sha256) {
3092
+ const b = el("button", "add", "Edit poses");
3093
+ b.type = "button";
3094
+ b.onclick = () => {
3095
+ ui.editing = posesKey;
3096
+ ui.notice = null;
3097
+ renderDrawer();
3098
+ };
3099
+ s.append(b);
3100
+ }
3101
+ }
2742
3102
  } else {
2743
3103
  row(dl, "poses", el("span", "state-warn", "the keypoints file does not exist yet"));
2744
3104
  }
package/dist/index.cjs CHANGED
@@ -449,7 +449,7 @@ var CharacterProportionsSchema = import_zod.z.union([
449
449
  hipWidth: import_zod.z.number().min(0.5).max(2).optional()
450
450
  }).strict()
451
451
  ]);
452
- var CharacterAnimationModeSchema = import_zod.z.enum(["template", "v3", "pro"]);
452
+ var CharacterAnimationModeSchema = import_zod.z.enum(["template", "skeleton-v3", "v3", "pro"]);
453
453
  var UiPieceSchema = import_zod.z.discriminatedUnion("kind", [
454
454
  import_zod.z.object({
455
455
  id: import_zod.z.string().min(1),
@@ -563,7 +563,11 @@ var CharacterAnimationSchema = import_zod.z.object({
563
563
  fps: import_zod.z.number().int().min(1).max(60).default(8),
564
564
  /** v3 only: keep the resting pose as frame 0, so `frames` generated frames land as `frames + 1` files. */
565
565
  keepFirstFrame: import_zod.z.boolean().default(true),
566
- /** `template` when a template is named, otherwise `v3`; `pro` for the sequential high-quality engine. */
566
+ /**
567
+ * `template` when a template is named, otherwise `v3`; `skeleton-v3`
568
+ * poses a template with PixelLab's skeleton model instead; `pro` for the
569
+ * sequential high-quality engine.
570
+ */
567
571
  mode: CharacterAnimationModeSchema.optional(),
568
572
  /**
569
573
  * v3 only: a manifest-relative image of the pose to start from, instead
@@ -600,7 +604,7 @@ var CharacterAnimationSchema = import_zod.z.object({
600
604
  if (animation[key] !== void 0 && mode2 !== "v3") {
601
605
  context.addIssue({
602
606
  code: import_zod.z.ZodIssueCode.custom,
603
- message: `${key} is for v3 loops; a ${mode2} loop ${mode2 === "template" ? "starts from the character's rotation and follows its template" : "takes neither"}`,
607
+ 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"}`,
604
608
  path: [key]
605
609
  });
606
610
  }
@@ -610,10 +614,10 @@ var CharacterAnimationSchema = import_zod.z.object({
610
614
  context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: `${key} overrides apply to template loops only`, path: [key] });
611
615
  }
612
616
  }
613
- if (animation.template && animation.mode && animation.mode !== "template") {
617
+ if (animation.template && animation.mode && animation.mode !== "template" && animation.mode !== "skeleton-v3") {
614
618
  context.addIssue({
615
619
  code: import_zod.z.ZodIssueCode.custom,
616
- message: `a template animation cannot use mode "${animation.mode}"`,
620
+ message: `a template animation cannot use mode "${animation.mode}"; use "template" or "skeleton-v3"`,
617
621
  path: ["mode"]
618
622
  });
619
623
  }
@@ -624,8 +628,8 @@ var CharacterAnimationSchema = import_zod.z.object({
624
628
  path: ["frames"]
625
629
  });
626
630
  }
627
- if (!animation.template && animation.mode === "template") {
628
- context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: "template mode needs a template", path: ["template"] });
631
+ if (!animation.template && (animation.mode === "template" || animation.mode === "skeleton-v3")) {
632
+ context.addIssue({ code: import_zod.z.ZodIssueCode.custom, message: `${animation.mode} mode needs a template`, path: ["template"] });
629
633
  }
630
634
  });
631
635
  var RevisionDitheringSchema = import_zod.z.enum(["none", "2x2", "4x4", "8x8"]);
@@ -2559,12 +2563,20 @@ var PixelLabClient = class {
2559
2563
  }
2560
2564
  /**
2561
2565
  * One animation of one character. Template mode costs a generation per
2562
- * direction and fixes the frame count; v3 draws `frameCount` frames from
2563
- * the action text; pro is the sequential engine. One job per direction.
2566
+ * direction and fixes the frame count; skeleton-v3 poses the same template
2567
+ * with the skeleton video model (2-4 generations per direction); v3 draws
2568
+ * `frameCount` frames from the action text; pro is the sequential engine.
2569
+ * One job per direction.
2570
+ *
2571
+ * Every mode but skeleton-v3 goes to `/animate-character`, the path whose
2572
+ * shapes were measured live. skeleton-v3 is documented only on
2573
+ * `/characters/animations`, which takes the same request and answers with
2574
+ * the same job list (plus an `animation_group_id`).
2564
2575
  */
2565
2576
  async animateCharacter(args) {
2566
2577
  const encode = (image) => ({ type: "base64", base64: image.base64, format: image.format });
2567
- const raw = await this.request("/animate-character", {
2578
+ const endpoint = args.mode === "skeleton-v3" ? "/characters/animations" : "/animate-character";
2579
+ const raw = await this.request(endpoint, {
2568
2580
  method: "POST",
2569
2581
  body: JSON.stringify({
2570
2582
  character_id: args.characterId,
@@ -2588,7 +2600,7 @@ var PixelLabClient = class {
2588
2600
  ...args.seed != null ? { seed: args.seed } : {}
2589
2601
  })
2590
2602
  });
2591
- return validateResponse(AnimateSubmitSchema, raw, "animate-character");
2603
+ return validateResponse(AnimateSubmitSchema, raw, endpoint.slice(1));
2592
2604
  }
2593
2605
  /**
2594
2606
  * A full-body sprite in, a bust portrait out (or the reverse, unused
@@ -3881,6 +3893,7 @@ function characterCost(spec) {
3881
3893
  if (character.kind === "animation") {
3882
3894
  const animation = character.animation;
3883
3895
  if (animation.mode === "template") return 1;
3896
+ if (animation.mode === "skeleton-v3") return 4;
3884
3897
  if (animation.mode === "v3") return Math.max(1, Math.ceil(px * animation.frames / 65536));
3885
3898
  return generationCost(spec.width, spec.height, "1dir");
3886
3899
  }
@@ -4473,7 +4486,7 @@ var PixelLabProvider = class _PixelLabProvider {
4473
4486
  `${spec.styleId}/${spec.assetId} animates "${character.animation.direction}", but its character has ${character.directions} directions: ${allowed.join(", ")}`
4474
4487
  );
4475
4488
  }
4476
- if (character.animation.mode !== "template" && !spec.prompt.trim()) {
4489
+ if (character.animation.mode !== "template" && character.animation.mode !== "skeleton-v3" && !spec.prompt.trim()) {
4477
4490
  throw new Error(`${spec.styleId}/${spec.assetId} needs a prompt describing the motion, or a template`);
4478
4491
  }
4479
4492
  }
@@ -9401,8 +9414,8 @@ async function resolveObjectProShape(assetId, asset, style, kind, files) {
9401
9414
  );
9402
9415
  }
9403
9416
  }
9404
- if (asset.animation.mode === "template") {
9405
- throw new Error(`assets.${assetId}: objectPro animations have no "template" mode; use "v3" or "pro"`);
9417
+ if (asset.animation.mode === "template" || asset.animation.mode === "skeleton-v3") {
9418
+ throw new Error(`assets.${assetId}: objectPro animations have no "${asset.animation.mode}" mode; use "v3" or "pro"`);
9406
9419
  }
9407
9420
  const pose = async (rel, what) => {
9408
9421
  const image = await files.load(rel, what);
@@ -15947,6 +15960,15 @@ var SkeletonAnimationRequestSchema = import_zod10.z.object({
15947
15960
  /** Replace an existing `keypointsFile` with `set`. */
15948
15961
  overwrite: import_zod10.z.boolean().optional()
15949
15962
  }).strict();
15963
+ var SkeletonKeypointsUpdateSchema = import_zod10.z.object({
15964
+ action: import_zod10.z.literal("update-skeleton-keypoints"),
15965
+ project: import_zod10.z.string().min(1).optional(),
15966
+ styleId: import_zod10.z.string().min(1),
15967
+ assetId: import_zod10.z.string().min(1),
15968
+ /** The file's SHA-256 when the page loaded it; anything else on disk is a concurrent edit. */
15969
+ expectedSha256: import_zod10.z.string().regex(/^[0-9a-f]{64}$/),
15970
+ set: SkeletonSetSchema
15971
+ }).strict();
15950
15972
  async function createSkeletonAnimation(manifestPath, request) {
15951
15973
  const root = import_node_path29.default.dirname(import_node_path29.default.resolve(manifestPath));
15952
15974
  const file = import_node_path29.default.resolve(root, request.keypointsFile);
@@ -15992,6 +16014,26 @@ async function createSkeletonAnimation(manifestPath, request) {
15992
16014
  throw error;
15993
16015
  }
15994
16016
  }
16017
+ async function updateSkeletonKeypoints(ctx, request) {
16018
+ const key = lockKey(request.styleId, request.assetId);
16019
+ const spec = ctx.specs.find((candidate) => lockKey(candidate.styleId, candidate.assetId) === key);
16020
+ if (!spec) throw new ManifestEditError(`"${key}" is not declared by the manifest`);
16021
+ const file = spec.revision?.mode === "animate-skeleton" ? spec.revision.keypointsFile : void 0;
16022
+ if (!file) throw new ManifestEditError(`${key} is not an animate-skeleton revision`);
16023
+ const relative = import_node_path29.default.relative(ctx.loaded.root, file);
16024
+ if (relative.startsWith("..") || import_node_path29.default.isAbsolute(relative)) {
16025
+ throw new ManifestEditError(`${key}'s keypoints file is outside the project; edit it by hand`);
16026
+ }
16027
+ const current = (0, import_node_fs25.existsSync)(file) ? sha256(await (0, import_promises22.readFile)(file)) : null;
16028
+ if (current !== request.expectedSha256) {
16029
+ throw Object.assign(
16030
+ new Error(`${relative.split(import_node_path29.default.sep).join("/")} changed on disk since this page loaded it; press Refresh and edit again`),
16031
+ { status: 409 }
16032
+ );
16033
+ }
16034
+ await (0, import_promises22.writeFile)(file, JSON.stringify(request.set, null, 2) + "\n");
16035
+ return relative.split(import_node_path29.default.sep).join("/");
16036
+ }
15995
16037
 
15996
16038
  // src/gallery/edit.ts
15997
16039
  var Base64 = import_zod11.z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
@@ -16068,6 +16110,18 @@ function createGalleryEditHandler(opts) {
16068
16110
  }
16069
16111
  return opts.reload();
16070
16112
  }
16113
+ const poses = SkeletonKeypointsUpdateSchema.safeParse(body);
16114
+ if (poses.success) {
16115
+ if (!opts.loadProject) throw new ManifestEditError("pose edits are not available in this gallery");
16116
+ const written = await updateSkeletonKeypoints(await opts.loadProject(poses.data.project), poses.data);
16117
+ log(` poses saved: ${written} (${poses.data.styleId}/${poses.data.assetId} is stale until generated again)`);
16118
+ return opts.reload();
16119
+ }
16120
+ if (body?.action === "update-skeleton-keypoints") {
16121
+ throw new ManifestEditError(
16122
+ "invalid pose edit: " + poses.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
16123
+ );
16124
+ }
16071
16125
  const skeleton = SkeletonAnimationRequestSchema.safeParse(body);
16072
16126
  if (skeleton.success) {
16073
16127
  let manifestPath2;
@@ -16441,7 +16495,8 @@ async function buildGallerySnapshot(opts) {
16441
16495
  mirrorOfKey: spec.mirror ? lockKey(spec.styleId, spec.mirror.sourceAssetId) : entry?.mirror ? lockKey(spec.styleId, entry.mirror.sourceAssetId) : null,
16442
16496
  skeleton: spec.revision?.mode === "animate-skeleton" && spec.revision.keypointsFile ? {
16443
16497
  keypointsFile: portableOutputPath(spec.revision.keypointsFile, root),
16444
- set: spec.revision.skeleton ?? null
16498
+ set: spec.revision.skeleton ?? null,
16499
+ sha256: spec.revision.keypointsSha256 ?? null
16445
16500
  } : null,
16446
16501
  character: describeCharacter(spec, entry),
16447
16502
  outputs,