raubatz Oz commited on
Commit
f0e0729
Β·
1 Parent(s): 19856bb

JSON LoRA catalog, local path imports, and UI polish

Browse files

Move the LoRA list to an external bucket JSON with admin/active flags,
session add/save/remove, and sha256 dedupe. Also add nested local-path
LoRA loading, reference clipboard multi-upload, a larger download button,
and a standard UI font. Logging remains disabled.

Co-Authored-By: Oz <oz-agent@warp.dev>

Files changed (6) hide show
  1. .gitignore +9 -0
  2. app.py +139 -15
  3. config.py +6 -0
  4. image_utils.py +10 -0
  5. lora_registry.py +729 -74
  6. ui_theme.py +1 -1
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ _originalspace*/
2
+ _2do/
3
+ *.webloc
4
+ url*/
5
+ _push*
6
+ .DS_Store
7
+ __pycache__/
8
+ *.pyc
9
+ lorasplayground.json
app.py CHANGED
@@ -5,7 +5,6 @@ import time
5
  import random
6
  import uuid
7
  import zipfile
8
- import threading
9
 
10
  import gradio as gr
11
  import spaces
@@ -19,6 +18,7 @@ from config import (
19
  MODEL_REPO,
20
  MAX_SEED,
21
  MAX_LORA_SLOTS,
 
22
  )
23
  from ui_theme import orange_red_theme
24
  from upscale import UPSCALE_MODELS, apply_realesrgan
@@ -29,6 +29,10 @@ from lora_registry import (
29
  get_style_by_title,
30
  update_weight_sliders,
31
  add_custom_lora,
 
 
 
 
32
  )
33
 
34
  from image_utils import (
@@ -50,6 +54,7 @@ from image_utils import (
50
  push_pil_to_reference,
51
  extend_editor_canvas,
52
  render_extend_schematic,
 
53
  )
54
  from control_tools import (
55
  generate_depthmap,
@@ -117,6 +122,12 @@ def on_gallery_select(evt: gr.SelectData, gallery_value):
117
  return item[0] if isinstance(item, (list, tuple)) else item
118
 
119
 
 
 
 
 
 
 
120
 
121
  # ── GPU step (shared by single, batch, and bulk) ─────────────────────────────
122
 
@@ -365,12 +376,11 @@ with gr.Blocks() as demo:
365
  selected_output_state = gr.State(None)
366
 
367
  with gr.Column(elem_id="col-container"):
368
-
369
  gr.Markdown("# **flux2klein lora playground**", elem_id="main-title")
370
  gr.Markdown(
371
  f"Apply one or more [LoRA](https://huggingface.co/models?other=base_model:adapter:black-forest-labs/FLUX.2-klein-9B) "
372
  f"adapters using [FLUX.2-Klein-{MODEL_VARIANT}]({MODEL_REPO}). "
373
- f"**Model:** `{MODEL_VARIANT}` "
374
  )
375
 
376
  with gr.Tabs() as main_tabs:
@@ -387,6 +397,7 @@ with gr.Blocks() as demo:
387
  reference_images = gr.Gallery(
388
  label="Reference Image(s) β€” optional", type="filepath",
389
  columns=2, rows=1, height=140, allow_preview=True,
 
390
  elem_id="reference_gallery",
391
  )
392
  reference_info = gr.Markdown("πŸ“· No reference images")
@@ -451,15 +462,26 @@ with gr.Blocks() as demo:
451
  height=420, allow_preview=True, preview=True,
452
  object_fit="contain", show_label=True,
453
  )
454
- used_seed = gr.Textbox(label="🌱 Seed used (last run)",
455
- interactive=False, max_lines=1)
 
 
 
 
 
 
 
 
 
 
 
456
  with gr.Row():
457
  send_out_to_base_btn = gr.Button("↩ Send β†’ Base", size="sm")
458
  send_out_to_ref_btn = gr.Button("↩ Send β†’ Reference", size="sm")
459
  gr.Markdown(
460
- "*Click a gallery thumbnail before Send→ to pick that specific "
461
- "image; otherwise the latest is used. PNGs contain seed, prompt, "
462
- "LoRAs and settings as `parameters` tEXt chunks (sd-webui / "
463
  "Civitai / ComfyUI readable).*"
464
  )
465
 
@@ -478,18 +500,74 @@ with gr.Blocks() as demo:
478
  label=f"LoRA slot {i+1}", visible=False, interactive=True,
479
  ))
480
 
481
- with gr.Accordion("βž• Load Custom LoRA from HuggingFace", open=False):
482
- gr.Markdown("Add any FLUX.2-Klein-compatible LoRA. *Added LoRAs are only visible in your own session.*")
 
 
 
 
 
 
 
483
  with gr.Row():
484
- lora_repo_id = gr.Textbox(label="Repo ID", placeholder="username/repo-name")
 
 
 
485
  with gr.Row():
486
- lora_weight_name = gr.Textbox(label="Weight filename (optional)",
487
- placeholder="pytorch_lora_weights.safetensors")
 
 
488
  lora_adapter_name = gr.Textbox(label="Adapter name (optional)", placeholder="my-lora")
489
  with gr.Row():
490
- add_lora_btn = gr.Button("Add LoRA", variant="primary")
491
  lora_status = gr.Textbox(label="Status", interactive=False)
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  with gr.Accordion("πŸ“ Custom Prompts", open=False):
494
  gr.Markdown("Save reusable prompt snippets for this session.")
495
  custom_prompt_selector = gr.CheckboxGroup(
@@ -674,10 +752,30 @@ with gr.Blocks() as demo:
674
  )
675
 
676
  # add_custom_lora is also imported from lora_registry.
 
677
  add_lora_btn.click(
678
  fn=add_custom_lora,
679
  inputs=[lora_repo_id, lora_weight_name, lora_adapter_name, dynamic_loras_state],
680
- outputs=[lora_status, lora_selector, dynamic_loras_state],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
681
  )
682
 
683
  add_prompt_btn.click(
@@ -706,6 +804,32 @@ with gr.Blocks() as demo:
706
 
707
  output_gallery.select(fn=on_gallery_select, inputs=[output_gallery],
708
  outputs=[selected_output_state])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  # Reset any stale gallery selection before a new run starts, so Sendβ†’Base
710
  # / Sendβ†’Ref after this run can't accidentally reuse a path from the
711
  # previous run's gallery contents.
 
5
  import random
6
  import uuid
7
  import zipfile
 
8
 
9
  import gradio as gr
10
  import spaces
 
18
  MODEL_REPO,
19
  MAX_SEED,
20
  MAX_LORA_SLOTS,
21
+ PERSISTENT_LORA_CATALOG_PATH,
22
  )
23
  from ui_theme import orange_red_theme
24
  from upscale import UPSCALE_MODELS, apply_realesrgan
 
29
  get_style_by_title,
30
  update_weight_sliders,
31
  add_custom_lora,
32
+ save_session_lora_to_catalog,
33
+ fill_catalog_save_form,
34
+ remove_lora,
35
+ removable_catalog_titles,
36
  )
37
 
38
  from image_utils import (
 
54
  push_pil_to_reference,
55
  extend_editor_canvas,
56
  render_extend_schematic,
57
+ resolve_output_download_path,
58
  )
59
  from control_tools import (
60
  generate_depthmap,
 
122
  return item[0] if isinstance(item, (list, tuple)) else item
123
 
124
 
125
+ # ── Logging (disabled) ───────────────────────────────────────────────────────
126
+
127
+ def _spawn_log(*_args, **_kwargs):
128
+ """No-op β€” logging intentionally disabled."""
129
+ return
130
+
131
 
132
  # ── GPU step (shared by single, batch, and bulk) ─────────────────────────────
133
 
 
376
  selected_output_state = gr.State(None)
377
 
378
  with gr.Column(elem_id="col-container"):
 
379
  gr.Markdown("# **flux2klein lora playground**", elem_id="main-title")
380
  gr.Markdown(
381
  f"Apply one or more [LoRA](https://huggingface.co/models?other=base_model:adapter:black-forest-labs/FLUX.2-klein-9B) "
382
  f"adapters using [FLUX.2-Klein-{MODEL_VARIANT}]({MODEL_REPO}). "
383
+ f"**Model:** `{MODEL_VARIANT}`"
384
  )
385
 
386
  with gr.Tabs() as main_tabs:
 
397
  reference_images = gr.Gallery(
398
  label="Reference Image(s) β€” optional", type="filepath",
399
  columns=2, rows=1, height=140, allow_preview=True,
400
+ sources=["upload", "clipboard"],
401
  elem_id="reference_gallery",
402
  )
403
  reference_info = gr.Markdown("πŸ“· No reference images")
 
462
  height=420, allow_preview=True, preview=True,
463
  object_fit="contain", show_label=True,
464
  )
