pixelkiln 0.18.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
@@ -759,6 +759,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
759
759
  identity: {
760
760
  outputNodeId: options.outputNodeId,
761
761
  numImages: options.numImages,
762
+ ...options.frames ? { frames: options.frames } : {},
762
763
  bindings: options.bindings,
763
764
  workflowSha256
764
765
  }
@@ -783,56 +784,80 @@ var ComfyUIProvider = class _ComfyUIProvider {
783
784
  `ComfyUI providerInputs.${name} has no matching bindings.${name} target`
784
785
  );
785
786
  }
786
- if (isImageBinding(options.workflow, binding)) {
787
- if (typeof input !== "string") {
788
- throw new Error(`ComfyUI providerInputs.${name} must be a manifest-relative PNG or JPEG path`);
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
+ };
789
809
  }
790
- const file = path2.resolve(context.root, input);
791
- let bytes;
792
- try {
793
- bytes = await readFile(file);
794
- } catch (error) {
810
+ const current = options.workflow[binding.nodeId].inputs[binding.input];
811
+ if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") {
795
812
  throw new Error(
796
- `ComfyUI providerInputs.${name} could not be read at ${file}: ${error instanceof Error ? error.message : String(error)}`
813
+ `ComfyUI ${label} cannot replace non-scalar ${binding.nodeId}.${binding.input}`
797
814
  );
798
815
  }
799
- const format = inputImageFormat(bytes);
800
- if (!format) {
801
- throw new Error(`ComfyUI providerInputs.${name} is not a PNG or JPEG: ${file}`);
802
- }
803
- const hash = sha256(bytes);
804
- inputs[name] = { kind: "image", path: file, sha256: hash, format };
805
- identity[name] = { kind: "image", sha256: hash, format };
806
- } else {
807
- const current = options.workflow[binding.nodeId].inputs[binding.input];
808
- if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") {
816
+ if (typeof current !== typeof item) {
809
817
  throw new Error(
810
- `ComfyUI providerInputs.${name} cannot replace non-scalar ${binding.nodeId}.${binding.input}`
818
+ `ComfyUI ${label} must be ${typeof current} to match ${binding.nodeId}.${binding.input}`
811
819
  );
812
820
  }
813
- if (typeof current !== typeof input) {
821
+ return { runtime: item, stable: item };
822
+ };
823
+ if (Array.isArray(input)) {
824
+ if (!options.frames || name !== options.frames.vary) {
814
825
  throw new Error(
815
- `ComfyUI providerInputs.${name} must be ${typeof current} to match ${binding.nodeId}.${binding.input}`
826
+ `ComfyUI providerInputs.${name} is an array but frames.vary is ${options.frames ? `"${options.frames.vary}"` : "not configured"}`
816
827
  );
817
828
  }
818
- inputs[name] = input;
819
- identity[name] = input;
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;
820
836
  }
821
837
  }
822
838
  return { inputs, identity };
823
839
  }
824
840
  supports(generator) {
825
- return generator === "map";
841
+ return generator === "map" || generator === "frames";
826
842
  }
827
843
  supportsRevision(_mode) {
828
844
  return true;
829
845
  }
830
846
  estimate(spec) {
831
- 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
+ };
832
852
  }
833
853
  validate(spec, styleImages) {
834
854
  const options = resolvedOptions(spec);
835
- 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
+ }
836
861
  if (spec.width < 16 || spec.height < 16 || spec.width > 4096 || spec.height > 4096) {
837
862
  throw new Error("ComfyUI output dimensions must be between 16 and 4096 pixels");
838
863
  }
