Qi Cai commited on
Commit
dcd9bef
Β·
1 Parent(s): 6e7035e

add more control

Browse files
Files changed (1) hide show
  1. app.py +506 -10
app.py CHANGED
@@ -1,5 +1,6 @@
1
  import logging
2
  import os
 
3
  import time
4
  import traceback
5
  from io import BytesIO
@@ -28,6 +29,11 @@ MAX_RETRY_COUNT = int(os.environ.get("MAX_RETRY_COUNT", 3))
28
  POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", 2.0))
29
  MAX_POLL_TIME = int(os.environ.get("MAX_POLL_TIME", 300))
30
 
 
 
 
 
 
31
  # Predefined aspect ratios (wh_ratio) β€” kept in the same order as the original
32
  # (width, height) list so each entry mirrors the previous resolution choice:
33
  # 1:1 ←→ 2048Γ—2048 4:3 ←→ 2304Γ—1728 3:4 ←→ 1728Γ—2304
@@ -54,6 +60,8 @@ logger.info(
54
  logger.info(
55
  f"Retry configuration: MAX_RETRY_COUNT={MAX_RETRY_COUNT}, POLL_INTERVAL={POLL_INTERVAL}s, MAX_POLL_TIME={MAX_POLL_TIME}s"
56
  )
 
 
57
 
58
 
59
  class APIError(Exception):
@@ -81,13 +89,27 @@ def _headers() -> dict:
81
  return {"Authorization": f"Bearer {API_TOKEN}"}
82
 
83
 
84
- def create_request(prompt, wh_ratio):
 
 
 
 
 
 
 
85
  """
86
  Submit an image generation request to the API.
87
 
88
  Args:
89
  prompt (str): Text prompt describing the image to generate
90
  wh_ratio (str): Aspect ratio for the output image (e.g. "16:9")
 
 
 
 
 
 
 
91
 
92
  Returns:
93
  str: Task ID
@@ -96,7 +118,10 @@ def create_request(prompt, wh_ratio):
96
  APIError: If the API request fails
97
  """
98
  logger.info(
99
- f"Starting create_request with prompt='{prompt[:50]}...', wh_ratio={wh_ratio}"
 
 
 
100
  )
101
 
102
  if not prompt or not prompt.strip():
@@ -107,11 +132,27 @@ def create_request(prompt, wh_ratio):
107
  logger.error(f"Invalid wh_ratio: {wh_ratio}. Valid options: {WH_RATIO_OPTIONS}")
108
  raise ValueError(f"Invalid aspect ratio. Must be one of: {', '.join(WH_RATIO_OPTIONS)}")
109
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  model_params = {
111
  "prompt": prompt,
112
  "wh_ratio": wh_ratio,
113
  "model_id": MODEL_ID,
114
  "n": 1,
 
 
 
 
115
  }
116
 
117
  url = _build_request_url()
@@ -519,6 +560,177 @@ textarea:focus, input:focus,
519
  background: #ffffff !important;
520
  }
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  /* Footer tagline */
523
  .tagline {
524
  text-align: center;
@@ -603,6 +815,144 @@ def _status_html(text: str, kind: str = "info") -> str:
603
  )
604
 
605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  def create_ui():
607
  logger.info("Creating Gradio UI")
608
  with gr.Blocks(
@@ -610,6 +960,14 @@ def create_ui():
610
  theme=APPLE_THEME,
611
  css=APPLE_CSS,
612
  ) as demo:
 
 
 
 
 
 
 
 
613
  with gr.Row(equal_height=False):
614
  with gr.Column(scale=1, elem_classes=["panel-card"]):
615
  prompt = gr.Textbox(
@@ -619,6 +977,14 @@ def create_ui():
619
  show_label=True,
620
  )
621
 
 
 
 
 
 
 
 
 
622
  wh_ratio = gr.Dropdown(
623
  choices=WH_RATIO_OPTIONS,
624
  value=WH_RATIO_OPTIONS[0],
@@ -626,6 +992,34 @@ def create_ui():
626
  info="Width : Height",
627
  )
