开发基于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 "一只戴帽子的橘猫"

demo

我们也可以使用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"
}}

两个要点:

  1. unet_name 的可选值不是写死在节点里的——ComfyUI 扫描模型目录含 extra_model_paths.yaml 映射进来的目录)动态生成。这就是应用端做"模型管理"时以 /object_info 为权威列表的原因。
  2. 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
  • 显示后台图片的生成进度
  • 生成图片的保存、下载和管理
  • 提示词管理
  • 支持“文生图”和“图生图”
    本系列后续文章将呈现如何开发完整的应用。
posted @ 2026-11-04 15:47  寻找无名的特质  阅读(85)  评论(0)    收藏  举报