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} - API response + */ + static async savePreviewImage(loraName, imageBlob, loraPath = '', suffix = '') { + const formData = new FormData(); + + // Create a unique filename with optional suffix + const filename = `${loraName}${suffix}.jpg`; + + formData.append('image', imageBlob, filename); + formData.append('lora_name', loraName); + formData.append('lora_path', loraPath); + formData.append('suffix', suffix); + + try { + const response = await fetch('/wanvid/api/lora/preview-image', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Failed to save preview image:', error); + throw error; + } + } + + /** + * Complete workflow: extract/scale media and save as preview + * @param {Object} mediaInfo - Media object from dialog info + * @param {string} loraName - Name of the LoRA + * @param {string} loraPath - Optional relative path within loras directory + * @returns {Promise} - Result of the operation + */ + static async generateAndSavePreview(mediaInfo, loraName, loraPath = '') { + try { + const frameInfo = mediaInfo.type === 'video' ? 'middle frame' : 'image'; + console.log(`[ImageUtils] Generating preview for ${loraName} from ${mediaInfo.type} (${frameInfo})`); + + // Validate inputs + if (!mediaInfo || !mediaInfo.url) { + throw new Error('Invalid media info: missing URL'); + } + if (!loraName || loraName.trim() === '') { + throw new Error('Invalid LoRA name: empty or undefined'); + } + + // Process the media (extract frame from video or scale image) + const imageBlob = await this.processMediaForPreview(mediaInfo); + + console.log(`[ImageUtils] Media processed, blob size: ${imageBlob.size} bytes`); + + if (imageBlob.size === 0) { + throw new Error('Generated image blob is empty'); + } + + // Save through backend API + const result = await this.savePreviewImage(loraName, imageBlob, loraPath); + + console.log(`[ImageUtils] Preview saved successfully:`, result); + return result; + + } catch (error) { + console.error(`[ImageUtils] Failed to generate preview for ${loraName}:`, error); + throw error; + } + } + + /** + * Generate and save multiple preview images from available examples + * @param {Object} info - Dialog info object containing images array + * @param {string} loraName - Name of the LoRA + * @param {string} loraPath - Optional relative path within loras directory + * @param {number} maxImages - Maximum number of preview images to generate + * @returns {Promise} - Array of results for each generated preview + */ + static async generateMultiplePreviews(info, loraName, loraPath = '', maxImages = 3) { + try { + if (!info || !info.images || !info.images.length) { + throw new Error('No images available in info data'); + } + + console.log(`[ImageUtils] Generating up to ${maxImages} previews for ${loraName}`); + + const results = []; + const imagesToProcess = Math.min(maxImages, info.images.length); + + for (let i = 0; i < imagesToProcess; i++) { + const mediaInfo = info.images[i]; + const suffix = `_${String(i + 1).padStart(2, '0')}`; // _01, _02, _03 + + try { + console.log(`[ImageUtils] Processing preview ${suffix} for ${loraName}`); + + // Validate media info + if (!mediaInfo || !mediaInfo.url) { + console.warn(`[ImageUtils] Skipping ${suffix}: invalid media info`); + continue; + } + + // Process the media + const imageBlob = await this.processMediaForPreview(mediaInfo); + + if (imageBlob.size === 0) { + console.warn(`[ImageUtils] Skipping ${suffix}: empty blob generated`); + continue; + } + + // Save with suffix + const result = await this.savePreviewImage(loraName, imageBlob, loraPath, suffix); + result.suffix = suffix; + results.push(result); + + console.log(`[ImageUtils] Preview ${suffix} saved successfully:`, result); + + } catch (error) { + console.error(`[ImageUtils] Failed to generate preview ${suffix} for ${loraName}:`, error); + // Continue processing other images even if one fails + } + } + + if (results.length === 0) { + throw new Error('Failed to generate any preview images'); + } + + console.log(`[ImageUtils] Successfully generated ${results.length} previews for ${loraName}`); + return results; + + } catch (error) { + console.error(`[ImageUtils] Failed to generate multiple previews for ${loraName}:`, error); + throw error; + } + } +} + +/** + * Convenience function to generate multiple previews from available media in info + * @param {Object} info - Dialog info object containing images array + * @param {string} loraName - Name of the LoRA + * @param {string} loraPath - Optional relative path within loras directory + * @returns {Promise} - Result of the operation + */ +export async function generatePreviewFromFirstImage(info, loraName, loraPath = '') { + if (!info || !info.images || !info.images.length) { + throw new Error('No images available in info data'); + } + + return ImageUtils.generateMultiplePreviews(info, loraName, loraPath, 3); +} + +/** + * Test function to manually trigger preview generation + * @param {Object} info - Dialog info object containing images array + * @param {string} loraName - Name of the LoRA + * @param {string} loraPath - Optional relative path within loras directory + * @returns {Promise} - Result of the operation + */ +export async function testPreviewGeneration(info, loraName, loraPath = '') { + console.log(`[ImageUtils] Manual test: Starting preview generation for ${loraName}`); + + try { + const result = await generatePreviewFromFirstImage(info, loraName, loraPath); + console.log(`[ImageUtils] Manual test: Preview generation successful`, result); + return result; + } catch (error) { + console.error(`[ImageUtils] Manual test: Preview generation failed`, error); + throw error; + } +} + +/** + * Debug function to validate image info structure + * @param {Object} info - Dialog info object + * @returns {boolean} - True if structure is valid + */ +export function validateImageInfo(info) { + if (!info) { + console.error('[ImageUtils] Invalid info: null or undefined'); + return false; + } + + if (!info.images || !Array.isArray(info.images)) { + console.error('[ImageUtils] Invalid info: images array missing or not an array'); + return false; + } + + if (info.images.length === 0) { + console.warn('[ImageUtils] Warning: No images in info array'); + return false; + } + + const firstImage = info.images[0]; + if (!firstImage || !firstImage.url) { + console.error('[ImageUtils] Invalid first image: missing URL'); + return false; + } + + if (!firstImage.type || !['image', 'video'].includes(firstImage.type)) { + console.error('[ImageUtils] Invalid first image: missing or invalid type'); + return false; + } + + console.log(`[ImageUtils] Image info validation passed: ${firstImage.type} - ${firstImage.url}`); + return true; +} + +export default ImageUtils; \ No newline at end of file diff --git a/web/power_lora_loader_js/lora_picker_dialog.js b/web/power_lora_loader_js/lora_picker_dialog.js index ed003cc..b608fe1 100644 --- a/web/power_lora_loader_js/lora_picker_dialog.js +++ b/web/power_lora_loader_js/lora_picker_dialog.js @@ -136,8 +136,8 @@ style.textContent = ` z-index: 10000 !important; padding: 10px !important; border-radius: 0px !important; - width: calc(25vw + 150px) !important; /* Width + sidebar width */ - min-width: 550px; /* Increased min-width */ + width: calc(25vw + 150px + 200px) !important; /* Width + sidebar width + preview width */ + min-width: 750px; /* Increased min-width for preview */ max-height: 80vh; display: flex !important; flex-direction: column !important; @@ -228,9 +228,16 @@ style.textContent = ` margin-bottom: 10px !important; } +.lora-picker-search-container { + position: relative !important; + width: 100% !important; + display: flex !important; + align-items: center !important; +} + .lora-picker-header input { width: 100% !important; - padding: 4px 6px !important; + padding: 4px 30px 4px 6px !important; background-color: #1a1a1a !important; border: 1px solid #333 !important; color: #ddd !important; @@ -239,6 +246,33 @@ style.textContent = ` outline: none !important; } +.lora-picker-clear-button { + position: absolute !important; + right: 6px !important; + top: 50% !important; + transform: translateY(-50%) !important; + width: 16px !important; + height: 16px !important; + background: none !important; + color: #888 !important; + border: none !important; + cursor: pointer !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 14px !important; + font-weight: normal !important; + user-select: none !important; +} + +.lora-picker-clear-button:hover { + color: #aaa !important; +} + +.lora-picker-clear-button:active { + color: #666 !important; +} + .lora-picker-header select { width: 80px !important; padding: 4px 6px !important; @@ -321,6 +355,79 @@ style.textContent = ` font-size: 16px !important; user-select: none !important; } + +.lora-picker-preview-area { + position: fixed; + top: 20%; /* Same as dialog top */ + right: 0px; + width: 200px; + min-height: auto; /* Let content determine height */ + background-color: #1a1a1a !important; + border: none !important; /* Remove blue outline */ + z-index: 10001 !important; + padding: 8px !important; + display: none !important; + flex-direction: column !important; + font-family: Arial, sans-serif !important; + font-size: 11px !important; + color: #ddd !important; + transition: transform 0.2s ease !important; + transform: translateX(100%); +} + +.lora-picker-preview-area.visible { + display: flex !important; + transform: translateX(200px); +} + +.lora-picker-preview-images-container { + display: flex !important; + flex-direction: column !important; + gap: 8px !important; + margin-bottom: 8px !important; + width: 100% !important; + max-height: 600px !important; /* 3 images * 200px each + gaps */ + overflow-y: auto !important; +} + +.lora-picker-preview-image { + width: 100% !important; + height: auto !important; + max-height: 180px !important; /* Slightly smaller for multiple images */ + object-fit: contain !important; + border: 1px solid #333 !important; + border-radius: 4px !important; + flex-shrink: 0 !important; +} + +.lora-picker-preview-info { + flex: 1 !important; + overflow-y: auto !important; +} + +.lora-picker-preview-name { + font-weight: bold !important; + margin-bottom: 4px !important; + word-break: break-all !important; +} + +.lora-picker-preview-status { + color: #aaa !important; + font-size: 11px !important; +} + +.lora-picker-preview-indicator { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 8px !important; + height: 8px !important; + background-color: #4488bb !important; + border-radius: 50% !important; + margin-left: auto !important; + margin-right: 2px !important; + flex-shrink: 0 !important; +} `; document.head.appendChild(style); @@ -354,6 +461,93 @@ function loadSelectedFolder() { return selectedFolder !== null ? JSON.parse(selectedFolder) : null; } +// Persistence functions for eye refresh button state +function saveEyeRefreshState(eyeRefreshState) { + localStorage.setItem("wanVideoPowerLoraLoader.eyeRefreshState", JSON.stringify(eyeRefreshState)); +} + +function loadEyeRefreshState() { + const eyeRefreshState = localStorage.getItem("wanVideoPowerLoraLoader.eyeRefreshState"); + return eyeRefreshState !== null ? JSON.parse(eyeRefreshState) : false; +} + +function saveAllUIState(dialog) { + const uiState = { + foldersVisible: dialog.options.foldersVisible, + eyeRefreshState: dialog.eyeRefreshState, + selectedFolder: dialog.selectedFolder + }; + + saveFoldersVisible(dialog.options.foldersVisible); + saveEyeRefreshState(dialog.eyeRefreshState); + saveSelectedFolder(dialog.selectedFolder); + + if (dialog.parentNode && dialog.parentNode.widgets) { + // Try both widget names to support both node types + let uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "pll_ui_state"); + if (!uiStateWidget) { + uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "ui_state"); + } + if (uiStateWidget) { + const stateString = JSON.stringify(uiState); + uiStateWidget.value = stateString; + if (typeof app !== 'undefined' && app.graph) { + app.graph.setDirtyCanvas(true, true); + } + } + } +} + +function loadAllUIState(dialog) { + let uiState = { + foldersVisible: undefined, + eyeRefreshState: undefined, + selectedFolder: undefined + }; + + if (dialog.parentNode && dialog.parentNode.widgets) { + // Try both widget names to support both node types + let uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "pll_ui_state"); + if (!uiStateWidget) { + uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "ui_state"); + } + if (uiStateWidget && uiStateWidget.value) { + try { + uiState = JSON.parse(uiStateWidget.value); + } catch (e) { + console.warn("Failed to parse UI state from workflow:", e); + } + } + } + + if (uiState.foldersVisible === undefined) { + uiState.foldersVisible = loadFoldersVisible(); + } + if (uiState.eyeRefreshState === undefined) { + uiState.eyeRefreshState = loadEyeRefreshState(); + } + if (uiState.selectedFolder === undefined) { + uiState.selectedFolder = loadSelectedFolder(); + } + + dialog.options.foldersVisible = uiState.foldersVisible !== false; + dialog.eyeRefreshState = uiState.eyeRefreshState || false; + dialog.selectedFolder = uiState.selectedFolder || null; + + if (dialog.parentNode && dialog.parentNode.widgets) { + // Try both widget names to support both node types + let uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "pll_ui_state"); + if (!uiStateWidget) { + uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "ui_state"); + } + if (uiStateWidget && (uiState.foldersVisible !== undefined || uiState.eyeRefreshState !== undefined || uiState.selectedFolder !== undefined)) { + saveAllUIState(dialog); + } + } + + return uiState; +} + export class LoraPickerDialog { constructor(loras, options = {}) { // Filter out any "None" entries from the loras list @@ -364,23 +558,28 @@ export class LoraPickerDialog { this.options = options; this.element = null; this.refreshCallback = options.refreshCallback || null; - // Sidebar width configuration - change this value to adjust width + this.parentNode = options.parentNode || null; + this.sidebarWidth = 150; - // Load favorites only state from persistence if not provided + + loadAllUIState(this); + if (this.options.favoritesOnly === undefined) { this.options.favoritesOnly = loadFavoritesOnly(); } - // Load folders visible state from persistence if not provided - if (this.options.foldersVisible === undefined) { - this.options.foldersVisible = loadFoldersVisible(); - } - // Load selected folder from persistence - this.selectedFolder = loadSelectedFolder(); - // Extract folders from lora list + + this.previewArea = null; + this.previewImage = null; + this.previewName = null; + this.previewStatus = null; + this.currentPreviewTimeout = null; + this.previewAvailabilityCache = new Set(); this.folders = this.extractFolders(); + this.loadPreviewAvailability(); } show() { + // Create overlay this.overlay = document.createElement("div"); this.overlay.className = "lora-picker-overlay"; @@ -400,6 +599,9 @@ export class LoraPickerDialog { this.element = document.createElement("div"); this.element.className = "lora-picker-dialog"; + // Create preview area + this.createPreviewArea(); + // Add header const header = this.createHeader(); this.element.appendChild(header); @@ -441,11 +643,15 @@ export class LoraPickerDialog { toggleZone.style.height = "calc(100% - 50px)"; // Leave space at top for header toggleZone.style.zIndex = "10"; toggleZone.style.cursor = "pointer"; - // Add click handler to toggle sidebar + // Add click handler to toggle sidebar - trigger the same functionality as Folders button toggleZone.addEventListener("click", (e) => { e.stopPropagation(); // Prevent event bubbling + + // Trigger the same logic as the Folders button click this.options.foldersVisible = !this.options.foldersVisible; - const foldersContainer = this.element.querySelector(".lora-picker-header > div:nth-child(4)"); + + // Find and update the folders container (same as Folders button logic) + const foldersContainer = this.element.querySelector(".lora-picker-header > div:nth-child(5)"); if (foldersContainer) { const foldersText = foldersContainer.querySelector("span"); const indicatorLine = foldersContainer.querySelector("div[style*='position: absolute']"); @@ -453,8 +659,15 @@ export class LoraPickerDialog { foldersContainer.style.backgroundColor = this.options.foldersVisible ? "#2a2a2a" : "#1a1a1a"; indicatorLine.style.display = (this.selectedFolder && this.selectedFolder !== "root" && !this.options.foldersVisible) ? "block" : "none"; } - saveFoldersVisible(this.options.foldersVisible); + + // Save all UI state to both localStorage and workflow + saveAllUIState(this); + + // Toggle the sidebar this.toggleSidebar(); + + // Re-render list to ensure blue dots are visible + this.renderList(); }); // Assemble the dialog @@ -465,7 +678,29 @@ export class LoraPickerDialog { // Add to body document.body.appendChild(this.element); - + + // Position preview area relative to dialog + this.updatePreviewPosition(); + + // Wait for preview availability to load, then render the list + this.loadPreviewAvailability().then(() => { + // Now render the list with preview data available + this.renderList(); + + // Ensure sidebar is in the correct initial state based on saved preference + this.toggleSidebar(); + + // Restore preview panel visibility if eye refresh state is on + if (this.eyeRefreshState) { + // Make sure the preview area is in the correct initial state + // It will be shown on first hover, but ensure it's properly positioned + if (this.previewArea) { + // Start hidden, will show on hover + this.previewArea.classList.remove("visible"); + } + } + }); + // Focus on the search input automatically setTimeout(() => { const searchInput = this.element.querySelector("input[type=text]"); @@ -485,16 +720,78 @@ export class LoraPickerDialog { document.addEventListener('keydown', this.keydownHandler); } + createPreviewArea() { + // Create preview area container + this.previewArea = document.createElement("div"); + this.previewArea.className = "lora-picker-preview-area"; + + // Create preview images container (will hold multiple images) + this.previewImagesContainer = document.createElement("div"); + this.previewImagesContainer.className = "lora-picker-preview-images-container"; + this.previewImagesContainer.style.display = "none"; + + // Create preview info container + const previewInfo = document.createElement("div"); + previewInfo.className = "lora-picker-preview-info"; + + // Create preview name element + this.previewName = document.createElement("div"); + this.previewName.className = "lora-picker-preview-name"; + + // Create preview status element + this.previewStatus = document.createElement("div"); + this.previewStatus.className = "lora-picker-preview-status"; + + // Assemble preview area + previewInfo.appendChild(this.previewName); + previewInfo.appendChild(this.previewStatus); + this.previewArea.appendChild(this.previewImagesContainer); + this.previewArea.appendChild(previewInfo); + + // Add to document body (not dialog element) to avoid overflow constraints + document.body.appendChild(this.previewArea); + } + createHeader() { const header = document.createElement("div"); header.className = "lora-picker-header"; + // Search input container + const searchContainer = document.createElement("div"); + searchContainer.className = "lora-picker-search-container"; + // Search input const searchInput = document.createElement("input"); searchInput.type = "text"; searchInput.placeholder = "Search (multiple words supported)..."; searchInput.addEventListener("input", () => this.renderList()); - header.appendChild(searchInput); + + // Clear button + const clearButton = document.createElement("button"); + clearButton.className = "lora-picker-clear-button"; + clearButton.textContent = "✕"; + clearButton.type = "button"; + clearButton.title = "Clear search"; + + // Add click handler to clear button + clearButton.addEventListener("click", () => { + searchInput.value = ""; + searchInput.focus(); + this.renderList(); + }); + + // Only show clear button when there's text + searchInput.addEventListener("input", () => { + clearButton.style.display = searchInput.value ? "flex" : "none"; + this.renderList(); + }); + + // Initially hide clear button + clearButton.style.display = "none"; + + searchContainer.appendChild(searchInput); + searchContainer.appendChild(clearButton); + header.appendChild(searchContainer); // Sort dropdown const sortDropdown = document.createElement("select"); @@ -543,6 +840,47 @@ export class LoraPickerDialog { header.appendChild(refreshContainer); + // Eye refresh button + const eyeRefreshContainer = document.createElement("div"); + eyeRefreshContainer.style.display = "flex"; + eyeRefreshContainer.style.alignItems = "center"; + eyeRefreshContainer.style.gap = "5px"; + eyeRefreshContainer.style.cursor = "pointer"; + eyeRefreshContainer.style.margin = "0 10px"; + eyeRefreshContainer.style.padding = "4px 8px"; + eyeRefreshContainer.style.borderRadius = "4px"; + eyeRefreshContainer.style.backgroundColor = this.eyeRefreshState ? "#2a2a2a" : "#1a1a1a"; + eyeRefreshContainer.style.userSelect = "none"; + eyeRefreshContainer.title = "Eye Refresh Toggle"; + + const eyeRefreshIcon = document.createElement("span"); + eyeRefreshIcon.textContent = "👀"; + eyeRefreshIcon.style.fontSize = "14px"; + eyeRefreshIcon.style.color = this.eyeRefreshState ? "#4488bb" : "#aaa"; + eyeRefreshIcon.style.userSelect = "none"; + + eyeRefreshContainer.appendChild(eyeRefreshIcon); + + eyeRefreshContainer.addEventListener("click", () => { + this.eyeRefreshState = !this.eyeRefreshState; + eyeRefreshIcon.style.color = this.eyeRefreshState ? "#4488bb" : "#aaa"; + eyeRefreshContainer.style.backgroundColor = this.eyeRefreshState ? "#2a2a2a" : "#1a1a1a"; + // Save all UI state to both localStorage and workflow + saveAllUIState(this); + + // Hide preview if turning off + if (!this.eyeRefreshState) { + this.hidePreview(); + } + + // Call the refresh callback when toggled on + if (this.eyeRefreshState && this.refreshCallback) { + this.refreshCallback(); + } + }); + + header.appendChild(eyeRefreshContainer); + // Folders button const foldersContainer = document.createElement("div"); foldersContainer.style.display = "flex"; @@ -555,7 +893,7 @@ export class LoraPickerDialog { foldersContainer.style.backgroundColor = this.options.foldersVisible ? "#2a2a2a" : "#1a1a1a"; foldersContainer.style.userSelect = "none"; foldersContainer.style.position = "relative"; - + const foldersText = document.createElement("span"); foldersText.textContent = "Folders"; foldersText.style.color = this.options.foldersVisible ? "#4488bb" : "#aaa"; @@ -572,7 +910,7 @@ export class LoraPickerDialog { indicatorLine.style.background = "linear-gradient(to right, transparent 0%, rgba(68, 136, 187, 0.2) 10%, rgba(68, 136, 187, 0.5) 20%, rgba(68, 136, 187, 0.8) 30%, #4488bb 40%, #4488bb 60%, rgba(68, 136, 187, 0.8) 70%, rgba(68, 136, 187, 0.5) 80%, rgba(68, 136, 187, 0.2) 90%, transparent 100%)"; indicatorLine.style.borderRadius = "0.5px"; indicatorLine.style.display = (this.selectedFolder && this.selectedFolder !== "root" && !this.options.foldersVisible) ? "block" : "none"; - + foldersContainer.appendChild(foldersText); foldersContainer.appendChild(indicatorLine); @@ -582,9 +920,11 @@ export class LoraPickerDialog { foldersContainer.style.backgroundColor = this.options.foldersVisible ? "#2a2a2a" : "#1a1a1a"; // Update indicator line visibility indicatorLine.style.display = (this.selectedFolder && this.selectedFolder !== "root" && !this.options.foldersVisible) ? "block" : "none"; - // Save to persistence - saveFoldersVisible(this.options.foldersVisible); + // Save all UI state to both localStorage and workflow + saveAllUIState(this); this.toggleSidebar(); + // Re-render list to ensure blue dots are visible + this.renderList(); }); header.appendChild(foldersContainer); @@ -686,7 +1026,7 @@ export class LoraPickerDialog { renderFoldersInSidebar(container) { container.innerHTML = ""; - + for (const folder of this.folders) { const folderItem = document.createElement("div"); folderItem.className = "lora-picker-folder-item"; @@ -751,8 +1091,8 @@ export class LoraPickerDialog { prevSelected.classList.remove("selected"); } this.selectedFolder = null; - // Save to persistence - saveSelectedFolder(null); + // Save all UI state to both localStorage and workflow + saveAllUIState(this); // Update indicator line visibility this.updateFolderIndicator(); this.renderList(); // Refresh the list @@ -776,8 +1116,8 @@ export class LoraPickerDialog { } this.selectedFolder = folderName; - // Save to persistence - saveSelectedFolder(folderName); + // Save all UI state to both localStorage and workflow + saveAllUIState(this); // Update indicator line visibility this.updateFolderIndicator(); this.renderList(); // Refresh the list @@ -831,9 +1171,31 @@ export class LoraPickerDialog { const nameSpan = document.createElement("span"); nameSpan.textContent = displayName; nameSpan.style.userSelect = "none"; - - item.appendChild(starContainer); - item.appendChild(nameSpan); + + // Add blue dot indicator if preview is available + if (this.hasPreview(name)) { + const previewIndicator = document.createElement("div"); + previewIndicator.className = "lora-picker-preview-indicator"; + previewIndicator.title = "Preview image available"; + + item.appendChild(starContainer); + item.appendChild(nameSpan); + item.appendChild(previewIndicator); + } else { + item.appendChild(starContainer); + item.appendChild(nameSpan); + } + + // Add hover event handlers for preview + item.addEventListener("mouseenter", () => { + if (this.eyeRefreshState) { + this.showPreview(name); + } + }); + + item.addEventListener("mouseleave", () => { + this.hidePreview(); + }); item.addEventListener("click", () => { if (this.options.callback) { @@ -843,14 +1205,14 @@ export class LoraPickerDialog { } this.close(); }); - + // Add context menu event listener item.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); showLoraListContextMenu(e, name); // Use the full name for context menu }); - + this.list.appendChild(item); } } @@ -949,7 +1311,7 @@ export class LoraPickerDialog { updateFolderIndicator() { // Find and update the indicator line in the folders button - const foldersContainer = this.element.querySelector(".lora-picker-header > div:nth-child(4)"); + const foldersContainer = this.element.querySelector(".lora-picker-header > div:nth-child(5)"); if (foldersContainer) { // Find the indicator line (it's now positioned absolutely at the bottom) const indicatorLine = foldersContainer.querySelector("div[style*='position: absolute']"); @@ -960,18 +1322,205 @@ export class LoraPickerDialog { } } + showPreview(loraName) { + // Clear any existing timeout + if (this.currentPreviewTimeout) { + clearTimeout(this.currentPreviewTimeout); + } + + // Add a small delay to prevent flickering when quickly moving between items + this.currentPreviewTimeout = setTimeout(() => { + this.loadPreviewImage(loraName); + }, 200); + } + + hidePreview() { + // Clear any pending preview timeout + if (this.currentPreviewTimeout) { + clearTimeout(this.currentPreviewTimeout); + this.currentPreviewTimeout = null; + } + + // Hide preview area + if (this.previewArea) { + this.previewArea.classList.remove("visible"); + } + } + + loadPreviewImage(loraName) { + if (!this.previewArea || !this.previewImagesContainer || !this.previewName || !this.previewStatus) { + return; + } + + // Set the LoRA name + this.previewName.textContent = loraName; + this.previewStatus.textContent = "Loading preview..."; + + // Show the preview area + this.previewArea.classList.add("visible"); + + // Clear any existing images + this.previewImagesContainer.innerHTML = ""; + this.previewImagesContainer.style.display = "none"; + + // Remove extension + let loraPath = loraName.replace(/\.(safetensors|pt|ckpt)$/i, ''); + + // Parse subfolder and filename + let filename = loraPath; + let subfolder = ''; + + // Check if there's a subfolder path + const pathSeparator = loraPath.includes('/') ? '/' : '\\'; + if (loraPath.includes(pathSeparator)) { + const pathParts = loraPath.split(pathSeparator); + filename = pathParts.pop(); // Get the last part as filename + subfolder = pathParts.join('/'); // Join the rest with forward slashes + } + + // Try to load multiple preview images (_01.jpg, _02.jpg, _03.jpg) + this.loadMultiplePreviewImages(filename, subfolder); + } + + async loadMultiplePreviewImages(filename, subfolder) { + const imagePromises = []; + + // Create promises for each potential preview image + for (let i = 1; i <= 3; i++) { + const suffix = `_${String(i).padStart(2, '0')}`; + const promise = this.loadSinglePreviewImage(filename, subfolder, suffix); + imagePromises.push(promise); + } + + try { + // Wait for all images to load (or fail) + const results = await Promise.allSettled(imagePromises); + + // Filter successful results and create image elements + let loadedCount = 0; + results.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value) { + const imgElement = document.createElement("img"); + imgElement.className = "lora-picker-preview-image"; + imgElement.src = result.value; + imgElement.style.display = "block"; + this.previewImagesContainer.appendChild(imgElement); + loadedCount++; + } + }); + + if (loadedCount > 0) { + this.previewImagesContainer.style.display = "flex"; + this.previewStatus.textContent = `Loaded ${loadedCount} preview${loadedCount > 1 ? 's' : ''}`; + + // Update preview area height after images are loaded + setTimeout(() => this.updatePreviewPosition(), 100); + } else { + this.hidePreview(); + } + } catch (error) { + console.error('Error loading preview images:', error); + this.hidePreview(); + } + } + + loadSinglePreviewImage(filename, subfolder, suffix) { + return new Promise((resolve, reject) => { + // Build the API URL + const apiUrl = new URL('/wanvid/api/loras/preview', window.location.origin); + apiUrl.searchParams.set('file', filename); + if (subfolder) { + apiUrl.searchParams.set('subfolder', subfolder); + } + if (suffix) { + apiUrl.searchParams.set('suffix', suffix); + } + + const imagePath = apiUrl.pathname + apiUrl.search; + + // Create a new image object to test loading + const testImage = new Image(); + + testImage.onload = () => { + resolve(imagePath); + }; + + testImage.onerror = () => { + reject(new Error(`Failed to load image: ${imagePath}`)); + }; + + // Start loading the image + testImage.src = imagePath; + }); + } + + async loadPreviewAvailability() { + try { + const response = await fetch('/wanvid/api/loras/previews'); + if (response.ok) { + const data = await response.json(); + if (data.status === 200 && data.previews) { + this.previewAvailabilityCache.clear(); + data.previews.forEach(preview => { + this.previewAvailabilityCache.add(preview.lora); + }); + } + } + } catch (error) { + console.error('[Preview] Error loading preview availability:', error); + } + } + + hasPreview(loraName) { + return this.previewAvailabilityCache.has(loraName); + } + + close() { + saveAllUIState(this); + + // Clear any pending preview timeout + if (this.currentPreviewTimeout) { + clearTimeout(this.currentPreviewTimeout); + this.currentPreviewTimeout = null; + } + if (this.element) { this.element.remove(); } if (this.overlay) { this.overlay.remove(); } + // Remove preview area when dialog closes + if (this.previewArea) { + this.previewArea.remove(); + } if (this.keydownHandler) { document.removeEventListener('keydown', this.keydownHandler); } } - + + updatePreviewPosition() { + if (this.previewArea && this.element) { + const dialogRect = this.element.getBoundingClientRect(); + // Position preview area to align with dialog's right edge + this.previewArea.style.top = `${dialogRect.top + 10}px`; // +10 for dialog padding + // Let height be determined by content + this.previewArea.style.minHeight = 'auto'; + this.previewArea.style.right = `${window.innerWidth - dialogRect.right}px`; + + // Set height based on actual content, not scrollHeight to avoid cumulative growth + if (this.previewArea.classList.contains('visible')) { + // Reset height to auto first to get natural content height + this.previewArea.style.height = 'auto'; + + // Get the natural content height and add 50px + const contentHeight = this.previewArea.offsetHeight; + this.previewArea.style.height = `${contentHeight + 50}px`; + } + } + } + updateLoras(newLoras) { // Filter out any "None" entries from the new loras list this.loras = (newLoras || []).filter(l => { diff --git a/web/power_lora_loader_js/model_info_service.js b/web/power_lora_loader_js/model_info_service.js index 0337033..d861384 100644 --- a/web/power_lora_loader_js/model_info_service.js +++ b/web/power_lora_loader_js/model_info_service.js @@ -1,5 +1,6 @@ import { rgthreeApi } from "./rgthree_api.js"; import { api } from "../../../scripts/api.js"; +import { generatePreviewFromFirstImage } from "./image_utils.js"; class BaseModelInfoService extends EventTarget { constructor() { @@ -20,13 +21,36 @@ class BaseModelInfoService extends EventTarget { return this.fetchInfo(file, true); } async clearFetchedInfo(file) { - await rgthreeApi.clearModelsInfo({ type: this.modelInfoType, files: [file] }); + if (this.modelInfoType === 'loras') { + await rgthreeApi.clearSingleLoraInfo(file); + } else if (this.modelInfoType === 'checkpoints') { + await rgthreeApi.clearSingleCheckpointInfo(file); + } this.fileToInfo.delete(file); return null; } - async savePartialInfo(file, data) { + async savePartialInfo(file, data, options = {}) { let info = await rgthreeApi.saveModelInfo(this.modelInfoType, file, data); this.fileToInfo.set(file, info); + + // Generate preview image for LoRAs ONLY if explicitly requested and we have image data + const { generatePreview = false } = options; + + if (generatePreview && this.modelInfoType === 'loras' && info && info.images && info.images.length > 0) { + try { + // Extract LoRA name from file path + const loraName = file.replace(/\.[^/.]+$/, ""); // Remove extension + const loraPath = file.includes('/') ? file.substring(0, file.lastIndexOf('/')) : ''; + + // Generate and save preview image + const previewResult = await generatePreviewFromFirstImage(info, loraName, loraPath); + console.log(`[LoraInfoService] Preview image generated for ${loraName}:`, previewResult); + } catch (error) { + console.warn(`[LoraInfoService] Failed to generate preview image for ${file}:`, error); + // Don't let preview generation failure stop the save operation + } + } + return info; } handleAsyncUpdate(event) { @@ -59,7 +83,7 @@ class BaseModelInfoService extends EventTarget { class LoraInfoService extends BaseModelInfoService { constructor() { super(...arguments); - this.apiRefreshEventString = "rgthree-refreshed-loras-info"; + this.apiRefreshEventString = "wanvid-refreshed-loras-info"; this.modelInfoType = 'loras'; } } @@ -67,7 +91,7 @@ class LoraInfoService extends BaseModelInfoService { class CheckpointInfoService extends BaseModelInfoService { constructor() { super(...arguments); - this.apiRefreshEventString = "rgthree-refreshed-checkpoints-info"; + this.apiRefreshEventString = "wanvid-refreshed-checkpoints-info"; this.modelInfoType = 'checkpoints'; } } diff --git a/web/power_lora_loader_js/power_lora_loader.js b/web/power_lora_loader_js/power_lora_loader.js index db3f066..c29c5b0 100644 --- a/web/power_lora_loader_js/power_lora_loader.js +++ b/web/power_lora_loader_js/power_lora_loader.js @@ -1,6 +1,7 @@ import { app } from "../../../scripts/app.js"; -import { rgthreeApi } from "./rgthree_api.js"; +import { rgthree } from "../rgthree/common/rgthree.js"; +import { rgthreeApi as wanvidApi } from "./rgthree_api.js"; import { RgthreeBetterButtonWidget, RgthreeDividerWidget, CombinedOptionsWidget, PowerLoraLoaderHeaderWidget, PowerLoraLoaderWidget } from "./widgets.js"; import { LoraPickerDialog } from "./lora_picker_dialog.js"; import { getLoraSlotInPosition, getLoraSlotMenuOptions } from "./lora_context_menu.js"; @@ -26,7 +27,7 @@ async function getLoras(forceRefresh = false) { try { // Use rgthreeApi with forceRefresh parameter - const lorasData = await rgthreeApi.getLoras(forceRefresh); + const lorasData = await wanvidApi.getLoras(forceRefresh); // Convert rgthreeApi format to our expected format lorasCache = lorasData.map(lora => { if (typeof lora === 'string') { @@ -59,10 +60,11 @@ function loadFavorites() { return favorites !== null ? JSON.parse(favorites) : []; } -function showLoraPicker(event, callback) { +function showLoraPicker(event, callback, node = null) { getLoras().then(loras => { const savedFavorites = loadFavorites(); const dialog = new LoraPickerDialog(loras, { + parentNode: node, callback: callback, sort: "Latest", favorites: savedFavorites, @@ -83,13 +85,9 @@ function showLoraPicker(event, callback) { // Re-render the list to update star colors dialog.renderList(); }, - foldersVisible: false, refreshCallback: () => { - rgthreeApi.refreshLorasInfo().then(() => { - getLoras(true).then(newLoras => { - dialog.updateLoras(newLoras); - }); - }); + // Bulk refresh removed - refresh individual LoRAs through their dialogs instead + console.log('[WanVideoPowerLoraLoader] Individual LoRA refresh available in LoRA info dialog'); } }); dialog.show(); @@ -159,7 +157,15 @@ app.registerExtension({ } } }); - }; + + if (this.properties.uiState) { + const uiStateWidget = this.widgets.find(w => w.name === "pll_ui_state"); + if (uiStateWidget) { + uiStateWidget.value = this.properties.uiState; + } + } + + }; nodeType.prototype.addNewLoraWidget = function () { this.loraWidgetsCounter++; @@ -182,8 +188,13 @@ app.registerExtension({ if (lora) { this.addNewLoraWidget().setLora(lora); } - }); + }, node); })); + + // Add hidden widget for UI state persistence + const uiStateWidget = this.addWidget("string", "pll_ui_state", "{}", () => {}, + { serialize: true }); + uiStateWidget.type = "hidden"; }; nodeType.prototype.allLorasState = function() { @@ -272,6 +283,41 @@ app.registerExtension({ nodeType.prototype.getSlotInPosition = getLoraSlotInPosition; nodeType.prototype.getSlotMenuOptions = getLoraSlotMenuOptions; + const onSerialize = nodeType.prototype.onSerialize; + nodeType.prototype.onSerialize = function(o) { + onSerialize?.apply(this, arguments); + + let uiState = null; + const uiStateWidget = this.widgets.find(w => w.name === "pll_ui_state"); + + if (uiStateWidget && uiStateWidget.value) { + try { + uiState = JSON.parse(uiStateWidget.value); + } catch (e) { + console.warn("[WanVideoPowerLoraLoader] Failed to parse existing widget value:", e); + } + } + + if (!uiState) { + const foldersVisible = localStorage.getItem("wanVideoPowerLoraLoader.foldersVisible"); + const eyeRefreshState = localStorage.getItem("wanVideoPowerLoraLoader.eyeRefreshState"); + const selectedFolder = localStorage.getItem("wanVideoPowerLoraLoader.selectedFolder"); + + uiState = { + foldersVisible: foldersVisible ? JSON.parse(foldersVisible) : false, + eyeRefreshState: eyeRefreshState ? JSON.parse(eyeRefreshState) : false, + selectedFolder: selectedFolder ? JSON.parse(selectedFolder) : null, + }; + } + + this.properties.uiState = JSON.stringify(uiState); + + if (uiStateWidget) { + uiStateWidget.value = JSON.stringify(uiState); + } + }; + + // Pre-load loras getLoras().then(loras => { nodeType.prototype.lorasCache = loras; @@ -279,52 +325,53 @@ app.registerExtension({ // Add refreshComboInNode method to handle R key press for reloading definitions nodeType.prototype.refreshComboInNode = function(defs) { - // Use rgthreeApi to refresh the LoRA list - rgthreeApi.refreshLorasInfo().then(() => { - // Clear the cache and force refresh - lorasCache = null; - localStorage.removeItem("wanVideoPowerLoraLoader.lorasCache"); + // Use wanvidApi to refresh the LoRA list + // Bulk refresh removed - just clear cache and refresh list + lorasCache = null; + localStorage.removeItem("wanVideoPowerLoraLoader.lorasCache"); // Fetch fresh loras and update cache - getLoras(true).then((lorasDetails) => { + getLoras(true).then((lorasDetails) => { nodeType.prototype.lorasCache = lorasDetails; // Update all nodes of this type - app.graph._nodes.forEach(node => { - if (node.type === "WanVideoPowerLoraLoader") { - // Update any existing widgets that might need the fresh data - for (const widget of node.widgets || []) { - if (widget.name?.startsWith("lora_")) { - // Update the widget's parent reference to this node - widget.parent = node; + app.graph._nodes.forEach(node => { + if (node.type === "WanVideoPowerLoraLoader") { + // Update any existing widgets that might need the fresh data + for (const widget of node.widgets || []) { + if (widget.name?.startsWith("lora_")) { + // Update the widget's parent reference to this node + widget.parent = node; - // Refresh low variant detection for existing LoRAs with new cache - if (widget.value?.lora && widget.value.lora !== "None") { - const oldIsLow = widget.value.is_low; - const oldVariantName = widget.value.low_variant_name; + // Refresh low variant detection for existing LoRAs with new cache + if (widget.value?.lora && widget.value.lora !== "None") { + const oldIsLow = widget.value.is_low; + const oldVariantName = widget.value.low_variant_name; - // Re-run the low variant check with updated cache - widget.checkLowLoraVariant(widget.value.lora); + // Re-run the low variant check with updated cache + widget.checkLowLoraVariant(widget.value.lora); - // Log changes in low variant detection - if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) { - console.log(`[WanVideoPowerLoraLoader] Low variant status changed for '${widget.value.lora}': ` + - `${oldIsLow} -> ${widget.value.is_low}, ` + - `variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`); - } + // Log changes in low variant detection + if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) { + console.log(`[WanVideoPowerLoraLoader] Low variant status changed for '${widget.value.lora}': ` + + `${oldIsLow} -> ${widget.value.is_low}, ` + + `variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`); } } - } - // Trigger a redraw to update the green low icons - node.setDirtyCanvas(true, true); + // Trigger a value change to update the UI + widget.callback(widget.value); + } } - }); - }).catch(error => { - console.error('[WanVideoPowerLoraLoader] Error refreshing LoRA cache:', error); + + // Trigger a redraw to update the green low icons + node.setDirtyCanvas(true, true); + } }); + + console.log('[WanVideoPowerLoraLoader] LoRA definitions refreshed - individual LoRA info available in dialogs'); }).catch(error => { - console.error('[WanVideoPowerLoraLoader] Error refreshing LoRA info:', error); + console.error('[WanVideoPowerLoraLoader] Error refreshing LoRA list:', error); }); }; } diff --git a/web/power_lora_loader_js/power_lora_loader_v2.js b/web/power_lora_loader_js/power_lora_loader_v2.js index 0e38604..51e33d6 100644 --- a/web/power_lora_loader_js/power_lora_loader_v2.js +++ b/web/power_lora_loader_js/power_lora_loader_v2.js @@ -1,5 +1,6 @@ import { app } from "../../../scripts/app.js"; -import { rgthreeApi } from "./rgthree_api.js"; +import { rgthree } from "../rgthree/common/rgthree.js"; +import { rgthreeApi as wanvidApi } from "./rgthree_api.js"; import { RgthreeBetterButtonWidget, RgthreeDividerWidget, StrengthCopyWidget, PowerLoraLoaderHeaderWidget, PowerLoraLoaderWidget } from "./widgets.js"; import { LoraPickerDialog } from "./lora_picker_dialog.js"; import { getLoraSlotInPosition, getLoraSlotMenuOptions } from "./lora_context_menu.js"; @@ -25,7 +26,7 @@ async function getLoras(forceRefresh = false) { try { // Use rgthreeApi with forceRefresh parameter - const lorasData = await rgthreeApi.getLoras(forceRefresh); + const lorasData = await wanvidApi.getLoras(forceRefresh); // Convert rgthreeApi format to our expected format lorasCache = lorasData.map(lora => { if (typeof lora === 'string') { @@ -58,10 +59,59 @@ function loadFavorites() { return favorites !== null ? JSON.parse(favorites) : []; } -function showLoraPicker(event, callback) { +// Functions to persist UI state +function saveFoldersVisible(foldersVisible) { + localStorage.setItem("powerLoraLoaderV2.foldersVisible", JSON.stringify(foldersVisible)); +} + +function loadFoldersVisible() { + const foldersVisible = localStorage.getItem("powerLoraLoaderV2.foldersVisible"); + return foldersVisible !== null ? JSON.parse(foldersVisible) : false; +} + +function saveSelectedFolder(selectedFolder) { + localStorage.setItem("powerLoraLoaderV2.selectedFolder", JSON.stringify(selectedFolder)); +} + +function loadSelectedFolder() { + const selectedFolder = localStorage.getItem("powerLoraLoaderV2.selectedFolder"); + return selectedFolder !== null ? JSON.parse(selectedFolder) : null; +} + +function saveEyeRefreshState(eyeRefreshState) { + localStorage.setItem("powerLoraLoaderV2.eyeRefreshState", JSON.stringify(eyeRefreshState)); +} + +function loadEyeRefreshState() { + const eyeRefreshState = localStorage.getItem("powerLoraLoaderV2.eyeRefreshState"); + return eyeRefreshState !== null ? JSON.parse(eyeRefreshState) : false; +} + +function showLoraPicker(event, callback, node = null) { getLoras().then(loras => { const savedFavorites = loadFavorites(); + + // Load UI state from localStorage or node properties + let foldersVisible = loadFoldersVisible(); + let eyeRefreshState = loadEyeRefreshState(); + let selectedFolder = loadSelectedFolder(); + + if (node && node.widgets) { + const uiStateWidget = node.widgets.find(w => w.name === "ui_state"); + if (uiStateWidget && uiStateWidget.value) { + try { + const uiState = JSON.parse(uiStateWidget.value); + if (uiState.foldersVisible !== undefined) foldersVisible = uiState.foldersVisible; + if (uiState.eyeRefreshState !== undefined) eyeRefreshState = uiState.eyeRefreshState; + if (uiState.selectedFolder !== undefined) selectedFolder = uiState.selectedFolder; + } catch (e) { + console.warn("[PowerLoraLoaderV2] Failed to parse UI state from widget:", e); + } + } + } + const dialog = new LoraPickerDialog(loras, { + parentNode: node, callback: callback, sort: "Latest", favorites: savedFavorites, @@ -82,15 +132,17 @@ function showLoraPicker(event, callback) { // Re-render the list to update star colors dialog.renderList(); }, - foldersVisible: false, + foldersVisible: foldersVisible, refreshCallback: () => { - rgthreeApi.refreshLorasInfo().then(() => { - getLoras(true).then(newLoras => { - dialog.updateLoras(newLoras); - }); - }); + // Bulk refresh removed - refresh individual LoRAs through their dialogs instead + console.log('[PowerLoraLoaderV2] Individual LoRA refresh available in LoRA info dialog'); } }); + + // Initialize dialog's UI state + dialog.eyeRefreshState = eyeRefreshState; + dialog.selectedFolder = selectedFolder; + dialog.show(); }); } @@ -163,6 +215,36 @@ app.registerExtension({ } } }); + + // Restore UI state from widget if it exists + if (this.properties.uiState) { + const uiStateWidget = this.widgets.find(w => w.name === "ui_state"); + if (uiStateWidget) { + uiStateWidget.value = this.properties.uiState; + } + } + // Also check if there's a ui_state value directly in the widget_values + if (info.widgets_values) { + // Look for the ui_state value in the widgets_values + for (let i = 0; i < info.widgets_values.length; i++) { + const widgetValue = info.widgets_values[i]; + if (widgetValue && typeof widgetValue === 'string') { + try { + const parsed = JSON.parse(widgetValue); + if (parsed && typeof parsed === 'object' && !parsed.lora) { + // This looks like UI state data, update the widget + const uiStateWidget = this.widgets.find(w => w.name === "ui_state"); + if (uiStateWidget) { + uiStateWidget.value = widgetValue; + } + break; + } + } catch (e) { + // Not valid JSON, skip + } + } + } + } }; nodeType.prototype.addNewLoraWidget = function () { @@ -187,8 +269,14 @@ app.registerExtension({ if (lora) { this.addNewLoraWidget().setLora(lora); } - }); + }, node); })); + + // Add hidden widget for UI state persistence + const uiStateWidget = this.addWidget("string", "ui_state", "{}", () => {}, + { serialize: true }); + uiStateWidget.type = "hidden"; + // Keep serialize: true to ensure UI state is saved in workflow JSON }; nodeType.prototype.allLorasState = function() { @@ -297,6 +385,42 @@ app.registerExtension({ return data; }; + // Add onSerialize method to persist UI state + const onSerialize = nodeType.prototype.onSerialize; + nodeType.prototype.onSerialize = function(o) { + onSerialize?.apply(this, arguments); + + let uiState = null; + const uiStateWidget = this.widgets.find(w => w.name === "ui_state"); + + if (uiStateWidget && uiStateWidget.value) { + try { + uiState = JSON.parse(uiStateWidget.value); + } catch (e) { + console.warn("[PowerLoraLoaderV2] Failed to parse existing widget value:", e); + } + } + + if (!uiState) { + // Try to get from localStorage with correct keys for V2 + const foldersVisible = localStorage.getItem("powerLoraLoaderV2.foldersVisible"); + const eyeRefreshState = localStorage.getItem("powerLoraLoaderV2.eyeRefreshState"); + const selectedFolder = localStorage.getItem("powerLoraLoaderV2.selectedFolder"); + + uiState = { + foldersVisible: foldersVisible ? JSON.parse(foldersVisible) : false, + eyeRefreshState: eyeRefreshState ? JSON.parse(eyeRefreshState) : false, + selectedFolder: selectedFolder ? JSON.parse(selectedFolder) : null, + }; + } + + this.properties.uiState = JSON.stringify(uiState); + + if (uiStateWidget) { + uiStateWidget.value = JSON.stringify(uiState); + } + }; + // Pre-load loras getLoras().then(loras => { nodeType.prototype.lorasCache = loras; @@ -304,52 +428,53 @@ app.registerExtension({ // Add refreshComboInNode method to handle R key press for reloading definitions nodeType.prototype.refreshComboInNode = function(defs) { - // Use rgthreeApi to refresh the LoRA list - rgthreeApi.refreshLorasInfo().then(() => { - // Clear the cache and force refresh - lorasCache = null; - localStorage.removeItem("powerLoraLoaderV2.lorasCache"); + // Use wanvidApi to refresh the LoRA list + // Bulk refresh removed - just clear cache and refresh list + lorasCache = null; + localStorage.removeItem("powerLoraLoaderV2.lorasCache"); - // Fetch fresh loras and update cache - getLoras(true).then((lorasDetails) => { - nodeType.prototype.lorasCache = lorasDetails; + // Fetch fresh loras and update cache + getLoras(true).then((lorasDetails) => { + nodeType.prototype.lorasCache = lorasDetails; - // Update all nodes of this type - app.graph._nodes.forEach(node => { - if (node.type === "PowerLoraLoaderV2") { - // Update any existing widgets that might need the fresh data - for (const widget of node.widgets || []) { - if (widget.name?.startsWith("lora_")) { - // Update the widget's parent reference to this node - widget.parent = node; + // Update all nodes of this type + app.graph._nodes.forEach(node => { + if (node.type === "PowerLoraLoaderV2") { + // Update any existing widgets that might need the fresh data + for (const widget of node.widgets || []) { + if (widget.name?.startsWith("lora_")) { + // Update the widget's parent reference to this node + widget.parent = node; - // Refresh low variant detection for existing LoRAs with new cache - if (widget.value?.lora && widget.value.lora !== "None") { - const oldIsLow = widget.value.is_low; - const oldVariantName = widget.value.low_variant_name; + // Refresh low variant detection for existing LoRAs with new cache + if (widget.value?.lora && widget.value.lora !== "None") { + const oldIsLow = widget.value.is_low; + const oldVariantName = widget.value.low_variant_name; - // Re-run the low variant check with updated cache - widget.checkLowLoraVariant(widget.value.lora); + // Re-run the low variant check with updated cache + widget.checkLowLoraVariant(widget.value.lora); - // Log changes in low variant detection - if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) { - console.log(`[PowerLoraLoaderV2] Low variant status changed for '${widget.value.lora}': ` + - `${oldIsLow} -> ${widget.value.is_low}, ` + - `variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`); - } + // Log changes in low variant detection + if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) { + console.log(`[PowerLoraLoaderV2] Low variant status changed for '${widget.value.lora}': ` + + `${oldIsLow} -> ${widget.value.is_low}, ` + + `variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`); } } - } - // Trigger a redraw to update the green low icons - node.setDirtyCanvas(true, true); + // Trigger a value change to update the UI + widget.callback(widget.value); + } } - }); - }).catch(error => { - console.error('[PowerLoraLoaderV2] Error refreshing LoRA cache:', error); + + // Trigger a redraw to update the green low icons + node.setDirtyCanvas(true, true); + } }); + + console.log('[PowerLoraLoaderV2] LoRA definitions refreshed - individual LoRA info available in dialogs'); }).catch(error => { - console.error('[PowerLoraLoaderV2] Error refreshing LoRA info:', error); + console.error('[PowerLoraLoaderV2] Error refreshing LoRA list:', error); }); }; } diff --git a/web/power_lora_loader_js/rgthree_api.js b/web/power_lora_loader_js/rgthree_api.js index cc30bff..1ce97d1 100644 --- a/web/power_lora_loader_js/rgthree_api.js +++ b/web/power_lora_loader_js/rgthree_api.js @@ -13,15 +13,15 @@ class RgthreeApi { if (baseUrlArg) { baseUrl = baseUrlArg; } - else if (window.location.pathname.includes("/rgthree/")) { - const parts = (_a = window.location.pathname.split("/rgthree/")[1]) === null || _a === void 0 ? void 0 : _a.split("/"); + else if (window.location.pathname.includes("/wanvid/")) { + const parts = (_a = window.location.pathname.split("/wanvid/")[1]) === null || _a === void 0 ? void 0 : _a.split("/"); if (parts && parts.length) { - baseUrl = parts.map(() => "../").join("") + "rgthree/api"; + baseUrl = parts.map(() => "../").join("") + "wanvid/api"; } } - this.baseUrl = baseUrl || "./rgthree/api"; - const comfyBasePathname = location.pathname.includes("/rgthree/") - ? location.pathname.split("rgthree/")[0] + this.baseUrl = baseUrl || "./wanvid/api"; + const comfyBasePathname = location.pathname.includes("/wanvid/") + ? location.pathname.split("wanvid/")[0] : location.pathname; this.comfyBaseUrl = comfyBasePathname.split("/").slice(0, -1).join("/"); } @@ -77,42 +77,69 @@ class RgthreeApi { return this.getModelsInfo({ type: "checkpoints", ...options }); } async refreshModelsInfo(options) { - var _a; - const params = new URLSearchParams(); - if ((_a = options.files) === null || _a === void 0 ? void 0 : _a.length) { - params.set("files", options.files.join(",")); + // Always require files parameter - no bulk refresh allowed + if (!options.files || !options.files.length) { + throw new Error('refreshModelsInfo requires files parameter - bulk refresh is not supported'); } + + const params = new URLSearchParams(); + params.set("files", options.files.join(",")); + const path = `/${options.type}/info/refresh?` + params.toString(); const infos = await this.fetchApiJsonOrNull(path); return infos; } - async refreshLorasInfo(options = {}) { - // First clear the promise cache - this.getLorasPromise = null; - // Then refresh the models info - const result = await this.refreshModelsInfo({ type: "loras", ...options }); - // Finally, force a refresh of the loras list with cache busting - await this.getLoras(true); + + // Single file refresh functions - no bulk operations + async refreshSingleLoraInfo(file) { + if (!file) { + throw new Error('refreshSingleLoraInfo requires file parameter'); + } + + const result = await this.refreshModelsInfo({ type: "loras", files: [file] }); + + // Clear the specific file from cache + this.getLorasPromise = null; // Force refresh of list + return result; } - async refreshCheckpointsInfo(options = {}) { - return this.refreshModelsInfo({ type: "checkpoints", ...options }); + + async refreshSingleCheckpointInfo(file) { + if (!file) { + throw new Error('refreshSingleCheckpointInfo requires file parameter'); + } + + return this.refreshModelsInfo({ type: "checkpoints", files: [file] }); } async clearModelsInfo(options) { - var _a; - const params = new URLSearchParams(); - if ((_a = options.files) === null || _a === void 0 ? void 0 : _a.length) { - params.set("files", options.files.join(",")); + // Always require files parameter - no bulk clearing allowed + if (!options.files || !options.files.length) { + throw new Error('clearModelsInfo requires files parameter - bulk clearing is not supported'); } + + const params = new URLSearchParams(); + params.set("files", options.files.join(",")); + const path = `/${options.type}/info/clear?` + params.toString(); await this.fetchApiJsonOrNull(path); return; } - async clearLorasInfo(options = {}) { - return this.clearModelsInfo({ type: "loras", ...options }); + + // Single file clear functions - no bulk operations + async clearSingleLoraInfo(file) { + if (!file) { + throw new Error('clearSingleLoraInfo requires file parameter'); + } + + return this.clearModelsInfo({ type: "loras", files: [file] }); } - async clearCheckpointsInfo(options = {}) { - return this.clearModelsInfo({ type: "checkpoints", ...options }); + + async clearSingleCheckpointInfo(file) { + if (!file) { + throw new Error('clearSingleCheckpointInfo requires file parameter'); + } + + return this.clearModelsInfo({ type: "checkpoints", files: [file] }); } async saveModelInfo(type, file, data) { const body = new FormData(); diff --git a/web/power_lora_loader_js/widgets.js b/web/power_lora_loader_js/widgets.js index 8458651..7bdb6c0 100644 --- a/web/power_lora_loader_js/widgets.js +++ b/web/power_lora_loader_js/widgets.js @@ -706,7 +706,7 @@ export class PowerLoraLoaderWidget extends RgthreeBaseWidget { if (lora) { this.setLora(lora); } - }); + }, node); } onStrengthClick(event, pos, node) { diff --git a/web/power_spline_editor/persistence.js b/web/power_spline_editor/persistence.js index c47e824..53c72cb 100644 --- a/web/power_spline_editor/persistence.js +++ b/web/power_spline_editor/persistence.js @@ -4,6 +4,7 @@ import { app } from '../../../scripts/app.js'; const DRIVEN_DEFAULTS = { driver: "", rotate: 0, d_scale: 1.0 }; const EASING_DEFAULTS = { path: "each", strength: 1.0, acceleration: 0.00 }; const SCALE_DEFAULT = 1.00; +const SELECTED_FOLDER_DEFAULT = null; // == DRIVEN CONFIG PERSISTENCE --- @@ -183,10 +184,10 @@ export function updateScaleValue(widget, value) { if (typeof value !== 'number' || isNaN(value)) { value = SCALE_DEFAULT; } - + // Clamp to valid range value = Math.max(0.01, Math.min(8.00, value)); - + // Update the widget value widget.value.scale = value; @@ -194,3 +195,32 @@ export function updateScaleValue(widget, value) { app.graph.setDirtyCanvas(true, true); } } + + +// == SELECTED FOLDER PERSISTENCE == + +/** + * Initializes the SELECTED FOLDER configuration on a widget's value object. + * @param {object} widgetValue The widget.value object to initialize. + * @param {object} loadedValue The raw `v` object from the widget's `set value(v)` setter. + */ +export function initializeSelectedFolderConfig(widgetValue, loadedValue = null) { + // Ensure selectedFolder exists, using loaded data or default + if (widgetValue.selectedFolder === undefined) { + widgetValue.selectedFolder = loadedValue?.selectedFolder !== undefined ? loadedValue.selectedFolder : SELECTED_FOLDER_DEFAULT; + } +} + +/** + * Updates the selected folder value for a widget. + * @param {object} widget The widget instance. + * @param {string|null} value The new selected folder value. + */ +export function updateSelectedFolderValue(widget, value) { + // Update the widget value + widget.value.selectedFolder = value; + + if (app.graph) { + app.graph.setDirtyCanvas(true, true); + } +} diff --git a/web/rgthree/common/rgthree.js b/web/rgthree/common/rgthree.js new file mode 100644 index 0000000..f1b13be --- /dev/null +++ b/web/rgthree/common/rgthree.js @@ -0,0 +1,49 @@ +// Enhanced rgthree implementation for WanVideoWrapper QQ +// Provides proper toast messages and development utilities + +export const rgthree = { + showMessage: function(options) { + if (typeof options === 'string') { + // If it's just a string, convert to options object + options = { message: options }; + } + + const message = options.message || options.body || 'Unknown message'; + const timeout = options.timeout || 3000; + const type = options.type || 'info'; + + // Create nice toast message + const toast = document.createElement('div'); + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: ${type === 'error' ? '#d32f2f' : type === 'success' ? '#388e3c' : '#333'}; + color: white; + padding: 12px 20px; + border-radius: 4px; + z-index: 10000; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + font-family: Arial, sans-serif; + font-size: 12px; + max-width: 300px; + word-wrap: break-word; + `; + toast.textContent = message; + document.body.appendChild(toast); + + // Auto-remove after timeout + setTimeout(() => { + if (document.body.contains(toast)) { + document.body.removeChild(toast); + } + }, timeout); + }, + + isDevMode: function() { + // Check if we're in development mode + return window.location.hostname === '127.0.0.1' || + window.location.hostname === 'localhost' || + window.location.port === '8188'; + } +}; \ No newline at end of file