pixelkiln 0.74.0 → 0.76.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.
@@ -856,6 +856,555 @@
856
856
  return form;
857
857
  }
858
858
 
859
+ // src/gallery/client/src/grid.ts
860
+ function visibleItems() {
861
+ const q = ui.q.trim().toLowerCase();
862
+ let items = S.snap.items.filter((item) => {
863
+ if (ui.states.size && !ui.states.has(item.state)) return false;
864
+ if (ui.providers.size && !ui.providers.has(item.provider)) return false;
865
+ if (ui.generators.size && !ui.generators.has(item.generator)) return false;
866
+ if (ui.projects.size && !ui.projects.has(item.project ?? "")) return false;
867
+ if (!q) return true;
868
+ const hay = [
869
+ item.id,
870
+ item.prompt,
871
+ item.currentPrompt,
872
+ item.jobId,
873
+ item.objectId,
874
+ item.recordedSpecHash,
875
+ item.currentSpecHash,
876
+ item.category,
877
+ ...item.tags || [],
878
+ ...item.outputs.flatMap((o) => [o.path, o.sha256])
879
+ ].filter(Boolean).join("\n").toLowerCase();
880
+ return hay.includes(q);
881
+ });
882
+ const when = (item) => item.downloadedAt || item.submittedAt || "";
883
+ const sorters = {
884
+ key: (a, b) => a.id.localeCompare(b.id),
885
+ newest: (a, b) => when(b).localeCompare(when(a)) || a.id.localeCompare(b.id),
886
+ oldest: (a, b) => when(a).localeCompare(when(b)) || a.id.localeCompare(b.id),
887
+ cost: (a, b) => b.cost - a.cost || a.id.localeCompare(b.id),
888
+ size: (a, b) => b.width * b.height - a.width * a.height || a.id.localeCompare(b.id)
889
+ };
890
+ items.sort(sorters[ui.sort] || sorters.key);
891
+ return items;
892
+ }
893
+ function renderHeader() {
894
+ $("editing").hidden = !EDITABLE;
895
+ $("foot-edit").hidden = EDITABLE;
896
+ $("foot-editing").hidden = !EDITABLE;
897
+ $("foot-gen").hidden = !GENERATION;
898
+ const p = $("project");
899
+ p.textContent = "";
900
+ if (S.snap.workspace) {
901
+ p.append(el("b", null, "workspace"), " ", document.createTextNode(shortPath(S.snap.workspace.path)));
902
+ p.title = S.snap.workspace.path;
903
+ } else {
904
+ p.append(el("b", null, S.snap.project.name), " ", document.createTextNode(shortPath(S.snap.project.manifest)));
905
+ p.title = S.snap.project.manifest + "\nlockfile: " + S.snap.project.lock;
906
+ }
907
+ const t = $("totals");
908
+ t.textContent = "";
909
+ if (S.snap.workspace) {
910
+ const broken = S.snap.workspace.projects.filter((pr) => pr.error).length;
911
+ const w = el("span", null, "");
912
+ w.append(el("b", null, S.snap.workspace.projects.length), document.createTextNode(" project" + (S.snap.workspace.projects.length === 1 ? "" : "s") + (broken ? " (" + broken + " unreadable)" : "")));
913
+ t.append(w);
914
+ }
915
+ const n = S.snap.totals.entries;
916
+ t.append(el("span", null, ""));
917
+ t.lastChild.append(el("b", null, n), document.createTextNode(" lock " + (n === 1 ? "entry" : "entries")));
918
+ const declared = S.snap.items.filter((i) => i.declared).length;
919
+ t.append(el("span", null, declared + " declared by " + (S.snap.workspace ? "a manifest" : "the manifest")));
920
+ const recorded = fmtSpend(S.snap.totals.spendByUnit);
921
+ t.append(el("span", null, recorded === "no spend recorded" ? recorded : recorded + " recorded"));
922
+ if (S.snap.filter.styles.length || S.snap.filter.assets.length) {
923
+ t.append(el("span", "state-warn", "filtered: " + [
924
+ S.snap.filter.styles.length ? "--style " + S.snap.filter.styles.join(",") : "",
925
+ S.snap.filter.assets.length ? "--only " + S.snap.filter.assets.join(",") : ""
926
+ ].filter(Boolean).join(" ")));
927
+ }
928
+ t.append(el("span", null, "snapshot " + fmtWhen(S.snap.generatedAt)));
929
+ if (GENERATION) {
930
+ const line = budgetLine();
931
+ if (line) t.append(el("span", "budget", line));
932
+ }
933
+ const chips = $("chips");
934
+ chips.textContent = "";
935
+ const counts = (pick) => {
936
+ const m = /* @__PURE__ */ new Map();
937
+ for (const item of S.snap.items) {
938
+ const k = pick(item);
939
+ m.set(k, (m.get(k) || 0) + 1);
940
+ }
941
+ return m;
942
+ };
943
+ const chip = (label, count, set, key, tone) => {
944
+ const b = el("button", "chip" + (set.has(key) ? " on" : ""));
945
+ b.type = "button";
946
+ b.setAttribute("aria-pressed", String(set.has(key)));
947
+ if (tone) b.append(el("i", "dot " + tone));
948
+ b.append(document.createTextNode(label), el("span", "n", count));
949
+ b.onclick = () => {
950
+ set.has(key) ? set.delete(key) : set.add(key);
951
+ render();
952
+ };
953
+ return b;
954
+ };
955
+ if (S.snap.workspace) {
956
+ const byProject = counts((i) => i.project);
957
+ for (const pr of S.snap.workspace.projects) {
958
+ if (byProject.has(pr.id) || ui.projects.has(pr.id)) chips.append(chip(pr.id, byProject.get(pr.id) || 0, ui.projects, pr.id));
959
+ }
960
+ chips.append(el("span", "sep"));
961
+ }
962
+ const byState = counts((i) => i.state);
963
+ for (const s of STATE_ORDER) if (byState.has(s)) chips.append(chip(s, byState.get(s), ui.states, s, STATE_TONE[s]));
964
+ const byProvider = counts((i) => i.provider);
965
+ if (byProvider.size > 1 || ui.providers.size) {
966
+ chips.append(el("span", "sep"));
967
+ for (const [k, v] of [...byProvider].sort()) chips.append(chip(k, v, ui.providers, k));
968
+ }
969
+ const byGen = counts((i) => i.generator);
970
+ if (byGen.size > 1 || ui.generators.size) {
971
+ chips.append(el("span", "sep"));
972
+ for (const [k, v] of [...byGen].sort()) chips.append(chip(k, v, ui.generators, k));
973
+ }
974
+ if (ui.states.size || ui.providers.size || ui.generators.size || ui.projects.size || ui.q) {
975
+ const clear = el("button", "chip", "clear filters");
976
+ clear.type = "button";
977
+ clear.onclick = () => clearFilters();
978
+ chips.append(el("span", "sep"), clear);
979
+ }
980
+ }
981
+ function clearFilters() {
982
+ ui.states.clear();
983
+ ui.providers.clear();
984
+ ui.generators.clear();
985
+ ui.projects.clear();
986
+ ui.q = "";
987
+ $("q").value = "";
988
+ render();
989
+ }
990
+ function thumb(item) {
991
+ const cell = el("div", "cell");
992
+ const shown = item.edit && item.editStatus === "edited" && item.edits.every((e) => e.url) ? item.edits : item.outputs.filter((o) => o.url);
993
+ if (!shown.length) {
994
+ cell.classList.add("none");
995
+ cell.append(el("span", null, item.state === "missing" ? "not generated yet" : item.state === "in-flight" ? item.status === "review" ? "awaiting review" : "awaiting provider" : item.state === "recoverable" ? "ready to fetch" : item.state === "failed" ? "generation failed" : item.state === "blocked" ? "waiting on its parent" : item.outputs.length ? "file missing on disk" : "no output recorded"));
996
+ return cell;
997
+ }
998
+ const w = item.width, h = item.height;
999
+ if (shown.length > 1 && !isFrameSet(item)) {
1000
+ const multi = el("div", "multi");
1001
+ for (const o of shown.slice(0, 4)) {
1002
+ const box = el("div");
1003
+ const img2 = el("img");
1004
+ img2.src = o.url;
1005
+ img2.alt = o.role || item.assetId;
1006
+ img2.loading = "lazy";
1007
+ const s2 = displayScale(w, h, 66, 52);
1008
+ if (s2 >= 1) {
1009
+ img2.width = w * s2;
1010
+ img2.height = h * s2;
1011
+ } else img2.className = "fit";
1012
+ box.append(img2);
1013
+ multi.append(box);
1014
+ }
1015
+ cell.append(multi, el("span", "badge", shown.length + " outputs"));
1016
+ return cell;
1017
+ }
1018
+ const img = el("img");
1019
+ img.src = shown[0].url;
1020
+ img.alt = item.assetId;
1021
+ img.loading = "lazy";
1022
+ const s = displayScale(w, h, 156, 116);
1023
+ if (s >= 1) {
1024
+ img.width = w * s;
1025
+ img.height = h * s;
1026
+ } else img.className = "fit";
1027
+ cell.append(img);
1028
+ if (isFrameSet(item)) cell.append(el("span", "badge", "▸ " + shown.length + " frames"));
1029
+ else if (s >= 1 && s !== 1) cell.append(el("span", "badge", s + "×"));
1030
+ return cell;
1031
+ }
1032
+ function card(item) {
1033
+ const slot = ui.compare.indexOf(item.id);
1034
+ const c = el("button", "card" + (item.outputs.some((o) => o.url) ? "" : " ghost") + (ui.open === item.id ? " active" : "") + (slot >= 0 ? " compared" : ""));
1035
+ c.type = "button";
1036
+ c.dataset.key = item.id;
1037
+ c.setAttribute("aria-label", item.id + ", " + item.state + (slot >= 0 ? ", in comparison" : ""));
1038
+ const body = el("div", "cbody");
1039
+ body.append(el("div", "aid", item.assetId));
1040
+ const meta = el("div", "cmeta");
1041
+ const st = el("span", "st");
1042
+ st.append(el("i", "dot " + STATE_TONE[item.state]), document.createTextNode(item.state));
1043
+ meta.append(st, el("span", null, item.width + "×" + item.height));
1044
+ if (item.cost) meta.append(el("span", null, fmtCost(item.costUnit, item.cost)));
1045
+ body.append(meta);
1046
+ const family = familyLabel(item);
1047
+ if (family) body.append(el("div", "family", family));
1048
+ const cell = thumb(item);
1049
+ if (slot >= 0) cell.append(el("span", "slot", String(slot + 1)));
1050
+ if (item.editStatus === "edited" || item.editStatus === "regenerated-since") {
1051
+ const pen = el("span", "pen", "✎");
1052
+ pen.title = "hand-edited";
1053
+ cell.append(pen);
1054
+ }
1055
+ const job = activeJobFor(item);
1056
+ if (job) {
1057
+ const badge = el("span", "busy-badge");
1058
+ badge.append(el("i", "dot busy"), document.createTextNode(job.phase === "review" ? "review" : job.mode === "generate" ? "generating" : job.mode));
1059
+ badge.title = (JOB_VERB[job.mode] || job.mode) + " this asset: " + (PHASE_TEXT[job.phase] || job.phase);
1060
+ cell.append(badge);
1061
+ }
1062
+ c.append(cell, body);
1063
+ c.onclick = (e) => {
1064
+ if (e.shiftKey) toggleCompare(item.id);
1065
+ else openItem(item.id);
1066
+ };
1067
+ return c;
1068
+ }
1069
+ function renderMain(items) {
1070
+ const root = $("root");
1071
+ root.textContent = "";
1072
+ if (!S.snap.items.length) {
1073
+ const e = el("div", "empty");
1074
+ e.append(el("p", null, "Nothing has been generated for this project yet."));
1075
+ e.append(el("p", null, "pixelkiln plan shows what the manifest would make; pixelkiln gen makes it."));
1076
+ root.append(e);
1077
+ return;
1078
+ }
1079
+ if (!items.length) {
1080
+ const e = el("div", "empty");
1081
+ e.append(el("p", null, "No generations match these filters."));
1082
+ const b = el("button", null, "Clear filters");
1083
+ b.type = "button";
1084
+ b.onclick = clearFilters;
1085
+ e.append(b);
1086
+ root.append(e);
1087
+ return;
1088
+ }
1089
+ const hidden = Math.max(0, items.length - ui.limit);
1090
+ const shown = hidden ? items.slice(0, ui.limit) : items;
1091
+ const unfiltered = !ui.projects.size && !ui.q && !ui.states.size && !ui.providers.size && !ui.generators.size;
1092
+ const projects = S.snap.workspace ? S.snap.workspace.projects.filter((pr) => ui.projects.size ? ui.projects.has(pr.id) : unfiltered || shown.some((i) => i.project === pr.id)) : [null];
1093
+ const sections = [];
1094
+ for (const pr of projects) {
1095
+ const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
1096
+ 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 }] : [];
1097
+ if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
1098
+ sections.push(...styleSections);
1099
+ }
1100
+ for (const sec of sections) {
1101
+ if (sec.project) {
1102
+ root.append(projectHeader(sec.project, sec.count));
1103
+ continue;
1104
+ }
1105
+ const wrap = el("section", "style");
1106
+ if (sec.style) {
1107
+ const s = sec.style;
1108
+ const head = el("div", "shead");
1109
+ head.append(el("h2", null, s.id));
1110
+ const meta = el("div", "meta");
1111
+ meta.append(el("span", null, s.provider), el("span", null, s.generator));
1112
+ if (s.outDir) meta.append(el("span", "mono", s.outDir));
1113
+ if (s.palette.length) {
1114
+ const sw = el("span", "swatches");
1115
+ for (const c of s.palette) {
1116
+ const i = el("i");
1117
+ i.style.background = c;
1118
+ i.title = c;
1119
+ sw.append(i);
1120
+ }
1121
+ meta.append(sw);
1122
+ }
1123
+ if (s.quality) meta.append(el("span", null, "quality profile"));
1124
+ if (s.characters) {
1125
+ const parts = [];
1126
+ parts.push(s.characters.bases + (s.characters.bases === 1 ? " character" : " characters"));
1127
+ if (s.characters.states) parts.push(s.characters.states + (s.characters.states === 1 ? " state" : " states"));
1128
+ if (s.characters.animations) parts.push(s.characters.animations + (s.characters.animations === 1 ? " loop" : " loops"));
1129
+ if (s.characters.portraits) parts.push(s.characters.portraits + (s.characters.portraits === 1 ? " portrait" : " portraits"));
1130
+ if (s.characters.outfits) parts.push(s.characters.outfits + (s.characters.outfits === 1 ? " outfit" : " outfits"));
1131
+ meta.append(el("span", null, parts.join(", ")));
1132
+ }
1133
+ meta.append(el("span", null, sec.items.length + " of " + s.items + (s.items === 1 ? " asset" : " assets")));
1134
+ if (Object.keys(s.spendByUnit).length) meta.append(el("span", null, fmtSpend(s.spendByUnit)));
1135
+ head.append(meta);
1136
+ const addKey = "new:" + (s.project || "") + ":" + s.id;
1137
+ const tools = el("div", "tools");
1138
+ if (s.outDir && s.candidates !== null) tools.append(candidatesControl(s));
1139
+ if (GENERATION && s.actionable.keys.length) {
1140
+ const g = el("button", "primary", "Generate " + s.actionable.keys.length + " · " + fmtCost(s.actionable.costUnit, Math.round(s.actionable.cost * 100) / 100));
1141
+ g.type = "button";
1142
+ g.onclick = () => generateDialog(S.snap.items.filter((i) => i.project === s.project && s.actionable.keys.includes(i.key)), { project: s.project });
1143
+ tools.append(g);
1144
+ }
1145
+ const refreshable = S.snap.items.filter((i) => i.project === s.project && i.styleId === s.id && i.refreshable && i.upstreamUrl);
1146
+ if (GENERATION && refreshable.length) {
1147
+ const r = el("button", null, "Pull upstream " + refreshable.length);
1148
+ r.type = "button";
1149
+ r.title = "Re-download objects edited in the provider’s own editor; no cost";
1150
+ r.onclick = () => generateDialog(refreshable, { project: s.project, refresh: true });
1151
+ tools.append(r);
1152
+ }
1153
+ const styleKey = "style:" + (s.project || "") + ":" + s.id;
1154
+ if (EDITABLE && s.outDir) {
1155
+ const es = el("button", null, ui.editing === styleKey ? "Cancel" : "Edit style");
1156
+ es.type = "button";
1157
+ es.onclick = () => {
1158
+ ui.editing = ui.editing === styleKey ? null : styleKey;
1159
+ ui.notice = null;
1160
+ render();
1161
+ };
1162
+ tools.append(es);
1163
+ const add = el("button", "add", ui.editing === addKey ? "Cancel" : "+ Add asset");
1164
+ add.type = "button";
1165
+ add.onclick = () => {
1166
+ ui.editing = ui.editing === addKey ? null : addKey;
1167
+ render();
1168
+ };
1169
+ tools.append(add);
1170
+ }
1171
+ if (tools.childNodes.length) head.append(tools);
1172
+ wrap.append(head);
1173
+ if (ui.notice && ui.notice.id === styleKey) wrap.append(el("div", "notice", ui.notice.text));
1174
+ if (ui.editing === styleKey) wrap.append(styleForm(s));
1175
+ if (ui.editing === addKey) wrap.append(addAssetForm(s));
1176
+ }
1177
+ const grid = el("div", "grid");
1178
+ for (const item of sec.items) grid.append(card(item));
1179
+ wrap.append(grid);
1180
+ root.append(wrap);
1181
+ }
1182
+ if (hidden) {
1183
+ const more = el("div", "empty");
1184
+ more.append(el("p", null, hidden + " more " + (hidden === 1 ? "generation is" : "generations are") + " not shown."));
1185
+ const b = el("button", null, "Show all " + items.length);
1186
+ b.type = "button";
1187
+ b.onclick = () => {
1188
+ ui.limit = Infinity;
1189
+ render();
1190
+ };
1191
+ more.append(b);
1192
+ root.append(more);
1193
+ }
1194
+ }
1195
+ function projectHeader(pr, count) {
1196
+ const head = el("section", "project");
1197
+ const line = el("div", "phead");
1198
+ line.append(el("h2", null, pr.id));
1199
+ const meta = el("div", "meta");
1200
+ if (pr.name !== pr.id) meta.append(el("span", null, pr.name));
1201
+ meta.append(el("span", "mono", shortPath(pr.manifest)));
1202
+ if (pr.account) meta.append(el("span", null, pr.account));
1203
+ if (pr.error) {
1204
+ meta.append(el("span", "state-bad", "unreadable: " + pr.error));
1205
+ } else {
1206
+ meta.append(el("span", null, count + " of " + pr.items + " shown · " + pr.entries + " lock " + (pr.entries === 1 ? "entry" : "entries")));
1207
+ if (Object.keys(pr.spendByUnit).length) meta.append(el("span", null, fmtSpend(pr.spendByUnit)));
1208
+ }
1209
+ line.append(meta);
1210
+ head.append(line);
1211
+ return head;
1212
+ }
1213
+ function candidatesControl(style) {
1214
+ const wrap = el("span", "cand");
1215
+ const editKey = "cand:" + (style.project || "") + ":" + style.id;
1216
+ const declared = S.snap.items.filter((i) => i.project === style.project && i.styleId === style.id && i.declared).length;
1217
+ if (!(EDITABLE && style.candidatesEditable) || ui.editing !== editKey) {
1218
+ wrap.append(el("span", null, style.candidates + (style.candidates === 1 ? " candidate" : " candidates") + " per generation"));
1219
+ if (EDITABLE && style.candidatesEditable) {
1220
+ const b = el("button", null, "change");
1221
+ b.type = "button";
1222
+ b.onclick = () => {
1223
+ ui.editing = editKey;
1224
+ render();
1225
+ };
1226
+ wrap.append(b);
1227
+ } else if (style.provider === "pixellab") {
1228
+ wrap.title = "PixelLab: map and pixflux return one image per generation; a 1dir style returns 4–64 for its size.";
1229
+ }
1230
+ return wrap;
1231
+ }
1232
+ const input = el("input");
1233
+ input.type = "number";
1234
+ input.min = "1";
1235
+ input.max = "64";
1236
+ input.value = String(style.candidates);
1237
+ const save = el("button", "primary", "Save");
1238
+ save.type = "button";
1239
+ const cancel = el("button", null, "Cancel");
1240
+ cancel.type = "button";
1241
+ cancel.onclick = () => {
1242
+ ui.editing = null;
1243
+ render();
1244
+ };
1245
+ const note = el("span", null, "affects " + declared + (declared === 1 ? " asset, which becomes stale" : " assets, which become stale"));
1246
+ save.onclick = async () => {
1247
+ save.disabled = true;
1248
+ try {
1249
+ const pr = S.snap.workspace ? S.snap.workspace.projects.find((x) => x.id === style.project) : S.snap.project;
1250
+ const body = { action: "patch-style", styleId: style.id, expectedSha256: pr.manifestSha256, patch: { candidates: Number(input.value) } };
1251
+ if (style.project) body.project = style.project;
1252
+ S.snap = await postEdit(body);
1253
+ ui.editing = null;
1254
+ render();
1255
+ } catch (err) {
1256
+ save.disabled = false;
1257
+ note.textContent = err.message;
1258
+ note.className = "state-bad";
1259
+ }
1260
+ };
1261
+ wrap.append(input, save, cancel, note);
1262
+ setTimeout(() => input.focus(), 0);
1263
+ return wrap;
1264
+ }
1265
+ function historySection(item) {
1266
+ const limit = projectOf(item)?.historyLimit;
1267
+ if (!item.history.length && !((limit ?? 0) > 0 && item.status === "downloaded")) return null;
1268
+ const s = el("section", "meta");
1269
+ s.append(el("h3", null, item.history.length ? "Previous generations (" + item.history.length + ")" : "Previous generations"));
1270
+ if (!item.history.length) {
1271
+ s.append(el("div", "state-dim", "None yet. A regeneration keeps the generation it replaces, up to " + limit + " per asset (PIXELKILN_HISTORY, or the manifest’s history), so it can be brought back from here."));
1272
+ return s;
1273
+ }
1274
+ const list = el("div", "versions");
1275
+ const job = GENERATION ? activeJobFor(item) : null;
1276
+ for (const g of item.history) {
1277
+ const v = el("div", "version");
1278
+ const thumb2 = el("div", "thumb");
1279
+ const first = g.outputs[0];
1280
+ if (first && first.url) {
1281
+ const img = el("img");
1282
+ img.src = first.url;
1283
+ img.alt = "generation #" + g.index;
1284
+ thumb2.append(img);
1285
+ } else thumb2.append(el("span", null, g.cached ? "" : "not cached"));
1286
+ const facts = el("div", "facts");
1287
+ const head = el("div");
1288
+ const billedNote = g.billed && (g.billed.unit !== g.costUnit || g.billed.amount !== g.cost) ? " (billed " + fmtCost(g.billed.unit, g.billed.amount) + ")" : "";
1289
+ head.append(el("b", null, "#" + g.index), document.createTextNode(" · " + fmtWhen(g.downloadedAt || g.submittedAt) + " · " + fmtCost(g.costUnit, g.cost) + billedNote + (g.outputs.length > 1 ? " · " + g.outputs.length + " files" : "")));
1290
+ facts.append(head);
1291
+ const prompt = el("div", "prompt" + (g.promptDiffers ? " differs" : ""), (g.promptDiffers ? "prompt: " : "same prompt: ") + g.prompt);
1292
+ prompt.title = g.prompt;
1293
+ facts.append(prompt);
1294
+ const meta = el("div", "meta");
1295
+ meta.append(document.createTextNode((first ? first.sha256.slice(0, 12) + "…" : "") + (g.cached ? " · bytes cached locally" : " · not in the local cache; a restore re-downloads it")));
1296
+ if (g.upstreamUrl) {
1297
+ const a = el("a", null, "open in " + item.provider + " ↗");
1298
+ a.href = g.upstreamUrl;
1299
+ a.target = "_blank";
1300
+ a.rel = "noopener";
1301
+ meta.append(document.createTextNode(" · "), a);
1302
+ }
1303
+ facts.append(meta);
1304
+ const acts = el("div", "acts");
1305
+ if (GENERATION) {
1306
+ const b = el("button", null, "Restore this one · no cost");
1307
+ b.type = "button";
1308
+ b.disabled = !!job;
1309
+ b.title = job ? "Wait for the running job to finish" : "The current generation moves into history; this one becomes current and its bytes are written back";
1310
+ b.onclick = () => generateDialog([item], { project: item.project, revert: String(g.index), generation: g });
1311
+ acts.append(b);
1312
+ }
1313
+ v.append(thumb2, facts, acts);
1314
+ list.append(v);
1315
+ }
1316
+ s.append(list);
1317
+ s.append(el("div", "state-dim", "Keeping up to " + limit + " per asset; a restore moves the current generation into this list, so it can be undone the same way." + (GENERATION ? "" : " Restoring needs the gallery started with --budget (any amount; nothing is spent).")));
1318
+ return s;
1319
+ }
1320
+ function upstreamSection(item) {
1321
+ if (!item.upstreamUrl && !(GENERATION && item.refreshable)) return null;
1322
+ const s = el("section", "meta");
1323
+ s.append(el("h3", null, "Upstream"));
1324
+ const dl = el("dl");
1325
+ if (item.upstreamUrl) {
1326
+ const a = el("a", null, "Open in " + item.provider + " ↗");
1327
+ a.href = item.upstreamUrl;
1328
+ a.target = "_blank";
1329
+ a.rel = "noopener";
1330
+ const tiles = item.generator === "tiles";
1331
+ row(dl, tiles ? "tile set" : "object", a);
1332
+ row(dl, "note", item.provider === "pixellab" ? tiles ? "The tile set this generation was chosen from. Edit it there with PixelLab’s editor, then pull the changes back here; the lockfile records the new bytes as this generation." : "The account object this generation came from. Edit it there with PixelLab’s editor, then pull the changes back here; the lockfile records the new bytes as this generation." : "The provider’s page for this object.");
1333
+ }
1334
+ s.append(dl);
1335
+ if (GENERATION && item.refreshable) {
1336
+ const acts = el("div", "hand-actions");
1337
+ const b = el("button", null, "Pull upstream changes · no cost");
1338
+ b.type = "button";
1339
+ b.onclick = () => generateDialog([item], { project: item.project, refresh: true });
1340
+ acts.append(b);
1341
+ s.append(acts);
1342
+ }
1343
+ return s;
1344
+ }
1345
+ function jobStrip(item, job) {
1346
+ const strip = el("div", "job-strip");
1347
+ const ph = el("span", "ph");
1348
+ ph.append(el("i", "dot busy"), document.createTextNode(job.phase));
1349
+ const last = el("span", "last", job.messages.length ? job.messages[job.messages.length - 1].trim() : "");
1350
+ last.title = job.messages.join("\n");
1351
+ const what = el("span", "what", (JOB_VERB[job.mode] || job.mode) + " this asset: " + (PHASE_TEXT[job.phase] || job.phase) + (job.mode === "generate" && job.phase !== "review" ? ". The current file stays until the new result is downloaded." : "."));
1352
+ strip.append(ph, last, what);
1353
+ const acts = el("div", "acts");
1354
+ if (job.phase === "review" && job.review.includes(item.key)) {
1355
+ const b = el("button", "primary", "Review candidates");
1356
+ b.type = "button";
1357
+ b.onclick = () => openReview(job.id);
1358
+ acts.append(b);
1359
+ }
1360
+ const logBtn = el("button", null, ui.logs.has(job.id) ? "Hide log" : "Log");
1361
+ logBtn.type = "button";
1362
+ logBtn.onclick = () => {
1363
+ ui.logs.has(job.id) ? ui.logs.delete(job.id) : ui.logs.add(job.id);
1364
+ renderDrawer();
1365
+ };
1366
+ acts.append(logBtn);
1367
+ strip.append(acts);
1368
+ if (ui.logs.has(job.id)) {
1369
+ const pre = el("pre", null, job.messages.join("\n"));
1370
+ pre.style.gridColumn = "1 / -1";
1371
+ strip.append(pre);
1372
+ }
1373
+ return strip;
1374
+ }
1375
+ function generateActions(item) {
1376
+ const row2 = el("div", "gen");
1377
+ if (!GENERATION || !item.declared || item.currentSpecHash === null) return row2;
1378
+ const cost = item.estimatedCost === null ? "" : " · " + fmtCost(item.costUnit, item.estimatedCost);
1379
+ const add = (label, cls, opts) => {
1380
+ const b = el("button", cls, label);
1381
+ b.type = "button";
1382
+ b.onclick = () => generateDialog([item], { project: item.project, ...opts });
1383
+ row2.append(b);
1384
+ };
1385
+ if (item.state === "missing" || item.state === "stale" || item.state === "failed") add("Generate" + cost, "primary", {});
1386
+ else if (item.state === "ok") add("Regenerate" + cost, null, { force: true });
1387
+ else if (item.state === "orphaned" && item.outputs.every((o) => o.exists)) {
1388
+ add("Restore recorded bytes · no cost", null, { restore: true, force: true });
1389
+ add("Regenerate" + cost, null, { force: true });
1390
+ } else if (item.state === "orphaned") add("Restore · no cost", "primary", { restore: true });
1391
+ else if (item.state === "untracked") add("Generate" + cost + " · replaces the file", null, { force: true });
1392
+ if (item.state === "recoverable" && item.status === "downloaded") {
1393
+ add(paletteRuleOf(item) ? "Snap to the palette · no cost" : "Put the provider’s bytes back · no cost", "primary", { resume: true });
1394
+ } else if (item.state === "in-flight" || item.state === "recoverable") {
1395
+ const holder = S.GEN.jobs.find((job) => job.phase === "review" && job.project === item.project && job.review.includes(item.key));
1396
+ if (holder) {
1397
+ const b = el("button", "primary", "Review candidates");
1398
+ b.type = "button";
1399
+ b.onclick = () => openReview(holder.id);
1400
+ row2.append(b);
1401
+ } else {
1402
+ add(item.status === "review" ? "Resume review" : "Resume · no cost", "primary", { resume: true });
1403
+ }
1404
+ }
1405
+ return row2;
1406
+ }
1407
+
859
1408
  // src/gallery/client/src/jobs.ts
