techfreakworm commited on
Commit
af844a1
·
unverified ·
1 Parent(s): 663f937

feat(ui): soft dark restraint redesign — native gr.Radio + accordion + lora toggle

Browse files

Drops the custom model-card grid, the popover tooltip pattern, the JS shim,
and the decorative CSS. Uses Gradio-native shapes throughout (gr.Radio for
Base/Turbo, gr.Checkbox to toggle LoRA visibility, gr.Accordion for advanced
width/height/seed, info= subtitle text in place of the (i) icon).

Adds the ControlNet preprocessor preview slot (Canny / Depth / Pose render
live as the user changes the dropdown).

Single output meta under the image (the prior mockup duplicated it inside
Advanced).

Files changed (6) hide show
  1. app.py +69 -34
  2. tests/test_app.py +16 -0
  3. tests/test_theme.py +26 -45
  4. tests/test_ui.py +51 -43
  5. theme.py +96 -184
  6. ui.py +229 -124
app.py CHANGED
@@ -24,6 +24,7 @@ import gradio as gr
24
  import backend
25
  import lora as lora_mod
26
  import models
 
27
  import theme
28
  import ui
29
 
@@ -93,12 +94,26 @@ def _coerce_lora(lora_path: str | None) -> Path | None:
93
 
94
 
95
  def _on_model_change(model_name: str) -> tuple[int, float]:
96
- """When the user clicks Base / Turbo in the custom selector, update steps + cfg."""
97
  if model_name == "Base":
98
  return 25, 4.0
99
  return 8, 1.0 # Turbo
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def _esrgan_path() -> str:
103
  """Locate the preloaded RealESRGAN_x4plus.pth."""
104
  from huggingface_hub import hf_hub_download
@@ -115,10 +130,13 @@ def on_t2i_generate(
115
  width,
116
  height,
117
  seed,
 
118
  lora_path,
119
  lora_strength,
120
  progress=gr.Progress(track_tqdm=True), # noqa: B008
121
  ):
 
 
122
  try:
123
  lora_p = _coerce_lora(lora_path)
124
  except lora_mod.LoRAValidationError as e:
@@ -147,10 +165,13 @@ def on_controlnet_generate(
147
  controlnet_scale,
148
  steps,
149
  seed,
 
150
  lora_path,
151
  lora_strength,
152
  progress=gr.Progress(track_tqdm=True), # noqa: B008
153
  ):
 
 
154
  try:
155
  lora_p = _coerce_lora(lora_path)
156
  except lora_mod.LoRAValidationError as e:
@@ -176,10 +197,13 @@ def on_upscale_generate(
176
  refine_steps,
177
  refine_denoise,
178
  seed,
 
179
  lora_path,
180
  lora_strength,
181
  progress=gr.Progress(track_tqdm=True), # noqa: B008
182
  ):
 
 
183
  try:
184
  lora_p = _coerce_lora(lora_path)
185
  except lora_mod.LoRAValidationError as e:
@@ -203,42 +227,16 @@ def on_upscale_generate(
203
 
204
  HEADER_HTML = """
205
  <div style="display:flex;justify-content:space-between;align-items:baseline;padding:8px 0 4px 0;">
206
- <div style="font-family:'Geist',sans-serif;font-size:16px;font-weight:600;letter-spacing:-0.02em;">
207
- z<span style="color:#FFB02E;">·</span>image studio
208
  </div>
209
- <div class="zis-status">ready</div>
210
  </div>
211
  """.strip()
212
 
213
 
214
- _HEAD_JS = """
215
- <script>
216
- window.zis = {
217
- setModel: function(name) {
218
- document.querySelectorAll('.zis-model').forEach(el => {
219
- el.classList.toggle('on', el.dataset.value === name);
220
- });
221
- const hidden = document.querySelector('#zis-model-state textarea, #zis-model-state input');
222
- if (hidden) {
223
- hidden.value = name;
224
- hidden.dispatchEvent(new Event('input', { bubbles: true }));
225
- }
226
- }
227
- };
228
- // Tap-to-pin tooltips on mobile
229
- document.addEventListener('touchstart', function(e) {
230
- const tip = e.target.closest('.zis-info');
231
- document.querySelectorAll('.zis-info.shown').forEach(el => {
232
- if (el !== tip) el.classList.remove('shown');
233
- });
234
- if (tip) tip.classList.toggle('shown');
235
- }, { passive: true });
236
- </script>
237
- """.strip()
238
-
239
-
240
  def build_app() -> gr.Blocks:
241
- with gr.Blocks(theme=theme.build_theme(), css=theme.CSS, head=_HEAD_JS, title="z-image-studio") as demo:
242
  gr.HTML(HEADER_HTML)
243
 
244
  with gr.Tabs():
@@ -249,22 +247,35 @@ def build_app() -> gr.Blocks:
249
  inputs=[
250
  t["prompt"],
251
  t["negative_prompt"],
252
- t["model_state"],
253
  t["steps"],
254
  t["cfg"],
255
  t["width"],
256
  t["height"],
257
  t["seed"],
 
258
  t["lora_path"],
259
  t["lora_strength"],
260
  ],
261
  outputs=[t["output_image"], t["output_meta"]],
262
  )
263
- t["model_state"].change(
 
264
  fn=_on_model_change,
265
- inputs=[t["model_state"]],
266
  outputs=[t["steps"], t["cfg"]],
267
  )
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  with gr.Tab("ControlNet"):
270
  c = ui.build_controlnet_tab()
@@ -277,11 +288,29 @@ def build_app() -> gr.Blocks:
277
  c["controlnet_scale"],
278
  c["steps"],
279
  c["seed"],
 
280
  c["lora_path"],
281
  c["lora_strength"],
282
  ],
283
  outputs=[c["output_image"], c["output_meta"]],
284
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
  with gr.Tab("Upscale"):
287
  u = ui.build_upscale_tab()
@@ -293,11 +322,17 @@ def build_app() -> gr.Blocks:
293
  u["refine_steps"],
294
  u["refine_denoise"],
295
  u["seed"],
 
296
  u["lora_path"],
297
  u["lora_strength"],
298
  ],
299
  outputs=[u["output_image"], u["output_meta"]],
300
  )
 
 
 
 
 
301
  return demo
302
 
303
 
 
24
  import backend
25
  import lora as lora_mod
26
  import models
27
+ import preprocessors
28
  import theme
29
  import ui
30
 
 
94
 
95
 
96
  def _on_model_change(model_name: str) -> tuple[int, float]:
97
+ """When the user picks Base / Turbo in the radio, update steps + cfg defaults."""
98
  if model_name == "Base":
99
  return 25, 4.0
100
  return 8, 1.0 # Turbo
101
 
102
 
103
+ def _preview_cn(image, mode):
104
+ """Render the live preprocessor preview next to the input on the ControlNet tab.
105
+
106
+ Wrapped in ``try/except`` so that a missing optional dep (controlnet_aux for
107
+ Depth / Pose) never breaks the form — it just falls back to the raw input.
108
+ """
109
+ if image is None:
110
+ return None
111
+ try:
112
+ return preprocessors.run(mode, image)
113
+ except Exception:
114
+ return image
115
+
116
+
117
  def _esrgan_path() -> str:
118
  """Locate the preloaded RealESRGAN_x4plus.pth."""
119
  from huggingface_hub import hf_hub_download
 
130
  width,
131
  height,
132
  seed,
133
+ lora_enabled,
134
  lora_path,
135
  lora_strength,
136
  progress=gr.Progress(track_tqdm=True), # noqa: B008
137
  ):
138
+ if not lora_enabled:
139
+ lora_path = None
140
  try:
141
  lora_p = _coerce_lora(lora_path)
142
  except lora_mod.LoRAValidationError as e:
 
165
  controlnet_scale,
166
  steps,
167
  seed,