628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  with gr.Row():
630
  clear_btn = gr.Button("Clear", variant="secondary", scale=1)
631
  generate_btn = gr.Button("Generate", variant="primary", scale=3)
@@ -645,9 +1039,20 @@ def create_ui():
645
  elem_classes=["image-output"],
646
  )
647
 
648
- def generate_with_status(prompt, wh_ratio_value):
 
 
 
 
 
 
 
649
  logger.info(
650
- f"Starting image generation with prompt='{(prompt or '')[:50]}...', wh_ratio={wh_ratio_value}"
 
 
 
 
651
  )
652
 
653
  yield None, _status_html("Sending request to API…", "running")
@@ -663,8 +1068,30 @@ def create_ui():
663
  yield None, _status_html(f"Invalid aspect ratio β€œ{wh_ratio_value}”", "error")
664
  return
665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  logger.info("Creating API request")
667
- task_id = create_request(prompt, wh_ratio_value)
 
 
 
 
 
 
 
668
  yield None, _status_html(f"Request submitted Β· Task {task_id[:8]}…", "running")
669
 
670
  start_time = time.time()
@@ -752,21 +1179,90 @@ def create_ui():
752
  logger.error(f"Full traceback: {traceback.format_exc()}")
753
  yield None, _status_html(f"Unexpected error: {str(e)}", "error")
754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  generate_btn.click(
756
- fn=generate_with_status,
757
- inputs=[prompt, wh_ratio],
758
- outputs=[output_image, status_msg],
 
759
  show_progress="hidden",
 
 
 
 
 
 
760
  )
761
 
762
  def clear_outputs():
763
  logger.info("Clearing UI outputs")
764
- return None, _status_html("Ready", "info")
765
 
766
  clear_btn.click(
767
  fn=clear_outputs,
768
  inputs=None,
769
- outputs=[output_image, status_msg],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  )
771
 
