pixelkiln 0.17.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/README.md CHANGED
@@ -56,7 +56,7 @@ PixelKiln keeps the missing record:
56
56
  |---|---|
57
57
  | Plan and budget | Offline manifest/lock/disk diff, provider-grouped estimates, keyed mixed-provider budget ceilings, JSON/CI gate. |
58
58
  | Generate and review | Resumable submit/poll/pick/fetch pipeline, exact next-step hints, and a fast local candidate sheet. |
59
- | Controlled revisions | Hashed image-to-image/inpaint lineage, fail-closed parent approval, and source-versus-candidate review; ComfyUI is the first adapter. |
59
+ | Controlled inputs | Hashed image-to-image/inpaint lineage, fail-closed parent approval, source-versus-candidate review, and content-addressed per-asset ComfyUI bindings. |
60
60
  | Existing-art onboarding | Manifest scaffolding, exact-hash account adoption, and prompt recovery. |
61
61
  | Recovery | Safe stale-output replacement, validated caches, durable references, and resumable paid jobs. |
62
62
  | Shared-account safety | Cross-project claim files or a registered workspace catalog, sibling-style exclusion, reviewed salvage, keep/discard tags, separate confirmed purge. |
@@ -356,7 +356,7 @@ exporters, managed artifact writes, and offline provenance verification. See
356
356
  | [Getting started](./docs/GETTING_STARTED.md) | First project, existing-art onboarding, everyday workflow, and what to commit. |
357
357
  | [Set up PixelLab](./docs/PIXELLAB.md) | Production-provider credentials, manifest, generators, and account workflows. |
358
358
  | [Set up Retro Diffusion](./docs/RETRO_DIFFUSION.md) | Experimental-provider credentials, styles, formats, cost checks, and limits. |
359
- | [Set up ComfyUI](./docs/COMFYUI.md) | Experimental self-hosted server, workflow bindings, local cost semantics, and limits. |
359
+ | [Set up ComfyUI](./docs/COMFYUI.md) | Self-hosted workflows, per-asset input uploads, local cost semantics, and quality limits. |
360
360
  | [Set up Scenario](./docs/SCENARIO.md) | Experimental hosted models, two-part credentials, CU preflight, review, and durable downloads. |
361
361
  | [Versioned recipes](./docs/RECIPES.md) | Pinned workflow packs, model hashes, manifest templates, and quality contracts. |
362
362
  | [Controlled revisions](./docs/REVISIONS.md) | Image-to-image/inpaint parents, masks, fail-closed readiness, provenance, and ComfyUI bindings. |
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
  }
@@ -1768,7 +1879,13 @@ var AssetSchema = z2.object({
1768
1879
  * exactly right for a colour style. Editing the shared prompt to suit one
1769
1880
  * style would invalidate every other style's already-generated art.
1770
1881
  */
1771
- 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({})
1772
1889
  }).strict().superRefine((asset, context) => {
1773
1890
  if (asset.source && asset.revision) {
1774
1891
  context.addIssue({
@@ -3211,6 +3328,7 @@ async function resolveSpecs(loaded, filter) {
3211
3328
  return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
3212
3329
  });
3213
3330
  const styleSpecs = /* @__PURE__ */ new Map();
3331
+ const providerInputIdentities = /* @__PURE__ */ new Map();
3214
3332
  for (const [assetId, asset] of Object.entries(manifest.assets)) {
3215
3333
  if (!resolutionAssetIds.has(assetId)) continue;
3216
3334
  if (asset.styles.length && !asset.styles.includes(styleId)) continue;
@@ -3218,6 +3336,20 @@ async function resolveSpecs(loaded, filter) {
3218
3336
  if (!activeProvider.supports(generator)) {
3219
3337
  throw new Error(`Provider "${activeProvider.id}" does not support generator "${generator}"`);
3220
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);
3221
3353
  let width;
3222
3354
  let height;
3223
3355
  let size;
@@ -3246,6 +3378,7 @@ async function resolveSpecs(loaded, filter) {
3246
3378
  assetId,
3247
3379
  provider: activeProvider.id,
3248
3380
  providerOptions,
3381
+ providerInputs,
3249
3382
  generator,
3250
3383
  prompt,
3251
3384
  width,
@@ -3344,7 +3477,12 @@ async function resolveSpecs(loaded, filter) {
3344
3477
  ...asset.revision.strength == null ? {} : { strength: asset.revision.strength }
3345
3478
  };
3346
3479
  }
3347
- resolved.specHash = specHash(resolved, styleImageHashes, providerOptionIdentity);
3480
+ resolved.specHash = specHash(
3481
+ resolved,
3482
+ styleImageHashes,
3483
+ providerOptionIdentity,
3484
+ providerInputIdentities.get(assetId)
3485
+ );
3348
3486
  activeProvider.validate?.(resolved, resolvedImages);
3349
3487
  const estimate = validateCostEstimate(activeProvider.id, activeProvider.estimate(resolved));
3350
3488
  resolved.cost = estimate.amount;
@@ -6767,6 +6905,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
6767
6905
  file: asset.file,
6768
6906
  tags: [],
6769
6907
  styles: [],
6908
+ providerInputs: {},
6770
6909
  ...generator === "map" ? { width: asset.width, height: asset.height } : asset.width === asset.height && asset.width !== commonSize ? { size: asset.width } : {}
6771
6910
  };
6772
6911
  }
@@ -7829,6 +7968,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
7829
7968
  file: rel,
7830
7969
  tags: ["salvaged"],
7831
7970
  styles: [ctx.styleId],
7971
+ providerInputs: {},
7832
7972
  ...orphan.width === orphan.height ? { size: orphan.width } : { width: orphan.width, height: orphan.height }
7833
7973
  };
7834
7974
  const durableSource = shouldPersistSourceUrl(orphan.previewUrl) ? orphan.previewUrl : null;