465
+ with gr.Row(equal_height=True):
466
+ used_seed = gr.Textbox(
467
+ label="🌱 Seed used (last run)",
468
+ interactive=False, max_lines=1, scale=1,
469
+ )
470
+ download_output_btn = gr.DownloadButton(
471
+ label="⬇️ Download image",
472
+ value=None,
473
+ variant="primary",
474
+ size="lg",
475
+ scale=1,
476
+ elem_id="download_output_btn",
477
+ )
478
  with gr.Row():
479
  send_out_to_base_btn = gr.Button("↩ Send β†’ Base", size="sm")
480
  send_out_to_ref_btn = gr.Button("↩ Send β†’ Reference", size="sm")
481
  gr.Markdown(
482
+ "*Click a gallery thumbnail before Download / Send→ to pick that "
483
+ "specific image; otherwise the latest is used. PNGs contain seed, "
484
+ "prompt, LoRAs and settings as `parameters` tEXt chunks (sd-webui / "
485
  "Civitai / ComfyUI readable).*"
486
  )
487
 
 
500
  label=f"LoRA slot {i+1}", visible=False, interactive=True,
501
  ))
502
 
503
+ with gr.Accordion("βž• Load Custom LoRA (HF repo or local path)", open=False):
504
+ gr.Markdown(
505
+ "Add any FLUX.2-Klein-compatible LoRA from a **HuggingFace repo** "
506
+ "(`user/repo`) or a **local path** (file or directory), e.g. "
507
+ "`/loras-flux/my.safetensors` or `/loras-flux/foo/bar/male`. "
508
+ "**Import is always session-only** β€” try it first, then optionally "
509
+ f"save it to the catalog JSON (`{PERSISTENT_LORA_CATALOG_PATH}`). "
510
+ "Duplicates are blocked by title, repo+filename, and sha256 when available."
511
+ )
512
  with gr.Row():
513
+ lora_repo_id = gr.Textbox(
514
+ label="HF repo ID or local path",
515
+ placeholder="username/repo-name or /loras-flux/my.safetensors",
516
+ )
517
  with gr.Row():
518
+ lora_weight_name = gr.Textbox(
519
+ label="Weight filename (optional if path is a .safetensors file)",
520
+ placeholder="pytorch_lora_weights.safetensors",
521
+ )
522
  lora_adapter_name = gr.Textbox(label="Adapter name (optional)", placeholder="my-lora")
523
  with gr.Row():
524
+ add_lora_btn = gr.Button("Add LoRA (session only)", variant="primary")
525
  lora_status = gr.Textbox(label="Status", interactive=False)
526
 
527
+ gr.Markdown("#### πŸ’Ύ Save tried LoRA to catalog")
528
+ gr.Markdown(
529
+ "After testing a session LoRA, save it here so it appears for everyone "
530
+ "on the next load. UI saves are **not** admin-approved; set "
531
+ "`admin_approved: true` in the JSON yourself. Set `active: false` to "
532
+ "archive/hide without deleting."
533
+ )
534
+ catalog_save_select = gr.Dropdown(
535
+ label="Session LoRA to save",
536
+ choices=[], value=None, interactive=True,
537
+ )
538
+ with gr.Row():
539
+ catalog_save_title = gr.Textbox(
540
+ label="Catalog title", placeholder="My LoRA name", scale=2,
541
+ )
542
+ catalog_save_weight = gr.Slider(
543
+ label="Default weight", minimum=0.0, maximum=2.0,
544
+ step=0.05, value=1.0, scale=1,
545
+ )
546
+ catalog_save_prompt = gr.Textbox(
547
+ label="Default prompt (optional)", lines=2,
548
+ placeholder="Optional prompt auto-appended when this LoRA is selected",
549
+ )
550
+ with gr.Row():
551
+ catalog_save_btn = gr.Button(
552
+ "πŸ’Ύ Save to catalog", variant="secondary",
553
+ )
554
+ catalog_save_status = gr.Textbox(label="Catalog status", interactive=False)
555
+
556
+ gr.Markdown("#### πŸ—‘οΈ Remove LoRA")
557
+ gr.Markdown(
558
+ "Remove a **session** custom LoRA, or a catalog entry that is **not** "
559
+ "`admin_approved`. Admin-approved entries can only be archived via JSON "
560
+ "(`active: false`)."
561
+ )
562
+ with gr.Row():
563
+ catalog_remove_select = gr.Dropdown(
564
+ label="LoRA to remove",
565
+ choices=removable_catalog_titles(),
566
+ value=None, interactive=True, scale=2,
567
+ )
568
+ catalog_remove_btn = gr.Button("πŸ—‘οΈ Remove", variant="stop", scale=1)
569
+ catalog_remove_status = gr.Textbox(label="Remove status", interactive=False)
570
+
571
  with gr.Accordion("πŸ“ Custom Prompts", open=False):
572
  gr.Markdown("Save reusable prompt snippets for this session.")
573
  custom_prompt_selector = gr.CheckboxGroup(
 
752
  )
753
 
754
  # add_custom_lora is also imported from lora_registry.
755
+ # Always session-only; optional persist / remove are separate explicit actions.
756
  add_lora_btn.click(
757
  fn=add_custom_lora,
758
  inputs=[lora_repo_id, lora_weight_name, lora_adapter_name, dynamic_loras_state],
759
+ outputs=[lora_status, lora_selector, dynamic_loras_state,
760
+ catalog_save_select, catalog_remove_select],
761
+ )
762
+ catalog_save_select.change(
763
+ fn=fill_catalog_save_form,
764
+ inputs=[catalog_save_select, dynamic_loras_state],
765
+ outputs=[catalog_save_title, catalog_save_weight, catalog_save_prompt],
766
+ )
767
+ catalog_save_btn.click(
768
+ fn=save_session_lora_to_catalog,
769
+ inputs=[catalog_save_select, catalog_save_title, catalog_save_weight,
770
+ catalog_save_prompt, dynamic_loras_state],
771
+ outputs=[catalog_save_status, lora_selector, catalog_save_select,
772
+ catalog_remove_select, dynamic_loras_state],
773
+ )
774
+ catalog_remove_btn.click(
775
+ fn=remove_lora,
776
+ inputs=[catalog_remove_select, dynamic_loras_state, lora_selector],
777
+ outputs=[catalog_remove_status, lora_selector, catalog_save_select,
778
+ catalog_remove_select, dynamic_loras_state],
779
  )
780
 