772
  gr.HTML(
 
1
  import logging
2
  import os
3
+ import random
4
  import time
5
  import traceback
6
  from io import BytesIO
 
29
  POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", 2.0))
30
  MAX_POLL_TIME = int(os.environ.get("MAX_POLL_TIME", 300))
31
 
32
+ # Local-only test mode: skip the real API and return a random-colored image after
33
+ # a randomised delay. Useful for testing the UI / queueing flow without burning
34
+ # real model credits. Enable with FAKE_TEST=1 (also accepts "true"/"yes").
35
+ FAKE_TEST = os.environ.get("FAKE_TEST", "").strip().lower() in ("1", "true", "yes", "on")
36
+
37
  # Predefined aspect ratios (wh_ratio) β€” kept in the same order as the original
38
  # (width, height) list so each entry mirrors the previous resolution choice:
39
  # 1:1 ←→ 2048Γ—2048 4:3 ←→ 2304Γ—1728 3:4 ←→ 1728Γ—2304
 
60
  logger.info(
61
  f"Retry configuration: MAX_RETRY_COUNT={MAX_RETRY_COUNT}, POLL_INTERVAL={POLL_INTERVAL}s, MAX_POLL_TIME={MAX_POLL_TIME}s"
62
  )
63
+ if FAKE_TEST:
64
+ logger.warning("FAKE_TEST mode is ENABLED β€” no real API calls will be made.")
65
 
66
 
67
  class APIError(Exception):
 
89
  return {"Authorization": f"Bearer {API_TOKEN}"}
90
 
91
 
92
+ def create_request(
93
+ prompt,
94
+ wh_ratio,
95
+ negative_prompt="",
96
+ enable_prompt_refine=True,
97
+ seed=-1,
98
+ guidance_scale=5.0,
99
+ ):
100
  """
101
  Submit an image generation request to the API.
102
 
103
  Args:
104
  prompt (str): Text prompt describing the image to generate
105
  wh_ratio (str): Aspect ratio for the output image (e.g. "16:9")
106
+ negative_prompt (str): Optional text describing what to avoid.
107
+ enable_prompt_refine (bool): Whether to let the backend rewrite/expand
108
+ the prompt before generation. Sent to the API as 0 / 1.
109
+ seed (int): Generation seed. -1 means the backend will pick one
110
+ randomly; any other integer fixes the seed for reproducible runs.
111
+ guidance_scale (float): Classifier-free guidance strength. Higher
112
+ values follow the prompt more strictly.
113
 
114
  Returns:
115
  str: Task ID
 
118
  APIError: If the API request fails
119
  """
120
  logger.info(
121
+ f"Starting create_request with prompt='{prompt[:50]}...', "
122
+ f"wh_ratio={wh_ratio}, enable_prompt_refine={enable_prompt_refine}, "
123
+ f"seed={seed}, guidance_scale={guidance_scale}, "
124
+ f"negative_prompt='{(negative_prompt or '')[:30]}...'"
125
  )
126
 
127
  if not prompt or not prompt.strip():
 
132
  logger.error(f"Invalid wh_ratio: {wh_ratio}. Valid options: {WH_RATIO_OPTIONS}")
133
  raise ValueError(f"Invalid aspect ratio. Must be one of: {', '.join(WH_RATIO_OPTIONS)}")
134
 
135
+ try:
136
+ seed_int = int(seed)
137
+ except (TypeError, ValueError):
138
+ logger.warning(f"Invalid seed value '{seed}', falling back to -1 (random)")
139
+ seed_int = -1
140
+
141
+ try:
142
+ guidance_scale_f = float(guidance_scale)
143
+ except (TypeError, ValueError):
144
+ logger.warning(f"Invalid guidance_scale '{guidance_scale}', falling back to 5.0")
145
+ guidance_scale_f = 5.0
146
+
147
  model_params = {
148
  "prompt": prompt,
149
  "wh_ratio": wh_ratio,
150
  "model_id": MODEL_ID,
151
  "n": 1,
152
+ "negative_prompt": negative_prompt or "",
153
+ "enable_prompt_refine": 1 if enable_prompt_refine else 0,
154
+ "seed": seed_int,
155
+ "guidance_scale": guidance_scale_f,
156
  }
157
 
158
  url = _build_request_url()
 
560
  background: #ffffff !important;
561
  }
562
 
