Last active
October 9, 2025 20:33
-
-
Save kishida/0d4bd9d3a937a1383e7c2295fea88ef3 to your computer and use it in GitHub Desktop.
ComfyUI Client for Java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package naoki.vcc; | |
| import java.io.*; | |
| import java.net.URI; | |
| import java.net.URLEncoder; | |
| import java.net.http.*; | |
| import java.net.http.WebSocket; | |
| import java.nio.charset.StandardCharsets; | |
| import java.nio.file.Files; | |
| import java.util.*; | |
| import java.util.concurrent.*; | |
| import com.fasterxml.jackson.databind.*; | |
| import com.fasterxml.jackson.databind.node.*; | |
| import java.nio.file.Path; | |
| public class ComfyUIClient implements AutoCloseable { | |
| private static final String SERVER_ADDRESS = "localhost:8188"; | |
| private static final String CLIENT_ID = "my_client_id"; | |
| private static final String WORKFLOW = "qwen_image_gguf.json"; | |
| private final ObjectNode promptData; | |
| private WebSocket ws; | |
| private final HttpClient client; | |
| private static final ObjectMapper mapper = new ObjectMapper(); | |
| private ComfyHandler comfyHandler; | |
| private final Map<String, GenerationHandler> handlers = new HashMap<>(); | |
| private final Random rand = new Random(); | |
| public interface ComfyHandler { | |
| void onOpen(); | |
| void onSid(String sid); | |
| void onQueueStatus(int remaining); | |
| void onClose(int statusCode, String reason); | |
| void onError(Throwable error); | |
| } | |
| public interface GenerationHandler { | |
| void onStart(); | |
| void onFinish(byte[] imageData); | |
| void onProgress(int value, int max); | |
| void onError(IOException ex); | |
| } | |
| public ComfyUIClient() throws IOException{ | |
| this(null); | |
| } | |
| public ComfyUIClient(ComfyHandler handler) throws IOException{ | |
| this.comfyHandler = handler; | |
| client = HttpClient.newHttpClient(); | |
| // プロンプトJSON読み込み | |
| promptData = loadPromptJson(WORKFLOW); | |
| // WebSocket接続開始 | |
| startWebSocket(); | |
| } | |
| public void startGenerate(String positivePrompt, String negativePrompt, GenerationHandler handler) throws UncheckedIOException{ | |
| startGenerate(positivePrompt, negativePrompt, rand.nextInt(9_999_999), handler); | |
| } | |
| public void startGenerate(String positivePrompt, String negativePrompt, int seed, GenerationHandler handler) throws UncheckedIOException{ | |
| try { | |
| var data = createPromptData(promptData, positivePrompt, negativePrompt, seed); | |
| var id = comfyUIPrompt(data); | |
| synchronized (handlers) { | |
| handlers.put(id, handler); | |
| } | |
| } catch (IOException ex) { | |
| throw new UncheckedIOException(ex); | |
| } | |
| } | |
| @Override | |
| public void close() { | |
| if (ws != null) ws.sendClose(WebSocket.NORMAL_CLOSURE, "bye"); | |
| } | |
| /** WebSocket接続 */ | |
| private void startWebSocket() { | |
| CountDownLatch wsConnected = new CountDownLatch(1); | |
| client.newWebSocketBuilder() | |
| .buildAsync(URI.create("ws://" + SERVER_ADDRESS + "/ws?clientId=" + CLIENT_ID), | |
| new WSListener()) | |
| .thenAccept(socket -> { | |
| ws = socket; | |
| wsConnected.countDown(); | |
| }); | |
| try { | |
| wsConnected.await(); | |
| } catch (InterruptedException e) { | |
| if (comfyHandler != null) comfyHandler.onError(e); | |
| } | |
| } | |
| /** prompt.json 読み込み */ | |
| private ObjectNode loadPromptJson(String path) throws IOException { | |
| return (ObjectNode) mapper.readTree(Files.readString(Path.of(path), StandardCharsets.UTF_8)); | |
| } | |
| /** プロンプト更新 */ | |
| private ObjectNode createPromptData(ObjectNode n, String positive, String negative, int seed) { | |
| ObjectNode prompt = n.deepCopy(); | |
| Iterator<String> fields = prompt.fieldNames(); | |
| while (fields.hasNext()) { | |
| String key = fields.next(); | |
| JsonNode node = prompt.get(key); | |
| if (!(node instanceof ObjectNode obj)) continue; | |
| String classType = obj.path("class_type").asText(); | |
| ObjectNode inputs = (ObjectNode) obj.path("inputs"); | |
| if ("KSampler".equals(classType) && inputs != null) { | |
| if (inputs.has("seed")) { | |
| inputs.put("seed", seed); | |
| System.out.println("🛠️ " + key + " の seed を " + seed + " に設定"); | |
| } | |
| JsonNode posRef = inputs.get("positive"); | |
| if (posRef != null && posRef.isArray() && posRef.size() > 0) { | |
| String refId = posRef.get(0).asText(); | |
| JsonNode refNode = prompt.get(refId); | |
| if (refNode instanceof ObjectNode refObj) { | |
| ObjectNode refInputs = (ObjectNode) refObj.path("inputs"); | |
| refInputs.put("text", positive); | |
| System.out.println("📝 Positiveプロンプト: " + positive); | |
| } | |
| } | |
| JsonNode negRef = inputs.get("negative"); | |
| if (negRef != null && negRef.isArray() && negRef.size() > 0) { | |
| String refId = negRef.get(0).asText(); | |
| JsonNode refNode = prompt.get(refId); | |
| if (refNode instanceof ObjectNode refObj) { | |
| ObjectNode refInputs = (ObjectNode) refObj.path("inputs"); | |
| refInputs.put("text", negative); | |
| System.out.println("📝 Negativeプロンプト: " + negative); | |
| } | |
| } | |
| } | |
| } | |
| return prompt; | |
| } | |
| /** ComfyUI: プロンプト送信 */ | |
| private String comfyUIPrompt(ObjectNode prompt) throws IOException{ | |
| try { | |
| ObjectNode payload = mapper.createObjectNode(); | |
| payload.put("client_id", CLIENT_ID); | |
| payload.set("prompt", prompt); | |
| HttpRequest request = HttpRequest.newBuilder() | |
| .uri(URI.create("http://" + SERVER_ADDRESS + "/prompt")) | |
| .POST(HttpRequest.BodyPublishers.ofString(payload.toString())) | |
| .header("Content-Type", "application/json") | |
| .build(); | |
| HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); | |
| System.out.println("📥 サーバー応答: " + response.body()); | |
| JsonNode body = mapper.readTree(response.body()); | |
| String id = body.path("prompt_id").asText(null); | |
| System.out.println(" ⇨ prompt_id: " + id); | |
| return id; | |
| } catch ( InterruptedException e) { | |
| System.out.println("❌ workflow送信エラー: " + e.getMessage()); | |
| return null; | |
| } | |
| } | |
| /** ComfyUI: 画像取得 */ | |
| private byte[] comfyUIView(String filename, String type, String subfolder) throws IOException { | |
| String url = String.format("http://%s/view?filename=%s&type=%s&subfolder=%s", | |
| SERVER_ADDRESS, | |
| URLEncoder.encode(filename, StandardCharsets.UTF_8), | |
| URLEncoder.encode(type, StandardCharsets.UTF_8), | |
| URLEncoder.encode(subfolder, StandardCharsets.UTF_8)); | |
| System.out.println("✅ 画像取得: " + url); | |
| HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).build(); | |
| try { | |
| HttpResponse<byte[]> res = client.send(req, HttpResponse.BodyHandlers.ofByteArray()); | |
| return res.body(); | |
| } catch (InterruptedException ex) { | |
| throw new RuntimeException(ex); | |
| } | |
| } | |
| /** WebSocket リスナー */ | |
| private class WSListener implements WebSocket.Listener { | |
| private final StringBuilder buffer = new StringBuilder(); | |
| @Override | |
| public void onOpen(WebSocket webSocket) { | |
| if (comfyHandler != null) comfyHandler.onOpen(); | |
| WebSocket.Listener.super.onOpen(webSocket); | |
| } | |
| @Override | |
| public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) { | |
| buffer.append(data); | |
| if (last) { | |
| handleMessage(buffer.toString()); | |
| buffer.setLength(0); | |
| } | |
| return WebSocket.Listener.super.onText(webSocket, data, last); | |
| } | |
| private void handleMessage(String msgStr) { | |
| try { | |
| JsonNode msg = mapper.readTree(msgStr); | |
| String type = msg.path("type").asText(); | |
| switch (type) { | |
| case "status" -> { | |
| String sid = msg.path("data").path("sid").asText(null); | |
| if (sid != null && comfyHandler != null){ | |
| comfyHandler.onSid(sid); | |
| } | |
| var remaining = msg.findPath("queue_remaining").asInt(-1); | |
| if (remaining >= 0 && comfyHandler != null) { | |
| comfyHandler.onQueueStatus(remaining); | |
| } | |
| } | |
| case "progress" -> { | |
| JsonNode data = msg.path("data"); | |
| int value = data.path("value").asInt(0); | |
| int max = data.path("max").asInt(1); | |
| var id = data.path("prompt_id").asText(); | |
| GenerationHandler h; | |
| synchronized (handlers) { | |
| h = handlers.get(id); | |
| } | |
| if (h != null) h.onProgress(value, max); | |
| } | |
| case "executed" -> { | |
| System.out.println("📥 executed: " + msg); | |
| JsonNode data = msg.path("data"); | |
| var id = data.path("prompt_id").asText(); | |
| JsonNode images = data.path("output").path("images"); | |
| if (images.isArray() && images.size() > 0) { | |
| ObjectNode img = (ObjectNode) images.get(0); | |
| var filename = img.path("filename").asText(); | |
| var filetype = img.path("type").asText(); | |
| var subfolder = img.path("subfolder").asText(); | |
| GenerationHandler h; | |
| synchronized (handlers) { | |
| h = handlers.get(id); | |
| } | |
| if (h != null) { | |
| try { | |
| byte[] imageData = comfyUIView(filename, filetype, subfolder); | |
| h.onFinish(imageData); | |
| } catch (IOException ex) { | |
| h.onError(ex); | |
| } | |
| } | |
| } | |
| } | |
| case "execution_start" -> { | |
| var id = msg.findPath("prompt_id").asText(); | |
| GenerationHandler h; | |
| synchronized (handlers) { | |
| h = handlers.get(id); | |
| } | |
| if (h != null) h.onStart(); | |
| } | |
| case "execution_success" -> { | |
| var id = msg.findPath("prompt_id").asText(); | |
| synchronized (handlers) { | |
| handlers.remove(id); | |
| } | |
| } | |
| default -> {} // 無視 | |
| } | |
| } catch (Exception e) { | |
| if (comfyHandler != null) comfyHandler.onError(e); | |
| } | |
| } | |
| @Override | |
| public void onError(WebSocket webSocket, Throwable error) { | |
| if (comfyHandler != null) comfyHandler.onError(error); | |
| } | |
| @Override | |
| public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) { | |
| if (comfyHandler != null) comfyHandler.onClose(statusCode, reason); | |
| return WebSocket.Listener.super.onClose(webSocket, statusCode, reason); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "3": { | |
| "inputs": { | |
| "seed": 123, | |
| "steps": 8, | |
| "cfg": 2.5, | |
| "sampler_name": "euler", | |
| "scheduler": "simple", | |
| "denoise": 1, | |
| "model": [ | |
| "66", | |
| 0 | |
| ], | |
| "positive": [ | |
| "6", | |
| 0 | |
| ], | |
| "negative": [ | |
| "7", | |
| 0 | |
| ], | |
| "latent_image": [ | |
| "58", | |
| 0 | |
| ] | |
| }, | |
| "class_type": "KSampler", | |
| "_meta": { | |
| "title": "Kサンプラー" | |
| } | |
| }, | |
| "6": { | |
| "inputs": { | |
| "text": "cat", | |
| "clip": [ | |
| "38", | |
| 0 | |
| ] | |
| }, | |
| "class_type": "CLIPTextEncode", | |
| "_meta": { | |
| "title": "CLIP Text Encode (Positive Prompt)" | |
| } | |
| }, | |
| "7": { | |
| "inputs": { | |
| "text": "", | |
| "clip": [ | |
| "38", | |
| 0 | |
| ] | |
| }, | |
| "class_type": "CLIPTextEncode", | |
| "_meta": { | |
| "title": "CLIP Text Encode (Negative Prompt)" | |
| } | |
| }, | |
| "8": { | |
| "inputs": { | |
| "samples": [ | |
| "3", | |
| 0 | |
| ], | |
| "vae": [ | |
| "39", | |
| 0 | |
| ] | |
| }, | |
| "class_type": "VAEDecode", | |
| "_meta": { | |
| "title": "VAEデコード" | |
| } | |
| }, | |
| "38": { | |
| "inputs": { | |
| "clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors", | |
| "type": "qwen_image", | |
| "device": "default" | |
| }, | |
| "class_type": "CLIPLoader", | |
| "_meta": { | |
| "title": "CLIPを読み込む" | |
| } | |
| }, | |
| "39": { | |
| "inputs": { | |
| "vae_name": "qwen_image_vae.safetensors" | |
| }, | |
| "class_type": "VAELoader", | |
| "_meta": { | |
| "title": "VAEを読み込む" | |
| } | |
| }, | |
| "58": { | |
| "inputs": { | |
| "width": 512, | |
| "height": 512, | |
| "batch_size": 1 | |
| }, | |
| "class_type": "EmptySD3LatentImage", | |
| "_meta": { | |
| "title": "空のSD3潜在画像" | |
| } | |
| }, | |
| "60": { | |
| "inputs": { | |
| "filename_prefix": "ComfyUI", | |
| "images": [ | |
| "8", | |
| 0 | |
| ] | |
| }, | |
| "class_type": "SaveImage", | |
| "_meta": { | |
| "title": "画像を保存" | |
| } | |
| }, | |
| "66": { | |
| "inputs": { | |
| "shift": 3.1000000000000005, | |
| "model": [ | |
| "73", | |
| 0 | |
| ] | |
| }, | |
| "class_type": "ModelSamplingAuraFlow", | |
| "_meta": { | |
| "title": "モデルサンプリングオーラフロー" | |
| } | |
| }, | |
| "73": { | |
| "inputs": { | |
| "lora_name": "Qwen-Image-Lightning-8steps-V2.0.safetensors", | |
| "strength_model": 1, | |
| "model": [ | |
| "76", | |
| 0 | |
| ] | |
| }, | |
| "class_type": "LoraLoaderModelOnly", | |
| "_meta": { | |
| "title": "LoRAローダーモデルのみ" | |
| } | |
| }, | |
| "76": { | |
| "inputs": { | |
| "unet_name": "Qwen_Image-Q3_K_M.gguf" | |
| }, | |
| "class_type": "UnetLoaderGGUF", | |
| "_meta": { | |
| "title": "Unet Loader (GGUF)" | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment