pixelkiln 0.17.0 → 0.19.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;
@@ -748,23 +759,105 @@ var ComfyUIProvider = class _ComfyUIProvider {
748
759
  identity: {
749
760
  outputNodeId: options.outputNodeId,
750
761
  numImages: options.numImages,
762
+ ...options.frames ? { frames: options.frames } : {},
751
763
  bindings: options.bindings,
752
764
  workflowSha256
753
765
  }
754
766
  };
755
767
  }
768
+ async resolveInputs(value, context) {
769
+ const options = parseOptions(context.providerOptions);
770
+ if (!options.workflow || !options.workflowSha256) {
771
+ throw new Error("ComfyUI provider inputs require resolved workflow options");
772
+ }
773
+ const inputs = {};
774
+ const identity = {};
775
+ for (const [name, input] of Object.entries(value)) {
776
+ if (BUILTIN_BINDINGS.has(name)) {
777
+ throw new Error(
778
+ `ComfyUI providerInputs.${name} is reserved; use PixelKiln's built-in ${name} field`
779
+ );
780
+ }
781
+ const binding = Object.hasOwn(options.bindings, name) ? options.bindings[name] : void 0;
782
+ if (!binding) {
783
+ throw new Error(
784
+ `ComfyUI providerInputs.${name} has no matching bindings.${name} target`
785
+ );
786
+ }
787
+ const resolveValue = async (item, index) => {
788
+ const label = `providerInputs.${name}${index == null ? "" : `[${index}]`}`;
789
+ if (isImageBinding(options.workflow, binding)) {
790
+ if (typeof item !== "string") {
791
+ throw new Error(`ComfyUI ${label} must be a manifest-relative PNG or JPEG path`);
792
+ }
793
+ const file = path2.resolve(context.root, item);
794
+ let bytes;
795
+ try {
796
+ bytes = await readFile(file);
797
+ } catch (error) {
798
+ throw new Error(
799
+ `ComfyUI ${label} could not be read at ${file}: ${error instanceof Error ? error.message : String(error)}`
800
+ );
801
+ }
802
+ const format = inputImageFormat(bytes);
803
+ if (!format) throw new Error(`ComfyUI ${label} is not a PNG or JPEG: ${file}`);
804
+ const hash = sha256(bytes);
805
+ return {
806
+ runtime: { kind: "image", path: file, sha256: hash, format },
807
+ stable: { kind: "image", sha256: hash, format }
808
+ };
809
+ }
810
+ const current = options.workflow[binding.nodeId].inputs[binding.input];
811
+ if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") {
812
+ throw new Error(
813
+ `ComfyUI ${label} cannot replace non-scalar ${binding.nodeId}.${binding.input}`
814
+ );
815
+ }
816
+ if (typeof current !== typeof item) {
817
+ throw new Error(
818
+ `ComfyUI ${label} must be ${typeof current} to match ${binding.nodeId}.${binding.input}`
819
+ );
820
+ }
821
+ return { runtime: item, stable: item };
822
+ };
823
+ if (Array.isArray(input)) {
824
+ if (!options.frames || name !== options.frames.vary) {
825
+ throw new Error(
826
+ `ComfyUI providerInputs.${name} is an array but frames.vary is ${options.frames ? `"${options.frames.vary}"` : "not configured"}`
827
+ );
828
+ }
829
+ const resolved = await Promise.all(input.map((item, index) => resolveValue(item, index)));
830
+ inputs[name] = resolved.map((item) => item.runtime);
831
+ identity[name] = resolved.map((item) => item.stable);
832
+ } else {
833
+ const resolved = await resolveValue(input);
834
+ inputs[name] = resolved.runtime;
835
+ identity[name] = resolved.stable;
836
+ }
837
+ }
838
+ return { inputs, identity };
839
+ }
756
840
  supports(generator) {
757
- return generator === "map";
841
+ return generator === "map" || generator === "frames";
758
842
  }
759
843
  supportsRevision(_mode) {
760
844
  return true;
761
845
  }
762
846
  estimate(spec) {
763
- return { unit: "free", amount: 0, candidates: resolvedOptions(spec).numImages };
847
+ return {
848
+ unit: "free",
849
+ amount: 0,
850
+ candidates: spec.generator === "frames" ? 1 : resolvedOptions(spec).numImages
851
+ };
764
852
  }
