pixelkiln 0.78.0 → 0.79.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.
@@ -466,3 +466,12 @@ td.col-on { background:rgba(255,107,53,.08); }
466
466
  .selbar .msg { font-size:12px; color:var(--dim); }
467
467
  .selbar .msg.bad { color:var(--bad); } .selbar .msg.ok { color:var(--ok); }
468
468
  @media (max-width: 640px) { .selbar { padding:10px 12px; } }
469
+ .lg-add { width:76px; height:76px; padding:0; font:400 22px/1 var(--mono); color:var(--dim); border:1px dashed var(--line-strong); background:transparent; }
470
+ .lg-add:hover, .lg-add.on { color:var(--accent-soft); border-color:var(--accent); }
471
+ .gap-panel { margin-top:10px; border:1px solid var(--accent); padding:12px; display:grid; gap:8px; max-width:720px; }
472
+ .gap-panel h4 { margin:0; font:650 12.5px/1.2 var(--mono); color:var(--accent-soft); }
473
+ .gap-options { display:grid; gap:4px; }
474
+ .gap-options .field.check { display:flex; align-items:baseline; gap:6px; font-size:12.5px; }
475
+ .gap-price { font:11.5px/1.45 var(--mono); }
476
+ .gap-panel .actions { display:flex; gap:6px; flex-wrap:wrap; }
477
+ @media (max-width: 640px) { .lg-add { width:56px; height:56px; } }
@@ -2645,7 +2645,8 @@
2645
2645
  panel: null,
2646
2646
  anims: [],
2647
2647
  columns: /* @__PURE__ */ new Map(),
2648
- turn: null
2648
+ turn: null,
2649
+ gap: null
2649
2650
  };
2650
2651
  renderFamily();
2651
2652
  }
@@ -2938,7 +2939,20 @@
2938
2939
  const td = el("td");
2939
2940
  column(d, td);
2940
2941
  const item = cells.find((l) => l.character.direction === d);
2941
- td.append(item ? loopCell(item, parent, d) : el("span", "lg-none", "·"));
2942
+ if (item) td.append(loopCell(item, parent, d));
2943
+ else if (fillable(root, parent, cells, d)) {
2944
+ const open = F.gap && F.gap.row === k && F.gap.direction === d;
2945
+ const add = el("button", "lg-add" + (open ? " on" : ""), "+");
2946
+ add.type = "button";
2947
+ add.title = "Add " + name + " facing " + d;
2948
+ add.setAttribute("aria-label", add.title);
2949
+ add.onclick = () => {
2950
+ F.gap = open ? null : { row: k, direction: d };
2951
+ F.drawn = null;
2952
+ renderFamily();
2953
+ };
2954
+ td.append(add);
2955
+ } else td.append(el("span", "lg-none", "·"));
2942
2956
  tr.append(td);
2943
2957
  }
2944
2958
  tbody.append(tr);
@@ -2947,9 +2961,167 @@
2947
2961
  const scroller = el("div", "lg-scroll");
2948
2962
  scroller.append(table);
2949
2963
  wrap.append(scroller);
2964
+ const gap = F.gap && rows.get(F.gap.row);
2965
+ if (gap) {
2966
+ wrap.append(gapPanel(root, gap, F.gap.direction));
2967
+ } else F.gap = null;
2950
2968
  wrap.append(el("small", "state-dim", "Every loop plays on one clock. ⇋ marks a mirror, flipped locally at no cost. A dimmed cell is the parent's rotation, standing in until that loop is generated."));
2951
2969
  return wrap;
2952
2970
  }