860
1409
  var ACTIVE_PHASES = /* @__PURE__ */ new Set(["queued", "submitting", "polling", "fetching", "review"]);
861
1410
  var PHASE_TONE = { queued: "cool", submitting: "cool", polling: "cool", fetching: "cool", review: "warn", done: "ok", failed: "bad" };
@@ -1550,574 +2099,487 @@
1550
2099
  pollJobs();
1551
2100
  refresh();
1552
2101
  };
1553
- bar.append(close, el("span", null, "Choose from the candidates below. Apply selections writes the lockfile and downloads what you chose; unchosen rows stay in review."));
1554
- const frame = el("iframe");
1555
- frame.src = "/review/" + encodeURIComponent(jobId);
1556
- frame.title = "Candidate review";
1557
- panel.append(bar, frame);
1558
- host.append(scrim, panel);
1559
- }
1560
- window.addEventListener("message", (e) => {
1561
- if (e.origin !== location.origin || !e.data || typeof e.data.type !== "string") return;
1562
- if (e.data.type === "pixelkiln:review-applied") {
1563
- setTimeout(() => {
1564
- $("dialog-host").textContent = "";
1565
- pollJobs();
1566
- refresh();
1567
- }, 600);
1568
- return;
1569
- }
1570
- if (S.SHEET && e.source === S.SHEET.frame.contentWindow) onEditorMessage(e.data);
1571
- });
1572
-
1573
- // src/gallery/client/src/grid.ts
1574
- function visibleItems() {
1575
- const q = ui.q.trim().toLowerCase();
1576
- let items = S.snap.items.filter((item) => {
1577
- if (ui.states.size && !ui.states.has(item.state)) return false;
1578
- if (ui.providers.size && !ui.providers.has(item.provider)) return false;
1579
- if (ui.generators.size && !ui.generators.has(item.generator)) return false;
1580
- if (ui.projects.size && !ui.projects.has(item.project ?? "")) return false;
1581
- if (!q) return true;
1582
- const hay = [
1583
- item.id,
1584
- item.prompt,
1585
- item.currentPrompt,
1586
- item.jobId,
1587
- item.objectId,
1588
- item.recordedSpecHash,
1589
- item.currentSpecHash,
1590
- item.category,
1591
- ...item.tags || [],
1592
- ...item.outputs.flatMap((o) => [o.path, o.sha256])
1593
- ].filter(Boolean).join("\n").toLowerCase();
1594
- return hay.includes(q);
1595
- });
1596
- const when = (item) => item.downloadedAt || item.submittedAt || "";
1597
- const sorters = {
1598
- key: (a, b) => a.id.localeCompare(b.id),
1599
- newest: (a, b) => when(b).localeCompare(when(a)) || a.id.localeCompare(b.id),
1600
- oldest: (a, b) => when(a).localeCompare(when(b)) || a.id.localeCompare(b.id),
1601
- cost: (a, b) => b.cost - a.cost || a.id.localeCompare(b.id),
1602
- size: (a, b) => b.width * b.height - a.width * a.height || a.id.localeCompare(b.id)
1603
- };
1604
- items.sort(sorters[ui.sort] || sorters.key);
1605
- return items;
1606
- }
1607
- function renderHeader() {
1608
- $("editing").hidden = !EDITABLE;
1609
- $("foot-edit").hidden = EDITABLE;
1610
- $("foot-editing").hidden = !EDITABLE;
1611
- $("foot-gen").hidden = !GENERATION;
1612
- const p = $("project");
1613
- p.textContent = "";
1614
- if (S.snap.workspace) {
1615
- p.append(el("b", null, "workspace"), " ", document.createTextNode(shortPath(S.snap.workspace.path)));
1616
- p.title = S.snap.workspace.path;
1617
- } else {
1618
- p.append(el("b", null, S.snap.project.name), " ", document.createTextNode(shortPath(S.snap.project.manifest)));
1619
- p.title = S.snap.project.manifest + "\nlockfile: " + S.snap.project.lock;
1620
- }
1621
- const t = $("totals");
1622
- t.textContent = "";
1623
- if (S.snap.workspace) {
1624
- const broken = S.snap.workspace.projects.filter((pr) => pr.error).length;
1625
- const w = el("span", null, "");
1626
- w.append(el("b", null, S.snap.workspace.projects.length), document.createTextNode(" project" + (S.snap.workspace.projects.length === 1 ? "" : "s") + (broken ? " (" + broken + " unreadable)" : "")));
1627
- t.append(w);
1628
- }
1629
- const n = S.snap.totals.entries;
1630
- t.append(el("span", null, ""));
1631
- t.lastChild.append(el("b", null, n), document.createTextNode(" lock " + (n === 1 ? "entry" : "entries")));
1632
- const declared = S.snap.items.filter((i) => i.declared).length;
1633
- t.append(el("span", null, declared + " declared by " + (S.snap.workspace ? "a manifest" : "the manifest")));
1634
- const recorded = fmtSpend(S.snap.totals.spendByUnit);
1635
- t.append(el("span", null, recorded === "no spend recorded" ? recorded : recorded + " recorded"));
1636
- if (S.snap.filter.styles.length || S.snap.filter.assets.length) {
1637
- t.append(el("span", "state-warn", "filtered: " + [
1638
- S.snap.filter.styles.length ? "--style " + S.snap.filter.styles.join(",") : "",
1639
- S.snap.filter.assets.length ? "--only " + S.snap.filter.assets.join(",") : ""
1640
- ].filter(Boolean).join(" ")));
1641
- }
1642
- t.append(el("span", null, "snapshot " + fmtWhen(S.snap.generatedAt)));
1643
- if (GENERATION) {
1644
- const line = budgetLine();
1645
- if (line) t.append(el("span", "budget", line));
1646
- }
1647
- const chips = $("chips");
1648
- chips.textContent = "";
1649
- const counts = (pick) => {
1650
- const m = /* @__PURE__ */ new Map();
1651
- for (const item of S.snap.items) {
1652
- const k = pick(item);
1653
- m.set(k, (m.get(k) || 0) + 1);
1654
- }
1655
- return m;
1656
- };
1657
- const chip = (label, count, set, key, tone) => {
1658
- const b = el("button", "chip" + (set.has(key) ? " on" : ""));
1659
- b.type = "button";
1660
- b.setAttribute("aria-pressed", String(set.has(key)));
1661
- if (tone) b.append(el("i", "dot " + tone));
1662
- b.append(document.createTextNode(label), el("span", "n", count));
1663
- b.onclick = () => {
1664
- set.has(key) ? set.delete(key) : set.add(key);
1665
- render();
1666
- };
1667
- return b;
1668
- };
1669
- if (S.snap.workspace) {
1670
- const byProject = counts((i) => i.project);
1671
- for (const pr of S.snap.workspace.projects) {
1672
- if (byProject.has(pr.id) || ui.projects.has(pr.id)) chips.append(chip(pr.id, byProject.get(pr.id) || 0, ui.projects, pr.id));
1673
- }
1674
- chips.append(el("span", "sep"));
1675
- }
1676
- const byState = counts((i) => i.state);
1677
- for (const s of STATE_ORDER) if (byState.has(s)) chips.append(chip(s, byState.get(s), ui.states, s, STATE_TONE[s]));
1678
- const byProvider = counts((i) => i.provider);
1679
- if (byProvider.size > 1 || ui.providers.size) {
1680
- chips.append(el("span", "sep"));
1681
- for (const [k, v] of [...byProvider].sort()) chips.append(chip(k, v, ui.providers, k));
1682
- }
1683
- const byGen = counts((i) => i.generator);
1684
- if (byGen.size > 1 || ui.generators.size) {
1685
- chips.append(el("span", "sep"));
1686
- for (const [k, v] of [...byGen].sort()) chips.append(chip(k, v, ui.generators, k));
2102
+ bar.append(close, el("span", null, "Choose from the candidates below. Apply selections writes the lockfile and downloads what you chose; unchosen rows stay in review."));
2103
+ const frame = el("iframe");
2104
+ frame.src = "/review/" + encodeURIComponent(jobId);
2105
+ frame.title = "Candidate review";
2106
+ panel.append(bar, frame);
2107
+ host.append(scrim, panel);
2108
+ }
2109
+ window.addEventListener("message", (e) => {
2110
+ if (e.origin !== location.origin || !e.data || typeof e.data.type !== "string") return;
2111
+ if (e.data.type === "pixelkiln:review-applied") {
2112
+ setTimeout(() => {
2113
+ $("dialog-host").textContent = "";
2114
+ pollJobs();
2115
+ refresh();
2116
+ }, 600);
2117
+ return;
1687
2118
  }
1688
- if (ui.states.size || ui.providers.size || ui.generators.size || ui.projects.size || ui.q) {
1689
- const clear = el("button", "chip", "clear filters");
1690
- clear.type = "button";
1691
- clear.onclick = () => clearFilters();
1692
- chips.append(el("span", "sep"), clear);
2119
+ if (S.SHEET && e.source === S.SHEET.frame.contentWindow) onEditorMessage(e.data);
2120
+ });
2121
+
2122
+ // src/gallery/client/src/family.ts
2123
+ var COMPASS = ["south", "south-east", "east", "north-east", "north", "north-west", "west", "south-west"];
2124
+ var SHORT = {
2125
+ south: "S",
2126
+ "south-east": "SE",
2127
+ east: "E",
2128
+ "north-east": "NE",
2129
+ north: "N",
2130
+ "north-west": "NW",
2131
+ west: "W",
2132
+ "south-west": "SW"
2133
+ };
2134
+ var ROSE = ["north-west", "north", "north-east", "west", null, "east", "south-west", "south", "south-east"];
2135
+ var SPEEDS = [0.5, 1, 2];
2136
+ var SPIN_MS = 420;
2137
+ var DRAG_STEP_PX = 28;
2138
+ var F = null;
2139
+ var reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
2140
+ var byKey = (project, key) => S.snap.items.find((i) => i.key === key && i.project === project);
2141
+ function familyRoot(item) {
2142
+ let at = item;
2143
+ for (let guard = 0; at && at.character && at.character.parentKey && guard < 16; guard++) {
2144
+ at = byKey(at.project, at.character.parentKey);
1693
2145
  }
2146
+ return at && at.character && !at.character.parentKey ? at : null;
1694
2147
  }
1695
- function clearFilters() {
1696
- ui.states.clear();
1697
- ui.providers.clear();
1698
- ui.generators.clear();
1699
- ui.projects.clear();
1700
- ui.q = "";
1701
- $("q").value = "";
1702
- render();
2148
+ function familyMembers(root) {
2149
+ return S.snap.items.filter((i) => i.project === root.project && i.character && familyRoot(i) === root);
1703
2150
  }
1704
- function thumb(item) {
1705
- const cell = el("div", "cell");
1706
- const shown = item.edit && item.editStatus === "edited" && item.edits.every((e) => e.url) ? item.edits : item.outputs.filter((o) => o.url);
1707
- if (!shown.length) {
1708
- cell.classList.add("none");
1709
- cell.append(el("span", null, item.state === "missing" ? "not generated yet" : item.state === "in-flight" ? item.status === "review" ? "awaiting review" : "awaiting provider" : item.state === "recoverable" ? "ready to fetch" : item.state === "failed" ? "generation failed" : item.state === "blocked" ? "waiting on its parent" : item.outputs.length ? "file missing on disk" : "no output recorded"));
1710
- return cell;
1711
- }
1712
- const w = item.width, h = item.height;
1713
- if (shown.length > 1 && !isFrameSet(item)) {
1714
- const multi = el("div", "multi");
1715
- for (const o of shown.slice(0, 4)) {
1716
- const box = el("div");
1717
- const img2 = el("img");
1718
- img2.src = o.url;
1719
- img2.alt = o.role || item.assetId;
1720
- img2.loading = "lazy";
1721
- const s2 = displayScale(w, h, 66, 52);
1722
- if (s2 >= 1) {
1723
- img2.width = w * s2;
1724
- img2.height = h * s2;
1725
- } else img2.className = "fit";
1726
- box.append(img2);
1727
- multi.append(box);
1728
- }
1729
- cell.append(multi, el("span", "badge", shown.length + " outputs"));
1730
- return cell;
1731
- }
1732
- const img = el("img");
1733
- img.src = shown[0].url;
1734
- img.alt = item.assetId;
1735
- img.loading = "lazy";
1736
- const s = displayScale(w, h, 156, 116);
1737
- if (s >= 1) {
1738
- img.width = w * s;
1739
- img.height = h * s;
1740
- } else img.className = "fit";
1741
- cell.append(img);
1742
- if (isFrameSet(item)) cell.append(el("span", "badge", "▸ " + shown.length + " frames"));
1743
- else if (s >= 1 && s !== 1) cell.append(el("span", "badge", s + "×"));
1744
- return cell;
2151
+ function loopName(item) {
2152
+ const d = item.character && item.character.direction;
2153
+ return d && item.assetId.endsWith("." + d) ? item.assetId.slice(0, -(d.length + 1)) : item.assetId;
1745
2154
  }
1746
- function card(item) {
1747
- const slot = ui.compare.indexOf(item.id);
1748
- const c = el("button", "card" + (item.outputs.some((o) => o.url) ? "" : " ghost") + (ui.open === item.id ? " active" : "") + (slot >= 0 ? " compared" : ""));
1749
- c.type = "button";
1750
- c.dataset.key = item.id;
1751
- c.setAttribute("aria-label", item.id + ", " + item.state + (slot >= 0 ? ", in comparison" : ""));
1752
- const body = el("div", "cbody");
1753
- body.append(el("div", "aid", item.assetId));
1754
- const meta = el("div", "cmeta");
1755
- const st = el("span", "st");
1756
- st.append(el("i", "dot " + STATE_TONE[item.state]), document.createTextNode(item.state));
1757
- meta.append(st, el("span", null, item.width + "×" + item.height));
1758
- if (item.cost) meta.append(el("span", null, fmtCost(item.costUnit, item.cost)));
1759
- body.append(meta);
1760
- const family = familyLabel(item);
1761
- if (family) body.append(el("div", "family", family));
1762
- const cell = thumb(item);
1763
- if (slot >= 0) cell.append(el("span", "slot", String(slot + 1)));
1764
- if (item.editStatus === "edited" || item.editStatus === "regenerated-since") {
1765
- const pen = el("span", "pen", "✎");
1766
- pen.title = "hand-edited";
1767
- cell.append(pen);
1768
- }
1769
- const job = activeJobFor(item);
1770
- if (job) {
1771
- const badge = el("span", "busy-badge");
1772
- badge.append(el("i", "dot busy"), document.createTextNode(job.phase === "review" ? "review" : job.mode === "generate" ? "generating" : job.mode));
1773
- badge.title = (JOB_VERB[job.mode] || job.mode) + " this asset: " + (PHASE_TEXT[job.phase] || job.phase);
1774
- cell.append(badge);
1775
- }
1776
- c.append(cell, body);
1777
- c.onclick = (e) => {
1778
- if (e.shiftKey) toggleCompare(item.id);
1779
- else openItem(item.id);
2155
+ function turnOrder(item) {
2156
+ const roles = new Set(item.outputs.map((o) => o.role));
2157
+ const shown = COMPASS.filter((d) => roles.has(d));
2158
+ if (shown.length) return shown;
2159
+ const n = item.character ? item.character.directions : 1;
2160
+ return n >= 8 ? COMPASS : n === 4 ? ["south", "east", "north", "west"] : ["south"];
2161
+ }
2162
+ var rotationUrl = (item, direction) => {
2163
+ const out = item && item.outputs.find((o) => o.role === direction && o.url);
2164
+ return out ? out.url : item && direction === "south" && item.outputs[0] && item.outputs[0].url && !item.outputs[0].role ? item.outputs[0].url : null;
2165
+ };
2166
+ var frameUrls = (item) => item.outputs.filter((o) => o.url).map((o) => o.url);
2167
+ function openFamily(item) {
2168
+ const root = familyRoot(item);
2169
+ if (!root) return;
2170
+ stopFamily();
2171
+ const turnable = item.character && (item.character.kind === "base" || item.character.kind === "state") ? item : root;
2172
+ F = {
2173
+ rootId: root.id,
2174
+ drawn: null,
2175
+ source: turnable.id,
2176
+ direction: item.character && item.character.direction && turnOrder(turnable).includes(item.character.direction) ? item.character.direction : "south",
2177
+ spinning: false,
2178
+ playing: !reducedMotion(),
2179
+ speed: 1,
2180
+ t0: performance.now(),
2181
+ lastSpin: 0,
2182
+ raf: null,
2183
+ panel: null,
2184
+ anims: [],
2185
+ columns: /* @__PURE__ */ new Map(),
2186
+ turn: null
1780
2187
  };
1781
- return c;
2188
+ renderFamily();
1782
2189
  }
1783
- function renderMain(items) {
1784
- const root = $("root");
1785
- root.textContent = "";
1786
- if (!S.snap.items.length) {
1787
- const e = el("div", "empty");
1788
- e.append(el("p", null, "Nothing has been generated for this project yet."));
1789
- e.append(el("p", null, "pixelkiln plan shows what the manifest would make; pixelkiln gen makes it."));
1790
- root.append(e);
2190
+ function closeFamily() {
2191
+ stopFamily();
2192
+ $("dialog-host").textContent = "";
2193
+ }
2194
+ function stopFamily() {
2195
+ if (F && F.raf !== null) cancelAnimationFrame(F.raf);
2196
+ F = null;
2197
+ }
2198
+ function refreshFamily() {
2199
+ if (!F) return;
2200
+ if (!F.panel || !F.panel.isConnected) {
2201
+ stopFamily();
1791
2202
  return;
1792
2203
  }
1793
- if (!items.length) {
1794
- const e = el("div", "empty");
1795
- e.append(el("p", null, "No generations match these filters."));
1796
- const b = el("button", null, "Clear filters");
1797
- b.type = "button";
1798
- b.onclick = clearFilters;
1799
- e.append(b);
1800
- root.append(e);
2204
+ if (F.drawn !== S.snap) renderFamily();
2205
+ }
2206
+ function renderFamily() {
2207
+ if (!F) return;
2208
+ const root = S.snap.items.find((i) => i.id === F.rootId);
2209
+ if (!root) {
2210
+ closeFamily();
1801
2211
  return;
1802
2212
  }
1803
- const hidden = Math.max(0, items.length - ui.limit);
1804
- const shown = hidden ? items.slice(0, ui.limit) : items;
1805
- const unfiltered = !ui.projects.size && !ui.q && !ui.states.size && !ui.providers.size && !ui.generators.size;
1806
- const projects = S.snap.workspace ? S.snap.workspace.projects.filter((pr) => ui.projects.size ? ui.projects.has(pr.id) : unfiltered || shown.some((i) => i.project === pr.id)) : [null];
1807
- const sections = [];
1808
- for (const pr of projects) {
1809
- const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
1810
- 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 }] : [];
1811
- if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
1812
- sections.push(...styleSections);
1813
- }
1814
- for (const sec of sections) {
1815
- if (sec.project) {
1816
- root.append(projectHeader(sec.project, sec.count));
1817
- continue;
1818
- }
1819
- const wrap = el("section", "style");
1820
- if (sec.style) {
1821
- const s = sec.style;
1822
- const head = el("div", "shead");
1823
- head.append(el("h2", null, s.id));
1824
- const meta = el("div", "meta");
1825
- meta.append(el("span", null, s.provider), el("span", null, s.generator));
1826
- if (s.outDir) meta.append(el("span", "mono", s.outDir));
1827
- if (s.palette.length) {
1828
- const sw = el("span", "swatches");
1829
- for (const c of s.palette) {
1830
- const i = el("i");
1831
- i.style.background = c;
1832
- i.title = c;
1833
- sw.append(i);
1834
- }
1835
- meta.append(sw);
1836
- }
1837
- if (s.quality) meta.append(el("span", null, "quality profile"));
1838
- if (s.characters) {
1839
- const parts = [];
1840
- parts.push(s.characters.bases + (s.characters.bases === 1 ? " character" : " characters"));
1841
- if (s.characters.states) parts.push(s.characters.states + (s.characters.states === 1 ? " state" : " states"));
1842
- if (s.characters.animations) parts.push(s.characters.animations + (s.characters.animations === 1 ? " loop" : " loops"));
1843
- if (s.characters.portraits) parts.push(s.characters.portraits + (s.characters.portraits === 1 ? " portrait" : " portraits"));
1844
- if (s.characters.outfits) parts.push(s.characters.outfits + (s.characters.outfits === 1 ? " outfit" : " outfits"));
1845
- meta.append(el("span", null, parts.join(", ")));
1846
- }
1847
- meta.append(el("span", null, sec.items.length + " of " + s.items + (s.items === 1 ? " asset" : " assets")));
1848
- if (Object.keys(s.spendByUnit).length) meta.append(el("span", null, fmtSpend(s.spendByUnit)));
1849
- head.append(meta);
1850
- const addKey = "new:" + (s.project || "") + ":" + s.id;
1851
- const tools = el("div", "tools");
1852
- if (s.outDir && s.candidates !== null) tools.append(candidatesControl(s));
1853
- if (GENERATION && s.actionable.keys.length) {
1854
- const g = el("button", "primary", "Generate " + s.actionable.keys.length + " · " + fmtCost(s.actionable.costUnit, Math.round(s.actionable.cost * 100) / 100));
1855
- g.type = "button";
1856
- g.onclick = () => generateDialog(S.snap.items.filter((i) => i.project === s.project && s.actionable.keys.includes(i.key)), { project: s.project });
1857
- tools.append(g);
1858
- }
1859
- const refreshable = S.snap.items.filter((i) => i.project === s.project && i.styleId === s.id && i.refreshable && i.upstreamUrl);
1860
- if (GENERATION && refreshable.length) {
1861
- const r = el("button", null, "Pull upstream " + refreshable.length);
1862
- r.type = "button";
1863
- r.title = "Re-download objects edited in the provider’s own editor; no cost";
1864
- r.onclick = () => generateDialog(refreshable, { project: s.project, refresh: true });
1865
- tools.append(r);
1866
- }
1867
- const styleKey = "style:" + (s.project || "") + ":" + s.id;
1868
- if (EDITABLE && s.outDir) {
1869
- const es = el("button", null, ui.editing === styleKey ? "Cancel" : "Edit style");
1870
- es.type = "button";
1871
- es.onclick = () => {
1872
- ui.editing = ui.editing === styleKey ? null : styleKey;
1873
- ui.notice = null;
1874
- render();
1875
- };
1876
- tools.append(es);
1877
- const add = el("button", "add", ui.editing === addKey ? "Cancel" : "+ Add asset");
1878
- add.type = "button";
1879
- add.onclick = () => {
1880
- ui.editing = ui.editing === addKey ? null : addKey;
1881
- render();
1882
- };
1883
- tools.append(add);
1884
- }
1885
- if (tools.childNodes.length) head.append(tools);
1886
- wrap.append(head);
1887
- if (ui.notice && ui.notice.id === styleKey) wrap.append(el("div", "notice", ui.notice.text));
1888
- if (ui.editing === styleKey) wrap.append(styleForm(s));
1889
- if (ui.editing === addKey) wrap.append(addAssetForm(s));
1890
- }
1891
- const grid = el("div", "grid");
1892
- for (const item of sec.items) grid.append(card(item));
1893
- wrap.append(grid);
1894
- root.append(wrap);
1895
- }
1896
- if (hidden) {
1897
- const more = el("div", "empty");
1898
- more.append(el("p", null, hidden + " more " + (hidden === 1 ? "generation is" : "generations are") + " not shown."));
1899
- const b = el("button", null, "Show all " + items.length);
1900
- b.type = "button";
1901
- b.onclick = () => {
1902
- ui.limit = Infinity;
1903
- render();
1904
- };
1905
- more.append(b);
1906
- root.append(more);
2213
+ F.drawn = S.snap;
2214
+ const members = familyMembers(root);
2215
+ const turnables = members.filter((m) => m.character.kind === "base" || m.character.kind === "state");
2216
+ if (!turnables.some((m) => m.id === F.source)) F.source = root.id;
2217
+ const source = turnables.find((m) => m.id === F.source) || root;
2218
+ const order = turnOrder(source);
2219
+ if (!order.includes(F.direction)) F.direction = order[0];
2220
+ const host = $("dialog-host");
2221
+ const scroll = F.panel ? F.panel.scrollTop : 0;
2222
+ host.textContent = "";
2223
+ F.anims = [];
2224
+ F.columns = /* @__PURE__ */ new Map();
2225
+ const scrim = el("div", "sheet-scrim");
2226
+ scrim.onclick = closeFamily;
2227
+ const panel = el("div", "sheet family-sheet");
2228
+ panel.setAttribute("role", "dialog");
2229
+ panel.setAttribute("aria-label", "Family of " + root.key);
2230
+ F.panel = panel;
2231
+ const bar = el("div", "rbar");
2232
+ const back = el("button", null, "Back to gallery");
2233
+ back.type = "button";
2234
+ back.onclick = closeFamily;
2235
+ const title = el("b", "mono", root.key);
2236
+ const c = root.character;
2237
+ bar.append(back, title, el("span", null, (c.generator === "objectPro" ? "object" : "character") + " · " + c.mode + " · " + root.width + "×" + root.height));
2238
+ const tally = el("span", "family-tally");
2239
+ const counts = {};
2240
+ for (const m of members) counts[m.state] = (counts[m.state] || 0) + 1;
2241
+ for (const [state, n] of Object.entries(counts)) {
2242
+ const t = el("span", "st");
2243
+ t.append(el("i", "dot " + STATE_TONE[state]), document.createTextNode(n + " " + state));
2244
+ tally.append(t);
1907
2245
  }
1908
- }
1909
- function projectHeader(pr, count) {
1910
- const head = el("section", "project");
1911
- const line = el("div", "phead");
1912
- line.append(el("h2", null, pr.id));
1913
- const meta = el("div", "meta");
1914
- if (pr.name !== pr.id) meta.append(el("span", null, pr.name));
1915
- meta.append(el("span", "mono", shortPath(pr.manifest)));
1916
- if (pr.account) meta.append(el("span", null, pr.account));
1917
- if (pr.error) {
1918
- meta.append(el("span", "state-bad", "unreadable: " + pr.error));
1919
- } else {
1920
- meta.append(el("span", null, count + " of " + pr.items + " shown · " + pr.entries + " lock " + (pr.entries === 1 ? "entry" : "entries")));
1921
- if (Object.keys(pr.spendByUnit).length) meta.append(el("span", null, fmtSpend(pr.spendByUnit)));
2246
+ bar.append(tally);
2247
+ if (GENERATION) {
2248
+ const todo = members.filter((m) => m.declared && m.currentSpecHash !== null && ["missing", "stale", "failed"].includes(m.state));
2249
+ if (todo.length) {
2250
+ const totals = {};
2251
+ for (const m of todo) if (m.estimatedCost !== null) totals[m.costUnit] = (totals[m.costUnit] || 0) + m.estimatedCost;
2252
+ const price = Object.entries(totals).map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(" + ");
2253
+ const g = el("button", "primary", "Generate " + todo.length + (price ? " · " + price : ""));
2254
+ g.type = "button";
2255
+ g.title = "One job for the whole family: parents first, then everything drawn from them";
2256
+ g.onclick = () => {
2257
+ stopFamily();
2258
+ generateDialog(todo, { project: root.project });
2259
+ };
2260
+ bar.append(g);
2261
+ }
1922
2262
  }
1923
- line.append(meta);
1924
- head.append(line);
1925
- return head;
2263
+ panel.append(bar);
2264
+ const body = el("div", "family-body");
2265
+ body.append(turntable(root, turnables, source, order));
2266
+ body.append(loopGrid(root, members, order));
2267
+ const extras = members.filter((m) => m.character.kind === "portrait" || m.character.kind === "outfit");
2268
+ if (extras.length) body.append(extrasStrip(extras));
2269
+ panel.append(body);
2270
+ host.append(scrim, panel);
2271
+ panel.scrollTop = scroll;
2272
+ highlight(F.direction);
2273
+ if (F.raf === null) F.raf = requestAnimationFrame(tick);
1926
2274
  }
1927
- function candidatesControl(style) {
1928
- const wrap = el("span", "cand");
1929
- const editKey = "cand:" + (style.project || "") + ":" + style.id;
1930
- const declared = S.snap.items.filter((i) => i.project === style.project && i.styleId === style.id && i.declared).length;
1931
- if (!(EDITABLE && style.candidatesEditable) || ui.editing !== editKey) {
1932
- wrap.append(el("span", null, style.candidates + (style.candidates === 1 ? " candidate" : " candidates") + " per generation"));
1933
- if (EDITABLE && style.candidatesEditable) {
1934
- const b = el("button", null, "change");
2275
+ function turntable(root, turnables, source, order) {
2276
+ const wrap = el("section", "turntable");
2277
+ if (turnables.length > 1) {
2278
+ const tabs = el("div", "family-tabs");
2279
+ for (const t of turnables) {
2280
+ const b = el("button", t.id === source.id ? "on" : null, t.id === root.id ? "base" : t.assetId.replace(root.assetId + ".", ""));
1935
2281
  b.type = "button";
2282
+ b.title = t.key + " · " + t.state;
1936
2283
  b.onclick = () => {
1937
- ui.editing = editKey;
1938
- render();
2284
+ F.source = t.id;
2285
+ renderFamily();
1939
2286
  };
1940
- wrap.append(b);
1941
- } else if (style.provider === "pixellab") {
1942
- wrap.title = "PixelLab: map and pixflux return one image per generation; a 1dir style returns 4–64 for its size.";
2287
+ tabs.append(b);
1943
2288
  }
1944
- return wrap;
2289
+ wrap.append(tabs);
1945
2290
  }
1946
- const input = el("input");
1947
- input.type = "number";
1948
- input.min = "1";
1949
- input.max = "64";
1950
- input.value = String(style.candidates);
1951
- const save = el("button", "primary", "Save");
1952
- save.type = "button";
1953
- const cancel = el("button", null, "Cancel");
1954
- cancel.type = "button";
1955
- cancel.onclick = () => {
1956
- ui.editing = null;
1957
- render();
2291
+ const stage = el("div", "tt-stage");
2292
+ stage.tabIndex = 0;
2293
+ stage.setAttribute("role", "img");
2294
+ const img = el("img");
2295
+ const empty = el("div", "tt-empty");
2296
+ const scale = displayScale(source.width, source.height, 256, 256);
2297
+ img.width = Math.round(source.width * scale);
2298
+ img.height = Math.round(source.height * scale);
2299
+ img.alt = source.assetId;
2300
+ stage.append(img, empty);
2301
+ for (const d of order) {
2302
+ const u = rotationUrl(source, d);
2303
+ if (u) {
2304
+ const p = new Image();
2305
+ p.src = u;
2306
+ }
2307
+ }
2308
+ const label = el("div", "tt-label");
2309
+ const rose = el("div", "tt-rose");
2310
+ const roseButtons = /* @__PURE__ */ new Map();
2311
+ for (const d of ROSE) {
2312
+ if (!d) {
2313
+ rose.append(el("i", "tt-hub"));
2314
+ continue;
2315
+ }
2316
+ const b = el("button", null, SHORT[d]);
2317
+ b.type = "button";
2318
+ b.title = d;
2319
+ b.disabled = !order.includes(d);
2320
+ b.onclick = () => turn(d);
2321
+ roseButtons.set(d, b);
2322
+ rose.append(b);
2323
+ }
2324
+ const show = (direction) => {
2325
+ const url = rotationUrl(source, direction);
2326
+ img.hidden = !url;
2327
+ empty.hidden = !!url;
2328
+ if (url) img.src = url;
2329
+ else empty.textContent = source.state === "ok" ? "no " + direction + " rotation" : source.state + ": " + (source.reason || "not generated yet");
2330
+ stage.setAttribute("aria-label", source.assetId + " facing " + direction);
2331
+ label.textContent = direction + " · " + (order.indexOf(direction) + 1) + " of " + order.length;
2332
+ for (const [d, b] of roseButtons) b.classList.toggle("on", d === direction);
1958
2333
  };
1959
- const note = el("span", null, "affects " + declared + (declared === 1 ? " asset, which becomes stale" : " assets, which become stale"));
1960
- save.onclick = async () => {
1961
- save.disabled = true;
1962
- try {
1963
- const pr = S.snap.workspace ? S.snap.workspace.projects.find((x) => x.id === style.project) : S.snap.project;
1964
- const body = { action: "patch-style", styleId: style.id, expectedSha256: pr.manifestSha256, patch: { candidates: Number(input.value) } };
1965
- if (style.project) body.project = style.project;
1966
- S.snap = await postEdit(body);
1967
- ui.editing = null;
1968
- render();
1969
- } catch (err) {
1970
- save.disabled = false;
1971
- note.textContent = err.message;
1972
- note.className = "state-bad";
2334
+ const turn = (direction) => {
2335
+ if (!F) return;
2336
+ F.direction = direction;
2337
+ show(direction);
2338
+ highlight(direction);
2339
+ };
2340
+ const stepBy = (n) => {
2341
+ const i = order.indexOf(F.direction);
2342
+ turn(order[(i + n + order.length * 4) % order.length]);
2343
+ };
2344
+ F.turn = turn;
2345
+ show(F.direction);
2346
+ let dragX = null;
2347
+ stage.onpointerdown = (e) => {
2348
+ dragX = e.clientX;
2349
+ stage.setPointerCapture(e.pointerId);
2350
+ F.spinning = false;
2351
+ spin.classList.remove("on");
2352
+ };
2353
+ stage.onpointermove = (e) => {
2354
+ if (dragX === null) return;
2355
+ const steps = Math.trunc((e.clientX - dragX) / DRAG_STEP_PX);
2356
+ if (steps) {
2357
+ dragX += steps * DRAG_STEP_PX;
2358
+ stepBy(steps);
1973
2359
  }
1974
2360
  };
1975
- wrap.append(input, save, cancel, note);
1976
- setTimeout(() => input.focus(), 0);
2361
+ stage.onpointerup = stage.onpointercancel = () => {
2362
+ dragX = null;
2363
+ };
2364
+ stage.onkeydown = (e) => {
2365
+ if (e.key === "ArrowRight") {
2366
+ e.preventDefault();
2367
+ e.stopPropagation();
2368
+ stepBy(1);
2369
+ } else if (e.key === "ArrowLeft") {
2370
+ e.preventDefault();
2371
+ e.stopPropagation();
2372
+ stepBy(-1);
2373
+ }
2374
+ };
2375
+ const controls = el("div", "tt-controls");
2376
+ const prev = el("button", null, "◀");
2377
+ prev.type = "button";
2378
+ prev.title = "Turn back";
2379
+ prev.onclick = () => stepBy(-1);
2380
+ const next = el("button", null, "▶");
2381
+ next.type = "button";
2382
+ next.title = "Turn on";
2383
+ next.onclick = () => stepBy(1);
2384
+ const spin = el("button", F.spinning ? "on" : null, "Spin");
2385
+ spin.type = "button";
2386
+ spin.title = "Turn through every rotation on a loop";
2387
+ spin.onclick = () => {
2388
+ F.spinning = !F.spinning;
2389
+ F.lastSpin = performance.now();
2390
+ spin.classList.toggle("on", F.spinning);
2391
+ };
2392
+ prev.disabled = next.disabled = spin.disabled = order.length < 2;
2393
+ controls.append(prev, next, spin);
2394
+ const side = el("div", "tt-side");
2395
+ side.append(rose, controls, label);
2396
+ const open = el("button", "linkish", "Open " + source.assetId);
2397
+ open.type = "button";
2398
+ open.onclick = () => {
2399
+ closeFamily();
2400
+ openItem(source.id);
2401
+ };
2402
+ side.append(open);
2403
+ const row2 = el("div", "tt-row");
2404
+ row2.append(stage, side);
2405
+ wrap.append(row2);
2406
+ wrap.append(el("small", "state-dim", order.length > 1 ? "Drag the sprite sideways, use ← and →, or pick a direction to turn it. The loops below follow." : "One direction: this " + (root.character.generator === "objectPro" ? "object" : "character") + " does not turn."));
1977
2407
  return wrap;
1978
2408
  }
1979
- function historySection(item) {
1980
- const limit = projectOf(item)?.historyLimit;
1981
- if (!item.history.length && !((limit ?? 0) > 0 && item.status === "downloaded")) return null;
1982
- const s = el("section", "meta");
1983
- s.append(el("h3", null, item.history.length ? "Previous generations (" + item.history.length + ")" : "Previous generations"));
1984
- if (!item.history.length) {
1985
- s.append(el("div", "state-dim", "None yet. A regeneration keeps the generation it replaces, up to " + limit + " per asset (PIXELKILN_HISTORY, or the manifest’s history), so it can be brought back from here."));
1986
- return s;
2409
+ function loopGrid(root, members, order) {
2410
+ const loops = members.filter((m) => m.character.kind === "animation");
2411
+ const wrap = el("section", "loop-grid");
2412
+ const head = el("div", "lg-head");
2413
+ head.append(el("h3", null, loops.length ? "Loops" : "No loops yet"));
2414
+ if (loops.length) {
2415
+ const play = el("button", null, F.playing ? "Pause" : "Play");
2416
+ play.type = "button";
2417
+ play.onclick = () => {
2418
+ F.playing = !F.playing;
2419
+ play.textContent = F.playing ? "Pause" : "Play";
2420
+ if (F.playing) F.t0 = performance.now();
2421
+ };
2422
+ head.append(play);
2423
+ const speed = el("select");
2424
+ speed.setAttribute("aria-label", "Playback speed");
2425
+ for (const s of SPEEDS) speed.append(new Option(s + "×", String(s)));
2426
+ speed.value = String(F.speed);
2427
+ speed.onchange = () => {
2428
+ F.speed = Number(speed.value);
2429
+ F.t0 = performance.now();
2430
+ };
2431
+ head.append(speed);
1987
2432
  }
1988
- const list = el("div", "versions");
1989
- const job = GENERATION ? activeJobFor(item) : null;
1990
- for (const g of item.history) {
1991
- const v = el("div", "version");
1992
- const thumb2 = el("div", "thumb");
1993
- const first = g.outputs[0];
1994
- if (first && first.url) {
1995
- const img = el("img");
1996
- img.src = first.url;
1997
- img.alt = "generation #" + g.index;
1998
- thumb2.append(img);
1999
- } else thumb2.append(el("span", null, g.cached ? "" : "not cached"));
2000
- const facts = el("div", "facts");
2001
- const head = el("div");
2002
- const billedNote = g.billed && (g.billed.unit !== g.costUnit || g.billed.amount !== g.cost) ? " (billed " + fmtCost(g.billed.unit, g.billed.amount) + ")" : "";
2003
- head.append(el("b", null, "#" + g.index), document.createTextNode(" · " + fmtWhen(g.downloadedAt || g.submittedAt) + " · " + fmtCost(g.costUnit, g.cost) + billedNote + (g.outputs.length > 1 ? " · " + g.outputs.length + " files" : "")));
2004
- facts.append(head);
2005
- const prompt = el("div", "prompt" + (g.promptDiffers ? " differs" : ""), (g.promptDiffers ? "prompt: " : "same prompt: ") + g.prompt);
2006
- prompt.title = g.prompt;
2007
- facts.append(prompt);
2008
- const meta = el("div", "meta");
2009
- meta.append(document.createTextNode((first ? first.sha256.slice(0, 12) + "…" : "") + (g.cached ? " · bytes cached locally" : " · not in the local cache; a restore re-downloads it")));
2010
- if (g.upstreamUrl) {
2011
- const a = el("a", null, "open in " + item.provider + " ↗");
2012
- a.href = g.upstreamUrl;
2013
- a.target = "_blank";
2014
- a.rel = "noopener";
2015
- meta.append(document.createTextNode(" · "), a);
2016
- }
2017
- facts.append(meta);
2018
- const acts = el("div", "acts");
2019
- if (GENERATION) {
2020
- const b = el("button", null, "Restore this one · no cost");
2021
- b.type = "button";
2022
- b.disabled = !!job;
2023
- b.title = job ? "Wait for the running job to finish" : "The current generation moves into history; this one becomes current and its bytes are written back";
2024
- b.onclick = () => generateDialog([item], { project: item.project, revert: String(g.index), generation: g });
2025
- acts.append(b);
2026
- }
2027
- v.append(thumb2, facts, acts);
2028
- list.append(v);
2433
+ wrap.append(head);
2434
+ if (!loops.length) {
2435
+ wrap.append(el("p", "state-dim", EDITABLE ? "Add one from the base's drawer (+ New animation), or draft a whole character in the studio." : "None declared."));
2436
+ return wrap;
2029
2437
  }
2030
- s.append(list);
2031
- s.append(el("div", "state-dim", "Keeping up to " + limit + " per asset; a restore moves the current generation into this list, so it can be undone the same way." + (GENERATION ? "" : " Restoring needs the gallery started with --budget (any amount; nothing is spent).")));
2032
- return s;
2033
- }
2034
- function upstreamSection(item) {
2035
- if (!item.upstreamUrl && !(GENERATION && item.refreshable)) return null;
2036
- const s = el("section", "meta");
2037
- s.append(el("h3", null, "Upstream"));
2038
- const dl = el("dl");
2039
- if (item.upstreamUrl) {
2040
- const a = el("a", null, "Open in " + item.provider + " ↗");
2041
- a.href = item.upstreamUrl;
2042
- a.target = "_blank";
2043
- a.rel = "noopener";
2044
- const tiles = item.generator === "tiles";
2045
- row(dl, tiles ? "tile set" : "object", a);
2046
- row(dl, "note", item.provider === "pixellab" ? tiles ? "The tile set this generation was chosen from. Edit it there with PixelLab’s editor, then pull the changes back here; the lockfile records the new bytes as this generation." : "The account object this generation came from. Edit it there with PixelLab’s editor, then pull the changes back here; the lockfile records the new bytes as this generation." : "The provider’s page for this object.");
2438
+ const used = /* @__PURE__ */ new Set([...order, ...loops.map((l) => l.character.direction).filter(Boolean)]);
2439
+ const columns = COMPASS.filter((d) => used.has(d));
2440
+ const rows = /* @__PURE__ */ new Map();
2441
+ for (const l of loops) {
2442
+ const k = (l.character.parentKey || "") + "|" + loopName(l);
2443
+ if (!rows.has(k)) rows.set(k, []);
2444
+ rows.get(k).push(l);
2047
2445
  }
2048
- s.append(dl);
2049
- if (GENERATION && item.refreshable) {
2050
- const acts = el("div", "hand-actions");
2051
- const b = el("button", null, "Pull upstream changes · no cost");
2446
+ const table = el("table", "lg");
2447
+ const thead = el("thead");
2448
+ const hr = el("tr");
2449
+ hr.append(el("th", "lg-name", ""));
2450
+ for (const d of columns) {
2451
+ const th = el("th", null);
2452
+ const b = el("button", "lg-dir", SHORT[d]);
2052
2453
  b.type = "button";
2053
- b.onclick = () => generateDialog([item], { project: item.project, refresh: true });
2054
- acts.append(b);
2055
- s.append(acts);
2454
+ b.title = "Turn to " + d;
2455
+ b.disabled = !order.includes(d);
2456
+ b.onclick = () => F && F.turn && F.turn(d);
2457
+ th.append(b);
2458
+ hr.append(th);
2459
+ column(d, th);
2056
2460
  }
2057
- return s;
2058
- }
2059
- function jobStrip(item, job) {
2060
- const strip = el("div", "job-strip");
2061
- const ph = el("span", "ph");
2062
- ph.append(el("i", "dot busy"), document.createTextNode(job.phase));
2063
- const last = el("span", "last", job.messages.length ? job.messages[job.messages.length - 1].trim() : "");
2064
- last.title = job.messages.join("\n");
2065
- const what = el("span", "what", (JOB_VERB[job.mode] || job.mode) + " this asset: " + (PHASE_TEXT[job.phase] || job.phase) + (job.mode === "generate" && job.phase !== "review" ? ". The current file stays until the new result is downloaded." : "."));
2066
- strip.append(ph, last, what);
2067
- const acts = el("div", "acts");
2068
- if (job.phase === "review" && job.review.includes(item.key)) {
2069
- const b = el("button", "primary", "Review candidates");
2070
- b.type = "button";
2071
- b.onclick = () => openReview(job.id);
2072
- acts.append(b);
2461
+ thead.append(hr);
2462
+ table.append(thead);
2463
+ const tbody = el("tbody");
2464
+ for (const [k, cells] of rows) {
2465
+ const parentKey = k.split("|")[0];
2466
+ const parent = byKey(root.project, parentKey) || root;
2467
+ const tr = el("tr");
2468
+ const th = el("th", "lg-name");
2469
+ const name = loopName(cells[0]);
2470
+ th.append(el("b", "mono", name.startsWith(root.assetId + ".") ? name.slice(root.assetId.length + 1) : name));
2471
+ const sample = cells.find((l) => !l.mirrorOfKey) || cells[0];
2472
+ const meta = [sample.character.mode, frameUrls(sample).length ? frameUrls(sample).length + " frames" : null, parent !== root ? "from " + parent.assetId.replace(root.assetId + ".", "") : null].filter(Boolean).join(" · ");
2473
+ th.append(el("small", null, meta));
2474
+ tr.append(th);
2475
+ for (const d of columns) {
2476
+ const td = el("td");
2477
+ column(d, td);
2478
+ const item = cells.find((l) => l.character.direction === d);
2479
+ td.append(item ? loopCell(item, parent, d) : el("span", "lg-none", "·"));
2480
+ tr.append(td);
2481
+ }
2482
+ tbody.append(tr);
2073
2483
  }
2074
- const logBtn = el("button", null, ui.logs.has(job.id) ? "Hide log" : "Log");
2075
- logBtn.type = "button";
2076
- logBtn.onclick = () => {
2077
- ui.logs.has(job.id) ? ui.logs.delete(job.id) : ui.logs.add(job.id);
2078
- renderDrawer();
2484
+ table.append(tbody);
2485
+ const scroller = el("div", "lg-scroll");
2486
+ scroller.append(table);
2487
+ wrap.append(scroller);
2488
+ 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."));
2489
+ return wrap;
2490
+ }
2491
+ function column(direction, node) {
2492
+ if (!F.columns.has(direction)) F.columns.set(direction, []);
2493
+ F.columns.get(direction).push(node);
2494
+ }
2495
+ function loopCell(item, parent, direction) {
2496
+ const tone = STATE_TONE[item.state] || "dim";
2497
+ const b = el("button", "lg-cell tone-" + tone);
2498
+ b.type = "button";
2499
+ b.title = item.key + " · " + item.state + (item.reason ? ": " + item.reason : "") + (item.mirrorOfKey ? " · mirror of " + item.mirrorOfKey : "");
2500
+ b.onclick = () => {
2501
+ closeFamily();
2502
+ openItem(item.id);
2079
2503
  };
2080
- acts.append(logBtn);
2081
- strip.append(acts);
2082
- if (ui.logs.has(job.id)) {
2083
- const pre = el("pre", null, job.messages.join("\n"));
2084
- pre.style.gridColumn = "1 / -1";
2085
- strip.append(pre);
2504
+ const img = el("img");
2505
+ img.alt = item.assetId;
2506
+ const urls = frameUrls(item);
2507
+ if (urls.length) {
2508
+ img.src = urls[0];
2509
+ for (const u of urls) {
2510
+ const p = new Image();
2511
+ p.src = u;
2512
+ }
2513
+ if (urls.length > 1) F.anims.push({ img, urls, fps: item.fps || 12, index: 0 });
2514
+ } else {
2515
+ const ghost = rotationUrl(parent, direction);
2516
+ if (ghost) {
2517
+ img.src = ghost;
2518
+ b.classList.add("ghost");
2519
+ } else img.hidden = true;
2086
2520
  }
2087
- return strip;
2521
+ b.append(img);
2522
+ if (item.mirrorOfKey) b.append(el("span", "lg-mirror", "⇋"));
2523
+ if (item.state !== "ok") b.append(el("span", "lg-state", item.state));
2524
+ return b;
2088
2525
  }
2089
- function generateActions(item) {
2090
- const row2 = el("div", "gen");
2091
- if (!GENERATION || !item.declared || item.currentSpecHash === null) return row2;
2092
- const cost = item.estimatedCost === null ? "" : " · " + fmtCost(item.costUnit, item.estimatedCost);
2093
- const add = (label, cls, opts) => {
2094
- const b = el("button", cls, label);
2526
+ function extrasStrip(extras) {
2527
+ const wrap = el("section", "family-extras");
2528
+ wrap.append(el("h3", null, extras.some((e) => e.character.kind === "outfit") ? "Portraits and outfits" : extras.length === 1 ? "Portrait" : "Portraits"));
2529
+ const strip = el("div", "fx-strip");
2530
+ for (const item of extras) {
2531
+ const b = el("button", "lg-cell tone-" + (STATE_TONE[item.state] || "dim"));
2095
2532
  b.type = "button";
2096
- b.onclick = () => generateDialog([item], { project: item.project, ...opts });
2097
- row2.append(b);
2098
- };
2099
- if (item.state === "missing" || item.state === "stale" || item.state === "failed") add("Generate" + cost, "primary", {});
2100
- else if (item.state === "ok") add("Regenerate" + cost, null, { force: true });
2101
- else if (item.state === "orphaned" && item.outputs.every((o) => o.exists)) {
2102
- add("Restore recorded bytes · no cost", null, { restore: true, force: true });
2103
- add("Regenerate" + cost, null, { force: true });
2104
- } else if (item.state === "orphaned") add("Restore · no cost", "primary", { restore: true });
2105
- else if (item.state === "untracked") add("Generate" + cost + " · replaces the file", null, { force: true });
2106
- if (item.state === "recoverable" && item.status === "downloaded") {
2107
- add(paletteRuleOf(item) ? "Snap to the palette · no cost" : "Put the provider’s bytes back · no cost", "primary", { resume: true });
2108
- } else if (item.state === "in-flight" || item.state === "recoverable") {
2109
- const holder = S.GEN.jobs.find((job) => job.phase === "review" && job.project === item.project && job.review.includes(item.key));
2110
- if (holder) {
2111
- const b = el("button", "primary", "Review candidates");
2112
- b.type = "button";
2113
- b.onclick = () => openReview(holder.id);
2114
- row2.append(b);
2115
- } else {
2116
- add(item.status === "review" ? "Resume review" : "Resume · no cost", "primary", { resume: true });
2533
+ b.title = item.key + " · " + item.state;
2534
+ b.onclick = () => {
2535
+ closeFamily();
2536
+ openItem(item.id);
2537
+ };
2538
+ const img = el("img");
2539
+ img.alt = item.assetId;
2540
+ const urls = frameUrls(item);
2541
+ if (urls.length) {
2542
+ img.src = urls[0];
2543
+ if (urls.length > 1) F.anims.push({ img, urls, fps: item.fps || 12, index: 0 });
2544
+ } else img.hidden = true;
2545
+ b.append(img);
2546
+ if (item.state !== "ok") b.append(el("span", "lg-state", item.state));
2547
+ const fig = el("figure");
2548
+ fig.append(b, el("figcaption", "mono", item.assetId));
2549
+ strip.append(fig);
2550
+ }
2551
+ wrap.append(strip);
2552
+ return wrap;
2553
+ }
2554
+ function highlight(direction) {
2555
+ if (!F) return;
2556
+ for (const [d, nodes] of F.columns) for (const n of nodes) n.classList.toggle("col-on", d === direction);
2557
+ }
2558
+ function tick(now) {
2559
+ if (!F) return;
2560
+ if (!F.panel || !F.panel.isConnected) {
2561
+ stopFamily();
2562
+ return;
2563
+ }
2564
+ if (F.playing) {
2565
+ const seconds = Math.max(0, (now - F.t0) / 1e3 * F.speed);
2566
+ for (const a of F.anims) {
2567
+ const i = Math.floor(seconds * a.fps) % a.urls.length;
2568
+ if (i !== a.index) {
2569
+ a.index = i;
2570
+ a.img.src = a.urls[i];
2571
+ }
2117
2572
  }
2118
2573
  }
2119
- return row2;
2574
+ if (F.spinning && F.turn && now - F.lastSpin >= SPIN_MS / F.speed) {
2575
+ F.lastSpin = now;
2576
+ const source = S.snap.items.find((i) => i.id === F.source);
2577
+ const order = source ? turnOrder(source) : COMPASS;
2578
+ F.turn(order[(order.indexOf(F.direction) + 1) % order.length]);
2579
+ }
2580
+ F.raf = requestAnimationFrame(tick);
2120
2581
  }
2582
+ var familyState = () => F && { rootId: F.rootId, source: F.source, direction: F.direction, playing: F.playing, anims: F.anims.length };
2121
2583
 
2122
2584
  // src/gallery/client/src/pose-editor.ts
2123
2585
  var MIN_FRAMES = 3;
@@ -3023,6 +3485,15 @@
3023
3485
  if (c.kind === "animation") row(dl, "direction", c.direction || "—");
3024
3486
  else row(dl, "directions", String(c.directions));
3025
3487
  if (c.characterId) row(dl, "character id", c.characterId, { mono: true, copy: c.characterId });
3488
+ const root = familyRoot(item);
3489
+ const size = root ? familyMembers(root).length : 0;
3490
+ if (root && size > 1) {
3491
+ const fam = el("button", "add", "Family view · " + size);
3492
+ fam.type = "button";
3493
+ fam.title = "Turn " + root.assetId + " through its rotations and play every loop by direction";
3494
+ fam.onclick = () => openFamily(item);
3495
+ row(dl, "family", fam);
3496
+ }
3026
3497
  const family = S.snap.items.filter((i) => i.character && i.character.parentKey === item.key && i.project === item.project);
3027
3498
  if (family.length) {
3028
3499
  const list = el("div");
@@ -3300,6 +3771,7 @@
3300
3771
  renderMain(visibleItems());
3301
3772
  renderDrawer();
3302
3773
  renderTray();
3774
+ refreshFamily();
3303
3775
  }
3304
3776
  function renderQuietly() {
3305
3777
  const region = formRegion();
@@ -3308,6 +3780,7 @@
3308
3780
  if (region !== "main") renderMain(visibleItems());
3309
3781
  if (region !== "drawer") renderDrawer();
3310
3782
  renderTray();
3783
+ refreshFamily();
3311
3784
  }
3312
3785
 
3313
3786
  // src/gallery/client/src/refresh.ts
@@ -3340,7 +3813,7 @@
3340
3813
  ];
3341
3814
  var BODIES = ["mannequin", "bear", "cat", "dog", "horse", "lion"];
3342
3815
  var VIEWS = ["low top-down", "high top-down", "side"];
3343
- var COMPASS = ["south", "south-east", "east", "north-east", "north", "north-west", "west", "south-west"];
3816
+ var COMPASS2 = ["south", "south-east", "east", "north-east", "north", "north-west", "west", "south-west"];
3344
3817
  var FOUR = ["south", "east", "north", "west"];
3345
3818
  var PORTRAIT_SIZES = [16, 32, 48, 64, 128, 160];
3346
3819
  var LOOP_MODES = [
@@ -3469,8 +3942,8 @@
3469
3942
  const chosenEngine = () => ENGINES.find((e) => e.id === engine.value);
3470
3943
  const isNewStyle = () => style.value === "";
3471
3944
  const styleDirections = () => {
3472
- if (isNewStyle()) return Number(directions.value) === 4 ? FOUR : COMPASS;
3473
- return COMPASS;
3945
+ if (isNewStyle()) return Number(directions.value) === 4 ? FOUR : COMPASS2;
3946
+ return COMPASS2;
3474
3947
  };
3475
3948
  function fillStyles() {
3476
3949
  const keep = style.value;
@@ -3529,7 +4002,7 @@
3529
4002
  what.value = defaults.what ?? "";
3530
4003
  const dirs = el("div", "studio-dirs");
3531
4004
  const boxes = /* @__PURE__ */ new Map();
3532
- for (const d of COMPASS) {
4005
+ for (const d of COMPASS2) {
3533
4006
  const box = el("input");
3534
4007
  box.type = "checkbox";
3535
4008
  box.checked = (defaults.directions ?? ["south", "west", "north"]).includes(d);
@@ -3641,10 +4114,21 @@
3641
4114
  const table = el("div", "studio-price-rows");
3642
4115
  for (const i of mine) {
3643
4116
  const row2 = el("div");
3644
- row2.append(el("span", "mono", i.key), el("span", null, i.cost ? fmtCost(i.costUnit, i.cost) : "free (mirrored)"));
4117
+ const cost = el("span", null, i.cost ? fmtCost(i.costUnit, i.cost) : i.quote === "live" ? "free" : "free (mirrored)");
4118
+ if (i.quote === "live") {
4119
+ const tag = el("span", "quote-live", "live");
4120
+ tag.title = "PixelLab's own Pro Flash quote; the offline estimate was " + fmtCost(i.costUnit, i.estimate);
4121
+ cost.append(" ", tag);
4122
+ }
4123
+ row2.append(el("span", "mono", i.key), cost);
3645
4124
  table.append(row2);
3646
4125
  }
3647
4126
  priceBox.append(table);
4127
+ if (mine.some((i) => i.quote === "live")) {
4128
+ priceBox.append(el("small", "state-dim", "Rows marked live are PixelLab's current Pro Flash quote, which it calls provisional; the lock records what the finished job billed."));
4129
+ } else if (price.quoteError) {
4130
+ priceBox.append(el("small", "state-dim", "Pro Flash is priced offline: PixelLab's quote could not be had (" + price.quoteError + ")."));
4131
+ }
3648
4132
  const totals = {};
3649
4133
  for (const i of mine) totals[i.costUnit] = (totals[i.costUnit] || 0) + i.cost;
3650
4134
  priceBox.append(el("div", "studio-total", "Total: " + (Object.entries(totals).map(([u, n]) => fmtCost(u, n)).join(" + ") || "free")));
@@ -3859,5 +4343,5 @@
3859
4343
  if (GENERATION) pollJobs();
3860
4344
  if (EDITOR) pollEditor();
3861
4345
  if (ui.compare.length >= 2 && !ui.open) openCompare();
3862
- window.__pixelkiln = { S, ui };
4346
+ window.__pixelkiln = { S, ui, family: familyState };
3863
4347
  })();