pixelkiln 0.18.0 → 0.20.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) {
@@ -1651,7 +1836,7 @@ var StyleImageSchema = z2.object({
1651
1836
  path: z2.string()
1652
1837
  });
1653
1838
  var HexColorSchema = z2.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour");
1654
- var QualityProfileSchema = z2.object({
1839
+ var QualityProfileObjectSchema = z2.object({
1655
1840
  /** Output root for approved refined art, relative to the manifest. */
1656
1841
  outDir: z2.string().min(1),
1657
1842
  /** Closed final palette applied after native-grid recovery, without dithering. */
@@ -1663,8 +1848,11 @@ 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()
1667
- }).strict().refine(
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()
1854
+ }).strict();
1855
+ var QualityProfileSchema = QualityProfileObjectSchema.refine(
1668
1856
  (profile) => new Set(profile.palette.map((color) => color.replace(/^#/, "").toLowerCase())).size === profile.palette.length,
1669
1857
  { message: "quality palette colors must be unique", path: ["palette"] }
1670
1858
  );
@@ -1693,7 +1881,7 @@ var RevisionSchema = z2.object({
1693
1881
  });
1694
1882
  }
1695
1883
  });
1696
- var StyleSchema = z2.object({
1884
+ var StyleObjectSchema = z2.object({
1697
1885
  /** Generation backend for this style. Omit to inherit the manifest default. */
1698
1886
  provider: z2.string().min(1).optional(),
1699
1887
  // `map` is the default because it is 20-40x cheaper and correct for any
@@ -1808,7 +1996,8 @@ var StyleSchema = z2.object({
1808
1996
  tags: z2.array(z2.string()).default([]),
1809
1997
  /** Adapter-owned settings, keyed by provider id. */
1810
1998
  providerOptions: z2.record(z2.record(z2.unknown())).default({})
1811
- }).strict().refine((s) => !(s.tileFeature && s.styleImages.length), {
1999
+ }).strict();
2000
+ var StyleSchema = StyleObjectSchema.refine((s) => !(s.tileFeature && s.styleImages.length), {
1812
2001
  message: "tileFeature and styleImages cannot be combined \u2014 a connectable set derives its own tile geometry, so remove one or the other",
1813
2002
  path: ["tileFeature"]
1814
2003
  }).superRefine((style, ctx) => {
@@ -1820,6 +2009,14 @@ var StyleSchema = z2.object({
1820
2009
  });
1821
2010
  }
1822
2011
  });
2012
+ var InheritedStyleSchema = StyleObjectSchema.partial().extend({
2013
+ extends: z2.string().min(1),
2014
+ /** Children own their destination; sharing a parent's output is never implicit. */
2015
+ outDir: z2.string(),
2016
+ /** A child may override only part of the inherited final-art policy. */
2017
+ quality: QualityProfileObjectSchema.partial().optional()
2018
+ }).strict();
2019
+ var StyleInputSchema = z2.union([StyleSchema, InheritedStyleSchema]);
1823
2020
  var AssetSchema = z2.object({
1824
2021
  /** The subject. Style wrapping comes from the style's prefix/suffix. */
1825
2022
  prompt: z2.string(),
@@ -1882,10 +2079,15 @@ var AssetSchema = z2.object({
1882
2079
  promptByStyle: z2.record(z2.string()).default({}),
1883
2080
  /**
1884
2081
  * 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.
2082
+ * bindings. Values are JSON scalars, or a frame-set sequence of scalars;
2083
+ * adapters may interpret a string as a manifest-relative uploaded file.
1887
2084
  */
1888
- providerInputs: z2.record(z2.union([z2.string(), z2.number().finite(), z2.boolean()])).default({})
2085
+ providerInputs: z2.record(z2.union([
2086
+ z2.string(),
2087
+ z2.number().finite(),
2088
+ z2.boolean(),
2089
+ z2.array(z2.union([z2.string(), z2.number().finite(), z2.boolean()])).min(2).max(64)
2090
+ ])).default({})
1889
2091
  }).strict().superRefine((asset, context) => {
1890
2092
  if (asset.source && asset.revision) {
1891
2093
  context.addIssue({
@@ -1903,6 +2105,13 @@ var ManifestSchema = z2.object({
1903
2105
  styles: z2.record(StyleSchema),
1904
2106
  assets: z2.record(AssetSchema)
1905
2107
  }).strict();
2108
+ var ManifestInputSchema = z2.object({
2109
+ $schema: z2.string().optional(),
2110
+ name: z2.string(),
2111
+ provider: z2.string().min(1).default("pixellab"),
2112
+ styles: z2.record(StyleInputSchema),
2113
+ assets: z2.record(AssetSchema)
2114
+ }).strict();
1906
2115
  var LockEntrySchema = z2.object({
1907
2116
  styleId: z2.string(),
1908
2117
  assetId: z2.string(),
@@ -3195,136 +3404,483 @@ registerProvider({
3195
3404
  });
3196
3405
 
3197
3406
  // src/manifest.ts
3198
- import { readFile as readFile2 } from "fs/promises";
3407
+ import { readFile as readFile3 } from "fs/promises";
3408
+ import { existsSync as existsSync4 } from "fs";
3409
+ import path6 from "path";
3410
+
3411
+ // src/outputs.ts
3412
+ import path5 from "path";
3413
+
3414
+ // src/lock.ts
3415
+ import { readFile as readFile2, writeFile, rename, mkdir, rmdir, rm, stat } from "fs/promises";
3199
3416
  import { existsSync as existsSync3 } from "fs";
3200
3417
  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
- }
3418
+ async function loadLock(lockPath) {
3419
+ if (!existsSync3(lockPath)) return { version: 2, entries: {} };
3420
+ try {
3421
+ return parseLock(JSON.parse(await readFile2(lockPath, "utf8")));
3422
+ } catch (err) {
3423
+ throw new Error(
3424
+ `Lockfile at ${lockPath} is malformed:
3425
+ ${err instanceof Error ? err.message : String(err)}`
3426
+ );
3230
3427
  }
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);
3428
+ }
3429
+ var saveQueues = /* @__PURE__ */ new Map();
3430
+ var dirtyPatches = /* @__PURE__ */ new WeakMap();
3431
+ var dirtyDeletes = /* @__PURE__ */ new WeakMap();
3432
+ function saveLock(lockPath, lock) {
3433
+ const queueKey = path4.resolve(lockPath);
3434
+ const previous = saveQueues.get(queueKey) ?? Promise.resolve();
3435
+ const next = previous.catch(() => {
3436
+ }).then(() => writeLockNow(lockPath, lock));
3437
+ saveQueues.set(queueKey, next);
3438
+ const cleanup = () => {
3439
+ if (saveQueues.get(queueKey) === next) saveQueues.delete(queueKey);
3249
3440
  };
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")}`);
3441
+ void next.then(cleanup, cleanup);
3442
+ return next;
3443
+ }
3444
+ async function writeLockNow(lockPath, lock) {
3445
+ await mkdir(path4.dirname(path4.resolve(lockPath)), { recursive: true });
3446
+ const release = await acquireFileLock(lockPath);
3447
+ try {
3448
+ await writeLockWhileHeld(lockPath, lock);
3449
+ } finally {
3450
+ await release();
3254
3451
  }
3255
- return { manifest: parsed.data, root: path4.dirname(abs), path: abs };
3256
3452
  }
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]) {
3453
+ async function writeLockWhileHeld(file, lock) {
3454
+ let disk = { version: 2, entries: {} };
3455
+ if (existsSync3(file)) {
3456
+ try {
3457
+ disk = parseLock(JSON.parse(await readFile2(file, "utf8")));
3458
+ } catch (err) {
3276
3459
  throw new Error(
3277
- `Unknown style "${unknownStyle}". Defined: ${Object.keys(manifest.styles).join(", ") || "(none)"}`
3460
+ `Refusing to overwrite malformed lockfile at ${file}: ${err instanceof Error ? err.message : String(err)}`
3278
3461
  );
3279
3462
  }
3280
3463
  }
3281
- for (const unknownAsset of filter?.assets ?? []) {
3282
- if (!manifest.assets[unknownAsset]) {
3283
- throw new Error(`Unknown asset "${unknownAsset}".`);
3284
- }
3464
+ const dirty = dirtyPatches.get(lock) ?? /* @__PURE__ */ new Map();
3465
+ const snapshot = /* @__PURE__ */ new Map();
3466
+ for (const [key, patch] of dirty) snapshot.set(key, { ...patch });
3467
+ const merged = { ...disk.entries };
3468
+ for (const [key, patch] of snapshot) {
3469
+ const local = lock.entries[key];
3470
+ if (!local) continue;
3471
+ merged[key] = merged[key] ? { ...merged[key], ...patch } : local;
3285
3472
  }
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
- }
3473
+ for (const [key, entry] of Object.entries(lock.entries)) {
3474
+ if (!merged[key]) merged[key] = entry;
3294
3475
  }
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}`);
3476
+ const deletes = dirtyDeletes.get(lock);
3477
+ const deleteSnapshot = deletes ? new Set(deletes) : /* @__PURE__ */ new Set();
3478
+ for (const key of deleteSnapshot) delete merged[key];
3479
+ const sorted = {};
3480
+ for (const key of Object.keys(merged).sort()) {
3481
+ sorted[key] = merged[key];
3482
+ }
3483
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
3484
+ try {
3485
+ await writeFile(tmp, JSON.stringify({ version: 2, entries: sorted }, null, 2) + "\n");
3486
+ await rename(tmp, file);
3487
+ } finally {
3488
+ await rm(tmp, { force: true });
3489
+ }
3490
+ const currentDirty = dirtyPatches.get(lock);
3491
+ if (currentDirty) {
3492
+ for (const [key, patch] of snapshot) {
3493
+ const current = currentDirty.get(key);
3494
+ if (!current) continue;
3495
+ for (const [field, value] of Object.entries(patch)) {
3496
+ if (Object.is(current[field], value)) {
3497
+ delete current[field];
3498
+ }
3306
3499
  }
3307
- hit = { base64: buf.toString("base64"), hash: sha256(buf), ...metadata };
3308
- styleImageCache.set(abs, hit);
3500
+ if (Object.keys(current).length === 0) currentDirty.delete(key);
3309
3501
  }
3310
- return hit;
3311
3502
  }
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 = [];
3503
+ const stillDirty = dirtyDeletes.get(lock);
3504
+ if (stillDirty) {
3505
+ for (const key of deleteSnapshot) stillDirty.delete(key);
3506
+ if (stillDirty.size === 0) dirtyDeletes.delete(lock);
3507
+ }
3508
+ const localAfterWrite = lock.entries;
3509
+ lock.entries = { ...merged };
3510
+ for (const [key, patch] of currentDirty ?? []) {
3511
+ const base = lock.entries[key] ?? localAfterWrite[key];
3512
+ if (base) lock.entries[key] = { ...base, ...patch };
3513
+ }
3514
+ }
3515
+ function upsert(lock, key, patch) {
3516
+ dirtyDeletes.get(lock)?.delete(key);
3517
+ const existing = lock.entries[key];
3518
+ const next = { ...existing, ...patch };
3519
+ lock.entries[key] = next;
3520
+ let dirty = dirtyPatches.get(lock);
3521
+ if (!dirty) {
3522
+ dirty = /* @__PURE__ */ new Map();
3523
+ dirtyPatches.set(lock, dirty);
3524
+ }
3525
+ dirty.set(key, { ...dirty.get(key) ?? {}, ...patch });
3526
+ return next;
3527
+ }
3528
+ function remove(lock, key) {
3529
+ if (!lock.entries[key]) return false;
3530
+ delete lock.entries[key];
3531
+ const patches = dirtyPatches.get(lock);
3532
+ if (patches) {
3533
+ patches.delete(key);
3534
+ if (patches.size === 0) dirtyPatches.delete(lock);
3535
+ }
3536
+ let deletes = dirtyDeletes.get(lock);
3537
+ if (!deletes) {
3538
+ deletes = /* @__PURE__ */ new Set();
3539
+ dirtyDeletes.set(lock, deletes);
3540
+ }
3541
+ deletes.add(key);
3542
+ return true;
3543
+ }
3544
+ var FILE_LOCK_TIMEOUT_MS = 1e4;
3545
+ var STALE_FILE_LOCK_MS = 2 * 60 * 1e3;
3546
+ async function acquireFileLock(file) {
3547
+ const lockDir = `${file}.lock`;
3548
+ const deadline = Date.now() + FILE_LOCK_TIMEOUT_MS;
3549
+ for (; ; ) {
3550
+ try {
3551
+ await mkdir(lockDir);
3552
+ return async () => {
3553
+ await rmdir(lockDir).catch(() => {
3554
+ });
3555
+ };
3556
+ } catch (err) {
3557
+ const code = err.code;
3558
+ if (code !== "EEXIST") throw err;
3559
+ try {
3560
+ const age = Date.now() - (await stat(lockDir)).mtimeMs;
3561
+ if (age > STALE_FILE_LOCK_MS) {
3562
+ await rmdir(lockDir);
3563
+ continue;
3564
+ }
3565
+ } catch (lockErr) {
3566
+ if (lockErr.code === "ENOENT") continue;
3567
+ }
3568
+ if (Date.now() >= deadline) {
3569
+ throw new Error(`Timed out waiting for lockfile writer ${lockDir}`);
3570
+ }
3571
+ await new Promise((resolve) => setTimeout(resolve, 25 + Math.floor(Math.random() * 25)));
3572
+ }
3573
+ }
3574
+ }
3575
+ function spendByUnit(lock) {
3576
+ const totals = { generations: 0, usd: 0, free: 0 };
3577
+ for (const entry of Object.values(lock.entries)) {
3578
+ const unit = entry.costUnit ?? "generations";
3579
+ totals[unit] = (totals[unit] ?? 0) + (entry.cost ?? 0);
3580
+ }
3581
+ return totals;
3582
+ }
3583
+
3584
+ // src/outputs.ts
3585
+ function portableOutputPath(file, manifestDir) {
3586
+ const root = path5.resolve(manifestDir);
3587
+ const absolute = path5.isAbsolute(file) ? path5.normalize(file) : path5.resolve(root, file);
3588
+ const relative = path5.relative(root, absolute);
3589
+ const value = path5.isAbsolute(relative) ? absolute : relative;
3590
+ return value.split(path5.sep).join("/");
3591
+ }
3592
+ function resolveOutputPath(recordedPath, manifestDir) {
3593
+ if (path5.isAbsolute(recordedPath)) return path5.normalize(recordedPath);
3594
+ if (path5.win32.isAbsolute(recordedPath)) return recordedPath;
3595
+ return path5.resolve(manifestDir, recordedPath.split(/[\\/]/).join(path5.sep));
3596
+ }
3597
+ function expectedOutputPath(spec, role, index, total, mediaType) {
3598
+ const originalExt = path5.extname(spec.outFile);
3599
+ const ext = mediaType ? mediaExtension(mediaType) : originalExt || ".png";
3600
+ const stem = originalExt ? spec.outFile.slice(0, -originalExt.length) : spec.outFile;
3601
+ if (total === 1) return `${stem}${ext}`;
3602
+ const safeRole = (role ?? fallbackOutputRole(index)).replace(/[^a-zA-Z0-9_-]+/g, "-");
3603
+ return `${stem}-${safeRole}${ext}`;
3604
+ }
3605
+ function currentOutputPath(output, spec, index, total) {
3606
+ return expectedOutputPath(spec, output.role, index, total, output.mediaType);
3607
+ }
3608
+ function currentEntryOutputPath(entry, spec, index) {
3609
+ const output = entry.outputs[index];
3610
+ const sourceIndex = output.role ? (entry.sourceUrls ?? []).findIndex((source) => source.role === output.role) : -1;
3611
+ const position = sourceIndex >= 0 ? sourceIndex : index;
3612
+ const total = Math.max(entry.outputs.length, entry.sourceUrls?.length ?? 0);
3613
+ return currentOutputPath(output, spec, position, total);
3614
+ }
3615
+ function normalizeLockOutputPaths(lock, specs) {
3616
+ const byKey = new Map(specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
3617
+ let changed = 0;
3618
+ for (const [key, entry] of Object.entries(lock.entries)) {
3619
+ const spec = byKey.get(key);
3620
+ if (!spec) continue;
3621
+ let entryChanged = false;
3622
+ const outputs = entry.outputs.map((output, index) => {
3623
+ const absolute = currentEntryOutputPath(entry, spec, index);
3624
+ const portable = portableOutputPath(absolute, spec.root);
3625
+ if (portable === output.path) return output;
3626
+ changed++;
3627
+ entryChanged = true;
3628
+ return { ...output, path: portable };
3629
+ });
3630
+ if (entryChanged) upsert(lock, key, { outputs });
3631
+ }
3632
+ return changed;
3633
+ }
3634
+ function fallbackOutputRole(index) {
3635
+ return `output-${String(index).padStart(2, "0")}`;
3636
+ }
3637
+ function outputId(assetId, output, index, total) {
3638
+ if (total === 1) return assetId;
3639
+ return `${assetId}/${output.role ?? fallbackOutputRole(index)}`;
3640
+ }
3641
+ function resolveEntryOutputs(entry, assetId, manifestDir) {
3642
+ return entry.outputs.map((output, index) => ({
3643
+ ...output,
3644
+ assetId,
3645
+ id: outputId(assetId, output, index, entry.outputs.length),
3646
+ index,
3647
+ absolutePath: resolveOutputPath(output.path, manifestDir)
3648
+ }));
3649
+ }
3650
+ function resolveSpecEntryOutputs(entry, spec) {
3651
+ return entry.outputs.map((output, index) => ({
3652
+ ...output,
3653
+ assetId: spec.assetId,
3654
+ id: outputId(spec.assetId, output, index, entry.outputs.length),
3655
+ index,
3656
+ absolutePath: currentEntryOutputPath(entry, spec, index)
3657
+ }));
3658
+ }
3659
+ function selectEntryOutput(entry, role) {
3660
+ if (role) {
3661
+ const output = entry.outputs.find((candidate) => candidate.role === role);
3662
+ return output ? { ok: true, output } : { ok: false, reason: `output role "${role}" is not recorded` };
3663
+ }
3664
+ if (entry.outputs.length === 0) return { ok: false, reason: "no output recorded in the lockfile" };
3665
+ if (entry.outputs.length === 1) return { ok: true, output: entry.outputs[0] };
3666
+ const primary = entry.outputs.filter((output) => !output.role);
3667
+ if (primary.length === 1) return { ok: true, output: primary[0] };
3668
+ return {
3669
+ ok: false,
3670
+ reason: `${entry.outputs.length} outputs are recorded; set \`outputRole\` to choose one`
3671
+ };
3672
+ }
3673
+ function resolveSpecOutputs(spec, lock, manifestDir) {
3674
+ const entry = lock?.entries[lockKey(spec.styleId, spec.assetId)];
3675
+ if (entry?.outputs.length) return resolveSpecEntryOutputs(entry, spec);
3676
+ return [{
3677
+ assetId: spec.assetId,
3678
+ id: spec.assetId,
3679
+ index: 0,
3680
+ path: spec.outFile,
3681
+ absolutePath: path5.resolve(spec.outFile),
3682
+ sha256: ""
3683
+ }];
3684
+ }
3685
+
3686
+ // src/manifest.ts
3687
+ async function loadManifest(manifestPath) {
3688
+ const abs = path6.resolve(manifestPath);
3689
+ if (!existsSync4(abs)) throw new Error(`No manifest at ${abs}`);
3690
+ const input = ManifestInputSchema.safeParse(JSON.parse(await readFile3(abs, "utf8")));
3691
+ if (!input.success) {
3692
+ const issues = formatManifestIssues(input.error.issues);
3693
+ throw new Error(`Manifest at ${abs} is invalid:
3694
+ ${issues}`);
3695
+ }
3696
+ let styles;
3697
+ try {
3698
+ styles = resolveStyleInheritance(input.data.styles);
3699
+ } catch (error) {
3700
+ throw new Error(
3701
+ `Manifest at ${abs} is invalid:
3702
+ ${error instanceof Error ? error.message : String(error)}`
3703
+ );
3704
+ }
3705
+ const parsed = ManifestSchema.safeParse({ ...input.data, styles });
3706
+ if (!parsed.success) {
3707
+ const issues = formatManifestIssues(parsed.error.issues);
3708
+ throw new Error(`Manifest at ${abs} is invalid:
3709
+ ${issues}`);
3710
+ }
3711
+ const styleIds = new Set(Object.keys(parsed.data.styles));
3712
+ const unknownReferences = [];
3713
+ for (const [assetId, asset] of Object.entries(parsed.data.assets)) {
3714
+ for (const styleId of asset.styles) {
3715
+ if (!styleIds.has(styleId)) unknownReferences.push(`assets.${assetId}.styles: unknown style "${styleId}"`);
3716
+ }
3717
+ for (const styleId of Object.keys(asset.promptByStyle)) {
3718
+ if (!styleIds.has(styleId)) {
3719
+ unknownReferences.push(`assets.${assetId}.promptByStyle: unknown style "${styleId}"`);
3720
+ }
3721
+ }
3722
+ if (asset.revision) {
3723
+ if (!parsed.data.assets[asset.revision.from]) {
3724
+ unknownReferences.push(
3725
+ `assets.${assetId}.revision.from: unknown asset "${asset.revision.from}"`
3726
+ );
3727
+ } else if (asset.revision.from === assetId) {
3728
+ unknownReferences.push(`assets.${assetId}.revision.from: an asset cannot revise itself`);
3729
+ }
3730
+ }
3731
+ }
3732
+ const visiting = /* @__PURE__ */ new Set();
3733
+ const visited = /* @__PURE__ */ new Set();
3734
+ const visitRevision = (assetId, chain) => {
3735
+ if (visited.has(assetId)) return;
3736
+ if (visiting.has(assetId)) {
3737
+ const start = chain.indexOf(assetId);
3738
+ unknownReferences.push(
3739
+ `assets.${assetId}.revision.from: revision cycle ${[...chain.slice(start), assetId].join(" -> ")}`
3740
+ );
3741
+ return;
3742
+ }
3743
+ visiting.add(assetId);
3744
+ const parent = parsed.data.assets[assetId]?.revision?.from;
3745
+ if (parent && parent !== assetId && parsed.data.assets[parent]) {
3746
+ visitRevision(parent, [...chain, assetId]);
3747
+ }
3748
+ visiting.delete(assetId);
3749
+ visited.add(assetId);
3750
+ };
3751
+ for (const assetId of Object.keys(parsed.data.assets)) visitRevision(assetId, []);
3752
+ if (unknownReferences.length) {
3753
+ throw new Error(`Manifest at ${abs} is invalid:
3754
+ ${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
3755
+ }
3756
+ return { manifest: parsed.data, root: path6.dirname(abs), path: abs };
3757
+ }
3758
+ function formatManifestIssues(issues) {
3759
+ return issues.flatMap((issue) => {
3760
+ if (issue.code === "invalid_union") {
3761
+ return issue.unionErrors.flatMap((error) => formatManifestIssues(error.issues).split("\n"));
3762
+ }
3763
+ return [` ${issue.path.join(".")}: ${issue.message}`];
3764
+ }).join("\n");
3765
+ }
3766
+ function resolveStyleInheritance(styles) {
3767
+ const resolved = /* @__PURE__ */ new Map();
3768
+ const visit = (styleId, chain) => {
3769
+ const cached = resolved.get(styleId);
3770
+ if (cached) return cached;
3771
+ const cycleStart = chain.indexOf(styleId);
3772
+ if (cycleStart >= 0) {
3773
+ throw new Error(
3774
+ `styles.${styleId}.extends: inheritance cycle ${[...chain.slice(cycleStart), styleId].join(" -> ")}`
3775
+ );
3776
+ }
3777
+ const style = styles[styleId];
3778
+ if (!style) throw new Error(`styles.${styleId}: unknown style`);
3779
+ if (!("extends" in style)) {
3780
+ resolved.set(styleId, style);
3781
+ return style;
3782
+ }
3783
+ const parentId = style.extends;
3784
+ if (!Object.hasOwn(styles, parentId)) {
3785
+ throw new Error(`styles.${styleId}.extends: unknown style "${parentId}"`);
3786
+ }
3787
+ const parent = visit(parentId, [...chain, styleId]);
3788
+ const { extends: _parent, ...child } = style;
3789
+ const merged = {
3790
+ ...parent,
3791
+ ...child,
3792
+ quality: child.quality == null ? parent.quality : { ...parent.quality ?? {}, ...child.quality },
3793
+ providerOptions: mergeProviderOptions(parent.providerOptions, child.providerOptions)
3794
+ };
3795
+ const parsed = StyleSchema.safeParse(merged);
3796
+ if (!parsed.success) {
3797
+ const issues = parsed.error.issues.map((issue) => `styles.${styleId}.${issue.path.join(".")}: ${issue.message}`).join("; ");
3798
+ throw new Error(issues);
3799
+ }
3800
+ resolved.set(styleId, parsed.data);
3801
+ return parsed.data;
3802
+ };
3803
+ return Object.fromEntries(Object.keys(styles).map((styleId) => [styleId, visit(styleId, [])]));
3804
+ }
3805
+ function mergeProviderOptions(parent, child) {
3806
+ if (!child) return parent;
3807
+ const merged = { ...parent };
3808
+ for (const [provider, options] of Object.entries(child)) {
3809
+ merged[provider] = { ...parent[provider] ?? {}, ...options };
3810
+ }
3811
+ return merged;
3812
+ }
3813
+ async function resolveSpecs(loaded, filter) {
3814
+ const { manifest, root } = loaded;
3815
+ const providerOverride = filter?.provider;
3816
+ const providers = /* @__PURE__ */ new Map();
3817
+ const providerFor = (id) => {
3818
+ if (providerOverride) return providerOverride;
3819
+ let provider = providers.get(id);
3820
+ if (!provider) {
3821
+ provider = createProvider(id, "offline");
3822
+ providers.set(id, provider);
3823
+ }
3824
+ return provider;
3825
+ };
3826
+ const specs = [];
3827
+ const styleIds = Object.keys(manifest.styles).filter(
3828
+ (id) => !filter?.styles?.length || filter.styles.includes(id)
3829
+ );
3830
+ for (const unknownStyle of filter?.styles ?? []) {
3831
+ if (!manifest.styles[unknownStyle]) {
3832
+ throw new Error(
3833
+ `Unknown style "${unknownStyle}". Defined: ${Object.keys(manifest.styles).join(", ") || "(none)"}`
3834
+ );
3835
+ }
3836
+ }
3837
+ for (const unknownAsset of filter?.assets ?? []) {
3838
+ if (!manifest.assets[unknownAsset]) {
3839
+ throw new Error(`Unknown asset "${unknownAsset}".`);
3840
+ }
3841
+ }
3842
+ const requestedAssetIds = new Set(filter?.assets?.length ? filter.assets : Object.keys(manifest.assets));
3843
+ const resolutionAssetIds = new Set(requestedAssetIds);
3844
+ for (const assetId of [...requestedAssetIds]) {
3845
+ let current = manifest.assets[assetId]?.revision?.from;
3846
+ while (current && !resolutionAssetIds.has(current)) {
3847
+ resolutionAssetIds.add(current);
3848
+ current = manifest.assets[current]?.revision?.from;
3849
+ }
3850
+ }
3851
+ const styleImageCache = /* @__PURE__ */ new Map();
3852
+ async function loadStyleImage(rel) {
3853
+ const abs = path6.resolve(root, rel);
3854
+ let hit = styleImageCache.get(abs);
3855
+ if (!hit) {
3856
+ if (!existsSync4(abs)) throw new Error(`Style image not found: ${abs}`);
3857
+ const buf = await readFile3(abs);
3858
+ const metadata = imageMetadata(buf);
3859
+ if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${abs}`);
3860
+ if (metadata.width < 1 || metadata.height < 1) {
3861
+ throw new Error(`Style image has invalid dimensions: ${abs}`);
3862
+ }
3863
+ hit = { base64: buf.toString("base64"), hash: sha256(buf), ...metadata };
3864
+ styleImageCache.set(abs, hit);
3865
+ }
3866
+ return hit;
3867
+ }
3868
+ for (const styleId of styleIds) {
3869
+ const style = manifest.styles[styleId];
3870
+ const activeProvider = providerFor(style.provider ?? manifest.provider);
3871
+ const rawProviderOptions = style.providerOptions[activeProvider.id] ?? {};
3872
+ const optionResolution = activeProvider.resolveOptions ? await activeProvider.resolveOptions(rawProviderOptions, { root, styleId }) : { options: rawProviderOptions };
3873
+ const providerOptions = optionResolution.options;
3874
+ const providerOptionIdentity = optionResolution.identity ?? providerOptions;
3875
+ const styleImageHashes = [];
3876
+ const styleImageDimensions = [];
3321
3877
  for (const img of style.styleImages) {
3322
3878
  const loadedImage = await loadStyleImage(img.path);
3323
3879
  styleImageHashes.push(loadedImage.hash);
3324
3880
  styleImageDimensions.push(loadedImage);
3325
3881
  }
3326
3882
  const resolvedImages = style.styleImages.map((image) => {
3327
- const hit = styleImageCache.get(path4.resolve(root, image.path));
3883
+ const hit = styleImageCache.get(path6.resolve(root, image.path));
3328
3884
  return { base64: hit.base64, width: hit.width, height: hit.height, format: hit.format };
3329
3885
  });
3330
3886
  const styleSpecs = /* @__PURE__ */ new Map();
@@ -3345,6 +3901,7 @@ async function resolveSpecs(loaded, filter) {
3345
3901
  root,
3346
3902
  styleId,
3347
3903
  assetId,
3904
+ generator,
3348
3905
  providerOptions
3349
3906
  }) : { inputs: {} };
3350
3907
  const providerInputs = inputResolution.inputs;
@@ -3368,9 +3925,9 @@ async function resolveSpecs(loaded, filter) {
3368
3925
  }
3369
3926
  const subject = asset.promptByStyle[styleId] ?? asset.prompt;
3370
3927
  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;
3928
+ const relFile = asset.file ?? path6.join(asset.category ?? "", `${assetId}.png`);
3929
+ const outFile = path6.resolve(root, style.outDir, relFile);
3930
+ const qualityOutFile = style.quality ? path6.resolve(root, style.quality.outDir, pngPath(relFile)) : void 0;
3374
3931
  const tileSize = generator === "tiles" ? size : style.tileSize ?? 32;
3375
3932
  const tileVariations = tileFeatureOutputCount(generator === "tiles" ? style.tileFeature : void 0) ?? tileVariationCount(countNumberedDescriptions(prompt));
3376
3933
  const base = {
@@ -3421,7 +3978,8 @@ async function resolveSpecs(loaded, filter) {
3421
3978
  minGridConfidence: style.quality.minGridConfidence,
3422
3979
  ...style.quality.minTransparency == null ? {} : { minTransparency: style.quality.minTransparency },
3423
3980
  ...style.quality.fixerRevision ? { fixerRevision: style.quality.fixerRevision } : {},
3424
- ...style.quality.fixerPython ? { fixerPython: path4.resolve(root, style.quality.fixerPython) } : {}
3981
+ ...style.quality.fixerPython ? { fixerPython: path6.resolve(root, style.quality.fixerPython) } : {},
3982
+ ...style.quality.fps == null ? {} : { fps: style.quality.fps }
3425
3983
  }
3426
3984
  } : {},
3427
3985
  tags,
@@ -3449,9 +4007,9 @@ async function resolveSpecs(loaded, filter) {
3449
4007
  );
3450
4008
  }
3451
4009
  const sourceSpec = await finalize(asset.revision.from);
3452
- const sourceFile = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path4.resolve(root, sourceSpec.source) : sourceSpec.outFile);
4010
+ const sourceFile = sourceSpec.quality?.outFile ?? (sourceSpec.source ? path6.resolve(root, sourceSpec.source) : sourceSpec.outFile);
3453
4011
  const sourceImage = await optionalRevisionImage(sourceFile, "revision source");
3454
- const maskFile = asset.revision.mask ? path4.resolve(root, asset.revision.mask) : void 0;
4012
+ const maskFile = asset.revision.mask ? path6.resolve(root, asset.revision.mask) : void 0;
3455
4013
  const maskImage = maskFile ? await optionalRevisionImage(maskFile, "revision mask", "png") : null;
3456
4014
  if (sourceImage && maskImage && (sourceImage.width !== maskImage.width || sourceImage.height !== maskImage.height)) {
3457
4015
  throw new Error(
@@ -3507,349 +4065,92 @@ async function resolveSpecs(loaded, filter) {
3507
4065
  for (const spec of specs) {
3508
4066
  const key = `${spec.styleId}/${spec.assetId}`;
3509
4067
  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}`);
4068
+ if (spec.quality) {
4069
+ if (spec.generator === "frames") {
4070
+ const frameInputs = Object.values(spec.providerInputs ?? {}).find(Array.isArray);
4071
+ const count = frameInputs?.length ?? 0;
4072
+ for (let index = 0; index < count; index++) {
4073
+ const role = `frame-${String(index).padStart(2, "0")}`;
4074
+ claimOutput(
4075
+ expectedOutputPath(
4076
+ { ...spec, outFile: spec.quality.outFile },
4077
+ role,
4078
+ index,
4079
+ count,
4080
+ MediaType.PNG
4081
+ ),
4082
+ `${key} quality ${role}`
4083
+ );
4084
+ }
4085
+ } else {
4086
+ claimOutput(spec.quality.outFile, `${key} quality output`);
3736
4087
  }
3737
- await new Promise((resolve) => setTimeout(resolve, 25 + Math.floor(Math.random() * 25)));
3738
4088
  }
3739
4089
  }
4090
+ return specs;
3740
4091
  }
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);
4092
+ function pngPath(file) {
4093
+ const extension = path6.extname(file);
4094
+ return extension ? `${file.slice(0, -extension.length)}.png` : `${file}.png`;
3780
4095
  }
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 });
4096
+ async function optionalRevisionImage(file, label, requiredFormat) {
4097
+ if (!existsSync4(file)) return null;
4098
+ const bytes = await readFile3(file);
4099
+ const metadata = imageMetadata(bytes);
4100
+ if (!metadata) throw new Error(`${label} is not a readable PNG or JPEG: ${file}`);
4101
+ if (requiredFormat && metadata.format !== requiredFormat) {
4102
+ throw new Error(`${label} must be ${requiredFormat.toUpperCase()}: ${file}`);
3797
4103
  }
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
- }));
4104
+ return { hash: sha256(bytes), ...metadata };
3824
4105
  }
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` };
4106
+ async function resolveStyleImages(loaded, styleId) {
4107
+ const style = loaded.manifest.styles[styleId];
4108
+ if (!style) throw new Error(`Unknown style "${styleId}"`);
4109
+ const out = [];
4110
+ for (const img of style.styleImages) {
4111
+ const file = path6.resolve(loaded.root, img.path);
4112
+ const buf = await readFile3(file);
4113
+ const metadata = imageMetadata(buf);
4114
+ if (!metadata) throw new Error(`Style image is not a readable PNG or JPEG: ${file}`);
4115
+ if (metadata.width < 1 || metadata.height < 1) {
4116
+ throw new Error(`Style image has invalid dimensions: ${file}`);
4117
+ }
4118
+ out.push({ base64: buf.toString("base64"), ...metadata });
3829
4119
  }
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
- };
4120
+ return out;
3838
4121
  }
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
- }];
4122
+ function imageMetadata(buf) {
4123
+ if (buf.length >= 24 && buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834 && buf.toString("ascii", 12, 16) === "IHDR") {
4124
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), format: "png" };
4125
+ }
4126
+ if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216) return null;
4127
+ let offset = 2;
4128
+ while (offset + 3 < buf.length) {
4129
+ if (buf[offset] !== 255) return null;
4130
+ while (buf[offset] === 255) offset++;
4131
+ const marker = buf[offset++];
4132
+ if (marker == null || marker === 217 || marker === 218) break;
4133
+ if (marker === 1 || marker >= 208 && marker <= 215) continue;
4134
+ if (offset + 2 > buf.length) return null;
4135
+ const length = buf.readUInt16BE(offset);
4136
+ if (length < 2 || offset + length > buf.length) return null;
4137
+ const isStartOfFrame = marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker);
4138
+ if (isStartOfFrame) {
4139
+ if (length < 7) return null;
4140
+ return {
4141
+ width: buf.readUInt16BE(offset + 5),
4142
+ height: buf.readUInt16BE(offset + 3),
4143
+ format: "jpeg"
4144
+ };
4145
+ }
4146
+ offset += length;
4147
+ }
4148
+ return null;
3850
4149
  }
3851
4150
 
3852
4151
  // src/pipeline/pack.ts
4152
+ import { readFileSync as readFileSync3 } from "fs";
4153
+ import path7 from "path";
3853
4154
  function resolvePackInputs(raw, inputsFilePath) {
3854
4155
  if (!Array.isArray(raw) || !raw.length) {
3855
4156
  throw new Error("--inputs must be a non-empty JSON array of { id, path }");
@@ -3928,7 +4229,7 @@ function packSprites(inputs, options = {}) {
3928
4229
  function packStyle(lock, styleId, manifestDir, options = {}) {
3929
4230
  if (options.sourceOverrides) {
3930
4231
  if (options.outputRoles?.length) {
3931
- throw new Error("Quality-profile packing does not accept --output-role; profiles own one PNG per asset.");
4232
+ throw new Error("Quality-profile packing does not accept --output-role; profiles own final output selection.");
3932
4233
  }
3933
4234
  const inputs2 = Object.entries(options.sourceOverrides).map(([id, source]) => ({
3934
4235
  id,
@@ -4740,6 +5041,8 @@ function hex(c) {
4740
5041
 
4741
5042
  // src/pipeline/refine.ts
4742
5043
  var execFileAsync = promisify(execFile);
5044
+ var PIXEL_ART_FIXER_MIN_SIDE = 16;
5045
+ var PIXEL_ART_FIXER_MAX_PIXELS = 4e6;
4743
5046
  var PIXEL_ART_FIXER_URL = "https://github.com/Retro-Diffusion/pixel-art-fixer";
4744
5047
  var PIXEL_ART_FIXER_REVISION = "ef376e57e1c272633ca2dbf5f29ec3fcf6596465";
4745
5048
  function message2(error) {
@@ -4787,25 +5090,33 @@ function firstJsonObject(text) {
4787
5090
  }
4788
5091
  var PYTHON_FIXER_BRIDGE = [
4789
5092
  "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))"
5093
+ "from PIL import Image",
5094
+ "from pixelfixer.api import _load",
5095
+ "from pixelfixer.core import detect",
5096
+ "from pixelfixer.reconstruct import two_stage_pack",
5097
+ "rgba = _load(open(sys.argv[1], 'rb').read())",
5098
+ "result = detect(rgba, mode='full')",
5099
+ "Image.fromarray(two_stage_pack(rgba, result['cols'], result['rows'])).save(sys.argv[2], 'PNG')",
5100
+ "print(json.dumps({key: result[key] for key in ('cols', 'rows', 'step_x', 'step_y', 'phase_x', 'phase_y', 'consensus')}))"
4794
5101
  ].join("; ");
4795
5102
  function parseDetection(raw) {
4796
5103
  if (!raw || typeof raw !== "object") throw new Error("Pixel Art Fixer returned no detection result.");
4797
5104
  const value = raw;
4798
5105
  const stepX = Number(value.step_x);
4799
5106
  const stepY = Number(value.step_y);
5107
+ const phaseX = Number(value.phase_x);
5108
+ const phaseY = Number(value.phase_y);
4800
5109
  const columns = Number(value.cols);
4801
5110
  const rows = Number(value.rows);
4802
5111
  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) {
5112
+ 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
5113
  throw new Error("Pixel Art Fixer returned an invalid grid detection result.");
4805
5114
  }
4806
5115
  return {
4807
5116
  stepX,
4808
5117
  stepY,
5118
+ phaseX,
5119
+ phaseY,
4809
5120
  columns,
4810
5121
  rows,
4811
5122
  consensus,
@@ -4916,6 +5227,23 @@ function auditRefinement(png, detection, palette, minTransparency, minGridConfid
4916
5227
  function isGridConfidence(value) {
4917
5228
  return value === "low" || value === "medium" || value === "high";
4918
5229
  }
5230
+ function isDetection(value) {
5231
+ if (!value || typeof value !== "object") return false;
5232
+ const detection = value;
5233
+ 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);
5234
+ }
5235
+ function isAudit(value) {
5236
+ if (!value || typeof value !== "object") return false;
5237
+ const audit = value;
5238
+ 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");
5239
+ }
5240
+ function validFrameSet(value) {
5241
+ if (!value || typeof value !== "object") return false;
5242
+ const frameSet = value;
5243
+ 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(
5244
+ (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)
5245
+ );
5246
+ }
4919
5247
  function parseRecordOptions(manifest, record) {
4920
5248
  if (manifest.kind !== "refine") throw new Error(`${record} is not a PixelKiln refinement record.`);
4921
5249
  const raw = manifest.options;
@@ -4925,11 +5253,26 @@ function parseRecordOptions(manifest, record) {
4925
5253
  const palette = options.palette;
4926
5254
  const audit = options.audit;
4927
5255
  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") {
5256
+ 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
5257
  throw new Error(`${record} has invalid quality metadata.`);
4930
5258
  }
4931
5259
  return options;
4932
5260
  }
5261
+ function safeFrameRole(role, index) {
5262
+ const safe = role.trim().replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
5263
+ return safe || `frame-${String(index).padStart(2, "0")}`;
5264
+ }
5265
+ function qualityFrameOutputPath(output, role, index) {
5266
+ if (!/\.png$/i.test(output)) throw new Error("Refined frame-set output must use a .png stem.");
5267
+ return output.replace(/\.png$/i, `-${safeFrameRole(role, index)}.png`);
5268
+ }
5269
+ function sameFrameGrid(first, next) {
5270
+ const phaseMatches = (a, b, step) => {
5271
+ const distance = Math.abs(a - b) % step;
5272
+ return Math.min(distance, step - distance) <= 0.01;
5273
+ };
5274
+ 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;
5275
+ }
4933
5276
  async function refineAsset(options) {
4934
5277
  const source = path10.resolve(options.source);
4935
5278
  const output = path10.resolve(options.output);
@@ -4944,9 +5287,21 @@ async function refineAsset(options) {
4944
5287
  } catch (error) {
4945
5288
  throw new Error(`Cannot read refinement source ${source}: ${message2(error)}`, { cause: error });
4946
5289
  }
5290
+ const compatibilityCommand = options.fixerCommand;
5291
+ if (!compatibilityCommand) {
5292
+ if (sourcePng.width < PIXEL_ART_FIXER_MIN_SIDE || sourcePng.height < PIXEL_ART_FIXER_MIN_SIDE) {
5293
+ throw new Error(
5294
+ `Pixel Art Fixer requires each source side to be at least ${PIXEL_ART_FIXER_MIN_SIDE}px; got ${sourcePng.width}x${sourcePng.height}.`
5295
+ );
5296
+ }
5297
+ if (sourcePng.width * sourcePng.height > PIXEL_ART_FIXER_MAX_PIXELS) {
5298
+ throw new Error(
5299
+ `Pixel Art Fixer accepts at most ${PIXEL_ART_FIXER_MAX_PIXELS.toLocaleString("en-US")} source pixels; got ${sourcePng.width}x${sourcePng.height}.`
5300
+ );
5301
+ }
5302
+ }
4947
5303
  const temp = await mkdtemp(path10.join(tmpdir(), "pixelkiln-refine-"));
4948
5304
  const recovered = path10.join(temp, "native.png");
4949
- const compatibilityCommand = options.fixerCommand;
4950
5305
  const fixerCommand = compatibilityCommand ?? options.fixerPython ?? process.env.PIXELKILN_PIXEL_FIXER_PYTHON ?? "python3";
4951
5306
  const args = compatibilityCommand ? [...options.fixerArgsPrefix ?? [], source, "--extract", recovered] : ["-c", PYTHON_FIXER_BRIDGE, source, recovered];
4952
5307
  try {
@@ -4993,11 +5348,13 @@ async function refineAsset(options) {
4993
5348
  tool: "pixel-art-fixer",
4994
5349
  url: PIXEL_ART_FIXER_URL,
4995
5350
  revision: options.fixerRevision ?? PIXEL_ART_FIXER_REVISION,
4996
- protocol: compatibilityCommand ? "python-cli-v1" : "python-api-v1",
5351
+ protocol: compatibilityCommand ? "python-cli-v1" : "python-core-v1",
4997
5352
  confidence: detection.confidence,
4998
5353
  consensus: detection.consensus,
4999
5354
  stepX: detection.stepX,
5000
5355
  stepY: detection.stepY,
5356
+ phaseX: detection.phaseX,
5357
+ phaseY: detection.phaseY,
5001
5358
  sourceWidth: sourcePng.width,
5002
5359
  sourceHeight: sourcePng.height,
5003
5360
  nativeWidth: quantized.width,
@@ -5018,19 +5375,142 @@ async function refineAsset(options) {
5018
5375
  await rm3(temp, { recursive: true, force: true });
5019
5376
  }
5020
5377
  }
5378
+ async function refineFrameSet(options) {
5379
+ if (!Number.isInteger(options.fps) || options.fps < 1 || options.fps > 60) {
5380
+ throw new Error("Frame-set fps must be an integer between 1 and 60.");
5381
+ }
5382
+ if (options.sources.length < 2 || options.sources.length > 64) {
5383
+ throw new Error(`A frame set must contain 2\u201364 ordered sources; got ${options.sources.length}.`);
5384
+ }
5385
+ const roles = options.sources.map((source) => source.role);
5386
+ if (roles.some((role) => !role.trim()) || new Set(roles).size !== roles.length) {
5387
+ throw new Error("Frame-set roles must be non-empty and unique.");
5388
+ }
5389
+ const output = path10.resolve(options.output);
5390
+ const record = path10.resolve(qualityRecordPath(output));
5391
+ const palette = normalizeRefinementPalette(options.palette);
5392
+ const finalOutputs = options.sources.map((source, index) => qualityFrameOutputPath(output, source.role, index));
5393
+ if (new Set(finalOutputs).size !== finalOutputs.length) {
5394
+ throw new Error("Frame-set roles resolve to duplicate output filenames.");
5395
+ }
5396
+ const sourceSet = new Set(options.sources.map((source) => path10.resolve(source.path)));
5397
+ if (finalOutputs.some((file) => sourceSet.has(file))) {
5398
+ throw new Error("Frame-set refinement sources and outputs must be different files.");
5399
+ }
5400
+ const temp = await mkdtemp(path10.join(tmpdir(), "pixelkiln-refine-frames-"));
5401
+ try {
5402
+ const frames = [];
5403
+ const outputs = [];
5404
+ const provenanceSources = [];
5405
+ const sourcePaths = [];
5406
+ let firstOptions = null;
5407
+ for (const [index, sourceEntry] of options.sources.entries()) {
5408
+ const source = path10.resolve(sourceEntry.path);
5409
+ const temporaryOutput = path10.join(temp, `${String(index).padStart(2, "0")}.png`);
5410
+ const refined = await refineAsset({
5411
+ source,
5412
+ output: temporaryOutput,
5413
+ palette,
5414
+ fixerPython: options.fixerPython,
5415
+ fixerCommand: options.fixerCommand,
5416
+ fixerArgsPrefix: options.fixerArgsPrefix,
5417
+ fixerRevision: options.fixerRevision,
5418
+ minGridConfidence: options.minGridConfidence,
5419
+ minTransparency: options.minTransparency,
5420
+ force: true
5421
+ });
5422
+ const temporaryRecord = await readArtifactBundleManifest(refined.record);
5423
+ const frameOptions = parseRecordOptions(temporaryRecord, refined.record);
5424
+ firstOptions ??= frameOptions;
5425
+ frames.push({
5426
+ role: sourceEntry.role,
5427
+ sourceWidth: frameOptions.nativeGrid.sourceWidth,
5428
+ sourceHeight: frameOptions.nativeGrid.sourceHeight,
5429
+ detection: refined.detection,
5430
+ audit: refined.audit
5431
+ });
5432
+ sourcePaths.push(source);
5433
+ provenanceSources.push({
5434
+ id: sourceEntry.role,
5435
+ path: source,
5436
+ sha256: await sha256File(source),
5437
+ included: true
5438
+ });
5439
+ outputs.push({
5440
+ path: finalOutputs[index],
5441
+ data: await readFile6(temporaryOutput)
5442
+ });
5443
+ }
5444
+ const first = frames[0];
5445
+ const mismatch = frames.find((frame) => !sameFrameGrid(first, frame));
5446
+ if (mismatch) {
5447
+ throw new Error(
5448
+ `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}.`
5449
+ );
5450
+ }
5451
+ const reasons = frames.flatMap(
5452
+ (frame) => frame.audit.reasons.map((reason) => `${frame.role}: ${reason}`)
5453
+ );
5454
+ const audit = {
5455
+ width: first.audit.width,
5456
+ height: first.audit.height,
5457
+ colorCount: Math.max(...frames.map((frame) => frame.audit.colorCount)),
5458
+ paletteConformant: frames.every((frame) => frame.audit.paletteConformant),
5459
+ transparency: Math.min(...frames.map((frame) => frame.audit.transparency)),
5460
+ thresholds: first.audit.thresholds,
5461
+ safe: frames.every((frame) => frame.audit.safe) && reasons.length === 0,
5462
+ reasons
5463
+ };
5464
+ const baseOptions = firstOptions;
5465
+ const recordOptions = {
5466
+ ...baseOptions,
5467
+ audit,
5468
+ review: { status: "pending" },
5469
+ frameSet: {
5470
+ fps: options.fps,
5471
+ count: frames.length,
5472
+ roles,
5473
+ frames
5474
+ }
5475
+ };
5476
+ await writeManagedArtifactBundle(
5477
+ record,
5478
+ outputs,
5479
+ { kind: "refine", sources: provenanceSources, options: recordOptions },
5480
+ { force: options.force }
5481
+ );
5482
+ return {
5483
+ sources: sourcePaths,
5484
+ outputs: outputs.map((item) => path10.resolve(item.path)),
5485
+ record,
5486
+ frames,
5487
+ palette,
5488
+ audit
5489
+ };
5490
+ } finally {
5491
+ await rm3(temp, { recursive: true, force: true });
5492
+ }
5493
+ }
5021
5494
  async function checkQualityRecord(recordPath) {
5022
5495
  const record = path10.resolve(recordPath);
5023
5496
  const manifest = await readArtifactBundleManifest(record);
5024
5497
  const options = parseRecordOptions(manifest, record);
5025
5498
  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) : "";
5499
+ const sources = manifest.sources.map((source2) => path10.resolve(path10.dirname(record), source2.path));
5500
+ const outputs = manifest.outputs.map((output2) => path10.resolve(path10.dirname(record), output2.path));
5501
+ const source = sources[0] ?? "";
5502
+ const output = outputs[0] ?? "";
5503
+ const expectedCount = options.frameSet?.count ?? 1;
5028
5504
  const reasons = [];
5029
5505
  if (!verification.fingerprintValid) reasons.push("quality record fingerprint changed");
5030
5506
  for (const source2 of verification.changedSources) reasons.push(`source changed: ${source2}`);
5031
5507
  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");
5508
+ if (manifest.sources.length !== expectedCount) {
5509
+ reasons.push(`quality record must contain exactly ${expectedCount} source(s)`);
5510
+ }
5511
+ if (manifest.outputs.length !== expectedCount) {
5512
+ reasons.push(`quality record must contain exactly ${expectedCount} output(s)`);
5513
+ }
5034
5514
  if (!options.audit.safe) reasons.push(...options.audit.reasons.map((reason) => `audit: ${reason}`));
5035
5515
  if (options.review.status !== "approved") reasons.push("human 1\xD7 review is pending");
5036
5516
  return {
@@ -5041,6 +5521,8 @@ async function checkQualityRecord(recordPath) {
5041
5521
  record,
5042
5522
  source,
5043
5523
  output,
5524
+ sources,
5525
+ outputs,
5044
5526
  options,
5045
5527
  reasons
5046
5528
  };
@@ -5055,7 +5537,7 @@ async function approveQualityRecord(recordPath, approval) {
5055
5537
  if (!verification.current) {
5056
5538
  const changes = [
5057
5539
  ...verification.changedSources.map((source) => `source ${source}`),
5058
- ...verification.changedOutputs.map((output2) => `output ${output2}`),
5540
+ ...verification.changedOutputs.map((output) => `output ${output}`),
5059
5541
  ...!verification.fingerprintValid ? ["quality metadata"] : []
5060
5542
  ];
5061
5543
  throw new Error(`Cannot approve a stale refinement; changed: ${changes.join(", ")}. Run refine again.`);
@@ -5063,12 +5545,17 @@ async function approveQualityRecord(recordPath, approval) {
5063
5545
  if (!options.audit.safe) {
5064
5546
  throw new Error(`Cannot approve a failed quality audit: ${options.audit.reasons.join("; ")}`);
5065
5547
  }
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.");
5548
+ const expectedCount = options.frameSet?.count ?? 1;
5549
+ if (manifest.sources.length !== expectedCount || manifest.outputs.length !== expectedCount) {
5550
+ throw new Error(
5551
+ `A refinement record must contain exactly ${expectedCount} source(s) and output(s).`
5552
+ );
5068
5553
  }
5069
5554
  const root = path10.dirname(record);
5070
- const outputPath = path10.resolve(root, manifest.outputs[0].path);
5071
- const output = await readFile6(outputPath);
5555
+ const outputs = await Promise.all(manifest.outputs.map(async (entry) => {
5556
+ const outputPath = path10.resolve(root, entry.path);
5557
+ return { path: outputPath, data: await readFile6(outputPath) };
5558
+ }));
5072
5559
  const sources = manifest.sources.map((source) => ({
5073
5560
  ...source,
5074
5561
  path: path10.resolve(root, source.path)
@@ -5091,7 +5578,7 @@ async function approveQualityRecord(recordPath, approval) {
5091
5578
  };
5092
5579
  await writeManagedArtifactBundle(
5093
5580
  record,
5094
- [{ path: outputPath, data: output }],
5581
+ outputs,
5095
5582
  { kind: "refine", sources, options: approved }
5096
5583
  );
5097
5584
  return checkQualityRecord(record);
@@ -5100,33 +5587,46 @@ async function approveQualityRecord(recordPath, approval) {
5100
5587
  // src/pipeline/quality-profile.ts
5101
5588
  async function qualitySource(spec, lock) {
5102
5589
  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}` };
5590
+ if (spec.generator === "frames") {
5591
+ return { items: null, reason: "frame-set quality requires ordered downloaded provider outputs" };
5592
+ }
5593
+ const source = path11.resolve(spec.root, spec.source);
5594
+ return existsSync6(source) ? { items: [{ role: "source", path: source }] } : { items: null, reason: `declared source is missing: ${spec.source}` };
5105
5595
  }
5106
5596
  const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
5107
- if (!entry) return { path: null, reason: "raw provider output is not in the lockfile" };
5597
+ if (!entry) return { items: null, reason: "raw provider output is not in the lockfile" };
5108
5598
  if (entry.specHash !== spec.specHash) {
5109
- return { path: null, reason: "raw provider output does not match the current generation spec" };
5599
+ return { items: null, reason: "raw provider output does not match the current generation spec" };
5110
5600
  }
5111
5601
  if (entry.status !== "downloaded") {
5112
- return { path: null, reason: `raw provider output is ${entry.status}, not downloaded` };
5602
+ return { items: null, reason: `raw provider output is ${entry.status}, not downloaded` };
5113
5603
  }
5114
- if (entry.outputs.length !== 1) {
5604
+ const frameSet = spec.generator === "frames";
5605
+ if (!frameSet && entry.outputs.length !== 1 || frameSet && entry.outputs.length < 2) {
5115
5606
  return {
5116
- path: null,
5117
- reason: `quality profiles require one PNG output; lock entry has ${entry.outputs.length}`
5607
+ items: null,
5608
+ 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
5609
  };
5119
5610
  }
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" };
5611
+ const roles = /* @__PURE__ */ new Set();
5612
+ const items = [];
5613
+ for (const [index, output] of entry.outputs.entries()) {
5614
+ if (output.mediaType && output.mediaType !== MediaType.PNG) {
5615
+ return { items: null, reason: `quality profiles require PNG input; lock entry is ${output.mediaType}` };
5616
+ }
5617
+ const role = frameSet ? output.role : "source";
5618
+ if (frameSet && (!role || roles.has(role))) {
5619
+ return { items: null, reason: "frame-set quality requires a unique role for every ordered output" };
5620
+ }
5621
+ if (role) roles.add(role);
5622
+ const source = currentEntryOutputPath(entry, spec, index);
5623
+ if (!existsSync6(source)) return { items: null, reason: `raw provider output is missing: ${source}` };
5624
+ if (await sha256File(source) !== output.sha256) {
5625
+ return { items: null, reason: "raw provider output was modified after download" };
5626
+ }
5627
+ items.push({ role, path: source });
5128
5628
  }
5129
- return { path: source };
5629
+ return { items };
5130
5630
  }
5131
5631
  async function inspectQualityProfile(spec, lock) {
5132
5632
  if (!spec.quality) return null;
@@ -5134,16 +5634,20 @@ async function inspectQualityProfile(spec, lock) {
5134
5634
  const output = spec.quality.outFile;
5135
5635
  const record = qualityRecordPath(output);
5136
5636
  const source = await qualitySource(spec, lock);
5137
- if (!source.path) {
5637
+ if (!source.items) {
5138
5638
  return { key, state: "blocked", reason: source.reason, source: null, output, record };
5139
5639
  }
5640
+ const sourcePaths = source.items.map((item) => item.path);
5641
+ const expectedOutputs = spec.generator === "frames" ? source.items.map((item, index) => qualityFrameOutputPath(output, item.role, index)) : [output];
5140
5642
  if (!existsSync6(record)) {
5141
5643
  return {
5142
5644
  key,
5143
5645
  state: "needs-refinement",
5144
5646
  reason: "no refinement record",
5145
- source: source.path,
5647
+ source: sourcePaths[0],
5648
+ sources: sourcePaths,
5146
5649
  output,
5650
+ outputs: expectedOutputs,
5147
5651
  record
5148
5652
  };
5149
5653
  }
@@ -5152,14 +5656,17 @@ async function inspectQualityProfile(spec, lock) {
5152
5656
  const expectedPalette = normalizeRefinementPalette(spec.quality.palette);
5153
5657
  const expectedRevision = spec.quality.fixerRevision ?? PIXEL_ART_FIXER_REVISION;
5154
5658
  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;
5659
+ const expectedFrameSet = spec.generator === "frames" ? { fps: spec.quality.fps ?? 12, count: source.items.length, roles: source.items.map((item) => item.role) } : null;
5660
+ 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
5661
  if (contractChanged) {
5157
5662
  return {
5158
5663
  key,
5159
5664
  state: "needs-refinement",
5160
5665
  reason: "quality profile changed",
5161
- source: source.path,
5666
+ source: sourcePaths[0],
5667
+ sources: sourcePaths,
5162
5668
  output,
5669
+ outputs: expectedOutputs,
5163
5670
  record
5164
5671
  };
5165
5672
  }
@@ -5168,8 +5675,10 @@ async function inspectQualityProfile(spec, lock) {
5168
5675
  key,
5169
5676
  state: "needs-refinement",
5170
5677
  reason: check.reasons.filter((reason) => reason !== "human 1\xD7 review is pending").join("; ") || "refinement record is stale",
5171
- source: source.path,
5678
+ source: sourcePaths[0],
5679
+ sources: sourcePaths,
5172
5680
  output,
5681
+ outputs: expectedOutputs,
5173
5682
  record
5174
5683
  };
5175
5684
  }
@@ -5178,8 +5687,10 @@ async function inspectQualityProfile(spec, lock) {
5178
5687
  key,
5179
5688
  state: "needs-approval",
5180
5689
  reason: "automated checks passed; human 1\xD7 review is pending",
5181
- source: source.path,
5690
+ source: sourcePaths[0],
5691
+ sources: sourcePaths,
5182
5692
  output,
5693
+ outputs: expectedOutputs,
5183
5694
  record
5184
5695
  };
5185
5696
  }
@@ -5187,8 +5698,10 @@ async function inspectQualityProfile(spec, lock) {
5187
5698
  key,
5188
5699
  state: "approved",
5189
5700
  reason: "refined output and human approval are current",
5190
- source: source.path,
5701
+ source: sourcePaths[0],
5702
+ sources: sourcePaths,
5191
5703
  output,
5704
+ outputs: expectedOutputs,
5192
5705
  record
5193
5706
  };
5194
5707
  } catch (error) {
@@ -5196,8 +5709,10 @@ async function inspectQualityProfile(spec, lock) {
5196
5709
  key,
5197
5710
  state: "needs-refinement",
5198
5711
  reason: `invalid refinement record: ${error instanceof Error ? error.message : String(error)}`,
5199
- source: source.path,
5712
+ source: sourcePaths[0],
5713
+ sources: sourcePaths,
5200
5714
  output,
5715
+ outputs: expectedOutputs,
5201
5716
  record
5202
5717
  };
5203
5718
  }
@@ -5221,8 +5736,7 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5221
5736
  continue;
5222
5737
  }
5223
5738
  try {
5224
- await refineAsset({
5225
- source: inspection.source,
5739
+ const common = {
5226
5740
  output: inspection.output,
5227
5741
  palette: spec.quality.palette,
5228
5742
  minGridConfidence: spec.quality.minGridConfidence,
@@ -5234,7 +5748,18 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5234
5748
  fixerCommand: options.fixerCommand,
5235
5749
  fixerArgsPrefix: options.fixerArgsPrefix,
5236
5750
  force: options.force
5237
- });
5751
+ };
5752
+ if (spec.generator === "frames") {
5753
+ const frameSources = await qualitySource(spec, lock);
5754
+ if (!frameSources.items) throw new Error(frameSources.reason);
5755
+ await refineFrameSet({
5756
+ ...common,
5757
+ sources: frameSources.items,
5758
+ fps: spec.quality.fps ?? 12
5759
+ });
5760
+ } else {
5761
+ await refineAsset({ ...common, source: inspection.source });
5762
+ }
5238
5763
  result.processed++;
5239
5764
  result.items.push(await inspectQualityProfile(spec, lock));
5240
5765
  } catch (error) {
@@ -5248,7 +5773,7 @@ async function refineQualityProfiles(specs, lock, options = {}) {
5248
5773
  }
5249
5774
  return result;
5250
5775
  }
5251
- async function requireApprovedQualitySources(specs, lock, assetIds) {
5776
+ async function requireApprovedQualitySources(specs, lock, assetIds, outputRoles) {
5252
5777
  const configured = specs.filter(
5253
5778
  (spec) => spec.quality && (!assetIds || assetIds.has(spec.assetId))
5254
5779
  );
@@ -5257,8 +5782,33 @@ async function requireApprovedQualitySources(specs, lock, assetIds) {
5257
5782
  const rejected = [];
5258
5783
  for (const spec of configured) {
5259
5784
  const inspection = await inspectQualityProfile(spec, lock);
5260
- if (inspection.state === "approved") sources[spec.assetId] = inspection.output;
5261
- else rejected.push(inspection);
5785
+ if (inspection.state === "approved") {
5786
+ if (spec.generator === "frames") {
5787
+ const check = await checkQualityRecord(inspection.record);
5788
+ const roles = check.options.frameSet.roles;
5789
+ if (outputRoles) {
5790
+ const selectedRole = outputRoles[spec.assetId];
5791
+ if (!selectedRole) {
5792
+ throw new Error(
5793
+ `Mounting quality-approved frame set ${inspection.key} requires asset.outputRole.`
5794
+ );
5795
+ }
5796
+ const index = roles.indexOf(selectedRole);
5797
+ if (index < 0) {
5798
+ throw new Error(
5799
+ `Frame set ${inspection.key} has no approved output role "${selectedRole}".`
5800
+ );
5801
+ }
5802
+ sources[spec.assetId] = check.outputs[index];
5803
+ } else {
5804
+ for (const [index, output] of check.outputs.entries()) {
5805
+ sources[`${spec.assetId}/${roles[index]}`] = output;
5806
+ }
5807
+ }
5808
+ } else {
5809
+ sources[spec.assetId] = inspection.output;
5810
+ }
5811
+ } else rejected.push(inspection);
5262
5812
  }
5263
5813
  if (rejected.length) {
5264
5814
  const detail = rejected.slice(0, 5).map((item) => `${item.key}: ${item.state} (${item.reason})`).join("; ");
@@ -5611,7 +6161,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
5611
6161
  jobId,
5612
6162
  // A multi-candidate generator routes through review; record the parent
5613
6163
  // so `pick` knows where to look.
5614
- reviewObjectId: estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
6164
+ reviewObjectId: spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
5615
6165
  status: "processing",
5616
6166
  providerMetadata: metadata ? { ...lock.entries[key].providerMetadata, [provider.id]: metadata } : lock.entries[key].providerMetadata
5617
6167
  });
@@ -5659,7 +6209,7 @@ async function poll(provider, lock, lockPath, opts = {}) {
5659
6209
  tileFeature: entry.tileFeature ?? currentSpec?.tileFeature,
5660
6210
  spec: currentSpec
5661
6211
  });
5662
- if (state.status === "review") {
6212
+ if (state.status === "review" || state.status === "review-set") {
5663
6213
  upsert(lock, key, {
5664
6214
  status: "review",
5665
6215
  reviewObjectId: entry.jobId,
@@ -5672,7 +6222,9 @@ async function poll(provider, lock, lockPath, opts = {}) {
5672
6222
  } : entry.providerMetadata
5673
6223
  });
5674
6224
  result.review++;
5675
- log2(` review ${key} (${state.candidateUrls.length} candidates)`);
6225
+ log2(
6226
+ state.status === "review-set" ? ` review ${key} (${state.frameUrls.length} ordered frames)` : ` review ${key} (${state.candidateUrls.length} candidates)`
6227
+ );
5676
6228
  } else if (state.status === "ready") {
5677
6229
  const sourceUrls = state.sources?.length ? state.sources : state.sourceUrl ? [{ url: state.sourceUrl }] : [];
5678
6230
  upsert(lock, key, {
@@ -6431,12 +6983,17 @@ function renderSheet(groups) {
6431
6983
  linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%);
6432
6984
  background-size:12px 12px; background-position:0 0,6px 6px; }
6433
6985
  .frames { display:flex; flex-wrap:wrap; gap:10px; max-width:100%; overflow-x:auto; }
6986
+ .loop { display:flex; align-items:flex-start; gap:12px; margin-bottom:12px; }
6987
+ .loop img { image-rendering:pixelated; border:2px solid var(--accent); padding:7px;
6988
+ background:var(--panel-deep); max-width:min(var(--preview),calc(100vw - 62px)); height:auto; }
6989
+ .loop-copy { color:var(--dim); font-size:11px; max-width:32ch; }
6434
6990
  .cand { border:2px solid var(--line-strong); border-radius:0; padding:7px; background:var(--panel-deep);
6435
6991
  cursor:pointer; display:flex; flex:0 0 auto; flex-direction:column; align-items:center; gap:5px;
6436
6992
  position:relative; max-width:100%; }
6437
6993
  .cand:hover { border-color:var(--dim); }
6438
6994
  .cand.active { border-color:var(--dim); box-shadow:0 0 0 2px color-mix(in srgb, var(--dim) 20%, transparent); }
6439
6995
  .cand.sel { border-color:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent); }
6996
+ .cand.set-frame { cursor:pointer; }
6440
6997
  .cand img { image-rendering:pixelated; display:block;
6441
6998
  background-image:
6442
6999
  linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
@@ -6474,7 +7031,8 @@ function renderSheet(groups) {
6474
7031
  </header>
6475
7032
  <main id="root"></main>
6476
7033
  <footer>
6477
- Click a candidate to choose it; click again to unchoose. In the focused row,
7034
+ Click a candidate to choose it, or any ordered frame to accept its whole set;
7035
+ click again to undo. In the focused row,
6478
7036
  <kbd>\u2190</kbd>/<kbd>\u2192</kbd> browses every candidate and <kbd>Enter</kbd> chooses;
6479
7037
  <kbd>1</kbd>\u2013<kbd>9</kbd> picks directly, <kbd>0</kbd> skips, and
6480
7038
  <kbd>\u2191</kbd>/<kbd>\u2193</kbd> changes rows. Unchosen rows stay in review \u2014 nothing is
@@ -6522,12 +7080,34 @@ GROUPS.forEach((g, gi) => {
6522
7080
  context.append(label, source);
6523
7081
  review.insertBefore(context, frames);
6524
7082
  }
7083
+ if (g.mode === 'frame-set') {
7084
+ const loop = document.createElement('div');
7085
+ loop.className = 'loop';
7086
+ const preview = document.createElement('img');
7087
+ preview.src = g.frameUrls[0];
7088
+ preview.width = displayWidth;
7089
+ preview.height = displayHeight;
7090
+ preview.alt = 'Animated frame-set preview';
7091
+ const copy = document.createElement('div');
7092
+ copy.className = 'loop-copy';
7093
+ copy.textContent = g.frameUrls.length + ' ordered frames \xB7 ' + (g.fps || 12) +
7094
+ ' fps. Accepting keeps every frame; one bad frame means leave the set unchosen.';
7095
+ loop.append(preview, copy);
7096
+ review.insertBefore(loop, frames);
7097
+ let loopIndex = 0;
7098
+ setInterval(() => {
7099
+ loopIndex = (loopIndex + 1) % g.frameUrls.length;
7100
+ preview.src = g.frameUrls[loopIndex];
7101
+ }, Math.max(16, Math.round(1000 / (g.fps || 12))));
7102
+ }
6525
7103
  g.frameUrls.forEach((url, i) => {
6526
7104
  const c = document.createElement('button');
6527
7105
  c.type = 'button';
6528
- c.className = 'cand';
7106
+ c.className = 'cand' + (g.mode === 'frame-set' ? ' set-frame' : '');
6529
7107
  c.tabIndex = -1;
6530
- c.setAttribute('aria-label', 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length);
7108
+ c.setAttribute('aria-label', g.mode === 'frame-set'
7109
+ ? 'Accept ordered frame set from frame ' + (i + 1)
7110
+ : 'Choose candidate ' + (i + 1) + ' of ' + g.frameUrls.length);
6531
7111
 
6532
7112
  const preview = document.createElement('img');
6533
7113
  preview.className = 'preview';
@@ -6537,7 +7117,8 @@ GROUPS.forEach((g, gi) => {
6537
7117
  preview.loading = 'lazy';
6538
7118
  const index = document.createElement('span');
6539
7119
  index.className = 'idx';
6540
- index.textContent = String(i + 1) + ' \xB7 ' + g.width + '\xD7' + g.height + ' \xB7 ' + scaleLabel;
7120
+ index.textContent = (g.frameLabels?.[i] || String(i + 1)) + ' \xB7 ' +
7121
+ g.width + '\xD7' + g.height + ' \xB7 ' + scaleLabel;
6541
7122
  c.append(preview, index);
6542
7123
  if (displayScale > 1 && largestSide <= 96) {
6543
7124
  const actual = document.createElement('img');
@@ -6578,6 +7159,14 @@ function activate(i, frames) {
6578
7159
  }
6579
7160
 
6580
7161
  function choose(gi, i, node, frames, groupEl) {
7162
+ if (GROUPS[gi].mode === 'frame-set') {
7163
+ const selected = picks.has(gi);
7164
+ frames.querySelectorAll('.cand').forEach(n => n.classList.toggle('sel', !selected));
7165
+ if (selected) { picks.delete(gi); groupEl.classList.remove('done'); }
7166
+ else { picks.set(gi, 0); groupEl.classList.add('done'); }
7167
+ refresh();
7168
+ return;
7169
+ }
6581
7170
  const current = picks.get(gi);
6582
7171
  frames.querySelectorAll('.cand').forEach(n => n.classList.remove('sel'));
6583
7172
  if (current === i) { picks.delete(gi); groupEl.classList.remove('done'); }
@@ -6754,7 +7343,8 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6754
7343
  try {
6755
7344
  const spec = specByKey.get(key);
6756
7345
  const state = await provider.poll(entry.reviewObjectId, entry.generator, { spec });
6757
- if (state.status !== "review" || !state.candidateUrls.length) continue;
7346
+ if (state.status !== "review" && state.status !== "review-set" || (state.status === "review" ? !state.candidateUrls.length : !state.frameUrls.length)) continue;
7347
+ const frameUrls = state.status === "review" ? state.candidateUrls : state.frameUrls;
6758
7348
  const sourceRoute = spec?.revision ? `/revision-source/${encodeURIComponent(String(groups.length))}` : null;
6759
7349
  if (sourceRoute && spec?.revision) {
6760
7350
  reviewAssets.set(sourceRoute, {
@@ -6768,9 +7358,14 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6768
7358
  styleId: entry.styleId,
6769
7359
  prompt: entry.prompt,
6770
7360
  reviewObjectId: entry.reviewObjectId,
6771
- frameUrls: state.candidateUrls,
7361
+ frameUrls,
6772
7362
  width: entry.width,
6773
7363
  height: entry.height,
7364
+ ...state.status === "review-set" ? {
7365
+ mode: "frame-set",
7366
+ frameLabels: state.sources.map((source, index) => source.role ?? `frame-${index}`),
7367
+ fps: state.fps
7368
+ } : { mode: "candidates" },
6774
7369
  ...sourceRoute && spec?.revision && spec.revision.sourceWidth && spec.revision.sourceHeight ? {
6775
7370
  revision: {
6776
7371
  mode: spec.revision.mode,
@@ -6813,6 +7408,31 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
6813
7408
  const entry = lock.entries[key];
6814
7409
  if (!group || !entry?.reviewObjectId) continue;
6815
7410
  if (!Number.isInteger(index) || index < 0 || index >= group.frameUrls.length) continue;
7411
+ if (group.mode === "frame-set") {
7412
+ const spec = specByKey.get(key);
7413
+ const state = await provider.poll(entry.reviewObjectId, entry.generator, { spec });
7414
+ if (state.status !== "review-set") {
7415
+ throw new Error(`Frame set ${key} is no longer ready for review`);
7416
+ }
7417
+ upsert(lock, key, {
7418
+ status: "selected",
7419
+ objectId: state.objectId,
7420
+ candidateIndex: null,
7421
+ sourceUrl: state.sources[0]?.url ?? null,
7422
+ sourceUrls: state.sources,
7423
+ provider: provider.id,
7424
+ providerMetadata: state.metadata ? {
7425
+ ...entry.providerMetadata,
7426
+ [provider.id]: {
7427
+ ...entry.providerMetadata[provider.id],
7428
+ ...state.metadata
7429
+ }
7430
+ } : entry.providerMetadata
7431
+ });
7432
+ selected++;
7433
+ log2(` accepted ${key} \u2192 ${state.sources.length} ordered frames`);
7434
+ continue;
7435
+ }
6816
7436
  const { objectId, sourceUrl, metadata } = await requireSelectCandidate(provider)(
6817
7437
  entry.reviewObjectId,
6818
7438
  index,
@@ -9522,9 +10142,15 @@ async function main() {
9522
10142
  if (!args.reviewer?.trim()) {
9523
10143
  throw new Error("refine approve needs --reviewer <name>.");
9524
10144
  }
9525
- log(` Inspect the final PNG at 1\xD7 and integer zoom.`);
10145
+ const pending = await checkQualityRecord(args.from);
10146
+ log(
10147
+ 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.`
10148
+ );
9526
10149
  log(` Confirm crisp edges, readable forms, palette separation, alpha, and seams.`);
9527
- if (!await confirm(" Record this asset as human-approved?", args.yes)) {
10150
+ if (!await confirm(
10151
+ pending.options.frameSet ? " Record this complete frame set as human-approved?" : " Record this asset as human-approved?",
10152
+ args.yes
10153
+ )) {
9528
10154
  log(" approval not recorded");
9529
10155
  return;
9530
10156
  }
@@ -9533,7 +10159,11 @@ async function main() {
9533
10159
  note: args.note
9534
10160
  });
9535
10161
  if (args.json) log(JSON.stringify(result2, null, 2));
9536
- else log(` approved ${path24.relative(process.cwd(), result2.output)} by ${args.reviewer.trim()}`);
10162
+ else {
10163
+ log(
10164
+ 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()}`
10165
+ );
10166
+ }
9537
10167
  return;
9538
10168
  }
9539
10169
  const result = await checkQualityRecord(args.from);
@@ -10046,7 +10676,10 @@ async function main() {
10046
10676
  { path: `${base}.json`, data: JSON.stringify(atlas, null, 2) + "\n" }
10047
10677
  ];
10048
10678
  const qualityRecords = qualitySources ? await Promise.all(
10049
- Object.entries(qualitySources).map(([assetId, source]) => provenanceFile(`$quality/${assetId}`, qualityRecordPath(source)))
10679
+ packagingSpecs.filter((spec) => spec.quality).map((spec) => provenanceFile(
10680
+ `$quality/${spec.assetId}`,
10681
+ qualityRecordPath(spec.quality.outFile)
10682
+ ))
10050
10683
  ) : [];
10051
10684
  await writeManagedArtifactBundle(`${base}.pixelkiln.json`, outputs, {
10052
10685
  kind: "pack",
@@ -10098,7 +10731,8 @@ async function main() {
10098
10731
  const qualitySources = await requireApprovedQualitySources(
10099
10732
  packagingSpecs,
10100
10733
  lock,
10101
- new Set(Object.keys(cells))
10734
+ new Set(Object.keys(cells)),
10735
+ outputRoles
10102
10736
  );
10103
10737
  if (qualitySources) Object.assign(sources, qualitySources);
10104
10738
  const { png, atlas, skipped, overBase, sources: artifactSources } = mountStyle(
@@ -10118,7 +10752,10 @@ async function main() {
10118
10752
  { path: metadata, data: JSON.stringify(atlas, null, 2) + "\n" }
10119
10753
  ];
10120
10754
  const qualityRecords = qualitySources ? await Promise.all(
10121
- Object.entries(qualitySources).map(([assetId, source]) => provenanceFile(`$quality/${assetId}`, qualityRecordPath(source)))
10755
+ packagingSpecs.filter((spec) => spec.quality && Object.hasOwn(cells, spec.assetId)).map((spec) => provenanceFile(
10756
+ `$quality/${spec.assetId}`,
10757
+ qualityRecordPath(spec.quality.outFile)
10758
+ ))
10122
10759
  ) : [];
10123
10760
  await writeManagedArtifactBundle(companion, outputs, {
10124
10761
  kind: "mount",