diff --git a/.gitignore b/.gitignore
index e8e12f1..ab49070 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,6 @@ wanvideo_cached_samples.pt
wanvideo_cached_videos.pt
ref_image.jpg
QWEN.md
-ComfyUI-WanVideoWrapper/
\ No newline at end of file
+ComfyUI-WanVideoWrapper/
+rgthree-comfy-main/
+utility/userdata/info/
\ No newline at end of file
diff --git a/__init__.py b/__init__.py
index 8635225..64c5da8 100644
--- a/__init__.py
+++ b/__init__.py
@@ -23,6 +23,7 @@ from .nodes.cache_samples import *
# Import API endpoints to register routes
from . import api
+from .utility.rgthree_api import routes_model_info
NODE_CONFIG = {}
diff --git a/api.py b/api.py
index 206d7d9..39e5b27 100644
--- a/api.py
+++ b/api.py
@@ -63,9 +63,10 @@ async def save_ref_image(request):
rgb_img.save(ref_image_path, format='JPEG', quality=95)
return web.json_response({"success": True, "path": ref_image_path, "message": "Reference image saved successfully"})
-
+
except Exception as e:
print(f"Error saving ref image: {e}")
import traceback
traceback.print_exc()
- return web.json_response({"error": str(e)}, status=500)
\ No newline at end of file
+ return web.json_response({"error": str(e)}, status=500)
+
diff --git a/examples/qwen_edit_2509_base_lora_usage.json b/examples/qwen_edit_2509_base_lora_usage.json
new file mode 100644
index 0000000..02c0128
--- /dev/null
+++ b/examples/qwen_edit_2509_base_lora_usage.json
@@ -0,0 +1,1333 @@
+{
+ "id": "7e3c279b-7bb9-4142-a02c-09160a97ed36",
+ "revision": 0,
+ "last_node_id": 59,
+ "last_link_id": 92,
+ "nodes": [
+ {
+ "id": 46,
+ "type": "MarkdownNote",
+ "pos": [
+ -653.4710693359375,
+ 1120.431396484375
+ ],
+ "size": [
+ 395.231201171875,
+ 552.5040283203125
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "title": "Model links",
+ "properties": {
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ "**Qwen-Image-Edit-2509** [YouTube](https://www.youtube.com/@Aininja94)\n\n------------------\n**Model**\n\n[All-Modesl](https://huggingface.co/aidiffuser/Qwen-Image-Edit-2509/tree/main)\n\nFolder: models/diffusion_models\n\n------------------\n**GGUF**\n\n[All-GGUF](https://huggingface.co/QuantStack/Qwen-Image-Edit-2509-GGUF/tree/main)\n\nFolder: models/unet\n\n------------------\n**Qwen Text Encoder**\n\n[ALL Encoder](https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/tree/main/split_files/text_encoders)\n\nFolder: models/text_encoders\n\n------------------\n**Lightning Lora**\n\n[All-Lightning-Loras](https://huggingface.co/lightx2v/Qwen-Image-Lightning/tree/main)\n\nFolder: models/lora\n\n------------------\n**VAE**\n\n[VAE-Direct-Download](https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors)\n\nFolder: models/vae\n"
+ ],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 27,
+ "type": "CFGNorm",
+ "pos": [
+ 326.8143310546875,
+ 1263.1610107421875
+ ],
+ "size": [
+ 270,
+ 58
+ ],
+ "flags": {},
+ "order": 16,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "model",
+ "type": "MODEL",
+ "link": 37
+ }
+ ],
+ "outputs": [
+ {
+ "name": "patched_model",
+ "type": "MODEL",
+ "links": [
+ 53
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "CFGNorm",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "strength": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ 1
+ ]
+ },
+ {
+ "id": 26,
+ "type": "VAEDecode",
+ "pos": [
+ 666.2556762695312,
+ 1618.9246826171875
+ ],
+ "size": [
+ 140,
+ 46
+ ],
+ "flags": {
+ "collapsed": true
+ },
+ "order": 19,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "samples",
+ "type": "LATENT",
+ "link": 35
+ },
+ {
+ "name": "vae",
+ "type": "VAE",
+ "link": 36
+ }
+ ],
+ "outputs": [
+ {
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 64
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "VAEDecode",
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 48,
+ "type": "PreviewImage",
+ "pos": [
+ 668.2689819335938,
+ 1660.909423828125
+ ],
+ "size": [
+ 471.1981506347656,
+ 581.22509765625
+ ],
+ "flags": {},
+ "order": 20,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "images",
+ "type": "IMAGE",
+ "link": 64
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.60",
+ "Node name for S&R": "PreviewImage",
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 34,
+ "type": "VAELoader",
+ "pos": [
+ -212.9727325439453,
+ 1583.016845703125
+ ],
+ "size": [
+ 359.342529296875,
+ 60.030513763427734
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "VAE",
+ "type": "VAE",
+ "links": [
+ 36,
+ 45,
+ 49
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "VAELoader",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "vae_name": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ "qwen_image_vae.safetensors"
+ ]
+ },
+ {
+ "id": 50,
+ "type": "INTConstant",
+ "pos": [
+ -208.8269500732422,
+ 2062.228271484375
+ ],
+ "size": [
+ 210,
+ 58
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "value",
+ "type": "INT",
+ "links": [
+ 65,
+ 66
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfyui-kjnodes",
+ "ver": "fbdb08f9d6b0e821919d2c9f9caf8972ed439690",
+ "Node name for S&R": "INTConstant",
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ 1168
+ ],
+ "color": "#1b4669",
+ "bgcolor": "#29699c"
+ },
+ {
+ "id": 49,
+ "type": "ImageResizeKJv2",
+ "pos": [
+ 58.77898406982422,
+ 2091.620361328125
+ ],
+ "size": [
+ 270,
+ 336
+ ],
+ "flags": {
+ "collapsed": true
+ },
+ "order": 8,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "image",
+ "type": "IMAGE",
+ "link": 67
+ },
+ {
+ "name": "mask",
+ "shape": 7,
+ "type": "MASK",
+ "link": null
+ },
+ {
+ "name": "width",
+ "type": "INT",
+ "widget": {
+ "name": "width"
+ },
+ "link": 65
+ },
+ {
+ "name": "height",
+ "type": "INT",
+ "widget": {
+ "name": "height"
+ },
+ "link": 66
+ }
+ ],
+ "outputs": [
+ {
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 69,
+ 70,
+ 72
+ ]
+ },
+ {
+ "name": "width",
+ "type": "INT",
+ "links": [
+ 83
+ ]
+ },
+ {
+ "name": "height",
+ "type": "INT",
+ "links": [
+ 84
+ ]
+ },
+ {
+ "name": "mask",
+ "type": "MASK",
+ "links": null
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfyui-kjnodes",
+ "ver": "fbdb08f9d6b0e821919d2c9f9caf8972ed439690",
+ "Node name for S&R": "ImageResizeKJv2",
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ 512,
+ 512,
+ "nearest-exact",
+ "resize",
+ "0, 0, 0",
+ "center",
+ 16,
+ "cpu",
+ "
| Output: | 1 x 1168 x 768 | 10.27MB |
"
+ ]
+ },
+ {
+ "id": 54,
+ "type": "EmptyLatentImage",
+ "pos": [
+ 961.21142578125,
+ 1594.0811767578125
+ ],
+ "size": [
+ 270,
+ 106
+ ],
+ "flags": {
+ "collapsed": true
+ },
+ "order": 11,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "width",
+ "type": "INT",
+ "widget": {
+ "name": "width"
+ },
+ "link": 83
+ },
+ {
+ "name": "height",
+ "type": "INT",
+ "widget": {
+ "name": "height"
+ },
+ "link": 84
+ }
+ ],
+ "outputs": [
+ {
+ "name": "LATENT",
+ "type": "LATENT",
+ "links": [
+ 82
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.60",
+ "Node name for S&R": "EmptyLatentImage",
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ 512,
+ 512,
+ 1
+ ]
+ },
+ {
+ "id": 51,
+ "type": "Seed (rgthree)",
+ "pos": [
+ 326.8143310546875,
+ 1458.4371337890625
+ ],
+ "size": [
+ 315,
+ 130
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "dir": 4,
+ "name": "SEED",
+ "shape": 3,
+ "type": "INT",
+ "links": [
+ 73
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "rgthree-comfy",
+ "ver": "c5ffa43de4ddb17244626a65a30700a05dd6b67d",
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ -1,
+ "",
+ "",
+ ""
+ ],
+ "color": "#323",
+ "bgcolor": "#535"
+ },
+ {
+ "id": 53,
+ "type": "LatentRotate",
+ "pos": [
+ 968.5299682617188,
+ 1475.108154296875
+ ],
+ "size": [
+ 270,
+ 58
+ ],
+ "flags": {},
+ "order": 15,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "samples",
+ "type": "LATENT",
+ "link": 82
+ }
+ ],
+ "outputs": [
+ {
+ "name": "LATENT",
+ "type": "LATENT",
+ "links": [
+ 85
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.60",
+ "Node name for S&R": "LatentRotate",
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ "none"
+ ]
+ },
+ {
+ "id": 42,
+ "type": "LoadImage",
+ "pos": [
+ -211.11781311035156,
+ 1683.4879150390625
+ ],
+ "size": [
+ 270.01904296875,
+ 326
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 67
+ ]
+ },
+ {
+ "name": "MASK",
+ "type": "MASK",
+ "links": null
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "LoadImage",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "image": true,
+ "upload": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ "00071.png",
+ "image"
+ ]
+ },
+ {
+ "id": 45,
+ "type": "Text Multiline",
+ "pos": [
+ 91.9709243774414,
+ 1683.4879150390625
+ ],
+ "size": [
+ 550.2879028320312,
+ 193.27035522460938
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "STRING",
+ "type": "STRING",
+ "links": [
+ 63
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "was-node-suite-comfyui",
+ "ver": "1.0.2",
+ "Node name for S&R": "Text Multiline",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "text": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ "Next Scene: Rotate the angle of the photo to an ultra-high angle shot (bird's eye view) of the subject, with the camera's point of view positioned far above and looking from side down."
+ ]
+ },
+ {
+ "id": 33,
+ "type": "TextEncodeQwenImageEditPlus",
+ "pos": [
+ 326.8143310546875,
+ 1416.2794189453125
+ ],
+ "size": [
+ 400,
+ 200
+ ],
+ "flags": {
+ "collapsed": true
+ },
+ "order": 14,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "clip",
+ "type": "CLIP",
+ "link": 92
+ },
+ {
+ "name": "vae",
+ "shape": 7,
+ "type": "VAE",
+ "link": 45
+ },
+ {
+ "name": "image1",
+ "shape": 7,
+ "type": "IMAGE",
+ "link": 72
+ },
+ {
+ "name": "image2",
+ "shape": 7,
+ "type": "IMAGE",
+ "link": null
+ },
+ {
+ "name": "image3",
+ "shape": 7,
+ "type": "IMAGE",
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "links": [
+ 55
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "TextEncodeQwenImageEditPlus",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "prompt": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ ""
+ ]
+ },
+ {
+ "id": 39,
+ "type": "ModelSamplingAuraFlow",
+ "pos": [
+ 326.8143310546875,
+ 1166.1168212890625
+ ],
+ "size": [
+ 270,
+ 58
+ ],
+ "flags": {},
+ "order": 12,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "model",
+ "type": "MODEL",
+ "link": 90
+ }
+ ],
+ "outputs": [
+ {
+ "name": "MODEL",
+ "type": "MODEL",
+ "links": [
+ 37
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "ModelSamplingAuraFlow",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "shift": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ 3
+ ]
+ },
+ {
+ "id": 35,
+ "type": "TextEncodeQwenImageEditPlus",
+ "pos": [
+ 326.8143310546875,
+ 1376.9676513671875
+ ],
+ "size": [
+ 400,
+ 200
+ ],
+ "flags": {
+ "collapsed": true
+ },
+ "order": 13,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "clip",
+ "type": "CLIP",
+ "link": 91
+ },
+ {
+ "name": "vae",
+ "shape": 7,
+ "type": "VAE",
+ "link": 49
+ },
+ {
+ "name": "image1",
+ "shape": 7,
+ "type": "IMAGE",
+ "link": 70
+ },
+ {
+ "name": "image2",
+ "shape": 7,
+ "type": "IMAGE",
+ "link": null
+ },
+ {
+ "name": "image3",
+ "shape": 7,
+ "type": "IMAGE",
+ "link": null
+ },
+ {
+ "name": "prompt",
+ "type": "STRING",
+ "widget": {
+ "name": "prompt"
+ },
+ "link": 63
+ }
+ ],
+ "outputs": [
+ {
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "links": [
+ 54
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "TextEncodeQwenImageEditPlus",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "prompt": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {
+ "prompt": true
+ }
+ },
+ "widgets_values": [
+ "make them hug each other "
+ ]
+ },
+ {
+ "id": 29,
+ "type": "PreviewImage",
+ "pos": [
+ 234.8931884765625,
+ 1910.397705078125
+ ],
+ "size": [
+ 402.4670715332031,
+ 323.59783935546875
+ ],
+ "flags": {},
+ "order": 10,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "images",
+ "type": "IMAGE",
+ "link": 69
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "PreviewImage",
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 59,
+ "type": "PowerLoraLoaderV2",
+ "pos": [
+ -212.9727325439453,
+ 1166.1168212890625
+ ],
+ "size": [
+ 517.6498413085938,
+ 365.52618408203125
+ ],
+ "flags": {},
+ "order": 9,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "model",
+ "type": "MODEL",
+ "link": 88
+ },
+ {
+ "name": "model_low",
+ "shape": 7,
+ "type": "MODEL",
+ "link": null
+ },
+ {
+ "name": "clip",
+ "shape": 7,
+ "type": "CLIP",
+ "link": 89
+ }
+ ],
+ "outputs": [
+ {
+ "name": "MODEL",
+ "type": "MODEL",
+ "links": [
+ 90
+ ]
+ },
+ {
+ "name": "MODEL_LOW",
+ "type": "MODEL",
+ "links": null
+ },
+ {
+ "name": "CLIP",
+ "type": "CLIP",
+ "links": [
+ 91,
+ 92
+ ]
+ }
+ ],
+ "properties": {
+ "widget_ue_connectable": {},
+ "Node name for S&R": "PowerLoraLoaderV2",
+ "Show Strengths": "Single Strength"
+ },
+ "widgets_values": [
+ {},
+ {},
+ {
+ "type": "PowerLoraLoaderHeaderWidget"
+ },
+ {
+ "on": true,
+ "lora": "qwen\\Qwen-Image-Edit-2509-Lightning-8steps-V1.0-fp32.safetensors",
+ "strength": 1,
+ "low_strength": 1,
+ "is_low": false,
+ "low_variant_name": null
+ },
+ {
+ "on": true,
+ "lora": "qwen\\next-scene_lora_v1-3000.safetensors",
+ "strength": 1,
+ "low_strength": 1,
+ "is_low": false,
+ "low_variant_name": null
+ },
+ {},
+ "",
+ "{}"
+ ],
+ "hasClip": true
+ },
+ {
+ "id": 40,
+ "type": "UNETLoader",
+ "pos": [
+ -212.9727325439453,
+ 1029.8311767578125
+ ],
+ "size": [
+ 442.7705383300781,
+ 89.98434448242188
+ ],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "MODEL",
+ "type": "MODEL",
+ "links": [
+ 88
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "UNETLoader",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "unet_name": true,
+ "weight_dtype": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ "Qwen-Image-Edit-2509_fp8_e4m3fn.safetensors",
+ "fp8_e4m3fn"
+ ]
+ },
+ {
+ "id": 37,
+ "type": "CLIPLoader",
+ "pos": [
+ -211.8653106689453,
+ 869.1613159179688
+ ],
+ "size": [
+ 436.9512939453125,
+ 110.98419952392578
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "CLIP",
+ "type": "CLIP",
+ "links": [
+ 89
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "CLIPLoader",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "clip_name": true,
+ "type": true,
+ "device": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ "qwen_2.5_vl_7b_fp8_scaled.safetensors",
+ "qwen_image",
+ "default"
+ ]
+ },
+ {
+ "id": 55,
+ "type": "Reroute",
+ "pos": [
+ 1044.2806396484375,
+ 1263.9898681640625
+ ],
+ "size": [
+ 75,
+ 26
+ ],
+ "flags": {},
+ "order": 17,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "",
+ "type": "*",
+ "link": 85
+ }
+ ],
+ "outputs": [
+ {
+ "name": "",
+ "type": "LATENT",
+ "links": [
+ 87
+ ]
+ }
+ ],
+ "properties": {
+ "showOutputText": false,
+ "horizontal": false,
+ "widget_ue_connectable": {}
+ }
+ },
+ {
+ "id": 38,
+ "type": "KSampler",
+ "pos": [
+ 663.2356567382812,
+ 1108.53515625
+ ],
+ "size": [
+ 270,
+ 474
+ ],
+ "flags": {
+ "collapsed": false
+ },
+ "order": 18,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "model",
+ "type": "MODEL",
+ "link": 53
+ },
+ {
+ "name": "positive",
+ "type": "CONDITIONING",
+ "link": 54
+ },
+ {
+ "name": "negative",
+ "type": "CONDITIONING",
+ "link": 55
+ },
+ {
+ "name": "latent_image",
+ "type": "LATENT",
+ "link": 87
+ },
+ {
+ "name": "seed",
+ "type": "INT",
+ "widget": {
+ "name": "seed"
+ },
+ "link": 73
+ }
+ ],
+ "outputs": [
+ {
+ "name": "LATENT",
+ "type": "LATENT",
+ "links": [
+ 35
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.59",
+ "Node name for S&R": "KSampler",
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "seed": true,
+ "steps": true,
+ "cfg": true,
+ "sampler_name": true,
+ "scheduler": true,
+ "denoise": true
+ },
+ "version": "7.1",
+ "input_ue_unconnectable": {}
+ },
+ "widget_ue_connectable": {}
+ },
+ "widgets_values": [
+ 117862694520320,
+ "increment",
+ 8,
+ 1,
+ "euler",
+ "simple",
+ 1
+ ]
+ }
+ ],
+ "links": [
+ [
+ 35,
+ 38,
+ 0,
+ 26,
+ 0,
+ "LATENT"
+ ],
+ [
+ 36,
+ 34,
+ 0,
+ 26,
+ 1,
+ "VAE"
+ ],
+ [
+ 37,
+ 39,
+ 0,
+ 27,
+ 0,
+ "MODEL"
+ ],
+ [
+ 45,
+ 34,
+ 0,
+ 33,
+ 1,
+ "VAE"
+ ],
+ [
+ 49,
+ 34,
+ 0,
+ 35,
+ 1,
+ "VAE"
+ ],
+ [
+ 53,
+ 27,
+ 0,
+ 38,
+ 0,
+ "MODEL"
+ ],
+ [
+ 54,
+ 35,
+ 0,
+ 38,
+ 1,
+ "CONDITIONING"
+ ],
+ [
+ 55,
+ 33,
+ 0,
+ 38,
+ 2,
+ "CONDITIONING"
+ ],
+ [
+ 63,
+ 45,
+ 0,
+ 35,
+ 5,
+ "STRING"
+ ],
+ [
+ 64,
+ 26,
+ 0,
+ 48,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 65,
+ 50,
+ 0,
+ 49,
+ 2,
+ "INT"
+ ],
+ [
+ 66,
+ 50,
+ 0,
+ 49,
+ 3,
+ "INT"
+ ],
+ [
+ 67,
+ 42,
+ 0,
+ 49,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 69,
+ 49,
+ 0,
+ 29,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 70,
+ 49,
+ 0,
+ 35,
+ 2,
+ "IMAGE"
+ ],
+ [
+ 72,
+ 49,
+ 0,
+ 33,
+ 2,
+ "IMAGE"
+ ],
+ [
+ 73,
+ 51,
+ 0,
+ 38,
+ 4,
+ "INT"
+ ],
+ [
+ 82,
+ 54,
+ 0,
+ 53,
+ 0,
+ "LATENT"
+ ],
+ [
+ 83,
+ 49,
+ 1,
+ 54,
+ 0,
+ "INT"
+ ],
+ [
+ 84,
+ 49,
+ 2,
+ 54,
+ 1,
+ "INT"
+ ],
+ [
+ 85,
+ 53,
+ 0,
+ 55,
+ 0,
+ "*"
+ ],
+ [
+ 87,
+ 55,
+ 0,
+ 38,
+ 3,
+ "LATENT"
+ ],
+ [
+ 88,
+ 40,
+ 0,
+ 59,
+ 0,
+ "MODEL"
+ ],
+ [
+ 89,
+ 37,
+ 0,
+ 59,
+ 2,
+ "CLIP"
+ ],
+ [
+ 90,
+ 59,
+ 0,
+ 39,
+ 0,
+ "MODEL"
+ ],
+ [
+ 91,
+ 59,
+ 2,
+ 35,
+ 0,
+ "CLIP"
+ ],
+ [
+ 92,
+ 59,
+ 2,
+ 33,
+ 0,
+ "CLIP"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "ds": {
+ "scale": 0.6567688489155116,
+ "offset": [
+ 559.458831190264,
+ -788.607948823826
+ ]
+ },
+ "ue_links": [],
+ "links_added_by_ue": [],
+ "frontendVersion": "1.25.11",
+ "VHS_latentpreview": false,
+ "VHS_latentpreviewrate": 0,
+ "VHS_MetadataImage": true,
+ "VHS_KeepIntermediate": true
+ },
+ "version": 0.4
+}
\ No newline at end of file
diff --git a/nodes/power_loras.py b/nodes/power_loras.py
index 0d61bf5..483abfc 100644
--- a/nodes/power_loras.py
+++ b/nodes/power_loras.py
@@ -140,7 +140,9 @@ class WanVideoPowerLoraLoader:
"prev_lora": ("WANVIDLORA", {"default": None, "tooltip": "For loading multiple LoRAs"}),
"blocks": ("SELECTEDBLOCKS", {"tooltip": "Block selection for lora application"}),
}),
- "hidden": {},
+ "hidden": {
+ "ui_state": ("STRING", {"default": "{}"}),
+ },
}
RETURN_TYPES = ("WANVIDLORA", "WANVIDLORA")
@@ -308,7 +310,9 @@ class PowerLoraLoaderV2:
"model_low": ("MODEL",),
"clip": ("CLIP",),
}),
- "hidden": {},
+ "hidden": {
+ "ui_state": ("STRING", {"default": "{}"}),
+ },
}
RETURN_TYPES = ("MODEL", "MODEL", "CLIP")
diff --git a/utility/rgthree_api/routes_model_info.py b/utility/rgthree_api/routes_model_info.py
new file mode 100644
index 0000000..9a09068
--- /dev/null
+++ b/utility/rgthree_api/routes_model_info.py
@@ -0,0 +1,470 @@
+import os
+import json
+from aiohttp import web
+
+from server import PromptServer
+import folder_paths
+
+from .utils import path_exists
+from .utils_server import get_param, is_param_falsy
+from .utils_info import delete_model_info, get_model_info, set_model_info_partial, get_file_info
+
+routes = PromptServer.instance.routes
+
+
+def _check_valid_model_type(request):
+ model_type = request.match_info['type']
+ if model_type not in ['loras', 'checkpoints']:
+ return web.json_response({'status': 404, 'error': f'Invalid model type: {model_type}'})
+ return None
+
+
+@routes.get('/wanvid/api/{type}')
+async def api_get_models_list(request):
+ """Returns a list of model types from user configuration.
+
+ By default, a list of filenames are provided. If `format=details` is specified, a list of objects
+ with additional _file info_ is provided. This includes modigied time, hasInfoFile, and imageLocal
+ among others.
+ """
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ model_type = request.match_info['type']
+ files = folder_paths.get_filename_list(model_type)
+ format_param = get_param(request, 'format')
+ if format_param == 'details':
+ response = []
+ for file in files:
+ response.append(get_file_info(file, model_type))
+ return web.json_response(response)
+
+ return web.json_response(list(files))
+
+
+@routes.get('/wanvid/api/{type}/info')
+async def api_get_models_info(request):
+ """Returns a list model info; either all or a specific ones if provided a 'files' param.
+
+ If a `light` param is specified and not falsy, no metadata will be fetched.
+ """
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ model_type = request.match_info['type']
+ files_param = get_param(request, 'files')
+ maybe_fetch_metadata = files_param is not None
+ if not is_param_falsy(request, 'light'):
+ maybe_fetch_metadata = False
+ api_response = await models_info_response(
+ request, model_type, maybe_fetch_metadata=maybe_fetch_metadata
+ )
+ return web.json_response(api_response)
+
+
+@routes.get('/wanvid/api/{type}/info/refresh')
+async def api_get_refresh_get_models_info(request):
+ """Refreshes model info; either all or specific ones if provided a 'files' param. """
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ model_type = request.match_info['type']
+ api_response = await models_info_response(
+ request, model_type, maybe_fetch_civitai=True, maybe_fetch_metadata=True
+ )
+ return web.json_response(api_response)
+
+
+@routes.get('/wanvid/api/{type}/info/clear')
+async def api_get_delete_model_info(request):
+ """Clears model info from the filesystem for specific files only - no bulk clearing."""
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ api_response = {'status': 200}
+ model_type = request.match_info['type']
+ files_param = get_param(request, 'files')
+
+ # Always require files parameter - no bulk clearing
+ if not files_param:
+ api_response['status'] = '400'
+ api_response['error'] = 'Missing required parameter: files'
+ return api_response
+
+ files_param = files_param.split(',')
+ del_info = not is_param_falsy(request, 'del_info')
+ del_metadata = not is_param_falsy(request, 'del_metadata')
+ del_civitai = not is_param_falsy(request, 'del_civitai')
+
+ # Validate that files exist
+ all_files = folder_paths.get_filename_list(model_type)
+ valid_files = []
+ for file_param in files_param:
+ if file_param in all_files:
+ valid_files.append(file_param)
+ else:
+ print(f"[Warning] File not found for clearing: {file_param}")
+
+ if not valid_files:
+ api_response['status'] = '404'
+ api_response['error'] = 'No valid files found'
+ return api_response
+
+ # Clear info for only the specified files
+ for file_param in valid_files:
+ await delete_model_info(
+ file_param,
+ model_type,
+ del_info=del_info,
+ del_metadata=del_metadata,
+ del_civitai=del_civitai
+ )
+ return web.json_response(api_response)
+
+
+@routes.post('/wanvid/api/{type}/info')
+async def api_post_save_model_data(request):
+ """Saves data to a model by name. """
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ model_type = request.match_info['type']
+ api_response = {'status': 200}
+ file_param = get_param(request, 'file')
+ if file_param is None:
+ api_response['status'] = '404'
+ api_response['error'] = 'No model found at path'
+ else:
+ post = await request.post()
+ await set_model_info_partial(file_param, model_type, json.loads(post.get("json")))
+ info_data = await get_model_info(file_param, model_type)
+ api_response['data'] = info_data
+ return web.json_response(api_response)
+
+
+@routes.get('/wanvid/api/{type}/img')
+async def api_get_models_info_img(request):
+ """ Returns an image response if one exists for the model. """
+ if _check_valid_model_type(request):
+ return _check_valid_model_type(request)
+
+ model_type = request.match_info['type']
+ file_param = get_param(request, 'file')
+ file_path = folder_paths.get_full_path(model_type, file_param)
+ if not path_exists(file_path):
+ file_path = os.path.abspath(file_path)
+
+ img_path = None
+ for ext in ['jpg', 'png', 'jpeg']:
+ try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
+ if path_exists(try_path):
+ img_path = try_path
+ break
+
+ if not path_exists(img_path):
+ api_response = {}
+ api_response['status'] = '404'
+ api_response['error'] = 'No model found at path'
+ return web.json_response(api_response)
+
+ return web.FileResponse(img_path)
+
+
+@routes.get('/wanvid/api/loras/preview')
+async def api_get_lora_preview_image(request):
+ """Returns a preview image from the _power_preview directory."""
+ api_response = {'status': 200}
+
+ try:
+ # Get parameters
+ file_param = get_param(request, 'file')
+ subfolder_param = get_param(request, 'subfolder', '')
+ suffix_param = get_param(request, 'suffix', '') # Optional suffix parameter
+
+ if not file_param:
+ api_response['status'] = '400'
+ api_response['error'] = 'Missing required parameter: file'
+ return web.json_response(api_response)
+
+ # Security: prevent directory traversal
+ if '..' in file_param or '..' in subfolder_param:
+ api_response['status'] = '400'
+ api_response['error'] = 'Invalid file path'
+ return web.json_response(api_response)
+
+ # Get loras directory
+ loras_dir = folder_paths.get_folder_paths('loras')[0]
+
+ # Create _power_preview directory structure (same as save function)
+ power_preview_dir = os.path.join(loras_dir, '_power_preview')
+
+ # Construct the full path
+ if subfolder_param:
+ # Remove leading/trailing slashes from subfolder
+ subfolder_param = subfolder_param.strip('/\\')
+ search_dir = os.path.join(power_preview_dir, subfolder_param)
+ else:
+ search_dir = power_preview_dir
+
+ # Try different image extensions
+ img_path = None
+
+ # If suffix is specified, look for that specific file
+ if suffix_param:
+ for ext in ['jpg', 'jpeg', 'png']:
+ try_path = os.path.join(search_dir, f"{file_param}{suffix_param}.{ext}")
+ if path_exists(try_path):
+ img_path = try_path
+ break
+ else:
+ # Try suffixed images first (_01.jpg, _02.jpg, _03.jpg)
+ for i in range(1, 4): # Check for _01, _02, _03
+ suffix = f"_{i:02d}"
+ for ext in ['jpg', 'jpeg', 'png']:
+ try_path = os.path.join(search_dir, f"{file_param}{suffix}.{ext}")
+ if path_exists(try_path):
+ img_path = try_path
+ break
+ if img_path:
+ break
+
+ # If no suffixed images found, try original image (backward compatibility)
+ if not img_path:
+ for ext in ['jpg', 'jpeg', 'png']:
+ try_path = os.path.join(search_dir, f"{file_param}.{ext}")
+ if path_exists(try_path):
+ img_path = try_path
+ break
+
+ if not img_path:
+ api_response['status'] = '404'
+ api_response['error'] = 'Preview image not found'
+ return web.json_response(api_response)
+
+ # Security: ensure the file is within the power_preview directory
+ if not os.path.abspath(img_path).startswith(os.path.abspath(power_preview_dir)):
+ api_response['status'] = '403'
+ api_response['error'] = 'Access denied'
+ return web.json_response(api_response)
+
+ return web.FileResponse(img_path)
+
+ except Exception as e:
+ api_response['status'] = '500'
+ api_response['error'] = f'Server error: {str(e)}'
+ return web.json_response(api_response)
+
+
+@routes.get('/wanvid/api/loras/previews')
+async def api_get_lora_previews_list(request):
+ """Returns a list of LoRAs that have preview images available."""
+ api_response = {'status': 200, 'previews': []}
+
+ try:
+ # Get loras directory
+ loras_dir = folder_paths.get_folder_paths('loras')[0]
+ power_preview_dir = os.path.join(loras_dir, '_power_preview')
+
+ if not path_exists(power_preview_dir):
+ return web.json_response(api_response)
+
+ # Get all LoRA files
+ lora_files = folder_paths.get_filename_list('loras')
+
+ # Check each LoRA for preview images
+ for lora_file in lora_files:
+ lora_name = lora_file
+ # Remove extension
+ for ext in ['.safetensors', '.pt', '.ckpt']:
+ if lora_name.endswith(ext):
+ lora_name = lora_name[:-len(ext)]
+ break
+
+ # Check for preview in various locations
+ preview_found = False
+ preview_paths = []
+
+ # Check for suffixed preview images first (_01.jpg, _02.jpg, _03.jpg)
+ found_suffixes = []
+ for i in range(1, 4): # Check for _01, _02, _03
+ suffix = f"_{i:02d}"
+
+ # Check root _power_preview directory
+ for img_ext in ['jpg', 'jpeg', 'png']:
+ preview_path = os.path.join(power_preview_dir, f"{lora_name}{suffix}.{img_ext}")
+ if path_exists(preview_path):
+ preview_found = True
+ preview_paths.append(f"_power_preview/{lora_name}{suffix}.{img_ext}")
+ found_suffixes.append(suffix)
+ break
+
+ # Check subfolders
+ path_parts = lora_name.replace('/', os.sep).replace('\\', os.sep).split(os.sep)
+ if len(path_parts) > 1:
+ subfolder = os.sep.join(path_parts[:-1])
+ filename = path_parts[-1]
+
+ subfolder_path = os.path.join(power_preview_dir, subfolder)
+ if path_exists(subfolder_path):
+ for img_ext in ['jpg', 'jpeg', 'png']:
+ preview_path = os.path.join(subfolder_path, f"{filename}{suffix}.{img_ext}")
+ if path_exists(preview_path):
+ preview_found = True
+ preview_paths.append(f"_power_preview/{subfolder}/{filename}{suffix}.{img_ext}")
+ found_suffixes.append(suffix)
+ break
+
+ # If no suffixed images found, check for single image (backward compatibility)
+ if not found_suffixes:
+ # Check root _power_preview directory
+ for img_ext in ['jpg', 'jpeg', 'png']:
+ preview_path = os.path.join(power_preview_dir, f"{lora_name}.{img_ext}")
+ if path_exists(preview_path):
+ preview_found = True
+ preview_paths.append(f"_power_preview/{lora_name}.{img_ext}")
+ break
+
+ # Check subfolders if not found in root
+ if not preview_found:
+ # Look for subfolder structure
+ path_parts = lora_name.replace('/', os.sep).replace('\\', os.sep).split(os.sep)
+ if len(path_parts) > 1:
+ subfolder = os.sep.join(path_parts[:-1])
+ filename = path_parts[-1]
+
+ subfolder_path = os.path.join(power_preview_dir, subfolder)
+ if path_exists(subfolder_path):
+ for img_ext in ['jpg', 'jpeg', 'png']:
+ preview_path = os.path.join(subfolder_path, f"{filename}.{img_ext}")
+ if path_exists(preview_path):
+ preview_found = True
+ preview_paths.append(f"_power_preview/{subfolder}/{filename}.{img_ext}")
+ break
+
+ if preview_found:
+ api_response['previews'].append({
+ 'lora': lora_file,
+ 'preview_paths': preview_paths
+ })
+
+ return web.json_response(api_response)
+
+ except Exception as e:
+ api_response['status'] = '500'
+ api_response['error'] = f'Server error: {str(e)}'
+ return web.json_response(api_response)
+
+
+@routes.post('/wanvid/api/lora/preview-image')
+async def api_save_lora_preview_image(request):
+ """Save a preview image for a LoRA in the _power_preview directory."""
+ import io
+ from PIL import Image
+
+ api_response = {'status': 200, 'message': 'Preview image saved successfully'}
+
+ try:
+ # Get form data
+ post = await request.post()
+ image_file = post.get('image')
+ lora_name = post.get('lora_name')
+ lora_path = post.get('lora_path', '')
+ suffix = post.get('suffix', '')
+
+ if not image_file or not lora_name:
+ api_response['status'] = '400'
+ api_response['error'] = 'Missing required parameters: image and lora_name'
+ return web.json_response(api_response)
+
+ # Get loras directory
+ loras_dir = folder_paths.get_folder_paths('loras')[0]
+
+ # Create _power_preview directory structure
+ power_preview_dir = os.path.join(loras_dir, '_power_preview')
+ if lora_path:
+ save_dir = os.path.join(power_preview_dir, lora_path)
+ else:
+ save_dir = power_preview_dir
+
+ # Ensure directory exists
+ os.makedirs(save_dir, exist_ok=True)
+
+ # Construct save path with optional suffix
+ filename = f"{lora_name}{suffix}.jpg"
+ save_path = os.path.join(save_dir, filename)
+
+ # Read image data
+ image_data = image_file.file.read()
+
+ # Open with PIL, process, and save
+ with Image.open(io.BytesIO(image_data)) as img:
+ # Convert to RGB if necessary (for JPEG)
+ if img.mode in ('RGBA', 'LA', 'P'):
+ # Create white background for transparency
+ background = Image.new('RGB', img.size, (255, 255, 255))
+ if img.mode == 'P':
+ img = img.convert('RGBA')
+ if img.mode in ('RGBA', 'LA'):
+ background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
+ img = background
+ else:
+ img = img.convert('RGB')
+ elif img.mode != 'RGB':
+ img = img.convert('RGB')
+
+ # Save as JPEG with high quality
+ img.save(save_path, 'JPEG', quality=90, optimize=True)
+
+ api_response['save_path'] = os.path.relpath(save_path, loras_dir)
+ api_response['file_size'] = os.path.getsize(save_path)
+ api_response['filename'] = filename
+
+ except Exception as e:
+ api_response['status'] = '500'
+ api_response['error'] = f'Failed to save preview image: {str(e)}'
+ print(f"[Preview Image Error] {str(e)}")
+
+ return web.json_response(api_response)
+
+
+async def models_info_response(
+ request, model_type, maybe_fetch_civitai=False, maybe_fetch_metadata=False
+):
+ """Gets model info for specific files only - no bulk processing allowed."""
+ api_response = {'status': 200, 'data': []}
+ light = not is_param_falsy(request, 'light')
+ files_param = get_param(request, 'files')
+
+ # Always require files parameter - no bulk processing
+ if not files_param:
+ api_response['status'] = '400'
+ api_response['error'] = 'Missing required parameter: files'
+ return api_response
+
+ files_param = files_param.split(',')
+
+ # Validate that files exist
+ all_files = folder_paths.get_filename_list(model_type)
+ valid_files = []
+ for file_param in files_param:
+ if file_param in all_files:
+ valid_files.append(file_param)
+ else:
+ print(f"[Warning] File not found: {file_param}")
+
+ if not valid_files:
+ api_response['status'] = '404'
+ api_response['error'] = 'No valid files found'
+ return api_response
+
+ # Process only the specified files
+ for file_param in valid_files:
+ info_data = await get_model_info(
+ file_param,
+ model_type,
+ maybe_fetch_civitai=maybe_fetch_civitai,
+ maybe_fetch_metadata=maybe_fetch_metadata,
+ light=light
+ )
+ api_response['data'].append(info_data)
+ return api_response
diff --git a/utility/rgthree_api/utils.py b/utility/rgthree_api/utils.py
new file mode 100644
index 0000000..bd876e7
--- /dev/null
+++ b/utility/rgthree_api/utils.py
@@ -0,0 +1,162 @@
+import json
+import os
+import re
+
+from typing import Union
+
+
+class AnyType(str):
+ """A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
+
+ def __ne__(self, __value: object) -> bool:
+ return False
+
+
+class FlexibleOptionalInputType(dict):
+ """A special class to make flexible nodes that pass data to our python handlers.
+
+ Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
+ (like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc).
+
+ Initially, ComfyUI only needed to return True for `__contains__` below, which told ComfyUI that
+ our node will handle the input, regardless of what it is.
+
+ However, after https://github.com/comfyanonymous/ComfyUI/pull/2666 ComdyUI's execution changed
+ also checking the data for the key; specifcially, the type which is the first tuple entry. This
+ type is supplied to our FlexibleOptionalInputType and returned for any non-data key. This can be a
+ real type, or use the AnyType for additional flexibility.
+ """
+
+ def __init__(self, type, data: Union[dict, None] = None):
+ """Initializes the FlexibleOptionalInputType.
+
+ Args:
+ type: The flexible type to use when ComfyUI retrieves an unknown key (via `__getitem__`).
+ data: An optional dict to use as the basis. This is stored both in a `data` attribute, so we
+ can look it up without hitting our overrides, as well as iterated over and adding its key
+ and values to our `self` keys. This way, when looked at, we will appear to represent this
+ data. When used in an "optional" INPUT_TYPES, these are the starting optional node types.
+ """
+ self.type = type
+ self.data = data
+ if self.data is not None:
+ for k, v in self.data.items():
+ self[k] = v
+
+ def __getitem__(self, key):
+ # If we have this key in the initial data, then return it. Otherwise return the tuple with our
+ # flexible type.
+ if self.data is not None and key in self.data:
+ val = self.data[key]
+ return val
+ return (self.type,)
+
+ def __contains__(self, key):
+ """Always contain a key, and we'll always return the tuple above when asked for it."""
+ return True
+
+
+any_type = AnyType("*")
+
+
+def is_dict_value_falsy(data: dict, dict_key: str):
+ """Checks if a dict value is falsy."""
+ val = get_dict_value(data, dict_key)
+ return not val
+
+
+def get_dict_value(data: dict, dict_key: str, default=None):
+ """Gets a deeply nested value given a dot-delimited key."""
+ keys = dict_key.split('.')
+ key = keys.pop(0) if len(keys) > 0 else None
+ found = data[key] if key in data else None
+ if found is not None and len(keys) > 0:
+ return get_dict_value(found, '.'.join(keys), default)
+ return found if found is not None else default
+
+
+def set_dict_value(data: dict, dict_key: str, value, create_missing_objects=True):
+ """Sets a deeply nested value given a dot-delimited key."""
+ keys = dict_key.split('.')
+ key = keys.pop(0) if len(keys) > 0 else None
+ if key not in data:
+ if create_missing_objects is False:
+ return data
+ data[key] = {}
+ if len(keys) == 0:
+ data[key] = value
+ else:
+ set_dict_value(data[key], '.'.join(keys), value, create_missing_objects)
+
+ return data
+
+
+def dict_has_key(data: dict, dict_key):
+ """Checks if a dict has a deeply nested dot-delimited key."""
+ keys = dict_key.split('.')
+ key = keys.pop(0) if len(keys) > 0 else None
+ if key is None or key not in data:
+ return False
+ if len(keys) == 0:
+ return True
+ return dict_has_key(data[key], '.'.join(keys))
+
+
+def load_json_file(file: str, default=None):
+ """Reads a json file and returns the json dict, stripping out "//" comments first."""
+ if path_exists(file):
+ with open(file, 'r', encoding='UTF-8') as file:
+ config = file.read()
+ try:
+ return json.loads(config)
+ except json.decoder.JSONDecodeError:
+ try:
+ config = re.sub(r"^\s*//\s.*", "", config, flags=re.MULTILINE)
+ return json.loads(config)
+ except json.decoder.JSONDecodeError:
+ try:
+ config = re.sub(r"(?:^|\s)//.*", "", config, flags=re.MULTILINE)
+ return json.loads(config)
+ except json.decoder.JSONDecodeError:
+ pass
+ return default
+
+
+def save_json_file(file_path: str, data: dict):
+ """Saves a json file."""
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
+ with open(file_path, 'w+', encoding='UTF-8') as file:
+ json.dump(data, file, sort_keys=False, indent=2, separators=(",", ": "))
+
+
+def path_exists(path):
+ """Checks if a path exists, accepting None type."""
+ if path is not None:
+ return os.path.exists(path)
+ return False
+
+
+def file_exists(path):
+ """Checks if a file exists, accepting None type."""
+ if path is not None:
+ return os.path.isfile(path)
+ return False
+
+
+def remove_path(path):
+ """Removes a path, if it exists."""
+ if path_exists(path):
+ os.remove(path)
+ return True
+ return False
+
+
+class ByPassTypeTuple(tuple):
+ """A special class that will return additional "AnyType" strings beyond defined values.
+ Credit to Trung0246
+ """
+
+ def __getitem__(self, index):
+ if index > len(self) - 1:
+ return AnyType("*")
+ return super().__getitem__(index)
diff --git a/utility/rgthree_api/utils_info.py b/utility/rgthree_api/utils_info.py
new file mode 100644
index 0000000..1e730bb
--- /dev/null
+++ b/utility/rgthree_api/utils_info.py
@@ -0,0 +1,473 @@
+import hashlib
+import json
+import os
+import re
+from datetime import datetime
+
+import requests
+
+from server import PromptServer
+import folder_paths
+
+from .utils import get_dict_value, load_json_file, file_exists, remove_path, save_json_file
+from .utils_userdata import read_userdata_json, save_userdata_json, delete_userdata_file
+
+
+def _get_info_cache_file(data_type: str, file_hash: str):
+ return f'info/{file_hash}.{data_type}.json'
+
+
+async def delete_model_info(
+ file: str, model_type, del_info=True, del_metadata=True, del_civitai=True
+):
+ """Delete the info json, and the civitai & metadata caches."""
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return
+ if del_info:
+ remove_path(get_info_file(file_path))
+ if del_civitai or del_metadata:
+ file_hash = _get_sha256_hash(file_path)
+ if del_civitai:
+ json_file_path = _get_info_cache_file(file_hash, 'civitai')
+ delete_userdata_file(json_file_path)
+ if del_metadata:
+ json_file_path = _get_info_cache_file(file_hash, 'metadata')
+ delete_userdata_file(json_file_path)
+
+
+def get_file_info(file: str, model_type):
+ """Gets basic file info, like created or modified date."""
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return None
+ return {
+ 'file': file,
+ 'path': file_path,
+ 'modified': os.path.getmtime(file_path) * 1000, # millis
+ 'imageLocal': f'/wanvid/api/{model_type}/img?file={file}' if get_img_file(file_path) else None,
+ 'hasInfoFile': get_info_file(file_path) is not None,
+ }
+
+
+def get_info_file(file_path: str, force=False):
+ """ Returns the path to the info file.
+ Saves JSON files with lora name in _power_preview folder inside loras.
+ """
+ loras_dir = folder_paths.get_folder_paths('loras')[0]
+
+ # Create a mirrored folder structure in loras/_power_preview
+ power_preview_dir = os.path.join(loras_dir, '_power_preview')
+
+ relative_path = os.path.relpath(file_path, loras_dir)
+ # Get the filename without extension and change to .json
+ filename_without_ext = os.path.splitext(os.path.basename(relative_path))[0]
+ relative_dir = os.path.dirname(relative_path)
+
+ if relative_dir:
+ info_path = os.path.join(power_preview_dir, relative_dir, f'{filename_without_ext}.json')
+ else:
+ info_path = os.path.join(power_preview_dir, f'{filename_without_ext}.json')
+
+ # Create the directory if it doesn't exist
+ info_dir = os.path.dirname(info_path)
+ if not os.path.exists(info_dir):
+ os.makedirs(info_dir)
+
+ return info_path if file_exists(info_path) or force else None
+
+
+def get_img_file(file_path: str, force=False):
+ for ext in ['jpg', 'png', 'jpeg', 'webp']:
+ try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
+ if file_exists(try_path):
+ return try_path
+
+
+def get_model_info_file_data(file: str, model_type, default=None):
+ """Returns the data from the info file, or a default value if it doesn't exist."""
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return default
+ return load_json_file(get_info_file(file_path), default=default)
+
+
+async def get_model_info(
+ file: str,
+ model_type,
+ default=None,
+ maybe_fetch_civitai=False,
+ force_fetch_civitai=False,
+ maybe_fetch_metadata=False,
+ force_fetch_metadata=False,
+ light=False
+):
+ """Compiles a model info given a stored file next to the model, and/or metadata/civitai."""
+
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return default
+
+ should_save = False
+ # basic data
+ basic_data = get_file_info(file, model_type)
+ # Try to load a rgthree-info.json file next to the file.
+ info_data = get_model_info_file_data(file, model_type, default={})
+
+ for key in ['file', 'path', 'modified', 'imageLocal', 'hasInfoFile']:
+ if key in basic_data and basic_data[key] and (
+ key not in info_data or info_data[key] != basic_data[key]
+ ):
+ info_data[key] = basic_data[key]
+ should_save = True
+
+ # Check if we have an image next to the file and, if so, add it to the front of the images
+ # (if it isn't already).
+ img_next_to_file = basic_data['imageLocal']
+
+ if 'images' not in info_data:
+ info_data['images'] = []
+ should_save = True
+
+ if img_next_to_file:
+ if len(info_data['images']) == 0 or info_data['images'][0]['url'] != img_next_to_file:
+ info_data['images'].insert(0, {'url': img_next_to_file})
+ should_save = True
+
+ # If we just want light data then bail now with just existing data, plus file, path and img if
+ # next to the file.
+ if light and not maybe_fetch_metadata and not force_fetch_metadata and not maybe_fetch_civitai and not force_fetch_civitai:
+ return info_data
+
+ if 'raw' not in info_data:
+ info_data['raw'] = {}
+ should_save = True
+
+ should_save = _update_data(info_data) or should_save
+
+ should_fetch_civitai = force_fetch_civitai is True or (
+ maybe_fetch_civitai is True and 'civitai' not in info_data['raw']
+ )
+ should_fetch_metadata = force_fetch_metadata is True or (
+ maybe_fetch_metadata is True and 'metadata' not in info_data['raw']
+ )
+
+ if should_fetch_metadata:
+ data_meta = _get_model_metadata(file, model_type, default={}, refresh=force_fetch_metadata)
+ should_save = _merge_metadata(info_data, data_meta) or should_save
+
+ if should_fetch_civitai:
+ data_civitai = _get_model_civitai_data(
+ file, model_type, default={}, refresh=force_fetch_civitai
+ )
+ should_save = _merge_civitai_data(info_data, data_civitai) or should_save
+
+ if 'sha256' not in info_data:
+ file_hash = _get_sha256_hash(file_path)
+ if file_hash is not None:
+ info_data['sha256'] = file_hash
+ should_save = True
+
+ if should_save:
+ if 'trainedWords' in info_data:
+ # Sort by count; if it doesn't exist, then assume it's a top item from civitai or elsewhere.
+ info_data['trainedWords'] = sorted(
+ info_data['trainedWords'],
+ key=lambda w: w['count'] if 'count' in w else 99999,
+ reverse=True
+ )
+ save_model_info(file, info_data, model_type)
+
+ # If we're saving, then the UI is likely waiting to see if the refreshed data is coming in.
+ await PromptServer.instance.send(f"rgthree-refreshed-{model_type}-info", {"data": info_data})
+
+ return info_data
+
+
+def _update_data(info_data: dict) -> bool:
+ """Ports old data to new data if necessary."""
+ should_save = False
+ # If we have "triggerWords" then move them over to "trainedWords"
+ if 'triggerWords' in info_data and len(info_data['triggerWords']) > 0:
+ civitai_words = ','.join((
+ get_dict_value(info_data, 'raw.civitai.triggerWords', default=[]) +
+ get_dict_value(info_data, 'raw.civitai.trainedWords', default=[])
+ ))
+ if 'trainedWords' not in info_data:
+ info_data['trainedWords'] = []
+ for trigger_word in info_data['triggerWords']:
+ word_data = next((data for data in info_data['trainedWords'] if data['word'] == trigger_word),
+ None)
+ if word_data is None:
+ word_data = {'word': trigger_word}
+ info_data['trainedWords'].append(word_data)
+ if trigger_word in civitai_words:
+ word_data['civitai'] = True
+ else:
+ word_data['user'] = True
+
+ del info_data['triggerWords']
+ should_save = True
+ return should_save
+
+
+def _merge_metadata(info_data: dict, data_meta: dict) -> bool:
+ """Returns true if data was saved."""
+ should_save = False
+
+ base_model_file = get_dict_value(data_meta, 'ss_sd_model_name', None)
+ if base_model_file:
+ info_data['baseModelFile'] = base_model_file
+
+ # Loop over metadata tags
+ trained_words = {}
+ if 'ss_tag_frequency' in data_meta and isinstance(data_meta['ss_tag_frequency'], dict):
+ for bucket_value in data_meta['ss_tag_frequency'].values():
+ if isinstance(bucket_value, dict):
+ for tag, count in bucket_value.items():
+ if tag not in trained_words:
+ trained_words[tag] = {'word': tag, 'count': 0, 'metadata': True}
+ trained_words[tag]['count'] = trained_words[tag]['count'] + count
+
+ if 'trainedWords' not in info_data:
+ info_data['trainedWords'] = list(trained_words.values())
+ should_save = True
+ else:
+ # We can't merge, because the list may have other data, like it's part of civitaidata.
+ merged_dict = {}
+ for existing_word_data in info_data['trainedWords']:
+ merged_dict[existing_word_data['word']] = existing_word_data
+ for new_key, new_word_data in trained_words.items():
+ if new_key not in merged_dict:
+ merged_dict[new_key] = {}
+ merged_dict[new_key] = {**merged_dict[new_key], **new_word_data}
+ info_data['trainedWords'] = list(merged_dict.values())
+ should_save = True
+
+ # trained_words = list(trained_words.values())
+ # info_data['meta_trained_words'] = trained_words
+ info_data['raw']['metadata'] = data_meta
+ should_save = True
+
+ if 'sha256' not in info_data and '_sha256' in data_meta:
+ info_data['sha256'] = data_meta['_sha256']
+ should_save = True
+
+ return should_save
+
+
+def _merge_civitai_data(info_data: dict, data_civitai: dict) -> bool:
+ """Returns true if data was saved."""
+ should_save = False
+
+ if 'name' not in info_data:
+ info_data['name'] = get_dict_value(data_civitai, 'model.name', '')
+ should_save = True
+ version_name = get_dict_value(data_civitai, 'name')
+ if version_name is not None:
+ info_data['name'] += f' - {version_name}'
+
+ if 'type' not in info_data:
+ info_data['type'] = get_dict_value(data_civitai, 'model.type')
+ should_save = True
+ if 'baseModel' not in info_data:
+ info_data['baseModel'] = get_dict_value(data_civitai, 'baseModel')
+ should_save = True
+
+ # We always want to merge triggerword.
+ civitai_trigger = get_dict_value(data_civitai, 'triggerWords', default=[])
+ civitai_trained = get_dict_value(data_civitai, 'trainedWords', default=[])
+ civitai_words = ','.join(civitai_trigger + civitai_trained)
+ if civitai_words:
+ civitai_words = re.sub(r"\s*,\s*", ",", civitai_words)
+ civitai_words = re.sub(r",+", ",", civitai_words)
+ civitai_words = re.sub(r"^,", "", civitai_words)
+ civitai_words = re.sub(r",$", "", civitai_words)
+ if civitai_words:
+ civitai_words = civitai_words.split(',')
+ if 'trainedWords' not in info_data:
+ info_data['trainedWords'] = []
+ for trigger_word in civitai_words:
+ word_data = next(
+ (data for data in info_data['trainedWords'] if data['word'] == trigger_word), None
+ )
+ if word_data is None:
+ word_data = {'word': trigger_word}
+ info_data['trainedWords'].append(word_data)
+ word_data['civitai'] = True
+
+ if 'sha256' not in info_data:
+ info_data['sha256'] = data_civitai['_sha256']
+ should_save = True
+
+ if 'modelId' in data_civitai:
+ info_data['links'] = info_data['links'] if 'links' in info_data else []
+ civitai_link = f'https://civitai.com/models/{get_dict_value(data_civitai, "modelId")}'
+ if get_dict_value(data_civitai, "id"):
+ civitai_link += f'?modelVersionId={get_dict_value(data_civitai, "id")}'
+ info_data['links'].append(civitai_link)
+ info_data['links'].append(data_civitai['_civitai_api'])
+ should_save = True
+
+ # Take images from civitai
+ if 'images' in data_civitai:
+ info_data_image_urls = list(
+ map(lambda i: i['url'] if 'url' in i else None, info_data['images'])
+ )
+ for img in data_civitai['images']:
+ img_url = get_dict_value(img, 'url')
+ if img_url is not None and img_url not in info_data_image_urls:
+ img_id = os.path.splitext(os.path.basename(img_url))[0] if img_url is not None else None
+ img_data = {
+ 'url': img_url,
+ 'civitaiUrl': f'https://civitai.com/images/{img_id}' if img_id is not None else None,
+ 'width': get_dict_value(img, 'width'),
+ 'height': get_dict_value(img, 'height'),
+ 'type': get_dict_value(img, 'type'),
+ 'nsfwLevel': get_dict_value(img, 'nsfwLevel'),
+ 'seed': get_dict_value(img, 'meta.seed'),
+ 'positive': get_dict_value(img, 'meta.prompt'),
+ 'negative': get_dict_value(img, 'meta.negativePrompt'),
+ 'steps': get_dict_value(img, 'meta.steps'),
+ 'sampler': get_dict_value(img, 'meta.sampler'),
+ 'cfg': get_dict_value(img, 'meta.cfgScale'),
+ 'model': get_dict_value(img, 'meta.Model'),
+ 'resources': get_dict_value(img, 'meta.resources'),
+ }
+ info_data['images'].append(img_data)
+ should_save = True
+
+ # The raw data
+ if 'civitai' not in info_data['raw']:
+ info_data['raw']['civitai'] = data_civitai
+ should_save = True
+
+ return should_save
+
+
+def _get_model_civitai_data(file: str, model_type, default=None, refresh=False):
+ """Gets the civitai data, either cached from the user directory, or from civitai api."""
+ file_hash = _get_sha256_hash(get_folder_path(file, model_type))
+ if file_hash is None:
+ return None
+
+ json_file_path = _get_info_cache_file(file_hash, 'civitai')
+
+ api_url = f'https://civitai.com/api/v1/model-versions/by-hash/{file_hash}'
+ file_data = read_userdata_json(json_file_path)
+ if file_data is None or refresh is True:
+ try:
+ response = requests.get(api_url, timeout=5000)
+ data = response.json()
+ save_userdata_json(
+ json_file_path, {
+ 'url': api_url,
+ 'timestamp': datetime.now().timestamp(),
+ 'response': data
+ }
+ )
+ file_data = read_userdata_json(json_file_path)
+ except requests.exceptions.RequestException as e: # This is the correct syntax
+ print(e)
+ response = file_data['response'] if file_data is not None and 'response' in file_data else None
+ if response is not None:
+ response['_sha256'] = file_hash
+ response['_civitai_api'] = api_url
+ return response if response is not None else default
+
+
+def _get_model_metadata(file: str, model_type, default=None, refresh=False):
+ """Gets the metadata from the file itself."""
+ file_path = get_folder_path(file, model_type)
+ file_hash = _get_sha256_hash(file_path)
+ if file_hash is None:
+ return default
+
+ json_file_path = _get_info_cache_file(file_hash, 'metadata')
+
+ file_data = read_userdata_json(json_file_path)
+ if file_data is None or refresh is True:
+ data = _read_file_metadata_from_header(file_path)
+ if data is not None:
+ file_data = {'url': file, 'timestamp': datetime.now().timestamp(), 'response': data}
+ save_userdata_json(json_file_path, file_data)
+ response = file_data['response'] if file_data is not None and 'response' in file_data else None
+ if response is not None:
+ response['_sha256'] = file_hash
+ return response if response is not None else default
+
+
+def _read_file_metadata_from_header(file_path: str) -> dict:
+ """Reads the file's header and returns a JSON dict metdata if available."""
+ data = None
+ try:
+ if file_path.endswith('.safetensors'):
+ with open(file_path, "rb") as file:
+ # https://github.com/huggingface/safetensors#format
+ # 8 bytes: N, an unsigned little-endian 64-bit integer, containing the size of the header
+ header_size = int.from_bytes(file.read(8), "little", signed=False)
+
+ if header_size <= 0:
+ raise BufferError("Invalid header size")
+
+ header = file.read(header_size)
+ if header is None:
+ raise BufferError("Invalid header")
+
+ header_json = json.loads(header)
+ data = header_json["__metadata__"] if "__metadata__" in header_json else None
+
+ if data is not None:
+ for key, value in data.items():
+ if isinstance(value, str) and value.startswith('{') and value.endswith('}'):
+ try:
+ value_as_json = json.loads(value)
+ data[key] = value_as_json
+ except Exception:
+ print(f'metdata for field {key} did not parse as json')
+ except requests.exceptions.RequestException as e:
+ print(e)
+ data = None
+
+ return data
+
+
+def get_folder_path(file: str, model_type) -> str | None:
+ """Gets the file path ensuring it exists."""
+ file_path = folder_paths.get_full_path(model_type, file)
+ if file_path and not file_exists(file_path):
+ file_path = os.path.abspath(file_path)
+ if not file_exists(file_path):
+ file_path = None
+ return file_path
+
+
+def _get_sha256_hash(file_path: str | None):
+ """Returns the hash for the file."""
+ if not file_path or not file_exists(file_path):
+ return None
+ BUF_SIZE = 1024 * 128 # lets read stuff in 64kb chunks!
+ file_hash = None
+ sha256_hash = hashlib.sha256()
+ with open(file_path, "rb") as f:
+ # Read and update hash string value in blocks of BUF_SIZE
+ for byte_block in iter(lambda: f.read(BUF_SIZE), b""):
+ sha256_hash.update(byte_block)
+ file_hash = sha256_hash.hexdigest()
+ return file_hash
+
+
+async def set_model_info_partial(file: str, model_type: str, info_data_partial):
+ """Sets partial data into the existing model info data."""
+ info_data = await get_model_info(file, model_type, default={})
+ info_data = {**info_data, **info_data_partial}
+ save_model_info(file, info_data, model_type)
+
+
+def save_model_info(file: str, info_data, model_type):
+ """Saves the model info alongside the model itself."""
+ file_path = get_folder_path(file, model_type)
+ if file_path is None:
+ return
+ info_path = get_info_file(file_path, force=True)
+ save_json_file(info_path, info_data)
diff --git a/utility/rgthree_api/utils_server.py b/utility/rgthree_api/utils_server.py
new file mode 100644
index 0000000..b19d8a8
--- /dev/null
+++ b/utility/rgthree_api/utils_server.py
@@ -0,0 +1,56 @@
+import os
+from aiohttp import web
+
+THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+DIR_WEB = os.path.abspath(f'{THIS_DIR}/../../web/')
+
+
+def get_param(request, param, default=None):
+ """Gets a param from a request."""
+ return request.rel_url.query[param] if param in request.rel_url.query else default
+
+
+def is_param_falsy(request, param):
+ """Determines if a param is explicitly 0 or false."""
+ val = get_param(request, param)
+ return val is not None and (val == "0" or val.upper() == "FALSE")
+
+
+def is_param_truthy(request, param):
+ """Determines if a param is explicitly 0 or false."""
+ val = get_param(request, param)
+ return val is not None and not is_param_falsy(request, param)
+
+
+def set_default_page_resources(path, routes):
+ """ Sets up routes for handling static files under a path."""
+
+ @routes.get(f'/rgthree/{path}/{{file}}')
+ async def get_resource(request):
+ """ Returns a resource file. """
+ return web.FileResponse(os.path.join(DIR_WEB, path, request.match_info['file']))
+
+ @routes.get(f'/rgthree/{path}/{{subdir}}/{{file}}')
+ async def get_resource_subdir(request):
+ """ Returns a resource file. """
+ return web.FileResponse(
+ os.path.join(DIR_WEB, path, request.match_info['subdir'], request.match_info['file']))
+
+
+def set_default_page_routes(path, routes):
+ """ Sets default path handling for a hosted rgthree page. """
+
+ @routes.get(f'/rgthree/{path}')
+ async def get_path_redir(request):
+ """ Redirects to the path adding a trailing slash. """
+ raise web.HTTPFound(f'{request.path}/')
+
+ @routes.get(f'/rgthree/{path}/')
+ async def get_path_index(request):
+ """ Handles the page's index loading. """
+ html = ''
+ with open(os.path.join(DIR_WEB, path, 'index.html'), 'r', encoding='UTF-8') as file:
+ html = file.read()
+ return web.Response(text=html, content_type='text/html')
+
+ set_default_page_resources(path, routes)
diff --git a/utility/rgthree_api/utils_userdata.py b/utility/rgthree_api/utils_userdata.py
new file mode 100644
index 0000000..77d9547
--- /dev/null
+++ b/utility/rgthree_api/utils_userdata.py
@@ -0,0 +1,50 @@
+import os
+
+from .utils import load_json_file, path_exists, save_json_file
+
+THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+USERDATA = os.path.join(THIS_DIR, '..', 'userdata')
+
+
+def read_userdata_file(rel_path: str):
+ """Reads a file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ if path_exists(file_path):
+ with open(file_path, 'r', encoding='UTF-8') as file:
+ return file.read()
+ return None
+
+
+def save_userdata_file(rel_path: str, content: str):
+ """Saves a file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ with open(file_path, 'w+', encoding='UTF-8') as file:
+ file.write(content)
+
+
+def delete_userdata_file(rel_path: str):
+ """Deletes a file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ if os.path.isfile(file_path):
+ os.remove(file_path)
+
+
+def read_userdata_json(rel_path: str):
+ """Reads a json file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ return load_json_file(file_path)
+
+
+def save_userdata_json(rel_path: str, data: dict):
+ """Saves a json file from the userdata directory."""
+ file_path = clean_path(rel_path)
+ return save_json_file(file_path, data)
+
+
+def clean_path(rel_path: str):
+ """Cleans a relative path by splitting on forward slash and os.path.joining."""
+ cleaned = USERDATA
+ paths = rel_path.split('/')
+ for path in paths:
+ cleaned = os.path.join(cleaned, path)
+ return cleaned
diff --git a/web/power_lora_loader_js/dialog_info.js b/web/power_lora_loader_js/dialog_info.js
index 8e030a7..0c871ec 100644
--- a/web/power_lora_loader_js/dialog_info.js
+++ b/web/power_lora_loader_js/dialog_info.js
@@ -3,32 +3,7 @@ import { createElement as $el, empty, appendChildren, getClosestOrSelf, query, q
import { logoCivitai, link, pencilColored, diskColored, dotdotdot } from "./svgs.js";
import { LORA_INFO_SERVICE } from "./model_info_service.js";
import { generateId, injectCss } from "./shared_utils.js";
-
-// Simplified rgthree object for WanVideo
-const rgthree = {
- showMessage: function(options) {
- // Simple toast message
- const toast = document.createElement('div');
- toast.style.cssText = `
- position: fixed;
- top: 20px;
- right: 20px;
- background: #333;
- color: white;
- padding: 12px 20px;
- border-radius: 4px;
- z-index: 10000;
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
- `;
- toast.textContent = options.message;
- document.body.appendChild(toast);
-
- setTimeout(() => {
- document.body.removeChild(toast);
- }, options.timeout || 3000);
- },
- isDevMode: () => false
-};
+import { rgthree } from "../rgthree/common/rgthree.js";
class WanInfoDialog extends WanDialog {
constructor(file) {
@@ -492,14 +467,45 @@ class WanInfoDialog extends WanDialog {
this.modelInfo = await this.refreshModelInfo(info.file);
this.setContent(this.getInfoContent());
this.setTitle(((_a = this.modelInfo) === null || _a === void 0 ? void 0 : _a["name"]) || ((_b = this.modelInfo) === null || _b === void 0 ? void 0 : _b["file"]) || "Unknown");
-
- // Show success message
- rgthree.showMessage({
- id: "refresh-info-" + generateId(4),
- type: "success",
- message: "LoRA information refreshed successfully",
- timeout: 3000,
- });
+
+ // Generate preview image after successful refresh if we have image data
+ if (this.modelInfo && this.modelInfo.images && this.modelInfo.images.length > 0) {
+ try {
+ // Extract LoRA name from file path
+ const loraName = info.file.replace(/\.[^/.]+$/, ""); // Remove extension
+ const loraPath = info.file.includes('/') ? info.file.substring(0, info.file.lastIndexOf('/')) : '';
+
+ // Import and use the preview generation function
+ const { generatePreviewFromFirstImage } = await import('./image_utils.js');
+ const previewResult = await generatePreviewFromFirstImage(this.modelInfo, loraName, loraPath);
+ console.log(`[DialogInfo] Preview image generated after refresh for ${loraName}:`, previewResult);
+
+ // Add to success message
+ rgthree.showMessage({
+ id: "refresh-info-" + generateId(4),
+ type: "success",
+ message: "LoRA information refreshed successfully (preview image generated)",
+ timeout: 3000,
+ });
+ } catch (previewError) {
+ console.warn(`[DialogInfo] Failed to generate preview image for ${info.file}:`, previewError);
+ // Still show success message for the refresh itself
+ rgthree.showMessage({
+ id: "refresh-info-" + generateId(4),
+ type: "success",
+ message: "LoRA information refreshed successfully",
+ timeout: 3000,
+ });
+ }
+ } else {
+ // Show success message
+ rgthree.showMessage({
+ id: "refresh-info-" + generateId(4),
+ type: "success",
+ message: "LoRA information refreshed successfully",
+ timeout: 3000,
+ });
+ }
} catch (error) {
console.error("Error refreshing LoRA info:", error);
@@ -516,6 +522,22 @@ class WanInfoDialog extends WanDialog {
this.modelInfo = await this.refreshModelInfo(info.file);
this.setContent(this.getInfoContent());
this.setTitle(((_a = this.modelInfo) === null || _a === void 0 ? void 0 : _a["name"]) || ((_b = this.modelInfo) === null || _b === void 0 ? void 0 : _b["file"]) || "Unknown");
+
+ // Generate preview image after successful Civitai fetch if we have image data
+ if (this.modelInfo && this.modelInfo.images && this.modelInfo.images.length > 0) {
+ try {
+ // Extract LoRA name from file path
+ const loraName = info.file.replace(/\.[^/.]+$/, ""); // Remove extension
+ const loraPath = info.file.includes('/') ? info.file.substring(0, info.file.lastIndexOf('/')) : '';
+
+ // Import and use the preview generation function
+ const { generatePreviewFromFirstImage } = await import('./image_utils.js');
+ const previewResult = await generatePreviewFromFirstImage(this.modelInfo, loraName, loraPath);
+ console.log(`[DialogInfo] Preview image generated after Civitai fetch for ${loraName}:`, previewResult);
+ } catch (previewError) {
+ console.warn(`[DialogInfo] Failed to generate preview image for ${info.file}:`, previewError);
+ }
+ }
}
else if (action === "copy-positive") {
// Get the positive prompt from the data attribute
@@ -730,7 +752,7 @@ function saveEditableRow(info, tr, saving = true) {
}
newValue = (Math.round(Number(newValue) * 100) / 100).toFixed(2);
}
- LORA_INFO_SERVICE.savePartialInfo(info.file, { [fieldName]: newValue });
+ LORA_INFO_SERVICE.savePartialInfo(info.file, { [fieldName]: newValue }, { generatePreview: true });
modified = true;
}
tr.classList.remove("-rgthree-editing");
diff --git a/web/power_lora_loader_js/image_utils.js b/web/power_lora_loader_js/image_utils.js
new file mode 100644
index 0000000..0b92f42
--- /dev/null
+++ b/web/power_lora_loader_js/image_utils.js
@@ -0,0 +1,362 @@
+/**
+ * Image and Video Processing Utilities for WanVideoWrapper QQ
+ * Handles image extraction, scaling, and preview generation for LoRA files
+ */
+
+import { rgthreeApi } from "./rgthree_api.js";
+
+/**
+ * Image Utils class containing all image/video processing functionality
+ */
+export class ImageUtils {
+
+ /**
+ * Extract middle frame from a video URL
+ * @param {string} videoUrl - URL of the video
+ * @param {number} maxWidth - Maximum width for the extracted frame
+ * @returns {Promise} - Blob containing the extracted frame
+ */
+ static async extractFrameFromVideo(videoUrl, maxWidth = 250) {
+ return new Promise((resolve, reject) => {
+ const video = document.createElement('video');
+ video.crossOrigin = 'anonymous';
+ video.src = videoUrl;
+
+ video.addEventListener('loadeddata', () => {
+ // Seek to the middle frame instead of the first frame
+ video.currentTime = video.duration / 2;
+ });
+
+ video.addEventListener('seeked', () => {
+ try {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+
+ // Calculate dimensions maintaining aspect ratio
+ const aspectRatio = video.videoWidth / video.videoHeight;
+ const width = Math.min(maxWidth, video.videoWidth);
+ const height = width / aspectRatio;
+
+ canvas.width = width;
+ canvas.height = height;
+
+ // Draw the video frame to canvas
+ ctx.drawImage(video, 0, 0, width, height);
+
+ // Convert to blob as JPEG
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob);
+ } else {
+ reject(new Error('Failed to convert canvas to blob'));
+ }
+ }, 'image/jpeg', 0.9);
+
+ } catch (error) {
+ reject(error);
+ }
+ });
+
+ video.addEventListener('error', () => {
+ reject(new Error(`Failed to load video: ${videoUrl}`));
+ });
+ });
+ }
+
+ /**
+ * Scale an image maintaining aspect ratio
+ * @param {string} imageUrl - URL of the image
+ * @param {number} maxHeight - Maximum height for the scaled image
+ * @returns {Promise} - Blob containing the scaled image
+ */
+ static async scaleImage(imageUrl, maxHeight = 250) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.src = imageUrl;
+
+ img.addEventListener('load', () => {
+ try {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+
+ // Calculate dimensions maintaining aspect ratio
+ const aspectRatio = img.width / img.height;
+ const height = Math.min(maxHeight, img.height);
+ const width = height * aspectRatio;
+
+ canvas.width = width;
+ canvas.height = height;
+
+ // Draw and scale the image
+ ctx.drawImage(img, 0, 0, width, height);
+
+ // Convert to blob as JPEG
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob);
+ } else {
+ reject(new Error('Failed to convert canvas to blob'));
+ }
+ }, 'image/jpeg', 0.9);
+
+ } catch (error) {
+ reject(error);
+ }
+ });
+
+ img.addEventListener('error', () => {
+ reject(new Error(`Failed to load image: ${imageUrl}`));
+ });
+ });
+ }
+
+ /**
+ * Process image or video to generate preview
+ * @param {Object} mediaInfo - Media object containing url and type
+ * @param {number} maxSize - Maximum size (width for video, height for image)
+ * @returns {Promise} - Processed image blob
+ */
+ static async processMediaForPreview(mediaInfo, maxSize = 250) {
+ if (!mediaInfo || !mediaInfo.url) {
+ throw new Error('Invalid media info provided');
+ }
+
+ if (mediaInfo.type === 'video') {
+ // Extract middle frame from video for better preview representation
+ return this.extractFrameFromVideo(mediaInfo.url, maxSize);
+ } else {
+ return this.scaleImage(mediaInfo.url, maxSize);
+ }
+ }
+
+ /**
+ * Convert blob to data URL for preview/ debugging
+ * @param {Blob} blob - Image blob
+ * @returns {Promise} - Data URL
+ */
+ static async blobToDataUrl(blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+ }
+
+ /**
+ * Save preview image through the backend API
+ * @param {string} loraName - Name of the LoRA file (without extension)
+ * @param {Blob} imageBlob - Processed image blob
+ * @param {string} loraPath - Optional relative path within loras directory
+ * @param {string} suffix - Optional suffix for filename (e.g., '_01', '_02')
+ * @returns {Promise