@@ -854,6 +879,25 @@ var ComfyUIProvider = class _ComfyUIProvider {
854
879
  if (options.numImages > 1 && !options.bindings.batchSize) {
855
880
  throw new Error("ComfyUI numImages greater than 1 requires bindings.batchSize");
856
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
+ }
857
901
  if (spec.revision) {
858
902
  if (!options.bindings.sourceImage) {
859
903
  throw new Error("ComfyUI revisions require bindings.sourceImage");
@@ -878,30 +922,64 @@ var ComfyUIProvider = class _ComfyUIProvider {
878
922
  async submit(spec, styleImages) {
879
923
  this.validate(spec, styleImages);
880
924
  const options = resolvedOptions(spec);
881
- const workflow = structuredClone(options.workflow);
882
- setBinding(workflow, options.bindings.prompt, spec.prompt);
883
- if (options.bindings.width) setBinding(workflow, options.bindings.width, spec.width);
884
- if (options.bindings.height) setBinding(workflow, options.bindings.height, spec.height);
885
- if (options.bindings.batchSize) {
886
- setBinding(workflow, options.bindings.batchSize, options.numImages);
887
- }
888
- if (spec.seed != null && options.bindings.seed) {
889
- setBinding(workflow, options.bindings.seed, spec.seed);
890
- }
891
- for (const [name, input] of Object.entries(spec.providerInputs ?? {})) {
925
+ const uploadedImages = /* @__PURE__ */ new Map();
926
+ const bindInput = async (workflow2, name, input) => {
892
927
  const binding = Object.hasOwn(options.bindings, name) ? options.bindings[name] : void 0;
893
928
  if (!binding) throw new Error(`ComfyUI provider input "${name}" has no binding`);
894
- if (isComfyImageInput(input)) {
895
- const bytes = await readFile(input.path);
896
- if (sha256(bytes) !== input.sha256) {
897
- throw new Error(`ComfyUI provider input "${name}" changed before upload`);
898
- }
899
- const uploaded = await this.client.uploadImage(bytes, input.sha256, input.format);
900
- setBinding(workflow, binding, uploaded);
901
- } else {
902
- setBinding(workflow, binding, input);
929
+ if (!isComfyImageInput(input)) {
930
+ setBinding(workflow2, binding, input);
931
+ return;
903
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
+ };
904
981
  }
982
+ const workflow = await buildWorkflow();
905
983
  if (spec.revision) {
906
984
  if (!spec.revision.sourceSha256 || !spec.revision.sourceFormat) {
907
985
  throw new Error("ComfyUI revision source is not ready");
@@ -936,7 +1014,6 @@ var ComfyUIProvider = class _ComfyUIProvider {
936
1014
  }
937
1015
  }
938
1016
  const promptId = await this.client.submit(workflow);
939
- const inputs = providerInputProvenance(spec.providerInputs);
940
1017
  const metadata = {
941
1018
  ...Object.keys(inputs).length ? { inputs } : {},
942
1019
  ...spec.revision ? {
@@ -953,7 +1030,47 @@ var ComfyUIProvider = class _ComfyUIProvider {
953
1030
  ...Object.keys(metadata).length ? { metadata } : {}
954
1031
  };
955
1032
  }
956
- 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
+ }
957
1074
  const { promptId, outputNodeId } = decodeJob(jobId);
958
1075
  const history = await this.client.history(promptId);
959
1076
  const entry = historyEntry(history, promptId);
@@ -1016,7 +1133,15 @@ var ComfyUIProvider = class _ComfyUIProvider {
1016
1133
  }
1017
1134
  };
1018
1135
  function parseOptions(value) {
1019
- 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
+ ]);
1020
1145
  const extra = Object.keys(value).filter((key) => !allowed.has(key));
1021
1146
  if (extra.length) throw new Error(`Unknown ComfyUI option(s): ${extra.join(", ")}`);
1022
1147
  const workflowFile = requiredString(value.workflowFile, "workflowFile");
@@ -1030,6 +1155,15 @@ function parseOptions(value) {
1030
1155
  Object.entries(value.bindings).map(([name, binding]) => [name, parseBinding(binding, name)])
1031
1156
  );
1032
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
+ }
1033
1167
  const workflow = value.workflow == null ? void 0 : parseWorkflow(value.workflow, workflowFile);
1034
1168
  const workflowSha256 = value.workflowSha256 == null ? void 0 : requiredString(value.workflowSha256, "workflowSha256");
1035
1169
  return {
@@ -1037,10 +1171,22 @@ function parseOptions(value) {
1037
1171
  outputNodeId,
1038
1172
  numImages: Number(numImages),
1039
1173
  bindings,
1174
+ ...frames ? { frames } : {},
1040
1175
  ...workflow ? { workflow } : {},
1041
1176
  ...workflowSha256 ? { workflowSha256 } : {}
1042
1177
  };
1043
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
+ }
1044
1190
  function resolvedOptions(spec) {
1045
1191
  const options = parseOptions(spec.providerOptions);
1046
1192
  if (!options.workflow || !options.workflowSha256) {
@@ -1142,6 +1288,23 @@ function comfyMetadata(spec, promptId, outputNodeId, images) {
1142
1288
  } : {}
1143
1289
  };
1144
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
+ }
1145
1308
  function isImageBinding(workflow, binding) {
1146
1309
  const node = workflow[binding.nodeId];
1147
1310
  return binding.input === "image" && (node?.class_type === "LoadImage" || node?.class_type === "LoadImageMask");
@@ -1152,9 +1315,12 @@ function isComfyImageInput(value) {
1152
1315
  function providerInputProvenance(inputs) {
1153
1316
  return Object.fromEntries(Object.entries(inputs ?? {}).map(([name, input]) => [
1154
1317
  name,
1155
- isComfyImageInput(input) ? { kind: "image", sha256: input.sha256, format: input.format } : { kind: "value", value: input }
1318
+ Array.isArray(input) ? { kind: "sequence", values: input.map(providerInputValueProvenance) } : providerInputValueProvenance(input)
1156
1319
  ]));
1157
1320
  }
1321
+ function providerInputValueProvenance(input) {
1322
+ return isComfyImageInput(input) ? { kind: "image", sha256: input.sha256, format: input.format } : { kind: "value", value: input };
1323
+ }
1158
1324
  function inputImageFormat(bytes) {
1159
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";
1160
1326
  if (bytes.length < 4 || bytes[0] !== 255 || bytes[1] !== 216) return null;
@@ -1180,6 +1346,25 @@ function inputImageFormat(bytes) {
1180
1346
  function encodeJob(promptId, outputNodeId) {
1181
1347
  return `${promptId}#${encodeURIComponent(outputNodeId)}`;
1182
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
+ }
1183
1368
  function decodeJob(jobId) {
1184
1369
  const split = jobId.lastIndexOf("#");
1185
1370
  if (split < 1 || split === jobId.length - 1) throw new Error(`Invalid ComfyUI job id "${jobId}"`);
@@ -1613,7 +1798,7 @@ function clientFromEnv() {
1613
1798
  // src/types.ts
1614
1799
  import { z as z2 } from "zod";
1615
1800
  var MediaTypeSchema = z2.enum(["image/png", "image/gif"]);
1616
- var GeneratorSchema = z2.enum(["1dir", "map", "pixflux", "tiles", "animation"]);
1801
+ var GeneratorSchema = z2.enum(["1dir", "map", "pixflux", "tiles", "animation", "frames"]);
1617
1802
  var GridConfidenceSchema = z2.enum(["low", "medium", "high"]);
1618
1803
  var RevisionModeSchema = z2.enum(["image-to-image", "inpaint", "outpaint"]);
1619
1804
  function tileVariationCount(descriptions) {
@@ -1663,7 +1848,9 @@ var QualityProfileSchema = z2.object({
1663
1848
  /** Pixel Art Fixer revision written into the quality record. */
1664
1849
  fixerRevision: z2.string().min(1).optional(),
1665
1850
  /** Python executable containing Pixel Art Fixer, relative to the manifest unless absolute. */
1666
- 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()
1667
1854
  }).strict().refine(
1668
1855
  (profile) => new Set(profile.palette.map((color) => color.replace(/^#/, "").toLowerCase())).size === profile.palette.length,
1669
1856
  { message: "quality palette colors must be unique", path: ["palette"] }
@@ -1882,10 +2069,15 @@ var AssetSchema = z2.object({
1882
2069
  promptByStyle: z2.record(z2.string()).default({}),
1883
2070
  /**
1884
2071
  * Named per-asset values consumed by the active provider's declared
1885
- * bindings. Values are JSON scalars; adapters may interpret a string as a
1886
- * manifest-relative file when the target node accepts an uploaded input.
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.
1887
2074
  */
1888
- providerInputs: z2.record(z2.union([z2.string(), z2.number().finite(), z2.boolean()])).default({})
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({})
1889
2081
  }).strict().superRefine((asset, context) => {
1890
2082
  if (asset.source && asset.revision) {
1891
2083
  context.addIssue({
@@ -3195,138 +3387,415 @@ registerProvider({
3195
3387
  });
3196
3388
 
3197
3389
  // src/manifest.ts
3198
- 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";
3199
3399
  import { existsSync as existsSync3 } from "fs";
3200
3400
  import path4 from "path";
3201
- async function loadManifest(manifestPath) {
3202
- const abs = path4.resolve(manifestPath);
3203
- if (!existsSync3(abs)) throw new Error(`No manifest at ${abs}`);
3204
- const parsed = ManifestSchema.safeParse(JSON.parse(await readFile2(abs, "utf8")));
3205
- if (!parsed.success) {
3206
- const issues = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
3207
- throw new Error(`Manifest at ${abs} is invalid:
3208
- ${issues}`);
3209
- }
3210
- const styleIds = new Set(Object.keys(parsed.data.styles));
3211
- const unknownReferences = [];
3212
- for (const [assetId, asset] of Object.entries(parsed.data.assets)) {
3213
- for (const styleId of asset.styles) {
3214
- if (!styleIds.has(styleId)) unknownReferences.push(`assets.${assetId}.styles: unknown style "${styleId}"`);
3215
- }
3216
- for (const styleId of Object.keys(asset.promptByStyle)) {
3217
- if (!styleIds.has(styleId)) {
3218
- unknownReferences.push(`assets.${assetId}.promptByStyle: unknown style "${styleId}"`);
3219
- }
3220
- }
3221
- if (asset.revision) {
3222
- if (!parsed.data.assets[asset.revision.from]) {
3223
- unknownReferences.push(
3224
- `assets.${assetId}.revision.from: unknown asset "${asset.revision.from}"`
3225
- );
3226
- } else if (asset.revision.from === assetId) {
3227
- unknownReferences.push(`assets.${assetId}.revision.from: an asset cannot revise itself`);
3228
- }
3229
- }
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
+ );
3230
3410
  }
3231
- const visiting = /* @__PURE__ */ new Set();
3232
- const visited = /* @__PURE__ */ new Set();
3233
- const visitRevision = (assetId, chain) => {
3234
- if (visited.has(assetId)) return;
3235
- if (visiting.has(assetId)) {
3236
- const start = chain.indexOf(assetId);
3237
- unknownReferences.push(
3238
- `assets.${assetId}.revision.from: revision cycle ${[...chain.slice(start), assetId].join(" -> ")}`
3239
- );
3240
- return;
3241
- }
3242
- visiting.add(assetId);
3243
- const parent = parsed.data.assets[assetId]?.revision?.from;
3244
- if (parent && parent !== assetId && parsed.data.assets[parent]) {
3245
- visitRevision(parent, [...chain, assetId]);
3246
- }
3247
- visiting.delete(assetId);
3248
- 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);
3249
3423
  };
3250
- for (const assetId of Object.keys(parsed.data.assets)) visitRevision(assetId, []);
3251
- if (unknownReferences.length) {
3252
- throw new Error(`Manifest at ${abs} is invalid:
3253
- ${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();
3254
3434
  }
3255
- return { manifest: parsed.data, root: path4.dirname(abs), path: abs };
3256
3435
  }
3257
- async function resolveSpecs(loaded, filter) {
3258
- const { manifest, root } = loaded;
3259
- const providerOverride = filter?.provider;
3260
- const providers = /* @__PURE__ */ new Map();
3261
- const providerFor = (id) => {
3262
- if (providerOverride) return providerOverride;
3263
- let provider = providers.get(id);
3264
- if (!provider) {
3265
- provider = createProvider(id, "offline");
3266
- providers.set(id, provider);
3267
- }
3268
- return provider;
3269
- };
3270
- const specs = [];
3271
- const styleIds = Object.keys(manifest.styles).filter(
3272
- (id) => !filter?.styles?.length || filter.styles.includes(id)
3273
- );
3274
- for (const unknownStyle of filter?.styles ?? []) {
3275
- 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) {
3276
3442
  throw new Error(
3277
- `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)}`
3278
3444
  );
3279
3445
  }
3280
3446
  }
3281
- for (const unknownAsset of filter?.assets ?? []) {
3282
- if (!manifest.assets[unknownAsset]) {
3283
- throw new Error(`Unknown asset "${unknownAsset}".`);
3284
- }
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;
3285
3455
  }
3286
- const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
3287
- const resolutionAssetIds = new Set(requestedAssetIds);
3288
- for (const assetId of [...requestedAssetIds]) {
3289
- let current = manifest.assets[assetId]?.revision?.from;
3290
- while (current && !resolutionAssetIds.has(current)) {
3291
- resolutionAssetIds.add(current);
3292
- current = manifest.assets[current]?.revision?.from;
3293
- }
3456
+ for (const [key, entry] of Object.entries(lock.entries)) {
3457
+ if (!merged[key]) merged[key] = entry;
3294
3458
  }
3295
- const styleImageCache = /* @__PURE__ */ new Map();
3296
- async function loadStyleImage(rel) {
3297
- const abs = path4.resolve(root, rel);
3298
- let hit = styleImageCache.get(abs);
3299
- if (!hit) {
3300
- if (!existsSync3(abs)) throw new Error(`Style image not found: ${abs}`);
3301
- const buf = await readFile2(abs);
3302
- const metadata = imageMetadata(buf);
3303
- if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
3304
- if (metadata.width < 1 || metadata.height < 1) {
3305
- throw new Error(`Style image has invalid dimensions: ${abs}`);
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
+ }
3306
3482
  }
3307
- hit = { base64: buf.toString("base64"), hash: sha256(buf), ...metadata };
3308
- styleImageCache.set(abs, hit);
3483
+ if (Object.keys(current).length === 0) currentDirty.delete(key);
3309
3484
  }
3310
- return hit;
3311
3485
  }
3312
- for (const styleId of styleIds) {
3313
- const style = manifest.styles[styleId];
3314
- const activeProvider = providerFor(style.provider ?? manifest.provider);
3315
- const rawProviderOptions = style.providerOptions[activeProvider.id] ?? {};
3316
- const optionResolution = activeProvider.resolveOptions ? await activeProvider.resolveOptions(rawProviderOptions, { root, styleId }) : { options: rawProviderOptions };
3317
- const providerOptions = optionResolution.options;
3318
- const providerOptionIdentity = optionResolution.identity ?? providerOptions;
3319
- const styleImageHashes = [];
3320
- const styleImageDimensions = [];
3321
- for (const img of style.styleImages) {
3322
- const loadedImage = await loadStyleImage(img.path);
3323
- styleImageHashes.push(loadedImage.hash);
3324
- styleImageDimensions.push(loadedImage);
3325
- }
3326
- const resolvedImages = style.styleImages.map((image) => {
3327
- const hit = styleImageCache.get(path4.resolve(root, image.path));
3328
- return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
3329
- });
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) {
3774
+ throw new Error(`Style image has invalid dimensions: ${abs}`);
3775
+ }
3776
+ hit = { base64: buf.toString("base64"), hash: sha256(buf), ...metadata };
3777
+ styleImageCache.set(abs, hit);
3778
+ }
3779
+ return hit;
3780
+ }
3781
+ for (const styleId of styleIds) {
3782
+ const style = manifest.styles[styleId];
3783
+ const activeProvider = providerFor(style.provider ?? manifest.provider);
3784
+ const rawProviderOptions = style.providerOptions[activeProvider.id] ?? {};
3785
+ const optionResolution = activeProvider.resolveOptions ? await activeProvider.resolveOptions(rawProviderOptions, { root, styleId }) : { options: rawProviderOptions };
3786
+ const providerOptions = optionResolution.options;
3787
+ const providerOptionIdentity = optionResolution.identity ?? providerOptions;
3788
+ const styleImageHashes = [];
3789
+ const styleImageDimensions = [];
3790
+ for (const img of style.styleImages) {
3791
+ const loadedImage = await loadStyleImage(img.path);
3792
+ styleImageHashes.push(loadedImage.hash);
3793
+ styleImageDimensions.push(loadedImage);
3794
+ }
3795
+ const resolvedImages = style.styleImages.map((image) => {
3796
+ const hit = styleImageCache.get(path6.resolve(root, image.path));
3797
+ return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
3798
+ });
3330
3799
  const styleSpecs = /* @__PURE__ */ new Map();
3331
3800
  const providerInputIdentities = /* @__PURE__ */ new Map();
3332
3801
  for (const [assetId, asset] of Object.entries(manifest.assets)) {
@@ -3345,6 +3814,7 @@ async function resolveSpecs(loaded, filter) {
3345
3814
  root,
3346
3815
  styleId,
3347
3816
  assetId,
3817
+ generator,
3348
3818
  providerOptions
3349
3819
  }) : { inputs: {} };
3350
3820
  const providerInputs = inputResolution.inputs;
@@ -3368,9 +3838,9 @@ async function resolveSpecs(loaded, filter) {
3368
3838
  }
3369
3839
  const subject = asset.promptByStyle[styleId] ?? asset.prompt;
3370
3840
  const prompt = [style.promptPrefix, subject, style.promptSuffix].map((p) => p.trim()).filter(Boolean).join(", ");
3371
- const relFile = asset.file ?? path4.join(asset.category ?? "", `${assetId}.png`);
3372
- const outFile = path4.resolve(root, style.outDir, relFile);
3373
- 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;
3374
3844
  const tileSize = generator === "tiles" ? size : style.tileSize ?? 32;
3375
3845
  const tileVariations = tileFeatureOutputCount(generator === "tiles" ? style.tileFeature : void 0) ?? tileVariationCount(countNumberedDescriptions(prompt));
3376
3846
  const base = {
@@ -3421,7 +3891,8 @@ async function resolveSpecs(loaded, filter) {
3421
3891
  minGridConfidence: style.quality.minGridConfidence,
3422
3892
  ...style.quality.minTransparency == null ? {} : { minTransparency: style.quality.minTransparency },
3423
3893
  ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {},
3424
- ...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 }
3425
3896
  }
3426
3897
  } : {},
3427
3898
  tags,
@@ -3449,9 +3920,9 @@ async function resolveSpecs(loaded, filter) {
3449
3920
  );
3450
3921
  }
3451
3922
  const sourceSpec = await finalize(asset.revision.from);
3452
- 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);
3453
3924
  const sourceImage = await optionalRevisionImage(sourceFile, "revision source");
3454
- 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;
3455
3926
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
3456
3927
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
3457
3928
  throw new Error(
@@ -3507,349 +3978,92 @@ async function resolveSpecs(loaded, filter) {
3507
3978
  for (const spec of specs) {
3508
3979
  const key = `${spec.styleId}/${spec.assetId}`;
3509
3980
  claimOutput(spec.outFile, key);
3510
- if (spec.quality) claimOutput(spec.quality.outFile, `${key} quality output`);
3511
- }
3512
- return specs;
3513
- }
3514
- function pngPath(file) {
3515
- const extension = path4.extname(file);
3516
- return extension ? `${file.slice(0, -extension.length)}.png` : `${file}.png`;
3517
- }
3518
- async function optionalRevisionImage(file, label, requiredFormat) {
3519
- if (!existsSync3(file)) return null;
3520
- const bytes = await readFile2(file);
3521
- const metadata = imageMetadata(bytes);
3522
- if (!metadata) throw new Error(`${label} is not a readable PNG or JPEG: ${file}`);
3523
- if (requiredFormat && metadata.format !== requiredFormat) {
3524
- throw new Error(`${label} must be ${requiredFormat.toUpperCase()}: ${file}`);
3525
- }
3526
- return { hash: sha256(bytes), ...metadata };
3527
- }
3528
- async function resolveStyleImages(loaded, styleId) {
3529
- const style = loaded.manifest.styles[styleId];
3530
- if (!style) throw new Error(`Unknown style "${styleId}"`);
3531
- const out = [];
3532
- for (const img of style.styleImages) {
3533
- const file = path4.resolve(loaded.root, img.path);
3534
- const buf = await readFile2(file);
3535
- const metadata = imageMetadata(buf);
3536
- if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
3537
- if (metadata.width < 1 || metadata.height < 1) {
3538
- throw new Error(`Style image has invalid dimensions: ${file}`);
3539
- }
3540
- out.push({ base64: buf.toString("base64"), ...metadata });
3541
- }
3542
- return out;
3543
- }
3544
- function imageMetadata(buf) {
3545
- if (buf.length >= 24 && buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834 && buf.toString("ascii", 12, 16) === "IHDR") {
3546
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), format: "png" };
3547
- }
3548
- if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216) return null;
3549
- let offset = 2;
3550
- while (offset + 3 < buf.length) {
3551
- if (buf[offset] !== 255) return null;
3552
- while (buf[offset] === 255) offset++;
3553
- const marker = buf[offset++];
3554
- if (marker == null || marker === 217 || marker === 218) break;
3555
- if (marker === 1 || marker >= 208 && marker <= 215) continue;
3556
- if (offset + 2 > buf.length) return null;
3557
- const length = buf.readUInt16BE(offset);
3558
- if (length < 2 || offset + length > buf.length) return null;
3559
- const isStartOfFrame = marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker);
3560
- if (isStartOfFrame) {
3561
- if (length < 7) return null;
3562
- return {
3563
- width: buf.readUInt16BE(offset + 5),
3564
- height: buf.readUInt16BE(offset + 3),
3565
- format: "jpeg"
3566
- };
3567
- }
3568
- offset += length;
3569
- }
3570
- return null;
3571
- }
3572
-
3573
- // src/pipeline/pack.ts
3574
- import { readFileSync as readFileSync3 } from "fs";
3575
- import path7 from "path";
3576
-
3577
- // src/outputs.ts
3578
- import path6 from "path";
3579
-
3580
- // src/lock.ts
3581
- import { readFile as readFile3, writeFile, rename, mkdir, rmdir, rm, stat } from "fs/promises";
3582
- import { existsSync as existsSync4 } from "fs";
3583
- import path5 from "path";
3584
- async function loadLock(lockPath) {
3585
- if (!existsSync4(lockPath)) return { version: 2, entries: {} };
3586
- try {
3587
- return parseLock(JSON.parse(await readFile3(lockPath, "utf8")));
3588
- } catch (err) {
3589
- throw new Error(
3590
- `Lockfile at ${lockPath} is malformed:
3591
- ${err instanceof Error ? err.message : String(err)}`
3592
- );
3593
- }
3594
- }
3595
- var saveQueues = /* @__PURE__ */ new Map();
3596
- var dirtyPatches = /* @__PURE__ */ new WeakMap();
3597
- var dirtyDeletes = /* @__PURE__ */ new WeakMap();
3598
- function saveLock(lockPath, lock) {
3599
- const queueKey = path5.resolve(lockPath);
3600
- const previous = saveQueues.get(queueKey) ?? Promise.resolve();
3601
- const next = previous.catch(() => {
3602
- }).then(() => writeLockNow(lockPath, lock));
3603
- saveQueues.set(queueKey, next);
3604
- const cleanup = () => {
3605
- if (saveQueues.get(queueKey) === next) saveQueues.delete(queueKey);
3606
- };
3607
- void next.then(cleanup, cleanup);
3608
- return next;
3609
- }
3610
- async function writeLockNow(lockPath, lock) {
3611
- await mkdir(path5.dirname(path5.resolve(lockPath)), { recursive: true });
3612
- const release = await acquireFileLock(lockPath);
3613
- try {
3614
- await writeLockWhileHeld(lockPath, lock);
3615
- } finally {
3616
- await release();
3617
- }
3618
- }
3619
- async function writeLockWhileHeld(file, lock) {
3620
- let disk = { version: 2, entries: {} };
3621
- if (existsSync4(file)) {
3622
- try {
3623
- disk = parseLock(JSON.parse(await readFile3(file, "utf8")));
3624
- } catch (err) {
3625
- throw new Error(
3626
- `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`
3627
- );
3628
- }
3629
- }
3630
- const dirty = dirtyPatches.get(lock) ?? /* @__PURE__ */ new Map();
3631
- const snapshot = /* @__PURE__ */ new Map();
3632
- for (const [key, patch] of dirty) snapshot.set(key, { ...patch });
3633
- const merged = { ...disk.entries };
3634
- for (const [key, patch] of snapshot) {
3635
- const local = lock.entries[key];
3636
- if (!local) continue;
3637
- merged[key] = merged[key] ? { ...merged[key], ...patch } : local;
3638
- }
3639
- for (const [key, entry] of Object.entries(lock.entries)) {
3640
- if (!merged[key]) merged[key] = entry;
3641
- }
3642
- const deletes = dirtyDeletes.get(lock);
3643
- const deleteSnapshot = deletes ? new Set(deletes) : /* @__PURE__ */ new Set();
3644
- for (const key of deleteSnapshot) delete merged[key];
3645
- const sorted = {};
3646
- for (const key of Object.keys(merged).sort()) {
3647
- sorted[key] = merged[key];
3648
- }
3649
- const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3650
- try {
3651
- await writeFile(tmp, JSON.stringify({ version: 2, entries: sorted }, null, 2) + "\n");
3652
- await rename(tmp, file);
3653
- } finally {
3654
- await rm(tmp, { force: true });
3655
- }
3656
- const currentDirty = dirtyPatches.get(lock);
3657
- if (currentDirty) {
3658
- for (const [key, patch] of snapshot) {
3659
- const current = currentDirty.get(key);
3660
- if (!current) continue;
3661
- for (const [field, value] of Object.entries(patch)) {
3662
- if (Object.is(current[field], value)) {
3663
- delete current[field];
3664
- }
3665
- }
3666
- if (Object.keys(current).length === 0) currentDirty.delete(key);
3667
- }
3668
- }
3669
- const stillDirty = dirtyDeletes.get(lock);
3670
- if (stillDirty) {
3671
- for (const key of deleteSnapshot) stillDirty.delete(key);
3672
- if (stillDirty.size === 0) dirtyDeletes.delete(lock);
3673
- }
3674
- const localAfterWrite = lock.entries;
3675
- lock.entries = { ...merged };
3676
- for (const [key, patch] of currentDirty ?? []) {
3677
- const base = lock.entries[key] ?? localAfterWrite[key];
3678
- if (base) lock.entries[key] = { ...base, ...patch };
3679
- }
3680
- }
3681
- function upsert(lock, key, patch) {
3682
- dirtyDeletes.get(lock)?.delete(key);
3683
- const existing = lock.entries[key];
3684
- const next = { ...existing, ...patch };
3685
- lock.entries[key] = next;
3686
- let dirty = dirtyPatches.get(lock);
3687
- if (!dirty) {
3688
- dirty = /* @__PURE__ */ new Map();
3689
- dirtyPatches.set(lock, dirty);
3690
- }
3691
- dirty.set(key, { ...dirty.get(key) ?? {}, ...patch });
3692
- return next;
3693
- }
3694
- function remove(lock, key) {
3695
- if (!lock.entries[key]) return false;
3696
- delete lock.entries[key];
3697
- const patches = dirtyPatches.get(lock);
3698
- if (patches) {
3699
- patches.delete(key);
3700
- if (patches.size === 0) dirtyPatches.delete(lock);
3701
- }
3702
- let deletes = dirtyDeletes.get(lock);
3703
- if (!deletes) {
3704
- deletes = /* @__PURE__ */ new Set();
3705
- dirtyDeletes.set(lock, deletes);
3706
- }
3707
- deletes.add(key);
3708
- return true;
3709
- }
3710
- var FILE_LOCK_TIMEOUT_MS = 1e4;
3711
- var STALE_FILE_LOCK_MS = 2 * 60 * 1e3;
3712
- async function acquireFileLock(file) {
3713
- const lockDir = `${file}.lock`;
3714
- const deadline = Date.now() + FILE_LOCK_TIMEOUT_MS;
3715
- for (; ; ) {
3716
- try {
3717
- await mkdir(lockDir);
3718
- return async () => {
3719
- await rmdir(lockDir).catch(() => {
3720
- });
3721
- };
3722
- } catch (err) {
3723
- const code = err.code;
3724
- if (code !== "EEXIST") throw err;
3725
- try {
3726
- const age = Date.now() - (await stat(lockDir)).mtimeMs;
3727
- if (age > STALE_FILE_LOCK_MS) {
3728
- await rmdir(lockDir);
3729
- continue;
3730
- }
3731
- } catch (lockErr) {
3732
- if (lockErr.code === "ENOENT") continue;
3733
- }
3734
- if (Date.now() >= deadline) {
3735
- 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`);
3736
4000
  }
3737
- await new Promise((resolve) => setTimeout(resolve, 25 + Math.floor(Math.random() * 25)));
3738
4001
  }
3739
4002
  }
4003
+ return specs;
3740
4004
  }
3741
- function spendByUnit(lock) {
3742
- const totals = { generations: 0, usd: 0, free: 0 };
3743
- for (const entry of Object.values(lock.entries)) {
3744
- const unit = entry.costUnit ?? "generations";
3745
- totals[unit] = (totals[unit] ?? 0) + (entry.cost ?? 0);
3746
- }
3747
- return totals;
3748
- }
3749
-
3750
- // src/outputs.ts
3751
- function portableOutputPath(file, manifestDir) {
3752
- const root = path6.resolve(manifestDir);
3753
- const absolute = path6.isAbsolute(file) ? path6.normalize(file) : path6.resolve(root, file);
3754
- const relative = path6.relative(root, absolute);
3755
- const value = path6.isAbsolute(relative) ? absolute : relative;
3756
- return value.split(path6.sep).join("/");
3757
- }
3758
- function resolveOutputPath(recordedPath, manifestDir) {
3759
- if (path6.isAbsolute(recordedPath)) return path6.normalize(recordedPath);
3760
- if (path6.win32.isAbsolute(recordedPath)) return recordedPath;
3761
- return path6.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path6.sep));
3762
- }
3763
- function expectedOutputPath(spec, role, index, total, mediaType) {
3764
- const originalExt = path6.extname(spec.outFile);
3765
- const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
3766
- const stem = originalExt ? spec.outFile.slice(0, -originalExt.length) : spec.outFile;
3767
- if (total === 1) return `${stem}${ext}`;
3768
- const safeRole = (role ?? fallbackOutputRole(index)).replace(/[^a-zA-Z0-9_-]+/g, "-");
3769
- return `${stem}-${safeRole}${ext}`;
3770
- }
3771
- function currentOutputPath(output, spec, index, total) {
3772
- return expectedOutputPath(spec, output.role, index, total, output.mediaType);
3773
- }
3774
- function currentEntryOutputPath(entry, spec, index) {
3775
- const output = entry.outputs[index];
3776
- const sourceIndex = output.role ? (entry.sourceUrls ?? []).findIndex((source) => source.role === output.role) : -1;
3777
- const position = sourceIndex >= 0 ? sourceIndex : index;
3778
- const total = Math.max(entry.outputs.length, entry.sourceUrls?.length ?? 0);
3779
- 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`;
3780
4008
  }
3781
- function normalizeLockOutputPaths(lock, specs) {
3782
- const byKey = new Map(specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
3783
- let changed = 0;
3784
- for (const [key, entry] of Object.entries(lock.entries)) {
3785
- const spec = byKey.get(key);
3786
- if (!spec) continue;
3787
- let entryChanged = false;
3788
- const outputs = entry.outputs.map((output, index) => {
3789
- const absolute = currentEntryOutputPath(entry, spec, index);
3790
- const portable = portableOutputPath(absolute, spec.root);
3791
- if (portable === output.path) return output;
3792
- changed++;
3793
- entryChanged = true;
3794
- return { ...output, path: portable };
3795
- });
3796
- 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}`);
3797
4016
  }
3798
- return changed;
3799
- }
3800
- function fallbackOutputRole(index) {
3801
- return `output-${String(index).padStart(2, "0")}`;
3802
- }
3803
- function outputId(assetId, output, index, total) {
3804
- if (total === 1) return assetId;
3805
- return `${assetId}/${output.role ?? fallbackOutputRole(index)}`;
3806
- }
3807
- function resolveEntryOutputs(entry, assetId, manifestDir) {
3808
- return entry.outputs.map((output, index) => ({
3809
- ...output,
3810
- assetId,
3811
- id: outputId(assetId, output, index, entry.outputs.length),
3812
- index,
3813
- absolutePath: resolveOutputPath(output.path, manifestDir)
3814
- }));
3815
- }
3816
- function resolveSpecEntryOutputs(entry, spec) {
3817
- return entry.outputs.map((output, index) => ({
3818
- ...output,
3819
- assetId: spec.assetId,
3820
- id: outputId(spec.assetId, output, index, entry.outputs.length),
3821
- index,
3822
- absolutePath: currentEntryOutputPath(entry, spec, index)
3823
- }));
4017
+ return { hash: sha256(bytes), ...metadata };
3824
4018
  }
3825
- function selectEntryOutput(entry, role) {
3826
- if (role) {
3827
- const output = entry.outputs.find((candidate) => candidate.role === role);
3828
- 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 });
3829
4032
  }
3830
- if (entry.outputs.length === 0) return { ok: false, reason: "no output recorded in the lockfile" };
3831
- if (entry.outputs.length === 1) return { ok: true, output: entry.outputs[0] };
3832
- const primary = entry.outputs.filter((output) => !output.role);
3833
- if (primary.length === 1) return { ok: true, output: primary[0] };
3834
- return {
3835
- ok: false,
3836
- reason: `${entry.outputs.length} outputs are recorded; set \`outputRole\` to choose one`
3837
- };
4033
+ return out;
3838
4034
  }
3839
- function resolveSpecOutputs(spec, lock, manifestDir) {
3840
- const entry = lock?.entries[lockKey(spec.styleId, spec.assetId)];
3841
- if (entry?.outputs.length) return resolveSpecEntryOutputs(entry, spec);
3842
- return [{
3843
- assetId: spec.assetId,
3844
- id: spec.assetId,
3845
- index: 0,
3846
- path: spec.outFile,
3847
- absolutePath: path6.resolve(spec.outFile),
3848
- sha256: ""
3849
- }];
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;
3850
4062
  }
3851
4063
 
3852
4064
  // src/pipeline/pack.ts
4065
+ import { readFileSync as readFileSync3 } from "fs";
4066
+ import path7 from "path";
3853
4067
  function resolvePackInputs(raw, inputsFilePath) {
3854
4068
  if (!Array.isArray(raw) || !raw.length) {
3855
4069
  throw new Error("--inputs must be a non-empty JSON array of { id, path }");
@@ -3928,7 +4142,7 @@ function packSprites(inputs, options = {}) {
3928
4142
  function packStyle(lock, styleId, manifestDir, options = {}) {
3929
4143
  if (options.sourceOverrides) {
3930
4144
  if (options.outputRoles?.length) {
3931
- 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.");
3932
4146
  }
3933
4147
  const inputs2 = Object.entries(options.sourceOverrides).map(([id, source]) => ({
3934
4148
  id,
@@ -4740,6 +4954,8 @@ function hex(c) {
4740
4954
 
4741
4955
  // src/pipeline/refine.ts
4742
4956
  var execFileAsync = promisify(execFile);
4957
+ var PIXEL_ART_FIXER_MIN_SIDE = 16;
4958
+ var PIXEL_ART_FIXER_MAX_PIXELS = 4e6;
4743
4959
  var PIXEL_ART_FIXER_URL = "https://github.com/Retro-Diffusion/pixel-art-fixer";
4744
4960
  var PIXEL_ART_FIXER_REVISION = "ef376e57e1c272633ca2dbf5f29ec3fcf6596465";
4745
4961
  function message2(error) {
@@ -4787,25 +5003,33 @@ function firstJsonObject(text) {
4787
5003
  }
4788
5004
  var PYTHON_FIXER_BRIDGE = [
4789
5005
  "import json, sys",
4790
- "from pixelfixer.api import process",
4791
- "result = process(open(sys.argv[1], 'rb').read(), mode='full')",
4792
- "open(sys.argv[2], 'wb').write(result.pop('png'))",
4793
- "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')}))"
4794
5014
  ].join("; ");
4795
5015
  function parseDetection(raw) {
4796
5016
  if (!raw || typeof raw !== "object") throw new Error("Pixel Art Fixer returned no detection result.");
4797
5017
  const value = raw;
4798
5018
  const stepX = Number(value.step_x);
4799
5019
  const stepY = Number(value.step_y);
5020
+ const phaseX = Number(value.phase_x);
5021
+ const phaseY = Number(value.phase_y);
4800
5022
  const columns = Number(value.cols);
4801
5023
  const rows = Number(value.rows);
4802
5024
  const consensus = value.consensus;
4803
- 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) {
4804
5026
  throw new Error("Pixel Art Fixer returned an invalid grid detection result.");
4805
5027
  }
4806
5028
  return {
4807
5029
  stepX,
4808
5030
  stepY,
5031
+ phaseX,
5032
+ phaseY,
4809
5033
  columns,
4810
5034
  rows,
4811
5035
  consensus,
@@ -4916,6 +5140,23 @@ function auditRefinement(png, detection, palette, minTransparency, minGridConfid
4916
5140
  function isGridConfidence(value) {
4917
5141
  return value === "low" || value === "medium" || value === "high";
4918
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
+ }
4919
5160
  function parseRecordOptions(manifest, record) {
4920
5161
  if (manifest.kind !== "refine") throw new Error(`${record} is not a PixelKiln refinement record.`);
4921
5162
  const raw = manifest.options;
@@ -4925,11 +5166,26 @@ function parseRecordOptions(manifest, record) {
4925
5166
  const palette = options.palette;
4926
5167
  const audit = options.audit;
4927
5168
  const review = options.review;
4928
- 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)) {
4929
5170
  throw new Error(`${record} has invalid quality metadata.`);
4930
5171
  }
4931
5172
  return options;
4932
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
+ }
4933
5189
  async function refineAsset(options) {
4934
5190
  const source = path10.resolve(options.source);
4935
5191
  const output = path10.resolve(options.output);
@@ -4944,9 +5200,21 @@ async function refineAsset(options) {
4944
5200
  } catch (error) {
4945
5201
  throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
4946
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
+ }
4947
5216
  const temp = await mkdtemp(path10.join(tmpdir(), "pixelkiln-refine-"));
4948
5217
  const recovered = path10.join(temp, "native.png");
4949
- const compatibilityCommand = options.fixerCommand;
4950
5218
  const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
4951
5219
  const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
4952
5220
  try {
@@ -4993,11 +5261,13 @@ async function refineAsset(options) {
4993
5261
  tool: "pixel-art-fixer",
4994
5262
  url: PIXEL_ART_FIXER_URL,
4995
5263
  revision: options.fixerRevision ?? PIXEL_ART_FIXER_REVISION,
4996
- protocol: compatibilityCommand ? "python-cli-v1" : "python-api-v1",
5264
+ protocol: compatibilityCommand ? "python-cli-v1" : "python-core-v1",
4997
5265
  confidence: detection.confidence,
4998
5266
  consensus: detection.consensus,
4999
5267
  stepX: detection.stepX,
5000
5268
  stepY: detection.stepY,
5269
+ phaseX: detection.phaseX,
5270
+ phaseY: detection.phaseY,
5001
5271
  sourceWidth: sourcePng.width,
5002
5272
  sourceHeight: sourcePng.height,
5003
5273
  nativeWidth: quantized.width,
@@ -5018,19 +5288,142 @@ async function refineAsset(options) {
5018
5288
  await rm3(temp, { recursive: true, force: true });
5019
5289
  }
5020
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
+ }
5021
5407
  async function checkQualityRecord(recordPath) {
5022
5408
  const record = path10.resolve(recordPath);
5023
5409
  const manifest = await readArtifactBundleManifest(record);
5024
5410
  const options = parseRecordOptions(manifest, record);
5025
5411
  const verification = await verifyArtifactBundle(record);
5026
- const source = manifest.sources.length === 1 ? path10.resolve(path10.dirname(record), manifest.sources[0].path) : "";
5027
- 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;
5028
5417
  const reasons = [];
5029
5418
  if (!verification.fingerprintValid) reasons.push("quality record fingerprint changed");
5030
5419
  for (const source2 of verification.changedSources) reasons.push(`source changed: ${source2}`);
5031
5420
  for (const changed of verification.changedOutputs) reasons.push(`output changed: ${changed}`);
5032
- if (manifest.sources.length !== 1) reasons.push("quality record must contain exactly one source");
5033
- 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
+ }
5034
5427
  if (!options.audit.safe) reasons.push(...options.audit.reasons.map((reason) => `audit: ${reason}`));
5035
5428
  if (options.review.status !== "approved") reasons.push("human 1\xD7 review is pending");
5036
5429
  return {
@@ -5041,6 +5434,8 @@ async function checkQualityRecord(recordPath) {
5041
5434
  record,
5042
5435
  source,
5043
5436
  output,
5437
+ sources,
5438
+ outputs,
5044
5439
  options,
5045
5440
  reasons
5046
5441
  };
@@ -5055,7 +5450,7 @@ async function approveQualityRecord(recordPath, approval) {
5055
5450
  if (!verification.current) {
5056
5451
  const changes = [
5057
5452
  ...verification.changedSources.map((source) => `source ${source}`),
5058
- ...verification.changedOutputs.map((output2) => `output ${output2}`),
5453
+ ...verification.changedOutputs.map((output) => `output ${output}`),
5059
5454
  ...!verification.fingerprintValid ? ["quality metadata"] : []
5060
5455
  ];
5061
5456
  throw new Error(`Cannot approve a stale refinement; changed: ${changes.join(", ")}. Run refine again.`);
@@ -5063,12 +5458,17 @@ async function approveQualityRecord(recordPath, approval) {
5063
5458
  if (!options.audit.safe) {
5064
5459
  throw new Error(`Cannot approve a failed quality audit: ${options.audit.reasons.join("; ")}`);
5065
5460
  }
5066
- if (manifest.sources.length !== 1 || manifest.outputs.length !== 1) {
5067
- 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
+ );
5068
5466
  }
5069
5467
  const root = path10.dirname(record);
5070
- const outputPath = path10.resolve(root, manifest.outputs[0].path);
5071
- 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
+ }));
5072
5472
  const sources = manifest.sources.map((source) => ({
5073
5473
  ...source,
5074
5474
  path: path10.resolve(root, source.path)
@@ -5091,7 +5491,7 @@ async function approveQualityRecord(recordPath, approval) {
5091
5491
  };
5092
5492
  await writeManagedArtifactBundle(
5093
5493
  record,
5094
- [{ path: outputPath, data: output }],
5494
+ outputs,
5095
5495
  { kind: "refine", sources, options: approved }
5096
5496
  );
5097
5497
  return checkQualityRecord(record);
@@ -5100,33 +5500,46 @@ async function approveQualityRecord(recordPath, approval) {
5100
5500
  // src/pipeline/quality-profile.ts
5101
5501
  async function qualitySource(spec, lock) {
5102
5502
  if (spec.source) {
5103
- const source2 = path11.resolve(spec.root, spec.source);
5104
- 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}` };
5105
5508
  }
5106
5509
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
5107
- 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" };
5108
5511
  if (entry.specHash !== spec.specHash) {
5109
- 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" };
5110
5513
  }
5111
5514
  if (entry.status !== "downloaded") {
5112
- 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` };
5113
5516
  }
5114
- if (entry.outputs.length !== 1) {
5517
+ const frameSet = spec.generator === "frames";
5518
+ if (!frameSet && entry.outputs.length !== 1 || frameSet && entry.outputs.length < 2) {
5115
5519
  return {
5116
- path: null,
5117
- 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}`
5118
5522
  };
5119
5523
  }
5120
- const output = entry.outputs[0];
5121
- if (output.mediaType && output.mediaType !== MediaType.PNG) {
5122
- return { path: null, reason: `quality profiles require PNG input; lock entry is ${output.mediaType}` };
5123
- }
5124
- const source = currentEntryOutputPath(entry, spec, 0);
5125
- if (!existsSync6(source)) return { path: null, reason: `raw provider output is missing: ${source}` };
5126
- if (await sha256File(source) !== output.sha256) {
5127
- 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 });
5128
5541
  }
5129
- return { path: source };
5542
+ return { items };
5130
5543
  }
5131
5544
  async function inspectQualityProfile(spec, lock) {
5132
5545
  if (!spec.quality) return null;
@@ -5134,16 +5547,20 @@ async function inspectQualityProfile(spec, lock) {
5134
5547
  const output = spec.quality.outFile;
5135
5548
  const record = qualityRecordPath(output);
5136
5549
  const source = await qualitySource(spec, lock);
5137
- if (!source.path) {
5550
+ if (!source.items) {
5138
5551
  return { key, state: "blocked", reason: source.reason, source: null, output, record };
5139
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];
5140
5555
  if (!existsSync6(record)) {
5141
5556
  return {
5142
5557
  key,
5143
5558
  state: "needs-refinement",
5144
5559
  reason: "no refinement record",
5145
- source: source.path,
5560
+ source: sourcePaths[0],
5561
+ sources: sourcePaths,
5146
5562
  output,
5563
+ outputs: expectedOutputs,
5147
5564
  record
5148
5565
  };
5149
5566
  }
@@ -5152,14 +5569,17 @@ async function inspectQualityProfile(spec, lock) {
5152
5569
  const expectedPalette = normalizeRefinementPalette(spec.quality.palette);
5153
5570
  const expectedRevision = spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION;
5154
5571
  const expectedTransparency = spec.quality.minTransparency ?? null;
5155
- 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);
5156
5574
  if (contractChanged) {
5157
5575
  return {
5158
5576
  key,
5159
5577
  state: "needs-refinement",
5160
5578
  reason: "quality profile changed",
5161
- source: source.path,
5579
+ source: sourcePaths[0],
5580
+ sources: sourcePaths,
5162
5581
  output,
5582
+ outputs: expectedOutputs,
5163
5583
  record
5164
5584
  };
5165
5585
  }
@@ -5168,8 +5588,10 @@ async function inspectQualityProfile(spec, lock) {
5168
5588
  key,
5169
5589
  state: "needs-refinement",
5170
5590
  reason: check.reasons.filter((reason) => reason !== "human 1\xD7 review is pending").join("; ") || "refinement record is stale",
5171
- source: source.path,
5591
+ source: sourcePaths[0],
5592
+ sources: sourcePaths,
5172
5593
  output,
5594
+ outputs: expectedOutputs,
5173
5595
  record
5174
5596
  };
5175
5597
  }
@@ -5178,8 +5600,10 @@ async function inspectQualityProfile(spec, lock) {
5178
5600
  key,
5179
5601
  state: "needs-approval",
5180
5602
  reason: "automated checks passed; human 1\xD7 review is pending",
5181
- source: source.path,
5603
+ source: sourcePaths[0],
5604
+ sources: sourcePaths,
5182
5605
  output,
5606
+ outputs: expectedOutputs,
5183
5607
  record
5184
5608
  };
5185
5609
  }
@@ -5187,8 +5611,10 @@ async function inspectQualityProfile(spec, lock) {
5187
5611
  key,
5188
5612
  state: "approved",
5189
5613
  reason: "refined output and human approval are current",
5190
- source: source.path,
5614
+ source: sourcePaths[0],
5615
+ sources: sourcePaths,
5191
5616
  output,
5617
+ outputs: expectedOutputs,
5192
5618
  record
5193
5619
  };
5194
5620
  } catch (error) {
@@ -5196,8 +5622,10 @@ async function inspectQualityProfile(spec, lock) {
5196
5622
  key,
5197
5623
  state: "needs-refinement",
5198
5624
  reason: `invalid refinement record: ${error instanceof Error ? error.message : String(error)}`,
5199
- source: source.path,
5625
+ source: sourcePaths[0],
5626
+ sources: sourcePaths,
5200
5627
  output,
5628
+ outputs: expectedOutputs,
5201
5629
  record
5202
5630
  };
5203
5631
  }
@@ -5221,8 +5649,7 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5221
5649
  continue;
5222
5650
  }
5223
5651
  try {
5224
- await refineAsset({
5225
- source: inspection.source,
5652
+ const common = {
5226
5653
  output: inspection.output,
5227
5654
  palette: spec.quality.palette,
5228
5655
  minGridConfidence: spec.quality.minGridConfidence,
@@ -5234,7 +5661,18 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5234
5661
  fixerCommand: options.fixerCommand,
5235
5662
  fixerArgsPrefix: options.fixerArgsPrefix,
5236
5663
  force: options.force
5237
- });
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
+ }
5238
5676
  result.processed++;
5239
5677
  result.items.push(await inspectQualityProfile(spec, lock));
5240
5678
  } catch (error) {
@@ -5248,7 +5686,7 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5248
5686
  }
5249
5687
  return result;
5250
5688
  }
5251
- async function requireApprovedQualitySources(specs, lock, assetIds) {
5689
+ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles) {
5252
5690
  const configured = specs.filter(
5253
5691
  (spec) => spec.quality && (!assetIds || assetIds.has(spec.assetId))
5254
5692
  );
@@ -5257,8 +5695,33 @@ async function requireApprovedQualitySources(specs, lock, assetIds) {
5257
5695
  const rejected = [];
5258
5696
  for (const spec of configured) {
5259
5697
  const inspection = await inspectQualityProfile(spec, lock);
5260
- if (inspection.state === "approved") sources[spec.assetId] = inspection.output;
5261
- 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);
5262
5725
  }
5263
5726
  if (rejected.length) {
5264
5727
  const detail = rejected.slice(0, 5).map((item) => `${item.key}: ${item.state} (${item.reason})`).join("; ");
@@ -5611,7 +6074,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
5611
6074
  jobId,
5612
6075
  // A multi-candidate generator routes through review; record the parent
5613
6076
  // so `pick` knows where to look.
5614
- reviewObjectId: estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
6077
+ reviewObjectId: spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
5615
6078
  status: "processing",
5616
6079
  providerMetadata: metadata ? { ...lock.entries[key].providerMetadata, [provider.id]: metadata } : lock.entries[key].providerMetadata
5617
6080
  });
@@ -5659,7 +6122,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
5659
6122
  tileFeature: entry.tileFeature ?? currentSpec?.tileFeature,
5660
6123
  spec: currentSpec
5661
6124
  });
5662
- if (state.status === "review") {
6125
+ if (state.status === "review" || state.status === "review-set") {
5663
6126
  upsert(lock, key, {
5664
6127
  status: "review",
5665
6128
  reviewObjectId: entry.jobId,
@@ -5672,7 +6135,9 @@ async function poll(provider, lock, lockPath, opts = {}) {
5672
6135
  } : entry.providerMetadata
5673
6136
  });
5674
6137
  result.review++;
5675
- 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
+ );
5676
6141
  } else if (state.status === "ready") {
5677
6142
  const sourceUrls = state.sources?.length ? state.sources : state.sourceUrl ? [{ url: state.sourceUrl }] : [];
5678
6143
  upsert(lock, key, {
@@ -6431,12 +6896,17 @@ function renderSheet(groups) {
6431
6896
  linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%);
6432
6897
  background-size:12px 12px; background-position:0 0,6px 6px; }
6433
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; }
6434
6903
  .cand { border:2px solid var(--line-strong); border-radius:0; padding:7px; background:var(--panel-deep);
6435
6904
  cursor:pointer; display:flex; flex:0 0 auto; flex-direction:column; align-items:center; gap:5px;
6436
6905
  position:relative; max-width:100%; }
6437
6906
  .cand:hover { border-color:var(--dim); }
6438
6907
  .cand.active { border-color:var(--dim); box-shadow:0 0 0 2px color-mix(in srgb, var(--dim) 20%, transparent); }
6439
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; }
6440
6910
  .cand img { image-rendering:pixelated; display:block;
6441
6911
  background-image:
6442
6912
  linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
@@ -6474,7 +6944,8 @@ function renderSheet(groups) {
6474
6944
  </header>
6475
6945
  <main id="root"></main>
6476
6946
  <footer>
6477
- 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,
6478
6949
  <kbd>\u2190</kbd>/<kbd>\u2192</kbd> browses every candidate and <kbd>Enter</kbd> chooses;
6479
6950
  <kbd>1</kbd>\u2013<kbd>9</kbd> picks directly, <kbd>0</kbd> skips, and
6480
6951
  <kbd>\u2191</kbd>/<kbd>\u2193</kbd> changes rows. Unchosen rows stay in review \u2014 nothing is
@@ -6522,12 +6993,34 @@ GROUPS.forEach((g, gi) => {
6522
6993
  context.append(label, source);
6523
6994
  review.insertBefore(context, frames);
6524
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
+ }
6525
7016
  g.frameUrls.forEach((url, i) => {
6526
7017
  const c = document.createElement('button');
6527
7018
  c.type = 'button';
6528
- c.className = 'cand';
7019
+ c.className = 'cand' + (g.mode === 'frame-set' ? ' set-frame' : '');
6529
7020
  c.tabIndex = -1;
6530
- 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);
6531
7024
 
6532
7025
  const preview = document.createElement('img');
6533
7026
  preview.className = 'preview';
@@ -6537,7 +7030,8 @@ GROUPS.forEach((g, gi) => {
6537
7030
  preview.loading = 'lazy';
6538
7031
  const index = document.createElement('span');
6539
7032
  index.className = 'idx';
6540
- 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;
6541
7035
  c.append(preview, index);
6542
7036
  if (displayScale > 1 && largestSide <= 96) {
6543
7037
  const actual = document.createElement('img');
@@ -6578,6 +7072,14 @@ function activate(i, frames) {
6578
7072
  }
6579
7073
 
6580
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
+ }
6581
7083
  const current = picks.get(gi);
6582
7084
  frames.querySelectorAll('.cand').forEach(n => n.classList.remove('sel'));
6583
7085
  if (current === i) { picks.delete(gi); groupEl.classList.remove('done'); }
@@ -6754,7 +7256,8 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6754
7256
  try {
6755
7257
  const spec = specByKey.get(key);
6756
7258
  const state = await provider.poll(entry.reviewObjectId, entry.generator, { spec });
6757
- 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;
6758
7261
  const sourceRoute = spec?.revision ? `/revision-source/${encodeURIComponent(String(groups.length))}` : null;
6759
7262
  if (sourceRoute && spec?.revision) {
6760
7263
  reviewAssets.set(sourceRoute, {
@@ -6768,9 +7271,14 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6768
7271
  styleId: entry.styleId,
6769
7272
  prompt: entry.prompt,
6770
7273
  reviewObjectId: entry.reviewObjectId,
6771
- frameUrls: state.candidateUrls,
7274
+ frameUrls,
6772
7275
  width: entry.width,
6773
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" },
6774
7282
  ...sourceRoute && spec?.revision && spec.revision.sourceWidth && spec.revision.sourceHeight ? {
6775
7283
  revision: {
6776
7284
  mode: spec.revision.mode,
@@ -6813,6 +7321,31 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6813
7321
  const entry = lock.entries[key];
6814
7322
  if (!group || !entry?.reviewObjectId) continue;
6815
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
+ }
6816
7349
  const { objectId, sourceUrl, metadata } = await requireSelectCandidate(provider)(
6817
7350
  entry.reviewObjectId,
6818
7351
  index,
@@ -9522,9 +10055,15 @@ async function main() {
9522
10055
  if (!args.reviewer?.trim()) {
9523
10056
  throw new Error("refine approve needs --reviewer <name>.");
9524
10057
  }
9525
- 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
+ );
9526
10062
  log(` Confirm crisp edges, readable forms, palette separation, alpha, and seams.`);
9527
- 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
+ )) {
9528
10067
  log(" approval not recorded");
9529
10068
  return;
9530
10069
  }
@@ -9533,7 +10072,11 @@ async function main() {
9533
10072
  note: args.note
9534
10073
  });
9535
10074
  if (args.json) log(JSON.stringify(result2, null, 2));
9536
- 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
+ }
9537
10080
  return;
9538
10081
  }
9539
10082
  const result = await checkQualityRecord(args.from);
@@ -10046,7 +10589,10 @@ async function main() {
10046
10589
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
10047
10590
  ];
10048
10591
  const qualityRecords = qualitySources ? await Promise.all(
10049
- 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
+ ))
10050
10596
  ) : [];
10051
10597
  await writeManagedArtifactBundle(`${base}.pixelkiln.json`, outputs, {
10052
10598
  kind: "pack",
@@ -10098,7 +10644,8 @@ async function main() {
10098
10644
  const qualitySources = await requireApprovedQualitySources(
10099
10645
  packagingSpecs,
10100
10646
  lock,
10101
- new Set(Object.keys(cells))
10647
+ new Set(Object.keys(cells)),
10648
+ outputRoles
10102
10649
  );
10103
10650
  if (qualitySources) Object.assign(sources, qualitySources);
10104
10651
  const { png, atlas, skipped, overBase, sources: artifactSources } = mountStyle(
@@ -10118,7 +10665,10 @@ async function main() {
10118
10665
  { path: metadata, data: JSON.stringify(atlas, null, 2) + "\n" }
10119
10666
  ];
10120
10667
  const qualityRecords = qualitySources ? await Promise.all(
10121
- 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
+ ))
10122
10672
  ) : [];
10123
10673
  await writeManagedArtifactBundle(companion, outputs, {
10124
10674
  kind: "mount",