2971
+ var MIRRORED = {
2972
+ south: "south",
2973
+ north: "north",
2974
+ east: "west",
2975
+ west: "east",
2976
+ "south-east": "south-west",
2977
+ "south-west": "south-east",
2978
+ "north-east": "north-west",
2979
+ "north-west": "north-east"
2980
+ };
2981
+ var ANIMATION_FIELDS = ["of", "template", "frames", "fps", "mode", "subject", "enhancePrompt"];
2982
+ function fillable(root, parent, cells, direction) {
2983
+ if (!EDITABLE || !projectOf(root)?.manifestSha256) return false;
2984
+ if (!turnOrder(parent).includes(direction)) return false;
2985
+ return cells.some((l) => !l.mirrorOfKey && l.declaredAs && l.asset);
2986
+ }
2987
+ function gapOptions(cells, direction) {
2988
+ const drawn = cells.filter((l) => !l.mirrorOfKey);
2989
+ const sample = drawn[0];
2990
+ const name = loopName(sample);
2991
+ const shorthand = sample.declaredAs !== sample.assetId ? sample.declaredAs : null;
2992
+ const flip = MIRRORED[direction];
2993
+ const options = [];
2994
+ if (shorthand) {
2995
+ const now = drawn.map((l) => l.character.direction);
2996
+ const next = COMPASS.filter((d) => d === direction || now.includes(d));
2997
+ const freeFlip = flip !== direction && !cells.some((l) => l.character.direction === flip);
2998
+ options.push({
2999
+ id: "draw",
3000
+ label: "Draw it",
3001
+ note: "adds " + direction + " to " + shorthand + "'s directions" + (freeFlip ? "; " + flip + " comes with it as a free mirror" : ""),
3002
+ edit: { action: "patch-asset", assetId: shorthand, patch: { loopDirections: next } }
3003
+ });
3004
+ return options;
3005
+ }
3006
+ const newId = name + "." + direction;
3007
+ const flipped = drawn.find((l) => l.character.direction === flip && flip !== direction);
3008
+ if (flipped) {
3009
+ const mirror = { mirror: flipped.declaredAs };
3010
+ if (flipped.asset?.styles) mirror.styles = flipped.asset.styles;
3011
+ options.push({ id: "mirror", label: "Mirror " + flip, note: "flips " + flipped.assetId + " locally; free", edit: { action: "add-asset", assetId: newId, asset: mirror } });
3012
+ }
3013
+ const base = sample.asset || {};
3014
+ const animation = { direction };
3015
+ for (const key of ANIMATION_FIELDS) if (base.animation && base.animation[key] !== void 0) animation[key] = base.animation[key];
3016
+ const asset = { animation };
3017
+ for (const key of ["prompt", "styles", "tags", "category"]) if (base[key] !== void 0) asset[key] = base[key];
3018
+ options.push({ id: "draw", label: "Draw it", note: "a copy of " + sample.assetId + " facing " + direction, edit: { action: "add-asset", assetId: newId, asset } });
3019
+ return options;
3020
+ }
3021
+ function gapPanel(root, cells, direction) {
3022
+ const panel = el("div", "gap-panel");
3023
+ const name = loopName(cells[0]);
3024
+ panel.append(el("h4", null, "Add " + (name.startsWith(root.assetId + ".") ? name.slice(root.assetId.length + 1) : name) + " facing " + direction));
3025
+ const options = gapOptions(cells, direction);
3026
+ let chosen = options[0];
3027
+ const list = el("div", "gap-options");
3028
+ const priceLine = el("div", "gap-price state-dim", "pricing…");
3029
+ const msg = el("div", "msg");
3030
+ for (const option of options) {
3031
+ const label = el("label", "field check");
3032
+ const radio = el("input");
3033
+ radio.type = "radio";
3034
+ radio.name = "gap-option";
3035
+ radio.checked = option === chosen;
3036
+ radio.onchange = () => {
3037
+ chosen = option;
3038
+ price();
3039
+ };
3040
+ label.append(radio, el("b", null, " " + option.label), el("span", "state-dim", " — " + option.note));
3041
+ list.append(label);
3042
+ }
3043
+ const actions = el("div", "actions");
3044
+ const add = el("button", null, "Add");
3045
+ add.type = "button";
3046
+ const addGen = el("button", "primary", "Add & generate");
3047
+ addGen.type = "button";
3048
+ const cancel = el("button", null, "Cancel");
3049
+ cancel.type = "button";
3050
+ cancel.onclick = () => {
3051
+ F.gap = null;
3052
+ F.drawn = null;
3053
+ renderFamily();
3054
+ };
3055
+ actions.append(add);
3056
+ if (GENERATION) actions.append(addGen);
3057
+ actions.append(cancel);
3058
+ panel.append(list, priceLine, actions, msg);
3059
+ const pr = projectOf(root);
3060
+ const request = () => {
3061
+ const body = { ...chosen.edit, expectedSha256: pr.manifestSha256 };
3062
+ if (root.project) body.project = root.project;
3063
+ return body;
3064
+ };
3065
+ let quote = null;
3066
+ let seq = 0;
3067
+ async function price() {
3068
+ const mine = ++seq;
3069
+ quote = null;
3070
+ priceLine.textContent = "pricing…";
3071
+ add.disabled = addGen.disabled = true;
3072
+ try {
3073
+ const res = await fetch("/api/price", {
3074
+ method: "POST",
3075
+ headers: { "Content-Type": "application/json", "X-Pixelkiln-Session": SESSION ?? "" },
3076
+ body: JSON.stringify(request())
3077
+ });
3078
+ if (mine !== seq) return;
3079
+ if (!res.ok) throw new Error(await res.text());
3080
+ quote = await res.json();
3081
+ const rows = quote.items.map((i) => i.key.split("/").slice(1).join("/") + " " + (i.cost ? fmtCost(i.costUnit, i.cost) : "free"));
3082
+ const totals = Object.entries(quote.totals).filter(([, n]) => n);
3083
+ priceLine.textContent = (rows.length ? rows.join(" · ") : "nothing new") + " — total " + (totals.map(([u, n]) => fmtCost(u, Math.round(n * 100) / 100)).join(" + ") || "free");
3084
+ const over = totals.some(([unit, n]) => {
3085
+ const provider = quote.items.find((i) => i.costUnit === unit)?.provider;
3086
+ const left = provider ? remainingBudget(provider) : null;
3087
+ return left !== null && n > left;
3088
+ });
3089
+ add.disabled = !quote.items.length;
3090
+ addGen.disabled = !quote.items.length || over;
3091
+ addGen.textContent = "Add & generate" + (totals.length ? " · " + totals.map(([u, n]) => fmtCost(u, Math.round(n * 100) / 100)).join(" + ") : "");
3092
+ if (over) addGen.title = "More than the session budget has left";
3093
+ } catch (err) {
3094
+ if (mine !== seq) return;
3095
+ priceLine.className = "gap-price msg bad";
3096
+ priceLine.textContent = err.message;
3097
+ }
3098
+ }
3099
+ const save = async (generate) => {
3100
+ if (!quote) return;
3101
+ const keys = quote.items.map((i) => i.key);
3102
+ add.disabled = addGen.disabled = true;
3103
+ msg.className = "msg";
3104
+ msg.textContent = "saving…";
3105
+ try {
3106
+ S.snap = await postEdit(request());
3107
+ if (generate) {
3108
+ await postGenerate({ keys, ...root.project ? { project: root.project } : {} });
3109
+ pollJobs();
3110
+ }
3111
+ F.gap = null;
3112
+ F.drawn = null;
3113
+ render();
3114
+ } catch (err) {
3115
+ msg.className = "msg bad";
3116
+ msg.textContent = err.status === 409 ? "The manifest changed on disk; refresh and try again." : err.message;
3117
+ add.disabled = addGen.disabled = false;
3118
+ }
3119
+ };
3120
+ add.onclick = () => save(false);
3121
+ addGen.onclick = () => save(true);
3122
+ price();
3123
+ return panel;
3124
+ }
2953
3125
  function column(direction, node) {
2954
3126
  if (!F.columns.has(direction)) F.columns.set(direction, []);
2955
3127
  F.columns.get(direction).push(node);
package/dist/index.cjs CHANGED
@@ -15409,7 +15409,12 @@ var AssetPatchSchema = import_zod8.z.object({
15409
15409
  width: import_zod8.z.number().int().nullable().optional(),
15410
15410
  height: import_zod8.z.number().int().nullable().optional(),
15411
15411
  size: import_zod8.z.number().int().nullable().optional(),
15412
- tags: import_zod8.z.array(import_zod8.z.string()).optional()
15412
+ tags: import_zod8.z.array(import_zod8.z.string()).optional(),
15413
+ /**
15414
+ * A loop shorthand's `animation.directions`, replaced whole: each named
15415
+ * direction is drawn, and each one's flip comes free as a mirror.
15416
+ */
15417
+ loopDirections: import_zod8.z.array(CharacterDirectionSchema).min(1).optional()
15413
15418
  }).strict();
15414
15419
  var NewStateSchema = import_zod8.z.object({
15415
15420
  /** Asset id of the parent this states; must already exist. */
@@ -15758,6 +15763,13 @@ function applyEdit(raw, edit) {
15758
15763
  if (patch.tags.length) asset.tags = patch.tags;
15759
15764
  else delete asset.tags;
15760
15765
  }
15766
+ if (patch.loopDirections) {
15767
+ const animation = asset.animation;
15768
+ if (!animation || !Array.isArray(animation.directions)) {
15769
+ throw new ManifestEditError(`asset "${edit.assetId}" is not a loop declared with animation.directions`);
15770
+ }
15771
+ asset.animation = { ...animation, directions: patch.loopDirections };
15772
+ }
15761
15773
  }
15762
15774
  async function withCandidate(manifestPath, edit, use, unchanged) {
15763
15775
  const absolute = import_node_path27.default.resolve(manifestPath);