q6 commited on
Commit
f5e47ed
·
1 Parent(s): 0193712

Standardize configurable Comfy pipeline

Browse files
Files changed (4) hide show
  1. API.md +23 -2
  2. app.py +196 -251
  3. requirements.txt +0 -1
  4. workflow_api.py +82 -28
API.md CHANGED
@@ -16,7 +16,28 @@ Returns `{"status":true}` when the application is ready.
16
  POST /api/generate?p=PASSWORD
17
  Content-Type: application/json
18
 
19
- {"prompt":"an orange cat","width":1152,"height":896,"steps":16,"negative":"text, watermark","sampler":"euler_ancestral","scheduler":"karras","cfg":4}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  ```
21
 
22
- Returns the generated PNG at 50% of its full dimensions with content type `image/png`. Add `&res=full` to return the full resolution. Only `prompt` is required. Width and height default to 1152 by 896, steps defaults to 16, sampler defaults to `euler_ancestral`, scheduler defaults to `karras`, CFG defaults to 4, and negative defaults to the built-in negative prompt. Width and height must be multiples of 8 from 64 through 2048; steps must be from 1 through 100.
 
16
  POST /api/generate?p=PASSWORD
17
  Content-Type: application/json
18
 
19
+ {
20
+ "prompt": "an orange cat",
21
+ "negative": "text, watermark",
22
+ "width": 1152,
23
+ "height": 896,
24
+ "model": "52_novaAnimeXL_ilV190.safetensors",
25
+ "loras": [{"name": "8_bikabaka.safetensors", "strength": 1, "clip": 0}],
26
+ "sampler": "euler_ancestral",
27
+ "scheduler": "karras",
28
+ "steps": 16,
29
+ "cfg": 4,
30
+ "upscale": true,
31
+ "upscale_method": "bislerp",
32
+ "upscale_scale": 1.1,
33
+ "second_model": "",
34
+ "second_loras": [],
35
+ "second_sampler": "euler",
36
+ "second_scheduler": "karras",
37
+ "second_steps": 18,
38
+ "second_cfg": 5,
39
+ "denoise": 0.5
40
+ }
41
  ```
42
 
43
+ Returns the generated PNG at 50% of its full dimensions with content type `image/png`. Add `&res=full` to return the full resolution. Only `prompt` is required for the built-in site defaults. LoRAs run in array order. Each request creates a random first-pass seed and, when `upscale` is true, performs latent upscaling followed by a second sampler with another random seed. Leave `second_model` empty to reuse the first model and LoRA chain; otherwise `second_loras` applies to the selected second model. Width and height must be multiples of 8 from 64 through 2048; steps must be from 1 through 100.
app.py CHANGED
@@ -1,4 +1,3 @@
1
- import math
2
  import os
3
  import random
4
  import re
@@ -12,7 +11,6 @@ import warnings
12
  from asyncio.base_events import BaseEventLoop
13
  from concurrent.futures import ThreadPoolExecutor
14
  from datetime import datetime
15
- from io import BytesIO
16
  from pathlib import Path
17
  from zoneinfo import ZoneInfo
18
 
@@ -34,8 +32,6 @@ import gradio as gr
34
  import numpy as np
35
  import spaces
36
  import torch
37
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
38
- from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
39
  from fastapi import Depends, HTTPException, Query
40
  from fastapi.middleware.cors import CORSMiddleware
41
  from fastapi.responses import FileResponse
@@ -56,10 +52,6 @@ OUTPUT_DIR = DATA_DIR / "output"
56
  TIMEZONE = ZoneInfo("Asia/Singapore")
57
  MIB = 1024 * 1024
58
  COPY_SIZE = 16 * MIB
59
- SALT_SIZE = 16
60
- NONCE_SIZE = 12
61
- SCRYPT_N = 2**14
62
- FILE_MAGIC = b"EPNG1"
63
  RETURN_SCALE = .5
64
  PORT = int(os.environ.get("PORT", "7860"))
65
  LOCAL_URL = os.environ.get("LOCAL_URL", f"http://127.0.0.1:{PORT}")
@@ -94,11 +86,6 @@ ASSETS = {
94
  "vae",
95
  "vae/3_sdxlVAE_sdxlVAE.safetensors",
96
  ),
97
- "upscale": (
98
- "1x-Archivist_Soft.pth",
99
- "upscale_models",
100
- "upscale_models/1x-Archivist_Soft.pth",
101
- ),
102
  }
103
 
