pixelkiln 0.20.0 → 0.20.2

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/index.cjs CHANGED
@@ -558,6 +558,8 @@ var LockEntrySchema = import_zod.z.object({
558
558
  ).optional(),
559
559
  /** Set at submit time, before the request is awaited, so a crash is recoverable. */
560
560
  jobId: import_zod.z.string().nullable().default(null),
561
+ /** False only when a provider checkpoint says more requests remain. */
562
+ submissionComplete: import_zod.z.boolean().optional(),
561
563
  /** For `1dir`: the multi-candidate parent object awaiting selection. */
562
564
  reviewObjectId: import_zod.z.string().nullable().default(null),
563
565
  /** The chosen candidate's own object id, once selected. */
@@ -2990,7 +2992,7 @@ var ComfyUIProvider = class _ComfyUIProvider {
2990
2992
  rateLimit() {
2991
2993
  return { spacingMs: 0, maxInFlight: 1 };
2992
2994
  }
2993
- async submit(spec, styleImages) {
2995
+ async submit(spec, styleImages, context) {
2994
2996
  this.validate(spec, styleImages);
2995
2997
  const options = resolvedOptions(spec);
2996
2998
  const uploadedImages = /* @__PURE__ */ new Map();
@@ -3032,22 +3034,35 @@ var ComfyUIProvider = class _ComfyUIProvider {
3032
3034
  if (spec.generator === "frames") {
3033
3035
  const frames = options.frames;
3034
3036
  const values = spec.providerInputs[frames.vary];
3035
- const promptIds = [];
3036
- for (let index = 0; index < values.length; index++) {
3037
+ let promptIds = [];
3038
+ if (context?.previousJobId) {
3039
+ const previous = decodeFrameSetJob(context.previousJobId);
3040
+ if (previous.outputNodeId !== options.outputNodeId || previous.expectedCount !== values.length) {
3041
+ throw new Error("Saved ComfyUI frame checkpoint does not match the current frame set");
3042
+ }
3043
+ promptIds = [...previous.promptIds];
3044
+ }
3045
+ for (let index = promptIds.length; index < values.length; index++) {
3037
3046
  promptIds.push(await this.client.submit(await buildWorkflow(index)));
3047
+ const frameJob2 = {
3048
+ expectedCount: values.length,
3049
+ promptIds: [...promptIds],
3050
+ outputNodeId: options.outputNodeId
3051
+ };
3052
+ await context?.checkpoint({
3053
+ jobId: encodeFrameSetJob(frameJob2),
3054
+ metadata: comfyFrameSubmissionMetadata(spec, frameJob2),
3055
+ complete: promptIds.length === values.length
3056
+ });
3038
3057
  }
3058
+ const frameJob = {
3059
+ expectedCount: values.length,
3060
+ promptIds,
3061
+ outputNodeId: options.outputNodeId
3062
+ };
3039
3063
  return {
3040
- jobId: encodeFrameSetJob({ promptIds, outputNodeId: options.outputNodeId }),
3041
- metadata: {
3042
- inputs,
3043
- frameSet: {
3044
- count: values.length,
3045
- vary: frames.vary,
3046
- seedStep: frames.seedStep,
3047
- fps: spec.quality?.fps ?? 12,
3048
- promptIds
3049
- }
3050
- }
3064
+ jobId: encodeFrameSetJob(frameJob),
3065
+ metadata: comfyFrameSubmissionMetadata(spec, frameJob)
3051
3066
  };
3052
3067
  }
3053
3068
  const workflow = await buildWorkflow();
@@ -3104,6 +3119,9 @@ var ComfyUIProvider = class _ComfyUIProvider {
3104
3119
  async poll(jobId, generator, context) {
3105
3120
  if (generator === "frames") {
3106
3121
  const frameJob = decodeFrameSetJob(jobId);
3122
+ if (frameJob.promptIds.length < frameJob.expectedCount) {
3123
+ return { status: "processing" };
3124
+ }
3107
3125
  const images2 = [];
3108
3126
  for (let index = 0; index < frameJob.promptIds.length; index++) {
3109
3127
  const promptId2 = frameJob.promptIds[index];
@@ -3376,6 +3394,19 @@ function comfyFrameMetadata(spec, job, images, fps) {
3376
3394
  }
3377
3395
  };
3378
3396
  }
3397
+ function comfyFrameSubmissionMetadata(spec, job) {
3398
+ const options = resolvedOptions(spec);
3399
+ return {
3400
+ inputs: providerInputProvenance(spec.providerInputs),
3401
+ frameSet: {
3402
+ count: job.expectedCount,
3403
+ vary: options.frames.vary,
3404
+ seedStep: options.frames.seedStep,
3405
+ fps: spec.quality?.fps ?? 12,
3406
+ promptIds: job.promptIds
3407
+ }
3408
+ };
3409
+ }
3379
3410
  function isImageBinding(workflow, binding) {
3380
3411
  const node = workflow[binding.nodeId];
3381
3412
  return binding.input === "image" && (node?.class_type === "LoadImage" || node?.class_type === "LoadImageMask");
@@ -3431,10 +3462,18 @@ function decodeFrameSetJob(jobId) {
3431
3462
  } catch {
3432
3463
  throw new Error(`Invalid ComfyUI frame-set job id "${jobId}"`);
3433
3464
  }
3434
- 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)) {
3465
+ if (!isObject(value) || !Array.isArray(value.promptIds)) {
3435
3466
  throw new Error(`Invalid ComfyUI frame-set job id "${jobId}"`);
3436
3467
  }
3437
- return { outputNodeId: value.outputNodeId, promptIds: value.promptIds };
3468
+ const expectedCount = value.expectedCount === void 0 ? value.promptIds.length : value.expectedCount;
3469
+ if (typeof value.outputNodeId !== "string" || !value.outputNodeId || typeof expectedCount !== "number" || !Number.isSafeInteger(expectedCount) || expectedCount < 2 || expectedCount > 64 || value.promptIds.length < 1 || value.promptIds.length > expectedCount || !value.promptIds.every((id) => typeof id === "string" && id.length > 0)) {
3470
+ throw new Error(`Invalid ComfyUI frame-set job id "${jobId}"`);
3471
+ }
3472
+ return {
3473
+ expectedCount,
3474
+ outputNodeId: value.outputNodeId,
3475
+ promptIds: value.promptIds
3476
+ };
3438
3477
  }
3439
3478
  function decodeJob(jobId) {
3440
3479
  const split = jobId.lastIndexOf("#");
@@ -6381,6 +6420,7 @@ function resumeActions(specs, lock) {
6381
6420
  const key = lockKey(spec.styleId, spec.assetId);
6382
6421
  const entry = lock.entries[key];
6383
6422
  if (!entry || entry.specHash !== spec.specHash) continue;
6423
+ if (entry.submissionComplete === false) continue;
6384
6424
  const command = resumeCommandForStatus(entry.status);
6385
6425
  if (!command) continue;
6386
6426
  if (command === "poll" && !entry.jobId) continue;
@@ -6431,10 +6471,13 @@ async function buildPlan(specs, lock, opts = {}) {
6431
6471
  reason = `${entry.error ?? "download failed"}; run pixelkiln fetch${force} (no generation cost)`;
6432
6472
  } else if (entry.status === "failed") {
6433
6473
  state = "failed";
6434
- reason = entry.error ?? "previous attempt failed";
6474
+ reason = entry.jobId && entry.submissionComplete === false ? `${entry.error ?? "submission interrupted"}; saved checkpoint will resume` : entry.error ?? "previous attempt failed";
6435
6475
  } else if (entry.status === "selected") {
6436
6476
  state = "recoverable";
6437
6477
  reason = "provider output is selected; run pixelkiln fetch (no generation cost)";
6478
+ } else if ((entry.status === "pending" || entry.status === "processing") && entry.jobId && entry.submissionComplete === false) {
6479
+ state = "failed";
6480
+ reason = "submission stopped after a saved checkpoint; rerun pixelkiln submit to resume";
6438
6481
  } else if (entry.status === "pending" || entry.status === "processing") {
6439
6482
  state = "in-flight";
6440
6483
  reason = entry.jobId ? "awaiting processing; run pixelkiln poll" : "submission state has no job id; run pixelkiln doctor before retrying";
@@ -7682,6 +7725,11 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
7682
7725
  if (since < spacing) await sleep2(spacing - since);
7683
7726
  await requireRevisionReady(spec, lock);
7684
7727
  const previousEntry = lock.entries[key];
7728
+ const resumesCheckpoint = Boolean(
7729
+ previousEntry?.specHash === spec.specHash && previousEntry.provider === provider.id && previousEntry.generator === spec.generator && previousEntry.submissionComplete === false && previousEntry.jobId
7730
+ );
7731
+ const previousJobId = resumesCheckpoint ? previousEntry.jobId : void 0;
7732
+ const previousMetadata = resumesCheckpoint ? previousEntry.providerMetadata[provider.id] : void 0;
7685
7733
  const supersededOutputs = previousEntry?.outputs.length ? previousEntry.outputs : previousEntry?.supersededOutputs ?? [];
7686
7734
  upsert(lock, key, {
7687
7735
  styleId: spec.styleId,
@@ -7700,7 +7748,8 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
7700
7748
  ...spec.revision.strength == null ? {} : { strength: spec.revision.strength }
7701
7749
  } : null,
7702
7750
  status: "pending",
7703
- jobId: null,
7751
+ jobId: previousJobId ?? null,
7752
+ ...previousJobId ? { submissionComplete: false } : { submissionComplete: void 0 },
7704
7753
  objectId: null,
7705
7754
  reviewObjectId: null,
7706
7755
  candidateIndex: null,
@@ -7709,10 +7758,10 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
7709
7758
  // Keep the old ownership proof while new bytes are pending. Fetch may
7710
7759
  // replace that file only while its hash still matches this record.
7711
7760
  supersededOutputs,
7712
- providerMetadata: {},
7761
+ providerMetadata: resumesCheckpoint ? previousEntry.providerMetadata : {},
7713
7762
  sourceUrl: null,
7714
7763
  sourceUrls: [],
7715
- submittedAt: (/* @__PURE__ */ new Date()).toISOString(),
7764
+ submittedAt: resumesCheckpoint ? previousEntry.submittedAt : (/* @__PURE__ */ new Date()).toISOString(),
7716
7765
  cost: estimate.amount,
7717
7766
  costUnit: estimate.unit,
7718
7767
  // Persist routing before the request starts. If the process stops while
@@ -7725,9 +7774,32 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
7725
7774
  lastSubmitAt = Date.now();
7726
7775
  try {
7727
7776
  const refs = styleImages.get(spec.styleId) ?? [];
7728
- const { jobId, metadata } = await provider.submit(spec, refs);
7777
+ const { jobId, metadata } = await provider.submit(spec, refs, {
7778
+ ...previousJobId ? { previousJobId } : {},
7779
+ ...previousMetadata ? { previousMetadata } : {},
7780
+ checkpoint: async (checkpoint) => {
7781
+ if (!checkpoint.jobId) throw new Error("Provider checkpoint returned an empty job id");
7782
+ const entry = lock.entries[key];
7783
+ upsert(lock, key, {
7784
+ jobId: checkpoint.jobId,
7785
+ submissionComplete: checkpoint.complete,
7786
+ reviewObjectId: checkpoint.complete && (spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature) ? checkpoint.jobId : null,
7787
+ status: checkpoint.complete ? "processing" : "pending",
7788
+ error: null,
7789
+ providerMetadata: checkpoint.metadata ? {
7790
+ ...entry.providerMetadata,
7791
+ [provider.id]: {
7792
+ ...entry.providerMetadata[provider.id],
7793
+ ...checkpoint.metadata
7794
+ }
7795
+ } : entry.providerMetadata
7796
+ });
7797
+ await saveLock(lockPath, lock);
7798
+ }
7799
+ });
7729
7800
  upsert(lock, key, {
7730
7801
  jobId,
7802
+ submissionComplete: true,
7731
7803
  // A multi-candidate generator routes through review; record the parent
7732
7804
  // so `pick` knows where to look.
7733
7805
  reviewObjectId: spec.generator === "frames" || estimate.candidates > 1 && !spec.tileFeature ? jobId : null,
@@ -7741,10 +7813,23 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
7741
7813
  ` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
7742
7814
  );
7743
7815
  } catch (err) {
7744
- failed++;
7745
7816
  const message5 = err instanceof Error ? err.message : String(err);
7746
- upsert(lock, key, { status: "failed", error: message5, cost: 0 });
7747
- log(` FAILED ${key}: ${message5}`);
7817
+ const entry = lock.entries[key];
7818
+ if (entry.jobId && entry.submissionComplete === true) {
7819
+ upsert(lock, key, { status: "processing", error: null });
7820
+ inFlight.set(entry.jobId, spec);
7821
+ submitted++;
7822
+ spent += estimate.amount;
7823
+ log(` ${key} \u2192 ${entry.jobId} (recovered from completed checkpoint)`);
7824
+ } else {
7825
+ failed++;
7826
+ upsert(lock, key, {
7827
+ status: "failed",
7828
+ error: message5,
7829
+ cost: entry.jobId ? estimate.amount : 0
7830
+ });
7831
+ log(` FAILED ${key}: ${message5}`);
7832
+ }
7748
7833
  }
7749
7834
  await saveLock(lockPath, lock);
7750
7835
  }
@@ -7762,12 +7847,19 @@ async function poll(provider, lock, lockPath, opts = {}) {
7762
7847
  const specByKey = new Map((opts.specs ?? []).map((s) => [lockKey(s.styleId, s.assetId), s]));
7763
7848
  const selectedKeys = opts.specs ? new Set(specByKey.keys()) : null;
7764
7849
  const result = { review: 0, completed: 0, failed: 0, stillRunning: 0 };
7850
+ const incompleteSubmissions = Object.entries(lock.entries).filter(
7851
+ ([key, entry]) => entry.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && entry.jobId && entry.submissionComplete === false && (entry.status === "pending" || entry.status === "processing")
7852
+ );
7853
+ for (const [key] of incompleteSubmissions) {
7854
+ log(` incomplete submission ${key}; rerun \`pixelkiln submit\` to resume`);
7855
+ }
7856
+ result.stillRunning = incompleteSubmissions.length;
7765
7857
  const pending = () => Object.entries(lock.entries).filter(
7766
- ([key, e]) => e.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && e.jobId && (e.status === "pending" || e.status === "processing")
7858
+ ([key, e]) => e.provider === provider.id && (!selectedKeys || selectedKeys.has(key)) && e.jobId && e.submissionComplete !== false && (e.status === "pending" || e.status === "processing")
7767
7859
  );
7768
7860
  while (pending().length > 0) {
7769
7861
  if (Date.now() - started > timeout) {
7770
- result.stillRunning = pending().length;
7862
+ result.stillRunning += pending().length;
7771
7863
  log(` timed out with ${result.stillRunning} job(s) still running \u2014 re-run \`poll\` to resume`);
7772
7864
  break;
7773
7865
  }
@@ -8416,7 +8508,10 @@ function renderSheet(groups) {
8416
8508
  .loop { display:flex; align-items:flex-start; gap:12px; margin-bottom:12px; }
8417
8509
  .loop img { image-rendering:pixelated; border:2px solid var(--accent); padding:7px;
8418
8510
  background:var(--panel-deep); max-width:min(var(--preview),calc(100vw - 62px)); height:auto; }
8419
- .loop-copy { color:var(--dim); font-size:11px; max-width:32ch; }
8511
+ .loop-copy { color:var(--dim); font-size:11px; max-width:32ch; display:flex;
8512
+ flex-direction:column; gap:8px; }
8513
+ .loop-copy p { margin:0; }
8514
+ .loop-copy button { align-self:flex-start; padding:5px 9px; font-size:10.5px; }
8420
8515
  .cand { border:2px solid var(--line-strong); border-radius:0; padding:7px; background:var(--panel-deep);
8421
8516
  cursor:pointer; display:flex; flex:0 0 auto; flex-direction:column; align-items:center; gap:5px;
8422
8517
  position:relative; max-width:100%; }
@@ -8520,15 +8615,49 @@ GROUPS.forEach((g, gi) => {
8520
8615
  preview.alt = 'Animated frame-set preview';
8521
8616
  const copy = document.createElement('div');
8522
8617
  copy.className = 'loop-copy';
8523
- copy.textContent = g.frameUrls.length + ' ordered frames \xB7 ' + (g.fps || 12) +
8618
+ const copyText = document.createElement('p');
8619
+ copyText.textContent = g.frameUrls.length + ' ordered frames \xB7 ' + (g.fps || 12) +
8524
8620
  ' fps. Accepting keeps every frame; one bad frame means leave the set unchosen.';
8621
+ const playback = document.createElement('button');
8622
+ playback.type = 'button';
8623
+ let userPaused = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
8624
+ let loopVisible = true;
8625
+ let pageVisible = !document.hidden;
8626
+ let loopTimer = null;
8627
+ let loopIndex = 0;
8628
+ const stopLoop = () => {
8629
+ if (loopTimer !== null) window.clearInterval(loopTimer);
8630
+ loopTimer = null;
8631
+ };
8632
+ const syncLoop = () => {
8633
+ stopLoop();
8634
+ playback.textContent = userPaused ? 'Play preview' : 'Pause preview';
8635
+ playback.setAttribute('aria-pressed', String(userPaused));
8636
+ if (!userPaused && loopVisible && pageVisible) {
8637
+ loopTimer = window.setInterval(() => {
8638
+ loopIndex = (loopIndex + 1) % g.frameUrls.length;
8639
+ preview.src = g.frameUrls[loopIndex];
8640
+ }, Math.max(16, Math.round(1000 / (g.fps || 12))));
8641
+ }
8642
+ };
8643
+ playback.addEventListener('click', () => {
8644
+ userPaused = !userPaused;
8645
+ syncLoop();
8646
+ });
8647
+ copy.append(copyText, playback);
8525
8648
  loop.append(preview, copy);
8526
8649
  review.insertBefore(loop, frames);
8527
- let loopIndex = 0;
8528
- setInterval(() => {
8529
- loopIndex = (loopIndex + 1) % g.frameUrls.length;
8530
- preview.src = g.frameUrls[loopIndex];
8531
- }, Math.max(16, Math.round(1000 / (g.fps || 12))));
8650
+ document.addEventListener('visibilitychange', () => {
8651
+ pageVisible = !document.hidden;
8652
+ syncLoop();
8653
+ });
8654
+ syncLoop();
8655
+ if ('IntersectionObserver' in window) {
8656
+ new IntersectionObserver((entries) => {
8657
+ loopVisible = entries[0]?.isIntersecting ?? false;
8658
+ syncLoop();
8659
+ }).observe(loop);
8660
+ }
8532
8661
  }
8533
8662
  g.frameUrls.forEach((url, i) => {
8534
8663
  const c = document.createElement('button');