563
+ /* Negative prompt β€” softer accent so it visually de-emphasises vs. the main prompt */
564
+ .negative-prompt textarea {
565
+ background: #fbfbfd !important;
566
+ border-color: #e3e3e8 !important;
567
+ }
568
+ .negative-prompt textarea:focus {
569
+ background: #ffffff !important;
570
+ }
571
+
572
+ /* Advanced options row β€” keeps the refine switch + seed input visually paired */
573
+ .advanced-row {
574
+ gap: 14px !important;
575
+ margin-top: 2px;
576
+ }
577
+
578
+ /* Refine toggle β€” iOS-style settings card.
579
+ Goal: title + helper text on the left, a polished pill switch on the right,
580
+ everything contained inside a single soft card so the helper text no longer
581
+ floats orphaned above the box. */
582
+ .refine-toggle {
583
+ background: linear-gradient(180deg, #ffffff 0%, #f5f5f7 100%) !important;
584
+ border-radius: 14px !important;
585
+ border: 1px solid rgba(0,0,0,0.06) !important;
586
+ padding: 14px 16px !important;
587
+ box-shadow: 0 1px 2px rgba(0,0,0,0.03) !important;
588
+ transition: border-color 0.18s ease, box-shadow 0.18s ease !important;
589
+ min-height: 88px;
590
+ display: flex !important;
591
+ flex-direction: column !important;
592
+ justify-content: center !important;
593
+ }
594
+ .refine-toggle:hover {
595
+ border-color: rgba(0,0,0,0.10) !important;
596
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 14px rgba(0,0,0,0.05) !important;
597
+ }
598
+
599
+ /* Strip default backgrounds from gradio's inner wrappers so only our card shows. */
600
+ .refine-toggle .form,
601
+ .refine-toggle .wrap,
602
+ .refine-toggle .form-wrap,
603
+ .refine-toggle > div {
604
+ background: transparent !important;
605
+ border: none !important;
606
+ padding: 0 !important;
607
+ margin: 0 !important;
608
+ box-shadow: none !important;
609
+ }
610
+
611
+ /* Helper / "info" text becomes a proper subtitle UNDER the toggle row. */
612
+ .refine-toggle [data-testid="block-info"],
613
+ .refine-toggle .info {
614
+ color: #6e6e73 !important;
615
+ font-size: 12px !important;
616
+ line-height: 1.4 !important;
617
+ margin: 8px 0 0 0 !important;
618
+ padding: 0 !important;
619
+ text-align: left !important;
620
+ order: 2 !important;
621
+ }
622
+
623
+ /* Force the gradio wrapper to stack: label-row first, info below. */
624
+ .refine-toggle .form,
625
+ .refine-toggle > div:not([data-testid="block-info"]):not(.info) {
626
+ display: flex !important;
627
+ flex-direction: column !important;
628
+ align-items: stretch !important;
629
+ }
630
+
631
+ /* Label row: title on the LEFT (full width), toggle pinned to the RIGHT. */
632
+ .refine-toggle label {
633
+ display: flex !important;
634
+ align-items: center !important;
635
+ justify-content: space-between !important;
636
+ flex-direction: row-reverse !important;
637
+ cursor: pointer !important;
638
+ margin: 0 !important;
639
+ padding: 0 !important;
640
+ gap: 14px !important;
641
+ width: 100% !important;
642
+ order: 1 !important;
643
+ }
644
+ .refine-toggle label > span {
645
+ color: #1d1d1f !important;
646
+ font-size: 15px !important;
647
+ font-weight: 600 !important;
648
+ letter-spacing: -0.01em;
649
+ flex: 1 1 auto !important;
650
+ text-align: left !important;
651
+ line-height: 1.3 !important;
652
+ }
653
+
654
+ /* Pill switch β€” bigger, smoother, more "Apple-like" */
655
+ .refine-toggle input[type="checkbox"] {
656
+ appearance: none;
657
+ -webkit-appearance: none;
658
+ width: 46px !important;
659
+ height: 28px !important;
660
+ border-radius: 999px !important;
661
+ background: #e5e5ea !important;
662
+ position: relative;
663
+ cursor: pointer;
664
+ transition: background 0.22s ease, box-shadow 0.22s ease;
665
+ border: none !important;
666
+ flex-shrink: 0 !important;
667
+ margin: 0 !important;
668
+ box-shadow: inset 0 0 1px rgba(0,0,0,0.06);
669
+ }
670
+ .refine-toggle input[type="checkbox"]::after {
671
+ content: "";
672
+ position: absolute;
673
+ top: 2px;
674
+ left: 2px;
675
+ width: 24px;
676
+ height: 24px;
677
+ border-radius: 50%;
678
+ background: #ffffff;
679
+ box-shadow: 0 2px 5px rgba(0,0,0,0.18), 0 0 1px rgba(0,0,0,0.05);
680
+ transition: transform 0.24s cubic-bezier(0.4, 0.0, 0.2, 1);
681
+ }
682
+ .refine-toggle input[type="checkbox"]:hover {
683
+ background: #dcdce0 !important;
684
+ }
685
+ .refine-toggle input[type="checkbox"]:checked {
686
+ background: #34c759 !important;
687
+ }
688
+ .refine-toggle input[type="checkbox"]:checked:hover {
689
+ background: #30b352 !important;
690
+ }
691
+ .refine-toggle input[type="checkbox"]:checked::after {
692
+ transform: translateX(18px);
693
+ }
694
+ .refine-toggle input[type="checkbox"]:focus-visible {
695
+ box-shadow: 0 0 0 4px rgba(0,113,227,0.20) !important;
696
+ }
697
+ .refine-toggle input[type="checkbox"]:active::after {
698
+ /* tiny squish on press, very iOS */
699
+ width: 28px;
700
+ }
701
+ .refine-toggle input[type="checkbox"]:checked:active::after {
702
+ transform: translateX(14px);
703
+ }
704
+
705
+ /* Seed number input β€” match the prompt/dropdown rounding */
706
+ .seed-input input[type="number"] {
707
+ border-radius: 12px !important;
708
+ padding: 10px 14px !important;
709
+ font-variant-numeric: tabular-nums;
710
+ }
711
+ /* Hide the native spinner buttons on number inputs for a cleaner look */
712
+ .seed-input input[type="number"]::-webkit-outer-spin-button,
713
+ .seed-input input[type="number"]::-webkit-inner-spin-button {
714
+ -webkit-appearance: none;
715
+ margin: 0;
716
+ }
717
+ .seed-input input[type="number"] {
718
+ -moz-appearance: textfield;
719
+ }
720
+ /* Keep the seed column visually aligned with the refine card next to it */
721
+ .seed-input {
722
+ align-self: stretch !important;
723
+ }
724
+
725
+ /* Guidance scale slider β€” Apple-blue track + softer thumb */
726
+ .guidance-slider input[type="range"] {
727
+ accent-color: #0071e3 !important;
728
+ }
729
+ .guidance-slider .head { padding-top: 0 !important; }
730
+ .guidance-slider {
731
+ margin-top: 4px;
732
+ }
733
+
734
  /* Footer tagline */
735
  .tagline {
736
  text-align: center;
 
815
  )
816
 
817
 
818
+ def _queue_text(waiting: int, running: int) -> str:
819
+ """Inline status text describing the queue from the user's POV.
820
+
821
+ Used inside the right-side status pill while THIS user's request is
822
+ sitting in the queue waiting for an open slot.
823
+ """
824
+ if waiting <= 0 and running <= 0:
825
+ return "Queued Β· waiting for an open slot…"
826
+ parts = []
827
+ if waiting > 0:
828
+ parts.append(f"{waiting} waiting")
829
+ if running > 0:
830
+ parts.append(f"{running} generating")
831
+ return f"In queue Β· {' Β· '.join(parts)}"
832
+
833
+
834
+ def _read_queue_stats(demo_obj) -> tuple[int, int, float]:
835
+ """Best-effort read of gradio's internal queue.
836
+
837
+ Gradio 5.x structure (gradio/queueing.py):
838
+ demo._queue.event_queue_per_concurrency_id: dict[str, EventQueue]
839
+ EventQueue.queue: list[Event] ← actually-waiting events
840
+ demo._queue.active_jobs: list[None | list[Event]]
841
+ each slot is None (idle) or a list of currently-processing events.
842
+ demo._queue.process_time_per_fn: dict[BlockFunction, ProcessTime]
843
+ ProcessTime.avg_time: float
844
+
845
+ Returns (waiting, running, avg_secs). Each lookup is wrapped in a try
846
+ so the UI degrades gracefully ("idle") if Gradio ever renames a field.
847
+ """
848
+ try:
849
+ q = getattr(demo_obj, "_queue", None)
850
+ if q is None:
851
+ return 0, 0, 0.0
852
+
853
+ # ---- Waiting: events sitting in EventQueue.queue ----
854
+ waiting = 0
855
+ events_per_cid = getattr(q, "event_queue_per_concurrency_id", None) or {}
856
+ for ev_q in events_per_cid.values():
857
+ # Newer gradio: ev_q is an EventQueue with a .queue list.
858
+ # Older/alt: ev_q might already be a list. Handle both.
859
+ inner = getattr(ev_q, "queue", ev_q)
860
+ try:
861
+ waiting += len(inner)
862
+ except (TypeError, AttributeError):
863
+ # Last resort: try iterating
864
+ try:
865
+ waiting += sum(1 for _ in inner)
866
+ except Exception:
867
+ continue
868
+
869
+ # ---- Running: count events held in active_jobs slots ----
870
+ # Each slot is None or a list[Event]; sum the list lengths.
871
+ running = 0
872
+ active = getattr(q, "active_jobs", None) or []
873
+ for slot in active:
874
+ if slot is None:
875
+ continue
876
+ try:
877
+ running += len(slot)
878
+ except (TypeError, AttributeError):
879
+ running += 1 # very old gradio: single Event per slot
880
+
881
+ # ---- Average per-run time (best effort across versions) ----
882
+ avg_secs = 0.0
883
+ # Gradio 5.x: dict[BlockFunction, ProcessTime] with .avg_time
884
+ ptpf = getattr(q, "process_time_per_fn", None)
885
+ if isinstance(ptpf, dict) and ptpf:
886
+ try:
887
+ vals = []
888
+ for v in ptpf.values():
889
+ avg_t = getattr(v, "avg_time", None)
890
+ if avg_t:
891
+ vals.append(float(avg_t))
892
+ if vals:
893
+ avg_secs = sum(vals) / len(vals)
894
+ except Exception:
895
+ avg_secs = 0.0
896
+ # Older: dict[int, float]
897
+ if not avg_secs:
898
+ ptpfi = getattr(q, "process_time_per_fn_index", None)
899
+ if isinstance(ptpfi, dict) and ptpfi:
900
+ try:
901
+ vals = [float(v) for v in ptpfi.values() if v]
902
+ if vals:
903
+ avg_secs = sum(vals) / len(vals)
904
+ except Exception:
905
+ avg_secs = 0.0
906
+ # Oldest: single float on the queue
907
+ if not avg_secs:
908
+ apt = getattr(q, "avg_process_time", None)
909
+ if apt:
910
+ try:
911
+ avg_secs = float(apt)
912
+ except Exception:
913
+ avg_secs = 0.0
914
+
915
+ return waiting, running, avg_secs
916
+ except Exception as exc:
917
+ logger.debug(f"Queue introspection failed: {exc}")
918
+ return 0, 0, 0.0
919
+
920
+
921
+ def _fake_generation_iter(prompt: str, wh_ratio_value: str):
922
+ """FAKE_TEST mode generator.
923
+
924
+ Mimics the real flow's yield protocol without hitting any external API.
925
+ Useful for exercising the queue UI / status pill locally.
926
+
927
+ Yields (image_or_None, status_html) tuples. The very first 'Sending
928
+ request to API…' yield is emitted by the caller, so this iterator picks
929
+ up from 'Request submitted' onwards.
930
+ """
931
+ time.sleep(random.uniform(0.4, 1.0))
932
+
933
+ fake_id = f"{random.randint(0, 0xFFFFFFFF):08x}"
934
+ yield None, _status_html(f"Request submitted Β· Task {fake_id}…", "running")
935
+
936
+ target_secs = random.uniform(8.0, 22.0)
937
+ start = time.time()
938
+ while time.time() - start < target_secs:
939
+ elapsed = int(time.time() - start)
940
+ yield None, _status_html(f"Generating… {elapsed}s", "running")
941
+ time.sleep(POLL_INTERVAL)
942
+
943
+ yield None, _status_html("Downloading image…", "running")
944
+ time.sleep(0.3)
945
+
946
+ rgb = (
947
+ random.randint(40, 220),
948
+ random.randint(40, 220),
949
+ random.randint(40, 220),
950
+ )
951
+ fake_image = Image.new("RGB", (1024, 1024), color=rgb)
952
+ logger.info(f"FAKE_TEST: returning random color image rgb={rgb}, took {target_secs:.1f}s")
953
+ yield fake_image, _status_html("Image generated", "success")
954
+
955
+
956
  def create_ui():
957
  logger.info("Creating Gradio UI")
958
  with gr.Blocks(
 
960
  theme=APPLE_THEME,
961
  css=APPLE_CSS,
962
  ) as demo:
963
+ # Per-session state used to drive the right-side status pill while
964
+ # this user's request is sitting in the queue. `last_pill_state`
965
+ # caches the most recent pill HTML so the timer can return
966
+ # `gr.update()` (=no-op, no DOM replacement β†’ no flicker) when the
967
+ # queue counts haven't actually changed between ticks.
968
+ queued_state = gr.State(False)
969
+ last_pill_state = gr.State("")
970
+
971
  with gr.Row(equal_height=False):
972
  with gr.Column(scale=1, elem_classes=["panel-card"]):
973
  prompt = gr.Textbox(
 
977
  show_label=True,
978
  )
979
 
980
+ negative_prompt = gr.Textbox(
981
+ label="Negative Prompt",
982
+ placeholder="Things you want to avoid in the image (optional)...",
983
+ lines=2,
984
+ show_label=True,
985
+ elem_classes=["negative-prompt"],
986
+ )
987
+
988
  wh_ratio = gr.Dropdown(
989
  choices=WH_RATIO_OPTIONS,
990
  value=WH_RATIO_OPTIONS[0],
 
992
  info="Width : Height",
993
  )
994
 
995
+ guidance_scale = gr.Slider(
996
+ minimum=1.0,
997
+ maximum=20.0,
998
+ step=0.1,
999
+ value=5.0,
1000
+ label="Guidance Scale",
1001
+ info="Higher values follow the prompt more strictly",
1002
+ elem_classes=["guidance-slider"],
1003
+ )
1004
+
1005
+ with gr.Row(elem_classes=["advanced-row"], equal_height=True):
1006
+ enable_prompt_refine = gr.Checkbox(
1007
+ value=True,
1008
+ label="Prompt Refine",
1009
+ info="Let the model rewrite & enrich your prompt",
1010
+ elem_classes=["refine-toggle"],
1011
+ scale=1,
1012
+ )
1013
+ seed = gr.Number(
1014
+ value=-1,
1015
+ label="Seed",
1016
+ info="Use -1 for a random seed",
1017
+ precision=0,
1018
+ minimum=-1,
1019
+ elem_classes=["seed-input"],
1020
+ scale=1,
1021
+ )
1022
+
1023
  with gr.Row():
1024
  clear_btn = gr.Button("Clear", variant="secondary", scale=1)
1025
  generate_btn = gr.Button("Generate", variant="primary", scale=3)
 
1039
  elem_classes=["image-output"],
1040
  )
