commit 1ed3c9ec4b6a8b122cfa009631aa4a75a2523e35 Author: Khoa (Revenovich) Tran Gia Date: Mon Mar 2 09:55:48 2026 +0700 Initial commit — ComfyUI Discord bot + web UI Full source for the-third-rev: Discord bot (discord.py), FastAPI web UI (React/TS/Vite/Tailwind), ComfyUI integration, generation history DB, preset manager, workflow inspector, and all supporting modules. Excluded from tracking: .env, invite_tokens.json, *.db (SQLite), current-workflow-changes.json, user_settings/, presets/, logs/, web-static/ (build output), frontend/node_modules/. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4d27233 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Normalize line endings to LF in the repository (CRLF on Windows checkout) +* text=auto eol=lf + +# Binary files — no line-ending conversion +*.db binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.mp4 binary +*.webm binary +*.zip binary +*.whl binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2dbfdea --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# ── Python ──────────────────────────────────────────────────────────────────── +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# ── Virtual environments ─────────────────────────────────────────────────────── +venv/ +env/ +ENV/ +.venv/ + +# ── Secrets / environment ───────────────────────────────────────────────────── +# Contains DISCORD_BOT_TOKEN and other credentials — NEVER commit +.env + +# Hashed invite tokens — auth credentials; regenerate via token_store.py CLI +invite_tokens.json + +# ── SQLite databases ────────────────────────────────────────────────────────── +# generation_history.db — user generation records +# input_images.db — image BLOBs (can be hundreds of MB) +*.db + +# ── Runtime / generated state ───────────────────────────────────────────────── +# Active workflow overrides (prompt, seed, etc.) — machine-local runtime state +current-workflow-changes.json + +# Per-user persistent settings (created at runtime under user labels) +user_settings/ + +# User-created presets — runtime data; not project source +presets/ + +# NSSM / service log files +logs/ + +# ── Frontend build artefacts ────────────────────────────────────────────────── +# Regenerate with: cd frontend && npm run build +web-static/ + +# npm dependencies — restored with: cd frontend && npm install +frontend/node_modules/ + +# Vite cache +frontend/.vite/ + +# ── IDE / editor ────────────────────────────────────────────────────────────── +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# ── OS ──────────────────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db + +# ── Syncthing ───────────────────────────────────────────────────────────────── +.stfolder/ +.stignore + +# ── Claude Code project files ───────────────────────────────────────────────── +# Local conversation history and per-project Claude config — not shared +.claude/ diff --git a/AIO.json b/AIO.json new file mode 100644 index 0000000..c0cfb95 --- /dev/null +++ b/AIO.json @@ -0,0 +1,4462 @@ +{ + "id": "2aa4d2f1-3f0d-431f-b2dd-ecd2da6eb639", + "revision": 0, + "last_node_id": 181, + "last_link_id": 31972, + "nodes": [ + { + "id": 9, + "type": "PreviewImage", + "pos": [ + -785.0748901367188, + -429.7545471191406 + ], + "size": [ + 576.9996337890625, + 604.3910522460938 + ], + "flags": {}, + "order": 43, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 333 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.36", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [] + }, + { + "id": 53, + "type": "PreviewImage", + "pos": [ + -199.56124877929688, + -414.9339294433594 + ], + "size": [ + 1808.1868896484375, + 1018.1249389648438 + ], + "flags": {}, + "order": 49, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 92 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.36", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [] + }, + { + "id": 58, + "type": "Anything Everywhere", + "pos": [ + -2876.5464584308, + -245.05321723767557 + ], + "size": [ + 172.1890625, + 46 + ], + "flags": { + "collapsed": false + }, + "order": 41, + "mode": 0, + "inputs": [ + { + "color_on": "#FFD500", + "label": "CLIP", + "name": "anything", + "shape": 7, + "type": "CLIP", + "link": 102 + }, + { + "label": "anything", + "name": "anything11", + "type": "*", + "link": null + } + ], + "outputs": [], + "properties": { + "cnr_id": "cg-use-everywhere", + "ver": "189b898245973555683e07299ce107147e697ac8", + "Node name for S&R": "Anything Everywhere", + "ue_properties": { + "version": "7.5", + "group_restricted": 0, + "color_restricted": 0, + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "title_regex": null, + "input_regex": null, + "group_regex": null, + "title_regex_invert": false, + "input_regex_invert": false, + "group_regex_invert": false, + "repeated_type_rule": 0, + "string_to_combo": 0, + "next_input_index": 11 + } + }, + "widgets_values": [] + }, + { + "id": 60, + "type": "LoRA Stacker", + "pos": [ + -3686.243481111911, + -390.2396839890429 + ], + "size": [ + 270, + 226 + ], + "flags": {}, + "order": 38, + "mode": 0, + "inputs": [ + { + "name": "lora_stack", + "shape": 7, + "type": "LORA_STACK", + "link": null + }, + { + "name": "lora_count", + "type": "INT", + "widget": { + "name": "lora_count" + }, + "link": 179 + }, + { + "name": "lora_wt_3", + "type": "FLOAT", + "widget": { + "name": "lora_wt_3" + }, + "link": 679 + } + ], + "outputs": [ + { + "name": "LORA_STACK", + "type": "LORA_STACK", + "links": [ + 129 + ] + } + ], + "properties": { + "cnr_id": "efficiency-nodes-comfyui", + "ver": "f0971b5553ead8f6e66bb99564431e2590cd3981", + "Node name for S&R": "LoRA Stacker", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "simple", + 3, + "breastsizeslideroffset.safetensors", + -0.4, + 1, + 1, + "cuteGirlMix4_v10.safetensors", + 0.4, + 1, + 1, + "Celestine_v1.safetensors", + 0.7, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1, + "None", + 1, + 1, + 1 + ], + "color": "#222233", + "bgcolor": "#333355", + "shape": 1 + }, + { + "id": 56, + "type": "CLIPTextEncode", + "pos": [ + -2800.2773792411585, + -1308.5540473972683 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": null + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 122 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "ultra-HD, high-res, 8k, masterpiece, best quality,standing,summer style,a beautiful asian girl,a cute girl,instagram influncer,(nude,nipples detail,pussy detail),indoor" + ] + }, + { + "id": 59, + "type": "CLIPTextEncode", + "pos": [ + -2795.3386651329865, + -1065.4995669473124 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": null + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 123 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "ultra-HD, high-res, 8k, masterpiece, best quality,standing,summer style,a beautiful asian girl,a cute girl,instagram influncer,(nude,nipples detail,pussy detail),indoor, elf, pointy ears" + ] + }, + { + "id": 78, + "type": "easy ifElse", + "pos": [ + -3675.9290490525987, + -575.6548663830428 + ], + "size": [ + 270, + 78 + ], + "flags": {}, + "order": 37, + "mode": 0, + "inputs": [ + { + "name": "on_true", + "type": "*", + "link": 181 + }, + { + "name": "on_false", + "type": "*", + "link": 182 + }, + { + "name": "boolean", + "type": "BOOLEAN", + "widget": { + "name": "boolean" + }, + "link": 180 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 179 + ] + } + ], + "properties": { + "cnr_id": "comfyui-easy-use", + "ver": "76b5896f089d5b4f7619559cf5f7fc0560e3dc40", + "Node name for S&R": "easy ifElse", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 77, + "type": "easy compare", + "pos": [ + -3088.0033716657067, + -691.0248922708711 + ], + "size": [ + 270, + 78 + ], + "flags": {}, + "order": 35, + "mode": 0, + "inputs": [ + { + "name": "a", + "shape": 7, + "type": "*", + "link": 217 + }, + { + "name": "b", + "shape": 7, + "type": "*", + "link": 175 + } + ], + "outputs": [ + { + "name": "boolean", + "type": "BOOLEAN", + "links": [ + 180 + ] + } + ], + "properties": { + "cnr_id": "comfyui-easy-use", + "ver": "76b5896f089d5b4f7619559cf5f7fc0560e3dc40", + "Node name for S&R": "easy compare", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "a == b" + ] + }, + { + "id": 79, + "type": "INTConstant", + "pos": [ + -3917.7444375272316, + -675.3345444142681 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "INT", + "links": [ + 181 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "INTConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 3 + ], + "color": "#1b4669", + "bgcolor": "#29699c" + }, + { + "id": 75, + "type": "INTConstant", + "pos": [ + -3916.8214352957066, + -783.3209186144337 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "INT", + "links": [ + 175, + 182 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "INTConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 2 + ], + "color": "#1b4669", + "bgcolor": "#29699c" + }, + { + "id": 81, + "type": "PreviewAny", + "pos": [ + -3679.8140361671194, + -108.16751728486656 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 34, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 680 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "PreviewAny", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [] + }, + { + "id": 63, + "type": "Bjornulf_RandomIntNode", + "pos": [ + -3757.4151538923693, + -973.8276193830604 + ], + "size": [ + 270, + 150 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 216 + ] + }, + { + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "bjornulf_custom_nodes", + "ver": "1.1.8", + "Node name for S&R": "Bjornulf_RandomIntNode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + 2, + 4039568042, + "randomize" + ] + }, + { + "id": 71, + "type": "ImpactSwitch", + "pos": [ + -2236.847118291885, + -810.1971541516524 + ], + "size": [ + 270, + 122 + ], + "flags": {}, + "order": 36, + "mode": 0, + "inputs": [ + { + "name": "input1", + "shape": 7, + "type": "*", + "link": 122 + }, + { + "name": "select", + "type": "INT", + "widget": { + "name": "select" + }, + "link": 218 + }, + { + "name": "input2", + "type": "CONDITIONING", + "link": 123 + }, + { + "name": "input3", + "type": "CONDITIONING", + "link": null + } + ], + "outputs": [ + { + "label": "CONDITIONING", + "name": "selected_value", + "type": "*", + "links": [ + 126 + ] + }, + { + "name": "selected_label", + "type": "STRING", + "links": null + }, + { + "name": "selected_index", + "type": "INT", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-impact-pack", + "ver": "2804f7944f125b1c10bc9d1fbb5a997a25a62726", + "Node name for S&R": "ImpactSwitch", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + false + ] + }, + { + "id": 54, + "type": "Efficient Loader", + "pos": [ + -3359.792062459413, + -391.61630026250486 + ], + "size": [ + 466.6376647949219, + 703.9756469726562 + ], + "flags": {}, + "order": 39, + "mode": 0, + "inputs": [ + { + "name": "lora_stack", + "shape": 7, + "type": "LORA_STACK", + "link": 129 + }, + { + "name": "cnet_stack", + "shape": 7, + "type": "CONTROL_NET_STACK", + "link": null + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 94 + ] + }, + { + "name": "CONDITIONING+", + "type": "CONDITIONING", + "links": [] + }, + { + "name": "CONDITIONING-", + "type": "CONDITIONING", + "links": [ + 229 + ] + }, + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 97 + ] + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 98 + ] + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 102 + ] + }, + { + "name": "DEPENDENCIES", + "type": "DEPENDENCIES", + "links": null + } + ], + "properties": { + "cnr_id": "efficiency-nodes-comfyui", + "ver": "9e3c5aa4976ad457065ef06a0dfdfc66e17c59ee", + "Node name for S&R": "Efficient Loader", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "majicmixRealistic_v7.safetensors", + "Baked VAE", + -1, + "None", + 1, + 1, + "ultra-HD, high-res, 8k, masterpiece, best quality,standing,summer style,a beautiful asian girl,a cute girl,instagram influncer,(nude,nipples detail,pussy detail),indoor", + "(worst quality:2), (low quality:2), (normal quality:2), lowres, bad anatomy, bad hands, normal quality, ((monochrome)), ((grayscale)) ,By bad artist -neg, BadDream ,badhandv4, ((big head))", + "none", + "comfy++", + 512, + 512, + 1 + ], + "color": "#2a363b", + "bgcolor": "#3f5159", + "shape": 1 + }, + { + "id": 104, + "type": "SaveImageExtended", + "pos": [ + -157.5774500659013, + 740.4545025528054 + ], + "size": [ + 400, + 698 + ], + "flags": {}, + "order": 50, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 282 + }, + { + "name": "positive_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "negative_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": 287 + }, + { + "name": "foldername_prefix", + "type": "STRING", + "widget": { + "name": "foldername_prefix" + }, + "link": 288 + } + ], + "outputs": [], + "properties": { + "cnr_id": "save-image-extended-comfyui", + "ver": "2.64.0", + "Node name for S&R": "SaveImageExtended", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "ComfyUI", + "%F-%H-%M-%S", + "", + "", + "-", + "basic, models, sampler, prompt", + true, + "", + true, + 6, + "last", + true, + true, + ".png", + 100 + ] + }, + { + "id": 96, + "type": "StringConstant", + "pos": [ + -4921.178206421058, + 1737.3660439469718 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 263, + 344, + 345 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "StringConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "hv" + ] + }, + { + "id": 97, + "type": "StringConstant", + "pos": [ + -3785.210301363505, + 1732.9362022271741 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 264, + 348, + 349 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "StringConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "mp" + ] + }, + { + "id": 105, + "type": "StringConstant", + "pos": [ + -2482.55681281589, + 1721.3048910725645 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 302, + 351, + 352 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "StringConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "ts" + ] + }, + { + "id": 107, + "type": "StringConstant", + "pos": [ + -1374.4114864811809, + 1735.6053972163706 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 304, + 353, + 354 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "StringConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "ht" + ] + }, + { + "id": 125, + "type": "SaveImageExtended", + "pos": [ + -1033.3606592904016, + 2132.0417700881126 + ], + "size": [ + 400, + 698 + ], + "flags": {}, + "order": 60, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 664 + }, + { + "name": "positive_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "negative_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": 353 + }, + { + "name": "foldername_prefix", + "type": "STRING", + "widget": { + "name": "foldername_prefix" + }, + "link": 354 + } + ], + "outputs": [], + "properties": { + "cnr_id": "save-image-extended-comfyui", + "ver": "2.64.0", + "Node name for S&R": "SaveImageExtended", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "ComfyUI", + "%F-%H-%M-%S", + "", + "", + "-", + "basic, models, sampler, prompt", + true, + "", + true, + 6, + "last", + true, + true, + ".png", + 100 + ] + }, + { + "id": 108, + "type": "LoadImage", + "pos": [ + -1094.7510959279175, + 1735.2298357605498 + ], + "size": [ + 493.00128173828125, + 347.702880859375 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 303, + 343 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.36", + "Node name for S&R": "LoadImage", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "00190-3379197727cut.png", + "image" + ] + }, + { + "id": 124, + "type": "SaveImageExtended", + "pos": [ + -2124.073476608162, + 2119.932431614931 + ], + "size": [ + 400, + 698 + ], + "flags": {}, + "order": 58, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 662 + }, + { + "name": "positive_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "negative_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": 351 + }, + { + "name": "foldername_prefix", + "type": "STRING", + "widget": { + "name": "foldername_prefix" + }, + "link": 352 + } + ], + "outputs": [], + "properties": { + "cnr_id": "save-image-extended-comfyui", + "ver": "2.64.0", + "Node name for S&R": "SaveImageExtended", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "ComfyUI", + "%F-%H-%M-%S", + "", + "", + "-", + "basic, models, sampler, prompt", + true, + "", + true, + 6, + "last", + true, + true, + ".png", + 100 + ] + }, + { + "id": 88, + "type": "LoadImage", + "pos": [ + -3484.4834104657566, + 1716.7607655129898 + ], + "size": [ + 493.00128173828125, + 347.702880859375 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 257, + 341 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.36", + "Node name for S&R": "LoadImage", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "mp_6.jpg", + "image" + ] + }, + { + "id": 123, + "type": "SaveImageExtended", + "pos": [ + -3443.009525536289, + 2100.6695985764004 + ], + "size": [ + 400, + 698 + ], + "flags": {}, + "order": 57, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 659 + }, + { + "name": "positive_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "negative_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": 348 + }, + { + "name": "foldername_prefix", + "type": "STRING", + "widget": { + "name": "foldername_prefix" + }, + "link": 349 + } + ], + "outputs": [], + "properties": { + "cnr_id": "save-image-extended-comfyui", + "ver": "2.64.0", + "Node name for S&R": "SaveImageExtended", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "ComfyUI", + "%F-%H-%M-%S", + "", + "", + "-", + "basic, models, sampler, prompt", + true, + "", + true, + 6, + "last", + true, + true, + ".png", + 100 + ] + }, + { + "id": 122, + "type": "SaveImageExtended", + "pos": [ + -4590.5179927457575, + 2137.5263851540244 + ], + "size": [ + 400, + 698 + ], + "flags": {}, + "order": 55, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 626 + }, + { + "name": "positive_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "negative_text_opt", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": 344 + }, + { + "name": "foldername_prefix", + "type": "STRING", + "widget": { + "name": "foldername_prefix" + }, + "link": 345 + } + ], + "outputs": [], + "properties": { + "cnr_id": "save-image-extended-comfyui", + "ver": "2.64.0", + "Node name for S&R": "SaveImageExtended", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + "ComfyUI", + "%F-%H-%M-%S", + "", + "", + "-", + "basic, models, sampler, prompt", + true, + "", + true, + 6, + "last", + true, + true, + ".png", + 100 + ] + }, + { + "id": 55, + "type": "LoadImage", + "pos": [ + -4628.615372058339, + 1727.8624989184755 + ], + "size": [ + 493.00128173828125, + 347.702880859375 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 255, + 340 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.36", + "Node name for S&R": "LoadImage", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "Screenshot 2025-11-15 195853.png", + "image" + ] + }, + { + "id": 98, + "type": "ImpactSwitch", + "pos": [ + -537.9980728499417, + 839.3665406046165 + ], + "size": [ + 270, + 162 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "name": "input1", + "shape": 7, + "type": "*", + "link": 263 + }, + { + "name": "select", + "type": "INT", + "widget": { + "name": "select" + }, + "link": 265 + }, + { + "name": "input2", + "type": "STRING", + "link": 264 + }, + { + "name": "input3", + "type": "STRING", + "link": 302 + }, + { + "name": "input4", + "type": "STRING", + "link": 304 + }, + { + "name": "input5", + "type": "STRING", + "link": null + } + ], + "outputs": [ + { + "label": "STRING", + "name": "selected_value", + "type": "*", + "links": [ + 287, + 288 + ] + }, + { + "name": "selected_label", + "type": "STRING", + "links": null + }, + { + "name": "selected_index", + "type": "INT", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-impact-pack", + "ver": "2804f7944f125b1c10bc9d1fbb5a997a25a62726", + "Node name for S&R": "ImpactSwitch", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + false + ] + }, + { + "id": 94, + "type": "ImpactSwitch", + "pos": [ + -538.8651521165418, + 595.9837313526884 + ], + "size": [ + 270, + 162 + ], + "flags": {}, + "order": 33, + "mode": 0, + "inputs": [ + { + "name": "input1", + "shape": 7, + "type": "*", + "link": 255 + }, + { + "name": "select", + "type": "INT", + "widget": { + "name": "select" + }, + "link": 254 + }, + { + "name": "input2", + "type": "IMAGE", + "link": 257 + }, + { + "name": "input3", + "type": "IMAGE", + "link": 301 + }, + { + "name": "input4", + "type": "IMAGE", + "link": 303 + }, + { + "name": "input5", + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "label": "IMAGE", + "name": "selected_value", + "type": "*", + "links": [ + 256 + ] + }, + { + "name": "selected_label", + "type": "STRING", + "links": null + }, + { + "name": "selected_index", + "type": "INT", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-impact-pack", + "ver": "2804f7944f125b1c10bc9d1fbb5a997a25a62726", + "Node name for S&R": "ImpactSwitch", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + false + ] + }, + { + "id": 16, + "type": "ReActorFaceSwap", + "pos": [ + -552.716694578189, + 194.2061044781388 + ], + "size": [ + 315, + 358 + ], + "flags": {}, + "order": 44, + "mode": 0, + "inputs": [ + { + "name": "input_image", + "type": "IMAGE", + "link": 334 + }, + { + "name": "source_image", + "shape": 7, + "type": "IMAGE", + "link": 256 + }, + { + "name": "face_model", + "shape": 7, + "type": "FACE_MODEL", + "link": null + }, + { + "name": "face_boost", + "shape": 7, + "type": "FACE_BOOST", + "link": null + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 92, + 282 + ] + }, + { + "name": "FACE_MODEL", + "type": "FACE_MODEL", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-reactor-node", + "ver": "89dba216aaed06bce7177be327b78e0f49c9f800", + "Node name for S&R": "ReActorFaceSwap", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + true, + "inswapper_128.onnx", + "retinaface_resnet50", + "GFPGANv1.4.pth", + 1, + 0.5, + "no", + "no", + "0,1", + "0", + 1 + ] + }, + { + "id": 93, + "type": "Bjornulf_RandomIntNode", + "pos": [ + -872.7650578932331, + 739.3272047327746 + ], + "size": [ + 270, + 150 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 254, + 265 + ] + }, + { + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "bjornulf_custom_nodes", + "ver": "1.1.8", + "Node name for S&R": "Bjornulf_RandomIntNode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + 4, + 1636957159, + "randomize" + ] + }, + { + "id": 126, + "type": "Bjornulf_RandomIntNode", + "pos": [ + -1856.5620973676191, + -757.0513673794609 + ], + "size": [ + 270, + 150 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 362 + ] + }, + { + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "bjornulf_custom_nodes", + "ver": "1.1.8", + "Node name for S&R": "Bjornulf_RandomIntNode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + 2, + 1623342495, + "randomize" + ] + }, + { + "id": 118, + "type": "ImpactInversedSwitch", + "pos": [ + -1749.4769456149866, + -368.06366887427123 + ], + "size": [ + 270, + 122 + ], + "flags": {}, + "order": 42, + "mode": 0, + "inputs": [ + { + "name": "input", + "type": "IMAGE", + "link": 332 + }, + { + "name": "select", + "type": "INT", + "widget": { + "name": "select" + }, + "link": 364 + } + ], + "outputs": [ + { + "name": "output1", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 333, + 334 + ] + }, + { + "name": "output2", + "type": "IMAGE", + "slot_index": 1, + "links": [ + 587, + 588, + 589, + 590 + ] + }, + { + "name": "output3", + "type": "IMAGE", + "slot_index": 2, + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-impact-pack", + "ver": "2804f7944f125b1c10bc9d1fbb5a997a25a62726", + "Node name for S&R": "ImpactInversedSwitch", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + false + ] + }, + { + "id": 112, + "type": "ReActorFaceSwap", + "pos": [ + -4119.793775784407, + 1739.9823515071641 + ], + "size": [ + 315, + 358 + ], + "flags": { + "collapsed": false + }, + "order": 45, + "mode": 0, + "inputs": [ + { + "name": "input_image", + "type": "IMAGE", + "link": 587 + }, + { + "name": "source_image", + "shape": 7, + "type": "IMAGE", + "link": 340 + }, + { + "name": "face_model", + "shape": 7, + "type": "FACE_MODEL", + "link": null + }, + { + "name": "face_boost", + "shape": 7, + "type": "FACE_BOOST", + "link": null + }, + { + "name": "enabled", + "type": "BOOLEAN", + "widget": { + "name": "enabled" + }, + "link": 628 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 625 + ] + }, + { + "name": "FACE_MODEL", + "type": "FACE_MODEL", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-reactor-node", + "ver": "89dba216aaed06bce7177be327b78e0f49c9f800", + "Node name for S&R": "ReActorFaceSwap", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + false, + "inswapper_128.onnx", + "retinaface_resnet50", + "GFPGANv1.4.pth", + 1, + 0.5, + "no", + "no", + "0,1", + "0", + 1 + ] + }, + { + "id": 158, + "type": "PreviewAny", + "pos": [ + -4136.327806715713, + 2300.636076375613 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 56, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 627 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "PreviewAny", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [] + }, + { + "id": 157, + "type": "ImpactInversedSwitch", + "pos": [ + -4142.834949976452, + 2135.6300914477433 + ], + "size": [ + 270, + 122 + ], + "flags": {}, + "order": 51, + "mode": 0, + "inputs": [ + { + "name": "input", + "type": "IMAGE", + "link": 625 + }, + { + "name": "select", + "type": "INT", + "widget": { + "name": "select" + }, + "link": 630 + } + ], + "outputs": [ + { + "name": "output1", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 626 + ] + }, + { + "name": "output2", + "type": "IMAGE", + "slot_index": 1, + "links": [ + 627 + ] + }, + { + "name": "output3", + "type": "IMAGE", + "slot_index": 2, + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-impact-pack", + "ver": "2804f7944f125b1c10bc9d1fbb5a997a25a62726", + "Node name for S&R": "ImpactInversedSwitch", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + false + ] + }, + { + "id": 160, + "type": "easy ifElse", + "pos": [ + -4485.880304285801, + 1579.1950577396924 + ], + "size": [ + 270, + 78 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "name": "on_true", + "type": "*", + "link": 632 + }, + { + "name": "on_false", + "type": "*", + "link": 631 + }, + { + "name": "boolean", + "type": "BOOLEAN", + "widget": { + "name": "boolean" + }, + "link": 629 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 630 + ] + } + ], + "properties": { + "cnr_id": "comfyui-easy-use", + "ver": "76b5896f089d5b4f7619559cf5f7fc0560e3dc40", + "Node name for S&R": "easy ifElse", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 167, + "type": "easy ifElse", + "pos": [ + -3352.837528865682, + 1583.5458461158871 + ], + "size": [ + 270, + 78 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "name": "on_true", + "type": "*", + "link": 652 + }, + { + "name": "on_false", + "type": "*", + "link": 657 + }, + { + "name": "boolean", + "type": "BOOLEAN", + "widget": { + "name": "boolean" + }, + "link": 640 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 641 + ] + } + ], + "properties": { + "cnr_id": "comfyui-easy-use", + "ver": "76b5896f089d5b4f7619559cf5f7fc0560e3dc40", + "Node name for S&R": "easy ifElse", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 170, + "type": "PreviewAny", + "pos": [ + -3003.2850312955948, + 2304.986864751808 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 61, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 660 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "PreviewAny", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [] + }, + { + "id": 174, + "type": "PreviewAny", + "pos": [ + -1657.6139674010549, + 2329.5130633757894 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 59, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 645 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "PreviewAny", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [] + }, + { + "id": 178, + "type": "PreviewAny", + "pos": [ + -567.0155687906454, + 2316.4324490590075 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 62, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 665 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "PreviewAny", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [] + }, + { + "id": 175, + "type": "easy ifElse", + "pos": [ + -916.5680663607328, + 1594.9914304230851 + ], + "size": [ + 270, + 78 + ], + "flags": {}, + "order": 30, + "mode": 0, + "inputs": [ + { + "name": "on_true", + "type": "*", + "link": 654 + }, + { + "name": "on_false", + "type": "*", + "link": 655 + }, + { + "name": "boolean", + "type": "BOOLEAN", + "widget": { + "name": "boolean" + }, + "link": 646 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 647 + ] + } + ], + "properties": { + "cnr_id": "comfyui-easy-use", + "ver": "76b5896f089d5b4f7619559cf5f7fc0560e3dc40", + "Node name for S&R": "easy ifElse", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 161, + "type": "INTConstant", + "pos": [ + -3944.129734641443, + 925.2077691047783 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "INT", + "links": [ + 632, + 652, + 653, + 654 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "INTConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1 + ], + "color": "#1b4669", + "bgcolor": "#29699c" + }, + { + "id": 171, + "type": "easy ifElse", + "pos": [ + -2088.9204603844128, + 1599.8965952998656 + ], + "size": [ + 270, + 78 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "name": "on_true", + "type": "*", + "link": 653 + }, + { + "name": "on_false", + "type": "*", + "link": 656 + }, + { + "name": "boolean", + "type": "BOOLEAN", + "widget": { + "name": "boolean" + }, + "link": 643 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 644 + ] + } + ], + "properties": { + "cnr_id": "comfyui-easy-use", + "ver": "76b5896f089d5b4f7619559cf5f7fc0560e3dc40", + "Node name for S&R": "easy ifElse", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 162, + "type": "INTConstant", + "pos": [ + -3937.9874359627574, + 1032.4187835465143 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "INT", + "links": [ + 631, + 655, + 656, + 657 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "INTConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 2 + ], + "color": "#1b4669", + "bgcolor": "#29699c" + }, + { + "id": 111, + "type": "ReActorFaceSwap", + "pos": [ + -2983.2783381174518, + 1726.1908274504008 + ], + "size": [ + 315, + 358 + ], + "flags": { + "collapsed": false + }, + "order": 46, + "mode": 0, + "inputs": [ + { + "name": "input_image", + "type": "IMAGE", + "link": 588 + }, + { + "name": "source_image", + "shape": 7, + "type": "IMAGE", + "link": 341 + }, + { + "name": "face_model", + "shape": 7, + "type": "FACE_MODEL", + "link": null + }, + { + "name": "face_boost", + "shape": 7, + "type": "FACE_BOOST", + "link": null + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 658 + ] + }, + { + "name": "FACE_MODEL", + "type": "FACE_MODEL", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-reactor-node", + "ver": "89dba216aaed06bce7177be327b78e0f49c9f800", + "Node name for S&R": "ReActorFaceSwap", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + true, + "inswapper_128.onnx", + "retinaface_resnet50", + "GFPGANv1.4.pth", + 1, + 0.5, + "no", + "no", + "0,1", + "0", + 1 + ] + }, + { + "id": 169, + "type": "ImpactInversedSwitch", + "pos": [ + -3009.792174556333, + 2139.9808798239383 + ], + "size": [ + 270, + 122 + ], + "flags": {}, + "order": 52, + "mode": 0, + "inputs": [ + { + "name": "input", + "type": "IMAGE", + "link": 658 + }, + { + "name": "select", + "type": "INT", + "widget": { + "name": "select" + }, + "link": 641 + } + ], + "outputs": [ + { + "name": "output1", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 659 + ] + }, + { + "name": "output2", + "type": "IMAGE", + "slot_index": 2, + "links": null + }, + { + "name": "output3", + "type": "IMAGE", + "slot_index": 2, + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-impact-pack", + "ver": "2804f7944f125b1c10bc9d1fbb5a997a25a62726", + "Node name for S&R": "ImpactInversedSwitch", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + false + ] + }, + { + "id": 109, + "type": "ReActorFaceSwap", + "pos": [ + -1666.1278605430243, + 1752.79651610413 + ], + "size": [ + 315, + 358 + ], + "flags": { + "collapsed": false + }, + "order": 47, + "mode": 0, + "inputs": [ + { + "name": "input_image", + "type": "IMAGE", + "link": 589 + }, + { + "name": "source_image", + "shape": 7, + "type": "IMAGE", + "link": 342 + }, + { + "name": "face_model", + "shape": 7, + "type": "FACE_MODEL", + "link": null + }, + { + "name": "face_boost", + "shape": 7, + "type": "FACE_BOOST", + "link": null + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 661 + ] + }, + { + "name": "FACE_MODEL", + "type": "FACE_MODEL", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-reactor-node", + "ver": "89dba216aaed06bce7177be327b78e0f49c9f800", + "Node name for S&R": "ReActorFaceSwap", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + true, + "inswapper_128.onnx", + "retinaface_resnet50", + "GFPGANv1.4.pth", + 1, + 0.5, + "no", + "no", + "0,1", + "0", + 1 + ] + }, + { + "id": 173, + "type": "ImpactInversedSwitch", + "pos": [ + -1664.1211106617932, + 2164.507078447923 + ], + "size": [ + 270, + 122 + ], + "flags": {}, + "order": 53, + "mode": 0, + "inputs": [ + { + "name": "input", + "type": "IMAGE", + "link": 661 + }, + { + "name": "select", + "type": "INT", + "widget": { + "name": "select" + }, + "link": 644 + } + ], + "outputs": [ + { + "name": "output1", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 662 + ] + }, + { + "name": "output2", + "type": "IMAGE", + "slot_index": 1, + "links": [ + 645 + ] + }, + { + "name": "output3", + "type": "IMAGE", + "slot_index": 2, + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-impact-pack", + "ver": "2804f7944f125b1c10bc9d1fbb5a997a25a62726", + "Node name for S&R": "ImpactInversedSwitch", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + false + ] + }, + { + "id": 110, + "type": "ReActorFaceSwap", + "pos": [ + -569.8845630549902, + 1725.065864530079 + ], + "size": [ + 315, + 358 + ], + "flags": { + "collapsed": false + }, + "order": 48, + "mode": 0, + "inputs": [ + { + "name": "input_image", + "type": "IMAGE", + "link": 590 + }, + { + "name": "source_image", + "shape": 7, + "type": "IMAGE", + "link": 343 + }, + { + "name": "face_model", + "shape": 7, + "type": "FACE_MODEL", + "link": null + }, + { + "name": "face_boost", + "shape": 7, + "type": "FACE_BOOST", + "link": null + }, + { + "name": "enabled", + "type": "BOOLEAN", + "widget": { + "name": "enabled" + }, + "link": 651 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 663 + ] + }, + { + "name": "FACE_MODEL", + "type": "FACE_MODEL", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-reactor-node", + "ver": "89dba216aaed06bce7177be327b78e0f49c9f800", + "Node name for S&R": "ReActorFaceSwap", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + true, + "inswapper_128.onnx", + "retinaface_resnet50", + "GFPGANv1.4.pth", + 1, + 0.5, + "no", + "no", + "0,1", + "0", + 1 + ] + }, + { + "id": 177, + "type": "ImpactInversedSwitch", + "pos": [ + -573.5227120513837, + 2151.426464131138 + ], + "size": [ + 270, + 122 + ], + "flags": {}, + "order": 54, + "mode": 0, + "inputs": [ + { + "name": "input", + "type": "IMAGE", + "link": 663 + }, + { + "name": "select", + "type": "INT", + "widget": { + "name": "select" + }, + "link": 647 + } + ], + "outputs": [ + { + "name": "output1", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 664 + ] + }, + { + "name": "output2", + "type": "IMAGE", + "slot_index": 2, + "links": null + }, + { + "name": "output3", + "type": "IMAGE", + "slot_index": 2, + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-impact-pack", + "ver": "2804f7944f125b1c10bc9d1fbb5a997a25a62726", + "Node name for S&R": "ImpactInversedSwitch", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1, + false + ] + }, + { + "id": 168, + "type": "BOOLConstant", + "pos": [ + -3707.974138814297, + 1605.8814947154701 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "BOOLEAN", + "links": [ + 640 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "BOOLConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + true + ] + }, + { + "id": 80, + "type": "Bjornulf_RandomFloatNode", + "pos": [ + -4449.011833238098, + 23.833729089286628 + ], + "size": [ + 270, + 150 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 681 + ] + }, + { + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "bjornulf_custom_nodes", + "ver": "1.1.8", + "Node name for S&R": "Bjornulf_RandomFloatNode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 0.5, + 0.7, + 1621833922, + "randomize" + ] + }, + { + "id": 179, + "type": "easy ifElse", + "pos": [ + -4034.832346104166, + -279.10315864048783 + ], + "size": [ + 270, + 78 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "name": "on_true", + "type": "*", + "link": 681 + }, + { + "name": "on_false", + "type": "*", + "link": 682 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 679, + 680 + ] + } + ], + "properties": { + "cnr_id": "comfyui-easy-use", + "ver": "76b5896f089d5b4f7619559cf5f7fc0560e3dc40", + "Node name for S&R": "easy ifElse", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 181, + "type": "FloatConstant", + "pos": [ + -4403.514668307471, + -124.22976805120429 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "FLOAT", + "links": [ + 682 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "FloatConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 0.65 + ], + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 159, + "type": "BOOLConstant", + "pos": [ + -4841.016914234416, + 1601.5307063392754 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "BOOLEAN", + "links": [ + 628, + 629 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "BOOLConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 172, + "type": "BOOLConstant", + "pos": [ + -2457.137709599152, + 1592.8009053480287 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "BOOLEAN", + "links": [ + 643 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "BOOLConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 176, + "type": "BOOLConstant", + "pos": [ + -1271.7046763093467, + 1617.3270790226682 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "BOOLEAN", + "links": [ + 646, + 651 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "BOOLConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 85, + "type": "INTConstant", + "pos": [ + -3678.9403323524853, + -1116.948018923497 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "INT", + "links": [ + 219 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "INTConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 1 + ], + "color": "#1b4669", + "bgcolor": "#29699c" + }, + { + "id": 84, + "type": "easy ifElse", + "pos": [ + -3287.101995393986, + -949.976244493646 + ], + "size": [ + 270, + 78 + ], + "flags": {}, + "order": 31, + "mode": 0, + "inputs": [ + { + "name": "on_true", + "type": "*", + "link": 216 + }, + { + "name": "on_false", + "type": "*", + "link": 219 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 217, + 218 + ] + } + ], + "properties": { + "cnr_id": "comfyui-easy-use", + "ver": "76b5896f089d5b4f7619559cf5f7fc0560e3dc40", + "Node name for S&R": "easy ifElse", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + true + ] + }, + { + "id": 129, + "type": "INTConstant", + "pos": [ + -1979.9161621760024, + -548.0289360666728 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "value", + "type": "INT", + "links": [ + 363 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "c661baadd9683c0033cd2a6ad90157c6d099a6c2", + "Node name for S&R": "INTConstant", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + 2 + ], + "color": "#1b4669", + "bgcolor": "#29699c" + }, + { + "id": 130, + "type": "Switch any [Crystools]", + "pos": [ + -1718.1311491647123, + -542.994608893379 + ], + "size": [ + 270, + 78 + ], + "flags": {}, + "order": 32, + "mode": 0, + "inputs": [ + { + "name": "on_true", + "type": "*", + "link": 362 + }, + { + "name": "on_false", + "type": "*", + "link": 363 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": [ + 364 + ] + } + ], + "properties": { + "cnr_id": "ComfyUI-Crystools", + "ver": "2f18256c5b5063937106f29a8e0a7db3ae3869b7", + "Node name for S&R": "Switch any [Crystools]", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5" + } + }, + "widgets_values": [ + false + ] + }, + { + "id": 106, + "type": "LoadImage", + "pos": [ + -2169.0101191690205, + 1730.941389552231 + ], + "size": [ + 493.00128173828125, + 347.702880859375 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 301, + 342 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.36", + "Node name for S&R": "LoadImage", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "295565292_3208328849497440_2193062820573065028_n (1).jpg", + "image" + ] + }, + { + "id": 5, + "type": "KSampler (Efficient)", + "pos": [ + -2206.3061370512023, + -388.0646703912667 + ], + "size": [ + 325, + 562 + ], + "flags": {}, + "order": 40, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 94 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 126 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 229 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 97 + }, + { + "name": "optional_vae", + "shape": 7, + "type": "VAE", + "link": 98 + }, + { + "name": "script", + "shape": 7, + "type": "SCRIPT", + "link": null + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": null + }, + { + "name": "CONDITIONING+", + "type": "CONDITIONING", + "links": null + }, + { + "name": "CONDITIONING-", + "type": "CONDITIONING", + "links": null + }, + { + "name": "LATENT", + "type": "LATENT", + "links": null + }, + { + "name": "VAE", + "type": "VAE", + "links": null + }, + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 5, + "links": [ + 332 + ] + } + ], + "properties": { + "cnr_id": "efficiency-nodes-comfyui", + "ver": "9e3c5aa4976ad457065ef06a0dfdfc66e17c59ee", + "Node name for S&R": "KSampler (Efficient)", + "ue_properties": { + "version": "7.5", + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + -1, + null, + 25, + 3, + "euler_ancestral", + "normal", + 0.9500000000000001, + "auto", + "true" + ], + "color": "#332233", + "bgcolor": "#553355", + "shape": 1 + } + ], + "links": [ + [ + 92, + 16, + 0, + 53, + 0, + "IMAGE" + ], + [ + 94, + 54, + 0, + 5, + 0, + "MODEL" + ], + [ + 97, + 54, + 3, + 5, + 3, + "LATENT" + ], + [ + 98, + 54, + 4, + 5, + 4, + "VAE" + ], + [ + 102, + 54, + 5, + 58, + 0, + "CLIP" + ], + [ + 122, + 56, + 0, + 71, + 0, + "*" + ], + [ + 123, + 59, + 0, + 71, + 2, + "CONDITIONING" + ], + [ + 126, + 71, + 0, + 5, + 1, + "CONDITIONING" + ], + [ + 129, + 60, + 0, + 54, + 0, + "LORA_STACK" + ], + [ + 175, + 75, + 0, + 77, + 1, + "*" + ], + [ + 179, + 78, + 0, + 60, + 1, + "INT" + ], + [ + 180, + 77, + 0, + 78, + 2, + "BOOLEAN" + ], + [ + 181, + 79, + 0, + 78, + 0, + "*" + ], + [ + 182, + 75, + 0, + 78, + 1, + "*" + ], + [ + 216, + 63, + 0, + 84, + 0, + "*" + ], + [ + 217, + 84, + 0, + 77, + 0, + "*" + ], + [ + 218, + 84, + 0, + 71, + 1, + "INT" + ], + [ + 219, + 85, + 0, + 84, + 1, + "*" + ], + [ + 229, + 54, + 2, + 5, + 2, + "CONDITIONING" + ], + [ + 254, + 93, + 0, + 94, + 1, + "INT" + ], + [ + 255, + 55, + 0, + 94, + 0, + "*" + ], + [ + 256, + 94, + 0, + 16, + 1, + "IMAGE" + ], + [ + 257, + 88, + 0, + 94, + 2, + "IMAGE" + ], + [ + 263, + 96, + 0, + 98, + 0, + "*" + ], + [ + 264, + 97, + 0, + 98, + 2, + "STRING" + ], + [ + 265, + 93, + 0, + 98, + 1, + "INT" + ], + [ + 282, + 16, + 0, + 104, + 0, + "IMAGE" + ], + [ + 287, + 98, + 0, + 104, + 3, + "STRING" + ], + [ + 288, + 98, + 0, + 104, + 4, + "STRING" + ], + [ + 301, + 106, + 0, + 94, + 3, + "IMAGE" + ], + [ + 302, + 105, + 0, + 98, + 3, + "STRING" + ], + [ + 303, + 108, + 0, + 94, + 4, + "IMAGE" + ], + [ + 304, + 107, + 0, + 98, + 4, + "STRING" + ], + [ + 332, + 5, + 5, + 118, + 0, + "*" + ], + [ + 333, + 118, + 0, + 9, + 0, + "IMAGE" + ], + [ + 334, + 118, + 0, + 16, + 0, + "IMAGE" + ], + [ + 340, + 55, + 0, + 112, + 1, + "IMAGE" + ], + [ + 341, + 88, + 0, + 111, + 1, + "IMAGE" + ], + [ + 342, + 106, + 0, + 109, + 1, + "IMAGE" + ], + [ + 343, + 108, + 0, + 110, + 1, + "IMAGE" + ], + [ + 344, + 96, + 0, + 122, + 3, + "STRING" + ], + [ + 345, + 96, + 0, + 122, + 4, + "STRING" + ], + [ + 348, + 97, + 0, + 123, + 3, + "STRING" + ], + [ + 349, + 97, + 0, + 123, + 4, + "STRING" + ], + [ + 351, + 105, + 0, + 124, + 3, + "STRING" + ], + [ + 352, + 105, + 0, + 124, + 4, + "STRING" + ], + [ + 353, + 107, + 0, + 125, + 3, + "STRING" + ], + [ + 354, + 107, + 0, + 125, + 4, + "STRING" + ], + [ + 362, + 126, + 0, + 130, + 0, + "*" + ], + [ + 363, + 129, + 0, + 130, + 1, + "*" + ], + [ + 364, + 130, + 0, + 118, + 1, + "INT" + ], + [ + 587, + 118, + 1, + 112, + 0, + "IMAGE" + ], + [ + 588, + 118, + 1, + 111, + 0, + "IMAGE" + ], + [ + 589, + 118, + 1, + 109, + 0, + "IMAGE" + ], + [ + 590, + 118, + 1, + 110, + 0, + "IMAGE" + ], + [ + 625, + 112, + 0, + 157, + 0, + "*" + ], + [ + 626, + 157, + 0, + 122, + 0, + "IMAGE" + ], + [ + 627, + 157, + 1, + 158, + 0, + "*" + ], + [ + 628, + 159, + 0, + 112, + 4, + "BOOLEAN" + ], + [ + 629, + 159, + 0, + 160, + 2, + "BOOLEAN" + ], + [ + 630, + 160, + 0, + 157, + 1, + "INT" + ], + [ + 631, + 162, + 0, + 160, + 1, + "*" + ], + [ + 632, + 161, + 0, + 160, + 0, + "*" + ], + [ + 640, + 168, + 0, + 167, + 2, + "BOOLEAN" + ], + [ + 641, + 167, + 0, + 169, + 1, + "INT" + ], + [ + 643, + 172, + 0, + 171, + 2, + "BOOLEAN" + ], + [ + 644, + 171, + 0, + 173, + 1, + "INT" + ], + [ + 645, + 173, + 1, + 174, + 0, + "*" + ], + [ + 646, + 176, + 0, + 175, + 2, + "BOOLEAN" + ], + [ + 647, + 175, + 0, + 177, + 1, + "INT" + ], + [ + 651, + 176, + 0, + 110, + 4, + "BOOLEAN" + ], + [ + 652, + 161, + 0, + 167, + 0, + "*" + ], + [ + 653, + 161, + 0, + 171, + 0, + "*" + ], + [ + 654, + 161, + 0, + 175, + 0, + "*" + ], + [ + 655, + 162, + 0, + 175, + 1, + "*" + ], + [ + 656, + 162, + 0, + 171, + 1, + "*" + ], + [ + 657, + 162, + 0, + 167, + 1, + "*" + ], + [ + 658, + 111, + 0, + 169, + 0, + "*" + ], + [ + 659, + 169, + 0, + 123, + 0, + "IMAGE" + ], + [ + 660, + 169, + 1, + 170, + 0, + "*" + ], + [ + 661, + 109, + 0, + 173, + 0, + "*" + ], + [ + 662, + 173, + 0, + 124, + 0, + "IMAGE" + ], + [ + 663, + 110, + 0, + 177, + 0, + "*" + ], + [ + 664, + 177, + 0, + 125, + 0, + "IMAGE" + ], + [ + 665, + 177, + 1, + 178, + 0, + "*" + ], + [ + 679, + 179, + 0, + 60, + 2, + "FLOAT" + ], + [ + 680, + 179, + 0, + 81, + 0, + "*" + ], + [ + 681, + 80, + 0, + 179, + 0, + "*" + ], + [ + 682, + 181, + 0, + 179, + 1, + "*" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.6115909044842041, + "offset": [ + 3298.511120138438, + 887.407311815079 + ] + }, + "ue_links": [ + { + "downstream": 56, + "downstream_slot": 0, + "upstream": "54", + "upstream_slot": 5, + "controller": 58, + "type": "CLIP" + }, + { + "downstream": 59, + "downstream_slot": 0, + "upstream": "54", + "upstream_slot": 5, + "controller": 58, + "type": "CLIP" + } + ], + "links_added_by_ue": [ + 31971, + 31972 + ], + "frontendVersion": "1.28.8", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..efca8e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,194 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Discord bot that integrates with ComfyUI to generate AI images and videos. Users interact via Discord commands, which queue generation requests that execute on a ComfyUI server. + +## Architecture + +### File Structure + +The codebase is organized into focused modules for maintainability: + +``` +the-third-rev/ +├── config.py # Configuration and constants +├── job_queue.py # Job queue system (SerialJobQueue) +├── workflow_manager.py # Workflow manipulation logic +├── workflow_state.py # Runtime workflow state management +├── discord_utils.py # Discord helpers and decorators +├── commands/ # Command handlers (organized by functionality) +│ ├── __init__.py # Command registration +│ ├── generation.py # generate, workflow-gen commands +│ ├── workflow.py # workflow-load command +│ ├── upload.py # upload command +│ ├── history.py # history, get-history commands +│ └── workflow_changes.py # get/set workflow changes commands +├── bot.py # Main bot entry point (~150 lines) +└── comfy_client.py # ComfyUI API client (~650 lines) +``` + +### Core Components + +- **bot.py**: Minimal Discord bot entry point. Loads configuration, creates dependencies, and registers commands. No command logic here. +- **comfy_client.py**: Async client wrapping ComfyUI's REST and WebSocket APIs. Dependencies (WorkflowManager, WorkflowStateManager) are injected via constructor. +- **config.py**: Centralized configuration with `BotConfig.from_env()` for loading environment variables and constants. +- **job_queue.py**: `SerialJobQueue` ensuring generation requests execute sequentially, preventing ComfyUI server overload. +- **workflow_manager.py**: `WorkflowManager` class handling workflow template storage and node manipulation (finding/replacing prompts, seeds, etc). +- **workflow_state.py**: `WorkflowStateManager` class managing runtime workflow changes (prompt, negative_prompt, input_image) in memory with optional file persistence. +- **discord_utils.py**: Reusable Discord utilities including `@require_comfy_client` decorator, argument parsing, and the `UploadView` component. +- **commands/**: Command handlers organized by functionality. Each module exports a `setup_*_commands(bot, config)` function. + +### Key Architectural Patterns + +1. **Dependency Injection**: ComfyClient receives WorkflowManager and WorkflowStateManager as constructor parameters, eliminating tight coupling to file-based state. + +2. **Job Queue System**: All generation requests are queued through `SerialJobQueue` in job_queue.py. Jobs execute serially with a worker loop that catches and logs exceptions without crashing the bot. + +3. **Workflow System**: The bot uses two modes: + - **Prompt mode**: Simple prompt + negative_prompt (requires workflow template with KSampler node) + - **Workflow mode**: Full workflow JSON with dynamic modifications from WorkflowStateManager + +4. **Workflow Modification Flow**: + - Load workflow template via `bot.comfy.set_workflow()` or `bot.comfy.load_workflow_from_file()` + - Runtime changes (prompt, negative_prompt, input_image) stored in WorkflowStateManager + - At generation time, WorkflowManager methods locate nodes by class_type and title metadata, then inject values + - Seeds are randomized automatically via `workflow_manager.find_and_replace_seed()` + +5. **Command Registration**: Commands are registered via `commands.register_all_commands(bot, config)` which calls individual `setup_*_commands()` functions from each command module. + +6. **Configuration Management**: All configuration loaded via `BotConfig.from_env()` in config.py. Constants (command prefixes, error messages, limits) centralized in config.py. + +7. **History Management**: ComfyClient maintains a bounded deque of recent generations (configurable via `history_limit`) for retrieval via `ttr!get-history`. + +## Environment Variables + +Required in `.env`: +- `DISCORD_BOT_TOKEN`: Discord bot authentication token +- `COMFY_SERVER`: ComfyUI server address (e.g., `localhost:8188` or `example.com:8188`) + +Optional: +- `WORKFLOW_FILE`: Path to JSON workflow file to load at startup +- `COMFY_HISTORY_LIMIT`: Number of generations to keep in history (default: 10) +- `COMFY_OUTPUT_PATH`: Path to ComfyUI output directory (default: `C:\Users\ktrangia\Documents\ComfyUI\output`) + +## Running the Bot + +```bash +python bot.py +``` + +The bot will: +1. Load configuration from environment variables via `BotConfig.from_env()` +2. Create WorkflowStateManager and WorkflowManager instances +3. Initialize ComfyClient with injected dependencies +4. Load workflow from `WORKFLOW_FILE` if specified +5. Register all commands via `commands.register_all_commands()` +6. Start Discord bot and job queue +7. Listen for commands with prefix `ttr!` + +## Development Commands + +No build/test/lint commands exist. This is a standalone Python application. + +To run: `python bot.py` + +## Key Implementation Details + +### ComfyUI Workflow Node Injection + +When generating with workflows, WorkflowManager searches for specific node patterns: +- **Prompt**: Finds `CLIPTextEncode` nodes with `_meta.title` containing "Positive Prompt" +- **Negative Prompt**: Finds `CLIPTextEncode` nodes with `_meta.title` containing "Negative Prompt" +- **Input Image**: Finds `LoadImage` nodes and replaces the `image` input +- **Seeds**: Finds any node with `inputs.seed` or `inputs.noise_seed` and randomizes + +This pattern-matching approach means workflows must follow naming conventions in their node titles for dynamic updates to work. + +### Discord Command Pattern + +Commands use a labelled parameter syntax: `ttr!generate prompt: negative_prompt:` + +Parsing is handled by helpers in discord_utils.py (e.g., `parse_labeled_args()`). The bot splits on keyword markers (`prompt:`, `negative_prompt:`, `type:`, etc.) rather than traditional argparse. Case is preserved for prompts. + +### Job Queue Mechanics + +Jobs are dataclasses with `run: Callable[[], Awaitable[None]]` and a `label` for logging. The queue returns position on submit. Jobs capture their context (ctx, prompts) via lambda closures when submitted. + +### Image/Video Output Handling + +The `_general_generate` method in ComfyClient returns both images and videos. Videos are identified by file extension (mp4, webm, avi) in the history response. For videos, the bot reads the file from disk at the path specified by `COMFY_OUTPUT_PATH` rather than downloading via the API. + +### Command Validation + +The `@require_comfy_client` decorator (from discord_utils.py) validates that `bot.comfy` exists before executing commands. This eliminates repetitive validation code in every command handler. + +### State Management + +WorkflowStateManager maintains runtime workflow changes in memory with optional persistence to `current-workflow-changes.json`. The file is loaded on initialization if it exists, and saved automatically when changes are made. + +## Configuration System + +Configuration is managed via the `BotConfig` dataclass in config.py: + +```python +from config import BotConfig + +# Load from environment +config = BotConfig.from_env() + +# Access configuration +server = config.comfy_server +history_limit = config.comfy_history_limit +output_path = config.comfy_output_path +``` + +All constants (command prefixes, error messages, defaults) are defined in config.py and imported where needed. + +## Adding New Commands + +To add a new command: + +1. Create a new module in `commands/` (e.g., `commands/my_feature.py`) +2. Define a `setup_my_feature_commands(bot, config=None)` function +3. Use `@bot.command(name="...")` decorators to define commands +4. Use `@require_comfy_client` decorator if command needs ComfyClient +5. Import and call your setup function in `commands/__init__.py`'s `register_all_commands()` + +Example: + +```python +# commands/my_feature.py +from discord.ext import commands +from discord_utils import require_comfy_client + +def setup_my_feature_commands(bot): + @bot.command(name="my-command") + @require_comfy_client + async def my_command(ctx: commands.Context): + await ctx.reply("Hello from my command!") +``` + +## Dependencies + +From imports: +- discord.py +- aiohttp +- websockets +- python-dotenv (optional, for .env loading) + +No requirements.txt exists. Install manually: `pip install discord.py aiohttp websockets python-dotenv` + +## Code Organization Principles + +The refactored codebase follows these principles: + +1. **Single Responsibility**: Each module has one clear purpose +2. **Dependency Injection**: Dependencies passed via constructor, not created internally +3. **Configuration Centralization**: All configuration in config.py +4. **Command Separation**: Commands grouped by functionality in separate modules +5. **No Magic Strings**: Constants defined once in config.py +6. **Type Safety**: Modern Python type hints throughout (dict[str, Any] instead of Dict) +7. **Logging**: Using logger methods instead of print() statements diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..51acdc5 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,780 @@ +# Development Guide + +This guide explains how to add new commands, features, and modules to the Discord ComfyUI bot. + +## Table of Contents + +- [Adding a New Command](#adding-a-new-command) +- [Adding a New Feature Module](#adding-a-new-feature-module) +- [Adding Configuration Options](#adding-configuration-options) +- [Working with Workflows](#working-with-workflows) +- [Best Practices](#best-practices) +- [Common Patterns](#common-patterns) +- [Testing Your Changes](#testing-your-changes) + +--- + +## Adding a New Command + +Commands are organized in the `commands/` directory by functionality. Here's how to add a new command: + +### Step 1: Choose the Right Module + +Determine which existing command module your command belongs to: + +- **generation.py** - Image/video generation commands +- **workflow.py** - Workflow template management +- **upload.py** - File upload commands +- **history.py** - History viewing and retrieval +- **workflow_changes.py** - Runtime workflow parameter management + +If none fit, create a new module (see [Adding a New Feature Module](#adding-a-new-feature-module)). + +### Step 2: Add Your Command Function + +Edit the appropriate module in `commands/` and add your command to the `setup_*_commands()` function: + +```python +# commands/generation.py + +def setup_generation_commands(bot, config): + # ... existing commands ... + + @bot.command(name="my-new-command", aliases=["mnc", "my-cmd"]) + @require_comfy_client # Use this decorator if you need bot.comfy + async def my_new_command(ctx: commands.Context, *, args: str = "") -> None: + """ + Brief description of what your command does. + + Usage: + ttr!my-new-command [arguments] + + Longer description with examples and details. + """ + # Parse arguments if needed + if not args: + await ctx.reply("Please provide arguments!", mention_author=False) + return + + try: + # Your command logic here + result = await bot.comfy.some_method(args) + + # Send response + await ctx.reply(f"Success! Result: {result}", mention_author=False) + + except Exception as exc: + logger.exception("Failed to execute my-new-command") + await ctx.reply( + f"An error occurred: {type(exc).__name__}: {exc}", + mention_author=False, + ) +``` + +### Step 3: Import Required Dependencies + +At the top of your command module, import what you need: + +```python +import logging +from discord.ext import commands +from discord_utils import require_comfy_client, parse_labeled_args +from config import ARG_PROMPT_KEY, ARG_TYPE_KEY +from job_queue import Job + +logger = logging.getLogger(__name__) +``` + +### Step 4: Test Your Command + +Run the bot and test your command: + +```bash +python bot.py +``` + +Then in Discord: +``` +ttr!my-new-command test arguments +``` + +--- + +## Adding a New Feature Module + +If your commands don't fit existing modules, create a new one: + +### Step 1: Create the Module File + +Create `commands/your_feature.py`: + +```python +""" +commands/your_feature.py +======================== + +Description of what this module handles. +""" + +from __future__ import annotations + +import logging +from discord.ext import commands +from discord_utils import require_comfy_client + +logger = logging.getLogger(__name__) + + +def setup_your_feature_commands(bot, config): + """ + Register your feature commands with the bot. + + Parameters + ---------- + bot : commands.Bot + The Discord bot instance. + config : BotConfig + The bot configuration object. + """ + + @bot.command(name="feature-command") + @require_comfy_client + async def feature_command(ctx: commands.Context, *, args: str = "") -> None: + """Command description.""" + await ctx.reply("Feature command executed!", mention_author=False) + + @bot.command(name="another-command", aliases=["ac"]) + async def another_command(ctx: commands.Context) -> None: + """Another command description.""" + await ctx.reply("Another command!", mention_author=False) +``` + +### Step 2: Register in commands/__init__.py + +Edit `commands/__init__.py` to import and register your module: + +```python +from .generation import setup_generation_commands +from .workflow import setup_workflow_commands +from .upload import setup_upload_commands +from .history import setup_history_commands +from .workflow_changes import setup_workflow_changes_commands +from .your_feature import setup_your_feature_commands # ADD THIS + + +def register_all_commands(bot, config): + """Register all bot commands.""" + setup_generation_commands(bot, config) + setup_workflow_commands(bot) + setup_upload_commands(bot) + setup_history_commands(bot) + setup_workflow_changes_commands(bot) + setup_your_feature_commands(bot, config) # ADD THIS +``` + +### Step 3: Update Documentation + +Add your module to `CLAUDE.md`: + +```markdown +### File Structure + +``` +commands/ +├── __init__.py +├── generation.py +├── workflow.py +├── upload.py +├── history.py +├── workflow_changes.py +└── your_feature.py # Your new module +``` +``` + +--- + +## Adding Configuration Options + +Configuration is centralized in `config.py`. Here's how to add new options: + +### Step 1: Add Constants (if needed) + +Edit `config.py` and add constants in the appropriate section: + +```python +# ======================================== +# Your Feature Constants +# ======================================== + +MY_FEATURE_DEFAULT_VALUE = 42 +"""Default value for my feature.""" + +MY_FEATURE_MAX_LIMIT = 100 +"""Maximum limit for my feature.""" +``` + +### Step 2: Add to BotConfig (if environment variable) + +If your config comes from environment variables, add it to `BotConfig`: + +```python +@dataclass +class BotConfig: + """Configuration container for the Discord ComfyUI bot.""" + + discord_bot_token: str + comfy_server: str + comfy_output_path: str + comfy_history_limit: int + workflow_file: Optional[str] = None + my_feature_enabled: bool = False # ADD THIS + my_feature_value: int = MY_FEATURE_DEFAULT_VALUE # ADD THIS +``` + +### Step 3: Load in from_env() + +Add loading logic in `BotConfig.from_env()`: + +```python +@classmethod +def from_env(cls) -> BotConfig: + """Create a BotConfig instance by loading from environment.""" + # ... existing code ... + + # Load your feature config + my_feature_enabled = os.getenv("MY_FEATURE_ENABLED", "false").lower() == "true" + + try: + my_feature_value = int(os.getenv("MY_FEATURE_VALUE", str(MY_FEATURE_DEFAULT_VALUE))) + except ValueError: + my_feature_value = MY_FEATURE_DEFAULT_VALUE + + return cls( + # ... existing parameters ... + my_feature_enabled=my_feature_enabled, + my_feature_value=my_feature_value, + ) +``` + +### Step 4: Use in Your Commands + +Access config in your commands: + +```python +def setup_your_feature_commands(bot, config): + @bot.command(name="feature") + async def feature_command(ctx: commands.Context): + if not config.my_feature_enabled: + await ctx.reply("Feature is disabled!", mention_author=False) + return + + value = config.my_feature_value + await ctx.reply(f"Feature value: {value}", mention_author=False) +``` + +### Step 5: Document the Environment Variable + +Update `CLAUDE.md` and add to `.env.example` (if you create one): + +```bash +# Feature Configuration +MY_FEATURE_ENABLED=true +MY_FEATURE_VALUE=42 +``` + +--- + +## Working with Workflows + +The bot has separate concerns for workflows: + +- **WorkflowManager** (`workflow_manager.py`) - Template storage and node manipulation +- **WorkflowStateManager** (`workflow_state.py`) - Runtime state (prompt, negative_prompt, input_image) +- **ComfyClient** (`comfy_client.py`) - Uses both managers to generate images + +### Adding New Workflow Node Types + +If you need to manipulate new types of nodes in workflows: + +#### Step 1: Add Method to WorkflowManager + +Edit `workflow_manager.py`: + +```python +def find_and_replace_my_node( + self, workflow: Dict[str, Any], my_value: str +) -> Dict[str, Any]: + """ + Find and replace my custom node type. + + This searches for nodes of a specific class_type and updates their inputs. + + Parameters + ---------- + workflow : Dict[str, Any] + The workflow definition to modify. + my_value : str + The value to inject. + + Returns + ------- + Dict[str, Any] + The modified workflow. + """ + for node_id, node in workflow.items(): + if node.get("class_type") == "MyCustomNodeType" and node.get("inputs"): + # Check metadata for specific node identification + meta = node.get("_meta", {}) + if "My Custom Node" in meta.get("title", ""): + workflow[node_id]["inputs"]["my_input"] = my_value + logger.debug("Replaced my_value in node %s", node_id) + + return workflow +``` + +#### Step 2: Add to apply_state_changes() + +Update `apply_state_changes()` to include your new manipulation: + +```python +def apply_state_changes( + self, + workflow: Dict[str, Any], + prompt: Optional[str] = None, + negative_prompt: Optional[str] = None, + input_image: Optional[str] = None, + my_custom_value: Optional[str] = None, # ADD THIS + randomize_seed: bool = True, +) -> Dict[str, Any]: + """Apply multiple state changes to a workflow in one pass.""" + if randomize_seed: + workflow = self.find_and_replace_seed(workflow) + + if prompt is not None: + workflow = self.find_and_replace_prompt(workflow, prompt) + + if negative_prompt is not None: + workflow = self.find_and_replace_negative_prompt(workflow, negative_prompt) + + if input_image is not None: + workflow = self.find_and_replace_input_image(workflow, input_image) + + # ADD THIS + if my_custom_value is not None: + workflow = self.find_and_replace_my_node(workflow, my_custom_value) + + return workflow +``` + +#### Step 3: Add State to WorkflowStateManager + +Edit `workflow_state.py` to track the new state: + +```python +def __init__(self, state_file: Optional[str] = None): + """Initialize the workflow state manager.""" + self._state: Dict[str, Any] = { + "prompt": None, + "negative_prompt": None, + "input_image": None, + "my_custom_value": None, # ADD THIS + } + # ... rest of init ... + +def set_my_custom_value(self, value: str) -> None: + """Set the custom value.""" + self._state["my_custom_value"] = value + if self._state_file: + try: + self.save_to_file() + except Exception: + pass + +def get_my_custom_value(self) -> Optional[str]: + """Get the custom value.""" + return self._state.get("my_custom_value") +``` + +#### Step 4: Use in ComfyClient + +The ComfyClient will automatically use your new state if you update `generate_image_with_workflow()`: + +```python +async def generate_image_with_workflow(self) -> tuple[List[bytes], List[dict[str, Any]], str]: + # ... existing code ... + + # Get current state changes + changes = self.state_manager.get_changes() + prompt = changes.get("prompt") + negative_prompt = changes.get("negative_prompt") + input_image = changes.get("input_image") + my_custom_value = changes.get("my_custom_value") # ADD THIS + + # Apply changes using WorkflowManager + workflow = self.workflow_manager.apply_state_changes( + workflow, + prompt=prompt, + negative_prompt=negative_prompt, + input_image=input_image, + my_custom_value=my_custom_value, # ADD THIS + randomize_seed=True, + ) + + # ... rest of method ... +``` + +--- + +## Best Practices + +### Command Design + +1. **Use descriptive names**: `ttr!generate` is better than `ttr!gen` (but provide aliases) +2. **Validate inputs early**: Check arguments before starting long operations +3. **Provide clear feedback**: Tell users what's happening and when it's done +4. **Handle errors gracefully**: Catch exceptions and show user-friendly messages +5. **Use decorators**: `@require_comfy_client` eliminates boilerplate + +### Code Organization + +1. **One responsibility per module**: Don't mix unrelated commands +2. **Keep functions small**: If a function is > 50 lines, consider splitting it +3. **Use type hints**: Help future developers understand your code +4. **Document with docstrings**: Explain what, why, and how + +### Discord Best Practices + +1. **Use `mention_author=False`**: Prevents spam from @mentions +2. **Use `delete_after=X`**: For temporary status messages +3. **Use `ephemeral=True`**: For interaction responses (buttons/modals) +4. **Limit file attachments**: Discord has a 4-file limit (use `MAX_IMAGES_PER_RESPONSE`) + +### Performance + +1. **Use job queue for long operations**: Queue generation requests +2. **Use typing indicator**: `async with ctx.typing():` shows bot is working +3. **Batch operations**: Don't send 10 separate messages when 1 will do +4. **Close resources**: Always close aiohttp sessions, file handles + +--- + +## Common Patterns + +### Pattern 1: Labeled Argument Parsing + +For commands with `key:value` syntax: + +```python +from discord_utils import parse_labeled_args +from config import ARG_PROMPT_KEY, ARG_TYPE_KEY + +@bot.command(name="my-command") +async def my_command(ctx: commands.Context, *, args: str = ""): + # Parse labeled arguments + parsed = parse_labeled_args(args, [ARG_PROMPT_KEY, ARG_TYPE_KEY]) + + prompt = parsed.get("prompt") # None if not provided + image_type = parsed.get("type") or "input" # Default to "input" + + if not prompt: + await ctx.reply("Please provide a prompt!", mention_author=False) + return +``` + +### Pattern 2: Queued Job Execution + +For long-running operations: + +```python +from job_queue import Job + +@bot.command(name="long-operation") +@require_comfy_client +async def long_operation(ctx: commands.Context, *, args: str = ""): + try: + # Define the job function + async def _run_job(): + async with ctx.typing(): + result = await bot.comfy.some_long_operation(args) + await ctx.reply(f"Done! Result: {result}", mention_author=False) + + # Submit to queue + position = await bot.jobq.submit( + Job( + label=f"long-operation:{ctx.author.id}", + run=_run_job, + ) + ) + + await ctx.reply( + f"Queued ✅ (position: {position})", + mention_author=False, + delete_after=2.0, + ) + + except Exception as exc: + logger.exception("Failed to queue long operation") + await ctx.reply( + f"An error occurred: {type(exc).__name__}: {exc}", + mention_author=False, + ) +``` + +### Pattern 3: File Attachments + +For uploading files to ComfyUI: + +```python +@bot.command(name="upload-and-process") +@require_comfy_client +async def upload_and_process(ctx: commands.Context): + if not ctx.message.attachments: + await ctx.reply("Please attach a file!", mention_author=False) + return + + for attachment in ctx.message.attachments: + try: + # Download attachment + data = await attachment.read() + + # Upload to ComfyUI + result = await bot.comfy.upload_image( + data, + attachment.filename, + image_type="input", + ) + + # Process the uploaded file + filename = result.get("name") + await ctx.reply(f"Uploaded: {filename}", mention_author=False) + + except Exception as exc: + logger.exception("Failed to process attachment") + await ctx.reply( + f"Failed: {attachment.filename}: {exc}", + mention_author=False, + ) +``` + +### Pattern 4: Interactive UI (Buttons) + +For adding buttons to messages: + +```python +from discord.ui import View, Button +import discord + +class MyView(View): + def __init__(self, data: str): + super().__init__(timeout=None) + self.data = data + + @discord.ui.button(label="Click Me", style=discord.ButtonStyle.primary) + async def button_callback( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + # Handle button click + await interaction.response.send_message( + f"You clicked! Data: {self.data}", + ephemeral=True, + ) + +@bot.command(name="interactive") +async def interactive(ctx: commands.Context): + view = MyView(data="example") + await ctx.reply("Click the button:", view=view, mention_author=False) +``` + +### Pattern 5: Using Configuration + +Access bot configuration in commands: + +```python +def setup_my_commands(bot, config): + @bot.command(name="check-config") + async def check_config(ctx: commands.Context): + # Access config values + server = config.comfy_server + output_path = config.comfy_output_path + + await ctx.reply( + f"Server: {server}\nOutput: {output_path}", + mention_author=False, + ) +``` + +--- + +## Testing Your Changes + +### Manual Testing Checklist + +1. **Start the bot**: `python bot.py` + - Verify no import errors + - Check configuration loads correctly + - Confirm all commands register + +2. **Test basic functionality**: + ``` + ttr!test + ttr!help + ttr!your-new-command + ``` + +3. **Test error handling**: + - Run command with missing arguments + - Run command with invalid arguments + - Test when ComfyUI is unavailable (if applicable) + +4. **Test edge cases**: + - Very long inputs + - Special characters + - Concurrent command execution + - Commands while queue is full + +### Syntax Validation + +Check for syntax errors without running: + +```bash +python -m py_compile bot.py +python -m py_compile commands/your_feature.py +``` + +### Check Imports + +Verify all imports work: + +```bash +python -c "from commands.your_feature import setup_your_feature_commands; print('OK')" +``` + +### Code Style + +Follow these conventions: + +- **Indentation**: 4 spaces (no tabs) +- **Line length**: Max 100 characters (documentation can be longer) +- **Docstrings**: Use Google style or NumPy style (match existing code) +- **Imports**: Group stdlib, third-party, local (separated by blank lines) +- **Type hints**: Use modern syntax (`dict[str, Any]` not `Dict[str, Any]`) + +--- + +## Example: Complete Feature Addition + +Here's a complete example adding a "status" command: + +### 1. Create commands/status.py + +```python +""" +commands/status.py +================== + +Bot status and diagnostics commands. +""" + +from __future__ import annotations + +import logging +import asyncio +from discord.ext import commands +from discord_utils import require_comfy_client + +logger = logging.getLogger(__name__) + + +def setup_status_commands(bot, config): + """Register status commands with the bot.""" + + @bot.command(name="status", aliases=["s", "stat"]) + async def status_command(ctx: commands.Context) -> None: + """ + Show bot status and queue information. + + Usage: + ttr!status + + Displays: + - Bot connection status + - ComfyUI connection status + - Current queue size + - Configuration info + """ + # Check bot status + latency_ms = round(bot.latency * 1000) + + # Check queue + if hasattr(bot, "jobq"): + queue_size = await bot.jobq.get_queue_size() + else: + queue_size = 0 + + # Check ComfyUI + comfy_status = "✅ Connected" if hasattr(bot, "comfy") else "❌ Not configured" + + # Build status message + status_msg = [ + "**Bot Status**", + f"• Latency: {latency_ms}ms", + f"• Queue size: {queue_size}", + f"• ComfyUI: {comfy_status}", + f"• Server: {config.comfy_server}", + ] + + await ctx.reply("\n".join(status_msg), mention_author=False) + + @bot.command(name="ping") + async def ping_command(ctx: commands.Context) -> None: + """ + Check bot responsiveness. + + Usage: + ttr!ping + """ + latency_ms = round(bot.latency * 1000) + await ctx.reply(f"🏓 Pong! Latency: {latency_ms}ms", mention_author=False) +``` + +### 2. Register in commands/__init__.py + +```python +from .status import setup_status_commands + +def register_all_commands(bot, config): + # ... existing registrations ... + setup_status_commands(bot, config) +``` + +### 3. Update CLAUDE.md + +```markdown +- **commands/status.py** - Bot status and diagnostics commands +``` + +### 4. Test + +```bash +python bot.py +``` + +In Discord: +``` +ttr!status +ttr!ping +ttr!s (alias test) +``` + +--- + +## Getting Help + +If you're stuck: + +1. **Check CLAUDE.md**: Architecture and patterns documented there +2. **Read existing commands**: See how similar features are implemented +3. **Check logs**: Run bot and check console output for errors +4. **Test incrementally**: Add small pieces and test frequently + +Remember: The refactored architecture makes adding features straightforward. Follow the patterns, and your code will fit right in! 🚀 diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..c24f231 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,196 @@ +# Quick Start Guide + +Quick reference for common development tasks. + +## Add a Simple Command + +```python +# commands/your_module.py + +def setup_your_commands(bot, config): + @bot.command(name="hello") + async def hello(ctx): + await ctx.reply("Hello!", mention_author=False) +``` + +Register it: +```python +# commands/__init__.py +from .your_module import setup_your_commands + +def register_all_commands(bot, config): + # ... existing ... + setup_your_commands(bot, config) +``` + +## Add a Command That Uses ComfyUI + +```python +from discord_utils import require_comfy_client + +@bot.command(name="my-cmd") +@require_comfy_client # Validates bot.comfy exists +async def my_cmd(ctx): + result = await bot.comfy.some_method() + await ctx.reply(f"Result: {result}", mention_author=False) +``` + +## Add a Long-Running Command + +```python +from job_queue import Job + +@bot.command(name="generate") +@require_comfy_client +async def generate(ctx, *, args: str = ""): + async def _run(): + async with ctx.typing(): + result = await bot.comfy.generate_image(args) + await ctx.reply(f"Done! {result}", mention_author=False) + + pos = await bot.jobq.submit(Job(label="generate", run=_run)) + await ctx.reply(f"Queued ✅ (position: {pos})", mention_author=False) +``` + +## Add Configuration + +```python +# config.py + +MY_FEATURE_ENABLED = True + +@dataclass +class BotConfig: + # ... existing fields ... + my_feature_enabled: bool = MY_FEATURE_ENABLED + + @classmethod + def from_env(cls) -> BotConfig: + # ... existing code ... + my_feature = os.getenv("MY_FEATURE_ENABLED", "true").lower() == "true" + return cls( + # ... existing params ... + my_feature_enabled=my_feature + ) +``` + +Use in commands: +```python +def setup_my_commands(bot, config): + @bot.command(name="feature") + async def feature(ctx): + if config.my_feature_enabled: + await ctx.reply("Enabled!", mention_author=False) +``` + +## Parse Command Arguments + +```python +from discord_utils import parse_labeled_args +from config import ARG_PROMPT_KEY, ARG_TYPE_KEY + +@bot.command(name="cmd") +async def cmd(ctx, *, args: str = ""): + # Parse "prompt:text type:value" format + parsed = parse_labeled_args(args, [ARG_PROMPT_KEY, ARG_TYPE_KEY]) + + prompt = parsed.get("prompt") + img_type = parsed.get("type") or "input" # Default + + if not prompt: + await ctx.reply("Missing prompt!", mention_author=False) + return +``` + +## Handle File Uploads + +```python +@bot.command(name="upload") +async def upload(ctx): + if not ctx.message.attachments: + await ctx.reply("Attach a file!", mention_author=False) + return + + for attachment in ctx.message.attachments: + data = await attachment.read() + # Process data... +``` + +## Access Bot State + +```python +@bot.command(name="info") +async def info(ctx): + # Queue size + queue_size = await bot.jobq.get_queue_size() + + # Config + server = bot.config.comfy_server + + # Last generation + last_id = bot.comfy.last_prompt_id + + await ctx.reply( + f"Queue: {queue_size}, Server: {server}, Last: {last_id}", + mention_author=False + ) +``` + +## Add Buttons + +```python +from discord.ui import View, Button +import discord + +class MyView(View): + @discord.ui.button(label="Click", style=discord.ButtonStyle.primary) + async def button_callback(self, interaction, button): + await interaction.response.send_message("Clicked!", ephemeral=True) + +@bot.command(name="interactive") +async def interactive(ctx): + await ctx.reply("Press button:", view=MyView(), mention_author=False) +``` + +## Common Imports + +```python +from __future__ import annotations +import logging +from discord.ext import commands +from discord_utils import require_comfy_client +from config import ARG_PROMPT_KEY, ARG_TYPE_KEY +from job_queue import Job + +logger = logging.getLogger(__name__) +``` + +## Test Your Changes + +```bash +# Syntax check +python -m py_compile commands/your_module.py + +# Run bot +python bot.py + +# Test in Discord +ttr!your-command +``` + +## File Structure + +``` +commands/ +├── __init__.py # Register all commands here +├── generation.py # Generation commands +├── workflow.py # Workflow management +├── upload.py # File uploads +├── history.py # History retrieval +├── workflow_changes.py # State management +└── your_module.py # Your new module +``` + +## Need More Details? + +See `DEVELOPMENT.md` for comprehensive guide with examples, patterns, and best practices. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e340325 --- /dev/null +++ b/README.md @@ -0,0 +1,345 @@ +# Discord ComfyUI Bot + +A Discord bot that integrates with ComfyUI to generate AI images and videos through Discord commands. + +## Features + +- 🎨 **Image Generation** - Generate images using simple prompts or complex workflows +- 🎬 **Video Generation** - Support for video output workflows +- 📝 **Workflow Management** - Load, modify, and execute ComfyUI workflows +- 📤 **Image Upload** - Upload reference images directly through Discord +- 📊 **Generation History** - Track and retrieve past generations +- ⚙️ **Runtime Workflow Modification** - Change prompts, negative prompts, and input images on the fly +- 🔄 **Job Queue System** - Sequential execution prevents server overload + +## Quick Start + +### Prerequisites + +- Python 3.9+ +- Discord Bot Token ([create one here](https://discord.com/developers/applications)) +- ComfyUI Server running and accessible +- Required packages: `discord.py`, `aiohttp`, `websockets`, `python-dotenv` + +### Installation + +1. **Clone or download this repository** + +2. **Install dependencies**: + ```bash + pip install discord.py aiohttp websockets python-dotenv + ``` + +3. **Create `.env` file** with your credentials: + ```bash + DISCORD_BOT_TOKEN=your_discord_bot_token_here + COMFY_SERVER=localhost:8188 + ``` + +4. **Run the bot**: + ```bash + python bot.py + ``` + +## Configuration + +Create a `.env` file in the project root: + +```bash +# Required +DISCORD_BOT_TOKEN=your_discord_bot_token +COMFY_SERVER=localhost:8188 + +# Optional +WORKFLOW_FILE=wan2.2-fast.json +COMFY_HISTORY_LIMIT=10 +COMFY_OUTPUT_PATH=C:\Users\YourName\Documents\ComfyUI\output +``` + +### Configuration Options + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DISCORD_BOT_TOKEN` | ✅ Yes | - | Discord bot authentication token | +| `COMFY_SERVER` | ✅ Yes | - | ComfyUI server address (host:port) | +| `WORKFLOW_FILE` | ❌ No | - | Path to workflow JSON to load at startup | +| `COMFY_HISTORY_LIMIT` | ❌ No | `10` | Number of generations to keep in history | +| `COMFY_OUTPUT_PATH` | ❌ No | `C:\Users\...\ComfyUI\output` | Path to ComfyUI output directory | + +## Usage + +All commands use the `ttr!` prefix. + +### Basic Commands + +```bash +# Test if bot is working +ttr!test + +# Generate an image with a prompt +ttr!generate prompt:a beautiful sunset over mountains + +# Generate with negative prompt +ttr!generate prompt:a cat negative_prompt:blurry, low quality + +# Execute loaded workflow +ttr!workflow-gen + +# Queue multiple workflow runs +ttr!workflow-gen queue:5 +``` + +### Workflow Management + +```bash +# Load a workflow from file +ttr!workflow-load path/to/workflow.json + +# Or attach a JSON file to the message: +ttr!workflow-load +[Attach: my_workflow.json] + +# View current workflow changes +ttr!get-current-workflow-changes type:all + +# Set workflow parameters +ttr!set-current-workflow-changes type:prompt A new prompt +ttr!set-current-workflow-changes type:negative_prompt blurry +ttr!set-current-workflow-changes type:input_image input/image.png +``` + +### Image Upload + +```bash +# Upload images to ComfyUI +ttr!upload +[Attach: image1.png, image2.png] + +# Upload to specific folder +ttr!upload type:temp +[Attach: reference.png] +``` + +### History + +```bash +# View recent generations +ttr!history + +# Retrieve images from a past generation +ttr!get-history +ttr!get-history 1 # By index +``` + +### Command Aliases + +Many commands have shorter aliases: + +- `ttr!generate` → `ttr!gen` +- `ttr!workflow-gen` → `ttr!wfg` +- `ttr!workflow-load` → `ttr!wfl` +- `ttr!get-history` → `ttr!gh` +- `ttr!get-current-workflow-changes` → `ttr!gcwc` +- `ttr!set-current-workflow-changes` → `ttr!scwc` + +## Architecture + +The bot is organized into focused, maintainable modules: + +``` +the-third-rev/ +├── config.py # Configuration and constants +├── job_queue.py # Job queue system +├── workflow_manager.py # Workflow manipulation +├── workflow_state.py # Runtime state management +├── discord_utils.py # Discord utilities +├── bot.py # Main entry point (~150 lines) +├── comfy_client.py # ComfyUI API client (~650 lines) +└── commands/ # Command handlers + ├── generation.py # Image/video generation + ├── workflow.py # Workflow management + ├── upload.py # File uploads + ├── history.py # History retrieval + └── workflow_changes.py # State management +``` + +### Key Design Principles + +- **Dependency Injection** - Dependencies passed via constructor +- **Single Responsibility** - Each module has one clear purpose +- **Configuration Centralization** - All config in `config.py` +- **Command Separation** - Commands grouped by functionality +- **Type Safety** - Modern Python type hints throughout + +## Development + +### Adding a New Command + +See `QUICK_START.md` for quick examples or `DEVELOPMENT.md` for comprehensive guide. + +Basic example: + +```python +# commands/your_module.py + +def setup_your_commands(bot, config): + @bot.command(name="hello") + async def hello(ctx): + await ctx.reply("Hello!", mention_author=False) +``` + +Register in `commands/__init__.py`: + +```python +from .your_module import setup_your_commands + +def register_all_commands(bot, config): + # ... existing ... + setup_your_commands(bot, config) +``` + +### Documentation + +- **README.md** (this file) - Project overview and setup +- **QUICK_START.md** - Quick reference for common tasks +- **DEVELOPMENT.md** - Comprehensive development guide +- **CLAUDE.md** - Architecture documentation for Claude Code + +## Workflow System + +The bot supports two generation modes: + +### 1. Prompt Mode (Simple) + +Uses a workflow template with a KSampler node: + +```bash +ttr!generate prompt:a cat negative_prompt:blurry +``` + +The bot automatically finds and replaces: +- Positive prompt in CLIPTextEncode node (title: "Positive Prompt") +- Negative prompt in CLIPTextEncode node (title: "Negative Prompt") +- Seed values (randomized each run) + +### 2. Workflow Mode (Advanced) + +Execute full workflow with runtime modifications: + +```bash +# Set workflow parameters +ttr!set-current-workflow-changes type:prompt A beautiful landscape +ttr!set-current-workflow-changes type:input_image input/reference.png + +# Execute workflow +ttr!workflow-gen +``` + +The bot: +1. Loads the workflow template +2. Applies runtime changes from WorkflowStateManager +3. Randomizes seeds +4. Executes on ComfyUI server +5. Returns images/videos + +### Node Naming Conventions + +For workflows to work with dynamic updates, nodes must follow naming conventions: + +- **Positive Prompt**: CLIPTextEncode node with title containing "Positive Prompt" +- **Negative Prompt**: CLIPTextEncode node with title containing "Negative Prompt" +- **Input Image**: LoadImage node (any title) +- **Seeds**: Any node with `inputs.seed` or `inputs.noise_seed` + +## Troubleshooting + +### Bot won't start + +**Issue**: `AttributeError: module 'queue' has no attribute 'SimpleQueue'` + +**Solution**: This was fixed by renaming `queue.py` to `job_queue.py`. Make sure you're using the latest version. + +### ComfyUI connection issues + +**Issue**: `ComfyUI client is not configured` + +**Solution**: +1. Check `.env` file has `DISCORD_BOT_TOKEN` and `COMFY_SERVER` +2. Verify ComfyUI server is running +3. Test connection: `curl http://localhost:8188` + +### Commands not responding + +**Issue**: Bot online but commands don't work + +**Solution**: +1. Check bot has Message Content Intent enabled in Discord Developer Portal +2. Verify bot has permissions in Discord server +3. Check console logs for errors + +### Video files not found + +**Issue**: `Failed to read video file` + +**Solution**: +1. Set `COMFY_OUTPUT_PATH` in `.env` to your ComfyUI output directory +2. Check path uses correct format for your OS + +## Advanced Usage + +### Batch Generation + +Queue multiple workflow runs: + +```bash +ttr!workflow-gen queue:10 +``` + +Each run uses randomized seeds for variation. + +### Custom Workflows + +1. Design workflow in ComfyUI +2. Export as API format (Save → API Format) +3. Load in bot: + ```bash + ttr!workflow-load path/to/workflow.json + ``` +4. Modify at runtime: + ```bash + ttr!set-current-workflow-changes type:prompt My prompt + ttr!workflow-gen + ``` + +### State Persistence + +Workflow changes are automatically saved to `current-workflow-changes.json` and persist across bot restarts. + +## Contributing + +We welcome contributions! Please: + +1. Read `DEVELOPMENT.md` for coding guidelines +2. Follow existing code style and patterns +3. Test your changes thoroughly +4. Update documentation as needed + +## License + +[Your License Here] + +## Support + +For issues or questions: +- Check the troubleshooting section above +- Review `DEVELOPMENT.md` for implementation details +- Check ComfyUI documentation for workflow issues +- Open an issue on GitHub + +## Credits + +Built with: +- [discord.py](https://github.com/Rapptz/discord.py) - Discord API wrapper +- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) - Stable Diffusion GUI +- [aiohttp](https://github.com/aio-libs/aiohttp) - Async HTTP client +- [websockets](https://github.com/python-websockets/websockets) - WebSocket implementation diff --git a/backfill_image_data.py b/backfill_image_data.py new file mode 100644 index 0000000..5d340bf --- /dev/null +++ b/backfill_image_data.py @@ -0,0 +1,156 @@ +""" +backfill_image_data.py +====================== + +One-shot script to download image bytes from Discord and store them in +input_images.db for rows that currently have image_data = NULL. + +These rows were created before the BLOB-storage migration, so their bytes +were never persisted. The script re-fetches each bot-reply message from +Discord and writes the raw attachment bytes back into the DB. + +Rows with bot_reply_id = 0 (web uploads that pre-date the migration) have +no Discord source and are skipped — re-upload them via the web UI to +backfill. + +Usage +----- + python backfill_image_data.py + +Requires: + DISCORD_BOT_TOKEN in .env (same token the bot uses) +""" + +from __future__ import annotations + +import asyncio +import logging +import sqlite3 + +import discord + +try: + from dotenv import load_dotenv + load_dotenv() +except Exception: + pass + +from config import BotConfig +from input_image_db import DB_PATH + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + + +def _load_null_rows() -> list[dict]: + """Return all rows that are missing image_data.""" + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + rows = conn.execute( + "SELECT id, bot_reply_id, channel_id, filename" + " FROM input_images WHERE image_data IS NULL" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def _save_image_data(row_id: int, data: bytes) -> None: + conn = sqlite3.connect(str(DB_PATH)) + conn.execute("UPDATE input_images SET image_data = ? WHERE id = ?", (data, row_id)) + conn.commit() + conn.close() + + +async def _backfill(client: discord.Client) -> None: + rows = _load_null_rows() + + discord_rows = [r for r in rows if r["bot_reply_id"] != 0] + web_rows = [r for r in rows if r["bot_reply_id"] == 0] + + logger.info( + "Rows missing image_data: %d total (%d from Discord, %d web-uploads skipped)", + len(rows), len(discord_rows), len(web_rows), + ) + + if web_rows: + logger.info( + "Skipped row IDs (no Discord source — re-upload via web UI): %s", + [r["id"] for r in web_rows], + ) + + if not discord_rows: + logger.info("Nothing to fetch. Exiting.") + return + + ok = 0 + failed = 0 + + for row in discord_rows: + row_id = row["id"] + ch_id = row["channel_id"] + msg_id = row["bot_reply_id"] + filename = row["filename"] + + try: + channel = client.get_channel(ch_id) or await client.fetch_channel(ch_id) + message = await channel.fetch_message(msg_id) + + attachment = next( + (a for a in message.attachments if a.filename == filename), None + ) + if attachment is None: + logger.warning( + "Row %d: attachment '%s' not found on message %d — skipping", + row_id, filename, msg_id, + ) + failed += 1 + continue + + data = await attachment.read() + _save_image_data(row_id, data) + logger.info("Row %d: saved '%s' (%d bytes)", row_id, filename, len(data)) + ok += 1 + + except discord.NotFound: + logger.warning("Row %d: message %d not found (deleted?) — skipping", row_id, msg_id) + failed += 1 + except discord.Forbidden: + logger.warning("Row %d: no access to channel %d — skipping", row_id, ch_id) + failed += 1 + except Exception as exc: + logger.error("Row %d: unexpected error — %s", row_id, exc) + failed += 1 + + logger.info( + "Done. %d saved, %d failed/skipped, %d web-upload rows not touched.", + ok, failed, len(web_rows), + ) + + +async def _main(token: str) -> None: + intents = discord.Intents.none() # no gateway events needed beyond connect + client = discord.Client(intents=intents) + + @client.event + async def on_ready(): + logger.info("Logged in as %s", client.user) + try: + await _backfill(client) + finally: + await client.close() + + await client.start(token) + + +def main() -> None: + try: + config = BotConfig.from_env() + except RuntimeError as exc: + logger.error("Config error: %s", exc) + return + + asyncio.run(_main(config.discord_bot_token)) + + +if __name__ == "__main__": + main() diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..bdc5cb7 --- /dev/null +++ b/bot.py @@ -0,0 +1,218 @@ +""" +bot.py +====== + +Discord bot entry point. In WEB_ENABLED mode, also starts a FastAPI/Uvicorn +web server in the same asyncio event loop via asyncio.gather. + +Jobs are submitted directly to ComfyUI — no internal SerialJobQueue. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime, timezone +from pathlib import Path + +import discord +from discord.ext import commands + +from comfy_client import ComfyClient +from config import BotConfig, COMMAND_PREFIX +import generation_db +from input_image_db import init_db, get_all_images +from status_monitor import StatusMonitor +from workflow_manager import WorkflowManager +from workflow_state import WorkflowStateManager +from commands import register_all_commands, CustomHelpCommand +from commands.input_images import PersistentSetInputView +from commands.server import autostart_comfy + +try: + from dotenv import load_dotenv + load_dotenv() +except Exception: + pass + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +_PROJECT_ROOT = Path(__file__).resolve().parent + + +# --------------------------------------------------------------------------- +# Bot setup +# --------------------------------------------------------------------------- + +def get_prefix(bot, message): + """Dynamic command prefix getter.""" + msg = message.content.lower() + if msg.startswith(COMMAND_PREFIX): + return COMMAND_PREFIX + return COMMAND_PREFIX + + +intents = discord.Intents.default() +intents.message_content = True +intents.guilds = True + +bot = commands.Bot( + command_prefix=get_prefix, + intents=intents, + help_command=CustomHelpCommand(), +) + + +# --------------------------------------------------------------------------- +# Event handlers +# --------------------------------------------------------------------------- + +@bot.event +async def on_ready() -> None: + logger.info("Logged in as %s (ID: %s)", bot.user, bot.user.id) + if not hasattr(bot, "start_time"): + bot.start_time = datetime.now(timezone.utc) + cfg = getattr(bot, "config", None) + if cfg: + for row in get_all_images(): + view = PersistentSetInputView(bot, cfg, row["id"]) + bot.add_view(view, message_id=row["bot_reply_id"]) + asyncio.create_task(autostart_comfy(cfg)) + + if not hasattr(bot, "status_monitor"): + log_ch = getattr(getattr(bot, "config", None), "log_channel_id", None) + if log_ch: + bot.status_monitor = StatusMonitor(bot, log_ch) + if hasattr(bot, "status_monitor"): + await bot.status_monitor.start() + + +@bot.event +async def on_disconnect() -> None: + logger.info("Discord connection closed") + + +@bot.event +async def on_resumed() -> None: + logger.info("Discord session resumed") + + +# --------------------------------------------------------------------------- +# Startup helpers +# --------------------------------------------------------------------------- + +async def create_comfy(config: BotConfig) -> ComfyClient: + state_manager = WorkflowStateManager(state_file="current-workflow-changes.json") + workflow_manager = WorkflowManager() + return ComfyClient( + server_address=config.comfy_server, + workflow_manager=workflow_manager, + state_manager=state_manager, + logger=logger, + history_limit=config.comfy_history_limit, + output_path=config.comfy_output_path, + ) + + +def _try_autoload_last_workflow(client: ComfyClient) -> None: + """Re-load the last used workflow from the workflows/ folder on startup.""" + last_wf = client.state_manager.get_last_workflow_file() + if not last_wf: + return + wf_path = _PROJECT_ROOT / "workflows" / last_wf + if not wf_path.exists(): + logger.warning("Last workflow file not found: %s", wf_path) + return + try: + with open(wf_path, "r", encoding="utf-8") as f: + workflow = json.load(f) + # Restore template without clearing overrides on restart + client.workflow_manager.set_workflow_template(workflow) + logger.info("Auto-loaded last workflow: %s", last_wf) + except Exception as exc: + logger.error("Failed to auto-load workflow %s: %s", last_wf, exc) + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +async def main() -> None: + try: + config: BotConfig = BotConfig.from_env() + logger.info("Configuration loaded") + except RuntimeError as exc: + logger.error("Configuration error: %s", exc) + return + + bot.comfy = await create_comfy(config) + bot.config = config + + # Auto-load last workflow (restores template without clearing overrides) + _try_autoload_last_workflow(bot.comfy) + + # Fallback: WORKFLOW_FILE env var + if not bot.comfy.get_workflow_template() and config.workflow_file: + try: + bot.comfy.load_workflow_from_file(config.workflow_file) + logger.info("Loaded workflow from %s", config.workflow_file) + except Exception as exc: + logger.error("Failed to load workflow %s: %s", config.workflow_file, exc) + + from user_state_registry import UserStateRegistry + bot.user_registry = UserStateRegistry( + settings_dir=_PROJECT_ROOT / "user_settings", + default_workflow=bot.comfy.get_workflow_template(), + ) + + init_db() + generation_db.init_db(_PROJECT_ROOT / "generation_history.db") + register_all_commands(bot, config) + logger.info("All commands registered") + + coroutines = [bot.start(config.discord_bot_token)] + + if config.web_enabled: + try: + import uvicorn + from web.deps import set_bot + from web.app import create_app + + set_bot(bot) + fastapi_app = create_app() + + uvi_config = uvicorn.Config( + fastapi_app, + host=config.web_host, + port=config.web_port, + log_level="info", + loop="none", # use existing event loop + ) + uvi_server = uvicorn.Server(uvi_config) + coroutines.append(uvi_server.serve()) + logger.info( + "Web UI enabled at http://%s:%d", config.web_host, config.web_port + ) + except ImportError: + logger.warning( + "uvicorn or fastapi not installed — web UI disabled. " + "pip install fastapi uvicorn[standard]" + ) + + try: + await asyncio.gather(*coroutines) + finally: + if hasattr(bot, "status_monitor"): + await bot.status_monitor.stop() + if hasattr(bot, "comfy"): + await bot.comfy.close() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Received interrupt, shutting down…") diff --git a/comfy_client.py b/comfy_client.py new file mode 100644 index 0000000..e0cf712 --- /dev/null +++ b/comfy_client.py @@ -0,0 +1,604 @@ +""" +comfy_client.py +================ + +Asynchronous client for the ComfyUI API. + +Wraps ComfyUI's REST and WebSocket endpoints. Workflow template injection +is now handled by :class:`~workflow_inspector.WorkflowInspector`, so this +class only needs to: + +1. Accept a workflow template (delegated to WorkflowManager). +2. Accept runtime overrides (delegated to WorkflowStateManager). +3. Build the final workflow via inspector.inject_overrides(). +4. Queue it to ComfyUI, wait for completion via WebSocket, fetch outputs. + +A ``{prompt_id: callback}`` map is maintained for future WebSocket +broadcasting (web UI phase). Discord commands still use the synchronous +await-and-return model. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from typing import Any, Callable, Dict, List, Optional + +import aiohttp +import websockets + +from workflow_inspector import WorkflowInspector + + +class ComfyClient: + """ + Asynchronous ComfyUI client. + + Parameters + ---------- + server_address : str + ``hostname:port`` of the ComfyUI server. + workflow_manager : WorkflowManager + Template storage (injected). + state_manager : WorkflowStateManager + Runtime overrides (injected). + logger : Optional[logging.Logger] + Logger for debug/info messages. + history_limit : int + Max recent generations to keep in the in-memory deque. + """ + + def __init__( + self, + server_address: str, + workflow_manager, + state_manager, + logger: Optional[logging.Logger] = None, + *, + history_limit: int = 10, + output_path: Optional[str] = None, + ) -> None: + self.server_address = server_address.strip().rstrip("/") + self.client_id = str(uuid.uuid4()) + self._session: Optional[aiohttp.ClientSession] = None + + self.protocol = "http" + self.ws_protocol = "ws" + + self.workflow_manager = workflow_manager + self.state_manager = state_manager + self.inspector = WorkflowInspector() + self.output_path = output_path + + # prompt_id → asyncio.Future for web-UI broadcast (Phase 4) + self._pending_callbacks: Dict[str, Callable] = {} + + from collections import deque + self._history = deque(maxlen=history_limit) + + self.last_prompt_id: Optional[str] = None + self.last_seed: Optional[int] = None + self.total_generated: int = 0 + + self.logger = logger if logger else logging.getLogger(__name__) + + # ------------------------------------------------------------------ + # Session + # ------------------------------------------------------------------ + + @property + def session(self) -> aiohttp.ClientSession: + """Lazily create and return an aiohttp session.""" + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + # ------------------------------------------------------------------ + # Low-level REST helpers + # ------------------------------------------------------------------ + + async def _queue_prompt( + self, + prompt: dict[str, Any], + prompt_id: str, + ws_client_id: str | None = None, + ) -> dict[str, Any]: + """Submit a workflow to the ComfyUI queue.""" + payload = { + "prompt": prompt, + "client_id": ws_client_id if ws_client_id is not None else self.client_id, + "prompt_id": prompt_id, + } + url = f"{self.protocol}://{self.server_address}/prompt" + async with self.session.post(url, json=payload, + headers={"Content-Type": "application/json"}) as resp: + resp.raise_for_status() + return await resp.json() + + async def _wait_for_execution( + self, + prompt_id: str, + on_progress: Optional[Callable[[str, str], None]] = None, + ws_client_id: str | None = None, + ) -> None: + """ + Wait for a queued prompt to finish executing via WebSocket. + + Parameters + ---------- + prompt_id : str + The prompt to wait for. + on_progress : Optional[Callable[[str, str], None]] + Called with ``(node_id, prompt_id)`` for each ``node_executing`` + event. Pass ``None`` for Discord commands (no web broadcast + needed). + """ + client_id = ws_client_id if ws_client_id is not None else self.client_id + ws_url = ( + f"{self.ws_protocol}://{self.server_address}/ws" + f"?clientId={client_id}" + ) + async with websockets.connect(ws_url) as ws: + try: + while True: + out = await ws.recv() + if not isinstance(out, str): + continue + message = json.loads(out) + mtype = message.get("type") + + if mtype == "executing": + data = message["data"] + node = data.get("node") + if node: + self.logger.debug("Executing node: %s", node) + if on_progress and data.get("prompt_id") == prompt_id: + try: + on_progress(node, prompt_id) + except Exception: + pass + if data["node"] is None and data.get("prompt_id") == prompt_id: + self.logger.info("Execution complete for prompt %s", prompt_id) + break + + elif mtype == "execution_success": + if message.get("data", {}).get("prompt_id") == prompt_id: + self.logger.info("execution_success for prompt %s", prompt_id) + break + + elif mtype == "execution_error": + if message.get("data", {}).get("prompt_id") == prompt_id: + error = message.get("data", {}).get("exception_message", "unknown error") + raise RuntimeError(f"ComfyUI execution error: {error}") + + except Exception as exc: + self.logger.error("Error during execution wait: %s", exc) + raise + + async def _get_history(self, prompt_id: str) -> dict[str, Any]: + """Retrieve execution history for a given prompt id.""" + url = f"{self.protocol}://{self.server_address}/history/{prompt_id}" + async with self.session.get(url) as resp: + resp.raise_for_status() + return await resp.json() + + async def _download_image(self, filename: str, subfolder: str, folder_type: str) -> bytes: + """Download an image from ComfyUI and return raw bytes.""" + url = f"{self.protocol}://{self.server_address}/view" + params = {"filename": filename, "subfolder": subfolder, "type": folder_type} + async with self.session.get(url, params=params) as resp: + resp.raise_for_status() + return await resp.read() + + # ------------------------------------------------------------------ + # Core generation pipeline + # ------------------------------------------------------------------ + + async def _general_generate( + self, + workflow: dict[str, Any], + prompt_id: str, + on_progress: Optional[Callable[[str, str], None]] = None, + ) -> tuple[List[bytes], List[dict[str, Any]]]: + """ + Queue a workflow, wait for it to execute, then collect outputs. + + Returns + ------- + tuple[List[bytes], List[dict]] + ``(images, videos)`` — images as raw bytes, videos as info dicts. + """ + ws_client_id = str(uuid.uuid4()) + await self._queue_prompt(workflow, prompt_id, ws_client_id) + try: + await self._wait_for_execution(prompt_id, on_progress=on_progress, ws_client_id=ws_client_id) + except Exception: + self.logger.error("Execution failed for prompt %s", prompt_id) + return [], [] + + history = await self._get_history(prompt_id) + if not history: + self.logger.warning("No history for prompt %s", prompt_id) + return [], [] + + images: List[bytes] = [] + videos: List[dict[str, Any]] = [] + + for node_output in history.get(prompt_id, {}).get("outputs", {}).values(): + for image_info in node_output.get("images", []): + name = image_info["filename"] + if name.rsplit(".", 1)[-1].lower() in {"mp4", "webm", "avi"}: + videos.append({ + "video_name": name, + "video_subfolder": image_info.get("subfolder", ""), + "video_type": image_info.get("type", "output"), + }) + else: + data = await self._download_image( + name, image_info["subfolder"], image_info["type"] + ) + images.append(data) + + return images, videos + + # ------------------------------------------------------------------ + # DB persistence helper + # ------------------------------------------------------------------ + + def _record_to_db( + self, + prompt_id: str, + source: str, + user_label: Optional[str], + overrides: Dict[str, Any], + seed: Optional[int], + images: List[bytes], + videos: List[Dict[str, Any]], + ) -> None: + """Persist generation metadata and file blobs to SQLite. Never raises.""" + try: + import generation_db + from pathlib import Path as _Path + gen_id = generation_db.record_generation( + prompt_id, source, user_label, overrides, seed + ) + for i, img_data in enumerate(images): + generation_db.record_file(gen_id, f"image_{i:04d}.png", img_data) + if videos and self.output_path: + for vid in videos: + vname = vid.get("video_name", "") + vsub = vid.get("video_subfolder", "") + vpath = ( + _Path(self.output_path) / vsub / vname + if vsub + else _Path(self.output_path) / vname + ) + try: + generation_db.record_file(gen_id, vname, vpath.read_bytes()) + except OSError as exc: + self.logger.warning( + "Could not read video for DB storage: %s: %s", vpath, exc + ) + except Exception as exc: + self.logger.warning("Failed to record generation to DB: %s", exc) + + # ------------------------------------------------------------------ + # Public generation API + # ------------------------------------------------------------------ + + async def generate_image( + self, + prompt: str, + negative_prompt: Optional[str] = None, + on_progress: Optional[Callable[[str, str], None]] = None, + *, + source: str = "discord", + user_label: Optional[str] = None, + ) -> tuple[List[bytes], str]: + """ + Generate images using the current workflow template with a text prompt. + + Injects *prompt* (and optionally *negative_prompt*) via the inspector, + plus any currently pinned seed from the state manager. All other + overrides in the state manager are **not** applied here — use + :meth:`generate_image_with_workflow` for the full override set. + + Parameters + ---------- + prompt : str + Positive prompt text. + negative_prompt : Optional[str] + Negative prompt text (optional). + on_progress : Optional[Callable] + Called with ``(node_id, prompt_id)`` for each executing node. + + Returns + ------- + tuple[List[bytes], str] + ``(images, prompt_id)`` + """ + template = self.workflow_manager.get_workflow_template() + if not template: + self.logger.warning("No workflow template set; cannot generate.") + return [], "" + + overrides: Dict[str, Any] = {"prompt": prompt} + if negative_prompt is not None: + overrides["negative_prompt"] = negative_prompt + # Respect pinned seed from state manager + seed_pin = self.state_manager.get_seed() + if seed_pin is not None: + overrides["seed"] = seed_pin + + workflow, applied = self.inspector.inject_overrides(template, overrides) + seed_used = applied.get("seed") + self.last_seed = seed_used + + prompt_id = str(uuid.uuid4()) + images, _videos = await self._general_generate(workflow, prompt_id, on_progress) + + self.last_prompt_id = prompt_id + self.total_generated += 1 + self._history.append({ + "prompt_id": prompt_id, + "prompt": prompt, + "negative_prompt": negative_prompt, + "seed": seed_used, + }) + self._record_to_db( + prompt_id, source, user_label, + {"prompt": prompt, "negative_prompt": negative_prompt}, + seed_used, images, [], + ) + return images, prompt_id + + async def generate_image_with_workflow( + self, + on_progress: Optional[Callable[[str, str], None]] = None, + *, + source: str = "discord", + user_label: Optional[str] = None, + ) -> tuple[List[bytes], List[dict[str, Any]], str]: + """ + Generate images/videos from the current workflow applying ALL + overrides stored in the state manager. + + Returns + ------- + tuple[List[bytes], List[dict], str] + ``(images, videos, prompt_id)`` + """ + template = self.workflow_manager.get_workflow_template() + prompt_id = str(uuid.uuid4()) + if not template: + self.logger.error("No workflow template set") + return [], [], prompt_id + + overrides = self.state_manager.get_overrides() + workflow, applied = self.inspector.inject_overrides(template, overrides) + seed_used = applied.get("seed") + self.last_seed = seed_used + + images, videos = await self._general_generate(workflow, prompt_id, on_progress) + + self.last_prompt_id = prompt_id + self.total_generated += 1 + prompt_str = overrides.get("prompt") or "" + neg_str = overrides.get("negative_prompt") or "" + self._history.append({ + "prompt_id": prompt_id, + "prompt": (prompt_str[:10] + "…") if len(prompt_str) > 10 else prompt_str or None, + "negative_prompt": (neg_str[:10] + "…") if len(neg_str) > 10 else neg_str or None, + "seed": seed_used, + }) + self._record_to_db(prompt_id, source, user_label, overrides, seed_used, images, videos) + return images, videos, prompt_id + + # ------------------------------------------------------------------ + # Workflow template management + # ------------------------------------------------------------------ + + def set_workflow(self, workflow: dict[str, Any]) -> None: + """Set the workflow template and clear all state overrides.""" + self.workflow_manager.set_workflow_template(workflow) + self.state_manager.clear_overrides() + + def load_workflow_from_file(self, path: str) -> None: + """ + Load a workflow template from a JSON file. + + Also clears state overrides and records the filename in the state + manager for auto-load on restart. + """ + import json as _json + with open(path, "r", encoding="utf-8") as f: + workflow = _json.load(f) + self.workflow_manager.set_workflow_template(workflow) + self.state_manager.clear_overrides() + from pathlib import Path + self.state_manager.set_last_workflow_file(Path(path).name) + + def get_workflow_template(self) -> Optional[dict[str, Any]]: + """Return the current workflow template (or None).""" + return self.workflow_manager.get_workflow_template() + + # ------------------------------------------------------------------ + # State management convenience wrappers + # ------------------------------------------------------------------ + + def get_workflow_current_changes(self) -> dict[str, Any]: + """Return all current overrides (backward-compat).""" + return self.state_manager.get_changes() + + def set_workflow_current_changes(self, changes: dict[str, Any]) -> None: + """Merge override changes (backward-compat).""" + self.state_manager.set_changes(changes, merge=True) + + def set_workflow_current_prompt(self, prompt: str) -> None: + self.state_manager.set_prompt(prompt) + + def set_workflow_current_negative_prompt(self, negative_prompt: str) -> None: + self.state_manager.set_negative_prompt(negative_prompt) + + def set_workflow_current_input_image(self, input_image: str) -> None: + self.state_manager.set_input_image(input_image) + + def get_current_workflow_prompt(self) -> Optional[str]: + return self.state_manager.get_prompt() + + def get_current_workflow_negative_prompt(self) -> Optional[str]: + return self.state_manager.get_negative_prompt() + + def get_current_workflow_input_image(self) -> Optional[str]: + return self.state_manager.get_input_image() + + # ------------------------------------------------------------------ + # Image upload + # ------------------------------------------------------------------ + + async def upload_image( + self, + data: bytes, + filename: str, + *, + image_type: str = "input", + overwrite: bool = False, + ) -> dict[str, Any]: + """Upload an image to ComfyUI via the /upload/image endpoint.""" + url = f"{self.protocol}://{self.server_address}/upload/image" + form = aiohttp.FormData() + form.add_field("image", data, filename=filename, + content_type="application/octet-stream") + form.add_field("type", image_type) + form.add_field("overwrite", str(overwrite).lower()) + async with self.session.post(url, data=form) as resp: + resp.raise_for_status() + try: + return await resp.json() + except aiohttp.ContentTypeError: + return {"status": await resp.text()} + + # ------------------------------------------------------------------ + # History + # ------------------------------------------------------------------ + + def get_history(self) -> List[dict]: + """Return a list of recently generated prompt records (from DB).""" + try: + from generation_db import get_history as db_get_history + return db_get_history(limit=self._history.maxlen or 50) + except Exception: + return list(self._history) + + async def fetch_history_images(self, prompt_id: str) -> List[bytes]: + """Re-download images for a previously generated prompt.""" + history = await self._get_history(prompt_id) + images: List[bytes] = [] + for node_output in history.get(prompt_id, {}).get("outputs", {}).values(): + for image_info in node_output.get("images", []): + data = await self._download_image( + image_info["filename"], + image_info["subfolder"], + image_info["type"], + ) + images.append(data) + return images + + # ------------------------------------------------------------------ + # Server info / queue + # ------------------------------------------------------------------ + + async def get_system_stats(self) -> Optional[dict[str, Any]]: + """Fetch ComfyUI system stats (/system_stats).""" + try: + url = f"{self.protocol}://{self.server_address}/system_stats" + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + resp.raise_for_status() + return await resp.json() + except Exception as exc: + self.logger.warning("Failed to fetch system stats: %s", exc) + return None + + async def get_comfy_queue(self) -> Optional[dict[str, Any]]: + """Fetch the ComfyUI queue (/queue).""" + try: + url = f"{self.protocol}://{self.server_address}/queue" + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + resp.raise_for_status() + return await resp.json() + except Exception as exc: + self.logger.warning("Failed to fetch comfy queue: %s", exc) + return None + + async def get_queue_depth(self) -> int: + """Return the total number of pending + running jobs in ComfyUI.""" + q = await self.get_comfy_queue() + if q: + return len(q.get("queue_running", [])) + len(q.get("queue_pending", [])) + return 0 + + async def clear_queue(self) -> bool: + """Clear all pending jobs from the ComfyUI queue.""" + try: + url = f"{self.protocol}://{self.server_address}/queue" + async with self.session.post( + url, + json={"clear": True}, + headers={"Content-Type": "application/json"}, + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + return resp.status in (200, 204) + except Exception as exc: + self.logger.warning("Failed to clear comfy queue: %s", exc) + return False + + async def check_connection(self) -> bool: + """Return True if the ComfyUI server is reachable.""" + try: + url = f"{self.protocol}://{self.server_address}/system_stats" + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + return resp.status == 200 + except Exception: + return False + + async def get_models(self, model_type: str = "checkpoints") -> List[str]: + """ + Fetch available model names from ComfyUI. + + Parameters + ---------- + model_type : str + One of ``"checkpoints"``, ``"loras"``, etc. + """ + try: + url = f"{self.protocol}://{self.server_address}/object_info" + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp: + resp.raise_for_status() + info = await resp.json() + if model_type == "checkpoints": + node = info.get("CheckpointLoaderSimple", {}) + return node.get("input", {}).get("required", {}).get("ckpt_name", [None])[0] or [] + elif model_type == "loras": + node = info.get("LoraLoader", {}) + return node.get("input", {}).get("required", {}).get("lora_name", [None])[0] or [] + return [] + except Exception as exc: + self.logger.warning("Failed to fetch models (%s): %s", model_type, exc) + return [] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def close(self) -> None: + """Close the underlying aiohttp session.""" + if self._session and not self._session.closed: + await self._session.close() + + async def __aenter__(self) -> "ComfyClient": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.close() diff --git a/commands/__init__.py b/commands/__init__.py new file mode 100644 index 0000000..b166c6c --- /dev/null +++ b/commands/__init__.py @@ -0,0 +1,64 @@ +""" +commands package +================ + +Discord bot commands for the ComfyUI bot. + +This package contains all command handlers organized by functionality: +- generation: Image/video generation commands (generate, workflow-gen, rerun, cancel) +- workflow: Workflow management commands +- upload: Image upload commands +- history: History viewing and retrieval commands +- workflow_changes: Runtime workflow parameter management (prompt, seed, etc.) +- utility: Quality-of-life commands (ping, status, comfy-stats, comfy-queue, uptime) +- presets: Named workflow preset management +""" +from __future__ import annotations +from config import BotConfig + +from .generation import setup_generation_commands +from .input_images import setup_input_image_commands +from .server import setup_server_commands +from .workflow import setup_workflow_commands +from .history import setup_history_commands +from .workflow_changes import setup_workflow_changes_commands +from .utility import setup_utility_commands +from .presets import setup_preset_commands +from .help_command import CustomHelpCommand + + +def register_all_commands(bot, config: BotConfig): + """ + Register all bot commands. + + This function should be called once during bot initialization to set up + all command handlers. + + Parameters + ---------- + bot : commands.Bot + The Discord bot instance. + config : BotConfig + The bot configuration object containing environment settings. + """ + setup_generation_commands(bot, config) + setup_input_image_commands(bot, config) + setup_server_commands(bot, config) + setup_workflow_commands(bot) + setup_history_commands(bot) + setup_workflow_changes_commands(bot) + setup_utility_commands(bot) + setup_preset_commands(bot) + + +__all__ = [ + "register_all_commands", + "setup_generation_commands", + "setup_input_image_commands", + "setup_workflow_commands", + "setup_history_commands", + "setup_workflow_changes_commands", + "setup_utility_commands", + "setup_preset_commands", + "CustomHelpCommand", +] diff --git a/commands/generation.py b/commands/generation.py new file mode 100644 index 0000000..7b2fffb --- /dev/null +++ b/commands/generation.py @@ -0,0 +1,389 @@ +""" +commands/generation.py +====================== + +Image and video generation commands for the Discord ComfyUI bot. + +Jobs are submitted directly to ComfyUI (no internal SerialJobQueue). +ComfyUI's own queue handles ordering. Each Discord command waits for its +prompt_id to complete via WebSocket and then replies with the result. +""" + +from __future__ import annotations + +import asyncio +import logging + +try: + import aiohttp # type: ignore +except Exception: # pragma: no cover + aiohttp = None # type: ignore + +from io import BytesIO +from pathlib import Path +from typing import Optional + +import discord +from discord.ext import commands + +from config import ARG_PROMPT_KEY, ARG_NEG_PROMPT_KEY, ARG_QUEUE_KEY, MAX_IMAGES_PER_RESPONSE +from discord_utils import require_comfy_client, convert_image_bytes_to_discord_files +from media_uploader import flush_pending + + +logger = logging.getLogger(__name__) + + +async def _safe_reply( + ctx: commands.Context, + *, + content: str | None = None, + files: list[discord.File] | None = None, + mention_author: bool = True, + delete_after: float | None = None, + tries: int = 4, + base_delay: float = 1.0, +): + """Reply to Discord with retries for transient network/Discord errors.""" + delay = base_delay + last_exc: Exception | None = None + + for attempt in range(1, tries + 1): + try: + return await ctx.reply( + content=content, + files=files or [], + mention_author=mention_author, + delete_after=delete_after, + ) + except Exception as exc: # noqa: BLE001 + last_exc = exc + transient = False + + if isinstance(exc, asyncio.TimeoutError): + transient = True + elif isinstance(exc, OSError) and getattr(exc, "winerror", None) in { + 64, 121, 1231, 10053, 10054, + }: + transient = True + + if aiohttp is not None: + try: + if isinstance(exc, (aiohttp.ClientOSError, aiohttp.ClientConnectionError)): + transient = True + except Exception: + pass + + if isinstance(exc, discord.HTTPException): + status = getattr(exc, "status", None) + if status is None or status >= 500 or status == 429: + transient = True + + if (not transient) or attempt == tries: + raise + + logger.warning( + "Transient error sending Discord message (attempt %d/%d): %s: %s", + attempt, tries, type(exc).__name__, exc, + ) + await asyncio.sleep(delay) + delay *= 2 + + raise last_exc # type: ignore[misc] + + +def _seed_line(bot) -> str: + """Return a formatted seed line if a seed was tracked, else empty string.""" + seed = getattr(bot.comfy, "last_seed", None) + return f"\nSeed: `{seed}`" if seed is not None else "" + + +async def _run_generate(ctx: commands.Context, bot, prompt_text: str, negative_text: Optional[str]): + """Execute a prompt-based generation and reply with results.""" + images, prompt_id = await bot.comfy.generate_image( + prompt_text, negative_text, + source="discord", user_label=ctx.author.display_name, + ) + if not images: + await ctx.reply( + "No images were generated. Please try again with a different prompt.", + mention_author=False, + ) + return + + files = convert_image_bytes_to_discord_files( + images, max_files=MAX_IMAGES_PER_RESPONSE, prefix="generated" + ) + response_text = f"Generated {len(images)} image(s). Prompt ID: `{prompt_id}`{_seed_line(bot)}" + await _safe_reply(ctx, content=response_text, files=files, mention_author=True) + + asyncio.create_task(flush_pending( + Path(bot.config.comfy_output_path), + bot.config.media_upload_user, + bot.config.media_upload_pass, + )) + + +async def _run_workflow(ctx: commands.Context, bot, config): + """Execute a workflow-based generation and reply with results.""" + logger.info("Executing workflow generation") + await ctx.reply("Executing workflow…", mention_author=False, delete_after=5.0) + images, videos, prompt_id = await bot.comfy.generate_image_with_workflow( + source="discord", user_label=ctx.author.display_name, + ) + + if not images and not videos: + await ctx.reply( + "No images or videos were generated. Check the workflow and ComfyUI logs.", + mention_author=False, + ) + return + + seed_info = _seed_line(bot) + + if videos: + output_path = config.comfy_output_path + video_file = None + for video_info in videos: + video_name = video_info.get("video_name") + video_subfolder = video_info.get("video_subfolder", "") + if video_name: + video_path = ( + Path(output_path) / video_subfolder / video_name + if video_subfolder + else Path(output_path) / video_name + ) + try: + video_file = discord.File( + BytesIO(video_path.read_bytes()), filename=video_name + ) + break + except Exception as exc: + logger.exception("Failed to read video %s: %s", video_path, exc) + + if video_file: + response_text = ( + f"Generated {len(images)} image(s) and a video. " + f"Prompt ID: `{prompt_id}`{seed_info}" + ) + await _safe_reply(ctx, content=response_text, files=[video_file], mention_author=True) + else: + await ctx.reply( + f"Generated output but failed to read video file. " + f"Prompt ID: `{prompt_id}`{seed_info}", + mention_author=True, + ) + else: + files = convert_image_bytes_to_discord_files( + images, max_files=MAX_IMAGES_PER_RESPONSE, prefix="generated" + ) + response_text = ( + f"Generated {len(images)} image(s) using workflow. " + f"Prompt ID: `{prompt_id}`{seed_info}" + ) + await _safe_reply(ctx, content=response_text, files=files, mention_author=True) + + asyncio.create_task(flush_pending( + Path(config.comfy_output_path), + config.media_upload_user, + config.media_upload_pass, + )) + + +def setup_generation_commands(bot, config): + """Register generation commands with the bot.""" + + @bot.command(name="test", extras={"category": "Generation"}) + async def test_command(ctx: commands.Context) -> None: + """A simple test command to verify the bot is working.""" + await ctx.reply( + "The bot is working! Use `ttr!generate` to create images.", + mention_author=False, + ) + + @bot.command(name="generate", aliases=["gen"], extras={"category": "Generation"}) + @require_comfy_client + async def generate(ctx: commands.Context, *, args: str = "") -> None: + """ + Generate images using ComfyUI. + + Usage:: + + ttr!generate prompt: negative_prompt: + + The ``prompt:`` keyword is required. ``negative_prompt:`` is optional. + """ + prompt_text: Optional[str] = None + negative_text: Optional[str] = None + + if args: + if ARG_PROMPT_KEY in args: + parts = args.split(ARG_PROMPT_KEY, 1)[1] + if ARG_NEG_PROMPT_KEY in parts: + p, n = parts.split(ARG_NEG_PROMPT_KEY, 1) + prompt_text = p.strip() + negative_text = n.strip() or None + else: + prompt_text = parts.strip() + else: + prompt_text = args.strip() + + if not prompt_text: + await ctx.reply( + f"Please specify a prompt: `{ARG_PROMPT_KEY}`.", + mention_author=False, + ) + return + + bot.last_gen = {"mode": "prompt", "prompt": prompt_text, "negative": negative_text} + + try: + # Show queue position from ComfyUI before waiting + depth = await bot.comfy.get_queue_depth() + pos = depth + 1 + ack = await ctx.reply( + f"Queued ✅ (ComfyUI position: ~{pos})", + mention_author=False, + delete_after=30.0, + ) + await _run_generate(ctx, bot, prompt_text, negative_text) + except Exception as exc: + logger.exception("Error generating image") + await ctx.reply( + f"An error occurred: {type(exc).__name__}: {exc}", + mention_author=False, + ) + + @bot.command( + name="workflow-gen", + aliases=["workflow-generate", "wfg"], + extras={"category": "Generation"}, + ) + @require_comfy_client + async def generate_workflow_command(ctx: commands.Context, *, args: str = "") -> None: + """ + Generate using the currently loaded workflow template. + + Usage:: + + ttr!workflow-gen + ttr!workflow-gen queue: + """ + bot.last_gen = {"mode": "workflow", "prompt": None, "negative": None} + + # Handle batch queue parameter + if ARG_QUEUE_KEY in args: + number_part = args.split(ARG_QUEUE_KEY, 1)[1].strip() + if number_part.isdigit(): + queue_times = int(number_part) + if queue_times > 1: + await ctx.reply( + f"Queuing {queue_times} workflow runs…", + mention_author=False, + ) + for i in range(queue_times): + try: + depth = await bot.comfy.get_queue_depth() + pos = depth + 1 + await ctx.reply( + f"Queued run {i+1}/{queue_times} ✅ (ComfyUI position: ~{pos})", + mention_author=False, + delete_after=30.0, + ) + await _run_workflow(ctx, bot, config) + except Exception as exc: + logger.exception("Error on workflow run %d", i + 1) + await ctx.reply( + f"Error on run {i+1}: {type(exc).__name__}: {exc}", + mention_author=False, + ) + return + else: + await ctx.reply( + "Please provide a number greater than 1 for queueing multiple runs.", + mention_author=False, + delete_after=30.0, + ) + return + else: + await ctx.reply( + f"Invalid queue parameter. Use `{ARG_QUEUE_KEY}`.", + mention_author=False, + delete_after=30.0, + ) + return + + try: + depth = await bot.comfy.get_queue_depth() + pos = depth + 1 + await ctx.reply( + f"Queued ✅ (ComfyUI position: ~{pos})", + mention_author=False, + delete_after=30.0, + ) + await _run_workflow(ctx, bot, config) + except Exception as exc: + logger.exception("Error generating with workflow") + await ctx.reply( + f"An error occurred: {type(exc).__name__}: {exc}", + mention_author=False, + ) + + @bot.command(name="rerun", aliases=["rr"], extras={"category": "Generation"}) + @require_comfy_client + async def rerun_command(ctx: commands.Context) -> None: + """ + Re-run the last generation with the same parameters. + + Re-submits the most recent ``ttr!generate`` or ``ttr!workflow-gen`` + with the same mode and prompt. Current state overrides (seed, + input_image, etc.) are applied at execution time. + """ + last = getattr(bot, "last_gen", None) + if last is None: + await ctx.reply( + "No previous generation to rerun.", + mention_author=False, + ) + return + + try: + depth = await bot.comfy.get_queue_depth() + pos = depth + 1 + await ctx.reply( + f"Rerun queued ✅ (ComfyUI position: ~{pos})", + mention_author=False, + delete_after=30.0, + ) + if last["mode"] == "prompt": + await _run_generate(ctx, bot, last["prompt"], last["negative"]) + else: + await _run_workflow(ctx, bot, config) + except Exception as exc: + logger.exception("Error queueing rerun") + await ctx.reply( + f"An error occurred: {type(exc).__name__}: {exc}", + mention_author=False, + ) + + @bot.command(name="cancel", extras={"category": "Generation"}) + @require_comfy_client + async def cancel_command(ctx: commands.Context) -> None: + """ + Clear all pending jobs from the ComfyUI queue. + + Usage:: + + ttr!cancel + """ + try: + ok = await bot.comfy.clear_queue() + if ok: + await ctx.reply("ComfyUI queue cleared.", mention_author=False) + else: + await ctx.reply( + "Failed to clear the ComfyUI queue (server may have returned an error).", + mention_author=False, + ) + except Exception as exc: + await ctx.reply(f"Error: {exc}", mention_author=False) diff --git a/commands/help_command.py b/commands/help_command.py new file mode 100644 index 0000000..063a7f1 --- /dev/null +++ b/commands/help_command.py @@ -0,0 +1,134 @@ +""" +commands/help_command.py +======================== + +Custom help command for the Discord ComfyUI bot. + +Replaces discord.py's default help with a categorised listing that +automatically includes every registered command. + +How it works +------------ +Each ``@bot.command()`` decorator should carry an ``extras`` dict with a +``"category"`` key: + + @bot.command(name="my-command", extras={"category": "Generation"}) + async def my_command(ctx): + \"""One-line brief shown in the listing. + + Longer description shown in ttr!help my-command. + \""" + +The first line of the docstring becomes the brief shown in the main +listing. The full docstring is shown when the user asks for per-command +detail. Commands without a category appear under **Other**. + +Usage +----- + ttr!help — list all commands grouped by category + ttr!help — detailed help for a specific command +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import List, Mapping, Optional + +from discord.ext import commands + + +# Order in which categories appear in the full help listing. +# Any category not listed here appears at the end, sorted alphabetically. +CATEGORY_ORDER = ["Generation", "Workflow", "Upload", "History", "Presets", "Utility"] + + +def _category_sort_key(name: str) -> tuple: + """Return a sort key that respects CATEGORY_ORDER, then alphabetical.""" + try: + return (CATEGORY_ORDER.index(name), name) + except ValueError: + return (len(CATEGORY_ORDER), name) + + +class CustomHelpCommand(commands.HelpCommand): + """ + Categorised help command. + + Groups commands by the ``"category"`` value in their ``extras`` dict. + Commands that omit this appear under **Other**. + + Adding a new command to the help output requires no changes here — + just set ``extras={"category": "..."}`` on the decorator and write a + descriptive docstring. + """ + + # ------------------------------------------------------------------ + # Main listing — ttr!help + # ------------------------------------------------------------------ + + async def send_bot_help( + self, + mapping: Mapping[Optional[commands.Cog], List[commands.Command]], + ) -> None: + """Send the full command listing grouped by category.""" + # Collect all visible commands across every cog / None bucket + all_commands: List[commands.Command] = [] + for cmds in mapping.values(): + filtered = await self.filter_commands(cmds) + all_commands.extend(filtered) + + # Group by category + categories: dict[str, list[commands.Command]] = defaultdict(list) + for cmd in all_commands: + cat = cmd.extras.get("category", "Other") + categories[cat].append(cmd) + + prefix = self.context.prefix + lines: list[str] = [f"**Commands** — prefix: `{prefix}`\n"] + + for cat in sorted(categories.keys(), key=_category_sort_key): + cmds = sorted(categories[cat], key=lambda c: c.name) + lines.append(f"**{cat}**") + for cmd in cmds: + aliases = ( + f" ({', '.join(cmd.aliases)})" if cmd.aliases else "" + ) + brief = cmd.short_doc or "No description." + lines.append(f" `{cmd.name}`{aliases} — {brief}") + lines.append("") + + lines.append( + f"Use `{prefix}help ` for details on a specific command." + ) + + await self.get_destination().send("\n".join(lines)) + + # ------------------------------------------------------------------ + # Per-command detail — ttr!help + # ------------------------------------------------------------------ + + async def send_command_help(self, command: commands.Command) -> None: + """Send detailed help for a single command.""" + prefix = self.context.prefix + header = f"`{prefix}{command.name}`" + if command.aliases: + alias_list = ", ".join(f"`{a}`" for a in command.aliases) + header += f" (aliases: {alias_list})" + + category = command.extras.get("category", "Other") + lines: list[str] = [header, f"Category: **{category}**", ""] + + if command.help: + lines.append(command.help.strip()) + else: + lines.append("No description available.") + + await self.get_destination().send("\n".join(lines)) + + # ------------------------------------------------------------------ + # Error — unknown command name + # ------------------------------------------------------------------ + + async def send_error_message(self, error: str) -> None: + """Forward the error text to the channel.""" + await self.get_destination().send(error) diff --git a/commands/history.py b/commands/history.py new file mode 100644 index 0000000..b146b3d --- /dev/null +++ b/commands/history.py @@ -0,0 +1,169 @@ +""" +commands/history.py +=================== + +History management commands for the Discord ComfyUI bot. + +This module contains commands for viewing and retrieving past generation +results from the bot's history. +""" + +from __future__ import annotations + +import logging +from io import BytesIO +from typing import Optional + +import discord +from discord.ext import commands + +from config import MAX_IMAGES_PER_RESPONSE +from discord_utils import require_comfy_client, truncate_text, convert_image_bytes_to_discord_files + + +logger = logging.getLogger(__name__) + + +def setup_history_commands(bot): + """ + Register history management commands with the bot. + + Parameters + ---------- + bot : commands.Bot + The Discord bot instance. + """ + + @bot.command(name="history", extras={"category": "History"}) + @require_comfy_client + async def history_command(ctx: commands.Context) -> None: + """ + Show a list of recently generated prompts. + + The bot keeps a rolling history of the last few generations. Each + entry lists the prompt id along with the positive and negative + prompt texts. You can retrieve the images from a previous + generation with the ``ttr!gethistory `` command. + """ + hist = bot.comfy.get_history() + if not hist: + await ctx.reply( + "No history available yet. Generate something first!", + mention_author=False, + ) + return + + # Build a human readable list + lines = ["Here are the most recent generations (oldest first):"] + for entry in hist: + pid = entry.get("prompt_id", "unknown") + prompt = entry.get("prompt") or "" + neg = entry.get("negative_prompt") or "" + # Truncate long prompts for readability + lines.append( + f"• ID: {pid} | prompt: '{truncate_text(prompt, 60)}' | negative: '{truncate_text(neg, 60)}'" + ) + await ctx.reply("\n".join(lines), mention_author=False) + + @bot.command(name="get-history", aliases=["gethistory", "gh"], extras={"category": "History"}) + @require_comfy_client + async def get_history_command(ctx: commands.Context, *, arg: str = "") -> None: + """ + Retrieve images from a previous generation, or search history by keyword. + + Usage: + ttr!gethistory + ttr!gethistory search: + + Provide either the prompt id returned in the generation response + (shown in `ttr!history`) or the 1‑based index into the history + list. The bot will fetch the images associated with that + generation and resend them. If no images are found, you will be + notified. + + Use ``search:`` to filter history by prompt text, checkpoint + name, seed value, or any other override field. + """ + if not arg: + await ctx.reply( + "Please provide a prompt id, history index, or `search:`. See `ttr!history` for a list.", + mention_author=False, + ) + return + + # Handle search: + lower_arg = arg.lower() + if lower_arg.startswith("search:"): + keyword = arg[len("search:"):].strip() + if not keyword: + await ctx.reply("Please provide a keyword after `search:`.", mention_author=False) + return + from generation_db import search_history_for_user, get_history as db_get_history + # Use get_history for Discord since Discord bot doesn't have per-user context like the web UI + hist = db_get_history(limit=50) + matches = [ + e for e in hist + if keyword.lower() in str(e.get("overrides", {})).lower() + ] + if not matches: + await ctx.reply(f"No history entries matching `{keyword}`.", mention_author=False) + return + lines = [f"**History matching `{keyword}`** ({len(matches)} result(s))"] + for entry in matches[:10]: + pid = entry.get("prompt_id", "unknown") + overrides = entry.get("overrides") or {} + prompt = str(overrides.get("prompt") or "") + lines.append( + f"• `{pid[:12]}…` | {truncate_text(prompt, 60) if prompt else '(no prompt)'}" + ) + if len(matches) > 10: + lines.append(f"_(showing first 10 of {len(matches)})_") + await ctx.reply("\n".join(lines), mention_author=False) + return + + # Determine whether arg refers to an index or an id + target_id: Optional[str] = None + hist = bot.comfy.get_history() + + # If arg is a digit, interpret as 1‑based index + if arg.isdigit(): + idx = int(arg) - 1 + if idx < 0 or idx >= len(hist): + await ctx.reply( + f"Index out of range. There are {len(hist)} entries in history.", + mention_author=False, + ) + return + target_id = hist[idx]["prompt_id"] + else: + # Otherwise treat as an explicit prompt id + target_id = arg.strip() + + try: + images = await bot.comfy.fetch_history_images(target_id) + if not images: + await ctx.reply( + f"No images found for prompt id `{target_id}`.", + mention_author=False, + ) + return + + files = [] + for idx, img_bytes in enumerate(images): + if idx >= MAX_IMAGES_PER_RESPONSE: + break + file_obj = BytesIO(img_bytes) + file_obj.seek(0) + files.append(discord.File(file_obj, filename=f"history_{target_id}_{idx+1}.png")) + + await ctx.reply( + content=f"Here are the images for prompt id `{target_id}`:", + files=files, + mention_author=False, + ) + except Exception as exc: + logger.exception("Failed to fetch history for %s", target_id) + await ctx.reply( + f"An error occurred: {type(exc).__name__}: {exc}", + mention_author=False, + ) diff --git a/commands/input_images.py b/commands/input_images.py new file mode 100644 index 0000000..2c41882 --- /dev/null +++ b/commands/input_images.py @@ -0,0 +1,178 @@ +""" +commands/input_images.py +======================== + +Channel-backed input image management. + +Images uploaded to the designated `comfy-input` channel get a persistent +"✅ Set as input" button posted by the bot — one reply per attachment so +every image in a multi-image message is independently selectable. + +Persistent views survive bot restarts: on_ready re-registers every view +stored in the SQLite database. +""" + +from __future__ import annotations + +import io +import logging +from pathlib import Path + +import discord +from discord.ext import commands + +from image_utils import compress_to_discord_limit +from input_image_db import ( + activate_image_for_slot, + get_all_images, + upsert_image, +) + +logger = logging.getLogger(__name__) + +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"} + + + +class PersistentSetInputView(discord.ui.View): + """ + A persistent view that survives bot restarts. + + One instance is created per DB row (i.e. per attachment). + The button's custom_id encodes the row id so the callback can look + up the exact filename to download. + """ + + def __init__(self, bot, config, row_id: int): + super().__init__(timeout=None) + self._bot = bot + self._config = config + self._row_id = row_id + + btn = discord.ui.Button( + label="✅ Set as input", + style=discord.ButtonStyle.success, + custom_id=f"set_input:{row_id}", + ) + btn.callback = self._set_callback + self.add_item(btn) + + async def _set_callback(self, interaction: discord.Interaction) -> None: + await interaction.response.defer(ephemeral=True) + try: + filename = activate_image_for_slot( + self._row_id, "input_image", self._config.comfy_input_path + ) + self._bot.comfy.state_manager.set_override("input_image", filename) + await interaction.followup.send( + f"✅ Input image set to `{filename}`", ephemeral=True + ) + except Exception as exc: + logger.exception("set_input button failed for row %s", self._row_id) + await interaction.followup.send(f"❌ Error: {exc}", ephemeral=True) + + +async def _register_attachment(bot, config, message: discord.Message, attachment: discord.Attachment) -> None: + """Post a reply with the image preview, a Set-as-input button, and record it in the DB.""" + logger.info("[_register_attachment] Start") + original_data = await attachment.read() + original_filename = attachment.filename + logger.info("[_register_attachment] Reading attachment") + + # Compress only for the Discord re-send (8 MiB bot limit) + send_data, send_filename = compress_to_discord_limit(original_data, original_filename) + + file = discord.File(io.BytesIO(send_data), filename=send_filename) + reply = await message.channel.send(f"`{original_filename}`", file=file) + + # Store original quality bytes in DB + row_id = upsert_image(message.id, reply.id, message.channel.id, original_filename, image_data=original_data) + view = PersistentSetInputView(bot, config, row_id) + bot.add_view(view, message_id=reply.id) + logger.info("[_register_attachment] Done") + await reply.edit(view=view) + + +def setup_input_image_commands(bot, config=None): + """Register input image commands and the on_message listener.""" + + @bot.listen("on_message") + async def _on_input_channel_message(message: discord.Message) -> None: + """Watch the comfy-input channel and attach a Set-as-input button to every image upload.""" + if config is None: + logger.warning("[_on_input_channel_message] Config is none") + return + if message.channel.id != config.comfy_input_channel_id: + return + if message.author.bot: + return + + image_attachments = [ + a for a in message.attachments + if Path(a.filename).suffix.lower() in IMAGE_EXTENSIONS + ] + if not image_attachments: + logger.info("[_on_input_channel_message] No image attachments") + return + + for attachment in image_attachments: + await _register_attachment(bot, config, message, attachment) + + try: + await message.delete() + except discord.Forbidden: + logger.warning("Missing manage_messages permission to delete message %s", message.id) + except Exception as exc: + logger.warning("Could not delete message %s: %s", message.id, exc) + + @bot.command( + name="sync-inputs", + aliases=["si"], + extras={"category": "Files"}, + help="Scan the comfy-input channel and add 'Set as input' buttons to any untracked images.", + ) + async def sync_inputs_command(ctx: commands.Context) -> None: + """Backfill Set-as-input buttons for images uploaded while the bot was offline.""" + if config is None: + await ctx.reply("Bot config is not available.", mention_author=False) + return + + channel = bot.get_channel(config.comfy_input_channel_id) + if channel is None: + try: + channel = await bot.fetch_channel(config.comfy_input_channel_id) + except Exception as exc: + await ctx.reply(f"❌ Could not access input channel: {exc}", mention_author=False) + return + + # Track existing records as (message_id, filename) pairs + existing = {(row["original_message_id"], row["filename"]) for row in get_all_images()} + + new_count = 0 + async for message in channel.history(limit=None): + if message.author.bot: + continue + + had_new = False + for attachment in message.attachments: + if Path(attachment.filename).suffix.lower() not in IMAGE_EXTENSIONS: + continue + if (message.id, attachment.filename) in existing: + continue + + await _register_attachment(bot, config, message, attachment) + existing.add((message.id, attachment.filename)) + new_count += 1 + had_new = True + + if had_new: + try: + await message.delete() + except Exception as exc: + logger.warning("sync-inputs: could not delete message %s: %s", message.id, exc) + + already = len(get_all_images()) - new_count + await ctx.reply( + f"Synced {new_count} new image(s). {already} already known.", + mention_author=False, + ) diff --git a/commands/presets.py b/commands/presets.py new file mode 100644 index 0000000..d1c40ce --- /dev/null +++ b/commands/presets.py @@ -0,0 +1,370 @@ +""" +commands/presets.py +=================== + +Named workflow preset commands for the Discord ComfyUI bot. + +A preset is a saved snapshot of the current workflow template and runtime +state (prompt, negative_prompt, input_image, seed). Presets make it easy +to switch between different setups (e.g. "portrait", "landscape", "anime") +with a single command. + +All sub-commands are accessed through the single ``ttr!preset`` command: + + ttr!preset save [description:] — capture current workflow + state + ttr!preset load — restore workflow + state + ttr!preset list — list all saved presets + ttr!preset view — show preset details + ttr!preset delete — permanently remove a preset + ttr!preset save-last [description:] — save last generation as preset +""" + +from __future__ import annotations + +import logging + +from discord.ext import commands + +from discord_utils import require_comfy_client +from preset_manager import PresetManager + + +logger = logging.getLogger(__name__) + + +def _parse_name_and_description(args: str) -> tuple[str, str | None]: + """ + Split `` [description:]`` into (name, description). + + The name is the first whitespace-delimited token. Everything after + ``description:`` (case-insensitive) in the remaining text is the + description. Returns (name, None) if no description keyword is found. + """ + parts = args.strip().split(maxsplit=1) + name = parts[0] if parts else "" + description: str | None = None + if len(parts) > 1: + rest = parts[1] + lower = rest.lower() + idx = lower.find("description:") + if idx >= 0: + description = rest[idx + len("description:"):].strip() or None + return name, description + + +def setup_preset_commands(bot): + """ + Register preset commands with the bot. + + Parameters + ---------- + bot : commands.Bot + The Discord bot instance. + """ + preset_manager = PresetManager() + + @bot.command(name="preset", extras={"category": "Presets"}) + @require_comfy_client + async def preset_command(ctx: commands.Context, *, args: str = "") -> None: + """ + Save, load, list, view, or delete named workflow presets. + + A preset captures the current workflow template and all runtime + state changes (prompt, negative_prompt, input_image, seed) under a + short name. Load it later to restore everything in one step. + + Usage: + ttr!preset save [description:] — save current workflow + state + ttr!preset load — restore workflow + state + ttr!preset list — list all saved presets + ttr!preset view — show preset details + ttr!preset delete — permanently delete a preset + ttr!preset save-last [description:] — save last generation as preset + + Names may only contain letters, digits, hyphens, and underscores. + + Examples: + ttr!preset save portrait description:studio lighting style + ttr!preset load portrait + ttr!preset list + ttr!preset view portrait + ttr!preset delete portrait + ttr!preset save-last my-last + """ + parts = args.strip().split(maxsplit=1) + subcommand = parts[0].lower() if parts else "" + rest = parts[1].strip() if len(parts) > 1 else "" + + if subcommand == "save": + name, description = _parse_name_and_description(rest) + await _preset_save(ctx, bot, preset_manager, name, description) + elif subcommand == "load": + await _preset_load(ctx, bot, preset_manager, rest.split()[0] if rest.split() else "") + elif subcommand == "list": + await _preset_list(ctx, preset_manager) + elif subcommand == "view": + await _preset_view(ctx, preset_manager, rest.split()[0] if rest.split() else "") + elif subcommand == "delete": + await _preset_delete(ctx, preset_manager, rest.split()[0] if rest.split() else "") + elif subcommand == "save-last": + name, description = _parse_name_and_description(rest) + await _preset_save_last(ctx, preset_manager, name, description) + else: + await ctx.reply( + "Usage: `ttr!preset [name]`\n" + "Run `ttr!help preset` for full details.", + mention_author=False, + ) + + +async def _preset_save( + ctx: commands.Context, bot, preset_manager: PresetManager, name: str, + description: str | None = None, +) -> None: + """Handle ttr!preset save [description:].""" + if not name: + await ctx.reply( + "Please provide a name. Example: `ttr!preset save portrait`", + mention_author=False, + ) + return + + if not PresetManager.is_valid_name(name): + await ctx.reply( + "Invalid name. Use only letters, digits, hyphens, and underscores (max 64 chars).", + mention_author=False, + ) + return + + try: + workflow_template = bot.comfy.get_workflow_template() + state = bot.comfy.get_workflow_current_changes() + preset_manager.save(name, workflow_template, state, description=description) + + # Build a summary of what was saved + has_workflow = workflow_template is not None + state_parts = [] + if state.get("prompt"): + state_parts.append("prompt") + if state.get("negative_prompt"): + state_parts.append("negative_prompt") + if state.get("input_image"): + state_parts.append("input_image") + if state.get("seed") is not None: + state_parts.append(f"seed={state['seed']}") + + summary_parts = [] + if has_workflow: + summary_parts.append("workflow template") + summary_parts.extend(state_parts) + summary = ", ".join(summary_parts) if summary_parts else "empty state" + + desc_note = f"\n> {description}" if description else "" + await ctx.reply( + f"Preset **{name}** saved ({summary}).{desc_note}", + mention_author=False, + ) + except Exception as exc: + logger.exception("Failed to save preset '%s'", name) + await ctx.reply( + f"Failed to save preset: {type(exc).__name__}: {exc}", + mention_author=False, + ) + + +async def _preset_load( + ctx: commands.Context, bot, preset_manager: PresetManager, name: str +) -> None: + """Handle ttr!preset load .""" + if not name: + await ctx.reply( + "Please provide a name. Example: `ttr!preset load portrait`", + mention_author=False, + ) + return + + data = preset_manager.load(name) + if data is None: + presets = preset_manager.list_presets() + hint = f" Available: {', '.join(presets)}" if presets else " No presets saved yet." + await ctx.reply( + f"Preset **{name}** not found.{hint}", + mention_author=False, + ) + return + + try: + restored: list[str] = [] + + # Restore workflow template if present + workflow = data.get("workflow") + if workflow is not None: + bot.comfy.set_workflow(workflow) + restored.append("workflow template") + + # Restore state changes + state = data.get("state", {}) + if state: + bot.comfy.set_workflow_current_changes(state) + if state.get("prompt"): + restored.append("prompt") + if state.get("negative_prompt"): + restored.append("negative_prompt") + if state.get("input_image"): + restored.append("input_image") + if state.get("seed") is not None: + restored.append(f"seed={state['seed']}") + + summary = ", ".join(restored) if restored else "nothing (preset was empty)" + description = data.get("description") + desc_note = f"\n> {description}" if description else "" + await ctx.reply( + f"Preset **{name}** loaded ({summary}).{desc_note}", + mention_author=False, + ) + except Exception as exc: + logger.exception("Failed to load preset '%s'", name) + await ctx.reply( + f"Failed to load preset: {type(exc).__name__}: {exc}", + mention_author=False, + ) + + +async def _preset_view( + ctx: commands.Context, preset_manager: PresetManager, name: str +) -> None: + """Handle ttr!preset view .""" + if not name: + await ctx.reply( + "Please provide a name. Example: `ttr!preset view portrait`", + mention_author=False, + ) + return + + data = preset_manager.load(name) + if data is None: + await ctx.reply(f"Preset **{name}** not found.", mention_author=False) + return + + lines = [f"**Preset: {name}**"] + if data.get("description"): + lines.append(f"> {data['description']}") + if data.get("owner"): + lines.append(f"Owner: {data['owner']}") + + state = data.get("state", {}) + if state.get("prompt"): + # Truncate long prompts + p = str(state["prompt"]) + if len(p) > 200: + p = p[:197] + "…" + lines.append(f"**Prompt:** {p}") + if state.get("negative_prompt"): + np = str(state["negative_prompt"]) + if len(np) > 100: + np = np[:97] + "…" + lines.append(f"**Negative:** {np}") + if state.get("seed") is not None: + seed_note = " (random)" if state["seed"] == -1 else "" + lines.append(f"**Seed:** {state['seed']}{seed_note}") + + other = {k: v for k, v in state.items() if k not in ("prompt", "negative_prompt", "seed", "input_image")} + if other: + other_str = ", ".join(f"{k}={v}" for k, v in other.items()) + lines.append(f"**Other:** {other_str[:200]}") + + if data.get("workflow") is not None: + lines.append("_(includes workflow template)_") + else: + lines.append("_(no workflow template — load separately)_") + + await ctx.reply("\n".join(lines), mention_author=False) + + +async def _preset_list(ctx: commands.Context, preset_manager: PresetManager) -> None: + """Handle ttr!preset list.""" + presets = preset_manager.list_preset_details() + if not presets: + await ctx.reply( + "No presets saved yet. Use `ttr!preset save ` to create one.", + mention_author=False, + ) + return + + lines = [f"**Saved presets** ({len(presets)})"] + for p in presets: + entry = f" • {p['name']}" + if p.get("description"): + entry += f" — {p['description']}" + lines.append(entry) + lines.append("\nUse `ttr!preset load ` to restore one.") + await ctx.reply("\n".join(lines), mention_author=False) + + +async def _preset_delete( + ctx: commands.Context, preset_manager: PresetManager, name: str +) -> None: + """Handle ttr!preset delete .""" + if not name: + await ctx.reply( + "Please provide a name. Example: `ttr!preset delete portrait`", + mention_author=False, + ) + return + + deleted = preset_manager.delete(name) + if deleted: + await ctx.reply(f"Preset **{name}** deleted.", mention_author=False) + else: + await ctx.reply( + f"Preset **{name}** not found.", + mention_author=False, + ) + + +async def _preset_save_last( + ctx: commands.Context, preset_manager: PresetManager, name: str, + description: str | None = None, +) -> None: + """Handle ttr!preset save-last [description:].""" + if not name: + await ctx.reply( + "Please provide a name. Example: `ttr!preset save-last my-last`", + mention_author=False, + ) + return + + if not PresetManager.is_valid_name(name): + await ctx.reply( + "Invalid name. Use only letters, digits, hyphens, and underscores (max 64 chars).", + mention_author=False, + ) + return + + from generation_db import get_history as db_get_history + history = db_get_history(limit=1) + if not history: + await ctx.reply( + "No generation history found. Generate something first!", + mention_author=False, + ) + return + + last = history[0] + overrides = last.get("overrides") or {} + try: + preset_manager.save(name, None, overrides, description=description) + desc_note = f"\n> {description}" if description else "" + await ctx.reply( + f"Preset **{name}** saved from last generation.{desc_note}\n" + "Note: workflow template not included — load it separately before generating.", + mention_author=False, + ) + except ValueError as exc: + await ctx.reply(str(exc), mention_author=False) + except Exception as exc: + logger.exception("Failed to save preset '%s' from history", name) + await ctx.reply( + f"Failed to save preset: {type(exc).__name__}: {exc}", + mention_author=False, + ) diff --git a/commands/server.py b/commands/server.py new file mode 100644 index 0000000..e4085d7 --- /dev/null +++ b/commands/server.py @@ -0,0 +1,484 @@ +""" +commands/server.py +================== + +ComfyUI server lifecycle management via NSSM Windows service. + +On bot startup, `autostart_comfy()` runs as a background task: + 1. If the service does not exist, it is installed automatically. + 2. If the service exists but ComfyUI is not responding, it is started. + +NSSM handles: + - Background process management (no console window) + - Stdout / stderr capture to rotating log files + - Complete isolation from the bot's own NSSM service + +Commands: + ttr!server start — start the service + ttr!server stop — stop the service + ttr!server restart — restart the service + ttr!server status — NSSM service state + HTTP reachability + ttr!server install — (re)install / reconfigure the NSSM service + ttr!server uninstall — remove the service from Windows + +Requires: + - nssm.exe in PATH + - The bot service account must have permission to manage Windows services + (Local System or a user with SeServiceLogonRight works) +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +import aiohttp +from discord.ext import commands + +logger = logging.getLogger(__name__) + +_POLL_INTERVAL = 5 # seconds between HTTP up-checks +_MAX_ATTEMPTS = 24 # 24 × 5s = 120s max wait + +# Public — imported by status_monitor for emoji rendering +STATUS_EMOJI: dict[str, str] = { + "SERVICE_RUNNING": "🟢", + "SERVICE_STOPPED": "🔴", + "SERVICE_PAUSED": "🟡", + "SERVICE_START_PENDING": "⏳", + "SERVICE_STOP_PENDING": "⏳", + "SERVICE_PAUSE_PENDING": "⏳", + "SERVICE_CONTINUE_PENDING": "⏳", +} + + +# --------------------------------------------------------------------------- +# Low-level subprocess helpers +# --------------------------------------------------------------------------- + +async def _nssm(*args: str) -> tuple[int, str]: + """Run `nssm ` and return (returncode, stdout).""" + try: + proc = await asyncio.create_subprocess_exec( + "nssm", *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30) + return proc.returncode, stdout.decode(errors="replace").strip() + except FileNotFoundError: + return -1, "nssm not found — is it installed and in PATH?" + except asyncio.TimeoutError: + return -1, "nssm command timed out." + except Exception as exc: + return -1, str(exc) + + +async def _get_service_pid(service_name: str) -> int: + """Return the PID of the process backing *service_name*, or 0 if unavailable.""" + rc, out = await _nssm("getpid", service_name) + if rc != 0: + return 0 + try: + return int(out.strip()) + except ValueError: + return 0 + + +async def _kill_service_process(service_name: str) -> None: + """ + Forcefully kill the process backing *service_name*. + + NSSM does not have a `kill` subcommand. Instead we retrieve the PID + via `nssm getpid` and then use `taskkill /F /PID`. Safe to call when + the service is already stopped (no-op if PID is 0). + """ + pid = await _get_service_pid(service_name) + if not pid: + return + try: + proc = await asyncio.create_subprocess_exec( + "taskkill", "/F", "/PID", str(pid), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await asyncio.wait_for(proc.communicate(), timeout=10) + logger.debug("taskkill /F /PID %d sent for service '%s'", pid, service_name) + except Exception as exc: + logger.warning("taskkill failed for PID %d (%s): %s", pid, service_name, exc) + + +async def _is_comfy_up(server_address: str, timeout: float = 3.0) -> bool: + """Return True if the ComfyUI HTTP endpoint is responding.""" + url = f"http://{server_address}/system_stats" + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp: + return resp.status == 200 + except Exception: + return False + + +async def _service_exists(service_name: str) -> bool: + """Return True if the Windows service is installed (running or stopped).""" + try: + proc = await asyncio.create_subprocess_exec( + "sc", "query", service_name, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.communicate() + return proc.returncode == 0 + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Public API — used by status_monitor and other modules +# --------------------------------------------------------------------------- + +async def get_service_state(service_name: str) -> str: + """ + Return the NSSM service state string for *service_name*. + + Returns one of the SERVICE_* keys in STATUS_EMOJI on success, or + "error" / "timeout" / "unknown" on failure. Intended for use by + the status dashboard — callers should not raise on these sentinel values. + """ + try: + rc, out = await asyncio.wait_for(_nssm("status", service_name), timeout=5.0) + if rc == -1: + return "error" + return out.strip() or "unknown" + except asyncio.TimeoutError: + return "timeout" + except Exception: + return "error" + + +# --------------------------------------------------------------------------- +# Service installation +# --------------------------------------------------------------------------- + +async def _install_service(config) -> tuple[bool, str]: + """ + Install the ComfyUI NSSM service with log capture and rotation. + + We install directly via python.exe (not the .bat file) to avoid the + "Terminate batch job (Y/N)?" prompt that can cause NSSM to hang on STOP. + + Safe to call even if the service already exists — it will be removed first. + Returns (success, message). + """ + name = config.comfy_service_name + start_bat = Path(config.comfy_start_bat) + log_dir = Path(config.comfy_log_dir) + log_file = str(log_dir / "comfyui.log") + max_bytes = str(config.comfy_log_max_mb * 1024 * 1024) + + # Derive portable paths from the .bat location (ComfyUI_windows_portable root): + # /run_nvidia_gpu.bat + # /python_embeded/python.exe + # /ComfyUI/main.py + portable_root = start_bat.parent + python_exe = portable_root / "python_embeded" / "python.exe" + main_py = portable_root / "ComfyUI" / "main.py" + + if not start_bat.exists(): + return False, f"Start bat not found (used to derive paths): `{start_bat}`" + if not python_exe.exists(): + return False, f"Portable python not found: `{python_exe}`" + if not main_py.exists(): + return False, f"ComfyUI main.py not found: `{main_py}`" + + log_dir.mkdir(parents=True, exist_ok=True) + + # Optional extra args from config (accepts string or list/tuple) + extra_args: list[str] = [] + extra = getattr(config, "comfy_extra_args", None) + try: + if isinstance(extra, (list, tuple)): + extra_args = [str(x) for x in extra if str(x).strip()] + elif isinstance(extra, str) and extra.strip(): + import shlex + extra_args = shlex.split(extra) + except Exception: + extra_args = [] # ignore parse errors rather than aborting install + + # Remove any existing service cleanly before reinstalling + if await _service_exists(name): + await _nssm("stop", name) + await _kill_service_process(name) # force-kill if stuck in STOP_PENDING + rc, out = await _nssm("remove", name, "confirm") + if rc != 0: + return False, f"Could not remove existing service: {out}" + + # nssm install -s --windows-standalone-build [extra] + steps: list[tuple[str, ...]] = [ + ("install", name, str(python_exe), "-s", str(main_py), "--windows-standalone-build", *extra_args), + ("set", name, "AppDirectory", str(portable_root)), + ("set", name, "DisplayName", "ComfyUI Server"), + ("set", name, "AppStdout", log_file), + ("set", name, "AppStderr", log_file), + ("set", name, "AppRotateFiles", "1"), + ("set", name, "AppRotateBytes", max_bytes), + ("set", name, "AppRotateOnline", "1"), + ("set", name, "Start", "SERVICE_DEMAND_START"), + # Stop behavior — prevent NSSM from hanging indefinitely + ("set", name, "AppKillProcessTree", "1"), + ("set", name, "AppStopMethodConsole", "1500"), + ("set", name, "AppStopMethodWindow", "1500"), + ("set", name, "AppStopMethodThreads", "1500"), + ] + + for step in steps: + rc, out = await _nssm(*step) + if rc != 0: + return False, f"`nssm {' '.join(step[:3])}` failed: {out}" + + return True, f"Service `{name}` installed. Log: `{log_file}`" + + +# --------------------------------------------------------------------------- +# Autostart (called from bot.py on_ready) +# --------------------------------------------------------------------------- + +async def autostart_comfy(config) -> None: + """ + Ensure ComfyUI is running when the bot starts. + + 1. Install the NSSM service if it is missing. + 2. Start the service if ComfyUI is not already responding. + + Does nothing if config.comfy_autostart is False. + """ + if not getattr(config, "comfy_autostart", True): + return + + if not await _service_exists(config.comfy_service_name): + logger.info("NSSM service '%s' not found — installing", config.comfy_service_name) + ok, msg = await _install_service(config) + if not ok: + logger.error("Failed to install ComfyUI service: %s", msg) + return + logger.info("ComfyUI service installed: %s", msg) + + if await _is_comfy_up(config.comfy_server): + logger.info("ComfyUI already running at %s", config.comfy_server) + return + + logger.info("Starting NSSM service '%s'", config.comfy_service_name) + rc, out = await _nssm("start", config.comfy_service_name) + if rc != 0: + logger.warning("nssm start returned %d: %s", rc, out) + return + + for attempt in range(_MAX_ATTEMPTS): + await asyncio.sleep(_POLL_INTERVAL) + if await _is_comfy_up(config.comfy_server): + logger.info("ComfyUI is up after ~%ds", (attempt + 1) * _POLL_INTERVAL) + return + + logger.warning( + "ComfyUI did not respond within %ds after service start", + _MAX_ATTEMPTS * _POLL_INTERVAL, + ) + + +# --------------------------------------------------------------------------- +# Discord commands +# --------------------------------------------------------------------------- + +def setup_server_commands(bot, config=None): + """Register ComfyUI server management commands.""" + + def _no_config(ctx): + """Reply and return True when config is missing (guards every subcommand).""" + return config is None + + @bot.group(name="server", invoke_without_command=True, extras={"category": "Server"}) + async def server_group(ctx: commands.Context) -> None: + """ComfyUI server management. Subcommands: start, stop, restart, status, install, uninstall.""" + await ctx.send_help(ctx.command) + + @server_group.command(name="start") + async def server_start(ctx: commands.Context) -> None: + """Start the ComfyUI service.""" + if config is None: + await ctx.reply("Bot config not available.", mention_author=False) + return + + if await _is_comfy_up(config.comfy_server): + await ctx.reply("✅ ComfyUI is already running.", mention_author=False) + return + + msg = await ctx.reply( + f"⏳ Starting service `{config.comfy_service_name}`…", mention_author=False + ) + rc, out = await _nssm("start", config.comfy_service_name) + if rc != 0: + await msg.edit(content=f"❌ `{out}`") + return + + await msg.edit(content="⏳ Waiting for ComfyUI to respond…") + for attempt in range(_MAX_ATTEMPTS): + await asyncio.sleep(_POLL_INTERVAL) + if await _is_comfy_up(config.comfy_server): + await msg.edit( + content=f"✅ ComfyUI is up! (took ~{(attempt + 1) * _POLL_INTERVAL}s)" + ) + return + + await msg.edit(content="⚠️ Service started but ComfyUI did not respond within 120 seconds.") + + @server_group.command(name="stop") + async def server_stop(ctx: commands.Context) -> None: + """Stop the ComfyUI service (force-kills if graceful stop fails).""" + if config is None: + await ctx.reply("Bot config not available.", mention_author=False) + return + + msg = await ctx.reply( + f"⏳ Stopping service `{config.comfy_service_name}`…", mention_author=False + ) + rc, out = await _nssm("stop", config.comfy_service_name) + if rc == 0: + await msg.edit(content="✅ ComfyUI service stopped.") + return + + # Graceful stop failed (timed out or error) — force-kill the process. + await msg.edit(content="⏳ Graceful stop failed — force-killing process…") + await _kill_service_process(config.comfy_service_name) + await asyncio.sleep(2) + + state = await get_service_state(config.comfy_service_name) + if state == "SERVICE_STOPPED": + await msg.edit(content="✅ ComfyUI service force-killed and stopped.") + else: + await msg.edit( + content=f"⚠️ Force-kill sent but service state is `{state}`. " + f"Use `ttr!server kill` to try again." + ) + + @server_group.command(name="kill") + async def server_kill(ctx: commands.Context) -> None: + """Force-kill the ComfyUI process when it is stuck in STOPPING/STOP_PENDING.""" + if config is None: + await ctx.reply("Bot config not available.", mention_author=False) + return + + msg = await ctx.reply( + f"⏳ Force-killing `{config.comfy_service_name}` process…", mention_author=False + ) + await _kill_service_process(config.comfy_service_name) + await asyncio.sleep(2) + + state = await get_service_state(config.comfy_service_name) + emoji = STATUS_EMOJI.get(state, "⚪") + await msg.edit( + content=f"💀 taskkill sent. Service state is now {emoji} `{state}`." + ) + + @server_group.command(name="restart") + async def server_restart(ctx: commands.Context) -> None: + """Restart the ComfyUI service (force-kills if graceful stop fails).""" + if config is None: + await ctx.reply("Bot config not available.", mention_author=False) + return + + msg = await ctx.reply( + f"⏳ Stopping `{config.comfy_service_name}` for restart…", mention_author=False + ) + + # Step 1: graceful stop. + rc, out = await _nssm("stop", config.comfy_service_name) + if rc != 0: + # Stop timed out or failed — force-kill so we can start fresh. + await msg.edit(content="⏳ Graceful stop failed — force-killing process…") + await _kill_service_process(config.comfy_service_name) + await asyncio.sleep(2) + + # Step 2: verify stopped before starting. + state = await get_service_state(config.comfy_service_name) + if state not in ("SERVICE_STOPPED", "error", "unknown", "timeout"): + # Still not fully stopped — try one more force-kill. + await _kill_service_process(config.comfy_service_name) + await asyncio.sleep(2) + + # Step 3: start. + await msg.edit(content=f"⏳ Starting `{config.comfy_service_name}`…") + rc, out = await _nssm("start", config.comfy_service_name) + if rc != 0: + await msg.edit(content=f"❌ Start failed: `{out}`") + return + + # Step 4: wait for HTTP. + await msg.edit(content="⏳ Waiting for ComfyUI to come back up…") + for attempt in range(_MAX_ATTEMPTS): + await asyncio.sleep(_POLL_INTERVAL) + if await _is_comfy_up(config.comfy_server): + await msg.edit( + content=f"✅ ComfyUI is back up! (took ~{(attempt + 1) * _POLL_INTERVAL}s)" + ) + return + + await msg.edit(content="⚠️ Service started but ComfyUI did not respond within 120 seconds.") + + @server_group.command(name="status") + async def server_status(ctx: commands.Context) -> None: + """Show NSSM service state and HTTP reachability.""" + if config is None: + await ctx.reply("Bot config not available.", mention_author=False) + return + + state, http_up = await asyncio.gather( + get_service_state(config.comfy_service_name), + _is_comfy_up(config.comfy_server), + ) + + emoji = STATUS_EMOJI.get(state, "⚪") + svc_line = f"{emoji} `{state}`" + http_line = ( + f"🟢 Responding at `{config.comfy_server}`" + if http_up else + f"🔴 Not responding at `{config.comfy_server}`" + ) + + await ctx.reply( + f"**ComfyUI Server Status**\n" + f"Service `{config.comfy_service_name}`: {svc_line}\n" + f"HTTP: {http_line}", + mention_author=False, + ) + + @server_group.command(name="install") + async def server_install(ctx: commands.Context) -> None: + """(Re)install the ComfyUI NSSM service with current config settings.""" + if config is None: + await ctx.reply("Bot config not available.", mention_author=False) + return + + msg = await ctx.reply( + f"⏳ Installing service `{config.comfy_service_name}`…", mention_author=False + ) + ok, detail = await _install_service(config) + await msg.edit(content=f"{'✅' if ok else '❌'} {detail}") + + @server_group.command(name="uninstall") + async def server_uninstall(ctx: commands.Context) -> None: + """Stop and remove the ComfyUI NSSM service from Windows.""" + if config is None: + await ctx.reply("Bot config not available.", mention_author=False) + return + + msg = await ctx.reply( + f"⏳ Removing service `{config.comfy_service_name}`…", mention_author=False + ) + await _nssm("stop", config.comfy_service_name) + await _kill_service_process(config.comfy_service_name) + rc, out = await _nssm("remove", config.comfy_service_name, "confirm") + if rc == 0: + await msg.edit(content=f"✅ Service `{config.comfy_service_name}` removed.") + else: + await msg.edit(content=f"❌ `{out}`") diff --git a/commands/utility.py b/commands/utility.py new file mode 100644 index 0000000..4232c91 --- /dev/null +++ b/commands/utility.py @@ -0,0 +1,268 @@ +""" +commands/utility.py +=================== + +Quality-of-life utility commands for the Discord ComfyUI bot. + +Commands provided: +- ping: Show bot latency (Discord WebSocket round-trip). +- status: Full overview of bot health, ComfyUI connectivity, + workflow state, and queue. +- queue-status: Quick view of pending job count and worker state. +- uptime: How long the bot has been running since it connected. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from discord.ext import commands + + +logger = logging.getLogger(__name__) + + +def _format_uptime(start_time: datetime) -> str: + """Return a human-readable uptime string from a UTC start datetime.""" + delta = datetime.now(timezone.utc) - start_time + total_seconds = int(delta.total_seconds()) + days, remainder = divmod(total_seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + if days: + return f"{days}d {hours}h {minutes}m {seconds}s" + if hours: + return f"{hours}h {minutes}m {seconds}s" + return f"{minutes}m {seconds}s" + + +def setup_utility_commands(bot): + """ + Register quality-of-life utility commands with the bot. + + Parameters + ---------- + bot : commands.Bot + The Discord bot instance. + """ + + @bot.command(name="ping", extras={"category": "Utility"}) + async def ping_command(ctx: commands.Context) -> None: + """ + Show the bot's current Discord WebSocket latency. + + Usage: + ttr!ping + """ + latency_ms = round(bot.latency * 1000) + await ctx.reply(f"Pong! Latency: **{latency_ms} ms**", mention_author=False) + + @bot.command(name="status", extras={"category": "Utility"}) + async def status_command(ctx: commands.Context) -> None: + """ + Show a full health overview of the bot and ComfyUI. + + Displays: + - Bot latency and uptime + - ComfyUI server address and reachability + - Whether a workflow template is loaded + - Current workflow changes (prompt / negative_prompt / input_image) + - Job queue size and worker state + + Usage: + ttr!status + """ + latency_ms = round(bot.latency * 1000) + + # Uptime + if hasattr(bot, "start_time") and bot.start_time: + uptime_str = _format_uptime(bot.start_time) + else: + uptime_str = "N/A" + + # ComfyUI info + comfy_ok = hasattr(bot, "comfy") and bot.comfy is not None + comfy_server = bot.comfy.server_address if comfy_ok else "not configured" + comfy_reachable = await bot.comfy.check_connection() if comfy_ok else False + workflow_loaded = comfy_ok and bot.comfy.get_workflow_template() is not None + + # ComfyUI queue + comfy_pending = 0 + comfy_running = 0 + if comfy_ok: + q = await bot.comfy.get_comfy_queue() + if q: + comfy_pending = len(q.get("queue_pending", [])) + comfy_running = len(q.get("queue_running", [])) + + # Workflow state summary + changes_parts: list[str] = [] + if comfy_ok: + overrides = bot.comfy.state_manager.get_overrides() + if overrides.get("prompt"): + changes_parts.append("prompt") + if overrides.get("negative_prompt"): + changes_parts.append("negative_prompt") + if overrides.get("input_image"): + changes_parts.append(f"input_image: {overrides['input_image']}") + if overrides.get("seed") is not None: + changes_parts.append(f"seed={overrides['seed']}") + changes_summary = ", ".join(changes_parts) if changes_parts else "none" + + conn_status = ( + "reachable" if comfy_reachable + else ("unreachable" if comfy_ok else "not configured") + ) + + lines = [ + "**Bot**", + f" Latency : {latency_ms} ms", + f" Uptime : {uptime_str}", + "", + f"**ComfyUI** — `{comfy_server}`", + f" Connection : {conn_status}", + f" Queue : {comfy_running} running, {comfy_pending} pending", + f" Workflow : {'loaded' if workflow_loaded else 'not loaded'}", + f" Changes set : {changes_summary}", + ] + await ctx.reply("\n".join(lines), mention_author=False) + + @bot.command(name="queue-status", aliases=["qs", "qstatus"], extras={"category": "Utility"}) + async def queue_status_command(ctx: commands.Context) -> None: + """ + Show the current ComfyUI queue depth. + + Usage: + ttr!queue-status + ttr!qs + """ + if not hasattr(bot, "comfy") or not bot.comfy: + await ctx.reply("ComfyUI client is not configured.", mention_author=False) + return + + q = await bot.comfy.get_comfy_queue() + if q is None: + await ctx.reply("Could not reach ComfyUI server.", mention_author=False) + return + + pending = len(q.get("queue_pending", [])) + running = len(q.get("queue_running", [])) + await ctx.reply( + f"ComfyUI queue: **{running}** running, **{pending}** pending.", + mention_author=False, + ) + + @bot.command(name="uptime", extras={"category": "Utility"}) + async def uptime_command(ctx: commands.Context) -> None: + """ + Show how long the bot has been running since it last connected. + + Usage: + ttr!uptime + """ + if not hasattr(bot, "start_time") or not bot.start_time: + await ctx.reply("Uptime information is not available.", mention_author=False) + return + uptime_str = _format_uptime(bot.start_time) + await ctx.reply(f"Uptime: **{uptime_str}**", mention_author=False) + + @bot.command(name="comfy-stats", aliases=["cstats"], extras={"category": "Utility"}) + async def comfy_stats_command(ctx: commands.Context) -> None: + """ + Show GPU and system stats from the ComfyUI server. + + Displays OS, Python version, and per-device VRAM usage reported + by the ComfyUI ``/system_stats`` endpoint. + + Usage: + ttr!comfy-stats + ttr!cstats + """ + if not hasattr(bot, "comfy") or not bot.comfy: + await ctx.reply("ComfyUI client is not configured.", mention_author=False) + return + + stats = await bot.comfy.get_system_stats() + if stats is None: + await ctx.reply( + "Could not reach the ComfyUI server to fetch stats.", mention_author=False + ) + return + + system = stats.get("system", {}) + devices = stats.get("devices", []) + + lines = [ + f"**ComfyUI System Stats** — `{bot.comfy.server_address}`", + f" OS : {system.get('os', 'N/A')}", + f" Python : {system.get('python_version', 'N/A')}", + ] + + if devices: + lines.append("") + lines.append("**Devices**") + for dev in devices: + name = dev.get("name", "unknown") + vram_total = dev.get("vram_total", 0) + vram_free = dev.get("vram_free", 0) + vram_used = vram_total - vram_free + + def _mb(b: int) -> str: + return f"{b / 1024 / 1024:.0f} MB" + + lines.append( + f" {name} — {_mb(vram_used)} / {_mb(vram_total)} VRAM used" + ) + else: + lines.append(" No device info available.") + + await ctx.reply("\n".join(lines), mention_author=False) + + @bot.command(name="comfy-queue", aliases=["cqueue", "cq"], extras={"category": "Utility"}) + async def comfy_queue_command(ctx: commands.Context) -> None: + """ + Show the ComfyUI server's internal queue state. + + Displays jobs currently running and pending on the ComfyUI server + itself (separate from the Discord bot's own job queue). + + Usage: + ttr!comfy-queue + ttr!cq + """ + if not hasattr(bot, "comfy") or not bot.comfy: + await ctx.reply("ComfyUI client is not configured.", mention_author=False) + return + + queue_data = await bot.comfy.get_comfy_queue() + if queue_data is None: + await ctx.reply( + "Could not reach the ComfyUI server to fetch queue info.", mention_author=False + ) + return + + running = queue_data.get("queue_running", []) + pending = queue_data.get("queue_pending", []) + + lines = [ + f"**ComfyUI Server Queue** — `{bot.comfy.server_address}`", + f" Running : {len(running)} job(s)", + f" Pending : {len(pending)} job(s)", + ] + + if running: + lines.append("") + lines.append("**Currently running**") + for entry in running[:5]: # cap at 5 to avoid huge messages + prompt_id = entry[1] if len(entry) > 1 else "unknown" + lines.append(f" `{prompt_id}`") + + if pending: + lines.append("") + lines.append(f"**Pending** (showing up to 5 of {len(pending)})") + for entry in pending[:5]: + prompt_id = entry[1] if len(entry) > 1 else "unknown" + lines.append(f" `{prompt_id}`") + + await ctx.reply("\n".join(lines), mention_author=False) diff --git a/commands/workflow.py b/commands/workflow.py new file mode 100644 index 0000000..10b2700 --- /dev/null +++ b/commands/workflow.py @@ -0,0 +1,100 @@ +""" +commands/workflow.py +==================== + +Workflow management commands for the Discord ComfyUI bot. + +This module contains commands for loading and managing workflow templates. +""" + +from __future__ import annotations + +import json +import logging +from typing import Optional, Dict + +from discord.ext import commands + +from discord_utils import require_comfy_client + + +logger = logging.getLogger(__name__) + + +def setup_workflow_commands(bot): + """ + Register workflow management commands with the bot. + + Parameters + ---------- + bot : commands.Bot + The Discord bot instance. + """ + + @bot.command(name="workflow-load", aliases=["workflowload", "wfl"], extras={"category": "Workflow"}) + @require_comfy_client + async def load_workflow_command(ctx: commands.Context, *, path: Optional[str] = None) -> None: + """ + Load a ComfyUI workflow from a JSON file. + + Usage: + ttr!workflow-load path/to/workflow.json + + You can also attach a JSON file to the command message instead of + providing a path. The loaded workflow will replace the current + workflow template used by the bot. After loading a workflow you + can generate images with your prompts while reusing the loaded + graph structure. + """ + workflow_data: Optional[Dict] = None + + # Check for attached JSON file first + for attachment in ctx.message.attachments: + if attachment.filename.lower().endswith(".json"): + raw = await attachment.read() + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + await ctx.reply( + f"`{attachment.filename}` is not valid UTF-8: {exc}", + mention_author=False, + ) + return + try: + workflow_data = json.loads(text) + break + except json.JSONDecodeError as exc: + await ctx.reply( + f"Failed to parse `{attachment.filename}` as JSON: {exc}", + mention_author=False, + ) + return + + # Otherwise try to load from provided path + if workflow_data is None and path: + try: + with open(path, "r", encoding="utf-8") as f: + workflow_data = json.load(f) + except FileNotFoundError: + await ctx.reply(f"File not found: `{path}`", mention_author=False) + return + except json.JSONDecodeError as exc: + await ctx.reply(f"Invalid JSON in `{path}`: {exc}", mention_author=False) + return + + if workflow_data is None: + await ctx.reply( + "Please provide a JSON workflow file either as an attachment or a path.", + mention_author=False, + ) + return + + # Set the workflow on the client + try: + bot.comfy.set_workflow(workflow_data) + await ctx.reply("Workflow loaded successfully.", mention_author=False) + except Exception as exc: + await ctx.reply( + f"Failed to set workflow: {type(exc).__name__}: {exc}", + mention_author=False, + ) diff --git a/commands/workflow_changes.py b/commands/workflow_changes.py new file mode 100644 index 0000000..e0675bb --- /dev/null +++ b/commands/workflow_changes.py @@ -0,0 +1,252 @@ +""" +commands/workflow_changes.py +============================ + +Workflow override management commands for the Discord ComfyUI bot. + +Works with any NodeInput.key discovered by WorkflowInspector — not just +the four original hard-coded keys. Backward-compat aliases are preserved: +``type:prompt``, ``type:negative_prompt``, ``type:input_image``, +``type:seed``. +""" + +from __future__ import annotations + +import logging + +from discord.ext import commands + +from config import ARG_TYPE_KEY +from discord_utils import require_comfy_client + + +logger = logging.getLogger(__name__) + + +def setup_workflow_changes_commands(bot): + """Register workflow changes commands with the bot.""" + + @bot.command( + name="get-current-workflow-changes", + aliases=["getworkflowchanges", "gcwc"], + extras={"category": "Workflow"}, + ) + @require_comfy_client + async def get_current_workflow_changes_command( + ctx: commands.Context, *, args: str = "" + ) -> None: + """ + Show current workflow override values. + + Usage:: + + ttr!get-current-workflow-changes type:all + ttr!get-current-workflow-changes type:prompt + ttr!get-current-workflow-changes type: + """ + try: + overrides = bot.comfy.state_manager.get_overrides() + + if ARG_TYPE_KEY not in args: + await ctx.reply( + f"Use `{ARG_TYPE_KEY}all` to see all overrides, or " + f"`{ARG_TYPE_KEY}` for a specific key.", + mention_author=False, + ) + return + + param = args.split(ARG_TYPE_KEY, 1)[1].strip().lower() + + if param == "all": + if not overrides: + await ctx.reply("No overrides set.", mention_author=False) + return + lines = [f"**{k}**: `{v}`" for k, v in sorted(overrides.items())] + await ctx.reply( + "Current overrides:\n" + "\n".join(lines), + mention_author=False, + ) + else: + # Support multi-word value with the key as prefix + key = param.split()[0] if " " in param else param + val = overrides.get(key) + if val is None: + await ctx.reply( + f"Override `{key}` is not set.", + mention_author=False, + ) + else: + await ctx.reply( + f"**{key}**: `{val}`", + mention_author=False, + ) + except Exception as exc: + logger.exception("Failed to get workflow overrides") + await ctx.reply(f"An error occurred: {type(exc).__name__}: {exc}", mention_author=False) + + @bot.command( + name="set-current-workflow-changes", + aliases=["setworkflowchanges", "scwc"], + extras={"category": "Workflow"}, + ) + @require_comfy_client + async def set_current_workflow_changes_command( + ctx: commands.Context, *, args: str = "" + ) -> None: + """ + Set a workflow override value. + + Supports any NodeInput.key discovered by WorkflowInspector as well + as the legacy fixed keys. + + Usage:: + + ttr!set-current-workflow-changes type: + + Examples:: + + ttr!scwc type:prompt A beautiful landscape + ttr!scwc type:negative_prompt blurry + ttr!scwc type:input_image my_image.png + ttr!scwc type:steps 30 + ttr!scwc type:cfg 7.5 + ttr!scwc type:seed 42 + """ + try: + if not args or ARG_TYPE_KEY not in args: + await ctx.reply( + f"Usage: `ttr!set-current-workflow-changes {ARG_TYPE_KEY} `", + mention_author=False, + ) + return + + rest = args.split(ARG_TYPE_KEY, 1)[1] + # Key is the first word; value is everything after the first space + parts = rest.split(None, 1) + if len(parts) < 2: + await ctx.reply( + "Please provide both a key and a value. " + f"Example: `ttr!scwc {ARG_TYPE_KEY}prompt A cat`", + mention_author=False, + ) + return + + key = parts[0].strip().lower() + raw_value: str = parts[1].strip() + + if not key: + await ctx.reply("Key cannot be empty.", mention_author=False) + return + + # Type-coerce well-known numeric keys + _int_keys = {"steps", "width", "height"} + _float_keys = {"cfg", "denoise"} + _seed_keys = {"seed", "noise_seed"} + + value: object = raw_value + try: + if key in _int_keys: + value = int(raw_value) + elif key in _float_keys: + value = float(raw_value) + elif key in _seed_keys: + value = int(raw_value) + except ValueError: + await ctx.reply( + f"Invalid value for `{key}`: expected a number, got `{raw_value}`.", + mention_author=False, + ) + return + + bot.comfy.state_manager.set_override(key, value) + await ctx.reply( + f"Override **{key}** set to `{value}`.", + mention_author=False, + ) + except Exception as exc: + logger.exception("Failed to set workflow override") + await ctx.reply(f"An error occurred: {type(exc).__name__}: {exc}", mention_author=False) + + @bot.command( + name="clear-workflow-change", + aliases=["clearworkflowchange", "cwc"], + extras={"category": "Workflow"}, + ) + @require_comfy_client + async def clear_workflow_change_command( + ctx: commands.Context, *, args: str = "" + ) -> None: + """ + Remove a single override key. + + Usage:: + + ttr!clear-workflow-change type: + """ + try: + if ARG_TYPE_KEY not in args: + await ctx.reply( + f"Usage: `ttr!clear-workflow-change {ARG_TYPE_KEY}`", + mention_author=False, + ) + return + key = args.split(ARG_TYPE_KEY, 1)[1].strip().lower() + bot.comfy.state_manager.delete_override(key) + await ctx.reply(f"Override **{key}** cleared.", mention_author=False) + except Exception as exc: + logger.exception("Failed to clear override") + await ctx.reply(f"An error occurred: {type(exc).__name__}: {exc}", mention_author=False) + + @bot.command( + name="set-seed", + aliases=["setseed"], + extras={"category": "Workflow"}, + ) + @require_comfy_client + async def set_seed_command(ctx: commands.Context, *, args: str = "") -> None: + """ + Pin a specific seed for deterministic generation. + + Usage:: + + ttr!set-seed 42 + """ + seed_str = args.strip() + if not seed_str: + await ctx.reply("Usage: `ttr!set-seed `", mention_author=False) + return + if not seed_str.isdigit(): + await ctx.reply("Seed must be a non-negative integer.", mention_author=False) + return + seed_val = int(seed_str) + max_seed = 2 ** 32 - 1 + if seed_val > max_seed: + await ctx.reply(f"Seed must be between 0 and {max_seed}.", mention_author=False) + return + try: + bot.comfy.state_manager.set_seed(seed_val) + await ctx.reply(f"Seed pinned to `{seed_val}`.", mention_author=False) + except Exception as exc: + logger.exception("Failed to set seed") + await ctx.reply(f"An error occurred: {type(exc).__name__}: {exc}", mention_author=False) + + @bot.command( + name="clear-seed", + aliases=["clearseed"], + extras={"category": "Workflow"}, + ) + @require_comfy_client + async def clear_seed_command(ctx: commands.Context) -> None: + """ + Clear the pinned seed and return to random generation. + + Usage:: + + ttr!clear-seed + """ + try: + bot.comfy.state_manager.clear_seed() + await ctx.reply("Seed cleared; generation will now use random seeds.", mention_author=False) + except Exception as exc: + logger.exception("Failed to clear seed") + await ctx.reply(f"An error occurred: {type(exc).__name__}: {exc}", mention_author=False) diff --git a/config.py b/config.py new file mode 100644 index 0000000..b0942c6 --- /dev/null +++ b/config.py @@ -0,0 +1,283 @@ +""" +config.py +========= + +Configuration module for the Discord ComfyUI bot. +This module centralizes all constants, magic strings, and environment +variable loading to make configuration management easier and more maintainable. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +try: + from dotenv import load_dotenv + + load_dotenv() +except Exception: + pass + +# ======================================== +# Command and Argument Constants +# ======================================== + +COMMAND_PREFIX = os.getenv("BOT_PREFIX", "ttr!") +"""The command prefix used for Discord bot commands.""" + +ARG_PROMPT_KEY = "prompt:" +"""The keyword marker for prompt arguments in commands.""" + +ARG_NEG_PROMPT_KEY = "negative_prompt:" +"""The keyword marker for negative prompt arguments in commands.""" + +ARG_TYPE_KEY = "type:" +"""The keyword marker for type arguments in commands.""" + +ARG_QUEUE_KEY = "queue:" +"""The keyword marker for queue count arguments in commands.""" + + +# ======================================== +# Discord and Message Constants +# ======================================== + +MAX_IMAGES_PER_RESPONSE = 4 +"""Maximum number of images to include in a single Discord response.""" + +DEFAULT_UPLOAD_TYPE = "input" +"""Default folder type for ComfyUI image uploads.""" + +MESSAGE_AUTO_DELETE_TIMEOUT = 60.0 +"""Default timeout in seconds for auto-deleting temporary messages.""" + + +# ======================================== +# Error Messages +# ======================================== + +COMFY_NOT_CONFIGURED_MSG = "ComfyUI client is not configured. Please set environment variables." +"""Error message displayed when ComfyUI client is not properly configured.""" + + +# ======================================== +# Default Configuration Values +# ======================================== + +DEFAULT_COMFY_HISTORY_LIMIT = 10 +"""Default number of generation history entries to keep.""" + +# Resolve paths relative to this file's location so both the bot project and +# the portable ComfyUI folder only need to share the same parent directory. +# Layout assumed: +# / +# ComfyUI_windows_portable/ComfyUI/output ← default output +# ComfyUI_windows_portable/ComfyUI/input ← default input +# the-third-rev/ ← this project +_COMFY_PORTABLE_ROOT = Path(__file__).resolve().parent.parent / "ComfyUI_windows_portable" / "ComfyUI" +DEFAULT_COMFY_OUTPUT_PATH = str(_COMFY_PORTABLE_ROOT / "output") +DEFAULT_COMFY_INPUT_PATH = str(_COMFY_PORTABLE_ROOT / "input") + + +# ======================================== +# Configuration Class +# ======================================== + +@dataclass +class BotConfig: + """ + Configuration container for the Discord ComfyUI bot. + + This dataclass holds all configuration values loaded from environment + variables. Use the `from_env()` class method to create an instance + with values loaded from the environment. + + Attributes + ---------- + discord_bot_token : str + Discord bot authentication token (required). + comfy_server : str + ComfyUI server address in format "hostname:port" (required). + comfy_output_path : str + Path to ComfyUI output directory for reading generated files. + comfy_history_limit : int + Number of generation history entries to keep in memory. + workflow_file : Optional[str] + Path to a workflow JSON file to load at startup (optional). + """ + + discord_bot_token: str + comfy_server: str + comfy_output_path: str + comfy_input_path: str + comfy_history_limit: int + comfy_input_channel_id: int = 1475791295665405962 + comfy_service_name: str = "ComfyUI" + comfy_start_bat: str = "" + comfy_log_dir: str = "" + comfy_log_max_mb: int = 10 + comfy_autostart: bool = True + workflow_file: Optional[str] = None + log_channel_id: Optional[int] = None + zip_password: Optional[str] = None + media_upload_user: Optional[str] = None + media_upload_pass: Optional[str] = None + # Web UI fields + web_enabled: bool = True + web_host: str = "0.0.0.0" + web_port: int = 8080 + web_secret_key: str = "" + web_token_file: str = "invite_tokens.json" + web_jwt_expire_hours: int = 8 + web_secure_cookie: bool = True + admin_password: Optional[str] = None + + @classmethod + def from_env(cls) -> BotConfig: + """ + Create a BotConfig instance by loading values from environment variables. + + Environment Variables + --------------------- + DISCORD_BOT_TOKEN : str (required) + Discord bot authentication token. + COMFY_SERVER : str (required) + ComfyUI server address (e.g., "localhost:8188" or "example.com:8188"). + COMFY_OUTPUT_PATH : str (optional) + Path to ComfyUI output directory. Defaults to DEFAULT_COMFY_OUTPUT_PATH + if not specified. + COMFY_HISTORY_LIMIT : int (optional) + Number of generation history entries to keep. Defaults to + DEFAULT_COMFY_HISTORY_LIMIT if not specified or invalid. + WORKFLOW_FILE : str (optional) + Path to a workflow JSON file to load at startup. + + Returns + ------- + BotConfig + A configured BotConfig instance. + + Raises + ------ + RuntimeError + If required environment variables (DISCORD_BOT_TOKEN or COMFY_SERVER) + are not set. + """ + # Load required variables + discord_token = os.getenv("DISCORD_BOT_TOKEN") + if not discord_token: + raise RuntimeError( + "DISCORD_BOT_TOKEN environment variable is required. " + "Please set it in your .env file or environment." + ) + + comfy_server = os.getenv("COMFY_SERVER") + if not comfy_server: + raise RuntimeError( + "COMFY_SERVER environment variable is required. " + "Please set it in your .env file or environment." + ) + + # Load optional variables with defaults + comfy_output_path = os.getenv("COMFY_OUTPUT_PATH", DEFAULT_COMFY_OUTPUT_PATH) + comfy_input_path = os.getenv("COMFY_INPUT_PATH", DEFAULT_COMFY_INPUT_PATH) + + # Parse history limit with fallback to default + try: + comfy_history_limit = int(os.getenv("COMFY_HISTORY_LIMIT", str(DEFAULT_COMFY_HISTORY_LIMIT))) + except ValueError: + comfy_history_limit = DEFAULT_COMFY_HISTORY_LIMIT + + workflow_file = os.getenv("WORKFLOW_FILE") + + log_channel_id_str = os.getenv("LOG_CHANNEL_ID", "1475408462740721809") + try: + log_channel_id = int(log_channel_id_str) if log_channel_id_str else None + except ValueError: + log_channel_id = None + + zip_password = os.getenv("ZIP_PASSWORD", "0Revel512796@") + + media_upload_user = os.getenv("MEDIA_UPLOAD_USER") or None + media_upload_pass = os.getenv("MEDIA_UPLOAD_PASS") or None + + try: + comfy_input_channel_id = int(os.getenv("COMFY_INPUT_CHANNEL_ID", "1475791295665405962")) + except ValueError: + comfy_input_channel_id = 1475791295665405962 + + comfy_service_name = os.getenv("COMFY_SERVICE_NAME", "ComfyUI") + + default_bat = str(_COMFY_PORTABLE_ROOT.parent / "run_nvidia_gpu.bat") + comfy_start_bat = os.getenv("COMFY_START_BAT", default_bat) + + default_log_dir = str(_COMFY_PORTABLE_ROOT.parent / "logs") + comfy_log_dir = os.getenv("COMFY_LOG_DIR", default_log_dir) + + try: + comfy_log_max_mb = int(os.getenv("COMFY_LOG_MAX_MB", "10")) + except ValueError: + comfy_log_max_mb = 10 + + comfy_autostart = os.getenv("COMFY_AUTOSTART", "true").lower() not in ("false", "0", "no") + + # Web UI config + web_enabled = os.getenv("WEB_ENABLED", "true").lower() not in ("false", "0", "no") + web_host = os.getenv("WEB_HOST", "0.0.0.0") + try: + web_port = int(os.getenv("WEB_PORT", "8080")) + except ValueError: + web_port = 8080 + web_secret_key = os.getenv("WEB_SECRET_KEY", "") + web_token_file = os.getenv("WEB_TOKEN_FILE", "invite_tokens.json") + try: + web_jwt_expire_hours = int(os.getenv("WEB_JWT_EXPIRE_HOURS", "8")) + except ValueError: + web_jwt_expire_hours = 8 + web_secure_cookie = os.getenv("WEB_SECURE_COOKIE", "true").lower() not in ("false", "0", "no") + admin_password = os.getenv("ADMIN_PASSWORD") or None + + return cls( + discord_bot_token=discord_token, + comfy_server=comfy_server, + comfy_output_path=comfy_output_path, + comfy_input_path=comfy_input_path, + comfy_history_limit=comfy_history_limit, + comfy_input_channel_id=comfy_input_channel_id, + comfy_service_name=comfy_service_name, + comfy_start_bat=comfy_start_bat, + comfy_log_dir=comfy_log_dir, + comfy_log_max_mb=comfy_log_max_mb, + comfy_autostart=comfy_autostart, + workflow_file=workflow_file, + log_channel_id=log_channel_id, + zip_password=zip_password, + media_upload_user=media_upload_user, + media_upload_pass=media_upload_pass, + web_enabled=web_enabled, + web_host=web_host, + web_port=web_port, + web_secret_key=web_secret_key, + web_token_file=web_token_file, + web_jwt_expire_hours=web_jwt_expire_hours, + web_secure_cookie=web_secure_cookie, + admin_password=admin_password, + ) + + def __repr__(self) -> str: + """Return a string representation with sensitive data masked.""" + return ( + f"BotConfig(" + f"discord_bot_token='***masked***', " + f"comfy_server='{self.comfy_server}', " + f"comfy_output_path='{self.comfy_output_path}', " + f"comfy_input_path='{self.comfy_input_path}', " + f"comfy_history_limit={self.comfy_history_limit}, " + f"comfy_input_channel_id={self.comfy_input_channel_id}, " + f"workflow_file={self.workflow_file!r}, " + f"log_channel_id={self.log_channel_id!r}, " + f"zip_password={'***masked***' if self.zip_password else None})" + ) diff --git a/discord_utils.py b/discord_utils.py new file mode 100644 index 0000000..c5714ee --- /dev/null +++ b/discord_utils.py @@ -0,0 +1,251 @@ +""" +discord_utils.py +================ + +Discord utility functions and helpers for the Discord ComfyUI bot. + +This module provides reusable Discord-specific utilities including: +- Command decorators for validation +- Argument parsing helpers +- Message formatting utilities +- Discord UI components +""" + +from __future__ import annotations + +import functools +from io import BytesIO +from typing import Dict, Optional, List, Tuple + +import discord +from discord.ext import commands +from discord.ui import View + +from config import COMFY_NOT_CONFIGURED_MSG + + +def require_comfy_client(func): + """ + Decorator that validates bot.comfy exists before executing a command. + + This decorator checks if the bot has a configured ComfyClient instance + (bot.comfy) and sends an error message if not. This eliminates the need + for repeated validation code in every command. + + Usage + ----- + @bot.command(name="generate") + @require_comfy_client + async def generate_command(ctx: commands.Context, *, args: str = ""): + # bot.comfy is guaranteed to exist here + await bot.comfy.generate_image(...) + + Parameters + ---------- + func : callable + The command function to wrap. + + Returns + ------- + callable + The wrapped command function with ComfyClient validation. + """ + + @functools.wraps(func) + async def wrapper(ctx: commands.Context, *args, **kwargs): + bot = ctx.bot + if not hasattr(bot, "comfy") or bot.comfy is None: + await ctx.reply(COMFY_NOT_CONFIGURED_MSG, mention_author=False) + return + return await func(ctx, *args, **kwargs) + + return wrapper + + +def parse_labeled_args(args: str, keys: List[str]) -> Dict[str, Optional[str]]: + """ + Parse labeled arguments from a command string. + + This parser handles Discord command arguments in the format: + "key1:value1 key2:value2 ..." + + The parser splits on keyword markers and preserves case in values. + If a key is not found in the args string, its value will be None. + + Parameters + ---------- + args : str + The argument string to parse. + keys : List[str] + List of keys to extract (e.g., ["prompt:", "negative_prompt:"]). + + Returns + ------- + Dict[str, Optional[str]] + Dictionary mapping keys (without colons) to their values. + Keys not found in args will have None values. + + Examples + -------- + >>> parse_labeled_args("prompt:a cat negative_prompt:blurry", ["prompt:", "negative_prompt:"]) + {"prompt": "a cat", "negative_prompt": "blurry"} + + >>> parse_labeled_args("prompt:hello world", ["prompt:", "type:"]) + {"prompt": "hello world", "type": None} + """ + result = {key.rstrip(":"): None for key in keys} + remaining = args + + # Sort keys by position in string to parse left-to-right + found_keys = [] + for key in keys: + if key in remaining: + idx = remaining.find(key) + found_keys.append((idx, key)) + + found_keys.sort() + + for i, (_, key) in enumerate(found_keys): + # Split on this key + parts = remaining.split(key, 1) + if len(parts) < 2: + continue + + value_part = parts[1] + + # Find the next key, if any + next_key_idx = len(value_part) + if i + 1 < len(found_keys): + next_key = found_keys[i + 1][1] + if next_key in value_part: + next_key_idx = value_part.find(next_key) + + # Extract value up to next key + value = value_part[:next_key_idx].strip() + result[key.rstrip(":")] = value if value else None + + return result + + +def convert_image_bytes_to_discord_files( + images: List[bytes], max_files: int = 4, prefix: str = "generated" +) -> List[discord.File]: + """ + Convert a list of image bytes to Discord File objects. + + Parameters + ---------- + images : List[bytes] + List of raw image data as bytes. + max_files : int + Maximum number of files to convert (default: 4, Discord's limit). + prefix : str + Filename prefix for generated files (default: "generated"). + + Returns + ------- + List[discord.File] + List of Discord.File objects ready to send. + """ + files = [] + for idx, img_bytes in enumerate(images): + if idx >= max_files: + break + file_obj = BytesIO(img_bytes) + file_obj.seek(0) + files.append(discord.File(file_obj, filename=f"{prefix}_{idx + 1}.png")) + return files + + +async def send_queue_status(ctx: commands.Context, queue_size: int) -> None: + """ + Send a queue status message to the channel. + + Parameters + ---------- + ctx : commands.Context + The command context. + queue_size : int + Current number of jobs in the queue. + """ + await ctx.send(f"Queue size: {queue_size}", mention_author=False) + + +async def send_typing_with_callback(ctx: commands.Context, callback): + """ + Execute a callback while showing typing indicator. + + Parameters + ---------- + ctx : commands.Context + The command context. + callback : callable + Async function to execute while typing. + + Returns + ------- + Any + The return value of the callback. + """ + async with ctx.typing(): + return await callback() + + +def truncate_text(text: str, length: int = 50) -> str: + """ + Truncate text to a maximum length with ellipsis. + + Parameters + ---------- + text : str + The text to truncate. + length : int + Maximum length (default: 50). + + Returns + ------- + str + Truncated text with "..." suffix if longer than length. + """ + return text if len(text) <= length else text[: length - 3] + "..." + + +def extract_arg_value(args: str, key: str) -> Tuple[Optional[str], str]: + """ + Extract a single argument value from a labeled args string. + + This is a simpler alternative to parse_labeled_args for extracting just + one value. + + Parameters + ---------- + args : str + The full argument string. + key : str + The key to extract (e.g., "type:"). + + Returns + ------- + Tuple[Optional[str], str] + A tuple of (extracted_value, remaining_args). If key not found, + returns (None, original_args). + + Examples + -------- + >>> extract_arg_value("type:input some other text", "type:") + ("input", "some other text") + """ + if key not in args: + return None, args + + parts = args.split(key, 1) + if len(parts) < 2: + return None, args + + value_and_rest = parts[1].strip() + # Take first word as value + words = value_and_rest.split(None, 1) + value = words[0] if words else None + remaining = words[1] if len(words) > 1 else "" + + return value, remaining diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a713720 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + ComfyUI Bot + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..88302da --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2774 @@ +{ + "name": "comfyui-bot-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "comfyui-bot-ui", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://artifactory.ubisoft.org/api/npm/npm/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4cfa59f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "comfyui-bot-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "@tanstack/react-query": "^5.62.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..fd7cc7d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { useAuth } from './hooks/useAuth' +import Layout from './components/Layout' +import { GenerationProvider } from './context/GenerationContext' +import LoginPage from './pages/LoginPage' +import GeneratePage from './pages/GeneratePage' +import InputImagesPage from './pages/InputImagesPage' +import WorkflowPage from './pages/WorkflowPage' +import PresetsPage from './pages/PresetsPage' +import StatusPage from './pages/StatusPage' +import ServerPage from './pages/ServerPage' +import HistoryPage from './pages/HistoryPage' +import AdminPage from './pages/AdminPage' +import SharePage from './pages/SharePage' + +function RequireAuth({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth() + if (isLoading) return
Loading...
+ if (!isAuthenticated) return + return <>{children} +} + +function RequireAdmin({ children }: { children: React.ReactNode }) { + const { isAdmin, isLoading } = useAuth() + if (isLoading) return null + if (!isAdmin) return + return <>{children} +} + +export default function App() { + return ( + + + } /> + + + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + } /> + } /> + + + ) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..21c3b22 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,198 @@ +/** Typed API client for the ComfyUI Bot web API. */ + +const BASE = '' // same-origin in prod; Vite proxy in dev + +async function _fetch(path: string, init?: RequestInit): Promise { + const res = await fetch(BASE + path, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...init?.headers }, + ...init, + }) + if (!res.ok) { + const msg = await res.text().catch(() => res.statusText) + throw new Error(`${res.status}: ${msg}`) + } + return res.json() as Promise +} + +// Auth +export const authLogin = (token: string) => + _fetch<{ label: string; admin: boolean }>('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ token }), + }) + +export const authLogout = () => + _fetch<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }) + +export const authMe = () => + _fetch<{ label: string; admin: boolean }>('/api/auth/me') + +// Admin +export const adminLogin = (password: string) => + _fetch<{ label: string; admin: boolean }>('/api/admin/login', { + method: 'POST', + body: JSON.stringify({ password }), + }) + +export const adminListTokens = () => + _fetch>('/api/admin/tokens') + +export const adminCreateToken = (label: string, admin = false) => + _fetch<{ token: string; label: string; admin: boolean }>('/api/admin/tokens', { + method: 'POST', + body: JSON.stringify({ label, admin }), + }) + +export const adminRevokeToken = (id: string) => + _fetch<{ ok: boolean }>(`/api/admin/tokens/${id}`, { method: 'DELETE' }) + +// Status +export const getStatus = () => _fetch>('/api/status') + +// State / overrides +export const getState = () => _fetch>('/api/state') + +export const putState = (overrides: Record) => + _fetch>('/api/state', { + method: 'PUT', + body: JSON.stringify(overrides), + }) + +export const deleteStateKey = (key: string) => + _fetch<{ ok: boolean }>(`/api/state/${key}`, { method: 'DELETE' }) + +// Generation +export interface GenerateRequest { + prompt: string + negative_prompt?: string + overrides?: Record +} +export const generate = (body: GenerateRequest) => + _fetch<{ queued: boolean; queue_position: number }>('/api/generate', { + method: 'POST', + body: JSON.stringify(body), + }) + +export interface WorkflowGenRequest { + count?: number + overrides?: Record +} +export const workflowGen = (body: WorkflowGenRequest) => + _fetch<{ queued: boolean; count: number; queue_position: number }>('/api/workflow-gen', { + method: 'POST', + body: JSON.stringify(body), + }) + +// Inputs +export interface InputImage { + id: number + original_message_id: number + bot_reply_id: number | null + channel_id: number + filename: string + is_active: number + active_slot_key: string | null +} +export const listInputs = () => _fetch('/api/inputs') + +export const uploadInput = (file: File, slotKey = 'input_image') => { + const form = new FormData() + form.append('file', file) + form.append('slot_key', slotKey) + return fetch('/api/inputs', { + method: 'POST', + credentials: 'include', + body: form, + }).then(r => r.json()) +} + +export const activateInput = (id: number, slotKey = 'input_image') => + _fetch<{ ok: boolean }>(`/api/inputs/${id}/activate?slot_key=${slotKey}`, { method: 'POST' }) + +export const deleteInput = (id: number) => + _fetch<{ ok: boolean }>(`/api/inputs/${id}`, { method: 'DELETE' }) + +export const getInputImage = (id: number) => `/api/inputs/${id}/image` +export const getInputThumb = (id: number) => `/api/inputs/${id}/thumb` +export const getInputMid = (id: number) => `/api/inputs/${id}/mid` + +// Presets +export interface PresetMeta { name: string; owner: string | null; description: string | null } +export const listPresets = () => _fetch<{ presets: PresetMeta[] }>('/api/presets') +export const savePreset = (name: string, description?: string) => + _fetch<{ ok: boolean }>('/api/presets', { method: 'POST', body: JSON.stringify({ name, description: description ?? null }) }) +export const getPreset = (name: string) => _fetch>(`/api/presets/${name}`) +export const loadPreset = (name: string) => + _fetch<{ ok: boolean }>(`/api/presets/${name}/load`, { method: 'POST' }) +export const deletePreset = (name: string) => + _fetch<{ ok: boolean }>(`/api/presets/${name}`, { method: 'DELETE' }) +export const savePresetFromHistory = (promptId: string, name: string, description?: string) => + _fetch<{ ok: boolean; name: string }>(`/api/presets/from-history/${promptId}`, { + method: 'POST', + body: JSON.stringify({ name, description: description ?? null }), + }) + +// Server +export const getServerStatus = () => + _fetch<{ service_state: string; http_reachable: boolean }>('/api/server/status') +export const serverAction = (action: string) => + _fetch<{ ok: boolean }>(`/api/server/${action}`, { method: 'POST' }) +export const tailLogs = (lines = 100) => + _fetch<{ lines: string[] }>(`/api/logs/tail?lines=${lines}`) + +// History +export const getHistory = (q?: string) => + _fetch<{ history: Array> }>(q ? `/api/history?q=${encodeURIComponent(q)}` : '/api/history') + +export const createHistoryShare = (promptId: string) => + _fetch<{ share_token: string }>(`/api/history/${promptId}/share`, { method: 'POST' }) + +export const revokeHistoryShare = (promptId: string) => + _fetch<{ ok: boolean }>(`/api/history/${promptId}/share`, { method: 'DELETE' }) + +export const getShareFileUrl = (token: string, filename: string) => + `/api/share/${token}/file/${encodeURIComponent(filename)}` + +// Workflow +export const getWorkflow = () => + _fetch<{ loaded: boolean; node_count: number; last_workflow_file: string | null }>('/api/workflow') + +export interface NodeInput { + key: string + label: string + input_type: string + current_value: unknown + node_class: string + node_title: string + is_common: boolean +} +export const getWorkflowInputs = () => + _fetch<{ common: NodeInput[]; advanced: NodeInput[] }>('/api/workflow/inputs') + +export const listWorkflowFiles = () => + _fetch<{ files: string[] }>('/api/workflow/files') + +export const uploadWorkflow = (file: File) => { + const form = new FormData() + form.append('file', file) + return fetch('/api/workflow/upload', { + method: 'POST', + credentials: 'include', + body: form, + }).then(r => r.json()) +} + +export const loadWorkflow = (filename: string) => { + const form = new FormData() + form.append('filename', filename) + return fetch('/api/workflow/load', { + method: 'POST', + credentials: 'include', + body: form, + }).then(r => r.json()) +} + +export const getModels = (type: 'checkpoints' | 'loras') => + _fetch<{ type: string; models: string[] }>(`/api/workflow/models?type=${type}`) + diff --git a/frontend/src/components/DynamicWorkflowForm.tsx b/frontend/src/components/DynamicWorkflowForm.tsx new file mode 100644 index 0000000..767b498 --- /dev/null +++ b/frontend/src/components/DynamicWorkflowForm.tsx @@ -0,0 +1,329 @@ +import React, { useEffect, useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + getWorkflowInputs, + getModels, + putState, + deleteStateKey, + getState, + NodeInput, + activateInput, + listInputs, + getInputImage, + getInputThumb, + getInputMid, +} from '../api/client' +import LazyImage from './LazyImage' + +interface Props { + /** Called when the Generate button is clicked with the current overrides */ + onGenerate: (overrides: Record, count: number) => void + /** Live seed from WS generation_complete event */ + lastSeed?: number | null + generating?: boolean + /** The authenticated user's label (used to find their active input image) */ + userLabel?: string +} + +export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating, userLabel }: Props) { + const qc = useQueryClient() + const { data: inputsData, isLoading: inputsLoading } = useQuery({ + queryKey: ['workflow', 'inputs'], + queryFn: getWorkflowInputs, + }) + const { data: stateData } = useQuery({ + queryKey: ['state'], + queryFn: getState, + }) + const { data: checkpoints } = useQuery({ + queryKey: ['models', 'checkpoints'], + queryFn: () => getModels('checkpoints'), + staleTime: 60_000, + }) + const { data: loras } = useQuery({ + queryKey: ['models', 'loras'], + queryFn: () => getModels('loras'), + staleTime: 60_000, + }) + const { data: inputImages } = useQuery({ + queryKey: ['inputs'], + queryFn: listInputs, + }) + + const [localValues, setLocalValues] = useState>({}) + const [randomSeeds, setRandomSeeds] = useState>({}) + const [imagePicker, setImagePicker] = useState(null) // key of slot being picked + const [count, setCount] = useState(1) + + // Sync local values from state when stateData arrives + useEffect(() => { + if (stateData) setLocalValues(stateData as Record) + }, [stateData]) + + // Update seed field when WS reports completed seed + useEffect(() => { + if (lastSeed != null) { + setLocalValues(v => ({ ...v, seed: lastSeed })) + } + }, [lastSeed]) + + const putStateMut = useMutation({ + mutationFn: (overrides: Record) => putState(overrides), + onSuccess: () => qc.invalidateQueries({ queryKey: ['state'] }), + }) + const deleteKeyMut = useMutation({ + mutationFn: (key: string) => deleteStateKey(key), + onSuccess: () => qc.invalidateQueries({ queryKey: ['state'] }), + }) + + const setValue = (key: string, value: unknown) => { + setLocalValues(v => ({ ...v, [key]: value })) + putStateMut.mutate({ [key]: value }) + } + + const handleActivateImage = async (imageId: number, slotKey: string) => { + await activateInput(imageId, slotKey) + qc.invalidateQueries({ queryKey: ['inputs'] }) + qc.invalidateQueries({ queryKey: ['state'] }) + setImagePicker(null) + } + + const handleGenerate = () => { + const overrides: Record = {} + const allInputs = [...(inputsData?.common ?? []), ...(inputsData?.advanced ?? [])] + for (const inp of allInputs) { + if (inp.input_type === 'seed') { + overrides[inp.key] = randomSeeds[inp.key] !== false ? -1 : (localValues[inp.key] ?? -1) + } else if (inp.input_type === 'image') { + // image slot — server reads from state_manager + } else { + const v = localValues[inp.key] + if (v !== undefined && v !== '') overrides[inp.key] = v + } + } + onGenerate(overrides, count) + } + + if (inputsLoading) return
Loading workflow inputs…
+ if (!inputsData) return
No workflow loaded.
+ + const renderField = (inp: NodeInput) => { + const val = localValues[inp.key] ?? inp.current_value + + if (inp.input_type === 'text') { + return ( +