pixelkiln 0.77.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.
- package/dist/cli.js +18 -1
- package/dist/cli.js.map +1 -1
- package/dist/client/gallery.css +35 -0
- package/dist/client/gallery.js +460 -9
- package/dist/index.cjs +18 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +18 -1
- package/dist/index.js.map +1 -1
- package/docs/CHARACTERS.md +12 -0
- package/docs/CLI.md +20 -0
- package/package.json +1 -1
package/dist/client/gallery.css
CHANGED
|
@@ -440,3 +440,38 @@ td.col-on { background:rgba(255,107,53,.08); }
|
|
|
440
440
|
.frames-bar .count { font:11.5px/1 var(--mono); color:var(--dim); min-width:44px; }
|
|
441
441
|
.frames-bar select { font:11.5px/1 var(--mono); padding:5px 6px; background:var(--panel-deep); border:1px solid var(--line); }
|
|
442
442
|
.frames-bar .chip { font-size:12px; color:var(--dim); display:inline-flex; align-items:center; gap:4px; }
|
|
443
|
+
|
|
444
|
+
/* ---- families on the grid, selection ---- */
|
|
445
|
+
.fam-group { margin:0 0 18px; }
|
|
446
|
+
.fam-group + .fam-group { border-top:1px dashed var(--line); padding-top:14px; }
|
|
447
|
+
.fam-head { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin:0 0 10px; }
|
|
448
|
+
.fam-head > b { font:650 13px/1.3 var(--mono); }
|
|
449
|
+
.fam-tally { display:inline-flex; flex-wrap:wrap; gap:10px; color:var(--dim); font-size:12px; }
|
|
450
|
+
.fam-tally .st { display:inline-flex; align-items:center; gap:5px; font-family:var(--mono); font-size:11.5px; }
|
|
451
|
+
.fam-head .tools { margin-left:auto; display:flex; gap:6px; flex-wrap:wrap; }
|
|
452
|
+
.fam-head .tools button { font-size:12.5px; padding:5px 10px; }
|
|
453
|
+
#select.on { border-color:var(--accent); color:var(--accent-soft); }
|
|
454
|
+
.card .sel { position:absolute; top:6px; left:6px; width:20px; height:20px; display:grid; place-items:center;
|
|
455
|
+
font:700 12px/1 var(--mono); color:transparent; background:rgba(16,15,12,.75); border:1px solid var(--line-strong); }
|
|
456
|
+
.card.selected .sel { color:var(--bg); background:var(--accent); border-color:var(--accent); }
|
|
457
|
+
.card.selected { outline:2px solid var(--accent); outline-offset:-2px; }
|
|
458
|
+
.selbar { position:sticky; bottom:0; z-index:12; display:flex; flex-wrap:wrap; align-items:center; gap:10px 14px;
|
|
459
|
+
padding:10px 22px; background:var(--panel); border-top:1px solid var(--line-strong); box-shadow:0 -6px 18px rgba(0,0,0,.35); }
|
|
460
|
+
.selbar[hidden] { display:none; }
|
|
461
|
+
.selbar > b { font:650 12.5px/1 var(--mono); }
|
|
462
|
+
.selbar .acts { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
|
|
463
|
+
.selbar .acts button { font-size:12.5px; padding:5px 10px; }
|
|
464
|
+
.selbar .tagbox { display:inline-flex; gap:4px; }
|
|
465
|
+
.selbar .tagbox input { width:120px; padding:5px 8px; background:var(--panel-deep); border:1px solid var(--line); }
|
|
466
|
+
.selbar .msg { font-size:12px; color:var(--dim); }
|
|
467
|
+
.selbar .msg.bad { color:var(--bad); } .selbar .msg.ok { color:var(--ok); }
|
|
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; } }
|
package/dist/client/gallery.js
CHANGED
|
@@ -44,7 +44,9 @@
|
|
|
44
44
|
backdrop: "checker",
|
|
45
45
|
notify: false,
|
|
46
46
|
fps: null,
|
|
47
|
-
onion: false
|
|
47
|
+
onion: false,
|
|
48
|
+
selecting: false,
|
|
49
|
+
selected: /* @__PURE__ */ new Set()
|
|
48
50
|
};
|
|
49
51
|
var PREFS_KEY = "pixelkiln.gallery.prefs";
|
|
50
52
|
var PREF_NAMES = ["playLoops", "backdrop", "notify", "onion"];
|
|
@@ -1012,6 +1014,180 @@
|
|
|
1012
1014
|
if (!document.hidden) wake();
|
|
1013
1015
|
});
|
|
1014
1016
|
|
|
1017
|
+
// src/gallery/client/src/selection.ts
|
|
1018
|
+
var selectedItems = () => S.snap.items.filter((i) => ui.selected.has(i.id));
|
|
1019
|
+
var regenerable = (i) => i.declared && i.currentSpecHash !== null && i.state === "ok";
|
|
1020
|
+
function setSelecting(on) {
|
|
1021
|
+
ui.selecting = on;
|
|
1022
|
+
if (!on) ui.selected.clear();
|
|
1023
|
+
$("select").textContent = on ? "Done selecting" : "Select";
|
|
1024
|
+
$("select").classList.toggle("on", on);
|
|
1025
|
+
render();
|
|
1026
|
+
}
|
|
1027
|
+
function toggleSelected(id) {
|
|
1028
|
+
if (!ui.selecting) {
|
|
1029
|
+
ui.selecting = true;
|
|
1030
|
+
$("select").textContent = "Done selecting";
|
|
1031
|
+
$("select").classList.add("on");
|
|
1032
|
+
}
|
|
1033
|
+
if (ui.selected.has(id)) ui.selected.delete(id);
|
|
1034
|
+
else ui.selected.add(id);
|
|
1035
|
+
const c = document.querySelector('.card[data-key="' + CSS.escape(id) + '"]');
|
|
1036
|
+
if (c) {
|
|
1037
|
+
c.classList.toggle("selected", ui.selected.has(id));
|
|
1038
|
+
c.setAttribute("aria-pressed", String(ui.selected.has(id)));
|
|
1039
|
+
}
|
|
1040
|
+
if (!document.querySelector(".card .sel")) render();
|
|
1041
|
+
else {
|
|
1042
|
+
refreshSelectAll();
|
|
1043
|
+
renderSelectionBar();
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
function selectAllButton(items) {
|
|
1047
|
+
const b = el("button", "select-all");
|
|
1048
|
+
b.type = "button";
|
|
1049
|
+
const ids = items.map((i) => i.id);
|
|
1050
|
+
b.__ids = ids;
|
|
1051
|
+
const label = () => {
|
|
1052
|
+
b.textContent = ids.every((id) => ui.selected.has(id)) ? "Clear " + ids.length : "Select all " + ids.length;
|
|
1053
|
+
};
|
|
1054
|
+
b.__label = label;
|
|
1055
|
+
label();
|
|
1056
|
+
b.onclick = () => {
|
|
1057
|
+
const all = ids.every((id) => ui.selected.has(id));
|
|
1058
|
+
for (const id of ids) {
|
|
1059
|
+
if (all) ui.selected.delete(id);
|
|
1060
|
+
else ui.selected.add(id);
|
|
1061
|
+
}
|
|
1062
|
+
for (const id of ids) {
|
|
1063
|
+
const c = document.querySelector('.card[data-key="' + CSS.escape(id) + '"]');
|
|
1064
|
+
if (c) {
|
|
1065
|
+
c.classList.toggle("selected", ui.selected.has(id));
|
|
1066
|
+
c.setAttribute("aria-pressed", String(ui.selected.has(id)));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
refreshSelectAll();
|
|
1070
|
+
renderSelectionBar();
|
|
1071
|
+
};
|
|
1072
|
+
return b;
|
|
1073
|
+
}
|
|
1074
|
+
function refreshSelectAll() {
|
|
1075
|
+
for (const b of document.querySelectorAll(".select-all")) b.__label?.();
|
|
1076
|
+
}
|
|
1077
|
+
function renderSelectionBar() {
|
|
1078
|
+
const bar = $("selbar");
|
|
1079
|
+
bar.textContent = "";
|
|
1080
|
+
for (const id of [...ui.selected]) if (!S.snap.items.some((i) => i.id === id)) ui.selected.delete(id);
|
|
1081
|
+
bar.hidden = !ui.selecting;
|
|
1082
|
+
if (!ui.selecting) return;
|
|
1083
|
+
const items = selectedItems();
|
|
1084
|
+
const projects = new Set(items.map((i) => i.project));
|
|
1085
|
+
const oneProject = projects.size <= 1;
|
|
1086
|
+
const project = items.length ? items[0].project : null;
|
|
1087
|
+
bar.append(el("b", null, items.length ? items.length + " selected" : "Click cards to select them"));
|
|
1088
|
+
const acts = el("div", "acts");
|
|
1089
|
+
const add = (label, run, opts = {}) => {
|
|
1090
|
+
const b = el("button", opts.primary ? "primary" : null, label);
|
|
1091
|
+
b.type = "button";
|
|
1092
|
+
b.disabled = Boolean(opts.disabled);
|
|
1093
|
+
if (opts.title) b.title = opts.title;
|
|
1094
|
+
b.onclick = run;
|
|
1095
|
+
acts.append(b);
|
|
1096
|
+
return b;
|
|
1097
|
+
};
|
|
1098
|
+
const crossProject = "Select within one project: a job and an edit each belong to one";
|
|
1099
|
+
if (GENERATION) {
|
|
1100
|
+
const todo = items.filter(actionable);
|
|
1101
|
+
if (todo.length) add("Generate " + todo.length + " · " + priceOf(todo), () => generateDialog(todo, { project }), { primary: true, disabled: !oneProject, title: oneProject ? "One job: parents first, then everything drawn from them" : crossProject });
|
|
1102
|
+
const redo = items.filter(regenerable);
|
|
1103
|
+
if (redo.length) add("Regenerate " + redo.length + " · " + priceOf(redo), () => generateDialog(redo, { project, force: true }), { disabled: !oneProject, title: oneProject ? "Replace these with new generations; each old one stays in its history" : crossProject });
|
|
1104
|
+
}
|
|
1105
|
+
const shown = items.filter((i) => i.outputs.some((o) => o.url));
|
|
1106
|
+
if (shown.length >= 2) {
|
|
1107
|
+
add("Compare" + (shown.length > 4 ? " first 4" : ""), () => {
|
|
1108
|
+
ui.compare = shown.slice(0, 4).map((i) => i.id);
|
|
1109
|
+
openCompare();
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
if (EDITABLE && items.length) {
|
|
1113
|
+
const taggable = items.filter((i) => i.declaredAs && projectOf(i)?.manifestSha256);
|
|
1114
|
+
const tag = el("input");
|
|
1115
|
+
tag.type = "text";
|
|
1116
|
+
tag.placeholder = "tag";
|
|
1117
|
+
tag.setAttribute("aria-label", "Tag to add or remove");
|
|
1118
|
+
tag.pattern = "[^,]+";
|
|
1119
|
+
const addTag = el("button", null, "Add tag");
|
|
1120
|
+
addTag.type = "button";
|
|
1121
|
+
const dropTag = el("button", null, "Remove tag");
|
|
1122
|
+
dropTag.type = "button";
|
|
1123
|
+
addTag.disabled = dropTag.disabled = !taggable.length || !oneProject;
|
|
1124
|
+
addTag.title = dropTag.title = !oneProject ? crossProject : taggable.length < items.length ? "Undeclared records have no manifest entry to tag" : "One manifest write for every selected asset";
|
|
1125
|
+
const run = (adding) => retag(taggable, tag.value.trim(), adding, tag);
|
|
1126
|
+
addTag.onclick = () => run(true);
|
|
1127
|
+
dropTag.onclick = () => run(false);
|
|
1128
|
+
tag.onkeydown = (e) => {
|
|
1129
|
+
if (e.key === "Enter") {
|
|
1130
|
+
e.preventDefault();
|
|
1131
|
+
run(true);
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
const box = el("span", "tagbox");
|
|
1135
|
+
box.append(tag, addTag, dropTag);
|
|
1136
|
+
acts.append(box);
|
|
1137
|
+
}
|
|
1138
|
+
add("Clear", () => {
|
|
1139
|
+
ui.selected.clear();
|
|
1140
|
+
render();
|
|
1141
|
+
}, { disabled: !items.length });
|
|
1142
|
+
add("Done", () => setSelecting(false));
|
|
1143
|
+
bar.append(acts);
|
|
1144
|
+
const msg = el("span", "msg");
|
|
1145
|
+
msg.id = "selmsg";
|
|
1146
|
+
bar.append(msg);
|
|
1147
|
+
}
|
|
1148
|
+
async function retag(items, tag, adding, input) {
|
|
1149
|
+
const msg = $("selmsg");
|
|
1150
|
+
if (!tag || tag.includes(",")) {
|
|
1151
|
+
msg.className = "msg bad";
|
|
1152
|
+
msg.textContent = "Type one tag (no commas).";
|
|
1153
|
+
input.focus();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1157
|
+
for (const i of items) {
|
|
1158
|
+
if (byId.has(i.declaredAs)) continue;
|
|
1159
|
+
const now = i.asset && i.asset.tags || [];
|
|
1160
|
+
const next = adding ? now.includes(tag) ? now : [...now, tag] : now.filter((t) => t !== tag);
|
|
1161
|
+
if (next.length !== now.length) byId.set(i.declaredAs, next);
|
|
1162
|
+
}
|
|
1163
|
+
if (!byId.size) {
|
|
1164
|
+
msg.className = "msg";
|
|
1165
|
+
msg.textContent = adding ? "They all have it already." : "None of them has it.";
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const pr = projectOf(items[0]);
|
|
1169
|
+
const body = {
|
|
1170
|
+
action: "batch",
|
|
1171
|
+
expectedSha256: pr.manifestSha256,
|
|
1172
|
+
edits: [...byId].map(([assetId, tags]) => ({ action: "patch-asset", assetId, patch: { tags } }))
|
|
1173
|
+
};
|
|
1174
|
+
if (items[0].project) body.project = items[0].project;
|
|
1175
|
+
msg.className = "msg";
|
|
1176
|
+
msg.textContent = "saving…";
|
|
1177
|
+
try {
|
|
1178
|
+
S.snap = await postEdit(body);
|
|
1179
|
+
render();
|
|
1180
|
+
const after = $("selmsg");
|
|
1181
|
+
if (after) {
|
|
1182
|
+
after.className = "msg ok";
|
|
1183
|
+
after.textContent = (adding ? "Tagged " : "Untagged ") + byId.size + (byId.size === 1 ? " asset" : " assets") + " “" + tag + "”.";
|
|
1184
|
+
}
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
msg.className = "msg bad";
|
|
1187
|
+
msg.textContent = err.status === 409 ? "The manifest changed on disk; refresh and try again." : err.message;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1015
1191
|
// src/gallery/client/src/grid.ts
|
|
1016
1192
|
function visibleItems() {
|
|
1017
1193
|
const q = ui.q.trim().toLowerCase();
|
|
@@ -1187,7 +1363,9 @@
|
|
|
1187
1363
|
}
|
|
1188
1364
|
function card(item) {
|
|
1189
1365
|
const slot = ui.compare.indexOf(item.id);
|
|
1190
|
-
const
|
|
1366
|
+
const picked = ui.selected.has(item.id);
|
|
1367
|
+
const c = el("button", "card" + (item.outputs.some((o) => o.url) ? "" : " ghost") + (ui.open === item.id ? " active" : "") + (slot >= 0 ? " compared" : "") + (picked ? " selected" : ""));
|
|
1368
|
+
if (ui.selecting) c.setAttribute("aria-pressed", String(picked));
|
|
1191
1369
|
c.type = "button";
|
|
1192
1370
|
c.dataset.key = item.id;
|
|
1193
1371
|
c.setAttribute("aria-label", item.id + ", " + item.state + (slot >= 0 ? ", in comparison" : ""));
|
|
@@ -1216,8 +1394,10 @@
|
|
|
1216
1394
|
cell.append(badge);
|
|
1217
1395
|
}
|
|
1218
1396
|
c.append(cell, body);
|
|
1397
|
+
if (ui.selecting) cell.append(el("span", "sel", "✓"));
|
|
1219
1398
|
c.onclick = (e) => {
|
|
1220
1399
|
if (e.shiftKey) toggleCompare(item.id);
|
|
1400
|
+
else if (ui.selecting || e.metaKey || e.ctrlKey) toggleSelected(item.id);
|
|
1221
1401
|
else openItem(item.id);
|
|
1222
1402
|
};
|
|
1223
1403
|
const still = cell.querySelector(":scope > img");
|
|
@@ -1253,7 +1433,7 @@
|
|
|
1253
1433
|
const sections = [];
|
|
1254
1434
|
for (const pr of projects) {
|
|
1255
1435
|
const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
|
|
1256
|
-
const styleSections = ui.group === "style" ? S.snap.styles.filter((s) => !pr || s.project === pr.id).map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length) : inProject.length ? [{ style: null, items: inProject }] : [];
|
|
1436
|
+
const styleSections = ui.group === "style" || ui.group === "family" ? S.snap.styles.filter((s) => !pr || s.project === pr.id).map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length) : inProject.length ? [{ style: null, items: inProject }] : [];
|
|
1257
1437
|
if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
|
|
1258
1438
|
sections.push(...styleSections);
|
|
1259
1439
|
}
|
|
@@ -1310,6 +1490,7 @@
|
|
|
1310
1490
|
r.onclick = () => generateDialog(refreshable, { project: s.project, refresh: true });
|
|
1311
1491
|
tools.append(r);
|
|
1312
1492
|
}
|
|
1493
|
+
if (ui.selecting && ui.group !== "family") tools.append(selectAllButton(sec.items));
|
|
1313
1494
|
const styleKey = "style:" + (s.project || "") + ":" + s.id;
|
|
1314
1495
|
if (EDITABLE && s.outDir) {
|
|
1315
1496
|
const es = el("button", null, ui.editing === styleKey ? "Cancel" : "Edit style");
|
|
@@ -1334,9 +1515,12 @@
|
|
|
1334
1515
|
if (ui.editing === styleKey) wrap.append(styleForm(s));
|
|
1335
1516
|
if (ui.editing === addKey) wrap.append(addAssetForm(s));
|
|
1336
1517
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1518
|
+
if (ui.group === "family") wrap.append(...familyGroups(sec.items));
|
|
1519
|
+
else {
|
|
1520
|
+
const grid = el("div", "grid");
|
|
1521
|
+
for (const item of sec.items) grid.append(card(item));
|
|
1522
|
+
wrap.append(grid);
|
|
1523
|
+
}
|
|
1340
1524
|
root.append(wrap);
|
|
1341
1525
|
}
|
|
1342
1526
|
if (hidden) {
|
|
@@ -1352,6 +1536,93 @@
|
|
|
1352
1536
|
root.append(more);
|
|
1353
1537
|
}
|
|
1354
1538
|
}
|
|
1539
|
+
var KIND_ORDER = { base: 0, state: 1, animation: 2, portrait: 3, outfit: 4 };
|
|
1540
|
+
var TURN = ["south", "south-east", "east", "north-east", "north", "north-west", "west", "south-west"];
|
|
1541
|
+
function familyOrder(a, b) {
|
|
1542
|
+
const ka = KIND_ORDER[a.character.kind] ?? 9, kb = KIND_ORDER[b.character.kind] ?? 9;
|
|
1543
|
+
if (ka !== kb) return ka - kb;
|
|
1544
|
+
if (a.character.kind === "animation") {
|
|
1545
|
+
const la = loopName(a), lb = loopName(b);
|
|
1546
|
+
if (la !== lb) return la < lb ? -1 : 1;
|
|
1547
|
+
return TURN.indexOf(a.character.direction) - TURN.indexOf(b.character.direction);
|
|
1548
|
+
}
|
|
1549
|
+
return a.assetId < b.assetId ? -1 : a.assetId > b.assetId ? 1 : 0;
|
|
1550
|
+
}
|
|
1551
|
+
function familyGroups(items) {
|
|
1552
|
+
const families = /* @__PURE__ */ new Map();
|
|
1553
|
+
const loose = [];
|
|
1554
|
+
for (const item of items) {
|
|
1555
|
+
const root = item.character ? familyRoot(item) : null;
|
|
1556
|
+
if (!root) {
|
|
1557
|
+
loose.push(item);
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
if (!families.has(root.id)) families.set(root.id, { root, members: [] });
|
|
1561
|
+
families.get(root.id).members.push(item);
|
|
1562
|
+
}
|
|
1563
|
+
const out = [];
|
|
1564
|
+
for (const { root, members } of families.values()) {
|
|
1565
|
+
members.sort(familyOrder);
|
|
1566
|
+
const group = el("div", "fam-group");
|
|
1567
|
+
const head = el("div", "fam-head");
|
|
1568
|
+
head.append(el("b", "mono", root.assetId));
|
|
1569
|
+
const counts = {};
|
|
1570
|
+
for (const m of members) counts[m.state] = (counts[m.state] || 0) + 1;
|
|
1571
|
+
const tally = el("span", "fam-tally");
|
|
1572
|
+
const loops2 = new Set(members.filter((m) => m.character.kind === "animation").map(loopName)).size;
|
|
1573
|
+
tally.append(document.createTextNode(members.length + (members.length === 1 ? " asset" : " assets") + (loops2 ? " · " + loops2 + (loops2 === 1 ? " loop" : " loops") : "")));
|
|
1574
|
+
for (const [state, n] of Object.entries(counts)) {
|
|
1575
|
+
const t = el("span", "st");
|
|
1576
|
+
t.append(el("i", "dot " + STATE_TONE[state]), document.createTextNode(n + " " + state));
|
|
1577
|
+
tally.append(t);
|
|
1578
|
+
}
|
|
1579
|
+
head.append(tally);
|
|
1580
|
+
const tools = el("div", "tools");
|
|
1581
|
+
if (ui.selecting) tools.append(selectAllButton(members));
|
|
1582
|
+
const view = el("button", null, "Family view");
|
|
1583
|
+
view.type = "button";
|
|
1584
|
+
view.onclick = () => openFamily(root);
|
|
1585
|
+
tools.append(view);
|
|
1586
|
+
const everything = familyMembers(root);
|
|
1587
|
+
const todo = everything.filter(actionable);
|
|
1588
|
+
if (GENERATION && todo.length) {
|
|
1589
|
+
const g = el("button", "primary", "Generate " + todo.length + " · " + priceOf(todo));
|
|
1590
|
+
g.type = "button";
|
|
1591
|
+
g.title = "One job for the family: parents first, then everything drawn from them";
|
|
1592
|
+
g.onclick = () => generateDialog(todo, { project: root.project });
|
|
1593
|
+
tools.append(g);
|
|
1594
|
+
}
|
|
1595
|
+
head.append(tools);
|
|
1596
|
+
const grid = el("div", "grid");
|
|
1597
|
+
for (const m of members) grid.append(card(m));
|
|
1598
|
+
group.append(head, grid);
|
|
1599
|
+
out.push(group);
|
|
1600
|
+
}
|
|
1601
|
+
if (loose.length) {
|
|
1602
|
+
const group = el("div", "fam-group loose");
|
|
1603
|
+
if (families.size) {
|
|
1604
|
+
const head = el("div", "fam-head");
|
|
1605
|
+
head.append(el("b", null, "Not in a family"), el("span", "fam-tally", loose.length + (loose.length === 1 ? " asset" : " assets")));
|
|
1606
|
+
if (ui.selecting) {
|
|
1607
|
+
const tools = el("div", "tools");
|
|
1608
|
+
tools.append(selectAllButton(loose));
|
|
1609
|
+
head.append(tools);
|
|
1610
|
+
}
|
|
1611
|
+
group.append(head);
|
|
1612
|
+
}
|
|
1613
|
+
const grid = el("div", "grid");
|
|
1614
|
+
for (const item of loose) grid.append(card(item));
|
|
1615
|
+
group.append(grid);
|
|
1616
|
+
out.push(group);
|
|
1617
|
+
}
|
|
1618
|
+
return out;
|
|
1619
|
+
}
|
|
1620
|
+
var actionable = (item) => item.declared && item.currentSpecHash !== null && ["missing", "stale", "failed"].includes(item.state);
|
|
1621
|
+
function priceOf(items) {
|
|
1622
|
+
const totals = {};
|
|
1623
|
+
for (const i of items) if (i.estimatedCost !== null) totals[i.costUnit] = (totals[i.costUnit] || 0) + i.estimatedCost;
|
|
1624
|
+
return Object.entries(totals).map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(" + ") || "free";
|
|
1625
|
+
}
|
|
1355
1626
|
function projectHeader(pr, count) {
|
|
1356
1627
|
const head = el("section", "project");
|
|
1357
1628
|
const line = el("div", "phead");
|
|
@@ -2374,7 +2645,8 @@
|
|
|
2374
2645
|
panel: null,
|
|
2375
2646
|
anims: [],
|
|
2376
2647
|
columns: /* @__PURE__ */ new Map(),
|
|
2377
|
-
turn: null
|
|
2648
|
+
turn: null,
|
|
2649
|
+
gap: null
|
|
2378
2650
|
};
|
|
2379
2651
|
renderFamily();
|
|
2380
2652
|
}
|
|
@@ -2667,7 +2939,20 @@
|
|
|
2667
2939
|
const td = el("td");
|
|
2668
2940
|
column(d, td);
|
|
2669
2941
|
const item = cells.find((l) => l.character.direction === d);
|
|
2670
|
-
td.append(
|
|
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", "·"));
|
|
2671
2956
|
tr.append(td);
|
|
2672
2957
|
}
|
|
2673
2958
|
tbody.append(tr);
|
|
@@ -2676,9 +2961,167 @@
|
|
|
2676
2961
|
const scroller = el("div", "lg-scroll");
|
|
2677
2962
|
scroller.append(table);
|
|
2678
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;
|
|
2679
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."));
|
|
2680
2969
|
return wrap;
|
|
2681
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
|
+
}
|
|
2682
3125
|
function column(direction, node) {
|
|
2683
3126
|
if (!F.columns.has(direction)) F.columns.set(direction, []);
|
|
2684
3127
|
F.columns.get(direction).push(node);
|
|
@@ -3978,7 +4421,7 @@
|
|
|
3978
4421
|
ui.projects = new Set(list("project"));
|
|
3979
4422
|
ui.compare = list("compare").slice(0, 4);
|
|
3980
4423
|
if (["key", "newest", "oldest", "cost", "size"].includes(params.get("sort") ?? "")) ui.sort = params.get("sort");
|
|
3981
|
-
if (["style", "none"].includes(params.get("group") ?? "")) ui.group = params.get("group");
|
|
4424
|
+
if (["style", "family", "none"].includes(params.get("group") ?? "")) ui.group = params.get("group");
|
|
3982
4425
|
}
|
|
3983
4426
|
function writeUrlState() {
|
|
3984
4427
|
const params = new URLSearchParams();
|
|
@@ -4051,6 +4494,7 @@
|
|
|
4051
4494
|
renderMain(visibleItems());
|
|
4052
4495
|
renderDrawer();
|
|
4053
4496
|
renderTray();
|
|
4497
|
+
renderSelectionBar();
|
|
4054
4498
|
refreshFamily();
|
|
4055
4499
|
}
|
|
4056
4500
|
function renderQuietly() {
|
|
@@ -4060,6 +4504,7 @@
|
|
|
4060
4504
|
if (region !== "main") renderMain(visibleItems());
|
|
4061
4505
|
if (region !== "drawer") renderDrawer();
|
|
4062
4506
|
renderTray();
|
|
4507
|
+
renderSelectionBar();
|
|
4063
4508
|
refreshFamily();
|
|
4064
4509
|
}
|
|
4065
4510
|
|
|
@@ -4548,6 +4993,7 @@
|
|
|
4548
4993
|
// src/gallery/client/src/main.ts
|
|
4549
4994
|
loadPrefs();
|
|
4550
4995
|
$("refresh").onclick = refresh;
|
|
4996
|
+
$("select").onclick = () => setSelecting(!ui.selecting);
|
|
4551
4997
|
$("playloops").checked = ui.playLoops;
|
|
4552
4998
|
$("playloops").onchange = (e) => {
|
|
4553
4999
|
setPlayLoops(e.target.checked);
|
|
@@ -4610,6 +5056,11 @@
|
|
|
4610
5056
|
if (ui.open) {
|
|
4611
5057
|
e.preventDefault();
|
|
4612
5058
|
closeItem();
|
|
5059
|
+
return;
|
|
5060
|
+
}
|
|
5061
|
+
if (ui.selecting) {
|
|
5062
|
+
e.preventDefault();
|
|
5063
|
+
setSelecting(false);
|
|
4613
5064
|
}
|
|
4614
5065
|
return;
|
|
4615
5066
|
}
|