jmpplbp commited on
Commit
378ebcb
·
verified ·
1 Parent(s): 7e8f83f

Remove unused segmented prompt feature

Browse files
APP_FILES.json CHANGED
@@ -31,8 +31,8 @@
31
  },
32
  {
33
  "path": "RUNTIME_SOURCES.json",
34
- "size": 4138,
35
- "sha256": "8c49d0cd826006266902ca6a53ff39b0907aabb68916f74f19f268438000d607"
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": 78586,
160
- "sha256": "8e91bfdeff26bdf812d2b83ed8b326c88affb4d62ed855039bc1708ec0e2f850"
161
  },
162
  {
163
  "path": "style.css",
164
- "size": 11010,
165
- "sha256": "654c77251c61e1116f36b564db061ff9969914c1b7847aca222a19fc25104730"
166
  },
167
  {
168
  "path": "laogames.json",
@@ -181,8 +131,8 @@
181
  },
182
  {
183
  "path": "MODEL_SOURCES.json",
184
- "size": 2600,
185
- "sha256": "603317717ee9cd6cdc269772cd2e3d868b4e7232f0f46bf31d177cdf66dd44d4"
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, prompt-relay modes using this default slot."
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, prompt-relay modes using this default slot."
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, prompt-relay modes using this default slot."
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
- .relay-scene{display:grid;gap:10px;padding:14px;border:1px solid #cbd5e1;border-radius:12px;min-width:0}.relay-scene>.field{min-width:0}.relay-scene input[type=number]{width:100%;max-width:180px}.relay-duration-hint{margin:0}
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
- .relay-scenes{display:grid;gap:16px}.relay-remove{min-height:44px;justify-self:end;grid-column:1/-1}.relay-add{min-height:48px;width:100%}
 
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
+