Remove unused segmented prompt feature
Browse files- APP_FILES.json +8 -58
- MODEL_SOURCES.json +3 -3
- RUNTIME_SOURCES.json +1 -12
- app.js +0 -0
- custom_nodes/hf_prompt_relay/.promptrelay-commit +0 -1
- custom_nodes/hf_prompt_relay/README.md +0 -123
- custom_nodes/hf_prompt_relay/__init__.py +0 -42
- custom_nodes/hf_prompt_relay/advanced_options.py +0 -58
- custom_nodes/hf_prompt_relay/nodes.py +0 -241
- custom_nodes/hf_prompt_relay/parser.py +0 -152
- custom_nodes/hf_prompt_relay/patches.py +0 -179
- custom_nodes/hf_prompt_relay/prompt_relay.py +0 -204
- custom_nodes/hf_prompt_relay/requirements.txt +0 -1
- custom_nodes/hf_prompt_relay/smart_nodes.py +0 -145
- style.css +2 -2
APP_FILES.json
CHANGED
|
@@ -31,8 +31,8 @@
|
|
| 31 |
},
|
| 32 |
{
|
| 33 |
"path": "RUNTIME_SOURCES.json",
|
| 34 |
-
"size":
|
| 35 |
-
"sha256": "
|
| 36 |
},
|
| 37 |
{
|
| 38 |
"path": "custom_nodes/hf_video_extensions/lora_stack_av.py",
|
|
@@ -99,56 +99,6 @@
|
|
| 99 |
"size": 78,
|
| 100 |
"sha256": "976b2b972fe79ee1464504a3509108f0e9f289d119d10d70b1327bd704e41f1d"
|
| 101 |
},
|
| 102 |
-
{
|
| 103 |
-
"path": "custom_nodes/hf_prompt_relay/.promptrelay-commit",
|
| 104 |
-
"size": 41,
|
| 105 |
-
"sha256": "2c3151f945c1879766e69bddff7c57be025b6f52a09932709ff77e9db83bfdc8"
|
| 106 |
-
},
|
| 107 |
-
{
|
| 108 |
-
"path": "custom_nodes/hf_prompt_relay/advanced_options.py",
|
| 109 |
-
"size": 3225,
|
| 110 |
-
"sha256": "f4623279bf5535a2997acd013423c704d191ee2d8fae664f8484276e707bfe0a"
|
| 111 |
-
},
|
| 112 |
-
{
|
| 113 |
-
"path": "custom_nodes/hf_prompt_relay/nodes.py",
|
| 114 |
-
"size": 10941,
|
| 115 |
-
"sha256": "11a8c513c1b069a80a60d06a1917602a5123c4785bb1c147de9d790cdda7c014"
|
| 116 |
-
},
|
| 117 |
-
{
|
| 118 |
-
"path": "custom_nodes/hf_prompt_relay/parser.py",
|
| 119 |
-
"size": 5817,
|
| 120 |
-
"sha256": "bef700925aa2a9fc27cfb859a393c0356106843e38aec70e0c7cf13a1f9ab2b2"
|
| 121 |
-
},
|
| 122 |
-
{
|
| 123 |
-
"path": "custom_nodes/hf_prompt_relay/patches.py",
|
| 124 |
-
"size": 6919,
|
| 125 |
-
"sha256": "ba2386e9a89f03e96d7d3ddcb0d9b76b40a0fd7721884b35c0844219e2d9e622"
|
| 126 |
-
},
|
| 127 |
-
{
|
| 128 |
-
"path": "custom_nodes/hf_prompt_relay/prompt_relay.py",
|
| 129 |
-
"size": 7984,
|
| 130 |
-
"sha256": "c13a99550fae816231eea444db11c33b42502f1bd204418bf1446b7ef1b62a17"
|
| 131 |
-
},
|
| 132 |
-
{
|
| 133 |
-
"path": "custom_nodes/hf_prompt_relay/README.md",
|
| 134 |
-
"size": 5104,
|
| 135 |
-
"sha256": "16c8b1cbceaa6e8dedb9c77812de9bc2eebc6e7d177d29202a95cb7a873ed3a7"
|
| 136 |
-
},
|
| 137 |
-
{
|
| 138 |
-
"path": "custom_nodes/hf_prompt_relay/requirements.txt",
|
| 139 |
-
"size": 11,
|
| 140 |
-
"sha256": "724abb011d42013ae5dfdf76ae6b1c8c78c87101e8ac30f66c6addf8059d51ea"
|
| 141 |
-
},
|
| 142 |
-
{
|
| 143 |
-
"path": "custom_nodes/hf_prompt_relay/smart_nodes.py",
|
| 144 |
-
"size": 6058,
|
| 145 |
-
"sha256": "55d071155f44283625dcf7bb1b20ea0a50aded5cee2bf6bb7020711921e3914d"
|
| 146 |
-
},
|
| 147 |
-
{
|
| 148 |
-
"path": "custom_nodes/hf_prompt_relay/__init__.py",
|
| 149 |
-
"size": 1480,
|
| 150 |
-
"sha256": "ea5e0d53842a8c9c1aa2e3d9f204347c3268f6973120b2a6aca7453eb1398872"
|
| 151 |
-
},
|
| 152 |
{
|
| 153 |
"path": "index.html",
|
| 154 |
"size": 18993,
|
|
@@ -156,13 +106,13 @@
|
|
| 156 |
},
|
| 157 |
{
|
| 158 |
"path": "app.js",
|
| 159 |
-
"size":
|
| 160 |
-
"sha256": "
|
| 161 |
},
|
| 162 |
{
|
| 163 |
"path": "style.css",
|
| 164 |
-
"size":
|
| 165 |
-
"sha256": "
|
| 166 |
},
|
| 167 |
{
|
| 168 |
"path": "laogames.json",
|
|
@@ -181,8 +131,8 @@
|
|
| 181 |
},
|
| 182 |
{
|
| 183 |
"path": "MODEL_SOURCES.json",
|
| 184 |
-
"size":
|
| 185 |
-
"sha256": "
|
| 186 |
},
|
| 187 |
{
|
| 188 |
"path": "SUPPORT_SOURCES.json",
|
|
|
|
| 31 |
},
|
| 32 |
{
|
| 33 |
"path": "RUNTIME_SOURCES.json",
|
| 34 |
+
"size": 3160,
|
| 35 |
+
"sha256": "b578d016ab1e692e7a5078b15cc94694ebb179556feb8df41c1f828437112e78"
|
| 36 |
},
|
| 37 |
{
|
| 38 |
"path": "custom_nodes/hf_video_extensions/lora_stack_av.py",
|
|
|
|
| 99 |
"size": 78,
|
| 100 |
"sha256": "976b2b972fe79ee1464504a3509108f0e9f289d119d10d70b1327bd704e41f1d"
|
| 101 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
{
|
| 103 |
"path": "index.html",
|
| 104 |
"size": 18993,
|
|
|
|
| 106 |
},
|
| 107 |
{
|
| 108 |
"path": "app.js",
|
| 109 |
+
"size": 72624,
|
| 110 |
+
"sha256": "433e10f36bd3241b4d6ee0d49876a277271564203ea788048303f7b944f383f1"
|
| 111 |
},
|
| 112 |
{
|
| 113 |
"path": "style.css",
|
| 114 |
+
"size": 10647,
|
| 115 |
+
"sha256": "44ec3a484b2857ac491f071a18342dc9068309d9ca01a5887c23e19fa5a267a7"
|
| 116 |
},
|
| 117 |
{
|
| 118 |
"path": "laogames.json",
|
|
|
|
| 131 |
},
|
| 132 |
{
|
| 133 |
"path": "MODEL_SOURCES.json",
|
| 134 |
+
"size": 2558,
|
| 135 |
+
"sha256": "c93d66119230e4164b076c79e0661107d63999cc71dcb69af5ecc56ba9494985"
|
| 136 |
},
|
| 137 |
{
|
| 138 |
"path": "SUPPORT_SOURCES.json",
|
MODEL_SOURCES.json
CHANGED
|
@@ -19,7 +19,7 @@
|
|
| 19 |
"size": 29161843630,
|
| 20 |
"sha256": "54bcb40427ff1a3e54cfa6087df765b617bee238d58aa49947c517a727722ad2",
|
| 21 |
"license": null,
|
| 22 |
-
"required_when": "All i2v, v2v, t2v
|
| 23 |
},
|
| 24 |
{
|
| 25 |
"slot": "video-flow.distilled-lora",
|
|
@@ -30,7 +30,7 @@
|
|
| 30 |
"size": 2741024390,
|
| 31 |
"sha256": "31e0c0195fb841bf31af78e8b60858f489e87ddcea4a5239abc80943da65e3ac",
|
| 32 |
"license": "other",
|
| 33 |
-
"required_when": "All i2v, v2v, t2v
|
| 34 |
},
|
| 35 |
{
|
| 36 |
"slot": "video-flow.text-encoder",
|
|
@@ -41,7 +41,7 @@
|
|
| 41 |
"size": 9447702218,
|
| 42 |
"sha256": "aaca463d11e6d8d2a4bdb0d6299214c15ef78a3f73e0ef8113d5a9d0219b3f6d",
|
| 43 |
"license": "other",
|
| 44 |
-
"required_when": "All i2v, v2v, t2v
|
| 45 |
},
|
| 46 |
{
|
| 47 |
"slot": "video-flow.latent-upscaler",
|
|
|
|
| 19 |
"size": 29161843630,
|
| 20 |
"sha256": "54bcb40427ff1a3e54cfa6087df765b617bee238d58aa49947c517a727722ad2",
|
| 21 |
"license": null,
|
| 22 |
+
"required_when": "All i2v, v2v, t2v modes using this default slot."
|
| 23 |
},
|
| 24 |
{
|
| 25 |
"slot": "video-flow.distilled-lora",
|
|
|
|
| 30 |
"size": 2741024390,
|
| 31 |
"sha256": "31e0c0195fb841bf31af78e8b60858f489e87ddcea4a5239abc80943da65e3ac",
|
| 32 |
"license": "other",
|
| 33 |
+
"required_when": "All i2v, v2v, t2v modes using this default slot."
|
| 34 |
},
|
| 35 |
{
|
| 36 |
"slot": "video-flow.text-encoder",
|
|
|
|
| 41 |
"size": 9447702218,
|
| 42 |
"sha256": "aaca463d11e6d8d2a4bdb0d6299214c15ef78a3f73e0ef8113d5a9d0219b3f6d",
|
| 43 |
"license": "other",
|
| 44 |
+
"required_when": "All i2v, v2v, t2v modes using this default slot."
|
| 45 |
},
|
| 46 |
{
|
| 47 |
"slot": "video-flow.latent-upscaler",
|
RUNTIME_SOURCES.json
CHANGED
|
@@ -14,10 +14,6 @@
|
|
| 14 |
],
|
| 15 |
"export": "mxSlider"
|
| 16 |
},
|
| 17 |
-
"hf_prompt_relay": {
|
| 18 |
-
"source": "https://github.com/kijai/ComfyUI-PromptRelay",
|
| 19 |
-
"revision": "ca5d4e3edb6abd9c2a4c68a3a6798eec1980f450"
|
| 20 |
-
},
|
| 21 |
"hf_failsafe_translate": {
|
| 22 |
"source": "https://github.com/ah-kun/ComfyUI-FailSafe-Translate-Node",
|
| 23 |
"revision": "b0b901a926f561176776c29ac8bfe0b4d3acaf03"
|
|
@@ -63,13 +59,6 @@
|
|
| 63 |
"custom_nodes/hf_video_extensions/__init__.py": "05f242fb4ac2b91923d8a4da3826cd6abdef3e374ad43c92b71515c62a2bd913",
|
| 64 |
"custom_nodes/hf_mx/mxtoolkit.py": "4167ce23fed719f651618965e0d2af094b2b7c0b7efef77bf1168a5d55c2deb2",
|
| 65 |
"custom_nodes/hf_mx/__init__.py": "976b2b972fe79ee1464504a3509108f0e9f289d119d10d70b1327bd704e41f1d",
|
| 66 |
-
"custom_nodes/hf_nvidia_rtx/__init__.py": "185aa7648151904a1741237f54a186bd19647172d78cdfe4e598aef726314252"
|
| 67 |
-
"custom_nodes/hf_prompt_relay/advanced_options.py": "f4623279bf5535a2997acd013423c704d191ee2d8fae664f8484276e707bfe0a",
|
| 68 |
-
"custom_nodes/hf_prompt_relay/nodes.py": "11a8c513c1b069a80a60d06a1917602a5123c4785bb1c147de9d790cdda7c014",
|
| 69 |
-
"custom_nodes/hf_prompt_relay/parser.py": "bef700925aa2a9fc27cfb859a393c0356106843e38aec70e0c7cf13a1f9ab2b2",
|
| 70 |
-
"custom_nodes/hf_prompt_relay/patches.py": "ba2386e9a89f03e96d7d3ddcb0d9b76b40a0fd7721884b35c0844219e2d9e622",
|
| 71 |
-
"custom_nodes/hf_prompt_relay/prompt_relay.py": "c13a99550fae816231eea444db11c33b42502f1bd204418bf1446b7ef1b62a17",
|
| 72 |
-
"custom_nodes/hf_prompt_relay/smart_nodes.py": "55d071155f44283625dcf7bb1b20ea0a50aded5cee2bf6bb7020711921e3914d",
|
| 73 |
-
"custom_nodes/hf_prompt_relay/__init__.py": "ea5e0d53842a8c9c1aa2e3d9f204347c3268f6973120b2a6aca7453eb1398872"
|
| 74 |
}
|
| 75 |
}
|
|
|
|
| 14 |
],
|
| 15 |
"export": "mxSlider"
|
| 16 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"hf_failsafe_translate": {
|
| 18 |
"source": "https://github.com/ah-kun/ComfyUI-FailSafe-Translate-Node",
|
| 19 |
"revision": "b0b901a926f561176776c29ac8bfe0b4d3acaf03"
|
|
|
|
| 59 |
"custom_nodes/hf_video_extensions/__init__.py": "05f242fb4ac2b91923d8a4da3826cd6abdef3e374ad43c92b71515c62a2bd913",
|
| 60 |
"custom_nodes/hf_mx/mxtoolkit.py": "4167ce23fed719f651618965e0d2af094b2b7c0b7efef77bf1168a5d55c2deb2",
|
| 61 |
"custom_nodes/hf_mx/__init__.py": "976b2b972fe79ee1464504a3509108f0e9f289d119d10d70b1327bd704e41f1d",
|
| 62 |
+
"custom_nodes/hf_nvidia_rtx/__init__.py": "185aa7648151904a1741237f54a186bd19647172d78cdfe4e598aef726314252"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
}
|
app.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
custom_nodes/hf_prompt_relay/.promptrelay-commit
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
ca5d4e3edb6abd9c2a4c68a3a6798eec1980f450
|
|
|
|
|
|
custom_nodes/hf_prompt_relay/README.md
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
# ComfyUI Prompt-Relay
|
| 2 |
-
|
| 3 |
-
WORK IN PROGRESS
|
| 4 |
-
|
| 5 |
-
<img width="1486" height="1022" alt="image" src="./assets/timeline_node.png" />
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
Original project:
|
| 9 |
-
|
| 10 |
-
https://gordonchen19.github.io/Prompt-Relay/
|
| 11 |
-
|
| 12 |
-
---
|
| 13 |
-
|
| 14 |
-
## Prompt Relay Encode (Smart)
|
| 15 |
-
|
| 16 |
-
<img width="1486" alt="image" src="./assets/smart_nodes.png" />
|
| 17 |
-
|
| 18 |
-
The Smart node accepts a single `smart_prompt` field and automatically calculates how to distribute your video's latent frames across segments. No manual frame counting required.
|
| 19 |
-
|
| 20 |
-
There are two syntax styles. Pick one per prompt — do not mix them.
|
| 21 |
-
|
| 22 |
-
---
|
| 23 |
-
|
| 24 |
-
### Syntax 1 — Inline (pipe-separated)
|
| 25 |
-
|
| 26 |
-
Segments are separated by `|`. Weights are optional. If omitted, all segments get equal time.
|
| 27 |
-
|
| 28 |
-
**Equal distribution:**
|
| 29 |
-
```
|
| 30 |
-
A man walks through a forest | He stops and looks around | He runs back
|
| 31 |
-
```
|
| 32 |
-
|
| 33 |
-
**Proportional distribution using `[start-end]` tags:**
|
| 34 |
-
```
|
| 35 |
-
A man walks through a forest [0-50] | He stops and looks around [50-150] | He runs back [150-200]
|
| 36 |
-
```
|
| 37 |
-
|
| 38 |
-
The numbers inside `[ ]` are not frame counts — they are relative positions on an arbitrary scale. Only the span of each range matters. In the example above the total span is 200, so the first segment gets 25%, the second 50%, and the third 25% of the video regardless of how many actual frames the video has.
|
| 39 |
-
|
| 40 |
-
You can also use a single number `[50]` as a plain weight instead of a range.
|
| 41 |
-
|
| 42 |
-
---
|
| 43 |
-
|
| 44 |
-
### Syntax 2 — Block (newline headers)
|
| 45 |
-
|
| 46 |
-
Each segment is preceded by a header line on its own line. The header is any words followed by a number and a colon. The header line is stripped entirely before encoding — it never reaches the tokenizer.
|
| 47 |
-
|
| 48 |
-
**Equal distribution (ordinal headers):**
|
| 49 |
-
```
|
| 50 |
-
Scene 1:
|
| 51 |
-
A man walks through a forest
|
| 52 |
-
Scene 2:
|
| 53 |
-
He stops and looks around
|
| 54 |
-
Scene 3:
|
| 55 |
-
He runs back
|
| 56 |
-
```
|
| 57 |
-
|
| 58 |
-
A single number in the header (`Scene 1:`, `Part 4:`, `segment 9:`) is treated as a sequence marker and gives every segment equal weight.
|
| 59 |
-
|
| 60 |
-
**Proportional distribution (range headers):**
|
| 61 |
-
```
|
| 62 |
-
Scene 1-2:
|
| 63 |
-
A man walks through a forest
|
| 64 |
-
Scene 2-5:
|
| 65 |
-
He stops and looks around
|
| 66 |
-
Scene 5-6:
|
| 67 |
-
He runs back
|
| 68 |
-
```
|
| 69 |
-
|
| 70 |
-
When the header contains a range (`1-2`, `2-5`, `5-6`), the span of the range (`1`, `3`, `1`) becomes the segment's proportional weight. Here the second segment gets three times as much screen time as the first and third.
|
| 71 |
-
|
| 72 |
-
The prefix words before the number are completely flexible — `Scene`, `Part`, `Shot`, `Segment`, `Second`, `Beat`, `Chapter`, `Step`, or any word you choose. Only the number and colon matter structurally.
|
| 73 |
-
|
| 74 |
-
If the `word2number` Python package is installed (`pip install word2number`), the number can also be written as a word: `Scene eleven:`, `Part twenty one:`.
|
| 75 |
-
|
| 76 |
-
---
|
| 77 |
-
|
| 78 |
-
### The `global_prompt` field
|
| 79 |
-
|
| 80 |
-
`global_prompt` anchors persistent details across the entire video — style, character description, lighting, quality tags. Leave it empty and the node will automatically use the first segment's text as the global anchor.
|
| 81 |
-
|
| 82 |
-
**Recommended pattern:** put your establishing description in segment 1, leave `global_prompt` blank, and use subsequent segments for action and motion changes only.
|
| 83 |
-
|
| 84 |
-
---
|
| 85 |
-
|
| 86 |
-
### The `normalize_by_tokens` toggle
|
| 87 |
-
|
| 88 |
-
When enabled, each segment's weight is multiplied by its CLIP token count before frame distribution. This means segments with more tokens are allocated proportionally more latent frames. Off by default.
|
| 89 |
-
|
| 90 |
-
---
|
| 91 |
-
|
| 92 |
-
### Writing prompts with a VLM or LLM
|
| 93 |
-
|
| 94 |
-
Paste the raw output directly into `smart_prompt`. The templates below are instruction blocks you send to the model. The model needs no knowledge of this node or how it works. Replace `[PLACEHOLDERS]` with your values before sending.
|
| 95 |
-
|
| 96 |
-
The first segment is automatically used as the global anchor for the entire video. It must therefore contain only a static description of what is directly visible in the provided content. The model must not add, infer, or invent anything not explicitly present in the input. All subsequent segments must describe only what changes during that period and must not re-establish what was already described in segment 1.
|
| 97 |
-
|
| 98 |
-
---
|
| 99 |
-
|
| 100 |
-
**Block syntax:**
|
| 101 |
-
|
| 102 |
-
```
|
| 103 |
-
Write a video prompt with exactly [NUMBER] segments describing [YOUR SUBJECT/SCENARIO].
|
| 104 |
-
|
| 105 |
-
Rules:
|
| 106 |
-
1. Begin each segment with a header on its own line. Format: one word, a space, a number, a colon. Example: Scene 1:
|
| 107 |
-
2. Write the segment text on the line after the header.
|
| 108 |
-
3. Segment 1 describes only the static visible state of the provided input. No motion. No action. Nothing not present in the input.
|
| 109 |
-
4. Each segment after segment 1 describes only what is changing or moving during that period. Do not repeat anything from segment 1.
|
| 110 |
-
5. Output only the formatted segments. Nothing else.
|
| 111 |
-
```
|
| 112 |
-
|
| 113 |
-
**Inline syntax:**
|
| 114 |
-
|
| 115 |
-
```
|
| 116 |
-
Write a video prompt with exactly [NUMBER] segments describing [YOUR SUBJECT/SCENARIO].
|
| 117 |
-
|
| 118 |
-
Rules:
|
| 119 |
-
1. Write all segments on one line separated by |
|
| 120 |
-
2. Segment 1 describes only the static visible state of the provided input. No motion. No action. Nothing not present in the input.
|
| 121 |
-
3. Each segment after segment 1 describes only what is changing or moving during that period. Do not repeat anything from segment 1.
|
| 122 |
-
4. Output only the single line. Nothing else.
|
| 123 |
-
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
custom_nodes/hf_prompt_relay/__init__.py
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
| 2 |
-
from .nodes import PromptRelayEncode, PromptRelayEncodeTimeline, PromptRelayAdvancedOptions
|
| 3 |
-
from .smart_nodes import PromptRelaySmartEncode, PromptRelaySmartEncodeTest
|
| 4 |
-
from comfy_api.latest import ComfyExtension, io
|
| 5 |
-
from typing_extensions import override
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class PromptRelay(ComfyExtension):
|
| 9 |
-
@override
|
| 10 |
-
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
| 11 |
-
return [
|
| 12 |
-
PromptRelayEncode,
|
| 13 |
-
PromptRelayEncodeTimeline,
|
| 14 |
-
PromptRelaySmartEncode,
|
| 15 |
-
PromptRelaySmartEncodeTest,
|
| 16 |
-
PromptRelayAdvancedOptions
|
| 17 |
-
]
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
async def comfy_entrypoint() -> PromptRelay:
|
| 21 |
-
return PromptRelay()
|
| 22 |
-
|
| 23 |
-
NODE_CLASS_MAPPINGS = {
|
| 24 |
-
"PromptRelayEncode": PromptRelayEncode,
|
| 25 |
-
"PromptRelayEncodeTimeline": PromptRelayEncodeTimeline,
|
| 26 |
-
"PromptRelaySmartEncode": PromptRelaySmartEncode,
|
| 27 |
-
"PromptRelaySmartEncodeTest": PromptRelaySmartEncodeTest,
|
| 28 |
-
"PromptRelayAdvancedOptions": PromptRelayAdvancedOptions
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 32 |
-
"PromptRelayEncode": "Prompt Relay Encode",
|
| 33 |
-
"PromptRelayEncodeTimeline": "Prompt Relay Encode (Timeline)",
|
| 34 |
-
"PromptRelaySmartEncode": "Prompt Relay Encode (Smart)",
|
| 35 |
-
"PromptRelaySmartEncodeTest": "Prompt Relay Smart Encode Test",
|
| 36 |
-
"PromptRelayAdvancedOptions": "Prompt Relay Advanced Options"
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
WEB_DIRECTORY = "./web"
|
| 41 |
-
|
| 42 |
-
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
custom_nodes/hf_prompt_relay/advanced_options.py
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 1 |
-
from comfy_api.latest import io
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
RelayOptions = io.Custom("RELAY_OPTIONS")
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class PromptRelayAdvancedOptions(io.ComfyNode):
|
| 9 |
-
"""Per-stream temporal-penalty knobs for Prompt Relay encoders."""
|
| 10 |
-
|
| 11 |
-
@classmethod
|
| 12 |
-
def define_schema(cls):
|
| 13 |
-
return io.Schema(
|
| 14 |
-
node_id="PromptRelayAdvancedOptions",
|
| 15 |
-
display_name="Prompt Relay Advanced Options",
|
| 16 |
-
category="conditioning/prompt_relay",
|
| 17 |
-
is_experimental=True,
|
| 18 |
-
description=(
|
| 19 |
-
"Optional per-stream tuning. Connect to the relay_options input on a Prompt Relay encoder. Audio fields only affect LTX2."
|
| 20 |
-
),
|
| 21 |
-
inputs=[
|
| 22 |
-
io.Float.Input(
|
| 23 |
-
"video_strength", default=1.0, min=0.0, max=10.0, step=0.05,
|
| 24 |
-
tooltip="Multiplier on the video temporal penalty. 0 disables video segmentation. Most useful in the 0–1 range to soften boundaries; values >1 saturate quickly at the default epsilon (1e-3) because the bias is already large enough to zero out distant tokens. To make >1 visibly meaningful, raise epsilon to ~0.1 or higher.",
|
| 25 |
-
),
|
| 26 |
-
io.Float.Input(
|
| 27 |
-
"video_window_scale", default=1.0, min=0.0, max=4.0, step=0.05,
|
| 28 |
-
tooltip="Scales the flat anchor zone (default L/2 - 2 frames). <1 starts the soft falloff sooner; >1 widens the rigid zone. 0 collapses the anchor to a point — falloff begins immediately at the segment midpoint (sharper than default, not softer).",
|
| 29 |
-
),
|
| 30 |
-
io.Float.Input(
|
| 31 |
-
"audio_epsilon", default=0.0, min=0.0, max=0.99, step=1e-4,
|
| 32 |
-
tooltip="Epsilon for the audio stream. 0 = inherit from the encoder's main epsilon.",
|
| 33 |
-
),
|
| 34 |
-
io.Float.Input(
|
| 35 |
-
"audio_strength", default=1.0, min=0.0, max=10.0, step=0.05,
|
| 36 |
-
tooltip="Multiplier on the audio temporal penalty. 0 lets audio bleed across visual cuts. Most useful in the 0–1 range; values >1 saturate quickly at the default epsilon (1e-3) — raise audio_epsilon to ~0.1 or higher to make >1 visibly meaningful.",
|
| 37 |
-
),
|
| 38 |
-
io.Float.Input(
|
| 39 |
-
"audio_window_scale", default=1.0, min=0.0, max=4.0, step=0.05,
|
| 40 |
-
tooltip="Scales the flat anchor zone width for the audio stream. <1 starts the soft falloff sooner; >1 widens the rigid zone. 0 collapses the anchor to a point — falloff begins immediately at the segment midpoint (sharper than default, not softer).",
|
| 41 |
-
),
|
| 42 |
-
],
|
| 43 |
-
outputs=[
|
| 44 |
-
RelayOptions.Output(display_name="relay_options"),
|
| 45 |
-
],
|
| 46 |
-
)
|
| 47 |
-
|
| 48 |
-
@classmethod
|
| 49 |
-
def execute(cls, video_strength, video_window_scale,
|
| 50 |
-
audio_epsilon, audio_strength, audio_window_scale) -> io.NodeOutput:
|
| 51 |
-
opts = {
|
| 52 |
-
"video_strength": video_strength,
|
| 53 |
-
"video_window_scale": video_window_scale,
|
| 54 |
-
"audio_epsilon": audio_epsilon if audio_epsilon > 0 else None,
|
| 55 |
-
"audio_strength": audio_strength,
|
| 56 |
-
"audio_window_scale": audio_window_scale,
|
| 57 |
-
}
|
| 58 |
-
return io.NodeOutput(opts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
custom_nodes/hf_prompt_relay/nodes.py
DELETED
|
@@ -1,241 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
|
| 3 |
-
from comfy_api.latest import io
|
| 4 |
-
|
| 5 |
-
from .prompt_relay import (
|
| 6 |
-
get_raw_tokenizer,
|
| 7 |
-
map_token_indices,
|
| 8 |
-
build_segments,
|
| 9 |
-
create_mask_fn,
|
| 10 |
-
distribute_segment_lengths,
|
| 11 |
-
)
|
| 12 |
-
|
| 13 |
-
from .patches import detect_model_type, apply_patches
|
| 14 |
-
from .advanced_options import PromptRelayAdvancedOptions, RelayOptions
|
| 15 |
-
|
| 16 |
-
log = logging.getLogger(__name__)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames):
|
| 20 |
-
"""Convert pixel-space segment lengths to integer latent-space lengths using the
|
| 21 |
-
largest-remainder method. Targets the full `latent_frames` when the pixel sum looks
|
| 22 |
-
like full coverage (within one stride of latent_frames * stride). Otherwise targets
|
| 23 |
-
round(total_pixel / temporal_stride) so partial-coverage timelines stay partial.
|
| 24 |
-
"""
|
| 25 |
-
if not pixel_lengths:
|
| 26 |
-
return []
|
| 27 |
-
total_pixel = sum(pixel_lengths)
|
| 28 |
-
if total_pixel <= 0:
|
| 29 |
-
return [1] * len(pixel_lengths)
|
| 30 |
-
|
| 31 |
-
naive_total = max(1, round(total_pixel / temporal_stride))
|
| 32 |
-
target_total = min(latent_frames, naive_total)
|
| 33 |
-
# Within one frame of full → user clearly intended full coverage; pin to latent_frames.
|
| 34 |
-
if target_total >= latent_frames - 1:
|
| 35 |
-
target_total = latent_frames
|
| 36 |
-
|
| 37 |
-
exact = [p * target_total / total_pixel for p in pixel_lengths]
|
| 38 |
-
result = [int(e) for e in exact]
|
| 39 |
-
diff = target_total - sum(result)
|
| 40 |
-
if diff > 0:
|
| 41 |
-
order = sorted(range(len(exact)), key=lambda i: -(exact[i] - int(exact[i])))
|
| 42 |
-
for k in range(diff):
|
| 43 |
-
result[order[k % len(order)]] += 1
|
| 44 |
-
|
| 45 |
-
# Ensure every segment has ≥ 1 latent frame (steal from the largest if needed).
|
| 46 |
-
for i in range(len(result)):
|
| 47 |
-
if result[i] < 1:
|
| 48 |
-
max_idx = max(range(len(result)), key=lambda j: result[j])
|
| 49 |
-
if result[max_idx] > 1:
|
| 50 |
-
result[max_idx] -= 1
|
| 51 |
-
result[i] = 1
|
| 52 |
-
|
| 53 |
-
return result
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def _encode_relay(model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon, relay_options=None):
|
| 57 |
-
for name, val in (("global_prompt", global_prompt),
|
| 58 |
-
("local_prompts", local_prompts),
|
| 59 |
-
("segment_lengths", segment_lengths)):
|
| 60 |
-
if val is None:
|
| 61 |
-
raise ValueError(
|
| 62 |
-
f"PromptRelay: '{name}' arrived as None. "
|
| 63 |
-
"Likely causes: a stale workflow JSON saved with null, the timeline "
|
| 64 |
-
"editor's web extension failing to load, or an upstream node returning None. "
|
| 65 |
-
"Set the field to an empty string or fix the upstream connection."
|
| 66 |
-
)
|
| 67 |
-
|
| 68 |
-
locals_list = [p.strip() for p in local_prompts.split("|") if p.strip()]
|
| 69 |
-
if not locals_list:
|
| 70 |
-
raise ValueError("At least one local prompt is required (separate with |)")
|
| 71 |
-
|
| 72 |
-
arch, patch_size, temporal_stride = detect_model_type(model)
|
| 73 |
-
|
| 74 |
-
samples = latent["samples"]
|
| 75 |
-
latent_frames = samples.shape[2]
|
| 76 |
-
tokens_per_frame = (samples.shape[3] // patch_size[1]) * (samples.shape[4] // patch_size[2])
|
| 77 |
-
|
| 78 |
-
parsed_lengths = None
|
| 79 |
-
if segment_lengths.strip():
|
| 80 |
-
pixel_lengths = [int(x.strip()) for x in segment_lengths.split(",") if x.strip()]
|
| 81 |
-
parsed_lengths = _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames)
|
| 82 |
-
|
| 83 |
-
raw_tokenizer = get_raw_tokenizer(clip)
|
| 84 |
-
full_prompt, token_ranges = map_token_indices(raw_tokenizer, global_prompt, locals_list)
|
| 85 |
-
|
| 86 |
-
log.info("[PromptRelay] Global: tokens [0:%d] (%d tokens)", token_ranges[0][0], token_ranges[0][0])
|
| 87 |
-
for i, (s, e) in enumerate(token_ranges):
|
| 88 |
-
log.info("[PromptRelay] Segment %d: tokens [%d:%d] (%d tokens)", i, s, e, e - s)
|
| 89 |
-
|
| 90 |
-
conditioning = clip.encode_from_tokens_scheduled(clip.tokenize(full_prompt))
|
| 91 |
-
|
| 92 |
-
effective_lengths = distribute_segment_lengths(len(locals_list), latent_frames, parsed_lengths)
|
| 93 |
-
|
| 94 |
-
log.info(
|
| 95 |
-
"[PromptRelay] Latent: %d frames, %d tokens/frame, segments: %s",
|
| 96 |
-
latent_frames, tokens_per_frame, effective_lengths,
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
-
q_token_idx = build_segments(token_ranges, effective_lengths, epsilon, relay_options)
|
| 100 |
-
mask_fn = create_mask_fn(q_token_idx, tokens_per_frame, latent_frames)
|
| 101 |
-
|
| 102 |
-
patched = model.clone()
|
| 103 |
-
apply_patches(patched, arch, mask_fn)
|
| 104 |
-
|
| 105 |
-
return patched, conditioning
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
class PromptRelayEncode(io.ComfyNode):
|
| 109 |
-
"""Encodes temporal local prompts and patches the model for Prompt Relay."""
|
| 110 |
-
|
| 111 |
-
@classmethod
|
| 112 |
-
def define_schema(cls):
|
| 113 |
-
return io.Schema(
|
| 114 |
-
node_id="PromptRelayEncode",
|
| 115 |
-
display_name="Prompt Relay Encode",
|
| 116 |
-
category="conditioning/prompt_relay",
|
| 117 |
-
description=(
|
| 118 |
-
"Encodes a global prompt combined with temporal local prompts and patches the model "
|
| 119 |
-
"for Prompt Relay temporal control. Local prompts are separated by |. "
|
| 120 |
-
"Use a standard CLIPTextEncode for the negative prompt."
|
| 121 |
-
),
|
| 122 |
-
inputs=[
|
| 123 |
-
io.Model.Input("model"),
|
| 124 |
-
io.Clip.Input("clip"),
|
| 125 |
-
io.Latent.Input("latent", tooltip="Empty latent video — dimensions are read from its shape."),
|
| 126 |
-
io.String.Input(
|
| 127 |
-
"global_prompt", multiline=True, default="",
|
| 128 |
-
tooltip="Conditions the entire video. Anchors persistent characters, objects, and scene context.",
|
| 129 |
-
),
|
| 130 |
-
io.String.Input(
|
| 131 |
-
"local_prompts", multiline=True, default="",
|
| 132 |
-
tooltip="Ordered prompts for each temporal segment, separated by |",
|
| 133 |
-
),
|
| 134 |
-
io.String.Input(
|
| 135 |
-
"segment_lengths", default="",
|
| 136 |
-
tooltip="Comma-separated pixel space frame counts per segment. Leave empty to auto-distribute evenly.",
|
| 137 |
-
),
|
| 138 |
-
io.Float.Input(
|
| 139 |
-
"epsilon", default=1e-3, min=1e-6, max=0.99, step=1e-4,
|
| 140 |
-
tooltip="Penalty decay parameter. Values below ~0.1 all produce sharp boundaries (paper default 0.001). For softer transitions, try 0.5 or higher.",
|
| 141 |
-
),
|
| 142 |
-
RelayOptions.Input(
|
| 143 |
-
"relay_options", optional=True,
|
| 144 |
-
tooltip="Optional advanced per-stream tuning. Connect a Prompt Relay Advanced Options node.",
|
| 145 |
-
),
|
| 146 |
-
],
|
| 147 |
-
outputs=[
|
| 148 |
-
io.Model.Output(display_name="model"),
|
| 149 |
-
io.Conditioning.Output(display_name="positive"),
|
| 150 |
-
],
|
| 151 |
-
)
|
| 152 |
-
|
| 153 |
-
@classmethod
|
| 154 |
-
def execute(cls, model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon, relay_options=None) -> io.NodeOutput:
|
| 155 |
-
patched, conditioning = _encode_relay(
|
| 156 |
-
model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon, relay_options,
|
| 157 |
-
)
|
| 158 |
-
return io.NodeOutput(patched, conditioning)
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
class PromptRelayEncodeTimeline(io.ComfyNode):
|
| 162 |
-
"""WYSIWYG timeline variant — segments and lengths come from a visual editor in the node UI."""
|
| 163 |
-
|
| 164 |
-
@classmethod
|
| 165 |
-
def define_schema(cls):
|
| 166 |
-
return io.Schema(
|
| 167 |
-
node_id="PromptRelayEncodeTimeline",
|
| 168 |
-
display_name="Prompt Relay Encode (Timeline)",
|
| 169 |
-
category="conditioning/prompt_relay",
|
| 170 |
-
description=(
|
| 171 |
-
"Same as Prompt Relay Encode, but local prompts and segment lengths are edited "
|
| 172 |
-
"visually as draggable blocks on a timeline. The max_frames input only sets the "
|
| 173 |
-
"timeline scale (pixel space) — actual frame count is still read from the latent."
|
| 174 |
-
),
|
| 175 |
-
inputs=[
|
| 176 |
-
io.Model.Input("model"),
|
| 177 |
-
io.Clip.Input("clip"),
|
| 178 |
-
io.Latent.Input("latent", tooltip="Empty latent video — dimensions are read from its shape."),
|
| 179 |
-
io.String.Input(
|
| 180 |
-
"global_prompt", multiline=True, default="",
|
| 181 |
-
tooltip="Conditions the entire video. Anchors persistent characters, objects, and scene context.",
|
| 182 |
-
),
|
| 183 |
-
io.Int.Input(
|
| 184 |
-
"max_frames", default=129, min=1, max=10000, step=1,
|
| 185 |
-
tooltip="Total timeline length in pixel-space frames. Used by the editor for visual scale only.",
|
| 186 |
-
),
|
| 187 |
-
io.String.Input(
|
| 188 |
-
"timeline_data", default="",
|
| 189 |
-
tooltip="JSON state of the timeline editor (auto-managed; do not edit by hand).",
|
| 190 |
-
),
|
| 191 |
-
io.String.Input(
|
| 192 |
-
"local_prompts", multiline=True, default="",
|
| 193 |
-
tooltip="Auto-populated from the timeline editor.",
|
| 194 |
-
),
|
| 195 |
-
io.String.Input(
|
| 196 |
-
"segment_lengths", default="",
|
| 197 |
-
tooltip="Auto-populated from the timeline editor (pixel-space frame counts).",
|
| 198 |
-
),
|
| 199 |
-
io.Float.Input(
|
| 200 |
-
"epsilon", default=1e-3, min=1e-6, max=0.99, step=1e-4,
|
| 201 |
-
tooltip="Penalty decay parameter. Values below ~0.1 all produce sharp boundaries (paper default 0.001). For softer transitions, try 0.5 or higher.",
|
| 202 |
-
),
|
| 203 |
-
io.Float.Input(
|
| 204 |
-
"fps", default=24.0, min=0.1, max=240.0, step=0.1, optional=True,
|
| 205 |
-
tooltip="Frames per second — only affects how time is displayed in the timeline editor when time_units is set to 'seconds'.",
|
| 206 |
-
),
|
| 207 |
-
io.Combo.Input(
|
| 208 |
-
"time_units", options=["frames", "seconds"], default="frames", optional=True,
|
| 209 |
-
tooltip="Display the ruler, segment ranges, length input, and total in frames or seconds. Internal storage is always pixel-space frames.",
|
| 210 |
-
),
|
| 211 |
-
RelayOptions.Input(
|
| 212 |
-
"relay_options", optional=True,
|
| 213 |
-
tooltip="Optional advanced per-stream tuning. Connect a Prompt Relay Advanced Options node.",
|
| 214 |
-
),
|
| 215 |
-
],
|
| 216 |
-
outputs=[
|
| 217 |
-
io.Model.Output(display_name="model"),
|
| 218 |
-
io.Conditioning.Output(display_name="positive"),
|
| 219 |
-
],
|
| 220 |
-
)
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
@classmethod
|
| 224 |
-
def execute(cls, model, clip, latent, global_prompt, max_frames, timeline_data, local_prompts, segment_lengths, epsilon, fps=24.0, time_units="frames", relay_options=None) -> io.NodeOutput:
|
| 225 |
-
patched, conditioning = _encode_relay(
|
| 226 |
-
model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon, relay_options,
|
| 227 |
-
)
|
| 228 |
-
return io.NodeOutput(patched, conditioning)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
NODE_CLASS_MAPPINGS = {
|
| 232 |
-
"PromptRelayEncode": PromptRelayEncode,
|
| 233 |
-
"PromptRelayEncodeTimeline": PromptRelayEncodeTimeline,
|
| 234 |
-
"PromptRelayAdvancedOptions": PromptRelayAdvancedOptions,
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 238 |
-
"PromptRelayEncode": "Prompt Relay Encode",
|
| 239 |
-
"PromptRelayEncodeTimeline": "Prompt Relay Encode (Timeline)",
|
| 240 |
-
"PromptRelayAdvancedOptions": "Prompt Relay Advanced Options",
|
| 241 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
custom_nodes/hf_prompt_relay/parser.py
DELETED
|
@@ -1,152 +0,0 @@
|
|
| 1 |
-
import re
|
| 2 |
-
|
| 3 |
-
_INLINE_TAG_RE = re.compile(r"\[([\d\.]+)(?:[:\-]([\d\.]+))?\]")
|
| 4 |
-
_DIGIT_RANGE_TAIL_RE = re.compile(r"([\d]+(?:\.\d+)?)\s*[-\u2013]\s*([\d]+(?:\.\d+)?)\s*$")
|
| 5 |
-
|
| 6 |
-
def _try_parse_num(s):
|
| 7 |
-
"""Parse s as integer. Tries digit conversion first, then word2number if available."""
|
| 8 |
-
s = s.strip()
|
| 9 |
-
try:
|
| 10 |
-
return int(float(s))
|
| 11 |
-
except (ValueError, TypeError):
|
| 12 |
-
pass
|
| 13 |
-
try:
|
| 14 |
-
from word2number import w2n
|
| 15 |
-
return int(w2n.word_to_num(s))
|
| 16 |
-
except Exception:
|
| 17 |
-
pass
|
| 18 |
-
return None
|
| 19 |
-
|
| 20 |
-
def _parse_header(line):
|
| 21 |
-
"""Return (start, end_or_None) if line is a block segment header, else None.
|
| 22 |
-
|
| 23 |
-
Valid format: one or more prefix words, followed by a number (digit or word form),
|
| 24 |
-
optional range, then colon at end of line. Matching is case-insensitive.
|
| 25 |
-
|
| 26 |
-
Examples:
|
| 27 |
-
'Scene 1:' -> (1, None)
|
| 28 |
-
'My Scene 3:' -> (3, None)
|
| 29 |
-
'Shot 2-4:' -> (2, 4)
|
| 30 |
-
'Part eleven:' -> (11, None) [requires word2number]
|
| 31 |
-
'segment twenty:' -> (20, None) [requires word2number]
|
| 32 |
-
"""
|
| 33 |
-
line = line.strip()
|
| 34 |
-
if not line.endswith(':'):
|
| 35 |
-
return None
|
| 36 |
-
body = line[:-1].rstrip()
|
| 37 |
-
tokens = body.split()
|
| 38 |
-
# Need at least 2 tokens: at least one prefix word + one number token
|
| 39 |
-
if len(tokens) < 2:
|
| 40 |
-
return None
|
| 41 |
-
# Try digit range at end: "Scene 2-4:"
|
| 42 |
-
m = _DIGIT_RANGE_TAIL_RE.search(body)
|
| 43 |
-
if m and body[:m.start()].strip():
|
| 44 |
-
start = _try_parse_num(m.group(1))
|
| 45 |
-
end = _try_parse_num(m.group(2))
|
| 46 |
-
if start is not None and end is not None:
|
| 47 |
-
return (start, end)
|
| 48 |
-
# Try 1..N tail tokens as word-or-digit number (longest candidate first)
|
| 49 |
-
# Keep at least one prefix token so bare "1:" is not matched as a header
|
| 50 |
-
max_num_tokens = min(4, len(tokens) - 1)
|
| 51 |
-
for n in range(max_num_tokens, 0, -1):
|
| 52 |
-
candidate = " ".join(tokens[-n:])
|
| 53 |
-
val = _try_parse_num(candidate)
|
| 54 |
-
if val is not None:
|
| 55 |
-
return (val, None)
|
| 56 |
-
return None
|
| 57 |
-
|
| 58 |
-
def _extract_inline_tag(text):
|
| 59 |
-
"""Extract first [n] or [n-m] weight tag from text.
|
| 60 |
-
Returns (clean_text, weight_or_None). Tag is stripped from text.
|
| 61 |
-
"""
|
| 62 |
-
m = _INLINE_TAG_RE.search(text)
|
| 63 |
-
if not m:
|
| 64 |
-
return text.strip(), None
|
| 65 |
-
val1 = float(m.group(1))
|
| 66 |
-
val2 = float(m.group(2)) if m.group(2) else None
|
| 67 |
-
weight = (val2 - val1) if val2 is not None else val1
|
| 68 |
-
clean = _INLINE_TAG_RE.sub("", text).strip()
|
| 69 |
-
return clean, weight
|
| 70 |
-
|
| 71 |
-
def _parse_inline_syntax(text):
|
| 72 |
-
"""Parse pipe-separated inline syntax with optional [n-m] weight tags.
|
| 73 |
-
|
| 74 |
-
Syntax examples:
|
| 75 |
-
'one | two | three' -> equal weights
|
| 76 |
-
'one [0-50] | two [50-150] | three [150]' -> proportional weights
|
| 77 |
-
"""
|
| 78 |
-
segments = []
|
| 79 |
-
for part in text.split('|'):
|
| 80 |
-
clean, weight = _extract_inline_tag(part)
|
| 81 |
-
if clean:
|
| 82 |
-
segments.append({"text": clean, "weight": weight if weight is not None else 1.0})
|
| 83 |
-
return segments
|
| 84 |
-
|
| 85 |
-
def _parse_block_syntax(text):
|
| 86 |
-
"""Parse block header syntax where each segment is preceded by a header line.
|
| 87 |
-
|
| 88 |
-
Header format: any words followed by a number (or word-number) and a colon
|
| 89 |
-
on its own line. Optional [n-m] inline tag in body overrides header weight.
|
| 90 |
-
|
| 91 |
-
Syntax examples:
|
| 92 |
-
'Scene 1:\\ntext\\nScene 2:\\ntext'
|
| 93 |
-
'My Part 3-6:\\ntext' -> weight = 6-3 = 3
|
| 94 |
-
'segment eleven:\\ntext' -> weight = 1.0 (single number = sequence marker)
|
| 95 |
-
"""
|
| 96 |
-
lines = text.splitlines(keepends=True)
|
| 97 |
-
raw_segments = []
|
| 98 |
-
current_header = None
|
| 99 |
-
current_body = []
|
| 100 |
-
for line in lines:
|
| 101 |
-
h = _parse_header(line)
|
| 102 |
-
if h is not None:
|
| 103 |
-
if current_body or current_header is not None:
|
| 104 |
-
raw_segments.append((current_header, "".join(current_body)))
|
| 105 |
-
current_header = h
|
| 106 |
-
current_body = []
|
| 107 |
-
else:
|
| 108 |
-
current_body.append(line)
|
| 109 |
-
if current_body or current_header is not None:
|
| 110 |
-
raw_segments.append((current_header, "".join(current_body)))
|
| 111 |
-
segments = []
|
| 112 |
-
for header, body in raw_segments:
|
| 113 |
-
clean, inline_weight = _extract_inline_tag(body)
|
| 114 |
-
if not clean:
|
| 115 |
-
continue
|
| 116 |
-
if inline_weight is not None:
|
| 117 |
-
weight = inline_weight
|
| 118 |
-
elif header is not None:
|
| 119 |
-
start, end = header
|
| 120 |
-
# Range header: weight is proportional span. Single number: equal weight.
|
| 121 |
-
weight = (end - start) if end is not None else 1.0
|
| 122 |
-
else:
|
| 123 |
-
weight = 1.0
|
| 124 |
-
segments.append({"text": clean, "weight": weight})
|
| 125 |
-
return segments
|
| 126 |
-
|
| 127 |
-
def parse_smart_prompt(text):
|
| 128 |
-
"""Parse smart_prompt text into a list of {"text": str, "weight": float} dicts.
|
| 129 |
-
|
| 130 |
-
Detects syntax automatically:
|
| 131 |
-
|
| 132 |
-
--- Inline (newline-agnostic) ---
|
| 133 |
-
Segments separated by | with optional [n-m] proportional weight tags.
|
| 134 |
-
'man walks | man runs | man jumps'
|
| 135 |
-
'man walks [0-50] | man runs [50-150] | man jumps [150-200]'
|
| 136 |
-
|
| 137 |
-
--- Block (newline-specific) ---
|
| 138 |
-
Segments preceded by a header line: any words + number + colon on its own line.
|
| 139 |
-
The number (or range) is stripped and used only for weight. Ordinal numbers
|
| 140 |
-
like 'Scene 1:' are sequence markers (equal weight). Ranges like 'Scene 1-3:'
|
| 141 |
-
assign proportional weight (3-1=2). Word-form numbers require word2number package.
|
| 142 |
-
'Scene 1:\\nman walks\\nScene 2:\\nman runs'
|
| 143 |
-
'My Shot 1-3:\\nman walks\\nMy Shot 3-7:\\nman runs'
|
| 144 |
-
'segment eleven:\\nman walks' (requires: pip install word2number)
|
| 145 |
-
|
| 146 |
-
All syntax markers are fully stripped before text is returned.
|
| 147 |
-
"""
|
| 148 |
-
lines = text.splitlines()
|
| 149 |
-
has_blocks = any(_parse_header(line) is not None for line in lines)
|
| 150 |
-
if has_blocks:
|
| 151 |
-
return _parse_block_syntax(text)
|
| 152 |
-
return _parse_inline_syntax(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
custom_nodes/hf_prompt_relay/patches.py
DELETED
|
@@ -1,179 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
import types
|
| 3 |
-
|
| 4 |
-
import comfy.ldm.modules.attention
|
| 5 |
-
|
| 6 |
-
log = logging.getLogger(__name__)
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def _masked_attention(q, k, v, heads, mask, transformer_options={}, **kwargs):
|
| 10 |
-
# Bypass wrap_attn (sage/etc may ignore masks) by calling attention_pytorch directly.
|
| 11 |
-
return comfy.ldm.modules.attention.attention_pytorch(
|
| 12 |
-
q, k, v, heads, mask=mask,
|
| 13 |
-
_inside_attn_wrapper=True,
|
| 14 |
-
transformer_options=transformer_options,
|
| 15 |
-
**kwargs,
|
| 16 |
-
)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def _wan_t2v_forward(self, mask_fn, x, context, transformer_options={}, **kwargs):
|
| 20 |
-
q = self.norm_q(self.q(x))
|
| 21 |
-
k = self.norm_k(self.k(context))
|
| 22 |
-
v = self.v(context)
|
| 23 |
-
|
| 24 |
-
mask = mask_fn(q.shape[1], k.shape[1], q.dtype, q.device, transformer_options)
|
| 25 |
-
if mask is not None:
|
| 26 |
-
x = _masked_attention(q, k, v, heads=self.num_heads, mask=mask,
|
| 27 |
-
transformer_options=transformer_options)
|
| 28 |
-
else:
|
| 29 |
-
x = comfy.ldm.modules.attention.optimized_attention(
|
| 30 |
-
q, k, v, heads=self.num_heads, transformer_options=transformer_options,
|
| 31 |
-
)
|
| 32 |
-
return self.o(x)
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def _wan_i2v_forward(self, mask_fn, x, context, context_img_len, transformer_options={}, **kwargs):
|
| 36 |
-
context_img = context[:, :context_img_len]
|
| 37 |
-
context_text = context[:, context_img_len:]
|
| 38 |
-
|
| 39 |
-
q = self.norm_q(self.q(x))
|
| 40 |
-
|
| 41 |
-
k_img = self.norm_k_img(self.k_img(context_img))
|
| 42 |
-
v_img = self.v_img(context_img)
|
| 43 |
-
img_x = comfy.ldm.modules.attention.optimized_attention(
|
| 44 |
-
q, k_img, v_img, heads=self.num_heads, transformer_options=transformer_options,
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
k = self.norm_k(self.k(context_text))
|
| 48 |
-
v = self.v(context_text)
|
| 49 |
-
|
| 50 |
-
mask = mask_fn(q.shape[1], k.shape[1], q.dtype, q.device, transformer_options)
|
| 51 |
-
if mask is not None:
|
| 52 |
-
x = _masked_attention(q, k, v, heads=self.num_heads, mask=mask,
|
| 53 |
-
transformer_options=transformer_options)
|
| 54 |
-
else:
|
| 55 |
-
x = comfy.ldm.modules.attention.optimized_attention(
|
| 56 |
-
q, k, v, heads=self.num_heads, transformer_options=transformer_options,
|
| 57 |
-
)
|
| 58 |
-
|
| 59 |
-
return self.o(x + img_x)
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _make_masked_override(prev_override):
|
| 63 |
-
"""transformer_options override that routes mask-bearing attention calls through
|
| 64 |
-
attention_pytorch (sage/etc. drop arbitrary masks). Chains to a prior override
|
| 65 |
-
when no mask is present so we don't clobber other backends."""
|
| 66 |
-
def override(func, *args, **kwargs):
|
| 67 |
-
if kwargs.get("mask") is not None:
|
| 68 |
-
return comfy.ldm.modules.attention.attention_pytorch(*args, **kwargs)
|
| 69 |
-
if prev_override is not None:
|
| 70 |
-
return prev_override(func, *args, **kwargs)
|
| 71 |
-
return func(*args, **kwargs)
|
| 72 |
-
return override
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def _make_ltx_mask_wrapper(underlying, mask_fn):
|
| 76 |
-
"""Wrap an existing LTX cross-attn forward (the default `CrossAttention.forward`
|
| 77 |
-
or another node's patch — e.g. KJNodes NAG), injecting PromptRelay's additive
|
| 78 |
-
mask via the `mask` kwarg the upstream signature already accepts.
|
| 79 |
-
|
| 80 |
-
`underlying` must already be bound to its module — callable as
|
| 81 |
-
`underlying(x, context=..., mask=..., ...)`.
|
| 82 |
-
"""
|
| 83 |
-
def wrapped(_self, x, context=None, mask=None, pe=None, k_pe=None, transformer_options={}):
|
| 84 |
-
if context is not None:
|
| 85 |
-
pr_mask = mask_fn(x.shape[1], context.shape[1], x.dtype, x.device, transformer_options)
|
| 86 |
-
if pr_mask is not None:
|
| 87 |
-
mask = pr_mask if mask is None else mask + pr_mask
|
| 88 |
-
|
| 89 |
-
if mask is not None:
|
| 90 |
-
prev = transformer_options.get("optimized_attention_override")
|
| 91 |
-
transformer_options = {
|
| 92 |
-
**transformer_options,
|
| 93 |
-
"optimized_attention_override": _make_masked_override(prev),
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
return underlying(
|
| 97 |
-
x, context=context, mask=mask, pe=pe, k_pe=k_pe,
|
| 98 |
-
transformer_options=transformer_options,
|
| 99 |
-
)
|
| 100 |
-
|
| 101 |
-
wrapped._promptrelay_wrapper = True
|
| 102 |
-
return wrapped
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
class _CrossAttnPatch:
|
| 106 |
-
"""Descriptor that binds (impl, mask_fn) as a method onto a Wan cross-attn module."""
|
| 107 |
-
|
| 108 |
-
def __init__(self, impl, mask_fn):
|
| 109 |
-
self.impl = impl
|
| 110 |
-
self.mask_fn = mask_fn
|
| 111 |
-
|
| 112 |
-
def __get__(self, obj, objtype=None):
|
| 113 |
-
impl, mask_fn = self.impl, self.mask_fn
|
| 114 |
-
|
| 115 |
-
def wrapped(self_module, *args, **kwargs):
|
| 116 |
-
return impl(self_module, mask_fn, *args, **kwargs)
|
| 117 |
-
|
| 118 |
-
return types.MethodType(wrapped, obj)
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def detect_model_type(model):
|
| 122 |
-
"""Return (arch, patch_size, temporal_stride) for latent geometry.
|
| 123 |
-
|
| 124 |
-
temporal_stride is the VAE's pixel→latent temporal compression factor,
|
| 125 |
-
used to convert user-facing pixel frame counts to latent frames.
|
| 126 |
-
"""
|
| 127 |
-
diff_model = model.model.diffusion_model
|
| 128 |
-
|
| 129 |
-
if hasattr(diff_model, "patch_size") and not hasattr(diff_model, "patchifier"):
|
| 130 |
-
return "wan", tuple(diff_model.patch_size), 4
|
| 131 |
-
|
| 132 |
-
if hasattr(diff_model, "patchifier"):
|
| 133 |
-
return "ltx", (1, 1, 1), int(diff_model.vae_scale_factors[0])
|
| 134 |
-
|
| 135 |
-
raise ValueError(
|
| 136 |
-
f"Unsupported model type: {type(diff_model).__name__}. "
|
| 137 |
-
f"Currently supports Wan and LTX models."
|
| 138 |
-
)
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
def _check_unpatched(model_clone, key):
|
| 142 |
-
if key in getattr(model_clone, "object_patches", {}):
|
| 143 |
-
raise RuntimeError(
|
| 144 |
-
f"PromptRelay: cross-attention forward at '{key}' is already patched by "
|
| 145 |
-
"another node. Stacking is not supported for this architecture — remove "
|
| 146 |
-
"the conflicting node."
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def apply_patches(model_clone, arch, mask_fn):
|
| 151 |
-
diffusion_model = model_clone.get_model_object("diffusion_model")
|
| 152 |
-
|
| 153 |
-
if arch == "wan":
|
| 154 |
-
from comfy.ldm.wan.model import WanI2VCrossAttention
|
| 155 |
-
for idx, block in enumerate(diffusion_model.blocks):
|
| 156 |
-
key = f"diffusion_model.blocks.{idx}.cross_attn.forward"
|
| 157 |
-
_check_unpatched(model_clone, key)
|
| 158 |
-
cross_attn = block.cross_attn
|
| 159 |
-
impl = _wan_i2v_forward if isinstance(cross_attn, WanI2VCrossAttention) else _wan_t2v_forward
|
| 160 |
-
model_clone.add_object_patch(key, _CrossAttnPatch(impl, mask_fn).__get__(cross_attn, cross_attn.__class__))
|
| 161 |
-
return
|
| 162 |
-
|
| 163 |
-
if arch == "ltx":
|
| 164 |
-
to = model_clone.model_options["transformer_options"]
|
| 165 |
-
to["promptrelay_mask_fn"] = mask_fn
|
| 166 |
-
|
| 167 |
-
for idx, block in enumerate(diffusion_model.transformer_blocks):
|
| 168 |
-
for attr in ("attn2", "audio_attn2"):
|
| 169 |
-
module = getattr(block, attr, None)
|
| 170 |
-
if module is None:
|
| 171 |
-
continue
|
| 172 |
-
key = f"diffusion_model.transformer_blocks.{idx}.{attr}.forward"
|
| 173 |
-
# get_model_object returns the prior patch if present, else the default bound forward.
|
| 174 |
-
underlying = model_clone.get_model_object(key)
|
| 175 |
-
wrapper = _make_ltx_mask_wrapper(underlying, mask_fn)
|
| 176 |
-
model_clone.add_object_patch(key, types.MethodType(wrapper, module))
|
| 177 |
-
return
|
| 178 |
-
|
| 179 |
-
raise ValueError(f"Unknown model arch: {arch}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
custom_nodes/hf_prompt_relay/prompt_relay.py
DELETED
|
@@ -1,204 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
import math
|
| 3 |
-
import torch
|
| 4 |
-
|
| 5 |
-
log = logging.getLogger(__name__)
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def build_temporal_cost(q_token_idx, Lq, Lk, device, dtype, tokens_per_frame):
|
| 9 |
-
"""Gaussian penalty matrix [Lq, Lk] for video cross-attention (integer frame indexing)."""
|
| 10 |
-
offset = torch.zeros(Lq, Lk, device=device, dtype=dtype)
|
| 11 |
-
query_frames = torch.arange(Lq, device=device, dtype=torch.long) // tokens_per_frame
|
| 12 |
-
|
| 13 |
-
for seg in q_token_idx:
|
| 14 |
-
local = seg["local_token_idx"].to(device=device)
|
| 15 |
-
d = (query_frames.float()[:, None] - seg["midpoint"]).abs()
|
| 16 |
-
strength = seg.get("strength", 1.0)
|
| 17 |
-
cost = strength * (torch.relu(d - seg["window"]) ** 2) / (2 * seg["sigma"] ** 2)
|
| 18 |
-
offset[:, local] = cost.to(offset.dtype)
|
| 19 |
-
|
| 20 |
-
return offset
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def build_temporal_cost_scaled(q_token_idx, Lq, Lk, device, dtype, latent_frames):
|
| 24 |
-
"""Penalty matrix for queries that don't map to integer frames (e.g. LTXAV audio tokens)."""
|
| 25 |
-
offset = torch.zeros(Lq, Lk, device=device, dtype=dtype)
|
| 26 |
-
query_frames = torch.arange(Lq, device=device, dtype=torch.float32) * latent_frames / Lq
|
| 27 |
-
|
| 28 |
-
for seg in q_token_idx:
|
| 29 |
-
local = seg["local_token_idx"].to(device=device)
|
| 30 |
-
d = (query_frames[:, None] - seg["midpoint"]).abs()
|
| 31 |
-
sigma_a = seg.get("sigma_audio", seg["sigma"])
|
| 32 |
-
window_a = seg.get("window_audio", seg["window"])
|
| 33 |
-
strength_a = seg.get("strength_audio", 1.0)
|
| 34 |
-
cost = strength_a * (torch.relu(d - window_a) ** 2) / (2 * sigma_a ** 2)
|
| 35 |
-
offset[:, local] = cost.to(offset.dtype)
|
| 36 |
-
|
| 37 |
-
return offset
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def create_mask_fn(q_token_idx, fallback_tokens_per_frame, latent_frames):
|
| 41 |
-
"""Closure: mask_fn(Lq, Lk, dtype, device, transformer_options) -> additive mask or None.
|
| 42 |
-
|
| 43 |
-
Takes shapes/dtype/device instead of tensors so callers can compute the mask
|
| 44 |
-
without first materializing q/k projections — required so PromptRelay can
|
| 45 |
-
wrap an existing cross-attn forward (e.g. KJNodes NAG) instead of replacing it.
|
| 46 |
-
"""
|
| 47 |
-
cache = {}
|
| 48 |
-
max_token_idx = max(int(seg["local_token_idx"].max().item()) for seg in q_token_idx) + 1
|
| 49 |
-
|
| 50 |
-
def mask_fn(Lq, Lk, dtype, device, transformer_options):
|
| 51 |
-
if Lq == Lk:
|
| 52 |
-
return None
|
| 53 |
-
|
| 54 |
-
# Only apply on conditional pass — not unconditional (negative prompt)
|
| 55 |
-
cond_or_uncond = transformer_options.get("cond_or_uncond", [])
|
| 56 |
-
if 1 in cond_or_uncond and 0 not in cond_or_uncond:
|
| 57 |
-
return None
|
| 58 |
-
|
| 59 |
-
grid_sizes = transformer_options.get("grid_sizes", None)
|
| 60 |
-
video_tpf = int(grid_sizes[1]) * int(grid_sizes[2]) if grid_sizes is not None else fallback_tokens_per_frame
|
| 61 |
-
video_lq = latent_frames * video_tpf
|
| 62 |
-
|
| 63 |
-
# Skip cross-modal attention — text keys are padded to a fixed length ≥ max_token_idx and != video_lq
|
| 64 |
-
if Lk == video_lq or Lk < max_token_idx:
|
| 65 |
-
return None
|
| 66 |
-
|
| 67 |
-
mode = "video" if Lq == video_lq else "scaled"
|
| 68 |
-
|
| 69 |
-
key = (Lq, Lk, mode, device)
|
| 70 |
-
if key not in cache:
|
| 71 |
-
if mode == "video":
|
| 72 |
-
cost = build_temporal_cost(q_token_idx, Lq, Lk, device, dtype, video_tpf)
|
| 73 |
-
else:
|
| 74 |
-
cost = build_temporal_cost_scaled(q_token_idx, Lq, Lk, device, dtype, latent_frames)
|
| 75 |
-
log.info(
|
| 76 |
-
"[PromptRelay] Built penalty matrix (%s): Lq=%d, Lk=%d, nonzero=%d/%d",
|
| 77 |
-
mode, Lq, Lk, (cost > 0).sum().item(), cost.numel(),
|
| 78 |
-
)
|
| 79 |
-
cache[key] = -cost
|
| 80 |
-
|
| 81 |
-
return cache[key].to(dtype)
|
| 82 |
-
|
| 83 |
-
return mask_fn
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def build_segments(token_ranges, segment_lengths, epsilon=1e-3, relay_options=None):
|
| 87 |
-
"""Per-segment metadata for the temporal penalty.
|
| 88 |
-
|
| 89 |
-
relay_options (optional dict) overrides per-stream knobs:
|
| 90 |
-
video_strength, video_window_scale,
|
| 91 |
-
audio_epsilon, audio_strength, audio_window_scale
|
| 92 |
-
Audio knobs only affect architectures whose cross-attention takes the scaled
|
| 93 |
-
(non-integer-frame) path — currently LTX audio_attn2.
|
| 94 |
-
"""
|
| 95 |
-
# Paper uses constant sigma = 1/ln(1/epsilon) regardless of segment length
|
| 96 |
-
sigma = 1.0 / math.log(1.0 / epsilon) if 0 < epsilon < 1 else 0.1448
|
| 97 |
-
|
| 98 |
-
opts = relay_options or {}
|
| 99 |
-
v_strength = opts.get("video_strength", 1.0)
|
| 100 |
-
v_window_scale = opts.get("video_window_scale", 1.0)
|
| 101 |
-
a_epsilon = opts.get("audio_epsilon")
|
| 102 |
-
a_strength = opts.get("audio_strength", 1.0)
|
| 103 |
-
a_window_scale = opts.get("audio_window_scale", 1.0)
|
| 104 |
-
|
| 105 |
-
if a_epsilon is not None and 0 < a_epsilon < 1:
|
| 106 |
-
sigma_audio = 1.0 / math.log(1.0 / a_epsilon)
|
| 107 |
-
else:
|
| 108 |
-
sigma_audio = sigma
|
| 109 |
-
|
| 110 |
-
if relay_options:
|
| 111 |
-
log.info(
|
| 112 |
-
"[PromptRelay] Advanced options active — video: strength=%.3f window_scale=%.3f | "
|
| 113 |
-
"audio: epsilon=%s strength=%.3f window_scale=%.3f",
|
| 114 |
-
v_strength, v_window_scale,
|
| 115 |
-
f"{a_epsilon:.4f}" if a_epsilon is not None else "inherit",
|
| 116 |
-
a_strength, a_window_scale,
|
| 117 |
-
)
|
| 118 |
-
|
| 119 |
-
q_token_idx = []
|
| 120 |
-
frame_cursor = 0
|
| 121 |
-
|
| 122 |
-
for (tok_start, tok_end), L in zip(token_ranges, segment_lengths):
|
| 123 |
-
if L <= 0:
|
| 124 |
-
frame_cursor += L
|
| 125 |
-
continue
|
| 126 |
-
midpoint = (2 * frame_cursor + L) // 2
|
| 127 |
-
base_window = max(L // 2 - 2, 0)
|
| 128 |
-
q_token_idx.append({
|
| 129 |
-
"local_token_idx": torch.arange(tok_start, tok_end),
|
| 130 |
-
"midpoint": midpoint,
|
| 131 |
-
"window": max(base_window * v_window_scale, 0.0),
|
| 132 |
-
"sigma": sigma,
|
| 133 |
-
"strength": v_strength,
|
| 134 |
-
"window_audio": max(base_window * a_window_scale, 0.0),
|
| 135 |
-
"sigma_audio": sigma_audio,
|
| 136 |
-
"strength_audio": a_strength,
|
| 137 |
-
})
|
| 138 |
-
frame_cursor += L
|
| 139 |
-
|
| 140 |
-
return q_token_idx
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
def get_raw_tokenizer(clip):
|
| 144 |
-
"""Extract the raw SPiece/HF tokenizer from a ComfyUI CLIP object."""
|
| 145 |
-
tokenizer_wrapper = clip.tokenizer
|
| 146 |
-
for attr_name in dir(tokenizer_wrapper):
|
| 147 |
-
if attr_name.startswith("_"):
|
| 148 |
-
continue
|
| 149 |
-
inner = getattr(tokenizer_wrapper, attr_name, None)
|
| 150 |
-
if inner is not None and hasattr(inner, "tokenizer"):
|
| 151 |
-
return inner.tokenizer
|
| 152 |
-
|
| 153 |
-
raise RuntimeError(
|
| 154 |
-
f"Could not find raw tokenizer on CLIP object. "
|
| 155 |
-
f"Known attributes: {[a for a in dir(tokenizer_wrapper) if not a.startswith('_')]}"
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
def map_token_indices(raw_tokenizer, global_prompt, local_prompts):
|
| 160 |
-
"""Tokenize global + space-prefixed locals; return (full_prompt, per-local token ranges).
|
| 161 |
-
|
| 162 |
-
Uses incremental tokenization to avoid SentencePiece context-dependency issues.
|
| 163 |
-
"""
|
| 164 |
-
prefixed_locals = [" " + lp for lp in local_prompts]
|
| 165 |
-
full_prompt = global_prompt + "".join(prefixed_locals)
|
| 166 |
-
has_eos = getattr(raw_tokenizer, "add_eos", False)
|
| 167 |
-
eos_adj = 1 if has_eos else 0
|
| 168 |
-
|
| 169 |
-
prev_len = len(raw_tokenizer(global_prompt)["input_ids"]) - eos_adj
|
| 170 |
-
token_ranges = []
|
| 171 |
-
built = global_prompt
|
| 172 |
-
|
| 173 |
-
for plp in prefixed_locals:
|
| 174 |
-
built += plp
|
| 175 |
-
cur_len = len(raw_tokenizer(built)["input_ids"]) - eos_adj
|
| 176 |
-
if cur_len <= prev_len:
|
| 177 |
-
raise ValueError(f"Local prompt produced no tokens: '{plp.strip()}'")
|
| 178 |
-
token_ranges.append((prev_len, cur_len))
|
| 179 |
-
prev_len = cur_len
|
| 180 |
-
|
| 181 |
-
return full_prompt, token_ranges
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
def distribute_segment_lengths(num_segments, latent_frames, specified_lengths=None):
|
| 185 |
-
"""Validate or auto-distribute segment frame counts, capped to fit within latent_frames."""
|
| 186 |
-
if specified_lengths:
|
| 187 |
-
if len(specified_lengths) != num_segments:
|
| 188 |
-
raise ValueError(
|
| 189 |
-
f"Number of segment_lengths ({len(specified_lengths)}) "
|
| 190 |
-
f"must match number of local prompts ({num_segments})"
|
| 191 |
-
)
|
| 192 |
-
lengths = specified_lengths
|
| 193 |
-
else:
|
| 194 |
-
# ceil division — matches reference implementation
|
| 195 |
-
step = -(-latent_frames // num_segments)
|
| 196 |
-
lengths = [step] * num_segments
|
| 197 |
-
|
| 198 |
-
effective = []
|
| 199 |
-
cursor = 0
|
| 200 |
-
for L in lengths:
|
| 201 |
-
end = min(cursor + L, latent_frames)
|
| 202 |
-
effective.append(max(end - cursor, 0))
|
| 203 |
-
cursor = end
|
| 204 |
-
return effective
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
custom_nodes/hf_prompt_relay/requirements.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
word2number
|
|
|
|
|
|
custom_nodes/hf_prompt_relay/smart_nodes.py
DELETED
|
@@ -1,145 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
from comfy_api.latest import io
|
| 3 |
-
from .nodes import _encode_relay
|
| 4 |
-
from .prompt_relay import get_raw_tokenizer
|
| 5 |
-
from .parser import parse_smart_prompt
|
| 6 |
-
|
| 7 |
-
log = logging.getLogger(__name__)
|
| 8 |
-
|
| 9 |
-
class PromptRelaySmartEncode(io.ComfyNode):
|
| 10 |
-
"""Parses advanced syntax into Prompt Relay segments and lengths."""
|
| 11 |
-
|
| 12 |
-
@classmethod
|
| 13 |
-
def define_schema(cls):
|
| 14 |
-
return io.Schema(
|
| 15 |
-
node_id="PromptRelaySmartEncode",
|
| 16 |
-
display_name="Prompt Relay Encode (Smart)",
|
| 17 |
-
category="conditioning/prompt_relay",
|
| 18 |
-
description="Parses syntax like [0-50] or block headers (Second 1:) to automatically calculate segment lengths.",
|
| 19 |
-
inputs=[
|
| 20 |
-
io.Model.Input("model"),
|
| 21 |
-
io.Clip.Input("clip"),
|
| 22 |
-
io.Latent.Input("latent"),
|
| 23 |
-
io.String.Input(
|
| 24 |
-
"global_prompt", multiline=True, default="",
|
| 25 |
-
tooltip="Conditions entire video. Leave empty to auto-use the first parsed segment from smart_prompt as the global anchor."
|
| 26 |
-
),
|
| 27 |
-
io.String.Input(
|
| 28 |
-
"smart_prompt", multiline=True, default="",
|
| 29 |
-
tooltip="Enter prompt using Smart Syntax:\\n1. Inline: 'text one [0-50] | text two [50-100]'\\n2. Block: 'Second 1:\\ntext one\\nSecond 2:\\ntext two'\\nSyntax is auto-stripped and normalized evenly or proportionally."
|
| 30 |
-
),
|
| 31 |
-
io.Boolean.Input("normalize_by_tokens", default=False, tooltip="If true, scales the calculated length of each segment by its token count."),
|
| 32 |
-
io.Float.Input("epsilon", default=1e-3, min=1e-6, max=0.99, step=1e-4),
|
| 33 |
-
],
|
| 34 |
-
outputs=[
|
| 35 |
-
io.Model.Output(display_name="model"),
|
| 36 |
-
io.Conditioning.Output(display_name="positive"),
|
| 37 |
-
],
|
| 38 |
-
)
|
| 39 |
-
|
| 40 |
-
@classmethod
|
| 41 |
-
def execute(cls, model, clip, latent, global_prompt, smart_prompt, normalize_by_tokens, epsilon) -> io.NodeOutput:
|
| 42 |
-
parsed = parse_smart_prompt(smart_prompt)
|
| 43 |
-
|
| 44 |
-
valid_segments = [s for s in parsed if s["text"].strip()]
|
| 45 |
-
if not valid_segments:
|
| 46 |
-
valid_segments = [{"text": " ", "weight": 1.0}]
|
| 47 |
-
|
| 48 |
-
raw_tokenizer = get_raw_tokenizer(clip) if normalize_by_tokens else None
|
| 49 |
-
|
| 50 |
-
local_prompts_list = []
|
| 51 |
-
weights_list = []
|
| 52 |
-
|
| 53 |
-
for seg in valid_segments:
|
| 54 |
-
text = seg["text"]
|
| 55 |
-
weight = seg["weight"]
|
| 56 |
-
|
| 57 |
-
if normalize_by_tokens and raw_tokenizer:
|
| 58 |
-
try:
|
| 59 |
-
tokens = raw_tokenizer(text)["input_ids"]
|
| 60 |
-
has_eos = getattr(raw_tokenizer, "add_eos", False)
|
| 61 |
-
token_count = len(tokens) - (1 if has_eos else 0)
|
| 62 |
-
token_count = max(1, token_count)
|
| 63 |
-
weight *= token_count
|
| 64 |
-
except Exception as e:
|
| 65 |
-
log.warning(f"Token counting failed for segment '{text}': {e}")
|
| 66 |
-
|
| 67 |
-
local_prompts_list.append(text)
|
| 68 |
-
weights_list.append(weight)
|
| 69 |
-
|
| 70 |
-
local_prompts_str = " | ".join(local_prompts_list)
|
| 71 |
-
|
| 72 |
-
scale_factor = 100000.0
|
| 73 |
-
segment_lengths_str = ", ".join(str(int(w * scale_factor)) for w in weights_list)
|
| 74 |
-
|
| 75 |
-
global_prompt_str = global_prompt.strip()
|
| 76 |
-
if not global_prompt_str and valid_segments:
|
| 77 |
-
global_prompt_str = valid_segments[0]["text"]
|
| 78 |
-
|
| 79 |
-
patched, conditioning = _encode_relay(
|
| 80 |
-
model, clip, latent, global_prompt_str, local_prompts_str, segment_lengths_str, epsilon
|
| 81 |
-
)
|
| 82 |
-
|
| 83 |
-
return io.NodeOutput(patched, conditioning)
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
class PromptRelaySmartEncodeTest(io.ComfyNode):
|
| 87 |
-
"""Test node for Prompt Relay Smart Encode syntax parsing."""
|
| 88 |
-
|
| 89 |
-
@classmethod
|
| 90 |
-
def define_schema(cls):
|
| 91 |
-
return io.Schema(
|
| 92 |
-
node_id="PromptRelaySmartEncodeTest",
|
| 93 |
-
display_name="Prompt Relay Smart Encode Test",
|
| 94 |
-
category="conditioning/prompt_relay",
|
| 95 |
-
description="Outputs the parsed syntax for testing purposes.",
|
| 96 |
-
inputs=[
|
| 97 |
-
io.String.Input(
|
| 98 |
-
"smart_prompt", multiline=True, default="",
|
| 99 |
-
tooltip="Enter prompt using Smart Syntax:\\n1. Inline: 'text one [0-50] | text two [50-100]'\\n2. Block: 'Second 1:\\ntext one\\nSecond 2:\\ntext two'\\nSyntax is auto-stripped and normalized evenly or proportionally."
|
| 100 |
-
),
|
| 101 |
-
io.Boolean.Input("normalize_by_tokens", default=False),
|
| 102 |
-
io.Clip.Input("clip", optional=True),
|
| 103 |
-
],
|
| 104 |
-
outputs=[
|
| 105 |
-
io.String.Output(display_name="parsed_output"),
|
| 106 |
-
],
|
| 107 |
-
)
|
| 108 |
-
|
| 109 |
-
@classmethod
|
| 110 |
-
def execute(cls, smart_prompt, normalize_by_tokens, clip=None) -> io.NodeOutput:
|
| 111 |
-
parsed = parse_smart_prompt(smart_prompt)
|
| 112 |
-
|
| 113 |
-
valid_segments = [s for s in parsed if s["text"].strip()]
|
| 114 |
-
if not valid_segments:
|
| 115 |
-
valid_segments = [{"text": " ", "weight": 1.0}]
|
| 116 |
-
|
| 117 |
-
raw_tokenizer = None
|
| 118 |
-
if normalize_by_tokens and clip is not None:
|
| 119 |
-
from .prompt_relay import get_raw_tokenizer
|
| 120 |
-
raw_tokenizer = get_raw_tokenizer(clip)
|
| 121 |
-
|
| 122 |
-
output_lines = []
|
| 123 |
-
for i, seg in enumerate(valid_segments):
|
| 124 |
-
text = seg["text"]
|
| 125 |
-
weight = seg["weight"]
|
| 126 |
-
|
| 127 |
-
base_weight = weight
|
| 128 |
-
token_count = None
|
| 129 |
-
|
| 130 |
-
if normalize_by_tokens and raw_tokenizer:
|
| 131 |
-
try:
|
| 132 |
-
tokens = raw_tokenizer(text)["input_ids"]
|
| 133 |
-
has_eos = getattr(raw_tokenizer, "add_eos", False)
|
| 134 |
-
token_count = len(tokens) - (1 if has_eos else 0)
|
| 135 |
-
token_count = max(1, token_count)
|
| 136 |
-
weight *= token_count
|
| 137 |
-
except Exception:
|
| 138 |
-
pass
|
| 139 |
-
|
| 140 |
-
line = f"Segment {i+1}: text='{text}', base_weight={base_weight}"
|
| 141 |
-
if token_count is not None:
|
| 142 |
-
line += f", tokens={token_count}, final_weight={weight}"
|
| 143 |
-
output_lines.append(line)
|
| 144 |
-
|
| 145 |
-
return io.NodeOutput("\n".join(output_lines))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
style.css
CHANGED
|
@@ -14,8 +14,8 @@ html{background:#f4f6f4;color-scheme:light}body{max-width:none;margin:0;padding:
|
|
| 14 |
.preview-stage{position:relative;min-height:340px}.preview-loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:24px;color:#487ba7;font-size:13px;letter-spacing:.12em;background:radial-gradient(circle at center,#e5f3ff 0,transparent 65%)}.light-ring{display:block;width:84px;height:84px;border-radius:50%;border:3px solid #d9ebfa;border-top-color:#249dff;border-right-color:#74caff;box-shadow:0 0 18px #55b7ff55,inset 0 0 13px #55b7ff33;animation:preview-orbit 1s linear infinite}@keyframes preview-orbit{to{transform:rotate(360deg)}}@media(max-width:760px){.preview-stage{min-height:220px}.light-ring{width:68px;height:68px}}@media(prefers-reduced-motion:reduce){.light-ring{animation:none}}
|
| 15 |
@media(max-width:760px){.output{margin-bottom:24px}}
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
.video-loras{margin:16px 0;padding:14px;border:1px solid #cbd5e1;border-radius:12px;min-width:0}.video-lora-control{display:grid;grid-template-columns:minmax(0,1fr) 88px;gap:4px 12px;align-items:center;margin:18px 0}.video-lora-control label{grid-column:1;grid-row:1;font-weight:600}.video-lora-control input{min-width:0;width:100%;box-sizing:border-box}.video-lora-control input[type=number]{grid-column:2;grid-row:1;min-height:44px;font-size:16px;text-align:center}.video-lora-control input[type=range]{grid-column:1/-1;grid-row:2;appearance:none;-webkit-appearance:none;height:48px;margin:0;padding:0;background:transparent;border:0;border-radius:8px;cursor:pointer;touch-action:pan-y;accent-color:#286645}.video-lora-control input[type=range]::-webkit-slider-runnable-track{height:10px;background:#c8dfd1;border-radius:999px}.video-lora-control input[type=range]::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:32px;height:32px;margin-top:-11px;border:3px solid white;border-radius:50%;background:#286645;box-shadow:0 1px 5px #153e3540}.video-lora-control input[type=range]::-moz-range-track{height:10px;background:#c8dfd1;border-radius:999px}.video-lora-control input[type=range]::-moz-range-thumb{box-sizing:border-box;width:32px;height:32px;border:3px solid white;border-radius:50%;background:#286645;box-shadow:0 1px 5px #153e3540}.video-lora-control input[type=range]:focus-visible{outline:3px solid #59aa7b;outline-offset:3px}.video-loras>button{min-height:44px}
|
| 20 |
|
| 21 |
-
|
|
|
|
| 14 |
.preview-stage{position:relative;min-height:340px}.preview-loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:24px;color:#487ba7;font-size:13px;letter-spacing:.12em;background:radial-gradient(circle at center,#e5f3ff 0,transparent 65%)}.light-ring{display:block;width:84px;height:84px;border-radius:50%;border:3px solid #d9ebfa;border-top-color:#249dff;border-right-color:#74caff;box-shadow:0 0 18px #55b7ff55,inset 0 0 13px #55b7ff33;animation:preview-orbit 1s linear infinite}@keyframes preview-orbit{to{transform:rotate(360deg)}}@media(max-width:760px){.preview-stage{min-height:220px}.light-ring{width:68px;height:68px}}@media(prefers-reduced-motion:reduce){.light-ring{animation:none}}
|
| 15 |
@media(max-width:760px){.output{margin-bottom:24px}}
|
| 16 |
|
| 17 |
+
|
| 18 |
|
| 19 |
.video-loras{margin:16px 0;padding:14px;border:1px solid #cbd5e1;border-radius:12px;min-width:0}.video-lora-control{display:grid;grid-template-columns:minmax(0,1fr) 88px;gap:4px 12px;align-items:center;margin:18px 0}.video-lora-control label{grid-column:1;grid-row:1;font-weight:600}.video-lora-control input{min-width:0;width:100%;box-sizing:border-box}.video-lora-control input[type=number]{grid-column:2;grid-row:1;min-height:44px;font-size:16px;text-align:center}.video-lora-control input[type=range]{grid-column:1/-1;grid-row:2;appearance:none;-webkit-appearance:none;height:48px;margin:0;padding:0;background:transparent;border:0;border-radius:8px;cursor:pointer;touch-action:pan-y;accent-color:#286645}.video-lora-control input[type=range]::-webkit-slider-runnable-track{height:10px;background:#c8dfd1;border-radius:999px}.video-lora-control input[type=range]::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:32px;height:32px;margin-top:-11px;border:3px solid white;border-radius:50%;background:#286645;box-shadow:0 1px 5px #153e3540}.video-lora-control input[type=range]::-moz-range-track{height:10px;background:#c8dfd1;border-radius:999px}.video-lora-control input[type=range]::-moz-range-thumb{box-sizing:border-box;width:32px;height:32px;border:3px solid white;border-radius:50%;background:#286645;box-shadow:0 1px 5px #153e3540}.video-lora-control input[type=range]:focus-visible{outline:3px solid #59aa7b;outline-offset:3px}.video-loras>button{min-height:44px}
|
| 20 |
|
| 21 |
+
|