pixelkiln 0.75.0 → 0.77.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.
@@ -39,8 +39,72 @@
39
39
  compare: [],
40
40
  compareZoom: "auto",
41
41
  logs: /* @__PURE__ */ new Set(),
42
- settled: /* @__PURE__ */ new Set()
42
+ settled: /* @__PURE__ */ new Set(),
43
+ playLoops: false,
44
+ backdrop: "checker",
45
+ notify: false,
46
+ fps: null,
47
+ onion: false
43
48
  };
49
+ var PREFS_KEY = "pixelkiln.gallery.prefs";
50
+ var PREF_NAMES = ["playLoops", "backdrop", "notify", "onion"];
51
+ function loadPrefs() {
52
+ try {
53
+ const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
54
+ for (const name of PREF_NAMES) if (typeof saved[name] === typeof ui[name]) ui[name] = saved[name];
55
+ } catch {
56
+ }
57
+ applyBackdrop();
58
+ }
59
+ function savePrefs() {
60
+ try {
61
+ localStorage.setItem(PREFS_KEY, JSON.stringify(Object.fromEntries(PREF_NAMES.map((n) => [n, ui[n]]))));
62
+ } catch {
63
+ }
64
+ }
65
+ var HEX = /^#[0-9a-f]{6}$/i;
66
+ function applyBackdrop() {
67
+ const custom = HEX.test(ui.backdrop);
68
+ document.body.dataset.backdrop = custom ? "custom" : ui.backdrop;
69
+ document.body.style.setProperty("--stage-bg", custom ? ui.backdrop : "");
70
+ if (!custom) document.body.style.removeProperty("--stage-bg");
71
+ }
72
+ function backdropControl(onChange) {
73
+ const wrap = el("span", "backdrop");
74
+ wrap.title = "What sprites sit on: judge them against dark, light, or your game's own background";
75
+ const options = [["checker", "Transparency grid"], ["dark", "Dark"], ["light", "Light"]];
76
+ const buttons = [];
77
+ const picker = el("input");
78
+ picker.type = "color";
79
+ picker.setAttribute("aria-label", "Custom backdrop colour");
80
+ const sync = () => {
81
+ for (const b of buttons) b.classList.toggle("on", b.dataset.value === ui.backdrop);
82
+ picker.classList.toggle("on", HEX.test(ui.backdrop));
83
+ if (HEX.test(ui.backdrop)) picker.value = ui.backdrop;
84
+ };
85
+ const set = (value) => {
86
+ ui.backdrop = value;
87
+ applyBackdrop();
88
+ savePrefs();
89
+ for (const other of document.querySelectorAll(".backdrop")) other.__sync?.();
90
+ onChange?.();
91
+ };
92
+ for (const [value, label] of options) {
93
+ const b = el("button", "bd-" + value);
94
+ b.type = "button";
95
+ b.dataset.value = value;
96
+ b.title = label;
97
+ b.setAttribute("aria-label", "Backdrop: " + label);
98
+ b.onclick = () => set(value);
99
+ buttons.push(b);
100
+ wrap.append(b);
101
+ }
102
+ picker.oninput = () => set(picker.value.toLowerCase());
103
+ wrap.append(picker);
104
+ wrap.__sync = sync;
105
+ sync();
106
+ return wrap;
107
+ }
44
108
  var formRegion = () => !ui.editing ? null : /^(new|style|cand):/.test(ui.editing) ? "main" : "drawer";
45
109
  var $ = (id) => document.getElementById(id);
46
110
  var el = (tag, cls, text) => {
@@ -190,7 +254,7 @@
190
254
  };
191
255
  zoomBar.append(b);
192
256
  }
193
- bar.append(zoomBar);
257
+ bar.append(zoomBar, backdropControl());
194
258
  const zoom = ui.compareZoom === "auto" ? auto : ui.compareZoom === "fit" ? Math.min(auto, 1) : Number(ui.compareZoom);
195
259
  const body = el("div", "compare-body");
196
260
  const table = el("table");
@@ -856,6 +920,98 @@
856
920
  return form;
857
921
  }
858
922
 