1041
 
1042
+ def generate_with_status(
1043
+ prompt,
1044
+ wh_ratio_value,
1045
+ negative_prompt_value,
1046
+ enable_prompt_refine_value,
1047
+ seed_value,
1048
+ guidance_scale_value,
1049
+ ):
1050
  logger.info(
1051
+ f"Starting image generation with prompt='{(prompt or '')[:50]}...', "
1052
+ f"wh_ratio={wh_ratio_value}, "
1053
+ f"negative_prompt='{(negative_prompt_value or '')[:30]}...', "
1054
+ f"enable_prompt_refine={enable_prompt_refine_value}, "
1055
+ f"seed={seed_value}, guidance_scale={guidance_scale_value}"
1056
  )
1057
 
1058
  yield None, _status_html("Sending request to API…", "running")
 
1068
  yield None, _status_html(f"Invalid aspect ratio β€œ{wh_ratio_value}”", "error")
1069
  return
1070
 
1071
+ try:
1072
+ seed_int = int(seed_value) if seed_value is not None else -1
1073
+ except (TypeError, ValueError):
1074
+ seed_int = -1
1075
+
1076
+ try:
1077
+ guidance_scale_f = float(guidance_scale_value) if guidance_scale_value is not None else 5.0
1078
+ except (TypeError, ValueError):
1079
+ guidance_scale_f = 5.0
1080
+
1081
+ if FAKE_TEST:
1082
+ logger.info("FAKE_TEST mode active β€” bypassing real API call")
1083
+ yield from _fake_generation_iter(prompt, wh_ratio_value)
1084
+ return
1085
+
1086
  logger.info("Creating API request")
