开发基于ComfyUI图片生成类应用Step by step(二)极简的出图程序
我们已经搭建了ComfyUI环境,现在尝试生成一张图片。我们使用TypeScript编写一个极简的程序实现这个目标。
程序代码
是一个约 180 行、零第三方依赖的完整 TypeScript,这段程序完成如下工作流:
连接常驻服务 → 提交文生图工作流 → 排队等待 → 取回生成图片。
直接上代码:
/**
* 使用 ComfyUI 常驻服务的最小 TypeScript 示例(零依赖,Node 20+ 原生运行)。
*
* 演示完整的四步接入流程:
* 1. POST /upload/image 上传参考图(本示例跳过,文生图不需要)
* 2. POST /prompt 提交 API 格式的工作流 JSON
* 3. GET /history/<id> 轮询任务状态(也可用 WebSocket 实时监听)
* 4. GET /view 下载生成结果
*
* 用法:
* node demo.ts # 默认提示词
* node demo.ts "一只戴帽子的橘猫" # 自定义提示词
* COMFY_URL=http://127.0.0.1:8188 node demo.ts
*
* 前提:ComfyUI常驻服务已启动。
*/
// ---------------- 配置 ----------------
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
const BASE = process.env.COMFY_URL ?? "http://127.0.0.1:8188";
const CLIENT_ID = `demo-ts-${randomUUID().slice(0, 8)}`; // 多应用共享时区分订阅者
// 锚定到脚本所在目录,避免“从哪里运行就存哪里”
const OUTPUT = path.join(import.meta.dirname, "outputs", "demo.png");
// 与常驻服务加载的模型保持一致(模型目录映射见 extra_model_paths.yaml)
const UNET = "qwen-image-2.1-UC-Q4_K_M.gguf";
const CLIP = "qwen3vl_8b_int8_convrot.safetensors";
const VAE = "qwen_image_2.1_vae_bf16.safetensors";
// ---------------- HTTP helper ----------------
// Node 的 fetch 对 127.0.0.1 默认不走系统代理(NO_PROXY 语义内置)
async function api(path: string, init?: RequestInit): Promise<unknown> {
const res = await fetch(BASE + path, { ...init, signal: AbortSignal.timeout(30_000) });
if (!res.ok) {
const detail = (await res.text()).slice(0, 600);
throw new Error(`HTTP ${res.status}:${detail}`);
}
return res.json();
}
async function ping(): Promise<boolean> {
try {
await fetch(`${BASE}/system_stats`, { signal: AbortSignal.timeout(3000) });
return true;
} catch {
return false;
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ---------------- 工作流(API 格式:class_type + inputs 的节点字典) ----------------
interface WorkflowNode {
class_type: string;
inputs: Record<string, unknown>;
}
function buildWorkflow(prompt: string): { client_id: string; prompt: Record<string, WorkflowNode> } {
const g = (class_type: string, inputs: Record<string, unknown>): WorkflowNode =>
({ class_type, inputs });
return {
client_id: CLIENT_ID,
prompt: {
"1": g("UnetLoaderGGUF", { unet_name: UNET }),
"2": g("CLIPLoader", { clip_name: CLIP, type: "qwen_image", device: "default" }),
"3": g("VAELoader", { vae_name: VAE }),
"4": g("ModelSamplingAuraFlow", { model: ["1", 0], shift: 3.1 }),
"5": g("CLIPTextEncode", { clip: ["2", 0], text: prompt }),
"6": g("CLIPTextEncode", { clip: ["2", 0], text: "" }),
"7": g("EmptySD3LatentImage", { width: 768, height: 768, batch_size: 1 }),
"9": g("KSampler", {
model: ["4", 0], positive: ["5", 0], negative: ["6", 0],
latent_image: ["7", 0], seed: Date.now() % 2 ** 31,
steps: 8, cfg: 4.0, sampler_name: "euler", scheduler: "simple", denoise: 1.0,
}),
"10": g("VAEDecode", { samples: ["9", 0], vae: ["3", 0] }),
"11": g("SaveImage", { images: ["10", 0], filename_prefix: "comfy-demo-ts/api" }),
},
};
}
// ---------------- 主流程 ----------------
async function main(): Promise<void> {
const prompt = process.argv.slice(2).join(" ") || "一只戴帽子的橘猫,水彩画风格";
if (!(await ping())) {
console.error(`[错误] ComfyUI 服务不可达(${BASE})`);
console.error(" 请先启动常驻服务:在 comfy-service 项目根目录执行 ./start.sh");
process.exit(1);
}
console.log(`[连接] ${BASE} client_id=${CLIENT_ID}`);
// 1. 提交工作流(节点有错时 ComfyUI 返回 400 + node_errors 详情,api() 会带出)
let promptId: string;
let position: number;
try {
const resp = (await api("/prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildWorkflow(prompt)),
})) as { prompt_id: string; number: number };
promptId = resp.prompt_id;
// /prompt 返回的 number 是单个整数:本任务在队列中的位置(0 = 正在执行)
position = resp.number ?? 0;
} catch (err) {
console.error(`[失败] ComfyUI 拒绝了工作流:\n${(err as Error).message}`);
process.exit(1);
}
console.log(`[提交] prompt_id=${promptId}(队列位置 ${position}:` +
`${position === 0 ? "正在执行" : `前面还有 ${position} 个任务`})`);
console.log(`[提示词] ${prompt}`);
// 2. 轮询 /history 直到出现该任务(生产应用建议用 WebSocket 实时听进度)
const started = Date.now();
interface OutputImage { filename: string; subfolder?: string; type?: string }
let outputs: Record<string, { images?: OutputImage[] }> = {};
for (;;) {
await sleep(3000);
const history = (await api(`/history/${promptId}`)) as
Record<string, { status?: { status_str?: string }; outputs?: typeof outputs }>;
const entry = history[promptId];
if (!entry) {
process.stdout.write(`\r[等待] ${Math.floor((Date.now() - started) / 1000)}s …`);
continue;
}
if (entry.status?.status_str === "error") {
console.error(`\n[失败] ${JSON.stringify(entry.status)}`);
process.exit(1);
}
outputs = entry.outputs ?? {};
break;
}
// 3. 取回输出图片
for (const nodeOut of Object.values(outputs)) {
for (const img of nodeOut.images ?? []) {
const q = new URLSearchParams({
filename: img.filename,
subfolder: img.subfolder ?? "",
type: img.type ?? "output",
});
const res = await fetch(`${BASE}/view?${q}`, { signal: AbortSignal.timeout(60_000) });
const raw = Buffer.from(await res.arrayBuffer());
await mkdir(path.dirname(OUTPUT), { recursive: true });
await writeFile(OUTPUT, raw);
console.log(`\n[完成] 耗时 ${(Date.now() - started) / 1000 | 0}s → ${OUTPUT}(${(raw.length / 1024).toFixed(0)} KB)`);
return;
}
}
console.error("\n[失败] 输出中没有找到图片");
process.exit(1);
}
void main();
运行这段程序,生成如下图像:
node demo.ts "一只戴帽子的橘猫"

我们也可以使用Python等其他语言实现同样的功能,这里不赘述。
demo.ts 演示的接入流程
下表展示了demo的接入流程:
| 步骤 | 接口 | 说明 |
|---|---|---|
| ① 提交 | POST /prompt |
body 为 {"client_id": …, "prompt": {节点dict}}。节点 dict 就是 ComfyUI 画布上「导出(API 格式)」得到的 JSON。返回 prompt_id(任务凭据)和 number(队列位置,0 = 正在执行) |
| ② 等待 | GET /history/<prompt_id> 轮询 |
demo 用轮询演示最简形态;生产应用用 WebSocket ws://…/ws?clientId=… 实时收进度。⚠️ ws 库的 text 帧也以 Buffer 交付,须用 message 事件第二个参数 isBinary 区分(见 image-studio/docs/调试实录) |
| ③ 取图 | GET /view?filename=… |
history 的 outputs 里找到图片条目后下载 |
| (参考图) | POST /upload/image |
图生图 / 智能编辑场景先用 multipart 上传参考图,工作流节点里用返回的文件名引用 |
ComfyUI 工作流简介
现在我们结合demo简单介绍一下ComfyUI工作流。ComfyUI 的工作流本质是一张有向无环图(DAG):每个节点做一件事,节点之间用"连线"传递数据。demo 提交的 JSON 就是这张图的序列化形式:
"1": g("UnetLoaderGGUF", { unet_name: UNET }), // 加载扩散模型
"2": g("CLIPLoader", { clip_name: CLIP, ... }), // 加载文本编码器
"3": g("VAELoader", { vae_name: VAE }), // 加载 VAE
"4": g("ModelSamplingAuraFlow", { model: ["1", 0], shift: 3.1 }),
"5": g("CLIPTextEncode", { clip: ["2", 0], text: prompt }), // 文本 → 条件向量
"7": g("EmptySD3LatentImage", { width: 768, height: 768, ... }), // 空 latent(画布)
"9": g("KSampler", { model: ["4", 0], positive: ["5", 0], ... }), // 采样(核心)
"10": g("VAEDecode", { samples: ["9", 0], vae: ["3", 0] }), // latent → 像素
"11": g("SaveImage", { images: ["10", 0] }), // 输出节点
- key("1"、"9"…)只是节点编号,不重复即可
class_type是节点类型名——对应 ComfyUI 注册表里的一个 Python 类inputs是输入参数,分两类:- 字面量(
text、steps等)——直接写在 JSON 里 - 连线——
["源节点id", 输出槽位],例如["5", 0]= "取节点 5
的第 0 号输出"。连线就是图的边,ComfyUI 据此拓扑排序
- 字面量(
数据在连线上以类型化张量流动:MODEL(模型权重对象)、CLIP(文本编码器)、VAE、LATENT(潜空间张量)、IMAGE(像素张量)、CONDITIONING(条件向量)……类型不匹配的连线连不起来。
工作流节点是什么
每个节点就是一个 Python 类,约定实现以下成员:
class UnetLoaderGGUF:
@staticmethod
def unet_name(): return {"required": {"unet_name": (文件列表,)}}
INPUT_TYPES = unet_name # ① 输入声明(名字、类型、可选值列表)
RETURN_TYPES = ("MODEL",) # ② 输出槽位及类型
FUNCTION = "load_unet" # ③ 执行时调用的方法名
CATEGORY = "bootleg" # ④ 画布上的分组
# load_unet(self, unet_name) 返回 (model,) —— 元组按槽位对应 RETURN_TYPES
GET /object_info/UnetLoaderGGUF 返回的正是这个类的机器可读描述:
{"UnetLoaderGGUF": {
"input": { "required": { "unet_name": [["qwen-image-2.1-UC-Q4_K_M.gguf"]] } },
"output": ["MODEL"],
"python_module": "custom_nodes.ComfyUI-GGUF",
"category": "bootleg"
}}
两个要点:
unet_name的可选值不是写死在节点里的——ComfyUI 扫描模型目录含extra_model_paths.yaml映射进来的目录)动态生成。这就是应用端做"模型管理"时以/object_info为权威列表的原因。python_module字段暴露节点来源(内置 or 自定义)
demo中工作流节点的执行过程如下下面的描述所示:
[模型三件套,各加载一次,全图复用]
1 UnetLoaderGGUF ──MODEL──► 4 ModelSamplingAuraFlow(shift=3.1)──► 9
2 CLIPLoader ──CLIP───► 5/6 CLIPTextEncode(提示词/负面)────► 9
3 VAELoader ──VAE────► 10
[生成链路]
7 EmptySD3LatentImage ──LATENT──► 9 KSampler(8 步、cfg=4.0)
9 KSampler ──LATENT──► 10 VAEDecode ──IMAGE──► 11 SaveImage(写盘 + 登记 history)
从 demo 到完整应用
本 demo 刻意保持"最小"(轮询、无队列、无存储)。如果开发完整的应用,需要:
- 友好的用户界面,Web界面或者App
- 显示后台图片的生成进度
- 生成图片的保存、下载和管理
- 提示词管理
- 支持“文生图”和“图生图”
本系列后续文章将呈现如何开发完整的应用。
本文来自博客园,作者:寻找无名的特质,转载请注明原文链接:https://www.cnblogs.com/zhenl/p/23548613

浙公网安备 33010602011771号