pixelkiln 0.35.0 → 0.37.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.
@@ -97,6 +97,11 @@ const ACTIVE_PHASES = new Set(['queued', 'submitting', 'polling', 'fetching', 'r
97
97
  const PHASE_TONE = { queued: 'cool', submitting: 'cool', polling: 'cool', fetching: 'cool', review: 'warn', done: 'ok', failed: 'bad' };
98
98
  // The job still working on this record, if any: what the drawer and its card
99
99
  // show while a regeneration, restore, or pull is in flight.
100
+ /** The style's palette rule as the manifest has it now, or null. */
101
+ const paletteRuleOf = (item) => {
102
+ const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
103
+ return style && style.enforcePalette && style.palette.length >= 2 ? style.palette : null;
104
+ };
100
105
  const activeJobFor = (item) => GEN.jobs.find((job) => ACTIVE_PHASES.has(job.phase) && job.project === item.project && job.keys.includes(item.key)) || null;
101
106
  const JOB_VERB = { generate: 'regenerating', resume: 'resuming', refresh: 'pulling upstream changes for', restore: 'restoring', revert: 'bringing back a previous generation of' };
102
107
  const PHASE_TEXT = { queued: 'queued', submitting: 'submitting to the provider', polling: 'waiting for the provider to finish', fetching: 'downloading the result', review: 'candidates are ready to review' };
@@ -140,6 +145,7 @@ async function pollJobs() {
140
145
  : job.mode === 'restore' ? 'Restored. The recorded bytes are back on disk.'
141
146
  : job.mode === 'revert' ? 'Brought back generation #' + job.revert + '. The one it replaced is now #1 in the list below.'
142
147
  : job.mode === 'refresh' ? (job.counts.downloaded ? 'Pulled the upstream change; the lockfile records the new bytes.' : 'Nothing changed upstream; the file was left alone.')
148
+ : job.mode === 'resume' && job.counts.downloaded ? 'Done. The file on disk and the record now match the manifest.'
143
149
  : 'Done.' };
144
150
  }
145
151
  }