1087
+ task_id = create_request(
1088
+ prompt,
1089
+ wh_ratio_value,
1090
+ negative_prompt=negative_prompt_value or "",
1091
+ enable_prompt_refine=bool(enable_prompt_refine_value),
1092
+ seed=seed_int,
1093
+ guidance_scale=guidance_scale_f,
1094
+ )
1095
  yield None, _status_html(f"Request submitted Β· Task {task_id[:8]}…", "running")
1096
 
1097
  start_time = time.time()
 
1179
  logger.error(f"Full traceback: {traceback.format_exc()}")
1180
  yield None, _status_html(f"Unexpected error: {str(e)}", "error")
1181
 
1182
+ def _enter_queue():
1183
+ """Click handler #1 β€” runs IMMEDIATELY (queue=False).
1184
+
1185
+ Flips queued_state to True and seeds the pill with a snapshot of
1186
+ the current queue. Subsequent live updates come from the timer.
1187
+ """
1188
+ waiting, running, _ = _read_queue_stats(demo)
1189
+ html = _status_html(_queue_text(waiting, running), "running")
1190
+ return None, html, True, html
1191
+
1192
+ def _generate_wrapped(
1193
+ prompt_value,
1194
+ wh_ratio_value,
1195
+ negative_prompt_value,
1196
+ enable_prompt_refine_value,
1197
+ seed_value,
1198
+ guidance_scale_value,
1199
+ ):
1200
+ """Click handler #2 β€” queued.
1201
+
1202
+ Wraps the existing generator and, on the FIRST yield, also flips
1203
+ queued_state to False so the timer stops touching the pill and
1204
+ lets the generator's own `yield`s drive it (Generating XXs β†’ ...).
1205
+ """
1206
+ first = True
1207
+ for image, status_html in generate_with_status(
1208
+ prompt_value,
1209
+ wh_ratio_value,
1210
+ negative_prompt_value,
1211
+ enable_prompt_refine_value,
1212
+ seed_value,
1213
+ guidance_scale_value,
1214
+ ):
1215
+ if first:
1216
+ first = False
1217
+ yield image, status_html, False
1218
+ else:
1219
+ yield image, status_html, gr.update()
1220
+
1221
  generate_btn.click(
1222
+ fn=_enter_queue,
1223
+ inputs=None,
1224
+ outputs=[output_image, status_msg, queued_state, last_pill_state],
1225
+ queue=False,
1226
  show_progress="hidden",
1227
+ ).then(
1228
+ fn=_generate_wrapped,
1229
+ inputs=[prompt, wh_ratio, negative_prompt, enable_prompt_refine, seed, guidance_scale],
1230
+ outputs=[output_image, status_msg, queued_state],
1231
+ show_progress="minimal",
1232
+ show_progress_on=[generate_btn],
1233
  )
