JSON LoRA catalog, local path imports, and UI polish
Browse filesMove 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>
- .gitignore +9 -0
- app.py +139 -15
- config.py +6 -0
- image_utils.py +10 -0
- lora_registry.py +729 -74
- 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 |
-
|
| 455 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 461 |
-
"image; otherwise the latest is used. PNGs contain seed,
|
| 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
|
| 482 |
-
gr.Markdown(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
with gr.Row():
|
| 484 |
-
lora_repo_id = gr.Textbox(
|
|
|
|
|
|
|
|
|
|
| 485 |
with gr.Row():
|
| 486 |
-
lora_weight_name = gr.Textbox(
|
| 487 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
repo_id = repo_id.strip()
|
| 241 |
requested_name = adapter_name.strip() if adapter_name and adapter_name.strip() else None
|
| 242 |
try:
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
return (
|
| 254 |
-
f"
|
|
|
|
| 255 |
gr.update(),
|
| 256 |
dynamic_loras,
|
|
|
|
|
|
|
| 257 |
)
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 271 |
"title": f"Custom: {base_name}",
|
| 272 |
"adapter_name": final_adapter_name,
|
| 273 |
-
"repo":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
except Exception as e:
|
| 282 |
-
return f"β Failed: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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",
|