781
  add_prompt_btn.click(
 
804
 
805
  output_gallery.select(fn=on_gallery_select, inputs=[output_gallery],
806
  outputs=[selected_output_state])
807
+ # Keep the large Download button pointed at the selected gallery item
808
+ # (or the latest item when nothing valid is selected).
809
+ selected_output_state.change(
810
+ fn=resolve_output_download_path,
811
+ inputs=[selected_output_state, output_gallery],
812
+ outputs=[download_output_btn],
813
+ )
814
+ output_gallery.change(
815
+ fn=resolve_output_download_path,
816
+ inputs=[selected_output_state, output_gallery],
817
+ outputs=[download_output_btn],
818
+ )
819
+ # Native DownloadButton still saves the file; also open the served image
820
+ # URL in a new browser tab so it can be viewed immediately.
821
+ download_output_btn.click(
822
+ fn=None,
823
+ js="""() => {
824
+ const root = document.getElementById('download_output_btn');
825
+ if (!root) return;
826
+ const a = root.querySelector('a.download-link, a[href]');
827
+ const href = a && a.href;
828
+ if (href) {
829
+ window.open(href, '_blank', 'noopener,noreferrer');
830
+ }
831
+ }""",
832
+ )
833
  # Reset any stale gallery selection before a new run starts, so Sendβ†’Base
834
  # / Sendβ†’Ref after this run can't accidentally reuse a path from the
835
  # previous run's gallery contents.
config.py CHANGED
@@ -23,3 +23,9 @@ MAX_SEED = int(np.iinfo(np.int32).max)
23
  # Maximum number of simultaneous LoRA weight sliders to pre-render in the UI.
24
  MAX_LORA_SLOTS = 6
25
 
 
 
 
 
 
 
 
23
  # Maximum number of simultaneous LoRA weight sliders to pre-render in the UI.
24
  MAX_LORA_SLOTS = 6
25
 
26
+ # External LoRA catalog (bucket-mounted JSON). Single source of truth for the
27
+ # selectable list. If missing/empty, the app seeds it from built-in defaults.
28
+ PERSISTENT_LORA_CATALOG_PATH = os.environ.get(
29
+ "PERSISTENT_LORA_CATALOG_PATH",
30
+ "/loras-flux/config/lorasplayground.json",
31
+ )
image_utils.py CHANGED
@@ -243,6 +243,16 @@ def _resolve_gallery_path(selected_path, gallery_value):
243
  return current_paths[-1]
244
 
245
 
 
 
 
 
 
 
 
 
 
 
246
  def send_output_to_base(selected_path, gallery_value):
247
  path = _resolve_gallery_path(selected_path, gallery_value)
248
  if not path:
 
243
  return current_paths[-1]
244
 
245
 
246
+ def resolve_output_download_path(selected_path, gallery_value):
247
+ """Path for the large Download button under the output gallery.
248
+
249
+ Same selection rules as Send→*: honour a still-valid gallery selection,
250
+ otherwise the latest generated image. Returns None when empty so the
251
+ DownloadButton stays inactive.
252
+ """
253
+ return _resolve_gallery_path(selected_path, gallery_value)
254
+
255
+
256
  def send_output_to_base(selected_path, gallery_value):
257
  path = _resolve_gallery_path(selected_path, gallery_value)
258
  if not path:
lora_registry.py CHANGED
@@ -1,24 +1,32 @@
1
- """LoRA catalog + per-session custom LoRA loader.
2
-
3
- Two layers of state:
4
-
5
- * LORA_STYLES (module-level constant) β€” the curated catalog, identical for
6
- every visitor.
7
- * dynamic_loras (gr.State, per session) β€” a session-private dict of any
8
- custom HuggingFace LoRAs the user has added via the UI. Other sessions
9
- never see them.
10
-
11
- Adapter loading itself is tracked in LOADED_ADAPTERS, which is a *legitimate*
12
- module-level set: it mirrors what's been attached to the shared pipe object
13
- in generation.py so we don't re-download/re-attach the same weights twice.
 
 
 
14
  """
15
  from __future__ import annotations
16
 
 
 
 
 
17
  import uuid
 
18
 
19
  import gradio as gr
20
 
21
- from config import MAX_LORA_SLOTS
22
 
23
 
24
  FACE_SWAP_PROMPT = """head_swap: start with Picture 1 as the base image, keeping its lighting, environment, and background. Remove the head from Picture 1 completely and replace it with the head from Picture 2.
@@ -33,25 +41,33 @@ FROM PICTURE 2 (strictly preserve identity):
33
  - Skin: texture, tone, complexion
34
  The replaced head must seamlessly match Picture 1's lighting and expression while maintaining the complete identity from Picture 2. High quality, photorealistic, sharp details, 4k."""
35
 
36
- # NOTE: MAX_LORA_SLOTS is imported from config.py β€” do NOT re-declare it here.
 
 
 
37
 
38
- LORA_STYLES = [
39
- {
40
- "image": "https://huggingface.co/spaces/prithivMLmods/FLUX.2-Klein-LoRA-Studio/resolve/main/examples/image.webp",
41
- "title": "None",
42
- "adapter_name": None,
43
- "repo": None,
44
- "weights": None,
45
- "default_prompt": None,
46
- "default_weight": 1.0,
47
- },
48
  {
49
  "title": "Klein-Delight-Style",
50
  "adapter_name": "klein-delight",
51
  "repo": "linoyts/Flux2-Klein-Delight-LoRA",
52
  "weights": "pytorch_lora_weights.safetensors",
53
- "default_prompt": "Relight the image to remove all existing lighting conditions and replace them with neutral, uniform illumination. Apply soft, evenly distributed lighting with no directional shadows, no harsh highlights, and no dramatic contrast. Maintain the original identity of all subjects exactlyβ€”preserve facial structure, skin tone, proportions, expressions, hair, clothing, and textures. Do not alter pose, camera angle, background geometry, or image composition. Lighting should appear balanced, and studio-neutral, similar to diffuse overcast or a soft lightbox setup. Ensure consistent exposure across the entire image with realistic depth and subtle shading only where necessary for form.",
 
 
 
 
 
 
 
 
 
 
54
  "default_weight": 1.0,
 
 
55
  },
56
  {
57
  "title": "Klein-Consistency",
@@ -60,6 +76,8 @@ LORA_STYLES = [
60
  "weights": "Klein-consistency.safetensors",
61
  "default_prompt": None,
62
  "default_weight": 0.3,
 
 
63
  },
64
  {
65
  "title": "Best-Face-Swap",
@@ -68,6 +86,8 @@ LORA_STYLES = [
68
  "weights": "bfs_head_v1_flux-klein_9b_step3750_rank64.safetensors",
69
  "default_prompt": FACE_SWAP_PROMPT,
70
  "default_weight": 1.0,
 
 
71
  },
72
  {
73
  "title": "NSFW v2",
@@ -76,14 +96,23 @@ LORA_STYLES = [
76
  "weights": "Flux Klein - NSFW v2.safetensors",
77
  "default_prompt": None,
78
  "default_weight": 1.0,
 
 
79
  },
80
  {
81
  "title": "Ultimate Upscaler Klein-9b",
82
  "adapter_name": "Ultimate Upscaler",
83
  "repo": "loras",
84
  "weights": "Flux2-Klein-Image-RestoreV1.safetensors",
85
- "default_prompt": "restore the image quality, remove any compression artefacts, remove any haze and soft edges, enrich the original with new intricate detail in all textures and surfaces creating a professional photorealistic photograph with natural lighting and skin texture.",
 
 
 
 
 
86
  "default_weight": 1.0,
 
 
87
  },
88
  {
89
  "title": "High Resolution",
@@ -92,6 +121,8 @@ LORA_STYLES = [
92
  "weights": "HighResolution9B.safetensors",
93
  "default_prompt": "High Resolution",
94
  "default_weight": 1.0,
 
 
95
  },
96
  {
97
  "title": "InstaPic",
@@ -100,6 +131,8 @@ LORA_STYLES = [
100
  "weights": "InstaPic V3.safetensors",
101
  "default_prompt": "instapic",
102
  "default_weight": 1.0,
 
 
103
  },
104
  {
105
  "title": "Realistic Nudes",
@@ -108,6 +141,8 @@ LORA_STYLES = [
108
  "weights": "realistic_nudes_klein_v3.safetensors",
109
  "default_prompt": None,
110
  "default_weight": 1.0,
 
 
111
  },
112
  {
113
  "title": "Perky Pointy Puffy Breasts",
@@ -116,6 +151,8 @@ LORA_STYLES = [
116
  "weights": "PerkyPointyPuffy_v1.1_small_pointy_breasts_large_puffy_nipples.safetensors",
117
  "default_prompt": "Small pointy breasts with large puffy nipples",
118
  "default_weight": 1.0,
 
 
119
  },
120
  {
121
  "title": "Flat Chested",
@@ -124,6 +161,8 @@ LORA_STYLES = [
124
  "weights": "Flux2-Klein-9b-FlatChested-v1.safetensors",
125
  "default_prompt": "flat chested",
126
  "default_weight": 1.5,
 
 
127
  },
128
  {
129
  "title": "Controllight",
@@ -132,6 +171,8 @@ LORA_STYLES = [
132
  "weights": "controllight.safetensors",
133
  "default_prompt": None,
134
  "default_weight": 1.0,
 
 
135
  },
136
  {
137
  "title": "RefControl - Depth",
@@ -140,6 +181,8 @@ LORA_STYLES = [
140
  "weights": "flux2_klein_9b_refcontrol_depth.safetensors",
141
  "default_prompt": "refcontrol",
142
  "default_weight": 1.0,
 
 
143
  },
144
  {
145
  "title": "RefControl - Pose",
@@ -148,31 +191,272 @@ LORA_STYLES = [
148
  "weights": "refcontrol_v2_poses.safetensors",
149
  "default_prompt": "apply pose from image 1 with reference from image 2",
150
  "default_weight": 1.0,
 
 
151
  },
152
  ]
153
 
 
 
154
 
155
- # LOADED_ADAPTERS is the only piece of LoRA state that's legitimately global:
156
- # it just tracks which adapter names have been loaded onto the shared `pipe`
157
- # at least once, so we don't re-download/re-attach weights on every call.
158
- # It is NOT used to decide what a user can select β€” that comes from each
159
- # session's own `dynamic_loras_state` (a gr.State dict), so one user's custom
160
- # HuggingFace LoRA never shows up in another user's checklist.
161
  LOADED_ADAPTERS: set[str] = set()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
- # ── Catalog lookup helpers ────────────────────────────────────────────────────
165
 
166
  def get_all_styles(dynamic_loras):
167
- all_styles = list(LORA_STYLES)
168
  for dynamic_lora in (dynamic_loras or {}).values():
169
  all_styles.append(dynamic_lora)
170
  return all_styles
171
 
172
 
173
  def get_selectable_styles(dynamic_loras):
174
- """Return all styles except 'None' for the checkbox selector."""
175
- return [s for s in get_all_styles(dynamic_loras) if s["adapter_name"] is not None]
176
 
177
 
178
  def get_style_by_title(title, dynamic_loras):
@@ -189,16 +473,7 @@ def get_style_by_adapter_name(adapter_name, dynamic_loras):
189
  return None
190
 
191
 
192
- # ── LoRA selection β†’ update weight sliders + LoRA prompt display ──────────────
193
-
194
  def update_weight_sliders(selected_titles, dynamic_loras):
195
- """Given a list of selected LoRA titles, return updated visibility/label/value
196
- for each of the MAX_LORA_SLOTS pre-rendered sliders, plus an update for the
197
- LoRA prompt display box (visible when any selected LoRA has a default prompt).
198
-
199
- The user's own prompt is never touched here β€” LoRA prompts are shown
200
- separately and appended at inference time.
201
- """
202
  selected_styles = [
203
  get_style_by_title(t, dynamic_loras)
204
  for t in (selected_titles or [])
@@ -226,57 +501,437 @@ def update_weight_sliders(selected_titles, dynamic_loras):
226
  return slider_updates + [lora_prompt_update]
227
 
228
 
229
- # ── Dynamic HF LoRA loader (per-session) ─────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
  def add_custom_lora(repo_id, weight_name, adapter_name, dynamic_loras_state):
232
- """Adds a custom LoRA to *this session's* state only β€” other users never see
233
- it in their selector. The adapter_name attached to the shared pipe is
234
- still made globally unique (via a short random suffix) since the pipe
235
- object itself is one shared resource across all sessions.
236
- """
237
  dynamic_loras = dict(dynamic_loras_state or {})
238
  if not repo_id or not repo_id.strip():
239
- return "Please enter a valid HuggingFace repo ID.", gr.update(), dynamic_loras
 
 
 
 
 
240
  repo_id = repo_id.strip()
241
  requested_name = adapter_name.strip() if adapter_name and adapter_name.strip() else None
242
  try:
243
- from huggingface_hub import model_info
244
- info = model_info(repo_id)
245
- actual_weight = weight_name.strip() if weight_name and weight_name.strip() else None
246
- if not actual_weight:
247
- for name in ["pytorch_lora_weights.safetensors", "lora.safetensors", "adapter_model.safetensors"]:
248
- if any(f.filename == name for f in info.siblings):
249
- actual_weight = name
250
- break
251
- if not actual_weight:
252
- available = [f.filename for f in info.siblings if f.filename.endswith(('.safetensors', '.bin'))]
 
 
 
 
 
 
 
 
253
  return (
254
- f"No weight found. Available: {', '.join(available) or 'None'}",
 
255
  gr.update(),
256
  dynamic_loras,
 
 
257
  )
258
 
259
- base_name = (
260
- "".join(c if c.isalnum() or c in "-_" else "_" for c in requested_name)
261
- if requested_name else "custom"
262
- )
263
-
264
- static_names = {s["adapter_name"] for s in LORA_STYLES if s["adapter_name"]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  final_adapter_name = f"{base_name}_{uuid.uuid4().hex[:6]}"
266
  while final_adapter_name in static_names or final_adapter_name in LOADED_ADAPTERS:
267
  final_adapter_name = f"{base_name}_{uuid.uuid4().hex[:6]}"
268
 
269
  custom_style = {
270
- "image": "https://huggingface.co/spaces/prithivMLmods/FLUX.2-Klein-LoRA-Studio/resolve/main/examples/image.webp",
271
  "title": f"Custom: {base_name}",
272
  "adapter_name": final_adapter_name,
273
- "repo": repo_id,
274
  "weights": actual_weight,
275
  "default_prompt": None,
276
  "default_weight": 1.0,
 
 
 
 
277
  }
278
  dynamic_loras[final_adapter_name] = custom_style
279
  new_choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
280
- return f"βœ… Added: {base_name} from {repo_id}", gr.update(choices=new_choices), dynamic_loras
 
 
 
 
 
 
 
 
 
 
281
  except Exception as e:
282
- return f"❌ Failed: {str(e)}", gr.update(), dynamic_loras
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LoRA catalog + per-session custom LoRA loader (JSON-first).
2
+
3
+ Single shared catalog lives in an external JSON file
4
+ (``PERSISTENT_LORA_CATALOG_PATH``, default
5
+ ``/loras-flux/config/lorasplayground.json``).
6
+
7
+ Layers:
8
+ * EXTERNAL_LORA_STYLES β€” full catalog from JSON (including archived).
9
+ UI listing only shows entries with ``active: true``.
10
+ * dynamic_loras (gr.State) β€” session-private try-outs.
11
+ * LOADED_ADAPTERS β€” names already attached to the shared pipe.
12
+
13
+ JSON entry fields:
14
+ title, adapter_name, repo, weights, default_prompt, default_weight,
15
+ admin_approved (bool), active (bool; false = archived/hidden from UI),
16
+ sha256 (optional hex digest of the weight file), image (optional).
17
  """
18
  from __future__ import annotations
19
 
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import threading
24
  import uuid
25
+ from pathlib import Path
26
 
27
  import gradio as gr
28
 
29
+ from config import MAX_LORA_SLOTS, PERSISTENT_LORA_CATALOG_PATH
30
 
31
 
32
  FACE_SWAP_PROMPT = """head_swap: start with Picture 1 as the base image, keeping its lighting, environment, and background. Remove the head from Picture 1 completely and replace it with the head from Picture 2.
 
41
  - Skin: texture, tone, complexion
42
  The replaced head must seamlessly match Picture 1's lighting and expression while maintaining the complete identity from Picture 2. High quality, photorealistic, sharp details, 4k."""
43
 
44
+ _DEFAULT_LORA_IMAGE = (
45
+ "https://huggingface.co/spaces/prithivMLmods/FLUX.2-Klein-LoRA-Studio/"
46
+ "resolve/main/examples/image.webp"
47
+ )
48
 
49
+ # Seed used only when the external JSON is missing/empty. After first write,
50
+ # the JSON is the single source of truth β€” nothing here is merged at runtime.
51
+ _SEED_LORA_STYLES = [
 
 
 
 
 
 
 
52
  {
53
  "title": "Klein-Delight-Style",
54
  "adapter_name": "klein-delight",
55
  "repo": "linoyts/Flux2-Klein-Delight-LoRA",
56
  "weights": "pytorch_lora_weights.safetensors",
57
+ "default_prompt": (
58
+ "Relight the image to remove all existing lighting conditions and replace them "
59
+ "with neutral, uniform illumination. Apply soft, evenly distributed lighting with "
60
+ "no directional shadows, no harsh highlights, and no dramatic contrast. Maintain "
61
+ "the original identity of all subjects exactlyβ€”preserve facial structure, skin tone, "
62
+ "proportions, expressions, hair, clothing, and textures. Do not alter pose, camera "
63
+ "angle, background geometry, or image composition. Lighting should appear balanced, "
64
+ "and studio-neutral, similar to diffuse overcast or a soft lightbox setup. Ensure "
65
+ "consistent exposure across the entire image with realistic depth and subtle shading "
66
+ "only where necessary for form."
67
+ ),
68
  "default_weight": 1.0,
69
+ "admin_approved": True,
70
+ "active": True,
71
  },
72
  {
73
  "title": "Klein-Consistency",
 
76
  "weights": "Klein-consistency.safetensors",
77
  "default_prompt": None,
78
  "default_weight": 0.3,
79
+ "admin_approved": True,
80
+ "active": True,
81
  },
82
  {
83
  "title": "Best-Face-Swap",
 
86
  "weights": "bfs_head_v1_flux-klein_9b_step3750_rank64.safetensors",
87
  "default_prompt": FACE_SWAP_PROMPT,
88
  "default_weight": 1.0,
89
+ "admin_approved": True,
90
+ "active": True,
91
  },
92
  {
93
  "title": "NSFW v2",
 
96
  "weights": "Flux Klein - NSFW v2.safetensors",
97
  "default_prompt": None,
98
  "default_weight": 1.0,
99
+ "admin_approved": True,
100
+ "active": True,
101
  },
102
  {
103
  "title": "Ultimate Upscaler Klein-9b",
104
  "adapter_name": "Ultimate Upscaler",
105
  "repo": "loras",
106
  "weights": "Flux2-Klein-Image-RestoreV1.safetensors",
107
+ "default_prompt": (
108
+ "restore the image quality, remove any compression artefacts, remove any haze "
109
+ "and soft edges, enrich the original with new intricate detail in all textures "
110
+ "and surfaces creating a professional photorealistic photograph with natural "
111
+ "lighting and skin texture."
112
+ ),
113
  "default_weight": 1.0,
114
+ "admin_approved": True,
115
+ "active": True,
116
  },
117
  {
118
  "title": "High Resolution",
 
121
  "weights": "HighResolution9B.safetensors",
122
  "default_prompt": "High Resolution",
123
  "default_weight": 1.0,
124
+ "admin_approved": True,
125
+ "active": True,
126
  },
127
  {
128
  "title": "InstaPic",
 
131
  "weights": "InstaPic V3.safetensors",
132
  "default_prompt": "instapic",
133
  "default_weight": 1.0,
134
+ "admin_approved": True,
135
+ "active": True,
136
  },
137
  {
138
  "title": "Realistic Nudes",
 
141
  "weights": "realistic_nudes_klein_v3.safetensors",
142
  "default_prompt": None,
143
  "default_weight": 1.0,
144
+ "admin_approved": True,
145
+ "active": True,
146
  },
147
  {
148
  "title": "Perky Pointy Puffy Breasts",
 
151
  "weights": "PerkyPointyPuffy_v1.1_small_pointy_breasts_large_puffy_nipples.safetensors",
152
  "default_prompt": "Small pointy breasts with large puffy nipples",
153
  "default_weight": 1.0,
154
+ "admin_approved": True,
155
+ "active": True,
156
  },
157
  {
158
  "title": "Flat Chested",
 
161
  "weights": "Flux2-Klein-9b-FlatChested-v1.safetensors",
162
  "default_prompt": "flat chested",
163
  "default_weight": 1.5,
164
+ "admin_approved": True,
165
+ "active": True,
166
  },
167
  {
168
  "title": "Controllight",
 
171
  "weights": "controllight.safetensors",
172
  "default_prompt": None,
173
  "default_weight": 1.0,
174
+ "admin_approved": True,
175
+ "active": True,
176
  },
177
  {
178
  "title": "RefControl - Depth",
 
181
  "weights": "flux2_klein_9b_refcontrol_depth.safetensors",
182
  "default_prompt": "refcontrol",
183
  "default_weight": 1.0,
184
+ "admin_approved": True,
185
+ "active": True,
186
  },
187
  {
188
  "title": "RefControl - Pose",
 
191
  "weights": "refcontrol_v2_poses.safetensors",
192
  "default_prompt": "apply pose from image 1 with reference from image 2",
193
  "default_weight": 1.0,
194
+ "admin_approved": True,
195
+ "active": True,
196
  },
197
  ]
198
 
199
+ # Back-compat alias β€” older imports still work; runtime catalog is JSON-only.
200
+ LORA_STYLES: list[dict] = []
201
 
 
 
 
 
 
 
202
  LOADED_ADAPTERS: set[str] = set()
203
+ _CATALOG_LOCK = threading.Lock()
204
+ EXTERNAL_LORA_STYLES: list[dict] = [] # full JSON list (active + archived)
205
+
206
+ _WEIGHT_EXTS = (".safetensors", ".bin")
207
+ _DEFAULT_WEIGHT_CANDIDATES = (
208
+ "pytorch_lora_weights.safetensors",
209
+ "lora.safetensors",
210
+ "adapter_model.safetensors",
211
+ )
212
+ _HASH_CHUNK = 1024 * 1024
213
+
214
+
215
+ def _sanitize_name(value: str, fallback: str = "custom") -> str:
216
+ cleaned = "".join(c if c.isalnum() or c in "-_" else "_" for c in (value or ""))
217
+ return cleaned.strip("_") or fallback
218
+
219
+
220
+ def _as_bool(value, default: bool = False) -> bool:
221
+ if value is None:
222
+ return default
223
+ if isinstance(value, bool):
224
+ return value
225
+ if isinstance(value, (int, float)):
226
+ return bool(value)
227
+ if isinstance(value, str):
228
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
229
+ return default
230
+
231
+
232
+ def _file_sha256(path: str | Path) -> str | None:
233
+ p = Path(path)
234
+ if not p.is_file():
235
+ return None
236
+ h = hashlib.sha256()
237
+ try:
238
+ with open(p, "rb") as f:
239
+ while True:
240
+ chunk = f.read(_HASH_CHUNK)
241
+ if not chunk:
242
+ break
243
+ h.update(chunk)
244
+ return h.hexdigest()
245
+ except OSError as e:
246
+ print(f"[lora_registry] sha256 failed for {p}: {e}")
247
+ return None
248
+
249
+
250
+ def _weight_path(repo: str, weights: str) -> Path | None:
251
+ if not repo or not weights:
252
+ return None
253
+ if str(repo).startswith("/"):
254
+ candidate = Path(repo) / weights
255
+ if candidate.is_file():
256
+ return candidate
257
+ candidate = Path(repo) / weights
258
+ if candidate.is_file():
259
+ return candidate
260
+ if str(repo).endswith(_WEIGHT_EXTS) and Path(repo).is_file():
261
+ return Path(repo)
262
+ return None
263
+
264
+
265
+ def _compute_entry_sha256(entry: dict) -> str | None:
266
+ if entry.get("sha256"):
267
+ return str(entry["sha256"]).lower()
268
+ path = _weight_path(entry.get("repo", ""), entry.get("weights", ""))
269
+ if path is None:
270
+ return None
271
+ return _file_sha256(path)
272
+
273
+
274
+ def _normalize_catalog_entry(raw) -> dict | None:
275
+ if not isinstance(raw, dict):
276
+ return None
277
+ title = (raw.get("title") or "").strip()
278
+ repo = raw.get("repo")
279
+ weights = raw.get("weights")
280
+ if not title or not repo or not weights:
281
+ return None
282
+ adapter = (raw.get("adapter_name") or "").strip() or _sanitize_name(title)
283
+ try:
284
+ default_weight = float(raw.get("default_weight", 1.0))
285
+ except (TypeError, ValueError):
286
+ default_weight = 1.0
287
+ prompt = raw.get("default_prompt")
288
+ if isinstance(prompt, str):
289
+ prompt = prompt.strip() or None
290
+ else:
291
+ prompt = None
292
+
293
+ if "active" in raw:
294
+ active = _as_bool(raw.get("active"), True)
295
+ else:
296
+ active = not _as_bool(raw.get("archived"), False)
297
+
298
+ sha = raw.get("sha256") or raw.get("hash") or raw.get("sha256_hex")
299
+ if isinstance(sha, str):
300
+ sha = sha.strip().lower() or None
301
+ else:
302
+ sha = None
303
+
304
+ return {
305
+ "image": raw.get("image") or _DEFAULT_LORA_IMAGE,
306
+ "title": title,
307
+ "adapter_name": adapter,
308
+ "repo": str(repo),
309
+ "weights": str(weights),
310
+ "default_prompt": prompt,
311
+ "default_weight": default_weight,
312
+ "admin_approved": _as_bool(raw.get("admin_approved"), False),
313
+ "active": active,
314
+ "sha256": sha,
315
+ }
316
+
317
+
318
+ def _serialize_entry(e: dict) -> dict:
319
+ out = {
320
+ "title": e["title"],
321
+ "adapter_name": e["adapter_name"],
322
+ "repo": e["repo"],
323
+ "weights": e["weights"],
324
+ "default_prompt": e.get("default_prompt"),
325
+ "default_weight": float(e.get("default_weight", 1.0)),
326
+ "admin_approved": bool(e.get("admin_approved", False)),
327
+ "active": bool(e.get("active", True)),
328
+ }
329
+ if e.get("sha256"):
330
+ out["sha256"] = e["sha256"]
331
+ if e.get("image") and e["image"] != _DEFAULT_LORA_IMAGE:
332
+ out["image"] = e["image"]
333
+ return out
334
+
335
+
336
+ def _read_catalog_file(path: str | None = None) -> list[dict]:
337
+ catalog_path = Path(path or PERSISTENT_LORA_CATALOG_PATH)
338
+ if not catalog_path.is_file():
339
+ return []
340
+ try:
341
+ with open(catalog_path, "r", encoding="utf-8") as f:
342
+ data = json.load(f)
343
+ except Exception as e:
344
+ print(f"[lora_registry] Could not read catalog {catalog_path}: {e}")
345
+ return []
346
+
347
+ if isinstance(data, dict):
348
+ items = data.get("loras") or data.get("styles") or data.get("items") or []
349
+ elif isinstance(data, list):
350
+ items = data
351
+ else:
352
+ return []
353
+
354
+ out, seen_titles = [], set()
355
+ for raw in items:
356
+ entry = _normalize_catalog_entry(raw)
357
+ if not entry or entry["title"] in seen_titles:
358
+ continue
359
+ seen_titles.add(entry["title"])
360
+ out.append(entry)
361
+ return out
362
+
363
+
364
+ def _write_catalog_file(entries: list[dict], path: str | None = None) -> str:
365
+ catalog_path = Path(path or PERSISTENT_LORA_CATALOG_PATH)
366
+ catalog_path.parent.mkdir(parents=True, exist_ok=True)
367
+ payload = {
368
+ "version": 2,
369
+ "loras": [_serialize_entry(e) for e in entries],
370
+ }
371
+ tmp_path = catalog_path.with_suffix(catalog_path.suffix + ".tmp")
372
+ with open(tmp_path, "w", encoding="utf-8") as f:
373
+ json.dump(payload, f, indent=2, ensure_ascii=False)
374
+ f.write("\n")
375
+ os.replace(tmp_path, catalog_path)
376
+ return str(catalog_path)
377
+
378
+
379
+ def _seed_catalog_if_needed(path: str | None = None) -> list[dict]:
380
+ catalog_path = Path(path or PERSISTENT_LORA_CATALOG_PATH)
381
+ entries = _read_catalog_file(str(catalog_path))
382
+ if entries:
383
+ dirty = False
384
+ for e in entries:
385
+ if not e.get("sha256"):
386
+ digest = _compute_entry_sha256(e)
387
+ if digest:
388
+ e["sha256"] = digest
389
+ dirty = True
390
+ if dirty:
391
+ try:
392
+ _write_catalog_file(entries, str(catalog_path))
393
+ except Exception as ex:
394
+ print(f"[lora_registry] Could not backfill sha256: {ex}")
395
+ return entries
396
+
397
+ seeded = []
398
+ for raw in _SEED_LORA_STYLES:
399
+ entry = _normalize_catalog_entry(raw)
400
+ if not entry:
401
+ continue
402
+ digest = _compute_entry_sha256(entry)
403
+ if digest:
404
+ entry["sha256"] = digest
405
+ seeded.append(entry)
406
+ try:
407
+ written = _write_catalog_file(seeded, str(catalog_path))
408
+ print(f"[lora_registry] Seeded catalog with {len(seeded)} LoRA(s) β†’ {written}")
409
+ except Exception as e:
410
+ print(f"[lora_registry] Could not seed catalog at {catalog_path}: {e}")
411
+ return seeded
412
+
413
+
414
+ def load_external_catalog(path: str | None = None) -> list[dict]:
415
+ global EXTERNAL_LORA_STYLES, LORA_STYLES
416
+ with _CATALOG_LOCK:
417
+ EXTERNAL_LORA_STYLES = _seed_catalog_if_needed(path)
418
+ LORA_STYLES = [e for e in EXTERNAL_LORA_STYLES if e.get("active", True)]
419
+ print(
420
+ f"[lora_registry] Catalog: {len(LORA_STYLES)} active / "
421
+ f"{len(EXTERNAL_LORA_STYLES)} total from "
422
+ f"{path or PERSISTENT_LORA_CATALOG_PATH}"
423
+ )
424
+ return list(EXTERNAL_LORA_STYLES)
425
+
426
+
427
+ load_external_catalog()
428
+
429
+
430
+ def _active_catalog() -> list[dict]:
431
+ return [e for e in EXTERNAL_LORA_STYLES if e.get("active", True)]
432
+
433
 
434
+ def _find_duplicate(entries: list[dict], *, title=None, sha256=None, repo=None, weights=None):
435
+ if sha256:
436
+ sha = str(sha256).lower()
437
+ for e in entries:
438
+ if e.get("sha256") and e["sha256"].lower() == sha:
439
+ return e
440
+ if title:
441
+ for e in entries:
442
+ if e.get("title") == title:
443
+ return e
444
+ if repo and weights:
445
+ for e in entries:
446
+ if e.get("repo") == repo and e.get("weights") == weights:
447
+ return e
448
+ return None
449
 
 
450
 
451
  def get_all_styles(dynamic_loras):
452
+ all_styles = list(_active_catalog())
453
  for dynamic_lora in (dynamic_loras or {}).values():
454
  all_styles.append(dynamic_lora)
455
  return all_styles
456
 
457
 
458
  def get_selectable_styles(dynamic_loras):
459
+ return [s for s in get_all_styles(dynamic_loras) if s.get("adapter_name") is not None]
 
460
 
461
 
462
  def get_style_by_title(title, dynamic_loras):
 
473
  return None
474
 
475
 
 
 
476
  def update_weight_sliders(selected_titles, dynamic_loras):
 
 
 
 
 
 
 
477
  selected_styles = [
478
  get_style_by_title(t, dynamic_loras)
479
  for t in (selected_titles or [])
 
501
  return slider_updates + [lora_prompt_update]
502
 
503
 
504
+ def _looks_like_local_path(value: str) -> bool:
505
+ return bool(value) and value.startswith("/")
506
+
507
+
508
+ def _resolve_local_lora(path_str: str, weight_name: str | None):
509
+ path = Path(os.path.expanduser(path_str)).resolve()
510
+ requested = weight_name.strip() if weight_name and weight_name.strip() else None
511
+
512
+ if path.is_file():
513
+ if path.suffix.lower() not in _WEIGHT_EXTS:
514
+ raise ValueError(f"Not a LoRA weight file: {path.name}")
515
+ return str(path.parent), path.name, path.stem
516
+
517
+ if not path.is_dir():
518
+ raise FileNotFoundError(f"Local path not found: {path}")
519
+
520
+ if requested:
521
+ candidate = path / requested
522
+ if not candidate.is_file():
523
+ available = sorted(
524
+ p.name for p in path.iterdir()
525
+ if p.is_file() and p.suffix.lower() in _WEIGHT_EXTS
526
+ )
527
+ raise FileNotFoundError(
528
+ f"'{requested}' not in {path}. Available: {', '.join(available) or 'None'}"
529
+ )
530
+ return str(path), requested, Path(requested).stem
531
+
532
+ for name in _DEFAULT_WEIGHT_CANDIDATES:
533
+ if (path / name).is_file():
534
+ return str(path), name, path.name
535
+
536
+ available = sorted(
537
+ p.name for p in path.iterdir()
538
+ if p.is_file() and p.suffix.lower() in _WEIGHT_EXTS
539
+ )
540
+ if len(available) == 1:
541
+ return str(path), available[0], Path(available[0]).stem
542
+ if not available:
543
+ raise FileNotFoundError(f"No .safetensors/.bin weights found in {path}")
544
+ raise FileNotFoundError(
545
+ f"Multiple weights in {path}; set Weight filename. Available: {', '.join(available)}"
546
+ )
547
+
548
+
549
+ def _resolve_hf_lora(repo_id: str, weight_name: str | None):
550
+ from huggingface_hub import model_info
551
+
552
+ info = model_info(repo_id)
553
+ actual_weight = weight_name.strip() if weight_name and weight_name.strip() else None
554
+ if not actual_weight:
555
+ for name in _DEFAULT_WEIGHT_CANDIDATES:
556
+ if any(f.filename == name for f in info.siblings):
557
+ actual_weight = name
558
+ break
559
+ if not actual_weight:
560
+ available = [
561
+ f.filename for f in info.siblings
562
+ if f.filename.endswith(_WEIGHT_EXTS)
563
+ ]
564
+ raise FileNotFoundError(
565
+ f"No weight found. Available: {', '.join(available) or 'None'}"
566
+ )
567
+
568
+ sha = None
569
+ for sib in info.siblings:
570
+ if sib.filename == actual_weight:
571
+ lfs = getattr(sib, "lfs", None) or {}
572
+ if isinstance(lfs, dict):
573
+ sha = lfs.get("sha256") or lfs.get("oid")
574
+ break
575
+
576
+ display = repo_id.split("/")[-1] if "/" in repo_id else repo_id
577
+ return repo_id, actual_weight, display, (str(sha).lower() if sha else None)
578
+
579
+
580
+ def session_custom_lora_titles(dynamic_loras) -> list[str]:
581
+ return [s["title"] for s in (dynamic_loras or {}).values() if s.get("title")]
582
+
583
+
584
+ def removable_catalog_titles() -> list[str]:
585
+ return [
586
+ e["title"] for e in EXTERNAL_LORA_STYLES
587
+ if not e.get("admin_approved", False)
588
+ ]
589
+
590
+
591
+ def _unique_adapter_name(base: str) -> str:
592
+ existing = {
593
+ s["adapter_name"]
594
+ for s in EXTERNAL_LORA_STYLES
595
+ if s.get("adapter_name")
596
+ } | set(LOADED_ADAPTERS)
597
+ if base not in existing:
598
+ return base
599
+ for i in range(2, 1000):
600
+ candidate = f"{base}_{i}"
601
+ if candidate not in existing:
602
+ return candidate
603
+ return f"{base}_{uuid.uuid4().hex[:6]}"
604
+
605
+
606
+ def _empty_add_result(msg, dynamic_loras):
607
+ save_choices = session_custom_lora_titles(dynamic_loras)
608
+ remove_choices = save_choices + removable_catalog_titles()
609
+ return (
610
+ msg,
611
+ gr.update(),
612
+ dynamic_loras,
613
+ gr.update(choices=save_choices),
614
+ gr.update(choices=remove_choices),
615
+ )
616
+
617
 
618
  def add_custom_lora(repo_id, weight_name, adapter_name, dynamic_loras_state):
 
 
 
 
 
619
  dynamic_loras = dict(dynamic_loras_state or {})
620
  if not repo_id or not repo_id.strip():
621
+ return _empty_add_result(
622
+ "Please enter a HuggingFace repo ID or a local path "
623
+ "(e.g. /loras-flux/my.safetensors).",
624
+ dynamic_loras,
625
+ )
626
+
627
  repo_id = repo_id.strip()
628
  requested_name = adapter_name.strip() if adapter_name and adapter_name.strip() else None
629
  try:
630
+ sha = None
631
+ if _looks_like_local_path(repo_id):
632
+ resolved_repo, actual_weight, auto_name = _resolve_local_lora(repo_id, weight_name)
633
+ source_label = resolved_repo
634
+ sha = _file_sha256(Path(resolved_repo) / actual_weight)
635
+ else:
636
+ resolved_repo, actual_weight, auto_name, sha = _resolve_hf_lora(repo_id, weight_name)
637
+ source_label = resolved_repo
638
+
639
+ with _CATALOG_LOCK:
640
+ dup = _find_duplicate(
641
+ EXTERNAL_LORA_STYLES,
642
+ sha256=sha,
643
+ repo=resolved_repo,
644
+ weights=actual_weight,
645
+ )
646
+ if dup:
647
+ where = "active catalog" if dup.get("active", True) else "archived catalog"
648
  return (
649
+ f"⚠️ Already in {where} as '{dup['title']}'"
650
+ + (f" (sha256 {dup['sha256'][:12]}…)" if dup.get("sha256") else ""),
651
  gr.update(),
652
  dynamic_loras,
653
+ gr.update(choices=session_custom_lora_titles(dynamic_loras)),
654
+ gr.update(choices=session_custom_lora_titles(dynamic_loras) + removable_catalog_titles()),
655
  )
656
 
657
+ for s in dynamic_loras.values():
658
+ if sha and s.get("sha256") and s["sha256"] == sha:
659
+ return (
660
+ f"⚠️ Already added this session as '{s['title']}' (same sha256).",
661
+ gr.update(),
662
+ dynamic_loras,
663
+ gr.update(choices=session_custom_lora_titles(dynamic_loras)),
664
+ gr.update(choices=session_custom_lora_titles(dynamic_loras) + removable_catalog_titles()),
665
+ )
666
+ if s.get("repo") == resolved_repo and s.get("weights") == actual_weight:
667
+ return (
668
+ f"⚠️ Already added this session as '{s['title']}'.",
669
+ gr.update(),
670
+ dynamic_loras,
671
+ gr.update(choices=session_custom_lora_titles(dynamic_loras)),
672
+ gr.update(choices=session_custom_lora_titles(dynamic_loras) + removable_catalog_titles()),
673
+ )
674
+
675
+ base_name = _sanitize_name(requested_name or auto_name, fallback="custom")
676
+ static_names = {
677
+ s["adapter_name"] for s in EXTERNAL_LORA_STYLES if s.get("adapter_name")
678
+ }
679
  final_adapter_name = f"{base_name}_{uuid.uuid4().hex[:6]}"
680
  while final_adapter_name in static_names or final_adapter_name in LOADED_ADAPTERS:
681
  final_adapter_name = f"{base_name}_{uuid.uuid4().hex[:6]}"
682
 
683
  custom_style = {
684
+ "image": _DEFAULT_LORA_IMAGE,
685
  "title": f"Custom: {base_name}",
686
  "adapter_name": final_adapter_name,
687
+ "repo": resolved_repo,
688
  "weights": actual_weight,
689
  "default_prompt": None,
690
  "default_weight": 1.0,
691
+ "admin_approved": False,
692
+ "active": True,
693
+ "sha256": sha,
694
+ "session_only": True,
695
  }
696
  dynamic_loras[final_adapter_name] = custom_style
697
  new_choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
698
+ save_choices = session_custom_lora_titles(dynamic_loras)
699
+ remove_choices = save_choices + removable_catalog_titles()
700
+ hash_note = f", sha256={sha[:12]}…" if sha else ""
701
+ return (
702
+ f"βœ… Added (session only): {base_name} from {source_label} "
703
+ f"({actual_weight}{hash_note}). Try it, then save or remove below.",
704
+ gr.update(choices=new_choices),
705
+ dynamic_loras,
706
+ gr.update(choices=save_choices, value=custom_style["title"]),
707
+ gr.update(choices=remove_choices, value=custom_style["title"]),
708
+ )
709
  except Exception as e:
710
+ return _empty_add_result(f"❌ Failed: {e}", dynamic_loras)
711
+
712
+
713
+ def remove_lora(selected_title, dynamic_loras_state, currently_selected):
714
+ dynamic_loras = dict(dynamic_loras_state or {})
715
+ if not selected_title:
716
+ choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
717
+ remove_choices = session_custom_lora_titles(dynamic_loras) + removable_catalog_titles()
718
+ return (
719
+ "Pick a LoRA to remove.",
720
+ gr.update(choices=choices, value=currently_selected or []),
721
+ gr.update(choices=session_custom_lora_titles(dynamic_loras)),
722
+ gr.update(choices=remove_choices),
723
+ dynamic_loras,
724
+ )
725
+
726
+ session_key = None
727
+ for k, v in dynamic_loras.items():
728
+ if v.get("title") == selected_title:
729
+ session_key = k
730
+ break
731
+ if session_key is not None:
732
+ dynamic_loras.pop(session_key, None)
733
+ new_sel = [t for t in (currently_selected or []) if t != selected_title]
734
+ choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
735
+ save_choices = session_custom_lora_titles(dynamic_loras)
736
+ remove_choices = save_choices + removable_catalog_titles()
737
+ return (
738
+ f"πŸ—‘οΈ Removed session LoRA '{selected_title}'.",
739
+ gr.update(choices=choices, value=new_sel),
740
+ gr.update(choices=save_choices, value=None),
741
+ gr.update(choices=remove_choices, value=None),
742
+ dynamic_loras,
743
+ )
744
+
745
+ with _CATALOG_LOCK:
746
+ entries = _read_catalog_file()
747
+ global EXTERNAL_LORA_STYLES
748
+ EXTERNAL_LORA_STYLES = list(entries)
749
+ idx = next((i for i, e in enumerate(entries) if e["title"] == selected_title), None)
750
+ if idx is None:
751
+ choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
752
+ remove_choices = session_custom_lora_titles(dynamic_loras) + removable_catalog_titles()
753
+ return (
754
+ f"❌ '{selected_title}' not found in session or catalog.",
755
+ gr.update(choices=choices),
756
+ gr.update(choices=session_custom_lora_titles(dynamic_loras)),
757
+ gr.update(choices=remove_choices),
758
+ dynamic_loras,
759
+ )
760
+ if entries[idx].get("admin_approved", False):
761
+ choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
762
+ remove_choices = session_custom_lora_titles(dynamic_loras) + removable_catalog_titles()
763
+ return (
764
+ f"❌ '{selected_title}' is admin-approved and cannot be removed from the UI. "
765
+ f"Set active=false in the JSON to archive it.",
766
+ gr.update(choices=choices),
767
+ gr.update(choices=session_custom_lora_titles(dynamic_loras)),
768
+ gr.update(choices=remove_choices),
769
+ dynamic_loras,
770
+ )
771
+ entries.pop(idx)
772
+ try:
773
+ path = _write_catalog_file(entries)
774
+ except Exception as e:
775
+ return (
776
+ f"❌ Failed to write catalog: {e}",
777
+ gr.update(),
778
+ gr.update(),
779
+ gr.update(),
780
+ dynamic_loras,
781
+ )
782
+ EXTERNAL_LORA_STYLES = list(entries)
783
+ LORA_STYLES[:] = [e for e in EXTERNAL_LORA_STYLES if e.get("active", True)]
784
+
785
+ new_sel = [t for t in (currently_selected or []) if t != selected_title]
786
+ choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
787
+ save_choices = session_custom_lora_titles(dynamic_loras)
788
+ remove_choices = save_choices + removable_catalog_titles()
789
+ return (
790
+ f"πŸ—‘οΈ Removed catalog LoRA '{selected_title}' β†’ {path}",
791
+ gr.update(choices=choices, value=new_sel),
792
+ gr.update(choices=save_choices, value=None),
793
+ gr.update(choices=remove_choices, value=None),
794
+ dynamic_loras,
795
+ )
796
+
797
+
798
+ def save_session_lora_to_catalog(
799
+ selected_title,
800
+ catalog_title,
801
+ default_weight,
802
+ default_prompt,
803
+ dynamic_loras_state,
804
+ ):
805
+ dynamic_loras = dict(dynamic_loras_state or {})
806
+ save_choices = session_custom_lora_titles(dynamic_loras)
807
+ remove_choices = save_choices + removable_catalog_titles()
808
+ selector_choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
809
+
810
+ def _fail(msg):
811
+ return (
812
+ msg,
813
+ gr.update(choices=selector_choices),
814
+ gr.update(choices=save_choices),
815
+ gr.update(choices=remove_choices),
816
+ dynamic_loras,
817
+ )
818
+
819
+ if not selected_title:
820
+ return _fail("Pick a session LoRA to save (add one above first).")
821
+
822
+ source = None
823
+ source_key = None
824
+ for k, style in dynamic_loras.items():
825
+ if style.get("title") == selected_title:
826
+ source = style
827
+ source_key = k
828
+ break
829
+ if source is None:
830
+ return _fail(f"❌ '{selected_title}' is not a session custom LoRA.")
831
+
832
+ title = (catalog_title or "").strip() or selected_title.removeprefix("Custom: ").strip()
833
+ if not title:
834
+ title = source.get("adapter_name") or "Custom LoRA"
835
+
836
+ try:
837
+ weight = float(default_weight) if default_weight is not None else float(
838
+ source.get("default_weight", 1.0)
839
+ )
840
+ except (TypeError, ValueError):
841
+ weight = 1.0
842
+
843
+ prompt = (default_prompt or "").strip() or source.get("default_prompt") or None
844
+ adapter_base = _sanitize_name(
845
+ title, fallback=_sanitize_name(source.get("adapter_name", "custom"))
846
+ )
847
+ sha = source.get("sha256") or _compute_entry_sha256(source)
848
+
849
+ with _CATALOG_LOCK:
850
+ entries = _read_catalog_file()
851
+ global EXTERNAL_LORA_STYLES
852
+ EXTERNAL_LORA_STYLES = list(entries)
853
+
854
+ dup = _find_duplicate(
855
+ entries, sha256=sha, title=title,
856
+ repo=source["repo"], weights=source["weights"],
857
+ )
858
+ if dup and dup["title"] != title:
859
+ return _fail(
860
+ f"❌ Duplicate of existing catalog entry '{dup['title']}'"
861
+ + (" (sha256 match)" if sha and dup.get("sha256") == sha else "")
862
+ )
863
+ if dup and dup.get("admin_approved", False) and dup["title"] == title:
864
+ return _fail(
865
+ f"❌ '{title}' is admin-approved β€” edit the JSON directly to change it."
866
+ )
867
+
868
+ existing_idx = next((i for i, e in enumerate(entries) if e["title"] == title), None)
869
+ if existing_idx is None:
870
+ adapter_name = _unique_adapter_name(adapter_base)
871
+ prev_approved = False
872
+ prev_active = True
873
+ else:
874
+ adapter_name = entries[existing_idx].get("adapter_name") or _unique_adapter_name(adapter_base)
875
+ prev_approved = bool(entries[existing_idx].get("admin_approved", False))
876
+ prev_active = bool(entries[existing_idx].get("active", True))
877
+
878
+ entry = {
879
+ "image": source.get("image") or _DEFAULT_LORA_IMAGE,
880
+ "title": title,
881
+ "adapter_name": adapter_name,
882
+ "repo": source["repo"],
883
+ "weights": source["weights"],
884
+ "default_prompt": prompt,
885
+ "default_weight": weight,
886
+ "admin_approved": prev_approved,
887
+ "active": prev_active,
888
+ "sha256": sha,
889
+ }
890
+
891
+ if existing_idx is None:
892
+ entries.append(entry)
893
+ action = "Saved"
894
+ else:
895
+ entries[existing_idx] = entry
896
+ action = "Updated"
897
+
898
+ try:
899
+ path = _write_catalog_file(entries)
900
+ except Exception as e:
901
+ return _fail(f"❌ Failed to write catalog: {e}")
902
+
903
+ EXTERNAL_LORA_STYLES = list(entries)
904
+ LORA_STYLES[:] = [e for e in EXTERNAL_LORA_STYLES if e.get("active", True)]
905
+
906
+ if source_key is not None:
907
+ dynamic_loras.pop(source_key, None)
908
+
909
+ selector_choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
910
+ save_choices = session_custom_lora_titles(dynamic_loras)
911
+ remove_choices = save_choices + removable_catalog_titles()
912
+ hash_note = f", sha256={sha[:12]}…" if sha else ""
913
+ return (
914
+ f"βœ… {action} '{title}' β†’ {path} (weight={weight}{hash_note})",
915
+ gr.update(choices=selector_choices),
916
+ gr.update(choices=save_choices, value=None),
917
+ gr.update(choices=remove_choices, value=None),
918
+ dynamic_loras,
919
+ )
920
+
921
+
922
+ def fill_catalog_save_form(selected_title, dynamic_loras_state):
923
+ if not selected_title:
924
+ return gr.update(), gr.update(), gr.update()
925
+ style = None
926
+ for s in (dynamic_loras_state or {}).values():
927
+ if s.get("title") == selected_title:
928
+ style = s
929
+ break
930
+ if not style:
931
+ return gr.update(), gr.update(), gr.update()
932
+ suggested = selected_title.removeprefix("Custom: ").strip() or selected_title
933
+ return (
934
+ gr.update(value=suggested),
935
+ gr.update(value=float(style.get("default_weight", 1.0))),
936
+ gr.update(value=style.get("default_prompt") or ""),
937
+ )
ui_theme.py CHANGED
@@ -24,7 +24,7 @@ class OrangeRedTheme(Soft):
24
  neutral_hue: colors.Color | str = colors.slate,
25
  text_size: sizes.Size | str = sizes.text_lg,
26
  font: fonts.Font | str | Iterable[fonts.Font | str] = (
27
- fonts.GoogleFont("Permanent Marker"), "Arial", "sans-serif",
28
  ),
29
  font_mono: fonts.Font | str | Iterable[fonts.Font | str] = (
30
  fonts.GoogleFont("Ubuntu Mono"), "ui-monospace", "monospace",
 
24
  neutral_hue: colors.Color | str = colors.slate,
25
  text_size: sizes.Size | str = sizes.text_lg,
26
  font: fonts.Font | str | Iterable[fonts.Font | str] = (
27
+ "system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif",
28
  ),
29
  font_mono: fonts.Font | str | Iterable[fonts.Font | str] = (
30
  fonts.GoogleFont("Ubuntu Mono"), "ui-monospace", "monospace",