104
  DEFAULT_NEGATIVE = (
@@ -110,6 +97,19 @@ DEFAULT_NEGATIVE = (
110
  DEFAULT_SAMPLER = "euler_ancestral"
111
  DEFAULT_SCHEDULER = "karras"
112
  DEFAULT_CFG = 4
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  GPU_ABORT_ERROR = "GPU task aborted"
114
 
115
  paths = {}
@@ -207,6 +207,42 @@ def stage_assets():
207
  log(f"Copied {target.name}: {size // MIB} MiB")
208
 
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  def ensure_comfy():
211
  if COMFYUI_PATH.is_dir():
212
  log(f"Found ComfyUI at {COMFYUI_PATH}")
@@ -242,90 +278,44 @@ def init_comfy():
242
 
243
  args.cpu_vae = False
244
 
245
- from comfy_extras.nodes_upscale_model import (
246
- ImageUpscaleWithModel,
247
- UpscaleModelLoader,
248
- )
249
  from nodes import (
 
250
  CheckpointLoaderSimple,
251
  EmptyLatentImage,
252
  KSampler,
 
253
  LoraLoader,
254
  VAEDecode,
255
  VAELoader,
256
  )
257
 
 
 
 
258
  for key, path in paths.items():
259
  kind = ASSETS[key][1]
260
  folder_paths.add_model_folder_path(kind, str(path.parent))
261
 
262
- log(f"Loading checkpoint {paths['checkpoint'].name}")
263
- checkpoint = CheckpointLoaderSimple().load_checkpoint(
264
- ckpt_name=paths["checkpoint"].name
265
- )
266
-
267
- loader = LoraLoader()
268
-
269
- log(f"Loading LoRA {paths['bikabaka'].name}")
270
- first = loader.load_lora(
271
- model=checkpoint[0],
272
- clip=checkpoint[1],
273
- lora_name=paths["bikabaka"].name,
274
- strength_model=.5,
275
- strength_clip=0,
276
- )
277
-
278
- log(f"Loading LoRA {paths['5cm'].name}")
279
- second = loader.load_lora(
280
- model=first[0],
281
- clip=first[1],
282
- lora_name=paths["5cm"].name,
283
- strength_model=1.2,
284
- strength_clip=0,
285
- )
286
-
287
- log(f"Loading LoRA {paths['saturation'].name}")
288
- final = loader.load_lora(
289
- model=second[0],
290
- clip=second[1],
291
- lora_name=paths["saturation"].name,
292
- strength_model=-1.4,
293
- strength_clip=0,
294
- )
295
-
296
  log(f"Loading VAE {paths['vae'].name}")
297
- log(f"Loading upscale model {paths['upscale'].name}")
298
 
299
  state.update(
300
- model=final[0],
301
- clip=final[1],
302
- negative_clip=second[1],
303
  vae=VAELoader().load_vae(
304
  vae_name=paths["vae"].name
305
  )[0],
306
  latent=EmptyLatentImage(),
307
  sample=KSampler(),
308
  decode=VAEDecode(),
309
- upscale_model=UpscaleModelLoader().load_model(
310
- paths["upscale"].name
311
- )[0],
312
- upscale=ImageUpscaleWithModel(),
313
  )
314
 
315
  log("ComfyUI initialization complete")
316
 
317
 
318
- @spaces.GPU(duration=11)
319
- def generate_core(
320
- steps,
321
- positive,
322
- negative,
323
- latent,
324
- seed,
325
- sampler,
326
- scheduler,
327
- cfg,
328
- ):
329
  with torch.inference_mode(), warnings.catch_warnings():
330
  warnings.filterwarnings(
331
  "ignore",
@@ -334,90 +324,105 @@ def generate_core(
334
  module=r"torchsde\._brownian\.brownian_interval",
335
  )
336
 
 
 
 
 
 
 
 
 
 
 
337
  samples = state["sample"].sample(
338
- model=state["model"],
339
- seed=seed,
340
- steps=steps,
341
- cfg=cfg,
342
- sampler_name=sampler,
343
- scheduler=scheduler,
344
- positive=state["clip"].encode_from_tokens_scheduled(positive),
345
- negative=state["negative_clip"].encode_from_tokens_scheduled(negative),
346
  latent_image=latent,
347
  denoise=1,
348
  )[0]
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  image = state["decode"].decode(
351
  vae=state["vae"],
352
  samples=samples,
353
  )[0]
 
354
 
355
- return state["upscale"].upscale(
356
- state["upscale_model"],
357
- image,
358
- )[0][0]
359
 
 
 
 
 
360
 
361
- def generate_image(
362
- prompt,
363
- width=1152,
364
- height=896,
365
- steps=16,
366
- negative=DEFAULT_NEGATIVE,
367
- sampler=DEFAULT_SAMPLER,
368
- scheduler=DEFAULT_SCHEDULER,
369
- cfg=DEFAULT_CFG,
370
- tag="ST",
371
- ):
372
- width = int(width)
373
- height = int(height)
374
- steps = int(steps)
375
- cfg = float(cfg)
376
-
377
- if (
378
- not 64 <= width <= 2048
379
- or width % 8
380
- or not 64 <= height <= 2048
381
- or height % 8
382
- ):
383
- raise ValueError(
384
- "Width and height must be multiples of 8 from 64 to 2048"
385
  )
386
 
387
- if not 1 <= steps <= 100:
388
- raise ValueError("Steps must be from 1 to 100")
389
 
390
- if not 0 <= cfg <= 100:
391
- raise ValueError("CFG must be from 0 to 100")
 
 
 
 
 
392
 
393
  with lock:
394
  init_comfy()
395
 
396
  options = state["sample"].INPUT_TYPES()["required"]
397
- if (
398
- sampler not in options["sampler_name"][0]
399
- or scheduler not in options["scheduler"][0]
400
- ):
 
 
 
 
401
  raise ValueError("Unsupported sampler or scheduler")
402
 
403
- positive = state["clip"].tokenize(prompt)
404
- negative_tokens = state["negative_clip"].tokenize(negative)
405
- latent = state["latent"].generate(
406
- width=width,
407
- height=height,
408
- batch_size=1,
409
- )[0]
410
- seed = random.randrange(2**64)
411
-
412
- image = generate_core(
413
- steps,
414
- positive,
415
- negative_tokens,
416
- latent,
417
- seed,
418
- sampler,
419
- scheduler,
420
- cfg,
421
  )
422
 
423
  result = Image.fromarray(
@@ -430,15 +435,9 @@ def generate_image(
430
 
431
  result.info.update(
432
  image_metadata(
433
- prompt,
434
- width,
435
- height,
436
- steps,
437
- seed,
438
- negative,
439
- sampler,
440
- scheduler,
441
- cfg,
442
  )
443
  )
444
 
@@ -450,38 +449,22 @@ def archive_image(image, tag):
450
  try:
451
  path = DATA_DIR / "img" / datetime.now(TIMEZONE).date().isoformat()
452
  path.mkdir(parents=True, exist_ok=True)
453
- data = BytesIO()
454
  pnginfo = PngInfo()
455
 
456
- for key in ("prompt", "workflow"):
457
- pnginfo.add_text(key, image.info[key])
458
 
459
- image.save(
460
- data,
461
- format="PNG",
462
- pnginfo=pnginfo,
463
- )
464
- salt = os.urandom(SALT_SIZE)
465
- nonce = os.urandom(NONCE_SIZE)
466
- key = Scrypt(
467
- salt=salt,
468
- length=32,
469
- n=SCRYPT_N,
470
- r=8,
471
- p=1,
472
- ).derive(PASSWORD.encode())
473
- encrypted = AESGCM(key).encrypt(nonce, data.getvalue(), FILE_MAGIC)
474
  number = (
475
  sum(
476
- file.suffix.casefold() == ".epng"
477
  for file in path.iterdir()
478
  )
479
  + 1
480
  )
481
- name = f"{number}{tag}.epng"
482
  output = path / name
483
- temp = output.with_suffix(".epng.part")
484
- temp.write_bytes(FILE_MAGIC + salt + nonce + encrypted)
485
  temp.replace(output)
486
  log(f"Saved img/{path.name}/{name}")
487
  except Exception as error:
@@ -506,68 +489,33 @@ def generate(
506
  if not valid_pass(p):
507
  raise gr.Error("Invalid password")
508
 
509
- return generate_image(
510
- prompt,
511
- width,
512
- height,
513
- steps,
514
- negative,
515
- sampler,
516
- scheduler,
517
- cfg,
518
- "GR",
519
  )
 
520
 
521
 
522
- def api_generate(
523
- prompt,
524
- width=1152,
525
- height=896,
526
- steps=16,
527
- negative=DEFAULT_NEGATIVE,
528
- sampler=DEFAULT_SAMPLER,
529
- scheduler=DEFAULT_SCHEDULER,
530
- cfg=DEFAULT_CFG,
531
- p="",
532
- ):
533
  if not valid_pass(p):
534
  raise gr.Error("Invalid password")
535
 
536
- return generate_image(
537
- prompt,
538
- width,
539
- height,
540
- steps,
541
- negative,
542
- sampler,
543
- scheduler,
544
- cfg,
545
- )
546
 
547
 
548
- def queued_generate(
549
- prompt,
550
- width,
551
- height,
552
- steps,
553
- negative=DEFAULT_NEGATIVE,
554
- sampler=DEFAULT_SAMPLER,
555
- scheduler=DEFAULT_SCHEDULER,
556
- cfg=DEFAULT_CFG,
557
- ):
558
  client = Client(LOCAL_URL, verbose=False)
559
 
560
  for attempt in range(2):
561
  try:
562
  result = client.predict(
563
- prompt,
564
- width,
565
- height,
566
- steps,
567
- negative,
568
- sampler,
569
- scheduler,
570
- cfg,
571
  PASSWORD,
572
  api_name="/generate",
573
  )
@@ -654,14 +602,16 @@ def run_job(job_id, workflow):
654
  ) = workflow_values(workflow)
655
 
656
  image = queued_generate(
657
- prompt,
658
- width,
659
- height,
660
- steps,
661
- negative,
662
- sampler,
663
- scheduler,
664
- cfg,
 
 
665
  )
666
 
667
  filename = f"{job_id}.png"
@@ -706,17 +656,6 @@ def run_job(job_id, workflow):
706
  }
707
 
708
 
709
- class DirectRequest(BaseModel):
710
- prompt: str
711
- width: int = Field(1152, ge=64, le=2048)
712
- height: int = Field(896, ge=64, le=2048)
713
- steps: int = Field(16, ge=1, le=100)
714
- negative: str = DEFAULT_NEGATIVE
715
- sampler: str = DEFAULT_SAMPLER
716
- scheduler: str = DEFAULT_SCHEDULER
717
- cfg: float = Field(DEFAULT_CFG, ge=0, le=100)
718
-
719
-
720
  api = App()
721
  api.add_middleware(
722
  CORSMiddleware,
@@ -755,8 +694,11 @@ def system_stats():
755
  dependencies=[Depends(require_pass)],
756
  )
757
  def object_info():
758
- ensure_assets()
759
  options = state["sample"].INPUT_TYPES()["required"]
 
 
 
760
 
761
  return {
762
  "KSampler": {
@@ -775,7 +717,25 @@ def object_info():
775
  "input": {
776
  "required": {
777
  "ckpt_name": [
778
- [paths["checkpoint"].name]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
779
  ]
780
  }
781
  }
@@ -871,16 +831,7 @@ def direct(
871
  res: str = Query(""),
872
  ):
873
  try:
874
- image = queued_generate(
875
- body.prompt,
876
- body.width,
877
- body.height,
878
- body.steps,
879
- body.negative,
880
- body.sampler,
881
- body.scheduler,
882
- body.cfg,
883
- )
884
  except ValueError as error:
885
  raise HTTPException(422, str(error)) from error
886
 
@@ -967,6 +918,7 @@ with gr.Blocks(title="Image API") as demo:
967
 
968
  api_button = gr.Button(visible=False)
969
  api_pass_input = gr.Textbox(visible=False)
 
970
  negative_input = gr.Textbox(
971
  DEFAULT_NEGATIVE,
972
  visible=False,
@@ -1013,14 +965,7 @@ with gr.Blocks(title="Image API") as demo:
1013
  api_button.click(
1014
  api_generate,
1015
  [
1016
- prompt_input,
1017
- width_input,
1018
- height_input,
1019
- steps_input,
1020
- negative_input,
1021
- sampler_input,
1022
- scheduler_input,
1023
- cfg_input,
1024
  api_pass_input,
1025
  ],
1026
  output,
 
 
1
  import os
2
  import random
3
  import re
 
11
  from asyncio.base_events import BaseEventLoop
12
  from concurrent.futures import ThreadPoolExecutor
13
  from datetime import datetime
 
14
  from pathlib import Path
15
  from zoneinfo import ZoneInfo
16
 
 
32
  import numpy as np
33
  import spaces
34
  import torch
 
 
35
  from fastapi import Depends, HTTPException, Query
36
  from fastapi.middleware.cors import CORSMiddleware
37
  from fastapi.responses import FileResponse
 
52
  TIMEZONE = ZoneInfo("Asia/Singapore")
53
  MIB = 1024 * 1024
54
  COPY_SIZE = 16 * MIB
 
 
 
 
55
  RETURN_SCALE = .5
56
  PORT = int(os.environ.get("PORT", "7860"))
57
  LOCAL_URL = os.environ.get("LOCAL_URL", f"http://127.0.0.1:{PORT}")
 
86
  "vae",
87
  "vae/3_sdxlVAE_sdxlVAE.safetensors",
88
  ),
 
 
 
 
 
89
  }
90
 
91
  DEFAULT_NEGATIVE = (
 
97
  DEFAULT_SAMPLER = "euler_ancestral"
98
  DEFAULT_SCHEDULER = "karras"
99
  DEFAULT_CFG = 4
100
+ DEFAULT_MODEL = ASSETS["checkpoint"][0]
101
+ DEFAULT_LORAS = (
102
+ (ASSETS["bikabaka"][0], .5, 0),
103
+ (ASSETS["5cm"][0], 1.2, 0),
104
+ (ASSETS["saturation"][0], -1.4, 0),
105
+ )
106
+ DEFAULT_UPSCALE_METHOD = "bislerp"
107
+ DEFAULT_UPSCALE_SCALE = 1.1
108
+ DEFAULT_SECOND_SAMPLER = "euler"
109
+ DEFAULT_SECOND_SCHEDULER = "karras"
110
+ DEFAULT_SECOND_STEPS = 18
111
+ DEFAULT_SECOND_CFG = 5
112
+ DEFAULT_DENOISE = .5
113
  GPU_ABORT_ERROR = "GPU task aborted"
114
 
115
  paths = {}
 
207
  log(f"Copied {target.name}: {size // MIB} MiB")
208
 
209
 
210
+ class LoraRequest(BaseModel):
211
+ name: str
212
+ strength: float = 1
213
+ clip: float = 0
214
+
215
+
216
+ def default_loras():
217
+ return [
218
+ LoraRequest(name=name, strength=strength, clip=clip)
219
+ for name, strength, clip in DEFAULT_LORAS
220
+ ]
221
+
222
+
223
+ class DirectRequest(BaseModel):
224
+ prompt: str
225
+ negative: str = DEFAULT_NEGATIVE
226
+ width: int = Field(1152, ge=64, le=2048)
227
+ height: int = Field(896, ge=64, le=2048)
228
+ model: str = DEFAULT_MODEL
229
+ loras: list[LoraRequest] = Field(default_factory=default_loras)
230
+ sampler: str = DEFAULT_SAMPLER
231
+ scheduler: str = DEFAULT_SCHEDULER
232
+ steps: int = Field(16, ge=1, le=100)
233
+ cfg: float = Field(DEFAULT_CFG, ge=0, le=100)
234
+ upscale: bool = False
235
+ upscale_method: str = DEFAULT_UPSCALE_METHOD
236
+ upscale_scale: float = Field(DEFAULT_UPSCALE_SCALE, gt=0)
237
+ second_model: str = ""
238
+ second_loras: list[LoraRequest] = Field(default_factory=list)
239
+ second_sampler: str = DEFAULT_SECOND_SAMPLER
240
+ second_scheduler: str = DEFAULT_SECOND_SCHEDULER
241
+ second_steps: int = Field(DEFAULT_SECOND_STEPS, ge=1, le=100)
242
+ second_cfg: float = Field(DEFAULT_SECOND_CFG, ge=0, le=100)
243
+ denoise: float = Field(DEFAULT_DENOISE, ge=0, le=1)
244
+
245
+
246
  def ensure_comfy():
247
  if COMFYUI_PATH.is_dir():
248
  log(f"Found ComfyUI at {COMFYUI_PATH}")
 
278
 
279
  args.cpu_vae = False
280
 
 
 
 
 
281
  from nodes import (
282
+ CLIPTextEncode,
283
  CheckpointLoaderSimple,
284
  EmptyLatentImage,
285
  KSampler,
286
+ LatentUpscaleBy,
287
  LoraLoader,
288
  VAEDecode,
289
  VAELoader,
290
  )
291
 
292
+ for kind in {asset[1] for asset in ASSETS.values()}:
293
+ folder_paths.add_model_folder_path(kind, str(DATA_DIR / kind))
294
+
295
  for key, path in paths.items():
296
  kind = ASSETS[key][1]
297
  folder_paths.add_model_folder_path(kind, str(path.parent))
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  log(f"Loading VAE {paths['vae'].name}")
 
300
 
301
  state.update(
302
+ checkpoint=CheckpointLoaderSimple(),
303
+ lora=LoraLoader(),
304
+ encode=CLIPTextEncode(),
305
  vae=VAELoader().load_vae(
306
  vae_name=paths["vae"].name
307
  )[0],
308
  latent=EmptyLatentImage(),
309
  sample=KSampler(),
310
  decode=VAEDecode(),
311
+ upscale=LatentUpscaleBy(),
 
 
 
312
  )
313
 
314
  log("ComfyUI initialization complete")
315
 
316
 
317
+ @spaces.GPU(duration=30)
318
+ def generate_core(body):
 
 
 
 
 
 
 
 
 
319
  with torch.inference_mode(), warnings.catch_warnings():
320
  warnings.filterwarnings(
321
  "ignore",
 
324
  module=r"torchsde\._brownian\.brownian_interval",
325
  )
326
 
327
+ request = DirectRequest.model_validate_json(body)
328
+ model, clip = load_chain(request.model, request.loras)
329
+ positive = state["encode"].encode(request.prompt, clip)[0]
330
+ negative = state["encode"].encode(request.negative, clip)[0]
331
+ latent = state["latent"].generate(
332
+ width=request.width,
333
+ height=request.height,
334
+ batch_size=1,
335
+ )[0]
336
+ first_seed = random.randrange(2**64)
337
  samples = state["sample"].sample(
338
+ model=model,
339
+ seed=first_seed,
340
+ steps=request.steps,
341
+ cfg=request.cfg,
342
+ sampler_name=request.sampler,
343
+ scheduler=request.scheduler,
344
+ positive=positive,
345
+ negative=negative,
346
  latent_image=latent,
347
  denoise=1,
348
  )[0]
349
 
350
+ second_seed = None
351
+ if request.upscale:
352
+ samples = state["upscale"].upscale(
353
+ samples=samples,
354
+ upscale_method=request.upscale_method,
355
+ scale_by=request.upscale_scale,
356
+ )[0]
357
+ if request.second_model:
358
+ model, clip = load_chain(
359
+ request.second_model,
360
+ request.second_loras,
361
+ )
362
+ positive = state["encode"].encode(request.prompt, clip)[0]
363
+ negative = state["encode"].encode(request.negative, clip)[0]
364
+
365
+ second_seed = random.randrange(2**64)
366
+ samples = state["sample"].sample(
367
+ model=model,
368
+ seed=second_seed,
369
+ steps=request.second_steps,
370
+ cfg=request.second_cfg,
371
+ sampler_name=request.second_sampler,
372
+ scheduler=request.second_scheduler,
373
+ positive=positive,
374
+ negative=negative,
375
+ latent_image=samples,
376
+ denoise=request.denoise,
377
+ )[0]
378
+
379
  image = state["decode"].decode(
380
  vae=state["vae"],
381
  samples=samples,
382
  )[0]
383
+ return image[0], first_seed, second_seed
384
 
 
 
 
 
385
 
386
+ def load_chain(model_name, loras):
387
+ model, clip = state["checkpoint"].load_checkpoint(
388
+ ckpt_name=model_name
389
+ )[:2]
390
 
391
+ for lora in loras:
392
+ model, clip = state["lora"].load_lora(
393
+ model=model,
394
+ clip=clip,
395
+ lora_name=lora.name,
396
+ strength_model=lora.strength,
397
+ strength_clip=lora.clip,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  )
399
 
400
+ return model, clip
 
401
 
402
+
403
+ def generate_image(request, tag="ST"):
404
+ if (
405
+ request.width % 8
406
+ or request.height % 8
407
+ ):
408
+ raise ValueError("Width and height must be multiples of 8")
409
 
410
  with lock:
411
  init_comfy()
412
 
413
  options = state["sample"].INPUT_TYPES()["required"]
414
+ samplers = [request.sampler]
415
+ schedulers = [request.scheduler]
416
+ if request.upscale:
417
+ samplers.append(request.second_sampler)
418
+ schedulers.append(request.second_scheduler)
419
+ if any(value not in options["sampler_name"][0] for value in samplers):
420
+ raise ValueError("Unsupported sampler or scheduler")
421
+ if any(value not in options["scheduler"][0] for value in schedulers):
422
  raise ValueError("Unsupported sampler or scheduler")
423
 
424
+ image, first_seed, second_seed = generate_core(
425
+ request.model_dump_json()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  )
427
 
428
  result = Image.fromarray(
 
435
 
436
  result.info.update(
437
  image_metadata(
438
+ request.model_dump(),
439
+ [first_seed, second_seed],
440
+ paths["vae"].name,
 
 
 
 
 
 
441
  )
442
  )
443
 
 
449
  try:
450
  path = DATA_DIR / "img" / datetime.now(TIMEZONE).date().isoformat()
451
  path.mkdir(parents=True, exist_ok=True)
 
452
  pnginfo = PngInfo()
453
 
454
+ for key, value in image.info.items():
455
+ pnginfo.add_text(key, value)
456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  number = (
458
  sum(
459
+ file.suffix.casefold() == ".png"
460
  for file in path.iterdir()
461
  )
462
  + 1
463
  )
464
+ name = f"{number}_{tag}.png"
465
  output = path / name
466
+ temp = output.with_suffix(".png.part")
467
+ image.save(temp, format="PNG", pnginfo=pnginfo)
468
  temp.replace(output)
469
  log(f"Saved img/{path.name}/{name}")
470
  except Exception as error:
 
489
  if not valid_pass(p):
490
  raise gr.Error("Invalid password")
491
 
492
+ request = DirectRequest(
493
+ prompt=prompt,
494
+ width=width,
495
+ height=height,
496
+ steps=steps,
497
+ negative=negative,
498
+ sampler=sampler,
499
+ scheduler=scheduler,
500
+ cfg=cfg,
 
501
  )
502
+ return generate_image(request, "GR")
503
 
504
 
505
+ def api_generate(body, p=""):
 
 
 
 
 
 
 
 
 
 
506
  if not valid_pass(p):
507
  raise gr.Error("Invalid password")
508
 
509
+ return generate_image(DirectRequest.model_validate_json(body))
 
 
 
 
 
 
 
 
 
510
 
511
 
512
+ def queued_generate(request):
 
 
 
 
 
 
 
 
 
513
  client = Client(LOCAL_URL, verbose=False)
514
 
515
  for attempt in range(2):
516
  try:
517
  result = client.predict(
518
+ request.model_dump_json(),
 
 
 
 
 
 
 
519
  PASSWORD,
520
  api_name="/generate",
521
  )
 
602
  ) = workflow_values(workflow)
603
 
604
  image = queued_generate(
605
+ DirectRequest(
606
+ prompt=prompt,
607
+ width=width,
608
+ height=height,
609
+ steps=steps,
610
+ negative=negative,
611
+ sampler=sampler,
612
+ scheduler=scheduler,
613
+ cfg=cfg,
614
+ )
615
  )
616
 
617
  filename = f"{job_id}.png"
 
656
  }
657
 
658
 
 
 
 
 
 
 
 
 
 
 
 
659
  api = App()
660
  api.add_middleware(
661
  CORSMiddleware,
 
694
  dependencies=[Depends(require_pass)],
695
  )
696
  def object_info():
697
+ init_comfy()
698
  options = state["sample"].INPUT_TYPES()["required"]
699
+ checkpoints = state["checkpoint"].INPUT_TYPES()["required"]
700
+ loras = state["lora"].INPUT_TYPES()["required"]
701
+ upscalers = state["upscale"].INPUT_TYPES()["required"]
702
 
703
  return {
704
  "KSampler": {
 
717
  "input": {
718
  "required": {
719
  "ckpt_name": [
720
+ checkpoints["ckpt_name"][0]
721
+ ]
722
+ }
723
+ }
724
+ },
725
+ "LoraLoader": {
726
+ "input": {
727
+ "required": {
728
+ "lora_name": [
729
+ loras["lora_name"][0]
730
+ ]
731
+ }
732
+ }
733
+ },
734
+ "LatentUpscaleBy": {
735
+ "input": {
736
+ "required": {
737
+ "upscale_method": [
738
+ upscalers["upscale_method"][0]
739
  ]
740
  }
741
  }
 
831
  res: str = Query(""),
832
  ):
833
  try:
834
+ image = queued_generate(body)
 
 
 
 
 
 
 
 
 
835
  except ValueError as error:
836
  raise HTTPException(422, str(error)) from error
837
 
 
918
 
919
  api_button = gr.Button(visible=False)
920
  api_pass_input = gr.Textbox(visible=False)
921
+ request_input = gr.Textbox(visible=False)
922
  negative_input = gr.Textbox(
923
  DEFAULT_NEGATIVE,
924
  visible=False,
 
965
  api_button.click(
966
  api_generate,
967
  [
968
+ request_input,
 
 
 
 
 
 
 
969
  api_pass_input,
970
  ],
971
  output,
requirements.txt CHANGED
@@ -1,5 +1,4 @@
1
  fastapi
2
- cryptography
3
  gradio==6.20.0
4
  huggingface_hub>=1.6
5
  numpy
 
1
  fastapi
 
2
  gradio==6.20.0
3
  huggingface_hub>=1.6
4
  numpy
workflow_api.py CHANGED
@@ -582,36 +582,90 @@ def build_extra_pnginfo() -> dict[str, Any] | None:
582
  }
583
 
584
 
585
- def image_metadata(text, width, height, steps, seed, negative, sampler, scheduler, cfg):
586
- api = build_workflow()
587
- api["118"]["inputs"]["string_a"] = text
588
- api["3"]["inputs"]["text"] = negative
589
- api["27"]["inputs"].update(width=width, height=height)
590
- api["5"]["inputs"].update(
591
- seed=seed, steps=steps, sampler_name=sampler, scheduler=scheduler, cfg=cfg,
592
- )
593
- api["28"]["inputs"].update(model=["10", 0], clip=["10", 1])
594
- api.pop("38")
595
-
596
- workflow = build_extra_pnginfo()["workflow"]
597
- nodes = {node["id"]: node for node in workflow["nodes"]}
598
- nodes[118]["widgets_values"][0] = text
599
- nodes[3]["widgets_values"][0] = negative
600
- nodes[27]["widgets_values"][:2] = [width, height]
601
- nodes[5]["widgets_values"][0] = seed
602
- nodes[5]["widgets_values"][2] = steps
603
- nodes[5]["widgets_values"][3] = cfg
604
- nodes[5]["widgets_values"][4:6] = [sampler, scheduler]
605
- nodes[10]["outputs"][0]["links"] = [27]
606
- nodes[10]["outputs"][1]["links"] = [28]
607
- workflow["nodes"] = [node for node in workflow["nodes"] if node["id"] != 38]
608
- workflow["links"] = [
609
- [link[0], 10, *link[2:]] if link[0] in {27, 28} else link
610
- for link in workflow["links"] if link[0] not in {29, 30}
611
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
  return {
613
  "prompt": json.dumps(api, separators=(",", ":")),
614
- "workflow": json.dumps(workflow, separators=(",", ":")),
615
  }
616
 
617
 
 
582
  }
583
 
584
 
585
+ def image_metadata(config, seeds, vae):
586
+ api = {}
587
+
588
+ def add(class_type, inputs):
589
+ node_id = str(len(api) + 1)
590
+ api[node_id] = {"inputs": inputs, "class_type": class_type}
591
+ return node_id
592
+
593
+ def load_chain(model_name, loras):
594
+ node_id = add("CheckpointLoaderSimple", {"ckpt_name": model_name})
595
+ model = [node_id, 0]
596
+ clip = [node_id, 1]
597
+ for lora in loras:
598
+ node_id = add("LoraLoader", {
599
+ "lora_name": lora["name"],
600
+ "strength_model": lora["strength"],
601
+ "strength_clip": lora["clip"],
602
+ "model": model,
603
+ "clip": clip,
604
+ })
605
+ model = [node_id, 0]
606
+ clip = [node_id, 1]
607
+ return model, clip
608
+
609
+ model, clip = load_chain(config["model"], config["loras"])
610
+ positive = [add("CLIPTextEncode", {
611
+ "text": config["prompt"], "clip": clip,
612
+ }), 0]
613
+ negative = [add("CLIPTextEncode", {
614
+ "text": config["negative"], "clip": clip,
615
+ }), 0]
616
+ latent = [add("EmptyLatentImage", {
617
+ "width": config["width"],
618
+ "height": config["height"],
619
+ "batch_size": 1,
620
+ }), 0]
621
+ samples = [add("KSampler", {
622
+ "seed": seeds[0],
623
+ "steps": config["steps"],
624
+ "cfg": config["cfg"],
625
+ "sampler_name": config["sampler"],
626
+ "scheduler": config["scheduler"],
627
+ "denoise": 1,
628
+ "model": model,
629
+ "positive": positive,
630
+ "negative": negative,
631
+ "latent_image": latent,
632
+ }), 0]
633
+
634
+ if config["upscale"]:
635
+ samples = [add("LatentUpscaleBy", {
636
+ "upscale_method": config["upscale_method"],
637
+ "scale_by": config["upscale_scale"],
638
+ "samples": samples,
639
+ }), 0]
640
+ if config["second_model"]:
641
+ model, clip = load_chain(
642
+ config["second_model"], config["second_loras"],
643
+ )
644
+ positive = [add("CLIPTextEncode", {
645
+ "text": config["prompt"], "clip": clip,
646
+ }), 0]
647
+ negative = [add("CLIPTextEncode", {
648
+ "text": config["negative"], "clip": clip,
649
+ }), 0]
650
+ samples = [add("KSampler", {
651
+ "seed": seeds[1],
652
+ "steps": config["second_steps"],
653
+ "cfg": config["second_cfg"],
654
+ "sampler_name": config["second_sampler"],
655
+ "scheduler": config["second_scheduler"],
656
+ "denoise": config["denoise"],
657
+ "model": model,
658
+ "positive": positive,
659
+ "negative": negative,
660
+ "latent_image": samples,
661
+ }), 0]
662
+
663
+ vae_node = add("VAELoader", {"vae_name": vae})
664
+ image = add("VAEDecode", {"samples": samples, "vae": [vae_node, 0]})
665
+ add("PreviewImage", {"images": [image, 0]})
666
  return {
667
  "prompt": json.dumps(api, separators=(",", ":")),
668
+ "parameters": json.dumps(config, separators=(",", ":")),
669
  }
670
 
671