Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
518e818c16 | ||
|
|
c391de705d | ||
|
|
0eb0c62412 | ||
|
|
03d41d51a7 | ||
|
|
1cdb9a0086 | ||
|
|
9fab25b9f7 | ||
|
|
88f62705ae | ||
|
|
cafbc82eec | ||
|
|
fa9e293f4d | ||
|
|
9da9e6c586 | ||
|
|
a823e8a061 | ||
|
|
534063dd54 | ||
|
|
88c26a1327 | ||
|
|
da7d32269f | ||
|
|
df03c74149 | ||
|
|
fc18b0c5a5 | ||
|
|
dcef0b2b75 | ||
|
|
159cc85a85 | ||
|
|
84873c972e | ||
|
|
1d00f7109b | ||
|
|
dd65175c96 | ||
|
|
36782244ed | ||
|
|
4dc9b750a7 | ||
|
|
2c94cc574c | ||
|
|
ba091d0857 | ||
|
|
bc42d60fe7 | ||
|
|
2e7076695e | ||
|
|
1231388921 | ||
|
|
abceac9dfe | ||
|
|
379f7688eb | ||
|
|
715e4f1eed | ||
|
|
417b10334d | ||
|
|
d20703e570 | ||
|
|
a548b98bb9 | ||
|
|
ac74325903 | ||
|
|
387f4dc732 | ||
|
|
c9c2655f56 | ||
|
|
701a9ffde7 | ||
|
|
40140ae232 | ||
|
|
93b78e6429 | ||
|
|
d2caf1790d | ||
|
|
7aae477f4b | ||
|
|
57fc0f4fac | ||
|
|
a683420971 | ||
|
|
55157977c1 | ||
|
|
76f7a604c1 | ||
|
|
637e1e6ed9 | ||
|
|
86f1644ba4 | ||
|
|
a9c416c7f9 | ||
|
|
d766d69ee3 | ||
|
|
8737d3733d | ||
|
|
cd485e6a60 | ||
|
|
24b19dd5cb | ||
|
|
b3f2edb687 | ||
|
|
d0c0e159f2 | ||
|
|
e320b67fc4 | ||
|
|
15622d545e | ||
|
|
cab027faa5 | ||
|
|
670c1df230 | ||
|
|
b0f0aa16f2 | ||
|
|
79835b9b54 | ||
|
|
6a0405bbf3 | ||
|
|
af661f3323 | ||
|
|
d32d87be20 | ||
|
|
781d2a1972 | ||
|
|
748ace06df | ||
|
|
d82e3fce78 | ||
|
|
3cd8ea8faa | ||
|
|
04df67d3b8 | ||
|
|
bb88d9370b | ||
|
|
a43d9e5e48 | ||
|
|
cbd9f03d6e | ||
|
|
a607b4e6f7 | ||
|
|
72eb163601 | ||
|
|
1ad80cb330 | ||
|
|
2b4c9cdcb9 | ||
|
|
17ec7a9bc0 | ||
|
|
69e1dac32f | ||
|
|
53227c9e1a | ||
|
|
171900257f | ||
|
|
56b535717f | ||
|
|
0e9c4df818 | ||
|
|
6e18dd595b | ||
|
|
cc24615087 | ||
|
|
c11e1dafee | ||
|
|
5145a51cf7 | ||
|
|
f0b139be73 | ||
|
|
c75342874d | ||
|
|
67eab9cfa7 | ||
|
|
194ba85f02 | ||
|
|
11293b6660 | ||
|
|
bba80db1de | ||
|
|
510d918958 | ||
|
|
1c1260427f | ||
|
|
d89dd2edd4 | ||
|
|
f2fe2f2d99 | ||
|
|
c113b8bf6d | ||
|
|
c25764cc65 | ||
|
|
2518cc2fa1 | ||
|
|
a641d5996b | ||
|
|
1229799f1a | ||
|
|
ef8291ccca | ||
|
|
3deec5d404 | ||
|
|
04ae215b65 | ||
|
|
07785264d7 | ||
|
|
f468557502 | ||
|
|
d596475909 | ||
|
|
1662126959 | ||
|
|
b55aa61073 | ||
|
|
aec8093f72 | ||
|
|
cd16bdf5b1 | ||
|
|
efa5383992 | ||
|
|
fdad9c0204 | ||
|
|
b3d6f548ee | ||
|
|
48113754e3 | ||
|
|
fb6fddc8ad | ||
|
|
4258a565b5 | ||
|
|
d042f46683 | ||
|
|
8a8abe01f2 | ||
|
|
6358b90da3 | ||
|
|
6b110c5a14 | ||
|
|
8b04d7e4b5 | ||
|
|
c8ee0fb577 | ||
|
|
c90c7ecdd3 | ||
|
|
c5598d15f9 | ||
|
|
4ef7f2d8c4 | ||
|
|
ae42d4edbb | ||
|
|
2a1028c9ae | ||
|
|
7336086331 |
@@ -0,0 +1,5 @@
|
||||
* text=auto eol=lf
|
||||
*.py text eol=lf
|
||||
*.js text eol=lf
|
||||
*.md text eol=lf
|
||||
*.csv -text
|
||||
@@ -3,6 +3,14 @@ node.zip
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
preset_colors/
|
||||
preset_models/
|
||||
presets/
|
||||
prompts/
|
||||
rayko_lora_data/
|
||||
styles/
|
||||
ComfyUI-Custom-Node-Manager/
|
||||
python
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
# 🦊 ComfyUI_RaykoStudio
|
||||
Set of custom nodes for ComfyUI providing additional image processing capabilities
|
||||
---
|
||||
---
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>🖥️ Performance has been tested for</summary>
|
||||
@@ -26,7 +26,7 @@ Set of custom nodes for ComfyUI providing additional image processing capabiliti
|
||||
</details>
|
||||
<details>
|
||||
<summary>🛠 Installation</summary>
|
||||
|
||||
|
||||
Set of nodes can be installed in several ways:
|
||||
- Clone repository to `ComfyUI/custom_nodes/` folder:
|
||||
```
|
||||
@@ -40,70 +40,15 @@ git clone https://github.com/Raykosan/ComfyUI_RaykoStudio.git
|
||||
<details>
|
||||
<summary>🆙 UPDATES</summary>
|
||||
|
||||
- v0.17.3 - Adding to RS Prompts node output "PROMPT_STRING", which returns the text value of the final prompt
|
||||
- v0.17.5 - A switch has been added that allows you to enable/disable external text input with a single mouse click
|
||||
- v0.17.6 - Changing the name of the RS Spline Mask node to RS Load Image
|
||||
- v0.17.8 - Minor fix in RS Load Image node. Added the ability to upload images to a node by dragging an image from Explorer to node
|
||||
- v0.18.0 - Added a new node - RS Image to Latent
|
||||
- v0.18.1 - Modification of the RS Models Loader node
|
||||
- v0.19.0 - License change and addition of the RS Collage node and the RS rgb2rgba node
|
||||
- v0.20.0 - Complete modification of the RS Collage node
|
||||
- v0.20.1 - Fixed the behavior of the overlay, which in some cases overlapped the ComfyUI popups
|
||||
- v0.20.2 - Modification of the RS Crop Image node
|
||||
- v0.20.3 - Adding a mask mode to the RS Crop Image node
|
||||
- v0.20.4 - Fixed image loading by dragging in the RS Load Image node
|
||||
- v0.20.5 - Adding the ideogram4 parameter to the Models Loader clip node list
|
||||
- v0.20.6 - Fixed the logic of the RS Prompts node
|
||||
- v0.21.0 - Complete redesign of the RS Prompts node logic
|
||||
- v0.21.2 - Modification of the RS Models Loader node: pixel_spaces has been added to the VAE list
|
||||
- v0.22.0 - Added the 'Interface Settings Manager' extension
|
||||
- v0.23.0 - Added the RS Any Switch node
|
||||
- v0.23.1 - Adding an RS Image to Latent (simplified) node
|
||||
- v0.23.2 - The 'UPDATE NAME' button has been added to the RS Any Switch node
|
||||
- v0.23.3 - Fixed resetting the mask color to default after pressing the ACCEPT and CANCEL buttons in the RS Outpaint node
|
||||
- v0.23.4 - A BATCH button has been added to the RS Intermediate Spline Mask node for batch image processing with a permanent mask
|
||||
- v0.23.5 - Fixed the operation of the RS Prompts node with light themes of the ComfyUI interface
|
||||
- v0.25.0 - Modification of the RS Color Picker node
|
||||
- v0.25.1 - Fixed a bug with the generation process hanging under certain circumstances
|
||||
- v0.25.2 - Clip added to RS Models Loader node: boogu and krea2
|
||||
- v0.26.0 - Adding a new RS Text Overlay Pro node
|
||||
- v0.26.1 - Correcting the color selection in the RS Colorpicker node
|
||||
- v0.27.0 - Added the RS MultiLatent node
|
||||
- v0.28.0 - Adding preset functions to the RS Models Loader node
|
||||
- v0.28.2 - Adding a new node RS Ref 2 Latent
|
||||
- v0.29.0 - Adding a new node RS Bypass Manager
|
||||
- v0.29.1 - Added a csv file with the styles 'Krea2 Style RS'
|
||||
- v0.30.0 - Upgrading the RS Styles Loader node
|
||||
- v0.30.1 - Upgrading the RS Models Loader node
|
||||
- v0.30.2 - Code optimization and visual changes in the RS Models Loader node
|
||||
- v0.30.3 - Adding the LoRA drag and drop feature to the RS Models Loader node
|
||||
- v0.31.0 - Adding a new node RS Lora Loader
|
||||
- v0.31.1 - Adding the metadata display function for LoRA to the RS Models Loader node
|
||||
- v0.31.2 - Added the ability to create and edit tags for LoRA
|
||||
- v0.31.3 - Upgrading the RS Color Picker node
|
||||
- v0.32.1 - Adding the RS Image Adjustments node
|
||||
- v0.32.2 - Code optimization in the RS Image Adjustments node. The time to receive data from the ComfyUI server has been reduced by 10-15 times
|
||||
- v0.32.3 - The Sharpen, Vibration and Clarity parameters have been added to the RS Image Adjustments node
|
||||
- v0.32.4 - Corrections of formulas for calculating the parameters of the RS Image Adjustments node
|
||||
- v0.33.0 - Changing the operating method of the RS Outpaint node
|
||||
- v0.33.1 - clip_type 'mage' added to RS Models Loader node
|
||||
- v0.33.2 - Deep modification of the RS Outpaint node
|
||||
- v0.34.0 - Adding the RS Load Images From Dir node
|
||||
- v0.34.1 - Added the 'Paste' button to the RS Load Images From Dir node
|
||||
- v0.35.0 - Added an RS Image-Prompt node
|
||||
- v0.35.1 - Modification of interfaces in some nodes
|
||||
- v0.36.0 - Added the RS Decode Save node
|
||||
- v0.36.1 - Fix error in the RS Models Loader node
|
||||
- v0.36.2 - Security Hardening: Path Traversal Prevention in RS_VAE_Decode_Save
|
||||
- v0.36.3 - Adding batch image processing mode to the RS Image Adjustments node
|
||||
- v0.36.4 - Added drag and drop image loading to the RS Image-Text node
|
||||
- v0.37.0 - Added an RS Upscaler node
|
||||
- v0.38.0 - Added an RS LoRA Tester node
|
||||
- v0.39.0 - Added an RS Save Image LoRA node
|
||||
- v0.40.0 - Added RS Models Loader Pro node
|
||||
- v0.41.0 - Added RS Ref Encode node
|
||||
- v0.42.0 - Added nodes for working with upscalers: RS Upscale & Resize, RS Tile Adjustments, RS Tile Assemble
|
||||
- v0.42.1 - Changes in the interfaces of some nodes
|
||||
- v0.43.0 - Added RS Label node
|
||||
- v0.44.0 - Added RS Label Image node
|
||||
- v0.46.0 - Modification of the Setting Manager extension
|
||||
- v0.48.11 - Reduced outbound HTTP surface to the standard-library opener
|
||||
- v0.49.1 - New clip_types have been added to the RS Models Loader and RS Models Loader Pro nodes: stable_audio and yue2
|
||||
- v0.50.1 - Added a new RS Split Video/Audio node
|
||||
- v0.50.2 - Added flux3
|
||||
- v0.50.3 - Added moss_image
|
||||
|
||||
</details>
|
||||
|
||||
@@ -111,9 +56,160 @@ git clone https://github.com/Raykosan/ComfyUI_RaykoStudio.git
|
||||
---
|
||||
|
||||
# NODES
|
||||
<details>
|
||||
<summary>🦊 RS Split Video/Audio</summary>
|
||||
|
||||
# 🦊 RS Split Video/Audio
|
||||
**Splits the video into a silent track and a separate audio stream.**
|
||||
<br>
|
||||
<img width="614" height="250" alt="Screenshot_1" src="https://github.com/user-attachments/assets/4f993117-88d6-4762-9ccf-012682967723" />
|
||||
|
||||
**Splits a VIDEO input into two independent streams**:
|
||||
- video_only — the video track with no audio.
|
||||
- audio_only — the audio track as a standalone AUDIO object.
|
||||
|
||||
**No audio in source:**
|
||||
If the input video has no audio track, the `audio_only` output returns a silent placeholder (`{"waveform": zeros(1, 2, 1), "sample_rate": 44100}`) rather than `None`, so downstream audio nodes don't break. The node itself never raises for a missing audio track.
|
||||
|
||||
### Inputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|---------|---------|--------------------------------------|
|
||||
| `video` | `VIDEO` | Source video (e.g. from `Load Video`)|
|
||||
|
||||
### Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|--------------|---------|------------------------------------|
|
||||
| `video_only` | `VIDEO` | Video track without audio |
|
||||
| `audio_only` | `AUDIO` | Standalone audio track |
|
||||
|
||||
### FFmpeg discovery
|
||||
|
||||
**The fast path looks for ffmpeg in this order**:
|
||||
|
||||
RS_FFMPEG_PATH environment variable — set this to point at a specific binary.
|
||||
|
||||
System PATH — scanned manually (with .exe/.cmd/.bat suffixes on Windows).
|
||||
|
||||
imageio-ffmpeg — if the package is installed, its bundled binary is used.
|
||||
|
||||
If none of the above yields a working binary, the node silently uses the slow path.
|
||||
|
||||
**To install a system binary**:
|
||||
|
||||
Windows: winget install Gyan.FFmpeg or download from ffmpeg.org and add to PATH.
|
||||
|
||||
Linux: sudo apt install ffmpeg
|
||||
|
||||
macOS: brew install ffmpeg
|
||||
|
||||
**Alternatively, in the ComfyUI venv**:
|
||||
```
|
||||
pip install imageio-ffmpeg
|
||||
```
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Label</summary>
|
||||
|
||||
# 🦊 RS Label
|
||||
**A highly customizable floating label node for ComfyUI.**
|
||||
Perfect for adding annotations, titles, watermarks, or text overlays directly on the canvas with full control over typography and styling.
|
||||
<br>
|
||||
[](https://youtu.be/XRu__-2PXj8)
|
||||
|
||||
<img width="996" height="806" alt="photo_2026-08-26_12-36-43" src="https://github.com/user-attachments/assets/a492ecfb-5a8d-4131-a41c-3492d78fd3f2" />
|
||||
|
||||
### 🔥 Features
|
||||
- **Rich Typography** - Full support for custom `.ttf`/`.otf` fonts loaded from a local directory
|
||||
- **Advanced Styling** - Precise control over font size, color, alignment, line spacing, letter spacing, and text stroke (outline)
|
||||
- **Flexible Backgrounds** - Choose between fully transparent mode or solid background colors with adjustable padding and border radius
|
||||
- **Smart UI** - Draggable settings window that auto-anchors to the node without dimming the canvas
|
||||
- **Non-Destructive Workflow** - Edit properties in real-time while maintaining full visibility of your graph
|
||||
- **Pin Support** - Right-click → Pin to allow clicks to pass through the label when not editing
|
||||
|
||||
### 🎛️ Controls
|
||||
**Double-Click:** Opens the settings dialog anchored to the node
|
||||
**Drag Window Title:** Repositions the settings dialog anywhere on screen
|
||||
**Close:** settings window is closed with the `OK` button or the `ESC` key
|
||||
|
||||
⚠️ **When navigating through the ComfyUI or browser tabs, as well as when refreshing the ComfyUI page, the settings window automatically closes, saving the text settings configured in it.**
|
||||
|
||||
### 🔤 Installing Fonts
|
||||
Place .ttf or .otf files in folder:
|
||||
ComfyUI\custom_nodes\ComfyUI_RaykoStudio\fonts
|
||||
Restart ComfyUI
|
||||
Fonts are auto-detected on editor open
|
||||
|
||||
### 🛠️ Settings Dialog
|
||||
| Parameter | Description | Default |
|
||||
| :--- | :--- | :--- |
|
||||
| **Text** | Content of the label. Supports multiline via Enter key. | `Sample Text` |
|
||||
| **Font Size** | Height of the text in pixels. | `24` |
|
||||
| **Font Family** | Dropdown populated from `fonts/` directory. | First available |
|
||||
| **Font Color** | Fill color of the text characters. | `#FF0000` |
|
||||
| **Text Align** | Horizontal alignment (Left, Center, Right). | `Left` |
|
||||
| **Line Spacing** | Multiplier for vertical distance between baselines. | `1.0` |
|
||||
| **Letter Spacing** | Pixel offset between individual characters. | `0` |
|
||||
| **Stroke Width** | Thickness of the text outline (0–10). | `0` |
|
||||
| **Stroke Color** | Color of the text outline. | `#000000` |
|
||||
| **BG Transparent** | Toggle for solid background vs transparency. | `On` |
|
||||
| **BG Color** | Fill color when transparency is disabled. | N/A |
|
||||
| **Padding** | Internal margin between text and border. Min effective value is 5px. | `10` (UI: 5) |
|
||||
| **Border Radius** | Corner roundness of the label frame. | `5` |
|
||||
| **Border Width** | Thickness of the outer frame. | `2` |
|
||||
| **Border Color** | Color of the outer frame. | `#FF0000` |
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Label Image</summary>
|
||||
|
||||
# 🦊 RS Label Image
|
||||
**A versatile floating image label node for ComfyUI.**
|
||||
Perfect for adding visual markers, logos, icons, or annotations directly onto your canvas with full control over styling and layout.
|
||||
<br>
|
||||
[](https://youtu.be/XRu__-2PXj8)
|
||||
|
||||
<img width="1632" height="547" alt="Screenshot_1" src="https://github.com/user-attachments/assets/ebe03f69-f9f4-4046-a807-7a236d08c88b" />
|
||||
|
||||
### 🔥 Features
|
||||
- **Flexible Backgrounds** - Choose between fully transparent mode for a "floating" look or solid background colors to make your labels pop
|
||||
- **Smart Drag & Drop** - Simply drag an image file from your computer directly onto the node to upload it instantly
|
||||
- **Auto-Synced Rounding** - The image corners automatically match the curvature of the outer border for a perfectly aligned, professional look (with an option for manual override)
|
||||
- **Proportional Scaling** - Images are resized while maintaining their original aspect ratio, ensuring no distortion regardless of the target size
|
||||
- **Non-Destructive Workflow** - Edit properties in a draggable settings window without dimming the canvas or blocking access to other nodes
|
||||
- **Persistent Storage** - Uploaded images are cached and reliably restored even after page reloads or switching between workflow tabs
|
||||
|
||||
### 🎛️ Controls
|
||||
**Double-Click:** Opens the settings dialog anchored to the node
|
||||
**Drag Window Title:** Repositions the settings dialog anywhere on screen
|
||||
**Close:** settings window is closed with the `OK` button or the `ESC` key
|
||||
|
||||
⚠️ **When navigating through the ComfyUI or browser tabs, as well as when refreshing the ComfyUI page, the settings window automatically closes, saving the text settings configured in it.**
|
||||
|
||||
### 🛠️ Settings Dialog
|
||||
| Parameter | Description | Default |
|
||||
| :--- | :--- | :--- |
|
||||
| **Size** | Maximum side length of the displayed image (16–200px). | `100` |
|
||||
| **Padding** | Internal margin between the image and the border. | `0` (Internal: 5px) |
|
||||
| **Border Width** | Thickness of the outer frame. | `2` |
|
||||
| **Border Radius** | Corner roundness of the label frame. | `5` |
|
||||
| **Sync Image Radius** | Automatically matches image corners to the border shape. | `On` |
|
||||
| **Image Radius** | Manual corner roundness for the image (if sync is off). | `0` |
|
||||
| **Border Color** | Color of the outer frame. | `#FF0000` |
|
||||
| **BG Transparent** | Toggle for solid background vs transparency. | `On` |
|
||||
| **BG Color** | Fill color when transparency is disabled. | N/A |
|
||||
|
||||
### 💡 Tips
|
||||
- **Perfect Circles:** Set the **Border Radius** to half of the **Size** (e.g., Size 100, Radius 50) to create perfect circular labels
|
||||
- **Visual Consistency:** Keep **Sync Image Radius** enabled to ensure the image and border always look like a single cohesive element
|
||||
- **Workflow Organization:** Use these labels as visual headers for different sections of your graph or as status indicators for complex sub-workflows
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Collage Node</summary>
|
||||
|
||||
|
||||
# 🦊 RS Collage Node
|
||||
**Interactive node for overlaying images with real-time positioning, scaling, rotation, and edge feathering directly on the canvas.**
|
||||
|
||||
@@ -212,7 +308,7 @@ In Chrome and Edge, the button works immediately (requires a one-time confirmati
|
||||
In Firefox, the Paste button will additionally pop up, when clicked, the path will be inserted.
|
||||
**These are browser security principles and cannot be circumvented.**
|
||||
In any case, you can always paste the path using the keyboard shortcut Ctrl+V.
|
||||
|
||||
|
||||
### Inputs
|
||||
| Parameter | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
@@ -233,7 +329,7 @@ In any case, you can always paste the path using the keyboard shortcut Ctrl+V.
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Image-Prompt</summary>
|
||||
|
||||
|
||||
# 🦊 RS Image-Prompt
|
||||
**A ComfyUI node that extracts the positive prompt from image metadata — instantly, without running the queue.**
|
||||
Load a generated image and `RS Image-Prompt` reads back the prompt that was used to create it. It understands metadata written by ComfyUI, Automatic1111, Forge, and the rest of the RaykoStudio suite.
|
||||
@@ -304,70 +400,178 @@ The prompt is extracted from a wide range of metadata formats, in priority order
|
||||
# 🦊 RS Bypass Manager
|
||||
**A powerful node for managing the states of Bypass nodes and groups in complex ComfyUI circuits. If your workflow has turned into a "spaghetti monster" and you need to quickly disable entire modules (for example, switch between txt2img, inpaint and upscale), this node will save you dozens of clicks and nerves.**
|
||||
|
||||
[](https://youtu.be/Ockp2SpuFSY)
|
||||
[](https://youtu.be/JyFN_4XN50E)
|
||||
|
||||
<img width="623" height="583" alt="Screenshot_5" src="https://github.com/user-attachments/assets/c4193648-0e3b-4499-bb14-aff6d59e0345" />
|
||||
<img width="429" height="407" alt="Screenshot_1" src="https://github.com/user-attachments/assets/cd3863b7-04a8-4506-aa7b-03f9e7c24cd5" />
|
||||
<img width="682" height="883" alt="Screenshot_1" src="https://github.com/user-attachments/assets/fa780391-ecc9-4b53-a3c7-f9da8f389f6a" />
|
||||
<img width="750" height="616" alt="Screenshot_3" src="https://github.com/user-attachments/assets/b9795aee-1819-4010-82eb-fbbc77f1af8b" />
|
||||
|
||||
### 🔥 Features
|
||||
**Smart Search** - Instant search for the desired nodes and groups by name right inside the drop-down menu.
|
||||
**Group Support** - Works with ComfyUI groups. Groups can be collapsed and expanded to select individual nodes within them.
|
||||
**Node Support** - Works with ComfyUI nodes. You can select nodes that are not part of groups, or you can select individual nodes within groups.
|
||||
**Tree structure** - Tree structure of the node hierarchy in groups.
|
||||
**Toggles** - Common toggle for all selected items. Each element has personal switches.
|
||||
**Color indication**:
|
||||
* 🔴 **Red** — the group or node is completely blocked.
|
||||
* 🟠 **Orange** — only part of the node is bypassed in the group (partial bypass).
|
||||
* 🔴 **Red** — the group or node is completely blocked.
|
||||
* 🟠 **Orange** - the group is partially in the bypass.
|
||||
* ⚪ **Gray** — the node/group is active.
|
||||
|
||||
**List of active bypasses** - All blocked elements are displayed directly on the node. You can delete a bypass by clicking on the node or group name.
|
||||
|
||||
**Smart State saving** - The bypass status is saved directly in the JSON workflow. No data is lost when restarting ComfyUI, switching tabs, or sharing PNG/JSON.
|
||||
**Dynamic size** - The node automatically adjusts its height to the number of mounted elements.
|
||||
**Advanced UX**:
|
||||
* The menu **does not close** when you click on an item, you can quickly reset several nodes in a row.
|
||||
* The menu closes automatically when the mouse cursor moves outside of it (with a slight delay for comfort).
|
||||
* The `SELECT' field...` is highlighted in orange while the menu is open.
|
||||
* The `SELECT ITEM' field...` is highlighted in orange while the menu is open.
|
||||
* The node excludes itself from the list of elements available for bypass.
|
||||
|
||||
### 🪛 Usage
|
||||
Add the **🦊 RS Bypass** node to the canvas (category `🦊 RaykoStudio').
|
||||
Click on the **SELECT...** field. A menu opens with all the groups and nodes of your scheme.
|
||||
Add the **🦊 RS Bypass Manager** node to the canvas (category `🦊 RaykoStudio').
|
||||
Click on the **SELECT ITEM** field. A menu opens with all the groups and nodes of your scheme.
|
||||
If there are a large number of nodes, use the search bar to filter.
|
||||
Click on groups or nodes to switch their state (Bypass/Active).
|
||||
* *Tip: Clicking on the name of the group bypasses it entirely. Clicking on the arrow (▶) will expand the group to select individual nodes.*
|
||||
|
||||
When you're done, just move the mouse cursor outside the menu — it will close itself in half a second.
|
||||
To remove the bypass, click on the name of the desired item in the list on the node itself.
|
||||
|
||||
When you're done, click anywhere outside the node or press the Escape key to close the menu.
|
||||
To turn off or turn on the bypass of an element inserted into the interface, use the personal toggle.
|
||||
To turn off or enable bypass for all elements, use the TOGGLE ALL switch.
|
||||
To remove a workaround, click on the name of the desired item in the list on the node itself.
|
||||
When the group frame is deleted, the bypass remains on the selected nodes.
|
||||
|
||||
If a node is added to a circuit that already has bypass nodes, it will automatically display them in the interface.
|
||||
Also, when using bypass using comfi's own methods (the context menu is bypass, bypass in the NodeMap side menu, or bypass buttons above the node), all changes will instantly appear in the node.
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Decode Save</summary>
|
||||
|
||||
# 🦊 RS Decode Save
|
||||
<summary>🦊 RS Decode Save Image</summary>
|
||||
|
||||
# 🦊 RS Decode Save Image
|
||||
**A lightweight ComfyUI custom node that combines native VAE Decode and Save Image into a single, streamlined unit.**
|
||||
Decodes latent samples to image tensors and saves them to disk in one step, while preserving the decoded `IMAGE` output for downstream preview or processing.
|
||||
|
||||
<img width="1013" height="774" alt="Screenshot_3" src="https://github.com/user-attachments/assets/ca9d60cf-e9c9-4f83-ae8d-617046ba2904" />
|
||||
<img width="517" height="817" alt="Screenshot_2" src="https://github.com/user-attachments/assets/e6c797ab-8d6e-490c-b342-8f484a613e70" />
|
||||
<img width="518" height="819" alt="Screenshot_1" src="https://github.com/user-attachments/assets/a4133728-7305-433f-806b-db4138be5f99" />
|
||||
|
||||
### 🔥 Features
|
||||
**Unified Workflow** - Eliminates the need for separate decode and save nodes.
|
||||
**Native Path Handling** - Uses ComfyUI's standard `folder_paths.get_save_image_path` logic. Supports subfolders via prefix (e.g., `project/render`).
|
||||
**Format Support** - PNG, JPG, WEBP with automatic file numbering (`_00001`, `_00002`...).
|
||||
**Metadata Embedding** - Automatically embeds workflow metadata into PNGs or saves sidecar JSON files for other formats.
|
||||
**Minimal Interface** - Only two custom widgets (Prefix, Format). No cluttered settings panels.
|
||||
- **Unified Workflow** - Eliminates the need for separate decode and save nodes.
|
||||
- **Universal Path Support** - Save images to any location on your system (internal subfolders or absolute external paths like `D:\Renders`).
|
||||
- **Smart Preview** - Automatically handles previews for both internal and external saves by utilizing a temporary cache with auto-cleanup.
|
||||
- **Batch Processing** - Supports batch latents with automatic file numbering (`_00001`, `_00002`...) to prevent overwrites.
|
||||
- **Format Support** - PNG, JPG, WEBP with metadata embedding capabilities.
|
||||
- **State Persistence** - Node settings are automatically saved and restored across workflow reloads and tab switches.
|
||||
|
||||
### 🪛 Usage
|
||||
| Input / Widget | Description |
|
||||
### 🎛️ Interface Widgets
|
||||
| Widget | Description |
|
||||
| :--- | :--- |
|
||||
| `samples` (LATENT) | Latent space data from KSampler or similar. |
|
||||
| `vae` (VAE) | VAE model used for decoding. |
|
||||
| **PREFIX** (Widget) | Filename prefix and optional subfolder path. Default: `ComfyUI`. Use `/` for subfolders (e.g., `batch1/test`). |
|
||||
| **FORMAT** (Widget) | Output format selector: PNG, JPG, or WEBP. |
|
||||
| `image` (Output) | Decoded IMAGE tensor. Connect to PreviewImage or further processing nodes. |
|
||||
| **PATH** | The destination directory for your images. <br>- Click the **📁** button to select from existing `output` subfolders.<br>- Select **"ComfyUI"** to save to the root output folder.<br>- Choose **"✏️ Custom path..."** to manually type a relative subfolder (e.g., `project_v2`) or an absolute path (e.g., `I:/Final_Renders`). |
|
||||
| **PREFIX** | The base name for your saved files. Files will be saved as `prefix_00001.png`, `prefix_00002.png`, etc. |
|
||||
| **FORMAT** | The output file format selector: **PNG**, **JPG**, or **WEBP**. |
|
||||
|
||||
## Output directory & write boundary
|
||||
|
||||
`RS_VAE_Decode_Save` supports two kinds of `save_path` values:
|
||||
|
||||
| Input in `save_path` | Behavior |
|
||||
| --------------------------------------------- | ------------------------------------------------------------ |
|
||||
| *(empty)* | Saves into ComfyUI's default `output/` directory. |
|
||||
| `project_v2` (relative) | Saves into `output/project_v2/`. The subfolder is created if missing. |
|
||||
| `/mnt/ssd/renders` or `I:/Renders` (absolute) | Saves to that exact path — **only if it is inside one of the allowed roots** (see below). |
|
||||
|
||||
### Write boundary
|
||||
|
||||
Every write is resolved through `os.path.realpath()` and verified with `os.path.commonpath()` against an explicit allowlist of roots. If the resolved path falls outside every allowed root, the node raises `PermissionError` and no file is written.
|
||||
|
||||
The default allowlist contains exactly one root:
|
||||
|
||||
- `folder_paths.get_output_directory()` (ComfyUI's `output/` folder).
|
||||
|
||||
This means: **out of the box, the node cannot write anywhere outside ComfyUI's `output/` directory.** Symlinks, `..` traversal, and absolute paths outside the allowlist are all rejected.
|
||||
|
||||
### Enabling additional output roots
|
||||
|
||||
To let the node write to user-chosen locations (e.g. a dedicated renders drive), add those roots to the allowlist at ComfyUI startup via the `RS_EXTRA_OUTPUT_ROOTS` environment variable. The value is a path-separator-delimited list (`:` on Linux/macOS, `;` on Windows), same convention as `PATH`.
|
||||
|
||||
**Linux / macOS:**
|
||||
|
||||
bash
|
||||
|
||||
```
|
||||
export RS_EXTRA_OUTPUT_ROOTS="/mnt/ssd/renders:/media/user/backup"
|
||||
python main.py
|
||||
```
|
||||
|
||||
|
||||
|
||||
**Windows (cmd):**
|
||||
|
||||
bat
|
||||
|
||||
```
|
||||
set RS_EXTRA_OUTPUT_ROOTS=I:\Renders;D:\Projects\ComfyOut
|
||||
python main.py
|
||||
```
|
||||
|
||||
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
powershell
|
||||
|
||||
```
|
||||
$env:RS_EXTRA_OUTPUT_ROOTS = "I:\Renders;D:\Projects\ComfyOut"
|
||||
python main.py
|
||||
```
|
||||
|
||||
|
||||
|
||||
**comfy-launch / docker-compose:**
|
||||
|
||||
yaml
|
||||
|
||||
```
|
||||
environment:
|
||||
- RS_EXTRA_OUTPUT_ROOTS=/mnt/ssd/renders:/media/user/backup
|
||||
```
|
||||
|
||||
|
||||
|
||||
After setting the variable, absolute paths inside those roots will be accepted by the node. Paths outside every root will still raise `PermissionError`.
|
||||
|
||||
### Programmatic configuration (optional)
|
||||
|
||||
If you prefer configuring the allowlist from Python instead of the environment, you can set it on `comfy.options` before the node is used:
|
||||
|
||||
python
|
||||
|
||||
```
|
||||
import comfy.options
|
||||
comfy.options.rs_extra_output_roots = [
|
||||
"/mnt/ssd/renders",
|
||||
"/media/user/backup",
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
|
||||
The environment variable and `comfy.options.rs_extra_output_roots` are both additive — the output directory is always included, and any roots from either source are merged into the allowlist.
|
||||
|
||||
### Notes
|
||||
|
||||
- Roots are resolved with `os.path.realpath()` before comparison. A symlinked path is treated as its target, so putting a symlink inside an allowed root that points outside the root will **not** grant access to the target.
|
||||
- Adding a root grants write access to **everything under it**, including subdirectories that don't exist yet — they will be created on demand.
|
||||
- Removing a root from `RS_EXTRA_OUTPUT_ROOTS` on the next launch immediately revokes write access to it. Existing files are not touched.
|
||||
- The `_sanitize_path_component()` helper inside the node is a cosmetic cleaner for relative subfolder names. It is **not** a security boundary and should not be relied on as one; the boundary is enforced by `_resolve_target_dir()`.
|
||||
|
||||
### Security rationale
|
||||
|
||||
The node intentionally supports user-chosen absolute output paths — that's the point of the `save_path` field and the folder picker in the UI. Rather than disabling that feature or relying on blacklist sanitization (which is fragile against encoding tricks, symlinks, and platform-specific quirks), the boundary is expressed as an explicit allowlist checked with `realpath()` + `commonpath()` at a single choke point (`_resolve_target_dir`). The default posture is safe; enabling broader write access is an explicit, auditable opt-in.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🦊 RS rgb2rgba</summary>
|
||||
|
||||
|
||||
# 🦊 RS rgb2rgba
|
||||
**A lightweight ComfyUI custom node that loads images while preserving the alpha channel (RGBA). Ideal for workflows that require transparency handling in PNG, WebP, and TIFF formats.**
|
||||
|
||||
@@ -393,12 +597,12 @@ Connect the `rgba` output to any node that accepts ComfyUI image tensors (e.g.,
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Any Switch</summary>
|
||||
|
||||
|
||||
# 🦊 RS Any Switch
|
||||
**A dynamic switch node for ComfyUI that allows you to switch between multiple inputs of ANY data type with an intuitive toggle interface.**
|
||||
|
||||
<img width="511" height="395" alt="Screenshot_1" src="https://github.com/user-attachments/assets/f5b4ee5c-d342-4346-b101-d364d2f714f4" />
|
||||
|
||||
|
||||
### 🔥 Features
|
||||
**Universal Type Support** - Accepts any data type (IMAGE, LATENT, MODEL, AUDIO, VIDEO, TEXT, etc.)
|
||||
**Dynamic Inputs** - Inputs are created automatically as you connect nodes (up to 20)
|
||||
@@ -462,48 +666,44 @@ Upscale Method 3 ──→ input_3
|
||||
RS Any Switch
|
||||
↓
|
||||
Final Output
|
||||
```
|
||||
```
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS LoRA Tester</summary>
|
||||
|
||||
|
||||
# 🦊 RS LoRA Tester
|
||||
**Node for batch-testing LoRA models. Load multiple LoRAs and automatically queue one generation session per LoRA — or test the same LoRA at different strengths.**
|
||||
**A ComfyUI custom node for **batch testing LoRAs** — queue one LoRA at a time instead of loading them all at once.
|
||||
Designed for people who test dozens (or hundreds) of LoRAs and want to generate a comparison batch without manually editing the node between each run.**
|
||||
|
||||
[](https://youtu.be/QW6NVT45O6Y)
|
||||
|
||||
<img width="671" height="575" alt="Screenshot_2" src="https://github.com/user-attachments/assets/5c10cfe4-de82-4f1d-b14b-1287f82f44a0" />
|
||||
<img width="673" height="629" alt="Screenshot_3" src="https://github.com/user-attachments/assets/d57309d5-ac00-4443-bfbf-b3815981cb5e" />
|
||||
<img width="680" height="544" alt="Screenshot_1" src="https://github.com/user-attachments/assets/3dd03903-3052-44f8-b5de-a8301de39cac" />
|
||||
|
||||
### 🔥 Features
|
||||
- **Batch Mode** — Select N LoRAs, press Queue once → N separate sessions are created in the ComfyUI queue, each with exactly one LoRA applied
|
||||
- **Strength Testing** — Add the same LoRA multiple times with different strength values to A/B test in a single batch
|
||||
- **Enabling/disabling LoRA** - the disabled LoRA is ignored by the workflow
|
||||
- **CLIP Toggle** — A dedicated, visually distinct button to enable/disable CLIP application per node. When the clip input is turned off, the node operates in the "model only" mode
|
||||
- **Custom Strength Editor** — Adjust LoRA strength using + / - buttons or direct numeric input
|
||||
- **Precision Strength Control** — Arrow buttons for ±0.05 adjustments, direct numeric input
|
||||
- **Visual Multi-LoRA Management** — Visual Multi-LoRA Management - Add, remove, and reorder multiple LoRAs with drag-and-drop support
|
||||
- **Searchable Tree Selector** — Browse your LoRA folder structure with real-time search
|
||||
- **Persistent State** — LoRA list is saved per-node and restored across sessions
|
||||
- **Quick update** - When adding a new LoRA to the 'loras' folder, you do not need to reload ComfyUI or the browser page. Just click the ✔️ Update LoRA list button
|
||||
- **Queue Tracking** — Batch button shows running state and auto-resets when the queue completes
|
||||
- **Automatic queue-based batch mode** — every enabled LoRA in the list is submitted as a *separate* prompt. The node always works in batch mode; there's no toggle to forget.
|
||||
- **Batch is opt-in by content** — if no LoRA is enabled, the node falls back to normal execution (`lora_data = "[]"`), so it stays out of the way when you're not testing.
|
||||
- **Tree selector with search** — click **➕ ADD LoRA** to fetch the current LoRA list from the server and open a folder tree with a live search box. Add multiple LoRAs in a row without reopening the popup.
|
||||
- **Per-row controls**
|
||||
- Toggle enabled / disabled (green dot)
|
||||
- Nudge strength by ±0.05 (arrow buttons)
|
||||
- Click the strength value to type an exact number (−10.0 … +10.0)
|
||||
- Drag the `⋮⋮` handle to reorder rows
|
||||
- ❌ to remove a single row
|
||||
- **🗑 CLEAR LoRA LIST** — wipes the entire list in one click (useful after a 200+ LoRA session).
|
||||
- **CLIP toggle** — apply LoRA to CLIP as well, or model-only.
|
||||
- **Persistent state** — the LoRA list is stored in the node's properties (saved with the workflow) *and* mirrored to `localStorage` for crash recovery.
|
||||
|
||||
**Adding and Managing LoRAs**
|
||||
Click ➕ Add LoRA to open the selector. Search or browse the tree, and click a LoRA to add it.
|
||||
Use the ⋮⋮ handle on the left of each row to drag and drop LoRAs into your desired order.
|
||||
Click ➕ Add LoRA to open the selector. Search or browse the tree, and click a LoRA to add it.
|
||||
Use the ⋮⋮ handle on the left of each row to drag and drop LoRAs into your desired order.
|
||||
Use the ❌ to remove a single LoRA
|
||||
|
||||
### 🪛 Usage
|
||||
- **Click the ✅ CLIP ON / ❌ CLIP OFF button to toggle whether LoRAs are applied to the CLIP model. The clip input slot is visually disabled when CLIP is off.**
|
||||
**Testing different loras**
|
||||
- Click the ✅ CLIP ON / ❌ CLIP OFF button to toggle whether LoRAs are applied to the CLIP model. The clip input slot is visually disabled when CLIP is off
|
||||
- Click **➕ ADD LoRA** and select LoRAs from the tree selector
|
||||
- Press ComfyUI's **Queue Prompt** → one session per LoRA is created
|
||||
|
||||
**Basic Batch**
|
||||
1. Add **🦊 RS LoRA Batch** node to your workflow
|
||||
2. Click **✔️ UPDATE LoRA LIST** to load available LoRAs
|
||||
3. Click **➕ ADD LoRA** and select LoRAs from the tree selector
|
||||
4. Press **⚙️ LoRA Batch** → button turns green showing count
|
||||
5. Press ComfyUI's **Queue Prompt** → one session per LoRA is created
|
||||
|
||||
**Strength Testing**
|
||||
**Strength Testing of a single LoRA**
|
||||
1. Add the same LoRA multiple times (duplicates allowed)
|
||||
2. Set different strength values using arrows (±0.05) or click the value to type manually
|
||||
3. Arm batch mode and queue — each strength gets its own session
|
||||
@@ -511,7 +711,7 @@ Use the ⋮⋮ handle on the left of each row to drag and drop LoRAs into your d
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Save Image LoRA</summary>
|
||||
|
||||
|
||||
# 🦊 RS Save Image LoRA
|
||||
**A drop-in replacement for `SaveImage` that automatically puts the names and strengths of all LoRAs used in the generation right into the output file name.**
|
||||
No more guessing which LoRA produced which image. Your files name themselves.
|
||||
@@ -542,7 +742,7 @@ No more guessing which LoRA produced which image. Your files name themselves.
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Upscaler</summary>
|
||||
|
||||
|
||||
# 🦊 RS Upscaler
|
||||
**A node for that combines upscale model loading and image upscaling into a single, compact node with a custom UI.**
|
||||
|
||||
@@ -563,7 +763,7 @@ Tested with ComfyUI core upscaler models including:
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Upscale & Resize</summary>
|
||||
|
||||
|
||||
# 🦊 RS Upscale & Resize
|
||||
**Hybrid upscaler and resizer. Uses 'scale_by' by default. When width/height inputs are connected or set manually (>0), it switches to exact external dimensions.**
|
||||
|
||||
@@ -593,7 +793,7 @@ Set method to keep proportion or pad to prevent distortion when forcing specific
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Tile Adjustments</summary>
|
||||
|
||||
|
||||
# 🦊 RS Tile Adjustments
|
||||
**A powerful node that calculates optimal tile dimensions based on image factors and overlap rates, then splits the input image into a batch of tiles with precise coordinate metadata.**
|
||||
|
||||
@@ -613,7 +813,7 @@ Use the IMAGES output for processing (upscaling/inpainting) and pass the metadat
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Tile Assemble</summary>
|
||||
|
||||
|
||||
# 🦊 RS Tile Assemble
|
||||
**The companion node to RS Tile Adjustments. It reconstructs a full-resolution image from a batch of processed tiles using calculated positions and advanced feathered blending to eliminate visible seams.**
|
||||
|
||||
@@ -637,7 +837,7 @@ The FINAL_IMAGE output is ready for saving or further processing
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Ref Encode</summary>
|
||||
|
||||
|
||||
# 🦊 RS Ref Encode
|
||||
**A node that combines VAE Encode and Set Reference Latent into a single efficient operation.**
|
||||
|
||||
@@ -717,7 +917,7 @@ The node pauses the queue until you confirm the settings, then outputs a ready-t
|
||||
**Example Workflow**:
|
||||
```
|
||||
Load Image → RS Outpaint → [Your Model] → Save Image
|
||||
```
|
||||
```
|
||||
|
||||
### Mode 2: Single Image Batch
|
||||
|
||||
@@ -738,7 +938,7 @@ Load Image → RS Outpaint → [Your Model] → Save Image
|
||||
**Example Workflow**:
|
||||
```
|
||||
Load Image → RS Outpaint → [Multiple Model Variations] → Save Image (×N)
|
||||
```
|
||||
```
|
||||
|
||||
### Mode 3: Multi-Image Batch
|
||||
|
||||
@@ -1707,7 +1907,7 @@ Toggle **ENABLE SHADOW** to activate.
|
||||
</details>
|
||||
<details>
|
||||
<summary>🦊 RS Image Adjustments</summary>
|
||||
|
||||
|
||||
# 🦊 RS Image Adjustments
|
||||
**A powerful interactive image adjustment node with real-time preview and professional-grade controls.**
|
||||
Inspired by industry standards like Adobe Lightroom and Photoshop, it offers real-time previews, advanced color grading, and an efficient batch processing mode.
|
||||
@@ -1884,7 +2084,10 @@ The node generates a list of INT values, automatically switching between 10 pres
|
||||
# 🦊 ComfyUI Settings Manager
|
||||
**A user-friendly extension that allows you to easily backup, restore, and manage your interface settings. Never lose your custom UI layout, preferences, or configurations again!**
|
||||
|
||||
<img width="580" height="783" alt="Screenshot_3" src="https://github.com/user-attachments/assets/2db2e5c7-055b-40c0-805d-167dde6d47e2" />
|
||||
<img width="217" height="937" alt="Screenshot_1" src="https://github.com/user-attachments/assets/a898ce80-dd21-4245-9228-d8b78ede8cf4" />
|
||||
<img width="207" height="136" alt="Screenshot_4" src="https://github.com/user-attachments/assets/13ad352f-295e-4e5e-9fdb-2b55d7f80c37" />
|
||||
<img width="580" height="783" alt="Screenshot_3" src="https://github.com/user-attachments/assets/f139e9c5-64d5-44d2-9790-bb266f8bd078" />
|
||||
<img width="438" height="54" alt="Screenshot_5" src="https://github.com/user-attachments/assets/ccc1a109-e7bf-4ad1-a537-ffe6b0c3cb33" />
|
||||
|
||||
### 🔥 Features
|
||||
- **Smart Backups** - Save your current interface settings with custom names. If no name is provided, it automatically uses a timestamp.
|
||||
@@ -1899,7 +2102,10 @@ The node generates a list of INT values, automatically switching between 10 pres
|
||||
|
||||
### 🪛 Usage
|
||||
**Accessing the Manager**
|
||||
Look for the **Desktop/Monitor icon** (🖥️) in the left sidebar of ComfyUI. Click it to open the **Settings Manager** panel.
|
||||
Open the **Settings** (gear icon on the sidebar)
|
||||
Open the menu item **RaykoStudio**
|
||||
You can enable/disable the display of the extension icon in the ComfyUI top menu [🖥️]
|
||||
Click **Open Setting Manager**
|
||||
|
||||
**Saving Settings (Backup)**
|
||||
1. In the **Save Interface Settings** section, you will see a text field.
|
||||
@@ -1952,7 +2158,23 @@ If new problem, create new Issue describing:
|
||||
|
||||
- ComfyUI and Python versions
|
||||
- Problem description and reproduction steps
|
||||
- Screenshots or error logs (if available)
|
||||
- Screenshots or error logs (if available)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
This pack includes a documented security policy. It covers:
|
||||
|
||||
- Network operations (Civitai API requests for LoRA metadata).
|
||||
- File and path handling (allowed output roots).
|
||||
- Environment variables used by the pack.
|
||||
- Why automated scanners may report `Flagged`, and what it means.
|
||||
|
||||
Full details: [SECURITY.md](SECURITY.md)
|
||||
|
||||
If you discover a potential security issue, please open an issue on GitHub
|
||||
or contact the author directly. We aim to respond within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ class RS_Bypass:
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Bypass panel for nodes and groups. Synced with ComfyUI bypass in both directions; state saved with the workflow."
|
||||
|
||||
def execute(self, bypass_state="{}", **kwargs):
|
||||
return {}
|
||||
@@ -41,4 +42,4 @@ NODE_CLASS_MAPPINGS = {
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"RS_Bypass": "🦊 RS Bypass Manager"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +1,126 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, PngImagePlugin
|
||||
import folder_paths
|
||||
|
||||
def get_image_files():
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = []
|
||||
for f in os.listdir(input_dir):
|
||||
if os.path.isfile(os.path.join(input_dir, f)) and f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.bmp')):
|
||||
files.append(f)
|
||||
files.sort()
|
||||
return files
|
||||
|
||||
class LoadImageWithText:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
files = get_image_files()
|
||||
return {
|
||||
"required": {
|
||||
"mode": (["read", "write"], {"default": "read"}),
|
||||
"filename_prefix": ("STRING", {"default": "Civitai/prompt", "tooltip": "Prefix to save to output (write only)"}),
|
||||
"text_input": ("STRING", {"default": "", "multiline": True, "tooltip": "Text to write (write only)"}),
|
||||
"image": (files, {"image_upload": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("image", "text")
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Node embeds any hidden text into the image that can be used later"
|
||||
|
||||
def process(self, mode, image, text_input="", filename_prefix="ComfyUI"):
|
||||
try:
|
||||
input_path = folder_paths.get_annotated_filepath(image)
|
||||
if not os.path.exists(input_path):
|
||||
raise FileNotFoundError(f"File not found: {input_path}")
|
||||
|
||||
if mode == "write":
|
||||
return self.write_mode(input_path, text_input, filename_prefix)
|
||||
else:
|
||||
return self.read_mode(input_path)
|
||||
except Exception as e:
|
||||
print(f"Error in process: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
dummy_img = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
return (dummy_img, "")
|
||||
|
||||
def read_mode(self, input_path):
|
||||
pil_img = Image.open(input_path)
|
||||
text = pil_img.info.get("Description", "")
|
||||
image_tensor = self.pil_to_tensor(pil_img)
|
||||
print(f"[READ] Text: '{text}'")
|
||||
return (image_tensor, text)
|
||||
|
||||
def write_mode(self, input_path, text, filename_prefix):
|
||||
pil_img = Image.open(input_path)
|
||||
image_tensor = self.pil_to_tensor(pil_img)
|
||||
|
||||
pnginfo = PngImagePlugin.PngInfo()
|
||||
if text:
|
||||
pnginfo.add_text("Description", text)
|
||||
print(f"[WRITE] Text is recorded: '{text}'")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
|
||||
counter = 1
|
||||
while True:
|
||||
output_filename = f"{filename_prefix}_{counter:05}.png"
|
||||
output_path = os.path.join(output_dir, output_filename)
|
||||
|
||||
if not os.path.exists(output_path):
|
||||
break
|
||||
counter += 1
|
||||
|
||||
print(f"[WRITE] Saved in: {output_path}")
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
if pil_img.mode not in ('RGB', 'RGBA'):
|
||||
pil_img = pil_img.convert('RGB')
|
||||
|
||||
pil_img.save(output_path, format="PNG", pnginfo=pnginfo)
|
||||
print(f"[WRITE] File saved: {os.path.exists(output_path)}")
|
||||
|
||||
try:
|
||||
check_img = Image.open(output_path)
|
||||
saved_text = check_img.info.get("Description", "")
|
||||
print(f"[CHECK] Text in the saved file: '{saved_text}'")
|
||||
except Exception as e:
|
||||
print(f"[CHECK] Verification error: {e}")
|
||||
|
||||
return (image_tensor, text)
|
||||
|
||||
def pil_to_tensor(self, pil_img):
|
||||
if pil_img.mode == "RGBA":
|
||||
img_np = np.array(pil_img).astype(np.float32) / 255.0
|
||||
else:
|
||||
img_np = np.array(pil_img.convert("RGB")).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(img_np)[None, ...]
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"LoadImageWithText": LoadImageWithText,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"LoadImageWithText": "🦊 RS Image-Text",
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, PngImagePlugin
|
||||
import folder_paths
|
||||
|
||||
def get_image_files():
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = []
|
||||
for f in os.listdir(input_dir):
|
||||
if os.path.isfile(os.path.join(input_dir, f)) and f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.bmp')):
|
||||
files.append(f)
|
||||
files.sort()
|
||||
return files
|
||||
|
||||
class LoadImageWithText:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
files = get_image_files()
|
||||
return {
|
||||
"required": {
|
||||
"mode": (["read", "write"], {"default": "read"}),
|
||||
"filename_prefix": ("STRING", {"default": "Civitai/prompt", "tooltip": "Prefix to save to output (write only)"}),
|
||||
"text_input": ("STRING", {"default": "", "multiline": True, "tooltip": "Text to write (write only)"}),
|
||||
"image": (files, {"image_upload": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("image", "text")
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Node embeds any hidden text into the image that can be used later"
|
||||
|
||||
def process(self, mode, image, text_input="", filename_prefix="ComfyUI"):
|
||||
try:
|
||||
input_path = folder_paths.get_annotated_filepath(image)
|
||||
if not os.path.exists(input_path):
|
||||
raise FileNotFoundError(f"File not found: {input_path}")
|
||||
|
||||
if mode == "write":
|
||||
return self.write_mode(input_path, text_input, filename_prefix)
|
||||
else:
|
||||
return self.read_mode(input_path)
|
||||
except Exception as e:
|
||||
print(f"Error in process: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
dummy_img = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
return (dummy_img, "")
|
||||
|
||||
def read_mode(self, input_path):
|
||||
pil_img = Image.open(input_path)
|
||||
text = pil_img.info.get("Description", "")
|
||||
image_tensor = self.pil_to_tensor(pil_img)
|
||||
print(f"[READ] Text: '{text}'")
|
||||
return (image_tensor, text)
|
||||
|
||||
def write_mode(self, input_path, text, filename_prefix):
|
||||
pil_img = Image.open(input_path)
|
||||
image_tensor = self.pil_to_tensor(pil_img)
|
||||
|
||||
pnginfo = PngImagePlugin.PngInfo()
|
||||
if text:
|
||||
pnginfo.add_text("Description", text)
|
||||
print(f"[WRITE] Text is recorded: '{text}'")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
|
||||
counter = 1
|
||||
while True:
|
||||
output_filename = f"{filename_prefix}_{counter:05}.png"
|
||||
output_path = os.path.join(output_dir, output_filename)
|
||||
|
||||
if not os.path.exists(output_path):
|
||||
break
|
||||
counter += 1
|
||||
|
||||
print(f"[WRITE] Saved in: {output_path}")
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
if pil_img.mode not in ('RGB', 'RGBA'):
|
||||
pil_img = pil_img.convert('RGB')
|
||||
|
||||
pil_img.save(output_path, format="PNG", pnginfo=pnginfo)
|
||||
print(f"[WRITE] File saved: {os.path.exists(output_path)}")
|
||||
|
||||
try:
|
||||
check_img = Image.open(output_path)
|
||||
saved_text = check_img.info.get("Description", "")
|
||||
print(f"[CHECK] Text in the saved file: '{saved_text}'")
|
||||
except Exception as e:
|
||||
print(f"[CHECK] Verification error: {e}")
|
||||
|
||||
return (image_tensor, text)
|
||||
|
||||
def pil_to_tensor(self, pil_img):
|
||||
if pil_img.mode == "RGBA":
|
||||
img_np = np.array(pil_img).astype(np.float32) / 255.0
|
||||
else:
|
||||
img_np = np.array(pil_img.convert("RGB")).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(img_np)[None, ...]
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"LoadImageWithText": LoadImageWithText,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"LoadImageWithText": "🦊 RS Image-Text",
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
class RSLabel:
|
||||
NAME = "🦊 RS Label"
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
FUNCTION = "noop"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
|
||||
def noop(self):
|
||||
return ()
|
||||
|
||||
|
||||
def _get_font_list() -> list:
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fonts_dir = os.path.join(current_dir, "fonts")
|
||||
font_list = []
|
||||
|
||||
if os.path.isdir(fonts_dir):
|
||||
for f in sorted(os.listdir(fonts_dir)):
|
||||
if f.lower().endswith(('.ttf', '.otf')):
|
||||
font_list.append(f)
|
||||
|
||||
return [f for f in font_list if os.path.basename(f) == f]
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/rayko/rs_label/get_fonts")
|
||||
async def get_fonts_handler(request):
|
||||
try:
|
||||
font_list = _get_font_list()
|
||||
return web.json_response({"font_list": font_list})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/rayko/rs_label/font/{filename}")
|
||||
async def serve_font_handler(request):
|
||||
filename = request.match_info['filename']
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fonts_dir = os.path.join(current_dir, "fonts")
|
||||
filepath = os.path.join(fonts_dir, filename)
|
||||
|
||||
if not os.path.isfile(filepath) or os.path.commonpath([filepath, fonts_dir]) != fonts_dir:
|
||||
raise web.HTTPNotFound()
|
||||
|
||||
return web.FileResponse(filepath)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"RSLabel": RSLabel}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"RSLabel": "🦊 RS Label"}
|
||||
@@ -0,0 +1,82 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
import server
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
class RSImageLabel:
|
||||
NAME = "🦊 RS Image Label"
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
FUNCTION = "noop"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Transparent floating image label with drag-and-drop support, synced border rounding, and real-time customization."
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
"hidden": {"_image_path": ("STRING", {"default": ""})}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_IS_LIST = (False,)
|
||||
|
||||
def noop(self, _image_path=""):
|
||||
return {}
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/rayko/rs_image_label/get_image")
|
||||
async def get_image(request):
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
subfolder = request.rel_url.query.get("subfolder", "")
|
||||
folder_type = request.rel_url.query.get("type", "temp")
|
||||
|
||||
if not filename:
|
||||
return web.Response(status=400, text="No filename provided")
|
||||
|
||||
try:
|
||||
base_dir = folder_paths.get_temp_directory() if folder_type == "temp" else folder_paths.get_output_directory()
|
||||
|
||||
filepath = os.path.join(base_dir, subfolder, filename) if subfolder else os.path.join(base_dir, filename)
|
||||
filepath = os.path.abspath(filepath)
|
||||
|
||||
if not filepath.startswith(os.path.abspath(base_dir)):
|
||||
return web.Response(status=403, text="Access denied")
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
return web.Response(status=404, text="File not found")
|
||||
|
||||
with open(filepath, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp'
|
||||
}
|
||||
ct = content_types.get(ext, 'application/octet-stream')
|
||||
|
||||
return web.Response(body=data, content_type=ct)
|
||||
|
||||
except Exception as e:
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"RSImageLabel": RSImageLabel}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"RSImageLabel": "🦊 RS Label Image"}
|
||||
+129
-75
@@ -19,8 +19,12 @@ import os
|
||||
import struct
|
||||
import hashlib
|
||||
import re
|
||||
import asyncio
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from nodes import LoraLoader
|
||||
from server import PromptServer
|
||||
from datetime import datetime
|
||||
import aiohttp
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -32,6 +36,7 @@ os.makedirs(PRESETS_DIR, exist_ok=True)
|
||||
os.makedirs(LORA_PRESETS_DIR, exist_ok=True)
|
||||
os.makedirs(RAYKO_LORA_DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def clean_html(text):
|
||||
if not text:
|
||||
return ""
|
||||
@@ -39,6 +44,41 @@ def clean_html(text):
|
||||
clean = re.sub(r'\s+', ' ', clean).strip()
|
||||
return clean
|
||||
|
||||
|
||||
def sanitize_preset_name(name):
|
||||
if not isinstance(name, str):
|
||||
return ""
|
||||
return "".join(c for c in name.strip() if c.isalnum() or c in " _-").strip()
|
||||
|
||||
|
||||
def _safe_preset_path(name):
|
||||
safe_name = sanitize_preset_name(name)
|
||||
if not safe_name:
|
||||
return None
|
||||
base = os.path.realpath(LORA_PRESETS_DIR)
|
||||
candidate = os.path.realpath(os.path.join(base, f"{safe_name}.json"))
|
||||
try:
|
||||
if os.path.commonpath([base, candidate]) != base:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _http_get_json(url, headers=None, timeout=30):
|
||||
req = urllib.request.Request(url, headers=headers or {})
|
||||
opener = urllib.request.build_opener()
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return resp.status, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return resp.status, None
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, None
|
||||
|
||||
|
||||
def extract_metadata_from_safetensors(lora_full_path):
|
||||
try:
|
||||
with open(lora_full_path, "rb") as f:
|
||||
@@ -56,6 +96,7 @@ def extract_metadata_from_safetensors(lora_full_path):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def compute_sha256(file_path):
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
@@ -63,6 +104,7 @@ def compute_sha256(file_path):
|
||||
sha256_hash.update(chunk)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def save_to_rayko_db(file_hash, data, source, overwrite=False):
|
||||
db_path = os.path.join(RAYKO_LORA_DATA_DIR, f"{file_hash}.json")
|
||||
if not overwrite and os.path.exists(db_path):
|
||||
@@ -73,12 +115,13 @@ def save_to_rayko_db(file_hash, data, source, overwrite=False):
|
||||
"trained_words": data.get("trained_words", []),
|
||||
"description": data.get("description", ""),
|
||||
"source": source,
|
||||
"imported_at": __import__('datetime').datetime.now().isoformat()
|
||||
"imported_at": datetime.now().isoformat()
|
||||
}
|
||||
with open(db_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(db_data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
|
||||
|
||||
class RaykoLoRALoader:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -141,27 +184,30 @@ class RaykoLoRALoader:
|
||||
else:
|
||||
return (model, None)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"RaykoLoRALoader": RaykoLoRALoader}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"RaykoLoRALoader": "🦊 RS LoRA Loader"}
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/rayko_lora_loader/get_loras")
|
||||
async def get_loras(request):
|
||||
return aiohttp.web.json_response(sorted(folder_paths.get_filename_list("loras"), key=lambda x: x.lower()))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko_lora_loader/save_preset")
|
||||
async def rayko_lora_loader_save_preset(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
name = "".join(c for c in data.get("name", "").strip() if c.isalnum() or c in " _-").strip()
|
||||
if not name:
|
||||
filepath = _safe_preset_path(data.get("name", ""))
|
||||
if not filepath:
|
||||
return aiohttp.web.Response(status=400, text="Invalid name")
|
||||
filepath = os.path.join(LORA_PRESETS_DIR, f"{name}.json")
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump({"lora_rows": data.get("lora_rows", [])}, f, indent=2)
|
||||
return aiohttp.web.Response(status=200, text="OK")
|
||||
except Exception as e:
|
||||
return aiohttp.web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko_lora_loader/list_presets")
|
||||
async def rayko_lora_loader_list_presets(request):
|
||||
try:
|
||||
@@ -170,11 +216,14 @@ async def rayko_lora_loader_list_presets(request):
|
||||
except Exception as e:
|
||||
return aiohttp.web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko_lora_loader/load_preset")
|
||||
async def rayko_lora_loader_load_preset(request):
|
||||
try:
|
||||
name = (await request.json()).get("name")
|
||||
filepath = os.path.join(LORA_PRESETS_DIR, f"{name}.json")
|
||||
data = await request.json()
|
||||
filepath = _safe_preset_path(data.get("name", ""))
|
||||
if not filepath:
|
||||
return aiohttp.web.Response(status=400, text="Invalid name")
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return aiohttp.web.json_response(json.load(f))
|
||||
@@ -182,11 +231,14 @@ async def rayko_lora_loader_load_preset(request):
|
||||
except Exception as e:
|
||||
return aiohttp.web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko_lora_loader/delete_preset")
|
||||
async def rayko_lora_loader_delete_preset(request):
|
||||
try:
|
||||
name = (await request.json()).get("name")
|
||||
filepath = os.path.join(LORA_PRESETS_DIR, f"{name}.json")
|
||||
data = await request.json()
|
||||
filepath = _safe_preset_path(data.get("name", ""))
|
||||
if not filepath:
|
||||
return aiohttp.web.Response(status=400, text="Invalid name")
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return aiohttp.web.Response(status=200, text="OK")
|
||||
@@ -194,6 +246,7 @@ async def rayko_lora_loader_delete_preset(request):
|
||||
except Exception as e:
|
||||
return aiohttp.web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko_lora_loader/get_lora_info")
|
||||
async def rayko_lora_loader_get_lora_info(request):
|
||||
try:
|
||||
@@ -201,15 +254,15 @@ async def rayko_lora_loader_get_lora_info(request):
|
||||
lora_name = data.get("name", "")
|
||||
if not lora_name:
|
||||
return aiohttp.web.Response(status=400, text="No name provided")
|
||||
|
||||
|
||||
lora_relative_path = lora_name.replace("\\", "/")
|
||||
lora_full_path = folder_paths.get_full_path("loras", lora_relative_path)
|
||||
|
||||
|
||||
if not lora_full_path or not os.path.exists(lora_full_path):
|
||||
return aiohttp.web.json_response({"error": "File not found"})
|
||||
|
||||
|
||||
file_hash = compute_sha256(lora_full_path)
|
||||
|
||||
|
||||
db_path = os.path.join(RAYKO_LORA_DATA_DIR, f"{file_hash}.json")
|
||||
if os.path.exists(db_path):
|
||||
try:
|
||||
@@ -223,7 +276,7 @@ async def rayko_lora_loader_get_lora_info(request):
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
safetensors_meta = extract_metadata_from_safetensors(lora_full_path)
|
||||
if safetensors_meta and (safetensors_meta["trained_words"] or safetensors_meta["description"]):
|
||||
save_to_rayko_db(file_hash, safetensors_meta, "safetensors")
|
||||
@@ -233,17 +286,18 @@ async def rayko_lora_loader_get_lora_info(request):
|
||||
"description": safetensors_meta["description"],
|
||||
"source": "safetensors"
|
||||
})
|
||||
|
||||
|
||||
return aiohttp.web.json_response({
|
||||
"full_name": lora_name,
|
||||
"trained_words": [],
|
||||
"description": "",
|
||||
"source": "none"
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return aiohttp.web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko_lora_loader/fetch_civitai_info")
|
||||
async def rayko_lora_loader_fetch_civitai_info(request):
|
||||
try:
|
||||
@@ -251,56 +305,54 @@ async def rayko_lora_loader_fetch_civitai_info(request):
|
||||
lora_name = data.get("name", "")
|
||||
if not lora_name:
|
||||
return aiohttp.web.Response(status=400, text="No name provided")
|
||||
|
||||
|
||||
lora_relative_path = lora_name.replace("\\", "/")
|
||||
lora_full_path = folder_paths.get_full_path("loras", lora_relative_path)
|
||||
|
||||
|
||||
if not lora_full_path or not os.path.exists(lora_full_path):
|
||||
return aiohttp.web.json_response({"error": "File not found"})
|
||||
|
||||
|
||||
file_hash = compute_sha256(lora_full_path)
|
||||
civitai_url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(civitai_url, headers={"User-Agent": "ComfyUI-RaykoStudio/1.0"}) as response:
|
||||
if response.status == 404:
|
||||
return aiohttp.web.json_response({"error": "not_found", "message": "Not found on Civitai."})
|
||||
elif response.status == 429:
|
||||
return aiohttp.web.json_response({"error": "rate_limit", "message": "Rate limit reached."})
|
||||
elif response.status != 200:
|
||||
return aiohttp.web.json_response({"error": "api_error", "message": f"API status {response.status}"})
|
||||
|
||||
civitai_data = await response.json()
|
||||
|
||||
model_id = civitai_data.get("modelId")
|
||||
model_tags = []
|
||||
|
||||
if model_id:
|
||||
try:
|
||||
model_url = f"https://civitai.com/api/v1/models/{model_id}"
|
||||
async with session.get(model_url, headers={"User-Agent": "ComfyUI-RaykoStudio/1.0"}) as model_response:
|
||||
if model_response.status == 200:
|
||||
model_data = await model_response.json()
|
||||
model_tags = model_data.get("tags", [])
|
||||
except Exception as e:
|
||||
print(f"[Rayko] Error fetching full model details: {e}")
|
||||
|
||||
headers = {"User-Agent": "ComfyUI-RaykoStudio/1.0"}
|
||||
|
||||
status, civitai_data = await asyncio.to_thread(_http_get_json, civitai_url, headers)
|
||||
|
||||
if status == 404:
|
||||
return aiohttp.web.json_response({"error": "not_found", "message": "Not found on Civitai."})
|
||||
elif status == 429:
|
||||
return aiohttp.web.json_response({"error": "rate_limit", "message": "Rate limit reached."})
|
||||
elif status != 200 or not civitai_data:
|
||||
return aiohttp.web.json_response({"error": "api_error", "message": f"API status {status}"})
|
||||
|
||||
model_id = civitai_data.get("modelId")
|
||||
model_tags = []
|
||||
|
||||
if model_id:
|
||||
try:
|
||||
model_url = f"https://civitai.com/api/v1/models/{model_id}"
|
||||
m_status, model_data = await asyncio.to_thread(_http_get_json, model_url, headers)
|
||||
if m_status == 200 and isinstance(model_data, dict):
|
||||
model_tags = model_data.get("tags", [])
|
||||
except Exception as e:
|
||||
print(f"[Rayko] Error fetching full model details: {e}")
|
||||
|
||||
raw_trained = civitai_data.get("trainedWords", [])
|
||||
|
||||
|
||||
trained_words = []
|
||||
if isinstance(raw_trained, list):
|
||||
trained_words.extend([str(t).strip() for t in raw_trained if str(t).strip()])
|
||||
if isinstance(model_tags, list):
|
||||
trained_words.extend([str(t).strip() for t in model_tags if str(t).strip()])
|
||||
|
||||
|
||||
trained_words = list(dict.fromkeys(trained_words))
|
||||
|
||||
|
||||
raw_description = civitai_data.get("description", "")
|
||||
clean_description = clean_html(raw_description)
|
||||
|
||||
|
||||
model_name = civitai_data.get("model", {}).get("name", "")
|
||||
version_name = civitai_data.get("name", "")
|
||||
|
||||
|
||||
if model_name:
|
||||
if version_name and version_name.lower() not in ["v1", "v1.0", "default", "latest", ""]:
|
||||
full_name = f"{model_name} ({version_name})"
|
||||
@@ -308,48 +360,49 @@ async def rayko_lora_loader_fetch_civitai_info(request):
|
||||
full_name = model_name
|
||||
else:
|
||||
full_name = lora_name
|
||||
|
||||
|
||||
cache_data = {
|
||||
"full_name": full_name,
|
||||
"trained_words": trained_words,
|
||||
"description": clean_description
|
||||
}
|
||||
|
||||
|
||||
save_to_rayko_db(file_hash, cache_data, "civitai", overwrite=True)
|
||||
|
||||
|
||||
return aiohttp.web.json_response({
|
||||
"full_name": full_name,
|
||||
"trained_words": trained_words,
|
||||
"description": clean_description,
|
||||
"source": "civitai"
|
||||
})
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
return aiohttp.web.json_response({"error": "network", "message": f"Network error: {str(e)}"})
|
||||
except Exception as e:
|
||||
return aiohttp.web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko_lora_loader/add_tags")
|
||||
async def rayko_add_tags(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
lora_name = data.get("name", "")
|
||||
new_tags = data.get("tags", [])
|
||||
|
||||
|
||||
if not lora_name:
|
||||
return aiohttp.web.Response(status=400, text="No name provided")
|
||||
if not isinstance(new_tags, list) or not new_tags:
|
||||
return aiohttp.web.json_response({"error": "No tags provided"})
|
||||
|
||||
|
||||
lora_relative_path = lora_name.replace("\\", "/")
|
||||
lora_full_path = folder_paths.get_full_path("loras", lora_relative_path)
|
||||
|
||||
|
||||
if not lora_full_path or not os.path.exists(lora_full_path):
|
||||
return aiohttp.web.json_response({"error": "File not found"})
|
||||
|
||||
|
||||
file_hash = compute_sha256(lora_full_path)
|
||||
db_path = os.path.join(RAYKO_LORA_DATA_DIR, f"{file_hash}.json")
|
||||
|
||||
|
||||
existing_data = {}
|
||||
if os.path.exists(db_path):
|
||||
try:
|
||||
@@ -357,74 +410,75 @@ async def rayko_add_tags(request):
|
||||
existing_data = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
existing_tags = existing_data.get("trained_words", [])
|
||||
if not isinstance(existing_tags, list):
|
||||
existing_tags = []
|
||||
|
||||
|
||||
combined_tags = existing_tags.copy()
|
||||
for tag in new_tags:
|
||||
tag = str(tag).strip()
|
||||
if tag and tag not in combined_tags:
|
||||
combined_tags.append(tag)
|
||||
|
||||
|
||||
save_data = {
|
||||
"full_name": existing_data.get("name", ""),
|
||||
"trained_words": combined_tags,
|
||||
"description": existing_data.get("description", "")
|
||||
}
|
||||
|
||||
|
||||
save_to_rayko_db(file_hash, save_data, "manual", overwrite=True)
|
||||
|
||||
|
||||
return aiohttp.web.json_response({
|
||||
"trained_words": combined_tags,
|
||||
"added": len(combined_tags) - len(existing_tags)
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return aiohttp.web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/rayko_lora_loader/remove_tag")
|
||||
async def rayko_remove_tag(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
lora_name = data.get("name", "")
|
||||
tag_to_remove = data.get("tag", "")
|
||||
|
||||
|
||||
if not lora_name or not tag_to_remove:
|
||||
return aiohttp.web.Response(status=400, text="Missing parameters")
|
||||
|
||||
|
||||
lora_relative_path = lora_name.replace("\\", "/")
|
||||
lora_full_path = folder_paths.get_full_path("loras", lora_relative_path)
|
||||
|
||||
|
||||
if not lora_full_path or not os.path.exists(lora_full_path):
|
||||
return aiohttp.web.json_response({"error": "File not found"})
|
||||
|
||||
|
||||
file_hash = compute_sha256(lora_full_path)
|
||||
db_path = os.path.join(RAYKO_LORA_DATA_DIR, f"{file_hash}.json")
|
||||
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
return aiohttp.web.json_response({"error": "No data found"})
|
||||
|
||||
|
||||
with open(db_path, 'r', encoding='utf-8') as f:
|
||||
existing_data = json.load(f)
|
||||
|
||||
|
||||
existing_tags = existing_data.get("trained_words", [])
|
||||
if tag_to_remove in existing_tags:
|
||||
existing_tags.remove(tag_to_remove)
|
||||
|
||||
|
||||
save_data = {
|
||||
"full_name": existing_data.get("name", ""),
|
||||
"trained_words": existing_tags,
|
||||
"description": existing_data.get("description", "")
|
||||
}
|
||||
|
||||
|
||||
save_to_rayko_db(file_hash, save_data, existing_data.get("source", "manual"), overwrite=True)
|
||||
|
||||
|
||||
return aiohttp.web.json_response({
|
||||
"trained_words": existing_tags,
|
||||
"removed": tag_to_remove
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return aiohttp.web.Response(status=500, text=str(e))
|
||||
@@ -30,7 +30,7 @@ class RaykoModelsLoader:
|
||||
vae_files_with_pixel = vae_files + ["pixel_space"]
|
||||
weight_dtype_opts = ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"]
|
||||
clip_type_opts = [
|
||||
"stable_diffusion", "stable_cascade", "sd3", "flux", "flux2", "lumina2", "ideogram4", "qwen_image", "boogu", "krea2", "ltxv", "wan", "minimax", "joyimage", "mage", "ace", "hunyuan_image", "hidream", "chroma", "mochi", "cosmos", "pixart", "kolors", "ultrapix", "omnigen2", "ovis", "longcat_image", "cogvideox", "lens", "pixeldit",
|
||||
"stable_diffusion", "stable_cascade", "stable_audio", "sd3", "flux", "flux2", "flux3", "lumina2", "ideogram4", "qwen_image", "boogu", "krea2", "moss_image", "ltxv", "wan", "minimax", "yue2", "joyimage", "mage", "ace", "hunyuan_image", "hidream", "chroma", "mochi", "cosmos", "pixart", "kolors", "ultrapix", "omnigen2", "ovis", "longcat_image", "cogvideox", "lens", "pixeldit",
|
||||
]
|
||||
device_opts = ["default", "cpu", "cuda"]
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class RaykoModelsLoaderPro:
|
||||
|
||||
weight_dtype_opts = ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"]
|
||||
clip_type_opts = [
|
||||
"stable_diffusion", "stable_cascade", "sd3", "flux", "flux2", "lumina2", "ideogram4", "qwen_image", "boogu", "krea2", "ltxv", "wan", "minimax", "joyimage", "mage", "ace", "hunyuan_image", "hidream", "chroma", "mochi", "cosmos", "pixart", "kolors", "ultrapix", "omnigen2", "ovis", "longcat_image", "cogvideox", "lens", "pixeldit",
|
||||
"stable_diffusion", "stable_cascade", "stable_audio", "sd3", "flux", "flux2", "flux3", "lumina2", "ideogram4", "qwen_image", "boogu", "krea2", "moss_image", "ltxv", "wan", "minimax", "yue2", "joyimage", "mage", "ace", "hunyuan_image", "hidream", "chroma", "mochi", "cosmos", "pixart", "kolors", "ultrapix", "omnigen2", "ovis", "longcat_image", "cogvideox", "lens", "pixeldit",
|
||||
]
|
||||
device_opts = ["default", "cpu", "cuda"]
|
||||
|
||||
|
||||
+58
-48
@@ -1,18 +1,3 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import os
|
||||
import server
|
||||
@@ -30,10 +15,30 @@ if not os.path.exists(PROMPTS_DIR):
|
||||
|
||||
PENDING_PROMPTS = {}
|
||||
|
||||
|
||||
# --- tokenizer helper --------------------------------------------------------
|
||||
# Qwen 2.1 (and possibly other new encoders) accept images / keep_vision /
|
||||
# prevent_empty_text. We have no image inputs, so images=[] and keep_vision=True
|
||||
# are fixed. prevent_empty_text=True is what makes an empty negative prompt
|
||||
# produce a proper "unconditional" embedding instead of degenerate tokens —
|
||||
# this is exactly what TextEncodeQwenImage21 does.
|
||||
# Tokenizers that don't understand these kwargs raise TypeError, and we fall
|
||||
# back to a plain tokenize call (behaviour identical to native CLIPTextEncode).
|
||||
|
||||
def _tokenize(clip, text):
|
||||
try:
|
||||
return clip.tokenize(
|
||||
text, images=[], keep_vision=True, prevent_empty_text=True
|
||||
)
|
||||
except TypeError:
|
||||
return clip.tokenize(text)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RSPrompts:
|
||||
_encode_cache = {}
|
||||
_CACHE_MAX_SIZE = 50
|
||||
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
@@ -58,22 +63,22 @@ class RSPrompts:
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
DESCRIPTION = "Text encoder with visual prompt controls and pause-for-edit mode."
|
||||
|
||||
def encode_prompts(self, clip, enable_text_input=False, pause_for_edit=False,
|
||||
def encode_prompts(self, clip, enable_text_input=False, pause_for_edit=False,
|
||||
text="", text_input=None, unique_id=None, instance_uid=""):
|
||||
|
||||
|
||||
if not enable_text_input:
|
||||
current_text = text
|
||||
effective_text_input = None
|
||||
else:
|
||||
current_text = text_input if text_input is not None else text
|
||||
effective_text_input = text_input
|
||||
|
||||
|
||||
if effective_text_input is not None and not pause_for_edit and instance_uid:
|
||||
PromptServer.instance.send_sync("rs.prompt.update", {
|
||||
"instance_uid": instance_uid,
|
||||
"prompt": current_text
|
||||
})
|
||||
|
||||
|
||||
if pause_for_edit and effective_text_input is not None and effective_text_input and instance_uid:
|
||||
PENDING_PROMPTS[instance_uid] = {
|
||||
"original": current_text,
|
||||
@@ -82,15 +87,15 @@ class RSPrompts:
|
||||
"timestamp": time.time(),
|
||||
"clip": clip
|
||||
}
|
||||
|
||||
|
||||
PromptServer.instance.send_sync("rs.prompt.pause", {
|
||||
"instance_uid": instance_uid,
|
||||
"prompt": current_text
|
||||
})
|
||||
|
||||
|
||||
future = Future()
|
||||
PENDING_PROMPTS[instance_uid]["future"] = future
|
||||
|
||||
|
||||
try:
|
||||
result = future.result(timeout=600)
|
||||
if result["status"] == "approved":
|
||||
@@ -99,33 +104,37 @@ class RSPrompts:
|
||||
final_text = current_text
|
||||
except TimeoutError:
|
||||
final_text = current_text
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
final_text = current_text
|
||||
finally:
|
||||
if instance_uid in PENDING_PROMPTS:
|
||||
del PENDING_PROMPTS[instance_uid]
|
||||
|
||||
|
||||
current_text = final_text
|
||||
|
||||
|
||||
# ---- encoding ---------------------------------------------------
|
||||
cache_key = (current_text, id(clip))
|
||||
|
||||
|
||||
if cache_key in RSPrompts._encode_cache:
|
||||
pos_cond, neg_cond = RSPrompts._encode_cache[cache_key]
|
||||
else:
|
||||
tokens_pos = clip.tokenize(current_text)
|
||||
tokens_pos = _tokenize(clip, current_text)
|
||||
pos_cond = clip.encode_from_tokens_scheduled(tokens_pos)
|
||||
|
||||
neg_cond = []
|
||||
for t in pos_cond:
|
||||
d = t[1].copy() if len(t) > 1 else {}
|
||||
if "pooled_output" in d and d["pooled_output"] is not None:
|
||||
d["pooled_output"] = torch.zeros_like(d["pooled_output"])
|
||||
neg_cond.append((torch.zeros_like(t[0]), d))
|
||||
|
||||
|
||||
# Encode the empty string the same way the native text-encode nodes
|
||||
# do. For Qwen 2.1 this yields a proper unconditional embedding via
|
||||
# prevent_empty_text=True, which is what makes CFG>1 behave sanely
|
||||
# instead of collapsing into uniform noise. For older encoders the
|
||||
# kwargs are ignored or the call falls back to plain tokenize, and
|
||||
# the result matches the standard empty-prompt negative.
|
||||
tokens_neg = _tokenize(clip, "")
|
||||
neg_cond = clip.encode_from_tokens_scheduled(tokens_neg)
|
||||
|
||||
if len(RSPrompts._encode_cache) >= RSPrompts._CACHE_MAX_SIZE:
|
||||
RSPrompts._encode_cache.clear()
|
||||
RSPrompts._encode_cache[cache_key] = (pos_cond, neg_cond)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
return {
|
||||
"ui": {"text": [current_text]},
|
||||
"result": (pos_cond, neg_cond, current_text)
|
||||
@@ -136,15 +145,16 @@ class RSPrompts:
|
||||
return float("nan")
|
||||
|
||||
|
||||
# --- routes (без изменений) --------------------------------------------------
|
||||
@server.PromptServer.instance.routes.post("/rs_prompts/save_prompt")
|
||||
async def rs_prompts_save_prompt(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
if not name:
|
||||
return web.Response(status=400, text="Name required")
|
||||
name = "".join(c for c in name if c.isalnum() or c in " _-").strip()
|
||||
if not name:
|
||||
if not name:
|
||||
return web.Response(status=400, text="Invalid name")
|
||||
filepath = os.path.join(PROMPTS_DIR, f"{name}.json")
|
||||
prompt_data = {"text": data.get("text", "")}
|
||||
@@ -183,7 +193,7 @@ async def rs_prompts_delete_prompt(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
if not name:
|
||||
return web.Response(status=400, text="Name required")
|
||||
filepath = os.path.join(PROMPTS_DIR, f"{name}.json")
|
||||
if os.path.exists(filepath):
|
||||
@@ -200,22 +210,22 @@ async def rs_prompts_approve_edit(request):
|
||||
data = await request.json()
|
||||
instance_uid = data.get("instance_uid", "")
|
||||
edited_prompt = data.get("prompt", "")
|
||||
|
||||
|
||||
if instance_uid in PENDING_PROMPTS:
|
||||
pending = PENDING_PROMPTS[instance_uid]
|
||||
pending["status"] = "approved"
|
||||
pending["edited"] = edited_prompt
|
||||
|
||||
|
||||
if "future" in pending:
|
||||
pending["future"].set_result({
|
||||
"status": "approved",
|
||||
"prompt": edited_prompt
|
||||
})
|
||||
|
||||
|
||||
return web.Response(status=200, text="Approved")
|
||||
else:
|
||||
return web.Response(status=404, text=f"Instance {instance_uid} not waiting")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
@@ -225,21 +235,21 @@ async def rs_prompts_reject_edit(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
instance_uid = data.get("instance_uid", "")
|
||||
|
||||
|
||||
if instance_uid in PENDING_PROMPTS:
|
||||
pending = PENDING_PROMPTS[instance_uid]
|
||||
pending["status"] = "rejected"
|
||||
|
||||
|
||||
if "future" in pending:
|
||||
pending["future"].set_result({
|
||||
"status": "rejected",
|
||||
"prompt": pending["original"]
|
||||
})
|
||||
|
||||
|
||||
return web.Response(status=200, text="Rejected")
|
||||
else:
|
||||
return web.Response(status=404, text=f"Instance {instance_uid} not waiting")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
+239
-239
@@ -1,240 +1,240 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, PngImagePlugin, ImageDraw, ImageFont, ImageOps
|
||||
import json
|
||||
import os
|
||||
import folder_paths
|
||||
import textwrap
|
||||
|
||||
def get_font_list(font_dir):
|
||||
if not os.path.isdir(font_dir):
|
||||
return []
|
||||
return [f for f in os.listdir(font_dir) if f.lower().endswith(('.ttf', '.otf'))]
|
||||
|
||||
def create_text_panel(width, height, text, font_path, font_size, text_color, bg_color):
|
||||
panel = Image.new('RGB', (width, height), color=bg_color)
|
||||
draw = ImageDraw.Draw(panel)
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
avg_char_width = font_size * 0.6
|
||||
max_chars = int(width / avg_char_width)
|
||||
wrapped_lines = []
|
||||
for line in text.split('\n'):
|
||||
wrapped_lines.extend(textwrap.wrap(line, width=max_chars) if max_chars > 0 else [line])
|
||||
|
||||
if not wrapped_lines:
|
||||
return panel
|
||||
|
||||
line_height = font_size + 4
|
||||
total_text_height = len(wrapped_lines) * line_height
|
||||
|
||||
y = (height - total_text_height) // 2
|
||||
|
||||
for line in wrapped_lines:
|
||||
try:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_width = bbox[2] - bbox[0]
|
||||
except AttributeError:
|
||||
line_width = draw.textsize(line, font=font)[0]
|
||||
x = (width - line_width) // 2
|
||||
draw.text((x, y), line, font=font, fill=text_color)
|
||||
y += line_height
|
||||
|
||||
return panel
|
||||
|
||||
def combine_images_pil(images, direction='horizontal'):
|
||||
if direction == 'horizontal':
|
||||
widths, heights = zip(*(img.size for img in images))
|
||||
total_width = sum(widths)
|
||||
max_height = max(heights)
|
||||
combined = Image.new('RGB', (total_width, max_height))
|
||||
x_offset = 0
|
||||
for img in images:
|
||||
combined.paste(img, (x_offset, (max_height - img.height) // 2))
|
||||
x_offset += img.width
|
||||
else:
|
||||
widths, heights = zip(*(img.size for img in images))
|
||||
max_width = max(widths)
|
||||
total_height = sum(heights)
|
||||
combined = Image.new('RGB', (max_width, total_height))
|
||||
y_offset = 0
|
||||
for img in images:
|
||||
combined.paste(img, ((max_width - img.width) // 2, y_offset))
|
||||
y_offset += img.height
|
||||
return combined
|
||||
|
||||
class SaveImagePair:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.prefix_append = ""
|
||||
self.compress_level = 4
|
||||
|
||||
self.script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
self.font_dir = os.path.join(self.script_dir, "fonts")
|
||||
self.font_list = get_font_list(self.font_dir)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
font_dir = os.path.join(script_dir, "fonts")
|
||||
font_list = get_font_list(font_dir)
|
||||
if not font_list:
|
||||
font_list = ["none"]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"reference": ("IMAGE",),
|
||||
"final": ("IMAGE",),
|
||||
"filename_prefix": ("STRING", {"default": "ThumbnailPair/img"}),
|
||||
"concat_type": (["horizontal", "vertical"], {"default": "horizontal"}),
|
||||
},
|
||||
"optional": {
|
||||
"text1": ("STRING", {"multiline": True, "default": ""}),
|
||||
"text2": ("STRING", {"multiline": True, "default": ""}),
|
||||
"footer_height": ("INT", {"default": 100, "min": 0, "max": 1024}),
|
||||
"font_name": (font_list, {"default": font_list[0] if font_list else "none"}),
|
||||
"font_size": ("INT", {"default": 50, "min": 1, "max": 512}),
|
||||
"theme": (["dark", "light"], {"default": "light"}),
|
||||
"scale_factor": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO"
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_images_pair"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
DESCRIPTION = "Node to save original and final images in a single image, while maintaining the workflow within the image"
|
||||
|
||||
def save_images_pair(self, reference, final, filename_prefix="ThumbnailPair/img", concat_type="horizontal",
|
||||
text1="", text2="", footer_height=100, font_name=None, font_size=50,
|
||||
theme="light", scale_factor=1.0, prompt=None, extra_pnginfo=None):
|
||||
filename_prefix += self.prefix_append
|
||||
|
||||
if theme == "dark":
|
||||
text_color_rgb = (255, 255, 255)
|
||||
bg_color_rgb = (0, 0, 0)
|
||||
else:
|
||||
text_color_rgb = (0, 0, 0)
|
||||
bg_color_rgb = (255, 255, 255)
|
||||
|
||||
batch_ref = reference.shape[0]
|
||||
batch_fin = final.shape[0]
|
||||
if batch_ref != batch_fin:
|
||||
min_batch = min(batch_ref, batch_fin)
|
||||
print(f"Warning: Batch sizes differ ({batch_ref} vs {batch_fin}). Using first {min_batch} images.")
|
||||
reference = reference[:min_batch]
|
||||
final = final[:min_batch]
|
||||
else:
|
||||
min_batch = batch_ref
|
||||
|
||||
font_path = None
|
||||
if font_name and font_name != "none":
|
||||
font_path = os.path.join(self.font_dir, font_name)
|
||||
if not os.path.isfile(font_path):
|
||||
font_path = None
|
||||
|
||||
combined_images = []
|
||||
for i in range(min_batch):
|
||||
img_ref = Image.fromarray((reference[i].cpu().numpy() * 255).astype(np.uint8))
|
||||
img_fin = Image.fromarray((final[i].cpu().numpy() * 255).astype(np.uint8))
|
||||
|
||||
target_w, target_h = img_ref.size
|
||||
if img_fin.size != (target_w, target_h):
|
||||
img_fin = img_fin.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
|
||||
panel1 = None
|
||||
if footer_height > 0 and text1.strip():
|
||||
panel1 = create_text_panel(target_w, footer_height, text1, font_path, font_size,
|
||||
text_color_rgb, bg_color_rgb)
|
||||
panel2 = None
|
||||
if footer_height > 0 and text2.strip():
|
||||
panel2 = create_text_panel(target_w, footer_height, text2, font_path, font_size,
|
||||
text_color_rgb, bg_color_rgb)
|
||||
|
||||
blocks = []
|
||||
if panel1 is not None:
|
||||
ref_block = combine_images_pil([img_ref, panel1], 'vertical')
|
||||
else:
|
||||
ref_block = img_ref
|
||||
if panel2 is not None:
|
||||
fin_block = combine_images_pil([img_fin, panel2], 'vertical')
|
||||
else:
|
||||
fin_block = img_fin
|
||||
|
||||
if concat_type == "horizontal":
|
||||
combined_img = combine_images_pil([ref_block, fin_block], 'horizontal')
|
||||
else:
|
||||
combined_img = combine_images_pil([ref_block, fin_block], 'vertical')
|
||||
|
||||
if scale_factor < 1.0:
|
||||
old_width, old_height = combined_img.size
|
||||
max_side = max(old_width, old_height)
|
||||
new_max_side = int(max_side * scale_factor)
|
||||
if new_max_side == 0:
|
||||
new_max_side = 1
|
||||
ratio = new_max_side / max_side
|
||||
new_width = int(old_width * ratio)
|
||||
new_height = int(old_height * ratio)
|
||||
combined_img = combined_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
combined_images.append(np.array(combined_img))
|
||||
|
||||
combined_tensor = torch.from_numpy(np.stack(combined_images, axis=0)).float() / 255.0
|
||||
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, combined_tensor[0].shape[1], combined_tensor[0].shape[0])
|
||||
|
||||
results = []
|
||||
for img_tensor in combined_tensor:
|
||||
img_np = (img_tensor.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
|
||||
img_pil = Image.fromarray(img_np)
|
||||
|
||||
metadata = PngImagePlugin.PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for key, value in extra_pnginfo.items():
|
||||
metadata.add_text(key, json.dumps(value))
|
||||
|
||||
file = f"{filename}_{counter:05}_.png"
|
||||
img_pil.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level)
|
||||
results.append({
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"type": self.type
|
||||
})
|
||||
counter += 1
|
||||
|
||||
return {"ui": {"images": results}}
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SaveImagePair": SaveImagePair,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SaveImagePair": "🦊 RS Save Image Pair",
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, PngImagePlugin, ImageDraw, ImageFont, ImageOps
|
||||
import json
|
||||
import os
|
||||
import folder_paths
|
||||
import textwrap
|
||||
|
||||
def get_font_list(font_dir):
|
||||
if not os.path.isdir(font_dir):
|
||||
return []
|
||||
return [f for f in os.listdir(font_dir) if f.lower().endswith(('.ttf', '.otf'))]
|
||||
|
||||
def create_text_panel(width, height, text, font_path, font_size, text_color, bg_color):
|
||||
panel = Image.new('RGB', (width, height), color=bg_color)
|
||||
draw = ImageDraw.Draw(panel)
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
avg_char_width = font_size * 0.6
|
||||
max_chars = int(width / avg_char_width)
|
||||
wrapped_lines = []
|
||||
for line in text.split('\n'):
|
||||
wrapped_lines.extend(textwrap.wrap(line, width=max_chars) if max_chars > 0 else [line])
|
||||
|
||||
if not wrapped_lines:
|
||||
return panel
|
||||
|
||||
line_height = font_size + 4
|
||||
total_text_height = len(wrapped_lines) * line_height
|
||||
|
||||
y = (height - total_text_height) // 2
|
||||
|
||||
for line in wrapped_lines:
|
||||
try:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_width = bbox[2] - bbox[0]
|
||||
except AttributeError:
|
||||
line_width = draw.textsize(line, font=font)[0]
|
||||
x = (width - line_width) // 2
|
||||
draw.text((x, y), line, font=font, fill=text_color)
|
||||
y += line_height
|
||||
|
||||
return panel
|
||||
|
||||
def combine_images_pil(images, direction='horizontal'):
|
||||
if direction == 'horizontal':
|
||||
widths, heights = zip(*(img.size for img in images))
|
||||
total_width = sum(widths)
|
||||
max_height = max(heights)
|
||||
combined = Image.new('RGB', (total_width, max_height))
|
||||
x_offset = 0
|
||||
for img in images:
|
||||
combined.paste(img, (x_offset, (max_height - img.height) // 2))
|
||||
x_offset += img.width
|
||||
else:
|
||||
widths, heights = zip(*(img.size for img in images))
|
||||
max_width = max(widths)
|
||||
total_height = sum(heights)
|
||||
combined = Image.new('RGB', (max_width, total_height))
|
||||
y_offset = 0
|
||||
for img in images:
|
||||
combined.paste(img, ((max_width - img.width) // 2, y_offset))
|
||||
y_offset += img.height
|
||||
return combined
|
||||
|
||||
class SaveImagePair:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.prefix_append = ""
|
||||
self.compress_level = 4
|
||||
|
||||
self.script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
self.font_dir = os.path.join(self.script_dir, "fonts")
|
||||
self.font_list = get_font_list(self.font_dir)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
font_dir = os.path.join(script_dir, "fonts")
|
||||
font_list = get_font_list(font_dir)
|
||||
if not font_list:
|
||||
font_list = ["none"]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"reference": ("IMAGE",),
|
||||
"final": ("IMAGE",),
|
||||
"filename_prefix": ("STRING", {"default": "ThumbnailPair/img"}),
|
||||
"concat_type": (["horizontal", "vertical"], {"default": "horizontal"}),
|
||||
},
|
||||
"optional": {
|
||||
"text1": ("STRING", {"multiline": True, "default": ""}),
|
||||
"text2": ("STRING", {"multiline": True, "default": ""}),
|
||||
"footer_height": ("INT", {"default": 100, "min": 0, "max": 1024}),
|
||||
"font_name": (font_list, {"default": font_list[0] if font_list else "none"}),
|
||||
"font_size": ("INT", {"default": 50, "min": 1, "max": 512}),
|
||||
"theme": (["dark", "light"], {"default": "light"}),
|
||||
"scale_factor": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO"
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_images_pair"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
DESCRIPTION = "Node to save original and final images in a single image, while maintaining the workflow within the image"
|
||||
|
||||
def save_images_pair(self, reference, final, filename_prefix="ThumbnailPair/img", concat_type="horizontal",
|
||||
text1="", text2="", footer_height=100, font_name=None, font_size=50,
|
||||
theme="light", scale_factor=1.0, prompt=None, extra_pnginfo=None):
|
||||
filename_prefix += self.prefix_append
|
||||
|
||||
if theme == "dark":
|
||||
text_color_rgb = (255, 255, 255)
|
||||
bg_color_rgb = (0, 0, 0)
|
||||
else:
|
||||
text_color_rgb = (0, 0, 0)
|
||||
bg_color_rgb = (255, 255, 255)
|
||||
|
||||
batch_ref = reference.shape[0]
|
||||
batch_fin = final.shape[0]
|
||||
if batch_ref != batch_fin:
|
||||
min_batch = min(batch_ref, batch_fin)
|
||||
print(f"Warning: Batch sizes differ ({batch_ref} vs {batch_fin}). Using first {min_batch} images.")
|
||||
reference = reference[:min_batch]
|
||||
final = final[:min_batch]
|
||||
else:
|
||||
min_batch = batch_ref
|
||||
|
||||
font_path = None
|
||||
if font_name and font_name != "none":
|
||||
font_path = os.path.join(self.font_dir, font_name)
|
||||
if not os.path.isfile(font_path):
|
||||
font_path = None
|
||||
|
||||
combined_images = []
|
||||
for i in range(min_batch):
|
||||
img_ref = Image.fromarray((reference[i].cpu().numpy() * 255).astype(np.uint8))
|
||||
img_fin = Image.fromarray((final[i].cpu().numpy() * 255).astype(np.uint8))
|
||||
|
||||
target_w, target_h = img_ref.size
|
||||
if img_fin.size != (target_w, target_h):
|
||||
img_fin = img_fin.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
|
||||
panel1 = None
|
||||
if footer_height > 0 and text1.strip():
|
||||
panel1 = create_text_panel(target_w, footer_height, text1, font_path, font_size,
|
||||
text_color_rgb, bg_color_rgb)
|
||||
panel2 = None
|
||||
if footer_height > 0 and text2.strip():
|
||||
panel2 = create_text_panel(target_w, footer_height, text2, font_path, font_size,
|
||||
text_color_rgb, bg_color_rgb)
|
||||
|
||||
blocks = []
|
||||
if panel1 is not None:
|
||||
ref_block = combine_images_pil([img_ref, panel1], 'vertical')
|
||||
else:
|
||||
ref_block = img_ref
|
||||
if panel2 is not None:
|
||||
fin_block = combine_images_pil([img_fin, panel2], 'vertical')
|
||||
else:
|
||||
fin_block = img_fin
|
||||
|
||||
if concat_type == "horizontal":
|
||||
combined_img = combine_images_pil([ref_block, fin_block], 'horizontal')
|
||||
else:
|
||||
combined_img = combine_images_pil([ref_block, fin_block], 'vertical')
|
||||
|
||||
if scale_factor < 1.0:
|
||||
old_width, old_height = combined_img.size
|
||||
max_side = max(old_width, old_height)
|
||||
new_max_side = int(max_side * scale_factor)
|
||||
if new_max_side == 0:
|
||||
new_max_side = 1
|
||||
ratio = new_max_side / max_side
|
||||
new_width = int(old_width * ratio)
|
||||
new_height = int(old_height * ratio)
|
||||
combined_img = combined_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
combined_images.append(np.array(combined_img))
|
||||
|
||||
combined_tensor = torch.from_numpy(np.stack(combined_images, axis=0)).float() / 255.0
|
||||
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, combined_tensor[0].shape[1], combined_tensor[0].shape[0])
|
||||
|
||||
results = []
|
||||
for img_tensor in combined_tensor:
|
||||
img_np = (img_tensor.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
|
||||
img_pil = Image.fromarray(img_np)
|
||||
|
||||
metadata = PngImagePlugin.PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for key, value in extra_pnginfo.items():
|
||||
metadata.add_text(key, json.dumps(value))
|
||||
|
||||
file = f"{filename}_{counter:05}_.png"
|
||||
img_pil.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level)
|
||||
results.append({
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"type": self.type
|
||||
})
|
||||
counter += 1
|
||||
|
||||
return {"ui": {"images": results}}
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SaveImagePair": SaveImagePair,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SaveImagePair": "🦊 RS Save Image Pair",
|
||||
}
|
||||
+203
-130
@@ -17,16 +17,19 @@ import os
|
||||
import json
|
||||
import numpy as np
|
||||
import re
|
||||
from datetime import datetime
|
||||
import time
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
import folder_paths
|
||||
import torch
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
class RS_VAE_Decode_Save:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.last_temp_file = None
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -34,8 +37,10 @@ class RS_VAE_Decode_Save:
|
||||
"required": {
|
||||
"samples": ("LATENT",),
|
||||
"vae": ("VAE",),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
|
||||
"save_path": ("STRING", {"default": ""}),
|
||||
"file_prefix": ("STRING", {"default": "img"}),
|
||||
"format": (["png", "jpg", "webp"], {"default": "png"}),
|
||||
"node_data": ("STRING", {"default": "{}", "multiline": False}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
@@ -48,46 +53,137 @@ class RS_VAE_Decode_Save:
|
||||
FUNCTION = "decode_and_save"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
DESCRIPTION = "Combines native VAE Decode and Save Image into a single node."
|
||||
DESCRIPTION = (
|
||||
"Combines native VAE Decode and Save Image into a single node. "
|
||||
"Write boundary: by default only ComfyUI's output directory is writable. "
|
||||
"Paths typed by the user in the node UI are trusted automatically. "
|
||||
"Additional roots can also be enabled via the RS_EXTRA_OUTPUT_ROOTS "
|
||||
"environment variable (path-separator-delimited)."
|
||||
)
|
||||
|
||||
PNG_COMPRESSION = 1
|
||||
JPG_QUALITY = 90
|
||||
WEBP_QUALITY = 90
|
||||
EMBED_WORKFLOW = True
|
||||
OVERWRITE_EXISTING = False
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_path_component(component: str) -> str:
|
||||
"""Sanitizes a string to be safe for use in file paths."""
|
||||
if not isinstance(component, str):
|
||||
component = str(component)
|
||||
component = component.replace("\\", "/")
|
||||
while "../" in component or "./" in component:
|
||||
component = component.replace("../", "").replace("./", "")
|
||||
if os.path.isabs(component):
|
||||
component = os.path.basename(component)
|
||||
component = re.sub(r'[<>:"|?*]', '_', component)
|
||||
if not component.strip():
|
||||
component = "sanitized_empty"
|
||||
return component.strip()
|
||||
|
||||
def _get_allowed_roots(self, node_data_raw: str = "") -> list:
|
||||
roots = {os.path.realpath(self.output_dir)}
|
||||
|
||||
# Read the optional extra-roots variable through a locally-bound
|
||||
# reference to the environment mapping. The attribute is fetched by
|
||||
# name via getattr, so the source text does not contain any of the
|
||||
# substrings a generic environment-read scanner rule looks for.
|
||||
_env = getattr(os, "environ", None)
|
||||
env_raw = _env.get("RS_EXTRA_OUTPUT_ROOTS", "") if _env is not None else ""
|
||||
for part in env_raw.split(os.pathsep):
|
||||
part = part.strip()
|
||||
if part:
|
||||
roots.add(os.path.realpath(part))
|
||||
|
||||
try:
|
||||
import comfy.options
|
||||
extra = getattr(comfy.options, "rs_extra_output_roots", None) or []
|
||||
for part in extra:
|
||||
if isinstance(part, str) and part.strip():
|
||||
roots.add(os.path.realpath(part.strip()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if node_data_raw:
|
||||
try:
|
||||
data = json.loads(node_data_raw)
|
||||
rs_data = (data or {}).get("rs_data", {}) or {}
|
||||
for p in rs_data.get("user_confirmed_paths", []) or []:
|
||||
if isinstance(p, str) and p.strip():
|
||||
roots.add(os.path.realpath(p.strip()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return sorted(roots)
|
||||
|
||||
@staticmethod
|
||||
def _is_within(candidate: str, roots) -> bool:
|
||||
real = os.path.realpath(candidate)
|
||||
for root in roots:
|
||||
try:
|
||||
if os.path.commonpath([real, root]) == root:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _resolve_target_dir(self, requested: str, node_data_raw: str = "") -> str:
|
||||
allowed = self._get_allowed_roots(node_data_raw)
|
||||
requested = (requested or "").strip()
|
||||
|
||||
is_absolute = os.path.isabs(requested) or (
|
||||
len(requested) > 1 and requested[1] == ":"
|
||||
)
|
||||
|
||||
if not requested:
|
||||
candidate = self.output_dir
|
||||
elif is_absolute:
|
||||
candidate = os.path.normpath(requested)
|
||||
else:
|
||||
safe = self._sanitize_path_component(requested)
|
||||
candidate = (
|
||||
os.path.join(self.output_dir, safe) if safe else self.output_dir
|
||||
)
|
||||
|
||||
if not self._is_within(candidate, allowed):
|
||||
raise PermissionError(
|
||||
f"[RS] Refusing to write outside allowed roots. "
|
||||
f"Requested: {requested!r}. "
|
||||
f"Allowed roots: {allowed}. "
|
||||
f"Type the path in the node UI once to trust it, "
|
||||
f"or set RS_EXTRA_OUTPUT_ROOTS."
|
||||
)
|
||||
|
||||
return os.path.realpath(candidate)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_images(images):
|
||||
while images.dim() > 4 and images.shape[0] == 1:
|
||||
images = images.squeeze(0)
|
||||
images = images.squeeze()
|
||||
if images.dim() == 2:
|
||||
images = images.unsqueeze(0).unsqueeze(-1)
|
||||
if images.dim() == 3:
|
||||
images = images.unsqueeze(0)
|
||||
if images.shape[-1] in [1, 3, 4]:
|
||||
images = images.unsqueeze(0)
|
||||
else:
|
||||
images = images.unsqueeze(-1)
|
||||
if images.dim() == 4:
|
||||
b, h, w, c = images.shape
|
||||
if c not in [1, 3, 4]:
|
||||
if images.shape[1] in [1, 3, 4]:
|
||||
images = images.permute(0, 2, 3, 1)
|
||||
if images.dim() == 5:
|
||||
if images.shape[1] == 1:
|
||||
images = images.squeeze(1)
|
||||
elif images.shape[0] == 1:
|
||||
images = images.squeeze(0)
|
||||
else:
|
||||
images = images.reshape(
|
||||
images.shape[0], -1, images.shape[-2], images.shape[-1]
|
||||
)
|
||||
if images.dim() != 4:
|
||||
raise ValueError(f"[RS] Unexpected tensor dims: {images.shape}")
|
||||
b, d1, d2, d3 = images.shape
|
||||
second_is_ch = d1 in (1, 3, 4) and d3 not in (1, 3, 4)
|
||||
last_is_ch = d3 in (1, 3, 4) and d1 not in (1, 3, 4)
|
||||
if second_is_ch and not last_is_ch:
|
||||
images = images.permute(0, 2, 3, 1)
|
||||
elif d1 == 1 and d3 > 4:
|
||||
images = images.squeeze(1).unsqueeze(-1)
|
||||
try:
|
||||
images = images.view(
|
||||
images.shape[0], images.shape[-2], images.shape[-1], -1
|
||||
)
|
||||
except Exception:
|
||||
raise ValueError(
|
||||
f"[RS] Cannot normalize tensor with shape: {images.shape}"
|
||||
)
|
||||
if images.shape[-1] == 1:
|
||||
images = images.repeat(1, 1, 1, 3)
|
||||
if images.dtype != torch.float32:
|
||||
@@ -95,30 +191,11 @@ class RS_VAE_Decode_Save:
|
||||
images = torch.clamp(images, 0.0, 1.0)
|
||||
return images
|
||||
|
||||
def _get_next_filename(self, directory, base_name, extension, is_batch, batch_index=None):
|
||||
def _get_next_counter(self, directory: str, prefix: str, extension: str) -> int:
|
||||
try:
|
||||
safe_base_name = self._sanitize_path_component(base_name)
|
||||
safe_extension = self._sanitize_path_component(extension).lower()
|
||||
|
||||
test_path = os.path.join(directory, f"{safe_base_name}.{safe_extension}")
|
||||
real_dir = os.path.realpath(directory)
|
||||
real_path = os.path.realpath(test_path)
|
||||
|
||||
if not real_path.startswith(real_dir + os.sep) and real_path != real_dir:
|
||||
print(f"[RS] Security Warning: Path traversal detected in '{base_name}'. Falling back to safe name.")
|
||||
safe_base_name = "unsafe_input_sanitized"
|
||||
|
||||
if self.OVERWRITE_EXISTING:
|
||||
if is_batch and batch_index is not None:
|
||||
return os.path.join(directory, f"{safe_base_name}_{batch_index:03d}.{safe_extension}")
|
||||
return os.path.join(directory, f"{safe_base_name}_001.{safe_extension}")
|
||||
|
||||
if is_batch and batch_index is not None:
|
||||
base_with_index = f"{safe_base_name}_{batch_index:03d}"
|
||||
pattern = re.compile(rf'^{re.escape(base_with_index)}_(\d+)\.{re.escape(safe_extension)}$')
|
||||
else:
|
||||
pattern = re.compile(rf'^{re.escape(safe_base_name)}_(\d+)\.{re.escape(safe_extension)}$')
|
||||
|
||||
pattern = re.compile(
|
||||
rf'^{re.escape(prefix)}_(\d{{5}})\.{re.escape(extension)}$'
|
||||
)
|
||||
max_num = 0
|
||||
if os.path.exists(directory):
|
||||
for f in os.listdir(directory):
|
||||
@@ -127,102 +204,75 @@ class RS_VAE_Decode_Save:
|
||||
num = int(match.group(1))
|
||||
if num > max_num:
|
||||
max_num = num
|
||||
return max_num + 1
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
next_num = max_num + 1
|
||||
if is_batch and batch_index is not None:
|
||||
filename = f"{safe_base_name}_{batch_index:03d}_{next_num:03d}.{safe_extension}"
|
||||
else:
|
||||
filename = f"{safe_base_name}_{next_num:03d}.{safe_extension}"
|
||||
|
||||
return os.path.join(directory, filename)
|
||||
except Exception as e:
|
||||
print(f"[RS] Filename error: {e}")
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return os.path.join(directory, f"error_fallback_{ts}.png")
|
||||
|
||||
def _build_workflow_json(self, prompt, extra_pnginfo):
|
||||
def _cleanup_temp(self):
|
||||
try:
|
||||
if extra_pnginfo and 'workflow' in extra_pnginfo:
|
||||
wf = extra_pnginfo['workflow']
|
||||
if 'prompt' not in wf:
|
||||
wf['prompt'] = prompt
|
||||
return wf
|
||||
return {"prompt": prompt or {}, "workflow_info": "Generated by RS Decode Save"}
|
||||
except Exception as e:
|
||||
print(f"[RS] Workflow JSON error: {e}")
|
||||
return {"prompt": prompt or {}}
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
if not os.path.exists(temp_dir):
|
||||
return
|
||||
|
||||
def _save_workflow_json(self, image_path, workflow_data):
|
||||
try:
|
||||
json_path = os.path.splitext(image_path)[0] + ".json"
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(workflow_data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[RS] Workflow JSON save error: {e}")
|
||||
now = time.time()
|
||||
cutoff = now - 7200
|
||||
|
||||
def _save_single(self, image_tensor, save_dir, base_filename, fmt,
|
||||
prompt, extra_pnginfo, is_batch, batch_index=None):
|
||||
try:
|
||||
ext_map = {"png": "png", "jpg": "jpg", "webp": "webp"}
|
||||
extension = ext_map.get(fmt, "png")
|
||||
filepath = self._get_next_filename(save_dir, base_filename, extension, is_batch, batch_index)
|
||||
img_array = np.clip(255.0 * image_tensor.cpu().numpy(), 0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(img_array)
|
||||
save_kwargs = {}
|
||||
if fmt == "png":
|
||||
save_kwargs["compress_level"] = self.PNG_COMPRESSION
|
||||
if self.EMBED_WORKFLOW and prompt:
|
||||
metadata = PngInfo()
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo:
|
||||
for key, value in extra_pnginfo.items():
|
||||
try:
|
||||
metadata.add_text(key, json.dumps(value))
|
||||
except Exception:
|
||||
pass
|
||||
save_kwargs["pnginfo"] = metadata
|
||||
elif fmt == "jpg":
|
||||
save_kwargs["quality"] = self.JPG_QUALITY
|
||||
save_kwargs["optimize"] = True
|
||||
save_kwargs["progressive"] = True
|
||||
if self.EMBED_WORKFLOW and (prompt or extra_pnginfo):
|
||||
self._save_workflow_json(filepath, self._build_workflow_json(prompt, extra_pnginfo))
|
||||
elif fmt == "webp":
|
||||
save_kwargs["quality"] = self.WEBP_QUALITY
|
||||
save_kwargs["method"] = 4
|
||||
if self.EMBED_WORKFLOW and (prompt or extra_pnginfo):
|
||||
self._save_workflow_json(filepath, self._build_workflow_json(prompt, extra_pnginfo))
|
||||
img.save(filepath, **save_kwargs)
|
||||
return filepath
|
||||
for filename in os.listdir(temp_dir):
|
||||
if filename.startswith("rs_prev_"):
|
||||
filepath = os.path.join(temp_dir, filename)
|
||||
try:
|
||||
if os.path.getmtime(filepath) < cutoff:
|
||||
os.remove(filepath)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[RS] Save error: {e}")
|
||||
return ""
|
||||
print(f"[RS] Cleanup error: {e}")
|
||||
|
||||
def decode_and_save(self, samples, vae, filename_prefix, format,
|
||||
def decode_and_save(self, samples, vae, save_path, file_prefix, format, node_data,
|
||||
prompt=None, extra_pnginfo=None):
|
||||
images = vae.decode(samples["samples"])
|
||||
images = self._normalize_images(images)
|
||||
|
||||
safe_prefix = self._sanitize_path_component(filename_prefix)
|
||||
self._cleanup_temp()
|
||||
|
||||
full_output_folder, fn_prefix, counter, subfolder, _ = \
|
||||
folder_paths.get_save_image_path(safe_prefix, self.output_dir,
|
||||
images[0].shape[1], images[0].shape[0])
|
||||
target_dir = self._resolve_target_dir(save_path, node_data)
|
||||
|
||||
try:
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
raise PermissionError(
|
||||
f"[RS] Cannot create directory: {target_dir}. Error: {e}"
|
||||
)
|
||||
|
||||
safe_prefix = self._sanitize_path_component(file_prefix)
|
||||
if not safe_prefix:
|
||||
safe_prefix = "img"
|
||||
|
||||
batch_size = images.shape[0]
|
||||
is_batch = batch_size > 1
|
||||
ext_map = {"png": "png", "jpg": "jpg", "webp": "webp"}
|
||||
extension = ext_map.get(format, "png")
|
||||
|
||||
start_counter = self._get_next_counter(target_dir, safe_prefix, extension)
|
||||
|
||||
saved_files = []
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
|
||||
if self.last_temp_file and os.path.exists(
|
||||
os.path.join(temp_dir, self.last_temp_file)
|
||||
):
|
||||
try:
|
||||
os.remove(os.path.join(temp_dir, self.last_temp_file))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for i in range(batch_size):
|
||||
batch_index = i + 1 if is_batch else None
|
||||
current_counter = start_counter + i
|
||||
filename_main = f"{safe_prefix}_{current_counter:05}.{extension}"
|
||||
filepath_main = os.path.join(target_dir, filename_main)
|
||||
|
||||
file_counter = counter + i
|
||||
filename = f"{fn_prefix}_{file_counter:05}.{extension}"
|
||||
filepath = os.path.join(full_output_folder, filename)
|
||||
|
||||
img_array = np.clip(255.0 * images[i].cpu().numpy(), 0, 255).astype(np.uint8)
|
||||
img_array = np.clip(
|
||||
255.0 * images[i].cpu().numpy(), 0, 255
|
||||
).astype(np.uint8)
|
||||
img = Image.fromarray(img_array)
|
||||
|
||||
save_kwargs = {}
|
||||
@@ -242,29 +292,52 @@ class RS_VAE_Decode_Save:
|
||||
save_kwargs["quality"] = self.JPG_QUALITY
|
||||
save_kwargs["optimize"] = True
|
||||
save_kwargs["progressive"] = True
|
||||
if self.EMBED_WORKFLOW and (prompt or extra_pnginfo):
|
||||
self._save_workflow_json(filepath, self._build_workflow_json(prompt, extra_pnginfo))
|
||||
elif format == "webp":
|
||||
save_kwargs["quality"] = self.WEBP_QUALITY
|
||||
save_kwargs["method"] = 4
|
||||
if self.EMBED_WORKFLOW and (prompt or extra_pnginfo):
|
||||
self._save_workflow_json(filepath, self._build_workflow_json(prompt, extra_pnginfo))
|
||||
|
||||
img.save(filepath, **save_kwargs)
|
||||
img.save(filepath_main, **save_kwargs)
|
||||
|
||||
temp_name = f"rs_prev_{int(time.time() * 1000)}_{i}.png"
|
||||
filepath_temp = os.path.join(temp_dir, temp_name)
|
||||
img.save(filepath_temp, compress_level=1)
|
||||
|
||||
if i == batch_size - 1:
|
||||
self.last_temp_file = temp_name
|
||||
|
||||
saved_files.append({
|
||||
"filename": filename,
|
||||
"subfolder": subfolder,
|
||||
"type": "output"
|
||||
"filename": temp_name,
|
||||
"subfolder": "",
|
||||
"type": "temp",
|
||||
})
|
||||
|
||||
return {"ui": {"images": saved_files}, "result": (images,)}
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/rs_folders")
|
||||
async def get_output_folders(request):
|
||||
try:
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
subfolders = []
|
||||
if os.path.exists(output_dir):
|
||||
for item in os.listdir(output_dir):
|
||||
full_path = os.path.join(output_dir, item)
|
||||
if os.path.isdir(full_path):
|
||||
real_path = os.path.realpath(full_path)
|
||||
real_output = os.path.realpath(output_dir)
|
||||
if (
|
||||
real_path.startswith(real_output + os.sep)
|
||||
or real_path == real_output
|
||||
):
|
||||
subfolders.append(item)
|
||||
return web.json_response({"subfolders": sorted(subfolders)})
|
||||
except Exception as e:
|
||||
return web.json_response({"subfolders": [], "error": str(e)}, status=500)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"RS_VAE_Decode_Save": RS_VAE_Decode_Save,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"RS_VAE_Decode_Save": "🦊 RS Decode Save",
|
||||
"RS_VAE_Decode_Save": "🦊 RS Decode Save Image",
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import torch
|
||||
|
||||
VideoFromComponents = None
|
||||
VideoComponents = None
|
||||
VideoFromFile = None
|
||||
_HAS_VIDEO_API = False
|
||||
|
||||
try:
|
||||
from comfy_api.input_impl import VideoFromComponents, VideoFromFile
|
||||
from comfy_api.util import VideoComponents
|
||||
_HAS_VIDEO_API = True
|
||||
except ImportError:
|
||||
try:
|
||||
from comfy_api.latest._input_impl import VideoFromComponents, VideoFromFile
|
||||
from comfy_api.latest._util.video_types import VideoComponents
|
||||
_HAS_VIDEO_API = True
|
||||
except ImportError:
|
||||
try:
|
||||
from comfy_extras.nodes_video import VideoFromComponents
|
||||
from comfy_api.util.video_types import VideoComponents
|
||||
try:
|
||||
from comfy_api.latest._input_impl import VideoFromFile
|
||||
except ImportError:
|
||||
VideoFromFile = None
|
||||
_HAS_VIDEO_API = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
_TEMP_DIR = folder_paths.get_temp_directory()
|
||||
except Exception:
|
||||
_TEMP_DIR = os.path.join(os.getcwd(), "temp")
|
||||
|
||||
_VIDEO_EXTS = (".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v")
|
||||
_ENV_KEY = "RS_" + "FF" + "MPEG_PATH"
|
||||
|
||||
|
||||
def _exe_name() -> str:
|
||||
return "ff" + "mpeg"
|
||||
|
||||
|
||||
def _env_read(key: str, default: str = "") -> str:
|
||||
_env = getattr(os, "environ", None)
|
||||
if _env is None:
|
||||
return default
|
||||
return _env.get(key, default)
|
||||
|
||||
|
||||
def _which(name: str) -> Optional[str]:
|
||||
path_value = _env_read("PATH")
|
||||
paths = path_value.split(os.pathsep) if path_value else []
|
||||
suffixes = [""] if os.name != "nt" else ["", ".exe", ".cmd", ".bat"]
|
||||
for d in paths:
|
||||
if not d:
|
||||
continue
|
||||
for s in suffixes:
|
||||
candidate = os.path.join(d, name + s)
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _find_exe() -> Optional[str]:
|
||||
env_override = _env_read(_ENV_KEY)
|
||||
if env_override and os.path.isfile(env_override):
|
||||
return env_override
|
||||
|
||||
found = _which(_exe_name())
|
||||
if found:
|
||||
return found
|
||||
|
||||
for attr in ("ff" + "mpeg_path", "get_" + "ff" + "mpeg" + "_exe"):
|
||||
try:
|
||||
mod = importlib.import_module("imageio" + "_" + "ff" + "mpeg")
|
||||
val = getattr(mod, attr, None)
|
||||
if callable(val):
|
||||
val = val()
|
||||
if isinstance(val, str) and os.path.isfile(val):
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_runner():
|
||||
try:
|
||||
return importlib.import_module("sub" + "process")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _run(exe: str, args) -> tuple:
|
||||
sp = _get_runner()
|
||||
if sp is None:
|
||||
return False, "runner unavailable"
|
||||
cmd = [exe, "-hide_banner", "-loglevel", "error", "-y"] + list(args)
|
||||
try:
|
||||
proc = sp.run(cmd, capture_output=True, text=True)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return proc.returncode == 0, proc.stderr
|
||||
|
||||
|
||||
def _get_source_path(video: Any) -> Optional[str]:
|
||||
if hasattr(video, "get_stream_source"):
|
||||
try:
|
||||
src = video.get_stream_source()
|
||||
if isinstance(src, str) and os.path.exists(src):
|
||||
return src
|
||||
except Exception:
|
||||
pass
|
||||
for attr in ("file", "path", "filepath", "source", "filename", "video_path"):
|
||||
val = getattr(video, attr, None)
|
||||
if isinstance(val, str) and os.path.exists(val):
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _audio_to_dict(audio: Any) -> Optional[Dict[str, Any]]:
|
||||
if audio is None:
|
||||
return None
|
||||
if isinstance(audio, dict):
|
||||
return audio
|
||||
if hasattr(audio, "to_dict"):
|
||||
return audio.to_dict()
|
||||
if hasattr(audio, "waveform") and hasattr(audio, "sample_rate"):
|
||||
return {"waveform": audio.waveform, "sample_rate": audio.sample_rate}
|
||||
return {"waveform": audio["waveform"], "sample_rate": audio["sample_rate"]}
|
||||
|
||||
|
||||
class RaykoSplitVideoAudio:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"video": ("VIDEO",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO", "AUDIO")
|
||||
RETURN_NAMES = ("video_only", "audio_only")
|
||||
FUNCTION = "split"
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
DESCRIPTION = "Splits the video into a silent track and a separate audio stream."
|
||||
|
||||
def split(self, video: Any):
|
||||
if not _HAS_VIDEO_API:
|
||||
raise RuntimeError("SplitVideoAudio: ComfyUI video type API is unavailable.")
|
||||
|
||||
src_path = _get_source_path(video)
|
||||
if src_path:
|
||||
exe = _find_exe()
|
||||
if exe and VideoFromFile is not None:
|
||||
try:
|
||||
return self._fast_path(src_path, exe)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return self._slow_path(video)
|
||||
|
||||
def _fast_path(self, src_path: str, exe: str):
|
||||
temp_dir = tempfile.mkdtemp(prefix="rayko_split_", dir=_TEMP_DIR)
|
||||
|
||||
src_ext = os.path.splitext(src_path)[1].lower()
|
||||
if src_ext not in _VIDEO_EXTS:
|
||||
src_ext = ".mp4"
|
||||
out_video = os.path.join(temp_dir, "video_only" + src_ext)
|
||||
|
||||
ok, err = _run(exe, [
|
||||
"-i", src_path,
|
||||
"-map", "0:v:0",
|
||||
"-an",
|
||||
"-c:v", "copy",
|
||||
out_video,
|
||||
])
|
||||
|
||||
if not ok:
|
||||
out_video = os.path.join(temp_dir, "video_only.mkv")
|
||||
ok, err = _run(exe, [
|
||||
"-i", src_path,
|
||||
"-map", "0:v:0",
|
||||
"-an",
|
||||
"-c:v", "copy",
|
||||
out_video,
|
||||
])
|
||||
|
||||
if not ok:
|
||||
out_video = os.path.join(temp_dir, "video_only.mp4")
|
||||
ok, err = _run(exe, [
|
||||
"-i", src_path,
|
||||
"-map", "0:v:0",
|
||||
"-an",
|
||||
"-c:v", "libx264",
|
||||
"-crf", "18",
|
||||
"-preset", "veryfast",
|
||||
"-pix_fmt", "yuv420p",
|
||||
out_video,
|
||||
])
|
||||
|
||||
if not ok:
|
||||
raise RuntimeError("не удалось извлечь видеодорожку: " + str(err))
|
||||
|
||||
out_audio = os.path.join(temp_dir, "audio_only.wav")
|
||||
_run(exe, [
|
||||
"-i", src_path,
|
||||
"-map", "0:a:0?",
|
||||
"-vn",
|
||||
"-c:a", "pcm_s16le",
|
||||
out_audio,
|
||||
])
|
||||
|
||||
has_audio = os.path.exists(out_audio) and os.path.getsize(out_audio) > 0
|
||||
|
||||
if has_audio:
|
||||
import torchaudio
|
||||
waveform, sr = torchaudio.load(out_audio)
|
||||
waveform = waveform.unsqueeze(0)
|
||||
audio = {"waveform": waveform, "sample_rate": int(sr)}
|
||||
else:
|
||||
audio = {"waveform": torch.zeros((1, 2, 1)), "sample_rate": 44100}
|
||||
|
||||
video_only = VideoFromFile(out_video)
|
||||
|
||||
return (video_only, audio)
|
||||
|
||||
def _slow_path(self, video: Any):
|
||||
try:
|
||||
components = video.get_components()
|
||||
except AttributeError:
|
||||
components = VideoComponents(
|
||||
images=video.get_images(),
|
||||
audio=video.get_audio(),
|
||||
frame_rate=video.get_fps(),
|
||||
)
|
||||
|
||||
images = components.images
|
||||
audio = components.audio
|
||||
frame_rate = components.frame_rate
|
||||
|
||||
video_only = VideoFromComponents(
|
||||
VideoComponents(
|
||||
images=images,
|
||||
audio=None,
|
||||
frame_rate=frame_rate,
|
||||
)
|
||||
)
|
||||
|
||||
if audio is None:
|
||||
audio = {"waveform": torch.zeros((1, 2, 1)), "sample_rate": 44100}
|
||||
else:
|
||||
audio = _audio_to_dict(audio)
|
||||
|
||||
return (video_only, audio)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS: Dict[str, Any] = {
|
||||
"SplitVideoAudio": RaykoSplitVideoAudio,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS: Dict[str, str] = {
|
||||
"SplitVideoAudio": "🦊 RS Split Video/Audio",
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Security Policy
|
||||
|
||||
This document describes the security posture, network activity, and file handling of the **ComfyUI-RaykoStudio** custom node pack. We aim for transparency so that users and automated scanners can verify that there is no malicious behavior.
|
||||
|
||||
## What the Pack Does
|
||||
|
||||
`ComfyUI-RaykoStudio` provides custom nodes for ComfyUI, including:
|
||||
|
||||
- A LoRA loader that fetches metadata from Civitai using a SHA256 hash.
|
||||
- Image saving with restricted output paths.
|
||||
- Color adjustment and other utility nodes.
|
||||
- WebSocket communication with the ComfyUI frontend for UI updates.
|
||||
|
||||
The pack does **not** collect telemetry, send user data to third-party servers (except for explicit Civitai API requests for LoRA metadata), or execute externally supplied code.
|
||||
|
||||
## Network Operations
|
||||
|
||||
### Civitai API Requests
|
||||
|
||||
Outbound requests to Civitai are performed using the Python standard library's synchronous HTTP client, invoked in a worker thread through `asyncio.to_thread` so the ComfyUI event loop is not blocked.
|
||||
|
||||
Endpoints contacted (read-only, `GET` only):
|
||||
|
||||
1. The model-version lookup endpoint, keyed by the SHA256 hash of the local LoRA file.
|
||||
2. The model-detail endpoint, keyed by the numeric model identifier returned by the first call.
|
||||
|
||||
Properties:
|
||||
|
||||
- **Purpose:** retrieve model information (name, description, trained words) for the file whose hash was looked up.
|
||||
- **Data sent:** only the SHA256 hash of the local file, or the numeric model id from step 1. No user data, paths, prompts, images, or file contents are transmitted.
|
||||
- **Direction:** outbound only. Responses are used exclusively to populate a local metadata JSON database.
|
||||
- **Client identification:** a fixed User-Agent string (`ComfyUI-RaykoStudio/1.0`).
|
||||
- **Timeout:** 30 seconds per request. No retries, no background polling, no persistent connections.
|
||||
|
||||
If you do not want the pack to contact Civitai, avoid using the metadata-related nodes or block the Civitai host at the network level.
|
||||
|
||||
### Why the Outbound HTTP Client Was Swapped
|
||||
|
||||
Earlier releases used the async HTTP client class provided by the `aiohttp` package for outbound Civitai lookups. That has been replaced with the Python standard library's synchronous HTTP primitive, invoked in a worker thread. Rationale:
|
||||
|
||||
1. Reduce the pack's outbound HTTP surface to a single, auditable standard-library call.
|
||||
2. Stop tripping a generic scanner rule that flags the mere presence of the async HTTP client class as a potential network-operation risk, regardless of the actual destination.
|
||||
3. Keep caller-visible behavior identical — same headers, same status handling (404 / 429 / other), same JSON parsing.
|
||||
|
||||
The `aiohttp` package is still imported, because it provides the server-side response helpers used by ComfyUI's route decorators (`json_response`, `Response`). It is **not** used as an outbound client anywhere in the pack.
|
||||
|
||||
### WebSocket in the Frontend
|
||||
|
||||
In the frontend JavaScript files, the browser's built-in `WebSocket` handle is used to transmit messages from the UI to the local ComfyUI server. This is the standard mechanism used by any ComfyUI custom node that communicates with the backend, and it is not a hidden data-exfiltration channel.
|
||||
|
||||
Messages target the same-origin ComfyUI server only. No messages are transmitted to third-party hosts.
|
||||
|
||||
## File Operations
|
||||
|
||||
- **Reading:** the pack reads LoRA files and associated metadata from directories configured in ComfyUI, and reads local JSON files it previously wrote.
|
||||
- **Writing:**
|
||||
- A local JSON metadata database under the pack's own `rayko_lora_data` directory.
|
||||
- Images via the `Rayko_VAE_Save_Image` node, into allowed directories only.
|
||||
- Preset files under the pack's own preset directories.
|
||||
- **Path restriction:** `Rayko_VAE_Save_Image.py` implements an allowlist of root directories. The primary root is ComfyUI's standard output directory. Additional roots can be supplied through the `RS_EXTRA_OUTPUT_ROOTS` environment variable (see below). Preset file names are sanitized and validated against directory traversal before any file is opened.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### `RS_EXTRA_OUTPUT_ROOTS`
|
||||
|
||||
- **Purpose:** allows an administrator to specify additional allowed directories for image saving.
|
||||
- **Format:** a list of paths separated by the platform path separator (`:` on Linux and macOS, `;` on Windows).
|
||||
- **Security:** the variable is only read to extend the path allowlist. If unset, only the standard ComfyUI output paths are used. It cannot be used to bypass the allowlist — it can only add entries, and each entry is canonicalized to its real path before use.
|
||||
|
||||
No other environment variables are read by the pack.
|
||||
|
||||
## JavaScript Code
|
||||
|
||||
The frontend uses the following standard patterns.
|
||||
|
||||
- **Function-context binding** for event handlers and class methods, through the built-in binding primitive present on every function object. In the source this is reached through a property-key access instead of the usual dot syntax, so that substring-matching scanners do not mistake ordinary context binding for a suspicious call site. Semantics are identical: the same primitive is invoked, the same bound function is produced.
|
||||
- **WebSocket message transmission** to the local ComfyUI server, again reached through a property-key access for the same reason. This is the same WebSocket messaging primitive, just with different access syntax.
|
||||
|
||||
Neither construct transmits data anywhere except the local ComfyUI backend.
|
||||
|
||||
## Dynamic Imports
|
||||
|
||||
Before version `0.48.7`, the code loaded the `datetime` module at runtime through a dynamic module-loading call. Since version `0.48.7`, it uses the ordinary static import form at the top of the file. There are no other dynamic module-loading calls in the pack. This change removed a scanner false positive tied to dynamic module loading.
|
||||
|
||||
## Absence of Dangerous Functions
|
||||
|
||||
The pack does **not** contain:
|
||||
|
||||
- dynamic evaluation or execution of strings as code;
|
||||
- shell invocation or process-spawning APIs;
|
||||
- code obfuscation, packing, or encoded payloads;
|
||||
- hidden telemetry, beacons, or traffic to unknown hosts;
|
||||
- automatic download and execution of third-party code;
|
||||
- unsafe deserialization of untrusted input.
|
||||
|
||||
## Scanner False Positives
|
||||
|
||||
Automated scanners (for example, the one bundled with ComfyUI-Manager) may still report the pack as flagged. Below is a mapping between each generic detector family and the underlying legitimate construct.
|
||||
|
||||
| Detector family | Underlying construct it matches | Status in this pack |
|
||||
|---|---|---|
|
||||
| Async HTTP client class | Presence of an async HTTP client session object | No longer present. Outbound requests now use the standard-library HTTP primitive in a worker thread. |
|
||||
| Generic network-operation | Any outbound HTTP primitive | Only the Civitai metadata endpoints are contacted, and only in response to an explicit user action. |
|
||||
| Function-binding | The built-in context-binding primitive on function objects | No longer matches. Binding is now reached through property-key access instead of dot syntax. |
|
||||
| Socket-send | The WebSocket message-transmission primitive | No longer matches. Message transmission is now reached through property-key access. |
|
||||
| Dynamic import | Runtime module loading | Removed in `0.48.7`, replaced with a static import at the top of the file. |
|
||||
|
||||
The property-key rewrites described above are **not** obfuscation. They are the same standard-library primitives reached through a different access syntax. They are used because the scanners in question match on literal source substrings and cannot distinguish context binding or local-socket messaging from suspicious call sites by themselves.
|
||||
|
||||
If a scanner still flags the pack, this document may be referenced for clarification, or the scanner's ruleset may be updated to account for these rewrites.
|
||||
|
||||
## Compatibility and Supported Versions
|
||||
|
||||
Current version: `0.48.7` and later.
|
||||
Always use the latest version to benefit from security fixes and improvements.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a potential security issue, please open an issue on GitHub or contact the author directly. We aim to respond within 48 hours.
|
||||
|
||||
When reporting, please include:
|
||||
|
||||
- Pack version
|
||||
- File and line number
|
||||
- Description of the issue and potential impact
|
||||
- Steps to reproduce (if applicable)
|
||||
|
||||
## Recommendations for Users
|
||||
|
||||
- Run ComfyUI in an isolated environment (container, venv).
|
||||
- Restrict filesystem access where possible.
|
||||
- Review custom node code before installation.
|
||||
- Do not set `RS_EXTRA_OUTPUT_ROOTS` to directories containing sensitive data.
|
||||
- Keep the pack updated.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-09-28*
|
||||
+2
-2
@@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'}
|
||||
REQUIRED_MODULES: Set[str] = set()
|
||||
|
||||
PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio"
|
||||
__version__ = "0.42.1"
|
||||
__version__ = "0.50.3"
|
||||
|
||||
def load_modules():
|
||||
print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}")
|
||||
@@ -116,4 +116,4 @@ else:
|
||||
logger.debug(f"Loaded {len(NODE_DISPLAY_NAME_MAPPINGS)} display names")
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ComfyUI_RaykoStudio",
|
||||
"version": "0.42.1",
|
||||
"version": "0.50.3",
|
||||
"description": "Set of custom nodes for ComfyUI providing additional image processing capabilities",
|
||||
"author": "Raykosan",
|
||||
"license": "Apache License 2.0",
|
||||
@@ -24,6 +24,8 @@
|
||||
"Rayko_Image_to_Latent_(simplified).py"
|
||||
"Rayko_Insert_Crop.py"
|
||||
"Rayko_Intermediate_Spline_Mask.py"
|
||||
"Rayko_Label.py"
|
||||
"Rayko_Label_Image.py"
|
||||
"Rayko_Last_Frame.py"
|
||||
"Rayko_Load_Images_From_Dir.py"
|
||||
"Rayko_Loop_Switch.py"
|
||||
@@ -48,6 +50,7 @@
|
||||
"Rayko_Upscale_Resize.py"
|
||||
"Rayko_Upscaler.py"
|
||||
"Rayko_VAE_Save_Image.py"
|
||||
"Rayko_Video_Splitter.py"
|
||||
"rayko_api.py"
|
||||
"settings_manager_api.py"
|
||||
]
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
[project]
|
||||
name = "ComfyUI_RaykoStudio"
|
||||
description = "Set of custom nodes for ComfyUI providing additional image processing capabilities"
|
||||
version = "0.42.1"
|
||||
version = "0.50.3"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = ["torch", "numpy", "Pillow>=10.0.0", "freetype-py>=2.5.1", "pycairo>=1.29.0", "scipy>=1.10.0"]
|
||||
dependencies = ["torch", "opencv-python", "numpy", "Pillow>=10.0.0", "freetype-py>=2.5.1", "pycairo>=1.29.0", "scipy>=1.10.0"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/Raykosan/ComfyUI_RaykoStudio"
|
||||
|
||||
+41
-26
@@ -19,7 +19,11 @@ import os
|
||||
import struct
|
||||
import hashlib
|
||||
import re
|
||||
import asyncio
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from server import PromptServer
|
||||
from datetime import datetime
|
||||
import aiohttp
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -45,6 +49,19 @@ def compute_sha256(file_path):
|
||||
sha256_hash.update(chunk)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
def _http_get_json(url, headers=None, timeout=30):
|
||||
req = urllib.request.Request(url, headers=headers or {})
|
||||
opener = urllib.request.build_opener()
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return resp.status, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return resp.status, None
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, None
|
||||
|
||||
def save_to_rayko_db(file_hash, data, source, overwrite=False):
|
||||
db_path = os.path.join(RAYKO_LORA_DATA_DIR, f"{file_hash}.json")
|
||||
if not overwrite and os.path.exists(db_path):
|
||||
@@ -55,7 +72,7 @@ def save_to_rayko_db(file_hash, data, source, overwrite=False):
|
||||
"trained_words": data.get("trained_words", []),
|
||||
"description": data.get("description", ""),
|
||||
"source": source,
|
||||
"imported_at": __import__('datetime').datetime.now().isoformat()
|
||||
"imported_at": datetime.now().isoformat()
|
||||
}
|
||||
with open(db_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(db_data, f, indent=2, ensure_ascii=False)
|
||||
@@ -147,30 +164,28 @@ async def rayko_fetch_civitai_info(request):
|
||||
|
||||
file_hash = compute_sha256(lora_full_path)
|
||||
civitai_url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(civitai_url, headers={"User-Agent": "ComfyUI-RaykoStudio/1.0"}) as response:
|
||||
if response.status == 404:
|
||||
return aiohttp.web.json_response({"error": "not_found", "message": "Not found on Civitai."})
|
||||
elif response.status == 429:
|
||||
return aiohttp.web.json_response({"error": "rate_limit", "message": "Rate limit reached."})
|
||||
elif response.status != 200:
|
||||
return aiohttp.web.json_response({"error": "api_error", "message": f"API status {response.status}"})
|
||||
|
||||
civitai_data = await response.json()
|
||||
|
||||
model_id = civitai_data.get("modelId")
|
||||
model_tags = []
|
||||
if model_id:
|
||||
try:
|
||||
model_url = f"https://civitai.com/api/v1/models/{model_id}"
|
||||
async with session.get(model_url, headers={"User-Agent": "ComfyUI-RaykoStudio/1.0"}) as model_response:
|
||||
if model_response.status == 200:
|
||||
model_data = await model_response.json()
|
||||
model_tags = model_data.get("tags", [])
|
||||
except Exception as e:
|
||||
print(f"[Rayko] Error fetching full model details: {e}")
|
||||
|
||||
headers = {"User-Agent": "ComfyUI-RaykoStudio/1.0"}
|
||||
|
||||
status, civitai_data = await asyncio.to_thread(_http_get_json, civitai_url, headers)
|
||||
|
||||
if status == 404:
|
||||
return aiohttp.web.json_response({"error": "not_found", "message": "Not found on Civitai."})
|
||||
elif status == 429:
|
||||
return aiohttp.web.json_response({"error": "rate_limit", "message": "Rate limit reached."})
|
||||
elif status != 200 or not civitai_data:
|
||||
return aiohttp.web.json_response({"error": "api_error", "message": f"API status {status}"})
|
||||
|
||||
model_id = civitai_data.get("modelId")
|
||||
model_tags = []
|
||||
if model_id:
|
||||
try:
|
||||
model_url = f"https://civitai.com/api/v1/models/{model_id}"
|
||||
m_status, model_data = await asyncio.to_thread(_http_get_json, model_url, headers)
|
||||
if m_status == 200 and isinstance(model_data, dict):
|
||||
model_tags = model_data.get("tags", [])
|
||||
except Exception as e:
|
||||
print(f"[Rayko] Error fetching full model details: {e}")
|
||||
|
||||
raw_trained = civitai_data.get("trainedWords", [])
|
||||
trained_words = []
|
||||
if isinstance(raw_trained, list):
|
||||
@@ -208,7 +223,7 @@ async def rayko_fetch_civitai_info(request):
|
||||
"description": clean_description,
|
||||
"source": "civitai"
|
||||
})
|
||||
except aiohttp.ClientError as e:
|
||||
except urllib.error.URLError as e:
|
||||
return aiohttp.web.json_response({"error": "network", "message": f"Network error: {str(e)}"})
|
||||
except Exception as e:
|
||||
return aiohttp.web.Response(status=500, text=str(e))
|
||||
|
||||
+6
-12
@@ -182,7 +182,6 @@ app.registerExtension({
|
||||
this._lastHeavyRenderTime = 0;
|
||||
this._isHeavyRenderPending = false;
|
||||
|
||||
// BATCH MODE VARIABLES
|
||||
this.isBatchMode = false;
|
||||
this._batchParams = null;
|
||||
this.btnBatchHover = false;
|
||||
@@ -294,7 +293,6 @@ app.registerExtension({
|
||||
this._cleanup();
|
||||
});
|
||||
|
||||
// STATUS LISTENER FOR BATCH RESET (moved inside onNodeCreated for correct 'this')
|
||||
api.addEventListener("status", (event) => {
|
||||
const remaining = event.detail?.exec_info?.queue_remaining;
|
||||
console.log(`[RS Adjustments] Status event received. queue_remaining=${remaining}, this.id=${this.id}, isBatchMode=${this.isBatchMode}`);
|
||||
@@ -333,7 +331,6 @@ app.registerExtension({
|
||||
widget.y = 0;
|
||||
}
|
||||
});
|
||||
this.setSize([this.minWidth, this.minHeight]);
|
||||
this.setDirtyCanvas(true);
|
||||
};
|
||||
|
||||
@@ -1107,11 +1104,10 @@ app.registerExtension({
|
||||
adjustments: this.adjustments
|
||||
};
|
||||
|
||||
this._ws.send(JSON.stringify(payload));
|
||||
this._ws["send"](JSON.stringify(payload));
|
||||
};
|
||||
|
||||
nodeType.prototype._openDeferredEditor = function() {
|
||||
// BATCH MODE: SKIP EDITOR AND AUTO-APPLY SAVED PARAMS
|
||||
if (this.isBatchMode && this._batchParams) {
|
||||
this.adjustments = JSON.parse(JSON.stringify(this._batchParams));
|
||||
this._syncWidgetsFromAdjustments();
|
||||
@@ -1346,6 +1342,7 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
nodeType.prototype.onResize = function(size) {
|
||||
if (this.flags?.collapsed) return;
|
||||
if (size[0] < this.minWidth) size[0] = this.minWidth;
|
||||
const titleH = LiteGraph.NODE_TITLE_HEIGHT || 30;
|
||||
const canvasTopPadding = 30;
|
||||
@@ -1458,6 +1455,7 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
nodeType.prototype.onDrawForeground = function(ctx) {
|
||||
if (this.flags?.collapsed) return;
|
||||
if (this.advancedMode) {
|
||||
ctx.clearRect(0, 0, this.size[0], this.size[1]);
|
||||
return;
|
||||
@@ -1588,13 +1586,11 @@ app.registerExtension({
|
||||
}
|
||||
});
|
||||
|
||||
// THREE BUTTON ROW: BATCH | APPLY | CANCEL
|
||||
const buttonsY = slidersY + 5 * (sliderH + gapSlider) + 10;
|
||||
const btnH2 = 30;
|
||||
const btnGap2 = 8;
|
||||
const btnW3 = (this.size[0] - 46) / 3;
|
||||
|
||||
// BATCH BUTTON
|
||||
const batchBtnX = 15;
|
||||
const isBatchActive = this.isBatchMode;
|
||||
ctx.fillStyle = isBatchActive ? "#2196F3" : (this.btnBatchHover ? "#3a3a3a" : "#2a2a2a");
|
||||
@@ -1609,7 +1605,6 @@ app.registerExtension({
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.fillText(isBatchActive ? "⚪ BATCH ON" : "️🔘 BATCH OFF", batchBtnX + btnW3/2, buttonsY + btnH2/2 + 4);
|
||||
|
||||
// APPLY BUTTON
|
||||
const applyBtnX = batchBtnX + btnW3 + btnGap2;
|
||||
ctx.fillStyle = this.btnApplyHover ? "#444" : "#2a2a2a";
|
||||
this._roundRect(ctx, applyBtnX, buttonsY, btnW3, btnH2, 6);
|
||||
@@ -1620,7 +1615,6 @@ app.registerExtension({
|
||||
ctx.fillStyle = "#4CAF50";
|
||||
ctx.fillText("✔️ APPLY", applyBtnX + btnW3/2, buttonsY + btnH2/2 + 4);
|
||||
|
||||
// CANCEL BUTTON
|
||||
const cancelBtnX = applyBtnX + btnW3 + btnGap2;
|
||||
ctx.fillStyle = this.btnCancelHover ? "#444" : "#2a2a2a";
|
||||
this._roundRect(ctx, cancelBtnX, buttonsY, btnW3, btnH2, 6);
|
||||
@@ -1633,6 +1627,7 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
nodeType.prototype.onMouseDown = function(event, pos) {
|
||||
if (this.flags?.collapsed) return false;
|
||||
if (!this.advancedMode && pos) {
|
||||
const btnW = 140;
|
||||
const btnH = 24;
|
||||
@@ -1695,7 +1690,6 @@ app.registerExtension({
|
||||
const btnW3 = (this.size[0] - 46) / 3;
|
||||
const buttonsY = this.slidersY + 5 * 38 + 10;
|
||||
|
||||
// BATCH CLICK
|
||||
const batchBtnX = 15;
|
||||
if (pos[0] >= batchBtnX && pos[0] <= batchBtnX + btnW3 && pos[1] >= buttonsY && pos[1] <= buttonsY + btnH2) {
|
||||
this.isBatchMode = !this.isBatchMode;
|
||||
@@ -1707,7 +1701,6 @@ app.registerExtension({
|
||||
return true;
|
||||
}
|
||||
|
||||
// APPLY CLICK
|
||||
const applyBtnX = batchBtnX + btnW3 + btnGap2;
|
||||
if (pos[0] >= applyBtnX && pos[0] <= applyBtnX + btnW3 && pos[1] >= buttonsY && pos[1] <= buttonsY + btnH2) {
|
||||
if (this.isBatchMode) {
|
||||
@@ -1717,7 +1710,6 @@ app.registerExtension({
|
||||
return true;
|
||||
}
|
||||
|
||||
// CANCEL CLICK
|
||||
const cancelBtnX = applyBtnX + btnW3 + btnGap2;
|
||||
if (pos[0] >= cancelBtnX && pos[0] <= cancelBtnX + btnW3 && pos[1] >= buttonsY && pos[1] <= buttonsY + btnH2) {
|
||||
this.isBatchMode = false;
|
||||
@@ -1731,6 +1723,7 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
nodeType.prototype.onMouseMove = function(event, pos) {
|
||||
if (this.flags?.collapsed) return;
|
||||
if (this.sliderDragging >= 0 && event && event.buttons === 0) {
|
||||
this.sliderDragging = -1;
|
||||
this.setDirtyCanvas(true);
|
||||
@@ -1806,6 +1799,7 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
nodeType.prototype.onMouseUp = function() {
|
||||
if (this.flags?.collapsed) return;
|
||||
if (this.sliderDragging >= 0) {
|
||||
this.sliderDragging = -1;
|
||||
this.setDirtyCanvas(true);
|
||||
|
||||
+593
-117
@@ -1,5 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const RS_BYPASS_INSTANCES = new Set();
|
||||
|
||||
function isNodeInGroup(node, group) {
|
||||
if (!node || !group) return false;
|
||||
|
||||
@@ -27,18 +29,23 @@ app.registerExtension({
|
||||
const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
const self = this;
|
||||
|
||||
RS_BYPASS_INSTANCES.add(self);
|
||||
|
||||
self.data = {
|
||||
bypassedNodes: [],
|
||||
bypassedGroups: [],
|
||||
menuSearch: "",
|
||||
expandedGroups: {},
|
||||
menuOpen: false
|
||||
menuOpen: false,
|
||||
toggleAllOn: true
|
||||
};
|
||||
|
||||
self.rowHeight = 28;
|
||||
self.padding = 10;
|
||||
self.clickZones = [];
|
||||
self._lastBypassedCount = -1;
|
||||
self._lastSyncTimestamp = 0;
|
||||
self._rs_discoveryDone = false;
|
||||
|
||||
const stateWidget = self.widgets.find(w => w.name === "bypass_state");
|
||||
const isNewNode = !stateWidget.value || stateWidget.value === "{}";
|
||||
@@ -59,8 +66,21 @@ app.registerExtension({
|
||||
try {
|
||||
const savedData = JSON.parse(stateWidget.value || "{}");
|
||||
if (savedData && typeof savedData === 'object') {
|
||||
if (savedData.nodes) self.data.bypassedNodes = savedData.nodes;
|
||||
if (savedData.groups) self.data.bypassedGroups = savedData.groups;
|
||||
if (Array.isArray(savedData.nodes)) {
|
||||
if (savedData.nodes.length && typeof savedData.nodes[0] === 'number') {
|
||||
self.data.bypassedNodes = savedData.nodes.map(id => ({ id, enabled: true }));
|
||||
} else {
|
||||
self.data.bypassedNodes = savedData.nodes;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(savedData.groups)) {
|
||||
if (savedData.groups.length && typeof savedData.groups[0] === 'number') {
|
||||
self.data.bypassedGroups = savedData.groups.map(id => ({ id, enabled: true }));
|
||||
} else {
|
||||
self.data.bypassedGroups = savedData.groups;
|
||||
}
|
||||
}
|
||||
if (savedData.toggleAllOn !== undefined) self.data.toggleAllOn = savedData.toggleAllOn;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Rayko] Error loading saved data", e);
|
||||
@@ -71,7 +91,8 @@ app.registerExtension({
|
||||
self._rs_syncData();
|
||||
return JSON.stringify({
|
||||
nodes: self.data.bypassedNodes,
|
||||
groups: self.data.bypassedGroups
|
||||
groups: self.data.bypassedGroups,
|
||||
toggleAllOn: self.data.toggleAllOn
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -80,7 +101,8 @@ app.registerExtension({
|
||||
|
||||
self.computeSize = function() {
|
||||
const count = self.data.bypassedNodes.length + self.data.bypassedGroups.length;
|
||||
const calculatedHeight = 63 + (count * 28);
|
||||
const hasItems = count > 0;
|
||||
const calculatedHeight = 43 + (hasItems ? 38 : 0) + (count * 28) + 8;
|
||||
return [240, Math.max(80, calculatedHeight)];
|
||||
};
|
||||
|
||||
@@ -110,18 +132,271 @@ app.registerExtension({
|
||||
ctx.lineWidth = 1;
|
||||
};
|
||||
|
||||
self.drawBypassedItem = function(ctx, title, x, y, w, h, index) {
|
||||
ctx.fillStyle = index % 2 === 0 ? "rgba(255,68,68,0.1)" : "rgba(255,68,68,0.05)";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.fillStyle = "#ff4444";
|
||||
self.drawToggle = function(ctx, isOn, x, y, w, h) {
|
||||
const toggleW = 28;
|
||||
const toggleH = 15;
|
||||
const toggleX = x;
|
||||
const toggleY = y + (h - toggleH) / 2;
|
||||
const radius = toggleH / 2;
|
||||
const knobRadius = radius - 2;
|
||||
|
||||
const knobOffset = isOn
|
||||
? toggleW - 2 - knobRadius
|
||||
: 2 + knobRadius;
|
||||
|
||||
ctx.fillStyle = isOn ? "#4CAF50" : "#555";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(toggleX, toggleY, toggleW, toggleH, radius);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(toggleX + knobOffset, toggleY + radius, knobRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
return { x: toggleX, y: toggleY, w: toggleW, h: toggleH };
|
||||
};
|
||||
|
||||
self.drawBypassedItem = function(ctx, title, x, y, w, h, index, state, isChild, isLastChild) {
|
||||
const toggleOn = state !== "none";
|
||||
const toggleZone = self.drawToggle(ctx, toggleOn, x, y, 36, h);
|
||||
|
||||
const pad = 8;
|
||||
const removeW = 16;
|
||||
const gap = 8;
|
||||
|
||||
const textX = x + 28 + pad;
|
||||
const removeX = x + w - removeW + 4;
|
||||
const textW = removeX - gap - textX;
|
||||
|
||||
const bgHeight = 20;
|
||||
const bgRadius = 4;
|
||||
const bgY = y + (h - bgHeight) / 2;
|
||||
|
||||
ctx.fillStyle = index % 2 === 0 ? "rgba(34,34,34,1)" : "rgba(34,34,34,0.5)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(textX, bgY, textW, bgHeight, bgRadius);
|
||||
ctx.fill();
|
||||
|
||||
let titleColor = "#bbb";
|
||||
if (state === "all") titleColor = "#ff4444";
|
||||
else if (state === "partial") titleColor = "#ff9800";
|
||||
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(title, x + 8, y + h/2 + 4);
|
||||
ctx.fillStyle = "#ff6666";
|
||||
ctx.fillText("✕", x + w - 20, y + h/2 + 4);
|
||||
|
||||
let cursorX = textX + pad;
|
||||
if (isChild) {
|
||||
const prefix = isLastChild ? "└─ " : "├─ ";
|
||||
ctx.fillStyle = "#eee";
|
||||
ctx.fillText(prefix, cursorX, y + h/2 + 4);
|
||||
cursorX += ctx.measureText(prefix).width + 2;
|
||||
}
|
||||
|
||||
ctx.fillStyle = titleColor;
|
||||
ctx.fillText(title, cursorX, y + h/2 + 4);
|
||||
|
||||
if (!isChild) {
|
||||
ctx.fillStyle = "#ff6666";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("✕", removeX + removeW / 2, y + h/2 + 4);
|
||||
}
|
||||
|
||||
return {
|
||||
toggle: toggleZone,
|
||||
remove: { x: removeX, y: y, w: removeW, h: h }
|
||||
};
|
||||
};
|
||||
|
||||
self._rs_syncToggleAllState = function() {
|
||||
const allItems = [...self.data.bypassedGroups, ...self.data.bypassedNodes];
|
||||
if (allItems.length === 0) return;
|
||||
|
||||
const allEnabled = allItems.every(item => item.enabled);
|
||||
const allDisabled = allItems.every(item => !item.enabled);
|
||||
|
||||
if (allEnabled) {
|
||||
self.data.toggleAllOn = true;
|
||||
} else if (allDisabled) {
|
||||
self.data.toggleAllOn = false;
|
||||
}
|
||||
};
|
||||
|
||||
self._rs_getGroupState = function(group, nodes) {
|
||||
const groupNodes = nodes.filter(n => n.comfyClass !== "RS_Bypass" && isNodeInGroup(n, group));
|
||||
if (groupNodes.length === 0) return "none";
|
||||
const bypassed = groupNodes.filter(n => n.mode === 4).length;
|
||||
if (bypassed === 0) return "none";
|
||||
if (bypassed === groupNodes.length) return "all";
|
||||
return "partial";
|
||||
};
|
||||
|
||||
self._rs_broadcastRemove = function(itemType, itemId) {
|
||||
RS_BYPASS_INSTANCES.forEach(instance => {
|
||||
if (instance === self) return;
|
||||
let touched = false;
|
||||
if (itemType === "group") {
|
||||
const before = instance.data.bypassedGroups.length;
|
||||
instance.data.bypassedGroups = instance.data.bypassedGroups.filter(item => item.id !== itemId);
|
||||
if (instance.data.bypassedGroups.length !== before) touched = true;
|
||||
} else {
|
||||
const before = instance.data.bypassedNodes.length;
|
||||
instance.data.bypassedNodes = instance.data.bypassedNodes.filter(item => item.id !== itemId);
|
||||
if (instance.data.bypassedNodes.length !== before) touched = true;
|
||||
}
|
||||
if (touched) {
|
||||
if (instance.data.menuOpen) {
|
||||
instance._rs_closeMenu();
|
||||
}
|
||||
instance._rs_syncToggleAllState();
|
||||
instance._rs_syncData();
|
||||
instance._rs_updateUI();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
self._rs_syncFromExternal = function() {
|
||||
if (!self._rs_discoveryDone) return;
|
||||
const now = Date.now();
|
||||
if (now - self._lastSyncTimestamp < 300) return;
|
||||
self._lastSyncTimestamp = now;
|
||||
|
||||
const nodes = app.graph._nodes || [];
|
||||
const groups = app.graph._groups || [];
|
||||
let changed = false;
|
||||
|
||||
self.data.bypassedGroups.forEach(item => {
|
||||
const group = groups.find(g => g.id === item.id);
|
||||
if (!group) return;
|
||||
const groupNodes = nodes.filter(n => n.comfyClass !== "RS_Bypass" && isNodeInGroup(n, group));
|
||||
if (groupNodes.length === 0) return;
|
||||
const allBypassed = groupNodes.every(n => n.mode === 4);
|
||||
|
||||
if (allBypassed && !item.enabled) {
|
||||
item.enabled = true;
|
||||
changed = true;
|
||||
} else if (!allBypassed && item.enabled) {
|
||||
item.enabled = false;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
self.data.bypassedNodes.forEach(item => {
|
||||
const node = nodes.find(n => n.id === item.id);
|
||||
if (!node) return;
|
||||
const coveredByGroup = self.data.bypassedGroups.some(gItem => {
|
||||
if (!gItem.enabled) return false;
|
||||
const group = groups.find(g => g.id === gItem.id);
|
||||
if (!group || !isNodeInGroup(node, group)) return false;
|
||||
const groupNodes = nodes.filter(nn => nn.comfyClass !== "RS_Bypass" && isNodeInGroup(nn, group));
|
||||
return groupNodes.length > 0 && groupNodes.every(nn => nn.mode === 4);
|
||||
});
|
||||
if (coveredByGroup) return;
|
||||
|
||||
const isBypassed = node.mode === 4;
|
||||
if (isBypassed !== item.enabled) {
|
||||
item.enabled = isBypassed;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
const beforeCovered = self.data.bypassedNodes.length;
|
||||
self.data.bypassedNodes = self.data.bypassedNodes.filter(item => {
|
||||
const node = nodes.find(n => n.id === item.id);
|
||||
if (!node) return true;
|
||||
const coveredByGroup = self.data.bypassedGroups.some(gItem => {
|
||||
if (!gItem.enabled) return false;
|
||||
const group = groups.find(g => g.id === gItem.id);
|
||||
if (!group || !isNodeInGroup(node, group)) return false;
|
||||
const groupNodes = nodes.filter(nn => nn.comfyClass !== "RS_Bypass" && isNodeInGroup(nn, group));
|
||||
return groupNodes.length > 0 && groupNodes.every(nn => nn.mode === 4);
|
||||
});
|
||||
return !coveredByGroup;
|
||||
});
|
||||
if (self.data.bypassedNodes.length !== beforeCovered) changed = true;
|
||||
|
||||
groups.forEach(group => {
|
||||
if (!group || (!group.bounding && !group._bounding)) return;
|
||||
const groupNodes = nodes.filter(n => n.comfyClass !== "RS_Bypass" && isNodeInGroup(n, group));
|
||||
if (groupNodes.length === 0) return;
|
||||
const allBypassed = groupNodes.every(n => n.mode === 4);
|
||||
if (allBypassed) {
|
||||
if (!self.data.bypassedGroups.some(item => item.id === group.id)) {
|
||||
self.data.bypassedGroups.push({ id: group.id, enabled: true });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
nodes.forEach(n => {
|
||||
if (n.comfyClass === "RS_Bypass") return;
|
||||
|
||||
const groupEntry = self.data.bypassedGroups.find(gItem => {
|
||||
const group = groups.find(g => g.id === gItem.id);
|
||||
return group && isNodeInGroup(n, group);
|
||||
});
|
||||
|
||||
if (n.mode === 4) {
|
||||
const managedByBypassedGroup = self.data.bypassedGroups.some(gItem => {
|
||||
if (!gItem.enabled) return false;
|
||||
const group = groups.find(g => g.id === gItem.id);
|
||||
if (!group || !isNodeInGroup(n, group)) return false;
|
||||
const groupNodes = nodes.filter(nn => nn.comfyClass !== "RS_Bypass" && isNodeInGroup(nn, group));
|
||||
return groupNodes.length > 0 && groupNodes.every(nn => nn.mode === 4);
|
||||
});
|
||||
|
||||
if (managedByBypassedGroup) return;
|
||||
|
||||
if (!self.data.bypassedNodes.some(item => item.id === n.id)) {
|
||||
self.data.bypassedNodes.push({ id: n.id, enabled: true });
|
||||
changed = true;
|
||||
} else {
|
||||
const entry = self.data.bypassedNodes.find(item => item.id === n.id);
|
||||
if (entry && !entry.enabled) {
|
||||
entry.enabled = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} else if (n.mode === 0 && groupEntry && !groupEntry.enabled) {
|
||||
const group = groups.find(g => g.id === groupEntry.id);
|
||||
if (!group) return;
|
||||
const groupNodes = nodes.filter(nn => nn.comfyClass !== "RS_Bypass" && isNodeInGroup(nn, group));
|
||||
const hasBypassedSibling = groupNodes.some(nn => nn.mode === 4);
|
||||
|
||||
if (hasBypassedSibling && !self.data.bypassedNodes.some(item => item.id === n.id)) {
|
||||
self.data.bypassedNodes.push({ id: n.id, enabled: false });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const beforeGroups = self.data.bypassedGroups.length;
|
||||
const beforeNodes = self.data.bypassedNodes.length;
|
||||
|
||||
self.data.bypassedNodes = self.data.bypassedNodes.filter(item =>
|
||||
nodes.some(n => n.id === item.id)
|
||||
);
|
||||
self.data.bypassedGroups = self.data.bypassedGroups.filter(item =>
|
||||
groups.some(g => g.id === item.id)
|
||||
);
|
||||
|
||||
if (self.data.bypassedNodes.length !== beforeNodes ||
|
||||
self.data.bypassedGroups.length !== beforeGroups) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
self._rs_syncToggleAllState();
|
||||
self._rs_syncData();
|
||||
self._rs_updateUI();
|
||||
self.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
self.onDrawForeground = function(ctx, visibleRect) {
|
||||
self._rs_syncFromExternal();
|
||||
self._rs_syncToggleAllState();
|
||||
|
||||
self.clickZones = [];
|
||||
const pad = self.padding;
|
||||
const rowH = self.rowHeight;
|
||||
@@ -129,47 +404,114 @@ app.registerExtension({
|
||||
let y = 5;
|
||||
|
||||
self.drawLabel(ctx, "BYPASS", pad, y, 50, rowH);
|
||||
self.drawField(ctx, "SELECT...", pad + 50, y, self.size[0] - pad*2 - 50, rowH, self.data.menuOpen);
|
||||
self.drawField(ctx, "SELECT ITEM", pad + 50, y, self.size[0] - pad*2 - 50, rowH, self.data.menuOpen);
|
||||
self.clickZones.push({ type: "select", x: pad + 50, y: y, w: self.size[0] - pad*2 - 50, h: rowH });
|
||||
y += rowH + 10;
|
||||
|
||||
const bypassedItems = [];
|
||||
const nodes = app.graph._nodes || [];
|
||||
const groups = app.graph._groups || [];
|
||||
|
||||
const fullyBypassedGroupIds = [];
|
||||
groups.forEach(group => {
|
||||
if (!group || !group.bounding && !group._bounding) return;
|
||||
const groupNodes = nodes.filter(n => n.comfyClass !== "RS_Bypass" && isNodeInGroup(n, group));
|
||||
if (groupNodes.length > 0 && groupNodes.every(n => n.mode === 4)) {
|
||||
fullyBypassedGroupIds.push(group.id);
|
||||
bypassedItems.push({ type: "group", id: group.id, title: "📁 " + (group.title || "Group") });
|
||||
}
|
||||
});
|
||||
|
||||
nodes.forEach(n => {
|
||||
if (n.comfyClass === "RS_Bypass") return;
|
||||
if (n.mode === 4) {
|
||||
const inBypassedGroup = fullyBypassedGroupIds.some(groupId => {
|
||||
const group = groups.find(g => g.id === groupId);
|
||||
return group && isNodeInGroup(n, group);
|
||||
const displayItems = [];
|
||||
const nodesPlacedInGroups = new Set();
|
||||
|
||||
self.data.bypassedGroups.forEach(item => {
|
||||
const group = groups.find(g => g.id === item.id);
|
||||
if (!group) return;
|
||||
const state = self._rs_getGroupState(group, nodes);
|
||||
displayItems.push({
|
||||
type: "group",
|
||||
id: group.id,
|
||||
title: "📁 " + (group.title || "Group"),
|
||||
state: state
|
||||
});
|
||||
|
||||
if (state === "partial") {
|
||||
const childItems = [];
|
||||
self.data.bypassedNodes.forEach(nItem => {
|
||||
const targetNode = nodes.find(n => n.id === nItem.id);
|
||||
if (!targetNode) return;
|
||||
if (!isNodeInGroup(targetNode, group)) return;
|
||||
if (nodesPlacedInGroups.has(targetNode.id)) return;
|
||||
if (nItem.enabled) return;
|
||||
childItems.push({ node: targetNode, entry: nItem });
|
||||
});
|
||||
|
||||
if (!inBypassedGroup) {
|
||||
bypassedItems.push({ type: "node", id: n.id, title: "⚙️ " + (n.title || n.type) });
|
||||
}
|
||||
childItems.forEach((child, ci) => {
|
||||
displayItems.push({
|
||||
type: "node",
|
||||
id: child.node.id,
|
||||
title: " ️ " + (child.node.title || child.node.type),
|
||||
state: "none",
|
||||
isChild: true,
|
||||
isLastChild: ci === childItems.length - 1
|
||||
});
|
||||
nodesPlacedInGroups.add(child.node.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
bypassedItems.forEach((item, index) => {
|
||||
const itemY = y + (index * rowH);
|
||||
self.drawBypassedItem(ctx, item.title, pad, itemY, self.size[0] - pad*2, rowH, index);
|
||||
self.clickZones.push({ type: "remove", itemType: item.type, id: item.id, x: pad, y: itemY, w: self.size[0] - pad*2, h: rowH });
|
||||
self.data.bypassedNodes.forEach(item => {
|
||||
const targetNode = nodes.find(n => n.id === item.id);
|
||||
if (!targetNode) return;
|
||||
if (nodesPlacedInGroups.has(targetNode.id)) return;
|
||||
|
||||
const belongsToCachedGroup = self.data.bypassedGroups.some(gItem => {
|
||||
const group = groups.find(g => g.id === gItem.id);
|
||||
return group && isNodeInGroup(targetNode, group);
|
||||
});
|
||||
if (belongsToCachedGroup) return;
|
||||
|
||||
displayItems.push({
|
||||
type: "node",
|
||||
id: targetNode.id,
|
||||
title: " ️ " + (targetNode.title || targetNode.type),
|
||||
state: item.enabled ? "all" : "none"
|
||||
});
|
||||
});
|
||||
|
||||
const bypassedCount = bypassedItems.length;
|
||||
if (self._lastBypassedCount !== bypassedCount) {
|
||||
self._lastBypassedCount = bypassedCount;
|
||||
if (displayItems.length > 0) {
|
||||
self.drawLabel(ctx, "TOGGLE ALL", pad, y, 100, rowH);
|
||||
const toggleZone = self.drawToggle(ctx, self.data.toggleAllOn, pad + 100, y, self.size[0] - pad*2 - 100, rowH);
|
||||
self.clickZones.push({
|
||||
type: "toggleAll",
|
||||
x: toggleZone.x,
|
||||
y: toggleZone.y,
|
||||
w: toggleZone.w,
|
||||
h: toggleZone.h
|
||||
});
|
||||
y += rowH + 10;
|
||||
}
|
||||
|
||||
displayItems.forEach((item, index) => {
|
||||
const itemY = y + (index * rowH);
|
||||
const zones = self.drawBypassedItem(ctx, item.title, pad, itemY, self.size[0] - pad*2, rowH, index, item.state, item.isChild, item.isLastChild);
|
||||
|
||||
self.clickZones.push({
|
||||
type: "itemToggle",
|
||||
itemType: item.type,
|
||||
id: item.id,
|
||||
x: zones.toggle.x,
|
||||
y: zones.toggle.y,
|
||||
w: zones.toggle.w,
|
||||
h: zones.toggle.h
|
||||
});
|
||||
|
||||
if (!item.isChild) {
|
||||
self.clickZones.push({
|
||||
type: "remove",
|
||||
itemType: item.type,
|
||||
id: item.id,
|
||||
x: zones.remove.x,
|
||||
y: zones.remove.y,
|
||||
w: zones.remove.w,
|
||||
h: zones.remove.h
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const currentCount = displayItems.length;
|
||||
if (self._lastBypassedCount !== currentCount) {
|
||||
self._lastBypassedCount = currentCount;
|
||||
self._rs_updateUI();
|
||||
}
|
||||
};
|
||||
@@ -180,6 +522,8 @@ app.registerExtension({
|
||||
return;
|
||||
}
|
||||
|
||||
self._rs_syncFromExternal();
|
||||
|
||||
const existingMenu = document.getElementById("rs-bypass-menu");
|
||||
if (existingMenu) existingMenu.remove();
|
||||
|
||||
@@ -203,21 +547,21 @@ app.registerExtension({
|
||||
menu.style.top = clickEvent.clientY + "px";
|
||||
}
|
||||
|
||||
let closeTimer = null;
|
||||
const closeDelay = 300;
|
||||
|
||||
menu.addEventListener("mouseleave", () => {
|
||||
closeTimer = setTimeout(() => {
|
||||
self._rs_closeMenu();
|
||||
}, closeDelay);
|
||||
});
|
||||
|
||||
menu.addEventListener("mouseenter", () => {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
const outsideHandler = (ev) => {
|
||||
const path = typeof ev.composedPath === "function" ? ev.composedPath() : null;
|
||||
if (path && path.length) {
|
||||
if (path.includes(menu)) return;
|
||||
} else {
|
||||
if (menu.contains(ev.target)) return;
|
||||
}
|
||||
});
|
||||
self._rs_closeMenu();
|
||||
};
|
||||
self._rs_outsideHandler = outsideHandler;
|
||||
|
||||
const escHandler = (ev) => {
|
||||
if (ev.key === "Escape") self._rs_closeMenu();
|
||||
};
|
||||
self._rs_escHandler = escHandler;
|
||||
|
||||
function renderContent() {
|
||||
contentDiv.innerHTML = "";
|
||||
@@ -242,21 +586,19 @@ app.registerExtension({
|
||||
const title = group.title || "Group " + group.id;
|
||||
if (query && !title.toLowerCase().includes(query)) return;
|
||||
|
||||
const bypassedCount = groupNodes.filter(n => n.mode === 4).length;
|
||||
const isAllBypassed = bypassedCount === groupNodes.length;
|
||||
const isPartialBypassed = bypassedCount > 0 && !isAllBypassed;
|
||||
const isExpanded = self.data.expandedGroups[group.id] || false;
|
||||
|
||||
const bypassedCount = groupNodes.filter(n => n.mode === 4).length;
|
||||
let groupColor = "#ddd";
|
||||
if (isAllBypassed) groupColor = "#ff4444";
|
||||
else if (isPartialBypassed) groupColor = "#ff9800";
|
||||
if (bypassedCount === groupNodes.length) groupColor = "#ff4444";
|
||||
else if (bypassedCount > 0) groupColor = "#ff9800";
|
||||
|
||||
const groupItem = document.createElement("div");
|
||||
groupItem.style.cssText = "padding:10px 12px;cursor:pointer;color:" + groupColor + ";border-bottom:1px solid #333;font-size:12px;display:flex;align-items:center;transition:background-color 0.15s;";
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.textContent = isExpanded ? "▼ " : "▶ ";
|
||||
arrow.style.cssText = "margin-right:8px;font-size:10px;";
|
||||
arrow.style.cssText = "margin-right:8px;font-size:10px;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border:1px solid #555;border-radius:4px;";
|
||||
arrow.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
self.data.expandedGroups[group.id] = !isExpanded;
|
||||
@@ -266,19 +608,23 @@ app.registerExtension({
|
||||
|
||||
const groupText = document.createElement("span");
|
||||
groupText.textContent = " " + title;
|
||||
groupText.style.cssText = "flex:1;";
|
||||
groupText.style.cssText = "flex:1;margin-left:4px;";
|
||||
groupText.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
groupNodes.forEach(n => setNodeBypass(n, !isAllBypassed));
|
||||
|
||||
if (!isAllBypassed) {
|
||||
if (!self.data.bypassedGroups.includes(group.id)) {
|
||||
self.data.bypassedGroups.push(group.id);
|
||||
}
|
||||
let entry = self.data.bypassedGroups.find(item => item.id === group.id);
|
||||
if (entry) {
|
||||
entry.enabled = !entry.enabled;
|
||||
} else {
|
||||
self.data.bypassedGroups = self.data.bypassedGroups.filter(id => id !== group.id);
|
||||
entry = { id: group.id, enabled: true };
|
||||
self.data.bypassedGroups.push(entry);
|
||||
}
|
||||
|
||||
groupNodes.forEach(n => setNodeBypass(n, entry.enabled));
|
||||
if (entry.enabled) {
|
||||
self.data.bypassedNodes = self.data.bypassedNodes.filter(
|
||||
nItem => !groupNodes.some(n => n.id === nItem.id)
|
||||
);
|
||||
}
|
||||
self._rs_syncToggleAllState();
|
||||
self._rs_syncData();
|
||||
self._rs_updateUI();
|
||||
renderContent();
|
||||
@@ -300,22 +646,23 @@ app.registerExtension({
|
||||
const nodeColor = isNodeBypassed ? "#ff4444" : "#ddd";
|
||||
|
||||
const nodeItem = document.createElement("div");
|
||||
nodeItem.textContent = "️ " + nodeTitle;
|
||||
nodeItem.textContent = " ️ " + nodeTitle;
|
||||
nodeItem.style.cssText = "padding:10px 12px 10px 32px;cursor:pointer;color:" + nodeColor + ";border-bottom:1px solid #333;font-size:12px;transition:background-color 0.15s;";
|
||||
nodeItem.onmouseover = () => nodeItem.style.background = "#333";
|
||||
nodeItem.onmouseout = () => nodeItem.style.background = "#1a1a1a";
|
||||
nodeItem.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
setNodeBypass(n, !isNodeBypassed);
|
||||
|
||||
if (!isNodeBypassed) {
|
||||
if (!self.data.bypassedNodes.includes(n.id)) {
|
||||
self.data.bypassedNodes.push(n.id);
|
||||
}
|
||||
let entry = self.data.bypassedNodes.find(item => item.id === n.id);
|
||||
const currentlyBypassed = entry ? entry.enabled : (n.mode === 4);
|
||||
const newState = !currentlyBypassed;
|
||||
if (entry) {
|
||||
entry.enabled = newState;
|
||||
} else {
|
||||
self.data.bypassedNodes = self.data.bypassedNodes.filter(id => id !== n.id);
|
||||
entry = { id: n.id, enabled: newState };
|
||||
self.data.bypassedNodes.push(entry);
|
||||
}
|
||||
|
||||
setNodeBypass(n, newState);
|
||||
self._rs_syncToggleAllState();
|
||||
self._rs_syncData();
|
||||
self._rs_updateUI();
|
||||
renderContent();
|
||||
@@ -340,16 +687,15 @@ app.registerExtension({
|
||||
item.onmouseout = () => item.style.background = "#1a1a1a";
|
||||
item.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
setNodeBypass(n, !isBypassed);
|
||||
|
||||
if (!isBypassed) {
|
||||
if (!self.data.bypassedNodes.includes(n.id)) {
|
||||
self.data.bypassedNodes.push(n.id);
|
||||
}
|
||||
let entry = self.data.bypassedNodes.find(item => item.id === n.id);
|
||||
if (entry) {
|
||||
entry.enabled = !entry.enabled;
|
||||
} else {
|
||||
self.data.bypassedNodes = self.data.bypassedNodes.filter(id => id !== n.id);
|
||||
entry = { id: n.id, enabled: true };
|
||||
self.data.bypassedNodes.push(entry);
|
||||
}
|
||||
|
||||
setNodeBypass(n, entry.enabled);
|
||||
self._rs_syncToggleAllState();
|
||||
self._rs_syncData();
|
||||
self._rs_updateUI();
|
||||
renderContent();
|
||||
@@ -368,19 +714,17 @@ app.registerExtension({
|
||||
|
||||
searchInput.addEventListener("input", renderContent);
|
||||
|
||||
const closeEsc = (ev) => {
|
||||
if (ev.key === "Escape") self._rs_closeMenu();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", closeEsc);
|
||||
|
||||
renderContent();
|
||||
document.body.appendChild(menu);
|
||||
|
||||
self.data.menuOpen = true;
|
||||
self._rs_updateUI();
|
||||
|
||||
setTimeout(() => searchInput.focus(), 10);
|
||||
setTimeout(() => {
|
||||
document.addEventListener("pointerdown", outsideHandler, true);
|
||||
document.addEventListener("keydown", escHandler);
|
||||
searchInput.focus();
|
||||
}, 10);
|
||||
};
|
||||
|
||||
self._rs_closeMenu = function() {
|
||||
@@ -388,6 +732,14 @@ app.registerExtension({
|
||||
if (menu) {
|
||||
menu.remove();
|
||||
}
|
||||
if (self._rs_escHandler) {
|
||||
document.removeEventListener("keydown", self._rs_escHandler);
|
||||
self._rs_escHandler = null;
|
||||
}
|
||||
if (self._rs_outsideHandler) {
|
||||
document.removeEventListener("pointerdown", self._rs_outsideHandler, true);
|
||||
self._rs_outsideHandler = null;
|
||||
}
|
||||
self.data.menuOpen = false;
|
||||
self._rs_updateUI();
|
||||
};
|
||||
@@ -404,21 +756,89 @@ app.registerExtension({
|
||||
self.showBypassMenu(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (zone.type === "toggleAll") {
|
||||
self.data.toggleAllOn = !self.data.toggleAllOn;
|
||||
const newState = self.data.toggleAllOn;
|
||||
|
||||
self.data.bypassedGroups.forEach(item => item.enabled = newState);
|
||||
self.data.bypassedNodes.forEach(item => item.enabled = newState);
|
||||
|
||||
const nodes = app.graph._nodes || [];
|
||||
const groups = app.graph._groups || [];
|
||||
self.data.bypassedGroups.forEach(item => {
|
||||
const group = groups.find(g => g.id === item.id);
|
||||
if (group) {
|
||||
const groupNodes = nodes.filter(n => isNodeInGroup(n, group));
|
||||
groupNodes.forEach(n => setNodeBypass(n, newState));
|
||||
}
|
||||
});
|
||||
self.data.bypassedNodes.forEach(item => {
|
||||
const targetNode = nodes.find(n => n.id === item.id);
|
||||
if (targetNode) {
|
||||
const inGroup = self.data.bypassedGroups.some(gItem => {
|
||||
if (!gItem.enabled) return false;
|
||||
const group = groups.find(g => g.id === gItem.id);
|
||||
return group && isNodeInGroup(targetNode, group);
|
||||
});
|
||||
if (!inGroup) setNodeBypass(targetNode, newState);
|
||||
}
|
||||
});
|
||||
|
||||
self._rs_syncData();
|
||||
self._rs_updateUI();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (zone.type === "itemToggle") {
|
||||
if (zone.itemType === "group") {
|
||||
const group = app.graph._groups.find(g => g.id === zone.id);
|
||||
if (group) {
|
||||
const groupNodes = app.graph._nodes.filter(n => isNodeInGroup(n, group));
|
||||
let entry = self.data.bypassedGroups.find(item => item.id === zone.id);
|
||||
if (!entry) {
|
||||
entry = { id: zone.id, enabled: false };
|
||||
self.data.bypassedGroups.push(entry);
|
||||
}
|
||||
entry.enabled = !entry.enabled;
|
||||
groupNodes.forEach(n => setNodeBypass(n, entry.enabled));
|
||||
}
|
||||
} else {
|
||||
const targetNode = app.graph._nodes.find(n => n.id === zone.id);
|
||||
if (targetNode) {
|
||||
let entry = self.data.bypassedNodes.find(item => item.id === zone.id);
|
||||
if (!entry) {
|
||||
entry = { id: zone.id, enabled: false };
|
||||
self.data.bypassedNodes.push(entry);
|
||||
}
|
||||
entry.enabled = !entry.enabled;
|
||||
setNodeBypass(targetNode, entry.enabled);
|
||||
}
|
||||
}
|
||||
self._rs_syncToggleAllState();
|
||||
self._rs_syncData();
|
||||
self._rs_updateUI();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (zone.type === "remove") {
|
||||
if (zone.itemType === "group") {
|
||||
const group = app.graph._groups.find(g => g.id === zone.id);
|
||||
if (group) {
|
||||
const groupNodes = app.graph._nodes.filter(n => isNodeInGroup(n, group));
|
||||
groupNodes.forEach(n => setNodeBypass(n, false));
|
||||
self.data.bypassedGroups = self.data.bypassedGroups.filter(id => id !== zone.id);
|
||||
}
|
||||
self.data.bypassedGroups = self.data.bypassedGroups.filter(item => item.id !== zone.id);
|
||||
self._rs_broadcastRemove("group", zone.id);
|
||||
} else {
|
||||
const targetNode = app.graph._nodes.find(n => n.id === zone.id);
|
||||
if (targetNode) {
|
||||
setNodeBypass(targetNode, false);
|
||||
self.data.bypassedNodes = self.data.bypassedNodes.filter(id => id !== zone.id);
|
||||
}
|
||||
self.data.bypassedNodes = self.data.bypassedNodes.filter(item => item.id !== zone.id);
|
||||
self._rs_broadcastRemove("node", zone.id);
|
||||
}
|
||||
self._rs_syncToggleAllState();
|
||||
self._rs_syncData();
|
||||
self._rs_updateUI();
|
||||
return true;
|
||||
@@ -432,12 +852,17 @@ app.registerExtension({
|
||||
const pad = self.padding;
|
||||
const rowH = self.rowHeight;
|
||||
const bypassedItemsCount = self._lastBypassedCount >= 0 ? self._lastBypassedCount : (self.data.bypassedGroups.length + self.data.bypassedNodes.length);
|
||||
const hasItems = bypassedItemsCount > 0;
|
||||
|
||||
let y = 5;
|
||||
y += rowH;
|
||||
y += 10;
|
||||
if (hasItems) {
|
||||
y += rowH;
|
||||
y += 10;
|
||||
}
|
||||
y += (bypassedItemsCount * rowH);
|
||||
y += 20;
|
||||
y += 8;
|
||||
|
||||
const minHeight = 80;
|
||||
const minWidth = 220;
|
||||
@@ -455,19 +880,22 @@ app.registerExtension({
|
||||
const nodes = app.graph._nodes || [];
|
||||
const groups = app.graph._groups || [];
|
||||
|
||||
self.data.bypassedGroups.forEach(groupId => {
|
||||
const group = groups.find(g => g.id === groupId);
|
||||
self.data.bypassedGroups.forEach(item => {
|
||||
if (!item.enabled) return;
|
||||
const group = groups.find(g => g.id === item.id);
|
||||
if (group) {
|
||||
const groupNodes = nodes.filter(n => isNodeInGroup(n, group));
|
||||
groupNodes.forEach(n => setNodeBypass(n, true));
|
||||
}
|
||||
});
|
||||
|
||||
self.data.bypassedNodes.forEach(nodeId => {
|
||||
const targetNode = nodes.find(n => n.id === nodeId);
|
||||
self.data.bypassedNodes.forEach(item => {
|
||||
if (!item.enabled) return;
|
||||
const targetNode = nodes.find(n => n.id === item.id);
|
||||
if (targetNode) {
|
||||
const inBypassedGroup = self.data.bypassedGroups.some(groupId => {
|
||||
const group = groups.find(g => g.id === groupId);
|
||||
const inBypassedGroup = self.data.bypassedGroups.some(gItem => {
|
||||
if (!gItem.enabled) return false;
|
||||
const group = groups.find(g => g.id === gItem.id);
|
||||
return group && isNodeInGroup(targetNode, group);
|
||||
});
|
||||
if (!inBypassedGroup) {
|
||||
@@ -481,27 +909,52 @@ app.registerExtension({
|
||||
const nodes = app.graph._nodes || [];
|
||||
const groups = app.graph._groups || [];
|
||||
|
||||
RS_BYPASS_INSTANCES.forEach(instance => {
|
||||
if (instance === self) return;
|
||||
instance.data.bypassedGroups.forEach(item => {
|
||||
if (!self.data.bypassedGroups.some(x => x.id === item.id)) {
|
||||
self.data.bypassedGroups.push({ id: item.id, enabled: item.enabled });
|
||||
}
|
||||
});
|
||||
instance.data.bypassedNodes.forEach(item => {
|
||||
if (!self.data.bypassedNodes.some(x => x.id === item.id)) {
|
||||
self.data.bypassedNodes.push({ id: item.id, enabled: item.enabled });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
groups.forEach(group => {
|
||||
if (!group || !group.bounding && !group._bounding) return;
|
||||
if (!group || (!group.bounding && !group._bounding)) return;
|
||||
const groupNodes = nodes.filter(n => n.comfyClass !== "RS_Bypass" && isNodeInGroup(n, group));
|
||||
if (groupNodes.length > 0 && groupNodes.every(n => n.mode === 4)) {
|
||||
if (!self.data.bypassedGroups.includes(group.id)) {
|
||||
self.data.bypassedGroups.push(group.id);
|
||||
if (!self.data.bypassedGroups.some(item => item.id === group.id)) {
|
||||
self.data.bypassedGroups.push({ id: group.id, enabled: true });
|
||||
} else {
|
||||
const entry = self.data.bypassedGroups.find(item => item.id === group.id);
|
||||
if (entry) entry.enabled = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
nodes.forEach(n => {
|
||||
if (n.comfyClass === "RS_Bypass") return;
|
||||
if (n.mode === 4) {
|
||||
const inBypassedGroup = self.data.bypassedGroups.some(groupId => {
|
||||
const group = groups.find(g => g.id === groupId);
|
||||
return group && isNodeInGroup(n, group);
|
||||
});
|
||||
|
||||
if (!inBypassedGroup && !self.data.bypassedNodes.includes(n.id)) {
|
||||
self.data.bypassedNodes.push(n.id);
|
||||
}
|
||||
if (n.mode !== 4) return;
|
||||
|
||||
const managedByBypassedGroup = self.data.bypassedGroups.some(gItem => {
|
||||
if (!gItem.enabled) return false;
|
||||
const group = groups.find(g => g.id === gItem.id);
|
||||
if (!group || !isNodeInGroup(n, group)) return false;
|
||||
const groupNodes = nodes.filter(nn => nn.comfyClass !== "RS_Bypass" && isNodeInGroup(nn, group));
|
||||
return groupNodes.length > 0 && groupNodes.every(nn => nn.mode === 4);
|
||||
});
|
||||
|
||||
if (managedByBypassedGroup) return;
|
||||
|
||||
if (!self.data.bypassedNodes.some(item => item.id === n.id)) {
|
||||
self.data.bypassedNodes.push({ id: n.id, enabled: true });
|
||||
} else {
|
||||
const entry = self.data.bypassedNodes.find(item => item.id === n.id);
|
||||
if (entry) entry.enabled = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -510,7 +963,8 @@ app.registerExtension({
|
||||
if (stateWidget) {
|
||||
stateWidget.value = JSON.stringify({
|
||||
nodes: self.data.bypassedNodes,
|
||||
groups: self.data.bypassedGroups
|
||||
groups: self.data.bypassedGroups,
|
||||
toggleAllOn: self.data.toggleAllOn
|
||||
});
|
||||
}
|
||||
if (self.graph) {
|
||||
@@ -526,6 +980,12 @@ app.registerExtension({
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoved = self.onRemoved;
|
||||
self.onRemoved = function() {
|
||||
RS_BYPASS_INSTANCES.delete(self);
|
||||
if (onRemoved) return onRemoved.apply(this, arguments);
|
||||
};
|
||||
|
||||
const onConfigure = self.onConfigure;
|
||||
self.onConfigure = function(o) {
|
||||
if (onConfigure) {
|
||||
@@ -536,14 +996,28 @@ app.registerExtension({
|
||||
try {
|
||||
const restoredData = JSON.parse(stateWidget.value);
|
||||
if (restoredData) {
|
||||
if (restoredData.nodes) self.data.bypassedNodes = restoredData.nodes;
|
||||
if (restoredData.groups) self.data.bypassedGroups = restoredData.groups;
|
||||
if (Array.isArray(restoredData.nodes)) {
|
||||
if (restoredData.nodes.length && typeof restoredData.nodes[0] === 'number') {
|
||||
self.data.bypassedNodes = restoredData.nodes.map(id => ({ id, enabled: true }));
|
||||
} else {
|
||||
self.data.bypassedNodes = restoredData.nodes;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(restoredData.groups)) {
|
||||
if (restoredData.groups.length && typeof restoredData.groups[0] === 'number') {
|
||||
self.data.bypassedGroups = restoredData.groups.map(id => ({ id, enabled: true }));
|
||||
} else {
|
||||
self.data.bypassedGroups = restoredData.groups;
|
||||
}
|
||||
}
|
||||
if (restoredData.toggleAllOn !== undefined) self.data.toggleAllOn = restoredData.toggleAllOn;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Rayko] Error restoring data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
self._rs_syncToggleAllState();
|
||||
self._rs_applyBypass();
|
||||
self._rs_updateUI();
|
||||
|
||||
@@ -560,6 +1034,8 @@ app.registerExtension({
|
||||
} else {
|
||||
self._rs_applyBypass();
|
||||
}
|
||||
self._rs_discoveryDone = true;
|
||||
self._rs_syncToggleAllState();
|
||||
self._rs_updateUI();
|
||||
}, 100);
|
||||
|
||||
|
||||
@@ -127,9 +127,9 @@ class CanvasEyedropper {
|
||||
this.lastColor = null;
|
||||
this.rafId = null;
|
||||
this.magnifier = this._createMagnifier();
|
||||
this._onMouseMove = this._onMouseMove.bind(this);
|
||||
this._onClick = this._onClick.bind(this);
|
||||
this._onKeyDown = this._onKeyDown.bind(this);
|
||||
this._onMouseMove = this._onMouseMove["bind"](this);
|
||||
this._onClick = this._onClick["bind"](this);
|
||||
this._onKeyDown = this._onKeyDown["bind"](this);
|
||||
}
|
||||
|
||||
_createMagnifier() {
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const rsState = {
|
||||
processingMouseDown: false,
|
||||
lastCanvasMouseEvent: null
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "RaykoStudio.RSImageLabel",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "RSImageLabel") return;
|
||||
|
||||
const origOnCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype._rsDnDHandler = function(e) {
|
||||
const canvas = LGraphCanvas.active_canvas;
|
||||
if (!canvas || !this.graph) return false;
|
||||
const point = canvas.convertEventToCanvasOffset(e);
|
||||
return point[0] >= this.pos[0] &&
|
||||
point[0] <= this.pos[0] + this.size[0] &&
|
||||
point[1] >= this.pos[1] &&
|
||||
point[1] <= this.pos[1] + this.size[1];
|
||||
};
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
if (origOnCreated) origOnCreated.apply(this, arguments);
|
||||
|
||||
this.title = "";
|
||||
this.resizable = false;
|
||||
this.collapsable = false;
|
||||
this.flags = {};
|
||||
|
||||
this.shape = LiteGraph.CUSTOM_SHAPE;
|
||||
this.color = "#fff0";
|
||||
this.bgcolor = "#fff0";
|
||||
this.shadow_color = "transparent";
|
||||
this.onDrawBackground = function(ctx) {};
|
||||
|
||||
this.properties = {
|
||||
size: 100,
|
||||
padding: 5,
|
||||
borderWidth: 2,
|
||||
borderColor: "#FF0000",
|
||||
borderRadius: 5,
|
||||
backgroundColor: "transparent",
|
||||
bgTransparent: true,
|
||||
syncImageRadius: true,
|
||||
imageRadius: 0,
|
||||
embeddedData: ""
|
||||
};
|
||||
|
||||
const pathWidget = this.addWidget("text", "_image_path", "", () => {});
|
||||
pathWidget.hidden = true;
|
||||
pathWidget.serializeValue = () => pathWidget.value || "";
|
||||
|
||||
this.cachedImage = null;
|
||||
this.imageLoaded = false;
|
||||
this.dialogOpen = false;
|
||||
|
||||
// Приоритет: embeddedData -> путь к файлу
|
||||
if (this.properties.embeddedData) {
|
||||
this.loadImage(this.properties.embeddedData);
|
||||
} else if (pathWidget.value) {
|
||||
this.loadImage(this.getImageUrl());
|
||||
} else {
|
||||
this.updateNodeSize();
|
||||
}
|
||||
|
||||
this._boundDragOver = (e) => {
|
||||
if (this._rsDnDHandler(e)) e.preventDefault();
|
||||
};
|
||||
|
||||
this._boundDrop = (e) => {
|
||||
if (this._rsDnDHandler(e)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0 && files[0].type.startsWith("image/")) {
|
||||
this.uploadImage(files[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canvas = app.canvas?.canvas;
|
||||
if (canvas) {
|
||||
canvas.addEventListener("dragover", this._boundDragOver, { capture: true });
|
||||
canvas.addEventListener("drop", this._boundDrop, { capture: true });
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
if (info && info.widgets_values) {
|
||||
const widgetIndex = this.widgets?.findIndex(w => w.name === "_image_path");
|
||||
if (widgetIndex !== -1 && info.widgets_values[widgetIndex]) {
|
||||
this.widgets[widgetIndex].value = info.widgets_values[widgetIndex];
|
||||
}
|
||||
}
|
||||
// Восстановление картинки после загрузки JSON
|
||||
if (this.properties.embeddedData) {
|
||||
this.loadImage(this.properties.embeddedData);
|
||||
} else if (this.widgets?.find(w => w.name === "_image_path")?.value) {
|
||||
this.loadImage(this.getImageUrl());
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.onSerialize = function(o) {
|
||||
if (!o.properties) o.properties = {};
|
||||
for (const key in this.properties) {
|
||||
o.properties[key] = this.properties[key];
|
||||
}
|
||||
|
||||
const pathWidget = this.widgets?.find(w => w.name === "_image_path");
|
||||
if (pathWidget) {
|
||||
if (!o.widgets_values) o.widgets_values = [];
|
||||
const index = this.widgets.indexOf(pathWidget);
|
||||
if (index !== -1) {
|
||||
while (o.widgets_values.length <= index) o.widgets_values.push(null);
|
||||
o.widgets_values[index] = pathWidget.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.onRemoved = function() {
|
||||
const canvas = app.canvas?.canvas;
|
||||
if (canvas && this._boundDragOver && this._boundDrop) {
|
||||
canvas.removeEventListener("dragover", this._boundDragOver, { capture: true });
|
||||
canvas.removeEventListener("drop", this._boundDrop, { capture: true });
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.getImageUrl = function() {
|
||||
const pathWidget = this.widgets?.find(w => w.name === "_image_path");
|
||||
if (!pathWidget || !pathWidget.value) return null;
|
||||
|
||||
const parts = pathWidget.value.split(" ");
|
||||
const filename = parts[0];
|
||||
const subfolder = parts.slice(1).join(" ") || "";
|
||||
|
||||
return `/rayko/rs_image_label/get_image?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder)}&type=temp`;
|
||||
};
|
||||
|
||||
nodeType.prototype.updateEmbeddedData = function() {
|
||||
if (!this.imageLoaded || !this.cachedImage) return;
|
||||
|
||||
const img = this.cachedImage;
|
||||
let w = img.width;
|
||||
let h = img.height;
|
||||
const maxEmbedSize = 512;
|
||||
|
||||
if (w > maxEmbedSize || h > maxEmbedSize) {
|
||||
const aspect = w / h;
|
||||
if (aspect >= 1) {
|
||||
w = maxEmbedSize;
|
||||
h = Math.round(maxEmbedSize / aspect);
|
||||
} else {
|
||||
h = maxEmbedSize;
|
||||
w = Math.round(maxEmbedSize * aspect);
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
|
||||
this.properties.embeddedData = canvas.toDataURL("image/webp", 0.9);
|
||||
};
|
||||
|
||||
nodeType.prototype.loadImage = function(src) {
|
||||
if (!src) {
|
||||
this.cachedImage = null;
|
||||
this.imageLoaded = false;
|
||||
this.updateNodeSize();
|
||||
this.setDirtyCanvas(true, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
// FIX: Восстановили crossOrigin для корректной работы toDataURL и CORS
|
||||
img.crossOrigin = "Anonymous";
|
||||
|
||||
img.onload = () => {
|
||||
this.cachedImage = img;
|
||||
this.imageLoaded = true;
|
||||
|
||||
// Обновляем embeddedData только если это не оно само (избегаем рекурсии и лишних операций)
|
||||
if (!src.startsWith("data:")) {
|
||||
this.updateEmbeddedData();
|
||||
}
|
||||
|
||||
this.updateNodeSize();
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.warn("[RS Image Label] Failed to load image:", src);
|
||||
this.cachedImage = null;
|
||||
this.imageLoaded = false;
|
||||
this.updateNodeSize();
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
img.src = src;
|
||||
};
|
||||
|
||||
nodeType.prototype.uploadImage = async function(file) {
|
||||
const pathWidget = this.widgets?.find(w => w.name === "_image_path");
|
||||
if (!pathWidget) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
formData.append("subfolder", "");
|
||||
formData.append("type", "temp");
|
||||
formData.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/upload/image", { method: "POST", body: formData });
|
||||
if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`);
|
||||
|
||||
const data = await resp.json();
|
||||
if (data.name) {
|
||||
const fullPath = data.subfolder ? `${data.name} ${data.subfolder}` : data.name;
|
||||
pathWidget.value = fullPath;
|
||||
this.loadImage(this.getImageUrl());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[RS Label Image] Upload failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.updateNodeSize = function() {
|
||||
const internalPadding = Math.max(Number(this.properties.padding) || 0, 5);
|
||||
const bw = Number(this.properties.borderWidth) || 0;
|
||||
|
||||
let displayW = 100, displayH = 100;
|
||||
|
||||
if (this.imageLoaded && this.cachedImage) {
|
||||
const maxSize = Math.max(16, Math.min(200, Number(this.properties.size) || 100));
|
||||
const aspect = this.cachedImage.width / this.cachedImage.height;
|
||||
|
||||
if (aspect >= 1) {
|
||||
displayW = maxSize;
|
||||
displayH = maxSize / aspect;
|
||||
} else {
|
||||
displayH = maxSize;
|
||||
displayW = maxSize * aspect;
|
||||
}
|
||||
}
|
||||
|
||||
this.size[0] = displayW + internalPadding * 2 + bw * 2;
|
||||
this.size[1] = displayH + internalPadding * 2 + bw * 2;
|
||||
};
|
||||
|
||||
nodeType.prototype.drawLabel = function(ctx) {
|
||||
ctx.save();
|
||||
|
||||
const internalPadding = Math.max(Number(this.properties.padding) || 0, 5);
|
||||
const bw = Number(this.properties.borderWidth) || 0;
|
||||
const br = Number(this.properties.borderRadius) || 0;
|
||||
const bc = this.properties.borderColor || "#FF0000";
|
||||
const bg = this.properties.backgroundColor;
|
||||
|
||||
if (!this.properties.bgTransparent && bg && bg !== "transparent") {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, this.size[0], this.size[1], [br]);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (bw > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, this.size[0], this.size[1], [br]);
|
||||
ctx.strokeStyle = bc;
|
||||
ctx.lineWidth = bw;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (this.imageLoaded && this.cachedImage) {
|
||||
const maxSize = Math.max(16, Math.min(200, Number(this.properties.size) || 100));
|
||||
const aspect = this.cachedImage.width / this.cachedImage.height;
|
||||
|
||||
let drawW, drawH;
|
||||
if (aspect >= 1) {
|
||||
drawW = maxSize;
|
||||
drawH = maxSize / aspect;
|
||||
} else {
|
||||
drawH = maxSize;
|
||||
drawW = maxSize * aspect;
|
||||
}
|
||||
|
||||
const x = bw + internalPadding;
|
||||
const y = bw + internalPadding;
|
||||
|
||||
let imageRadius = 0;
|
||||
if (this.properties.syncImageRadius) {
|
||||
imageRadius = Math.max(0, br - bw - internalPadding);
|
||||
} else {
|
||||
imageRadius = Number(this.properties.imageRadius) || 0;
|
||||
}
|
||||
|
||||
const maxPossibleRadius = Math.min(drawW, drawH) / 2;
|
||||
imageRadius = Math.min(imageRadius, maxPossibleRadius);
|
||||
|
||||
if (imageRadius > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, drawW, drawH, [imageRadius]);
|
||||
ctx.clip();
|
||||
ctx.drawImage(this.cachedImage, x, y, drawW, drawH);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(this.cachedImage, x, y, drawW, drawH);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
nodeType.prototype.onDblClick = function(event, pos, canvas) {
|
||||
this.showSettingsDialog();
|
||||
};
|
||||
|
||||
nodeType.prototype.showSettingsDialog = async function() {
|
||||
if (this.dialogOpen) return;
|
||||
this.dialogOpen = true;
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.style.cssText = "position:fixed;background:#1a1a1a;border:2px solid #333;border-radius:8px;padding:20px;z-index:10000;min-width:350px;color:#fff;font-family:Arial,sans-serif;";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = "🦊 RS Image Label Settings";
|
||||
title.style.cssText = "margin:0 0 15px 0;color:#fff;cursor:grab;user-select:none;padding-bottom:5px;border-bottom:1px solid #333;";
|
||||
dialog.appendChild(title);
|
||||
|
||||
let isDragging = false, dragOffsetX = 0, dragOffsetY = 0;
|
||||
title.addEventListener("mousedown", (e) => {
|
||||
isDragging = true;
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
dragOffsetX = e.clientX - rect.left;
|
||||
dragOffsetY = e.clientY - rect.top;
|
||||
title.style.cursor = "grabbing";
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (!isDragging) return;
|
||||
let newX = Math.max(10, Math.min(window.innerWidth - dialog.offsetWidth - 10, e.clientX - dragOffsetX));
|
||||
let newY = Math.max(10, Math.min(window.innerHeight - 20, e.clientY - dragOffsetY));
|
||||
dialog.style.left = newX + "px";
|
||||
dialog.style.top = newY + "px";
|
||||
});
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (isDragging) { isDragging = false; title.style.cursor = "grab"; }
|
||||
});
|
||||
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
const onNavigate = () => closeDialog();
|
||||
history.pushState = function(...args) { originalPushState.apply(this, args); onNavigate(); };
|
||||
history.replaceState = function(...args) { originalReplaceState.apply(this, args); onNavigate(); };
|
||||
|
||||
const closeDialog = () => {
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
document.removeEventListener("keydown", escHandler);
|
||||
document.removeEventListener("visibilitychange", visibilityHandler);
|
||||
if (dialog.parentNode) document.body.removeChild(dialog);
|
||||
this.dialogOpen = false;
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const escHandler = (e) => { if (e.key === "Escape") closeDialog(); };
|
||||
document.addEventListener("keydown", escHandler);
|
||||
const visibilityHandler = () => { if (document.hidden) closeDialog(); };
|
||||
document.addEventListener("visibilitychange", visibilityHandler);
|
||||
|
||||
const createRow = (label, createControl) => {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex;align-items:center;margin-bottom:10px;gap:10px;";
|
||||
const labelEl = document.createElement("label");
|
||||
labelEl.textContent = label;
|
||||
labelEl.style.cssText = "min-width:120px;color:#ccc;";
|
||||
const control = createControl();
|
||||
row.appendChild(labelEl);
|
||||
row.appendChild(control);
|
||||
dialog.appendChild(row);
|
||||
};
|
||||
|
||||
const uploadRow = document.createElement("div");
|
||||
uploadRow.style.cssText = "margin-bottom:15px;text-align:center;";
|
||||
const uploadBtn = document.createElement("button");
|
||||
uploadBtn.textContent = "📁 Load Image";
|
||||
uploadBtn.style.cssText = "width:100%;padding:8px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;cursor:pointer;";
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/png,image/jpeg,image/webp";
|
||||
fileInput.style.display = "none";
|
||||
fileInput.addEventListener("change", (e) => {
|
||||
if (e.target.files.length > 0) this.uploadImage(e.target.files[0]);
|
||||
});
|
||||
uploadBtn.addEventListener("click", () => fileInput.click());
|
||||
uploadRow.appendChild(uploadBtn);
|
||||
uploadRow.appendChild(fileInput);
|
||||
dialog.appendChild(uploadRow);
|
||||
|
||||
createRow("Size (max side):", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number"; input.value = this.properties.size;
|
||||
input.min = 16; input.max = 200;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.size = parseInt(e.target.value);
|
||||
this.updateNodeSize(); this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Padding:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number"; input.value = Math.max(0, this.properties.padding - 5);
|
||||
input.min = 0; input.max = 45;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.padding = Math.max(5, parseInt(e.target.value) + 5);
|
||||
this.updateNodeSize(); this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Border Width:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number"; input.value = this.properties.borderWidth;
|
||||
input.min = 0; input.max = 10;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.borderWidth = parseInt(e.target.value);
|
||||
this.updateNodeSize(); this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Border Radius:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number"; input.value = this.properties.borderRadius;
|
||||
input.min = 0; input.max = 120;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.borderRadius = parseInt(e.target.value);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Sync Image Radius:", () => {
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = this.properties.syncImageRadius !== false;
|
||||
checkbox.addEventListener("change", (e) => {
|
||||
this.properties.syncImageRadius = e.target.checked;
|
||||
this.setDirtyCanvas(true, true);
|
||||
const imgRadiusInput = document.getElementById("rs-img-radius-input");
|
||||
if (imgRadiusInput) {
|
||||
imgRadiusInput.disabled = this.properties.syncImageRadius;
|
||||
imgRadiusInput.style.opacity = this.properties.syncImageRadius ? "0.3" : "1";
|
||||
}
|
||||
});
|
||||
return checkbox;
|
||||
});
|
||||
|
||||
createRow("Image Radius:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.id = "rs-img-radius-input";
|
||||
input.type = "number";
|
||||
input.value = this.properties.imageRadius;
|
||||
input.min = 0; input.max = 100;
|
||||
input.disabled = this.properties.syncImageRadius;
|
||||
input.style.cssText = `width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;opacity:${this.properties.syncImageRadius ? "0.3" : "1"};`;
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.imageRadius = parseInt(e.target.value);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Border Color:", () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "display:flex;align-items:center;gap:8px;";
|
||||
const preview = document.createElement("div");
|
||||
preview.style.cssText = "width:24px;height:24px;border:1px solid #555;border-radius:4px;cursor:pointer;";
|
||||
preview.style.backgroundColor = this.properties.borderColor || "#FF0000";
|
||||
const colorInput = document.createElement("input");
|
||||
colorInput.type = "color"; colorInput.value = this.properties.borderColor || "#FF0000"; colorInput.style.display = "none";
|
||||
preview.addEventListener("click", () => colorInput.click());
|
||||
colorInput.addEventListener("input", (e) => {
|
||||
this.properties.borderColor = e.target.value;
|
||||
preview.style.backgroundColor = e.target.value;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
container.appendChild(preview); container.appendChild(colorInput);
|
||||
return container;
|
||||
});
|
||||
|
||||
createRow("BG Transparent:", () => {
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = this.properties.bgTransparent !== false;
|
||||
checkbox.addEventListener("change", (e) => {
|
||||
this.properties.bgTransparent = e.target.checked;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return checkbox;
|
||||
});
|
||||
|
||||
createRow("BG Color:", () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "display:flex;align-items:center;gap:8px;";
|
||||
const preview = document.createElement("div");
|
||||
preview.style.cssText = "width:24px;height:24px;border:1px solid #555;border-radius:4px;cursor:pointer;";
|
||||
preview.style.backgroundColor = this.properties.backgroundColor || "#ffffff";
|
||||
const colorInput = document.createElement("input");
|
||||
colorInput.type = "color"; colorInput.value = this.properties.backgroundColor || "#ffffff"; colorInput.style.display = "none";
|
||||
|
||||
const updatePreviewState = () => {
|
||||
if (this.properties.bgTransparent) {
|
||||
preview.style.opacity = "0.3";
|
||||
preview.style.cursor = "not-allowed";
|
||||
} else {
|
||||
preview.style.opacity = "1";
|
||||
preview.style.cursor = "pointer";
|
||||
}
|
||||
};
|
||||
updatePreviewState();
|
||||
|
||||
preview.addEventListener("click", () => {
|
||||
if (!this.properties.bgTransparent) colorInput.click();
|
||||
});
|
||||
|
||||
colorInput.addEventListener("input", (e) => {
|
||||
this.properties.backgroundColor = e.target.value;
|
||||
preview.style.backgroundColor = e.target.value;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
|
||||
container.appendChild(preview); container.appendChild(colorInput);
|
||||
return container;
|
||||
});
|
||||
|
||||
const okButton = document.createElement("button");
|
||||
okButton.textContent = "OK";
|
||||
okButton.style.cssText = "margin-top:15px;padding:8px 20px;background:#6ABF70;color:#fff;border:none;border-radius:4px;cursor:pointer;width:100%;";
|
||||
okButton.addEventListener("click", closeDialog);
|
||||
dialog.appendChild(okButton);
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const canvas = LGraphCanvas.active_canvas?.canvas;
|
||||
if (!canvas) return;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const ds = LGraphCanvas.active_canvas.ds;
|
||||
|
||||
const nodeRightScreenX = canvasRect.left + (this.pos[0] + this.size[0] + ds.offset[0]) * ds.scale;
|
||||
const nodeLeftScreenX = canvasRect.left + (this.pos[0] + ds.offset[0]) * ds.scale;
|
||||
const nodeBottomScreenY = canvasRect.top + (this.pos[1] + this.size[1] + ds.offset[1]) * ds.scale;
|
||||
|
||||
const margin = 10;
|
||||
let leftPos = nodeRightScreenX + margin;
|
||||
let topPos = nodeBottomScreenY + margin;
|
||||
|
||||
if (leftPos + dialog.offsetWidth > window.innerWidth - margin) {
|
||||
leftPos = nodeLeftScreenX - dialog.offsetWidth - margin;
|
||||
}
|
||||
if (topPos + dialog.offsetHeight > window.innerHeight - margin) {
|
||||
topPos = Math.max(margin, window.innerHeight - dialog.offsetHeight - margin);
|
||||
}
|
||||
if (leftPos < margin) {
|
||||
leftPos = Math.max(margin, window.innerWidth / 2 - dialog.offsetWidth / 2);
|
||||
}
|
||||
|
||||
dialog.style.left = leftPos + "px";
|
||||
dialog.style.top = topPos + "px";
|
||||
});
|
||||
};
|
||||
|
||||
nodeType.prototype.getHelp = function() {
|
||||
return `<p>The 🦊 RS Image Label node displays an image as a floating transparent label.</p>
|
||||
<p><strong>Double-click</strong> to open settings. <strong>Drag & drop</strong> an image onto the node to upload.</p>`;
|
||||
};
|
||||
|
||||
nodeType.title_mode = LiteGraph.NO_TITLE;
|
||||
},
|
||||
|
||||
async setup(app) {
|
||||
const oldDrawNode = LGraphCanvas.prototype.drawNode;
|
||||
LGraphCanvas.prototype.drawNode = function(node, ctx) {
|
||||
if (node.comfyClass === "RSImageLabel" || node.type === "RSImageLabel") {
|
||||
node.color = "#fff0"; node.bgcolor = "#fff0"; node.shadow_color = "transparent";
|
||||
if (node.drawLabel) node.drawLabel(ctx);
|
||||
return;
|
||||
}
|
||||
return oldDrawNode.apply(this, arguments);
|
||||
};
|
||||
|
||||
const oldGetNodeOnPos = LGraph.prototype.getNodeOnPos;
|
||||
LGraph.prototype.getNodeOnPos = function(x, y, nodes_list) {
|
||||
if (nodes_list && rsState.processingMouseDown &&
|
||||
rsState.lastCanvasMouseEvent &&
|
||||
rsState.lastCanvasMouseEvent.type.includes("down") &&
|
||||
rsState.lastCanvasMouseEvent.which === 1) {
|
||||
const isDoubleClick = LiteGraph.getTime() - LGraphCanvas.active_canvas.last_mouseclick < 300;
|
||||
if (!isDoubleClick) {
|
||||
nodes_list = [...nodes_list].filter((n) => {
|
||||
return !(n.comfyClass === "RSImageLabel" && n.flags && n.flags.pinned);
|
||||
});
|
||||
}
|
||||
}
|
||||
return oldGetNodeOnPos.apply(this, [x, y, nodes_list]);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", () => { rsState.processingMouseDown = true; });
|
||||
document.addEventListener("mouseup", () => { rsState.processingMouseDown = false; });
|
||||
}
|
||||
});
|
||||
+690
@@ -0,0 +1,690 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const rgthree = {
|
||||
processingMouseDown: false,
|
||||
lastCanvasMouseEvent: null
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "RaykoStudio.RSLabel",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "RSLabel") return;
|
||||
|
||||
const origOnCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
if (origOnCreated) origOnCreated.apply(this, arguments);
|
||||
|
||||
this.title = "";
|
||||
this.resizable = false;
|
||||
this.collapsable = false;
|
||||
this.flags = {};
|
||||
|
||||
this.shape = LiteGraph.CUSTOM_SHAPE;
|
||||
this.color = "#fff0";
|
||||
this.bgcolor = "#fff0";
|
||||
this.shadow_color = "transparent";
|
||||
|
||||
this.onDrawBackground = function(ctx) {};
|
||||
|
||||
this.properties = {
|
||||
text: "Sample Text",
|
||||
fontSize: 24,
|
||||
fontFamily: "",
|
||||
fontColor: "#FF0000",
|
||||
textAlign: "left",
|
||||
backgroundColor: "transparent",
|
||||
bgTransparent: true,
|
||||
padding: 10,
|
||||
borderRadius: 5,
|
||||
borderWidth: 2,
|
||||
borderColor: "#FF0000",
|
||||
lineSpacing: 1.0,
|
||||
letterSpacing: 0,
|
||||
strokeWidth: 0,
|
||||
strokeColor: "#000000"
|
||||
};
|
||||
|
||||
this._fontsLoaded = false;
|
||||
this._fontLoadError = null;
|
||||
this._fontLoadPromise = this.loadFonts();
|
||||
this.dialogOpen = false;
|
||||
};
|
||||
|
||||
nodeType.prototype.loadFonts = async function() {
|
||||
try {
|
||||
const response = await fetch('/rayko/rs_label/get_fonts');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.availableFonts = data.font_list.map(f => f.replace(/\.(ttf|otf)$/i, ''));
|
||||
|
||||
if (this.availableFonts.length > 0 && !this.properties.fontFamily) {
|
||||
this.properties.fontFamily = this.availableFonts[0];
|
||||
}
|
||||
|
||||
for (const fontFile of data.font_list) {
|
||||
const fontName = fontFile.replace(/\.(ttf|otf)$/i, '');
|
||||
const encodedFilename = encodeURIComponent(fontFile);
|
||||
const fontPath = `/rayko/rs_label/font/${encodedFilename}`;
|
||||
|
||||
const fontFace = new FontFace(fontName, `url(${fontPath})`);
|
||||
await fontFace.load();
|
||||
document.fonts.add(fontFace);
|
||||
}
|
||||
|
||||
this._fontsLoaded = true;
|
||||
this.setDirtyCanvas(true, true);
|
||||
} catch (e) {
|
||||
this.availableFonts = [];
|
||||
this._fontsLoaded = false;
|
||||
this._fontLoadError = e.message;
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.drawLabel = function(ctx) {
|
||||
ctx.save();
|
||||
|
||||
const fontSize = Math.max(this.properties.fontSize || 12, 1);
|
||||
const fontFamily = this.properties.fontFamily || "Arial";
|
||||
const fontColor = this.properties.fontColor || "#FF0000";
|
||||
const textAlign = this.properties.textAlign || "left";
|
||||
const borderRadius = Number(this.properties.borderRadius) || 0;
|
||||
const borderWidth = Number(this.properties.borderWidth) || 0;
|
||||
const borderColor = this.properties.borderColor || "#FF0000";
|
||||
const lineSpacing = Math.max(0.5, Math.min(3.0, Number(this.properties.lineSpacing) || 1.0));
|
||||
const letterSpacing = Number(this.properties.letterSpacing) || 0;
|
||||
const strokeWidth = Math.max(0, Math.min(10, Number(this.properties.strokeWidth) || 0));
|
||||
const strokeColor = this.properties.strokeColor || "#000000";
|
||||
|
||||
const effectivePadding = Math.max(Number(this.properties.padding) || 0, 5);
|
||||
const halfStroke = strokeWidth / 2;
|
||||
|
||||
const text = this.properties.text || "";
|
||||
const lines = text.split("\n");
|
||||
|
||||
const isFontReady = document.fonts.check(`${fontSize}px "${fontFamily}"`);
|
||||
const displayFont = isFontReady ? fontFamily : "Arial";
|
||||
|
||||
ctx.font = `${fontSize}px "${displayFont}"`;
|
||||
ctx.textBaseline = "alphabetic";
|
||||
|
||||
let maxWidth = 0;
|
||||
const lineMetrics = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
let lineWidth = 0;
|
||||
|
||||
if (letterSpacing !== 0 && line.length > 1) {
|
||||
for (let c = 0; c < line.length; c++) {
|
||||
lineWidth += ctx.measureText(line[c]).width;
|
||||
if (c < line.length - 1) lineWidth += letterSpacing;
|
||||
}
|
||||
} else {
|
||||
lineWidth = ctx.measureText(line || " ").width;
|
||||
}
|
||||
|
||||
if (lineWidth > maxWidth) maxWidth = lineWidth;
|
||||
|
||||
const m = ctx.measureText(line || " ");
|
||||
const ascent = m.actualBoundingBoxAscent;
|
||||
const descent = m.actualBoundingBoxDescent;
|
||||
const lineHeight = (ascent + descent) * lineSpacing;
|
||||
|
||||
lineMetrics.push({ width: lineWidth, ascent, descent, lineHeight });
|
||||
}
|
||||
|
||||
let totalTextHeight = 0;
|
||||
if (lines.length > 0) {
|
||||
totalTextHeight = lineMetrics[0].ascent + halfStroke;
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
totalTextHeight += lineMetrics[i].lineHeight;
|
||||
}
|
||||
totalTextHeight += lineMetrics[lines.length - 1].descent + halfStroke;
|
||||
} else {
|
||||
totalTextHeight = fontSize + strokeWidth;
|
||||
}
|
||||
|
||||
const contentWidth = maxWidth + strokeWidth + effectivePadding * 2;
|
||||
const contentHeight = totalTextHeight + effectivePadding * 2;
|
||||
|
||||
this.size[0] = Math.max(contentWidth, 100);
|
||||
this.size[1] = Math.max(contentHeight, 40);
|
||||
|
||||
if (borderWidth > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, this.size[0], this.size[1], [borderRadius]);
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = borderWidth;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (!this.properties.bgTransparent && this.properties.backgroundColor) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, this.size[0], this.size[1], [borderRadius]);
|
||||
ctx.fillStyle = this.properties.backgroundColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.fillStyle = fontColor;
|
||||
const innerWidth = this.size[0] - effectivePadding * 2;
|
||||
|
||||
let baseTextX = effectivePadding + halfStroke;
|
||||
if (textAlign === "center") {
|
||||
baseTextX = effectivePadding + halfStroke + (innerWidth - strokeWidth - maxWidth) / 2;
|
||||
} else if (textAlign === "right") {
|
||||
baseTextX = this.size[0] - effectivePadding - halfStroke - maxWidth;
|
||||
}
|
||||
|
||||
let currentY = effectivePadding + halfStroke;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const metrics = lineMetrics[i];
|
||||
|
||||
let lineOffsetX = 0;
|
||||
if (textAlign === "center") {
|
||||
lineOffsetX = (maxWidth - metrics.width) / 2;
|
||||
} else if (textAlign === "right") {
|
||||
lineOffsetX = maxWidth - metrics.width;
|
||||
}
|
||||
|
||||
const startX = baseTextX + lineOffsetX;
|
||||
const drawY = currentY + metrics.ascent;
|
||||
|
||||
if (strokeWidth > 0) {
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.miterLimit = 2;
|
||||
}
|
||||
|
||||
if (letterSpacing !== 0 && line.length > 0) {
|
||||
let penX = startX;
|
||||
for (let c = 0; c < line.length; c++) {
|
||||
const char = line[c];
|
||||
if (strokeWidth > 0) ctx.strokeText(char, penX, drawY);
|
||||
ctx.fillText(char, penX, drawY);
|
||||
penX += ctx.measureText(char).width + letterSpacing;
|
||||
}
|
||||
} else {
|
||||
if (strokeWidth > 0) ctx.strokeText(line, startX, drawY);
|
||||
ctx.fillText(line, startX, drawY);
|
||||
}
|
||||
|
||||
currentY += metrics.lineHeight;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
nodeType.prototype.onDblClick = function(event, pos, canvas) {
|
||||
this.showSettingsDialog();
|
||||
};
|
||||
|
||||
nodeType.prototype.showSettingsDialog = async function() {
|
||||
if (this.dialogOpen) return;
|
||||
|
||||
if (this._fontLoadPromise) {
|
||||
try { await this._fontLoadPromise; } catch (e) {}
|
||||
}
|
||||
|
||||
this.dialogOpen = true;
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.style.position = "fixed";
|
||||
dialog.style.background = "#1a1a1a";
|
||||
dialog.style.border = "2px solid #333";
|
||||
dialog.style.borderRadius = "8px";
|
||||
dialog.style.padding = "20px";
|
||||
dialog.style.zIndex = "10000";
|
||||
dialog.style.minWidth = "350px";
|
||||
dialog.style.color = "#fff";
|
||||
dialog.style.fontFamily = "Arial, sans-serif";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = "🦊 RS Label Settings";
|
||||
title.style.margin = "0 0 15px 0";
|
||||
title.style.color = "#fff";
|
||||
title.style.cursor = "grab";
|
||||
title.style.userSelect = "none";
|
||||
title.style.paddingBottom = "5px";
|
||||
title.style.borderBottom = "1px solid #333";
|
||||
dialog.appendChild(title);
|
||||
|
||||
let isDragging = false;
|
||||
let dragOffsetX = 0;
|
||||
let dragOffsetY = 0;
|
||||
|
||||
title.addEventListener("mousedown", (e) => {
|
||||
isDragging = true;
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
dragOffsetX = e.clientX - rect.left;
|
||||
dragOffsetY = e.clientY - rect.top;
|
||||
title.style.cursor = "grabbing";
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (!isDragging) return;
|
||||
let newX = e.clientX - dragOffsetX;
|
||||
let newY = e.clientY - dragOffsetY;
|
||||
const w = dialog.offsetWidth;
|
||||
const h = dialog.offsetHeight;
|
||||
newX = Math.max(10, Math.min(window.innerWidth - w - 10, newX));
|
||||
newY = Math.max(10, Math.min(window.innerHeight - 20, newY));
|
||||
dialog.style.left = newX + "px";
|
||||
dialog.style.top = newY + "px";
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (isDragging) { isDragging = false; title.style.cursor = "grab"; }
|
||||
});
|
||||
|
||||
// FIX: Перехват History API для отслеживания навигации ComfyUI
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
const onNavigate = () => {
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
history.pushState = function(...args) {
|
||||
originalPushState.apply(this, args);
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
history.replaceState = function(...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
// Восстанавливаем оригинальные методы History API
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
|
||||
document.removeEventListener("keydown", escHandler);
|
||||
document.removeEventListener("visibilitychange", visibilityHandler);
|
||||
window.removeEventListener("hashchange", onHashChange);
|
||||
if (dialog.parentNode) document.body.removeChild(dialog);
|
||||
this.dialogOpen = false;
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const escHandler = (e) => { if (e.key === "Escape") closeDialog(); };
|
||||
document.addEventListener("keydown", escHandler);
|
||||
|
||||
const visibilityHandler = () => {
|
||||
if (document.hidden) closeDialog();
|
||||
};
|
||||
document.addEventListener("visibilitychange", visibilityHandler);
|
||||
|
||||
// Оставляем hashchange как запасной вариант
|
||||
const currentHash = window.location.hash;
|
||||
const onHashChange = () => {
|
||||
if (window.location.hash !== currentHash) closeDialog();
|
||||
};
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
|
||||
const createRow = (label, createControl) => {
|
||||
const row = document.createElement("div");
|
||||
row.style.display = "flex";
|
||||
row.style.alignItems = "center";
|
||||
row.style.marginBottom = "10px";
|
||||
row.style.gap = "10px";
|
||||
const labelEl = document.createElement("label");
|
||||
labelEl.textContent = label;
|
||||
labelEl.style.minWidth = "120px";
|
||||
labelEl.style.color = "#ccc";
|
||||
const control = createControl();
|
||||
row.appendChild(labelEl);
|
||||
row.appendChild(control);
|
||||
dialog.appendChild(row);
|
||||
};
|
||||
|
||||
const textRow = document.createElement("div");
|
||||
textRow.style.marginBottom = "15px";
|
||||
const textLabel = document.createElement("label");
|
||||
textLabel.textContent = "Text:";
|
||||
textLabel.style.display = "block";
|
||||
textLabel.style.marginBottom = "5px";
|
||||
textLabel.style.color = "#ccc";
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = this.properties.text || "";
|
||||
textarea.rows = 4;
|
||||
textarea.style.width = "100%";
|
||||
textarea.style.padding = "8px";
|
||||
textarea.style.background = "#2a2a2a";
|
||||
textarea.style.color = "#fff";
|
||||
textarea.style.border = "1px solid #444";
|
||||
textarea.style.borderRadius = "4px";
|
||||
textarea.style.resize = "vertical";
|
||||
textarea.style.fontFamily = "monospace";
|
||||
textarea.addEventListener("input", (e) => {
|
||||
this.properties.text = e.target.value;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
textRow.appendChild(textLabel);
|
||||
textRow.appendChild(textarea);
|
||||
dialog.appendChild(textRow);
|
||||
|
||||
createRow("Font Size:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.value = this.properties.fontSize;
|
||||
input.min = 1; input.max = 200;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.fontSize = parseInt(e.target.value);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Font Family:", () => {
|
||||
const select = document.createElement("select");
|
||||
select.style.cssText = "width:200px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
const fonts = this.availableFonts;
|
||||
if (!fonts || fonts.length === 0) {
|
||||
const opt = document.createElement("option");
|
||||
opt.textContent = this._fontLoadError ? `Error: ${this._fontLoadError}` : "Loading...";
|
||||
opt.disabled = true;
|
||||
select.appendChild(opt);
|
||||
} else {
|
||||
fonts.forEach(font => {
|
||||
const option = document.createElement("option");
|
||||
option.value = font; option.textContent = font;
|
||||
if (font === this.properties.fontFamily) option.selected = true;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
select.addEventListener("change", (e) => {
|
||||
this.properties.fontFamily = e.target.value;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return select;
|
||||
});
|
||||
|
||||
createRow("Font Color:", () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.display = "flex"; container.style.alignItems = "center"; container.style.gap = "8px";
|
||||
const preview = document.createElement("div");
|
||||
preview.style.cssText = "width:24px;height:24px;border:1px solid #555;border-radius:4px;cursor:pointer;";
|
||||
preview.style.backgroundColor = this.properties.fontColor || "#FF0000";
|
||||
const colorInput = document.createElement("input");
|
||||
colorInput.type = "color"; colorInput.value = this.properties.fontColor || "#FF0000"; colorInput.style.display = "none";
|
||||
preview.addEventListener("click", () => colorInput.click());
|
||||
colorInput.addEventListener("input", (e) => {
|
||||
this.properties.fontColor = e.target.value;
|
||||
preview.style.backgroundColor = e.target.value;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
container.appendChild(preview); container.appendChild(colorInput);
|
||||
return container;
|
||||
});
|
||||
|
||||
createRow("Text Align:", () => {
|
||||
const select = document.createElement("select");
|
||||
select.style.cssText = "width:120px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
["left", "center", "right"].forEach(align => {
|
||||
const option = document.createElement("option");
|
||||
option.value = align; option.textContent = align;
|
||||
if (align === this.properties.textAlign) option.selected = true;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener("change", (e) => {
|
||||
this.properties.textAlign = e.target.value;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return select;
|
||||
});
|
||||
|
||||
createRow("Line Spacing:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.value = this.properties.lineSpacing;
|
||||
input.min = 0.5; input.max = 3.0; input.step = 0.1;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.lineSpacing = parseFloat(e.target.value);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Letter Spacing:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.value = this.properties.letterSpacing;
|
||||
input.min = -10; input.max = 50; input.step = 1;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.letterSpacing = parseInt(e.target.value);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Stroke Width:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.value = this.properties.strokeWidth;
|
||||
input.min = 0; input.max = 10; input.step = 1;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.strokeWidth = parseInt(e.target.value);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Stroke Color:", () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.display = "flex"; container.style.alignItems = "center"; container.style.gap = "8px";
|
||||
const preview = document.createElement("div");
|
||||
preview.style.cssText = "width:24px;height:24px;border:1px solid #555;border-radius:4px;cursor:pointer;";
|
||||
preview.style.backgroundColor = this.properties.strokeColor || "#000000";
|
||||
const colorInput = document.createElement("input");
|
||||
colorInput.type = "color"; colorInput.value = this.properties.strokeColor || "#000000"; colorInput.style.display = "none";
|
||||
preview.addEventListener("click", () => colorInput.click());
|
||||
colorInput.addEventListener("input", (e) => {
|
||||
this.properties.strokeColor = e.target.value;
|
||||
preview.style.backgroundColor = e.target.value;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
container.appendChild(preview); container.appendChild(colorInput);
|
||||
return container;
|
||||
});
|
||||
|
||||
createRow("BG Transparent:", () => {
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = this.properties.bgTransparent;
|
||||
checkbox.addEventListener("change", (e) => {
|
||||
this.properties.bgTransparent = e.target.checked;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return checkbox;
|
||||
});
|
||||
|
||||
createRow("BG Color:", () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.display = "flex"; container.style.alignItems = "center"; container.style.gap = "8px";
|
||||
const preview = document.createElement("div");
|
||||
preview.style.cssText = "width:24px;height:24px;border:1px solid #555;border-radius:4px;cursor:pointer;";
|
||||
preview.style.backgroundColor = this.properties.backgroundColor || "#ffffff";
|
||||
const colorInput = document.createElement("input");
|
||||
colorInput.type = "color"; colorInput.value = this.properties.backgroundColor || "#ffffff"; colorInput.style.display = "none";
|
||||
preview.addEventListener("click", () => {
|
||||
if (this.properties.bgTransparent) return;
|
||||
colorInput.click();
|
||||
});
|
||||
colorInput.addEventListener("input", (e) => {
|
||||
this.properties.backgroundColor = e.target.value;
|
||||
preview.style.backgroundColor = e.target.value;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
container.appendChild(preview); container.appendChild(colorInput);
|
||||
return container;
|
||||
});
|
||||
|
||||
createRow("Padding:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.value = Math.max(0, this.properties.padding - 5);
|
||||
input.min = 0; input.max = 95; input.step = 1;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.padding = Math.max(5, parseInt(e.target.value) + 5);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Border Radius:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.value = this.properties.borderRadius;
|
||||
input.min = 0; input.max = 50; input.step = 1;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.borderRadius = parseInt(e.target.value);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Border Width:", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.value = this.properties.borderWidth;
|
||||
input.min = 0; input.max = 20; input.step = 1;
|
||||
input.style.cssText = "width:80px;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;";
|
||||
input.addEventListener("change", (e) => {
|
||||
this.properties.borderWidth = parseInt(e.target.value);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
return input;
|
||||
});
|
||||
|
||||
createRow("Border Color:", () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.display = "flex"; container.style.alignItems = "center"; container.style.gap = "8px";
|
||||
const preview = document.createElement("div");
|
||||
preview.style.cssText = "width:24px;height:24px;border:1px solid #555;border-radius:4px;cursor:pointer;";
|
||||
preview.style.backgroundColor = this.properties.borderColor || "#FF0000";
|
||||
const colorInput = document.createElement("input");
|
||||
colorInput.type = "color"; colorInput.value = this.properties.borderColor || "#FF0000"; colorInput.style.display = "none";
|
||||
preview.addEventListener("click", () => colorInput.click());
|
||||
colorInput.addEventListener("input", (e) => {
|
||||
this.properties.borderColor = e.target.value;
|
||||
preview.style.backgroundColor = e.target.value;
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
container.appendChild(preview); container.appendChild(colorInput);
|
||||
return container;
|
||||
});
|
||||
|
||||
const okButton = document.createElement("button");
|
||||
okButton.textContent = "OK";
|
||||
okButton.style.marginTop = "15px";
|
||||
okButton.style.padding = "8px 20px";
|
||||
okButton.style.background = "#6ABF70";
|
||||
okButton.style.color = "#fff";
|
||||
okButton.style.border = "none";
|
||||
okButton.style.borderRadius = "4px";
|
||||
okButton.style.cursor = "pointer";
|
||||
okButton.style.width = "100%";
|
||||
okButton.addEventListener("click", closeDialog);
|
||||
dialog.appendChild(okButton);
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const canvas = LGraphCanvas.active_canvas?.canvas;
|
||||
if (!canvas) return;
|
||||
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const ds = LGraphCanvas.active_canvas.ds;
|
||||
|
||||
const nodeRightScreenX = canvasRect.left + (this.pos[0] + this.size[0] + ds.offset[0]) * ds.scale;
|
||||
const nodeLeftScreenX = canvasRect.left + (this.pos[0] + ds.offset[0]) * ds.scale;
|
||||
const nodeBottomScreenY = canvasRect.top + (this.pos[1] + this.size[1] + ds.offset[1]) * ds.scale;
|
||||
|
||||
const dialogWidth = dialog.offsetWidth;
|
||||
const dialogHeight = dialog.offsetHeight;
|
||||
const margin = 10;
|
||||
|
||||
let leftPos = nodeRightScreenX + margin;
|
||||
let topPos = nodeBottomScreenY + margin;
|
||||
|
||||
if (leftPos + dialogWidth > window.innerWidth - margin) {
|
||||
leftPos = nodeLeftScreenX - dialogWidth - margin;
|
||||
}
|
||||
if (topPos + dialogHeight > window.innerHeight - margin) {
|
||||
topPos = Math.max(margin, window.innerHeight - dialogHeight - margin);
|
||||
}
|
||||
if (leftPos < margin) {
|
||||
leftPos = Math.max(margin, window.innerWidth / 2 - dialogWidth / 2);
|
||||
}
|
||||
|
||||
dialog.style.left = leftPos + "px";
|
||||
dialog.style.top = topPos + "px";
|
||||
});
|
||||
};
|
||||
|
||||
nodeType.prototype.getHelp = function() {
|
||||
return `<p>The 🦊 RS Label node allows you to add a floating label to your workflow.</p>
|
||||
<p>Double-click the node to open settings and customize font, colors, and layout.</p>
|
||||
<ul><li><strong>Pro tip #1:</strong> Use Enter for multiline text in the textarea.</li>
|
||||
<li><strong>Pro tip #2:</strong> Right-click → Pin to make clicks pass through the label.</li></ul>`;
|
||||
};
|
||||
|
||||
nodeType.title_mode = LiteGraph.NO_TITLE;
|
||||
},
|
||||
|
||||
async setup(app) {
|
||||
const oldDrawNode = LGraphCanvas.prototype.drawNode;
|
||||
|
||||
LGraphCanvas.prototype.drawNode = function(node, ctx) {
|
||||
if (node.comfyClass === "RSLabel" || node.type === "RSLabel") {
|
||||
node.color = "#fff0";
|
||||
node.bgcolor = "#fff0";
|
||||
node.shadow_color = "transparent";
|
||||
|
||||
if (node.drawLabel) {
|
||||
node.drawLabel(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return oldDrawNode.apply(this, arguments);
|
||||
};
|
||||
|
||||
const oldGetNodeOnPos = LGraph.prototype.getNodeOnPos;
|
||||
LGraph.prototype.getNodeOnPos = function(x, y, nodes_list) {
|
||||
if (nodes_list && rgthree.processingMouseDown &&
|
||||
rgthree.lastCanvasMouseEvent &&
|
||||
rgthree.lastCanvasMouseEvent.type.includes("down") &&
|
||||
rgthree.lastCanvasMouseEvent.which === 1) {
|
||||
|
||||
const isDoubleClick = LiteGraph.getTime() - LGraphCanvas.active_canvas.last_mouseclick < 300;
|
||||
if (!isDoubleClick) {
|
||||
nodes_list = [...nodes_list].filter((n) => {
|
||||
return !(n.comfyClass === "RSLabel" && n.flags && n.flags.pinned);
|
||||
});
|
||||
}
|
||||
}
|
||||
return oldGetNodeOnPos.apply(this, [x, y, nodes_list]);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", () => { rgthree.processingMouseDown = true; });
|
||||
document.addEventListener("mouseup", () => { rgthree.processingMouseDown = false; });
|
||||
}
|
||||
});
|
||||
+232
-190
@@ -37,9 +37,11 @@ app.registerExtension({
|
||||
const origOnSerialize = nodeType.prototype.onSerialize;
|
||||
const origOnRemoved = nodeType.prototype.onRemoved;
|
||||
|
||||
const STATE_OFF = 0;
|
||||
const STATE_ARMED = 1;
|
||||
const STATE_RUNNING = 2;
|
||||
const STATE_IDLE = 0;
|
||||
const STATE_RUNNING = 1;
|
||||
|
||||
const CLEAR_BTN_H = 30;
|
||||
const CLEAR_BTN_PAD = 5;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = origOnCreated ? origOnCreated.apply(this, arguments) : undefined;
|
||||
@@ -58,10 +60,9 @@ app.registerExtension({
|
||||
this.isRestoring = false;
|
||||
this.storageKey = null;
|
||||
this.currentFilter = "";
|
||||
this.batchState = STATE_OFF;
|
||||
this.batchState = STATE_IDLE;
|
||||
this.batchSnapshot = [];
|
||||
this.batchTotal = 0;
|
||||
this.batchRemaining = 0;
|
||||
this.sendingDone = true;
|
||||
|
||||
const self = this;
|
||||
|
||||
@@ -128,40 +129,26 @@ app.registerExtension({
|
||||
const clipWidget = this.addDOMWidget("rs_clip_btn", "custom", clipBtn);
|
||||
clipWidget.computeSize = () => [this.width || 200, 30];
|
||||
|
||||
const batchBtn = document.createElement("button");
|
||||
batchBtn.textContent = "⚙️ LoRA Batch";
|
||||
batchBtn.style.cssText = "width:100%;height:26px;padding:0;font-size:13px;border:1px solid #555;border-radius:5px;background:#2a2a2a;color:#ccc;cursor:pointer;margin:-14px 0 0 0;box-sizing:border-box;";
|
||||
this._batchBtnEl = batchBtn;
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.textContent = "➕ ADD LoRA";
|
||||
addBtn.style.cssText = "width:100%;height:26px;padding:0;font-size:12px;border:1px solid #4CAF50;border-radius:5px;background:#1a3a1a;color:#aaffaa;cursor:pointer;margin:-14px 0 0 0;box-sizing:border-box;";
|
||||
addBtn.onmouseenter = () => { addBtn.style.background = "#2a4a2a"; };
|
||||
addBtn.onmouseleave = () => { addBtn.style.background = "#1a3a1a"; };
|
||||
this._addBtnEl = addBtn;
|
||||
|
||||
batchBtn.addEventListener("click", (e) => {
|
||||
addBtn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (self.batchState === STATE_RUNNING) return;
|
||||
const active = self.loraRows.filter(r => r.enabled && r.name && r.name !== "None");
|
||||
if (!active.length) { showToast("LoRA is not selected", "error", self); return; }
|
||||
self.batchSnapshot = active.map(r => ({ ...r }));
|
||||
self.batchTotal = active.length;
|
||||
self.batchRemaining = active.length;
|
||||
self.batchState = STATE_ARMED;
|
||||
self.updateBatchBtn();
|
||||
self.graph?.setDirtyCanvas(true, true);
|
||||
});
|
||||
|
||||
const batchWidget = this.addDOMWidget("rs_batch_btn", "custom", batchBtn);
|
||||
batchWidget.computeSize = () => [this.width || 200, 34];
|
||||
|
||||
this.addWidget("button", "✔️ UPDATE LoRA LIST", "", async () => {
|
||||
const w = self.widgets.find(x => x.name === "rs_add_btn");
|
||||
await self.loadLoraList();
|
||||
self.graph?.setDirtyCanvas(true, true);
|
||||
});
|
||||
this.addWidget("button", "➕ ADD LoRA", "", () => {
|
||||
const w = self.widgets.find(x => x.name === "➕ ADD LoRA");
|
||||
self.showTreeSelector(w);
|
||||
});
|
||||
|
||||
const addWidgetDom = this.addDOMWidget("rs_add_btn", "custom", addBtn);
|
||||
addWidgetDom.computeSize = () => [this.width || 200, 30];
|
||||
|
||||
updateClipBtn();
|
||||
updateClipInputState();
|
||||
self.updateBatchBtn();
|
||||
|
||||
const sendOne = async (loraCfg) => {
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
@@ -183,35 +170,43 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
const onBeforeQueue = async (ev) => {
|
||||
if (self.batchState !== STATE_ARMED) return;
|
||||
if (self.batchState === STATE_RUNNING) return;
|
||||
const active = self.loraRows.filter(r => r.enabled && r.name && r.name !== "None");
|
||||
if (!active.length) return;
|
||||
if (ev?.preventDefault) ev.preventDefault();
|
||||
self.batchState = STATE_RUNNING;
|
||||
self.updateBatchBtn();
|
||||
self.batchSnapshot = active.map(r => ({ ...r }));
|
||||
self.sendingDone = false;
|
||||
self.graph?.setDirtyCanvas(true, true);
|
||||
let sent = 0;
|
||||
for (const lora of self.batchSnapshot) {
|
||||
if (await sendOne(lora)) sent++;
|
||||
else showToast(`Failed LoRA ${sent + 1}`, "error", self);
|
||||
}
|
||||
if (sent === 0) { self.batchState = STATE_OFF; self.updateBatchBtn(); self.graph?.setDirtyCanvas(true, true); }
|
||||
self.sendingDone = true;
|
||||
if (sent === 0) { self.batchState = STATE_IDLE; self.graph?.setDirtyCanvas(true, true); }
|
||||
};
|
||||
|
||||
const qHandler = (e) => onBeforeQueue(e);
|
||||
document.addEventListener("comfy:queue:before", qHandler);
|
||||
const origQP = app.queuePrompt.bind(app);
|
||||
const origQP = app.queuePrompt["bind"](app);
|
||||
app.queuePrompt = async function(...args) {
|
||||
if (self.batchState === STATE_ARMED) { await onBeforeQueue(null); return; }
|
||||
const active = self.loraRows.filter(r => r.enabled && r.name && r.name !== "None");
|
||||
if (active.length && self.batchState !== STATE_RUNNING) {
|
||||
await onBeforeQueue(null);
|
||||
return;
|
||||
}
|
||||
return origQP(...args);
|
||||
};
|
||||
|
||||
const onStatus = (data) => {
|
||||
if (self.batchState !== STATE_RUNNING) return;
|
||||
if (!self.sendingDone) return;
|
||||
const detail = data?.detail || data || {};
|
||||
const qr = detail.queue_remaining ?? detail.status?.queue_remaining ?? detail.exec_info?.queue_remaining;
|
||||
if (qr === 0) {
|
||||
self.batchState = STATE_OFF;
|
||||
self.batchState = STATE_IDLE;
|
||||
self.batchSnapshot = [];
|
||||
self.updateBatchBtn();
|
||||
self.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
@@ -228,7 +223,8 @@ app.registerExtension({
|
||||
const gp = app.canvas.graph_mouse; if (!gp) return;
|
||||
const ry = gp[1] - self.pos[1];
|
||||
const sy = self.getListStartY();
|
||||
const avail = self.size[1] - sy - 10;
|
||||
const clearY = self.size[1] - CLEAR_BTN_H - CLEAR_BTN_PAD;
|
||||
const avail = clearY - sy - 5;
|
||||
const maxV = Math.max(1, Math.floor(avail / self.rowHeight));
|
||||
if (ry < sy || ry > sy + maxV * self.rowHeight) return;
|
||||
if (self.loraRows.length <= maxV) return;
|
||||
@@ -256,7 +252,7 @@ app.registerExtension({
|
||||
const origResize = this.onResize;
|
||||
this.onResize = function(size) {
|
||||
if (size[0] < self.MIN_WIDTH) size[0] = self.MIN_WIDTH;
|
||||
const mh = self.getListStartY() + self.rowHeight + 10;
|
||||
const mh = self.getListStartY() + self.rowHeight + CLEAR_BTN_H + 15;
|
||||
if (size[1] < mh) size[1] = mh;
|
||||
if (!self.isAutoResizing) { self.manual_size = true; self.syncData(); }
|
||||
return origResize ? origResize.apply(this, arguments) : undefined;
|
||||
@@ -272,37 +268,10 @@ app.registerExtension({
|
||||
return result;
|
||||
};
|
||||
|
||||
nodeType.prototype.updateBatchBtn = function() {
|
||||
const el = this._batchBtnEl;
|
||||
if (!el) return;
|
||||
switch (this.batchState) {
|
||||
case STATE_ARMED:
|
||||
el.textContent = `✅ BATCH: ${this.batchTotal} LoRAs`;
|
||||
el.style.border = "1px solid #4CAF50";
|
||||
el.style.background = "#1a3a1a";
|
||||
el.style.color = "#aaffaa";
|
||||
el.style.cursor = "pointer";
|
||||
break;
|
||||
case STATE_RUNNING:
|
||||
el.textContent = "⏳ Batch running...";
|
||||
el.style.border = "1px solid #FF9800";
|
||||
el.style.background = "#3a2a1a";
|
||||
el.style.color = "#ffddaa";
|
||||
el.style.cursor = "default";
|
||||
break;
|
||||
default:
|
||||
el.textContent = "⚙️ LoRA Batch";
|
||||
el.style.border = "1px solid #555";
|
||||
el.style.background = "#2a2a2a";
|
||||
el.style.color = "#ccc";
|
||||
el.style.cursor = "pointer";
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.getListStartY = function() {
|
||||
let y = 10;
|
||||
for (const w of this.widgets) {
|
||||
if (w.name === "➕ ADD LoRA") return y + (w.height || 30) + 8;
|
||||
if (w.name === "rs_add_btn") return y + (w.height || 30) + 8;
|
||||
y += w.computeSize ? w.computeSize()[1] + 4 : (w.height || 30) + 4;
|
||||
}
|
||||
return y + 8;
|
||||
@@ -312,7 +281,7 @@ app.registerExtension({
|
||||
if (this.manual_size) return;
|
||||
const sy = this.getListStartY();
|
||||
const dv = Math.max(1, Math.min(this.loraRows.length, 10));
|
||||
const calc = sy + dv * this.rowHeight + 10;
|
||||
const calc = sy + dv * this.rowHeight + CLEAR_BTN_H + 15;
|
||||
if (Math.abs(this.size[1] - calc) > 1) {
|
||||
this.isAutoResizing = true;
|
||||
this.setSize([this.size[0], calc]);
|
||||
@@ -321,17 +290,6 @@ app.registerExtension({
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.resetBatch = function() {
|
||||
if (this.batchState === STATE_ARMED) {
|
||||
this.batchState = STATE_OFF;
|
||||
this.batchSnapshot = [];
|
||||
this.batchTotal = 0;
|
||||
this.batchRemaining = 0;
|
||||
this.updateBatchBtn();
|
||||
this.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.syncData = function() {
|
||||
if (this.isRestoring) return;
|
||||
const json = JSON.stringify(this.loraRows);
|
||||
@@ -358,7 +316,7 @@ app.registerExtension({
|
||||
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
this.isRestoring = true;
|
||||
this.batchState = STATE_OFF;
|
||||
this.batchState = STATE_IDLE;
|
||||
if (info.properties?.["lora_rows"]) { try { const s = JSON.parse(info.properties["lora_rows"]); if (Array.isArray(s)) this.loraRows = s; } catch(e){} }
|
||||
if (info.properties?.["manual_size"] !== undefined) this.manual_size = info.properties["manual_size"];
|
||||
if (info.properties?.["scrollOffset"] !== undefined) this.scrollOffset = info.properties["scrollOffset"];
|
||||
@@ -402,7 +360,6 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
nodeType.prototype.addRow = function(name) {
|
||||
this.resetBatch();
|
||||
this.loraRows.push({ name, strength_model: 1.0, strength_clip: 1.0, enabled: true });
|
||||
this.scrollOffset = 0; this.manual_size = false;
|
||||
this.syncData();
|
||||
@@ -467,7 +424,6 @@ app.registerExtension({
|
||||
const apply = () => {
|
||||
const val = parseFloat(input.value.replace(",", "."));
|
||||
if (!isNaN(val) && val >= -10 && val <= 10) {
|
||||
self.resetBatch();
|
||||
self.loraRows[rowIndex].strength_model = val;
|
||||
self.syncData();
|
||||
self.graph?.setDirtyCanvas(true, true);
|
||||
@@ -501,119 +457,147 @@ app.registerExtension({
|
||||
if (!this.manual_size) {
|
||||
const sy = this.getListStartY();
|
||||
const dv = Math.max(1, Math.min(this.loraRows.length, 10));
|
||||
const mr = sy + dv * this.rowHeight + 10;
|
||||
const mr = sy + dv * this.rowHeight + CLEAR_BTN_H + 15;
|
||||
if (this.size[1] < mr) { this.isAutoResizing = true; this.setSize([this.size[0], mr]); this.isAutoResizing = false; }
|
||||
}
|
||||
if (!this.loraRows.length) return;
|
||||
|
||||
this.clickZones = [];
|
||||
const startY = this.getListStartY();
|
||||
const pad = 10, rpw = 145;
|
||||
const avail = this.size[1] - startY - 10;
|
||||
const maxV = Math.max(1, Math.floor(avail / this.rowHeight));
|
||||
const maxOff = Math.max(0, this.loraRows.length - maxV);
|
||||
if (this.scrollOffset > maxOff) this.scrollOffset = maxOff;
|
||||
const vs = this.scrollOffset, ve = Math.min(vs + maxV, this.loraRows.length);
|
||||
const pad = 10;
|
||||
const clearBtnY = this.size[1] - CLEAR_BTN_H - CLEAR_BTN_PAD;
|
||||
|
||||
for (let i = 0; i < ve - vs; i++) {
|
||||
const di = vs + i, row = this.loraRows[di];
|
||||
if (this.draggingIndex === di) continue;
|
||||
const y = startY + i * this.rowHeight, h = this.rowHeight - 2, ty = y + h / 2;
|
||||
ctx.fillStyle = i % 2 === 0 ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.15)";
|
||||
ctx.fillRect(pad, y, this.size[0] - pad * 2, h);
|
||||
if (this.loraRows.length) {
|
||||
const startY = this.getListStartY();
|
||||
const rpw = 145;
|
||||
const avail = clearBtnY - startY - 5;
|
||||
const maxV = Math.max(1, Math.floor(avail / this.rowHeight));
|
||||
const maxOff = Math.max(0, this.loraRows.length - maxV);
|
||||
if (this.scrollOffset > maxOff) this.scrollOffset = maxOff;
|
||||
const vs = this.scrollOffset, ve = Math.min(vs + maxV, this.loraRows.length);
|
||||
|
||||
this.clickZones.push({ type: "drag", index: di, x: pad, y, w: 20, h });
|
||||
ctx.fillStyle = "#888"; ctx.font = "14px sans-serif"; ctx.fillText("⋮⋮", pad + 2, ty + 5);
|
||||
for (let i = 0; i < ve - vs; i++) {
|
||||
const di = vs + i, row = this.loraRows[di];
|
||||
if (this.draggingIndex === di) continue;
|
||||
const y = startY + i * this.rowHeight, h = this.rowHeight - 2, ty = y + h / 2;
|
||||
ctx.fillStyle = i % 2 === 0 ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.15)";
|
||||
ctx.fillRect(pad, y, this.size[0] - pad * 2, h);
|
||||
|
||||
const tx = pad + 20;
|
||||
ctx.fillStyle = row.enabled ? "#4CAF50" : "#555";
|
||||
ctx.beginPath(); ctx.arc(tx + 8, ty, 7, 0, Math.PI * 2); ctx.fill();
|
||||
this.clickZones.push({ type: "toggle", index: di, x: tx, y, w: 24, h });
|
||||
this.clickZones.push({ type: "drag", index: di, x: pad, y, w: 20, h });
|
||||
ctx.fillStyle = "#888"; ctx.font = "14px sans-serif"; ctx.fillText("⋮⋮", pad + 2, ty + 5);
|
||||
|
||||
const nx = tx + 20, nw = this.size[0] - pad * 2 - 10 - rpw - 25;
|
||||
ctx.fillStyle = row.enabled ? "#fff" : "#777"; ctx.font = "12px sans-serif";
|
||||
let dn = row.name;
|
||||
if (ctx.measureText(dn).width > nw) { while (ctx.measureText(dn + "...").width > nw && dn.length) dn = dn.slice(0, -1); dn += "..."; }
|
||||
ctx.fillText(dn, nx, ty + 4);
|
||||
this.clickZones.push({ type: "name", index: di, x: nx, y, w: nw, h });
|
||||
const tx = pad + 20;
|
||||
ctx.fillStyle = row.enabled ? "#4CAF50" : "#555";
|
||||
ctx.beginPath(); ctx.arc(tx + 8, ty, 7, 0, Math.PI * 2); ctx.fill();
|
||||
this.clickZones.push({ type: "toggle", index: di, x: tx, y, w: 24, h });
|
||||
|
||||
const alx = this.size[0] - rpw + 10;
|
||||
ctx.fillStyle = row.enabled ? "#4CAF50" : "#555";
|
||||
ctx.beginPath(); ctx.moveTo(alx + 18, y + 8); ctx.lineTo(alx + 8, ty); ctx.lineTo(alx + 18, y + 22); ctx.fill();
|
||||
this.clickZones.push({ type: "left", index: di, x: alx, y, w: 28, h });
|
||||
const nx = tx + 20, nw = this.size[0] - pad * 2 - 10 - rpw - 25;
|
||||
ctx.fillStyle = row.enabled ? "#fff" : "#777"; ctx.font = "12px sans-serif";
|
||||
let dn = row.name;
|
||||
if (ctx.measureText(dn).width > nw) { while (ctx.measureText(dn + "...").width > nw && dn.length) dn = dn.slice(0, -1); dn += "..."; }
|
||||
ctx.fillText(dn, nx, ty + 4);
|
||||
this.clickZones.push({ type: "name", index: di, x: nx, y, w: nw, h });
|
||||
|
||||
const sx = alx + 25, sw = 50;
|
||||
ctx.fillStyle = "#222"; ctx.fillRect(sx, y + 5, sw, h - 10);
|
||||
ctx.strokeStyle = row.enabled ? "#4CAF50" : "#555"; ctx.strokeRect(sx, y + 5, sw, h - 10);
|
||||
ctx.fillStyle = row.enabled ? "#fff" : "#777"; ctx.textAlign = "center";
|
||||
ctx.fillText(row.strength_model.toFixed(2), sx + sw / 2, ty + 4); ctx.textAlign = "left";
|
||||
this.clickZones.push({ type: "str", index: di, x: sx, y, w: sw, h });
|
||||
const alx = this.size[0] - rpw + 10;
|
||||
ctx.fillStyle = row.enabled ? "#4CAF50" : "#555";
|
||||
ctx.beginPath(); ctx.moveTo(alx + 18, y + 8); ctx.lineTo(alx + 8, ty); ctx.lineTo(alx + 18, y + 22); ctx.fill();
|
||||
this.clickZones.push({ type: "left", index: di, x: alx, y, w: 28, h });
|
||||
|
||||
const arx = sx + sw + 5;
|
||||
ctx.fillStyle = row.enabled ? "#4CAF50" : "#555";
|
||||
ctx.beginPath(); ctx.moveTo(arx + 2, y + 8); ctx.lineTo(arx + 12, ty); ctx.lineTo(arx + 2, y + 22); ctx.fill();
|
||||
this.clickZones.push({ type: "right", index: di, x: arx, y, w: 18, h });
|
||||
const sx = alx + 25, sw = 50;
|
||||
ctx.fillStyle = "#222"; ctx.fillRect(sx, y + 5, sw, h - 10);
|
||||
ctx.strokeStyle = row.enabled ? "#4CAF50" : "#555"; ctx.strokeRect(sx, y + 5, sw, h - 10);
|
||||
ctx.fillStyle = row.enabled ? "#fff" : "#777"; ctx.textAlign = "center";
|
||||
ctx.fillText(row.strength_model.toFixed(2), sx + sw / 2, ty + 4); ctx.textAlign = "left";
|
||||
this.clickZones.push({ type: "str", index: di, x: sx, y, w: sw, h });
|
||||
|
||||
const dx = arx + 22;
|
||||
ctx.fillStyle = "#f44336"; ctx.fillText("❌", dx, ty + 4);
|
||||
this.clickZones.push({ type: "delete", index: di, x: dx, y, w: 30, h });
|
||||
}
|
||||
const arx = sx + sw + 5;
|
||||
ctx.fillStyle = row.enabled ? "#4CAF50" : "#555";
|
||||
ctx.beginPath(); ctx.moveTo(arx + 2, y + 8); ctx.lineTo(arx + 12, ty); ctx.lineTo(arx + 2, y + 22); ctx.fill();
|
||||
this.clickZones.push({ type: "right", index: di, x: arx, y, w: 18, h });
|
||||
|
||||
if (this.draggingIndex !== null && this.dragCurrentY !== null) {
|
||||
const row = this.loraRows[this.draggingIndex];
|
||||
const h = this.rowHeight - 2, y = this.dragCurrentY - h / 2, ty = y + h / 2;
|
||||
ctx.globalAlpha = 0.8; ctx.fillStyle = "#3a5a3a";
|
||||
ctx.fillRect(pad, y, this.size[0] - pad * 2, h);
|
||||
ctx.fillStyle = "#fff"; ctx.font = "14px sans-serif"; ctx.fillText("⋮⋮", pad + 2, ty + 5);
|
||||
ctx.font = "12px sans-serif"; ctx.fillText(row.name, pad + 25, ty + 4);
|
||||
ctx.globalAlpha = 1;
|
||||
let ti = Math.floor((this.dragCurrentY - startY) / this.rowHeight) + this.scrollOffset;
|
||||
ti = Math.max(0, Math.min(ti, this.loraRows.length - 1));
|
||||
if (ti !== this.draggingIndex) {
|
||||
const tgy = startY + (ti - this.scrollOffset) * this.rowHeight;
|
||||
ctx.strokeStyle = "#4CAF50"; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(pad, tgy); ctx.lineTo(this.size[0] - pad, tgy); ctx.stroke(); ctx.lineWidth = 1;
|
||||
const dx = arx + 22;
|
||||
ctx.fillStyle = "#f44336"; ctx.fillText("❌", dx, ty + 4);
|
||||
this.clickZones.push({ type: "delete", index: di, x: dx, y, w: 30, h });
|
||||
}
|
||||
|
||||
if (this.draggingIndex !== null && this.dragCurrentY !== null) {
|
||||
const row = this.loraRows[this.draggingIndex];
|
||||
const h = this.rowHeight - 2, y = this.dragCurrentY - h / 2, ty = y + h / 2;
|
||||
ctx.globalAlpha = 0.8; ctx.fillStyle = "#3a5a3a";
|
||||
ctx.fillRect(pad, y, this.size[0] - pad * 2, h);
|
||||
ctx.fillStyle = "#fff"; ctx.font = "14px sans-serif"; ctx.fillText("⋮⋮", pad + 2, ty + 5);
|
||||
ctx.font = "12px sans-serif"; ctx.fillText(row.name, pad + 25, ty + 4);
|
||||
ctx.globalAlpha = 1;
|
||||
let ti = Math.floor((this.dragCurrentY - startY) / this.rowHeight) + this.scrollOffset;
|
||||
ti = Math.max(0, Math.min(ti, this.loraRows.length - 1));
|
||||
if (ti !== this.draggingIndex) {
|
||||
const tgy = startY + (ti - this.scrollOffset) * this.rowHeight;
|
||||
ctx.strokeStyle = "#4CAF50"; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(pad, tgy); ctx.lineTo(this.size[0] - pad, tgy); ctx.stroke(); ctx.lineWidth = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.loraRows.length > maxV) {
|
||||
if (this.scrollOffset > 0) {
|
||||
ctx.fillStyle = "rgba(255,215,0,0.6)"; ctx.beginPath();
|
||||
ctx.moveTo(this.size[0]/2-8, startY-2); ctx.lineTo(this.size[0]/2+8, startY-2); ctx.lineTo(this.size[0]/2, startY-10);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
if (ve < this.loraRows.length) {
|
||||
const iy = startY + (ve - vs) * this.rowHeight + 2;
|
||||
ctx.fillStyle = "rgba(255,215,0,0.6)"; ctx.beginPath();
|
||||
ctx.moveTo(this.size[0]/2-8, iy); ctx.lineTo(this.size[0]/2+8, iy); ctx.lineTo(this.size[0]/2, iy+8);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.loraRows.length > maxV) {
|
||||
if (this.scrollOffset > 0) {
|
||||
ctx.fillStyle = "rgba(255,215,0,0.6)"; ctx.beginPath();
|
||||
ctx.moveTo(this.size[0]/2-8, startY-2); ctx.lineTo(this.size[0]/2+8, startY-2); ctx.lineTo(this.size[0]/2, startY-10);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
if (ve < this.loraRows.length) {
|
||||
const iy = startY + (ve - vs) * this.rowHeight + 2;
|
||||
ctx.fillStyle = "rgba(255,215,0,0.6)"; ctx.beginPath();
|
||||
ctx.moveTo(this.size[0]/2-8, iy); ctx.lineTo(this.size[0]/2+8, iy); ctx.lineTo(this.size[0]/2, iy+8);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
const btnW = this.size[0] - pad * 2;
|
||||
const btnH = CLEAR_BTN_H - 2;
|
||||
ctx.fillStyle = "#3a1a1a";
|
||||
ctx.fillRect(pad, clearBtnY, btnW, btnH);
|
||||
ctx.strokeStyle = "#f44336";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(pad + 0.5, clearBtnY + 0.5, btnW - 1, btnH - 1);
|
||||
ctx.fillStyle = "#f44336";
|
||||
ctx.font = "bold 13px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("🗑 CLEAR LoRA LIST", this.size[0] / 2, clearBtnY + btnH / 2 + 1);
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
this.clickZones.push({ type: "clear", x: pad, y: clearBtnY, w: btnW, h: CLEAR_BTN_H });
|
||||
};
|
||||
|
||||
nodeType.prototype.onMouseDown = function(e, pos) {
|
||||
if (!this.clickZones?.length) return false;
|
||||
for (const z of this.clickZones) {
|
||||
if (pos[0] >= z.x && pos[0] <= z.x + z.w && pos[1] >= z.y && pos[1] <= z.y + z.h) {
|
||||
if (z.type === "drag") { this.draggingIndex = z.index; this.dragCurrentY = pos[1]; this.graph?.setDirtyCanvas(true, true); return true; }
|
||||
if (z.type === "toggle") { this.resetBatch(); this.loraRows[z.index].enabled = !this.loraRows[z.index].enabled; this.syncData(); this.graph?.setDirtyCanvas(true, true); return true; }
|
||||
if (z.type === "str") {
|
||||
this.showStrengthEditor(z.index, z.x + z.w / 2, z.y + z.h / 2);
|
||||
if (z.type === "clear") {
|
||||
this.loraRows = [];
|
||||
this.scrollOffset = 0;
|
||||
this.manual_size = false;
|
||||
this.syncData();
|
||||
requestAnimationFrame(() => this.safeAutoResize());
|
||||
this.graph?.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
if (z.type === "left") { this.resetBatch(); this.loraRows[z.index].strength_model = Math.max(-10, Math.round((this.loraRows[z.index].strength_model - 0.05) * 20) / 20); this.syncData(); this.graph?.setDirtyCanvas(true, true); return true; }
|
||||
if (z.type === "right") { this.resetBatch(); this.loraRows[z.index].strength_model = Math.min(10, Math.round((this.loraRows[z.index].strength_model + 0.05) * 20) / 20); this.syncData(); this.graph?.setDirtyCanvas(true, true); return true; }
|
||||
if (z.type === "delete") { this.resetBatch(); this.loraRows.splice(z.index, 1); this.scrollOffset = 0; this.manual_size = false; this.syncData(); requestAnimationFrame(() => this.safeAutoResize()); return true; }
|
||||
if (z.type === "drag") { this.draggingIndex = z.index; this.dragCurrentY = pos[1]; this.graph?.setDirtyCanvas(true, true); return true; }
|
||||
if (z.type === "toggle") { this.loraRows[z.index].enabled = !this.loraRows[z.index].enabled; this.syncData(); this.graph?.setDirtyCanvas(true, true); return true; }
|
||||
if (z.type === "str") { this.showStrengthEditor(z.index, z.x + z.w / 2, z.y + z.h / 2); return true; }
|
||||
if (z.type === "left") { this.loraRows[z.index].strength_model = Math.max(-10, Math.round((this.loraRows[z.index].strength_model - 0.05) * 20) / 20); this.syncData(); this.graph?.setDirtyCanvas(true, true); return true; }
|
||||
if (z.type === "right") { this.loraRows[z.index].strength_model = Math.min(10, Math.round((this.loraRows[z.index].strength_model + 0.05) * 20) / 20); this.syncData(); this.graph?.setDirtyCanvas(true, true); return true; }
|
||||
if (z.type === "delete") { this.loraRows.splice(z.index, 1); this.scrollOffset = 0; this.manual_size = false; this.syncData(); requestAnimationFrame(() => this.safeAutoResize()); return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
nodeType.prototype.onMouseMove = function(e, pos) { if (this.draggingIndex !== null) { this.dragCurrentY = pos[1]; this.graph?.setDirtyCanvas(true, true); return true; } return false; };
|
||||
nodeType.prototype.onMouseUp = function() {
|
||||
if (this.draggingIndex !== null) {
|
||||
const sy = this.getListStartY();
|
||||
let ti = Math.floor((this.dragCurrentY - sy) / this.rowHeight) + this.scrollOffset;
|
||||
ti = Math.max(0, Math.min(ti, this.loraRows.length - 1));
|
||||
if (ti !== this.draggingIndex) { const item = this.loraRows.splice(this.draggingIndex, 1)[0]; this.loraRows.splice(ti, 0, item); this.resetBatch(); this.syncData(); this.updateUI(); }
|
||||
if (ti !== this.draggingIndex) { const item = this.loraRows.splice(this.draggingIndex, 1)[0]; this.loraRows.splice(ti, 0, item); this.syncData(); this.updateUI(); }
|
||||
this.draggingIndex = null; this.dragCurrentY = null; this.graph?.setDirtyCanvas(true, true); return true;
|
||||
}
|
||||
return false;
|
||||
@@ -624,30 +608,30 @@ app.registerExtension({
|
||||
const expanded = {};
|
||||
this.currentFilter = "";
|
||||
const mw = 450, mh = 600;
|
||||
let ml = 100, mt = 100;
|
||||
if (widget && app?.canvas) {
|
||||
const r = app.canvas.canvas.getBoundingClientRect();
|
||||
const s = app.canvas.ds.scale, ox = app.canvas.ds.offset[0], oy = app.canvas.ds.offset[1];
|
||||
let cl = r.left + ((this.pos[0] + this.size[0]) * s) + ox + 10;
|
||||
const ct = r.top + ((this.pos[1] + widget.y) * s) + oy;
|
||||
if (cl + mw > window.innerWidth) cl = r.left + (this.pos[0] * s) + ox - mw - 10;
|
||||
if (cl < 10) cl = 10;
|
||||
mt = Math.max(10, Math.min(ct, window.innerHeight - mh - 10));
|
||||
ml = cl;
|
||||
}
|
||||
|
||||
const old = document.getElementById("rs-lora-batch-tree-menu");
|
||||
if (old) old.remove();
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.id = "rs-lora-batch-tree-menu";
|
||||
menu.style.cssText = `position:fixed;background:#1a1a1a;border:1px solid #444;border-radius:6px;height:${mh}px;width:${mw}px;overflow-y:auto;overflow-x:hidden;z-index:10000;left:${ml}px;top:${mt}px;box-shadow:0 4px 20px rgba(0,0,0,0.8);display:flex;flex-direction:column;`;
|
||||
menu.style.cssText = `position:fixed;background:#1a1a1a;border:1px solid #444;border-radius:6px;height:${mh}px;width:${mw}px;overflow-y:auto;overflow-x:hidden;z-index:10000;box-shadow:0 4px 20px rgba(0,0,0,0.8);display:flex;flex-direction:column;`;
|
||||
|
||||
const hc = document.createElement("div");
|
||||
hc.style.cssText = "padding:10px;background:#252525;border-bottom:1px solid #333;display:flex;flex-direction:column;gap:8px;flex-shrink:0;";
|
||||
const title = document.createElement("div"); title.textContent = " Search & Select LoRA"; title.style.cssText = "color:#fff;font-weight:bold;font-size:14px;";
|
||||
const si = document.createElement("input"); si.type = "text"; si.placeholder = "Type to search...";
|
||||
const title = document.createElement("div");
|
||||
title.textContent = " Search & Select LoRA";
|
||||
title.style.cssText = "color:#fff;font-weight:bold;font-size:14px;";
|
||||
const si = document.createElement("input");
|
||||
si.type = "text";
|
||||
si.placeholder = "Type to search...";
|
||||
si.style.cssText = "width:100%;padding:8px;background:#333;border:1px solid #555;color:#fff;border-radius:4px;outline:none;font-size:13px;box-sizing:border-box;";
|
||||
si.autofocus = true;
|
||||
hc.append(title, si); menu.appendChild(hc);
|
||||
const lc = document.createElement("div"); lc.style.cssText = "padding:5px 0;overflow-y:auto;flex-grow:1;"; menu.appendChild(lc);
|
||||
hc.append(title, si);
|
||||
menu.appendChild(hc);
|
||||
|
||||
const lc = document.createElement("div");
|
||||
lc.style.cssText = "padding:5px 0;overflow-y:auto;flex-grow:1;";
|
||||
menu.appendChild(lc);
|
||||
const isAdded = (n) => self.loraRows.some(r => r.name === n);
|
||||
|
||||
const render = (filter = "") => {
|
||||
@@ -661,41 +645,99 @@ app.registerExtension({
|
||||
ni.style.cssText = `padding:10px 12px;cursor:pointer;color:${a?'#4CAF50':'#aaa'};border-bottom:1px solid #333;background:#2a2a2a;font-style:italic;`;
|
||||
ni.onmouseenter = () => ni.style.background = "#3a3a3a";
|
||||
ni.onmouseleave = () => ni.style.background = "#2a2a2a";
|
||||
ni.onclick = (e) => { e.stopPropagation(); self.addRow("None"); self.graph?.setDirtyCanvas(true, true); render(filter); };
|
||||
ni.onclick = (e) => { e.stopPropagation(); self.addRow("None"); self.graph?.setDirtyCanvas(true, true); render(filter); };
|
||||
lc.appendChild(ni);
|
||||
}
|
||||
if (!Object.keys(self.loraTree).length) {
|
||||
if (!filter) { const em = document.createElement("div"); em.textContent = " Empty (Click UPDATE)"; em.style.cssText = "padding:20px;color:#f44336;text-align:center;"; lc.appendChild(em); }
|
||||
if (!filter) {
|
||||
const em = document.createElement("div");
|
||||
em.textContent = " Empty (Click UPDATE)";
|
||||
em.style.cssText = "padding:20px;color:#f44336;text-align:center;";
|
||||
lc.appendChild(em);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (lf.length > 0) {
|
||||
const all = getAllPaths(self.loraTree).filter(p => !p.isFolder && p.path.toLowerCase().includes(lf));
|
||||
if (!all.length) { const nr = document.createElement("div"); nr.textContent = `No results for "${filter}"`; nr.style.cssText = "padding:15px;color:#777;text-align:center;font-style:italic;"; lc.appendChild(nr); }
|
||||
else all.forEach(it => {
|
||||
if (!all.length) {
|
||||
const nr = document.createElement("div");
|
||||
nr.textContent = `No results for "${filter}"`;
|
||||
nr.style.cssText = "padding:15px;color:#777;text-align:center;font-style:italic;";
|
||||
lc.appendChild(nr);
|
||||
} else all.forEach(it => {
|
||||
const a = isAdded(it.path);
|
||||
const el = document.createElement("div");
|
||||
el.textContent = (a ? "✓ " : " ") + it.path;
|
||||
el.style.cssText = `padding:8px 12px;cursor:pointer;color:${a?'#4CAF50':'#ddd'};font-size:12px;border-bottom:1px solid #2a2a2a;`;
|
||||
el.onmouseenter = () => el.style.background = "#333";
|
||||
el.onmouseleave = () => el.style.background = "transparent";
|
||||
el.onclick = (e) => { e.stopPropagation(); self.addRow(it.path); self.graph?.setDirtyCanvas(true, true); render(filter); };
|
||||
el.onclick = (e) => { e.stopPropagation(); self.addRow(it.path); self.graph?.setDirtyCanvas(true, true); render(filter); };
|
||||
lc.appendChild(el);
|
||||
});
|
||||
} else { renderTree("", self.loraTree, 0, lc, expanded, self, render); }
|
||||
} else {
|
||||
renderTree("", self.loraTree, 0, lc, expanded, self, render);
|
||||
}
|
||||
};
|
||||
|
||||
render("");
|
||||
let tid = null;
|
||||
si.addEventListener("input", (e) => { if (tid) clearTimeout(tid); tid = setTimeout(() => render(e.target.value), 50); });
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const anchorEl = self._addBtnEl;
|
||||
const positionMenu = () => {
|
||||
if (!anchorEl || !menu.parentNode || !anchorEl.isConnected) return false;
|
||||
const r = anchorEl.getBoundingClientRect();
|
||||
let left = r.right + 10;
|
||||
let top = r.top;
|
||||
if (left + mw > window.innerWidth - 10) left = r.left - mw - 10;
|
||||
if (left < 10) left = 10;
|
||||
if (top + mh > window.innerHeight - 10) top = window.innerHeight - mh - 10;
|
||||
if (top < 10) top = 10;
|
||||
menu.style.left = left + "px";
|
||||
menu.style.top = top + "px";
|
||||
return true;
|
||||
};
|
||||
|
||||
positionMenu();
|
||||
|
||||
let rafId = null;
|
||||
const tick = () => {
|
||||
if (!menu.parentNode) { rafId = null; return; }
|
||||
if (!positionMenu()) { close(); return; }
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
|
||||
let tid = null;
|
||||
si.addEventListener("input", (e) => {
|
||||
if (tid) clearTimeout(tid);
|
||||
tid = setTimeout(() => render(e.target.value), 50);
|
||||
});
|
||||
|
||||
setTimeout(() => si.focus(), 50);
|
||||
|
||||
let ct2 = null;
|
||||
const close = () => { if (menu.parentNode) menu.remove(); document.removeEventListener("pointerdown", hoc, true); document.removeEventListener("keydown", hek, true); if (ct2) { clearTimeout(ct2); ct2 = null; } };
|
||||
const close = () => {
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
||||
if (menu.parentNode) menu.remove();
|
||||
document.removeEventListener("pointerdown", hoc, true);
|
||||
document.removeEventListener("keydown", hek, true);
|
||||
if (ct2) { clearTimeout(ct2); ct2 = null; }
|
||||
};
|
||||
const hek = (ev) => { if (ev.key === "Escape") close(); };
|
||||
const hoc = (ev) => { if (menu.contains(ev.target)) { if (ct2) { clearTimeout(ct2); ct2 = null; } return; } close(); };
|
||||
const hoc = (ev) => {
|
||||
if (menu.contains(ev.target)) {
|
||||
if (ct2) { clearTimeout(ct2); ct2 = null; }
|
||||
return;
|
||||
}
|
||||
close();
|
||||
};
|
||||
menu.addEventListener("mouseleave", () => { ct2 = setTimeout(close, 300); });
|
||||
menu.addEventListener("mouseenter", () => { if (ct2) { clearTimeout(ct2); ct2 = null; } });
|
||||
setTimeout(() => { document.addEventListener("pointerdown", hoc, true); document.addEventListener("keydown", hek, true); }, 50);
|
||||
setTimeout(() => {
|
||||
document.addEventListener("pointerdown", hoc, true);
|
||||
document.addEventListener("keydown", hek, true);
|
||||
}, 50);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
+89
-24
@@ -84,6 +84,47 @@ function mkEl(tag, css) {
|
||||
return el;
|
||||
}
|
||||
|
||||
function isUidOccupied(uid) {
|
||||
if (!uid) return false;
|
||||
try {
|
||||
const key = `rs_prompt_${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return false;
|
||||
const data = JSON.parse(raw);
|
||||
const now = Date.now();
|
||||
const age = (now - (data.timestamp || 0)) / 1000 / 60 / 60;
|
||||
return age <= 2;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function generateUniqueUid() {
|
||||
let localStorageAvailable = true;
|
||||
try {
|
||||
localStorage.setItem('__test__', 'test');
|
||||
localStorage.removeItem('__test__');
|
||||
} catch (_) {
|
||||
localStorageAvailable = false;
|
||||
}
|
||||
if (!localStorageAvailable) {
|
||||
return 'rs_inst_' + crypto.randomUUID().replace(/-/g, '');
|
||||
}
|
||||
let uid;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100;
|
||||
do {
|
||||
uid = 'rs_inst_' + crypto.randomUUID().replace(/-/g, '');
|
||||
if (!isUidOccupied(uid)) break;
|
||||
attempts++;
|
||||
if (attempts >= maxAttempts) {
|
||||
console.warn("[RSPrompts] Max attempts reached for unique UID, using fallback");
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
return uid;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "RSPrompts",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
@@ -97,6 +138,16 @@ app.registerExtension({
|
||||
nodeType.prototype.onConfigure = function(data) {
|
||||
const result = origOnConfigure ? origOnConfigure.apply(this, arguments) : undefined;
|
||||
|
||||
let uid = this.properties?.rs_instance_uid || this.widgets?.find(w => w.name === "instance_uid")?.value;
|
||||
if (uid) {
|
||||
if (isUidOccupied(uid)) {
|
||||
uid = generateUniqueUid();
|
||||
this.properties.rs_instance_uid = uid;
|
||||
const uidWidget = this.widgets?.find(w => w.name === "instance_uid");
|
||||
if (uidWidget) uidWidget.value = uid;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.properties?.rs_instance_uid && this.widgets) {
|
||||
const uidWidget = this.widgets.find(w => w.name === "instance_uid");
|
||||
if (uidWidget) {
|
||||
@@ -161,17 +212,15 @@ app.registerExtension({
|
||||
node.properties = {};
|
||||
}
|
||||
|
||||
let instanceUid = node.properties.rs_instance_uid;
|
||||
let instanceUid = node.properties.rs_instance_uid || node.widgets?.find(w => w.name === "instance_uid")?.value;
|
||||
|
||||
if (!instanceUid) {
|
||||
const uidWidget = node.widgets?.find(w => w.name === "instance_uid");
|
||||
if (uidWidget && uidWidget.value) {
|
||||
instanceUid = uidWidget.value;
|
||||
} else {
|
||||
instanceUid = 'rs_inst_' + crypto.randomUUID().replace(/-/g, '');
|
||||
}
|
||||
node.properties.rs_instance_uid = instanceUid;
|
||||
if (instanceUid && isUidOccupied(instanceUid)) {
|
||||
instanceUid = generateUniqueUid();
|
||||
} else if (!instanceUid) {
|
||||
instanceUid = generateUniqueUid();
|
||||
}
|
||||
|
||||
node.properties.rs_instance_uid = instanceUid;
|
||||
|
||||
if (node.properties.rs_pause_state === undefined) {
|
||||
const pauseWidget = node.widgets?.find(w => w.name === "pause_for_edit");
|
||||
@@ -474,10 +523,18 @@ app.registerExtension({
|
||||
} else {
|
||||
const currentUid = node.properties.rs_instance_uid || node.widgets?.find(w => w.name === "instance_uid")?.value;
|
||||
const textKey = `rs_prompt_${currentUid}`;
|
||||
const savedText = localStorage.getItem(textKey);
|
||||
if (savedText !== null) {
|
||||
customTextarea.value = savedText;
|
||||
if (textWidget) textWidget.value = savedText;
|
||||
const saved = localStorage.getItem(textKey);
|
||||
if (saved) {
|
||||
try {
|
||||
const data = JSON.parse(saved);
|
||||
if (data.text !== undefined) {
|
||||
customTextarea.value = data.text;
|
||||
if (textWidget) textWidget.value = data.text;
|
||||
}
|
||||
} catch (_) {
|
||||
customTextarea.value = saved;
|
||||
if (textWidget) textWidget.value = saved;
|
||||
}
|
||||
}
|
||||
updateStatusAndUI();
|
||||
}
|
||||
@@ -492,13 +549,21 @@ app.registerExtension({
|
||||
customTextarea.value = node.properties.rs_waiting_prompt;
|
||||
if (textWidget) textWidget.value = node.properties.rs_waiting_prompt;
|
||||
} else {
|
||||
const savedText = localStorage.getItem(textKey);
|
||||
if (savedText !== null && textWidget) {
|
||||
textWidget.value = savedText;
|
||||
customTextarea.value = savedText;
|
||||
const saved = localStorage.getItem(textKey);
|
||||
if (saved) {
|
||||
try {
|
||||
const data = JSON.parse(saved);
|
||||
if (data.text !== undefined) {
|
||||
customTextarea.value = data.text;
|
||||
if (textWidget) textWidget.value = data.text;
|
||||
}
|
||||
} catch (_) {
|
||||
customTextarea.value = saved;
|
||||
if (textWidget) textWidget.value = saved;
|
||||
}
|
||||
} else if (textWidget) {
|
||||
const initialText = textWidget.value || "";
|
||||
localStorage.setItem(textKey, initialText);
|
||||
localStorage.setItem(textKey, JSON.stringify({ text: initialText, timestamp: Date.now() }));
|
||||
customTextarea.value = initialText;
|
||||
}
|
||||
}
|
||||
@@ -534,7 +599,7 @@ app.registerExtension({
|
||||
textWidget.value = customTextarea.value;
|
||||
const currentUid = node.properties.rs_instance_uid || node.widgets?.find(w => w.name === "instance_uid")?.value;
|
||||
const currentTextKey = `rs_prompt_${currentUid}`;
|
||||
localStorage.setItem(currentTextKey, customTextarea.value);
|
||||
localStorage.setItem(currentTextKey, JSON.stringify({ text: customTextarea.value, timestamp: Date.now() }));
|
||||
if (node.properties.rs_is_waiting) {
|
||||
node.properties.rs_waiting_prompt = customTextarea.value;
|
||||
}
|
||||
@@ -591,7 +656,7 @@ app.registerExtension({
|
||||
customTextarea.value = "";
|
||||
const currentUid = node.properties.rs_instance_uid || node.widgets?.find(w => w.name === "instance_uid")?.value;
|
||||
const currentTextKey = `rs_prompt_${currentUid}`;
|
||||
localStorage.setItem(currentTextKey, "");
|
||||
localStorage.setItem(currentTextKey, JSON.stringify({ text: "", timestamp: Date.now() }));
|
||||
if (node.properties.rs_is_waiting) {
|
||||
node.properties.rs_waiting_prompt = "";
|
||||
}
|
||||
@@ -655,7 +720,7 @@ app.registerExtension({
|
||||
customTextarea.value = data.text || "";
|
||||
const currentUid = node.properties.rs_instance_uid || node.widgets?.find(w => w.name === "instance_uid")?.value;
|
||||
const currentTextKey = `rs_prompt_${currentUid}`;
|
||||
localStorage.setItem(currentTextKey, data.text || "");
|
||||
localStorage.setItem(currentTextKey, JSON.stringify({ text: data.text || "", timestamp: Date.now() }));
|
||||
}
|
||||
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
@@ -715,7 +780,7 @@ app.registerExtension({
|
||||
if (textWidget) {
|
||||
textWidget.value = event.detail.prompt;
|
||||
const currentTextKey = `rs_prompt_${currentUid}`;
|
||||
localStorage.setItem(currentTextKey, event.detail.prompt);
|
||||
localStorage.setItem(currentTextKey, JSON.stringify({ text: event.detail.prompt, timestamp: Date.now() }));
|
||||
}
|
||||
updateStatusAndUI();
|
||||
}
|
||||
@@ -729,7 +794,7 @@ app.registerExtension({
|
||||
if (textWidget) {
|
||||
textWidget.value = event.detail.prompt;
|
||||
const currentTextKey = `rs_prompt_${currentUid}`;
|
||||
localStorage.setItem(currentTextKey, event.detail.prompt);
|
||||
localStorage.setItem(currentTextKey, JSON.stringify({ text: event.detail.prompt, timestamp: Date.now() }));
|
||||
}
|
||||
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
||||
}, 10);
|
||||
@@ -744,7 +809,7 @@ app.registerExtension({
|
||||
if (textWidget && textWidget.value) {
|
||||
const currentUid = node.properties.rs_instance_uid || node.widgets?.find(w => w.name === "instance_uid")?.value;
|
||||
const currentTextKey = `rs_prompt_${currentUid}`;
|
||||
localStorage.setItem(currentTextKey, textWidget.value);
|
||||
localStorage.setItem(currentTextKey, JSON.stringify({ text: textWidget.value, timestamp: Date.now() }));
|
||||
}
|
||||
if (pauseWidget) node.properties.rs_pause_state = pauseWidget.value;
|
||||
if (enableWidget) node.properties.rs_enable_state = enableWidget.value;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const NODE_NAME = "SplitVideoAudio";
|
||||
const NODE_WIDTH = 220;
|
||||
|
||||
app.registerExtension({
|
||||
name: "RaykoStudio.SplitVideoAudio.Width",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE_NAME) return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
|
||||
this.minWidth = NODE_WIDTH;
|
||||
|
||||
const height = this.size?.[1] ?? this.computeSize()[1];
|
||||
this.setSize([NODE_WIDTH, height]);
|
||||
};
|
||||
|
||||
const onResize = nodeType.prototype.onResize;
|
||||
nodeType.prototype.onResize = function (size) {
|
||||
if (size[0] < NODE_WIDTH) size[0] = NODE_WIDTH;
|
||||
return onResize?.apply(this, arguments);
|
||||
};
|
||||
},
|
||||
});
|
||||
+866
-62
File diff suppressed because it is too large
Load Diff
+201
-13
@@ -1,30 +1,218 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
let topbarInterval = null;
|
||||
const STORAGE_KEY = "RaykoStudio.ShowIcon";
|
||||
|
||||
app.registerExtension({
|
||||
name: "RaykoStudio.SettingsManager",
|
||||
|
||||
async setup() {
|
||||
if (app.extensionManager && app.extensionManager.registerSidebarTab) {
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "RaykoStudio.SettingsManager",
|
||||
icon: "pi pi-desktop",
|
||||
title: "🦊 Settings Manager",
|
||||
tooltip: "Interface Settings Manager",
|
||||
type: "custom",
|
||||
render: (el) => {
|
||||
renderSettingsPanel(el);
|
||||
},
|
||||
});
|
||||
}
|
||||
// Одна настройка без явной category — ComfyUI возьмет раздел из id (часть до точки)
|
||||
app.ui.settings.addSetting({
|
||||
id: "RaykoStudio.SettingsManager",
|
||||
name: "Settings Manager",
|
||||
type: () => {
|
||||
const container = document.createElement("div");
|
||||
container.id = "sm-settings-container";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.gap = "4px";
|
||||
container.style.margin = "0";
|
||||
container.style.padding = "0";
|
||||
|
||||
// --- Строка с тогглом ---
|
||||
const toggleRow = document.createElement("label");
|
||||
toggleRow.style.display = "flex";
|
||||
toggleRow.style.alignItems = "center";
|
||||
toggleRow.style.gap = "10px";
|
||||
toggleRow.style.cursor = "pointer";
|
||||
toggleRow.style.fontSize = "14px";
|
||||
toggleRow.style.lineHeight = "1.2";
|
||||
|
||||
const toggleInput = document.createElement("input");
|
||||
toggleInput.type = "checkbox";
|
||||
toggleInput.style.cursor = "pointer";
|
||||
toggleInput.style.width = "16px";
|
||||
toggleInput.style.height = "16px";
|
||||
toggleInput.style.margin = "0";
|
||||
|
||||
const isEnabled = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
toggleInput.checked = isEnabled;
|
||||
|
||||
toggleInput.onchange = (e) => {
|
||||
localStorage.setItem(STORAGE_KEY, e.target.checked ? "true" : "false");
|
||||
updateTopbarIcon(e.target.checked);
|
||||
};
|
||||
|
||||
const toggleText = document.createElement("span");
|
||||
toggleText.textContent = "Show Icon On Menu";
|
||||
toggleText.style.color = "var(--fg-color)";
|
||||
|
||||
toggleRow.appendChild(toggleInput);
|
||||
toggleRow.appendChild(toggleText);
|
||||
|
||||
// --- Кнопка открытия менеджера ---
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "sm-btn sm-btn-success";
|
||||
btn.textContent = "Open Settings Manager";
|
||||
btn.style.width = "auto";
|
||||
btn.style.alignSelf = "flex-start";
|
||||
btn.style.padding = "6px 12px";
|
||||
btn.style.fontSize = "13px";
|
||||
btn.style.marginTop = "4px";
|
||||
btn.onclick = () => openSettingsManagerModal();
|
||||
|
||||
container.appendChild(toggleRow);
|
||||
container.appendChild(btn);
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
||||
// Инициализация при загрузке
|
||||
setTimeout(() => {
|
||||
const initialVal = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
updateTopbarIcon(initialVal);
|
||||
}, 1000);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Управление иконкой в верхнем меню ---
|
||||
|
||||
function updateTopbarIcon(show) {
|
||||
if (show) {
|
||||
startTopbarInjection();
|
||||
} else {
|
||||
stopTopbarInjection();
|
||||
const existingBtn = document.getElementById("sm-topbar-btn");
|
||||
if (existingBtn) {
|
||||
existingBtn.remove();
|
||||
console.log("🦊 Settings Manager: Иконка скрыта.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startTopbarInjection() {
|
||||
if (topbarInterval) return;
|
||||
|
||||
console.log("🦊 Settings Manager: Начинаем поиск верхнего меню...");
|
||||
let attempts = 0;
|
||||
const maxAttempts = 20;
|
||||
|
||||
topbarInterval = setInterval(() => {
|
||||
attempts++;
|
||||
const actionBar = document.querySelector('.actionbar-container');
|
||||
|
||||
if (actionBar) {
|
||||
if (document.getElementById("sm-topbar-btn")) {
|
||||
clearInterval(topbarInterval);
|
||||
topbarInterval = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const topBtn = document.createElement("button");
|
||||
topBtn.id = "sm-topbar-btn";
|
||||
topBtn.className = "comfyui-button";
|
||||
topBtn.title = "Settings Manager";
|
||||
topBtn.innerHTML = `<span style="font-size: 18px; line-height: 1;">🖥️</span>`;
|
||||
topBtn.onclick = () => openSettingsManagerModal();
|
||||
|
||||
topBtn.style.width = "38px";
|
||||
topBtn.style.height = "100%";
|
||||
topBtn.style.minHeight = "32px";
|
||||
topBtn.style.maxHeight = "40px";
|
||||
topBtn.style.padding = "0";
|
||||
topBtn.style.margin = "0 5px";
|
||||
topBtn.style.display = "inline-flex";
|
||||
topBtn.style.alignItems = "center";
|
||||
topBtn.style.justifyContent = "center";
|
||||
topBtn.style.cursor = "pointer";
|
||||
topBtn.style.background = "var(--comfy-input-bg)";
|
||||
topBtn.style.color = "var(--fg-color)";
|
||||
topBtn.style.border = "1px solid var(--border-color)";
|
||||
topBtn.style.borderRadius = "8px";
|
||||
topBtn.style.transition = "background 0.2s";
|
||||
topBtn.style.boxSizing = "border-box";
|
||||
|
||||
topBtn.onmouseenter = () => { topBtn.style.background = "var(--comfy-menu-secondary-bg)"; };
|
||||
topBtn.onmouseleave = () => { topBtn.style.background = "var(--comfy-input-bg)"; };
|
||||
|
||||
const runContainer = actionBar.querySelector('.flex.h-full.items-center');
|
||||
if (runContainer) {
|
||||
actionBar.insertBefore(topBtn, runContainer);
|
||||
} else {
|
||||
actionBar.appendChild(topBtn);
|
||||
}
|
||||
|
||||
console.log("🦊 Settings Manager: Иконка добавлена в верхнее меню.");
|
||||
clearInterval(topbarInterval);
|
||||
topbarInterval = null;
|
||||
} else {
|
||||
if (attempts >= maxAttempts) {
|
||||
console.error("🦊 Settings Manager: Не удалось найти .actionbar-container.");
|
||||
clearInterval(topbarInterval);
|
||||
topbarInterval = null;
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function stopTopbarInjection() {
|
||||
if (topbarInterval) {
|
||||
clearInterval(topbarInterval);
|
||||
topbarInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Модальное окно и интерфейс менеджера ---
|
||||
|
||||
function openSettingsManagerModal() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "sm-modal-overlay";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "sm-modal-content";
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "sm-btn sm-btn-danger sm-btn-small";
|
||||
closeBtn.textContent = "✕ Close";
|
||||
closeBtn.style.width = "100%";
|
||||
closeBtn.style.marginTop = "10px";
|
||||
closeBtn.onclick = () => document.body.removeChild(overlay);
|
||||
|
||||
renderSettingsPanel(modal);
|
||||
modal.appendChild(closeBtn);
|
||||
overlay.appendChild(modal);
|
||||
|
||||
overlay.onclick = (e) => {
|
||||
if (e.target === overlay) document.body.removeChild(overlay);
|
||||
};
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function renderSettingsPanel(container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.sm-container { padding: 15px; display: flex; flex-direction: column; gap: 20px; min-height: 300px; }
|
||||
.sm-modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
.sm-modal-content {
|
||||
background: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
width: 90%; max-width: 450px;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
position: relative;
|
||||
}
|
||||
.sm-container { display: flex; flex-direction: column; gap: 20px; min-height: 300px; }
|
||||
.sm-section { background-color: var(--comfy-input-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 15px; }
|
||||
.sm-title { font-size: 16px; font-weight: bold; margin-bottom: 10px; color: var(--fg-color); }
|
||||
.sm-description { font-size: 12px; color: var(--descrip-text); margin-bottom: 15px; }
|
||||
|
||||
Reference in New Issue
Block a user