168
+ lora_enabled,
169
  lora_path,
170
  lora_strength,
171
  progress=gr.Progress(track_tqdm=True), # noqa: B008
172
  ):
173
+ if not lora_enabled:
174
+ lora_path = None
175
  try:
176
  lora_p = _coerce_lora(lora_path)
177
  except lora_mod.LoRAValidationError as e:
 
197
  refine_steps,
198
  refine_denoise,
199
  seed,
200
+ lora_enabled,
201
  lora_path,
202
  lora_strength,
203
  progress=gr.Progress(track_tqdm=True), # noqa: B008
204
  ):
205
+ if not lora_enabled:
206
+ lora_path = None
207
  try:
208
  lora_p = _coerce_lora(lora_path)
209
  except lora_mod.LoRAValidationError as e:
 
227
 
228
  HEADER_HTML = """
229
  <div style="display:flex;justify-content:space-between;align-items:baseline;padding:8px 0 4px 0;">
230
+ <div style="font-size:16px;font-weight:600;letter-spacing:-0.01em;">
231
+ z-image-studio<span class="zis-brand-period">.</span>
232
  </div>
233
+ <div class="zis-status-dot" style="font-size:11px;color:#988B7C;letter-spacing:0.02em;">ready</div>
234
  </div>
235
  """.strip()
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  def build_app() -> gr.Blocks:
239
+ with gr.Blocks(theme=theme.build_theme(), css=theme.CSS, title="z-image-studio") as demo:
240
  gr.HTML(HEADER_HTML)
241
 
242
  with gr.Tabs():
 
247
  inputs=[
248
  t["prompt"],
249
  t["negative_prompt"],
250
+ t["model"],
251
  t["steps"],
252
  t["cfg"],
253
  t["width"],
254
  t["height"],
255
  t["seed"],
256
+ t["lora_enabled"],
257
  t["lora_path"],
258
  t["lora_strength"],
259
  ],
260
  outputs=[t["output_image"], t["output_meta"]],
261
  )
262
+ # Radio change → update step / cfg defaults + reveal Base-only fields.
263
+ t["model"].change(
264
  fn=_on_model_change,
265
+ inputs=[t["model"]],
266
  outputs=[t["steps"], t["cfg"]],
267
  )
268
+ t["model"].change(
269
+ fn=lambda m: gr.Group(visible=(m == "Base")),
270
+ inputs=[t["model"]],
271
+ outputs=[t["base_group"]],
272
+ )
273
+ # LoRA checkbox → reveal file + strength.
274
+ t["lora_enabled"].change(
275
+ fn=lambda v: gr.Group(visible=v),
276
+ inputs=[t["lora_enabled"]],
277
+ outputs=[t["lora_group"]],
278
+ )
279
 
280
  with gr.Tab("ControlNet"):
281
  c = ui.build_controlnet_tab()
 
288
  c["controlnet_scale"],
289
  c["steps"],
290
  c["seed"],
291
+ c["lora_enabled"],
292
  c["lora_path"],
293
  c["lora_strength"],
294
  ],
295
  outputs=[c["output_image"], c["output_meta"]],
296
  )
297
+ # Live preprocessor preview — fires on input change or mode change.
298
+ c["input_image"].change(
299
+ fn=_preview_cn,
300
+ inputs=[c["input_image"], c["preprocessor"]],
301
+ outputs=[c["preview_image"]],
302
+ )
303
+ c["preprocessor"].change(
304
+ fn=_preview_cn,
305
+ inputs=[c["input_image"], c["preprocessor"]],
306
+ outputs=[c["preview_image"]],
307
+ )
308
+ # LoRA checkbox → reveal file + strength.
309
+ c["lora_enabled"].change(
310
+ fn=lambda v: gr.Group(visible=v),
311
+ inputs=[c["lora_enabled"]],
312
+ outputs=[c["lora_group"]],
313
+ )
314
 
315
  with gr.Tab("Upscale"):
316
  u = ui.build_upscale_tab()
 
322
  u["refine_steps"],
323
  u["refine_denoise"],
324
  u["seed"],
325
+ u["lora_enabled"],
326
  u["lora_path"],
327
  u["lora_strength"],
328
  ],
329
  outputs=[u["output_image"], u["output_meta"]],
330
  )
331
+ u["lora_enabled"].change(
332
+ fn=lambda v: gr.Group(visible=v),
333
+ inputs=[u["lora_enabled"]],
334
+ outputs=[u["lora_group"]],
335
+ )
336
  return demo
337
 
338
 
tests/test_app.py CHANGED
@@ -11,3 +11,19 @@ def test_on_model_change_returns_turbo_defaults():
11
 
12
  def test_on_model_change_unknown_falls_back_to_turbo():
