pixelkiln 0.69.0 → 0.70.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.
@@ -321,3 +321,8 @@ form.edit .pal { margin-top:4px; }
321
321
  .drawer { animation: slide .16s ease-out; }
322
322
  @keyframes slide { from { transform: translateX(16px); opacity:.6; } to { transform:none; opacity:1; } }
323
323
  }
324
+ .poses { display:flex; gap:6px; overflow-x:auto; padding:10px 0 4px; }
325
+ .poses figure { margin:0; flex:none; display:grid; gap:3px; justify-items:center; }
326
+ .poses svg.pose { width:112px; height:112px; background:var(--panel-deep); border:1px solid var(--line); image-rendering:pixelated; }
327
+ .poses figcaption { font:10.5px/1 var(--mono); color:var(--dim); }
328
+ textarea.mono { font-family:var(--mono); font-size:11.5px; }
@@ -1677,6 +1677,208 @@ function newRevisionForm(item) {
1677
1677
  return form;
1678
1678
  }
1679
1679
 
1680
+ // ---- skeleton animation ----------------------------------------------------
1681
+
1682
+ /** PixelLab's 18-joint body, as src/skeleton.ts draws it: right orange, left blue. */
1683
+ const SKELETON_BONES = [
1684
+ ['NOSE', 'NECK'],
1685
+ ['NECK', 'RIGHT SHOULDER'], ['RIGHT SHOULDER', 'RIGHT ELBOW'], ['RIGHT ELBOW', 'RIGHT ARM'],
1686
+ ['NECK', 'LEFT SHOULDER'], ['LEFT SHOULDER', 'LEFT ELBOW'], ['LEFT ELBOW', 'LEFT ARM'],
1687
+ ['NECK', 'RIGHT HIP'], ['RIGHT HIP', 'RIGHT KNEE'], ['RIGHT KNEE', 'RIGHT LEG'],
1688
+ ['NECK', 'LEFT HIP'], ['LEFT HIP', 'LEFT KNEE'], ['LEFT KNEE', 'LEFT LEG'],
1689
+ ['NOSE', 'RIGHT EYE'], ['RIGHT EYE', 'RIGHT EAR'], ['NOSE', 'LEFT EYE'], ['LEFT EYE', 'LEFT EAR'],
1690
+ ];
1691
+ const SKELETON_LABELS = [...new Set(SKELETON_BONES.flat())];
1692
+ const boneColor = (label) => label.startsWith('RIGHT') ? '#ff8c28' : label.startsWith('LEFT') ? '#3c96ff' : '#f0f0f0';
1693
+ const SVG_NS = 'http://www.w3.org/2000/svg';
1694
+ const svgEl = (tag, attrs) => {
1695
+ const n = document.createElementNS(SVG_NS, tag);
1696
+ for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
1697
+ return n;
1698
+ };
1699
+
1700
+ /** One pose over the sprite, in a unit square: coordinates are fractions, as PixelLab's are. */
1701
+ function poseSvg(pose, imageUrl, dim) {
1702
+ const svg = svgEl('svg', { viewBox: '0 0 1 1', class: 'pose' });
1703
+ if (imageUrl) svg.append(svgEl('image', { href: imageUrl, x: 0, y: 0, width: 1, height: 1, preserveAspectRatio: 'none', opacity: dim ? 0.45 : 1 }));
1704
+ const at = new Map(pose.map((j) => [j.label, j]));
1705
+ for (const outline of [true, false]) {
1706
+ for (const [a, b] of SKELETON_BONES) {
1707
+ const p = at.get(a), q = at.get(b);
1708
+ if (!p || !q) continue;
1709
+ svg.append(svgEl('line', { x1: p.x, y1: p.y, x2: q.x, y2: q.y, stroke: outline ? '#000' : boneColor(b), 'stroke-width': outline ? 0.03 : 0.015, 'stroke-linecap': 'round' }));
1710
+ }
1711
+ for (const j of pose) {
1712
+ const title = outline ? null : svgEl('title', {});
1713
+ if (title) title.textContent = j.label + ' (' + j.x.toFixed(2) + ', ' + j.y.toFixed(2) + ')';
1714
+ const dot = svgEl('circle', { cx: j.x, cy: j.y, r: outline ? 0.025 : 0.016, fill: outline ? '#000' : boneColor(j.label) });
1715
+ if (title) dot.append(title);
1716
+ svg.append(dot);
1717
+ }
1718
+ }
1719
+ return svg;
1720
+ }
1721
+
1722
+ /** Every pose of a keypoints set: the starting pose over the full sprite, then each frame. */
1723
+ function poseStrip(set, imageUrl) {
1724
+ const strip = el('div', 'poses');
1725
+ [set.firstFrameKeypoints, ...set.frames].forEach((pose, i) => {
1726
+ const cell = el('figure');
1727
+ cell.append(poseSvg(pose, imageUrl, i > 0), el('figcaption', null, i === 0 ? 'start' : 'frame ' + i));
1728
+ strip.append(cell);
1729
+ });
1730
+ return strip;
1731
+ }
1732
+
1733
+ /** The image a record shows: its hand edit when it has one, else its first output. */
1734
+ const displayUrl = (item) => (item && ((item.edit && item.edit.url) || (item.outputs[0] && item.outputs[0].url))) || null;
1735
+
1736
+ /** What is wrong with pasted poses, or null; the server validates again before writing. */
1737
+ function skeletonProblem(set) {
1738
+ if (!set || typeof set !== 'object' || !Array.isArray(set.firstFrameKeypoints) || !Array.isArray(set.frames)) {
1739
+ return 'expected { "firstFrameKeypoints": [...], "frames": [[...], ...] }';
1740
+ }
1741
+ if (set.frames.length < 3 || set.frames.length > 15) return 'frames must hold 3 to 15 poses, not ' + set.frames.length;
1742
+ for (const [i, pose] of [set.firstFrameKeypoints, ...set.frames].entries()) {
1743
+ const where = i === 0 ? 'the starting pose' : 'frame ' + i;
1744
+ if (!Array.isArray(pose) || pose.length !== 18) return where + ' needs all 18 joints';
1745
+ const seen = new Set();
1746
+ for (const j of pose) {
1747
+ if (!SKELETON_LABELS.includes(j && j.label)) return where + ': "' + (j && j.label) + '" is not one of PixelLab\'s 18 joints';
1748
+ if (seen.has(j.label)) return where + ': "' + j.label + '" appears twice';
1749
+ seen.add(j.label);
1750
+ if (!(j.x >= 0 && j.x <= 1 && j.y >= 0 && j.y <= 1)) return where + ': ' + j.label + ' must sit inside the image (x and y 0 to 1)';
1751
+ }
1752
+ }
1753
+ return null;
1754
+ }
1755
+
1756
+ /** Why PixelLab's estimate would refuse this sprite, or null. */
1757
+ const estimateProblem = (item) => item.width === item.height && [16, 32, 64, 128, 256].includes(item.width)
1758
+ ? null
1759
+ : 'estimates need a square 16, 32, 64, 128, or 256 px sprite; this one is ' + item.width + '×' + item.height;
1760
+
1761
+ /**
1762
+ * A new `animate-skeleton` revision of `item`, a single PixelLab sprite. The
1763
+ * poses come from a keypoints file already in the project, from pasted JSON,
1764
+ * or from PixelLab's estimate of the sprite itself (a direct call the page
1765
+ * asks about first); pasted and estimated poses are written to the project
1766
+ * before the asset is added, and previewed over the sprite as they change.
1767
+ */
1768
+ function skeletonAnimationForm(item) {
1769
+ const pr = projectOf(item);
1770
+ const form = el('form', 'edit');
1771
+ form.append(el('h3', null, 'New skeleton animation of ' + item.assetId));
1772
+ const id = el('input'); id.type = 'text'; id.placeholder = 'asset-id'; id.required = true; id.autocomplete = 'off';
1773
+ id.pattern = '[^\\/\\\\]+';
1774
+ const prompt = el('input'); prompt.type = 'text'; prompt.placeholder = 'e.g. swinging the sword downward'; prompt.required = true;
1775
+ const direction = el('select');
1776
+ for (const d of ['south', 'south-east', 'east', 'north-east', 'north', 'north-west', 'west', 'south-west']) direction.append(new Option(d, d));
1777
+ const description = el('input'); description.type = 'text'; description.placeholder = 'optional: colours, clothing, held items';
1778
+ const row1 = el('div', 'row');
1779
+ row1.append(field('new asset id', id), field('facing', direction, 'Which way the sprite faces.'));
1780
+ form.append(row1, field('motion', prompt, 'A short label for the movement; sent as PixelLab\'s action.'), field('looks like', description));
1781
+
1782
+ const source = el('select');
1783
+ source.append(new Option('estimate from this sprite', 'estimate'), new Option('paste poses', 'paste'), new Option('use a file in the project', 'file'));
1784
+ const file = el('input'); file.type = 'text'; file.required = true;
1785
+ let fileTouched = false;
1786
+ file.oninput = () => { fileTouched = true; };
1787
+ id.oninput = () => { if (!fileTouched) file.value = id.value.trim() ? 'poses/' + id.value.trim() + '.json' : ''; };
1788
+ const overwrite = el('input'); overwrite.type = 'checkbox';
1789
+ const overwriteField = el('label', 'field check'); overwriteField.append(overwrite, el('span', null, 'replace the file if it exists'));
1790
+ const json = el('textarea'); json.className = 'mono'; json.rows = 8; json.spellcheck = false;
1791
+ json.placeholder = '{ "firstFrameKeypoints": [18 joints], "frames": [[18 joints], ...3 to 15] }';
1792
+ const estimate = el('button', null, 'Estimate poses'); estimate.type = 'button';
1793
+ const estimateNote = el('small', 'state-dim');
1794
+ const problem = estimateProblem(item);
1795
+ estimate.disabled = !!problem;
1796
+ estimateNote.textContent = problem || 'One PixelLab estimate-skeleton call on your account, outside the gallery budget (PixelLab documents about $0.02). The estimate becomes the starting pose and four frames to edit.';
1797
+ const estimateRow = el('div', 'actions'); estimateRow.append(estimate, estimateNote);
1798
+ const status = el('div', 'msg');
1799
+ const preview = el('div');
1800
+ const row2 = el('div', 'row');
1801
+ row2.append(field('poses from', source), field('keypoints file', file, 'Inside the project. The manifest records this path.'));
1802
+ form.append(row2, estimateRow, field('poses (JSON)', json, 'Move joints frame by frame; x and y are fractions of the sprite.'), overwriteField, status, preview);
1803
+
1804
+ const imageUrl = displayUrl(item);
1805
+ let set = null;
1806
+ const showPoses = () => {
1807
+ preview.textContent = '';
1808
+ status.className = 'msg'; status.textContent = '';
1809
+ if (source.value === 'file' || !json.value.trim()) { set = null; return; }
1810
+ try { set = JSON.parse(json.value); } catch (e) { set = null; status.className = 'msg bad'; status.textContent = 'not valid JSON: ' + e.message; return; }
1811
+ const bad = skeletonProblem(set);
1812
+ if (bad) { status.className = 'msg bad'; status.textContent = bad; set = null; return; }
1813
+ preview.append(poseStrip(set, imageUrl));
1814
+ };
1815
+ const sync = () => {
1816
+ const pasting = source.value !== 'file';
1817
+ estimateRow.hidden = source.value !== 'estimate';
1818
+ json.parentElement.hidden = !pasting;
1819
+ overwriteField.hidden = !pasting;
1820
+ showPoses();
1821
+ };
1822
+ source.onchange = sync;
1823
+ json.oninput = showPoses;
1824
+ estimate.onclick = async () => {
1825
+ if (!confirm('Ask PixelLab to estimate a skeleton for ' + item.assetId + '? This is one paid call on your account, outside the gallery budget.')) return;
1826
+ estimate.disabled = true; status.className = 'msg'; status.textContent = 'estimating…';
1827
+ try {
1828
+ const body = { styleId: item.styleId, assetId: item.assetId };
1829
+ if (item.project) body.project = item.project;
1830
+ const res = await fetch('/api/skeleton/estimate', {
1831
+ method: 'POST',
1832
+ headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
1833
+ body: JSON.stringify(body),
1834
+ });
1835
+ if (!res.ok) throw new Error(await res.text());
1836
+ const out = await res.json();
1837
+ json.value = JSON.stringify(out.set, null, 2);
1838
+ showPoses();
1839
+ status.className = 'msg'; status.textContent = 'Estimated from ' + out.source + '. Move the frames into the motion, then save.';
1840
+ } catch (err) {
1841
+ status.className = 'msg bad'; status.textContent = err.message;
1842
+ } finally {
1843
+ estimate.disabled = false;
1844
+ }
1845
+ };
1846
+
1847
+ const actions = el('div', 'actions');
1848
+ const save = el('button', 'primary', 'Create skeleton animation'); save.type = 'submit';
1849
+ const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; renderDrawer(); };
1850
+ const msg = el('span', 'msg');
1851
+ actions.append(save, cancel, msg);
1852
+ form.append(actions);
1853
+ form.onsubmit = async (e) => {
1854
+ e.preventDefault();
1855
+ if (source.value !== 'file' && !set) { msg.className = 'msg bad'; msg.textContent = 'Add poses first: estimate them or paste a keypoints file.'; return; }
1856
+ save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving…';
1857
+ const body = {
1858
+ action: 'create-skeleton-animation', expectedSha256: pr.manifestSha256, styleId: item.styleId, from: item.assetId,
1859
+ assetId: id.value.trim(), prompt: prompt.value.trim(), direction: direction.value, keypointsFile: file.value.trim(),
1860
+ };
1861
+ if (description.value.trim()) body.description = description.value.trim();
1862
+ if (source.value !== 'file') { body.set = set; if (overwrite.checked) body.overwrite = true; }
1863
+ if (item.project) body.project = item.project;
1864
+ try {
1865
+ snap = await postEdit(body);
1866
+ const newId = (item.project ? item.project + ':' : '') + item.styleId + '/' + body.assetId;
1867
+ ui.editing = null;
1868
+ ui.notice = { id: newId, text: 'Added to the manifest with ' + body.keypointsFile + '. Nothing is generated until you run pixelkiln gen.' };
1869
+ render();
1870
+ if (snap.items.some((i) => i.id === newId)) openItem(newId);
1871
+ } catch (err) {
1872
+ save.disabled = false;
1873
+ msg.className = 'msg bad';
1874
+ msg.textContent = err.message + (err.status === 409 ? ' Press Refresh.' : '');
1875
+ }
1876
+ };
1877
+ sync();
1878
+ setTimeout(() => id.focus(), 0);
1879
+ return form;
1880
+ }
1881
+
1680
1882
  /**
1681
1883
  * A new pose or outfit of `item`, an existing `character`/`objectPro` base
1682
1884
  * or state — the same family, restricted to `item`'s own style. A larger
@@ -2193,6 +2395,16 @@ function renderDrawer() {
2193
2395
  if (item.revision.maskSha256) row(dl, 'mask sha256', item.revision.maskSha256.slice(0, 16) + '…', { mono: true, copy: item.revision.maskSha256 });
2194
2396
  if (item.revision.strength !== undefined) row(dl, 'strength', item.revision.strength);
2195
2397
  }
2398
+ if (item.skeleton) {
2399
+ row(dl, 'keypoints', item.skeleton.keypointsFile, { mono: true });
2400
+ if (item.skeleton.set) {
2401
+ const parent = snap.items.find((i) => i.key === item.revisionParentKey && i.project === item.project);
2402
+ s.append(poseStrip(item.skeleton.set, displayUrl(parent)));
2403
+ 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.'));
2404
+ } else {
2405
+ row(dl, 'poses', el('span', 'state-warn', 'the keypoints file does not exist yet'));
2406
+ }
2407
+ }
2196
2408
  body.append(s);
2197
2409
  }
2198
2410
  const children = snap.items.filter((i) => i.revisionParentKey === item.key && i.project === item.project);
@@ -2205,8 +2417,18 @@ function renderDrawer() {
2205
2417
  const revise = el('button', ui.editing === reviseKey ? null : 'add', ui.editing === reviseKey ? 'Cancel' : '+ New revision');
2206
2418
  revise.type = 'button';
2207
2419
  revise.onclick = () => { ui.editing = ui.editing === reviseKey ? null : reviseKey; ui.notice = null; renderDrawer(); };
2208
- s.append(revise);
2420
+ const buttons = el('div', 'actions'); buttons.append(revise);
2421
+ // animate-skeleton poses one PixelLab sprite; a set of directions or frames is not one.
2422
+ const skeletonKey = 'skeleton:' + item.id;
2423
+ if (item.provider === 'pixellab' && item.outputs.length === 1) {
2424
+ const animate = el('button', ui.editing === skeletonKey ? null : 'add', ui.editing === skeletonKey ? 'Cancel' : '+ Skeleton animation');
2425
+ animate.type = 'button';
2426
+ animate.onclick = () => { ui.editing = ui.editing === skeletonKey ? null : skeletonKey; ui.notice = null; renderDrawer(); };
2427
+ buttons.append(animate);
2428
+ }
2429
+ s.append(buttons);
2209
2430
  if (ui.editing === reviseKey) s.append(newRevisionForm(item));
2431
+ if (ui.editing === skeletonKey) s.append(skeletonAnimationForm(item));
2210
2432
  }
2211
2433
  body.append(s);
2212
2434
  }