dsh-comfyui 0.3.0-beta.2 → 0.3.0-beta.4

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/lib/comfyui.d.ts CHANGED
@@ -101,11 +101,31 @@ export declare class ComfyUIClient {
101
101
  private endpoint;
102
102
  private request;
103
103
  /** Upload a file (multipart body forwarded verbatim) into ComfyUI's input directory. */
104
+ /** Forward a multipart/form-data upload body verbatim (the browser's own
105
+ * form, field "image", as ComfyUI's /upload/image expects it). */
104
106
  uploadFile(body: Uint8Array, contentType: string): Promise<{
105
107
  name?: string;
106
108
  subfolder?: string;
107
109
  type?: string;
108
110
  }>;
111
+ /**
112
+ * Put one file into ComfyUI's input directory (images, video, audio alike).
113
+ *
114
+ * /upload/image only accepts multipart/form-data: posting the bytes as a
115
+ * raw body with their own content-type answers HTTP 400, which is why
116
+ * picking a generated file into the load area used to fail instead of
117
+ * copying it. The form is built here so callers can pass plain bytes.
118
+ * `overwrite` keeps a repeated pick from piling up "name (1).png" copies.
119
+ */
120
+ uploadMedia(bytes: Uint8Array, filename: string, contentType: string, opts?: {
121
+ overwrite?: boolean;
122
+ subfolder?: string;
123
+ timeoutMs?: number;
124
+ }): Promise<{
125
+ name?: string;
126
+ subfolder?: string;
127
+ type?: string;
128
+ }>;
109
129
  /** Queue one API-format workflow and return its prompt id. */
110
130
  queuePrompt(workflow: unknown, options?: {
111
131
  promptId?: string;
@@ -182,12 +202,24 @@ export declare class ComfyUIClient {
182
202
  export declare function hasMedia(entry: ComfyUIHistoryEntry): boolean;
183
203
  /** Compose a readable failure message from history status messages. */
184
204
  export declare function historyErrorMessage(promptId: string, entry: ComfyUIHistoryEntry): string;
205
+ /**
206
+ * Build the same-origin proxy URL for one media file.
207
+ *
208
+ * The file is addressed by its own name/subfolder/type rather than by
209
+ * prompt+node+index: the latter has to be resolved through ComfyUI's
210
+ * /history, which lives in memory and is dropped on server restart or a
211
+ * "clear history" click — after which every stored asset URL 404s even though
212
+ * the file is still sitting in the output directory. Addressing the file
213
+ * directly keeps old assets viewable for as long as the file exists.
214
+ */
215
+ export declare function mediaProxyUrl(ref: ComfyUIMediaRef, proxyBase?: string): string;
185
216
  /**
186
217
  * Collect media items from a completed history entry, in node/output order,
187
218
  * capped by maxItems. The URL is the same-origin proxy route when a web
188
- * server is present, otherwise a ComfyUI /view URL (for headless hosts).
219
+ * server is present, otherwise the bare filename (for headless hosts).
189
220
  */
190
221
  export declare function collectMedia(opts: {
222
+ /** The run these outputs belong to; kept for call-site clarity and logging. */
191
223
  promptId: string;
192
224
  entry: ComfyUIHistoryEntry;
193
225
  maxItems: number;
package/lib/comfyui.js CHANGED
@@ -104,6 +104,8 @@ export class ComfyUIClient {
104
104
  }
105
105
  }
106
106
  /** Upload a file (multipart body forwarded verbatim) into ComfyUI's input directory. */
107
+ /** Forward a multipart/form-data upload body verbatim (the browser's own
108
+ * form, field "image", as ComfyUI's /upload/image expects it). */
107
109
  async uploadFile(body, contentType) {
108
110
  const data = await this.request('/upload/image', {
109
111
  method: 'POST',
@@ -112,6 +114,25 @@ export class ComfyUIClient {
112
114
  });
113
115
  return data ?? {};
114
116
  }
117
+ /**
118
+ * Put one file into ComfyUI's input directory (images, video, audio alike).
119
+ *
120
+ * /upload/image only accepts multipart/form-data: posting the bytes as a
121
+ * raw body with their own content-type answers HTTP 400, which is why
122
+ * picking a generated file into the load area used to fail instead of
123
+ * copying it. The form is built here so callers can pass plain bytes.
124
+ * `overwrite` keeps a repeated pick from piling up "name (1).png" copies.
125
+ */
126
+ async uploadMedia(bytes, filename, contentType, opts = {}) {
127
+ const form = new FormData();
128
+ form.append('image', new Blob([bytes], { type: contentType !== '' ? contentType : 'application/octet-stream' }), filename);
129
+ form.append('overwrite', opts.overwrite === false ? 'false' : 'true');
130
+ if (opts.subfolder !== undefined && opts.subfolder !== '')
131
+ form.append('subfolder', opts.subfolder);
132
+ // Video files are large; the connect timeout is far too tight for them.
133
+ const data = await this.request('/upload/image', { method: 'POST', body: form }, opts.timeoutMs ?? Math.max(this.connectTimeoutMs, 120_000));
134
+ return data ?? {};
135
+ }
115
136
  /** Queue one API-format workflow and return its prompt id. */
116
137
  async queuePrompt(workflow, options = {}) {
117
138
  const payload = { prompt: workflow, client_id: CLIENT_ID };
@@ -353,13 +374,27 @@ export function historyErrorMessage(promptId, entry) {
353
374
  }
354
375
  return `ComfyUI execution failed (prompt ${promptId}): ${details.join('; ') || 'unknown error'}`;
355
376
  }
377
+ /**
378
+ * Build the same-origin proxy URL for one media file.
379
+ *
380
+ * The file is addressed by its own name/subfolder/type rather than by
381
+ * prompt+node+index: the latter has to be resolved through ComfyUI's
382
+ * /history, which lives in memory and is dropped on server restart or a
383
+ * "clear history" click — after which every stored asset URL 404s even though
384
+ * the file is still sitting in the output directory. Addressing the file
385
+ * directly keeps old assets viewable for as long as the file exists.
386
+ */
387
+ export function mediaProxyUrl(ref, proxyBase) {
388
+ const query = new URLSearchParams({ file: ref.filename, subfolder: ref.subfolder ?? '', type: ref.type ?? 'output' });
389
+ return `${proxyBase ?? ''}/comfyui/media?${query.toString()}`;
390
+ }
356
391
  /**
357
392
  * Collect media items from a completed history entry, in node/output order,
358
393
  * capped by maxItems. The URL is the same-origin proxy route when a web
359
- * server is present, otherwise a ComfyUI /view URL (for headless hosts).
394
+ * server is present, otherwise the bare filename (for headless hosts).
360
395
  */
361
396
  export function collectMedia(opts) {
362
- const { promptId, entry, maxItems, proxyBase } = opts;
397
+ const { entry, maxItems, proxyBase } = opts;
363
398
  const items = [];
364
399
  const AUDIO_EXT = /\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i;
365
400
  const VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi)$/i;
@@ -377,13 +412,12 @@ export function collectMedia(opts) {
377
412
  // Some nodes emit audio/video filenames through the image/video
378
413
  // arrays; classify them by extension so the card renders a player.
379
414
  const itemKind = AUDIO_EXT.test(ref.filename) ? 'audio' : VIDEO_EXT.test(ref.filename) ? 'video' : kind;
380
- const query = new URLSearchParams({ prompt: promptId, node, index: String(index) });
381
415
  items.push({
382
416
  ...ref,
383
417
  node,
384
418
  index,
385
419
  kind: itemKind,
386
- url: proxyBase !== undefined ? `${proxyBase}/comfyui/media?${query.toString()}` : ref.filename,
420
+ url: proxyBase !== undefined ? mediaProxyUrl(ref, proxyBase) : ref.filename,
387
421
  });
388
422
  }
389
423
  }
package/lib/config.d.ts CHANGED
@@ -25,6 +25,11 @@ export declare const Config: z<Schemastery.ObjectS<{
25
25
  maxAssets: z<number, number>;
26
26
  /** External base URL for generated media (e.g. http://192.168.1.5:3080). Empty = auto-detect the browser's request host, then http://127.0.0.1:<webServerPort>. */
27
27
  mediaHost: z<string, string>;
28
+ /** ComfyUI's output directory on this machine, used to delete asset files
29
+ * from the panel. Empty = infer it from the file paths ComfyUI reports;
30
+ * deletion falls back to removing the index record when neither works
31
+ * (e.g. a ComfyUI running on another host). */
32
+ outputDir: z<string, string>;
28
33
  }>, Schemastery.ObjectT<{
29
34
  /** ComfyUI HTTP server base URL. */
30
35
  baseUrl: z<string, string>;
@@ -46,6 +51,11 @@ export declare const Config: z<Schemastery.ObjectS<{
46
51
  maxAssets: z<number, number>;
47
52
  /** External base URL for generated media (e.g. http://192.168.1.5:3080). Empty = auto-detect the browser's request host, then http://127.0.0.1:<webServerPort>. */
48
53
  mediaHost: z<string, string>;
54
+ /** ComfyUI's output directory on this machine, used to delete asset files
55
+ * from the panel. Empty = infer it from the file paths ComfyUI reports;
56
+ * deletion falls back to removing the index record when neither works
57
+ * (e.g. a ComfyUI running on another host). */
58
+ outputDir: z<string, string>;
49
59
  }>>;
50
60
  export type Config = {
51
61
  /** ComfyUI HTTP server base URL. */
@@ -68,4 +78,6 @@ export type Config = {
68
78
  maxAssets: number;
69
79
  /** External base URL for generated media; empty auto-detects the request host. */
70
80
  mediaHost: string;
81
+ /** ComfyUI's output directory on this machine; empty infers it from reported file paths. */
82
+ outputDir: string;
71
83
  };
package/lib/config.js CHANGED
@@ -25,4 +25,9 @@ export const Config = z.object({
25
25
  maxAssets: z.number().min(1).max(10_000).default(200),
26
26
  /** External base URL for generated media (e.g. http://192.168.1.5:3080). Empty = auto-detect the browser's request host, then http://127.0.0.1:<webServerPort>. */
27
27
  mediaHost: z.string().default(''),
28
+ /** ComfyUI's output directory on this machine, used to delete asset files
29
+ * from the panel. Empty = infer it from the file paths ComfyUI reports;
30
+ * deletion falls back to removing the index record when neither works
31
+ * (e.g. a ComfyUI running on another host). */
32
+ outputDir: z.string().default(''),
28
33
  });
package/lib/index.js CHANGED
@@ -77,6 +77,7 @@ export async function apply(ctx, entryConfig) {
77
77
  dataDir: entryConfig.dataDir !== undefined && entryConfig.dataDir !== '' ? entryConfig.dataDir : defaultDataDir(),
78
78
  maxAssets: entryConfig.maxAssets ?? 200,
79
79
  mediaHost: entryConfig.mediaHost ?? '',
80
+ outputDir: entryConfig.outputDir ?? '',
80
81
  };
81
82
  const store = new ComfyUIStore(resolved.dataDir, resolved.maxAssets);
82
83
  await store.init();
@@ -146,8 +147,9 @@ export async function apply(ctx, entryConfig) {
146
147
  let prompt = workflow;
147
148
  if (meta.parameters !== undefined && meta.parameters.length > 0) {
148
149
  const objectInfo = await objectInfoCached(client);
149
- const current = await store.loadCurrentImage();
150
- prompt = applyWorkflowParameters(prompt, meta.parameters, meta.values ?? {}, objectInfo, await store.loadMediaSizes(), current?.name);
150
+ const slots = await store.loadSlots();
151
+ const loaded = slots.filter((slot) => slot !== null);
152
+ prompt = applyWorkflowParameters(prompt, meta.parameters, meta.values ?? {}, objectInfo, await store.loadMediaSizes(), loaded);
151
153
  }
152
154
  const extraData = {};
153
155
  if (meta.workflowId !== undefined && meta.workflowId !== null && meta.workflowName !== null) {
@@ -169,9 +171,10 @@ export async function apply(ctx, entryConfig) {
169
171
  saveMediaSize: (name, size) => store.saveMediaSize(name, size),
170
172
  lookupMediaHash: (hash) => store.lookupMediaHash(hash),
171
173
  saveMediaHash: (hash, name) => store.saveMediaHash(hash, name),
172
- loadCurrentImage: () => store.loadCurrentImage(),
173
- saveCurrentImage: (image) => store.saveCurrentImage(image),
174
+ loadSlots: () => store.loadSlots(),
175
+ saveSlots: (slots) => store.saveSlots(slots),
174
176
  listAssets: () => store.listAssets(),
177
+ deleteAsset: (promptId) => store.deleteAsset(promptId),
175
178
  sweep: async () => {
176
179
  const client = runtime.createClient(await resolveApiKey(ctx, resolved.apiKeyEnv));
177
180
  return tracker.sweep({ client, store, maxItems: resolved.maxMediaItems, proxyBase: runtime.proxyBase() });
package/lib/params.d.ts CHANGED
@@ -16,6 +16,17 @@ export interface WorkflowParameter {
16
16
  description?: string;
17
17
  /** Number parameters (seeds): randomize on every run when true. */
18
18
  random?: boolean;
19
+ /** Number parameters: the numeric type object_info declares for the input.
20
+ * 'int' is rounded on apply; 'float' takes decimals. Absent means the
21
+ * declaration was unavailable (no object_info, unregistered node, ambiguous
22
+ * union type) — decimals are then accepted as-is, since a float value is
23
+ * the safe superset. */
24
+ numberKind?: 'int' | 'float';
25
+ /** Declared numeric bounds and step from object_info (editor hints only —
26
+ * values are not clamped, ComfyUI validates them at queue time). */
27
+ min?: number;
28
+ max?: number;
29
+ step?: number;
19
30
  /** Allowed values when the node input is a dropdown (object_info combo). */
20
31
  options?: Array<string | number>;
21
32
  /** Loader-node inputs (LoadImage/LoadVideo/LoadAudio): the value is a
@@ -38,6 +49,20 @@ export type { Workflow };
38
49
  * list and whose key name is loader-shaped. Returns the upload kind.
39
50
  */
40
51
  export declare function uploadKindOf(objectInfo: Record<string, unknown> | undefined, classType: string, inputKey: string): 'image' | 'video' | 'audio' | undefined;
52
+ /** The numeric type and bounds object_info declares for an input, if any.
53
+ *
54
+ * ComfyUI writes `["INT", {default,min,max,...}]` / `["FLOAT", {..., step,
55
+ * round}]`, and a few nodes use decorated or union type names (`"INT:seed"`,
56
+ * `"INT,FLOAT"`). A union that admits FLOAT is reported as float, because a
57
+ * float value is accepted wherever an int one is but not the reverse.
58
+ * Everything else returns undefined: the caller then treats the parameter as
59
+ * an unconstrained number (decimals allowed, no rounding). */
60
+ export declare function numberSpecOf(objectInfo: Record<string, unknown> | undefined, classType: string, inputKey: string): {
61
+ kind: 'int' | 'float';
62
+ min?: number;
63
+ max?: number;
64
+ step?: number;
65
+ } | undefined;
41
66
  /** The object_info input spec for one input name of a node class, if declared. */
42
67
  export declare function inputOptions(objectInfo: Record<string, unknown> | undefined, classType: string, inputKey: string): Array<string | number> | undefined;
43
68
  /** Full child info (key, options, default) for one selected DynamicCombo parent value. */
@@ -60,4 +85,7 @@ export declare function analyzeWorkflowParameters(workflow: Workflow, objectInfo
60
85
  export declare function applyWorkflowParameters(workflow: Workflow, parameters: WorkflowParameter[], values: Record<string, unknown>, objectInfo?: Record<string, unknown>, imageSizes?: Record<string, {
61
86
  width: number;
62
87
  height: number;
63
- }>, defaultImage?: string): Workflow;
88
+ }>, loadArea?: Array<{
89
+ name: string;
90
+ kind: 'image' | 'video' | 'audio';
91
+ }>): Workflow;
package/lib/params.js CHANGED
@@ -24,6 +24,28 @@ const TEXT_KEYS = new Set(['text', 'value', 'prompt']);
24
24
  function isPrimitive(value) {
25
25
  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
26
26
  }
27
+ /** Read a boolean parameter's value, accepting the string and 0/1 spellings.
28
+ *
29
+ * A boolean default authored before the panel had a checkbox was stored as a
30
+ * string ("true"), and an agent may pass "false" or 0 just as readily. Writing
31
+ * any of those into a BOOLEAN node input would make ComfyUI reject the prompt
32
+ * (and "false" would read as truthy), so they are normalized here; anything
33
+ * that is not a recognized spelling returns undefined and the input keeps its
34
+ * authored value. */
35
+ function coerceBoolean(value) {
36
+ if (typeof value === 'boolean')
37
+ return value;
38
+ if (typeof value === 'number')
39
+ return value === 1 ? true : value === 0 ? false : undefined;
40
+ if (typeof value !== 'string')
41
+ return undefined;
42
+ const text = value.trim().toLowerCase();
43
+ if (text === 'true' || text === '1' || text === 'yes' || text === 'on')
44
+ return true;
45
+ if (text === 'false' || text === '0' || text === 'no' || text === 'off')
46
+ return false;
47
+ return undefined;
48
+ }
27
49
  /** Parse a media-state JSON array (MiniMaxH3 loader media_state); undefined when not one. */
28
50
  function parseMediaState(raw) {
29
51
  try {
@@ -80,6 +102,45 @@ function displayName(workflow, nodeId, inputKey) {
80
102
  return inputKey === 'value' ? '文本' : '提示词';
81
103
  return `${node.class_type} · ${inputKey}`;
82
104
  }
105
+ /** The raw object_info spec tuple for one input of a node class. */
106
+ function inputSpec(objectInfo, classType, inputKey) {
107
+ if (objectInfo === undefined)
108
+ return undefined;
109
+ const def = objectInfo[classType];
110
+ const spec = def?.input?.required?.[inputKey] ?? def?.input?.optional?.[inputKey];
111
+ return Array.isArray(spec) ? spec : undefined;
112
+ }
113
+ /** The numeric type and bounds object_info declares for an input, if any.
114
+ *
115
+ * ComfyUI writes `["INT", {default,min,max,...}]` / `["FLOAT", {..., step,
116
+ * round}]`, and a few nodes use decorated or union type names (`"INT:seed"`,
117
+ * `"INT,FLOAT"`). A union that admits FLOAT is reported as float, because a
118
+ * float value is accepted wherever an int one is but not the reverse.
119
+ * Everything else returns undefined: the caller then treats the parameter as
120
+ * an unconstrained number (decimals allowed, no rounding). */
121
+ export function numberSpecOf(objectInfo, classType, inputKey) {
122
+ const spec = inputSpec(objectInfo, classType, inputKey);
123
+ if (spec === undefined)
124
+ return undefined;
125
+ const typeName = spec[0];
126
+ if (typeof typeName !== 'string')
127
+ return undefined;
128
+ // "INT:seed" → INT; "INT,FLOAT" → the union members.
129
+ const names = typeName.split(',').map((name) => name.split(':')[0]?.trim().toUpperCase() ?? '');
130
+ const hasFloat = names.includes('FLOAT');
131
+ const hasInt = names.includes('INT');
132
+ if (!hasFloat && !hasInt)
133
+ return undefined;
134
+ const meta = spec[1];
135
+ const flags = meta !== null && typeof meta === 'object' ? meta : {};
136
+ const numberOf = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
137
+ return {
138
+ kind: hasFloat ? 'float' : 'int',
139
+ min: numberOf(flags.min),
140
+ max: numberOf(flags.max),
141
+ step: numberOf(flags.step),
142
+ };
143
+ }
83
144
  /** The object_info input spec for one input name of a node class, if declared. */
84
145
  export function inputOptions(objectInfo, classType, inputKey) {
85
146
  if (objectInfo === undefined)
@@ -249,6 +310,11 @@ export function analyzeWorkflowParameters(workflow, objectInfo) {
249
310
  const options = input.options ?? (input.classType !== undefined
250
311
  ? inputOptions(objectInfo, input.classType, input.inputKey)
251
312
  : undefined);
313
+ // Number inputs carry their declared INT/FLOAT type so the panel editor
314
+ // and the run path know whether decimals are allowed.
315
+ const numberSpec = input.type === 'number' && input.classType !== undefined
316
+ ? numberSpecOf(objectInfo, input.classType, input.inputKey)
317
+ : undefined;
252
318
  params.push({
253
319
  id: randomUUID(),
254
320
  name: uniqueName(input.name),
@@ -258,6 +324,10 @@ export function analyzeWorkflowParameters(workflow, objectInfo) {
258
324
  inputKey: input.inputKey,
259
325
  default: input.value,
260
326
  random: input.random,
327
+ numberKind: numberSpec?.kind,
328
+ min: numberSpec?.min,
329
+ max: numberSpec?.max,
330
+ step: numberSpec?.step,
261
331
  options,
262
332
  upload: input.upload,
263
333
  subfolder: input.subfolder,
@@ -365,16 +435,39 @@ export function analyzeWorkflowParameters(workflow, objectInfo) {
365
435
  * workflow. Unknown parameters are ignored; omitted ones fall back to the
366
436
  * parameter default. The input workflow is not mutated.
367
437
  */
368
- export function applyWorkflowParameters(workflow, parameters, values, objectInfo, imageSizes, defaultImage) {
438
+ export function applyWorkflowParameters(workflow, parameters, values, objectInfo, imageSizes, loadArea) {
369
439
  const copy = structuredClone(workflow);
370
440
  let effectiveValues = values;
371
- // The load-area selection is the default source image: when an image upload
372
- // parameter is left unset, use it instead of the workflow's stored default.
373
- if (defaultImage !== undefined && typeof defaultImage === 'string' && defaultImage !== '') {
374
- const imageParam = parameters.find((param) => param.upload === 'image');
375
- if (imageParam !== undefined && !Object.prototype.hasOwnProperty.call(effectiveValues, imageParam.name)) {
376
- effectiveValues = { ...effectiveValues, [imageParam.name]: defaultImage };
441
+ // The load area supplies the default source media: each loader parameter
442
+ // left unset takes the next load-area slot of its own kind, in slot order
443
+ // (slot 1 → first image parameter, slot 2 → second, and so on). A workflow
444
+ // with a single image parameter therefore behaves exactly as it did when
445
+ // the load area held one file, and an explicit value always wins.
446
+ if (loadArea !== undefined && loadArea.length > 0) {
447
+ const queues = { image: [], video: [], audio: [] };
448
+ for (const slot of loadArea) {
449
+ if (typeof slot?.name === 'string' && slot.name !== '')
450
+ queues[slot.kind].push(slot.name);
451
+ }
452
+ const filled = { ...effectiveValues };
453
+ let used = false;
454
+ for (const param of parameters) {
455
+ const kind = param.upload;
456
+ if (kind !== 'image' && kind !== 'video' && kind !== 'audio')
457
+ continue;
458
+ if (Object.prototype.hasOwnProperty.call(effectiveValues, param.name))
459
+ continue;
460
+ // An audio parameter may also take a video slot: ComfyUI's LoadAudio
461
+ // accepts a video container and pulls its audio track, and the load
462
+ // area classifies an .mp4 as video.
463
+ const next = queues[kind].shift() ?? (kind === 'audio' ? queues.video.shift() : undefined);
464
+ if (next === undefined)
465
+ continue;
466
+ filled[param.name] = next;
467
+ used = true;
377
468
  }
469
+ if (used)
470
+ effectiveValues = filled;
378
471
  }
379
472
  // Auto-match the output size to the source image: when the effective source
380
473
  // image (explicit or load-area default) has a recorded pixel size but
@@ -421,8 +514,9 @@ export function applyWorkflowParameters(workflow, parameters, values, objectInfo
421
514
  const node = copy[param.nodeId];
422
515
  if (node === undefined)
423
516
  continue;
517
+ const explicit = Object.prototype.hasOwnProperty.call(effectiveValues, param.name);
424
518
  let value;
425
- if (Object.prototype.hasOwnProperty.call(effectiveValues, param.name)) {
519
+ if (explicit) {
426
520
  value = effectiveValues[param.name];
427
521
  }
428
522
  else if (param.random === true && param.type === 'number') {
@@ -431,6 +525,31 @@ export function applyWorkflowParameters(workflow, parameters, values, objectInfo
431
525
  else {
432
526
  value = param.default;
433
527
  }
528
+ // A loader parameter with an empty default and nothing to fill it (no
529
+ // explicit value, no load-area slot of its kind) leaves the workflow's
530
+ // authored file name alone. Writing the empty default instead would clear
531
+ // the node's file input and the run would fail on a missing image.
532
+ // 'media' slots are exempt: there an empty value *means* "remove this
533
+ // reference slot", and they are merged back separately below.
534
+ if (!explicit && param.upload !== undefined && param.upload !== 'media' && value === '')
535
+ continue;
536
+ // A parameter exposed on a *linked* input (its authored value is a
537
+ // [nodeId, slot] reference, not a widget value) carries an empty default,
538
+ // because a link cannot be represented as one. Writing that default would
539
+ // sever the link on every run, so an omitted value leaves the link alone —
540
+ // only an explicit value replaces it.
541
+ if (!explicit && param.default === '' && !isPrimitive(node.inputs[param.inputKey]) && node.inputs[param.inputKey] !== undefined)
542
+ continue;
543
+ // INT inputs reject decimals at queue time; round rather than fail on a
544
+ // value like 20.5. The stored kind wins, but a workflow saved before the
545
+ // kind was recorded still gets it resolved from object_info here, so old
546
+ // libraries behave the same as freshly analyzed ones. An input whose type
547
+ // stays unknown is left exactly as the caller passed it.
548
+ if (param.type === 'number' && typeof value === 'number' && !Number.isInteger(value)) {
549
+ const kind = param.numberKind ?? numberSpecOf(objectInfo, node.class_type, param.inputKey)?.kind;
550
+ if (kind === 'int')
551
+ value = Math.round(value);
552
+ }
434
553
  const isComboChild = comboChildKeys.has(`${param.nodeId}:${param.inputKey}`);
435
554
  // Upload parameters accept any server-side filename (uploaded files, or
436
555
  // ComfyUI's "[output]"-annotated paths); options are only a reference list.
@@ -441,8 +560,12 @@ export function applyWorkflowParameters(workflow, parameters, values, objectInfo
441
560
  continue;
442
561
  if (param.type === 'number' && typeof value !== 'number')
443
562
  continue;
444
- if (param.type === 'boolean' && typeof value !== 'boolean')
445
- continue;
563
+ if (param.type === 'boolean' && typeof value !== 'boolean') {
564
+ const coerced = coerceBoolean(value);
565
+ if (coerced === undefined)
566
+ continue;
567
+ value = coerced;
568
+ }
446
569
  if (param.upload === 'media')
447
570
  continue; // merged back into the JSON array below
448
571
  node.inputs[param.inputKey] = value;
package/lib/proxy.js CHANGED
@@ -63,11 +63,24 @@ export function mountComfyUIProxy(ctx, runtime) {
63
63
  nodeOutput?.videos,
64
64
  nodeOutput?.gifs,
65
65
  ];
66
- const ref = collections
66
+ let ref = collections
67
67
  .flatMap((items) => items ?? [])[index];
68
68
  if (ref === undefined) {
69
- sendJson(response, 404, { error: `no media item ${node}[${index}] for prompt ${prompt} (history may be evicted)` });
70
- return;
69
+ // ComfyUI's /history is in-memory: a server restart or a "clear
70
+ // history" click drops the entry and this lookup fails even though
71
+ // the file is still on disk. The plugin's own asset index keeps the
72
+ // file reference for every run it submitted, so fall back to it —
73
+ // this is what keeps older chat cards (whose URLs were minted before
74
+ // the proxy addressed files directly) from going blank.
75
+ const archived = await runtime.listAssets();
76
+ const item = archived
77
+ .find((asset) => asset.promptId === prompt)
78
+ ?.media.find((entry) => entry.node === node && entry.index === index);
79
+ if (item === undefined) {
80
+ sendJson(response, 404, { error: `no media item ${node}[${index}] for prompt ${prompt} (history evicted and not in the asset index)` });
81
+ return;
82
+ }
83
+ ref = { filename: item.filename, subfolder: item.subfolder, type: item.type };
71
84
  }
72
85
  const { bytes, contentType } = await client.fetchView(ref);
73
86
  response.writeHead(200, {