@@ -334,7 +340,14 @@ function generateDialog(items, { project = null, force = false, resume = false,
334
340
  sum.append(el('div', null, 'Puts the recorded bytes back on disk from the local cache or the provider object. Nothing is generated or submitted.'));
335
341
  if (force && replacing.length) sum.append(el('div', 'warn', 'The file on disk was changed after PixelKiln wrote it. Restoring discards that change. To keep it instead, cancel and use Edit by hand or Edit in browser first: the edit is copied beside the generated file, and the record stays intact.'));
336
342
  } else if (resume) {
337
- sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
343
+ const drifted = items.filter((i) => i.state === 'recoverable' && i.status === 'downloaded');
344
+ if (drifted.length === items.length) {
345
+ sum.append(el('div', null, drifted.every((i) => paletteRuleOf(i))
346
+ ? 'Snaps each file to the style’s palette from the provider’s bytes in the local cache (or the file itself). The record keeps both hashes, so this can be undone the same way. Nothing is submitted.'
347
+ : 'Puts the provider’s original bytes back from the local cache, or re-downloads them, and clears the palette record. Nothing is submitted.'));
348
+ } else {
349
+ sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
350
+ }
338
351
  } else {
339
352
  for (const [provider, g] of byProvider) {
340
353
  const left = remainingBudget(provider);
@@ -1114,7 +1127,11 @@ function generateActions(item) {
1114
1127
  add('Regenerate' + cost, null, { force: true });
1115
1128
  } else if (item.state === 'orphaned') add('Restore · no cost', 'primary', { restore: true });
1116
1129
  else if (item.state === 'untracked') add('Generate' + cost + ' · replaces the file', null, { force: true });
1117
- if (item.state === 'in-flight' || item.state === 'recoverable') {
1130
+ if (item.state === 'recoverable' && item.status === 'downloaded') {
1131
+ // The files are fine; the manifest's palette rule changed under them.
1132
+ // A resume runs the same fetch that re-applies it, from the raw bytes.
1133
+ add(paletteRuleOf(item) ? 'Snap to the palette · no cost' : 'Put the provider’s bytes back · no cost', 'primary', { resume: true });
1134
+ } else if (item.state === 'in-flight' || item.state === 'recoverable') {
1118
1135
  // A job already parked this asset in review: go straight to its sheet.
1119
1136
  const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
1120
1137
  if (holder) {
@@ -1386,7 +1403,8 @@ function inheritorsOf(style, field) {
1386
1403
  // noBackground reaches the request only for pixflux and non-PixelLab
1387
1404
  // providers; elsewhere a change is recorded but alters nothing.
1388
1405
  const fieldReaches = (field, style) =>
1389
- field !== 'noBackground' || style.generator === 'pixflux' || style.provider !== 'pixellab';
1406
+ field === 'enforcePalette' ? false
1407
+ : field !== 'noBackground' || style.generator === 'pixflux' || style.provider !== 'pixellab';
1390
1408
  function blastRadius(style, fields) {
1391
1409
  const byId = new Map(snap.styles.filter((x) => x.project === style.project).map((x) => [x.id, x]));
1392
1410
  const styleIds = new Set();
@@ -1424,6 +1442,9 @@ function styleForm(style) {
1424
1442
  const noBg = el('select');
1425
1443
  noBg.append(new Option(style.extends ? 'inherit from ' + style.extends : 'default (on)', 'default'), new Option('on — strip the generated background', 'on'), new Option('off — keep the background (scenes, banners)', 'off'));
1426
1444
  noBg.value = own('noBackground') ? (style.noBackground ? 'on' : 'off') : 'default';
1445
+ const enforce = el('input'); enforce.type = 'checkbox'; enforce.checked = style.enforcePalette;
1446
+ const enforceLabel = el('label', 'chip');
1447
+ enforceLabel.append(enforce, document.createTextNode(' snap downloaded art to this palette (no dithering)'));
1427
1448
  const swatches = el('div', 'pal');
1428
1449
  const drawSwatches = () => {
1429
1450
  swatches.textContent = '';
@@ -1433,8 +1454,9 @@ function styleForm(style) {
1433
1454
  form.append(
1434
1455
  field('prompt prefix', prefix, 'Prepended to every asset prompt in this style. ' + provenance('promptPrefix')),
1435
1456
  field('prompt suffix', suffix, 'Appended to every asset prompt in this style. ' + provenance('promptSuffix')),
1436
- field('palette', palette, 'Forced colours where the provider supports them (pixflux). ' + provenance('palette')),
1457
+ field('palette', palette, 'Sent to providers that take a palette; with snapping on, guaranteed on every file after download. ' + provenance('palette')),
1437
1458
  swatches,
1459
+ field('after download', enforceLabel, 'Turning this on or off changes no request: the next fetch re-applies it to the files already on disk, spending nothing. ' + provenance('enforcePalette')),
1438
1460
  );
1439
1461
  const row = el('div', 'row');
1440
1462
  row.append(
@@ -1448,6 +1470,7 @@ function styleForm(style) {
1448
1470
  if (prefix.value !== style.promptPrefix) out.push('promptPrefix');
1449
1471
  if (suffix.value !== style.promptSuffix) out.push('promptSuffix');
1450
1472
  if (parsePalette(palette.value).join(',') !== style.palette.map((c) => c.toLowerCase()).join(',')) out.push('palette');
1473
+ if (enforce.checked !== style.enforcePalette) out.push('enforcePalette');
1451
1474
  if (view.value.trim() !== (style.view || '')) out.push('view');
1452
1475
  const bgNow = own('noBackground') ? (style.noBackground ? 'on' : 'off') : 'default';
1453
1476
  if (noBg.value !== bgNow) out.push('noBackground');
@@ -1460,7 +1483,9 @@ function styleForm(style) {
1460
1483
  const r = blastRadius(style, fields);
1461
1484
  if (!r.assets) {
1462
1485
  blast.className = 'blast';
1463
- blast.textContent = 'Recorded in the manifest only: nothing in ' + style.id + ' sends this field, so no request changes.';
1486
+ blast.textContent = fields.every((f) => f === 'enforcePalette')
1487
+ ? 'No request changes. Generated files in ' + style.id + ' become recoverable; the next fetch or restore re-applies the palette rule to them for free.'
1488
+ : 'Recorded in the manifest only: nothing in ' + style.id + ' sends this field, so no request changes.';
1464
1489
  return;
1465
1490
  }
1466
1491
  blast.className = 'blast hot';
@@ -1474,6 +1499,7 @@ function styleForm(style) {
1474
1499
  };
1475
1500
  for (const input of [prefix, suffix, palette, view]) input.addEventListener('input', updateBlast);
1476
1501
  noBg.addEventListener('change', updateBlast);
1502
+ enforce.addEventListener('change', updateBlast);
1477
1503
  updateBlast();
1478
1504
  form.append(blast);
1479
1505
  const actions = el('div', 'actions');
@@ -1488,10 +1514,12 @@ function styleForm(style) {
1488
1514
  if (!fields.length) { ui.editing = null; render(); return; }
1489
1515
  const colors = parsePalette(palette.value);
1490
1516
  if (fields.includes('palette') && !paletteValid(colors)) { msg.className = 'msg bad'; msg.textContent = 'palette must be six-digit hex colours'; return; }
1517
+ if (enforce.checked && colors.length < 2) { msg.className = 'msg bad'; msg.textContent = 'snapping needs a palette of at least two colours'; return; }
1491
1518
  const patch = {};
1492
1519
  if (fields.includes('promptPrefix')) patch.promptPrefix = prefix.value;
1493
1520
  if (fields.includes('promptSuffix')) patch.promptSuffix = suffix.value;
1494
1521
  if (fields.includes('palette')) patch.palette = colors;
1522
+ if (fields.includes('enforcePalette')) patch.enforcePalette = enforce.checked;
1495
1523
  if (fields.includes('view')) patch.view = view.value.trim();
1496
1524
  if (fields.includes('noBackground')) patch.noBackground = noBg.value === 'default' ? null : noBg.value === 'on';
1497
1525
  save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving…';
@@ -1830,6 +1858,15 @@ function renderDrawer() {
1830
1858
  if (item.currentPrompt) row(dl, 'prompt now', el('div', 'prompt state-warn', item.currentPrompt));
1831
1859
  row(dl, 'size', item.width + ' × ' + item.height + ' px');
1832
1860
  if (item.status) row(dl, 'cost', fmtCost(item.costUnit, item.cost));
1861
+ if (item.postprocess && item.postprocess.palette) {
1862
+ // What fetch did to the provider's bytes before writing them.
1863
+ const p = item.postprocess.palette;
1864
+ const wrap = el('span');
1865
+ const sw = el('span', 'swatches');
1866
+ for (const c of p.colors) { const i = el('i'); i.style.background = c; i.title = c; sw.append(i); }
1867
+ wrap.append(document.createTextNode('snapped to ' + p.colors.length + ' colours, no dithering '), sw);
1868
+ row(dl, 'palette', wrap);
1869
+ }
1833
1870
  if (item.estimatedCost !== null && (item.estimatedCost !== item.cost || !item.status)) {
1834
1871
  row(dl, 'estimate now', fmtCost(item.costUnit, item.estimatedCost) +
1835
1872
  (item.candidates && item.candidates > 1 ? ' for ' + item.candidates + ' candidates' : ''));