pixelkiln 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -123,13 +123,14 @@ async function sha256File(path25) {
123
123
  stream.on("end", () => resolve(hash.digest("hex")));
124
124
  });
125
125
  }
126
- function specHash(spec, styleImageHashes, providerOptionIdentity = spec.providerOptions) {
126
+ function specHash(spec, styleImageHashes, providerOptionIdentity = spec.providerOptions, providerInputIdentity = spec.providerInputs ?? {}) {
127
127
  return sha256(
128
128
  JSON.stringify({
129
129
  // Preserve every existing PixelLab hash while making a provider switch
130
130
  // invalidate the spec. Older manifests implicitly mean pixellab.
131
131
  provider: spec.provider === "pixellab" ? void 0 : spec.provider,
132
132
  providerOptions: providerOptionIdentity && (typeof providerOptionIdentity !== "object" || Object.keys(providerOptionIdentity).length > 0) ? providerOptionIdentity : void 0,
133
+ providerInputs: providerInputIdentity && (typeof providerInputIdentity !== "object" || Object.keys(providerInputIdentity).length > 0) ? providerInputIdentity : void 0,
133
134
  generator: spec.generator,
134
135
  prompt: spec.prompt,
135
136
  width: spec.width,
@@ -604,6 +605,16 @@ function cacheFileName(hash, mediaType = MediaType.PNG) {
604
605
 
605
606
  // src/providers/comfyui.ts
606
607
  var DEFAULT_BASE_URL = "http://127.0.0.1:8188";
608
+ var BUILTIN_BINDINGS = /* @__PURE__ */ new Set([
609
+ "prompt",
610
+ "width",
611
+ "height",
612
+ "batchSize",
613
+ "seed",
614
+ "sourceImage",
615
+ "maskImage",
616
+ "strength"
617
+ ]);
607
618
  var ComfyUIClient = class {
608
619
  constructor(baseUrl = process.env.COMFYUI_BASE_URL ?? DEFAULT_BASE_URL, request = fetch) {
609
620
  this.request = request;
@@ -753,6 +764,63 @@ var ComfyUIProvider = class _ComfyUIProvider {
753
764
  }
754
765
  };
755
766
  }
767
+ async resolveInputs(value, context) {
768
+ const options = parseOptions(context.providerOptions);
769
+ if (!options.workflow || !options.workflowSha256) {
770
+ throw new Error("ComfyUI provider inputs require resolved workflow options");
771
+ }
772
+ const inputs = {};
773
+ const identity = {};
774
+ for (const [name, input] of Object.entries(value)) {
775
+ if (BUILTIN_BINDINGS.has(name)) {
776
+ throw new Error(
777
+ `ComfyUI providerInputs.${name} is reserved; use PixelKiln's built-in ${name} field`
778
+ );
779
+ }
780
+ const binding = Object.hasOwn(options.bindings, name) ? options.bindings[name] : void 0;
781
+ if (!binding) {
782
+ throw new Error(
783
+ `ComfyUI providerInputs.${name} has no matching bindings.${name} target`
784
+ );
785
+ }
786
+ if (isImageBinding(options.workflow, binding)) {
787
+ if (typeof input !== "string") {
788
+ throw new Error(`ComfyUI providerInputs.${name} must be a manifest-relative PNG or JPEG path`);
789
+ }
790
+ const file = path2.resolve(context.root, input);
791
+ let bytes;
792
+ try {
793
+ bytes = await readFile(file);
794
+ } catch (error) {
795
+ throw new Error(
796
+ `ComfyUI providerInputs.${name} could not be read at ${file}: ${error instanceof Error ? error.message : String(error)}`
797
+ );
798
+ }
799
+ const format = inputImageFormat(bytes);
800
+ if (!format) {
801
+ throw new Error(`ComfyUI providerInputs.${name} is not a PNG or JPEG: ${file}`);
802
+ }
803
+ const hash = sha256(bytes);
804
+ inputs[name] = { kind: "image", path: file, sha256: hash, format };
805
+ identity[name] = { kind: "image", sha256: hash, format };
806
+ } else {
807
+ const current = options.workflow[binding.nodeId].inputs[binding.input];
808
+ if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") {
809
+ throw new Error(
810
+ `ComfyUI providerInputs.${name} cannot replace non-scalar ${binding.nodeId}.${binding.input}`
811
+ );
812
+ }
813
+ if (typeof current !== typeof input) {
814
+ throw new Error(
815
+ `ComfyUI providerInputs.${name} must be ${typeof current} to match ${binding.nodeId}.${binding.input}`
816
+ );
817
+ }
818
+ inputs[name] = input;
819
+ identity[name] = input;
820
+ }
821
+ }
822
+ return { inputs, identity };
823
+ }
756
824
  supports(generator) {
757
825
  return generator === "map";
758
826
  }
@@ -820,6 +888,20 @@ var ComfyUIProvider = class _ComfyUIProvider {
820
888
  if (spec.seed != null && options.bindings.seed) {
821
889
  setBinding(workflow, options.bindings.seed, spec.seed);
822
890
  }
891
+ for (const [name, input] of Object.entries(spec.providerInputs ?? {})) {
892
+ const binding = Object.hasOwn(options.bindings, name) ? options.bindings[name] : void 0;
893
+ if (!binding) throw new Error(`ComfyUI provider input "${name}" has no binding`);
894
+ if (isComfyImageInput(input)) {
895
+ const bytes = await readFile(input.path);
896
+ if (sha256(bytes) !== input.sha256) {
897
+ throw new Error(`ComfyUI provider input "${name}" changed before upload`);
898
+ }
899
+ const uploaded = await this.client.uploadImage(bytes, input.sha256, input.format);
900
+ setBinding(workflow, binding, uploaded);
901
+ } else {
902
+ setBinding(workflow, binding, input);
903
+ }
904
+ }
823
905
  if (spec.revision) {
824
906
  if (!spec.revision.sourceSha256 || !spec.revision.sourceFormat) {
825
907
  throw new Error("ComfyUI revision source is not ready");
@@ -854,19 +936,22 @@ var ComfyUIProvider = class _ComfyUIProvider {
854
936
  }
855
937
  }
856
938
  const promptId = await this.client.submit(workflow);
857
- return {
858
- jobId: encodeJob(promptId, options.outputNodeId),
939
+ const inputs = providerInputProvenance(spec.providerInputs);
940
+ const metadata = {
941
+ ...Object.keys(inputs).length ? { inputs } : {},
859
942
  ...spec.revision ? {
860
- metadata: {
861
- revision: {
862
- mode: spec.revision.mode,
863
- sourceAssetId: spec.revision.sourceAssetId,
864
- sourceSha256: spec.revision.sourceSha256,
865
- ...spec.revision.maskSha256 ? { maskSha256: spec.revision.maskSha256 } : {}
866
- }
943
+ revision: {
944
+ mode: spec.revision.mode,
945
+ sourceAssetId: spec.revision.sourceAssetId,
946
+ sourceSha256: spec.revision.sourceSha256,
947
+ ...spec.revision.maskSha256 ? { maskSha256: spec.revision.maskSha256 } : {}
867
948
  }
868
949
  } : {}
869
950
  };
951
+ return {
952
+ jobId: encodeJob(promptId, options.outputNodeId),
953
+ ...Object.keys(metadata).length ? { metadata } : {}
954
+ };
870
955
  }
871
956
  async poll(jobId, _generator, context) {
872
957
  const { promptId, outputNodeId } = decodeJob(jobId);
@@ -941,28 +1026,10 @@ function parseOptions(value) {
941
1026
  throw new Error("ComfyUI numImages must be a whole number from 1 to 16");
942
1027
  }
943
1028
  if (!isObject(value.bindings)) throw new Error("ComfyUI bindings must be an object");
944
- const bindingKeys = /* @__PURE__ */ new Set([
945
- "prompt",
946
- "width",
947
- "height",
948
- "batchSize",
949
- "seed",
950
- "sourceImage",
951
- "maskImage",
952
- "strength"
953
- ]);
954
- const extraBindings = Object.keys(value.bindings).filter((key) => !bindingKeys.has(key));
955
- if (extraBindings.length) throw new Error(`Unknown ComfyUI binding(s): ${extraBindings.join(", ")}`);
956
- const bindings = {
957
- prompt: parseBinding(value.bindings.prompt, "prompt"),
958
- ...value.bindings.width == null ? {} : { width: parseBinding(value.bindings.width, "width") },
959
- ...value.bindings.height == null ? {} : { height: parseBinding(value.bindings.height, "height") },
960
- ...value.bindings.batchSize == null ? {} : { batchSize: parseBinding(value.bindings.batchSize, "batchSize") },
961
- ...value.bindings.seed == null ? {} : { seed: parseBinding(value.bindings.seed, "seed") },
962
- ...value.bindings.sourceImage == null ? {} : { sourceImage: parseBinding(value.bindings.sourceImage, "sourceImage") },
963
- ...value.bindings.maskImage == null ? {} : { maskImage: parseBinding(value.bindings.maskImage, "maskImage") },
964
- ...value.bindings.strength == null ? {} : { strength: parseBinding(value.bindings.strength, "strength") }
965
- };
1029
+ const bindings = Object.fromEntries(
1030
+ Object.entries(value.bindings).map(([name, binding]) => [name, parseBinding(binding, name)])
1031
+ );
1032
+ if (!bindings.prompt) throw new Error("ComfyUI bindings.prompt must be an object");
966
1033
  const workflow = value.workflow == null ? void 0 : parseWorkflow(value.workflow, workflowFile);
967
1034
  const workflowSha256 = value.workflowSha256 == null ? void 0 : requiredString(value.workflowSha256, "workflowSha256");
968
1035
  return {
@@ -1002,6 +1069,7 @@ function parseWorkflow(value, label) {
1002
1069
  return value;
1003
1070
  }
1004
1071
  function validateWorkflowBindings(workflow, options) {
1072
+ const targets = /* @__PURE__ */ new Map();
1005
1073
  for (const [name, binding] of Object.entries(options.bindings)) {
1006
1074
  if (!binding) continue;
1007
1075
  const node = workflow[binding.nodeId];
@@ -1011,6 +1079,12 @@ function validateWorkflowBindings(workflow, options) {
1011
1079
  `ComfyUI ${name} binding refers to missing input "${binding.input}" on node "${binding.nodeId}"`
1012
1080
  );
1013
1081
  }
1082
+ const target = `${binding.nodeId}.${binding.input}`;
1083
+ const prior = targets.get(target);
1084
+ if (prior) {
1085
+ throw new Error(`ComfyUI bindings.${name} and bindings.${prior} target the same input ${target}`);
1086
+ }
1087
+ targets.set(target, name);
1014
1088
  }
1015
1089
  if (!workflow[options.outputNodeId]) {
1016
1090
  throw new Error(`ComfyUI outputNodeId refers to missing node "${options.outputNodeId}"`);
@@ -1048,6 +1122,7 @@ function outputImages(entry, outputNodeId) {
1048
1122
  }
1049
1123
  function comfyMetadata(spec, promptId, outputNodeId, images) {
1050
1124
  const options = resolvedOptions(spec);
1125
+ const inputs = providerInputProvenance(spec.providerInputs);
1051
1126
  return {
1052
1127
  promptId,
1053
1128
  outputNodeId,
@@ -1055,6 +1130,7 @@ function comfyMetadata(spec, promptId, outputNodeId, images) {
1055
1130
  workflowFile: options.workflowFile,
1056
1131
  workflowSha256: options.workflowSha256,
1057
1132
  files: images.map((image) => ({ ...image })),
1133
+ ...Object.keys(inputs).length ? { inputs } : {},
1058
1134
  ...spec.revision ? {
1059
1135
  revision: {
1060
1136
  mode: spec.revision.mode,
@@ -1066,6 +1142,41 @@ function comfyMetadata(spec, promptId, outputNodeId, images) {
1066
1142
  } : {}
1067
1143
  };
1068
1144
  }
1145
+ function isImageBinding(workflow, binding) {
1146
+ const node = workflow[binding.nodeId];
1147
+ return binding.input === "image" && (node?.class_type === "LoadImage" || node?.class_type === "LoadImageMask");
1148
+ }
1149
+ function isComfyImageInput(value) {
1150
+ return isObject(value) && value.kind === "image" && typeof value.path === "string" && typeof value.sha256 === "string" && (value.format === "png" || value.format === "jpeg");
1151
+ }
1152
+ function providerInputProvenance(inputs) {
1153
+ return Object.fromEntries(Object.entries(inputs ?? {}).map(([name, input]) => [
1154
+ name,
1155
+ isComfyImageInput(input) ? { kind: "image", sha256: input.sha256, format: input.format } : { kind: "value", value: input }
1156
+ ]));
1157
+ }
1158
+ function inputImageFormat(bytes) {
1159
+ if (bytes.length >= 24 && bytes.readUInt32BE(0) === 2303741511 && bytes.readUInt32BE(4) === 218765834 && bytes.toString("ascii", 12, 16) === "IHDR" && bytes.readUInt32BE(16) > 0 && bytes.readUInt32BE(20) > 0) return "png";
1160
+ if (bytes.length < 4 || bytes[0] !== 255 || bytes[1] !== 216) return null;
1161
+ let offset = 2;
1162
+ while (offset + 3 < bytes.length) {
1163
+ if (bytes[offset] !== 255) return null;
1164
+ while (bytes[offset] === 255) offset++;
1165
+ const marker = bytes[offset++];
1166
+ if (marker == null || marker === 217 || marker === 218) break;
1167
+ if (marker === 1 || marker >= 208 && marker <= 215) continue;
1168
+ if (offset + 2 > bytes.length) return null;
1169
+ const length = bytes.readUInt16BE(offset);
1170
+ if (length < 2 || offset + length > bytes.length) return null;
1171
+ const isStartOfFrame = marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker);
1172
+ if (isStartOfFrame) {
1173
+ if (length < 7) return null;
1174
+ return bytes.readUInt16BE(offset + 3) > 0 && bytes.readUInt16BE(offset + 5) > 0 ? "jpeg" : null;
1175
+ }
1176
+ offset += length;
1177
+ }
1178
+ return null;
1179
+ }
1069
1180
  function encodeJob(promptId, outputNodeId) {
1070
1181
  return `${promptId}#${encodeURIComponent(outputNodeId)}`;
1071
1182
  }
@@ -1550,7 +1661,9 @@ var QualityProfileSchema = z2.object({
1550
1661
  /** Optional alpha requirement for isolated assets. */
1551
1662
  minTransparency: z2.number().min(0).max(1).optional(),
1552
1663
  /** Pixel Art Fixer revision written into the quality record. */
1553
- fixerRevision: z2.string().min(1).optional()
1664
+ fixerRevision: z2.string().min(1).optional(),
1665
+ /** Python executable containing Pixel Art Fixer, relative to the manifest unless absolute. */
1666
+ fixerPython: z2.string().min(1).optional()
1554
1667
  }).strict().refine(
1555
1668
  (profile) => new Set(profile.palette.map((color) => color.replace(/^#/, "").toLowerCase())).size === profile.palette.length,
1556
1669
  { message: "quality palette colors must be unique", path: ["palette"] }
@@ -1766,7 +1879,13 @@ var AssetSchema = z2.object({
1766
1879
  * exactly right for a colour style. Editing the shared prompt to suit one
1767
1880
  * style would invalidate every other style's already-generated art.
1768
1881
  */
1769
- promptByStyle: z2.record(z2.string()).default({})
1882
+ promptByStyle: z2.record(z2.string()).default({}),
1883
+ /**
1884
+ * Named per-asset values consumed by the active provider's declared
1885
+ * bindings. Values are JSON scalars; adapters may interpret a string as a
1886
+ * manifest-relative file when the target node accepts an uploaded input.
1887
+ */
1888
+ providerInputs: z2.record(z2.union([z2.string(), z2.number().finite(), z2.boolean()])).default({})
1770
1889
  }).strict().superRefine((asset, context) => {
1771
1890
  if (asset.source && asset.revision) {
1772
1891
  context.addIssue({
@@ -1804,6 +1923,18 @@ var LockEntrySchema = z2.object({
1804
1923
  maskSha256: z2.string().regex(/^[0-9a-f]{64}$/).optional(),
1805
1924
  strength: z2.number().min(0).max(1).optional()
1806
1925
  }).strict().nullable().default(null),
1926
+ /**
1927
+ * Output hashes owned by the previous generation while its replacement is
1928
+ * pending. They authorize replacing only unchanged PixelKiln-owned files.
1929
+ */
1930
+ supersededOutputs: z2.array(
1931
+ z2.object({
1932
+ path: z2.string(),
1933
+ sha256: z2.string(),
1934
+ role: z2.string().optional(),
1935
+ mediaType: MediaTypeSchema.optional()
1936
+ })
1937
+ ).optional(),
1807
1938
  /** Set at submit time, before the request is awaited, so a crash is recoverable. */
1808
1939
  jobId: z2.string().nullable().default(null),
1809
1940
  /** For `1dir`: the multi-candidate parent object awaiting selection. */
@@ -3197,6 +3328,7 @@ async function resolveSpecs(loaded, filter) {
3197
3328
  return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
3198
3329
  });
3199
3330
  const styleSpecs = /* @__PURE__ */ new Map();
3331
+ const providerInputIdentities = /* @__PURE__ */ new Map();
3200
3332
  for (const [assetId, asset] of Object.entries(manifest.assets)) {
3201
3333
  if (!resolutionAssetIds.has(assetId)) continue;
3202
3334
  if (asset.styles.length && !asset.styles.includes(styleId)) continue;
@@ -3204,6 +3336,20 @@ async function resolveSpecs(loaded, filter) {
3204
3336
  if (!activeProvider.supports(generator)) {
3205
3337
  throw new Error(`Provider "${activeProvider.id}" does not support generator "${generator}"`);
3206
3338
  }
3339
+ if (Object.keys(asset.providerInputs).length && !activeProvider.resolveInputs) {
3340
+ throw new Error(
3341
+ `Provider "${activeProvider.id}" does not support asset providerInputs (${styleId}/${assetId})`
3342
+ );
3343
+ }
3344
+ const inputResolution = activeProvider.resolveInputs ? await activeProvider.resolveInputs(asset.providerInputs, {
3345
+ root,
3346
+ styleId,
3347
+ assetId,
3348
+ providerOptions
3349
+ }) : { inputs: {} };
3350
+ const providerInputs = inputResolution.inputs;
3351
+ const providerInputIdentity = inputResolution.identity ?? providerInputs;
3352
+ providerInputIdentities.set(assetId, providerInputIdentity);
3207
3353
  let width;
3208
3354
  let height;
3209
3355
  let size;
@@ -3232,6 +3378,7 @@ async function resolveSpecs(loaded, filter) {
3232
3378
  assetId,
3233
3379
  provider: activeProvider.id,
3234
3380
  providerOptions,
3381
+ providerInputs,
3235
3382
  generator,
3236
3383
  prompt,
3237
3384
  width,
@@ -3273,7 +3420,8 @@ async function resolveSpecs(loaded, filter) {
3273
3420
  palette: style.quality.palette,
3274
3421
  minGridConfidence: style.quality.minGridConfidence,
3275
3422
  ...style.quality.minTransparency == null ? {} : { minTransparency: style.quality.minTransparency },
3276
- ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {}
3423
+ ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {},
3424
+ ...style.quality.fixerPython ? { fixerPython: path4.resolve(root, style.quality.fixerPython) } : {}
3277
3425
  }
3278
3426
  } : {},
3279
3427
  tags,
@@ -3329,7 +3477,12 @@ async function resolveSpecs(loaded, filter) {
3329
3477
  ...asset.revision.strength == null ? {} : { strength: asset.revision.strength }
3330
3478
  };
3331
3479
  }
3332
- resolved.specHash = specHash(resolved, styleImageHashes, providerOptionIdentity);
3480
+ resolved.specHash = specHash(
3481
+ resolved,
3482
+ styleImageHashes,
3483
+ providerOptionIdentity,
3484
+ providerInputIdentities.get(assetId)
3485
+ );
3333
3486
  activeProvider.validate?.(resolved, resolvedImages);
3334
3487
  const estimate = validateCostEstimate(activeProvider.id, activeProvider.estimate(resolved));
3335
3488
  resolved.cost = estimate.amount;
@@ -5075,7 +5228,9 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5075
5228
  minGridConfidence: spec.quality.minGridConfidence,
5076
5229
  ...spec.quality.minTransparency == null ? {} : { minTransparency: spec.quality.minTransparency },
5077
5230
  fixerRevision: spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION,
5078
- fixerPython: options.fixerPython,
5231
+ // A one-off CLI/library override wins; otherwise each style can carry
5232
+ // the stable project-local interpreter that provides its pinned fixer.
5233
+ fixerPython: options.fixerPython ?? spec.quality.fixerPython,
5079
5234
  fixerCommand: options.fixerCommand,
5080
5235
  fixerArgsPrefix: options.fixerArgsPrefix,
5081
5236
  force: options.force
@@ -5199,6 +5354,29 @@ async function anyOutputModified(entry, spec) {
5199
5354
  }
5200
5355
  return false;
5201
5356
  }
5357
+ function resumeCommandForStatus(status) {
5358
+ if (status === "pending" || status === "processing") return "poll";
5359
+ if (status === "review") return "pick";
5360
+ if (status === "selected" || status === "download-failed") return "fetch";
5361
+ return null;
5362
+ }
5363
+ function resumeActions(specs, lock) {
5364
+ const grouped = /* @__PURE__ */ new Map();
5365
+ for (const spec of specs) {
5366
+ const key = lockKey(spec.styleId, spec.assetId);
5367
+ const entry = lock.entries[key];
5368
+ if (!entry || entry.specHash !== spec.specHash) continue;
5369
+ const command = resumeCommandForStatus(entry.status);
5370
+ if (!command) continue;
5371
+ if (command === "poll" && !entry.jobId) continue;
5372
+ if (command === "pick" && !entry.reviewObjectId) continue;
5373
+ grouped.set(command, [...grouped.get(command) ?? [], key]);
5374
+ }
5375
+ return ["poll", "pick", "fetch"].flatMap((command) => {
5376
+ const keys = grouped.get(command);
5377
+ return keys?.length ? [{ command, keys }] : [];
5378
+ });
5379
+ }
5202
5380
  async function buildPlan(specs, lock, opts = {}) {
5203
5381
  const items = [];
5204
5382
  for (const spec of specs) {
@@ -5234,10 +5412,20 @@ async function buildPlan(specs, lock, opts = {}) {
5234
5412
  reason = "prompt, size, or style changed";
5235
5413
  } else if (entry.status === "download-failed") {
5236
5414
  state = "recoverable";
5237
- reason = `${entry.error ?? "download failed"}; run fetch or restore (no generation cost)`;
5415
+ const force = entry.error?.includes("pass --force") ? " --force" : "";
5416
+ reason = `${entry.error ?? "download failed"}; run pixelkiln fetch${force} (no generation cost)`;
5238
5417
  } else if (entry.status === "failed") {
5239
5418
  state = "failed";
5240
5419
  reason = entry.error ?? "previous attempt failed";
5420
+ } else if (entry.status === "selected") {
5421
+ state = "recoverable";
5422
+ reason = "provider output is selected; run pixelkiln fetch (no generation cost)";
5423
+ } else if (entry.status === "pending" || entry.status === "processing") {
5424
+ state = "in-flight";
5425
+ reason = entry.jobId ? "awaiting processing; run pixelkiln poll" : "submission state has no job id; run pixelkiln doctor before retrying";
5426
+ } else if (entry.status === "review") {
5427
+ state = "in-flight";
5428
+ reason = entry.reviewObjectId ? "awaiting review; run pixelkiln pick" : "review state has no review object id; run pixelkiln doctor";
5241
5429
  } else if (entry.status !== "downloaded") {
5242
5430
  state = "in-flight";
5243
5431
  reason = `awaiting ${entry.status}`;
@@ -5374,6 +5562,8 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
5374
5562
  const since = Date.now() - lastSubmitAt;
5375
5563
  if (since < spacing) await sleep2(spacing - since);
5376
5564
  await requireRevisionReady(spec, lock);
5565
+ const previousEntry = lock.entries[key];
5566
+ const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
5377
5567
  upsert(lock, key, {
5378
5568
  styleId: spec.styleId,
5379
5569
  assetId: spec.assetId,
@@ -5397,6 +5587,9 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
5397
5587
  candidateIndex: null,
5398
5588
  error: null,
5399
5589
  outputs: [],
5590
+ // Keep the old ownership proof while new bytes are pending. Fetch may
5591
+ // replace that file only while its hash still matches this record.
5592
+ supersededOutputs,
5400
5593
  providerMetadata: {},
5401
5594
  sourceUrl: null,
5402
5595
  sourceUrls: [],
@@ -5607,22 +5800,37 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
5607
5800
  );
5608
5801
  const target = recorded ? resolveOutputPath(recorded.path, spec.root) : expectedOutputPath(spec, source.role, index, sources.length, source.mediaType);
5609
5802
  if (existsSync9(target)) {
5610
- if (!recorded) {
5611
- throw new Error(`refusing to overwrite untracked output ${target}`);
5612
- }
5613
- if (await sha256File(target) !== recorded.sha256) {
5614
- throw new Error(`refusing to overwrite modified output ${target}`);
5803
+ const currentHash = await sha256File(target);
5804
+ if (recorded && currentHash === recorded.sha256) {
5805
+ if (cacheDir) {
5806
+ await cacheMedia(
5807
+ cacheDir,
5808
+ await readFile7(target),
5809
+ recorded.mediaType ?? MediaType.PNG,
5810
+ recorded.sha256
5811
+ );
5812
+ }
5813
+ outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
5814
+ continue;
5615
5815
  }
5616
- if (cacheDir) {
5617
- await cacheMedia(
5618
- cacheDir,
5619
- await readFile7(target),
5620
- recorded.mediaType ?? MediaType.PNG,
5621
- recorded.sha256
5622
- );
5816
+ const superseded = (entry.supersededOutputs ?? []).find(
5817
+ (output, oldIndex, all) => currentOutputPath(output, spec, oldIndex, all.length) === target
5818
+ );
5819
+ if (!opts.force) {
5820
+ if (!recorded && superseded && currentHash === superseded.sha256) {
5821
+ } else if (recorded || superseded) {
5822
+ throw new Error(
5823
+ `refusing to overwrite modified output ${target}; pass --force to replace it`
5824
+ );
5825
+ } else {
5826
+ throw new Error(
5827
+ `refusing to overwrite untracked output ${target}; pass --force to replace it`
5828
+ );
5829
+ }
5623
5830
  }
5624
- outputs.push({ ...recorded, path: portableOutputPath(target, spec.root) });
5625
- continue;
5831
+ log2(
5832
+ ` replace ${path14.relative(process.cwd(), target)}` + (opts.force ? " (--force)" : " (previous tracked generation)")
5833
+ );
5626
5834
  }
5627
5835
  const expectedMediaType = source.mediaType ?? recorded?.mediaType ?? MediaType.PNG;
5628
5836
  let buf = recorded && cacheDir ? await readCachedMedia(cacheDir, recorded.sha256, expectedMediaType) : null;
@@ -5670,7 +5878,8 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
5670
5878
  sourceUrl: persistentSources[0]?.url ?? null,
5671
5879
  sourceUrls: persistentSources,
5672
5880
  downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
5673
- error: null
5881
+ error: null,
5882
+ supersededOutputs: []
5674
5883
  });
5675
5884
  result.downloaded++;
5676
5885
  } catch (err) {
@@ -5841,6 +6050,14 @@ async function doctor(loaded, specs, lock, lockPath, opts = {}) {
5841
6050
  unsettled ? "warning" : "ok",
5842
6051
  unsettled ? `${unsettled} spec(s) are not current: ` + Object.entries(counts).filter(([, n]) => n > 0).map(([state, n]) => `${n} ${state}`).join(", ") : "every resolved spec is current"
5843
6052
  );
6053
+ const resumable = resumeActions(specs, lock);
6054
+ add(
6055
+ "next-actions",
6056
+ resumable.length ? "warning" : "ok",
6057
+ resumable.length ? "resume paid work without regenerating: " + resumable.map(
6058
+ (action) => `pixelkiln ${action.command} (${action.keys.length} asset${action.keys.length === 1 ? "" : "s"})`
6059
+ ).join(", ") : "no generation stage is waiting for poll, pick, or fetch"
6060
+ );
5844
6061
  const quality = plan.items.flatMap((item) => item.quality ? [item.quality] : []);
5845
6062
  const qualityPending = quality.filter((item) => item.state !== "approved");
5846
6063
  if (quality.length) {
@@ -6578,10 +6795,15 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6578
6795
  onProgress: log2,
6579
6796
  assets: reviewAssets,
6580
6797
  onReady: (url) => {
6581
- log2(`
6798
+ const info = { url, keys: groups.map((group) => group.key) };
6799
+ if (opts.onReady) {
6800
+ opts.onReady(info);
6801
+ } else {
6802
+ log2(`
6582
6803
  ${groups.length} asset(s) awaiting selection: ${url}`);
6583
- log2(` (leave this running; it exits once you apply)
6804
+ log2(` (leave this running; it exits once you apply)
6584
6805
  `);
6806
+ }
6585
6807
  },
6586
6808
  handleApply: async (body) => {
6587
6809
  const { selections } = body;
@@ -6683,6 +6905,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
6683
6905
  file: asset.file,
6684
6906
  tags: [],
6685
6907
  styles: [],
6908
+ providerInputs: {},
6686
6909
  ...generator === "map" ? { width: asset.width, height: asset.height } : asset.width === asset.height && asset.width !== commonSize ? { size: asset.width } : {}
6687
6910
  };
6688
6911
  }
@@ -7745,6 +7968,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
7745
7968
  file: rel,
7746
7969
  tags: ["salvaged"],
7747
7970
  styles: [ctx.styleId],
7971
+ providerInputs: {},
7748
7972
  ...orphan.width === orphan.height ? { size: orphan.width } : { width: orphan.width, height: orphan.height }
7749
7973
  };
7750
7974
  const durableSource = shouldPersistSourceUrl(orphan.previewUrl) ? orphan.previewUrl : null;
@@ -8543,6 +8767,17 @@ async function checkQualityBaseline(baselinePath) {
8543
8767
 
8544
8768
  // src/cli.ts
8545
8769
  var log = (msg = "") => console.log(msg);
8770
+ function announceReviewReady(info, stdout = process.stdout, stderr = process.stderr) {
8771
+ const stream = stdout.isTTY ? stdout : stderr;
8772
+ const count = info.keys.length;
8773
+ stream.write(
8774
+ `
8775
+ ${count} asset${count === 1 ? "" : "s"} awaiting selection: ${info.url}
8776
+ (leave this running; it exits once you apply)
8777
+
8778
+ `
8779
+ );
8780
+ }
8546
8781
  async function provenanceFile(id, file) {
8547
8782
  const absolute = path24.resolve(file);
8548
8783
  return {
@@ -8900,7 +9135,7 @@ Options
8900
9135
  --max-colors <n> audit: maximum distinct opaque colors
8901
9136
  --sigma <n> audit: relative outlier cutoff (default: 1.5)
8902
9137
  --palette <hexes> refine: final comma-separated #rrggbb colors (repeatable)
8903
- --fixer-python <path> refine: Python with Pixel Art Fixer installed
9138
+ --fixer-python <path> refine: override Python with Pixel Art Fixer installed
8904
9139
  --fixer-revision <sha> refine: Pixel Art Fixer revision to record
8905
9140
  --min-grid-confidence <level> refine: high (default), medium, or low
8906
9141
  --reviewer <name> refine approve: human reviewer recorded in provenance
@@ -8914,7 +9149,7 @@ Options
8914
9149
  --budget <n|provider=n> Refuse to exceed one provider ceiling; repeat keyed budgets
8915
9150
  for a mixed-provider run
8916
9151
  --provider <id> Choose the account for balance/adopt/salvage/purge in a mixed manifest
8917
- --force Regenerate; also rerun refinement or replace changed derived state
9152
+ --force Regenerate, replace a fetch destination, or rebuild managed output
8918
9153
  --dry-run Never spend; doctor also skips provider connectivity
8919
9154
  --all salvage --dry-run: list every unclaimed object, not just the first 30
8920
9155
  --json Machine-readable output where supported, including quality checks
@@ -8997,6 +9232,17 @@ function printPlan(plan) {
8997
9232
  }
8998
9233
  }
8999
9234
  }
9235
+ function printResumeActions(specs, lock) {
9236
+ const actions = resumeActions(specs, lock);
9237
+ for (const action of actions) {
9238
+ const shown = action.keys.slice(0, 3).join(", ");
9239
+ const more = action.keys.length > 3 ? `, +${action.keys.length - 3} more` : "";
9240
+ log(
9241
+ ` next: pixelkiln ${action.command} \u2014 ${action.keys.length} asset${action.keys.length === 1 ? "" : "s"} (${shown}${more})`
9242
+ );
9243
+ }
9244
+ return actions.length;
9245
+ }
9000
9246
  function manifestProviderIds(manifest) {
9001
9247
  return [...new Set(
9002
9248
  Object.values(manifest.styles).map((style) => style.provider ?? manifest.provider)
@@ -10395,7 +10641,10 @@ async function main() {
10395
10641
  ${total.completed} ready \xB7 ${total.review} awaiting selection \xB7 ${total.failed} failed`
10396
10642
  );
10397
10643
  if (total.failed || total.stillRunning) process.exitCode = 1;
10398
- if (args.command === "poll") return;
10644
+ if (args.command === "poll") {
10645
+ printResumeActions(specs, lock);
10646
+ return;
10647
+ }
10399
10648
  }
10400
10649
  if (args.command === "pick" || args.command === "gen") {
10401
10650
  const total = { selected: 0, skipped: 0 };
@@ -10404,6 +10653,7 @@ async function main() {
10404
10653
  port: args.port,
10405
10654
  open: !args.noOpen,
10406
10655
  onProgress: log,
10656
+ onReady: announceReviewReady,
10407
10657
  keys: providerSpecsForRun.map((spec) => lockKey(spec.styleId, spec.assetId)),
10408
10658
  specs: providerSpecsForRun
10409
10659
  });
@@ -10417,7 +10667,10 @@ async function main() {
10417
10667
  selected ${total.selected}, left in review ${total.skipped}`);
10418
10668
  }
10419
10669
  if (args.command === "gen" && total.skipped) process.exitCode = 1;
10420
- if (args.command === "pick") return;
10670
+ if (args.command === "pick") {
10671
+ printResumeActions(specs, lock);
10672
+ return;
10673
+ }
10421
10674
  }
10422
10675
  if (args.command === "fetch" || args.command === "restore" || args.command === "gen") {
10423
10676
  log(`
@@ -10427,7 +10680,8 @@ async function main() {
10427
10680
  const groupProvider = providerFor(providerId);
10428
10681
  const res = await fetchAssets(groupProvider, providerSpecsForRun, lock, args.lock, {
10429
10682
  onProgress: log,
10430
- repair: args.command === "restore"
10683
+ repair: args.command === "restore",
10684
+ force: args.force
10431
10685
  });
10432
10686
  total.downloaded += res.downloaded;
10433
10687
  total.skipped += res.skipped;
@@ -10446,10 +10700,22 @@ async function main() {
10446
10700
  if (args.tag) log(` tagged ${total.tagged} object(s) upstream`);
10447
10701
  await saveLock(args.lock, lock);
10448
10702
  log(` lockfile written: ${args.lock}`);
10703
+ const resumeActionCount = printResumeActions(specs, lock);
10449
10704
  if (args.command === "gen" && specs.some((spec) => spec.quality) && !total.failed) {
10450
- log(`
10705
+ const quality = (await Promise.all(
10706
+ specs.filter((spec) => spec.quality).map((spec) => inspectQualityProfile(spec, lock))
10707
+ )).filter((item) => item !== null);
10708
+ const blocked = quality.filter((item) => item.state === "blocked");
10709
+ if (blocked.length) {
10710
+ log(
10711
+ `
10712
+ ${blocked.length} quality source${blocked.length === 1 ? " is" : "s are"} not ready; ` + (resumeActionCount ? "finish the resume steps above before refining." : "run `pixelkiln plan` for the blocking reason before refining.")
10713
+ );
10714
+ } else {
10715
+ log(`
10451
10716
  Raw provider output is ready. Run \`pixelkiln refine\` to build the configured quality output.`);
10452
- log(` Packaging stays blocked until each refined PNG has a current human approval.`);
10717
+ log(` Packaging stays blocked until each refined PNG has a current human approval.`);
10718
+ }
10453
10719
  }
10454
10720
  return;
10455
10721
  }
@@ -10463,6 +10729,7 @@ main().catch((err) => {
10463
10729
  });
10464
10730
  export {
10465
10731
  COMMANDS,
10732
+ announceReviewReady,
10466
10733
  parseArgs
10467
10734
  };
10468
10735
  //# sourceMappingURL=cli.js.map