765
853
  validate(spec, styleImages) {
766
854
  const options = resolvedOptions(spec);
767
- if (spec.source && !spec.revision) return;
855
+ if (spec.source && !spec.revision) {
856
+ if (spec.generator === "frames") {
857
+ throw new Error("ComfyUI frame sets require generated provider outputs, not asset.source");
858
+ }
859
+ return;
860
+ }
768
861
  if (spec.width < 16 || spec.height < 16 || spec.width > 4096 || spec.height > 4096) {
769
862
  throw new Error("ComfyUI output dimensions must be between 16 and 4096 pixels");
770
863
  }
@@ -786,6 +879,25 @@ var ComfyUIProvider = class _ComfyUIProvider {
786
879
  if (options.numImages > 1 && !options.bindings.batchSize) {
787
880
  throw new Error("ComfyUI numImages greater than 1 requires bindings.batchSize");
788
881
  }
882
+ if (spec.generator === "frames") {
883
+ if (!options.frames) throw new Error("ComfyUI frames generation requires providerOptions.comfyui.frames");
884
+ if (options.numImages !== 1) throw new Error("ComfyUI frames generation requires numImages: 1");
885
+ if (spec.revision) throw new Error("ComfyUI frame sets do not support asset revisions yet");
886
+ const values = spec.providerInputs?.[options.frames.vary];
887
+ if (!Array.isArray(values) || values.length < 2 || values.length > 64) {
888
+ throw new Error(
889
+ `ComfyUI frames.vary "${options.frames.vary}" requires a providerInputs array of 2\u201364 values`
890
+ );
891
+ }
892
+ if (options.frames.seedStep !== 0 && (spec.seed == null || !options.bindings.seed)) {
893
+ throw new Error("ComfyUI frames.seedStep requires a style seed and bindings.seed");
894
+ }
895
+ if (spec.seed != null && !Number.isSafeInteger(spec.seed + (values.length - 1) * options.frames.seedStep)) {
896
+ throw new Error("ComfyUI frame seed schedule exceeds JavaScript's safe integer range");
897
+ }
898
+ } else if (options.frames) {
899
+ throw new Error("ComfyUI providerOptions.frames requires generator: frames");
900
+ }
789
901
  if (spec.revision) {
790
902
  if (!options.bindings.sourceImage) {
791
903
  throw new Error("ComfyUI revisions require bindings.sourceImage");
@@ -810,16 +922,64 @@ var ComfyUIProvider = class _ComfyUIProvider {
810
922
  async submit(spec, styleImages) {
811
923
  this.validate(spec, styleImages);
812
924
  const options = resolvedOptions(spec);
813
- const workflow = structuredClone(options.workflow);
814
- setBinding(workflow, options.bindings.prompt, spec.prompt);
815
- if (options.bindings.width) setBinding(workflow, options.bindings.width, spec.width);
816
- if (options.bindings.height) setBinding(workflow, options.bindings.height, spec.height);
817
- if (options.bindings.batchSize) {
818
- setBinding(workflow, options.bindings.batchSize, options.numImages);
819
- }
820
- if (spec.seed != null && options.bindings.seed) {
821
- setBinding(workflow, options.bindings.seed, spec.seed);
925
+ const uploadedImages = /* @__PURE__ */ new Map();
926
+ const bindInput = async (workflow2, name, input) => {
927
+ const binding = Object.hasOwn(options.bindings, name) ? options.bindings[name] : void 0;
928
+ if (!binding) throw new Error(`ComfyUI provider input "${name}" has no binding`);
929
+ if (!isComfyImageInput(input)) {
930
+ setBinding(workflow2, binding, input);
931
+ return;
932
+ }
933
+ const bytes = await readFile(input.path);
934
+ if (sha256(bytes) !== input.sha256) {
935
+ throw new Error(`ComfyUI provider input "${name}" changed before upload`);
936
+ }
937
+ let uploaded = uploadedImages.get(input.sha256);
938
+ if (!uploaded) {
939
+ uploaded = await this.client.uploadImage(bytes, input.sha256, input.format);
940
+ uploadedImages.set(input.sha256, uploaded);
941
+ }
942
+ setBinding(workflow2, binding, uploaded);
943
+ };
944
+ const buildWorkflow = async (frameIndex) => {
945
+ const workflow2 = structuredClone(options.workflow);
946
+ setBinding(workflow2, options.bindings.prompt, spec.prompt);
947
+ if (options.bindings.width) setBinding(workflow2, options.bindings.width, spec.width);
948
+ if (options.bindings.height) setBinding(workflow2, options.bindings.height, spec.height);
949
+ if (options.bindings.batchSize) setBinding(workflow2, options.bindings.batchSize, options.numImages);
950
+ if (spec.seed != null && options.bindings.seed) {
951
+ const seed = frameIndex == null ? spec.seed : spec.seed + frameIndex * (options.frames?.seedStep ?? 0);
952
+ setBinding(workflow2, options.bindings.seed, seed);
953
+ }
954
+ for (const [name, input] of Object.entries(spec.providerInputs ?? {})) {
955
+ const selected = Array.isArray(input) ? frameIndex == null ? input : input[frameIndex] : input;
956
+ await bindInput(workflow2, name, selected);
957
+ }
958
+ return workflow2;
959
+ };
960
+ const inputs = providerInputProvenance(spec.providerInputs);
961
+ if (spec.generator === "frames") {
962
+ const frames = options.frames;
963
+ const values = spec.providerInputs[frames.vary];
964
+ const promptIds = [];
965
+ for (let index = 0; index < values.length; index++) {
966
+ promptIds.push(await this.client.submit(await buildWorkflow(index)));
967
+ }
968
+ return {
969
+ jobId: encodeFrameSetJob({ promptIds, outputNodeId: options.outputNodeId }),
970
+ metadata: {
971
+ inputs,
972
+ frameSet: {
973
+ count: values.length,
974
+ vary: frames.vary,
975
+ seedStep: frames.seedStep,
976
+ fps: spec.quality?.fps ?? 12,
977
+ promptIds
978
+ }
979
+ }
980
+ };
822
981
  }
982
+ const workflow = await buildWorkflow();
823
983
  if (spec.revision) {
824
984
  if (!spec.revision.sourceSha256 || !spec.revision.sourceFormat) {
825
985
  throw new Error("ComfyUI revision source is not ready");
@@ -854,21 +1014,63 @@ var ComfyUIProvider = class _ComfyUIProvider {
854
1014
  }
855
1015
  }
856
1016
  const promptId = await this.client.submit(workflow);
857
- return {
858
- jobId: encodeJob(promptId, options.outputNodeId),
1017
+ const metadata = {
1018
+ ...Object.keys(inputs).length ? { inputs } : {},
859
1019
  ...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
- }
1020
+ revision: {
1021
+ mode: spec.revision.mode,
1022
+ sourceAssetId: spec.revision.sourceAssetId,
1023
+ sourceSha256: spec.revision.sourceSha256,
1024
+ ...spec.revision.maskSha256 ? { maskSha256: spec.revision.maskSha256 } : {}
867
1025
  }
868
1026
  } : {}
869
1027
  };
1028
+ return {
1029
+ jobId: encodeJob(promptId, options.outputNodeId),
1030
+ ...Object.keys(metadata).length ? { metadata } : {}
1031
+ };
870
1032
  }
871
- async poll(jobId, _generator, context) {
1033
+ async poll(jobId, generator, context) {
1034
+ if (generator === "frames") {
1035
+ const frameJob = decodeFrameSetJob(jobId);
1036
+ const images2 = [];
1037
+ for (let index = 0; index < frameJob.promptIds.length; index++) {
1038
+ const promptId2 = frameJob.promptIds[index];
1039
+ const history2 = await this.client.history(promptId2);
1040
+ const entry2 = historyEntry(history2, promptId2);
1041
+ if (!entry2) return { status: "processing" };
1042
+ const status2 = isObject(entry2.status) ? entry2.status : {};
1043
+ if (status2.status_str === "error") {
1044
+ return { status: "failed", error: `frame ${index}: ${historyError(status2)}` };
1045
+ }
1046
+ if (status2.completed === false) return { status: "processing" };
1047
+ const output = outputImages(entry2, frameJob.outputNodeId);
1048
+ if (output instanceof Error) {
1049
+ return { status: "failed", error: `frame ${index}: ${output.message}` };
1050
+ }
1051
+ if (output.length !== 1) {
1052
+ return {
1053
+ status: "failed",
1054
+ error: `frame ${index}: ComfyUI returned ${output.length} images, expected 1`
1055
+ };
1056
+ }
1057
+ images2.push(output[0]);
1058
+ }
1059
+ const fps = context?.spec?.quality?.fps ?? 12;
1060
+ const sources = images2.map((image, index) => ({
1061
+ url: sourceRef(image),
1062
+ role: frameRole(index),
1063
+ mediaType: MediaType.PNG
1064
+ }));
1065
+ return {
1066
+ status: "review-set",
1067
+ objectId: jobId,
1068
+ frameUrls: images2.map((image) => this.client.viewUrl(image)),
1069
+ sources,
1070
+ fps,
1071
+ metadata: context?.spec ? comfyFrameMetadata(context.spec, frameJob, images2, fps) : { frameSet: { count: images2.length, fps, promptIds: frameJob.promptIds } }
1072
+ };
1073
+ }
872
1074
  const { promptId, outputNodeId } = decodeJob(jobId);
873
1075
  const history = await this.client.history(promptId);
874
1076
  const entry = historyEntry(history, promptId);
@@ -931,7 +1133,15 @@ var ComfyUIProvider = class _ComfyUIProvider {
931
1133
  }
932
1134
  };
933
1135
  function parseOptions(value) {
934
- const allowed = /* @__PURE__ */ new Set(["workflowFile", "outputNodeId", "numImages", "bindings", "workflow", "workflowSha256"]);
1136
+ const allowed = /* @__PURE__ */ new Set([
1137
+ "workflowFile",
1138
+ "outputNodeId",
1139
+ "numImages",
1140
+ "frames",
1141
+ "bindings",
1142
+ "workflow",
1143
+ "workflowSha256"
1144
+ ]);
935
1145
  const extra = Object.keys(value).filter((key) => !allowed.has(key));
936
1146
  if (extra.length) throw new Error(`Unknown ComfyUI option(s): ${extra.join(", ")}`);
937
1147
  const workflowFile = requiredString(value.workflowFile, "workflowFile");
@@ -941,28 +1151,19 @@ function parseOptions(value) {
941
1151
  throw new Error("ComfyUI numImages must be a whole number from 1 to 16");
942
1152
  }
943
1153
  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
- };
1154
+ const bindings = Object.fromEntries(
1155
+ Object.entries(value.bindings).map(([name, binding]) => [name, parseBinding(binding, name)])
1156
+ );
1157
+ if (!bindings.prompt) throw new Error("ComfyUI bindings.prompt must be an object");
1158
+ const frames = value.frames == null ? void 0 : parseFrames(value.frames);
1159
+ if (frames) {
1160
+ if (BUILTIN_BINDINGS.has(frames.vary)) {
1161
+ throw new Error(`ComfyUI frames.vary must name a custom binding, not "${frames.vary}"`);
1162
+ }
1163
+ if (!Object.hasOwn(bindings, frames.vary)) {
1164
+ throw new Error(`ComfyUI frames.vary "${frames.vary}" has no matching binding`);
1165
+ }
1166
+ }
966
1167
  const workflow = value.workflow == null ? void 0 : parseWorkflow(value.workflow, workflowFile);
967
1168
  const workflowSha256 = value.workflowSha256 == null ? void 0 : requiredString(value.workflowSha256, "workflowSha256");
968
1169
  return {
@@ -970,10 +1171,22 @@ function parseOptions(value) {
970
1171
  outputNodeId,
971
1172
  numImages: Number(numImages),
972
1173
  bindings,
1174
+ ...frames ? { frames } : {},
973
1175
  ...workflow ? { workflow } : {},
974
1176
  ...workflowSha256 ? { workflowSha256 } : {}
975
1177
  };
976
1178
  }
1179
+ function parseFrames(value) {
1180
+ if (!isObject(value)) throw new Error("ComfyUI frames must be an object");
1181
+ const extra = Object.keys(value).filter((key) => key !== "vary" && key !== "seedStep");
1182
+ if (extra.length) throw new Error(`Unknown ComfyUI frames field(s): ${extra.join(", ")}`);
1183
+ const vary = requiredString(value.vary, "frames.vary");
1184
+ const seedStep = value.seedStep ?? 0;
1185
+ if (!Number.isSafeInteger(seedStep) || Math.abs(Number(seedStep)) > 1e6) {
1186
+ throw new Error("ComfyUI frames.seedStep must be a safe integer from -1000000 to 1000000");
1187
+ }
1188
+ return { vary, seedStep: Number(seedStep) };
1189
+ }
977
1190
  function resolvedOptions(spec) {
978
1191
  const options = parseOptions(spec.providerOptions);
979
1192
  if (!options.workflow || !options.workflowSha256) {
@@ -1002,6 +1215,7 @@ function parseWorkflow(value, label) {
1002
1215
  return value;
1003
1216
  }
1004
1217
  function validateWorkflowBindings(workflow, options) {
1218
+ const targets = /* @__PURE__ */ new Map();
1005
1219
  for (const [name, binding] of Object.entries(options.bindings)) {
1006
1220
  if (!binding) continue;
1007
1221
  const node = workflow[binding.nodeId];
@@ -1011,6 +1225,12 @@ function validateWorkflowBindings(workflow, options) {
1011
1225
  `ComfyUI ${name} binding refers to missing input "${binding.input}" on node "${binding.nodeId}"`
1012
1226
  );
1013
1227
  }
1228
+ const target = `${binding.nodeId}.${binding.input}`;
1229
+ const prior = targets.get(target);
1230
+ if (prior) {
1231
+ throw new Error(`ComfyUI bindings.${name} and bindings.${prior} target the same input ${target}`);
1232
+ }
1233
+ targets.set(target, name);
1014
1234
  }
1015
1235
  if (!workflow[options.outputNodeId]) {
1016
1236
  throw new Error(`ComfyUI outputNodeId refers to missing node "${options.outputNodeId}"`);
@@ -1048,6 +1268,7 @@ function outputImages(entry, outputNodeId) {
1048
1268
  }
1049
1269
  function comfyMetadata(spec, promptId, outputNodeId, images) {
1050
1270
  const options = resolvedOptions(spec);
1271
+ const inputs = providerInputProvenance(spec.providerInputs);
1051
1272
  return {
1052
1273
  promptId,
1053
1274
  outputNodeId,
@@ -1055,6 +1276,7 @@ function comfyMetadata(spec, promptId, outputNodeId, images) {
1055
1276
  workflowFile: options.workflowFile,
1056
1277
  workflowSha256: options.workflowSha256,
1057
1278
  files: images.map((image) => ({ ...image })),
1279
+ ...Object.keys(inputs).length ? { inputs } : {},
1058
1280
  ...spec.revision ? {
1059
1281
  revision: {
1060
1282
  mode: spec.revision.mode,
@@ -1066,9 +1288,83 @@ function comfyMetadata(spec, promptId, outputNodeId, images) {
1066
1288
  } : {}
1067
1289
  };
1068
1290
  }
1291
+ function comfyFrameMetadata(spec, job, images, fps) {
1292
+ const options = resolvedOptions(spec);
1293
+ return {
1294
+ workflowFile: options.workflowFile,
1295
+ workflowSha256: options.workflowSha256,
1296
+ outputNodeId: job.outputNodeId,
1297
+ inputs: providerInputProvenance(spec.providerInputs),
1298
+ frameSet: {
1299
+ count: images.length,
1300
+ vary: options.frames.vary,
1301
+ seedStep: options.frames.seedStep,
1302
+ fps,
1303
+ promptIds: job.promptIds,
1304
+ frames: images.map((image, index) => ({ role: frameRole(index), ...image }))
1305
+ }
1306
+ };
1307
+ }
1308
+ function isImageBinding(workflow, binding) {
1309
+ const node = workflow[binding.nodeId];
1310
+ return binding.input === "image" && (node?.class_type === "LoadImage" || node?.class_type === "LoadImageMask");
1311
+ }
1312
+ function isComfyImageInput(value) {
1313
+ return isObject(value) && value.kind === "image" && typeof value.path === "string" && typeof value.sha256 === "string" && (value.format === "png" || value.format === "jpeg");
1314
+ }
1315
+ function providerInputProvenance(inputs) {
1316
+ return Object.fromEntries(Object.entries(inputs ?? {}).map(([name, input]) => [
1317
+ name,
1318
+ Array.isArray(input) ? { kind: "sequence", values: input.map(providerInputValueProvenance) } : providerInputValueProvenance(input)
1319
+ ]));
1320
+ }
1321
+ function providerInputValueProvenance(input) {
1322
+ return isComfyImageInput(input) ? { kind: "image", sha256: input.sha256, format: input.format } : { kind: "value", value: input };
1323
+ }
1324
+ function inputImageFormat(bytes) {
1325
+ 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";
1326
+ if (bytes.length < 4 || bytes[0] !== 255 || bytes[1] !== 216) return null;
1327
+ let offset = 2;
1328
+ while (offset + 3 < bytes.length) {
1329
+ if (bytes[offset] !== 255) return null;
1330
+ while (bytes[offset] === 255) offset++;
1331
+ const marker = bytes[offset++];
1332
+ if (marker == null || marker === 217 || marker === 218) break;
1333
+ if (marker === 1 || marker >= 208 && marker <= 215) continue;
1334
+ if (offset + 2 > bytes.length) return null;
1335
+ const length = bytes.readUInt16BE(offset);
1336
+ if (length < 2 || offset + length > bytes.length) return null;
1337
+ const isStartOfFrame = marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker);
1338
+ if (isStartOfFrame) {
1339
+ if (length < 7) return null;
1340
+ return bytes.readUInt16BE(offset + 3) > 0 && bytes.readUInt16BE(offset + 5) > 0 ? "jpeg" : null;
1341
+ }
1342
+ offset += length;
1343
+ }
1344
+ return null;
1345
+ }
1069
1346
  function encodeJob(promptId, outputNodeId) {
1070
1347
  return `${promptId}#${encodeURIComponent(outputNodeId)}`;
1071
1348
  }
1349
+ function frameRole(index) {
1350
+ return `frame-${String(index).padStart(2, "0")}`;
1351
+ }
1352
+ function encodeFrameSetJob(job) {
1353
+ return `frames:${Buffer.from(JSON.stringify(job)).toString("base64url")}`;
1354
+ }
1355
+ function decodeFrameSetJob(jobId) {
1356
+ if (!jobId.startsWith("frames:")) throw new Error(`Invalid ComfyUI frame-set job id "${jobId}"`);
1357
+ let value;
1358
+ try {
1359
+ value = JSON.parse(Buffer.from(jobId.slice(7), "base64url").toString("utf8"));
1360
+ } catch {
1361
+ throw new Error(`Invalid ComfyUI frame-set job id "${jobId}"`);
1362
+ }
1363
+ if (!isObject(value) || typeof value.outputNodeId !== "string" || !value.outputNodeId || !Array.isArray(value.promptIds) || value.promptIds.length < 2 || value.promptIds.length > 64 || !value.promptIds.every((id) => typeof id === "string" && id.length > 0)) {
1364
+ throw new Error(`Invalid ComfyUI frame-set job id "${jobId}"`);
1365
+ }
1366
+ return { outputNodeId: value.outputNodeId, promptIds: value.promptIds };
1367
+ }
1072
1368
  function decodeJob(jobId) {
1073
1369
  const split = jobId.lastIndexOf("#");
1074
1370
  if (split < 1 || split === jobId.length - 1) throw new Error(`Invalid ComfyUI job id "${jobId}"`);
@@ -1502,7 +1798,7 @@ function clientFromEnv() {
1502
1798
  // src/types.ts
1503
1799
  import { z as z2 } from "zod";
1504
1800
  var MediaTypeSchema = z2.enum(["image/png", "image/gif"]);
1505
- var GeneratorSchema = z2.enum(["1dir", "map", "pixflux", "tiles", "animation"]);
1801
+ var GeneratorSchema = z2.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames"]);
1506
1802
  var GridConfidenceSchema = z2.enum(["low", "medium", "high"]);
1507
1803
  var RevisionModeSchema = z2.enum(["image-to-image", "inpaint", "outpaint"]);
1508
1804
  function tileVariationCount(descriptions) {
@@ -1552,7 +1848,9 @@ var QualityProfileSchema = z2.object({
1552
1848
  /** Pixel Art Fixer revision written into the quality record. */
1553
1849
  fixerRevision: z2.string().min(1).optional(),
1554
1850
  /** Python executable containing Pixel Art Fixer, relative to the manifest unless absolute. */
1555
- fixerPython: z2.string().min(1).optional()
1851
+ fixerPython: z2.string().min(1).optional(),
1852
+ /** Playback rate recorded for an ordered frame set. */
1853
+ fps: z2.number().int().min(1).max(60).optional()
1556
1854
  }).strict().refine(
1557
1855
  (profile) => new Set(profile.palette.map((color) => color.replace(/^#/, "").toLowerCase())).size === profile.palette.length,
1558
1856
  { message: "quality palette colors must be unique", path: ["palette"] }
@@ -1768,7 +2066,18 @@ var AssetSchema = z2.object({
1768
2066
  * exactly right for a colour style. Editing the shared prompt to suit one
1769
2067
  * style would invalidate every other style's already-generated art.
1770
2068
  */
1771
- promptByStyle: z2.record(z2.string()).default({})
2069
+ promptByStyle: z2.record(z2.string()).default({}),
2070
+ /**
2071
+ * Named per-asset values consumed by the active provider's declared
2072
+ * bindings. Values are JSON scalars, or a frame-set sequence of scalars;
2073
+ * adapters may interpret a string as a manifest-relative uploaded file.
2074
+ */
2075
+ providerInputs: z2.record(z2.union([
2076
+ z2.string(),
2077
+ z2.number().finite(),
2078
+ z2.boolean(),
2079
+ z2.array(z2.union([z2.string(), z2.number().finite(), z2.boolean()])).min(2).max(64)
2080
+ ])).default({})
1772
2081
  }).strict().superRefine((asset, context) => {
1773
2082
  if (asset.source && asset.revision) {
1774
2083
  context.addIssue({
@@ -3078,113 +3387,390 @@ registerProvider({
3078
3387
  });
3079
3388
 
3080
3389
  // src/manifest.ts
3081
- import { readFile as readFile2 } from "fs/promises";
3390
+ import { readFile as readFile3 } from "fs/promises";
3391
+ import { existsSync as existsSync4 } from "fs";
3392
+ import path6 from "path";
3393
+
3394
+ // src/outputs.ts
3395
+ import path5 from "path";
3396
+
3397
+ // src/lock.ts
3398
+ import { readFile as readFile2, writeFile, rename, mkdir, rmdir, rm, stat } from "fs/promises";
3082
3399
  import { existsSync as existsSync3 } from "fs";
3083
3400
  import path4 from "path";
3084
- async function loadManifest(manifestPath) {
3085
- const abs = path4.resolve(manifestPath);
3086
- if (!existsSync3(abs)) throw new Error(`No manifest at ${abs}`);
3087
- const parsed = ManifestSchema.safeParse(JSON.parse(await readFile2(abs, "utf8")));
3088
- if (!parsed.success) {
3089
- const issues = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
3090
- throw new Error(`Manifest at ${abs} is invalid:
3091
- ${issues}`);
3092
- }
3093
- const styleIds = new Set(Object.keys(parsed.data.styles));
3094
- const unknownReferences = [];
3095
- for (const [assetId, asset] of Object.entries(parsed.data.assets)) {
3096
- for (const styleId of asset.styles) {
3097
- if (!styleIds.has(styleId)) unknownReferences.push(`assets.${assetId}.styles: unknown style "${styleId}"`);
3098
- }
3099
- for (const styleId of Object.keys(asset.promptByStyle)) {
3100
- if (!styleIds.has(styleId)) {
3101
- unknownReferences.push(`assets.${assetId}.promptByStyle: unknown style "${styleId}"`);
3102
- }
3103
- }
3104
- if (asset.revision) {
3105
- if (!parsed.data.assets[asset.revision.from]) {
3106
- unknownReferences.push(
3107
- `assets.${assetId}.revision.from: unknown asset "${asset.revision.from}"`
3108
- );
3109
- } else if (asset.revision.from === assetId) {
3110
- unknownReferences.push(`assets.${assetId}.revision.from: an asset cannot revise itself`);
3111
- }
3112
- }
3401
+ async function loadLock(lockPath) {
3402
+ if (!existsSync3(lockPath)) return { version: 2, entries: {} };
3403
+ try {
3404
+ return parseLock(JSON.parse(await readFile2(lockPath, "utf8")));
3405
+ } catch (err) {
3406
+ throw new Error(
3407
+ `Lockfile at ${lockPath} is malformed:
3408
+ ${err instanceof Error ? err.message : String(err)}`
3409
+ );
3113
3410
  }
3114
- const visiting = /* @__PURE__ */ new Set();
3115
- const visited = /* @__PURE__ */ new Set();
3116
- const visitRevision = (assetId, chain) => {
3117
- if (visited.has(assetId)) return;
3118
- if (visiting.has(assetId)) {
3119
- const start = chain.indexOf(assetId);
3120
- unknownReferences.push(
3121
- `assets.${assetId}.revision.from: revision cycle ${[...chain.slice(start), assetId].join(" -> ")}`
3122
- );
3123
- return;
3124
- }
3125
- visiting.add(assetId);
3126
- const parent = parsed.data.assets[assetId]?.revision?.from;
3127
- if (parent && parent !== assetId && parsed.data.assets[parent]) {
3128
- visitRevision(parent, [...chain, assetId]);
3129
- }
3130
- visiting.delete(assetId);
3131
- visited.add(assetId);
3411
+ }
3412
+ var saveQueues = /* @__PURE__ */ new Map();
3413
+ var dirtyPatches = /* @__PURE__ */ new WeakMap();
3414
+ var dirtyDeletes = /* @__PURE__ */ new WeakMap();
3415
+ function saveLock(lockPath, lock) {
3416
+ const queueKey = path4.resolve(lockPath);
3417
+ const previous = saveQueues.get(queueKey) ?? Promise.resolve();
3418
+ const next = previous.catch(() => {
3419
+ }).then(() => writeLockNow(lockPath, lock));
3420
+ saveQueues.set(queueKey, next);
3421
+ const cleanup = () => {
3422
+ if (saveQueues.get(queueKey) === next) saveQueues.delete(queueKey);
3132
3423
  };
3133
- for (const assetId of Object.keys(parsed.data.assets)) visitRevision(assetId, []);
3134
- if (unknownReferences.length) {
3135
- throw new Error(`Manifest at ${abs} is invalid:
3136
- ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
3424
+ void next.then(cleanup, cleanup);
3425
+ return next;
3426
+ }
3427
+ async function writeLockNow(lockPath, lock) {
3428
+ await mkdir(path4.dirname(path4.resolve(lockPath)), { recursive: true });
3429
+ const release = await acquireFileLock(lockPath);
3430
+ try {
3431
+ await writeLockWhileHeld(lockPath, lock);
3432
+ } finally {
3433
+ await release();
3137
3434
  }
3138
- return { manifest: parsed.data, root: path4.dirname(abs), path: abs };
3139
3435
  }
3140
- async function resolveSpecs(loaded, filter) {
3141
- const { manifest, root } = loaded;
3142
- const providerOverride = filter?.provider;
3143
- const providers = /* @__PURE__ */ new Map();
3144
- const providerFor = (id) => {
3145
- if (providerOverride) return providerOverride;
3146
- let provider = providers.get(id);
3147
- if (!provider) {
3148
- provider = createProvider(id, "offline");
3149
- providers.set(id, provider);
3150
- }
3151
- return provider;
3152
- };
3153
- const specs = [];
3154
- const styleIds = Object.keys(manifest.styles).filter(
3155
- (id) => !filter?.styles?.length || filter.styles.includes(id)
3156
- );
3157
- for (const unknownStyle of filter?.styles ?? []) {
3158
- if (!manifest.styles[unknownStyle]) {
3436
+ async function writeLockWhileHeld(file, lock) {
3437
+ let disk = { version: 2, entries: {} };
3438
+ if (existsSync3(file)) {
3439
+ try {
3440
+ disk = parseLock(JSON.parse(await readFile2(file, "utf8")));
3441
+ } catch (err) {
3159
3442
  throw new Error(
3160
- `Unknown style "${unknownStyle}". Defined: ${Object.keys(manifest.styles).join(", ") || "(none)"}`
3443
+ `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`
3161
3444
  );
3162
3445
  }
3163
3446
  }
3164
- for (const unknownAsset of filter?.assets ?? []) {
3165
- if (!manifest.assets[unknownAsset]) {
3166
- throw new Error(`Unknown asset "${unknownAsset}".`);
3167
- }
3447
+ const dirty = dirtyPatches.get(lock) ?? /* @__PURE__ */ new Map();
3448
+ const snapshot = /* @__PURE__ */ new Map();
3449
+ for (const [key, patch] of dirty) snapshot.set(key, { ...patch });
3450
+ const merged = { ...disk.entries };
3451
+ for (const [key, patch] of snapshot) {
3452
+ const local = lock.entries[key];
3453
+ if (!local) continue;
3454
+ merged[key] = merged[key] ? { ...merged[key], ...patch } : local;
3168
3455
  }
3169
- const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
3170
- const resolutionAssetIds = new Set(requestedAssetIds);
3171
- for (const assetId of [...requestedAssetIds]) {
3172
- let current = manifest.assets[assetId]?.revision?.from;
3173
- while (current && !resolutionAssetIds.has(current)) {
3174
- resolutionAssetIds.add(current);
3175
- current = manifest.assets[current]?.revision?.from;
3176
- }
3456
+ for (const [key, entry] of Object.entries(lock.entries)) {
3457
+ if (!merged[key]) merged[key] = entry;
3177
3458
  }
3178
- const styleImageCache = /* @__PURE__ */ new Map();
3179
- async function loadStyleImage(rel) {
3180
- const abs = path4.resolve(root, rel);
3181
- let hit = styleImageCache.get(abs);
3182
- if (!hit) {
3183
- if (!existsSync3(abs)) throw new Error(`Style image not found: ${abs}`);
3184
- const buf = await readFile2(abs);
3185
- const metadata = imageMetadata(buf);
3186
- if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
3187
- if (metadata.width < 1 || metadata.height < 1) {
3459
+ const deletes = dirtyDeletes.get(lock);
3460
+ const deleteSnapshot = deletes ? new Set(deletes) : /* @__PURE__ */ new Set();
3461
+ for (const key of deleteSnapshot) delete merged[key];
3462
+ const sorted = {};
3463
+ for (const key of Object.keys(merged).sort()) {
3464
+ sorted[key] = merged[key];
3465
+ }
3466
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3467
+ try {
3468
+ await writeFile(tmp, JSON.stringify({ version: 2, entries: sorted }, null, 2) + "\n");
3469
+ await rename(tmp, file);
3470
+ } finally {
3471
+ await rm(tmp, { force: true });
3472
+ }
3473
+ const currentDirty = dirtyPatches.get(lock);
3474
+ if (currentDirty) {
3475
+ for (const [key, patch] of snapshot) {
3476
+ const current = currentDirty.get(key);
3477
+ if (!current) continue;
3478
+ for (const [field, value] of Object.entries(patch)) {
3479
+ if (Object.is(current[field], value)) {
3480
+ delete current[field];
3481
+ }
3482
+ }
3483
+ if (Object.keys(current).length === 0) currentDirty.delete(key);
3484
+ }
3485
+ }
3486
+ const stillDirty = dirtyDeletes.get(lock);
3487
+ if (stillDirty) {
3488
+ for (const key of deleteSnapshot) stillDirty.delete(key);
3489
+ if (stillDirty.size === 0) dirtyDeletes.delete(lock);
3490
+ }
3491
+ const localAfterWrite = lock.entries;
3492
+ lock.entries = { ...merged };
3493
+ for (const [key, patch] of currentDirty ?? []) {
3494
+ const base = lock.entries[key] ?? localAfterWrite[key];
3495
+ if (base) lock.entries[key] = { ...base, ...patch };
3496
+ }
3497
+ }
3498
+ function upsert(lock, key, patch) {
3499
+ dirtyDeletes.get(lock)?.delete(key);
3500
+ const existing = lock.entries[key];
3501
+ const next = { ...existing, ...patch };
3502
+ lock.entries[key] = next;
3503
+ let dirty = dirtyPatches.get(lock);
3504
+ if (!dirty) {
3505
+ dirty = /* @__PURE__ */ new Map();
3506
+ dirtyPatches.set(lock, dirty);
3507
+ }
3508
+ dirty.set(key, { ...dirty.get(key) ?? {}, ...patch });
3509
+ return next;
3510
+ }
3511
+ function remove(lock, key) {
3512
+ if (!lock.entries[key]) return false;
3513
+ delete lock.entries[key];
3514
+ const patches = dirtyPatches.get(lock);
3515
+ if (patches) {
3516
+ patches.delete(key);
3517
+ if (patches.size === 0) dirtyPatches.delete(lock);
3518
+ }
3519
+ let deletes = dirtyDeletes.get(lock);
3520
+ if (!deletes) {
3521
+ deletes = /* @__PURE__ */ new Set();
3522
+ dirtyDeletes.set(lock, deletes);
3523
+ }
3524
+ deletes.add(key);
3525
+ return true;
3526
+ }
3527
+ var FILE_LOCK_TIMEOUT_MS = 1e4;
3528
+ var STALE_FILE_LOCK_MS = 2 * 60 * 1e3;
3529
+ async function acquireFileLock(file) {
3530
+ const lockDir = `${file}.lock`;
3531
+ const deadline = Date.now() + FILE_LOCK_TIMEOUT_MS;
3532
+ for (; ; ) {
3533
+ try {
3534
+ await mkdir(lockDir);
3535
+ return async () => {
3536
+ await rmdir(lockDir).catch(() => {
3537
+ });
3538
+ };
3539
+ } catch (err) {
3540
+ const code = err.code;
3541
+ if (code !== "EEXIST") throw err;
3542
+ try {
3543
+ const age = Date.now() - (await stat(lockDir)).mtimeMs;
3544
+ if (age > STALE_FILE_LOCK_MS) {
3545
+ await rmdir(lockDir);
3546
+ continue;
3547
+ }
3548
+ } catch (lockErr) {
3549
+ if (lockErr.code === "ENOENT") continue;
3550
+ }
3551
+ if (Date.now() >= deadline) {
3552
+ throw new Error(`Timed out waiting for lockfile writer ${lockDir}`);
3553
+ }
3554
+ await new Promise((resolve) => setTimeout(resolve, 25 + Math.floor(Math.random() * 25)));
3555
+ }
3556
+ }
3557
+ }
3558
+ function spendByUnit(lock) {
3559
+ const totals = { generations: 0, usd: 0, free: 0 };
3560
+ for (const entry of Object.values(lock.entries)) {
3561
+ const unit = entry.costUnit ?? "generations";
3562
+ totals[unit] = (totals[unit] ?? 0) + (entry.cost ?? 0);
3563
+ }
3564
+ return totals;
3565
+ }
3566
+
3567
+ // src/outputs.ts
3568
+ function portableOutputPath(file, manifestDir) {
3569
+ const root = path5.resolve(manifestDir);
3570
+ const absolute = path5.isAbsolute(file) ? path5.normalize(file) : path5.resolve(root, file);
3571
+ const relative = path5.relative(root, absolute);
3572
+ const value = path5.isAbsolute(relative) ? absolute : relative;
3573
+ return value.split(path5.sep).join("/");
3574
+ }
3575
+ function resolveOutputPath(recordedPath, manifestDir) {
3576
+ if (path5.isAbsolute(recordedPath)) return path5.normalize(recordedPath);
3577
+ if (path5.win32.isAbsolute(recordedPath)) return recordedPath;
3578
+ return path5.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path5.sep));
3579
+ }
3580
+ function expectedOutputPath(spec, role, index, total, mediaType) {
3581
+ const originalExt = path5.extname(spec.outFile);
3582
+ const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
3583
+ const stem = originalExt ? spec.outFile.slice(0, -originalExt.length) : spec.outFile;
3584
+ if (total === 1) return `${stem}${ext}`;
3585
+ const safeRole = (role ?? fallbackOutputRole(index)).replace(/[^a-zA-Z0-9_-]+/g, "-");
3586
+ return `${stem}-${safeRole}${ext}`;
3587
+ }
3588
+ function currentOutputPath(output, spec, index, total) {
3589
+ return expectedOutputPath(spec, output.role, index, total, output.mediaType);
3590
+ }
3591
+ function currentEntryOutputPath(entry, spec, index) {
3592
+ const output = entry.outputs[index];
3593
+ const sourceIndex = output.role ? (entry.sourceUrls ?? []).findIndex((source) => source.role === output.role) : -1;
3594
+ const position = sourceIndex >= 0 ? sourceIndex : index;
3595
+ const total = Math.max(entry.outputs.length, entry.sourceUrls?.length ?? 0);
3596
+ return currentOutputPath(output, spec, position, total);
3597
+ }
3598
+ function normalizeLockOutputPaths(lock, specs) {
3599
+ const byKey = new Map(specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
3600
+ let changed = 0;
3601
+ for (const [key, entry] of Object.entries(lock.entries)) {
3602
+ const spec = byKey.get(key);
3603
+ if (!spec) continue;
3604
+ let entryChanged = false;
3605
+ const outputs = entry.outputs.map((output, index) => {
3606
+ const absolute = currentEntryOutputPath(entry, spec, index);
3607
+ const portable = portableOutputPath(absolute, spec.root);
3608
+ if (portable === output.path) return output;
3609
+ changed++;
3610
+ entryChanged = true;
3611
+ return { ...output, path: portable };
3612
+ });
3613
+ if (entryChanged) upsert(lock, key, { outputs });
3614
+ }
3615
+ return changed;
3616
+ }
3617
+ function fallbackOutputRole(index) {
3618
+ return `output-${String(index).padStart(2, "0")}`;
3619
+ }
3620
+ function outputId(assetId, output, index, total) {
3621
+ if (total === 1) return assetId;
3622
+ return `${assetId}/${output.role ?? fallbackOutputRole(index)}`;
3623
+ }
3624
+ function resolveEntryOutputs(entry, assetId, manifestDir) {
3625
+ return entry.outputs.map((output, index) => ({
3626
+ ...output,
3627
+ assetId,
3628
+ id: outputId(assetId, output, index, entry.outputs.length),
3629
+ index,
3630
+ absolutePath: resolveOutputPath(output.path, manifestDir)
3631
+ }));
3632
+ }
3633
+ function resolveSpecEntryOutputs(entry, spec) {
3634
+ return entry.outputs.map((output, index) => ({
3635
+ ...output,
3636
+ assetId: spec.assetId,
3637
+ id: outputId(spec.assetId, output, index, entry.outputs.length),
3638
+ index,
3639
+ absolutePath: currentEntryOutputPath(entry, spec, index)
3640
+ }));
3641
+ }
3642
+ function selectEntryOutput(entry, role) {
3643
+ if (role) {
3644
+ const output = entry.outputs.find((candidate) => candidate.role === role);
3645
+ return output ? { ok: true, output } : { ok: false, reason: `output role "${role}" is not recorded` };
3646
+ }
3647
+ if (entry.outputs.length === 0) return { ok: false, reason: "no output recorded in the lockfile" };
3648
+ if (entry.outputs.length === 1) return { ok: true, output: entry.outputs[0] };
3649
+ const primary = entry.outputs.filter((output) => !output.role);
3650
+ if (primary.length === 1) return { ok: true, output: primary[0] };
3651
+ return {
3652
+ ok: false,
3653
+ reason: `${entry.outputs.length} outputs are recorded; set \`outputRole\` to choose one`
3654
+ };
3655
+ }
3656
+ function resolveSpecOutputs(spec, lock, manifestDir) {
3657
+ const entry = lock?.entries[lockKey(spec.styleId, spec.assetId)];
3658
+ if (entry?.outputs.length) return resolveSpecEntryOutputs(entry, spec);
3659
+ return [{
3660
+ assetId: spec.assetId,
3661
+ id: spec.assetId,
3662
+ index: 0,
3663
+ path: spec.outFile,
3664
+ absolutePath: path5.resolve(spec.outFile),
3665
+ sha256: ""
3666
+ }];
3667
+ }
3668
+
3669
+ // src/manifest.ts
3670
+ async function loadManifest(manifestPath) {
3671
+ const abs = path6.resolve(manifestPath);
3672
+ if (!existsSync4(abs)) throw new Error(`No manifest at ${abs}`);
3673
+ const parsed = ManifestSchema.safeParse(JSON.parse(await readFile3(abs, "utf8")));
3674
+ if (!parsed.success) {
3675
+ const issues = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
3676
+ throw new Error(`Manifest at ${abs} is invalid:
3677
+ ${issues}`);
3678
+ }
3679
+ const styleIds = new Set(Object.keys(parsed.data.styles));
3680
+ const unknownReferences = [];
3681
+ for (const [assetId, asset] of Object.entries(parsed.data.assets)) {
3682
+ for (const styleId of asset.styles) {
3683
+ if (!styleIds.has(styleId)) unknownReferences.push(`assets.${assetId}.styles: unknown style "${styleId}"`);
3684
+ }
3685
+ for (const styleId of Object.keys(asset.promptByStyle)) {
3686
+ if (!styleIds.has(styleId)) {
3687
+ unknownReferences.push(`assets.${assetId}.promptByStyle: unknown style "${styleId}"`);
3688
+ }
3689
+ }
3690
+ if (asset.revision) {
3691
+ if (!parsed.data.assets[asset.revision.from]) {
3692
+ unknownReferences.push(
3693
+ `assets.${assetId}.revision.from: unknown asset "${asset.revision.from}"`
3694
+ );
3695
+ } else if (asset.revision.from === assetId) {
3696
+ unknownReferences.push(`assets.${assetId}.revision.from: an asset cannot revise itself`);
3697
+ }
3698
+ }
3699
+ }
3700
+ const visiting = /* @__PURE__ */ new Set();
3701
+ const visited = /* @__PURE__ */ new Set();
3702
+ const visitRevision = (assetId, chain) => {
3703
+ if (visited.has(assetId)) return;
3704
+ if (visiting.has(assetId)) {
3705
+ const start = chain.indexOf(assetId);
3706
+ unknownReferences.push(
3707
+ `assets.${assetId}.revision.from: revision cycle ${[...chain.slice(start), assetId].join(" -> ")}`
3708
+ );
3709
+ return;
3710
+ }
3711
+ visiting.add(assetId);
3712
+ const parent = parsed.data.assets[assetId]?.revision?.from;
3713
+ if (parent && parent !== assetId && parsed.data.assets[parent]) {
3714
+ visitRevision(parent, [...chain, assetId]);
3715
+ }
3716
+ visiting.delete(assetId);
3717
+ visited.add(assetId);
3718
+ };
3719
+ for (const assetId of Object.keys(parsed.data.assets)) visitRevision(assetId, []);
3720
+ if (unknownReferences.length) {
3721
+ throw new Error(`Manifest at ${abs} is invalid:
3722
+ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
3723
+ }
3724
+ return { manifest: parsed.data, root: path6.dirname(abs), path: abs };
3725
+ }
3726
+ async function resolveSpecs(loaded, filter) {
3727
+ const { manifest, root } = loaded;
3728
+ const providerOverride = filter?.provider;
3729
+ const providers = /* @__PURE__ */ new Map();
3730
+ const providerFor = (id) => {
3731
+ if (providerOverride) return providerOverride;
3732
+ let provider = providers.get(id);
3733
+ if (!provider) {
3734
+ provider = createProvider(id, "offline");
3735
+ providers.set(id, provider);
3736
+ }
3737
+ return provider;
3738
+ };
3739
+ const specs = [];
3740
+ const styleIds = Object.keys(manifest.styles).filter(
3741
+ (id) => !filter?.styles?.length || filter.styles.includes(id)
3742
+ );
3743
+ for (const unknownStyle of filter?.styles ?? []) {
3744
+ if (!manifest.styles[unknownStyle]) {
3745
+ throw new Error(
3746
+ `Unknown style "${unknownStyle}". Defined: ${Object.keys(manifest.styles).join(", ") || "(none)"}`
3747
+ );
3748
+ }
3749
+ }
3750
+ for (const unknownAsset of filter?.assets ?? []) {
3751
+ if (!manifest.assets[unknownAsset]) {
3752
+ throw new Error(`Unknown asset "${unknownAsset}".`);
3753
+ }
3754
+ }
3755
+ const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
3756
+ const resolutionAssetIds = new Set(requestedAssetIds);
3757
+ for (const assetId of [...requestedAssetIds]) {
3758
+ let current = manifest.assets[assetId]?.revision?.from;
3759
+ while (current && !resolutionAssetIds.has(current)) {
3760
+ resolutionAssetIds.add(current);
3761
+ current = manifest.assets[current]?.revision?.from;
3762
+ }
3763
+ }
3764
+ const styleImageCache = /* @__PURE__ */ new Map();
3765
+ async function loadStyleImage(rel) {
3766
+ const abs = path6.resolve(root, rel);
3767
+ let hit = styleImageCache.get(abs);
3768
+ if (!hit) {
3769
+ if (!existsSync4(abs)) throw new Error(`Style image not found: ${abs}`);
3770
+ const buf = await readFile3(abs);
3771
+ const metadata = imageMetadata(buf);
3772
+ if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
3773
+ if (metadata.width < 1 || metadata.height < 1) {
3188
3774
  throw new Error(`Style image has invalid dimensions: ${abs}`);
3189
3775
  }
3190
3776
  hit = { base64: buf.toString("base64"), hash: sha256(buf), ...metadata };
@@ -3207,10 +3793,11 @@ async function resolveSpecs(loaded, filter) {
3207
3793
  styleImageDimensions.push(loadedImage);
3208
3794
  }
3209
3795
  const resolvedImages = style.styleImages.map((image) => {
3210
- const hit = styleImageCache.get(path4.resolve(root, image.path));
3796
+ const hit = styleImageCache.get(path6.resolve(root, image.path));
3211
3797
  return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
3212
3798
  });
3213
3799
  const styleSpecs = /* @__PURE__ */ new Map();
3800
+ const providerInputIdentities = /* @__PURE__ */ new Map();
3214
3801
  for (const [assetId, asset] of Object.entries(manifest.assets)) {
3215
3802
  if (!resolutionAssetIds.has(assetId)) continue;
3216
3803
  if (asset.styles.length && !asset.styles.includes(styleId)) continue;
@@ -3218,6 +3805,21 @@ async function resolveSpecs(loaded, filter) {
3218
3805
  if (!activeProvider.supports(generator)) {
3219
3806
  throw new Error(`Provider "${activeProvider.id}" does not support generator "${generator}"`);
3220
3807
  }
3808
+ if (Object.keys(asset.providerInputs).length && !activeProvider.resolveInputs) {
3809
+ throw new Error(
3810
+ `Provider "${activeProvider.id}" does not support asset providerInputs (${styleId}/${assetId})`
3811
+ );
3812
+ }
3813
+ const inputResolution = activeProvider.resolveInputs ? await activeProvider.resolveInputs(asset.providerInputs, {
3814
+ root,
3815
+ styleId,
3816
+ assetId,
3817
+ generator,
3818
+ providerOptions
3819
+ }) : { inputs: {} };
3820
+ const providerInputs = inputResolution.inputs;
3821
+ const providerInputIdentity = inputResolution.identity ?? providerInputs;
3822
+ providerInputIdentities.set(assetId, providerInputIdentity);
3221
3823
  let width;
3222
3824
  let height;
3223
3825
  let size;
@@ -3236,9 +3838,9 @@ async function resolveSpecs(loaded, filter) {
3236
3838
  }
3237
3839
  const subject = asset.promptByStyle[styleId] ?? asset.prompt;
3238
3840
  const prompt = [style.promptPrefix, subject, style.promptSuffix].map((p) => p.trim()).filter(Boolean).join(", ");
3239
- const relFile = asset.file ?? path4.join(asset.category ?? "", `${assetId}.png`);
3240
- const outFile = path4.resolve(root, style.outDir, relFile);
3241
- const qualityOutFile = style.quality ? path4.resolve(root, style.quality.outDir, pngPath(relFile)) : void 0;
3841
+ const relFile = asset.file ?? path6.join(asset.category ?? "", `${assetId}.png`);
3842
+ const outFile = path6.resolve(root, style.outDir, relFile);
3843
+ const qualityOutFile = style.quality ? path6.resolve(root, style.quality.outDir, pngPath(relFile)) : void 0;
3242
3844
  const tileSize = generator === "tiles" ? size : style.tileSize ?? 32;
3243
3845
  const tileVariations = tileFeatureOutputCount(generator === "tiles" ? style.tileFeature : void 0) ?? tileVariationCount(countNumberedDescriptions(prompt));
3244
3846
  const base = {
@@ -3246,6 +3848,7 @@ async function resolveSpecs(loaded, filter) {
3246
3848
  assetId,
3247
3849
  provider: activeProvider.id,
3248
3850
  providerOptions,
3851
+ providerInputs,
3249
3852
  generator,
3250
3853
  prompt,
3251
3854
  width,
@@ -3288,7 +3891,8 @@ async function resolveSpecs(loaded, filter) {
3288
3891
  minGridConfidence: style.quality.minGridConfidence,
3289
3892
  ...style.quality.minTransparency == null ? {} : { minTransparency: style.quality.minTransparency },
3290
3893
  ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {},
3291
- ...style.quality.fixerPython ? { fixerPython: path4.resolve(root, style.quality.fixerPython) } : {}
3894
+ ...style.quality.fixerPython ? { fixerPython: path6.resolve(root, style.quality.fixerPython) } : {},
3895
+ ...style.quality.fps == null ? {} : { fps: style.quality.fps }
3292
3896
  }
3293
3897
  } : {},
3294
3898
  tags,
@@ -3316,9 +3920,9 @@ async function resolveSpecs(loaded, filter) {
3316
3920
  );
3317
3921
  }
3318
3922
  const sourceSpec = await finalize(asset.revision.from);
3319
- const sourceFile = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path4.resolve(root, sourceSpec.source) : sourceSpec.outFile);
3923
+ const sourceFile = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path6.resolve(root, sourceSpec.source) : sourceSpec.outFile);
3320
3924
  const sourceImage = await optionalRevisionImage(sourceFile, "revision source");
3321
- const maskFile = asset.revision.mask ? path4.resolve(root, asset.revision.mask) : void 0;
3925
+ const maskFile = asset.revision.mask ? path6.resolve(root, asset.revision.mask) : void 0;
3322
3926
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
3323
3927
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
3324
3928
  throw new Error(
@@ -3344,7 +3948,12 @@ async function resolveSpecs(loaded, filter) {
3344
3948
  ...asset.revision.strength == null ? {} : { strength: asset.revision.strength }
3345
3949
  };
3346
3950
  }
3347
- resolved.specHash = specHash(resolved, styleImageHashes, providerOptionIdentity);
3951
+ resolved.specHash = specHash(
3952
+ resolved,
3953
+ styleImageHashes,
3954
+ providerOptionIdentity,
3955
+ providerInputIdentities.get(assetId)
3956
+ );
3348
3957
  activeProvider.validate?.(resolved, resolvedImages);
3349
3958
  const estimate = validateCostEstimate(activeProvider.id, activeProvider.estimate(resolved));
3350
3959
  resolved.cost = estimate.amount;
@@ -3369,349 +3978,92 @@ async function resolveSpecs(loaded, filter) {
3369
3978
  for (const spec of specs) {
3370
3979
  const key = `${spec.styleId}/${spec.assetId}`;
3371
3980
  claimOutput(spec.outFile, key);
3372
- if (spec.quality) claimOutput(spec.quality.outFile, `${key} quality output`);
3373
- }
3374
- return specs;
3375
- }
3376
- function pngPath(file) {
3377
- const extension = path4.extname(file);
3378
- return extension ? `${file.slice(0, -extension.length)}.png` : `${file}.png`;
3379
- }
3380
- async function optionalRevisionImage(file, label, requiredFormat) {
3381
- if (!existsSync3(file)) return null;
3382
- const bytes = await readFile2(file);
3383
- const metadata = imageMetadata(bytes);
3384
- if (!metadata) throw new Error(`${label} is not a readable PNG or JPEG: ${file}`);
3385
- if (requiredFormat && metadata.format !== requiredFormat) {
3386
- throw new Error(`${label} must be ${requiredFormat.toUpperCase()}: ${file}`);
3387
- }
3388
- return { hash: sha256(bytes), ...metadata };
3389
- }
3390
- async function resolveStyleImages(loaded, styleId) {
3391
- const style = loaded.manifest.styles[styleId];
3392
- if (!style) throw new Error(`Unknown style "${styleId}"`);
3393
- const out = [];
3394
- for (const img of style.styleImages) {
3395
- const file = path4.resolve(loaded.root, img.path);
3396
- const buf = await readFile2(file);
3397
- const metadata = imageMetadata(buf);
3398
- if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
3399
- if (metadata.width < 1 || metadata.height < 1) {
3400
- throw new Error(`Style image has invalid dimensions: ${file}`);
3401
- }
3402
- out.push({ base64: buf.toString("base64"), ...metadata });
3403
- }
3404
- return out;
3405
- }
3406
- function imageMetadata(buf) {
3407
- if (buf.length >= 24 && buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834 && buf.toString("ascii", 12, 16) === "IHDR") {
3408
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), format: "png" };
3409
- }
3410
- if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216) return null;
3411
- let offset = 2;
3412
- while (offset + 3 < buf.length) {
3413
- if (buf[offset] !== 255) return null;
3414
- while (buf[offset] === 255) offset++;
3415
- const marker = buf[offset++];
3416
- if (marker == null || marker === 217 || marker === 218) break;
3417
- if (marker === 1 || marker >= 208 && marker <= 215) continue;
3418
- if (offset + 2 > buf.length) return null;
3419
- const length = buf.readUInt16BE(offset);
3420
- if (length < 2 || offset + length > buf.length) return null;
3421
- const isStartOfFrame = marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker);
3422
- if (isStartOfFrame) {
3423
- if (length < 7) return null;
3424
- return {
3425
- width: buf.readUInt16BE(offset + 5),
3426
- height: buf.readUInt16BE(offset + 3),
3427
- format: "jpeg"
3428
- };
3429
- }
3430
- offset += length;
3431
- }
3432
- return null;
3433
- }
3434
-
3435
- // src/pipeline/pack.ts
3436
- import { readFileSync as readFileSync3 } from "fs";
3437
- import path7 from "path";
3438
-
3439
- // src/outputs.ts
3440
- import path6 from "path";
3441
-
3442
- // src/lock.ts
3443
- import { readFile as readFile3, writeFile, rename, mkdir, rmdir, rm, stat } from "fs/promises";
3444
- import { existsSync as existsSync4 } from "fs";
3445
- import path5 from "path";
3446
- async function loadLock(lockPath) {
3447
- if (!existsSync4(lockPath)) return { version: 2, entries: {} };
3448
- try {
3449
- return parseLock(JSON.parse(await readFile3(lockPath, "utf8")));
3450
- } catch (err) {
3451
- throw new Error(
3452
- `Lockfile at ${lockPath} is malformed:
3453
- ${err instanceof Error ? err.message : String(err)}`
3454
- );
3455
- }
3456
- }
3457
- var saveQueues = /* @__PURE__ */ new Map();
3458
- var dirtyPatches = /* @__PURE__ */ new WeakMap();
3459
- var dirtyDeletes = /* @__PURE__ */ new WeakMap();
3460
- function saveLock(lockPath, lock) {
3461
- const queueKey = path5.resolve(lockPath);
3462
- const previous = saveQueues.get(queueKey) ?? Promise.resolve();
3463
- const next = previous.catch(() => {
3464
- }).then(() => writeLockNow(lockPath, lock));
3465
- saveQueues.set(queueKey, next);
3466
- const cleanup = () => {
3467
- if (saveQueues.get(queueKey) === next) saveQueues.delete(queueKey);
3468
- };
3469
- void next.then(cleanup, cleanup);
3470
- return next;
3471
- }
3472
- async function writeLockNow(lockPath, lock) {
3473
- await mkdir(path5.dirname(path5.resolve(lockPath)), { recursive: true });
3474
- const release = await acquireFileLock(lockPath);
3475
- try {
3476
- await writeLockWhileHeld(lockPath, lock);
3477
- } finally {
3478
- await release();
3479
- }
3480
- }
3481
- async function writeLockWhileHeld(file, lock) {
3482
- let disk = { version: 2, entries: {} };
3483
- if (existsSync4(file)) {
3484
- try {
3485
- disk = parseLock(JSON.parse(await readFile3(file, "utf8")));
3486
- } catch (err) {
3487
- throw new Error(
3488
- `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`
3489
- );
3490
- }
3491
- }
3492
- const dirty = dirtyPatches.get(lock) ?? /* @__PURE__ */ new Map();
3493
- const snapshot = /* @__PURE__ */ new Map();
3494
- for (const [key, patch] of dirty) snapshot.set(key, { ...patch });
3495
- const merged = { ...disk.entries };
3496
- for (const [key, patch] of snapshot) {
3497
- const local = lock.entries[key];
3498
- if (!local) continue;
3499
- merged[key] = merged[key] ? { ...merged[key], ...patch } : local;
3500
- }
3501
- for (const [key, entry] of Object.entries(lock.entries)) {
3502
- if (!merged[key]) merged[key] = entry;
3503
- }
3504
- const deletes = dirtyDeletes.get(lock);
3505
- const deleteSnapshot = deletes ? new Set(deletes) : /* @__PURE__ */ new Set();
3506
- for (const key of deleteSnapshot) delete merged[key];
3507
- const sorted = {};
3508
- for (const key of Object.keys(merged).sort()) {
3509
- sorted[key] = merged[key];
3510
- }
3511
- const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3512
- try {
3513
- await writeFile(tmp, JSON.stringify({ version: 2, entries: sorted }, null, 2) + "\n");
3514
- await rename(tmp, file);
3515
- } finally {
3516
- await rm(tmp, { force: true });
3517
- }
3518
- const currentDirty = dirtyPatches.get(lock);
3519
- if (currentDirty) {
3520
- for (const [key, patch] of snapshot) {
3521
- const current = currentDirty.get(key);
3522
- if (!current) continue;
3523
- for (const [field, value] of Object.entries(patch)) {
3524
- if (Object.is(current[field], value)) {
3525
- delete current[field];
3526
- }
3527
- }
3528
- if (Object.keys(current).length === 0) currentDirty.delete(key);
3529
- }
3530
- }
3531
- const stillDirty = dirtyDeletes.get(lock);
3532
- if (stillDirty) {
3533
- for (const key of deleteSnapshot) stillDirty.delete(key);
3534
- if (stillDirty.size === 0) dirtyDeletes.delete(lock);
3535
- }
3536
- const localAfterWrite = lock.entries;
3537
- lock.entries = { ...merged };
3538
- for (const [key, patch] of currentDirty ?? []) {
3539
- const base = lock.entries[key] ?? localAfterWrite[key];
3540
- if (base) lock.entries[key] = { ...base, ...patch };
3541
- }
3542
- }
3543
- function upsert(lock, key, patch) {
3544
- dirtyDeletes.get(lock)?.delete(key);
3545
- const existing = lock.entries[key];
3546
- const next = { ...existing, ...patch };
3547
- lock.entries[key] = next;
3548
- let dirty = dirtyPatches.get(lock);
3549
- if (!dirty) {
3550
- dirty = /* @__PURE__ */ new Map();
3551
- dirtyPatches.set(lock, dirty);
3552
- }
3553
- dirty.set(key, { ...dirty.get(key) ?? {}, ...patch });
3554
- return next;
3555
- }
3556
- function remove(lock, key) {
3557
- if (!lock.entries[key]) return false;
3558
- delete lock.entries[key];
3559
- const patches = dirtyPatches.get(lock);
3560
- if (patches) {
3561
- patches.delete(key);
3562
- if (patches.size === 0) dirtyPatches.delete(lock);
3563
- }
3564
- let deletes = dirtyDeletes.get(lock);
3565
- if (!deletes) {
3566
- deletes = /* @__PURE__ */ new Set();
3567
- dirtyDeletes.set(lock, deletes);
3568
- }
3569
- deletes.add(key);
3570
- return true;
3571
- }
3572
- var FILE_LOCK_TIMEOUT_MS = 1e4;
3573
- var STALE_FILE_LOCK_MS = 2 * 60 * 1e3;
3574
- async function acquireFileLock(file) {
3575
- const lockDir = `${file}.lock`;
3576
- const deadline = Date.now() + FILE_LOCK_TIMEOUT_MS;
3577
- for (; ; ) {
3578
- try {
3579
- await mkdir(lockDir);
3580
- return async () => {
3581
- await rmdir(lockDir).catch(() => {
3582
- });
3583
- };
3584
- } catch (err) {
3585
- const code = err.code;
3586
- if (code !== "EEXIST") throw err;
3587
- try {
3588
- const age = Date.now() - (await stat(lockDir)).mtimeMs;
3589
- if (age > STALE_FILE_LOCK_MS) {
3590
- await rmdir(lockDir);
3591
- continue;
3592
- }
3593
- } catch (lockErr) {
3594
- if (lockErr.code === "ENOENT") continue;
3595
- }
3596
- if (Date.now() >= deadline) {
3597
- throw new Error(`Timed out waiting for lockfile writer ${lockDir}`);
3981
+ if (spec.quality) {
3982
+ if (spec.generator === "frames") {
3983
+ const frameInputs = Object.values(spec.providerInputs ?? {}).find(Array.isArray);
3984
+ const count = frameInputs?.length ?? 0;
3985
+ for (let index = 0; index < count; index++) {
3986
+ const role = `frame-${String(index).padStart(2, "0")}`;
3987
+ claimOutput(
3988
+ expectedOutputPath(
3989
+ { ...spec, outFile: spec.quality.outFile },
3990
+ role,
3991
+ index,
3992
+ count,
3993
+ MediaType.PNG
3994
+ ),
3995
+ `${key} quality ${role}`
3996
+ );
3997
+ }
3998
+ } else {
3999
+ claimOutput(spec.quality.outFile, `${key} quality output`);
3598
4000
  }
3599
- await new Promise((resolve) => setTimeout(resolve, 25 + Math.floor(Math.random() * 25)));
3600
4001
  }
3601
4002
  }
4003
+ return specs;
3602
4004
  }
3603
- function spendByUnit(lock) {
3604
- const totals = { generations: 0, usd: 0, free: 0 };
3605
- for (const entry of Object.values(lock.entries)) {
3606
- const unit = entry.costUnit ?? "generations";
3607
- totals[unit] = (totals[unit] ?? 0) + (entry.cost ?? 0);
3608
- }
3609
- return totals;
3610
- }
3611
-
3612
- // src/outputs.ts
3613
- function portableOutputPath(file, manifestDir) {
3614
- const root = path6.resolve(manifestDir);
3615
- const absolute = path6.isAbsolute(file) ? path6.normalize(file) : path6.resolve(root, file);
3616
- const relative = path6.relative(root, absolute);
3617
- const value = path6.isAbsolute(relative) ? absolute : relative;
3618
- return value.split(path6.sep).join("/");
3619
- }
3620
- function resolveOutputPath(recordedPath, manifestDir) {
3621
- if (path6.isAbsolute(recordedPath)) return path6.normalize(recordedPath);
3622
- if (path6.win32.isAbsolute(recordedPath)) return recordedPath;
3623
- return path6.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path6.sep));
3624
- }
3625
- function expectedOutputPath(spec, role, index, total, mediaType) {
3626
- const originalExt = path6.extname(spec.outFile);
3627
- const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
3628
- const stem = originalExt ? spec.outFile.slice(0, -originalExt.length) : spec.outFile;
3629
- if (total === 1) return `${stem}${ext}`;
3630
- const safeRole = (role ?? fallbackOutputRole(index)).replace(/[^a-zA-Z0-9_-]+/g, "-");
3631
- return `${stem}-${safeRole}${ext}`;
3632
- }
3633
- function currentOutputPath(output, spec, index, total) {
3634
- return expectedOutputPath(spec, output.role, index, total, output.mediaType);
3635
- }
3636
- function currentEntryOutputPath(entry, spec, index) {
3637
- const output = entry.outputs[index];
3638
- const sourceIndex = output.role ? (entry.sourceUrls ?? []).findIndex((source) => source.role === output.role) : -1;
3639
- const position = sourceIndex >= 0 ? sourceIndex : index;
3640
- const total = Math.max(entry.outputs.length, entry.sourceUrls?.length ?? 0);
3641
- return currentOutputPath(output, spec, position, total);
4005
+ function pngPath(file) {
4006
+ const extension = path6.extname(file);
4007
+ return extension ? `${file.slice(0, -extension.length)}.png` : `${file}.png`;
3642
4008
  }
3643
- function normalizeLockOutputPaths(lock, specs) {
3644
- const byKey = new Map(specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
3645
- let changed = 0;
3646
- for (const [key, entry] of Object.entries(lock.entries)) {
3647
- const spec = byKey.get(key);
3648
- if (!spec) continue;
3649
- let entryChanged = false;
3650
- const outputs = entry.outputs.map((output, index) => {
3651
- const absolute = currentEntryOutputPath(entry, spec, index);
3652
- const portable = portableOutputPath(absolute, spec.root);
3653
- if (portable === output.path) return output;
3654
- changed++;
3655
- entryChanged = true;
3656
- return { ...output, path: portable };
3657
- });
3658
- if (entryChanged) upsert(lock, key, { outputs });
4009
+ async function optionalRevisionImage(file, label, requiredFormat) {
4010
+ if (!existsSync4(file)) return null;
4011
+ const bytes = await readFile3(file);
4012
+ const metadata = imageMetadata(bytes);
4013
+ if (!metadata) throw new Error(`${label} is not a readable PNG or JPEG: ${file}`);
4014
+ if (requiredFormat && metadata.format !== requiredFormat) {
4015
+ throw new Error(`${label} must be ${requiredFormat.toUpperCase()}: ${file}`);
3659
4016
  }
3660
- return changed;
3661
- }
3662
- function fallbackOutputRole(index) {
3663
- return `output-${String(index).padStart(2, "0")}`;
3664
- }
3665
- function outputId(assetId, output, index, total) {
3666
- if (total === 1) return assetId;
3667
- return `${assetId}/${output.role ?? fallbackOutputRole(index)}`;
3668
- }
3669
- function resolveEntryOutputs(entry, assetId, manifestDir) {
3670
- return entry.outputs.map((output, index) => ({
3671
- ...output,
3672
- assetId,
3673
- id: outputId(assetId, output, index, entry.outputs.length),
3674
- index,
3675
- absolutePath: resolveOutputPath(output.path, manifestDir)
3676
- }));
3677
- }
3678
- function resolveSpecEntryOutputs(entry, spec) {
3679
- return entry.outputs.map((output, index) => ({
3680
- ...output,
3681
- assetId: spec.assetId,
3682
- id: outputId(spec.assetId, output, index, entry.outputs.length),
3683
- index,
3684
- absolutePath: currentEntryOutputPath(entry, spec, index)
3685
- }));
4017
+ return { hash: sha256(bytes), ...metadata };
3686
4018
  }
3687
- function selectEntryOutput(entry, role) {
3688
- if (role) {
3689
- const output = entry.outputs.find((candidate) => candidate.role === role);
3690
- return output ? { ok: true, output } : { ok: false, reason: `output role "${role}" is not recorded` };
4019
+ async function resolveStyleImages(loaded, styleId) {
4020
+ const style = loaded.manifest.styles[styleId];
4021
+ if (!style) throw new Error(`Unknown style "${styleId}"`);
4022
+ const out = [];
4023
+ for (const img of style.styleImages) {
4024
+ const file = path6.resolve(loaded.root, img.path);
4025
+ const buf = await readFile3(file);
4026
+ const metadata = imageMetadata(buf);
4027
+ if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
4028
+ if (metadata.width < 1 || metadata.height < 1) {
4029
+ throw new Error(`Style image has invalid dimensions: ${file}`);
4030
+ }
4031
+ out.push({ base64: buf.toString("base64"), ...metadata });
3691
4032
  }
3692
- if (entry.outputs.length === 0) return { ok: false, reason: "no output recorded in the lockfile" };
3693
- if (entry.outputs.length === 1) return { ok: true, output: entry.outputs[0] };
3694
- const primary = entry.outputs.filter((output) => !output.role);
3695
- if (primary.length === 1) return { ok: true, output: primary[0] };
3696
- return {
3697
- ok: false,
3698
- reason: `${entry.outputs.length} outputs are recorded; set \`outputRole\` to choose one`
3699
- };
4033
+ return out;
3700
4034
  }
3701
- function resolveSpecOutputs(spec, lock, manifestDir) {
3702
- const entry = lock?.entries[lockKey(spec.styleId, spec.assetId)];
3703
- if (entry?.outputs.length) return resolveSpecEntryOutputs(entry, spec);
3704
- return [{
3705
- assetId: spec.assetId,
3706
- id: spec.assetId,
3707
- index: 0,
3708
- path: spec.outFile,
3709
- absolutePath: path6.resolve(spec.outFile),
3710
- sha256: ""
3711
- }];
4035
+ function imageMetadata(buf) {
4036
+ if (buf.length >= 24 && buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834 && buf.toString("ascii", 12, 16) === "IHDR") {
4037
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), format: "png" };
4038
+ }
4039
+ if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216) return null;
4040
+ let offset = 2;
4041
+ while (offset + 3 < buf.length) {
4042
+ if (buf[offset] !== 255) return null;
4043
+ while (buf[offset] === 255) offset++;
4044
+ const marker = buf[offset++];
4045
+ if (marker == null || marker === 217 || marker === 218) break;
4046
+ if (marker === 1 || marker >= 208 && marker <= 215) continue;
4047
+ if (offset + 2 > buf.length) return null;
4048
+ const length = buf.readUInt16BE(offset);
4049
+ if (length < 2 || offset + length > buf.length) return null;
4050
+ const isStartOfFrame = marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker);
4051
+ if (isStartOfFrame) {
4052
+ if (length < 7) return null;
4053
+ return {
4054
+ width: buf.readUInt16BE(offset + 5),
4055
+ height: buf.readUInt16BE(offset + 3),
4056
+ format: "jpeg"
4057
+ };
4058
+ }
4059
+ offset += length;
4060
+ }
4061
+ return null;
3712
4062
  }
3713
4063
 
3714
4064
  // src/pipeline/pack.ts
4065
+ import { readFileSync as readFileSync3 } from "fs";
4066
+ import path7 from "path";
3715
4067
  function resolvePackInputs(raw, inputsFilePath) {
3716
4068
  if (!Array.isArray(raw) || !raw.length) {
3717
4069
  throw new Error("--inputs must be a non-empty JSON array of { id, path }");
@@ -3790,7 +4142,7 @@ function packSprites(inputs, options = {}) {
3790
4142
  function packStyle(lock, styleId, manifestDir, options = {}) {
3791
4143
  if (options.sourceOverrides) {
3792
4144
  if (options.outputRoles?.length) {
3793
- throw new Error("Quality-profile packing does not accept --output-role; profiles own one PNG per asset.");
4145
+ throw new Error("Quality-profile packing does not accept --output-role; profiles own final output selection.");
3794
4146
  }
3795
4147
  const inputs2 = Object.entries(options.sourceOverrides).map(([id, source]) => ({
3796
4148
  id,
@@ -4602,6 +4954,8 @@ function hex(c) {
4602
4954
 
4603
4955
  // src/pipeline/refine.ts
4604
4956
  var execFileAsync = promisify(execFile);
4957
+ var PIXEL_ART_FIXER_MIN_SIDE = 16;
4958
+ var PIXEL_ART_FIXER_MAX_PIXELS = 4e6;
4605
4959
  var PIXEL_ART_FIXER_URL = "https://github.com/Retro-Diffusion/pixel-art-fixer";
4606
4960
  var PIXEL_ART_FIXER_REVISION = "ef376e57e1c272633ca2dbf5f29ec3fcf6596465";
4607
4961
  function message2(error) {
@@ -4649,25 +5003,33 @@ function firstJsonObject(text) {
4649
5003
  }
4650
5004
  var PYTHON_FIXER_BRIDGE = [
4651
5005
  "import json, sys",
4652
- "from pixelfixer.api import process",
4653
- "result = process(open(sys.argv[1], 'rb').read(), mode='full')",
4654
- "open(sys.argv[2], 'wb').write(result.pop('png'))",
4655
- "print(json.dumps(result))"
5006
+ "from PIL import Image",
5007
+ "from pixelfixer.api import _load",
5008
+ "from pixelfixer.core import detect",
5009
+ "from pixelfixer.reconstruct import two_stage_pack",
5010
+ "rgba = _load(open(sys.argv[1], 'rb').read())",
5011
+ "result = detect(rgba, mode='full')",
5012
+ "Image.fromarray(two_stage_pack(rgba, result['cols'], result['rows'])).save(sys.argv[2], 'PNG')",
5013
+ "print(json.dumps({key: result[key] for key in ('cols', 'rows', 'step_x', 'step_y', 'phase_x', 'phase_y', 'consensus')}))"
4656
5014
  ].join("; ");
4657
5015
  function parseDetection(raw) {
4658
5016
  if (!raw || typeof raw !== "object") throw new Error("Pixel Art Fixer returned no detection result.");
4659
5017
  const value = raw;
4660
5018
  const stepX = Number(value.step_x);
4661
5019
  const stepY = Number(value.step_y);
5020
+ const phaseX = Number(value.phase_x);
5021
+ const phaseY = Number(value.phase_y);
4662
5022
  const columns = Number(value.cols);
4663
5023
  const rows = Number(value.rows);
4664
5024
  const consensus = value.consensus;
4665
- if (!Number.isFinite(stepX) || stepX <= 0 || !Number.isFinite(stepY) || stepY <= 0 || !Number.isInteger(columns) || columns < 1 || !Number.isInteger(rows) || rows < 1 || typeof consensus !== "string" || !consensus) {
5025
+ if (!Number.isFinite(stepX) || stepX <= 0 || !Number.isFinite(stepY) || stepY <= 0 || !Number.isFinite(phaseX) || phaseX < 0 || phaseX > stepX || !Number.isFinite(phaseY) || phaseY < 0 || phaseY > stepY || !Number.isInteger(columns) || columns < 1 || !Number.isInteger(rows) || rows < 1 || typeof consensus !== "string" || !consensus) {
4666
5026
  throw new Error("Pixel Art Fixer returned an invalid grid detection result.");
4667
5027
  }
4668
5028
  return {
4669
5029
  stepX,
4670
5030
  stepY,
5031
+ phaseX,
5032
+ phaseY,
4671
5033
  columns,
4672
5034
  rows,
4673
5035
  consensus,
@@ -4778,6 +5140,23 @@ function auditRefinement(png, detection, palette, minTransparency, minGridConfid
4778
5140
  function isGridConfidence(value) {
4779
5141
  return value === "low" || value === "medium" || value === "high";
4780
5142
  }
5143
+ function isDetection(value) {
5144
+ if (!value || typeof value !== "object") return false;
5145
+ const detection = value;
5146
+ return Number.isFinite(detection.stepX) && detection.stepX > 0 && Number.isFinite(detection.stepY) && detection.stepY > 0 && Number.isFinite(detection.phaseX) && detection.phaseX >= 0 && detection.phaseX <= detection.stepX && Number.isFinite(detection.phaseY) && detection.phaseY >= 0 && detection.phaseY <= detection.stepY && Number.isInteger(detection.columns) && detection.columns > 0 && Number.isInteger(detection.rows) && detection.rows > 0 && typeof detection.consensus === "string" && !!detection.consensus && isGridConfidence(detection.confidence);
5147
+ }
5148
+ function isAudit(value) {
5149
+ if (!value || typeof value !== "object") return false;
5150
+ const audit = value;
5151
+ return Number.isInteger(audit.width) && audit.width > 0 && Number.isInteger(audit.height) && audit.height > 0 && Number.isInteger(audit.colorCount) && audit.colorCount >= 0 && typeof audit.paletteConformant === "boolean" && Number.isFinite(audit.transparency) && audit.transparency >= 0 && audit.transparency <= 1 && !!audit.thresholds && Number.isInteger(audit.thresholds.maxColors) && audit.thresholds.maxColors > 0 && (audit.thresholds.minTransparency === null || Number.isFinite(audit.thresholds.minTransparency) && audit.thresholds.minTransparency >= 0 && audit.thresholds.minTransparency <= 1) && isGridConfidence(audit.thresholds.minGridConfidence) && typeof audit.safe === "boolean" && Array.isArray(audit.reasons) && audit.reasons.every((reason) => typeof reason === "string");
5152
+ }
5153
+ function validFrameSet(value) {
5154
+ if (!value || typeof value !== "object") return false;
5155
+ const frameSet = value;
5156
+ return Number.isInteger(frameSet.fps) && frameSet.fps >= 1 && frameSet.fps <= 60 && Number.isInteger(frameSet.count) && frameSet.count >= 2 && frameSet.count <= 64 && Array.isArray(frameSet.roles) && frameSet.roles.length === frameSet.count && new Set(frameSet.roles).size === frameSet.roles.length && frameSet.roles.every((role) => typeof role === "string" && !!role) && Array.isArray(frameSet.frames) && frameSet.frames.length === frameSet.count && frameSet.frames.every(
5157
+ (frame, index) => frame.role === frameSet.roles[index] && Number.isInteger(frame.sourceWidth) && frame.sourceWidth > 0 && Number.isInteger(frame.sourceHeight) && frame.sourceHeight > 0 && isDetection(frame.detection) && isAudit(frame.audit)
5158
+ );
5159
+ }
4781
5160
  function parseRecordOptions(manifest, record) {
4782
5161
  if (manifest.kind !== "refine") throw new Error(`${record} is not a PixelKiln refinement record.`);
4783
5162
  const raw = manifest.options;
@@ -4787,11 +5166,26 @@ function parseRecordOptions(manifest, record) {
4787
5166
  const palette = options.palette;
4788
5167
  const audit = options.audit;
4789
5168
  const review = options.review;
4790
- if (options.schema !== "pixelkiln-quality" || options.version !== 1 || !grid || grid.tool !== "pixel-art-fixer" || grid.url !== PIXEL_ART_FIXER_URL || typeof grid.revision !== "string" || !grid.revision || grid.protocol !== "python-api-v1" && grid.protocol !== "python-cli-v1" || !isGridConfidence(grid.confidence) || typeof grid.consensus !== "string" || !Number.isFinite(grid.stepX) || !Number.isFinite(grid.stepY) || !Number.isInteger(grid.sourceWidth) || !Number.isInteger(grid.sourceHeight) || !Number.isInteger(grid.nativeWidth) || !Number.isInteger(grid.nativeHeight) || !palette || !Array.isArray(palette.colors) || palette.colors.length < 2 || !palette.colors.every((color) => typeof color === "string") || palette.dither !== "none" || palette.distance !== "redmean" || !audit || typeof audit.safe !== "boolean" || typeof audit.paletteConformant !== "boolean" || !Array.isArray(audit.reasons) || !review || review.status !== "pending" && review.status !== "approved") {
5169
+ if (options.schema !== "pixelkiln-quality" || options.version !== 1 || !grid || grid.tool !== "pixel-art-fixer" || grid.url !== PIXEL_ART_FIXER_URL || typeof grid.revision !== "string" || !grid.revision || grid.protocol !== "python-api-v1" && grid.protocol !== "python-core-v1" && grid.protocol !== "python-cli-v1" || !isGridConfidence(grid.confidence) || typeof grid.consensus !== "string" || !Number.isFinite(grid.stepX) || !Number.isFinite(grid.stepY) || (grid.phaseX !== void 0 || grid.phaseY !== void 0 || options.frameSet !== void 0) && (!Number.isFinite(grid.phaseX) || !Number.isFinite(grid.phaseY)) || !Number.isInteger(grid.sourceWidth) || !Number.isInteger(grid.sourceHeight) || !Number.isInteger(grid.nativeWidth) || !Number.isInteger(grid.nativeHeight) || !palette || !Array.isArray(palette.colors) || palette.colors.length < 2 || !palette.colors.every((color) => typeof color === "string") || palette.dither !== "none" || palette.distance !== "redmean" || !isAudit(audit) || !review || review.status !== "pending" && review.status !== "approved" || options.frameSet !== void 0 && !validFrameSet(options.frameSet)) {
4791
5170
  throw new Error(`${record} has invalid quality metadata.`);
4792
5171
  }
4793
5172
  return options;
4794
5173
  }
5174
+ function safeFrameRole(role, index) {
5175
+ const safe = role.trim().replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
5176
+ return safe || `frame-${String(index).padStart(2, "0")}`;
5177
+ }
5178
+ function qualityFrameOutputPath(output, role, index) {
5179
+ if (!/\.png$/i.test(output)) throw new Error("Refined frame-set output must use a .png stem.");
5180
+ return output.replace(/\.png$/i, `-${safeFrameRole(role, index)}.png`);
5181
+ }
5182
+ function sameFrameGrid(first, next) {
5183
+ const phaseMatches = (a, b, step) => {
5184
+ const distance = Math.abs(a - b) % step;
5185
+ return Math.min(distance, step - distance) <= 0.01;
5186
+ };
5187
+ return next.detection.stepX === first.detection.stepX && next.detection.stepY === first.detection.stepY && next.detection.columns === first.detection.columns && next.detection.rows === first.detection.rows && phaseMatches(next.detection.phaseX, first.detection.phaseX, first.detection.stepX) && phaseMatches(next.detection.phaseY, first.detection.phaseY, first.detection.stepY) && next.audit.width === first.audit.width && next.audit.height === first.audit.height && next.sourceWidth === first.sourceWidth && next.sourceHeight === first.sourceHeight;
5188
+ }
4795
5189
  async function refineAsset(options) {
4796
5190
  const source = path10.resolve(options.source);
4797
5191
  const output = path10.resolve(options.output);
@@ -4806,9 +5200,21 @@ async function refineAsset(options) {
4806
5200
  } catch (error) {
4807
5201
  throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
4808
5202
  }
5203
+ const compatibilityCommand = options.fixerCommand;
5204
+ if (!compatibilityCommand) {
5205
+ if (sourcePng.width < PIXEL_ART_FIXER_MIN_SIDE || sourcePng.height < PIXEL_ART_FIXER_MIN_SIDE) {
5206
+ throw new Error(
5207
+ `Pixel Art Fixer requires each source side to be at least ${PIXEL_ART_FIXER_MIN_SIDE}px; got ${sourcePng.width}x${sourcePng.height}.`
5208
+ );
5209
+ }
5210
+ if (sourcePng.width * sourcePng.height > PIXEL_ART_FIXER_MAX_PIXELS) {
5211
+ throw new Error(
5212
+ `Pixel Art Fixer accepts at most ${PIXEL_ART_FIXER_MAX_PIXELS.toLocaleString("en-US")} source pixels; got ${sourcePng.width}x${sourcePng.height}.`
5213
+ );
5214
+ }
5215
+ }
4809
5216
  const temp = await mkdtemp(path10.join(tmpdir(), "pixelkiln-refine-"));
4810
5217
  const recovered = path10.join(temp, "native.png");
4811
- const compatibilityCommand = options.fixerCommand;
4812
5218
  const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
4813
5219
  const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
4814
5220
  try {
@@ -4855,11 +5261,13 @@ async function refineAsset(options) {
4855
5261
  tool: "pixel-art-fixer",
4856
5262
  url: PIXEL_ART_FIXER_URL,
4857
5263
  revision: options.fixerRevision ?? PIXEL_ART_FIXER_REVISION,
4858
- protocol: compatibilityCommand ? "python-cli-v1" : "python-api-v1",
5264
+ protocol: compatibilityCommand ? "python-cli-v1" : "python-core-v1",
4859
5265
  confidence: detection.confidence,
4860
5266
  consensus: detection.consensus,
4861
5267
  stepX: detection.stepX,
4862
5268
  stepY: detection.stepY,
5269
+ phaseX: detection.phaseX,
5270
+ phaseY: detection.phaseY,
4863
5271
  sourceWidth: sourcePng.width,
4864
5272
  sourceHeight: sourcePng.height,
4865
5273
  nativeWidth: quantized.width,
@@ -4880,19 +5288,142 @@ async function refineAsset(options) {
4880
5288
  await rm3(temp, { recursive: true, force: true });
4881
5289
  }
4882
5290
  }
5291
+ async function refineFrameSet(options) {
5292
+ if (!Number.isInteger(options.fps) || options.fps < 1 || options.fps > 60) {
5293
+ throw new Error("Frame-set fps must be an integer between 1 and 60.");
5294
+ }
5295
+ if (options.sources.length < 2 || options.sources.length > 64) {
5296
+ throw new Error(`A frame set must contain 2\u201364 ordered sources; got ${options.sources.length}.`);
5297
+ }
5298
+ const roles = options.sources.map((source) => source.role);
5299
+ if (roles.some((role) => !role.trim()) || new Set(roles).size !== roles.length) {
5300
+ throw new Error("Frame-set roles must be non-empty and unique.");
5301
+ }
5302
+ const output = path10.resolve(options.output);
5303
+ const record = path10.resolve(qualityRecordPath(output));
5304
+ const palette = normalizeRefinementPalette(options.palette);
5305
+ const finalOutputs = options.sources.map((source, index) => qualityFrameOutputPath(output, source.role, index));
5306
+ if (new Set(finalOutputs).size !== finalOutputs.length) {
5307
+ throw new Error("Frame-set roles resolve to duplicate output filenames.");
5308
+ }
5309
+ const sourceSet = new Set(options.sources.map((source) => path10.resolve(source.path)));
5310
+ if (finalOutputs.some((file) => sourceSet.has(file))) {
5311
+ throw new Error("Frame-set refinement sources and outputs must be different files.");
5312
+ }
5313
+ const temp = await mkdtemp(path10.join(tmpdir(), "pixelkiln-refine-frames-"));
5314
+ try {
5315
+ const frames = [];
5316
+ const outputs = [];
5317
+ const provenanceSources = [];
5318
+ const sourcePaths = [];
5319
+ let firstOptions = null;
5320
+ for (const [index, sourceEntry] of options.sources.entries()) {
5321
+ const source = path10.resolve(sourceEntry.path);
5322
+ const temporaryOutput = path10.join(temp, `${String(index).padStart(2, "0")}.png`);
5323
+ const refined = await refineAsset({
5324
+ source,
5325
+ output: temporaryOutput,
5326
+ palette,
5327
+ fixerPython: options.fixerPython,
5328
+ fixerCommand: options.fixerCommand,
5329
+ fixerArgsPrefix: options.fixerArgsPrefix,
5330
+ fixerRevision: options.fixerRevision,
5331
+ minGridConfidence: options.minGridConfidence,
5332
+ minTransparency: options.minTransparency,
5333
+ force: true
5334
+ });
5335
+ const temporaryRecord = await readArtifactBundleManifest(refined.record);
5336
+ const frameOptions = parseRecordOptions(temporaryRecord, refined.record);
5337
+ firstOptions ??= frameOptions;
5338
+ frames.push({
5339
+ role: sourceEntry.role,
5340
+ sourceWidth: frameOptions.nativeGrid.sourceWidth,
5341
+ sourceHeight: frameOptions.nativeGrid.sourceHeight,
5342
+ detection: refined.detection,
5343
+ audit: refined.audit
5344
+ });
5345
+ sourcePaths.push(source);
5346
+ provenanceSources.push({
5347
+ id: sourceEntry.role,
5348
+ path: source,
5349
+ sha256: await sha256File(source),
5350
+ included: true
5351
+ });
5352
+ outputs.push({
5353
+ path: finalOutputs[index],
5354
+ data: await readFile6(temporaryOutput)
5355
+ });
5356
+ }
5357
+ const first = frames[0];
5358
+ const mismatch = frames.find((frame) => !sameFrameGrid(first, frame));
5359
+ if (mismatch) {
5360
+ throw new Error(
5361
+ `Frame-set native grid mismatch: ${first.role} detected ${first.detection.columns}x${first.detection.rows} at ${first.detection.stepX}x${first.detection.stepY}, phase ${first.detection.phaseX},${first.detection.phaseY}; but ${mismatch.role} detected ${mismatch.detection.columns}x${mismatch.detection.rows} at ${mismatch.detection.stepX}x${mismatch.detection.stepY}, phase ${mismatch.detection.phaseX},${mismatch.detection.phaseY}.`
5362
+ );
5363
+ }
5364
+ const reasons = frames.flatMap(
5365
+ (frame) => frame.audit.reasons.map((reason) => `${frame.role}: ${reason}`)
5366
+ );
5367
+ const audit = {
5368
+ width: first.audit.width,
5369
+ height: first.audit.height,
5370
+ colorCount: Math.max(...frames.map((frame) => frame.audit.colorCount)),
5371
+ paletteConformant: frames.every((frame) => frame.audit.paletteConformant),
5372
+ transparency: Math.min(...frames.map((frame) => frame.audit.transparency)),
5373
+ thresholds: first.audit.thresholds,
5374
+ safe: frames.every((frame) => frame.audit.safe) && reasons.length === 0,
5375
+ reasons
5376
+ };
5377
+ const baseOptions = firstOptions;
5378
+ const recordOptions = {
5379
+ ...baseOptions,
5380
+ audit,
5381
+ review: { status: "pending" },
5382
+ frameSet: {
5383
+ fps: options.fps,
5384
+ count: frames.length,
5385
+ roles,
5386
+ frames
5387
+ }
5388
+ };
5389
+ await writeManagedArtifactBundle(
5390
+ record,
5391
+ outputs,
5392
+ { kind: "refine", sources: provenanceSources, options: recordOptions },
5393
+ { force: options.force }
5394
+ );
5395
+ return {
5396
+ sources: sourcePaths,
5397
+ outputs: outputs.map((item) => path10.resolve(item.path)),
5398
+ record,
5399
+ frames,
5400
+ palette,
5401
+ audit
5402
+ };
5403
+ } finally {
5404
+ await rm3(temp, { recursive: true, force: true });
5405
+ }
5406
+ }
4883
5407
  async function checkQualityRecord(recordPath) {
4884
5408
  const record = path10.resolve(recordPath);
4885
5409
  const manifest = await readArtifactBundleManifest(record);
4886
5410
  const options = parseRecordOptions(manifest, record);
4887
5411
  const verification = await verifyArtifactBundle(record);
4888
- const source = manifest.sources.length === 1 ? path10.resolve(path10.dirname(record), manifest.sources[0].path) : "";
4889
- const output = manifest.outputs.length === 1 ? path10.resolve(path10.dirname(record), manifest.outputs[0].path) : "";
5412
+ const sources = manifest.sources.map((source2) => path10.resolve(path10.dirname(record), source2.path));
5413
+ const outputs = manifest.outputs.map((output2) => path10.resolve(path10.dirname(record), output2.path));
5414
+ const source = sources[0] ?? "";
5415
+ const output = outputs[0] ?? "";
5416
+ const expectedCount = options.frameSet?.count ?? 1;
4890
5417
  const reasons = [];
4891
5418
  if (!verification.fingerprintValid) reasons.push("quality record fingerprint changed");
4892
5419
  for (const source2 of verification.changedSources) reasons.push(`source changed: ${source2}`);
4893
5420
  for (const changed of verification.changedOutputs) reasons.push(`output changed: ${changed}`);
4894
- if (manifest.sources.length !== 1) reasons.push("quality record must contain exactly one source");
4895
- if (manifest.outputs.length !== 1) reasons.push("quality record must contain exactly one output");
5421
+ if (manifest.sources.length !== expectedCount) {
5422
+ reasons.push(`quality record must contain exactly ${expectedCount} source(s)`);
5423
+ }
5424
+ if (manifest.outputs.length !== expectedCount) {
5425
+ reasons.push(`quality record must contain exactly ${expectedCount} output(s)`);
5426
+ }
4896
5427
  if (!options.audit.safe) reasons.push(...options.audit.reasons.map((reason) => `audit: ${reason}`));
4897
5428
  if (options.review.status !== "approved") reasons.push("human 1\xD7 review is pending");
4898
5429
  return {
@@ -4903,6 +5434,8 @@ async function checkQualityRecord(recordPath) {
4903
5434
  record,
4904
5435
  source,
4905
5436
  output,
5437
+ sources,
5438
+ outputs,
4906
5439
  options,
4907
5440
  reasons
4908
5441
  };
@@ -4917,7 +5450,7 @@ async function approveQualityRecord(recordPath, approval) {
4917
5450
  if (!verification.current) {
4918
5451
  const changes = [
4919
5452
  ...verification.changedSources.map((source) => `source ${source}`),
4920
- ...verification.changedOutputs.map((output2) => `output ${output2}`),
5453
+ ...verification.changedOutputs.map((output) => `output ${output}`),
4921
5454
  ...!verification.fingerprintValid ? ["quality metadata"] : []
4922
5455
  ];
4923
5456
  throw new Error(`Cannot approve a stale refinement; changed: ${changes.join(", ")}. Run refine again.`);
@@ -4925,12 +5458,17 @@ async function approveQualityRecord(recordPath, approval) {
4925
5458
  if (!options.audit.safe) {
4926
5459
  throw new Error(`Cannot approve a failed quality audit: ${options.audit.reasons.join("; ")}`);
4927
5460
  }
4928
- if (manifest.sources.length !== 1 || manifest.outputs.length !== 1) {
4929
- throw new Error("A refinement record must contain exactly one source and one output.");
5461
+ const expectedCount = options.frameSet?.count ?? 1;
5462
+ if (manifest.sources.length !== expectedCount || manifest.outputs.length !== expectedCount) {
5463
+ throw new Error(
5464
+ `A refinement record must contain exactly ${expectedCount} source(s) and output(s).`
5465
+ );
4930
5466
  }
4931
5467
  const root = path10.dirname(record);
4932
- const outputPath = path10.resolve(root, manifest.outputs[0].path);
4933
- const output = await readFile6(outputPath);
5468
+ const outputs = await Promise.all(manifest.outputs.map(async (entry) => {
5469
+ const outputPath = path10.resolve(root, entry.path);
5470
+ return { path: outputPath, data: await readFile6(outputPath) };
5471
+ }));
4934
5472
  const sources = manifest.sources.map((source) => ({
4935
5473
  ...source,
4936
5474
  path: path10.resolve(root, source.path)
@@ -4953,7 +5491,7 @@ async function approveQualityRecord(recordPath, approval) {
4953
5491
  };
4954
5492
  await writeManagedArtifactBundle(
4955
5493
  record,
4956
- [{ path: outputPath, data: output }],
5494
+ outputs,
4957
5495
  { kind: "refine", sources, options: approved }
4958
5496
  );
4959
5497
  return checkQualityRecord(record);
@@ -4962,33 +5500,46 @@ async function approveQualityRecord(recordPath, approval) {
4962
5500
  // src/pipeline/quality-profile.ts
4963
5501
  async function qualitySource(spec, lock) {
4964
5502
  if (spec.source) {
4965
- const source2 = path11.resolve(spec.root, spec.source);
4966
- return existsSync6(source2) ? { path: source2 } : { path: null, reason: `declared source is missing: ${spec.source}` };
5503
+ if (spec.generator === "frames") {
5504
+ return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
5505
+ }
5506
+ const source = path11.resolve(spec.root, spec.source);
5507
+ return existsSync6(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
4967
5508
  }
4968
5509
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
4969
- if (!entry) return { path: null, reason: "raw provider output is not in the lockfile" };
5510
+ if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
4970
5511
  if (entry.specHash !== spec.specHash) {
4971
- return { path: null, reason: "raw provider output does not match the current generation spec" };
5512
+ return { items: null, reason: "raw provider output does not match the current generation spec" };
4972
5513
  }
4973
5514
  if (entry.status !== "downloaded") {
4974
- return { path: null, reason: `raw provider output is ${entry.status}, not downloaded` };
5515
+ return { items: null, reason: `raw provider output is ${entry.status}, not downloaded` };
4975
5516
  }
4976
- if (entry.outputs.length !== 1) {
5517
+ const frameSet = spec.generator === "frames";
5518
+ if (!frameSet && entry.outputs.length !== 1 || frameSet && entry.outputs.length < 2) {
4977
5519
  return {
4978
- path: null,
4979
- reason: `quality profiles require one PNG output; lock entry has ${entry.outputs.length}`
5520
+ items: null,
5521
+ reason: frameSet ? `frame-set quality requires at least two PNG outputs; lock entry has ${entry.outputs.length}` : `quality profiles require one PNG output; lock entry has ${entry.outputs.length}`
4980
5522
  };
4981
5523
  }
4982
- const output = entry.outputs[0];
4983
- if (output.mediaType && output.mediaType !== MediaType.PNG) {
4984
- return { path: null, reason: `quality profiles require PNG input; lock entry is ${output.mediaType}` };
4985
- }
4986
- const source = currentEntryOutputPath(entry, spec, 0);
4987
- if (!existsSync6(source)) return { path: null, reason: `raw provider output is missing: ${source}` };
4988
- if (await sha256File(source) !== output.sha256) {
4989
- return { path: null, reason: "raw provider output was modified after download" };
5524
+ const roles = /* @__PURE__ */ new Set();
5525
+ const items = [];
5526
+ for (const [index, output] of entry.outputs.entries()) {
5527
+ if (output.mediaType && output.mediaType !== MediaType.PNG) {
5528
+ return { items: null, reason: `quality profiles require PNG input; lock entry is ${output.mediaType}` };
5529
+ }
5530
+ const role = frameSet ? output.role : "source";
5531
+ if (frameSet && (!role || roles.has(role))) {
5532
+ return { items: null, reason: "frame-set quality requires a unique role for every ordered output" };
5533
+ }
5534
+ if (role) roles.add(role);
5535
+ const source = currentEntryOutputPath(entry, spec, index);
5536
+ if (!existsSync6(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5537
+ if (await sha256File(source) !== output.sha256) {
5538
+ return { items: null, reason: "raw provider output was modified after download" };
5539
+ }
5540
+ items.push({ role, path: source });
4990
5541
  }
4991
- return { path: source };
5542
+ return { items };
4992
5543
  }
4993
5544
  async function inspectQualityProfile(spec, lock) {
4994
5545
  if (!spec.quality) return null;
@@ -4996,16 +5547,20 @@ async function inspectQualityProfile(spec, lock) {
4996
5547
  const output = spec.quality.outFile;
4997
5548
  const record = qualityRecordPath(output);
4998
5549
  const source = await qualitySource(spec, lock);
4999
- if (!source.path) {
5550
+ if (!source.items) {
5000
5551
  return { key, state: "blocked", reason: source.reason, source: null, output, record };
5001
5552
  }
5553
+ const sourcePaths = source.items.map((item) => item.path);
5554
+ const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
5002
5555
  if (!existsSync6(record)) {
5003
5556
  return {
5004
5557
  key,
5005
5558
  state: "needs-refinement",
5006
5559
  reason: "no refinement record",
5007
- source: source.path,
5560
+ source: sourcePaths[0],
5561
+ sources: sourcePaths,
5008
5562
  output,
5563
+ outputs: expectedOutputs,
5009
5564
  record
5010
5565
  };
5011
5566
  }
@@ -5014,14 +5569,17 @@ async function inspectQualityProfile(spec, lock) {
5014
5569
  const expectedPalette = normalizeRefinementPalette(spec.quality.palette);
5015
5570
  const expectedRevision = spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION;
5016
5571
  const expectedTransparency = spec.quality.minTransparency ?? null;
5017
- const contractChanged = path11.resolve(check.source) !== path11.resolve(source.path) || path11.resolve(check.output) !== path11.resolve(output) || JSON.stringify(check.options.palette.colors) !== JSON.stringify(expectedPalette) || check.options.nativeGrid.revision !== expectedRevision || check.options.audit.thresholds.minGridConfidence !== spec.quality.minGridConfidence || check.options.audit.thresholds.minTransparency !== expectedTransparency;
5572
+ const expectedFrameSet = spec.generator === "frames" ? { fps: spec.quality.fps ?? 12, count: source.items.length, roles: source.items.map((item) => item.role) } : null;
5573
+ const contractChanged = JSON.stringify(check.sources.map((item) => path11.resolve(item))) !== JSON.stringify(sourcePaths.map((item) => path11.resolve(item))) || JSON.stringify(check.outputs.map((item) => path11.resolve(item))) !== JSON.stringify(expectedOutputs.map((item) => path11.resolve(item))) || JSON.stringify(check.options.palette.colors) !== JSON.stringify(expectedPalette) || check.options.nativeGrid.revision !== expectedRevision || check.options.audit.thresholds.minGridConfidence !== spec.quality.minGridConfidence || check.options.audit.thresholds.minTransparency !== expectedTransparency || (expectedFrameSet ? !check.options.frameSet || check.options.frameSet.fps !== expectedFrameSet.fps || check.options.frameSet.count !== expectedFrameSet.count || JSON.stringify(check.options.frameSet.roles) !== JSON.stringify(expectedFrameSet.roles) : check.options.frameSet !== void 0);
5018
5574
  if (contractChanged) {
5019
5575
  return {
5020
5576
  key,
5021
5577
  state: "needs-refinement",
5022
5578
  reason: "quality profile changed",
5023
- source: source.path,
5579
+ source: sourcePaths[0],
5580
+ sources: sourcePaths,
5024
5581
  output,
5582
+ outputs: expectedOutputs,
5025
5583
  record
5026
5584
  };
5027
5585
  }
@@ -5030,8 +5588,10 @@ async function inspectQualityProfile(spec, lock) {
5030
5588
  key,
5031
5589
  state: "needs-refinement",
5032
5590
  reason: check.reasons.filter((reason) => reason !== "human 1\xD7 review is pending").join("; ") || "refinement record is stale",
5033
- source: source.path,
5591
+ source: sourcePaths[0],
5592
+ sources: sourcePaths,
5034
5593
  output,
5594
+ outputs: expectedOutputs,
5035
5595
  record
5036
5596
  };
5037
5597
  }
@@ -5040,8 +5600,10 @@ async function inspectQualityProfile(spec, lock) {
5040
5600
  key,
5041
5601
  state: "needs-approval",
5042
5602
  reason: "automated checks passed; human 1\xD7 review is pending",
5043
- source: source.path,
5603
+ source: sourcePaths[0],
5604
+ sources: sourcePaths,
5044
5605
  output,
5606
+ outputs: expectedOutputs,
5045
5607
  record
5046
5608
  };
5047
5609
  }
@@ -5049,8 +5611,10 @@ async function inspectQualityProfile(spec, lock) {
5049
5611
  key,
5050
5612
  state: "approved",
5051
5613
  reason: "refined output and human approval are current",
5052
- source: source.path,
5614
+ source: sourcePaths[0],
5615
+ sources: sourcePaths,
5053
5616
  output,
5617
+ outputs: expectedOutputs,
5054
5618
  record
5055
5619
  };
5056
5620
  } catch (error) {
@@ -5058,8 +5622,10 @@ async function inspectQualityProfile(spec, lock) {
5058
5622
  key,
5059
5623
  state: "needs-refinement",
5060
5624
  reason: `invalid refinement record: ${error instanceof Error ? error.message : String(error)}`,
5061
- source: source.path,
5625
+ source: sourcePaths[0],
5626
+ sources: sourcePaths,
5062
5627
  output,
5628
+ outputs: expectedOutputs,
5063
5629
  record
5064
5630
  };
5065
5631
  }
@@ -5083,8 +5649,7 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5083
5649
  continue;
5084
5650
  }
5085
5651
  try {
5086
- await refineAsset({
5087
- source: inspection.source,
5652
+ const common = {
5088
5653
  output: inspection.output,
5089
5654
  palette: spec.quality.palette,
5090
5655
  minGridConfidence: spec.quality.minGridConfidence,
@@ -5096,7 +5661,18 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5096
5661
  fixerCommand: options.fixerCommand,
5097
5662
  fixerArgsPrefix: options.fixerArgsPrefix,
5098
5663
  force: options.force
5099
- });
5664
+ };
5665
+ if (spec.generator === "frames") {
5666
+ const frameSources = await qualitySource(spec, lock);
5667
+ if (!frameSources.items) throw new Error(frameSources.reason);
5668
+ await refineFrameSet({
5669
+ ...common,
5670
+ sources: frameSources.items,
5671
+ fps: spec.quality.fps ?? 12
5672
+ });
5673
+ } else {
5674
+ await refineAsset({ ...common, source: inspection.source });
5675
+ }
5100
5676
  result.processed++;
5101
5677
  result.items.push(await inspectQualityProfile(spec, lock));
5102
5678
  } catch (error) {
@@ -5110,7 +5686,7 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5110
5686
  }
5111
5687
  return result;
5112
5688
  }
5113
- async function requireApprovedQualitySources(specs, lock, assetIds) {
5689
+ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles) {
5114
5690
  const configured = specs.filter(
5115
5691
  (spec) => spec.quality && (!assetIds || assetIds.has(spec.assetId))
5116
5692
  );
@@ -5119,8 +5695,33 @@ async function requireApprovedQualitySources(specs, lock, assetIds) {
5119
5695
  const rejected = [];
5120
5696
  for (const spec of configured) {
5121
5697
  const inspection = await inspectQualityProfile(spec, lock);
5122
- if (inspection.state === "approved") sources[spec.assetId] = inspection.output;
5123
- else rejected.push(inspection);
5698
+ if (inspection.state === "approved") {
5699
+ if (spec.generator === "frames") {
5700
+ const check = await checkQualityRecord(inspection.record);
5701
+ const roles = check.options.frameSet.roles;
5702
+ if (outputRoles) {
5703
+ const selectedRole = outputRoles[spec.assetId];
5704
+ if (!selectedRole) {
5705
+ throw new Error(
5706
+ `Mounting quality-approved frame set ${inspection.key} requires asset.outputRole.`
5707
+ );
5708
+ }
5709
+ const index = roles.indexOf(selectedRole);
5710
+ if (index < 0) {
5711
+ throw new Error(
5712
+ `Frame set ${inspection.key} has no approved output role "${selectedRole}".`
5713
+ );
5714
+ }
5715
+ sources[spec.assetId] = check.outputs[index];
5716
+ } else {
5717
+ for (const [index, output] of check.outputs.entries()) {
5718
+ sources[`${spec.assetId}/${roles[index]}`] = output;
5719
+ }
5720
+ }
5721
+ } else {
5722
+ sources[spec.assetId] = inspection.output;
5723
+ }
5724
+ } else rejected.push(inspection);
5124
5725
  }
5125
5726
  if (rejected.length) {
5126
5727
  const detail = rejected.slice(0, 5).map((item) => `${item.key}: ${item.state} (${item.reason})`).join("; ");
@@ -5473,7 +6074,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
5473
6074
  jobId,
5474
6075
  // A multi-candidate generator routes through review; record the parent
5475
6076
  // so `pick` knows where to look.
5476
- reviewObjectId: estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
6077
+ reviewObjectId: spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
5477
6078
  status: "processing",
5478
6079
  providerMetadata: metadata ? { ...lock.entries[key].providerMetadata, [provider.id]: metadata } : lock.entries[key].providerMetadata
5479
6080
  });
@@ -5521,7 +6122,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
5521
6122
  tileFeature: entry.tileFeature ?? currentSpec?.tileFeature,
5522
6123
  spec: currentSpec
5523
6124
  });
5524
- if (state.status === "review") {
6125
+ if (state.status === "review" || state.status === "review-set") {
5525
6126
  upsert(lock, key, {
5526
6127
  status: "review",
5527
6128
  reviewObjectId: entry.jobId,
@@ -5534,7 +6135,9 @@ async function poll(provider, lock, lockPath, opts = {}) {
5534
6135
  } : entry.providerMetadata
5535
6136
  });
5536
6137
  result.review++;
5537
- log2(` review ${key} (${state.candidateUrls.length} candidates)`);
6138
+ log2(
6139
+ state.status === "review-set" ? ` review ${key} (${state.frameUrls.length} ordered frames)` : ` review ${key} (${state.candidateUrls.length} candidates)`
6140
+ );
5538
6141
  } else if (state.status === "ready") {
5539
6142
  const sourceUrls = state.sources?.length ? state.sources : state.sourceUrl ? [{ url: state.sourceUrl }] : [];
5540
6143
  upsert(lock, key, {
@@ -6293,12 +6896,17 @@ function renderSheet(groups) {
6293
6896
  linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%);
6294
6897
  background-size:12px 12px; background-position:0 0,6px 6px; }
6295
6898
  .frames { display:flex; flex-wrap:wrap; gap:10px; max-width:100%; overflow-x:auto; }
6899
+ .loop { display:flex; align-items:flex-start; gap:12px; margin-bottom:12px; }
6900
+ .loop img { image-rendering:pixelated; border:2px solid var(--accent); padding:7px;
6901
+ background:var(--panel-deep); max-width:min(var(--preview),calc(100vw - 62px)); height:auto; }
6902
+ .loop-copy { color:var(--dim); font-size:11px; max-width:32ch; }
6296
6903
  .cand { border:2px solid var(--line-strong); border-radius:0; padding:7px; background:var(--panel-deep);
6297
6904
  cursor:pointer; display:flex; flex:0 0 auto; flex-direction:column; align-items:center; gap:5px;
6298
6905
  position:relative; max-width:100%; }
6299
6906
  .cand:hover { border-color:var(--dim); }
6300
6907
  .cand.active { border-color:var(--dim); box-shadow:0 0 0 2px color-mix(in srgb, var(--dim) 20%, transparent); }
6301
6908
  .cand.sel { border-color:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent); }
6909
+ .cand.set-frame { cursor:pointer; }
6302
6910
  .cand img { image-rendering:pixelated; display:block;
6303
6911
  background-image:
6304
6912
  linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
@@ -6336,7 +6944,8 @@ function renderSheet(groups) {
6336
6944
  </header>
6337
6945
  <main id="root"></main>
6338
6946
  <footer>
6339
- Click a candidate to choose it; click again to unchoose. In the focused row,
6947
+ Click a candidate to choose it, or any ordered frame to accept its whole set;
6948
+ click again to undo. In the focused row,
6340
6949
  <kbd>\u2190</kbd>/<kbd>\u2192</kbd> browses every candidate and <kbd>Enter</kbd> chooses;
6341
6950
  <kbd>1</kbd>\u2013<kbd>9</kbd> picks directly, <kbd>0</kbd> skips, and
6342
6951
  <kbd>\u2191</kbd>/<kbd>\u2193</kbd> changes rows. Unchosen rows stay in review \u2014 nothing is
@@ -6384,12 +6993,34 @@ GROUPS.forEach((g, gi) => {
6384
6993
  context.append(label, source);
6385
6994
  review.insertBefore(context, frames);
6386
6995
  }
6996
+ if (g.mode === 'frame-set') {
6997
+ const loop = document.createElement('div');
6998
+ loop.className = 'loop';
6999
+ const preview = document.createElement('img');
7000
+ preview.src = g.frameUrls[0];
7001
+ preview.width = displayWidth;
7002
+ preview.height = displayHeight;
7003
+ preview.alt = 'Animated frame-set preview';
7004
+ const copy = document.createElement('div');
7005
+ copy.className = 'loop-copy';
7006
+ copy.textContent = g.frameUrls.length + ' ordered frames \xB7 ' + (g.fps || 12) +
7007
+ ' fps. Accepting keeps every frame; one bad frame means leave the set unchosen.';
7008
+ loop.append(preview, copy);
7009
+ review.insertBefore(loop, frames);
7010
+ let loopIndex = 0;
7011
+ setInterval(() => {
7012
+ loopIndex = (loopIndex + 1) % g.frameUrls.length;
7013
+ preview.src = g.frameUrls[loopIndex];
7014
+ }, Math.max(16, Math.round(1000 / (g.fps || 12))));
7015
+ }
6387
7016
  g.frameUrls.forEach((url, i) => {
6388
7017
  const c = document.createElement('button');
6389
7018
  c.type = 'button';
6390
- c.className = 'cand';
7019
+ c.className = 'cand' + (g.mode === 'frame-set' ? ' set-frame' : '');
6391
7020
  c.tabIndex = -1;
6392
- c.setAttribute('aria-label', 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length);
7021
+ c.setAttribute('aria-label', g.mode === 'frame-set'
7022
+ ? 'Accept ordered frame set from frame ' + (i + 1)
7023
+ : 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length);
6393
7024
 
6394
7025
  const preview = document.createElement('img');
6395
7026
  preview.className = 'preview';
@@ -6399,7 +7030,8 @@ GROUPS.forEach((g, gi) => {
6399
7030
  preview.loading = 'lazy';
6400
7031
  const index = document.createElement('span');
6401
7032
  index.className = 'idx';
6402
- index.textContent = String(i + 1) + ' \xB7 ' + g.width + '\xD7' + g.height + ' \xB7 ' + scaleLabel;
7033
+ index.textContent = (g.frameLabels?.[i] || String(i + 1)) + ' \xB7 ' +
7034
+ g.width + '\xD7' + g.height + ' \xB7 ' + scaleLabel;
6403
7035
  c.append(preview, index);
6404
7036
  if (displayScale > 1 && largestSide <= 96) {
6405
7037
  const actual = document.createElement('img');
@@ -6440,6 +7072,14 @@ function activate(i, frames) {
6440
7072
  }
6441
7073
 
6442
7074
  function choose(gi, i, node, frames, groupEl) {
7075
+ if (GROUPS[gi].mode === 'frame-set') {
7076
+ const selected = picks.has(gi);
7077
+ frames.querySelectorAll('.cand').forEach(n => n.classList.toggle('sel', !selected));
7078
+ if (selected) { picks.delete(gi); groupEl.classList.remove('done'); }
7079
+ else { picks.set(gi, 0); groupEl.classList.add('done'); }
7080
+ refresh();
7081
+ return;
7082
+ }
6443
7083
  const current = picks.get(gi);
6444
7084
  frames.querySelectorAll('.cand').forEach(n => n.classList.remove('sel'));
6445
7085
  if (current === i) { picks.delete(gi); groupEl.classList.remove('done'); }
@@ -6616,7 +7256,8 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6616
7256
  try {
6617
7257
  const spec = specByKey.get(key);
6618
7258
  const state = await provider.poll(entry.reviewObjectId, entry.generator, { spec });
6619
- if (state.status !== "review" || !state.candidateUrls.length) continue;
7259
+ if (state.status !== "review" && state.status !== "review-set" || (state.status === "review" ? !state.candidateUrls.length : !state.frameUrls.length)) continue;
7260
+ const frameUrls = state.status === "review" ? state.candidateUrls : state.frameUrls;
6620
7261
  const sourceRoute = spec?.revision ? `/revision-source/${encodeURIComponent(String(groups.length))}` : null;
6621
7262
  if (sourceRoute && spec?.revision) {
6622
7263
  reviewAssets.set(sourceRoute, {
@@ -6630,9 +7271,14 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6630
7271
  styleId: entry.styleId,
6631
7272
  prompt: entry.prompt,
6632
7273
  reviewObjectId: entry.reviewObjectId,
6633
- frameUrls: state.candidateUrls,
7274
+ frameUrls,
6634
7275
  width: entry.width,
6635
7276
  height: entry.height,
7277
+ ...state.status === "review-set" ? {
7278
+ mode: "frame-set",
7279
+ frameLabels: state.sources.map((source, index) => source.role ?? `frame-${index}`),
7280
+ fps: state.fps
7281
+ } : { mode: "candidates" },
6636
7282
  ...sourceRoute && spec?.revision && spec.revision.sourceWidth && spec.revision.sourceHeight ? {
6637
7283
  revision: {
6638
7284
  mode: spec.revision.mode,
@@ -6675,6 +7321,31 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6675
7321
  const entry = lock.entries[key];
6676
7322
  if (!group || !entry?.reviewObjectId) continue;
6677
7323
  if (!Number.isInteger(index) || index < 0 || index >= group.frameUrls.length) continue;
7324
+ if (group.mode === "frame-set") {
7325
+ const spec = specByKey.get(key);
7326
+ const state = await provider.poll(entry.reviewObjectId, entry.generator, { spec });
7327
+ if (state.status !== "review-set") {
7328
+ throw new Error(`Frame set ${key} is no longer ready for review`);
7329
+ }
7330
+ upsert(lock, key, {
7331
+ status: "selected",
7332
+ objectId: state.objectId,
7333
+ candidateIndex: null,
7334
+ sourceUrl: state.sources[0]?.url ?? null,
7335
+ sourceUrls: state.sources,
7336
+ provider: provider.id,
7337
+ providerMetadata: state.metadata ? {
7338
+ ...entry.providerMetadata,
7339
+ [provider.id]: {
7340
+ ...entry.providerMetadata[provider.id],
7341
+ ...state.metadata
7342
+ }
7343
+ } : entry.providerMetadata
7344
+ });
7345
+ selected++;
7346
+ log2(` accepted ${key} \u2192 ${state.sources.length} ordered frames`);
7347
+ continue;
7348
+ }
6678
7349
  const { objectId, sourceUrl, metadata } = await requireSelectCandidate(provider)(
6679
7350
  entry.reviewObjectId,
6680
7351
  index,
@@ -6767,6 +7438,7 @@ function buildManifest(name, styleId, generator, outDir, scanned) {
6767
7438
  file: asset.file,
6768
7439
  tags: [],
6769
7440
  styles: [],
7441
+ providerInputs: {},
6770
7442
  ...generator === "map" ? { width: asset.width, height: asset.height } : asset.width === asset.height && asset.width !== commonSize ? { size: asset.width } : {}
6771
7443
  };
6772
7444
  }
@@ -7829,6 +8501,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
7829
8501
  file: rel,
7830
8502
  tags: ["salvaged"],
7831
8503
  styles: [ctx.styleId],
8504
+ providerInputs: {},
7832
8505
  ...orphan.width === orphan.height ? { size: orphan.width } : { width: orphan.width, height: orphan.height }
7833
8506
  };
7834
8507
  const durableSource = shouldPersistSourceUrl(orphan.previewUrl) ? orphan.previewUrl : null;
@@ -9382,9 +10055,15 @@ async function main() {
9382
10055
  if (!args.reviewer?.trim()) {
9383
10056
  throw new Error("refine approve needs --reviewer <name>.");
9384
10057
  }
9385
- log(` Inspect the final PNG at 1\xD7 and integer zoom.`);
10058
+ const pending = await checkQualityRecord(args.from);
10059
+ log(
10060
+ pending.options.frameSet ? ` Inspect all ${pending.options.frameSet.count} ordered frames at 1\xD7, integer zoom, and playback speed.` : ` Inspect the final PNG at 1\xD7 and integer zoom.`
10061
+ );
9386
10062
  log(` Confirm crisp edges, readable forms, palette separation, alpha, and seams.`);
9387
- if (!await confirm(" Record this asset as human-approved?", args.yes)) {
10063
+ if (!await confirm(
10064
+ pending.options.frameSet ? " Record this complete frame set as human-approved?" : " Record this asset as human-approved?",
10065
+ args.yes
10066
+ )) {
9388
10067
  log(" approval not recorded");
9389
10068
  return;
9390
10069
  }
@@ -9393,7 +10072,11 @@ async function main() {
9393
10072
  note: args.note
9394
10073
  });
9395
10074
  if (args.json) log(JSON.stringify(result2, null, 2));
9396
- else log(` approved ${path24.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`);
10075
+ else {
10076
+ log(
10077
+ result2.options.frameSet ? ` approved ${result2.outputs.length} frames in ${path24.relative(process.cwd(), result2.record)} by ${args.reviewer.trim()}` : ` approved ${path24.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`
10078
+ );
10079
+ }
9397
10080
  return;
9398
10081
  }
9399
10082
  const result = await checkQualityRecord(args.from);
@@ -9906,7 +10589,10 @@ async function main() {
9906
10589
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
9907
10590
  ];
9908
10591
  const qualityRecords = qualitySources ? await Promise.all(
9909
- Object.entries(qualitySources).map(([assetId, source]) => provenanceFile(`$quality/${assetId}`, qualityRecordPath(source)))
10592
+ packagingSpecs.filter((spec) => spec.quality).map((spec) => provenanceFile(
10593
+ `$quality/${spec.assetId}`,
10594
+ qualityRecordPath(spec.quality.outFile)
10595
+ ))
9910
10596
  ) : [];
9911
10597
  await writeManagedArtifactBundle(`${base}.pixelkiln.json`, outputs, {
9912
10598
  kind: "pack",
@@ -9958,7 +10644,8 @@ async function main() {
9958
10644
  const qualitySources = await requireApprovedQualitySources(
9959
10645
  packagingSpecs,
9960
10646
  lock,
9961
- new Set(Object.keys(cells))
10647
+ new Set(Object.keys(cells)),
10648
+ outputRoles
9962
10649
  );
9963
10650
  if (qualitySources) Object.assign(sources, qualitySources);
9964
10651
  const { png, atlas, skipped, overBase, sources: artifactSources } = mountStyle(
@@ -9978,7 +10665,10 @@ async function main() {
9978
10665
  { path: metadata, data: JSON.stringify(atlas, null, 2) + "\n" }
9979
10666
  ];
9980
10667
  const qualityRecords = qualitySources ? await Promise.all(
9981
- Object.entries(qualitySources).map(([assetId, source]) => provenanceFile(`$quality/${assetId}`, qualityRecordPath(source)))
10668
+ packagingSpecs.filter((spec) => spec.quality && Object.hasOwn(cells, spec.assetId)).map((spec) => provenanceFile(
10669
+ `$quality/${spec.assetId}`,
10670
+ qualityRecordPath(spec.quality.outFile)
10671
+ ))
9982
10672
  ) : [];
9983
10673
  await writeManagedArtifactBundle(companion, outputs, {
9984
10674
  kind: "mount",