13
  assert app._on_model_change("Edit") == (8, 1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  def test_on_model_change_unknown_falls_back_to_turbo():
13
  assert app._on_model_change("Edit") == (8, 1.0)
14
+
15
+
16
+ def test_preview_cn_returns_none_when_no_image():
17
+ assert app._preview_cn(None, "Canny") is None
18
+
19
+
20
+ def test_preview_cn_falls_back_to_input_on_error():
21
+ """If the preprocessor raises (e.g. missing optional dep), pass-through."""
22
+
23
+ from PIL import Image
24
+
25
+ img = Image.new("RGB", (16, 16), "white")
26
+ # "BogusMode" raises ValueError inside preprocessors.run — _preview_cn should
27
+ # swallow it and return the raw input.
28
+ out = app._preview_cn(img, "BogusMode")
29
+ assert out is img
tests/test_theme.py CHANGED
@@ -1,68 +1,49 @@
 
 
1
  import theme
2
 
3
 
4
- def test_amber_palette_tokens_match_spec():
5
- pal = theme.AMBER
6
- assert pal["body_bg"] == "#0F0C08"
7
- assert pal["text"] == "#FAF1E3"
8
- assert pal["text_dim"] == "#A89478"
9
- assert pal["border"] == "#2A2218"
10
  assert pal["accent"] == "#FFB02E"
11
  assert pal["accent_text"] == "#1A1208"
12
- assert pal["radius"] == "8px"
13
 
14
 
15
  def test_build_theme_returns_gradio_base():
16
- import gradio as gr
17
-
18
  th = theme.build_theme()
19
  assert isinstance(th, gr.themes.Base)
20
 
21
 
22
- def test_css_string_contains_critical_selectors():
23
- css = theme.CSS
24
- # warm vignette + amber button glow are the two decorations the spec calls out
25
- assert "radial-gradient" in css
26
- assert "rgba(255,176,46" in css.lower() or "255, 176, 46" in css.lower()
27
-
28
-
29
- def test_fonts_geist_and_geist_mono():
30
  th = theme.build_theme()
31
-
32
- # (a) Iterable lists: .font_list / .font_mono_list expose the original entries.
33
- fonts = [str(f) for f in th.font_list]
34
- assert any("Geist" in f for f in fonts)
35
- monos = [str(f) for f in th.font_mono_list]
36
- assert any("Geist Mono" in f for f in monos)
37
-
38
- # (b) CSS variables: _get_theme_css() must emit --font and --font-mono that
39
- # reference Geist / Geist Mono so the browser actually loads the fonts.
40
- # This assertion is what catches the original bug where property setters
41
- # redirected self.font → font_str, causing --font-str to be emitted instead.
42
  css = th._get_theme_css()
43
- assert "--font:" in css, "--font CSS variable missing from generated theme CSS"
44
- assert "--font-mono:" in css, "--font-mono CSS variable missing from generated theme CSS"
45
- assert "Geist" in css, "Geist font name missing from generated theme CSS"
46
- assert "Geist Mono" in css, "Geist Mono font name missing from generated theme CSS"
47
 
48
 
49
- def test_css_includes_param_tooltip_rule():
50
  css = theme.CSS
51
- assert ".zis-info" in css
52
- assert "data-info" in css # attr() reference in ::after
53
- assert "::after" in css
54
 
55
 
56
- def test_css_includes_model_selector_rules():
57
  css = theme.CSS
58
- assert ".zis-models" in css
59
- assert ".zis-model" in css
60
- assert ".zis-model.on" in css
61
- assert ".zis-model.soon" in css
62
 
63
 
64
- def test_css_model_grid_is_responsive():
65
  css = theme.CSS
66
- assert "grid-template-columns" in css
67
- assert "@media" in css
68
- assert "min-width: 768px" in css or "min-width:768px" in css
 
 
 
 
1
+ import gradio as gr
2
+
3
  import theme
4
 
5
 
6
+ def test_palette_tokens_match_soft_dark_restraint_spec():
7
+ pal = theme.PALETTE
8
+ assert pal["body_bg"] == "#1A1614"
9
+ assert pal["text"] == "#F0E8DD"
10
+ assert pal["text_dim"] == "#988B7C"
11
+ assert pal["border"] == "#2A241E"
12
  assert pal["accent"] == "#FFB02E"
13
  assert pal["accent_text"] == "#1A1208"
14
+ assert pal["radius"] == "6px"
15
 
16
 
17
  def test_build_theme_returns_gradio_base():
 
 
18
  th = theme.build_theme()
19
  assert isinstance(th, gr.themes.Base)
20
 
21
 
22
+ def test_theme_drops_mono_font():
23
+ """Redesign uses Inter only — no Geist / Geist Mono / mono custom font."""
 
 
 
 
 
 
24
  th = theme.build_theme()
 
 
 
 
 
 
 
 
 
 
 
25
  css = th._get_theme_css()
26
+ assert "Geist" not in css
27
+ assert "Geist Mono" not in css
 
 
28
 
29
 
30
+ def test_css_includes_soon_row_styling():
31
  css = theme.CSS
32
+ assert ".zis-soon-row" in css
33
+ assert ".zis-soon-row a" in css
 
34
 
35
 
36
+ def test_css_includes_compact_lora_file_widget():
37
  css = theme.CSS
38
+ assert ".zis-lora-file" in css
39
+ assert "min-height: 56px" in css
 
 
40
 
41
 
42
+ def test_css_does_not_reference_deleted_selectors():
43
  css = theme.CSS
44
+ # Old (i) tooltip pattern is gone — info= flows through gr.* directly.
45
+ assert ".zis-info" not in css
46
+ # Old custom card grid is gone gr.Radio replaces it.
47
+ assert ".zis-models" not in css
48
+ assert ".zis-model.on" not in css
49
+ assert ".zis-model.soon" not in css
tests/test_ui.py CHANGED
@@ -4,48 +4,6 @@ import pytest
4
  import ui
5
 
6
 
7
- def test_labeled_label_returns_html_string():
8
- out = ui.labeled_label("Steps", "Denoising steps.")
9
- assert isinstance(out, str)
10
- assert "<label" in out and "</label>" in out
11
- assert ">Steps<" in out
12
- assert 'data-info="Denoising steps."' in out
13
- assert ">i<" in out # the icon glyph
14
-
15
-
16
- def test_labeled_label_escapes_html_chars():
17
- out = ui.labeled_label("Steps <x>", 'A "quoted" hint')
18
- assert "<x>" not in out
19
- assert "&lt;x&gt;" in out
20
- assert "&quot;quoted&quot;" in out
21
-
22
-
23
- def test_model_selector_html_marks_current_as_on():
24
- out = ui.model_selector_html(current="Turbo")
25
- assert 'class="zis-model on" data-value="Turbo"' in out
26
- assert 'class="zis-model" data-value="Base"' in out
27
-
28
-
29
- def test_model_selector_html_includes_both_soon_cards_with_github_link():
30
- out = ui.model_selector_html(current="Turbo")
31
- assert out.count("github.com/Tongyi-MAI/Z-Image#-model-zoo") == 2
32
- assert "Edit" in out
33
- assert "Omni Base" in out
34
- assert "soon-tag" in out
35
- assert 'target="_blank"' in out
36
- assert 'rel="noopener noreferrer"' in out
37
-
38
-
39
- def test_model_selector_html_defaults_to_turbo():
40
- out = ui.model_selector_html()
41
- assert 'class="zis-model on" data-value="Turbo"' in out
42
-
43
-
44
- def test_model_selector_html_escapes_current_value():
45
- out = ui.model_selector_html(current="<script>alert(1)</script>")
46
- assert "<script>" not in out
47
-
48
-
49
  @pytest.fixture(autouse=True)
50
  def _blocks_ctx():
51
  """Each builder must be called inside a gr.Blocks() context."""
@@ -53,12 +11,29 @@ def _blocks_ctx():
53
  yield
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def test_build_t2i_tab_returns_components():
57
  components = ui.build_t2i_tab()
58
  expected = {
59
  "prompt",
60
  "negative_prompt",
61
- "model_state",
 
 
 
62
  "steps",
63
  "cfg",
64
  "width",
@@ -73,15 +48,40 @@ def test_build_t2i_tab_returns_components():
73
  assert expected.issubset(components.keys())
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def test_build_controlnet_tab_returns_components():
77
  components = ui.build_controlnet_tab()
78
  expected = {
79
  "prompt",
80
  "input_image",
 
81
  "preprocessor",
82
  "controlnet_scale",
83
  "steps",
84
  "seed",
 
 
85
  "lora_path",
86
  "lora_strength",
87
  "generate_btn",
@@ -91,6 +91,12 @@ def test_build_controlnet_tab_returns_components():
91
  assert expected.issubset(components.keys())
92
 
93
 
 
 
 
 
 
 
94
  def test_build_upscale_tab_returns_components():
95
  components = ui.build_upscale_tab()
96
  expected = {
@@ -99,6 +105,8 @@ def test_build_upscale_tab_returns_components():
99
  "refine_steps",
100
  "refine_denoise",
101
  "seed",
 
 
102
  "lora_path",
103
  "lora_strength",
104
  "generate_btn",
 
4
  import ui
5
 
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  @pytest.fixture(autouse=True)
8
  def _blocks_ctx():
9
  """Each builder must be called inside a gr.Blocks() context."""
 
11
  yield
12
 
13
 
14
+ def test_model_soon_row_links_to_zoo_twice():
15
+ html = ui._model_soon_row_html()
16
+ assert html.count(ui.MODEL_ZOO_URL) == 2
17
+ assert "Edit" in html and "Omni Base" in html
18
+ assert "(coming soon)" in html
19
+ assert 'target="_blank"' in html
20
+ assert 'rel="noopener noreferrer"' in html
21
+
22
+
23
+ def test_model_soon_row_uses_dim_link_class():
24
+ html = ui._model_soon_row_html()
25
+ assert 'class="zis-soon-row"' in html
26
+
27
+
28
  def test_build_t2i_tab_returns_components():
29
  components = ui.build_t2i_tab()
30
  expected = {
31
  "prompt",
32
  "negative_prompt",
33
+ "model",
34
+ "base_group",
35
+ "lora_enabled",
36
+ "lora_group",
37
  "steps",
38
  "cfg",
39
  "width",
 
48
  assert expected.issubset(components.keys())
49
 
50
 
51
+ def test_build_t2i_tab_model_is_native_radio():
52
+ components = ui.build_t2i_tab()
53
+ assert isinstance(components["model"], gr.Radio)
54
+ assert components["model"].value == "Turbo"
55
+ # Confirm the radio carries both choices. Gradio stores them as (label, value)
56
+ # tuples on the .choices attribute.
57
+ values = [c[1] for c in components["model"].choices]
58
+ assert values == ["Base", "Turbo"]
59
+
60
+
61
+ def test_build_t2i_tab_lora_group_starts_hidden():
62
+ components = ui.build_t2i_tab()
63
+ assert components["lora_group"].visible is False
64
+ assert components["lora_enabled"].value is False
65
+
66
+
67
+ def test_build_t2i_tab_base_group_starts_hidden():
68
+ """Turbo is the default model, so the Base-only fields are hidden up front."""
69
+ components = ui.build_t2i_tab()
70
+ assert components["base_group"].visible is False
71
+
72
+
73
  def test_build_controlnet_tab_returns_components():
74
  components = ui.build_controlnet_tab()
75
  expected = {
76
  "prompt",
77
  "input_image",
78
+ "preview_image",
79
  "preprocessor",
80
  "controlnet_scale",
81
  "steps",
82
  "seed",
83
+ "lora_enabled",
84
+ "lora_group",
85
  "lora_path",
86
  "lora_strength",
87
  "generate_btn",
 
91
  assert expected.issubset(components.keys())
92
 
93
 
94
+ def test_build_controlnet_tab_preview_is_non_interactive_image():
95
+ components = ui.build_controlnet_tab()
96
+ assert isinstance(components["preview_image"], gr.Image)
97
+ assert components["preview_image"].interactive is False
98
+
99
+
100
  def test_build_upscale_tab_returns_components():
101
  components = ui.build_upscale_tab()
102
  expected = {
 
105
  "refine_steps",
106
  "refine_denoise",
107
  "seed",
108
+ "lora_enabled",
109
+ "lora_group",
110
  "lora_path",
111
  "lora_strength",
112
  "generate_btn",
theme.py CHANGED
@@ -1,54 +1,45 @@
1
- """Onyx Amber theme — palette tokens, gr.themes.Base subclass, and CSS string."""
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
  import gradio as gr
6
 
7
- AMBER: dict[str, str] = {
8
- "body_bg": "#0F0C08",
9
- "panel_bg": "#0F0C08",
10
- "input_bg": "#0F0C08",
11
- "canvas_bg": "#110D08",
12
- "border": "#2A2218",
13
- "text": "#FAF1E3",
14
- "text_dim": "#A89478",
 
 
 
15
  "accent": "#FFB02E",
16
  "accent_text": "#1A1208",
17
- "radius": "8px",
18
- "radius_sm": "6px",
19
  }
20
 
21
 
22
- class _OnyxAmberBase(gr.themes.Base):
23
- """gr.themes.Base subclass with helpers for iterating over font lists.
24
 
25
- Gradio collapses font lists into a CSS string at __init__; we expose the
26
- original lists via .font_list / .font_mono_list for code that needs to
27
- iterate. The public self.font / self.font_mono attributes are left alone so
28
- that _get_theme_css() emits the correct --font / --font-mono CSS variables.
29
  """
30
-
31
- @property
32
- def font_list(self) -> list:
33
- """Return the original font list (GoogleFont + str entries)."""
34
- return self._font
35
-
36
- @property
37
- def font_mono_list(self) -> list:
38
- """Return the original monospace font list (GoogleFont + str entries)."""
39
- return self._font_mono
40
-
41
-
42
- def build_theme() -> gr.themes.Base:
43
- """Return a Gradio theme matching the Onyx Amber palette."""
44
- return _OnyxAmberBase(
45
  primary_hue=gr.themes.Color(
46
  c50="#FFF8E6",
47
  c100="#FFEFC2",
48
  c200="#FFE08A",
49
  c300="#FFD161",
50
  c400="#FFC042",
51
- c500=AMBER["accent"],
52
  c600="#E69926",
53
  c700="#B37A1F",
54
  c800="#805717",
@@ -56,164 +47,85 @@ def build_theme() -> gr.themes.Base:
56
  c950="#1A1208",
57
  ),
58
  neutral_hue=gr.themes.Color(
59
- c50="#FAF1E3",
60
- c100="#E8DCC4",
61
- c200="#D4C2A1",
62
- c300="#A89478",
63
- c400="#867054",
64
- c500="#5C4D38",
65
- c600="#3C3225",
66
- c700="#2A2218",
67
- c800="#1C170F",
68
- c900="#100C08",
69
- c950="#0A0805",
70
  ),
71
- font=[gr.themes.GoogleFont("Geist"), "system-ui", "sans-serif"],
72
- font_mono=[gr.themes.GoogleFont("Geist Mono"), "ui-monospace", "monospace"],
73
- radius_size=gr.themes.sizes.radius_md,
74
  ).set(
75
- body_background_fill=AMBER["body_bg"],
76
- body_text_color=AMBER["text"],
77
- body_text_color_subdued=AMBER["text_dim"],
78
- background_fill_primary=AMBER["panel_bg"],
79
- background_fill_secondary=AMBER["canvas_bg"],
80
- block_background_fill=AMBER["panel_bg"],
81
- block_border_color=AMBER["border"],
82
  block_border_width="1px",
83
- block_radius=AMBER["radius"],
84
- input_background_fill=AMBER["input_bg"],
85
- input_border_color=AMBER["border"],
86
- button_primary_background_fill=AMBER["accent"],
87
- button_primary_background_fill_hover=AMBER["accent"],
88
- button_primary_text_color=AMBER["accent_text"],
89
- button_primary_border_color=AMBER["accent"],
90
- slider_color=AMBER["accent"],
91
- color_accent=AMBER["accent"],
92
  color_accent_soft="rgba(255,176,46,0.12)",
93
  )
94
 
95
 
96
  CSS: str = """
97
- /* Onyx Amberatmospheric layer that Gradio's theme can't express alone */
98
-
99
- body, .gradio-container {
100
- background-image: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(255,176,46,0.06), transparent 70%);
101
- }
102
-
103
- /* Amber glow on primary button */
104
- .gradio-container button.primary {
105
- box-shadow: 0 0 0 1px rgba(255,176,46,0.4), 0 8px 24px -8px rgba(255,176,46,0.35);
106
- }
107
-
108
- /* Slim status line typography */
109
- .zis-status {
110
- font-family: 'Geist Mono', ui-monospace, monospace;
111
- font-size: 11px;
112
- letter-spacing: 0.06em;
113
- color: #A89478;
114
- }
115
-
116
- /* LoRA file slot — solid amber border + slim icon when a file is loaded */
117
- .zis-lora.loaded {
118
- border: 1px solid #FFB02E !important;
119
- }
120
-
121
- /* ===== Param tooltip — (i) icon next to labels (spec § 4.6) ===== */
122
-
123
- .zis-row-label {
124
- display: inline-flex; align-items: center;
125
- font-size: 11px; color: #A89478; font-weight: 500;
126
- margin-bottom: 6px;
127
- }
128
- .zis-info {
129
- display: inline-flex; align-items: center; justify-content: center;
130
- width: 12px; height: 12px;
131
- font: italic 600 8px 'Geist', system-ui, sans-serif;
132
- border: 1px solid #2A2218; border-radius: 50%;
133
- color: #A89478; vertical-align: super;
134
- margin-left: 3px; cursor: help; position: relative;
135
- transition: border-color 0.12s, color 0.12s;
136
- }
137
- .zis-info:hover { border-color: #FFB02E; color: #FFB02E; }
138
- .zis-info::after {
139
- content: attr(data-info);
140
- position: absolute; bottom: 100%; left: 50%;
141
- transform: translateX(-50%) translateY(-4px);
142
- background: #1C170F; color: #FAF1E3;
143
- border: 1px solid #2A2218; border-radius: 6px;
144
- padding: 6px 10px;
145
- font: 400 11px 'Geist', system-ui, sans-serif; line-height: 1.4;
146
- width: 200px; white-space: normal;
147
- opacity: 0; pointer-events: none;
148
- transition: opacity 0.12s; z-index: 50;
149
- box-shadow: 0 4px 16px rgba(0,0,0,0.4);
150
- }
151
- .zis-info:hover::after, .zis-info.shown::after { opacity: 1; }
152
-
153
- /* Hidden state carrier — in DOM for JS targeting, invisible to users */
154
- .zis-hidden { display: none !important; }
155
-
156
- /* ===== Custom model selector — 2-col phone / 4-col tablet+ (spec § 4.7) ===== */
157
-
158
- .zis-models {
159
- display: grid;
160
- grid-template-columns: 1fr 1fr;
161
- gap: 8px;
162
- margin-bottom: 10px;
163
- }
164
- @media (min-width: 768px) {
165
- .zis-models { grid-template-columns: repeat(4, 1fr); }
166
- }
167
- .zis-model {
168
- display: flex; align-items: center; gap: 8px;
169
- padding: 10px 12px;
170
- border: 1px solid #2A2218; border-radius: 8px;
171
- background: transparent; cursor: pointer;
172
- color: #FAF1E3;
173
- font: 500 12px 'Geist', system-ui, sans-serif;
174
- text-decoration: none;
175
- transition: opacity 0.15s, border-color 0.15s, background 0.15s;
176
- }
177
- .zis-model .dot {
178
- width: 10px; height: 10px; border-radius: 50%;
179
- border: 1px solid #2A2218; flex-shrink: 0;
180
- }
181
- .zis-model .name { flex: 1; text-align: left; }
182
- .zis-model.on {
183
- background: #FFB02E; color: #1A1208; border-color: #FFB02E;
184
- }
185
- .zis-model.on .dot { background: #1A1208; border-color: #1A1208; }
186
- .zis-model.soon {
187
- opacity: 0.55;
188
- background: rgba(255,176,46,0.04);
189
- border-style: dashed;
190
- position: relative;
191
- }
192
- .zis-model.soon .name { color: #A89478; }
193
- .zis-model.soon .name .ext {
194
- font-size: 10px; color: #FFB02E;
195
- margin-left: 4px; vertical-align: super;
196
- }
197
- .zis-model.soon .soon-tag {
198
- font-family: 'Geist Mono', ui-monospace, monospace;
199
- font-size: 8.5px; letter-spacing: 0.12em; text-transform: uppercase;
200
- background: rgba(255,176,46,0.18); color: #FFB02E;
201
- padding: 2px 6px; border-radius: 100px;
202
- flex-shrink: 0;
203
- }
204
- .zis-model.soon:hover { opacity: 0.78; border-color: #FFB02E; }
205
- .zis-model.soon::after {
206
- content: "Coming soon — opens GitHub";
207
- position: absolute; bottom: 100%; left: 50%;
208
- transform: translateX(-50%) translateY(-4px);
209
- background: #1C170F; color: #FAF1E3;
210
- border: 1px solid #2A2218; border-radius: 6px;
211
- padding: 6px 10px;
212
- font: 400 11px 'Geist', system-ui, sans-serif;
213
- white-space: nowrap;
214
- opacity: 0; pointer-events: none;
215
- transition: opacity 0.12s; z-index: 50;
216
- box-shadow: 0 4px 16px rgba(0,0,0,0.4);
217
  }
218
- .zis-model.soon:hover::after { opacity: 1; }
219
  """.strip()
 
1
+ """Soft Dark Restraint theme — warm-toned dark surface with a single amber accent.
2
+
3
+ Palette tokens, ``gr.themes.Base`` configuration, and a small CSS string that
4
+ applies the touches Gradio's theme tokens can't express alone (link row under
5
+ the model radio + compact LoRA file widget).
6
+ """
7
 
8
  from __future__ import annotations
9
 
10
  import gradio as gr
11
 
12
+ # Single source of truth for the palette. The mockup at
13
+ # ``.superpowers/brainstorm/47889-1778679653/content/simple-v1.html`` is the
14
+ # locked spec for these values (Variant A — "Soft Dark Restraint").
15
+ PALETTE: dict[str, str] = {
16
+ "body_bg": "#1A1614",
17
+ "panel_bg": "#1F1B17",
18
+ "input_bg": "#14110F",
19
+ "text": "#F0E8DD",
20
+ "text_dim": "#988B7C",
21
+ "border": "#2A241E",
22
+ "border_strong": "#3A3128",
23
  "accent": "#FFB02E",
24
  "accent_text": "#1A1208",
25
+ "radius": "6px",
 
26
  }
27
 
28
 
29
+ def build_theme() -> gr.themes.Base:
30
+ """Return a Gradio theme matching the Soft Dark Restraint palette.
31
 
32
+ Uses Gradio's default font chain (Inter + system-ui fallbacks). No mono
33
+ font the redesign drops monospaced UI text entirely.
 
 
34
  """
35
+ return gr.themes.Base(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  primary_hue=gr.themes.Color(
37
  c50="#FFF8E6",
38
  c100="#FFEFC2",
39
  c200="#FFE08A",
40
  c300="#FFD161",
41
  c400="#FFC042",
42
+ c500=PALETTE["accent"],
43
  c600="#E69926",
44
  c700="#B37A1F",
45
  c800="#805717",
 
47
  c950="#1A1208",
48
  ),
49
  neutral_hue=gr.themes.Color(
50
+ c50="#F0E8DD",
51
+ c100="#E0D5C3",
52
+ c200="#C8B89E",
53
+ c300="#988B7C",
54
+ c400="#7A6E60",
55
+ c500="#5C5246",
56
+ c600="#3A3128",
57
+ c700="#2A241E",
58
+ c800="#1F1B17",
59
+ c900="#1A1614",
60
+ c950="#14110F",
61
  ),
62
+ radius_size=gr.themes.sizes.radius_sm,
 
 
63
  ).set(
64
+ body_background_fill=PALETTE["body_bg"],
65
+ body_text_color=PALETTE["text"],
66
+ body_text_color_subdued=PALETTE["text_dim"],
67
+ background_fill_primary=PALETTE["panel_bg"],
68
+ background_fill_secondary=PALETTE["body_bg"],
69
+ block_background_fill=PALETTE["panel_bg"],
70
+ block_border_color=PALETTE["border"],
71
  block_border_width="1px",
72
+ block_radius=PALETTE["radius"],
73
+ input_background_fill=PALETTE["input_bg"],
74
+ input_border_color=PALETTE["border"],
75
+ button_primary_background_fill=PALETTE["accent"],
76
+ button_primary_background_fill_hover=PALETTE["accent"],
77
+ button_primary_text_color=PALETTE["accent_text"],
78
+ button_primary_border_color=PALETTE["accent"],
79
+ slider_color=PALETTE["accent"],
80
+ color_accent=PALETTE["accent"],
81
  color_accent_soft="rgba(255,176,46,0.12)",
82
  )
83
 
84
 
85
  CSS: str = """
86
+ /* Soft Dark Restraint calm, single-accent decorations Gradio tokens can't express. */
87
+
88
+ /* Small dim link row under the model radio (Edit / Omni Base · coming soon). */
89
+ .zis-soon-row {
90
+ margin-top: 6px;
91
+ font-size: 12px;
92
+ color: #988B7C;
93
+ line-height: 1.45;
94
+ }
95
+ .zis-soon-row a {
96
+ color: #988B7C;
97
+ text-decoration: underline;
98
+ text-decoration-color: #3A3128;
99
+ text-underline-offset: 3px;
100
+ }
101
+ .zis-soon-row a:hover {
102
+ color: #F0E8DD;
103
+ text-decoration-color: #988B7C;
104
+ }
105
+ .zis-soon-row .sep {
106
+ margin: 0 6px;
107
+ color: #3A3128;
108
+ }
109
+ .zis-soon-row .dim {
110
+ margin-left: 6px;
111
+ color: #635A4E;
112
+ }
113
+
114
+ /* Compact LoRA file widget — tighten Gradio's default 400px drop zone. */
115
+ .zis-lora-file .upload-container { min-height: 56px !important; padding: 8px 12px !important; }
116
+ .zis-lora-file .icon-wrap, .zis-lora-file svg { display: none !important; }
117
+ .zis-lora-file .wrap > * { text-align: left; }
118
+
119
+ /* Brand period uses the accent — the only place the accent appears in chrome. */
120
+ .zis-brand-period { color: #FFB02E; }
121
+
122
+ /* Live-status dot — accent, matches the single-accent rule. */
123
+ .zis-status-dot::before {
124
+ content: "";
125
+ display: inline-block;
126
+ width: 6px; height: 6px; border-radius: 50%;
127
+ background: #FFB02E;
128
+ margin-right: 6px;
129
+ vertical-align: middle;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  }
 
131
  """.strip()
ui.py CHANGED
@@ -1,109 +1,118 @@
1
- """Gradio UI builders + small HTML helpers for the (i) tooltip pattern and the custom model selector."""
2
 
3
- from __future__ import annotations
 
 
 
 
4
 
5
- from html import escape
6
 
7
  import gradio as gr
8
 
9
  import preprocessors
10
  from tooltips import TOOLTIPS
11
 
12
- GITHUB_MODEL_ZOO_URL = "https://github.com/Tongyi-MAI/Z-Image#-model-zoo"
 
 
 
 
13
 
14
 
15
- def labeled_label(text: str, info_text: str) -> str:
16
- """Return HTML for a label with an (i) tooltip icon next to it.
17
 
18
- Use immediately before a ``gr.Slider`` / ``gr.Textbox`` / ``gr.File`` etc.
19
- that itself has ``show_label=False``. The CSS for ``.zis-row-label`` and
20
- ``.zis-info`` is defined in :mod:`theme`.
21
  """
22
  return (
23
- f'<label class="zis-row-label">{escape(text)}'
24
- f'<span class="zis-info" data-info="{escape(info_text)}">i</span>'
25
- f"</label>"
 
 
 
26
  )
27
 
28
 
29
- def model_selector_html(current: str = "Turbo") -> str:
30
- """Custom T2I model selector — 2-col phone / 4-col tablet+ grid of cards.
 
 
 
 
 
 
 
31
 
32
- Two functional ``<button>`` cards (Base, Turbo) — clicks fire
33
- ``zis.setModel('<name>')`` defined in app.py's ``head=`` script.
 
 
 
 
 
34
 
35
- Two coming-soon ``<a>`` cards (Edit, Omni Base) open the Z-Image GitHub
36
- README's Model Zoo section in a new tab. Marked with a `.soon` class and a
37
- "soon" pill that doesn't overlap the model name (separate flex children).
38
- """
39
- current_safe = escape(current)
40
- cards: list[str] = []
41
- for name in ("Base", "Turbo"):
42
- cls = "zis-model on" if name == current else "zis-model"
43
- cards.append(
44
- f'<button type="button" class="{cls}" data-value="{name}" '
45
- f"onclick=\"zis.setModel('{name}')\">"
46
- f'<span class="dot"></span>'
47
- f'<span class="name">{name}</span>'
48
- f"</button>"
49
- )
50
- for name in ("Edit", "Omni Base"):
51
- cards.append(
52
- f'<a class="zis-model soon" '
53
- f'href="{GITHUB_MODEL_ZOO_URL}" '
54
- f'target="_blank" rel="noopener noreferrer">'
55
- f'<span class="dot"></span>'
56
- f'<span class="name">{name}<span class="ext">↗</span></span>'
57
- f'<span class="soon-tag">soon</span>'
58
- f"</a>"
59
- )
60
- _ = current_safe # current is matched in cls above; this line keeps escape() exercised
61
- return f'<div class="zis-models">{"".join(cards)}</div>'
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- def build_t2i_tab() -> dict[str, gr.components.Component]:
65
- with gr.Row():
66
- with gr.Column(scale=4):
67
- gr.HTML(labeled_label("Prompt", TOOLTIPS["prompt"]))
68
- prompt = gr.Textbox(lines=4, show_label=False, placeholder="A latina model peeking through pine branches…")
69
- gr.HTML(labeled_label("Negative prompt (Base only)", TOOLTIPS["negative_prompt"]))
70
- negative_prompt = gr.Textbox(lines=2, show_label=False, placeholder="blurry, lowres, distorted")
71
- gr.HTML(labeled_label("Model", TOOLTIPS["model"]))
72
- model_state = gr.Textbox(value="Turbo", elem_id="zis-model-state", elem_classes=["zis-hidden"])
73
- gr.HTML(model_selector_html(current="Turbo"))
74
- with gr.Row():
75
- with gr.Column():
76
- gr.HTML(labeled_label("LoRA (optional)", TOOLTIPS["lora"]))
77
- lora_path = gr.File(file_types=[".safetensors"], type="filepath", show_label=False)
78
- with gr.Column():
79
- gr.HTML(labeled_label("LoRA strength", TOOLTIPS["lora_strength"]))
80
- lora_strength = gr.Slider(0.0, 1.5, value=0.8, step=0.05, show_label=False)
81
- with gr.Row():
82
- with gr.Column():
83
- gr.HTML(labeled_label("Steps", TOOLTIPS["steps"]))
84
- steps = gr.Slider(1, 50, value=8, step=1, show_label=False)
85
- with gr.Column():
86
- gr.HTML(labeled_label("CFG (Base only)", TOOLTIPS["cfg"]))
87
- cfg = gr.Slider(0.5, 12.0, value=1.0, step=0.1, show_label=False)
88
- with gr.Row():
89
- with gr.Column():
90
- gr.HTML(labeled_label("Width", TOOLTIPS["width"]))
91
- width = gr.Slider(384, 1536, value=1024, step=64, show_label=False)
92
- with gr.Column():
93
- gr.HTML(labeled_label("Height", TOOLTIPS["height"]))
94
- height = gr.Slider(384, 1536, value=1024, step=64, show_label=False)
95
- with gr.Column():
96
- gr.HTML(labeled_label("Seed (0 = random)", TOOLTIPS["seed"]))
97
- seed = gr.Number(value=0, precision=0, show_label=False)
98
  generate_btn = gr.Button("Generate", variant="primary")
 
99
  with gr.Column(scale=5):
100
- gr.HTML(labeled_label("Output", TOOLTIPS["output"]))
101
- output_image = gr.Image(type="pil", height=512, show_download_button=True, show_label=False)
 
 
 
 
 
102
  output_meta = gr.JSON(label="Meta", value={})
 
103
  return dict(
104
  prompt=prompt,
105
  negative_prompt=negative_prompt,
106
- model_state=model_state,
 
 
 
 
107
  steps=steps,
108
  cfg=cfg,
109
  width=width,
@@ -120,43 +129,95 @@ def build_t2i_tab() -> dict[str, gr.components.Component]:
120
  def build_controlnet_tab() -> dict[str, gr.components.Component]:
121
  with gr.Row():
122
  with gr.Column(scale=4):
123
- gr.HTML(labeled_label("Prompt", TOOLTIPS["prompt"]))
124
- prompt = gr.Textbox(lines=3, show_label=False)
125
- gr.HTML(labeled_label("Control image", TOOLTIPS["controlnet_image"]))
126
- input_image = gr.Image(type="pil", height=240, show_label=False)
127
- with gr.Row():
128
- with gr.Column():
129
- gr.HTML(labeled_label("Preprocessor", TOOLTIPS["controlnet_preprocessor"]))
130
- preprocessor = gr.Dropdown(list(preprocessors.MODES), value="Canny", show_label=False)
131
- with gr.Column():
132
- gr.HTML(labeled_label("ControlNet scale", TOOLTIPS["controlnet_scale"]))
133
- controlnet_scale = gr.Slider(0.0, 2.0, value=1.0, step=0.05, show_label=False)
134
  with gr.Row():
135
  with gr.Column():
136
- gr.HTML(labeled_label("LoRA (optional)", TOOLTIPS["lora"]))
137
- lora_path = gr.File(file_types=[".safetensors"], type="filepath", show_label=False)
 
 
 
 
 
 
 
138
  with gr.Column():
139
- gr.HTML(labeled_label("LoRA strength", TOOLTIPS["lora_strength"]))
140
- lora_strength = gr.Slider(0.0, 1.5, value=0.8, step=0.05, show_label=False)
 
 
 
 
 
 
 
 
 
141
  with gr.Row():
142
- with gr.Column():
143
- gr.HTML(labeled_label("Steps", TOOLTIPS["steps"]))
144
- steps = gr.Slider(1, 30, value=9, step=1, show_label=False)
145
- with gr.Column():
146
- gr.HTML(labeled_label("Seed (0 = random)", TOOLTIPS["seed"]))
147
- seed = gr.Number(value=0, precision=0, show_label=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  generate_btn = gr.Button("Generate", variant="primary")
 
149
  with gr.Column(scale=5):
150
- gr.HTML(labeled_label("Output", TOOLTIPS["output"]))
151
- output_image = gr.Image(type="pil", height=512, show_download_button=True, show_label=False)
 
 
 
 
 
152
  output_meta = gr.JSON(label="Meta", value={})
 
153
  return dict(
154
  prompt=prompt,
155
  input_image=input_image,
 
156
  preprocessor=preprocessor,
157
  controlnet_scale=controlnet_scale,
158
  steps=steps,
159
  seed=seed,
 
 
160
  lora_path=lora_path,
161
  lora_strength=lora_strength,
162
  generate_btn=generate_btn,
@@ -168,37 +229,81 @@ def build_controlnet_tab() -> dict[str, gr.components.Component]:
168
  def build_upscale_tab() -> dict[str, gr.components.Component]:
169
  with gr.Row():
170
  with gr.Column(scale=4):
171
- gr.HTML(labeled_label("Refinement prompt", TOOLTIPS["prompt"]))
172
- prompt = gr.Textbox(value="masterpiece, 8k", lines=2, show_label=False)
173
- gr.HTML(labeled_label("Input image", TOOLTIPS["upscale_image"]))
174
- input_image = gr.Image(type="pil", height=240, show_label=False)
175
- with gr.Row():
176
- with gr.Column():
177
- gr.HTML(labeled_label("Refine steps", TOOLTIPS["refine_steps"]))
178
- refine_steps = gr.Slider(1, 20, value=5, step=1, show_label=False)
179
- with gr.Column():
180
- gr.HTML(labeled_label("Refine denoise", TOOLTIPS["refine_denoise"]))
181
- refine_denoise = gr.Slider(0.0, 1.0, value=0.33, step=0.01, show_label=False)
 
 
 
 
182
  with gr.Row():
183
- with gr.Column():
184
- gr.HTML(labeled_label("LoRA (optional)", TOOLTIPS["lora"]))
185
- lora_path = gr.File(file_types=[".safetensors"], type="filepath", show_label=False)
186
- with gr.Column():
187
- gr.HTML(labeled_label("LoRA strength", TOOLTIPS["lora_strength"]))
188
- lora_strength = gr.Slider(0.0, 1.5, value=0.8, step=0.05, show_label=False)
189
- gr.HTML(labeled_label("Seed (0 = random)", TOOLTIPS["seed"]))
190
- seed = gr.Number(value=0, precision=0, show_label=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  generate_btn = gr.Button("Generate", variant="primary")
 
192
  with gr.Column(scale=5):
193
- gr.HTML(labeled_label("Output (2x upscaled)", TOOLTIPS["output"]))
194
- output_image = gr.Image(type="pil", height=512, show_download_button=True, show_label=False)
 
 
 
 
 
 
 
195
  output_meta = gr.JSON(label="Meta", value={})
 
196
  return dict(
197
  prompt=prompt,
198
  input_image=input_image,
199
  refine_steps=refine_steps,
200
  refine_denoise=refine_denoise,
201
  seed=seed,
 
 
202
  lora_path=lora_path,
203
  lora_strength=lora_strength,
204
  generate_btn=generate_btn,
 
1
+ """Gradio UI builders for z-image-studio (Soft Dark Restraint redesign).
2
 
3
+ Each ``build_*_tab()`` returns a dict of components so ``app.py:build_app``
4
+ can wire ``.click()`` / ``.change()`` handlers without reaching into local
5
+ scopes. All param help text flows through Gradio's native ``info=`` parameter
6
+ (dim subtitle under each label) — no custom popover or (i) icon helper.
7
+ """
8
 
9
+ from __future__ import annotations
10
 
11
  import gradio as gr
12
 
13
  import preprocessors
14
  from tooltips import TOOLTIPS
15
 
16
+ # Link targets for the small dim row under the model radio. The Z-Image
17
+ # README's Model Zoo section is the canonical "where to find more models"
18
+ # anchor; Omni Base lives in the same README. When more models ship, swap
19
+ # these constants and nothing else needs to change.
20
+ MODEL_ZOO_URL = "https://github.com/Tongyi-MAI/Z-Image#-model-zoo"
21
 
22
 
23
+ def _model_soon_row_html() -> str:
24
+ """Return the small dim link row that lives directly under the model radio.
25
 
26
+ Two anchor links + a "(coming soon)" qualifier. Static no state, no JS.
 
 
27
  """
28
  return (
29
+ '<div class="zis-soon-row">'
30
+ f'<a href="{MODEL_ZOO_URL}" target="_blank" rel="noopener noreferrer">Edit ↗</a>'
31
+ '<span class="sep">·</span>'
32
+ f'<a href="{MODEL_ZOO_URL}" target="_blank" rel="noopener noreferrer">Omni Base ↗</a>'
33
+ '<span class="dim">(coming soon)</span>'
34
+ "</div>"
35
  )
36
 
37
 
38
+ def build_t2i_tab() -> dict[str, gr.components.Component]:
39
+ with gr.Row():
40
+ with gr.Column(scale=4):
41
+ prompt = gr.Textbox(
42
+ label="Prompt",
43
+ info=TOOLTIPS["prompt"],
44
+ lines=4,
45
+ placeholder="A latina model peeking through pine branches…",
46
+ )
47
 
48
+ model = gr.Radio(
49
+ ["Base", "Turbo"],
50
+ value="Turbo",
51
+ label="Model",
52
+ info=TOOLTIPS["model"],
53
+ )
54
+ model_soon_row = gr.HTML(_model_soon_row_html())
55
 
56
+ with gr.Group(visible=False) as base_group:
57
+ negative_prompt = gr.Textbox(
58
+ label="Negative prompt",
59
+ info=TOOLTIPS["negative_prompt"],
60
+ lines=2,
61
+ placeholder="blurry, lowres, distorted",
62
+ )
63
+ cfg = gr.Slider(
64
+ 0.5,
65
+ 12.0,
66
+ value=1.0,
67
+ step=0.1,
68
+ label="CFG",
69
+ info=TOOLTIPS["cfg"],
70
+ )
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
+ lora_enabled = gr.Checkbox(label="Use a LoRA", value=False)
73
+ with gr.Group(visible=False) as lora_group:
74
+ lora_path = gr.File(
75
+ label="LoRA file",
76
+ file_types=[".safetensors"],
77
+ type="filepath",
78
+ elem_classes=["zis-lora-file"],
79
+ )
80
+ lora_strength = gr.Slider(
81
+ 0.0,
82
+ 1.5,
83
+ value=0.8,
84
+ step=0.05,
85
+ label="LoRA strength",
86
+ info=TOOLTIPS["lora_strength"],
87
+ )
88
+
89
+ steps = gr.Slider(1, 50, value=8, step=1, label="Steps", info=TOOLTIPS["steps"])
90
+
91
+ with gr.Accordion("Advanced", open=False):
92
+ width = gr.Slider(384, 1536, value=1024, step=64, label="Width", info=TOOLTIPS["width"])
93
+ height = gr.Slider(384, 1536, value=1024, step=64, label="Height", info=TOOLTIPS["height"])
94
+ seed = gr.Number(value=0, precision=0, label="Seed", info=TOOLTIPS["seed"])
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  generate_btn = gr.Button("Generate", variant="primary")
97
+
98
  with gr.Column(scale=5):
99
+ gr.Markdown(f"**Output** \n<span style='color:#988B7C;font-size:12px;'>{TOOLTIPS['output']}</span>")
100
+ output_image = gr.Image(
101
+ show_label=False,
102
+ type="pil",
103
+ height=512,
104
+ show_download_button=True,
105
+ )
106
  output_meta = gr.JSON(label="Meta", value={})
107
+
108
  return dict(
109
  prompt=prompt,
110
  negative_prompt=negative_prompt,
111
+ model=model,
112
+ model_soon_row=model_soon_row,
113
+ base_group=base_group,
114
+ lora_enabled=lora_enabled,
115
+ lora_group=lora_group,
116
  steps=steps,
117
  cfg=cfg,
118
  width=width,
 
129
  def build_controlnet_tab() -> dict[str, gr.components.Component]:
130
  with gr.Row():
131
  with gr.Column(scale=4):
132
+ prompt = gr.Textbox(
133
+ label="Prompt",
134
+ info=TOOLTIPS["prompt"],
135
+ lines=3,
136
+ )
137
+
 
 
 
 
 
138
  with gr.Row():
139
  with gr.Column():
140
+ gr.Markdown(
141
+ f"**Control image** \n<span style='color:#988B7C;font-size:12px;'>"
142
+ f"{TOOLTIPS['controlnet_image']}</span>"
143
+ )
144
+ input_image = gr.Image(
145
+ show_label=False,
146
+ type="pil",
147
+ height=240,
148
+ )
149
  with gr.Column():
150
+ gr.Markdown(
151
+ "**Preprocessor preview** \n<span style='color:#988B7C;font-size:12px;'>"
152
+ "Live edge map / depth map / pose. Updates as you change the preprocessor.</span>"
153
+ )
154
+ preview_image = gr.Image(
155
+ show_label=False,
156
+ type="pil",
157
+ height=240,
158
+ interactive=False,
159
+ )
160
+
161
  with gr.Row():
162
+ preprocessor = gr.Dropdown(
163
+ list(preprocessors.MODES),
164
+ value="Canny",
165
+ label="Preprocessor",
166
+ info=TOOLTIPS["controlnet_preprocessor"],
167
+ )
168
+ controlnet_scale = gr.Slider(
169
+ 0.0,
170
+ 2.0,
171
+ value=1.0,
172
+ step=0.05,
173
+ label="ControlNet scale",
174
+ info=TOOLTIPS["controlnet_scale"],
175
+ )
176
+
177
+ lora_enabled = gr.Checkbox(label="Use a LoRA", value=False)
178
+ with gr.Group(visible=False) as lora_group:
179
+ lora_path = gr.File(
180
+ label="LoRA file",
181
+ file_types=[".safetensors"],
182
+ type="filepath",
183
+ elem_classes=["zis-lora-file"],
184
+ )
185
+ lora_strength = gr.Slider(
186
+ 0.0,
187
+ 1.5,
188
+ value=0.8,
189
+ step=0.05,
190
+ label="LoRA strength",
191
+ info=TOOLTIPS["lora_strength"],
192
+ )
193
+
194
+ steps = gr.Slider(1, 30, value=9, step=1, label="Steps", info=TOOLTIPS["steps"])
195
+
196
+ with gr.Accordion("Advanced", open=False):
197
+ seed = gr.Number(value=0, precision=0, label="Seed", info=TOOLTIPS["seed"])
198
+
199
  generate_btn = gr.Button("Generate", variant="primary")
200
+
201
  with gr.Column(scale=5):
202
+ gr.Markdown(f"**Output** \n<span style='color:#988B7C;font-size:12px;'>{TOOLTIPS['output']}</span>")
203
+ output_image = gr.Image(
204
+ show_label=False,
205
+ type="pil",
206
+ height=512,
207
+ show_download_button=True,
208
+ )
209
  output_meta = gr.JSON(label="Meta", value={})
210
+
211
  return dict(
212
  prompt=prompt,
213
  input_image=input_image,
214
+ preview_image=preview_image,
215
  preprocessor=preprocessor,
216
  controlnet_scale=controlnet_scale,
217
  steps=steps,
218
  seed=seed,
219
+ lora_enabled=lora_enabled,
220
+ lora_group=lora_group,
221
  lora_path=lora_path,
222
  lora_strength=lora_strength,
223
  generate_btn=generate_btn,
 
229
  def build_upscale_tab() -> dict[str, gr.components.Component]:
230
  with gr.Row():
231
  with gr.Column(scale=4):
232
+ prompt = gr.Textbox(
233
+ label="Refinement prompt",
234
+ info=TOOLTIPS["prompt"],
235
+ value="masterpiece, 8k",
236
+ lines=2,
237
+ )
238
+ gr.Markdown(
239
+ f"**Input image** \n<span style='color:#988B7C;font-size:12px;'>{TOOLTIPS['upscale_image']}</span>"
240
+ )
241
+ input_image = gr.Image(
242
+ show_label=False,
243
+ type="pil",
244
+ height=240,
245
+ )
246
+
247
  with gr.Row():
248
+ refine_steps = gr.Slider(
249
+ 1,
250
+ 20,
251
+ value=5,
252
+ step=1,
253
+ label="Refine steps",
254
+ info=TOOLTIPS["refine_steps"],
255
+ )
256
+ refine_denoise = gr.Slider(
257
+ 0.0,
258
+ 1.0,
259
+ value=0.33,
260
+ step=0.01,
261
+ label="Refine denoise",
262
+ info=TOOLTIPS["refine_denoise"],
263
+ )
264
+
265
+ lora_enabled = gr.Checkbox(label="Use a LoRA", value=False)
266
+ with gr.Group(visible=False) as lora_group:
267
+ lora_path = gr.File(
268
+ label="LoRA file",
269
+ file_types=[".safetensors"],
270
+ type="filepath",
271
+ elem_classes=["zis-lora-file"],
272
+ )
273
+ lora_strength = gr.Slider(
274
+ 0.0,
275
+ 1.5,
276
+ value=0.8,
277
+ step=0.05,
278
+ label="LoRA strength",
279
+ info=TOOLTIPS["lora_strength"],
280
+ )
281
+
282
+ with gr.Accordion("Advanced", open=False):
283
+ seed = gr.Number(value=0, precision=0, label="Seed", info=TOOLTIPS["seed"])
284
+
285
  generate_btn = gr.Button("Generate", variant="primary")
286
+
287
  with gr.Column(scale=5):
288
+ gr.Markdown(
289
+ f"**Output (2x upscaled)** \n<span style='color:#988B7C;font-size:12px;'>{TOOLTIPS['output']}</span>"
290
+ )
291
+ output_image = gr.Image(
292
+ show_label=False,
293
+ type="pil",
294
+ height=512,
295
+ show_download_button=True,
296
+ )
297
  output_meta = gr.JSON(label="Meta", value={})
298
+
299
  return dict(
300
  prompt=prompt,
301
  input_image=input_image,
302
  refine_steps=refine_steps,
303
  refine_denoise=refine_denoise,
304
  seed=seed,
305
+ lora_enabled=lora_enabled,
306
+ lora_group=lora_group,
307
  lora_path=lora_path,
308
  lora_strength=lora_strength,
309
  generate_btn=generate_btn,