923
+ // src/gallery/client/src/motion.ts
924
+ var loops = /* @__PURE__ */ new Map();
925
+ var raf = null;
926
+ var t0 = 0;
927
+ var seen = typeof IntersectionObserver === "function" ? new IntersectionObserver((entries) => {
928
+ for (const e of entries) {
929
+ const loop = loops.get(e.target);
930
+ if (loop) loop.visible = e.isIntersecting;
931
+ }
932
+ wake();
933
+ }) : null;
934
+ function registerLoop(img, urls, fps, hoverTarget) {
935
+ if (urls.length < 2) return;
936
+ const loop = { img, urls, fps: fps || 12, index: 0, visible: !seen, hovered: false, first: img.src };
937
+ loops.set(img, loop);
938
+ seen?.observe(img);
939
+ let warmed = false;
940
+ const warm = () => {
941
+ if (warmed) return;
942
+ warmed = true;
943
+ for (const u of urls) {
944
+ const p = new Image();
945
+ p.src = u;
946
+ }
947
+ };
948
+ hoverTarget.addEventListener("pointerenter", () => {
949
+ warm();
950
+ loop.hovered = true;
951
+ wake();
952
+ });
953
+ hoverTarget.addEventListener("pointerleave", () => {
954
+ loop.hovered = false;
955
+ rest(loop);
956
+ });
957
+ hoverTarget.addEventListener("focus", () => {
958
+ warm();
959
+ loop.hovered = true;
960
+ wake();
961
+ });
962
+ hoverTarget.addEventListener("blur", () => {
963
+ loop.hovered = false;
964
+ rest(loop);
965
+ });
966
+ if (ui.playLoops) warm();
967
+ }
968
+ function pruneLoops() {
969
+ for (const [img, loop] of loops) if (!img.isConnected) {
970
+ seen?.unobserve(img);
971
+ loops.delete(img);
972
+ void loop;
973
+ }
974
+ }
975
+ function setPlayLoops(on) {
976
+ ui.playLoops = on;
977
+ if (on) for (const loop of loops.values()) for (const u of loop.urls) {
978
+ const p = new Image();
979
+ p.src = u;
980
+ }
981
+ else for (const loop of loops.values()) if (!loop.hovered) rest(loop);
982
+ wake();
983
+ }
984
+ function rest(loop) {
985
+ if (ui.playLoops) return;
986
+ loop.index = 0;
987
+ if (loop.img.src !== loop.first) loop.img.src = loop.first;
988
+ }
989
+ function wake() {
990
+ if (raf === null && typeof requestAnimationFrame === "function") {
991
+ t0 = performance.now();
992
+ raf = requestAnimationFrame(tick);
993
+ }
994
+ }
995
+ function tick(now) {
996
+ raf = null;
997
+ let active = false;
998
+ const seconds = Math.max(0, (now - t0) / 1e3);
999
+ for (const [img, loop] of loops) {
1000
+ if (!img.isConnected) continue;
1001
+ if (!loop.hovered && !(ui.playLoops && loop.visible)) continue;
1002
+ active = true;
1003
+ const i = Math.floor(seconds * loop.fps) % loop.urls.length;
1004
+ if (i !== loop.index) {
1005
+ loop.index = i;
1006
+ img.src = loop.urls[i];
1007
+ }
1008
+ }
1009
+ if (active && !document.hidden) raf = requestAnimationFrame(tick);
1010
+ }
1011
+ document.addEventListener("visibilitychange", () => {
1012
+ if (!document.hidden) wake();
1013
+ });
1014
+
859
1015
  // src/gallery/client/src/grid.ts
860
1016
  function visibleItems() {
861
1017
  const q = ui.q.trim().toLowerCase();
@@ -1064,11 +1220,15 @@
1064
1220
  if (e.shiftKey) toggleCompare(item.id);
1065
1221
  else openItem(item.id);
1066
1222
  };
1223
+ const still = cell.querySelector(":scope > img");
1224
+ const shown = item.edit && item.editStatus === "edited" && item.edits.every((e) => e.url) ? item.edits : item.outputs.filter((o) => o.url);
1225
+ if (still && isFrameSet(item) && shown.length > 1) registerLoop(still, shown.map((o) => o.url), item.fps || 12, c);
1067
1226
  return c;
1068
1227
  }
