@comfyorg/sdk 0.1.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/README.md +236 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +27 -0
- package/dist/low/errors.d.ts +73 -0
- package/dist/low/errors.js +104 -0
- package/dist/low/generated/index.d.ts +1 -0
- package/dist/low/generated/index.js +2 -0
- package/dist/low/generated/types.gen.d.ts +528 -0
- package/dist/low/generated/types.gen.js +2 -0
- package/dist/low/generated/zod.gen.d.ts +455 -0
- package/dist/low/generated/zod.gen.js +204 -0
- package/dist/low/index.d.ts +21 -0
- package/dist/low/index.js +21 -0
- package/dist/low/models.d.ts +47 -0
- package/dist/low/models.js +16 -0
- package/dist/low/sse.d.ts +24 -0
- package/dist/low/sse.js +44 -0
- package/dist/low/transport.d.ts +128 -0
- package/dist/low/transport.js +285 -0
- package/dist/sdk/abortable-sleep.d.ts +13 -0
- package/dist/sdk/abortable-sleep.js +31 -0
- package/dist/sdk/assets.d.ts +74 -0
- package/dist/sdk/assets.js +200 -0
- package/dist/sdk/client.d.ts +70 -0
- package/dist/sdk/client.js +120 -0
- package/dist/sdk/core.d.ts +33 -0
- package/dist/sdk/core.js +89 -0
- package/dist/sdk/events.d.ts +60 -0
- package/dist/sdk/events.js +84 -0
- package/dist/sdk/exceptions.d.ts +79 -0
- package/dist/sdk/exceptions.js +114 -0
- package/dist/sdk/hashing.d.ts +14 -0
- package/dist/sdk/hashing.js +28 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/sdk/index.js +12 -0
- package/dist/sdk/jobs.d.ts +47 -0
- package/dist/sdk/jobs.js +145 -0
- package/dist/sdk/outputs.d.ts +26 -0
- package/dist/sdk/outputs.js +48 -0
- package/dist/sdk/workflows.d.ts +32 -0
- package/dist/sdk/workflows.js +45 -0
- package/package.json +52 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed asset handles and their constructors.
|
|
3
|
+
*
|
|
4
|
+
* `client.assets.from*` returns a **lazy** {@link Asset} handle: no network
|
|
5
|
+
* at construction. On first use (`commit`/`asReference`/submit) the handle
|
|
6
|
+
* hashes its bytes locally with blake3, probes the server's dedup
|
|
7
|
+
* fast-path (`HEAD by-hash` then `from-hash` mint over existing bytes), and
|
|
8
|
+
* only streams a full multipart upload when the server does not already
|
|
9
|
+
* have the bytes. Because the handle carries an idempotency key,
|
|
10
|
+
* re-running a script re-uploads nothing whose bytes already made it to
|
|
11
|
+
* the server. Mirrors `comfy_sdk.assets` in the Python SDK — folded to one
|
|
12
|
+
* async class since JS is async-native (no separate sync variant).
|
|
13
|
+
*/
|
|
14
|
+
import { basename, extname } from "node:path";
|
|
15
|
+
import { ASSET_HANDLE, assetReference, newIdempotencyKey } from "./core.js";
|
|
16
|
+
import { translate } from "./exceptions.js";
|
|
17
|
+
import { hashBytes, hashFile } from "./hashing.js";
|
|
18
|
+
const DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
19
|
+
// A small, deliberately non-exhaustive extension -> MIME map: Node has no
|
|
20
|
+
// bundled equivalent of Python's `mimetypes.guess_type`, and pulling in a
|
|
21
|
+
// full MIME database for a handful of common asset kinds isn't worth the
|
|
22
|
+
// dependency. Anything unrecognized falls back to `application/octet-stream`
|
|
23
|
+
// (the server never trusts this — it is a convenience default only).
|
|
24
|
+
const EXTENSION_CONTENT_TYPES = {
|
|
25
|
+
".png": "image/png",
|
|
26
|
+
".jpg": "image/jpeg",
|
|
27
|
+
".jpeg": "image/jpeg",
|
|
28
|
+
".webp": "image/webp",
|
|
29
|
+
".gif": "image/gif",
|
|
30
|
+
".bmp": "image/bmp",
|
|
31
|
+
".mp4": "video/mp4",
|
|
32
|
+
".webm": "video/webm",
|
|
33
|
+
".mov": "video/quicktime",
|
|
34
|
+
".mp3": "audio/mpeg",
|
|
35
|
+
".wav": "audio/wav",
|
|
36
|
+
".flac": "audio/flac",
|
|
37
|
+
".json": "application/json",
|
|
38
|
+
".txt": "text/plain",
|
|
39
|
+
".safetensors": "application/octet-stream",
|
|
40
|
+
};
|
|
41
|
+
function guessContentType(name) {
|
|
42
|
+
if (!name)
|
|
43
|
+
return DEFAULT_CONTENT_TYPE;
|
|
44
|
+
return EXTENSION_CONTENT_TYPES[extname(name).toLowerCase()] ?? DEFAULT_CONTENT_TYPE;
|
|
45
|
+
}
|
|
46
|
+
function noOpener() {
|
|
47
|
+
throw new Error("this asset is already committed; nothing to upload");
|
|
48
|
+
}
|
|
49
|
+
function fileSource(path) {
|
|
50
|
+
const name = basename(path);
|
|
51
|
+
return {
|
|
52
|
+
contentType: guessContentType(name),
|
|
53
|
+
filePath: name,
|
|
54
|
+
hasher: () => hashFile(path),
|
|
55
|
+
// `fs.openAsBlob` is a lazy, disk-backed Blob: reading it (by
|
|
56
|
+
// `fetch`/undici, while encoding the multipart body) streams from disk
|
|
57
|
+
// on demand instead of loading the whole file into memory up front —
|
|
58
|
+
// this is what makes `postAssets` a genuine streaming upload.
|
|
59
|
+
opener: async () => {
|
|
60
|
+
const { openAsBlob } = await import("node:fs");
|
|
61
|
+
return openAsBlob(path, { type: guessContentType(name) });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function bytesSource(data, filename, contentType) {
|
|
66
|
+
const name = filename ?? "upload.bin";
|
|
67
|
+
return {
|
|
68
|
+
contentType: contentType ?? guessContentType(filename),
|
|
69
|
+
filePath: name,
|
|
70
|
+
hasher: () => hashBytes(data),
|
|
71
|
+
opener: async () => new Blob([data]),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export class Asset {
|
|
75
|
+
[ASSET_HANDLE] = true;
|
|
76
|
+
low;
|
|
77
|
+
source;
|
|
78
|
+
idempotencyKey = newIdempotencyKey();
|
|
79
|
+
hashValue;
|
|
80
|
+
idValue;
|
|
81
|
+
createdNewValue = null;
|
|
82
|
+
constructor(low, source) {
|
|
83
|
+
this.low = low;
|
|
84
|
+
this.source = source;
|
|
85
|
+
}
|
|
86
|
+
get id() {
|
|
87
|
+
return this.idValue;
|
|
88
|
+
}
|
|
89
|
+
get filePath() {
|
|
90
|
+
return this.source.filePath;
|
|
91
|
+
}
|
|
92
|
+
get createdNew() {
|
|
93
|
+
return this.createdNewValue;
|
|
94
|
+
}
|
|
95
|
+
/** The local blake3 (computed once, lazily). */
|
|
96
|
+
async hash() {
|
|
97
|
+
this.hashValue ??= await this.source.hasher();
|
|
98
|
+
return this.hashValue;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Adopt a resolved `Asset` model's identity. Not part of the public
|
|
102
|
+
* surface — used internally after a commit and by
|
|
103
|
+
* {@link AssetFactory.get} to rehydrate an already-committed handle.
|
|
104
|
+
*/
|
|
105
|
+
adopt(asset) {
|
|
106
|
+
this.idValue = asset.id;
|
|
107
|
+
if (asset.hash)
|
|
108
|
+
this.hashValue = asset.hash;
|
|
109
|
+
this.createdNewValue = asset.created_new ?? null;
|
|
110
|
+
}
|
|
111
|
+
/** Force the hash/dedup/upload now; return the asset UUID. */
|
|
112
|
+
async commit(signal) {
|
|
113
|
+
if (this.idValue !== undefined)
|
|
114
|
+
return this.idValue;
|
|
115
|
+
const digest = await this.hash();
|
|
116
|
+
const asset = await translate(async () => {
|
|
117
|
+
if (await this.low.headAssetByHash(digest, { signal })) {
|
|
118
|
+
return this.low.assetFromHash(digest, { filePath: this.source.filePath, signal });
|
|
119
|
+
}
|
|
120
|
+
const blob = await this.source.opener();
|
|
121
|
+
return this.low.postAssets(blob, this.source.contentType, this.source.filePath, {
|
|
122
|
+
expectedHash: digest,
|
|
123
|
+
idempotencyKey: this.idempotencyKey,
|
|
124
|
+
signal,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
this.adopt(asset);
|
|
128
|
+
return this.idValue;
|
|
129
|
+
}
|
|
130
|
+
/** The `core/ASSET` object (commits first if needed). */
|
|
131
|
+
async asReference(signal) {
|
|
132
|
+
await this.commit(signal);
|
|
133
|
+
return assetReference(this.idValue, { hash: this.hashValue, filePath: this.source.filePath });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** `client.assets` — alternative constructors for {@link Asset}. */
|
|
137
|
+
export class AssetFactory {
|
|
138
|
+
low;
|
|
139
|
+
constructor(low) {
|
|
140
|
+
this.low = low;
|
|
141
|
+
}
|
|
142
|
+
fromFile(path) {
|
|
143
|
+
return new Asset(this.low, fileSource(path));
|
|
144
|
+
}
|
|
145
|
+
fromBytes(data, options = {}) {
|
|
146
|
+
return new Asset(this.low, bytesSource(data, options.filename, options.contentType));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Buffers the stream fully so the dedup probe can hash before upload
|
|
150
|
+
* (the bytes must be re-readable for the upload itself). If the source
|
|
151
|
+
* is a file, prefer {@link fromFile} — it stays lazy end to end.
|
|
152
|
+
*/
|
|
153
|
+
async fromStream(stream, options = {}) {
|
|
154
|
+
const data = await bufferStream(stream);
|
|
155
|
+
return new Asset(this.low, bytesSource(data, options.filename, options.contentType));
|
|
156
|
+
}
|
|
157
|
+
/** Client-side download (not a server-side fetch): the bytes must
|
|
158
|
+
* transit the same blake3 -> dedup -> upload pipeline as every other
|
|
159
|
+
* source. */
|
|
160
|
+
async fromUrl(url) {
|
|
161
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
throw new Error(`failed to download ${url}: HTTP ${response.status}`);
|
|
164
|
+
}
|
|
165
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
166
|
+
const filename = basename(new URL(url).pathname) || "download.bin";
|
|
167
|
+
const contentType = response.headers.get("Content-Type")?.split(";")[0] || undefined;
|
|
168
|
+
return new Asset(this.low, bytesSource(data, filename, contentType));
|
|
169
|
+
}
|
|
170
|
+
/** Rehydrate an already-committed asset by UUID. */
|
|
171
|
+
async get(assetId) {
|
|
172
|
+
const model = await translate(() => this.low.getAsset(assetId));
|
|
173
|
+
const asset = new Asset(this.low, {
|
|
174
|
+
contentType: model.content_type,
|
|
175
|
+
filePath: model.file_path ?? assetId,
|
|
176
|
+
hasher: async () => model.hash ?? "",
|
|
177
|
+
opener: noOpener,
|
|
178
|
+
});
|
|
179
|
+
asset.adopt(model);
|
|
180
|
+
return asset;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function bufferStream(stream) {
|
|
184
|
+
if (Symbol.asyncIterator in stream) {
|
|
185
|
+
const chunks = [];
|
|
186
|
+
let total = 0;
|
|
187
|
+
for await (const chunk of stream) {
|
|
188
|
+
chunks.push(chunk);
|
|
189
|
+
total += chunk.length;
|
|
190
|
+
}
|
|
191
|
+
const merged = new Uint8Array(total);
|
|
192
|
+
let offset = 0;
|
|
193
|
+
for (const chunk of chunks) {
|
|
194
|
+
merged.set(chunk, offset);
|
|
195
|
+
offset += chunk.length;
|
|
196
|
+
}
|
|
197
|
+
return merged;
|
|
198
|
+
}
|
|
199
|
+
throw new TypeError("stream must be async-iterable (a Node Readable or a web ReadableStream)");
|
|
200
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Comfy` — the client integrators import.
|
|
3
|
+
*
|
|
4
|
+
* Runs an API-format workflow against any Comfy API v2 surface (self-hosted
|
|
5
|
+
* proxy, Comfy Cloud, serverless) — the only per-surface difference is the
|
|
6
|
+
* base URL and an optional key — and owns everything a generator cannot
|
|
7
|
+
* produce: local blake3 dedup-upload, `core/ASSET` substitution, idempotent
|
|
8
|
+
* submit, live SSE with a poll-authoritative backstop, range-aware
|
|
9
|
+
* downloads, and typed errors. It is layered over `../low` (the generated
|
|
10
|
+
* types/validators + thin transport).
|
|
11
|
+
*
|
|
12
|
+
* Mirrors `comfy_sdk.client.AsyncComfy` in the Python SDK — there is no
|
|
13
|
+
* separate sync client here (JS is async-native, so the Python SDK's
|
|
14
|
+
* sync/async split collapses to one class).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { Comfy } from "@comfyorg/sdk";
|
|
19
|
+
*
|
|
20
|
+
* const client = new Comfy("http://127.0.0.1:8189"); // self-hosted, no key
|
|
21
|
+
* // const client = new Comfy("https://api.comfy.org", { apiKey: "ck_..." });
|
|
22
|
+
*
|
|
23
|
+
* const wf = await client.workflows.fromFile("workflow_api.json");
|
|
24
|
+
* const asset = client.assets.fromFile("photo.png"); // lazy; uploaded on use
|
|
25
|
+
* wf.setInput("10", "image", asset);
|
|
26
|
+
*
|
|
27
|
+
* const job = await client.run(wf); // submit + poll-to-done
|
|
28
|
+
* (await job.getOutputs("13")[0].toFile("out.png"));
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import { type ComfyLowOptions } from "../low/index.js";
|
|
32
|
+
import { AssetFactory } from "./assets.js";
|
|
33
|
+
import { Job, JobFactory } from "./jobs.js";
|
|
34
|
+
import type { Workflow } from "./workflows.js";
|
|
35
|
+
import { WorkflowFactory } from "./workflows.js";
|
|
36
|
+
export interface ComfyOptions {
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
fetch?: ComfyLowOptions["fetch"];
|
|
40
|
+
}
|
|
41
|
+
export declare class Comfy {
|
|
42
|
+
private readonly low;
|
|
43
|
+
readonly assets: AssetFactory;
|
|
44
|
+
readonly workflows: WorkflowFactory;
|
|
45
|
+
readonly jobs: JobFactory;
|
|
46
|
+
constructor(baseUrl: string, options?: ComfyOptions);
|
|
47
|
+
private materialize;
|
|
48
|
+
/**
|
|
49
|
+
* Submit a workflow. Retries `queue_full` with `Retry-After`. An aborted
|
|
50
|
+
* `signal` stops asset materialization, the submit request, and the
|
|
51
|
+
* `queue_full` retry pause.
|
|
52
|
+
*
|
|
53
|
+
* Sends an auto-generated `Idempotency-Key` so the server rejects an
|
|
54
|
+
* accidental exact resend of *this* request (`422 idempotency_key_reuse`)
|
|
55
|
+
* instead of creating a duplicate job. Each call mints a fresh key, so
|
|
56
|
+
* calling `submit()` again is a distinct submission — to make a retry
|
|
57
|
+
* idempotent, pass an explicit `idempotencyKey` and reuse it. Note a reused
|
|
58
|
+
* key is *rejected*, not replayed: on reuse, catch the error and poll/list
|
|
59
|
+
* for the job the first attempt already created.
|
|
60
|
+
*/
|
|
61
|
+
submit(workflow: Workflow, options?: {
|
|
62
|
+
idempotencyKey?: string;
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
}): Promise<Job>;
|
|
65
|
+
/** Submit, then poll to terminal (authoritative). Throws on failure. */
|
|
66
|
+
run(workflow: Workflow, options?: {
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
}): Promise<Job>;
|
|
70
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Comfy` — the client integrators import.
|
|
3
|
+
*
|
|
4
|
+
* Runs an API-format workflow against any Comfy API v2 surface (self-hosted
|
|
5
|
+
* proxy, Comfy Cloud, serverless) — the only per-surface difference is the
|
|
6
|
+
* base URL and an optional key — and owns everything a generator cannot
|
|
7
|
+
* produce: local blake3 dedup-upload, `core/ASSET` substitution, idempotent
|
|
8
|
+
* submit, live SSE with a poll-authoritative backstop, range-aware
|
|
9
|
+
* downloads, and typed errors. It is layered over `../low` (the generated
|
|
10
|
+
* types/validators + thin transport).
|
|
11
|
+
*
|
|
12
|
+
* Mirrors `comfy_sdk.client.AsyncComfy` in the Python SDK — there is no
|
|
13
|
+
* separate sync client here (JS is async-native, so the Python SDK's
|
|
14
|
+
* sync/async split collapses to one class).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { Comfy } from "@comfyorg/sdk";
|
|
19
|
+
*
|
|
20
|
+
* const client = new Comfy("http://127.0.0.1:8189"); // self-hosted, no key
|
|
21
|
+
* // const client = new Comfy("https://api.comfy.org", { apiKey: "ck_..." });
|
|
22
|
+
*
|
|
23
|
+
* const wf = await client.workflows.fromFile("workflow_api.json");
|
|
24
|
+
* const asset = client.assets.fromFile("photo.png"); // lazy; uploaded on use
|
|
25
|
+
* wf.setInput("10", "image", asset);
|
|
26
|
+
*
|
|
27
|
+
* const job = await client.run(wf); // submit + poll-to-done
|
|
28
|
+
* (await job.getOutputs("13")[0].toFile("out.png"));
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import { ApiError, ComfyLow } from "../low/index.js";
|
|
32
|
+
import { abortableSleep } from "./abortable-sleep.js";
|
|
33
|
+
import { AssetFactory } from "./assets.js";
|
|
34
|
+
import { findAssetHandles, looksLikeUiFormat, newIdempotencyKey, substituteAssetHandles, SUCCESS, } from "./core.js";
|
|
35
|
+
import { JobFailed, QueueFull, WorkflowFormatUi, toSdkError } from "./exceptions.js";
|
|
36
|
+
import { Job, JobFactory } from "./jobs.js";
|
|
37
|
+
import { WorkflowFactory } from "./workflows.js";
|
|
38
|
+
// How long to keep retrying a full queue before giving up (ms).
|
|
39
|
+
const QUEUE_RETRY_BUDGET_MS = 60_000;
|
|
40
|
+
const DEFAULT_RETRY_AFTER_S = 2;
|
|
41
|
+
function guardUiFormat(workflow) {
|
|
42
|
+
if (looksLikeUiFormat(workflow.json)) {
|
|
43
|
+
throw new WorkflowFormatUi("workflow is in UI-export format (nodes/links/last_node_id); submit the API-format graph instead", { code: "workflow_format_ui", httpStatus: 422 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class Comfy {
|
|
47
|
+
low;
|
|
48
|
+
assets;
|
|
49
|
+
workflows;
|
|
50
|
+
jobs;
|
|
51
|
+
constructor(baseUrl, options = {}) {
|
|
52
|
+
this.low = new ComfyLow(baseUrl, options.apiKey, {
|
|
53
|
+
timeoutMs: options.timeoutMs,
|
|
54
|
+
fetch: options.fetch,
|
|
55
|
+
});
|
|
56
|
+
this.assets = new AssetFactory(this.low);
|
|
57
|
+
this.workflows = new WorkflowFactory();
|
|
58
|
+
this.jobs = new JobFactory(this.low);
|
|
59
|
+
}
|
|
60
|
+
async materialize(workflow, signal) {
|
|
61
|
+
const handles = findAssetHandles(workflow.json);
|
|
62
|
+
const refs = new Map();
|
|
63
|
+
for (const handle of handles) {
|
|
64
|
+
refs.set(handle, await handle.asReference(signal));
|
|
65
|
+
}
|
|
66
|
+
return substituteAssetHandles(workflow.json, refs);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Submit a workflow. Retries `queue_full` with `Retry-After`. An aborted
|
|
70
|
+
* `signal` stops asset materialization, the submit request, and the
|
|
71
|
+
* `queue_full` retry pause.
|
|
72
|
+
*
|
|
73
|
+
* Sends an auto-generated `Idempotency-Key` so the server rejects an
|
|
74
|
+
* accidental exact resend of *this* request (`422 idempotency_key_reuse`)
|
|
75
|
+
* instead of creating a duplicate job. Each call mints a fresh key, so
|
|
76
|
+
* calling `submit()` again is a distinct submission — to make a retry
|
|
77
|
+
* idempotent, pass an explicit `idempotencyKey` and reuse it. Note a reused
|
|
78
|
+
* key is *rejected*, not replayed: on reuse, catch the error and poll/list
|
|
79
|
+
* for the job the first attempt already created.
|
|
80
|
+
*/
|
|
81
|
+
async submit(workflow, options = {}) {
|
|
82
|
+
guardUiFormat(workflow);
|
|
83
|
+
const graph = await this.materialize(workflow, options.signal);
|
|
84
|
+
const key = options.idempotencyKey ?? newIdempotencyKey();
|
|
85
|
+
const deadline = Date.now() + QUEUE_RETRY_BUDGET_MS;
|
|
86
|
+
for (;;) {
|
|
87
|
+
try {
|
|
88
|
+
const job = await this.low.postJobs(graph, {
|
|
89
|
+
idempotencyKey: key,
|
|
90
|
+
signal: options.signal,
|
|
91
|
+
});
|
|
92
|
+
return new Job(this.low, job);
|
|
93
|
+
}
|
|
94
|
+
catch (exc) {
|
|
95
|
+
if (!(exc instanceof ApiError))
|
|
96
|
+
throw exc;
|
|
97
|
+
const err = toSdkError(exc);
|
|
98
|
+
if (err instanceof QueueFull && Date.now() < deadline) {
|
|
99
|
+
await abortableSleep((err.retryAfter || DEFAULT_RETRY_AFTER_S) * 1000, options.signal);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Submit, then poll to terminal (authoritative). Throws on failure. */
|
|
107
|
+
async run(workflow, options = {}) {
|
|
108
|
+
const job = await this.submit(workflow, { signal: options.signal });
|
|
109
|
+
return options.timeoutMs === undefined
|
|
110
|
+
? job.result(options.signal)
|
|
111
|
+
: runWithTimeout(job, options.timeoutMs, options.signal);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function runWithTimeout(job, timeoutMs, signal) {
|
|
115
|
+
await job.wait(timeoutMs, signal);
|
|
116
|
+
if (job.status !== SUCCESS) {
|
|
117
|
+
throw new JobFailed(`job ${job.id} ended ${job.status}`, { error: job.error });
|
|
118
|
+
}
|
|
119
|
+
return job;
|
|
120
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sans-IO core shared across the `sdk` layer.
|
|
3
|
+
*
|
|
4
|
+
* Pure decision logic — no network, no awaiting — for terminal-state
|
|
5
|
+
* detection, adaptive poll backoff, idempotency-key minting, the
|
|
6
|
+
* `core/ASSET` reference shape, and the workflow-graph walk that finds and
|
|
7
|
+
* substitutes asset handles. Mirrors `comfy_sdk._core` in the Python SDK.
|
|
8
|
+
*/
|
|
9
|
+
import type { AssetReference } from "../low/index.js";
|
|
10
|
+
export declare const TERMINAL: Set<string>;
|
|
11
|
+
export declare const SUCCESS = "succeeded";
|
|
12
|
+
export declare function newIdempotencyKey(): string;
|
|
13
|
+
export declare function isTerminal(status: string): boolean;
|
|
14
|
+
/** Adaptive poll intervals (ms): start small, grow, then hold at the cap. */
|
|
15
|
+
export declare function backoffSchedule(start?: number, factor?: number, cap?: number): Generator<number, never, void>;
|
|
16
|
+
/** Build the `core/ASSET` object substituted into workflow JSON. */
|
|
17
|
+
export declare function assetReference(assetId: string, options?: {
|
|
18
|
+
hash?: string | null;
|
|
19
|
+
filePath?: string | null;
|
|
20
|
+
}): AssetReference;
|
|
21
|
+
export declare const ASSET_HANDLE: unique symbol;
|
|
22
|
+
export interface AssetHandleLike {
|
|
23
|
+
readonly [ASSET_HANDLE]: true;
|
|
24
|
+
asReference(signal?: AbortSignal): Promise<AssetReference>;
|
|
25
|
+
}
|
|
26
|
+
/** Collect every asset handle embedded anywhere in the workflow graph. */
|
|
27
|
+
export declare function findAssetHandles(graph: unknown): AssetHandleLike[];
|
|
28
|
+
/**
|
|
29
|
+
* Return a copy of `graph` with each asset handle replaced by its
|
|
30
|
+
* `core/ASSET` reference, keyed by identity in `refs`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function substituteAssetHandles(graph: unknown, refs: Map<AssetHandleLike, AssetReference>): unknown;
|
|
33
|
+
export declare function looksLikeUiFormat(graph: unknown): boolean;
|
package/dist/sdk/core.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sans-IO core shared across the `sdk` layer.
|
|
3
|
+
*
|
|
4
|
+
* Pure decision logic — no network, no awaiting — for terminal-state
|
|
5
|
+
* detection, adaptive poll backoff, idempotency-key minting, the
|
|
6
|
+
* `core/ASSET` reference shape, and the workflow-graph walk that finds and
|
|
7
|
+
* substitutes asset handles. Mirrors `comfy_sdk._core` in the Python SDK.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
// Terminal job states. `canceling` is deliberately NOT terminal —
|
|
11
|
+
// cancellation takes effect at node/step boundaries and can take seconds.
|
|
12
|
+
export const TERMINAL = new Set(["succeeded", "canceled", "failed", "expired"]);
|
|
13
|
+
export const SUCCESS = "succeeded";
|
|
14
|
+
export function newIdempotencyKey() {
|
|
15
|
+
return randomUUID();
|
|
16
|
+
}
|
|
17
|
+
export function isTerminal(status) {
|
|
18
|
+
return TERMINAL.has(status);
|
|
19
|
+
}
|
|
20
|
+
/** Adaptive poll intervals (ms): start small, grow, then hold at the cap. */
|
|
21
|
+
export function* backoffSchedule(start = 500, factor = 1.5, cap = 5_000) {
|
|
22
|
+
let delay = start;
|
|
23
|
+
for (;;) {
|
|
24
|
+
yield delay;
|
|
25
|
+
delay = Math.min(delay * factor, cap);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Build the `core/ASSET` object substituted into workflow JSON. */
|
|
29
|
+
export function assetReference(assetId, options = {}) {
|
|
30
|
+
const info = { id: assetId };
|
|
31
|
+
if (options.hash)
|
|
32
|
+
info.hash = options.hash;
|
|
33
|
+
if (options.filePath)
|
|
34
|
+
info.file_path = options.filePath;
|
|
35
|
+
return { __type: "core/ASSET", info };
|
|
36
|
+
}
|
|
37
|
+
// A unique marker so `findAssetHandles`/`substituteAssetHandles` can spot an
|
|
38
|
+
// asset handle embedded anywhere in a workflow graph without importing
|
|
39
|
+
// `Asset` here (avoids a core <-> assets circular import).
|
|
40
|
+
export const ASSET_HANDLE = Symbol("comfy.assetHandle");
|
|
41
|
+
function isAssetHandle(value) {
|
|
42
|
+
return (typeof value === "object" &&
|
|
43
|
+
value !== null &&
|
|
44
|
+
value[ASSET_HANDLE] === true);
|
|
45
|
+
}
|
|
46
|
+
/** Collect every asset handle embedded anywhere in the workflow graph. */
|
|
47
|
+
export function findAssetHandles(graph) {
|
|
48
|
+
if (isAssetHandle(graph))
|
|
49
|
+
return [graph];
|
|
50
|
+
if (Array.isArray(graph))
|
|
51
|
+
return graph.flatMap(findAssetHandles);
|
|
52
|
+
if (graph !== null && typeof graph === "object") {
|
|
53
|
+
return Object.values(graph).flatMap(findAssetHandles);
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Return a copy of `graph` with each asset handle replaced by its
|
|
59
|
+
* `core/ASSET` reference, keyed by identity in `refs`.
|
|
60
|
+
*/
|
|
61
|
+
export function substituteAssetHandles(graph, refs) {
|
|
62
|
+
if (isAssetHandle(graph)) {
|
|
63
|
+
const ref = refs.get(graph);
|
|
64
|
+
if (!ref)
|
|
65
|
+
throw new Error("asset handle was not materialized before substitution");
|
|
66
|
+
return ref;
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(graph)) {
|
|
69
|
+
return graph.map((v) => substituteAssetHandles(v, refs));
|
|
70
|
+
}
|
|
71
|
+
if (graph !== null && typeof graph === "object") {
|
|
72
|
+
return Object.fromEntries(Object.entries(graph).map(([k, v]) => [
|
|
73
|
+
k,
|
|
74
|
+
substituteAssetHandles(v, refs),
|
|
75
|
+
]));
|
|
76
|
+
}
|
|
77
|
+
return graph;
|
|
78
|
+
}
|
|
79
|
+
// UI-export detection: the ComfyUI editor export carries these top-level
|
|
80
|
+
// keys, whereas the API format is a flat node-id -> node map. Catching it
|
|
81
|
+
// locally lets the SDK fail fast with a clear message instead of relying
|
|
82
|
+
// only on the server.
|
|
83
|
+
const UI_KEYS = ["nodes", "links", "last_node_id"];
|
|
84
|
+
export function looksLikeUiFormat(graph) {
|
|
85
|
+
if (graph === null || typeof graph !== "object" || Array.isArray(graph))
|
|
86
|
+
return false;
|
|
87
|
+
const record = graph;
|
|
88
|
+
return UI_KEYS.every((key) => key in record);
|
|
89
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed, discriminated (`switch (event.kind)`-able) streamed events.
|
|
3
|
+
*
|
|
4
|
+
* The raw SSE frames from `../low` (an event name + a data record) are
|
|
5
|
+
* lifted into a small closed set of interfaces so callers can pattern-match:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* for await (const event of job.events()) {
|
|
9
|
+
* switch (event.kind) {
|
|
10
|
+
* case "progress": ...
|
|
11
|
+
* case "outputReady": await event.output.toFile(...); break;
|
|
12
|
+
* case "statusChange": if (event.status === "succeeded") return;
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Named to match the Python SDK's `comfy_sdk.events` (`Progress`,
|
|
18
|
+
* `Preview`, `OutputReady`, `StatusChange`, `Log`) with one deviation: the
|
|
19
|
+
* union is `ComfyEvent`, not `Event` — `Event` is a global DOM/Node type.
|
|
20
|
+
*/
|
|
21
|
+
import type { RawEvent } from "../low/index.js";
|
|
22
|
+
import type { Output } from "./outputs.js";
|
|
23
|
+
export interface Progress {
|
|
24
|
+
kind: "progress";
|
|
25
|
+
value: number;
|
|
26
|
+
message: string | null;
|
|
27
|
+
nodesDone: number | null;
|
|
28
|
+
nodesTotal: number | null;
|
|
29
|
+
currentNode: string | null;
|
|
30
|
+
step: number | null;
|
|
31
|
+
steps: number | null;
|
|
32
|
+
}
|
|
33
|
+
export interface Preview {
|
|
34
|
+
kind: "preview";
|
|
35
|
+
nodeId: string;
|
|
36
|
+
contentType: string;
|
|
37
|
+
data: Uint8Array;
|
|
38
|
+
}
|
|
39
|
+
export interface OutputReady {
|
|
40
|
+
kind: "outputReady";
|
|
41
|
+
output: Output;
|
|
42
|
+
}
|
|
43
|
+
export interface StatusChange {
|
|
44
|
+
kind: "statusChange";
|
|
45
|
+
status: string;
|
|
46
|
+
queuePosition: number | null;
|
|
47
|
+
}
|
|
48
|
+
export interface Log {
|
|
49
|
+
kind: "log";
|
|
50
|
+
level: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
export type ComfyEvent = Progress | Preview | OutputReady | StatusChange | Log;
|
|
54
|
+
/**
|
|
55
|
+
* Lift a raw SSE frame into a typed event. `bindOutput` wraps the
|
|
56
|
+
* low-level output model into an SDK `Output` (the only event type that
|
|
57
|
+
* needs the transport, for download). Unknown event names return `null` so
|
|
58
|
+
* the iterator can skip them.
|
|
59
|
+
*/
|
|
60
|
+
export declare function eventFromRaw(raw: RawEvent, bindOutput: (model: Record<string, unknown>) => Output): ComfyEvent | null;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed, discriminated (`switch (event.kind)`-able) streamed events.
|
|
3
|
+
*
|
|
4
|
+
* The raw SSE frames from `../low` (an event name + a data record) are
|
|
5
|
+
* lifted into a small closed set of interfaces so callers can pattern-match:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* for await (const event of job.events()) {
|
|
9
|
+
* switch (event.kind) {
|
|
10
|
+
* case "progress": ...
|
|
11
|
+
* case "outputReady": await event.output.toFile(...); break;
|
|
12
|
+
* case "statusChange": if (event.status === "succeeded") return;
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Named to match the Python SDK's `comfy_sdk.events` (`Progress`,
|
|
18
|
+
* `Preview`, `OutputReady`, `StatusChange`, `Log`) with one deviation: the
|
|
19
|
+
* union is `ComfyEvent`, not `Event` — `Event` is a global DOM/Node type.
|
|
20
|
+
*/
|
|
21
|
+
function progressFrom(data) {
|
|
22
|
+
return {
|
|
23
|
+
kind: "progress",
|
|
24
|
+
value: typeof data.value === "number" ? data.value : 0,
|
|
25
|
+
message: data.message ?? null,
|
|
26
|
+
nodesDone: data.nodes_done ?? null,
|
|
27
|
+
nodesTotal: data.nodes_total ?? null,
|
|
28
|
+
currentNode: data.current_node ?? null,
|
|
29
|
+
step: data.step ?? null,
|
|
30
|
+
steps: data.steps ?? null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function previewFrom(data) {
|
|
34
|
+
const raw = typeof data.data_base64 === "string" ? data.data_base64 : "";
|
|
35
|
+
let decoded;
|
|
36
|
+
try {
|
|
37
|
+
decoded = Uint8Array.from(Buffer.from(raw, "base64"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
decoded = new Uint8Array(0);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
kind: "preview",
|
|
44
|
+
nodeId: typeof data.node_id === "string" ? data.node_id : "",
|
|
45
|
+
contentType: typeof data.content_type === "string" ? data.content_type : "application/octet-stream",
|
|
46
|
+
data: decoded,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function statusFrom(data) {
|
|
50
|
+
return {
|
|
51
|
+
kind: "statusChange",
|
|
52
|
+
status: typeof data.status === "string" ? data.status : "",
|
|
53
|
+
queuePosition: data.queue_position ?? null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function logFrom(data) {
|
|
57
|
+
return {
|
|
58
|
+
kind: "log",
|
|
59
|
+
level: typeof data.level === "string" ? data.level : "info",
|
|
60
|
+
message: typeof data.message === "string" ? data.message : "",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Lift a raw SSE frame into a typed event. `bindOutput` wraps the
|
|
65
|
+
* low-level output model into an SDK `Output` (the only event type that
|
|
66
|
+
* needs the transport, for download). Unknown event names return `null` so
|
|
67
|
+
* the iterator can skip them.
|
|
68
|
+
*/
|
|
69
|
+
export function eventFromRaw(raw, bindOutput) {
|
|
70
|
+
switch (raw.event) {
|
|
71
|
+
case "progress":
|
|
72
|
+
return progressFrom(raw.data);
|
|
73
|
+
case "preview":
|
|
74
|
+
return previewFrom(raw.data);
|
|
75
|
+
case "status":
|
|
76
|
+
return statusFrom(raw.data);
|
|
77
|
+
case "log":
|
|
78
|
+
return logFrom(raw.data);
|
|
79
|
+
case "output":
|
|
80
|
+
return { kind: "outputReady", output: bindOutput(raw.data) };
|
|
81
|
+
default:
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|