1234
 
1235
  def clear_outputs():
1236
  logger.info("Clearing UI outputs")
1237
+ return None, _status_html("Ready", "info"), False, ""
1238
 
1239
  clear_btn.click(
1240
  fn=clear_outputs,
1241
  inputs=None,
1242
+ outputs=[output_image, status_msg, queued_state, last_pill_state],
1243
+ )
1244
+
1245
+ # Live queue updates inside the right-side pill β€” only while THIS
1246
+ # user is queued. Returns gr.update() (no-op) when nothing changed,
1247
+ # which prevents the DOM from being replaced and avoids the pulse
1248
+ # animation resetting (= no flicker).
1249
+ pill_timer = gr.Timer(value=1.5, active=True)
1250
+
1251
+ def _tick_pill(queued_flag, last_html):
1252
+ if not queued_flag:
1253
+ return gr.update(), last_html
1254
+ waiting, running, _ = _read_queue_stats(demo)
1255
+ new_html = _status_html(_queue_text(waiting, running), "running")
1256
+ if new_html == last_html:
1257
+ return gr.update(), last_html
1258
+ return new_html, new_html
1259
+
1260
+ pill_timer.tick(
1261
+ fn=_tick_pill,
1262
+ inputs=[queued_state, last_pill_state],
1263
+ outputs=[status_msg, last_pill_state],
1264
+ queue=False,
1265
+ show_progress="hidden",
1266
  )
1267
 
1268
  gr.HTML(