1069
1228
  function renderMain(items) {
1070
1229
  const root = $("root");
1071
1230
  root.textContent = "";
1231
+ pruneLoops();
1072
1232
  if (!S.snap.items.length) {
1073
1233
  const e = el("div", "empty");
1074
1234
  e.append(el("p", null, "Nothing has been generated for this project yet."));
@@ -1464,6 +1624,7 @@
1464
1624
  if (!ACTIVE_PHASES.has(job.phase) && !ui.settled.has(job.id)) {
1465
1625
  ui.settled.add(job.id);
1466
1626
  changed = true;
1627
+ notifyJob(job);
1467
1628
  const open = ui.open && S.snap.items.find((i) => i.id === ui.open);
1468
1629
  if (open && job.project === open.project && job.keys.includes(open.key)) {
1469
1630
  ui.notice = { id: open.id, text: job.phase === "failed" ? (JOB_VERB[job.mode] || job.mode).replace(/ing$/, "ing") + " this asset failed: " + (job.error || "see the log above") : job.mode === "generate" ? "Regenerated. The new result is on disk and recorded as this generation." : job.mode === "restore" ? "Restored. The recorded bytes are back on disk." : job.mode === "revert" ? "Brought back generation #" + job.revert + ". The one it replaced is now #1 in the list below." : job.mode === "refresh" ? job.counts.downloaded ? "Pulled the upstream change; the lockfile records the new bytes." : "Nothing changed upstream; the file was left alone." : job.mode === "resume" && job.counts.downloaded ? "Done. The file on disk and the record now match the manifest." : "Done." };
@@ -1472,6 +1633,7 @@
1472
1633
  if (job.phase === "review" && !ui.settled.has(job.id + ":review")) {
1473
1634
  ui.settled.add(job.id + ":review");
1474
1635
  changed = true;
1636
+ notifyJob(job);
1475
1637
  }
1476
1638
  }
1477
1639
  if (changed) refresh();
@@ -1489,6 +1651,34 @@
1489
1651
  clearTimeout(jobsTimer ?? void 0);
1490
1652
  if (active) jobsTimer = setTimeout(pollJobs, 2e3);
1491
1653
  }
1654
+ var DONE_VERB = { generate: "Generated", resume: "Resumed", refresh: "Pulled upstream changes for", restore: "Restored", revert: "Brought back a generation of" };
1655
+ async function enableNotifications() {
1656
+ if (typeof Notification === "undefined") return false;
1657
+ if (Notification.permission === "granted") return true;
1658
+ if (Notification.permission === "denied") {
1659
+ $("note").textContent = " Notifications are blocked for this page in the browser's site settings.";
1660
+ return false;
1661
+ }
1662
+ try {
1663
+ return await Notification.requestPermission() === "granted";
1664
+ } catch {
1665
+ return false;
1666
+ }
1667
+ }
1668
+ function notifyJob(job) {
1669
+ if (!ui.notify || !document.hidden || typeof Notification === "undefined" || Notification.permission !== "granted") return;
1670
+ const n = job.keys.length;
1671
+ const what = n === 1 ? job.keys[0] : n + " assets";
1672
+ const body = job.phase === "review" ? job.review.length + " waiting for you to pick a candidate" : job.phase === "failed" ? "failed: " + (job.error || "see the log") : (DONE_VERB[job.mode] || "Finished") + " " + what;
1673
+ try {
1674
+ const note = new Notification("pixelkiln" + (job.project ? " · " + job.project : ""), { body, tag: "pixelkiln-job-" + job.id });
1675
+ note.onclick = () => {
1676
+ window.focus();
1677
+ note.close();
1678
+ };
1679
+ } catch {
1680
+ }
1681
+ }
1492
1682
  function renderJobs() {
1493
1683
  const host = $("jobs");
1494
1684
  host.textContent = "";
@@ -2135,6 +2325,7 @@
2135
2325
  var SPEEDS = [0.5, 1, 2];
2136
2326
  var SPIN_MS = 420;
2137
2327
  var DRAG_STEP_PX = 28;
2328
+ var CELL_PX = 68;
2138
2329
  var F = null;
2139
2330
  var reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
2140
2331
  var byKey = (project, key) => S.snap.items.find((i) => i.key === key && i.project === project);
@@ -2243,7 +2434,7 @@
2243
2434
  t.append(el("i", "dot " + STATE_TONE[state]), document.createTextNode(n + " " + state));
2244
2435
  tally.append(t);
2245
2436
  }
2246
- bar.append(tally);
2437
+ bar.append(tally, backdropControl());
2247
2438
  if (GENERATION) {
2248
2439
  const todo = members.filter((m) => m.declared && m.currentSpecHash !== null && ["missing", "stale", "failed"].includes(m.state));
2249
2440
  if (todo.length) {
@@ -2270,7 +2461,7 @@
2270
2461
  host.append(scrim, panel);
2271
2462
  panel.scrollTop = scroll;
2272
2463
  highlight(F.direction);
2273
- if (F.raf === null) F.raf = requestAnimationFrame(tick);
2464
+ if (F.raf === null) F.raf = requestAnimationFrame(tick2);
2274
2465
  }
2275
2466
  function turntable(root, turnables, source, order) {
2276
2467
  const wrap = el("section", "turntable");
@@ -2407,11 +2598,11 @@
2407
2598
  return wrap;
2408
2599
  }
2409
2600
  function loopGrid(root, members, order) {
2410
- const loops = members.filter((m) => m.character.kind === "animation");
2601
+ const loops2 = members.filter((m) => m.character.kind === "animation");
2411
2602
  const wrap = el("section", "loop-grid");
2412
2603
  const head = el("div", "lg-head");
2413
- head.append(el("h3", null, loops.length ? "Loops" : "No loops yet"));
2414
- if (loops.length) {
2604
+ head.append(el("h3", null, loops2.length ? "Loops" : "No loops yet"));
2605
+ if (loops2.length) {
2415
2606
  const play = el("button", null, F.playing ? "Pause" : "Play");
2416
2607
  play.type = "button";
2417
2608
  play.onclick = () => {
@@ -2431,14 +2622,14 @@
2431
2622
  head.append(speed);
2432
2623
  }
2433
2624
  wrap.append(head);
2434
- if (!loops.length) {
2625
+ if (!loops2.length) {
2435
2626
  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
2627
  return wrap;
2437
2628
  }
2438
- const used = /* @__PURE__ */ new Set([...order, ...loops.map((l) => l.character.direction).filter(Boolean)]);
2629
+ const used = /* @__PURE__ */ new Set([...order, ...loops2.map((l) => l.character.direction).filter(Boolean)]);
2439
2630
  const columns = COMPASS.filter((d) => used.has(d));
2440
2631
  const rows = /* @__PURE__ */ new Map();
2441
- for (const l of loops) {
2632
+ for (const l of loops2) {
2442
2633
  const k = (l.character.parentKey || "") + "|" + loopName(l);
2443
2634
  if (!rows.has(k)) rows.set(k, []);
2444
2635
  rows.get(k).push(l);
@@ -2505,6 +2696,7 @@
2505
2696
  img.alt = item.assetId;
2506
2697
  const urls = frameUrls(item);
2507
2698
  if (urls.length) {
2699
+ fitCell(img, item.width, item.height);
2508
2700
  img.src = urls[0];
2509
2701
  for (const u of urls) {
2510
2702
  const p = new Image();
@@ -2514,6 +2706,7 @@
2514
2706
  } else {
2515
2707
  const ghost = rotationUrl(parent, direction);
2516
2708
  if (ghost) {
2709
+ fitCell(img, parent.width, parent.height);
2517
2710
  img.src = ghost;
2518
2711
  b.classList.add("ghost");
2519
2712
  } else img.hidden = true;
@@ -2539,6 +2732,7 @@
2539
2732
  img.alt = item.assetId;
2540
2733
  const urls = frameUrls(item);
2541
2734
  if (urls.length) {
2735
+ fitCell(img, item.width, item.height);
2542
2736
  img.src = urls[0];
2543
2737
  if (urls.length > 1) F.anims.push({ img, urls, fps: item.fps || 12, index: 0 });
2544
2738
  } else img.hidden = true;
@@ -2551,11 +2745,16 @@
2551
2745
  wrap.append(strip);
2552
2746
  return wrap;
2553
2747
  }
2748
+ function fitCell(img, width, height) {
2749
+ const s = displayScale(width, height, CELL_PX, CELL_PX);
2750
+ img.width = Math.max(1, Math.round(width * s));
2751
+ img.height = Math.max(1, Math.round(height * s));
2752
+ }
2554
2753
  function highlight(direction) {
2555
2754
  if (!F) return;
2556
2755
  for (const [d, nodes] of F.columns) for (const n of nodes) n.classList.toggle("col-on", d === direction);
2557
2756
  }
2558
- function tick(now) {
2757
+ function tick2(now) {
2559
2758
  if (!F) return;
2560
2759
  if (!F.panel || !F.panel.isConnected) {
2561
2760
  stopFamily();
@@ -2577,7 +2776,7 @@
2577
2776
  const order = source ? turnOrder(source) : COMPASS;
2578
2777
  F.turn(order[(order.indexOf(F.direction) + 1) % order.length]);
2579
2778
  }
2580
- F.raf = requestAnimationFrame(tick);
2779
+ F.raf = requestAnimationFrame(tick2);
2581
2780
  }
2582
2781
  var familyState = () => F && { rootId: F.rootId, source: F.source, direction: F.direction, playing: F.playing, anims: F.anims.length };
2583
2782
 
@@ -2932,11 +3131,11 @@
2932
3131
  for (const [i, pose] of [set.firstFrameKeypoints, ...set.frames].entries()) {
2933
3132
  const where = i === 0 ? "the starting pose" : "frame " + i;
2934
3133
  if (!Array.isArray(pose) || pose.length !== 18) return where + " needs all 18 joints";
2935
- const seen = /* @__PURE__ */ new Set();
3134
+ const seen2 = /* @__PURE__ */ new Set();
2936
3135
  for (const j of pose) {
2937
3136
  if (!SKELETON_LABELS.includes(j && j.label)) return where + ': "' + (j && j.label) + `" is not one of PixelLab's 18 joints`;
2938
- if (seen.has(j.label)) return where + ': "' + j.label + '" appears twice';
2939
- seen.add(j.label);
3137
+ if (seen2.has(j.label)) return where + ': "' + j.label + '" appears twice';
3138
+ seen2.add(j.label);
2940
3139
  if (!(j.x >= 0 && j.x <= 1 && j.y >= 0 && j.y <= 1)) return where + ": " + j.label + " must sit inside the image (x and y 0 to 1)";
2941
3140
  }
2942
3141
  }
@@ -3255,25 +3454,34 @@
3255
3454
  host.append(preview);
3256
3455
  return;
3257
3456
  }
3258
- const member = shown[Math.min(ui.member, shown.length - 1)] || shown[0];
3259
- const frameSet = isFrameSet(item);
3457
+ let idx = Math.min(ui.member, shown.length - 1);
3458
+ const frameSet = isFrameSet(item) && shown.length > 1;
3260
3459
  const w = item.width, h = item.height;
3261
3460
  const box = Math.min(window.innerWidth, 540) - 62;
3262
3461
  const auto = displayScale(w, h, box, Math.min(window.innerHeight * 0.55, 560));
3263
3462
  const zoom = ui.zoom === "auto" ? auto : ui.zoom === "fit" ? Math.min(auto, 1) : Number(ui.zoom);
3264
- const img = el("img");
3265
- img.src = member.url;
3266
- img.alt = member.role || item.assetId;
3267
- if (zoom >= 1) {
3268
- img.width = Math.round(w * zoom);
3269
- img.height = Math.round(h * zoom);
3270
- } else {
3271
- img.style.maxWidth = "100%";
3272
- img.style.maxHeight = "55vh";
3273
- img.style.width = "auto";
3274
- img.style.height = "auto";
3275
- }
3276
- preview.append(img);
3463
+ const sized = (img2) => {
3464
+ if (zoom >= 1) {
3465
+ img2.width = Math.round(w * zoom);
3466
+ img2.height = Math.round(h * zoom);
3467
+ } else {
3468
+ img2.style.maxWidth = "100%";
3469
+ img2.style.maxHeight = "55vh";
3470
+ img2.style.width = "auto";
3471
+ img2.style.height = "auto";
3472
+ }
3473
+ return img2;
3474
+ };
3475
+ const stack = el("div", "stack");
3476
+ const img = sized(el("img"));
3477
+ img.src = shown[idx].url;
3478
+ img.alt = shown[idx].role || item.assetId;
3479
+ const onion = sized(el("img", "onion"));
3480
+ onion.alt = "";
3481
+ onion.setAttribute("aria-hidden", "true");
3482
+ onion.hidden = !(frameSet && ui.onion);
3483
+ stack.append(onion, img);
3484
+ preview.append(stack);
3277
3485
  const zoomBar = el("div", "zoom");
3278
3486
  zoomBar.append(el("span", null, "zoom"));
3279
3487
  for (const z of ["fit", 1, 2, 4, 8]) {
@@ -3286,34 +3494,98 @@
3286
3494
  zoomBar.append(b);
3287
3495
  }
3288
3496
  const label = el("span", "grow", w + "×" + h + (zoom >= 1 ? " at " + zoom + "×" : " fitted"));
3289
- zoomBar.append(label);
3290
- if (frameSet && shown.length > 1) {
3291
- const fps = item.fps || 12;
3292
- const play = el("button", null, "Play " + fps + " fps");
3497
+ zoomBar.append(label, backdropControl());
3498
+ host.append(preview, zoomBar);
3499
+ let strip = null;
3500
+ const show = (i) => {
3501
+ idx = (i + shown.length) % shown.length;
3502
+ ui.member = idx;
3503
+ img.src = shown[idx].url;
3504
+ img.alt = shown[idx].role || item.assetId;
3505
+ if (frameSet) {
3506
+ onion.src = shown[(idx - 1 + shown.length) % shown.length].url;
3507
+ scrub.value = String(idx);
3508
+ count.textContent = idx + 1 + " / " + shown.length;
3509
+ }
3510
+ if (strip) for (const [j, b] of [...strip.querySelectorAll("button")].entries()) b.classList.toggle("on", j === idx);
3511
+ };
3512
+ const scrub = el("input");
3513
+ const count = el("span", "count");
3514
+ if (frameSet) {
3515
+ const bar = el("div", "frames-bar");
3516
+ const own = item.fps || 12;
3517
+ const fps = () => ui.fps || own;
3518
+ const prev = el("button", null, "◀");
3519
+ prev.type = "button";
3520
+ prev.title = "Previous frame (,)";
3521
+ const next = el("button", null, "▶");
3522
+ next.type = "button";
3523
+ next.title = "Next frame (.)";
3524
+ const play = el("button", "play", "Play");
3293
3525
  play.type = "button";
3294
- let idx = shown.indexOf(member);
3295
- play.onclick = () => {
3296
- if (ui.playing) {
3297
- stopPlayback();
3298
- play.textContent = "Play " + fps + " fps";
3299
- return;
3300
- }
3526
+ play.title = "Play or pause (space)";
3527
+ const pause = () => {
3528
+ stopPlayback();
3529
+ play.textContent = "Play";
3530
+ };
3531
+ const start = () => {
3532
+ stopPlayback();
3301
3533
  play.textContent = "Pause";
3302
3534
  ui.playing = setInterval(() => {
3303
- if (document.hidden) return;
3304
- idx = (idx + 1) % shown.length;
3305
- img.src = shown[idx].url;
3306
- label.textContent = (shown[idx].role || "frame " + (idx + 1)) + " · " + w + "×" + h + (zoom >= 1 ? " at " + zoom + "×" : "");
3307
- for (const [i, b] of [...host.querySelectorAll(".members button")].entries()) b.classList.toggle("on", i === idx);
3308
- }, Math.max(16, Math.round(1e3 / fps)));
3535
+ if (!document.hidden) show(idx + 1);
3536
+ }, Math.max(16, Math.round(1e3 / fps())));
3537
+ };
3538
+ play.onclick = () => ui.playing ? pause() : start();
3539
+ prev.onclick = () => {
3540
+ pause();
3541
+ show(idx - 1);
3542
+ };
3543
+ next.onclick = () => {
3544
+ pause();
3545
+ show(idx + 1);
3546
+ };
3547
+ scrub.type = "range";
3548
+ scrub.min = "0";
3549
+ scrub.max = String(shown.length - 1);
3550
+ scrub.step = "1";
3551
+ scrub.setAttribute("aria-label", "Frame");
3552
+ scrub.oninput = () => {
3553
+ pause();
3554
+ show(Number(scrub.value));
3309
3555
  };
3310
- zoomBar.append(play);
3556
+ const speed = el("select");
3557
+ speed.setAttribute("aria-label", "Frames per second");
3558
+ const rates = [.../* @__PURE__ */ new Set([own, 4, 6, 8, 10, 12, 15, 24])].sort((a, b) => a - b);
3559
+ for (const r of rates) speed.append(new Option(r + " fps" + (r === own ? " (its own)" : ""), String(r)));
3560
+ speed.value = String(fps());
3561
+ speed.onchange = () => {
3562
+ ui.fps = Number(speed.value) === own ? null : Number(speed.value);
3563
+ if (ui.playing) start();
3564
+ };
3565
+ const onionBox = el("label", "chip");
3566
+ const onionToggle = el("input");
3567
+ onionToggle.type = "checkbox";
3568
+ onionToggle.checked = ui.onion;
3569
+ onionToggle.onchange = () => {
3570
+ ui.onion = onionToggle.checked;
3571
+ onion.hidden = !ui.onion;
3572
+ savePrefs();
3573
+ };
3574
+ onionBox.title = "Show the previous frame faintly under this one";
3575
+ onionBox.append(onionToggle, document.createTextNode(" onion skin"));
3576
+ bar.append(prev, play, next, scrub, count, speed, onionBox);
3577
+ host.append(bar);
3578
+ host.__frames = { step: (n) => {
3579
+ pause();
3580
+ show(idx + n);
3581
+ }, toggle: () => play.click() };
3582
+ } else {
3583
+ host.__frames = null;
3311
3584
  }
3312
- host.append(preview, zoomBar);
3313
3585
  if (shown.length > 1) {
3314
- const strip = el("div", "members");
3586
+ strip = el("div", "members");
3315
3587
  shown.forEach((o, i) => {
3316
- const b = el("button", o === member ? "on" : null);
3588
+ const b = el("button", i === idx ? "on" : null);
3317
3589
  b.type = "button";
3318
3590
  const t = el("img");
3319
3591
  t.src = o.url;
@@ -3321,13 +3593,21 @@
3321
3593
  t.loading = "lazy";
3322
3594
  b.append(t, el("span", null, o.role || String(i + 1)));
3323
3595
  b.onclick = () => {
3324
- ui.member = i;
3325
- renderPreview(item, host);
3596
+ if (frameSet) {
3597
+ stopPlayback();
3598
+ }
3599
+ show(i);
3600
+ if (!frameSet) renderPreview(item, host);
3326
3601
  };
3327
3602
  strip.append(b);
3328
3603
  });
3329
3604
  host.append(strip);
3330
3605
  }
3606
+ show(idx);
3607
+ }
3608
+ function drawerFrames() {
3609
+ const host = document.querySelector(".drawer .dbody > div");
3610
+ return host ? host.__frames ?? null : null;
3331
3611
  }
3332
3612
  var CHARACTER_KIND = {
3333
3613
  base: "base",
@@ -3907,7 +4187,7 @@
3907
4187
  const refField = field("own sprite (optional)", refInput, "A south-facing sprite at the style's size: PixelLab rotates it instead of drawing one, and only the rotations are billed.");
3908
4188
  refField.append(refPreview);
3909
4189
  baseSection.append(baseRow, field("description", prompt), refField);
3910
- const loops = [];
4190
+ const loops2 = [];
3911
4191
  const loopsSection = el("section", "studio-part");
3912
4192
  const loopList = el("div", "studio-loops");
3913
4193
  const addLoop = el("button", "add", "+ Add a loop");
@@ -3970,7 +4250,7 @@
3970
4250
  newStyle.hidden = !isNewStyle();
3971
4251
  styleId.required = isNewStyle();
3972
4252
  if (!outDirTouched) outDir.value = "art/" + (styleId.value.trim() || "characters");
3973
- for (const row2 of loops) syncLoopDirections(row2);
4253
+ for (const row2 of loops2) syncLoopDirections(row2);
3974
4254
  }
3975
4255
  function syncLoopDirections(row2) {
3976
4256
  const allowed = styleDirections();
@@ -4015,7 +4295,7 @@
4015
4295
  remove.type = "button";
4016
4296
  const row2 = { id, mode, what, directions: boxes, wrap };
4017
4297
  remove.onclick = () => {
4018
- loops.splice(loops.indexOf(row2), 1);
4298
+ loops2.splice(loops2.indexOf(row2), 1);
4019
4299
  wrap.remove();
4020
4300
  schedulePrice();
4021
4301
  };
@@ -4027,7 +4307,7 @@
4027
4307
  line.append(field("loop id", id), field("mode", mode), field("template or motion", what));
4028
4308
  wrap.append(line, dirs, remove);
4029
4309
  loopList.append(wrap);
4030
- loops.push(row2);
4310
+ loops2.push(row2);
4031
4311
  syncLoopMode(row2);
4032
4312
  syncLoopDirections(row2);
4033
4313
  }
@@ -4044,7 +4324,7 @@
4044
4324
  }
4045
4325
  const base = baseId.value.trim();
4046
4326
  out.push({ action: "add-asset", assetId: base, asset: { prompt: prompt.value.trim(), styles: [sid], ...reference ? { reference } : {} } });
4047
- for (const row2 of loops) {
4327
+ for (const row2 of loops2) {
4048
4328
  const chosen = [...row2.directions].filter(([, box]) => box.checked).map(([d]) => d);
4049
4329
  if (!row2.id.value.trim() || !chosen.length) continue;
4050
4330
  const animation = { of: base, directions: chosen };
@@ -4114,10 +4394,21 @@
4114
4394
  const table = el("div", "studio-price-rows");
4115
4395
  for (const i of mine) {
4116
4396
  const row2 = el("div");
4117
- row2.append(el("span", "mono", i.key), el("span", null, i.cost ? fmtCost(i.costUnit, i.cost) : "free (mirrored)"));
4397
+ const cost = el("span", null, i.cost ? fmtCost(i.costUnit, i.cost) : i.quote === "live" ? "free" : "free (mirrored)");
4398
+ if (i.quote === "live") {
4399
+ const tag = el("span", "quote-live", "live");
4400
+ tag.title = "PixelLab's own Pro Flash quote; the offline estimate was " + fmtCost(i.costUnit, i.estimate);
4401
+ cost.append(" ", tag);
4402
+ }
4403
+ row2.append(el("span", "mono", i.key), cost);
4118
4404
  table.append(row2);
4119
4405
  }
4120
4406
  priceBox.append(table);
4407
+ if (mine.some((i) => i.quote === "live")) {
4408
+ 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."));
4409
+ } else if (price.quoteError) {
4410
+ priceBox.append(el("small", "state-dim", "Pro Flash is priced offline: PixelLab's quote could not be had (" + price.quoteError + ")."));
4411
+ }
4121
4412
  const totals = {};
4122
4413
  for (const i of mine) totals[i.costUnit] = (totals[i.costUnit] || 0) + i.cost;
4123
4414
  priceBox.append(el("div", "studio-total", "Total: " + (Object.entries(totals).map(([u, n]) => fmtCost(u, n)).join(" + ") || "free")));
@@ -4255,7 +4546,22 @@
4255
4546
  }
4256
4547
 
4257
4548
  // src/gallery/client/src/main.ts
4549
+ loadPrefs();
4258
4550
  $("refresh").onclick = refresh;
4551
+ $("playloops").checked = ui.playLoops;
4552
+ $("playloops").onchange = (e) => {
4553
+ setPlayLoops(e.target.checked);
4554
+ savePrefs();
4555
+ };
4556
+ $("backdrop-slot").replaceWith(backdropControl());
4557
+ $("notify-chip").hidden = !GENERATION || typeof Notification === "undefined";
4558
+ $("notify").checked = ui.notify && typeof Notification !== "undefined" && Notification.permission === "granted";
4559
+ $("notify").onchange = async (e) => {
4560
+ const on = e.target.checked && await enableNotifications();
4561
+ e.target.checked = on;
4562
+ ui.notify = on;
4563
+ savePrefs();
4564
+ };
4259
4565
  $("studio").hidden = !EDITABLE;
4260
4566
  $("studio").onclick = openStudio;
4261
4567
  var autoTimer = null;
@@ -4307,7 +4613,7 @@
4307
4613
  }
4308
4614
  return;
4309
4615
  }
4310
- if (ui.open && !typing) {
4616
+ if (ui.open && !typing && !$("dialog-host").childNodes.length) {
4311
4617
  if (e.key === "ArrowLeft") {
4312
4618
  e.preventDefault();
4313
4619
  step(-1);
@@ -4315,6 +4621,14 @@
4315
4621
  e.preventDefault();
4316
4622
  step(1);
4317
4623
  }
4624
+ const frames = drawerFrames();
4625
+ if (frames && (e.key === "," || e.key === ".")) {
4626
+ e.preventDefault();
4627
+ frames.step(e.key === "," ? -1 : 1);
4628
+ } else if (frames && e.key === " " && !/^(BUTTON|A)$/.test(document.activeElement?.tagName || "")) {
4629
+ e.preventDefault();
4630
+ frames.toggle();
4631
+ }
4318
4632
  }
4319
4633
  });
4320
4634
  document.addEventListener("visibilitychange", () => {