nsfwalex Claude Opus 4.8 (1M context) commited on
Commit
b3b69d0
·
1 Parent(s): 3a2ca6a

feat: stream generation + assistant progress over SSE

Browse files

Convert generate_image / prompt_assistant into progress-yielding generators
so each yield surfaces as an SSE `generating` frame on /gradio_api/call. A
hidden JSON progress output (index 3 for generate_image, index 1 for
prompt_assistant) carries {stage,p,step,total,label} for downstream orchestration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +190 -49
app.py CHANGED
@@ -1,7 +1,10 @@
 
1
  import io
2
  import os
 
3
  import random
4
  import re
 
5
  import time
6
 
7
  import numpy as np
@@ -16,7 +19,24 @@ from diffusers import (
16
  )
17
  from compel import Compel, ReturnedEmbeddingsType
18
  from huggingface_hub import hf_hub_download
19
- from transformers import AutoProcessor, AutoModelForImageTextToText
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  import r2_uploader
22
 
@@ -334,6 +354,14 @@ def get_embed_new(prompt, pipeline, compel, only_convert_string=False, compel_pr
334
  # =============================================================================
335
  # Generation
336
  # =============================================================================
 
 
 
 
 
 
 
 
337
  @spaces.GPU
338
  def generate_image(
339
  model_name,
@@ -348,19 +376,53 @@ def generate_image(
348
  randomize_seed,
349
  progress=gr.Progress(track_tqdm=True),
350
  ):
351
- """Generate an image from the given prompt using the selected model."""
 
 
 
 
 
 
352
  _gpu_start = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  try:
354
- return _generate_image_inner(
355
- model_name, prompt, negative_prompt, use_negative_prompt,
356
- height, width, num_inference_steps, guidance_scale, seed, randomize_seed,
357
- )
 
358
  finally:
 
359
  print(
360
  f"[ImageStudio] GPU time consumed: {time.time() - _gpu_start:.2f}s "
361
  f"(model={model_name}, steps={num_inference_steps}, {int(width)}x{int(height)})",
362
  flush=True,
363
  )
 
 
 
364
 
365
 
366
  def _generate_image_inner(
@@ -374,6 +436,7 @@ def _generate_image_inner(
374
  guidance_scale,
375
  seed,
376
  randomize_seed,
 
377
  ):
378
  if randomize_seed:
379
  seed = random.randint(0, MAX_SEED)
@@ -405,7 +468,7 @@ def _generate_image_inner(
405
  [cond_prompt, cond_negative], precomputed_padding=empty_padding
406
  )
407
 
408
- image = noobxl_pipe(
409
  prompt_embeds=cond_prompt,
410
  pooled_prompt_embeds=pooled_prompt,
411
  negative_prompt_embeds=cond_negative,
@@ -416,19 +479,25 @@ def _generate_image_inner(
416
  num_inference_steps=int(num_inference_steps),
417
  generator=generator,
418
  use_resolution_binning=True,
419
- ).images[0]
 
 
 
420
  return image, seed
421
 
422
  # Default: Z-Image-Turbo (guidance-free distilled model)
423
  generator = torch.Generator("cuda").manual_seed(seed)
424
- image = zimage_pipe(
425
  prompt=prompt,
426
  height=int(height),
427
  width=int(width),
428
  num_inference_steps=int(num_inference_steps),
429
  guidance_scale=0.0,
430
  generator=generator,
431
- ).images[0]
 
 
 
432
  return image, seed
433
 
434
 
@@ -439,52 +508,82 @@ def _generate_image_inner(
439
  def vlm_chat(message, image, reasoning, max_new_tokens, progress=gr.Progress(track_tqdm=True)):
440
  """Answer a single user message, optionally grounded on an uploaded image.
441
 
 
 
 
 
 
442
  ``reasoning`` ("On"/"Off") drives Qwen's ``enable_thinking`` switch: Off skips
443
  the <think> trace for a direct answer (best for prompt rewriting); On lets the
444
  model reason step-by-step first (slower, needs more max_new_tokens).
445
  """
446
  message = (message or "").strip()
447
  if not message and image is None:
448
- return "Please enter a question (and optionally attach an image)."
 
449
 
450
  enable_thinking = (reasoning == "On")
 
451
  _gpu_start = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  try:
453
- content = []
454
- if image is not None:
455
- content.append({"type": "image", "image": image})
456
- content.append({"type": "text", "text": message or "Describe this image."})
457
- messages = [{"role": "user", "content": content}]
458
-
459
- inputs = vlm_processor.apply_chat_template(
460
- messages,
461
- tokenize=True,
462
- add_generation_prompt=True,
463
- return_dict=True,
464
- return_tensors="pt",
465
- enable_thinking=enable_thinking,
466
- ).to(vlm_model.device)
467
-
468
- with torch.inference_mode():
469
- generated = vlm_model.generate(
470
- **inputs,
471
- max_new_tokens=int(max_new_tokens),
472
- do_sample=False,
473
- )
474
- # Drop the prompt tokens so only the freshly generated answer is decoded.
475
- trimmed = generated[0][inputs["input_ids"].shape[1]:]
476
- text = vlm_processor.decode(trimmed, skip_special_tokens=True).strip()
477
- # With reasoning off, drop any stray <think>…</think> block so the answer
478
- # stays clean; with it on, keep the trace so the user can see it.
479
- if not enable_thinking and "</think>" in text:
480
- text = text.split("</think>")[-1].strip()
481
- return text
482
  finally:
 
483
  print(
484
  f"[ImageStudio] Assistant GPU time: {time.time() - _gpu_start:.2f}s "
485
- f"(has_image={image is not None}, reasoning={reasoning}, max_new_tokens={int(max_new_tokens)})",
486
  flush=True,
487
  )
 
 
 
 
 
 
 
 
488
 
489
 
490
  def generate_and_upload(
@@ -503,17 +602,30 @@ def generate_and_upload(
503
  ):
504
  """Generate, then upload the result to R2 outside the GPU window.
505
 
506
- Returns ``(image, seed, r2_status)``. The image is always the original
 
 
507
  HF-generated asset; ``r2_status`` reports the uploaded filekey on success or
508
  the error on failure. The caller's unique id (``uid`` cookie) is recorded in
509
  the uploaded object's metadata.
510
  """
511
- image, used = generate_image(
 
512
  model_name, prompt, negative_prompt, use_negative_prompt,
513
  height, width, num_inference_steps, guidance_scale, seed, randomize_seed,
514
  progress=progress,
515
- )
 
 
 
 
 
 
 
 
 
516
 
 
517
  uid = r2_uploader.uid_from_request(request)
518
  buf = io.BytesIO()
519
  image.save(buf, format="PNG")
@@ -541,7 +653,27 @@ def generate_and_upload(
541
  status = {"r2_filekey": result["filekey"], "r2_bucket": result["bucket"]}
542
  else:
543
  status = {"r2_error": result.get("error", "unknown error")}
544
- return image, used, status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
 
546
 
547
  # Recommended defaults per model: (steps, guidance, height, width)
@@ -724,6 +856,9 @@ with gr.Blocks(fill_height=True) as demo:
724
  label="🎲 Seed Used", interactive=False, container=True,
725
  )
726
  r2_status = gr.JSON(label="☁️ R2 Upload")
 
 
 
727
 
728
  with gr.Tab("💬 Prompt Assistant"):
729
  gr.Markdown(
@@ -764,6 +899,9 @@ with gr.Blocks(fill_height=True) as demo:
764
  label="🤖 Answer",
765
  lines=20,
766
  )
 
 
 
767
 
768
  gr.Markdown(
769
  """
@@ -795,20 +933,23 @@ with gr.Blocks(fill_height=True) as demo:
795
  # resolving even though the click now runs the upload wrapper.
796
  generate_btn.click(
797
  fn=generate_and_upload, inputs=gen_inputs,
798
- outputs=[output_image, used_seed, r2_status], api_name="generate_image",
 
799
  )
800
  prompt.submit(
801
  fn=generate_and_upload, inputs=gen_inputs,
802
- outputs=[output_image, used_seed, r2_status],
803
  )
804
 
805
  # Prompt Assistant (Qwen3.5-4B) — single-turn, optional image
806
  vlm_inputs = [vlm_prompt, vlm_image, vlm_reasoning, vlm_max_tokens]
807
  vlm_btn.click(
808
- fn=vlm_chat, inputs=vlm_inputs, outputs=[vlm_output],
809
  api_name="prompt_assistant",
810
  )
811
- vlm_prompt.submit(fn=vlm_chat, inputs=vlm_inputs, outputs=[vlm_output])
 
 
812
 
813
  if __name__ == "__main__":
814
  demo.launch(
 
1
+ import inspect
2
  import io
3
  import os
4
+ import queue
5
  import random
6
  import re
7
+ import threading
8
  import time
9
 
10
  import numpy as np
 
19
  )
20
  from compel import Compel, ReturnedEmbeddingsType
21
  from huggingface_hub import hf_hub_download
22
+ from transformers import AutoProcessor, AutoModelForImageTextToText, TextIteratorStreamer
23
+
24
+ # Structured progress contract (also see wan2.2 / LTX2.3 Spaces and the generator
25
+ # orchestrator). Every streaming endpoint yields a hidden JSON "progress" output
26
+ # in addition to its real outputs. Each yielded progress dict carries:
27
+ # {"stage": <phase id>, "p": <0..1 fraction>, "step": int, "total": int,
28
+ # "label": <human text>}
29
+ # Because the function is a generator, Gradio surfaces every yield as an
30
+ # `event: generating` frame on the /gradio_api/call SSE stream, so a downstream
31
+ # consumer reading the JSON at the progress index gets live progress over SSE.
32
+ def _progress(stage, p, step=0, total=0, label=""):
33
+ return {
34
+ "stage": stage,
35
+ "p": max(0.0, min(1.0, float(p))),
36
+ "step": int(step),
37
+ "total": int(total),
38
+ "label": label,
39
+ }
40
 
41
  import r2_uploader
42
 
 
354
  # =============================================================================
355
  # Generation
356
  # =============================================================================
357
+ def _supports_step_callback(pipe):
358
+ """True if this diffusers pipeline's __call__ accepts callback_on_step_end."""
359
+ try:
360
+ return "callback_on_step_end" in inspect.signature(pipe.__call__).parameters
361
+ except (TypeError, ValueError):
362
+ return False
363
+
364
+
365
  @spaces.GPU
366
  def generate_image(
367
  model_name,
 
376
  randomize_seed,
377
  progress=gr.Progress(track_tqdm=True),
378
  ):
379
+ """Generate an image, streaming per-step progress.
380
+
381
+ This is a generator (so ZeroGPU streams its yields back over SSE). It yields
382
+ ``("progress", step, total)`` tuples during sampling and a final
383
+ ``("image", image, seed)`` tuple. The sampler runs in a worker thread feeding
384
+ a queue so the main thread can yield progress as each diffusion step lands.
385
+ """
386
  _gpu_start = time.time()
387
+ total_steps = int(num_inference_steps)
388
+ q = queue.Queue()
389
+ result = {}
390
+
391
+ def _step_cb(_pipe, step, _timestep, callback_kwargs):
392
+ # diffusers calls this after each step; `step` is the 0-based index.
393
+ q.put(step + 1)
394
+ return callback_kwargs
395
+
396
+ def _run():
397
+ try:
398
+ result["image"], result["seed"] = _generate_image_inner(
399
+ model_name, prompt, negative_prompt, use_negative_prompt,
400
+ height, width, total_steps, guidance_scale, seed, randomize_seed,
401
+ callback=_step_cb,
402
+ )
403
+ except Exception as exc: # noqa: BLE001 - surfaced to the main thread
404
+ result["error"] = exc
405
+ finally:
406
+ q.put(None) # sentinel: generation finished (ok or error)
407
+
408
+ thread = threading.Thread(target=_run, daemon=True)
409
+ thread.start()
410
  try:
411
+ while True:
412
+ step = q.get()
413
+ if step is None:
414
+ break
415
+ yield ("progress", step, total_steps)
416
  finally:
417
+ thread.join()
418
  print(
419
  f"[ImageStudio] GPU time consumed: {time.time() - _gpu_start:.2f}s "
420
  f"(model={model_name}, steps={num_inference_steps}, {int(width)}x{int(height)})",
421
  flush=True,
422
  )
423
+ if "error" in result:
424
+ raise result["error"]
425
+ yield ("image", result["image"], result["seed"])
426
 
427
 
428
  def _generate_image_inner(
 
436
  guidance_scale,
437
  seed,
438
  randomize_seed,
439
+ callback=None,
440
  ):
441
  if randomize_seed:
442
  seed = random.randint(0, MAX_SEED)
 
468
  [cond_prompt, cond_negative], precomputed_padding=empty_padding
469
  )
470
 
471
+ kwargs = dict(
472
  prompt_embeds=cond_prompt,
473
  pooled_prompt_embeds=pooled_prompt,
474
  negative_prompt_embeds=cond_negative,
 
479
  num_inference_steps=int(num_inference_steps),
480
  generator=generator,
481
  use_resolution_binning=True,
482
+ )
483
+ if callback is not None and _supports_step_callback(noobxl_pipe):
484
+ kwargs["callback_on_step_end"] = callback
485
+ image = noobxl_pipe(**kwargs).images[0]
486
  return image, seed
487
 
488
  # Default: Z-Image-Turbo (guidance-free distilled model)
489
  generator = torch.Generator("cuda").manual_seed(seed)
490
+ kwargs = dict(
491
  prompt=prompt,
492
  height=int(height),
493
  width=int(width),
494
  num_inference_steps=int(num_inference_steps),
495
  guidance_scale=0.0,
496
  generator=generator,
497
+ )
498
+ if callback is not None and _supports_step_callback(zimage_pipe):
499
+ kwargs["callback_on_step_end"] = callback
500
+ image = zimage_pipe(**kwargs).images[0]
501
  return image, seed
502
 
503
 
 
508
  def vlm_chat(message, image, reasoning, max_new_tokens, progress=gr.Progress(track_tqdm=True)):
509
  """Answer a single user message, optionally grounded on an uploaded image.
510
 
511
+ Generator: yields ``("progress", produced, budget)`` as tokens stream in and a
512
+ final ``("text", answer)`` tuple. Token streaming (TextIteratorStreamer + a
513
+ worker thread) is the canonical ZeroGPU pattern and lets the downstream
514
+ orchestrator track this node's progress over SSE.
515
+
516
  ``reasoning`` ("On"/"Off") drives Qwen's ``enable_thinking`` switch: Off skips
517
  the <think> trace for a direct answer (best for prompt rewriting); On lets the
518
  model reason step-by-step first (slower, needs more max_new_tokens).
519
  """
520
  message = (message or "").strip()
521
  if not message and image is None:
522
+ yield ("text", "Please enter a question (and optionally attach an image).")
523
+ return
524
 
525
  enable_thinking = (reasoning == "On")
526
+ budget = int(max_new_tokens)
527
  _gpu_start = time.time()
528
+
529
+ content = []
530
+ if image is not None:
531
+ content.append({"type": "image", "image": image})
532
+ content.append({"type": "text", "text": message or "Describe this image."})
533
+ messages = [{"role": "user", "content": content}]
534
+
535
+ inputs = vlm_processor.apply_chat_template(
536
+ messages,
537
+ tokenize=True,
538
+ add_generation_prompt=True,
539
+ return_dict=True,
540
+ return_tensors="pt",
541
+ enable_thinking=enable_thinking,
542
+ ).to(vlm_model.device)
543
+
544
+ tokenizer = getattr(vlm_processor, "tokenizer", vlm_processor)
545
+ streamer = TextIteratorStreamer(
546
+ tokenizer, skip_prompt=True, skip_special_tokens=True
547
+ )
548
+ result = {}
549
+
550
+ def _run():
551
+ try:
552
+ with torch.inference_mode():
553
+ vlm_model.generate(
554
+ **inputs,
555
+ max_new_tokens=budget,
556
+ do_sample=False,
557
+ streamer=streamer,
558
+ )
559
+ except Exception as exc: # noqa: BLE001 - surfaced to the main thread
560
+ result["error"] = exc
561
+ streamer.end()
562
+
563
+ thread = threading.Thread(target=_run, daemon=True)
564
+ thread.start()
565
  try:
566
+ text = ""
567
+ produced = 0
568
+ for chunk in streamer:
569
+ text += chunk
570
+ produced += 1
571
+ yield ("progress", produced, budget, text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  finally:
573
+ thread.join()
574
  print(
575
  f"[ImageStudio] Assistant GPU time: {time.time() - _gpu_start:.2f}s "
576
+ f"(has_image={image is not None}, reasoning={reasoning}, max_new_tokens={budget})",
577
  flush=True,
578
  )
579
+ if "error" in result:
580
+ raise result["error"]
581
+ text = text.strip()
582
+ # With reasoning off, drop any stray <think>…</think> block so the answer
583
+ # stays clean; with it on, keep the trace so the user can see it.
584
+ if not enable_thinking and "</think>" in text:
585
+ text = text.split("</think>")[-1].strip()
586
+ yield ("text", text)
587
 
588
 
589
  def generate_and_upload(
 
602
  ):
603
  """Generate, then upload the result to R2 outside the GPU window.
604
 
605
+ Generator yielding ``(image, seed, r2_status, progress)``. Streams per-step
606
+ sampling progress (image still None) and finishes with the real image, seed
607
+ and R2 status once the upload completes. The image is always the original
608
  HF-generated asset; ``r2_status`` reports the uploaded filekey on success or
609
  the error on failure. The caller's unique id (``uid`` cookie) is recorded in
610
  the uploaded object's metadata.
611
  """
612
+ image, used = None, None
613
+ for ev in generate_image(
614
  model_name, prompt, negative_prompt, use_negative_prompt,
615
  height, width, num_inference_steps, guidance_scale, seed, randomize_seed,
616
  progress=progress,
617
+ ):
618
+ if ev[0] == "progress":
619
+ _, step, total = ev
620
+ # Reserve the last 5% of this node for the R2 upload that follows.
621
+ frac = (step / max(total, 1)) * 0.95
622
+ yield None, None, None, _progress(
623
+ "image", frac, step, total, f"Sampling {step}/{total}"
624
+ )
625
+ else:
626
+ _, image, used = ev
627
 
628
+ yield None, used, None, _progress("image", 0.97, label="Uploading")
629
  uid = r2_uploader.uid_from_request(request)
630
  buf = io.BytesIO()
631
  image.save(buf, format="PNG")
 
653
  status = {"r2_filekey": result["filekey"], "r2_bucket": result["bucket"]}
654
  else:
655
  status = {"r2_error": result.get("error", "unknown error")}
656
+ yield image, used, status, _progress("done", 1.0, label="Done")
657
+
658
+
659
+ def assistant_chat(
660
+ message, image, reasoning, max_new_tokens,
661
+ progress=gr.Progress(track_tqdm=True),
662
+ ):
663
+ """Gradio-facing wrapper around ``vlm_chat``.
664
+
665
+ Yields ``(answer, progress)`` so the UI streams the text live and a downstream
666
+ consumer reading the progress index sees this node advance over SSE. The final
667
+ ``complete`` frame carries the clean answer at index 0.
668
+ """
669
+ for ev in vlm_chat(message, image, reasoning, max_new_tokens, progress=progress):
670
+ if ev[0] == "progress":
671
+ _, produced, budget, partial = ev
672
+ frac = min(0.99, produced / max(budget, 1))
673
+ yield partial, _progress("prompt", frac, produced, budget, "Writing prompt")
674
+ else:
675
+ _, text = ev
676
+ yield text, _progress("done", 1.0, label="Done")
677
 
678
 
679
  # Recommended defaults per model: (steps, guidance, height, width)
 
856
  label="🎲 Seed Used", interactive=False, container=True,
857
  )
858
  r2_status = gr.JSON(label="☁️ R2 Upload")
859
+ # Hidden structured-progress channel (index 3 of generate_image
860
+ # outputs). Surfaces every yield as an SSE `generating` frame.
861
+ gen_progress = gr.JSON(label="progress", visible=False)
862
 
863
  with gr.Tab("💬 Prompt Assistant"):
864
  gr.Markdown(
 
899
  label="🤖 Answer",
900
  lines=20,
901
  )
902
+ # Hidden structured-progress channel (index 1 of prompt_assistant
903
+ # outputs).
904
+ vlm_progress = gr.JSON(label="progress", visible=False)
905
 
906
  gr.Markdown(
907
  """
 
933
  # resolving even though the click now runs the upload wrapper.
934
  generate_btn.click(
935
  fn=generate_and_upload, inputs=gen_inputs,
936
+ outputs=[output_image, used_seed, r2_status, gen_progress],
937
+ api_name="generate_image",
938
  )
939
  prompt.submit(
940
  fn=generate_and_upload, inputs=gen_inputs,
941
+ outputs=[output_image, used_seed, r2_status, gen_progress],
942
  )
943
 
944
  # Prompt Assistant (Qwen3.5-4B) — single-turn, optional image
945
  vlm_inputs = [vlm_prompt, vlm_image, vlm_reasoning, vlm_max_tokens]
946
  vlm_btn.click(
947
+ fn=assistant_chat, inputs=vlm_inputs, outputs=[vlm_output, vlm_progress],
948
  api_name="prompt_assistant",
949
  )
950
+ vlm_prompt.submit(
951
+ fn=assistant_chat, inputs=vlm_inputs, outputs=[vlm_output, vlm_progress],
952
+ )
953
 
954
  if __name__ == "__main__":
955
  demo.launch(