dsh-comfyui 0.5.1 → 0.5.3

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/analyze.js CHANGED
@@ -44,14 +44,36 @@ export function analyzeGraph(graph) {
44
44
  const isolated = active
45
45
  .filter((node) => !linkedIds.has(node.id))
46
46
  .map((node) => ({ id: node.id, type: node.type }));
47
+ // Bypassed nodes are pass-throughs at conversion time (resolveOrigin in
48
+ // convert.ts: the output follows the FIRST wired input). Mirror that rule
49
+ // here, or a chain `120 → [406 bypassed] → 119` splits into two components
50
+ // and "main flow" extraction ships 119 referencing a node it left out
51
+ // (Issue #8).
52
+ const linkById = new Map(links.map((link) => [link[0], link]));
53
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
54
+ const passThroughOrigin = (originId) => {
55
+ let current = originId;
56
+ const visited = new Set();
57
+ while (nodeById.get(current)?.mode === 4) {
58
+ if (visited.has(current))
59
+ return undefined;
60
+ visited.add(current);
61
+ const inputLink = (nodeById.get(current)?.inputs ?? []).find((entry) => entry.link !== null)?.link;
62
+ const upstream = inputLink === undefined || inputLink === null ? undefined : linkById.get(inputLink);
63
+ if (upstream === undefined)
64
+ return undefined;
65
+ current = upstream[1];
66
+ }
67
+ return current;
68
+ };
47
69
  // Connected components over linked active nodes.
48
70
  const adjacency = new Map();
49
71
  for (const node of active)
50
72
  adjacency.set(node.id, new Set());
51
73
  for (const link of links) {
52
- const a = link[1];
74
+ const a = passThroughOrigin(link[1]);
53
75
  const b = link[3];
54
- if (typeof a !== 'number' || typeof b !== 'number')
76
+ if (a === undefined)
55
77
  continue;
56
78
  if (!adjacency.has(a) || !adjacency.has(b))
57
79
  continue;
package/lib/comfyui.d.ts CHANGED
@@ -109,7 +109,10 @@ export declare class ComfyUIClient {
109
109
  private readonly connectTimeoutMs;
110
110
  private readonly maxMediaBytes;
111
111
  constructor(baseUrl: string, apiKey: string | undefined, connectTimeoutMs: number, maxMediaBytes: number);
112
+ private base;
112
113
  private endpoint;
114
+ /** fetch() one route, retrying a 404 under /api until the prefix is known. */
115
+ private fetchRoute;
113
116
  private request;
114
117
  /** Best-effort GET of an arbitrary JSON endpoint on the ComfyUI server.
115
118
  *
package/lib/comfyui.js CHANGED
@@ -62,6 +62,14 @@ export function guessContentType(filename) {
62
62
  }
63
63
  /** The per-process client id ComfyUI uses to correlate queued prompts. */
64
64
  export const CLIENT_ID = randomUUID();
65
+ /**
66
+ * Route prefix learned per base URL: '' when bare routes answer, '/api' when
67
+ * only the /api-prefixed mirror does. ComfyUI serves every route under both,
68
+ * but API-only reverse proxies (comfy-api-proxy, Issue #6) forward just
69
+ * `/api/*`, so the bare /system_stats probe 404s. Learned on the first
70
+ * request and kept for the process — clients are created per call.
71
+ */
72
+ const routePrefix = new Map();
65
73
  /** HTTP client over the ComfyUI REST API. */
66
74
  export class ComfyUIClient {
67
75
  baseUrl;
@@ -74,8 +82,34 @@ export class ComfyUIClient {
74
82
  this.connectTimeoutMs = connectTimeoutMs;
75
83
  this.maxMediaBytes = maxMediaBytes;
76
84
  }
85
+ base() {
86
+ return this.baseUrl.replace(/\/+$/, '');
87
+ }
77
88
  endpoint(path) {
78
- return `${this.baseUrl.replace(/\/+$/, '')}${path}`;
89
+ if (path.startsWith('/api/'))
90
+ return `${this.base()}${path}`;
91
+ return `${this.base()}${routePrefix.get(this.base()) ?? ''}${path}`;
92
+ }
93
+ /** fetch() one route, retrying a 404 under /api until the prefix is known. */
94
+ async fetchRoute(path, init) {
95
+ const base = this.base();
96
+ const response = await fetch(this.endpoint(path), init);
97
+ if (path.startsWith('/api/') || routePrefix.has(base))
98
+ return response;
99
+ if (response.ok) {
100
+ routePrefix.set(base, '');
101
+ return response;
102
+ }
103
+ if (response.status !== 404)
104
+ return response;
105
+ const retry = await fetch(`${base}/api${path}`, init);
106
+ if (!retry.ok) {
107
+ await retry.body?.cancel();
108
+ return response;
109
+ }
110
+ routePrefix.set(base, '/api');
111
+ await response.body?.cancel();
112
+ return retry;
79
113
  }
80
114
  async request(path, init = {}, timeoutMs) {
81
115
  const controller = new AbortController();
@@ -84,7 +118,7 @@ export class ComfyUIClient {
84
118
  const headers = { ...init.headers };
85
119
  if (this.apiKey !== undefined)
86
120
  headers['Authorization'] = `Bearer ${this.apiKey}`;
87
- const response = await fetch(this.endpoint(path), { ...init, headers, signal: controller.signal });
121
+ const response = await this.fetchRoute(path, { ...init, headers, signal: controller.signal });
88
122
  if (!response.ok) {
89
123
  const body = await response.text().catch(() => '');
90
124
  throw new ComfyUIError(`ComfyUI ${path} failed: HTTP ${response.status}${body !== '' ? ` — ${body.slice(0, 300)}` : ''}`, response.status);
@@ -284,7 +318,28 @@ export class ComfyUIClient {
284
318
  }
285
319
  /** List one user-data subdirectory (e.g. 'workflows') on the ComfyUI server. */
286
320
  async listUserData(subdir) {
287
- const data = await this.request(`/v2/userdata?path=${encodeURIComponent(subdir)}`);
321
+ let data;
322
+ try {
323
+ data = await this.request(`/v2/userdata?path=${encodeURIComponent(subdir)}`);
324
+ }
325
+ catch (error) {
326
+ // Some builds (e.g. the 0.35 desktop app, Issue #5) have no /v2/userdata;
327
+ // the v1 listing takes `dir` and returns paths relative to it.
328
+ if (!(error instanceof ComfyUIError) || error.status !== 404)
329
+ throw error;
330
+ const legacy = await this.request(`/userdata?dir=${encodeURIComponent(subdir)}&recurse=true&split=false&full_info=true`);
331
+ if (!Array.isArray(legacy))
332
+ return [];
333
+ return legacy.map((item) => {
334
+ const rel = (typeof item === 'string' ? item : item.path).replace(/\\/g, '/');
335
+ const entry = { name: rel, path: `${subdir}/${rel}`, type: 'file' };
336
+ if (typeof item !== 'string' && item.size !== undefined)
337
+ entry.size = item.size;
338
+ if (typeof item !== 'string' && item.modified !== undefined)
339
+ entry.modified = item.modified;
340
+ return entry;
341
+ });
342
+ }
288
343
  if (!Array.isArray(data))
289
344
  return [];
290
345
  if (typeof data[0] === 'string') {
@@ -319,7 +374,7 @@ export class ComfyUIClient {
319
374
  const headers = {};
320
375
  if (this.apiKey !== undefined)
321
376
  headers['Authorization'] = `Bearer ${this.apiKey}`;
322
- const response = await fetch(this.endpoint(`/view?${params.toString()}`), { headers, signal: controller.signal });
377
+ const response = await this.fetchRoute(`/view?${params.toString()}`, { headers, signal: controller.signal });
323
378
  if (!response.ok) {
324
379
  throw new ComfyUIError(`ComfyUI /view failed: HTTP ${response.status}`);
325
380
  }
@@ -352,7 +407,7 @@ export class ComfyUIClient {
352
407
  headers['Authorization'] = `Bearer ${this.apiKey}`;
353
408
  if (rangeHeader !== undefined)
354
409
  headers['Range'] = rangeHeader;
355
- const response = await fetch(this.endpoint(`/view?${params.toString()}`), { headers, method, signal: controller.signal });
410
+ const response = await this.fetchRoute(`/view?${params.toString()}`, { headers, method, signal: controller.signal });
356
411
  if (response.status !== 206 && response.status !== 416 && !response.ok) {
357
412
  throw new ComfyUIError(`ComfyUI /view failed: HTTP ${response.status}`);
358
413
  }
package/lib/config.d.ts CHANGED
@@ -1,86 +1,93 @@
1
1
  /**
2
2
  * dsh-comfyui host configuration. The same schema drives the Loader entry
3
- * config (cordis.yml patch) and the `comfyui:` settings section the browser
4
- * settings page writes, so one shape covers both doors into the same values.
3
+ * config (cordis.yml patch) and the settings page, so one shape covers both
4
+ * doors into the same values.
5
+ *
6
+ * Fields the plugin reads at use time are `.volatile()`: since dsh 0.1.7 the
7
+ * settings service only edits volatile fields (anything else fails with
8
+ * "Plugin entry has no volatile fields", Issue #11), and the Loader commits a
9
+ * volatile-only change into the running references without restarting the
10
+ * plugin. `dataDir` / `maxAssets` / `outputDir` stay ordinary: the store is
11
+ * built from them at startup, so a change there restarts the plugin.
5
12
  */
6
13
  import z from '@deepseek-ai/schemastery';
7
- export declare const Config: z<Schemastery.ObjectS<{
14
+ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
8
15
  /** ComfyUI HTTP server base URL. */
9
- baseUrl: z<string, string>;
16
+ baseUrl: z<string, string, "volatile-defined">;
10
17
  /** Environment-variable name of the optional API key (credentials ref). */
11
- apiKeyEnv: z<string, string>;
18
+ apiKeyEnv: z<string, string, "volatile-defined">;
12
19
  /** Per-request connect/read timeout for the ComfyUI HTTP client. */
13
- connectTimeoutMs: z<number, number>;
20
+ connectTimeoutMs: z<number, number, "volatile-defined">;
14
21
  /** How long a synchronous generation (or background job) waits for workflow completion. */
15
- timeoutMs: z<number, number>;
22
+ timeoutMs: z<number, number, "volatile-defined">;
16
23
  /** History polling interval while waiting for completion. */
17
- pollIntervalMs: z<number, number>;
24
+ pollIntervalMs: z<number, number, "volatile-defined">;
18
25
  /** Max media items returned per completed workflow. */
19
- maxMediaItems: z<number, number>;
26
+ maxMediaItems: z<number, number, "volatile-defined">;
20
27
  /** Max bytes the media proxy streams for one file. */
21
- maxMediaBytes: z<number, number>;
28
+ maxMediaBytes: z<number, number, "volatile-defined">;
22
29
  /** Directory for plugin data (workflow library, asset index); empty means DSH_HOME/data/dsh-comfyui. */
23
- dataDir: z<string, string>;
30
+ dataDir: z<string, string, "defined">;
24
31
  /** Max asset records kept in the asset index. */
25
- maxAssets: z<number, number>;
32
+ maxAssets: z<number, number, "defined">;
26
33
  /** Directory holding the per-workflow skill packs. Empty = `<dataDir>/skills`.
27
34
  * Separate from `dataDir` on purpose: packs are documents a user may want on
28
35
  * another drive, in a synced folder, or under version control, while the
29
36
  * JSON state files belong with the rest of the plugin data. Must be absolute;
30
37
  * a relative value is ignored. */
31
- skillsDir: z<string, string>;
38
+ skillsDir: z<string, string, "volatile-defined">;
32
39
  /** 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>. */
33
- mediaHost: z<string, string>;
40
+ mediaHost: z<string, string, "volatile-defined">;
34
41
  /** ComfyUI's output directory on this machine, used to delete asset files
35
42
  * from the panel. Empty = infer it from the file paths ComfyUI reports;
36
43
  * deletion falls back to removing the index record when neither works
37
44
  * (e.g. a ComfyUI running on another host). */
38
- outputDir: z<string, string>;
45
+ outputDir: z<string, string, "defined">;
39
46
  /** ComfyUI install root(s) on this machine (absolute paths). Multiple
40
47
  * entries are allowed because ComfyUI folders can be mapped/mounted (extra
41
48
  * models dirs, several installs, portable copies). The agent reads them to
42
49
  * locate ComfyUI files directly (workflows, models, the TTS-Audio-Suite
43
50
  * voice library) without guessing or asking the user. */
44
- comfyuiDirs: z<string[], string[]>;
45
- }>, Schemastery.ObjectT<{
51
+ comfyuiDirs: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
52
+ }>>, Schemastery.ObjectT<NoInfer<{
46
53
  /** ComfyUI HTTP server base URL. */
47
- baseUrl: z<string, string>;
54
+ baseUrl: z<string, string, "volatile-defined">;
48
55
  /** Environment-variable name of the optional API key (credentials ref). */
49
- apiKeyEnv: z<string, string>;
56
+ apiKeyEnv: z<string, string, "volatile-defined">;
50
57
  /** Per-request connect/read timeout for the ComfyUI HTTP client. */
51
- connectTimeoutMs: z<number, number>;
58
+ connectTimeoutMs: z<number, number, "volatile-defined">;
52
59
  /** How long a synchronous generation (or background job) waits for workflow completion. */
53
- timeoutMs: z<number, number>;
60
+ timeoutMs: z<number, number, "volatile-defined">;
54
61
  /** History polling interval while waiting for completion. */
55
- pollIntervalMs: z<number, number>;
62
+ pollIntervalMs: z<number, number, "volatile-defined">;
56
63
  /** Max media items returned per completed workflow. */
57
- maxMediaItems: z<number, number>;
64
+ maxMediaItems: z<number, number, "volatile-defined">;
58
65
  /** Max bytes the media proxy streams for one file. */
59
- maxMediaBytes: z<number, number>;
66
+ maxMediaBytes: z<number, number, "volatile-defined">;
60
67
  /** Directory for plugin data (workflow library, asset index); empty means DSH_HOME/data/dsh-comfyui. */
61
- dataDir: z<string, string>;
68
+ dataDir: z<string, string, "defined">;
62
69
  /** Max asset records kept in the asset index. */
63
- maxAssets: z<number, number>;
70
+ maxAssets: z<number, number, "defined">;
64
71
  /** Directory holding the per-workflow skill packs. Empty = `<dataDir>/skills`.
65
72
  * Separate from `dataDir` on purpose: packs are documents a user may want on
66
73
  * another drive, in a synced folder, or under version control, while the
67
74
  * JSON state files belong with the rest of the plugin data. Must be absolute;
68
75
  * a relative value is ignored. */
69
- skillsDir: z<string, string>;
76
+ skillsDir: z<string, string, "volatile-defined">;
70
77
  /** 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>. */
71
- mediaHost: z<string, string>;
78
+ mediaHost: z<string, string, "volatile-defined">;
72
79
  /** ComfyUI's output directory on this machine, used to delete asset files
73
80
  * from the panel. Empty = infer it from the file paths ComfyUI reports;
74
81
  * deletion falls back to removing the index record when neither works
75
82
  * (e.g. a ComfyUI running on another host). */
76
- outputDir: z<string, string>;
83
+ outputDir: z<string, string, "defined">;
77
84
  /** ComfyUI install root(s) on this machine (absolute paths). Multiple
78
85
  * entries are allowed because ComfyUI folders can be mapped/mounted (extra
79
86
  * models dirs, several installs, portable copies). The agent reads them to
80
87
  * locate ComfyUI files directly (workflows, models, the TTS-Audio-Suite
81
88
  * voice library) without guessing or asking the user. */
82
- comfyuiDirs: z<string[], string[]>;
83
- }>>;
89
+ comfyuiDirs: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
90
+ }>>, "plain">;
84
91
  export type Config = {
85
92
  /** ComfyUI HTTP server base URL. */
86
93
  baseUrl: string;
@@ -109,3 +116,12 @@ export type Config = {
109
116
  /** ComfyUI install root(s) on this machine; the agent uses them to locate files directly. */
110
117
  comfyuiDirs: string[];
111
118
  };
119
+ /**
120
+ * Resolve the Loader's parsed entry config into the plain object the plugin
121
+ * reads. Call again after `loader/volatile-update`: the references are the
122
+ * same objects, now holding the new values.
123
+ * @param raw - parsed entry config; fields may be volatile references.
124
+ * @param defaultDataDir - data directory used when `dataDir` is empty.
125
+ * @returns a fully populated plain config.
126
+ */
127
+ export declare function resolveConfig(raw: Partial<Record<keyof Config, unknown>>, defaultDataDir: string): Config;
package/lib/config.js CHANGED
@@ -1,24 +1,31 @@
1
1
  /**
2
2
  * dsh-comfyui host configuration. The same schema drives the Loader entry
3
- * config (cordis.yml patch) and the `comfyui:` settings section the browser
4
- * settings page writes, so one shape covers both doors into the same values.
3
+ * config (cordis.yml patch) and the settings page, so one shape covers both
4
+ * doors into the same values.
5
+ *
6
+ * Fields the plugin reads at use time are `.volatile()`: since dsh 0.1.7 the
7
+ * settings service only edits volatile fields (anything else fails with
8
+ * "Plugin entry has no volatile fields", Issue #11), and the Loader commits a
9
+ * volatile-only change into the running references without restarting the
10
+ * plugin. `dataDir` / `maxAssets` / `outputDir` stay ordinary: the store is
11
+ * built from them at startup, so a change there restarts the plugin.
5
12
  */
6
13
  import z from '@deepseek-ai/schemastery';
7
14
  export const Config = z.object({
8
15
  /** ComfyUI HTTP server base URL. */
9
- baseUrl: z.string().default('http://127.0.0.1:8188'),
16
+ baseUrl: z.string().default('http://127.0.0.1:8188').volatile(),
10
17
  /** Environment-variable name of the optional API key (credentials ref). */
11
- apiKeyEnv: z.string().default('COMFYUI_API_KEY'),
18
+ apiKeyEnv: z.string().default('COMFYUI_API_KEY').volatile(),
12
19
  /** Per-request connect/read timeout for the ComfyUI HTTP client. */
13
- connectTimeoutMs: z.number().min(1_000).max(60_000).default(10_000),
20
+ connectTimeoutMs: z.number().min(1_000).max(60_000).default(10_000).volatile(),
14
21
  /** How long a synchronous generation (or background job) waits for workflow completion. */
15
- timeoutMs: z.number().min(5_000).max(3_600_000).default(900_000),
22
+ timeoutMs: z.number().min(5_000).max(3_600_000).default(900_000).volatile(),
16
23
  /** History polling interval while waiting for completion. */
17
- pollIntervalMs: z.number().min(200).max(10_000).default(1_000),
24
+ pollIntervalMs: z.number().min(200).max(10_000).default(1_000).volatile(),
18
25
  /** Max media items returned per completed workflow. */
19
- maxMediaItems: z.number().min(1).max(50).default(12),
26
+ maxMediaItems: z.number().min(1).max(50).default(12).volatile(),
20
27
  /** Max bytes the media proxy streams for one file. */
21
- maxMediaBytes: z.number().min(64 * 1024).max(512 * 1024 * 1024).default(64 * 1024 * 1024),
28
+ maxMediaBytes: z.number().min(64 * 1024).max(512 * 1024 * 1024).default(64 * 1024 * 1024).volatile(),
22
29
  /** Directory for plugin data (workflow library, asset index); empty means DSH_HOME/data/dsh-comfyui. */
23
30
  dataDir: z.string().default(''),
24
31
  /** Max asset records kept in the asset index. */
@@ -28,9 +35,9 @@ export const Config = z.object({
28
35
  * another drive, in a synced folder, or under version control, while the
29
36
  * JSON state files belong with the rest of the plugin data. Must be absolute;
30
37
  * a relative value is ignored. */
31
- skillsDir: z.string().default(''),
38
+ skillsDir: z.string().default('').volatile(),
32
39
  /** 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>. */
33
- mediaHost: z.string().default(''),
40
+ mediaHost: z.string().default('').volatile(),
34
41
  /** ComfyUI's output directory on this machine, used to delete asset files
35
42
  * from the panel. Empty = infer it from the file paths ComfyUI reports;
36
43
  * deletion falls back to removing the index record when neither works
@@ -41,5 +48,45 @@ export const Config = z.object({
41
48
  * models dirs, several installs, portable copies). The agent reads them to
42
49
  * locate ComfyUI files directly (workflows, models, the TTS-Audio-Suite
43
50
  * voice library) without guessing or asking the user. */
44
- comfyuiDirs: z.array(z.string()).default([]),
51
+ comfyuiDirs: z.array(z.string()).default([]).volatile(),
45
52
  });
53
+ /** The volatile-reference protocol shared across cosmokit copies (Symbol.for). */
54
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write');
55
+ /** Read a config value that may be a volatile reference (dsh 0.1.7+) or a plain value (older hosts). */
56
+ function current(value) {
57
+ if (typeof value === 'object' && value !== null && VOLATILE_WRITE in value) {
58
+ return value.get();
59
+ }
60
+ return value;
61
+ }
62
+ /**
63
+ * Resolve the Loader's parsed entry config into the plain object the plugin
64
+ * reads. Call again after `loader/volatile-update`: the references are the
65
+ * same objects, now holding the new values.
66
+ * @param raw - parsed entry config; fields may be volatile references.
67
+ * @param defaultDataDir - data directory used when `dataDir` is empty.
68
+ * @returns a fully populated plain config.
69
+ */
70
+ export function resolveConfig(raw, defaultDataDir) {
71
+ const str = (value, fallback) => (typeof value === 'string' ? value : fallback);
72
+ const num = (value, fallback) => (typeof value === 'number' ? value : fallback);
73
+ const dataDir = str(current(raw.dataDir), '');
74
+ const dirs = current(raw.comfyuiDirs);
75
+ return {
76
+ baseUrl: str(current(raw.baseUrl), 'http://127.0.0.1:8188'),
77
+ apiKeyEnv: str(current(raw.apiKeyEnv), 'COMFYUI_API_KEY'),
78
+ connectTimeoutMs: num(current(raw.connectTimeoutMs), 10_000),
79
+ timeoutMs: num(current(raw.timeoutMs), 900_000),
80
+ pollIntervalMs: num(current(raw.pollIntervalMs), 1_000),
81
+ maxMediaItems: num(current(raw.maxMediaItems), 12),
82
+ maxMediaBytes: num(current(raw.maxMediaBytes), 64 * 1024 * 1024),
83
+ dataDir: dataDir !== '' ? dataDir : defaultDataDir,
84
+ maxAssets: num(current(raw.maxAssets), 200),
85
+ skillsDir: str(current(raw.skillsDir), ''),
86
+ mediaHost: str(current(raw.mediaHost), ''),
87
+ outputDir: str(current(raw.outputDir), ''),
88
+ comfyuiDirs: Array.isArray(dirs)
89
+ ? dirs.filter((dir) => typeof dir === 'string' && dir.trim() !== '')
90
+ : [],
91
+ };
92
+ }
package/lib/convert.d.ts CHANGED
@@ -12,6 +12,18 @@ export type ConvertResult = {
12
12
  ok: false;
13
13
  error: string;
14
14
  };
15
+ /**
16
+ * Flatten DynamicCombo values that 0.2.0–0.5.1 extraction wrapped as
17
+ * `{ key, inputs: { sub: value } }` back into the flat API shape
18
+ * (`master: key`, `master.sub: value`, recursively). Library entries saved by
19
+ * those versions still carry the wrapped shape; normalizing at queue time
20
+ * heals them without asking users to re-extract (Issue #10). Returns the same
21
+ * object when nothing needed flattening.
22
+ */
23
+ export declare function flattenDynamicCombos<W extends Record<string, {
24
+ class_type: string;
25
+ inputs: Record<string, unknown>;
26
+ }>>(workflow: W): W;
15
27
  /**
16
28
  * Convert a UI-graph workflow (or one extracted component of it) to API
17
29
  * format using the live node definitions.
package/lib/convert.js CHANGED
@@ -30,6 +30,51 @@ function inputSpec(objectInfo, classType, name) {
30
30
  }
31
31
  return undefined;
32
32
  }
33
+ /** Option keys a COMFY_DYNAMICCOMBO_V3 spec declares, or undefined when unreadable. */
34
+ function dynamicComboKeys(spec) {
35
+ const options = isObject(spec[1]) ? spec[1].options : undefined;
36
+ if (!Array.isArray(options))
37
+ return undefined;
38
+ const keys = options
39
+ .map((option) => (isObject(option) ? option.key : undefined))
40
+ .filter((key) => typeof key === 'string');
41
+ return keys.length > 0 ? keys : undefined;
42
+ }
43
+ /**
44
+ * Flatten DynamicCombo values that 0.2.0–0.5.1 extraction wrapped as
45
+ * `{ key, inputs: { sub: value } }` back into the flat API shape
46
+ * (`master: key`, `master.sub: value`, recursively). Library entries saved by
47
+ * those versions still carry the wrapped shape; normalizing at queue time
48
+ * heals them without asking users to re-extract (Issue #10). Returns the same
49
+ * object when nothing needed flattening.
50
+ */
51
+ export function flattenDynamicCombos(workflow) {
52
+ const isWrapped = (value) => isObject(value) && typeof value.key === 'string' && isObject(value.inputs)
53
+ && Object.keys(value).every((k) => k === 'key' || k === 'inputs');
54
+ const flatten = (prefix, value, into) => {
55
+ if (!isWrapped(value)) {
56
+ into[prefix] = value;
57
+ return;
58
+ }
59
+ into[prefix] = value.key;
60
+ for (const [sub, subValue] of Object.entries(value.inputs))
61
+ flatten(`${prefix}.${sub}`, subValue, into);
62
+ };
63
+ let changed = false;
64
+ const out = {};
65
+ for (const [id, node] of Object.entries(workflow)) {
66
+ if (!Object.values(node.inputs).some(isWrapped)) {
67
+ out[id] = node;
68
+ continue;
69
+ }
70
+ changed = true;
71
+ const inputs = {};
72
+ for (const [name, value] of Object.entries(node.inputs))
73
+ flatten(name, value, inputs);
74
+ out[id] = { ...node, inputs };
75
+ }
76
+ return changed ? out : workflow;
77
+ }
33
78
  /**
34
79
  * Derive the ordered widget-input names for one node. The graph's own input
35
80
  * array is the source of truth — widgets_values is stored in UI widget order,
@@ -289,27 +334,48 @@ export function convertGraphToApi(graph, objectInfo, options) {
289
334
  inputs[name] = value;
290
335
  }
291
336
  }
292
- // Collapse DynamicCombo V3 flat keys back into the API object shape:
293
- // { key, inputs: { subWidget: value } } — the master combo's value is the
294
- // selected option key, and its `master.sub` siblings become the sub-inputs.
337
+ // DynamicCombo V3 stays FLAT in the API prompt: the master input holds the
338
+ // selected option key as a string and its sub-widgets sit beside it as
339
+ // `master.sub` keys. The server rebuilds the nested shape itself
340
+ // (DynamicCombo._expand_schema_for_dynamic matches the plain value against
341
+ // option keys); wrapping it as { key, inputs } never matches, the input is
342
+ // dropped, and the node dies at execute() — Issue #10 (SaveVideo 'codec').
343
+ // Validate the selected key here so a bad value fails at extraction rather
344
+ // than after a full generation.
295
345
  for (const [name, value] of Object.entries(inputs)) {
296
346
  const spec = inputSpec(objectInfo, node.type, name);
297
347
  if (!Array.isArray(spec) || spec[0] !== 'COMFY_DYNAMICCOMBO_V3')
298
348
  continue;
299
- const sub = {};
300
- for (const [other, otherValue] of Object.entries(inputs)) {
301
- if (other.startsWith(`${name}.`))
302
- sub[other.slice(name.length + 1)] = otherValue;
349
+ const keys = dynamicComboKeys(spec);
350
+ if (keys === undefined || Array.isArray(value))
351
+ continue;
352
+ if (typeof value !== 'string' || !keys.includes(value)) {
353
+ return {
354
+ ok: false,
355
+ error: `节点 ${node.type}(id ${node.id})的输入 ${name} 取值 ${JSON.stringify(value)} 不是可选项之一(${keys.join(' / ')})——请在 ComfyUI 画布上重新选择后再提取`,
356
+ };
303
357
  }
304
- inputs[name] = { key: value, inputs: sub };
305
- for (const subName of Object.keys(sub))
306
- delete inputs[`${name}.${subName}`];
307
358
  }
308
359
  workflow[String(node.id)] = { class_type: node.type, inputs };
309
360
  }
310
361
  if (Object.keys(workflow).length === 0) {
311
362
  return { ok: false, error: '转换结果为空(图中没有可执行的节点)' };
312
363
  }
364
+ // Reference integrity: every link must point at a node of this same output.
365
+ // A component that leaves out an upstream node would otherwise extract as
366
+ // ok and only fail at run time (Issue #8).
367
+ for (const [id, node] of Object.entries(workflow)) {
368
+ for (const [name, value] of Object.entries(node.inputs)) {
369
+ if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== 'string' || typeof value[1] !== 'number')
370
+ continue;
371
+ if (workflow[value[0]] === undefined) {
372
+ return {
373
+ ok: false,
374
+ error: `节点 ${node.class_type}(id ${id})的输入 ${name} 引用了不在本次提取范围内的节点 ${value[0]}——请改用「整体提取」,或把这部分画布整理成独立的流程`,
375
+ };
376
+ }
377
+ }
378
+ }
313
379
  // Fail loudly on nodes whose required inputs are missing — the source graph
314
380
  // itself is broken (inputs never wired), and the server would only echo a
315
381
  // confusing per-node error at run time. Growable/lazy v3 inputs (template
package/lib/index.d.ts CHANGED
@@ -21,4 +21,4 @@ export declare const inject: string[];
21
21
  * The plugin body. The loader validates the entry config against `Config`
22
22
  * (defaults applied), then hands the resolved object to apply.
23
23
  */
24
- export declare function apply(ctx: Context, entryConfig: Partial<ConfigType>): Promise<void>;
24
+ export declare function apply(ctx: Context, entryConfig: Partial<Record<keyof ConfigType, unknown>>): Promise<void>;