PoopMan333 commited on
Commit
483545a
·
verified ·
1 Parent(s): ad7f8bc

Upload 42 files

Browse files
Files changed (42) hide show
  1. custom_nodes/ComfyUI-Nugget/README.md +525 -0
  2. custom_nodes/ComfyUI-Nugget/__init__.py +76 -0
  3. custom_nodes/ComfyUI-Nugget/__pycache__/__init__.cpython-313.pyc +0 -0
  4. custom_nodes/ComfyUI-Nugget/__pycache__/camera.cpython-313.pyc +0 -0
  5. custom_nodes/ComfyUI-Nugget/__pycache__/detect.cpython-313.pyc +0 -0
  6. custom_nodes/ComfyUI-Nugget/__pycache__/fingerprint.cpython-313.pyc +0 -0
  7. custom_nodes/ComfyUI-Nugget/__pycache__/interrupt.cpython-313.pyc +0 -0
  8. custom_nodes/ComfyUI-Nugget/__pycache__/llm.cpython-313.pyc +0 -0
  9. custom_nodes/ComfyUI-Nugget/__pycache__/motion_prompts.cpython-313.pyc +0 -0
  10. custom_nodes/ComfyUI-Nugget/__pycache__/nodes_image.cpython-313.pyc +0 -0
  11. custom_nodes/ComfyUI-Nugget/__pycache__/nodes_motion.cpython-313.pyc +0 -0
  12. custom_nodes/ComfyUI-Nugget/__pycache__/nodes_prompt.cpython-313.pyc +0 -0
  13. custom_nodes/ComfyUI-Nugget/__pycache__/nodes_transcribe.cpython-313.pyc +0 -0
  14. custom_nodes/ComfyUI-Nugget/__pycache__/prompts.cpython-313.pyc +0 -0
  15. custom_nodes/ComfyUI-Nugget/__pycache__/recall.cpython-313.pyc +0 -0
  16. custom_nodes/ComfyUI-Nugget/__pycache__/speech.cpython-313.pyc +0 -0
  17. custom_nodes/ComfyUI-Nugget/__pycache__/video.cpython-313.pyc +0 -0
  18. custom_nodes/ComfyUI-Nugget/camera.py +123 -0
  19. custom_nodes/ComfyUI-Nugget/detect.py +167 -0
  20. custom_nodes/ComfyUI-Nugget/fingerprint.py +158 -0
  21. custom_nodes/ComfyUI-Nugget/install.bat +30 -0
  22. custom_nodes/ComfyUI-Nugget/install.py +496 -0
  23. custom_nodes/ComfyUI-Nugget/interrupt.py +39 -0
  24. custom_nodes/ComfyUI-Nugget/llm.py +277 -0
  25. custom_nodes/ComfyUI-Nugget/motion_prompts.py +450 -0
  26. custom_nodes/ComfyUI-Nugget/nodes_image.py +328 -0
  27. custom_nodes/ComfyUI-Nugget/nodes_motion.py +472 -0
  28. custom_nodes/ComfyUI-Nugget/nodes_prompt.py +411 -0
  29. custom_nodes/ComfyUI-Nugget/nodes_transcribe.py +642 -0
  30. custom_nodes/ComfyUI-Nugget/prompts.py +361 -0
  31. custom_nodes/ComfyUI-Nugget/pyproject.toml +21 -0
  32. custom_nodes/ComfyUI-Nugget/recall.py +149 -0
  33. custom_nodes/ComfyUI-Nugget/selftest.py +111 -0
  34. custom_nodes/ComfyUI-Nugget/speech.py +383 -0
  35. custom_nodes/ComfyUI-Nugget/tests/run_all.py +28 -0
  36. custom_nodes/ComfyUI-Nugget/tests/test_image.py +227 -0
  37. custom_nodes/ComfyUI-Nugget/tests/test_prompt.py +201 -0
  38. custom_nodes/ComfyUI-Nugget/tests/test_registration.py +283 -0
  39. custom_nodes/ComfyUI-Nugget/tests/test_transcribe.py +715 -0
  40. custom_nodes/ComfyUI-Nugget/video.py +648 -0
  41. custom_nodes/ComfyUI-Nugget/web/js/nugget_batch_nocrop.js +150 -0
  42. custom_nodes/ComfyUI-Nugget/web/js/nugget_transcribe_ui.js +240 -0
custom_nodes/ComfyUI-Nugget/README.md ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Nugget
2
+
3
+ Three nodes for ComfyUI, all under **Nugget** in the node menu.
4
+
5
+ | node | what it does |
6
+ |---|---|
7
+ | **Video Auto Transcriber** | Describes a short video shot by shot, with cut times read from the file, camera movement measured rather than guessed, and dialogue transcribed into the shot it was spoken in. |
8
+ | **Batch images (no crop)** | Batches up to 9 images into one tensor by padding instead of centre-cropping. Sockets appear as you connect them. |
9
+ | **Pad image to size (no crop)** | Letterboxes one image to an exact size. |
10
+
11
+ ---
12
+
13
+ ## Video Auto Transcriber
14
+
15
+ One node. Give it a short video, get back what actually happens in it — shot by
16
+ shot, with cut times read from the file rather than guessed, camera movement
17
+ measured rather than described, and any dialogue transcribed and placed in the
18
+ shot it was spoken in.
19
+
20
+ ```
21
+ Load CLIP ─┐ ┌─ full description
22
+ Load Video ─┴─> [Video Auto Transcriber] ──┼─ overview
23
+ ├─ characters identified
24
+ ├─ shots
25
+ ├─ audio transcription
26
+ └─ audio language
27
+ ```
28
+
29
+ It uses the text encoder ComfyUI already has loaded. No second server, no API
30
+ key, nothing to start before you queue.
31
+
32
+ ---
33
+
34
+ ## Setup, in order
35
+
36
+ **1. Install the pack.** Drop the folder into `ComfyUI/custom_nodes` and restart.
37
+
38
+ **2. Run the installer.** It finds the Python ComfyUI actually uses, so it does
39
+ not matter which one is on your PATH:
40
+
41
+ ```
42
+ python install.py
43
+ ```
44
+
45
+ Windows: double-click `install.bat`.
46
+
47
+ This is not optional if you want dialogue. faster-whisper does not ship with
48
+ ComfyUI, and the most common reason it still reports as missing after you
49
+ install it is that `pip` went to your system Python while ComfyUI runs its own.
50
+ The installer detects that and re-runs itself in the right interpreter.
51
+
52
+ It also **builds a real model and transcribes a second of silence** to prove it
53
+ works, because a successful import proves nothing — CTranslate2 loads its CUDA
54
+ libraries only when a model is constructed.
55
+
56
+ `python install.py --check` reports without changing anything.
57
+
58
+ **3. Download a text encoder.** The `clip` input needs a **language model**, not
59
+ an image-generation text encoder. Pick one:
60
+
61
+ | model | file | size | notes |
62
+ |---|---|---|---|
63
+ | Gemma 4 E4B | [`gemma4_e4b_it_fp8_scaled.safetensors`](https://huggingface.co/Comfy-Org/gemma-4/tree/main/text_encoders) | 9.1 GB | ComfyUI's own default. **Start here on a 16 GB card.** |
64
+ | Qwen3-VL 8B | [`qwen3vl_8b_nvfp4.safetensors`](https://huggingface.co/Comfy-Org/Qwen3-VL/tree/main/text_encoders) | 6.3 GB | Most headroom of the 8B builds |
65
+ | Qwen3-VL 8B | [`qwen3vl_8b_fp8_scaled.safetensors`](https://huggingface.co/Comfy-Org/Qwen3-VL/tree/main/text_encoders) | 10.6 GB | Alternative family |
66
+ | Qwen3-VL 4B | [`qwen3vl_4b_fp8_scaled.safetensors`](https://huggingface.co/Comfy-Org/Qwen3-VL/tree/main/text_encoders) | ~5 GB | For 8 GB cards |
67
+ | Gemma 4 12B | [`gemma4_12b_int8_convrot.safetensors`](https://huggingface.co/Comfy-Org/gemma-4/tree/main/text_encoders) | 12.1 GB | Wants 20 GB+ in practice — see below |
68
+
69
+ All go in `ComfyUI/models/text_encoders/`. Restart, then select the file in
70
+ **Load CLIP**. ComfyUI works out the model type from the file itself, so the
71
+ type dropdown rarely matters; picking the wrong file is what does.
72
+
73
+ Both model families' chat markers are stripped from the output, so either works
74
+ with no configuration.
75
+
76
+ **Weights are not the whole cost.** On a 16 GB card a 12.1 GB encoder leaves
77
+ under 4 GB for the vision tower, the activations and the KV cache — and this
78
+ node sends images on *every* call, which is the expensive path. It loads, then
79
+ dies part way through with no Python traceback. Subtract the file size from your
80
+ VRAM and want about 6 GB left, not 4. The node logs free VRAM before the encoder
81
+ loads and warns below 5 GB.
82
+
83
+ Avoid abliterated or "uncensored" repacks. They damage special-token handling,
84
+ which shows up as pages of `<think></think>` and `<|image>` in the output.
85
+
86
+ ---
87
+
88
+ ## Why not just point the built-in node at a video
89
+
90
+ ComfyUI's `Generate Text` node has a `video` input. Its own tooltip says:
91
+
92
+ > Video frames as image batch. Assumed to be 24 FPS; subsampled to 1 FPS internally.
93
+
94
+ One frame per second cannot tell a cut from a camera move. Feed it a 14-second
95
+ clip and it sees 12 stills, then reports 12 shots — one per still, each
96
+ conveniently a second long. A slow crane from someone's boots to their face
97
+ comes back as five separate shots.
98
+
99
+ This node finds the cuts first, then shows the model several frames from inside
100
+ each shot, passed as an image batch so the 1 FPS subsampling never applies.
101
+
102
+ ## Settings
103
+
104
+ | | |
105
+ |---|---|
106
+ | **prompt max length** | Token budget for each shot description. `0` (the default) follows **description detail** — 160 / 300 / 500. Raise it if descriptions stop mid-sentence. The overview and character passes scale with it. |
107
+ | **description detail** | `brief` / `normal` / `detailed`. Sets how much is written per shot and how many frames the model sees. The frame count scales with shot length, so a half-second shot is not padded with near-identical frames and a ten-second one is not left with six-second gaps. |
108
+ | **scene sensitivity** | Cut detection. Raise if shots are being missed, lower if one shot is being split in two. |
109
+ | **min shot** | Shortest shot allowed, in seconds. Two cuts closer than this become one — so a high value *throws cuts away*. Fast-cut music video: `0.5`. Interviews: `5` is fine. |
110
+ | **max frame size** | Maximum width frames are scaled to. A **ceiling, not a target** — a 640-wide source stays 640 even at 1024, because upscaling invents no detail while still costing vision tokens. Drop to 512 for speed, raise to 1024 if fine detail or on-screen text is being missed. |
111
+ | **frame layout** | *(advanced)* `grid` composites each beat's frames into one image — much cheaper, and shows motion at a glance. `separate` sends individual images: more spatial detail per frame, several times slower. |
112
+ | **whisper model** | Speech model. `large-v3` by default. `off` skips audio entirely — useful for clips with no dialogue, and for isolating whether a problem is in the speech half or the vision half. |
113
+ | **audio language** | Named languages, not codes. Leave on `auto` unless detection keeps guessing wrong. |
114
+ | **seed** | *(advanced)* Only used if you raise temperature in `llm.py`. Output is greedy and repeatable by default. |
115
+
116
+ Temperature, top_k, top_p, min_p, both penalties, thinking mode and the chat
117
+ template are fixed in `llm.py`. For a description task each has one correct
118
+ value, and putting them on the node face would only be nine more ways to get a
119
+ worse answer. The repetition penalty in particular is set to 1.0 on purpose:
120
+ shot descriptions are *supposed* to repeat "the man", "the camera", "green
121
+ jacket", and penalising that makes the model invent variation that reads as real
122
+ change.
123
+
124
+ ## Output
125
+
126
+ ```
127
+ overview: Warm low sunlight, high contrast, steady unhurried pace.
128
+
129
+ characters identified:
130
+ a man in an olive puffer jacket and black beanie
131
+
132
+ shots:
133
+
134
+ [Shot 1] From 00:00.000 to 00:04.800, a wide aerial view of a sunlit snowy
135
+ ridge, a lone figure on the skyline; the camera is locked off.
136
+
137
+ [Shot 2] From 00:04.800 to 00:08.828, the man stands in profile, breath vapour
138
+ visible against jagged peaks; the camera pans right.
139
+ speech 00:05.100-00:06.400: "It took us nine hours to get up here."
140
+
141
+ [Shot 3] From 00:08.828 to 00:13.714, the frame starts on his boots and rises
142
+ to his face as he folds his arms and smiles; the camera tilts up.
143
+
144
+ audio: speech in English (98% confidence)
145
+ ```
146
+
147
+ **full description** is the block above — the thing you will usually wire onward.
148
+
149
+ **overview**, **characters identified** and **shots** are the same content as
150
+ separate strings, so a downstream node that wants only one part does not have to
151
+ parse it back out of a formatted block.
152
+
153
+ **audio transcription** is the dialogue on its own with timings, so it can be
154
+ reformatted without re-running the vision passes.
155
+
156
+ **audio language** is the detected language name (`English`, `Japanese`,
157
+ `French`) or `none` when nothing was said. It exists so a downstream prompt
158
+ writer can tag dialogue without parsing it back out — MiniMax H3, for instance,
159
+ wants spoken lines as `<d>[English] ...</d>`.
160
+
161
+ ## Switching it off
162
+
163
+ Bypass the **Load Video** node (Ctrl+B), or delete the link into `video`, and
164
+ this node does nothing: no error, no model load, six empty strings out.
165
+
166
+ Bypass is the fiddly one. Bypassing a node makes it pass through a reference to
167
+ whatever is on its own inputs — and Load Video has no VIDEO input to pass
168
+ through, so what arrives here is not a VIDEO. ComfyUI's own check then rejects
169
+ the entire prompt with *"Return type mismatch between linked nodes"* **before any
170
+ node runs**, which nothing inside the node can catch. So the node opts out of
171
+ that check for `video` specifically and decides for itself, treating anything
172
+ that cannot name a file on disk as "switched off".
173
+
174
+ If your ComfyUI is old enough that bypass still fails, **delete the link instead
175
+ of bypassing**. An unconnected optional input has nothing to type-check.
176
+
177
+ The outputs are empty rather than a placeholder like `no video` on purpose: they
178
+ usually feed a string join on the way into a prompt, and anything non-empty would
179
+ end up inside it.
180
+
181
+ A missing `clip` still errors, because that is a wiring mistake rather than a
182
+ deliberate switch-off.
183
+
184
+ ## How the cuts are found
185
+
186
+ Colour histograms of 64×64 thumbnails, twelve per second, compared frame to
187
+ frame. Histograms rather than pixel differences on purpose: a mean pixel
188
+ difference reacts strongly to camera movement, so a crane or a whip pan scores
189
+ like a cut. On the test footage, switching to histograms widened the gap between
190
+ the weakest true cut and the strongest false positive from 1.5× to 2.4×.
191
+
192
+ The threshold is per-neighbourhood (`median + k·MAD`, with a floor and a ratio
193
+ term), because three equally hard cuts in one clip can score 0.43, 0.17 and 0.12
194
+ — any single cutoff either misses the weak one or floods on noise.
195
+
196
+ Candidates are then accepted **strongest first**, not earliest first. This is the
197
+ part that matters and the part that is easy to get wrong. Walking the list in
198
+ time order and keeping anything far enough from the last accepted cut looks
199
+ reasonable and quietly isn't: a weak false positive a fraction of a second early
200
+ claims the slot, and the real cut is rejected for being too close to it. On real
201
+ footage the two strongest scores in the entire file were both discarded that way
202
+ in favour of noise scoring a quarter as high. Sorting by score first — ordinary
203
+ non-maximum suppression — recovers both exactly.
204
+
205
+ Both ends of the clip are held to at least a one-second minimum whatever
206
+ `min shot` says, so neither an opening flicker nor a fade to black produces a
207
+ sliver shot.
208
+
209
+ ## Camera movement is measured, not guessed
210
+
211
+ A vision model shown a handful of stills cannot tell a pan from a cut, or a
212
+ push-in from a subject walking closer. Ask it in the prompt and you get
213
+ confident wrong answers — "the camera remains stationary" over a crane shot is
214
+ the usual one.
215
+
216
+ So the movement is measured and handed to the model as fact. Translation comes
217
+ from phase correlation between frames; zoom comes from whether the quadrants
218
+ diverge, since in a push-in the left half drifts left and the right half drifts
219
+ right while the global shift stays near zero.
220
+
221
+ On the test clip, whose third shot is a crane from a man's boots up to his face:
222
+
223
+ ```
224
+ Shot 1 (0.00-4.80): the camera is locked off and does not move
225
+ Shot 2 (4.80-8.83): the camera pans right
226
+ Shot 3 (8.83-13.71): the camera pans right and tilts up
227
+ ```
228
+
229
+ It costs nothing extra — the numbers come out of the same decode pass that finds
230
+ the cuts.
231
+
232
+ ## Characters
233
+
234
+ One pass lists the characters — people and animals — and that list is handed to
235
+ **every** shot pass. That is the entire reason it is worth generating: it is what
236
+ lets shot 4 say "the man" and mean the same man as shot 2.
237
+
238
+ There are no `Man A` / `Woman A` labels. On their own the label carries no more
239
+ information than the description next to it. A clip with no characters — a
240
+ landscape, a product shot, an abstract animation — returns nothing and the
241
+ section is omitted.
242
+
243
+ **Repeats are merged.** The context frames deliberately span every shot, so the
244
+ model sees the same person in several framings and often describes each one:
245
+
246
+ ```
247
+ a man in a green jacket
248
+ a man in an olive puffer jacket and black beanie
249
+ a man wearing a dark beanie and green puffer jacket
250
+ ```
251
+
252
+ Those are one man. Entries whose distinguishing words overlap enough are
253
+ collapsed, keeping the fullest description. The merge repeats until nothing more
254
+ combines, because replacing an entry with a longer description gives it more
255
+ words, which can reveal a match the short version hid. A man and a woman, or a
256
+ man and a dog, stay separate.
257
+
258
+ ## Long shots
259
+
260
+ A cut is not the only thing worth timing. A twenty-second continuous take has
261
+ just as much happening in it as four five-second shots, and describing it in one
262
+ pass gives it the same handful of frames and the same two sentences a short shot
263
+ gets — everything between the samples is lost.
264
+
265
+ So a shot longer than its detail level's limit is described in **beats**, each
266
+ with its own times:
267
+
268
+ ```
269
+ [Shot 1] From 00:00.000 to 00:20.000:
270
+ 00:00.000 - 00:06.667 he steps into frame and starts walking left...
271
+ 00:06.667 - 00:13.333 the camera pushes in as he stops and turns...
272
+ 00:13.333 - 00:20.000 he looks off-screen right; the light drops...
273
+ ```
274
+
275
+ A twenty-second take at `normal` becomes 3 beats and 27 frames rather than 1
276
+ description and 9 frames. Beats after the first are told they are continuing the
277
+ same shot and to describe what *changes*, so you get progression rather than the
278
+ same scene restated three times. A shot only slightly over the limit is left
279
+ whole — splitting seven seconds into six plus one gives a one-second beat that
280
+ says nothing.
281
+
282
+ This is the partition-and-summarise approach from
283
+ [LVD-2M](https://arxiv.org/abs/2410.10816).
284
+
285
+ ## Frame grids
286
+
287
+ Each beat's frames are composited into a single image, read left to right then
288
+ top to bottom. Six frames cost roughly what one image costs, which is what makes
289
+ the dense sampling above affordable — and the model sees the whole progression
290
+ laid out at once instead of as a sequence it has to hold in mind.
291
+
292
+ This comes from [IG-VLM](https://arxiv.org/abs/2403.18406), which found
293
+ image-pretrained VLMs read grids zero-shot with no video training, and
294
+ [Video Panels](https://arxiv.org/abs/2509.23724), which confirmed it across five
295
+ benchmarks and several model families. Both frame it as trading spatial detail
296
+ for temporal resolution — the right trade when the thing you are describing is
297
+ motion.
298
+
299
+ At the default `max frame size` of 768 a six-panel grid gives each frame 256×192,
300
+ which stays legible enough to read on-screen text. If detail is being missed,
301
+ raise `max frame size` before switching to `separate`.
302
+
303
+ ## Length
304
+
305
+ Nothing caps the assembled description, but every model call has a token budget,
306
+ and generation simply stops when it runs out — mid-sentence if that is where the
307
+ budget lands. Defaults are 160 / 300 / 500 tokens per shot by detail level, 120
308
+ for the overview, 200 for the character list.
309
+
310
+ Since there is no truncation flag to read, the tell is a description ending
311
+ without terminal punctuation. When that happens the log says so and names the
312
+ budget it hit, so you know to raise **prompt max length**.
313
+
314
+ Worth remembering that a long shot is split into beats, each with its own full
315
+ budget — a 20-second single take at `normal` is three beats, so 900 tokens, not
316
+ 300.
317
+
318
+ ## Re-running without re-transcribing
319
+
320
+ ComfyUI skips any node whose inputs have not changed, so once a clip is
321
+ transcribed, queueing again costs nothing — change the sampler seed downstream
322
+ and only the sampler re-runs.
323
+
324
+ That only works if this node's inputs stay put, which is why **the seed here
325
+ does not auto-randomise**. It has no effect on output anyway (generation is
326
+ greedy unless you raise temperature in `llm.py`), so a `randomize` setting would
327
+ change the inputs on every queue, miss the cache, and re-transcribe the whole
328
+ video for nothing. If you set it to `randomize` yourself, that is exactly what
329
+ will happen.
330
+
331
+ Changing the video, or any setting on this node, does invalidate the cache — as
332
+ it should, because the description genuinely would be different.
333
+
334
+ To skip transcription entirely, bypass the Load Video node.
335
+
336
+ ## What it does about being slow
337
+
338
+ Several model calls on a 20-second clip takes a while, so:
339
+
340
+ - **Frames are decoded once.** Every timestamp the run needs is collected up
341
+ front and pulled in a single sequential pass. Fetching per shot instead means
342
+ re-decoding the whole file once per shot.
343
+ - **The transcript is cached** against the audio and the speech settings. Nudging
344
+ `detail` re-runs the vision passes, which is the point, but it will not re-run
345
+ a transcription that cannot have changed.
346
+ - **Progress shows on the node**, one step per pass.
347
+ - **Cancel works, including mid-transcription.** ComfyUI cancels by setting a
348
+ flag, so a node only stops when it next asks. Transcription is by far the
349
+ longest uninterruptible stretch — `large-v3` on the CPU can spend over a minute
350
+ on a ten-second clip — so the segment loop polls as faster-whisper yields, and
351
+ the frame decode polls too. The one thing that cannot be cut short is a single
352
+ `clip.generate()` call, which is one blocking call into ComfyUI with no hook to
353
+ poll; those run a few seconds each.
354
+ - **One failed shot does not lose the run.** It is marked in the output and the
355
+ rest continues.
356
+
357
+ ## Troubleshooting
358
+
359
+ | symptom | cause |
360
+ |---|---|
361
+ | `'SD1ClipModel' object has no attribute 'generate'` | Load CLIP is loading a Stable Diffusion text encoder, not a language model. An SD1 encoder is ~235 MB; the log line `Model SD1ClipModel prepared ... 235MB Staged` is the giveaway. Download one of the encoders above. |
362
+ | ComfyUI **exits** with a code instead of showing an error | Native crash — almost always VRAM. Check the `GB free before loading the text encoder` line in the log. Use a smaller encoder. |
363
+ | `Library cublas64_12.dll is not found` | Windows only, and the package is probably installed. pip puts the DLL in `site-packages/nvidia/...`, which Python has not searched since 3.8. The node registers those folders itself now. |
364
+ | Transcript says faster-whisper is missing after installing it | It went into the wrong Python. Run `install.py`. |
365
+ | Shot descriptions stop mid-sentence | Raise **prompt max length**. The log warns when this happens. |
366
+ | One shot reported as several | Raise **min shot**, or lower **scene sensitivity**. |
367
+ | Several shots reported as one | Raise **scene sensitivity**. |
368
+ | Dialogue invented over music | Set **whisper model** to `off`. `large-v3` hallucinates on non-speech audio; VAD suppresses most of it but not all. |
369
+
370
+ ### A note on CUDA versions
371
+
372
+ faster-whisper is built on CTranslate2, which is compiled against **CUDA 12**.
373
+ If your torch is built against a different CUDA, speech runs on the CPU and no
374
+ CUDA libraries are registered at all. Putting two CUDA runtimes in one process
375
+ does not raise an exception — it kills the process. On a 20-second clip the GPU
376
+ saves a few seconds; it is not worth a crash.
377
+
378
+ ## Hardware
379
+
380
+ Tested on: RTX 5060 Ti 16 GB, CUDA speech.
381
+
382
+ | | |
383
+ |---|---|
384
+ | **NVIDIA, 12 GB+** | Everything on the GPU. |
385
+ | **NVIDIA, 8 GB** | Use Qwen3-VL 4B or Gemma 4 E2B. Speech falls back to CPU. |
386
+ | **AMD / ROCm** | Vision on the GPU, **speech on CPU always** — CTranslate2 has no ROCm backend. |
387
+ | **Apple Silicon** | Same: vision on MPS, speech on CPU. No Metal backend either. |
388
+ | **CPU only** | Works. Slowly. |
389
+
390
+ Speech on CPU with `large-v3` at int8 is roughly 25–40 seconds for a 20-second
391
+ clip. Annoying, not broken. Drop to `medium` or `small` if that matters.
392
+
393
+ **Requires ComfyUI v0.15.0 or newer** — that is when `clip.generate()` arrived.
394
+ Older builds get a clear error rather than an AttributeError.
395
+
396
+ ## Nothing here censors the output
397
+
398
+ Filtering is assumed to happen outside this node, so nothing inside it filters
399
+ again. There is no word list, no blocklist, no content check, and no prompt
400
+ wording that steers the model toward tamer descriptions. The test suite asserts
401
+ all three and fails if any of them changes.
402
+
403
+ Exactly three things discard model output, none of them content-based:
404
+
405
+ - **Control tokens.** `<think>`, `<|im_start|>`, `<|image>`, `<|turn>` and the
406
+ rest of Gemma's and Qwen's chat markers. Core's own node leaves these in,
407
+ which is why raw use of it produces pages of `<think></think>`.
408
+ - **A truncated reasoning trace.** If generation opens `<think>` and runs out of
409
+ budget before closing it, there is no answer — only a half-finished thought —
410
+ and returning it as the description would be worse than returning nothing.
411
+ - **Duplicate character entries.** The same person described twice in different
412
+ framings is merged, keeping the fullest description. Content-agnostic: it
413
+ matches on word overlap, not on what the words are.
414
+
415
+ What the *model* will do is a separate matter. A safety-tuned encoder may decline
416
+ to describe some footage however the prompt is written, and Whisper has its own
417
+ opinions about what it transcribes. Neither is something this node can undo —
418
+ that is a question of which encoder you load.
419
+
420
+ ## Known limits
421
+
422
+ - **Under 2 minutes.** Longer clips are refused with a message rather than
423
+ running for twenty minutes and running out of memory.
424
+ - **No speaker diarisation.** Two people talking gives you the words but not who
425
+ said them.
426
+ - **Whisper on music.** `large-v3` invents dialogue over music and room tone.
427
+ Silero VAD is on by default and `condition_on_previous_text` is off, which
428
+ suppresses most of it, and a language confidence under 50% is reported as
429
+ uncertain rather than asserted. It is not a complete fix.
430
+ - **Speech crossing a cut** is assigned to whichever shot it spends most of
431
+ itself in, so it is listed once rather than under both.
432
+ - **Variable frame rate** is detected and logged, not corrected. It does not
433
+ affect the timings here — every time comes from the frame's own timestamp.
434
+ - **HEVC and AV1** depend on your PyAV build.
435
+
436
+ ## Tests
437
+
438
+ ```
439
+ python tests/run_all.py
440
+ ```
441
+
442
+ Around 270 tests across three suites, no GPU, no model, no network.
443
+
444
+ `pip install pyflakes` adds an undefined-name gate to the registration suite.
445
+ Two bugs have shipped that Python only raises when the offending line runs — a
446
+ `NODES` list broken by a find-and-replace, and a stale variable in a log call
447
+ that fired only after the first generation finished. Neither is a syntax error.
448
+ That check catches both. The video tests build their own clip
449
+ with PyAV. The detection tests include the non-maximum-suppression case above,
450
+ and the token-stripping tests include the exact runaway output that motivated
451
+ them.
452
+
453
+ ---
454
+
455
+ # Batch images (no crop)
456
+
457
+ ComfyUI's IMAGE type is one tensor shaped `(batch, height, width, channels)`, so
458
+ every image in a batch must share a height and a width. KJNodes'
459
+ `ImageBatchMulti` resolves that by scaling images 2..N to match image 1 and
460
+ centre-cropping the overflow — fine for images of the same shape, destructive for
461
+ anything else. A portrait dropped into a landscape slot 1 loses its top and
462
+ bottom, which is where a face usually is.
463
+
464
+ This pads instead. Every image is scaled to *fit* the canvas and the remainder
465
+ filled, so nothing is cropped and nothing is stretched.
466
+
467
+ **The canvas is the smallest box that holds every input**, then scaled down to
468
+ your ceiling preserving that box's aspect ratio. Which means a set that needs no
469
+ padding gets none:
470
+
471
+ | input | canvas | bars |
472
+ |---|---|---|
473
+ | one 1920×1080 | 1024×576 | 0% |
474
+ | three identical portraits | 701×1024 | 0% |
475
+ | one 4000×100 panorama | 1024×26 | 0% |
476
+ | portrait + landscape | 1024×1024 | 32% each |
477
+
478
+ Only genuinely mixed shapes cost anything, and there padding is the better
479
+ failure — you can raise resolution to offset wasted tokens, you cannot recover a
480
+ cropped face.
481
+
482
+ Sockets appear one at a time as you fill them, up to nine, and compact
483
+ themselves: disconnect `image_2` of three and `image_3` slides down. That keeps
484
+ the numbers lined up with `<Picture N>` in an H3 prompt.
485
+
486
+ **Bypassing a Load Image is different from disconnecting it.** Bypass leaves the
487
+ link in place and delivers nothing, so the socket still looks wired while no
488
+ image arrives. Nothing errors — the empty socket is skipped — but everything
489
+ after it shifts up, so `image_4` becomes picture 2. A prompt written against
490
+ `<Picture 4>` then names an image the model never saw. The console says so when
491
+ it happens:
492
+
493
+ ```
494
+ [Nugget] image_2, image_3 are empty (bypassed?), so the remaining references
495
+ were renumbered: image_1 is picture 1, image_4 is picture 2. A prompt naming a
496
+ higher picture number will not match.
497
+ ```
498
+
499
+ Disconnect rather than bypass if the numbering matters — the sockets then
500
+ compact on the canvas and what you see is what the model gets.
501
+
502
+ Outputs are the batch, a `content_mask` marking the real pixels, the canvas
503
+ width and height, and a `padding_report` naming how much of each image is bars.
504
+ Heavy padding means your references disagree about shape, and the fix is
505
+ upstream — crop them to a common aspect before loading.
506
+
507
+ `max width` / `max height` are a ceiling, never a target. The canvas also never
508
+ exceeds the largest input, so a 16384 ceiling with 64×64 images still gives a
509
+ 64×64 canvas.
510
+
511
+ # Pad image to size (no crop)
512
+
513
+ The same letterboxing for a single image, against an exact size rather than a
514
+ ceiling. Useful in front of any node that demands a fixed resolution.
515
+
516
+ ---
517
+
518
+ ## Credits
519
+
520
+ The workflow these were built for is a modified variant of **mackyb**'s H3
521
+ prompt enhancer. These nodes are independent of it and work in any graph.
522
+
523
+ ## Licence
524
+
525
+ MIT.
custom_nodes/ComfyUI-Nugget/__init__.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ @author: Nugget
3
+ @title: Nugget
4
+ @nickname: Nugget
5
+ @description: Video auto transcription with measured cuts and camera movement, plus image batching that never crops.
6
+
7
+ The tags above are how ComfyUI-Manager labels the badge in a node's top-right
8
+ corner. Without a @nickname it truncates @title to 20 characters; without either
9
+ the badge shows nothing recognisable.
10
+
11
+ Registration is deliberately defensive in two layers. Each node module is
12
+ imported on its own, so a failure in one does not take the others down -- if the
13
+ image nodes cannot load, the transcriber still registers, and vice versa. And if
14
+ everything fails, the pack logs the traceback and exposes no entrypoint, so
15
+ ComfyUI skips it rather than failing to start.
16
+
17
+ **This module must not define ``NODE_CLASS_MAPPINGS``, not even as an empty
18
+ dict.** ComfyUI's loader reads::
19
+
20
+ if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(...) is not None:
21
+ ...
22
+ elif hasattr(module, "comfy_entrypoint"):
23
+ ...
24
+
25
+ so the mere presence of the attribute takes the older branch and
26
+ ``comfy_entrypoint`` is never reached. Every node here is an ``io.ComfyNode``
27
+ subclass with no ``INPUT_TYPES``, so that branch would register broken nodes.
28
+ This is also why the image nodes were converted from the older style rather than
29
+ being merged in as they were: the two registration mechanisms cannot coexist in
30
+ one package.
31
+
32
+ To remove the pack, delete this folder.
33
+ """
34
+
35
+ import importlib
36
+ import logging
37
+ import traceback
38
+
39
+ log = logging.getLogger("Nugget")
40
+
41
+ __version__ = "1.1.0"
42
+
43
+ # Served to the browser for the auto-expanding image sockets. Read separately
44
+ # from node registration, so it works alongside comfy_entrypoint.
45
+ WEB_DIRECTORY = "./web/js"
46
+
47
+ _NODES = []
48
+
49
+ for _module, _label in (("nodes_transcribe", "transcriber"),
50
+ ("nodes_motion", "motion transfer"),
51
+ ("nodes_prompt", "prompt"),
52
+ ("nodes_image", "image")):
53
+ try:
54
+ _mod = importlib.import_module(f".{_module}", __name__)
55
+ _NODES.extend(_mod.NODES)
56
+ except Exception:
57
+ log.error("[Nugget] the %s nodes failed to load and were skipped:\n%s",
58
+ _label, traceback.format_exc())
59
+
60
+ if _NODES:
61
+ log.info("[Nugget] v%s loaded: %s", __version__,
62
+ ", ".join(sorted(c.__name__ for c in _NODES)))
63
+
64
+ from comfy_api.latest import ComfyExtension
65
+
66
+ class NuggetExtension(ComfyExtension):
67
+ async def get_node_list(self):
68
+ return list(_NODES)
69
+
70
+ async def comfy_entrypoint():
71
+ return NuggetExtension()
72
+
73
+ __all__ = ["comfy_entrypoint", "WEB_DIRECTORY"]
74
+ else:
75
+ log.error("[Nugget] no nodes could be loaded; registering nothing")
76
+ __all__ = ["WEB_DIRECTORY"]
custom_nodes/ComfyUI-Nugget/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (3.62 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/camera.cpython-313.pyc ADDED
Binary file (7.22 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/detect.cpython-313.pyc ADDED
Binary file (7.63 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/fingerprint.cpython-313.pyc ADDED
Binary file (8.08 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/interrupt.cpython-313.pyc ADDED
Binary file (1.9 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/llm.cpython-313.pyc ADDED
Binary file (12.1 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/motion_prompts.cpython-313.pyc ADDED
Binary file (20.9 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/nodes_image.cpython-313.pyc ADDED
Binary file (18.4 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/nodes_motion.cpython-313.pyc ADDED
Binary file (24.6 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/nodes_prompt.cpython-313.pyc ADDED
Binary file (19.5 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/nodes_transcribe.cpython-313.pyc ADDED
Binary file (28.7 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/prompts.cpython-313.pyc ADDED
Binary file (16.8 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/recall.cpython-313.pyc ADDED
Binary file (7.23 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/speech.cpython-313.pyc ADDED
Binary file (19.1 kB). View file
 
custom_nodes/ComfyUI-Nugget/__pycache__/video.cpython-313.pyc ADDED
Binary file (32.9 kB). View file
 
custom_nodes/ComfyUI-Nugget/camera.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measuring camera movement, instead of asking the model to guess it.
2
+
3
+ A vision model shown a handful of stills has no reliable way to tell a pan from
4
+ a cut, or a push-in from a subject walking closer. Asking it to describe camera
5
+ movement in the prompt produces confident, frequently wrong answers -- "the
6
+ camera remains stationary" over a crane shot being the usual one.
7
+
8
+ So the movement is measured instead, and the measurement is handed to the model
9
+ as fact.
10
+
11
+ **Translation** comes from phase correlation: multiply the two frames' Fourier
12
+ transforms, normalise to unit magnitude, invert, and the brightest point is the
13
+ shift between them. It is robust to brightness changes and cheap, and it locks
14
+ onto the dominant global motion rather than any one moving object.
15
+
16
+ **Zoom** comes from whether the quadrants diverge. In a push-in the left half
17
+ drifts left and the right half drifts right while the global shift stays near
18
+ zero; in a pan every quadrant moves the same way. Comparing quadrant shifts to
19
+ each other separates the two without any explicit scale search.
20
+
21
+ Note the sign convention throughout: this reports where the *image content*
22
+ went, and the camera moved the opposite way. Content sliding down means the
23
+ camera tilted up.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import numpy as np
29
+
30
+ # Frame edge used for correlation. 64 is too coarse to see a slow drift -- a
31
+ # gentle pan over five seconds rounds to zero pixels -- and 256 costs more than
32
+ # the extra precision is worth.
33
+ SIZE = 128
34
+
35
+ # Fractions of frame width, accumulated across the window.
36
+ PAN_THRESHOLD = 0.18
37
+ ZOOM_THRESHOLD = 0.25
38
+
39
+ # Above this ratio of path length to net displacement, the camera wandered
40
+ # rather than travelled: handheld, or a move that reverses.
41
+ SHAKE_RATIO = 2.5
42
+
43
+
44
+ def _shift(a: np.ndarray, b: np.ndarray) -> tuple[int, int]:
45
+ """Displacement of image content from ``a`` to ``b``, in pixels."""
46
+ fa, fb = np.fft.fft2(a), np.fft.fft2(b)
47
+ cross = fa * np.conj(fb)
48
+ magnitude = np.abs(cross)
49
+ magnitude[magnitude < 1e-9] = 1e-9
50
+ surface = np.fft.ifft2(cross / magnitude).real
51
+
52
+ dy, dx = np.unravel_index(int(np.argmax(surface)), surface.shape)
53
+ height, width = a.shape
54
+ if dy > height // 2:
55
+ dy -= height
56
+ if dx > width // 2:
57
+ dx -= width
58
+ return -int(dx), -int(dy)
59
+
60
+
61
+ def step(a: np.ndarray, b: np.ndarray) -> tuple[float, float, float]:
62
+ """(dx, dy, zoom) between two greyscale frames, in pixels."""
63
+ gx, gy = _shift(a, b)
64
+
65
+ height, width = a.shape
66
+ half_h, half_w = height // 2, width // 2
67
+ corners = {
68
+ "tl": (slice(0, half_h), slice(0, half_w)),
69
+ "tr": (slice(0, half_h), slice(half_w, width)),
70
+ "bl": (slice(half_h, height), slice(0, half_w)),
71
+ "br": (slice(half_h, height), slice(half_w, width)),
72
+ }
73
+ quadrant = {name: _shift(a[box], b[box]) for name, box in corners.items()}
74
+
75
+ # A quadrant shift larger than half its own size means the correlation peak
76
+ # wrapped and the number is meaningless. Large pans do this, and trusting it
77
+ # invents a zoom that is not there.
78
+ limit = min(half_h, half_w) / 2
79
+ if any(abs(x) > limit or abs(y) > limit for x, y in quadrant.values()):
80
+ return float(gx), float(gy), 0.0
81
+
82
+ spread_x = ((quadrant["tr"][0] + quadrant["br"][0])
83
+ - (quadrant["tl"][0] + quadrant["bl"][0])) / 2.0
84
+ spread_y = ((quadrant["bl"][1] + quadrant["br"][1])
85
+ - (quadrant["tl"][1] + quadrant["tr"][1])) / 2.0
86
+ return float(gx), float(gy), (spread_x + spread_y) / 2.0
87
+
88
+
89
+ def summarise(motion, start: float, end: float) -> str:
90
+ """A plain phrase for the camera movement between two times.
91
+
92
+ ``motion`` is [(time, dx, dy, zoom), ...] as produced during the scan.
93
+ """
94
+ window = [m for m in motion if start <= m[0] < end]
95
+ if len(window) < 2:
96
+ return ""
97
+
98
+ net_x = sum(m[1] for m in window) / SIZE
99
+ net_y = sum(m[2] for m in window) / SIZE
100
+ net_z = sum(m[3] for m in window) / SIZE
101
+ path = sum(abs(m[1]) + abs(m[2]) for m in window) / SIZE
102
+ net = abs(net_x) + abs(net_y)
103
+
104
+ parts: list[str] = []
105
+ if abs(net_x) >= PAN_THRESHOLD:
106
+ # Content moving right means the camera swung left.
107
+ parts.append("pans left" if net_x > 0 else "pans right")
108
+ if abs(net_y) >= PAN_THRESHOLD:
109
+ parts.append("tilts up" if net_y > 0 else "tilts down")
110
+ if abs(net_z) >= ZOOM_THRESHOLD:
111
+ parts.append("pushes in" if net_z > 0 else "pulls back")
112
+
113
+ if not parts:
114
+ if path > PAN_THRESHOLD * 2:
115
+ return "the camera is handheld, drifting slightly without going anywhere"
116
+ return "the camera is locked off and does not move"
117
+
118
+ movement = " and ".join(parts) if len(parts) < 3 else \
119
+ ", ".join(parts[:-1]) + " and " + parts[-1]
120
+
121
+ if net > 0 and path / max(net, 1e-6) > SHAKE_RATIO:
122
+ return f"the camera {movement}, unsteadily"
123
+ return f"the camera {movement}"
custom_nodes/ComfyUI-Nugget/detect.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hard-cut detection.
2
+
3
+ Two decisions in here are worth stating, because both are places the obvious
4
+ implementation gets it wrong.
5
+
6
+ **The threshold is adaptive, not fixed.** Three equally hard cuts in one clip can
7
+ score 0.43, 0.17 and 0.12. Any single cutoff either misses the weak one or floods
8
+ on noise, so the bar for each candidate is set from its own neighbourhood:
9
+ ``median + k*MAD`` with a floor and a ratio-over-median term.
10
+
11
+ **Candidates are accepted strongest-first, not earliest-first.** This is the part
12
+ that matters. Walking the candidate list in time order and keeping anything far
13
+ enough from the last accepted cut looks reasonable and is quietly wrong: a weak
14
+ false positive a fraction of a second early claims the slot, and the real cut
15
+ gets rejected for being too close to it. Measured on a real clip, the two
16
+ strongest scores in the whole file (0.427 and 0.634) were both discarded that way
17
+ in favour of noise scoring 0.120. Sorting by score first -- ordinary non-maximum
18
+ suppression -- recovers both exactly.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import statistics
24
+ from dataclasses import dataclass
25
+
26
+ # sensitivity -> (k for median + k*MAD, required ratio over the local median)
27
+ # Both knobs move together: on busy footage MAD often is not the binding
28
+ # constraint, so scaling k alone would make the setting do nothing.
29
+ #
30
+ # The ratios are calibrated for the histogram metric in video.py. On the test
31
+ # footage true cuts land at 8x and 14x the local median while the strongest
32
+ # false positive reaches 3.4x, so "normal" sits at 5.5 -- comfortably between
33
+ # the two. Recalibrate these together with the metric, never separately.
34
+ SENSITIVITY = {
35
+ "low": (6.0, 8.0),
36
+ "normal": (4.0, 5.5),
37
+ "high": (2.5, 3.5),
38
+ }
39
+
40
+ # Absolute floor, so static footage never fires on sensor noise. Kept low on
41
+ # purpose -- a real cut can score under 0.06 in absolute terms while still being
42
+ # 40x its local baseline, and a high floor silently misses those.
43
+ SCORE_FLOOR = 0.015
44
+
45
+ # Rolling window, in frames, for the adaptive threshold.
46
+ WINDOW = 30
47
+
48
+ # Two spikes closer together than this are one event, not two. A camera flash
49
+ # scores high going into white and high coming back out.
50
+ FLASH_WINDOW = 0.35
51
+
52
+ # The very start and end of a clip are held to at least this, whatever min_shot
53
+ # says. A fade to black, a logo sting or a first-frame flicker is a real visual
54
+ # change but not a cut, and at a low min_shot it otherwise produces a half-second
55
+ # shot at the end of every video that has one.
56
+ EDGE_MIN = 1.0
57
+
58
+
59
+ @dataclass
60
+ class Shot:
61
+ index: int
62
+ start: float
63
+ end: float
64
+
65
+ @property
66
+ def duration(self) -> float:
67
+ return max(0.0, self.end - self.start)
68
+
69
+
70
+ def candidates(scores, k: float, ratio: float) -> list[dict]:
71
+ """Local maxima that clear their own neighbourhood's adaptive threshold.
72
+
73
+ ``scores`` is [(time, score), ...] in playback order.
74
+ """
75
+ values = [s for _, s in scores]
76
+ n = len(values)
77
+ if n < 3:
78
+ return []
79
+
80
+ half = WINDOW // 2
81
+ found: list[dict] = []
82
+ for i in range(1, n - 1):
83
+ score = values[i]
84
+ # Local maximum. Without this one cut yields two or three adjacent
85
+ # candidates and every downstream rule has to cope with duplicates.
86
+ if not (score > values[i - 1] and score >= values[i + 1]):
87
+ continue
88
+
89
+ window = values[max(0, i - half):min(n, i + half + 1)]
90
+ median = statistics.median(window)
91
+ mad = statistics.median([abs(x - median) for x in window])
92
+ threshold = max(SCORE_FLOOR, median + k * mad, median * ratio)
93
+
94
+ if score >= threshold:
95
+ found.append({"t": scores[i][0], "score": score, "threshold": threshold})
96
+ return found
97
+
98
+
99
+ def suppress_flashes(found: list[dict]) -> list[dict]:
100
+ """Drop pairs that are one bright event rather than two cuts.
101
+
102
+ Only a genuinely flash-like pair is removed: two spikes inside
103
+ ``FLASH_WINDOW`` whose scores are within 25% of each other. A real cut
104
+ followed by unrelated activity has a much larger score gap, and non-maximum
105
+ suppression below handles that case on its own.
106
+ """
107
+ if len(found) < 2:
108
+ return found
109
+
110
+ drop: set[int] = set()
111
+ for i in range(len(found) - 1):
112
+ a, b = found[i], found[i + 1]
113
+ if b["t"] - a["t"] > FLASH_WINDOW:
114
+ continue
115
+ hi = max(a["score"], b["score"])
116
+ if hi > 0 and abs(a["score"] - b["score"]) / hi <= 0.25:
117
+ drop.add(i)
118
+ drop.add(i + 1)
119
+ return [c for i, c in enumerate(found) if i not in drop]
120
+
121
+
122
+ def suppress_non_maxima(found: list[dict], min_shot: float,
123
+ region_start: float = 0.0,
124
+ region_end: float | None = None) -> list[float]:
125
+ """Keep the strongest candidate in every ``min_shot`` neighbourhood.
126
+
127
+ Accepting in time order instead would let a weak early candidate shadow a
128
+ strong one just after it -- see the module docstring.
129
+ """
130
+ kept: list[dict] = []
131
+ for candidate in sorted(found, key=lambda c: -c["score"]):
132
+ # Both ends of the region count as boundaries, so the first and last
133
+ # shots are held to the same minimum as every other one. Without the
134
+ # head guard, noise in the opening frames produces a sliver shot; without
135
+ # the tail guard, a fade to black at the end does the same. The fade is
136
+ # the common one -- it is a real visual change, just not a cut.
137
+ edge = max(min_shot, EDGE_MIN)
138
+ if candidate["t"] - region_start < edge:
139
+ continue
140
+ if region_end is not None and region_end - candidate["t"] < edge:
141
+ continue
142
+ if any(abs(candidate["t"] - k["t"]) < min_shot for k in kept):
143
+ continue
144
+ kept.append(candidate)
145
+ return sorted(c["t"] for c in kept)
146
+
147
+
148
+ def find_cuts(scores, sensitivity: str = "normal", min_shot: float = 1.0,
149
+ region_start: float = 0.0,
150
+ region_end: float | None = None) -> list[float]:
151
+ """Cut times, in seconds, from a list of [(time, score), ...]."""
152
+ k, ratio = SENSITIVITY.get(sensitivity, SENSITIVITY["normal"])
153
+ found = candidates(scores, k, ratio)
154
+ found = suppress_flashes(found)
155
+ return suppress_non_maxima(found, min_shot, region_start, region_end)
156
+
157
+
158
+ def build_shots(cuts, start: float, end: float) -> list[Shot]:
159
+ """Turn cut times into contiguous shots tiling [start, end].
160
+
161
+ Guaranteed: no gaps, no overlaps, first starts at ``start``, last ends at
162
+ ``end``, and a clip with no cuts is exactly one shot.
163
+ """
164
+ edges = [start]
165
+ edges += [t for t in sorted(cuts) if start < t < end]
166
+ edges.append(end)
167
+ return [Shot(i + 1, edges[i], edges[i + 1]) for i in range(len(edges) - 1)]
custom_nodes/ComfyUI-Nugget/fingerprint.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deciding whether this is the same request as last time.
2
+
3
+ ComfyUI's default cache key for a node is its input signature *including every
4
+ ancestor node's inputs*. That is stricter than it sounds. It answers "was the
5
+ graph upstream of here identical?", not "is the content arriving here
6
+ identical?", and the two come apart in exactly the cases that matter:
7
+
8
+ - bypassing an upstream node rewires the links the frontend sends, so the
9
+ signature changes even though nothing reaches this node
10
+ - connecting or disconnecting a socket changes it for the same reason
11
+ - any ancestor that re-runs for its own reasons drags this node with it
12
+
13
+ Each of those makes a generation run again and produce text that is either
14
+ identical (wasted minutes) or, with sampling on, gratuitously different.
15
+
16
+ So the node fingerprints what it actually received instead: the prompt text,
17
+ every setting, the seed, which encoder is loaded, and the pixels on each
18
+ connected image socket. Same content, same fingerprint, no matter what the graph
19
+ around it did.
20
+
21
+ Images are sampled rather than hashed whole. A 9-image set at 2048px is hundreds
22
+ of megabytes and reading all of it on every queue would cost more than it saves;
23
+ a few thousand evenly spread elements plus the exact shape and dtype separates
24
+ any two images anyone would actually put in a workflow. The sample is taken with
25
+ a fixed stride, so it is the same sample every time for the same tensor.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import logging
32
+ from collections import OrderedDict
33
+
34
+ log = logging.getLogger("Nugget")
35
+
36
+ # Elements read per image. 4096 spread across the whole tensor -- not a corner,
37
+ # which would miss two frames differing only in the middle.
38
+ SAMPLE = 4096
39
+
40
+ # Text only, a few kilobytes an entry, so this can never be the thing that
41
+ # triggers a memory eviction.
42
+ MAX_ENTRIES = 8
43
+
44
+ _STORE: "OrderedDict[str, str]" = OrderedDict()
45
+
46
+
47
+ def tensor_id(value) -> str:
48
+ """A content fingerprint for one image tensor.
49
+
50
+ Shape and dtype first, because two different images almost always differ
51
+ there and it costs nothing. The sampled bytes are taken through numpy rather
52
+ than formatted as text: float repr is lossy and two visibly different frames
53
+ could round to the same string.
54
+ """
55
+ try:
56
+ shape = tuple(int(n) for n in value.shape)
57
+ head = f"{shape}|{value.dtype}"
58
+ flat = value.detach().reshape(-1)
59
+ count = int(flat.numel())
60
+ if count == 0:
61
+ return head + "|empty"
62
+ stride = max(1, count // SAMPLE)
63
+ sample = flat[::stride][:SAMPLE].to("cpu").contiguous()
64
+ raw = sample.numpy().tobytes()
65
+ return head + "|" + hashlib.blake2b(raw, digest_size=16).hexdigest()
66
+ except Exception as exc:
67
+ # Never let fingerprinting be the thing that breaks a run. An unknown
68
+ # object falls back to "assume it changed", which costs a regeneration
69
+ # and is the safe direction to be wrong in.
70
+ log.debug("[Nugget] could not fingerprint an image (%s)", exc)
71
+ return "unfingerprintable"
72
+
73
+
74
+ def is_image(value) -> bool:
75
+ """Is this an actual image tensor, or bypass debris?
76
+
77
+ Bypass hands a node whatever was on the bypassed node's inputs, so an image
78
+ socket can receive a string, a number, or nothing at all. Anything without a
79
+ shape is not an image and must not be treated as one.
80
+ """
81
+ if value is None:
82
+ return False
83
+ shape = getattr(value, "shape", None)
84
+ if shape is None or len(shape) < 3:
85
+ return False
86
+ return hasattr(value, "detach") and hasattr(value, "reshape")
87
+
88
+
89
+ def images_id(images: dict, limit: int) -> str:
90
+ """Every connected image socket, in slot order.
91
+
92
+ Slot order is part of the identity, not just the set of images: the prompt
93
+ can name picture 2, so the same images in a different order is a different
94
+ request.
95
+ """
96
+ parts = []
97
+ for i in range(1, limit + 1):
98
+ value = images.get(f"image_{i}")
99
+ if is_image(value):
100
+ parts.append(f"{i}:{tensor_id(value)}")
101
+ return ";".join(parts) if parts else "none"
102
+
103
+
104
+ def model_id(clip) -> str:
105
+ """Enough to tell one loaded encoder from another.
106
+
107
+ Same approach as recall.py: a different model writes different text from the
108
+ same input, so it belongs in the key. Falling back to object identity is
109
+ over-cautious rather than wrong -- it produces a miss where a hit was
110
+ possible, never a hit where the model has changed.
111
+ """
112
+ for path in (("tokenizer", "clip_name"), ("cond_stage_model", "__class__")):
113
+ obj = clip
114
+ for step in path:
115
+ obj = getattr(obj, step, None)
116
+ if obj is None:
117
+ break
118
+ if isinstance(obj, str):
119
+ return obj
120
+ if obj is not None and hasattr(obj, "__name__"):
121
+ return str(obj.__name__)
122
+ return str(id(clip))
123
+
124
+
125
+ def key(clip, settings: dict, images: dict, limit: int) -> str:
126
+ """The whole request as one hex string.
127
+
128
+ A string on purpose. ComfyUI stores whatever ``fingerprint_inputs`` returns
129
+ in the workflow it writes into saved images, and serialises that as JSON, so
130
+ returning a tuple or a custom object either fails to save or silently
131
+ disables caching.
132
+ """
133
+ body = "|".join([
134
+ model_id(clip),
135
+ images_id(images, limit),
136
+ repr(sorted((str(k), str(v)) for k, v in settings.items())),
137
+ ])
138
+ return hashlib.blake2b(body.encode("utf-8", "replace"),
139
+ digest_size=20).hexdigest()
140
+
141
+
142
+ def get(cache_key: str):
143
+ if not cache_key or cache_key not in _STORE:
144
+ return None
145
+ _STORE.move_to_end(cache_key)
146
+ return _STORE[cache_key]
147
+
148
+
149
+ def put(cache_key: str, value: str) -> None:
150
+ if not cache_key:
151
+ return
152
+ _STORE[cache_key] = value
153
+ while len(_STORE) > MAX_ENTRIES:
154
+ _STORE.popitem(last=False)
155
+
156
+
157
+ def clear() -> None:
158
+ _STORE.clear()
custom_nodes/ComfyUI-Nugget/install.bat ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM Double-click this to install what Nugget needs.
3
+ REM It finds the Python that ComfyUI uses on its own, so it does not matter
4
+ REM which Python is on your PATH.
5
+
6
+ cd /d "%~dp0"
7
+
8
+ REM Prefer ComfyUI's bundled interpreter directly if it is where we expect.
9
+ set "PY="
10
+ if exist "..\..\..\python_embeded\python.exe" set "PY=..\..\..\python_embeded\python.exe"
11
+ if exist "..\..\python_embeded\python.exe" set "PY=..\..\python_embeded\python.exe"
12
+
13
+ if not defined PY (
14
+ where python >nul 2>nul
15
+ if errorlevel 1 (
16
+ echo.
17
+ echo No Python found on your PATH, and no portable ComfyUI above this folder.
18
+ echo Open the folder containing ComfyUI's python.exe and run:
19
+ echo python.exe "%~dp0install.py"
20
+ echo.
21
+ pause
22
+ exit /b 1
23
+ )
24
+ set "PY=python"
25
+ )
26
+
27
+ "%PY%" install.py %*
28
+
29
+ echo.
30
+ pause
custom_nodes/ComfyUI-Nugget/install.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Install and verify everything this node needs.
3
+
4
+ python install.py # check, then offer to install what is missing
5
+ python install.py --yes # install without asking
6
+ python install.py --check # report only, change nothing
7
+
8
+ Run it with whatever Python you have. It finds the one ComfyUI actually uses and
9
+ re-runs itself there, because installing into the wrong interpreter is the single
10
+ most common reason faster-whisper still reports as missing after you installed
11
+ it: ``pip install`` hits your system Python while ComfyUI runs its own bundled or
12
+ virtual-environment one, and the two never see each other's packages.
13
+
14
+ Two things this deliberately does not do.
15
+
16
+ It does not capture pip's output. pip has a perfectly good progress bar, and
17
+ hiding it makes a 700 MB cuDNN download look like a hang.
18
+
19
+ It does not assume an NVIDIA card. ``torch.cuda.is_available()`` returns True on
20
+ ROCm builds too, so checking it alone would cheerfully install CUDA wheels onto a
21
+ Radeon. The backend is worked out from ``torch.version.hip`` and
22
+ ``torch.version.cuda``, and the CUDA major version chooses between the cu11 and
23
+ cu12 wheels rather than cu12 being hardcoded.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import os
30
+ import platform
31
+ import subprocess
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ HERE = Path(__file__).resolve().parent
37
+
38
+ # Where ComfyUI's interpreter lives, relative to a root containing main.py.
39
+ CANDIDATES = [
40
+ Path("python_embeded") / "python.exe", # Windows portable
41
+ Path("..") / "python_embeded" / "python.exe", # portable, node inside ComfyUI/
42
+ Path("venv") / "Scripts" / "python.exe",
43
+ Path(".venv") / "Scripts" / "python.exe",
44
+ Path("venv") / "bin" / "python",
45
+ Path(".venv") / "bin" / "python",
46
+ Path("venv") / "bin" / "python3",
47
+ Path(".venv") / "bin" / "python3",
48
+ ]
49
+
50
+ GIB = 1024 ** 3
51
+
52
+ RESET, BOLD, RED, GREEN, YELLOW = "\033[0m", "\033[1m", "\033[31m", "\033[32m", "\033[33m"
53
+ if os.name == "nt":
54
+ try: # Windows 10+ understands ANSI if asked
55
+ import ctypes
56
+ ctypes.windll.kernel32.SetConsoleMode(
57
+ ctypes.windll.kernel32.GetStdHandle(-11), 7)
58
+ except Exception:
59
+ RESET = BOLD = RED = GREEN = YELLOW = ""
60
+
61
+
62
+ def say(text: str = "") -> None:
63
+ print(text, flush=True)
64
+
65
+
66
+ def state(label: str, ok, detail: str = "") -> None:
67
+ mark = (f"{GREEN}ok{RESET}" if ok is True
68
+ else f"{YELLOW}--{RESET}" if ok is None
69
+ else f"{RED}MISSING{RESET}")
70
+ plain = "ok" if ok is True else "--" if ok is None else "MISSING"
71
+ say(f" {label:<26} {mark}{' ' * max(1, 10 - len(plain))} {detail}")
72
+
73
+
74
+ def installed(module: str) -> bool:
75
+ try:
76
+ __import__(module)
77
+ return True
78
+ except ImportError:
79
+ return False
80
+
81
+
82
+ class Machine:
83
+ """What this computer can actually do, worked out once."""
84
+
85
+ def __init__(self):
86
+ self.os = platform.system()
87
+ self.arch = platform.machine()
88
+ self.python = "%d.%d.%d" % sys.version_info[:3]
89
+ self.backend = "cpu" # cuda | rocm | mps | cpu
90
+ self.cuda_major = 0
91
+ self.gpu = ""
92
+ self.vram = 0
93
+ self.torch = False
94
+ self._probe()
95
+
96
+ def _probe(self) -> None:
97
+ try:
98
+ import torch
99
+ except ImportError:
100
+ return
101
+ self.torch = True
102
+
103
+ # ROCm builds report cuda.is_available() as True. Check HIP first, or we
104
+ # would install NVIDIA wheels onto an AMD card.
105
+ if getattr(torch.version, "hip", None):
106
+ self.backend = "rocm"
107
+ try:
108
+ self.gpu = torch.cuda.get_device_name(0)
109
+ except Exception:
110
+ pass
111
+ return
112
+
113
+ if getattr(torch, "cuda", None) and torch.cuda.is_available():
114
+ self.backend = "cuda"
115
+ version = getattr(torch.version, "cuda", "") or ""
116
+ self.cuda_major = int(version.split(".")[0]) if version[:1].isdigit() else 12
117
+ try:
118
+ self.gpu = torch.cuda.get_device_name(0)
119
+ self.vram = torch.cuda.mem_get_info()[1]
120
+ except Exception:
121
+ pass
122
+ return
123
+
124
+ backends = getattr(torch, "backends", None)
125
+ if getattr(backends, "mps", None) and torch.backends.mps.is_available():
126
+ self.backend = "mps"
127
+
128
+ @property
129
+ def gpu_speech(self) -> bool:
130
+ """Can the speech model use the GPU at all?
131
+
132
+ Only CUDA. faster-whisper runs on CTranslate2, which has no ROCm backend
133
+ and no Metal backend, so AMD and Apple Silicon transcribe on the CPU
134
+ however much VRAM is sitting idle.
135
+ """
136
+ return self.backend == "cuda"
137
+
138
+ def wheels(self) -> list[str]:
139
+ if not self.gpu_speech:
140
+ return []
141
+ suffix = "cu11" if self.cuda_major == 11 else "cu12"
142
+ return [f"nvidia-cublas-{suffix}", f"nvidia-cudnn-{suffix}"]
143
+
144
+ def suggested_model(self) -> str:
145
+ if not self.gpu_speech:
146
+ return "large-v3 on CPU is slow -- medium or small is a better trade"
147
+ # 10 GB, not 8: the text encoder is the other tenant, and an 8 GB card
148
+ # holding Gemma has nothing like 3 GB spare for large-v3.
149
+ if self.vram and self.vram < 10 * GIB:
150
+ return "limited VRAM: prefer medium over large-v3"
151
+ return "large-v3 should fit alongside your text encoder"
152
+
153
+ def report(self) -> None:
154
+ say(" this machine")
155
+ state("os / arch", None, f"{self.os} {self.arch}")
156
+ state("python", None, self.python)
157
+ if not self.torch:
158
+ state("torch", False, "ComfyUI cannot run without it")
159
+ return
160
+
161
+ label = {"cuda": f"CUDA {self.cuda_major}.x",
162
+ "rocm": "ROCm / HIP",
163
+ "mps": "Apple Silicon (Metal)",
164
+ "cpu": "no GPU"}[self.backend]
165
+ detail = self.gpu
166
+ if self.vram:
167
+ detail += f" -- {self.vram / GIB:.0f} GB"
168
+ state("torch backend", None, f"{label}{' ' + detail if detail else ''}")
169
+
170
+ where = {"rocm": "CPU -- CTranslate2 has no ROCm backend",
171
+ "mps": "CPU -- CTranslate2 has no Metal backend",
172
+ "cuda": "GPU, if the CUDA wheels below are present",
173
+ "cpu": "CPU"}[self.backend]
174
+ state("speech device", None, where)
175
+
176
+
177
+ def comfy_python() -> Path | None:
178
+ for parent in [HERE] + list(HERE.parents)[:5]:
179
+ if not (parent / "main.py").exists():
180
+ continue
181
+ for relative in CANDIDATES:
182
+ candidate = (parent / relative).resolve()
183
+ if candidate.exists():
184
+ return candidate
185
+ return None # system or conda install
186
+ return None
187
+
188
+
189
+ def externally_managed() -> bool:
190
+ """Is this a distro Python that refuses pip installs? (PEP 668)
191
+
192
+ Debian, Ubuntu and Fedora ship a marker file that makes pip refuse to touch
193
+ the system site-packages. ComfyUI's own bundled interpreter and any venv are
194
+ unaffected, but anyone running ComfyUI on their distro Python hits it, and
195
+ pip's suggestion -- make a virtualenv -- is useless here, because the package
196
+ has to land in the interpreter ComfyUI already runs.
197
+ """
198
+ import sysconfig
199
+
200
+ if sys.prefix != sys.base_prefix: # inside a venv; marker n/a
201
+ return False
202
+ return (Path(sysconfig.get_path("stdlib")) / "EXTERNALLY-MANAGED").exists()
203
+
204
+
205
+ def pip_install(packages: list[str]) -> bool:
206
+ """Run pip with the console attached, so its own progress bar shows."""
207
+ flags = ["--progress-bar", "on"]
208
+ if externally_managed():
209
+ say()
210
+ say(f" {YELLOW}note{RESET} this Python is distro-managed (PEP 668), so pip "
211
+ f"would normally refuse.")
212
+ say(" Adding --break-system-packages: the package has to go into the")
213
+ say(" interpreter ComfyUI runs, and a virtualenv would not be that one.")
214
+ flags.append("--break-system-packages")
215
+
216
+ say()
217
+ say(f" {BOLD}pip install {' '.join(packages)}{RESET}")
218
+ say()
219
+ started = time.time()
220
+ code = subprocess.call([sys.executable, "-m", "pip", "install",
221
+ *flags, *packages])
222
+ say()
223
+ if code == 0:
224
+ say(f" {GREEN}installed{RESET} in {time.time() - started:.0f}s")
225
+ return True
226
+ say(f" {RED}pip exited with code {code}{RESET} -- its error is above")
227
+ return False
228
+
229
+
230
+ # CTranslate2, which faster-whisper is built on, is compiled against CUDA 12 and
231
+ # asks for these by name. That is independent of the CUDA your torch uses: a
232
+ # torch built for CUDA 13 ships cublas64_13.dll, which is the wrong file however
233
+ # new it is. Checking for torch's version instead of this one reports success on
234
+ # a machine that then fails at run time.
235
+ CT2_DLLS = ["cublas64_12.dll", "cudnn64_9.dll"]
236
+
237
+
238
+ def cuda_dll_status(machine: "Machine") -> tuple[bool, str]:
239
+ """On Windows, are CTranslate2's CUDA DLLs present AND loadable?
240
+
241
+ Importing ``nvidia.cublas`` only proves the package is unpacked. The DLL sits
242
+ under ``site-packages/nvidia/...``, which Python has not searched since 3.8,
243
+ so it can be installed, importable, and still fail to load -- which is
244
+ exactly the "cublas64_12.dll is not found or cannot be loaded" report.
245
+ """
246
+ if os.name != "nt" or not machine.gpu_speech:
247
+ return True, ""
248
+
249
+ import ctypes
250
+ import site
251
+
252
+ bases = {b for b in (*site.getsitepackages(), site.getusersitepackages()) if b}
253
+
254
+ # Walk the nvidia packages rather than guessing at bin/ or lib/: the wheel
255
+ # layout has moved between releases, and the file is what matters.
256
+ folders: list[str] = []
257
+ for base in bases:
258
+ root = os.path.join(base, "nvidia")
259
+ if os.path.isdir(root):
260
+ for where, _, files in os.walk(root):
261
+ if any(f.lower().endswith(".dll") for f in files):
262
+ folders.append(where)
263
+ torch_lib = os.path.join(base, "torch", "lib")
264
+ if os.path.isdir(torch_lib):
265
+ folders.append(torch_lib)
266
+
267
+ for folder in folders:
268
+ try:
269
+ os.add_dll_directory(folder)
270
+ os.environ["PATH"] = folder + os.pathsep + os.environ.get("PATH", "")
271
+ except (OSError, AttributeError):
272
+ pass
273
+
274
+ missing, loaded = [], []
275
+ for name in CT2_DLLS:
276
+ where = next((f for f in folders
277
+ if os.path.exists(os.path.join(f, name))), None)
278
+ if where is None:
279
+ missing.append(name)
280
+ continue
281
+ try:
282
+ ctypes.CDLL(os.path.join(where, name))
283
+ loaded.append(name)
284
+ except OSError:
285
+ missing.append(f"{name} (present but will not load)")
286
+
287
+ if missing:
288
+ note = ", ".join(missing)
289
+ if machine.cuda_major and machine.cuda_major != 12:
290
+ note += (f" -- your torch is CUDA {machine.cuda_major}.x, but "
291
+ f"faster-whisper needs the CUDA 12 runtime alongside it")
292
+ return False, note
293
+ return True, f"{', '.join(loaded)} loadable"
294
+
295
+
296
+ def explain(exc: Exception) -> str:
297
+ """Turn a library traceback into something worth reading.
298
+
299
+ The three failures that actually happen -- a blocked model download, a
300
+ missing CUDA wheel, and no disk space -- all surface as opaque errors from
301
+ three different libraries, so they get named here rather than left as-is.
302
+ """
303
+ text = f"{type(exc).__name__}: {exc}".strip().splitlines()[0]
304
+ low = text.lower()
305
+ if any(word in low for word in ("hfhub", "huggingface", "403", "connection",
306
+ "timed out", "getaddrinfo", "ssl", "proxy")):
307
+ return ("could not download the model from huggingface.co -- check your "
308
+ "connection, proxy or firewall")
309
+ if "cudnn" in low or "cublas" in low:
310
+ return (f"CUDA library not loadable -- {text[:80]}. On Windows this is "
311
+ f"usually the DLL search path, not a missing package.")
312
+ if "no space" in low or "disk" in low:
313
+ return "not enough disk space for the model cache"
314
+ return text[:160]
315
+
316
+
317
+ def verify_speech(machine: Machine) -> tuple[bool, str]:
318
+ """Build a real model and transcribe silence.
319
+
320
+ A successful import proves nothing: CTranslate2 loads cuDNN and cuBLAS only
321
+ when a model is constructed, so a missing wheel throws here and nowhere
322
+ earlier.
323
+ """
324
+ try:
325
+ import numpy as np
326
+ from faster_whisper import WhisperModel
327
+ except ImportError as exc:
328
+ return False, f"not importable: {exc}"
329
+
330
+ import tempfile
331
+ import wave
332
+
333
+ handle, path = tempfile.mkstemp(suffix=".wav")
334
+ os.close(handle)
335
+
336
+ # Same DLL registration the node does at run time, so this test exercises
337
+ # the real path rather than a luckier one.
338
+ cuda_dll_status(machine)
339
+
340
+ def attempt(device: str, compute: str) -> None:
341
+ model = WhisperModel("tiny", device=device, compute_type=compute)
342
+ list(model.transcribe(path, vad_filter=True)[0])
343
+
344
+ try:
345
+ with wave.open(path, "wb") as out:
346
+ out.setnchannels(1)
347
+ out.setsampwidth(2)
348
+ out.setframerate(16000)
349
+ out.writeframes(np.zeros(16000, dtype=np.int16).tobytes())
350
+
351
+ if machine.gpu_speech:
352
+ try:
353
+ attempt("cuda", "float16")
354
+ return True, "GPU (cuda / float16) works"
355
+ except Exception as exc:
356
+ say(f" {YELLOW}GPU attempt failed:{RESET} {explain(exc)}")
357
+ say(" falling back to CPU...")
358
+ try:
359
+ attempt("cpu", "int8")
360
+ return True, "CPU works, GPU does not"
361
+ except Exception as inner:
362
+ return False, f"neither device works -- {explain(inner)}"
363
+
364
+ attempt("cpu", "int8")
365
+ return True, "CPU (int8) works"
366
+ except Exception as exc:
367
+ return False, explain(exc)
368
+ finally:
369
+ try:
370
+ os.remove(path)
371
+ except OSError:
372
+ pass
373
+
374
+
375
+ def main() -> int:
376
+ parser = argparse.ArgumentParser(add_help=True)
377
+ parser.add_argument("--yes", action="store_true", help="install without asking")
378
+ parser.add_argument("--check", action="store_true", help="report only")
379
+ parser.add_argument("--no-redirect", action="store_true", help=argparse.SUPPRESS)
380
+ options = parser.parse_args()
381
+
382
+ say()
383
+ say(f" {BOLD}Nugget - install{RESET}")
384
+ say(" " + "-" * 64)
385
+
386
+ target = comfy_python()
387
+ if target and not options.no_redirect and \
388
+ target.resolve() != Path(sys.executable).resolve():
389
+ say()
390
+ say(f" you ran this with: {sys.executable}")
391
+ say(f" ComfyUI actually uses: {target}")
392
+ say()
393
+ say(" re-running there, so packages land where ComfyUI can see them")
394
+ say()
395
+ return subprocess.call([str(target), str(Path(__file__).resolve()),
396
+ "--no-redirect", *sys.argv[1:]])
397
+
398
+ say()
399
+ say(f" interpreter: {sys.executable}")
400
+ if not target:
401
+ say(f" {YELLOW}no ComfyUI root found above this folder{RESET} -- if ComfyUI")
402
+ say(" runs on a different Python, run this script with that one")
403
+ say()
404
+
405
+ machine = Machine()
406
+ machine.report()
407
+
408
+ say()
409
+ say(" required (all ship with ComfyUI)")
410
+ for module, label in (("av", "PyAV"), ("numpy", "numpy"), ("PIL", "Pillow")):
411
+ state(label, installed(module))
412
+
413
+ say()
414
+ say(" speech")
415
+ wanted: list[str] = []
416
+
417
+ has_speech = installed("faster_whisper")
418
+ state("faster-whisper", has_speech)
419
+ if not has_speech:
420
+ wanted.append("faster-whisper")
421
+
422
+ for package in machine.wheels():
423
+ module = "nvidia." + package.split("-")[1]
424
+ present = installed(module)
425
+ state(package, present, "needed for GPU speech")
426
+ if not present:
427
+ wanted.append(package)
428
+ if machine.torch and not machine.gpu_speech:
429
+ state("cuda wheels", None, "not applicable on this backend")
430
+
431
+ if machine.gpu_speech and os.name == "nt":
432
+ ok, detail = cuda_dll_status(machine)
433
+ state("cuda dll loadable", ok, detail)
434
+ if not ok and "missing" in detail:
435
+ for package in machine.wheels():
436
+ if package not in wanted:
437
+ wanted.append(package)
438
+
439
+ state("model choice", None, machine.suggested_model())
440
+
441
+ if options.check:
442
+ say()
443
+ say(f" {YELLOW}missing:{RESET} " + ", ".join(wanted) if wanted
444
+ else f" {GREEN}nothing missing{RESET}")
445
+ say()
446
+ return 0
447
+
448
+ if wanted:
449
+ say()
450
+ say(" to install: " + ", ".join(wanted))
451
+ if machine.gpu_speech and len(wanted) > 1:
452
+ say(f" {YELLOW}heads up{RESET} the cuDNN wheel is around 700 MB, so "
453
+ f"this can take a few minutes")
454
+ if not options.yes:
455
+ try:
456
+ answer = input(" install now? [Y/n] ").strip().lower()
457
+ except EOFError:
458
+ answer = "y"
459
+ if answer and not answer.startswith("y"):
460
+ say("\n nothing installed.\n")
461
+ return 1
462
+ if not pip_install(wanted):
463
+ say()
464
+ return 1
465
+ else:
466
+ say()
467
+ say(" nothing to install")
468
+
469
+ say()
470
+ say(" verifying -- builds a real model and transcribes a second of silence")
471
+ say(" (downloads about 75 MB the first time; this is not a hang)")
472
+ say()
473
+ started = time.time()
474
+ ok, detail = verify_speech(machine)
475
+ state("faster-whisper", ok, f"{detail} [{time.time() - started:.0f}s]")
476
+
477
+ say()
478
+ if ok:
479
+ say(f" {GREEN}Ready.{RESET} Restart ComfyUI so it picks up the new packages.")
480
+ else:
481
+ say(f" {RED}Speech is still not working.{RESET}")
482
+ say(" The node will still describe the video; there will be no transcript.")
483
+ if machine.backend == "rocm":
484
+ say(" On ROCm, GPU speech is expected to fail -- CPU should still work.")
485
+ elif "huggingface" in detail.lower():
486
+ say(" The packages are fine; only the model download failed. Try again")
487
+ say(" on a different network, or set HF_ENDPOINT to a mirror.")
488
+ elif "cudnn" in detail.lower() or "cublas" in detail.lower():
489
+ say(" That error names cuDNN or cuBLAS: the wheels are missing or do")
490
+ say(f" not match your CUDA {machine.cuda_major}.x torch build.")
491
+ say()
492
+ return 0 if ok else 1
493
+
494
+
495
+ if __name__ == "__main__":
496
+ sys.exit(main())
custom_nodes/ComfyUI-Nugget/interrupt.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Letting Cancel actually cancel.
2
+
3
+ ComfyUI signals a cancellation by setting a flag; nothing is forcibly killed. A
4
+ node only stops when it next asks whether it should, so any loop that runs for
5
+ more than a second or two has to ask, or Cancel appears to do nothing until the
6
+ loop finishes on its own.
7
+
8
+ The long ones here are transcription -- large-v3 on the CPU can spend well over a
9
+ minute on a ten-second clip -- and, to a lesser degree, decoding frames. Checking
10
+ only between passes, as this used to, meant a cancel during transcription waited
11
+ out the whole thing.
12
+
13
+ The one place that cannot be interrupted is inside a single ``clip.generate()``
14
+ call: it is one blocking call into ComfyUI and there is no hook to poll. Those
15
+ run a few seconds each, so the worst case is the tail of one generation rather
16
+ than the tail of the whole run.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ try: # pragma: no cover - depends on the host
22
+ from comfy.model_management import (
23
+ InterruptProcessingException,
24
+ throw_exception_if_processing_interrupted,
25
+ )
26
+ except Exception: # running outside ComfyUI, e.g. the test suite
27
+ class InterruptProcessingException(Exception):
28
+ """Stand-in so ``except Interrupted`` is always valid."""
29
+
30
+ def throw_exception_if_processing_interrupted():
31
+ return None
32
+
33
+
34
+ Interrupted = InterruptProcessingException
35
+
36
+
37
+ def check() -> None:
38
+ """Raise if the user has pressed Cancel. Cheap enough to call in a loop."""
39
+ throw_exception_if_processing_interrupted()
custom_nodes/ComfyUI-Nugget/llm.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Talking to the text encoder ComfyUI already loaded.
2
+
3
+ ``TextGenerate`` in ``comfy_extras/nodes_textgen.py`` is three calls -- tokenize,
4
+ generate, decode -- and this does the same thing without going through the node,
5
+ so frames can be handed over at times we choose.
6
+
7
+ Two details are load-bearing.
8
+
9
+ **Frames go in as ``image``, never ``video``.** Core's ``video`` input carries the
10
+ tooltip "Assumed to be 24 FPS; subsampled to 1 FPS internally". One frame per
11
+ second cannot tell a cut from a camera move, which is how a three-shot clip gets
12
+ described as twelve shots. Passing an image batch bypasses that entirely.
13
+
14
+ **The output needs cleaning.** Gemma leaks its own turn and channel markers into
15
+ the text. Core knows this -- ``TextGenerateLTX2Prompt`` strips them with a regex
16
+ before returning -- but the plain ``TextGenerate`` node does not, which is why
17
+ raw use of it produces pages of ``<think></think>`` and ``<|image>``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import re
24
+
25
+ log = logging.getLogger("Nugget")
26
+
27
+ # Gemma 4 uses <|turn>role ... <turn|> with <|channel>final and <|image>.
28
+ # Gemma 3 uses <start_of_turn> ... <end_of_turn> with <image_soft_token>.
29
+ # Qwen3-VL uses <|im_start|> ... <|im_end|> with <|vision_start|> and friends.
30
+ # All three can be wired into this node, so strip all three rather than sniffing
31
+ # the tokenizer name and guessing wrong.
32
+ _THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL)
33
+ _MARKERS = re.compile(
34
+ r"</?think>"
35
+ r"|<\|think\|>"
36
+ r"|<\|channel>\w*\n?|<channel\|>"
37
+ r"|<\|turn>\w*\n?|<turn\|>"
38
+ r"|<\|image\|?>|<image\|>|<image_soft_token>"
39
+ r"|<start_of_turn>\w*\n?|<end_of_turn>"
40
+ r"|<\|im_start\|>\w*\n?|<\|im_end\|>"
41
+ r"|<\|vision_start\|>|<\|vision_end\|>|<\|vision_pad\|>"
42
+ r"|<\|image_pad\|>|<\|video_pad\|>|<\|endoftext\|>"
43
+ r"|<\|?eos\|?>|<pad>"
44
+ )
45
+
46
+
47
+ class LLMError(RuntimeError):
48
+ pass
49
+
50
+
51
+ # Inner text-encoder classes that are image-generation encoders, not language
52
+ # models. Wiring one in is the most common setup mistake, because "CLIP" in
53
+ # ComfyUI is a generic label for a text-encoder slot and the loader accepts any
54
+ # of them without complaint.
55
+ _NOT_LANGUAGE_MODELS = {
56
+ "SD1ClipModel": "a Stable Diffusion 1.x text encoder",
57
+ "SDXLClipModel": "a Stable Diffusion XL text encoder",
58
+ "SD2ClipModel": "a Stable Diffusion 2.x text encoder",
59
+ "SD3ClipModel": "a Stable Diffusion 3 text encoder",
60
+ "FluxClipModel": "a Flux text encoder",
61
+ "T5XXLModel": "a T5 text encoder",
62
+ "CLIPTextModel": "a plain CLIP text encoder",
63
+ }
64
+
65
+
66
+ def check(clip) -> None:
67
+ """Fail early and legibly if this CLIP cannot generate text.
68
+
69
+ The outer ComfyUI CLIP wrapper exposes ``generate`` whatever is inside it, so
70
+ checking the wrapper alone passes for a Stable Diffusion encoder and then
71
+ dies hundreds of lines later with ``'SD1ClipModel' object has no attribute
72
+ 'generate'``. The inner model is what has to be interrogated.
73
+ """
74
+ if clip is None:
75
+ raise LLMError("No CLIP connected. Wire a Load CLIP node loading a "
76
+ "Gemma text encoder into the `clip` input.")
77
+
78
+ for method in ("tokenize", "generate", "decode"):
79
+ if not hasattr(clip, method):
80
+ raise LLMError(
81
+ "This ComfyUI build cannot generate text from a CLIP "
82
+ f"(no `{method}` method). Text generation arrived in ComfyUI "
83
+ "v0.15.0 -- update ComfyUI.")
84
+
85
+ inner = getattr(clip, "cond_stage_model", None)
86
+ if inner is None:
87
+ return # unknown shape; let the call speak for itself
88
+
89
+ name = type(inner).__name__
90
+ if not hasattr(inner, "generate"):
91
+ # Substring match, not exact: ComfyUI subclasses these and the useful
92
+ # part of the name survives in the subclass.
93
+ bare = name.lstrip("_")
94
+ what = next((label for key, label in _NOT_LANGUAGE_MODELS.items()
95
+ if key.lower() in bare.lower()), f"`{name}`")
96
+ raise LLMError(
97
+ f"The Load CLIP node is loading {what}, which cannot generate text. "
98
+ "This node needs a language model: a Gemma 4 encoder such as "
99
+ "gemma4_12b_int8_convrot.safetensors, or a Qwen3-VL encoder such as "
100
+ "qwen3vl_8b_fp8_scaled.safetensors. Put it in "
101
+ "ComfyUI/models/text_encoders/ and select that file in Load CLIP. "
102
+ "ComfyUI works out the type from the file, so the type dropdown "
103
+ "rarely matters -- picking the wrong file is what does.")
104
+
105
+
106
+ def tidy(text: str) -> str:
107
+ """Strip leaked control tokens and any reasoning trace."""
108
+ if not text:
109
+ return ""
110
+ text = _THINK_BLOCK.sub("", text)
111
+ if "</think>" in text:
112
+ # Reasoning that was cut off at the start: keep what follows the close.
113
+ text = text.rsplit("</think>", 1)[-1]
114
+ elif "<think>" in text:
115
+ # An open tag with no close means generation ran out of budget while
116
+ # still deliberating, so everything after it is reasoning and there is no
117
+ # answer. Returning the trace would hand a half-finished thought
118
+ # downstream as though it were the description.
119
+ text = text.split("<think>", 1)[0]
120
+ text = _MARKERS.sub("", text)
121
+ # Collapse the blank-line runs the stripping leaves behind.
122
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
123
+
124
+
125
+ # Roughly how many characters a token is worth in English prose. Only used to
126
+ # tell "stopped because the budget ran out" from "stopped because it was done".
127
+ CHARS_PER_TOKEN = 3.7
128
+
129
+
130
+ def looks_truncated(text: str, budget: int = 0) -> bool:
131
+ """Did generation stop because it ran out of budget rather than finished?
132
+
133
+ There is no truncation flag to read, so the tell is text that ends
134
+ mid-sentence. That alone is not enough: a model can emit a stop token after
135
+ a fragment, and blaming the budget then sends the reader off to raise a
136
+ setting that was never the constraint. So when the budget is known, the
137
+ reply also has to be long enough to have plausibly hit it.
138
+ """
139
+ stripped = (text or "").strip()
140
+ if len(stripped) < 40:
141
+ return False
142
+ if stripped[-1] in ".!?\"')]}\u2019\u201d":
143
+ return False
144
+ if budget:
145
+ used = len(stripped) / CHARS_PER_TOKEN
146
+ # Well short of the ceiling means the model chose to stop.
147
+ if used < budget * 0.7:
148
+ return False
149
+ return True
150
+
151
+
152
+ def _batch(images):
153
+ """Whatever the caller gave us, in a shape the encoder will accept.
154
+
155
+ Three cases arrive here. PIL images (the transcriber's frame grids) get
156
+ stacked into one tensor. A list of tensors, one per reference image, is
157
+ passed straight through -- keeping them separate is what lets each keep its
158
+ own aspect ratio. A single tensor passes through untouched.
159
+ """
160
+ if images is None:
161
+ return None
162
+ if not isinstance(images, (list, tuple)):
163
+ return images # already a tensor
164
+ if not images:
165
+ return None
166
+
167
+ import torch
168
+
169
+ if torch.is_tensor(images[0]):
170
+ return list(images) if len(images) > 1 else images[0]
171
+
172
+ import numpy as np
173
+
174
+ first = images[0].size
175
+ arrays = []
176
+ for image in images:
177
+ if image.size != first:
178
+ image = image.resize(first)
179
+ arrays.append(np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0)
180
+ return torch.from_numpy(np.stack(arrays))
181
+
182
+
183
+ def _stack_padded(tensors):
184
+ """Fallback: letterbox differently-sized tensors onto one shared canvas.
185
+
186
+ Only reached when this build of ComfyUI will not take a list of images. The
187
+ canvas is the smallest box holding all of them, so a set that already agrees
188
+ on shape gets no bars at all.
189
+ """
190
+ import torch
191
+
192
+ widest = max(int(t.shape[2]) for t in tensors)
193
+ tallest = max(int(t.shape[1]) for t in tensors)
194
+
195
+ out = []
196
+ for tensor in tensors:
197
+ frame = tensor[0]
198
+ h, w = int(frame.shape[0]), int(frame.shape[1])
199
+ if (h, w) == (tallest, widest):
200
+ out.append(frame)
201
+ continue
202
+ canvas = torch.zeros((tallest, widest, int(frame.shape[2])),
203
+ device=frame.device, dtype=frame.dtype)
204
+ y, x = (tallest - h) // 2, (widest - w) // 2
205
+ canvas[y:y + h, x:x + w, :] = frame
206
+ out.append(canvas)
207
+ return torch.stack(out, dim=0)
208
+
209
+
210
+ def ask(clip, prompt: str, images=None, max_tokens: int = 512,
211
+ seed: int = 0, temperature: float = 0.0,
212
+ repetition_penalty: float = 1.0, sample: bool | None = None,
213
+ note: dict | None = None) -> str:
214
+ """One generation. Greedy unless ``sample`` says otherwise.
215
+
216
+ ``sample`` is explicit rather than inferred from ``temperature``. Inferring
217
+ it meant a node could offer a sampling on/off control, have the user switch
218
+ it on, and still decode greedily because temperature happened to sit at
219
+ zero -- the widget said one thing and the model did another. When ``sample``
220
+ is None the old behaviour applies, which keeps the transcriber's greedy
221
+ default working.
222
+
223
+ A temperature of zero with sampling on is not meaningful, so it is floored
224
+ rather than silently turning sampling off.
225
+ """
226
+ check(clip)
227
+
228
+ if sample is None:
229
+ sample = temperature > 0
230
+ if sample and temperature <= 0:
231
+ temperature = 0.7
232
+
233
+ payload = _batch(images)
234
+
235
+ def tokenize(media):
236
+ return clip.tokenize(prompt, image=media, skip_template=False,
237
+ min_length=1, thinking=False, video=None, audio=None)
238
+
239
+ try:
240
+ try:
241
+ tokens = tokenize(payload)
242
+ except Exception:
243
+ # This build will not take a list of separately-sized images, so
244
+ # letterbox them onto one canvas instead. Logged once: it is a
245
+ # capability of the host, not something that changes run to run.
246
+ if not isinstance(payload, list):
247
+ raise
248
+ if note is not None and not note.get("warned"):
249
+ note["warned"] = True
250
+ log.info("[Nugget] this ComfyUI build needs one image tensor, so "
251
+ "references are being letterboxed onto a shared canvas. "
252
+ "Matching aspect ratios avoids the bars entirely.")
253
+ tokens = tokenize(_stack_padded(payload))
254
+
255
+ generated = clip.generate(
256
+ tokens,
257
+ do_sample=bool(sample),
258
+ max_length=int(max_tokens),
259
+ # The filters below only bite when sampling; with do_sample False
260
+ # they are inert, so there is no need to branch on them.
261
+ temperature=float(temperature) if sample else 1.0,
262
+ top_k=64 if sample else 0,
263
+ top_p=0.95 if sample else 1.0,
264
+ min_p=0.05 if sample else 0.0,
265
+ # Applies either way: it reshapes the logits before the pick, so it
266
+ # affects greedy decoding as much as sampling. 1.0 is off, and off
267
+ # is right for structured output that is supposed to repeat its own
268
+ # labels.
269
+ repetition_penalty=float(repetition_penalty),
270
+ presence_penalty=0.0,
271
+ seed=int(seed),
272
+ )
273
+ return tidy(clip.decode(generated))
274
+ except LLMError:
275
+ raise
276
+ except Exception as exc:
277
+ raise LLMError(f"Text generation failed: {exc}") from exc
custom_nodes/ComfyUI-Nugget/motion_prompts.py ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompts and output assembly for the motion-transfer node.
2
+
3
+ Sibling of ``prompts.py``. Kept separate because the two nodes want almost
4
+ opposite things from the model: the transcriber wants "what is this shot about"
5
+ in prose; motion transfer wants "what does the body do, and when". Sharing a
6
+ prompt module would mean every edit needs a "is this the transcriber or the
7
+ motion node" branch, and the shot prompt would grow into a switch statement.
8
+
9
+ Frame density is higher here than in the transcriber. The transcriber can
10
+ describe a four-second shot from four frames -- the reader only needs to know
11
+ what happens, not the exact instant it happens. Motion transfer callouts are
12
+ tied to timestamps, so the model needs enough frames to place them: roughly one
13
+ frame per callout it will produce. The three detail levels below map to the two
14
+ styles in the examples, plus a middle ground.
15
+
16
+ Timestamps are handed to the model as a list beneath the frame grid, and the
17
+ model is asked to write callouts using only those timestamps. Left to invent its
18
+ own it tends to space them evenly regardless of what changes in the video, which
19
+ is exactly the failure the "brief" mode is meant to avoid.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+
25
+ # Frames per second of shot, and the range that count is clamped to, by detail
26
+ # level. Tuned to give the model one frame per timestamp it might call out --
27
+ # fewer frames than that and the model guesses the pose at unshown moments,
28
+ # which reads as fine until you check it against the video.
29
+ #
30
+ # ``beat`` is the longest span described in one call. Longer shots are split
31
+ # into equal beats and each is described on its own, then joined -- a single
32
+ # call over twenty seconds gets the same handful of frames a four-second shot
33
+ # gets, and everything between the samples is lost.
34
+ DETAIL = {
35
+ # pk-style: event-driven callouts, only when something changes.
36
+ "brief": {"per_second": 1.0, "min": 3, "max": 8, "tokens": 220, "beat": 8.0},
37
+ # In between: sparse enough to read at a glance, dense enough to reproduce.
38
+ "normal": {"per_second": 1.5, "min": 4, "max": 10, "tokens": 380, "beat": 6.0},
39
+ # plz-style: near-continuous, roughly every half second.
40
+ "detailed": {"per_second": 2.0, "min": 6, "max": 14, "tokens": 700, "beat": 4.0},
41
+ }
42
+
43
+ # Frames used by the overview and subject passes. Sampled per shot rather than
44
+ # evenly across the clip, so a short shot still contributes -- an even spread
45
+ # can miss a one-second shot entirely and lose whoever is only in it.
46
+ CONTEXT_FRAMES = 8
47
+
48
+ GRID_NOTE = ("The image is a grid of {count} video frames in time order, read "
49
+ "left to right then top to bottom, like a comic strip. They are "
50
+ "frames of one continuous shot showing the subject moving, not "
51
+ "separate pictures.")
52
+
53
+
54
+ OVERVIEW = """These frames are spread across a short video of someone moving or dancing.
55
+
56
+ In ONE sentence, describe the overall look and feel of the motion: pace (slow / brisk / fast), quality (sharp / flowing / loose / rhythmic), and the mood.
57
+
58
+ Do not describe any single movement, and do not list what happens moment by moment. One sentence, nothing else."""
59
+
60
+
61
+ CONTINUES = ("\n\nThis continues the same shot, already in progress. The "
62
+ "opening sentence about overall posture has already been written, "
63
+ "so do NOT repeat it -- write only the timestamped movement lines "
64
+ "for THIS window.")
65
+
66
+
67
+ # Deliberately singular. Motion transfer clips are almost always about one
68
+ # person moving, and asking for a list encourages the model to describe every
69
+ # body in the frame -- including reflections, background extras and cropped
70
+ # limbs that don't belong to the mover.
71
+ #
72
+ # The prompt describes the shape in prose rather than showing a template
73
+ # because template placeholders (angle-bracket shapes and named example words)
74
+ # leak into the output on smaller models even when the prompt tells them not
75
+ # to. clean_subjects strips the leaks when they happen, but not writing them
76
+ # in the first place is the more reliable half of the fix.
77
+ SUBJECTS = """These frames are from one short video of someone moving.
78
+
79
+ Describe the moving subject in ONE short line: a noun phrase covering what they are, followed by the details that identify them -- clothing, hair, build, colour. Focus only on appearance. The movement itself is described elsewhere and belongs there, not here.
80
+
81
+ If several people move together, write one line per mover. Skip anyone standing still in the background. If there is no clear human or animal subject -- an abstract animation, an object moving on its own -- write the single word NONE.
82
+
83
+ No preamble, no labels, no numbering, nothing else."""
84
+
85
+
86
+ SUBJECTS_KNOWN = ("\nThe mover(s) in this video:\n{subjects}\n"
87
+ "Refer to them the same way in every callout, so the "
88
+ "description stays consistent.\n")
89
+
90
+
91
+ # The camera movement is measured, not guessed. A locked-off dance shot is the
92
+ # common case for motion transfer, but hand-held and moving-camera clips do
93
+ # turn up and the model would otherwise call every camera drift a body sway.
94
+ CAMERA_FACT = ("\nThe camera has been measured for you: {motion}. Do not "
95
+ "attribute this to the subject's body -- their motion is what "
96
+ "moves relative to the frame after the camera is accounted for.\n")
97
+
98
+
99
+ # Per-style shot instructions, all sharing the same header (frames + timestamp
100
+ # list). The header is the load-bearing part: without the timestamp list the
101
+ # model invents its own MM:SS.mmm values and their spacing rarely matches the
102
+ # frames it was shown.
103
+ _SHOT_HEADER = """{layout} They span {span:.1f} seconds, from {start_clock} to {end_clock}.
104
+
105
+ The frames above are sampled at these timestamps (in order):
106
+ {timestamp_list}
107
+
108
+ """
109
+
110
+ # Prompts describe the callout format in prose rather than showing a template
111
+ # line. A demo line like `MM:SS.mmm <short phrase about hands, arms, hips>`
112
+ # reliably leaks: the model treats the shape as content to reproduce, and the
113
+ # words inside the angle brackets appear in real callouts. The same is true of
114
+ # quoted example adverbs ("gracefully" / "sharply") -- naming a word to
115
+ # suppress teaches the model to reach for it. So neither appears here.
116
+
117
+ _BODY_BRIEF = """Write ONE short opening sentence describing what the subject is doing overall in this window -- their posture, weight, and the general shape of the movement.
118
+
119
+ Then list only the moments where something visibly CHANGES: a new gesture, a step, a shift in weight, a new pose, a turn. Skip time where the pose is holding.
120
+
121
+ Each moment goes on its own line. Start the line with the timestamp written as MM:SS.mmm, then two spaces, then a short phrase describing what the body does at that instant (hands, arms, hips, feet, weight, gaze).
122
+
123
+ Use ONLY timestamps from the list above. Aim for about {aim} callouts across the whole span. Keep every line short and physical: say what the body does, not how well it does it, and not the mood behind it.
124
+
125
+ """
126
+
127
+ _BODY_NORMAL = """Write ONE short opening sentence describing the subject's overall movement in this window -- the shape of it, the pace.
128
+
129
+ Then describe the body at the key timestamps from the list -- roughly every other one, with more where the motion is busy and fewer where it holds.
130
+
131
+ Each callout goes on its own line. Start the line with the timestamp written as MM:SS.mmm, then two spaces, then a short phrase describing what the body is doing at that instant.
132
+
133
+ Use ONLY timestamps from the list above. Keep every line short and focused on the physical pose.
134
+
135
+ """
136
+
137
+ _BODY_DETAILED = """Describe the subject's body position and motion at EVERY timestamp in the list above, in order.
138
+
139
+ Each callout goes on its own line. Start the line with the timestamp written as MM:SS.mmm, then two spaces, then a short phrase about the body at that instant (hands, arms, hips, feet, weight, gaze).
140
+
141
+ One line per timestamp, no skips. Keep every line short and stick strictly to the pose at that instant. Say what the body does, not how well it does it and not what the camera does.
142
+
143
+ """
144
+
145
+ _STYLE_BODIES = {"brief": _BODY_BRIEF, "normal": _BODY_NORMAL,
146
+ "detailed": _BODY_DETAILED}
147
+
148
+ _SHOT_FOOTER = """{subjects}{camera}
149
+ Only what is visible in THESE frames. If the subject is out of frame at a timestamp, or too small to read, note that briefly on the callout for that timestamp rather than guessing at a pose.
150
+
151
+ Write only the description. No preamble, no bullet points, no shot number, no trailing summary."""
152
+
153
+
154
+ def shot_prompt(detail: str) -> str:
155
+ """The full shot prompt for a given detail level, ready for ``.format``.
156
+
157
+ Composed rather than three separate literal strings so the shared header
158
+ and footer stay in one place -- otherwise a fix to the timestamp-list
159
+ wording has to be made three times.
160
+ """
161
+ body = _STYLE_BODIES.get(detail, _BODY_NORMAL)
162
+ return _SHOT_HEADER + body + _SHOT_FOOTER
163
+
164
+
165
+ # Token budgets for the two setup passes when the user has not overridden the
166
+ # per-shot budget. The overview is one sentence and the subject line is one
167
+ # short phrase, so neither needs much.
168
+ OVERVIEW_TOKENS = 120
169
+ SUBJECT_TOKENS = 160
170
+
171
+
172
+ def token_budget(detail: str, override: int = 0) -> dict:
173
+ """How many tokens each pass may generate.
174
+
175
+ Detailed mode's per-shot budget has to accommodate one line per timestamp
176
+ -- roughly 20 tokens each and up to 14 timestamps per beat -- which is
177
+ where the 700 default comes from. Brief mode is deliberately much smaller
178
+ to keep the model from padding.
179
+ """
180
+ settings = DETAIL.get(detail, DETAIL["normal"])
181
+ if not override or override <= 0:
182
+ return {"shot": settings["tokens"],
183
+ "overview": OVERVIEW_TOKENS,
184
+ "subject": SUBJECT_TOKENS}
185
+
186
+ shot = int(override)
187
+ return {"shot": shot,
188
+ "overview": max(OVERVIEW_TOKENS, shot // 4),
189
+ "subject": max(SUBJECT_TOKENS, shot // 3)}
190
+
191
+
192
+ def frames_for(duration: float, detail: str) -> int:
193
+ """How many frames to show the model for a beat of this length."""
194
+ settings = DETAIL.get(detail, DETAIL["normal"])
195
+ wanted = int(round(max(0.0, duration) * settings["per_second"]))
196
+ return max(settings["min"], min(settings["max"], wanted))
197
+
198
+
199
+ def sample_times(start: float, end: float, count: int) -> list[float]:
200
+ """Frame times spread across a span, at bin centres.
201
+
202
+ Centres rather than edges: landing exactly on ``end`` picks up the next
203
+ shot's first frame, which would then show a pose from a shot the callouts
204
+ are not covering.
205
+ """
206
+ count = max(1, int(count))
207
+ span = max(0.0, end - start)
208
+ return [start + span * (i + 0.5) / count for i in range(count)]
209
+
210
+
211
+ def beats_for(shot, detail: str) -> list:
212
+ """Split a shot into equal beats short enough to describe densely.
213
+
214
+ Equal windows rather than content-adaptive ones: for motion there is no
215
+ reliable within-shot signal for where one beat ends and the next begins,
216
+ and the cuts (across shots) already handle the structural breaks.
217
+ """
218
+ length = float(DETAIL.get(detail, DETAIL["normal"])["beat"])
219
+ span = max(0.0, shot.end - shot.start)
220
+ if span <= length * 1.35:
221
+ # A shot a little over the ceiling is left whole. Splitting 5s into
222
+ # 4 + 1 gives a one-second beat that can only fit two callouts.
223
+ return [(shot.start, shot.end)]
224
+ count = max(2, int(round(span / length)))
225
+ return [(shot.start + span * i / count, shot.start + span * (i + 1) / count)
226
+ for i in range(count)]
227
+
228
+
229
+ def context_times(shots, total: int = CONTEXT_FRAMES) -> list[float]:
230
+ """Times for the overview and subject passes, with every shot represented.
231
+
232
+ Each shot gets at least one frame; whatever is left over goes to the
233
+ longest shots, which is where most of the movement lives.
234
+ """
235
+ if not shots:
236
+ return []
237
+ share = {shot.index: 1 for shot in shots}
238
+ spare = max(0, total - len(shots))
239
+ for shot in sorted(shots, key=lambda s: -s.duration):
240
+ if spare <= 0:
241
+ break
242
+ share[shot.index] += 1
243
+ spare -= 1
244
+
245
+ times: list[float] = []
246
+ for shot in shots:
247
+ times.extend(sample_times(shot.start, shot.end, share[shot.index]))
248
+ return sorted(times)
249
+
250
+
251
+ def clock(seconds: float) -> str:
252
+ """MM:SS.mmm -- the timestamp format used in the callouts."""
253
+ minutes, rest = divmod(max(0.0, float(seconds)), 60.0)
254
+ return f"{int(minutes):02d}:{rest:06.3f}"
255
+
256
+
257
+ def timestamp_list(times: list[float]) -> str:
258
+ """The frame timestamps, formatted as one comma-separated line.
259
+
260
+ One line rather than a bulleted list on purpose: the model treats a bulleted
261
+ list as things to describe one by one, which is exactly what "detailed" is
262
+ supposed to do and exactly what "brief" is supposed to avoid. A flat line
263
+ reads as reference material for both.
264
+ """
265
+ return ", ".join(clock(t) for t in times)
266
+
267
+
268
+ def aim_callouts(times: list[float], detail: str) -> int:
269
+ """Roughly how many callouts to ask for in brief mode.
270
+
271
+ Scales with the number of frames sampled -- about half of them -- so a
272
+ dense sampling doesn't get flattened into two callouts and a sparse
273
+ sampling doesn't get every-timestamp coverage. Floored at 2 so the model
274
+ still writes a callout even for the shortest beat.
275
+ """
276
+ if detail != "brief":
277
+ return len(times)
278
+ return max(2, len(times) // 2)
279
+
280
+
281
+ def assign_speech(speech, shots) -> dict:
282
+ """Map shot index -> its speech segments, each segment used exactly once.
283
+
284
+ Same rule as the transcriber: a line spoken across a cut goes to whichever
285
+ shot it spends most of itself in. Motion transfer clips rarely have
286
+ dialogue, but when they do (a spoken cue, a coach counting) placing it in
287
+ the right shot matters as much as anywhere.
288
+ """
289
+ placed: dict = {shot.index: [] for shot in shots}
290
+ if not shots:
291
+ return placed
292
+
293
+ for segment in speech.segments:
294
+ start, end = segment[0], segment[1]
295
+ best, most = shots[0], -1.0
296
+ for shot in shots:
297
+ overlap = min(end, shot.end) - max(start, shot.start)
298
+ if overlap > most:
299
+ best, most = shot, overlap
300
+ placed[best.index].append(segment)
301
+ return placed
302
+
303
+
304
+ # Words with no identifying weight, dropped before the "are these two lines
305
+ # the same subject" check.
306
+ _FILLER = {"a", "an", "the", "in", "on", "with", "and", "of", "is", "has",
307
+ "wearing", "wears", "dressed", "who", "that", "his", "her", "their",
308
+ "man", "woman", "person", "young", "older", "adult", "dancer",
309
+ "mover", "subject"}
310
+
311
+
312
+ def _same_subject(first: str, second: str) -> bool:
313
+ import re
314
+
315
+ def words(text):
316
+ return {w for w in re.findall(r"[a-z]+", text.lower()) if w not in _FILLER}
317
+
318
+ left, right = words(first), words(second)
319
+ if not left or not right:
320
+ return False
321
+ return len(left & right) / min(len(left), len(right)) >= 0.6
322
+
323
+
324
+ def clean_subjects(raw: str) -> str:
325
+ """Tidy the subject pass: strip formatting, drop NONE, merge repeats.
326
+
327
+ Motion clips usually have one mover, but a duet or group clip produces two
328
+ or three lines and the model sometimes lists the same person twice from
329
+ two framings. Same merge logic as the transcriber, tuned filler words for
330
+ the motion vocabulary.
331
+ """
332
+ import re
333
+
334
+ if not raw or raw.strip().upper().startswith("NONE"):
335
+ return ""
336
+
337
+ rows = []
338
+ for row in raw.splitlines():
339
+ row = row.strip()
340
+ if not row or row.upper() == "NONE":
341
+ continue
342
+ row = re.sub(r"^[-*\u2022]\s*|^\d+[.)]\s*", "", row)
343
+ row = re.sub(r"^(Man|Woman|Person|Subject|Mover|Dancer)\s*[A-Z0-9]?\s*[-:]\s*",
344
+ "", row, flags=re.IGNORECASE)
345
+ row = row.rstrip(" .;,")
346
+ # A model that echoes the prompt's placeholder rather than filling it
347
+ # in produces a line with angle brackets. That is instruction text, not
348
+ # a subject, and it must never reach the shot passes -- everything on
349
+ # this list is asserted as fact in every callout afterwards.
350
+ if "<" in row and ">" in row:
351
+ continue
352
+ if row:
353
+ rows.append(row)
354
+
355
+ merged = list(rows)
356
+ for _ in range(4):
357
+ collapsed: list[str] = []
358
+ for row in merged:
359
+ for index, existing in enumerate(collapsed):
360
+ if _same_subject(existing, row):
361
+ if len(row) > len(existing):
362
+ collapsed[index] = row
363
+ break
364
+ else:
365
+ collapsed.append(row)
366
+ if len(collapsed) == len(merged):
367
+ break
368
+ merged = collapsed
369
+ return "\n".join(merged)
370
+
371
+
372
+ def sections(overview: str, subjects: str, shots: list, speech) -> dict:
373
+ """The output broken into its pieces so each can leave the node separately.
374
+
375
+ Shape kept deliberately close to the transcriber's ``sections`` -- same
376
+ dict keys ("overview", "subjects", "shots", "audio"), same
377
+ ``[Shot N] From X to Y`` header on every shot, same indented ``speech ...``
378
+ lines -- so the two nodes' outputs slot into the same downstream text
379
+ handling and can be merged later without either side needing to know
380
+ which node it came from.
381
+
382
+ The beat text itself is the one place they differ: transcriber beats are
383
+ prose paragraphs, motion beats are timestamped callouts. Both drop under
384
+ the shot header the same way -- one blank line, then the text -- so the
385
+ surrounding structure is identical.
386
+ """
387
+ subject_text = clean_subjects(subjects)
388
+ placed = assign_speech(speech, [shot for shot, _ in shots])
389
+
390
+ rows: list[str] = []
391
+ for shot, beats in shots:
392
+ if rows:
393
+ rows.append("")
394
+ rows.append(f"[Shot {shot.index}] From {clock(shot.start)} to "
395
+ f"{clock(shot.end)}")
396
+ # Callouts already carry their own timestamps, so a per-beat time-range
397
+ # prefix would duplicate that. The beats are just concatenated with a
398
+ # blank line between them, and the callout stream reads as one shot.
399
+ for _, _, text in beats:
400
+ rows.append("")
401
+ rows.append(text.strip())
402
+ for start, end, said in placed.get(shot.index, []):
403
+ rows.append(f" speech {clock(start)}-{clock(end)}: \"{said}\"")
404
+
405
+ if speech.note:
406
+ audio = speech.note
407
+ elif speech.spoken:
408
+ audio = (f"speech in {speech.language_full or 'an unknown language'} "
409
+ f"({speech.probability:.0%} confidence)")
410
+ else:
411
+ audio = "no speech detected"
412
+
413
+ return {"overview": (overview or "").strip(),
414
+ "subjects": subject_text,
415
+ "shots": "\n".join(rows),
416
+ "audio": audio}
417
+
418
+
419
+ def assemble(overview: str, subjects: str, shots: list, speech) -> str:
420
+ """Everything, in one block -- the full description output.
421
+
422
+ Section headers match the transcriber's exactly ("overview:",
423
+ "characters identified:", "shots:", "audio:") so the two blocks are
424
+ interchangeable to whatever parses them downstream.
425
+ """
426
+ part = sections(overview, subjects, shots, speech)
427
+ lines: list[str] = []
428
+ if part["overview"]:
429
+ lines += [f"overview: {part['overview']}", ""]
430
+ if part["subjects"]:
431
+ lines += ["characters identified:", part["subjects"], ""]
432
+ lines += ["shots:", "", part["shots"]]
433
+ if part["audio"]:
434
+ # Two blank lines rather than one, so the audio footer visually
435
+ # breaks away from the shots block above. The shots block ends with
436
+ # a callout line, and a single blank line was reading as part of the
437
+ # last beat rather than a section break.
438
+ lines += ["", "", f"audio: {part['audio']}"]
439
+ return "\n".join(lines).strip()
440
+
441
+
442
+ def transcript_text(speech) -> str:
443
+ """The transcript on its own, with timings, for downstream reformatting."""
444
+ if not speech.spoken:
445
+ return speech.note or "no speech detected"
446
+ rows = [f"[{clock(start)} - {clock(end)}] {text}"
447
+ for start, end, text in speech.segments]
448
+ if speech.note:
449
+ rows.append(f"({speech.note})")
450
+ return "\n".join(rows)
custom_nodes/ComfyUI-Nugget/nodes_image.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Batching images without cropping them.
2
+
3
+ ComfyUI's IMAGE type is one tensor shaped (batch, height, width, channels), so
4
+ every image in a batch must share a height and a width. KJNodes' ImageBatchMulti
5
+ resolves that by scaling images 2..N to match image 1 and centre-cropping the
6
+ overflow, which is fine for images of the same shape and destructive for
7
+ anything else -- a portrait dropped into a landscape slot loses its top and
8
+ bottom, which is where a face usually is.
9
+
10
+ These nodes pad instead. Every image is scaled to *fit* the canvas and the
11
+ remainder is filled, so nothing is cropped and nothing is stretched.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+
18
+ import torch
19
+
20
+ import comfy.utils
21
+ from comfy_api.latest import io
22
+
23
+ log = logging.getLogger("Nugget")
24
+
25
+ MAX_INPUTS = 9
26
+
27
+ SCALE_METHODS = ["lanczos", "bicubic", "area", "bilinear", "nearest-exact"]
28
+ PAD_COLORS = ["black", "white", "grey", "custom hex"]
29
+ POSITIONS = ["center", "top", "bottom", "left", "right"]
30
+
31
+
32
+ def _hex_to_rgb(value: str):
33
+ value = (value or "").strip().lstrip("#")
34
+ if len(value) == 3:
35
+ value = "".join(c * 2 for c in value)
36
+ if len(value) != 6:
37
+ return (0.0, 0.0, 0.0)
38
+ try:
39
+ return tuple(int(value[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
40
+ except ValueError:
41
+ return (0.0, 0.0, 0.0)
42
+
43
+
44
+ def _pad_rgb(pad_color: str, pad_hex: str):
45
+ if pad_color == "white":
46
+ return (1.0, 1.0, 1.0)
47
+ if pad_color == "grey":
48
+ return (0.5, 0.5, 0.5)
49
+ if pad_color == "custom hex":
50
+ return _hex_to_rgb(pad_hex)
51
+ return (0.0, 0.0, 0.0)
52
+
53
+
54
+ def _offsets(canvas_w, canvas_h, new_w, new_h, position):
55
+ x = (canvas_w - new_w) // 2
56
+ y = (canvas_h - new_h) // 2
57
+ if position == "top":
58
+ y = 0
59
+ elif position == "bottom":
60
+ y = canvas_h - new_h
61
+ elif position == "left":
62
+ x = 0
63
+ elif position == "right":
64
+ x = canvas_w - new_w
65
+ return max(0, x), max(0, y)
66
+
67
+
68
+ def collect(images: dict):
69
+ """Flatten every connected socket -- each may itself be a batch -- into HWC frames.
70
+
71
+ Sockets are read in slot order and gaps are skipped rather than fatal, so a
72
+ graph with image_1 and image_3 connected still works.
73
+ """
74
+ frames = []
75
+ filled = []
76
+ for i in range(1, MAX_INPUTS + 1):
77
+ image = images.get(f"image_{i}")
78
+ if image is None or getattr(image, "shape", None) is None \
79
+ or len(image.shape) < 3:
80
+ # None is a bypassed loader with nothing to pass through. Something
81
+ # without a shape is a bypassed loader passing its widget value
82
+ # through instead -- a filename, a number. Neither is an image, and
83
+ # reading .shape off the second is an AttributeError mid-run.
84
+ continue
85
+ filled.append(i)
86
+ for f in range(image.shape[0]):
87
+ frames.append(image[f])
88
+
89
+ # A bypassed Load Image leaves its link in place and delivers None, so the
90
+ # canvas still shows the socket as wired while nothing arrives. Skipping the
91
+ # hole is the only option -- a vision encoder cannot be handed a gap -- but
92
+ # it renumbers everything after it, and a prompt written against <Picture 4>
93
+ # then refers to an image the model was never given. Silent is the wrong
94
+ # behaviour here, so say so.
95
+ if filled and filled != list(range(1, len(filled) + 1)):
96
+ empty = [i for i in range(1, max(filled)) if i not in filled]
97
+ mapping = ", ".join(f"image_{s} is picture {p}"
98
+ for p, s in enumerate(filled, start=1))
99
+ log.warning(
100
+ "[Nugget] %s %s empty (bypassed?), so the remaining references were "
101
+ "renumbered: %s. A prompt naming a higher picture number will not "
102
+ "match.",
103
+ ", ".join(f"image_{i}" for i in empty),
104
+ "is" if len(empty) == 1 else "are", mapping)
105
+
106
+ if not frames:
107
+ raise ValueError(
108
+ "No images connected. If an upstream Load Image node is bypassed, "
109
+ "its output is empty -- connect an image or remove this node.")
110
+
111
+ # Harmonise channel counts. Mixing RGB and RGBA would otherwise fail to
112
+ # stack; the narrower frames gain an opaque alpha instead.
113
+ channels = {int(f.shape[2]) for f in frames}
114
+ if len(channels) > 1:
115
+ target = max(channels)
116
+ fixed = []
117
+ for f in frames:
118
+ have = int(f.shape[2])
119
+ if have < target:
120
+ pad = torch.ones((int(f.shape[0]), int(f.shape[1]), target - have),
121
+ device=f.device, dtype=f.dtype)
122
+ f = torch.cat((f, pad), dim=2)
123
+ elif have > target:
124
+ f = f[:, :, :target]
125
+ fixed.append(f)
126
+ frames = fixed
127
+
128
+ # Sockets can carry tensors from different devices. Stacking those fails, so
129
+ # bring everything to the first frame's device before anything else.
130
+ device = frames[0].device
131
+ if any(f.device != device for f in frames):
132
+ frames = [f.to(device) for f in frames]
133
+ return frames
134
+
135
+
136
+ def canvas_for(frames, max_width: int, max_height: int):
137
+ """The smallest canvas that holds every input, scaled down to the ceiling.
138
+
139
+ The union box is (widest, tallest). That box is then scaled to fit inside
140
+ max_width x max_height **preserving its own aspect ratio**.
141
+
142
+ Clamping the two axes independently instead -- ``min(widest, max_width)`` and
143
+ ``min(tallest, max_height)`` -- looks equivalent and is not. It destroys the
144
+ box's aspect: a single 1920x1080 image against a 1024x1024 ceiling yields a
145
+ 1024x1024 square canvas, so a lone image with nothing to reconcile against
146
+ comes back 44% bars. Three identically shaped portraits come back 16% bars.
147
+ Padding a set that needs no padding is precisely the failure this node exists
148
+ to avoid.
149
+
150
+ The canvas never exceeds the largest input either, so a ceiling of 16384 with
151
+ 64x64 inputs still gives a 64x64 canvas rather than upscaling everything.
152
+ """
153
+ widest = max(int(f.shape[1]) for f in frames)
154
+ tallest = max(int(f.shape[0]) for f in frames)
155
+
156
+ ratio = min(int(max_width) / widest, int(max_height) / tallest, 1.0)
157
+ return (max(1, int(round(widest * ratio))),
158
+ max(1, int(round(tallest * ratio))))
159
+
160
+
161
+ def compose(frames, canvas_w, canvas_h, scale_method, allow_upscale,
162
+ pad_color, pad_hex, position):
163
+ """Scale each frame to fit the canvas and pad the remainder."""
164
+ rgb = _pad_rgb(pad_color, pad_hex)
165
+ device = frames[0].device
166
+ dtype = frames[0].dtype
167
+
168
+ out_frames, out_masks = [], []
169
+
170
+ for frame in frames:
171
+ src_h, src_w = int(frame.shape[0]), int(frame.shape[1])
172
+ channels = int(frame.shape[2])
173
+
174
+ # contain / letterbox: the whole image fits inside the canvas
175
+ ratio = min(canvas_w / src_w, canvas_h / src_h)
176
+ if not allow_upscale:
177
+ ratio = min(ratio, 1.0)
178
+
179
+ new_w = max(1, min(canvas_w, int(round(src_w * ratio))))
180
+ new_h = max(1, min(canvas_h, int(round(src_h * ratio))))
181
+
182
+ if (new_w, new_h) != (src_w, src_h):
183
+ nchw = frame.unsqueeze(0).movedim(-1, 1)
184
+ nchw = comfy.utils.common_upscale(
185
+ nchw.float(), new_w, new_h, scale_method, "disabled")
186
+ resized = nchw.movedim(1, -1).squeeze(0).to(dtype)
187
+ else:
188
+ resized = frame
189
+
190
+ canvas = torch.empty((canvas_h, canvas_w, channels),
191
+ device=device, dtype=torch.float32)
192
+ if channels == 1:
193
+ # Greyscale: collapse the pad colour to luminance rather than taking
194
+ # the red channel, which would render #0000ff as black.
195
+ canvas[:, :, 0] = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
196
+ else:
197
+ for c in range(channels):
198
+ # Alpha, where present, is opaque so the bars are visible rather
199
+ # than silently transparent.
200
+ canvas[:, :, c] = rgb[c] if c < 3 else 1.0
201
+
202
+ mask = torch.zeros((canvas_h, canvas_w), device=device, dtype=torch.float32)
203
+
204
+ x, y = _offsets(canvas_w, canvas_h, new_w, new_h, position)
205
+ canvas[y:y + new_h, x:x + new_w, :] = resized.float()
206
+ mask[y:y + new_h, x:x + new_w] = 1.0
207
+
208
+ out_frames.append(canvas.to(dtype))
209
+ out_masks.append(mask)
210
+
211
+ return torch.stack(out_frames, dim=0), torch.stack(out_masks, dim=0)
212
+
213
+
214
+ def padding_report(masks) -> str:
215
+ """How much of each frame is bars. Worth surfacing: heavy padding means the
216
+ references disagree about shape, and the fix is upstream, not here."""
217
+ shares = [100.0 * (1.0 - float(masks[i].mean())) for i in range(masks.shape[0])]
218
+ worst = max(shares) if shares else 0.0
219
+ rows = ", ".join(f"image {i + 1}: {s:.0f}%" for i, s in enumerate(shares))
220
+ if worst < 1.0:
221
+ return f"no padding needed ({rows})"
222
+ return f"padding {rows}"
223
+
224
+
225
+ class NuggetImageBatchNoCrop(io.ComfyNode):
226
+ @classmethod
227
+ def define_schema(cls):
228
+ return io.Schema(
229
+ node_id="ImageBatchMultiNoCrop",
230
+ display_name="Batch images (no crop)",
231
+ category="Nugget",
232
+ search_aliases=["batch", "pad", "letterbox", "reference images",
233
+ "imagebatchmulti"],
234
+ description=(
235
+ "Batches up to 9 images into one IMAGE tensor without cropping. "
236
+ "The canvas is the smallest box that holds every input, scaled "
237
+ "down to the ceiling you set. Each image is scaled to fit and "
238
+ "the remainder padded. Sockets appear as you connect them."),
239
+ inputs=[
240
+ io.Image.Input("image_1"),
241
+ *[io.Image.Input(f"image_{i}", optional=True)
242
+ for i in range(2, MAX_INPUTS + 1)],
243
+ io.Int.Input("max_width", default=1024, min=16, max=16384, step=8,
244
+ display_name="max width",
245
+ tooltip="Ceiling, not a target. The canvas never "
246
+ "exceeds this, and never exceeds the largest "
247
+ "input either."),
248
+ io.Int.Input("max_height", default=1024, min=16, max=16384, step=8,
249
+ display_name="max height"),
250
+ io.Combo.Input("scale_method", options=SCALE_METHODS,
251
+ default="lanczos", display_name="scale method"),
252
+ io.Boolean.Input("allow_upscale", default=True,
253
+ display_name="allow upscale",
254
+ tooltip="Off keeps images smaller than the canvas "
255
+ "at their native size, padding the rest."),
256
+ io.Combo.Input("pad_color", options=PAD_COLORS, default="black",
257
+ display_name="pad colour"),
258
+ io.String.Input("pad_hex", default="#000000", display_name="pad hex",
259
+ optional=True, advanced=True),
260
+ io.Combo.Input("position", options=POSITIONS, default="center",
261
+ tooltip="Where the image sits within the canvas."),
262
+ ],
263
+ outputs=[
264
+ io.Image.Output(display_name="images"),
265
+ io.Mask.Output(display_name="content_mask"),
266
+ io.Int.Output(display_name="width"),
267
+ io.Int.Output(display_name="height"),
268
+ io.String.Output(display_name="padding_report"),
269
+ ],
270
+ )
271
+
272
+ @classmethod
273
+ def execute(cls, max_width=1024, max_height=1024, scale_method="lanczos",
274
+ allow_upscale=True, pad_color="black", pad_hex="#000000",
275
+ position="center", **images) -> io.NodeOutput:
276
+ frames = collect(images)
277
+ canvas_w, canvas_h = canvas_for(frames, max_width, max_height)
278
+ batch, masks = compose(frames, canvas_w, canvas_h, scale_method,
279
+ allow_upscale, pad_color, pad_hex, position)
280
+ report = padding_report(masks)
281
+ if "no padding" not in report:
282
+ log.info("[Nugget] batch %dx%d -- %s", canvas_w, canvas_h, report)
283
+ return io.NodeOutput(batch, masks, canvas_w, canvas_h, report)
284
+
285
+
286
+ class NuggetImagePadToSize(io.ComfyNode):
287
+ @classmethod
288
+ def define_schema(cls):
289
+ return io.Schema(
290
+ node_id="ImagePadToSize",
291
+ display_name="Pad image to size (no crop)",
292
+ category="Nugget",
293
+ search_aliases=["pad", "letterbox", "resize", "fit"],
294
+ description="Letterbox an image to an exact size. Nothing is cropped.",
295
+ inputs=[
296
+ io.Image.Input("image"),
297
+ io.Int.Input("width", default=1024, min=16, max=16384, step=8),
298
+ io.Int.Input("height", default=1024, min=16, max=16384, step=8),
299
+ io.Combo.Input("scale_method", options=SCALE_METHODS,
300
+ default="lanczos", display_name="scale method"),
301
+ io.Boolean.Input("allow_upscale", default=True,
302
+ display_name="allow upscale"),
303
+ io.Combo.Input("pad_color", options=PAD_COLORS, default="black",
304
+ display_name="pad colour"),
305
+ io.String.Input("pad_hex", default="#000000", display_name="pad hex",
306
+ optional=True, advanced=True),
307
+ io.Combo.Input("position", options=POSITIONS, default="center"),
308
+ ],
309
+ outputs=[
310
+ io.Image.Output(display_name="image"),
311
+ io.Mask.Output(display_name="content_mask"),
312
+ ],
313
+ )
314
+
315
+ @classmethod
316
+ def execute(cls, image, width=1024, height=1024, scale_method="lanczos",
317
+ allow_upscale=True, pad_color="black", pad_hex="#000000",
318
+ position="center") -> io.NodeOutput:
319
+ frames = collect({"image_1": image})
320
+ # width/height are an exact target here, not a ceiling, so the canvas is
321
+ # used as given rather than derived from the inputs.
322
+ batch, masks = compose(frames, max(1, int(width)), max(1, int(height)),
323
+ scale_method, allow_upscale, pad_color, pad_hex,
324
+ position)
325
+ return io.NodeOutput(batch, masks)
326
+
327
+
328
+ NODES = [NuggetImageBatchNoCrop, NuggetImagePadToSize]
custom_nodes/ComfyUI-Nugget/nodes_motion.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The motion-transfer node.
2
+
3
+ Sibling of ``nodes_transcribe.py``. The pipeline is the same shape --
4
+ read the video, detect cuts, run Whisper (if asked), decode every frame
5
+ the plan needs in one pass, then run the vision passes -- because that
6
+ order is what keeps Whisper and the text encoder off the GPU at the
7
+ same time. Only the prompts and the output assembly differ.
8
+
9
+ The output focuses on WHAT THE BODY DOES, WHEN. A single "motion"
10
+ section holds timestamped callouts instead of prose shot descriptions.
11
+ When only one shot survives cut detection (the common motion-transfer
12
+ case: a single continuous take of a dancer), the shot header is
13
+ dropped -- there's no second shot to disambiguate from.
14
+
15
+ Whisper defaults to off here. Motion transfer clips almost always have
16
+ music rather than dialogue, and speech transcription of music beds is
17
+ confident-sounding nonsense that muddies the output. The widget is
18
+ still available for the rare clip with a spoken cue.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import time
25
+
26
+ from comfy_api.latest import io
27
+
28
+ from . import (camera, detect, llm, motion_prompts as prompts, recall,
29
+ speech as speech_mod, video as video_mod)
30
+
31
+ log = logging.getLogger("Nugget")
32
+
33
+ # Same limit as the transcriber. Past this the vision passes take long enough
34
+ # people assume the node has hung, and the frame counts stop fitting in
35
+ # context. Refuse clearly rather than accepting a clip we can't finish.
36
+ MAX_SECONDS = 120.0
37
+
38
+ WHISPER_MODELS = ["off", "large-v3", "large-v3-turbo", "medium", "small",
39
+ "base", "tiny"]
40
+
41
+ FRAME_SIZES = [384, 512, 768, 1024]
42
+
43
+ LANGUAGE_CHOICES = [
44
+ "auto", "English", "Spanish", "French", "German", "Italian", "Portuguese",
45
+ "Dutch", "Polish", "Russian", "Japanese", "Korean", "Chinese", "Cantonese",
46
+ "Arabic", "Hindi", "Turkish", "Vietnamese", "Thai", "Indonesian",
47
+ "Ukrainian", "Swedish", "Norwegian", "Danish", "Finnish", "Greek", "Czech",
48
+ "Romanian", "Hungarian", "Hebrew", "Malay", "Filipino",
49
+ ]
50
+
51
+
52
+ def _interrupted() -> None:
53
+ """Let Cancel actually cancel between model calls."""
54
+ try:
55
+ from comfy import model_management
56
+ model_management.throw_exception_if_processing_interrupted()
57
+ except ImportError:
58
+ pass
59
+
60
+
61
+ class _Progress:
62
+ """Progress bar + log line. Degrades to logging alone."""
63
+
64
+ def __init__(self, total: int):
65
+ self.bar = None
66
+ try:
67
+ from comfy.utils import ProgressBar
68
+ self.bar = ProgressBar(total)
69
+ except Exception:
70
+ pass
71
+
72
+ def step(self, message: str) -> None:
73
+ log.info("[Nugget] %s", message)
74
+ if self.bar is not None:
75
+ try:
76
+ self.bar.update(1)
77
+ except Exception:
78
+ pass
79
+
80
+
81
+ class NuggetMotionTransfer(io.ComfyNode):
82
+ @classmethod
83
+ def define_schema(cls):
84
+ return io.Schema(
85
+ node_id="NuggetMotionTransfer",
86
+ display_name="Nugget Motion Transfer",
87
+ category="Nugget",
88
+ search_aliases=["motion", "transfer", "pose", "dance", "ref2v",
89
+ "reference to video", "movement", "choreography"],
90
+ description=("Describe a short video's motion in timestamped "
91
+ "callouts, for feeding into a reference-to-video "
92
+ "prompt (H3 ref2v and similar)."),
93
+ inputs=[
94
+ io.Clip.Input("clip", tooltip="A Gemma or Qwen3-VL text "
95
+ "encoder, from Load CLIP."),
96
+ io.Video.Input(
97
+ "video", optional=True,
98
+ tooltip="The clip to describe. Under 2 minutes. Bypass "
99
+ "the Load Video node to switch this node off -- "
100
+ "it returns empty outputs instead of failing."),
101
+ io.Int.Input(
102
+ "prompt_max_length", default=0, min=0, max=4096, step=32,
103
+ display_name="prompt max length",
104
+ tooltip="Token budget per shot. 0 follows the detail "
105
+ "level (220 / 380 / 700) and is the default. "
106
+ "Raise it if callouts stop mid-sentence -- the "
107
+ "log warns when that happens."),
108
+ io.Combo.Input(
109
+ "description_detail", options=list(prompts.DETAIL.keys()),
110
+ default="normal", display_name="description detail",
111
+ tooltip="How dense the callouts are. brief writes a short "
112
+ "opening sentence then only lists moments where "
113
+ "the pose changes. detailed writes one callout "
114
+ "for every sampled timestamp (~0.5s apart). "
115
+ "normal is in between."),
116
+ io.Combo.Input(
117
+ "scene_sensitivity", options=["low", "normal", "high"],
118
+ default="normal", display_name="scene sensitivity",
119
+ tooltip="Cut detection. Most motion-transfer clips are "
120
+ "one shot; the detector only fires if the cut is "
121
+ "obvious, and the output collapses the shot "
122
+ "header when there's only one."),
123
+ io.Float.Input(
124
+ "min_shot", default=0.5, min=0.2, max=30.0, step=0.1,
125
+ display_name="min shot",
126
+ tooltip="Shortest shot allowed, in seconds. Two cuts "
127
+ "closer together than this become one."),
128
+ io.Combo.Input(
129
+ "max_frame_size", options=FRAME_SIZES, default=768,
130
+ display_name="max frame size",
131
+ tooltip="Maximum width frames are scaled to before the "
132
+ "model sees them. Ceiling, not target -- a "
133
+ "smaller source is never upscaled. Drop to 512 "
134
+ "for speed, raise to 1024 if the model is "
135
+ "missing fine limb positions."),
136
+ io.Combo.Input(
137
+ "frame_layout", options=["grid", "separate"],
138
+ default="grid", optional=True, advanced=True,
139
+ display_name="frame layout",
140
+ tooltip="grid composites each beat's frames into one "
141
+ "image and shows motion at a glance. separate "
142
+ "sends them as individual images -- more spatial "
143
+ "detail per frame, several times slower. For "
144
+ "motion the grid is almost always the right "
145
+ "choice because temporal order is what matters."),
146
+ io.Combo.Input(
147
+ "whisper_model", options=WHISPER_MODELS, default="off",
148
+ display_name="whisper model",
149
+ tooltip="Speech transcription. Defaulted OFF because "
150
+ "motion clips usually have music, not dialogue, "
151
+ "and Whisper on music beds returns confident "
152
+ "nonsense. Turn on only if the clip has a "
153
+ "spoken cue or count worth capturing."),
154
+ io.Combo.Input(
155
+ "audio_language", options=LANGUAGE_CHOICES, default="auto",
156
+ display_name="audio language",
157
+ tooltip="Spoken language, if you enabled whisper. Leave "
158
+ "on auto to detect it."),
159
+ # Deliberately no control_after_generate. See the transcriber
160
+ # for the full explanation: ComfyUI caches unchanged inputs,
161
+ # a self-randomising seed misses every queue, and generation
162
+ # is greedy so the re-run would be byte-identical anyway.
163
+ io.Int.Input(
164
+ "seed", default=0, min=0, max=0xffffffffffffffff,
165
+ optional=True, advanced=True,
166
+ tooltip="Only used if temperature is raised in llm.py. "
167
+ "Output is greedy and repeatable, so this is "
168
+ "fixed on purpose."),
169
+ ],
170
+ outputs=[
171
+ io.Image.Output(display_name="images"),
172
+ io.Audio.Output(display_name="audio"),
173
+ io.Float.Output(display_name="fps"),
174
+ io.Int.Output(display_name="frame_count"),
175
+ io.String.Output(display_name="full description"),
176
+ io.String.Output(display_name="overview"),
177
+ io.String.Output(display_name="characters identified"),
178
+ io.String.Output(display_name="shots"),
179
+ io.String.Output(display_name="audio transcription"),
180
+ io.String.Output(display_name="audio language"),
181
+ ],
182
+ )
183
+
184
+ @classmethod
185
+ def validate_inputs(cls, input_types=None, **kwargs):
186
+ """Skip ComfyUI's link type check so bypass doesn't fail the graph.
187
+
188
+ See VideoAutoTranscribe for the full explanation -- same reasoning
189
+ applies here.
190
+ """
191
+ return True
192
+
193
+ VALIDATE_INPUTS = validate_inputs
194
+
195
+ @classmethod
196
+ def _switched_off(cls, reason: str = "") -> io.NodeOutput:
197
+ """Empty outputs for a run that's deliberately doing nothing.
198
+
199
+ Strings are a single space rather than empty: some downstream text
200
+ nodes (Preview Text and its cousins) raise on an empty STRING input
201
+ instead of showing an empty box, which would take the whole graph
202
+ down exactly when the user has switched a branch off. A single space
203
+ passes every truthiness check downstream and reads as empty to
204
+ anything that strips whitespace.
205
+ """
206
+ import torch
207
+ if reason:
208
+ log.info("[Nugget] %s -- skipping, all outputs are empty", reason)
209
+ blank = " "
210
+ return io.NodeOutput(torch.zeros((1, 1, 1, 3)),
211
+ video_mod.silence(), 0.0, 0,
212
+ blank, blank, blank, blank, blank, blank)
213
+
214
+ @classmethod
215
+ def execute(cls, clip, video=None, prompt_max_length=0,
216
+ description_detail="normal",
217
+ scene_sensitivity="normal", min_shot=0.5, max_frame_size=768,
218
+ frame_layout="grid", whisper_model="off",
219
+ audio_language="auto", seed=0) -> io.NodeOutput:
220
+
221
+ detail, sensitivity = description_detail, scene_sensitivity
222
+ language = speech_mod.language_code(audio_language)
223
+ frame_size = max_frame_size
224
+
225
+ started = time.time()
226
+
227
+ # Bypassed Load Video hands us None. That's a branch being switched
228
+ # off, not a broken graph, so return empty strings that join onto
229
+ # downstream prompts without leaving a stray line.
230
+ if video_mod.missing(video):
231
+ log.info("[Nugget] no usable video on the input "
232
+ "(unconnected, or Load Video is bypassed) -- skipping")
233
+ return cls._switched_off()
234
+
235
+ llm.check(clip) # fail fast on a bad graph
236
+ try:
237
+ handle = video_mod.open_clip(video)
238
+ except video_mod.NoSource as exc:
239
+ return cls._switched_off(f"nothing usable on the video input ({exc})")
240
+ wav = None
241
+
242
+ try:
243
+ if handle.duration > MAX_SECONDS:
244
+ raise video_mod.VideoError(
245
+ f"That clip is {handle.duration:.0f}s. This node is built "
246
+ f"for clips under {MAX_SECONDS:.0f}s -- trim it, or split "
247
+ f"it and describe the parts separately.")
248
+ if handle.variable_fps:
249
+ log.warning("[Nugget] variable frame rate: tagged %.2f fps "
250
+ "but the real average is %.2f. Callout timestamps "
251
+ "come from each frame's own timestamp, so they "
252
+ "are correct.",
253
+ handle.declared_fps, handle.fps)
254
+
255
+ floor = float(min_shot)
256
+ if floor > handle.duration / 2:
257
+ floor = max(0.2, handle.duration / 4)
258
+ log.warning("[Nugget] min_shot %.1fs is too long for a %.1fs "
259
+ "clip; using %.2fs", min_shot, handle.duration, floor)
260
+
261
+ log.info("[Nugget] %dx%d, %.2fs",
262
+ handle.width, handle.height, handle.duration)
263
+
264
+ # Cache exactly like the transcriber, on all the settings that
265
+ # change the output. Motion transfer runs are slow and re-queuing
266
+ # the same clip with the same settings should be free.
267
+ settings = {"prompt_max_length": prompt_max_length,
268
+ "detail": detail,
269
+ "sensitivity": sensitivity,
270
+ "min_shot": round(float(floor), 3),
271
+ "frame_size": int(frame_size),
272
+ "frame_layout": frame_layout,
273
+ "whisper": whisper_model,
274
+ "language": language or "auto",
275
+ "seed": seed,
276
+ # Namespace the cache: same key would otherwise
277
+ # collide with a transcriber run on the same file,
278
+ # and the two nodes' outputs have different shapes.
279
+ "node": "motion_transfer"}
280
+ cache_key = recall.key(handle.path, clip, settings, handle.temporary)
281
+ remembered = recall.get(cache_key)
282
+ if remembered and remembered[0].strip():
283
+ log.info("[Nugget] already described this clip's motion with "
284
+ "these settings -- reusing the text, decoding the "
285
+ "frames again")
286
+ try:
287
+ all_frames, real_fps, frame_count = video_mod.decode_all(handle)
288
+ audio_cached = video_mod.audio_tensor(handle)
289
+ except video_mod.VideoError as exc:
290
+ import torch
291
+ log.warning("[Nugget] using cached description, but frames "
292
+ "could not be decoded (%s) -- images output "
293
+ "is a 1x1 placeholder", exc)
294
+ all_frames = torch.zeros((1, 1, 1, 3))
295
+ audio_cached = video_mod.silence()
296
+ real_fps, frame_count = 0.0, 0
297
+ return io.NodeOutput(all_frames, audio_cached,
298
+ real_fps, frame_count, *remembered)
299
+
300
+ # Full frames and soundtrack so this node stands in for a Get
301
+ # Video Components plus a frame count.
302
+ all_frames, real_fps, frame_count = video_mod.decode_all(handle)
303
+ audio_out = video_mod.audio_tensor(handle)
304
+ log.info("[Nugget] %d frames at %.2f fps", frame_count, real_fps)
305
+
306
+ # --- cuts. If the clip is one continuous shot, we'll notice
307
+ # here and drop the shot header from the output later.
308
+ scores, motion = video_mod.scan(handle)
309
+ _interrupted()
310
+ cuts = detect.find_cuts(scores, sensitivity, floor,
311
+ 0.0, handle.duration)
312
+ shots = detect.build_shots(cuts, 0.0, handle.duration)
313
+ log.info("[Nugget] %d shot(s), cuts at %s",
314
+ len(shots), [round(c, 3) for c in cuts])
315
+
316
+ # --- speech, before the encoder is touched.
317
+ total_beats = sum(len(prompts.beats_for(shot, detail))
318
+ for shot in shots)
319
+ progress = _Progress(total_beats + 3)
320
+ if whisper_model == "off":
321
+ progress.step("skipping audio (default for motion transfer)")
322
+ speech = speech_mod.Speech(note="transcription turned off")
323
+ else:
324
+ progress.step(f"transcribing with {whisper_model}")
325
+ wav = video_mod.extract_audio(handle)
326
+ speech = speech_mod.transcribe(wav, whisper_model, language)
327
+ video_mod.cleanup(wav)
328
+ wav = None
329
+ _interrupted()
330
+
331
+ # --- plan every frame the run needs, then decode once.
332
+ progress.step("decoding frames")
333
+ context = prompts.context_times(shots)
334
+
335
+ plan = [] # (shot, beat_start, beat_end, [times])
336
+ for shot in shots:
337
+ beats = prompts.beats_for(shot, detail)
338
+ for start, end in beats:
339
+ count = prompts.frames_for(end - start, detail)
340
+ plan.append((shot, start, end,
341
+ prompts.sample_times(start, end, count)))
342
+
343
+ wanted = list(context)
344
+ for _, _, _, times in plan:
345
+ wanted.extend(times)
346
+ decoded = {round(t, 3): image for t, image
347
+ in video_mod.frames_at(handle, wanted,
348
+ width=int(frame_size))}
349
+
350
+ def frames(times):
351
+ return [decoded[key] for key in (round(t, 3) for t in times)
352
+ if key in decoded]
353
+
354
+ def payload(images):
355
+ if frame_layout == "grid":
356
+ return (video_mod.to_grid(images, int(frame_size)),
357
+ len(images))
358
+ return images, len(images)
359
+
360
+ # --- vision. VRAM check before the encoder loads -- same
361
+ # rationale as the transcriber.
362
+ try:
363
+ from comfy import model_management
364
+ free = model_management.get_free_memory(
365
+ model_management.get_torch_device())
366
+ log.info("[Nugget] %.1f GB free before loading the text "
367
+ "encoder", free / (1024 ** 3))
368
+ if free < 5 * (1024 ** 3):
369
+ log.warning("[Nugget] under 5 GB free -- if ComfyUI exits "
370
+ "without an error, the encoder is too large "
371
+ "for this card. Try a smaller one.")
372
+ except Exception:
373
+ pass
374
+
375
+ budget = prompts.token_budget(detail, prompt_max_length)
376
+
377
+ wide, _ = payload(frames(context))
378
+ progress.step("overall motion and subject")
379
+ overview = llm.ask(clip, prompts.OVERVIEW, wide,
380
+ budget["overview"], seed)
381
+ _interrupted()
382
+ subjects = prompts.clean_subjects(
383
+ llm.ask(clip, prompts.SUBJECTS, wide, budget["subject"], seed))
384
+
385
+ tokens = budget["shot"]
386
+ shot_template = prompts.shot_prompt(detail)
387
+ grouped: dict = {}
388
+
389
+ for position, (shot, start, end, times) in enumerate(plan):
390
+ _interrupted()
391
+ progress.step(f"shot {shot.index} of {len(shots)}"
392
+ + (f", beat at {prompts.clock(start)}"
393
+ if len(prompts.beats_for(shot, detail)) > 1
394
+ else ""))
395
+ images, count = payload(frames(times))
396
+ first = shot.index not in grouped
397
+
398
+ if frame_layout == "grid" and count > 1:
399
+ layout = prompts.GRID_NOTE.format(count=count)
400
+ else:
401
+ layout = (f"These {count} frames are from ONE continuous "
402
+ f"shot, in order.")
403
+ call_started = time.time()
404
+ movement = camera.summarise(motion, start, end)
405
+ # The timestamps handed to the model are the actual sampled
406
+ # frame times, so every callout the model writes can be
407
+ # traced back to a frame it was shown.
408
+ ts_line = prompts.timestamp_list(times)
409
+ aim = prompts.aim_callouts(times, detail)
410
+ prompt = shot_template.format(
411
+ layout=layout,
412
+ span=end - start,
413
+ start_clock=prompts.clock(start),
414
+ end_clock=prompts.clock(end),
415
+ timestamp_list=ts_line,
416
+ aim=aim,
417
+ subjects=prompts.SUBJECTS_KNOWN.format(subjects=subjects)
418
+ if subjects else "",
419
+ camera=prompts.CAMERA_FACT.format(motion=movement)
420
+ if movement else "")
421
+ if not first:
422
+ prompt += prompts.CONTINUES
423
+
424
+ try:
425
+ text = llm.ask(clip, prompt, images, tokens, seed)
426
+ except llm.LLMError as exc:
427
+ log.warning("[Nugget] shot %d beat %d failed: %s",
428
+ shot.index, position, exc)
429
+ text = f"(this part could not be described: {exc})"
430
+ log.info("[Nugget] shot %d beat %s took %.1fs "
431
+ "(%d frames, %d token budget)", shot.index,
432
+ prompts.clock(start), time.time() - call_started,
433
+ count, tokens)
434
+ if llm.looks_truncated(text, tokens):
435
+ log.warning("[Nugget] shot %d ran out of tokens at %d and "
436
+ "stopped mid-sentence -- raise "
437
+ "prompt_max_length", shot.index, tokens)
438
+ grouped.setdefault(shot.index, []).append(
439
+ (start, end, text or "(no callouts returned)"))
440
+
441
+ described = [(shot, grouped.get(shot.index,
442
+ [(shot.start, shot.end,
443
+ "(not described)")]))
444
+ for shot in shots]
445
+
446
+ part = prompts.sections(overview, subjects, described, speech)
447
+ elapsed = time.time() - started
448
+ log.info("[Nugget] done in %.1fs across %d model call(s), "
449
+ "%d token budget per shot", elapsed, len(plan) + 2, tokens)
450
+ if elapsed > 120 and prompt_max_length > 500:
451
+ log.warning("[Nugget] that run took %.0fs with "
452
+ "prompt_max_length at %d. Detailed mode already "
453
+ "generates a callout per timestamp; 0 (automatic) "
454
+ "is much faster and the log says when it isn't "
455
+ "enough.", elapsed, prompt_max_length)
456
+
457
+ written = (prompts.assemble(overview, subjects, described, speech),
458
+ part["overview"],
459
+ part["subjects"],
460
+ part["shots"],
461
+ prompts.transcript_text(speech),
462
+ speech.language_full or "none")
463
+ recall.put(cache_key, written)
464
+ return io.NodeOutput(all_frames, audio_out, real_fps, frame_count,
465
+ *written)
466
+ finally:
467
+ video_mod.cleanup(wav)
468
+ if handle.temporary:
469
+ video_mod.cleanup(handle.path)
470
+
471
+
472
+ NODES = [NuggetMotionTransfer]
custom_nodes/ComfyUI-Nugget/nodes_prompt.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generating a prompt from reference images, in one node.
2
+
3
+ This replaces core's ``Generate Text`` plus a separate image batching node. The
4
+ merge is not cosmetic: batching and generating are the same decision.
5
+
6
+ ComfyUI's IMAGE type is one tensor shaped ``(batch, height, width, channels)``,
7
+ so every image in a batch must share a height and a width. That is a constraint
8
+ of the tensor, not of the model -- the vision encoder handles varied aspect
9
+ ratios natively. A node that batches images before handing them over is forced
10
+ to crop or pad them to match; a node that holds them separately is not.
11
+
12
+ So the images are kept apart for as long as possible and handed over as a list.
13
+ If this build of ComfyUI insists on one stacked tensor, the node falls back to
14
+ padding them onto a shared canvas and says so in the log once. Either way the
15
+ caller does nothing.
16
+
17
+ ``max_image_size`` caps each image independently, preserving its aspect ratio and
18
+ never upscaling. When the references share a shape -- the usual case -- that
19
+ alone makes them identical sizes, so even the fallback has nothing to pad.
20
+ """
21
+
22
+ from __future__ import annotations
23
+ import logging
24
+ import os
25
+
26
+ from comfy_api.latest import io
27
+
28
+ try:
29
+ from . import fingerprint, llm, nodes_image
30
+ except ImportError: # direct import in the test suite
31
+ import fingerprint
32
+ import llm
33
+ import nodes_image
34
+
35
+ log = logging.getLogger("Nugget")
36
+
37
+ MAX_INPUTS = 9
38
+
39
+ IMAGE_SIZES = [384, 512, 768, 1024, 1280, 1536, 2048]
40
+
41
+ # Appended to the prompt when set. "off" adds nothing, which is the right
42
+ # default: the usual job here is a full system prompt that already specifies its
43
+ # own format, and bolting a length instruction onto that just fights it. The
44
+ # other settings are for using this node as a plain captioner.
45
+ DETAIL = {
46
+ "off": ("", 0),
47
+ "brief": ("\n\nDescribe each reference image in one sentence.", 200),
48
+ "normal": ("\n\nDescribe each reference image in two or three sentences, "
49
+ "covering what it shows, clothing and colours, and the setting.",
50
+ 450),
51
+ "detailed": ("\n\nDescribe each reference image thoroughly: the subject, "
52
+ "their clothing and its colours and materials, hair, build, "
53
+ "the setting, the lighting, and the framing. Describe only what "
54
+ "is visible.", 900),
55
+ }
56
+
57
+
58
+ def _sampling_params():
59
+ return [
60
+ io.Float.Input("temperature", default=0.7, min=0.05, max=2.0, step=0.05,
61
+ tooltip="Higher is more varied."),
62
+ io.Float.Input("repetition_penalty", default=1.0, min=1.0, max=1.5,
63
+ step=0.01, display_name="repetition penalty",
64
+ tooltip="1.0 is off, and off is right for structured "
65
+ "output. A prompt format is supposed to repeat "
66
+ "its own labels."),
67
+ ]
68
+
69
+
70
+ SAMPLING_TIP = ("off is greedy: faster, and the same input gives the same "
71
+ "output. on adds randomness and reveals its parameters.")
72
+
73
+ # Set at import so the log can say which shape worked, and so a workflow author
74
+ # knows whether temperature is a top-level widget or nested under sampling_mode.
75
+ DYNAMIC_SAMPLING = False
76
+
77
+
78
+ def sampling_input():
79
+ """The sampling control, hiding its parameters when it is off.
80
+
81
+ Core's Generate Text uses a DynamicCombo for this, and Save Video's saved
82
+ keys (``format.codec``) confirm the type exists. The constructor signature
83
+ is not documented anywhere I could find and has been changing, so rather
84
+ than guess once, each plausible shape is tried and the one that builds is
85
+ used. A total failure falls back to plain always-visible widgets: losing the
86
+ auto-hide is a cosmetic loss, losing the node is not.
87
+ """
88
+ global DYNAMIC_SAMPLING
89
+
90
+ shapes = (
91
+ ("Option(id, inputs=[...])",
92
+ lambda: [io.DynamicCombo.Option("off"),
93
+ io.DynamicCombo.Option("on", inputs=_sampling_params())]),
94
+ ("Option(id=..., inputs=[...])",
95
+ lambda: [io.DynamicCombo.Option(id="off"),
96
+ io.DynamicCombo.Option(id="on", inputs=_sampling_params())]),
97
+ ("Option(id, [...])",
98
+ lambda: [io.DynamicCombo.Option("off", []),
99
+ io.DynamicCombo.Option("on", _sampling_params())]),
100
+ ("Option(value=..., inputs=[...])",
101
+ lambda: [io.DynamicCombo.Option(value="off"),
102
+ io.DynamicCombo.Option(value="on", inputs=_sampling_params())]),
103
+ )
104
+
105
+ tried = []
106
+ for label, build in shapes:
107
+ try:
108
+ control = io.DynamicCombo.Input(
109
+ "sampling_mode", options=build(), display_name="sampling",
110
+ tooltip=SAMPLING_TIP)
111
+ except Exception as exc:
112
+ tried.append(f"{label} -> {type(exc).__name__}: {exc}")
113
+ continue
114
+ DYNAMIC_SAMPLING = True
115
+ log.info("[Nugget] sampling parameters will hide when sampling is off "
116
+ "(DynamicCombo built with %s)", label)
117
+ return [control]
118
+
119
+ log.warning("[Nugget] could not build a DynamicCombo, so the sampling "
120
+ "parameters will always be visible. Tried: %s", " | ".join(tried))
121
+ return [
122
+ io.Combo.Input("sampling_mode", options=["off", "on"], default="off",
123
+ display_name="sampling", tooltip=SAMPLING_TIP),
124
+ ] + _sampling_params()
125
+
126
+
127
+ # Remembered so the letterbox notice is logged once per session rather than on
128
+ # every generation.
129
+ _LIST_MODE: dict = {}
130
+
131
+
132
+ def cap(frames, longest: int):
133
+ """Scale each frame so neither side exceeds ``longest``. Never upscales.
134
+
135
+ Each image is handled on its own, so they keep their own aspect ratios and
136
+ end up at different sizes. That is the point: nothing is cropped and nothing
137
+ is letterboxed to match a neighbour.
138
+ """
139
+ import comfy.utils
140
+
141
+ out = []
142
+ for frame in frames:
143
+ height, width = int(frame.shape[0]), int(frame.shape[1])
144
+ ratio = min(longest / max(1, width), longest / max(1, height), 1.0)
145
+ if ratio >= 1.0:
146
+ out.append(frame)
147
+ continue
148
+ new_w = max(1, int(round(width * ratio)))
149
+ new_h = max(1, int(round(height * ratio)))
150
+ nchw = frame.unsqueeze(0).movedim(-1, 1).float()
151
+ nchw = comfy.utils.common_upscale(nchw, new_w, new_h, "lanczos", "disabled")
152
+ out.append(nchw.movedim(1, -1).squeeze(0).to(frame.dtype))
153
+ return out
154
+
155
+
156
+ def as_batches(frames):
157
+ """Frames as a list of single-image tensors, ready for the encoder."""
158
+ return [f.unsqueeze(0) for f in frames]
159
+
160
+
161
+ def uniform(frames) -> bool:
162
+ sizes = {(int(f.shape[0]), int(f.shape[1])) for f in frames}
163
+ return len(sizes) <= 1
164
+
165
+
166
+ # The order the widgets are declared in, which is the order ComfyUI saves them.
167
+ # A workflow's positional widgets_values is matched against this, so inserting a
168
+ # widget without updating any saved workflow shifts every value after it -- that
169
+ # has now happened twice, so the test suite asserts this list against the schema.
170
+ WIDGET_ORDER = ["img_description_detail", "prompt", "max_image_size",
171
+ "max_length", "sampling_mode", "temperature",
172
+ "repetition_penalty", "seed"]
173
+
174
+
175
+ class NuggetGeneratePrompt(io.ComfyNode):
176
+ @classmethod
177
+ def define_schema(cls):
178
+ return io.Schema(
179
+ node_id="NuggetGeneratePrompt",
180
+ display_name="Generate prompt (with references)",
181
+ category="Nugget",
182
+ search_aliases=["generate text", "prompt", "enhance", "llm",
183
+ "reference images"],
184
+ description=(
185
+ "Generates text from a prompt and up to 9 reference images, "
186
+ "using the language model loaded in Load CLIP. Replaces core's "
187
+ "Generate Text plus an image batching node: images keep their "
188
+ "own aspect ratios and are never cropped."),
189
+ inputs=[
190
+ io.Clip.Input("clip", tooltip="A Gemma or Qwen3-VL text encoder."),
191
+ io.Combo.Input(
192
+ "img_description_detail", options=list(DETAIL.keys()),
193
+ default="off", display_name="img description detail",
194
+ tooltip="Appends an instruction asking the model to describe "
195
+ "the reference images, and raises the token budget to "
196
+ "suit. Leave off when the prompt already says what it "
197
+ "wants -- an H3 system prompt specifies its own "
198
+ "format and this would fight it."),
199
+ io.String.Input("prompt", multiline=True, default="",
200
+ tooltip="The instruction, usually a system "
201
+ "prompt joined with the user's text."),
202
+ *[io.Image.Input(f"image_{i}", optional=True)
203
+ for i in range(1, MAX_INPUTS + 1)],
204
+ io.Combo.Input(
205
+ "max_image_size", options=IMAGE_SIZES, default=1024,
206
+ display_name="max image size",
207
+ tooltip="Longest side each reference is scaled to. A cap, "
208
+ "not a target -- smaller images are left alone. "
209
+ "Lower it if generation is slow or memory is tight."),
210
+ io.Int.Input(
211
+ "max_length", default=1024, min=16, max=8192, step=16,
212
+ display_name="max length",
213
+ tooltip="Token budget for the reply. A full six-section H3 "
214
+ "prompt runs about 600-700 tokens, so 1024 leaves "
215
+ "headroom. A budget you do not reach costs nothing."),
216
+ ] + sampling_input() + [
217
+ io.Int.Input(
218
+ "seed", default=0, min=0, max=0xffffffffffffffff,
219
+ control_after_generate=True, optional=True,
220
+ tooltip="Leave on 'fixed' and re-queueing is free: the "
221
+ "node recognises an unchanged prompt, unchanged "
222
+ "images and unchanged settings and hands back what "
223
+ "it said last time. 'randomize' forces a fresh "
224
+ "generation -- but only produces DIFFERENT text "
225
+ "when sampling is on. With sampling off it is "
226
+ "minutes of work for an identical answer."),
227
+ ],
228
+ outputs=[
229
+ io.String.Output(display_name="generated_text"),
230
+ ],
231
+ )
232
+
233
+ # The settings that actually change the text. Widget values as they arrive,
234
+ # before execute() folds img_description_detail into the prompt and the
235
+ # budget -- that folding is deterministic, so the raw values identify the
236
+ # request just as exactly and both halves of the caching agree by
237
+ # construction.
238
+ @classmethod
239
+ def _request(cls, clip, images, prompt="", max_image_size=1024,
240
+ max_length=1024, sampling_mode="off",
241
+ img_description_detail="off", temperature=0.7,
242
+ repetition_penalty=1.0, seed=0):
243
+ settings = {"prompt": prompt or "",
244
+ "max_image_size": int(max_image_size),
245
+ "max_length": int(max_length),
246
+ "sampling_mode": sampling_mode,
247
+ "img_description_detail": img_description_detail,
248
+ "seed": int(seed)}
249
+ # Temperature and repetition penalty only reach the model when sampling
250
+ # is on. Keying on them regardless would make nudging a hidden widget
251
+ # throw away a perfectly good answer.
252
+ if sampling_mode == "on":
253
+ settings["temperature"] = round(float(temperature), 4)
254
+ settings["repetition_penalty"] = round(float(repetition_penalty), 4)
255
+ return fingerprint.key(clip, settings, images, MAX_INPUTS)
256
+
257
+ @classmethod
258
+ def validate_inputs(cls, input_types=None, **kwargs):
259
+ """Skip ComfyUI's link type check, so a bypassed image source is survivable.
260
+
261
+ Bypassing a node makes it pass through whatever is on its own inputs.
262
+ Load Image has no IMAGE input to pass through, so what arrives on an
263
+ image socket is not an IMAGE, and ComfyUI rejects the whole prompt with
264
+ "Return type mismatch between linked nodes" during validation -- before
265
+ this node, or any node, executes.
266
+
267
+ Accepting an ``input_types`` parameter is what turns that check off. It
268
+ is the signature that matters, not the parameter names: naming an input
269
+ here would only skip validation of its widget value. Whatever arrives is
270
+ checked in ``collect``, which ignores anything without a shape.
271
+ """
272
+ return True
273
+
274
+ VALIDATE_INPUTS = validate_inputs
275
+
276
+ @classmethod
277
+ def fingerprint_inputs(cls, clip=None, **kwargs):
278
+ """Has this request changed? -- answered from content, not from the graph.
279
+
280
+ ComfyUI's default answer is the node's input signature including every
281
+ ancestor's, which is a question about the shape of the graph rather than
282
+ about what arrived here. Bypassing an upstream node, or connecting a
283
+ socket, rewires what the frontend sends and changes that signature even
284
+ though the text and the pixels reaching this node are identical -- so
285
+ the node regenerated. Fingerprinting the content instead makes a
286
+ re-queue free whenever the request really is the same one.
287
+
288
+ Returned as a hex string because ComfyUI writes this value into the
289
+ workflow it embeds in saved images and serialises it as JSON.
290
+ """
291
+ images = {k: v for k, v in kwargs.items() if k.startswith("image_")}
292
+ widgets = {k: v for k, v in kwargs.items() if not k.startswith("image_")}
293
+ try:
294
+ return cls._request(clip, images, **widgets)
295
+ except Exception as exc:
296
+ # Being wrong here must never break a queue, and "assume it
297
+ # changed" is the safe direction: it costs a regeneration.
298
+ log.debug("[Nugget] could not fingerprint the request (%s)", exc)
299
+ return os.urandom(8).hex()
300
+
301
+ # ComfyUI reads the uppercase name on some builds and the lowercase one on
302
+ # others, and there are releases where the V3 hook is not called at all.
303
+ # Bound both ways, and execute() does not rely on either being honoured.
304
+ IS_CHANGED = fingerprint_inputs
305
+
306
+ @classmethod
307
+ def execute(cls, clip, prompt="", max_image_size=1024, max_length=1024,
308
+ sampling_mode="off", img_description_detail="off", temperature=0.7,
309
+ repetition_penalty=1.0, seed=0, **images) -> io.NodeOutput:
310
+ llm.check(clip)
311
+
312
+ # A DynamicCombo delivers its nested values under prefixed keys on some
313
+ # frontends, so fall back to a suffix match rather than silently using
314
+ # the defaults.
315
+ def nested(name, value, fallback):
316
+ if value != fallback:
317
+ return value
318
+ for key, found in images.items():
319
+ if key.endswith(name) and isinstance(found, (int, float)):
320
+ return found
321
+ return value
322
+
323
+ temperature = nested("temperature", temperature, 0.7)
324
+ repetition_penalty = nested("repetition_penalty", repetition_penalty, 1.0)
325
+ # Anything without a shape is not an image -- a bypassed loader passing
326
+ # its widget value through, most often. Dropped here so it can never
327
+ # reach collect() and be read as a tensor.
328
+ images = {k: v for k, v in images.items()
329
+ if k.startswith("image_") and fingerprint.is_image(v)}
330
+ # Second line of defence. fingerprint_inputs above should mean ComfyUI
331
+ # skips this node entirely on an unchanged request, but it is not
332
+ # honoured on every build, and a purged cache entry re-runs the node
333
+ # regardless. Either way the answer is already known, so hand it back
334
+ # rather than spending minutes reproducing it.
335
+ cache_key = ""
336
+ try:
337
+ cache_key = cls._request(
338
+ clip, images, prompt=prompt, max_image_size=max_image_size,
339
+ max_length=max_length, sampling_mode=sampling_mode,
340
+ img_description_detail=img_description_detail,
341
+ temperature=temperature, repetition_penalty=repetition_penalty,
342
+ seed=seed)
343
+ except Exception as exc:
344
+ log.debug("[Nugget] could not fingerprint the request (%s)", exc)
345
+ remembered = fingerprint.get(cache_key)
346
+ # An empty answer is not worth keeping or returning -- it would pass a
347
+ # failed generation off as a finished one.
348
+ if remembered:
349
+ log.info("[Nugget] same prompt, same images, same settings -- "
350
+ "reusing the previous text instead of generating again")
351
+ return io.NodeOutput(remembered)
352
+
353
+ # Collect frames FIRST so we know the real reference count before
354
+ # assembling the prompt. A composite / character-sheet reference is one
355
+ # tensor to the encoder; if the prompt does not say so, the model
356
+ # counts by what it can see and calls a 4-panel sheet four pictures,
357
+ # a 2x2 grid two, and so on. The count of *sockets* connected is what
358
+ # the downstream <picture N> placeholders are meant to match.
359
+ frames = []
360
+ try:
361
+ frames = nodes_image.collect(images)
362
+ except ValueError:
363
+ # No references is legitimate -- this is still a text generator.
364
+ pass
365
+
366
+ suffix, detail_budget = DETAIL.get(img_description_detail, DETAIL["off"])
367
+ if suffix and frames:
368
+ n = len(frames)
369
+ if n == 1:
370
+ grounding = (
371
+ "\n\nThere is exactly ONE reference image. Refer to it as "
372
+ "<picture 1>. If it contains multiple panels, poses, views "
373
+ "or crops of the same subject, describe them together as "
374
+ "parts of that one picture -- do NOT split them into "
375
+ "separate pictures.")
376
+ else:
377
+ grounding = (
378
+ f"\n\nThere are exactly {n} reference images, in order. "
379
+ f"Label them <picture 1> through <picture {n}>. If any "
380
+ "single image contains multiple panels, poses, views or "
381
+ "crops of one subject, keep those together under that "
382
+ "image's label -- do NOT split one image's panels into "
383
+ "separate pictures.")
384
+ suffix = grounding + suffix
385
+ prompt = (prompt or "") + suffix
386
+ if detail_budget:
387
+ max_length = max(int(max_length), detail_budget)
388
+
389
+ payload = None
390
+ if frames:
391
+ frames = cap(frames, int(max_image_size))
392
+ payload = as_batches(frames)
393
+
394
+ text = llm.ask(
395
+ clip, prompt, payload, int(max_length), int(seed),
396
+ temperature=float(temperature),
397
+ repetition_penalty=float(repetition_penalty),
398
+ # The widget is the single source of truth. Passing a temperature of
399
+ # zero to mean "off" is what made the control appear to do nothing.
400
+ sample=(sampling_mode == "on"),
401
+ note=_LIST_MODE)
402
+
403
+ if llm.looks_truncated(text, int(max_length)):
404
+ log.warning("[Nugget] the reply ran out of tokens at %d and stopped "
405
+ "mid-sentence -- raise max length", int(max_length))
406
+ if text and text.strip():
407
+ fingerprint.put(cache_key, text)
408
+ return io.NodeOutput(text)
409
+
410
+
411
+ NODES = [NuggetGeneratePrompt]
custom_nodes/ComfyUI-Nugget/nodes_transcribe.py ADDED
@@ -0,0 +1,642 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The node.
2
+
3
+ Everything that has one correct value for a description task -- temperature,
4
+ top_k, top_p, min_p, both penalties, thinking mode, the chat template -- is
5
+ fixed in ``llm.py`` rather than put on the face of the node. The widgets that
6
+ do show are the ones the user should reach for. A ``show`` widget hides the
7
+ less-used ones (via web/js/nugget_transcribe_ui.js) so the compact face is
8
+ short.
9
+
10
+ The order of work is deliberate and is the one thing not to rearrange:
11
+
12
+ read the video -> transcribe -> release the speech model -> touch the clip
13
+
14
+ The clip is not touched until the last step, because that is what triggers
15
+ ComfyUI to move the text encoder onto the GPU. Transcribing first means the two
16
+ models are never resident together.
17
+
18
+ Frames are decoded once. Every timestamp the run will need -- context frames and
19
+ every shot's frames -- is collected up front and pulled in a single sequential
20
+ pass. Fetching per shot instead means re-decoding the whole file once per shot,
21
+ which on a twenty-shot clip is twenty redundant passes.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import time
28
+
29
+ from comfy_api.latest import io
30
+
31
+ from . import (camera, detect, llm, motion_prompts, prompts, recall,
32
+ speech as speech_mod, video as video_mod)
33
+
34
+ log = logging.getLogger("Nugget")
35
+
36
+ # Past this the vision passes take long enough that people assume it has hung,
37
+ # and the frames stop fitting comfortably in context. Refuse clearly instead.
38
+ MAX_SECONDS = 120.0
39
+
40
+ WHISPER_MODELS = ["large-v3", "large-v3-turbo", "medium", "small", "base",
41
+ "tiny", "off"]
42
+
43
+ FRAME_SIZES = [384, 512, 768, 1024]
44
+
45
+ # Shown as names, passed to Whisper as codes. "auto" is almost always right --
46
+ # the picker exists for clips where detection keeps guessing wrong.
47
+ LANGUAGE_CHOICES = [
48
+ "auto", "English", "Spanish", "French", "German", "Italian", "Portuguese",
49
+ "Dutch", "Polish", "Russian", "Japanese", "Korean", "Chinese", "Cantonese",
50
+ "Arabic", "Hindi", "Turkish", "Vietnamese", "Thai", "Indonesian",
51
+ "Ukrainian", "Swedish", "Norwegian", "Danish", "Finnish", "Greek", "Czech",
52
+ "Romanian", "Hungarian", "Hebrew", "Malay", "Filipino",
53
+ ]
54
+
55
+
56
+ def _interrupted() -> None:
57
+ """Let Cancel actually cancel.
58
+
59
+ Without this a queued run keeps going through every remaining shot after the
60
+ user has pressed stop, because nothing between model calls ever checks.
61
+ """
62
+ try:
63
+ from comfy import model_management
64
+ model_management.throw_exception_if_processing_interrupted()
65
+ except ImportError:
66
+ pass
67
+
68
+
69
+ class _Progress:
70
+ """The node's progress bar, plus a log line. Degrades to logging alone."""
71
+
72
+ def __init__(self, total: int):
73
+ self.bar = None
74
+ try:
75
+ from comfy.utils import ProgressBar
76
+ self.bar = ProgressBar(total)
77
+ except Exception:
78
+ pass
79
+
80
+ def step(self, message: str) -> None:
81
+ log.info("[Nugget] %s", message)
82
+ if self.bar is not None:
83
+ try:
84
+ self.bar.update(1)
85
+ except Exception:
86
+ pass
87
+
88
+
89
+ class VideoAutoTranscribe(io.ComfyNode):
90
+ @classmethod
91
+ def define_schema(cls):
92
+ return io.Schema(
93
+ node_id="VideoAutoTranscribe",
94
+ display_name="Video Auto Transcriber",
95
+ category="Nugget",
96
+ search_aliases=["caption", "transcribe", "describe video", "scene", "shots"],
97
+ description=("Describe a short video shot by shot, with real cut "
98
+ "timestamps and transcribed dialogue folded in."),
99
+ inputs=[
100
+ io.Clip.Input("clip", tooltip="A Gemma text encoder, from Load CLIP."),
101
+ io.Video.Input(
102
+ "video", optional=True,
103
+ tooltip="The clip to caption. Under 2 minutes. Bypass the "
104
+ "Load Video node to switch this node off -- it "
105
+ "returns empty outputs instead of failing."),
106
+ # Mode picks which prompt pack drives the vision passes.
107
+ # General is the original transcribe behaviour; Movements
108
+ # (test) runs the same shot-by-shot pipeline through the
109
+ # motion-transfer prompts, so a single node can do either
110
+ # job without wiring in a second Load Video branch.
111
+ io.Combo.Input(
112
+ "mode", options=["General", "Movements (test)"],
113
+ default="General", display_name="mode",
114
+ tooltip="General describes each shot in prose with "
115
+ "dialogue folded in -- the original behaviour. "
116
+ "Movements (test) writes timestamped body-motion "
117
+ "callouts instead, for feeding into a "
118
+ "reference-to-video prompt (behaves like the "
119
+ "Nugget Motion Transfer node)."),
120
+ io.Int.Input(
121
+ "prompt_max_length", default=0, min=0, max=4096, step=32,
122
+ display_name="prompt max length",
123
+ tooltip="Token budget for each shot description. 0 follows "
124
+ "description detail (General: 160 / 300 / 500, "
125
+ "Movements: 220 / 380 / 700) and is the default. "
126
+ "Raise it if descriptions are stopping "
127
+ "mid-sentence; the log warns when that happens."),
128
+ io.Combo.Input(
129
+ "description_detail", options=list(prompts.DETAIL.keys()),
130
+ default="normal", display_name="description detail",
131
+ tooltip="How much is written per shot, and how many frames the "
132
+ "model sees. Frame count scales with shot length, and "
133
+ "long shots are split into beats. Higher is slower."),
134
+ # Parameter name kept as ``scene_sensitivity`` so old
135
+ # workflows loading against the schema still find the input
136
+ # by name -- only the display name and tooltip change here.
137
+ io.Combo.Input(
138
+ "scene_sensitivity", options=["low", "normal", "high"],
139
+ default="normal", display_name="scene detect",
140
+ tooltip="How aggressively the detector calls a cut. Each "
141
+ "frame boundary is scored for how much the image "
142
+ "changed; the setting decides how big that jump "
143
+ "has to be before it counts.\n\n"
144
+ "low -- conservative. Only large, obvious jumps "
145
+ "become cuts. Use this when the detector is "
146
+ "splitting one shot into two on a whip pan, a "
147
+ "flashbulb, or fast motion. Result: fewer, "
148
+ "longer shots.\n"
149
+ "normal -- balanced. Matches most editorial "
150
+ "cuts. The default.\n"
151
+ "high -- eager. Small jumps also become cuts, so "
152
+ "match cuts, cross-dissolves and quiet scene "
153
+ "changes register. Use this when shots are being "
154
+ "missed. Result: more, shorter shots.\n\n"
155
+ "Pair with 'min shot' to drop cuts closer "
156
+ "together than a chosen minimum, so a high "
157
+ "setting does not fragment the timeline."),
158
+ io.Float.Input(
159
+ "min_shot", default=0.5, min=0.2, max=30.0, step=0.1,
160
+ display_name="min shot",
161
+ tooltip="Shortest shot allowed, in seconds. Two cuts closer "
162
+ "together than this become one, so a high value "
163
+ "discards cuts."),
164
+ io.Combo.Input(
165
+ "frame_layout", options=["grid", "separate"], default="grid",
166
+ optional=True, advanced=True, display_name="frame layout",
167
+ tooltip="grid composites each beat's frames into one image, "
168
+ "which is much cheaper and shows motion at a glance. "
169
+ "separate sends them as individual images -- more "
170
+ "spatial detail per frame, several times slower."),
171
+ io.Combo.Input(
172
+ "whisper_model", options=WHISPER_MODELS, default="large-v3",
173
+ display_name="whisper model",
174
+ tooltip="Speech model. large-v3 is the most accurate; the "
175
+ "smaller ones are faster and need less memory. "
176
+ "'off' skips audio entirely -- useful for clips "
177
+ "with no dialogue, and for telling whether a "
178
+ "problem is in the speech half or the vision "
179
+ "half."),
180
+ io.Combo.Input(
181
+ "audio_language", options=LANGUAGE_CHOICES, default="auto",
182
+ display_name="audio language",
183
+ tooltip="Spoken language. Leave on auto to detect it."),
184
+ # Placed here, next to seed and marked advanced, so that when
185
+ # compact view hides it (see web/js/nugget_transcribe_ui.js)
186
+ # it sits next to another hidden widget (frame_layout) rather
187
+ # than sandwiching whisper_model / audio_language between two
188
+ # hidden widgets -- that sandwich confuses the frontend's
189
+ # widget renderer and the two combos come back as broken
190
+ # socket rows with no dropdown arrows.
191
+ io.Combo.Input(
192
+ "max_frame_size", options=FRAME_SIZES, default=768,
193
+ optional=True, advanced=True,
194
+ display_name="max frame size",
195
+ tooltip="Maximum width frames are scaled to before the model "
196
+ "sees them. This is a ceiling, not a target -- a "
197
+ "smaller source is never upscaled. Drop to 512 for "
198
+ "speed, raise to 1024 if fine detail or on-screen "
199
+ "text is being missed."),
200
+ # Deliberately no control_after_generate here. ComfyUI caches a
201
+ # node whose inputs have not changed, so a self-randomising seed
202
+ # is a cache miss on every queue -- and this node always
203
+ # generates greedily, so the re-run would produce byte-identical
204
+ # output. Minutes of work for nothing. To force a fresh
205
+ # transcription, change a setting or the video.
206
+ io.Int.Input(
207
+ "seed", default=0, min=0, max=0xffffffffffffffff,
208
+ optional=True, advanced=True,
209
+ tooltip="Only used if temperature is raised in llm.py. Output "
210
+ "is greedy and repeatable, so this is fixed on "
211
+ "purpose -- a randomised seed would re-transcribe "
212
+ "the whole video for an identical result."),
213
+ # Placed at the very bottom of the widget list on purpose --
214
+ # it controls what the rest of the widgets look like, so once
215
+ # a workflow is set up it is the least-touched setting. Kept
216
+ # in the Python schema so the widget value is saved with the
217
+ # workflow and restored on load; the hiding itself happens
218
+ # in web/js/nugget_transcribe_ui.js.
219
+ #
220
+ # Marked advanced so ComfyUI sorts it to the end of the
221
+ # widget column alongside 'seed'. The frontend auto-inserts
222
+ # a 'control after generate' widget straight after 'seed';
223
+ # having 'show' also advanced and later in the schema puts
224
+ # it below that auto-widget, which is where the user asked
225
+ # for it. Optional matches the seed row's decoration.
226
+ io.Combo.Input(
227
+ "show", options=["compact", "everything"],
228
+ default="compact", display_name="show",
229
+ optional=True, advanced=True,
230
+ tooltip="compact hides the mode / max frame size / frame "
231
+ "layout widgets, and the overview / characters / "
232
+ "shots / audio transcription / audio language "
233
+ "outputs. Switch to everything to see them all. "
234
+ "An output that is already wired up stays "
235
+ "visible even in compact -- if you connected it, "
236
+ "you probably still want it."),
237
+ ],
238
+ outputs=[
239
+ io.Image.Output(display_name="video_images"),
240
+ io.Audio.Output(display_name="video audio"),
241
+ io.Float.Output(display_name="fps"),
242
+ io.Int.Output(display_name="frame_count"),
243
+ io.String.Output(display_name="full description"),
244
+ io.String.Output(display_name="overview"),
245
+ io.String.Output(display_name="characters identified"),
246
+ io.String.Output(display_name="shots"),
247
+ io.String.Output(display_name="audio transcription"),
248
+ io.String.Output(display_name="audio language"),
249
+ ],
250
+ )
251
+
252
+ @classmethod
253
+ def validate_inputs(cls, input_types=None, **kwargs):
254
+ """Skip ComfyUI's link type check, so bypass does not fail the graph.
255
+
256
+ Accepting an ``input_types`` parameter is what turns the check off --
257
+ that is the documented switch, and it is the whole signature that
258
+ matters, not the parameter names. Naming an input here only skips
259
+ validation of its *widget* value, which is not what fails.
260
+
261
+ It has to be off because bypassing a node makes it pass through a
262
+ reference to whatever is on its own inputs. Load Video has no VIDEO
263
+ input to pass through, so what reaches this socket is not a VIDEO --
264
+ and ComfyUI rejects the whole prompt with "Return type mismatch
265
+ between linked nodes" during validation, before any node executes.
266
+ Nothing inside ``execute`` ever gets the chance to run. Whatever
267
+ arrives is checked there instead.
268
+
269
+ Bypassing a node makes it pass through a reference to whatever is on its
270
+ inputs. Load Video has no VIDEO input to pass through, so what reaches
271
+ this socket is not a VIDEO -- and ComfyUI rejects the whole prompt with
272
+ "Return type mismatch between linked nodes" during validation, before
273
+ any node executes. The skip in ``execute`` never gets the chance to run.
274
+
275
+ Naming a parameter after an input tells ComfyUI not to validate that
276
+ input, which lets the prompt through so ``execute`` can decide for
277
+ itself. Whatever arrives is checked there instead.
278
+ """
279
+ return True
280
+
281
+ # ComfyUI reads the uppercase name. Bound here as well as declared
282
+ # above so the signature survives however the V3 wrapper exposes it.
283
+ VALIDATE_INPUTS = validate_inputs
284
+
285
+ @classmethod
286
+ def _switched_off(cls, reason: str = "") -> io.NodeOutput:
287
+ """The outputs for a run that is deliberately doing nothing.
288
+
289
+ An 8-frame 64x64 black tensor rather than None or a single pixel: an
290
+ IMAGE output that is None breaks anything downstream that expects a
291
+ tensor, and the point of a skipped run is that nothing downstream falls
292
+ over. A 1x1x1 placeholder is technically a tensor but many video nodes
293
+ reject it -- MiniMax H3, for instance, needs at least 5 frames and
294
+ raises ValueError on anything shorter. 8 frames clears that bar with
295
+ headroom for other minimums, and 64x64 avoids nodes that reject
296
+ sub-patch-size dimensions. The audio is a second of silence for the
297
+ same reason.
298
+
299
+ The strings are a single space rather than empty: some downstream text
300
+ nodes (Preview Text and its cousins) raise on an empty STRING input
301
+ instead of showing an empty box, which would take the whole graph down
302
+ exactly when the user has switched a branch off. A single space passes
303
+ every truthiness check downstream, joins into a prompt as nothing
304
+ meaningful, and reads as "empty" to anything that strips whitespace.
305
+ """
306
+ import torch
307
+ if reason:
308
+ log.info("[Nugget] %s -- skipping, all outputs are empty", reason)
309
+ blank = " "
310
+ return io.NodeOutput(torch.zeros((8, 64, 64, 3)),
311
+ video_mod.silence(), 0.0, 0,
312
+ blank, blank, blank, blank, blank, blank)
313
+
314
+ @classmethod
315
+ def execute(cls, clip, video=None, show="compact", mode="General",
316
+ prompt_max_length=0, description_detail="normal",
317
+ scene_sensitivity="normal", min_shot=0.5, max_frame_size=768,
318
+ frame_layout="grid", whisper_model="large-v3",
319
+ audio_language="auto", seed=0) -> io.NodeOutput:
320
+
321
+ # ``show`` is a UI-only setting -- the JS extension hides widgets and
322
+ # output sockets based on it, but nothing about the work itself
323
+ # changes, so it never appears again after this line. Accepted here
324
+ # only because ComfyUI passes every widget value into execute.
325
+ del show
326
+
327
+ detail, sensitivity = description_detail, scene_sensitivity
328
+ # The widget shows names; Whisper wants codes.
329
+ language = speech_mod.language_code(audio_language)
330
+ frame_size = max_frame_size
331
+ # Which prompt pack drives the vision passes. Both modules expose the
332
+ # same DETAIL keys, the same clock/sections/assemble/transcript_text
333
+ # signatures, and a token_budget that returns a dict -- so the only
334
+ # place execute needs a mode branch is the per-shot prompt itself,
335
+ # further down. The cache key includes ``mode`` too, so a General run
336
+ # and a Movements run on the same clip do not read each other's
337
+ # cached text back.
338
+ active_prompts = (motion_prompts if mode == "Movements (test)"
339
+ else prompts)
340
+ movements = mode == "Movements (test)"
341
+
342
+ started = time.time()
343
+
344
+ # A bypassed Load Video node hands us None. That is someone switching
345
+ # this branch off, not a broken graph, so do nothing quietly and return
346
+ # empty strings -- they join onto a downstream prompt without leaving a
347
+ # stray "no video" line in it.
348
+ if video_mod.missing(video):
349
+ log.info("[Nugget] no usable video on the input "
350
+ "(unconnected, or the Load Video node is bypassed) -- "
351
+ "skipping, all outputs are empty")
352
+ # A small black tensor rather than None: an IMAGE output that is
353
+ # None breaks anything downstream that expects a tensor, and the
354
+ # point of a skipped run is that nothing downstream falls over. The
355
+ # shape (8 frames, 64x64) clears the frame-count and dimension
356
+ # minimums imposed by common video nodes -- see _switched_off. The
357
+ # audio is a full second of silence for the same reason -- an
358
+ # encoder given a one-sample waveform raises rather than producing
359
+ # nothing.
360
+ return cls._switched_off()
361
+
362
+ llm.check(clip) # fail on a bad graph before doing any slow work
363
+ try:
364
+ handle = video_mod.open_clip(video)
365
+ except video_mod.NoSource as exc:
366
+ # Something arrived on the socket, but it cannot name a file --
367
+ # a bypassed loader passing its widget value through, most often.
368
+ # That is the branch being switched off, not a broken graph.
369
+ return cls._switched_off(f"nothing usable on the video input ({exc})")
370
+ wav = None
371
+
372
+ try:
373
+ if handle.duration > MAX_SECONDS:
374
+ raise video_mod.VideoError(
375
+ f"That clip is {handle.duration:.0f}s. This node is built for "
376
+ f"clips under {MAX_SECONDS:.0f}s -- trim it, or split it and "
377
+ f"caption the parts separately.")
378
+ if handle.variable_fps:
379
+ log.warning("[Nugget] variable frame rate: tagged %.2f fps "
380
+ "but the real average is %.2f. Times here come from each "
381
+ "frame's own timestamp, so they are correct.",
382
+ handle.declared_fps, handle.fps)
383
+
384
+ # A min_shot longer than the clip would suppress every cut and
385
+ # silently return one shot, which looks like broken detection.
386
+ floor = float(min_shot)
387
+ if floor > handle.duration / 2:
388
+ floor = max(0.2, handle.duration / 4)
389
+ log.warning("[Nugget] min_shot %.1fs is too long for a "
390
+ "%.1fs clip; using %.2fs", min_shot, handle.duration, floor)
391
+
392
+ log.info("[Nugget] %dx%d, %.2fs",
393
+ handle.width, handle.height, handle.duration)
394
+ # Has this exact video already been described with exactly these
395
+ # settings? The seed is in the key like everything else, so leaving
396
+ # it fixed is what makes a re-queue free; changing it, or the file,
397
+ # or any setting, is a miss and the work happens again. The frames
398
+ # are still decoded -- that is a second, and they are too large to
399
+ # be worth remembering -- but the minutes of transcription and
400
+ # vision passes are not repeated.
401
+ settings = {"prompt_max_length": prompt_max_length,
402
+ "detail": detail,
403
+ "sensitivity": sensitivity,
404
+ "min_shot": round(float(floor), 3),
405
+ "frame_size": int(frame_size),
406
+ "frame_layout": frame_layout,
407
+ "whisper": whisper_model,
408
+ "language": language or "auto",
409
+ "seed": seed,
410
+ # Namespace by mode: the two prompt packs produce
411
+ # differently-shaped text, and a cached General run
412
+ # served as a Movements answer (or vice versa) would
413
+ # look like a bug in the mode switch.
414
+ "mode": mode}
415
+ cache_key = recall.key(handle.path, clip, settings, handle.temporary)
416
+ remembered = recall.get(cache_key)
417
+ # Only trust an entry that actually holds a description. An empty
418
+ # one would silently pass a skipped run off as a finished one.
419
+ if remembered and remembered[0].strip():
420
+ log.info("[Nugget] already described this video with these "
421
+ "settings -- reusing the text, decoding the frames "
422
+ "again")
423
+ # If the clip is too big for decode_all's RAM guard, the cached
424
+ # text is still valid -- return it with placeholder frames
425
+ # rather than throwing away the cache hit as well.
426
+ try:
427
+ all_frames, real_fps, frame_count = video_mod.decode_all(handle)
428
+ audio_cached = video_mod.audio_tensor(handle)
429
+ except video_mod.VideoError as exc:
430
+ import torch
431
+ log.warning("[Nugget] using cached description, but frames "
432
+ "could not be decoded (%s) -- images output is "
433
+ "an 8-frame 64x64 placeholder", exc)
434
+ all_frames = torch.zeros((8, 64, 64, 3))
435
+ audio_cached = video_mod.silence()
436
+ real_fps, frame_count = 0.0, 0
437
+ return io.NodeOutput(all_frames, audio_cached,
438
+ real_fps, frame_count, *remembered)
439
+
440
+ # Full frames and the soundtrack, so this node can stand in for a
441
+ # Get Video Components plus a frame count.
442
+ all_frames, real_fps, frame_count = video_mod.decode_all(handle)
443
+ audio_out = video_mod.audio_tensor(handle)
444
+ log.info("[Nugget] %d frames at %.2f fps", frame_count, real_fps)
445
+
446
+ # --- cuts ------------------------------------------------------
447
+ scores, motion = video_mod.scan(handle)
448
+ _interrupted()
449
+ cuts = detect.find_cuts(scores, sensitivity, floor, 0.0, handle.duration)
450
+ shots = detect.build_shots(cuts, 0.0, handle.duration)
451
+ log.info("[Nugget] %d shot(s), cuts at %s",
452
+ len(shots), [round(c, 3) for c in cuts])
453
+
454
+ # --- speech, before the encoder is ever touched ----------------
455
+ total_beats = sum(len(active_prompts.beats_for(shot, detail))
456
+ for shot in shots)
457
+ progress = _Progress(total_beats + 3)
458
+ if whisper_model == "off":
459
+ progress.step("skipping audio")
460
+ speech = speech_mod.Speech(note="transcription turned off")
461
+ else:
462
+ progress.step(f"transcribing with {whisper_model}")
463
+ wav = video_mod.extract_audio(handle)
464
+ speech = speech_mod.transcribe(wav, whisper_model, language)
465
+ video_mod.cleanup(wav)
466
+ wav = None
467
+ _interrupted()
468
+
469
+ # --- plan every frame the run needs, then decode once ----------
470
+ progress.step("decoding frames")
471
+ context = active_prompts.context_times(shots)
472
+
473
+ plan = [] # (shot, beat_start, beat_end, [times])
474
+ for shot in shots:
475
+ beats = active_prompts.beats_for(shot, detail)
476
+ for start, end in beats:
477
+ count = active_prompts.frames_for(end - start, detail)
478
+ plan.append((shot, start, end,
479
+ active_prompts.sample_times(start, end, count)))
480
+
481
+ wanted = list(context)
482
+ for _, _, _, times in plan:
483
+ wanted.extend(times)
484
+ decoded = {round(t, 3): image for t, image
485
+ in video_mod.frames_at(handle, wanted, width=int(frame_size))}
486
+
487
+ def frames(times):
488
+ return [decoded[key] for key in (round(t, 3) for t in times)
489
+ if key in decoded]
490
+
491
+ def payload(images):
492
+ if frame_layout == "grid":
493
+ return video_mod.to_grid(images, int(frame_size)), len(images)
494
+ return images, len(images)
495
+
496
+ # --- vision. this is where the encoder loads -------------------
497
+ # Report headroom before the encoder loads. A crash with no Python
498
+ # traceback is almost always VRAM, and the number here is what tells
499
+ # you whether the model had room -- a 12 GB encoder on a 16 GB card
500
+ # leaves under 4 GB for the vision tower, activations and KV cache.
501
+ try:
502
+ from comfy import model_management
503
+ free = model_management.get_free_memory(
504
+ model_management.get_torch_device())
505
+ log.info("[Nugget] %.1f GB free before loading the "
506
+ "text encoder", free / (1024 ** 3))
507
+ if free < 5 * (1024 ** 3):
508
+ log.warning("[Nugget] under 5 GB free -- if "
509
+ "ComfyUI exits without an error, the encoder is "
510
+ "too large for this card. Try a smaller one.")
511
+ except Exception:
512
+ pass
513
+
514
+ budget = active_prompts.token_budget(detail, prompt_max_length)
515
+ # The two modules name the middle-pass budget differently --
516
+ # ``characters`` in the general pack, ``subject`` in the motion
517
+ # pack -- because the underlying pass asks for a different thing.
518
+ # Fall back either way so a future rename of one does not silently
519
+ # break the other.
520
+ middle_budget = budget.get("characters",
521
+ budget.get("subject", 200))
522
+
523
+ wide, _ = payload(frames(context))
524
+ progress.step("overall look and characters"
525
+ if not movements
526
+ else "overall motion and subject")
527
+ overview = llm.ask(clip, active_prompts.OVERVIEW, wide,
528
+ budget["overview"], seed)
529
+ _interrupted()
530
+ subjects = active_prompts.clean_subjects(
531
+ llm.ask(clip, active_prompts.SUBJECTS, wide,
532
+ middle_budget, seed))
533
+
534
+ tokens = budget["shot"]
535
+ # The motion pack composes its per-shot prompt from a header +
536
+ # style body + footer, and needs the timestamp list built once
537
+ # per detail level. The general pack just uses its SHOT literal.
538
+ movements_shot_template = (active_prompts.shot_prompt(detail)
539
+ if movements else None)
540
+ grouped: dict = {}
541
+ for position, (shot, start, end, times) in enumerate(plan):
542
+ _interrupted()
543
+ progress.step(
544
+ f"shot {shot.index} of {len(shots)}"
545
+ + (f", beat at {active_prompts.clock(start)}"
546
+ if len(active_prompts.beats_for(shot, detail)) > 1
547
+ else ""))
548
+ images, count = payload(frames(times))
549
+ first = shot.index not in grouped
550
+
551
+ if frame_layout == "grid" and count > 1:
552
+ layout = active_prompts.GRID_NOTE.format(count=count)
553
+ else:
554
+ layout = (f"These {count} frames are from ONE continuous "
555
+ f"shot, in order.")
556
+ call_started = time.time()
557
+ movement = camera.summarise(motion, start, end)
558
+ if movements:
559
+ # Motion-transfer prompts need the sampled timestamps
560
+ # spelled out under the frame grid, plus a target
561
+ # callout count -- see motion_prompts.shot_prompt for
562
+ # the placeholders it expects.
563
+ ts_line = active_prompts.timestamp_list(times)
564
+ aim = active_prompts.aim_callouts(times, detail)
565
+ prompt = movements_shot_template.format(
566
+ layout=layout, span=end - start,
567
+ start_clock=active_prompts.clock(start),
568
+ end_clock=active_prompts.clock(end),
569
+ timestamp_list=ts_line,
570
+ aim=aim,
571
+ subjects=active_prompts.SUBJECTS_KNOWN.format(
572
+ subjects=subjects) if subjects else "",
573
+ camera=active_prompts.CAMERA_FACT.format(motion=movement)
574
+ if movement else "")
575
+ else:
576
+ prompt = active_prompts.SHOT.format(
577
+ layout=layout, span=end - start,
578
+ sentences=active_prompts.sentences_for(detail),
579
+ # The subject list is passed into every shot; that is
580
+ # the whole reason it is worth generating.
581
+ subjects=active_prompts.SUBJECTS_KNOWN.format(
582
+ subjects=subjects) if subjects else "",
583
+ camera=active_prompts.CAMERA_FACT.format(motion=movement)
584
+ if movement else "")
585
+ if not first:
586
+ prompt += active_prompts.CONTINUES
587
+
588
+ try:
589
+ text = llm.ask(clip, prompt, images, tokens, seed)
590
+ except llm.LLMError as exc:
591
+ # One bad beat should not throw away the rest of the run.
592
+ log.warning("[Nugget] shot %d beat %d failed: %s",
593
+ shot.index, position, exc)
594
+ text = f"(this part could not be described: {exc})"
595
+ # Per-call timing, because "the node is slow" is almost always
596
+ # one setting rather than the whole pipeline, and the split
597
+ # between passes is what tells you which.
598
+ log.info("[Nugget] shot %d beat %s took %.1fs "
599
+ "(%d frames, %d token budget)", shot.index,
600
+ active_prompts.clock(start),
601
+ time.time() - call_started, count, tokens)
602
+ if llm.looks_truncated(text, tokens):
603
+ log.warning("[Nugget] shot %d ran out of tokens "
604
+ "at %d and stopped mid-sentence -- raise "
605
+ "prompt_max_length", shot.index, tokens)
606
+ fallback = ("(no callouts returned)" if movements
607
+ else "(no description returned)")
608
+ grouped.setdefault(shot.index, []).append(
609
+ (start, end, text or fallback))
610
+
611
+ described = [(shot, grouped.get(shot.index, [(shot.start, shot.end,
612
+ "(not described)")]))
613
+ for shot in shots]
614
+
615
+ part = active_prompts.sections(overview, subjects, described, speech)
616
+ elapsed = time.time() - started
617
+ log.info("[Nugget] done in %.1fs across %d model call(s), "
618
+ "%d token budget per shot", elapsed, len(plan) + 2, tokens)
619
+ if elapsed > 120 and prompt_max_length > 400:
620
+ log.warning("[Nugget] that run took %.0fs with "
621
+ "prompt_max_length at %d. A busy scene generates up "
622
+ "to that budget on every beat; 0 (automatic) is much "
623
+ "faster and the log says when it is not enough.",
624
+ elapsed, prompt_max_length)
625
+
626
+ written = (active_prompts.assemble(overview, subjects,
627
+ described, speech),
628
+ part["overview"],
629
+ part["subjects"],
630
+ part["shots"],
631
+ active_prompts.transcript_text(speech),
632
+ speech.language_full or "none")
633
+ recall.put(cache_key, written)
634
+ return io.NodeOutput(all_frames, audio_out, real_fps, frame_count,
635
+ *written)
636
+ finally:
637
+ video_mod.cleanup(wav)
638
+ if handle.temporary:
639
+ video_mod.cleanup(handle.path)
640
+
641
+
642
+ NODES = [VideoAutoTranscribe]
custom_nodes/ComfyUI-Nugget/prompts.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The prompts, the frame budget, and how the pieces get assembled.
2
+
3
+ Written short on purpose. A long instruction block does not make a 12B model more
4
+ careful -- it gives it more to drift from. Each pass asks for one thing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # Frames per second of shot, and the range that count is clamped to, by detail
10
+ # level. Scaling with duration matters in both directions: a fixed count gives a
11
+ # half-second shot five near-identical frames, and gives a thirty-second shot
12
+ # five frames six seconds apart, which is back to guessing.
13
+ DETAIL = {
14
+ "brief": {"per_second": 0.8, "min": 2, "max": 6, "tokens": 160, "beat": 12.0},
15
+ "normal": {"per_second": 1.6, "min": 3, "max": 9, "tokens": 300, "beat": 6.0},
16
+ "detailed": {"per_second": 2.5, "min": 5, "max": 12, "tokens": 500, "beat": 4.0},
17
+ }
18
+
19
+ # A shot longer than its detail level's ``beat`` is described in pieces rather
20
+ # than in one go. A single call covering twenty seconds gets the same handful of
21
+ # frames and the same two or three sentences that a four-second shot gets, so
22
+ # everything between the samples is lost -- exactly the failure this pack exists
23
+ # to avoid, just one level down. Splitting long shots into beats is the
24
+ # partition-and-summarise approach used by LVD-2M (arXiv 2410.10816), which
25
+ # chunks long video, captions each chunk from a frame grid, then merges.
26
+
27
+ # Frames used by the overview and cast passes. Sampled per shot rather than
28
+ # evenly across the clip, so a short shot is still represented -- an even spread
29
+ # can miss a one-second shot entirely, and then nobody in it reaches the cast.
30
+ CONTEXT_FRAMES = 8
31
+
32
+ GRID_NOTE = ("The image is a grid of {count} video frames in time order, read "
33
+ "left to right then top to bottom, like a comic strip. They are "
34
+ "frames of one continuous shot, not separate pictures.")
35
+
36
+ OVERVIEW = """These frames are spread across one short video.
37
+
38
+ In ONE sentence, describe the overall look: lighting, colour grade, image quality, and pace.
39
+
40
+ Do not describe any individual moment. Do not list what happens. One sentence, nothing else."""
41
+
42
+ CONTINUES = ("\n\nThis continues the same shot, already in progress. Describe what "
43
+ "CHANGES across these frames. Do not restate appearance or setting that "
44
+ "has not changed.")
45
+
46
+ SHOT = """{layout} They span {span:.1f} seconds.
47
+
48
+ Describe what this shot shows, in {sentences}. Cover:
49
+ - who or what is on screen, and how they look (clothing, hair, colours)
50
+ - what they do, and how they move
51
+ - the setting and the lighting
52
+ - the framing and the camera movement
53
+ {subjects}{camera}
54
+ The frames are one shot, not several. Anything that changes between them is movement, not a cut.
55
+
56
+ Describe only what is visible in THESE frames. If a person is too small or too far away to make out, say so rather than filling in details from elsewhere.
57
+
58
+ Write only the description. No preamble, no bullet points, no timestamps, no shot number."""
59
+
60
+ # The old version of this asked for "Man A / Woman A" labels, which came from a
61
+ # pipeline where a downstream writer mapped placeholders onto reference images.
62
+ # On its own that is worse than useless: it forces every video to be about people
63
+ # when it may be about a dog, a car or a landscape, and a label carries no more
64
+ # information than the description beside it.
65
+ #
66
+ # What a subject list is actually for is consistency -- so shot 4 can say "the
67
+ # man" and mean the same man as shot 2. That only works if the list reaches the
68
+ # shot passes, which is what SUBJECTS_KNOWN below does.
69
+ SUBJECTS = """These frames are from one short video, showing the same scene at different moments.
70
+
71
+ List each character who appears: people and animals. One per line, as a short noun phrase -- what they are, plus whatever distinguishes them. Follow this shape:
72
+
73
+ <what they are> <the details that identify them: clothing, colour, hair, build>
74
+
75
+ Fill that in from THESE frames. The angle brackets are a placeholder, not content -- do not repeat any wording from these instructions.
76
+
77
+ List each character ONCE. The same person seen from different angles, in
78
+ different framings, or in several frames is still ONE character, so do not list
79
+ them again. If you are unsure whether two are the same person, assume they are.
80
+
81
+ Describe only what you can see. No labels, no names, no numbering. Skip anyone
82
+ who appears once in the background. If there are no characters -- a landscape, a
83
+ product shot, an abstract animation -- write NONE.
84
+
85
+ No preamble, nothing else."""
86
+
87
+ SUBJECTS_KNOWN = ("\nThe characters in this video are:\n{subjects}\n"
88
+ "Refer to them the same way, so the description stays "
89
+ "consistent between shots. Do not mention a character who is "
90
+ "not visible in these frames.\n")
91
+
92
+
93
+ # The camera movement is measured, not guessed, so it is given to the model as
94
+ # fact rather than asked for. Left to itself a VLM reports "the camera remains
95
+ # stationary" over a crane shot -- it cannot recover motion from stills.
96
+ CAMERA_FACT = ("\nThe camera movement has been measured for you: {motion}. "
97
+ "State this in your description and do not contradict it.\n")
98
+
99
+
100
+ def beats_for(shot, detail: str) -> list:
101
+ """Split a shot into equal windows short enough to describe properly.
102
+
103
+ Equal windows rather than content-adaptive ones: the cuts already handle the
104
+ big structural breaks, and inside a single shot there is no reliable signal
105
+ for where one beat ends and the next begins that is worth the extra pass.
106
+ """
107
+ length = float(DETAIL.get(detail, DETAIL["normal"])["beat"])
108
+ span = max(0.0, shot.end - shot.start)
109
+ if span <= length * 1.35:
110
+ # A shot only a little over the limit is left whole. Splitting 7 seconds
111
+ # into 6 + 1 gives a one-second beat that says nothing.
112
+ return [(shot.start, shot.end)]
113
+ count = max(2, int(round(span / length)))
114
+ return [(shot.start + span * i / count, shot.start + span * (i + 1) / count)
115
+ for i in range(count)]
116
+
117
+
118
+ # Token budgets for the two setup passes when the user has not overridden them.
119
+ # The overview is one sentence and the character list is a handful of short
120
+ # lines, so neither needs much; the shot passes are what actually produce length.
121
+ OVERVIEW_TOKENS = 120
122
+ CHARACTER_TOKENS = 200
123
+
124
+
125
+ def token_budget(detail: str, override: int = 0) -> dict:
126
+ """How many tokens each pass may generate.
127
+
128
+ ``override`` of 0 means follow the detail level, which is the default and
129
+ reproduces the behaviour before this setting existed. A real value sets the
130
+ per-shot budget directly and scales the two setup passes with it, so raising
131
+ it does not leave a long shot description next to a truncated character list.
132
+ """
133
+ settings = DETAIL.get(detail, DETAIL["normal"])
134
+ if not override or override <= 0:
135
+ return {"shot": settings["tokens"],
136
+ "overview": OVERVIEW_TOKENS,
137
+ "characters": CHARACTER_TOKENS}
138
+
139
+ shot = int(override)
140
+ return {"shot": shot,
141
+ "overview": max(OVERVIEW_TOKENS, shot // 4),
142
+ "characters": max(CHARACTER_TOKENS, shot // 2)}
143
+
144
+
145
+ def frames_for(duration: float, detail: str) -> int:
146
+ """How many frames to show the model for a shot of this length."""
147
+ settings = DETAIL.get(detail, DETAIL["normal"])
148
+ wanted = int(round(max(0.0, duration) * settings["per_second"]))
149
+ return max(settings["min"], min(settings["max"], wanted))
150
+
151
+
152
+ def sample_times(start: float, end: float, count: int) -> list[float]:
153
+ """Frame times spread across a shot, at bin centres.
154
+
155
+ Centres rather than edges: landing exactly on ``end`` picks up the next
156
+ shot's first frame, which is how a description acquires things that are not
157
+ in the shot.
158
+ """
159
+ count = max(1, int(count))
160
+ span = max(0.0, end - start)
161
+ return [start + span * (i + 0.5) / count for i in range(count)]
162
+
163
+
164
+ def context_times(shots, total: int = CONTEXT_FRAMES) -> list[float]:
165
+ """Times for the overview and cast passes, with every shot represented.
166
+
167
+ Each shot gets at least one frame; whatever is left over goes to the longest
168
+ shots, which is where most of the clip's content is.
169
+ """
170
+ if not shots:
171
+ return []
172
+ share = {shot.index: 1 for shot in shots}
173
+ spare = max(0, total - len(shots))
174
+ for shot in sorted(shots, key=lambda s: -s.duration):
175
+ if spare <= 0:
176
+ break
177
+ share[shot.index] += 1
178
+ spare -= 1
179
+
180
+ times: list[float] = []
181
+ for shot in shots:
182
+ times.extend(sample_times(shot.start, shot.end, share[shot.index]))
183
+ return sorted(times)
184
+
185
+
186
+ def assign_speech(speech, shots) -> dict:
187
+ """Map shot index -> its speech segments, each segment used exactly once.
188
+
189
+ A line spoken across a cut overlaps two shots. Listing it under both reads as
190
+ though it were said twice, so it goes to whichever shot it spends most of
191
+ itself in.
192
+ """
193
+ placed: dict = {shot.index: [] for shot in shots}
194
+ if not shots:
195
+ return placed
196
+
197
+ for segment in speech.segments:
198
+ start, end = segment[0], segment[1]
199
+ best, most = shots[0], -1.0
200
+ for shot in shots:
201
+ overlap = min(end, shot.end) - max(start, shot.start)
202
+ if overlap > most:
203
+ best, most = shot, overlap
204
+ placed[best.index].append(segment)
205
+ return placed
206
+
207
+
208
+ # Words that carry no identity, so they should not make two descriptions of
209
+ # different people look alike.
210
+ _FILLER = {"a", "an", "the", "in", "on", "with", "and", "of", "is", "has",
211
+ "wearing", "wears", "dressed", "who", "that", "his", "her", "their",
212
+ "man", "woman", "person", "young", "older", "adult"}
213
+
214
+
215
+ def _same_character(first: str, second: str) -> bool:
216
+ """Do these two lines describe the same character?
217
+
218
+ Token overlap on the distinguishing words. "a man in a green jacket" and "a
219
+ man in an olive puffer jacket and black beanie" share the wardrobe words that
220
+ matter once the filler is dropped -- and they are exactly the pair the model
221
+ produces when it lists someone twice from two different framings.
222
+ """
223
+ import re
224
+
225
+ def words(text):
226
+ return {w for w in re.findall(r"[a-z]+", text.lower()) if w not in _FILLER}
227
+
228
+ left, right = words(first), words(second)
229
+ if not left or not right:
230
+ return False
231
+ return len(left & right) / min(len(left), len(right)) >= 0.6
232
+
233
+
234
+ def clean_subjects(raw: str) -> str:
235
+ """Tidy the character pass: strip formatting, drop NONE, merge repeats.
236
+
237
+ The model lists the same person more than once fairly often -- the context
238
+ frames deliberately span every shot, so it sees them in several framings and
239
+ describes each. The prompt asks for one line per character; this enforces it,
240
+ keeping the longest description of each since that is the most informative.
241
+ """
242
+ import re
243
+
244
+ if not raw or raw.strip().upper().startswith("NONE"):
245
+ return ""
246
+
247
+ rows = []
248
+ for row in raw.splitlines():
249
+ row = row.strip()
250
+ if not row or row.upper() == "NONE":
251
+ continue
252
+ row = re.sub(r"^[-*\u2022]\s*|^\d+[.)]\s*", "", row)
253
+ row = re.sub(r"^(Man|Woman|Person|Subject|Character)\s*[A-Z0-9]?\s*[-:]\s*",
254
+ "", row, flags=re.IGNORECASE)
255
+ row = row.rstrip(" .;,")
256
+ # A model that echoes the prompt's placeholder rather than filling it in
257
+ # produces a line with angle brackets. That is instruction text, not a
258
+ # character, and it must never reach the shot passes -- whatever lands in
259
+ # this list gets asserted as fact in every shot description afterwards.
260
+ if "<" in row and ">" in row:
261
+ continue
262
+ if row:
263
+ rows.append(row)
264
+
265
+ # Repeat until nothing more merges. One pass is not enough: replacing an
266
+ # entry with a fuller description gives it more words, which can make it
267
+ # match a third entry that the short version did not. "green jacket" and
268
+ # "olive puffer jacket, black beanie" look unrelated until the first becomes
269
+ # "dark beanie and green puffer jacket".
270
+ merged = list(rows)
271
+ for _ in range(4):
272
+ collapsed: list[str] = []
273
+ for row in merged:
274
+ for index, existing in enumerate(collapsed):
275
+ if _same_character(existing, row):
276
+ if len(row) > len(existing):
277
+ collapsed[index] = row # keep the fuller description
278
+ break
279
+ else:
280
+ collapsed.append(row)
281
+ if len(collapsed) == len(merged):
282
+ break
283
+ merged = collapsed
284
+ return "\n".join(merged)
285
+
286
+
287
+ def clock(seconds: float) -> str:
288
+ """MM:SS.mmm -- the form H3 wants cut times written in."""
289
+ minutes, rest = divmod(max(0.0, float(seconds)), 60.0)
290
+ return f"{int(minutes):02d}:{rest:06.3f}"
291
+
292
+
293
+ def sentences_for(detail: str) -> str:
294
+ return {"brief": "one sentence",
295
+ "normal": "two or three sentences",
296
+ "detailed": "three to five sentences"}.get(detail, "two or three sentences")
297
+
298
+
299
+ def sections(overview: str, subjects: str, shots: list, speech) -> dict:
300
+ """The output broken into its pieces, so each can leave the node separately.
301
+
302
+ ``assemble`` joins these into the full description. Keeping them apart means
303
+ a downstream node that only wants the cast list does not have to parse it
304
+ back out of a formatted block.
305
+ """
306
+ subject_text = clean_subjects(subjects)
307
+
308
+ placed = assign_speech(speech, [shot for shot, _ in shots])
309
+
310
+ rows: list[str] = []
311
+ for shot, beats in shots:
312
+ if rows:
313
+ rows.append("")
314
+ header = f"[Shot {shot.index}] From {clock(shot.start)} to {clock(shot.end)}"
315
+ if len(beats) == 1:
316
+ rows.append(f"{header}, {beats[0][2].strip()}")
317
+ else:
318
+ rows.append(header + ":")
319
+ for start, end, text in beats:
320
+ rows.append(f" {clock(start)} - {clock(end)} {text.strip()}")
321
+ for start, end, said in placed.get(shot.index, []):
322
+ rows.append(f" speech {clock(start)}-{clock(end)}: \"{said}\"")
323
+
324
+ if speech.note:
325
+ audio = speech.note
326
+ elif speech.spoken:
327
+ audio = (f"speech in {speech.language_full or 'an unknown language'} "
328
+ f"({speech.probability:.0%} confidence)")
329
+ else:
330
+ audio = "no speech detected"
331
+
332
+ return {"overview": (overview or "").strip(),
333
+ "subjects": subject_text,
334
+ "shots": "\n".join(rows),
335
+ "audio": audio}
336
+
337
+
338
+ def assemble(overview: str, subjects: str, shots: list, speech) -> str:
339
+ """Everything, in one block -- the full description output."""
340
+ part = sections(overview, subjects, shots, speech)
341
+ lines: list[str] = []
342
+ if part["overview"]:
343
+ lines += [f"overview: {part['overview']}", ""]
344
+ if part["subjects"]:
345
+ lines += ["characters identified:", part["subjects"], ""]
346
+ lines += ["shots:", "", part["shots"]]
347
+ if part["audio"]:
348
+ lines += ["", f"audio: {part['audio']}"]
349
+ return "\n".join(lines).strip()
350
+
351
+
352
+ def transcript_text(speech) -> str:
353
+ """The transcript on its own, with timings, so dialogue can be reformatted
354
+ downstream without re-running the vision passes."""
355
+ if not speech.spoken:
356
+ return speech.note or "no speech detected"
357
+ rows = [f"[{clock(start)} - {clock(end)}] {text}"
358
+ for start, end, text in speech.segments]
359
+ if speech.note:
360
+ rows.append(f"({speech.note})")
361
+ return "\n".join(rows)
custom_nodes/ComfyUI-Nugget/pyproject.toml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "comfyui-nugget"
3
+ version = "1.1.0"
4
+ description = "Nugget nodes for ComfyUI: video auto transcription with measured cuts and camera movement, and image batching that pads instead of cropping."
5
+ license = { text = "MIT" }
6
+ requires-python = ">=3.10"
7
+ # Nothing here is a hard install. PyAV, numpy, Pillow and torch all ship with
8
+ # ComfyUI. faster-whisper is the one optional extra and the node degrades to a
9
+ # visual-only description without it.
10
+ dependencies = []
11
+
12
+ [project.optional-dependencies]
13
+ speech = ["faster-whisper"]
14
+
15
+ [project.urls]
16
+ Repository = ""
17
+
18
+ [tool.comfy]
19
+ PublisherId = ""
20
+ DisplayName = "Nugget"
21
+ Icon = ""
custom_nodes/ComfyUI-Nugget/recall.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Remembering the expensive half of a run.
2
+
3
+ ComfyUI caches a node's outputs and skips the node entirely when its inputs have
4
+ not changed. That works until the cached result is large: in RAM pressure mode
5
+ ComfyUI purges big entries to keep memory free, and this node's ``images`` output
6
+ is over 800 MB for a thirteen-second clip. When that entry is purged the whole
7
+ node re-runs -- cut detection, transcription and every vision pass -- to rebuild
8
+ frames that took about a second to decode.
9
+
10
+ The two halves have wildly different costs and should not share a fate:
11
+
12
+ decoding frames about a second, and hundreds of megabytes
13
+ describing the clip minutes, and a few kilobytes of text
14
+
15
+ So the description is cached here, keyed on the video and on every setting that
16
+ could change it. If ComfyUI evicts the node's outputs and runs it again, the
17
+ frames are decoded afresh -- cheap -- and the text comes straight back out of
18
+ this. Nothing large is held, so there is nothing worth evicting.
19
+
20
+ This is deliberately not a substitute for ComfyUI's cache. When that works, this
21
+ is never consulted. It only earns its keep on the path where the outputs were
22
+ thrown away but the inputs did not actually change.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import logging
29
+ import os
30
+ from collections import OrderedDict
31
+
32
+ log = logging.getLogger("Nugget")
33
+
34
+ # Entries are text, a few kilobytes each. Small enough that the cache can never
35
+ # become the thing that triggers an eviction.
36
+ MAX_ENTRIES = 8
37
+
38
+ # Bumped whenever the shape of a stored entry or the meaning of a setting
39
+ # changes, so an old entry from a previous version of the pack can never be
40
+ # handed back as if it were current.
41
+ SCHEMA = 2
42
+
43
+ # How much of a temporary copy is read to fingerprint it. Head, middle and tail
44
+ # rather than the whole file: a re-encode or a different clip changes at least
45
+ # one of the three, and reading 3 MB is immaterial next to a decode.
46
+ SAMPLE = 1024 * 1024
47
+
48
+ _STORE: "OrderedDict[tuple, tuple]" = OrderedDict()
49
+
50
+
51
+ def _model_id(clip) -> str:
52
+ """Enough to tell one loaded encoder from another.
53
+
54
+ A different model writes a different description from the same video, so it
55
+ has to be part of the key. Falls back to the object's identity when the name
56
+ is not exposed -- that is over-cautious rather than wrong: it produces a miss
57
+ where a hit was possible, never a hit where the model has changed.
58
+ """
59
+ for path in (("tokenizer", "clip_name"), ("cond_stage_model", "__class__")):
60
+ obj = clip
61
+ for step in path:
62
+ obj = getattr(obj, step, None)
63
+ if obj is None:
64
+ break
65
+ if isinstance(obj, str):
66
+ return obj
67
+ if obj is not None and hasattr(obj, "__name__"):
68
+ return str(obj.__name__)
69
+ return str(id(clip))
70
+
71
+
72
+ def _digest(path: str, size: int) -> str:
73
+ """A content fingerprint from three slices of the file.
74
+
75
+ Used only for temporary copies. Reading the whole of a two-minute clip to
76
+ decide whether to skip a minute of work is affordable but pointless -- head,
77
+ middle and tail plus the exact byte count is already far more discriminating
78
+ than the mtime it replaces.
79
+ """
80
+ hasher = hashlib.blake2b(str(size).encode(), digest_size=16)
81
+ spots = [0]
82
+ if size > SAMPLE:
83
+ spots.append(max(0, size // 2 - SAMPLE // 2))
84
+ spots.append(max(0, size - SAMPLE))
85
+ try:
86
+ with open(path, "rb") as handle:
87
+ for spot in sorted(set(spots)):
88
+ handle.seek(spot)
89
+ hasher.update(handle.read(SAMPLE))
90
+ except OSError:
91
+ return ""
92
+ return hasher.hexdigest()
93
+
94
+
95
+ def identity(video_path: str, temporary: bool = False):
96
+ """Which video this is, without decoding it.
97
+
98
+ For a file that lives on disk in its own right -- what Load Video normally
99
+ hands over -- name, size and mtime settle it. Size and mtime rather than a
100
+ hash of the contents because reading gigabytes to decide whether to skip a
101
+ minute of work is a poor trade, and a video that changes in place without
102
+ changing either is not a case worth paying for.
103
+
104
+ A temporary copy is the exception and has to be treated differently. It is
105
+ written fresh on every queue, so its mtime is always new and its path always
106
+ different: keyed that way, the same video would miss every single time and
107
+ the cache would never fire at all. Those get a content fingerprint instead,
108
+ which is the same for the same footage no matter how many times it is
109
+ re-extracted.
110
+ """
111
+ try:
112
+ stat = os.stat(video_path)
113
+ except OSError:
114
+ return None
115
+ if temporary:
116
+ digest = _digest(video_path, stat.st_size)
117
+ if not digest:
118
+ return None
119
+ return ("content", stat.st_size, digest)
120
+ return ("file", os.path.basename(video_path), stat.st_size,
121
+ round(stat.st_mtime, 3))
122
+
123
+
124
+ def key(video_path: str, clip, settings: dict, temporary: bool = False):
125
+ """Identity of a description: the file, the model, and every setting."""
126
+ who = identity(video_path, temporary)
127
+ if who is None:
128
+ return None
129
+ return (SCHEMA, who, _model_id(clip),
130
+ tuple(sorted((k, str(v)) for k, v in settings.items())))
131
+
132
+
133
+ def get(cache_key):
134
+ if cache_key is None or cache_key not in _STORE:
135
+ return None
136
+ _STORE.move_to_end(cache_key)
137
+ return _STORE[cache_key]
138
+
139
+
140
+ def put(cache_key, value) -> None:
141
+ if cache_key is None:
142
+ return
143
+ _STORE[cache_key] = value
144
+ while len(_STORE) > MAX_ENTRIES:
145
+ _STORE.popitem(last=False)
146
+
147
+
148
+ def clear() -> None:
149
+ _STORE.clear()
custom_nodes/ComfyUI-Nugget/selftest.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """What this machine can and cannot do, without loading a model.
3
+
4
+ ComfyUI/python_embeded/python.exe custom_nodes/ComfyUI-Nugget/selftest.py
5
+
6
+ Run this before wiring anything. It reports which parts are available and where
7
+ speech will run, and it spends no VRAM and no time doing it.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+
15
+ GIB = 1024 ** 3
16
+
17
+
18
+ def line(label, state, detail=""):
19
+ print(" %-22s %-9s %s" % (label, state, detail))
20
+
21
+
22
+ print("\n Nugget self-test")
23
+ print(" " + "-" * 62)
24
+
25
+ print("\n required")
26
+ try:
27
+ import av
28
+ line("PyAV", "ok", f"{av.__version__} -- video decoding")
29
+ except ImportError:
30
+ line("PyAV", "MISSING", "ships with ComfyUI; if LoadVideo works, this should")
31
+
32
+ try:
33
+ import numpy
34
+ line("numpy", "ok", numpy.__version__)
35
+ except ImportError:
36
+ line("numpy", "MISSING", "ships with ComfyUI")
37
+
38
+ try:
39
+ from PIL import Image # noqa: F401
40
+ line("Pillow", "ok", "")
41
+ except ImportError:
42
+ line("Pillow", "MISSING", "ships with ComfyUI")
43
+
44
+ print("\n optional -- speech")
45
+ try:
46
+ import faster_whisper # noqa: F401
47
+ line("faster-whisper", "ok", "dialogue will be transcribed")
48
+ except ImportError:
49
+ line("faster-whisper", "absent", "no transcript without it")
50
+ try:
51
+ import speech as _sp
52
+ print(" " + " " * 22 + "install with:")
53
+ print(" " + " " * 22 + _sp.install_hint())
54
+ except Exception:
55
+ pass
56
+
57
+ print("\n compute")
58
+ device, compute = "cpu", "int8"
59
+ try:
60
+ import torch
61
+ line("torch", "ok", torch.__version__)
62
+ if hasattr(torch, "cuda") and torch.cuda.is_available():
63
+ name = torch.cuda.get_device_name(0)
64
+ try:
65
+ free, total = torch.cuda.mem_get_info()
66
+ line("CUDA", "ok", f"{name} -- {free / GIB:.1f} of "
67
+ f"{total / GIB:.1f} GiB free")
68
+ except Exception:
69
+ line("CUDA", "ok", name)
70
+ for module in ("nvidia.cublas", "nvidia.cudnn"):
71
+ try:
72
+ __import__(module)
73
+ line(module.split(".")[1], "ok", "needed for GPU speech")
74
+ except ImportError:
75
+ line(module.split(".")[1], "absent",
76
+ "GPU speech will fall back to CPU "
77
+ f"(pip install {module.replace('.', '-')}-cu12)")
78
+ elif getattr(getattr(torch, "backends", None), "mps", None) and \
79
+ torch.backends.mps.is_available():
80
+ line("MPS", "ok", "Apple Silicon -- speech runs on CPU "
81
+ "(CTranslate2 has no Metal backend)")
82
+ else:
83
+ line("GPU", "none", "everything runs on CPU")
84
+ except ImportError:
85
+ line("torch", "MISSING", "ComfyUI cannot run without it")
86
+
87
+ try:
88
+ import speech
89
+ device, compute = speech._plan()
90
+ line("speech will use", device, compute)
91
+ except Exception as exc:
92
+ line("speech plan", "unknown", str(exc))
93
+
94
+ print("\n text generation")
95
+ try:
96
+ from comfy_api.latest import io # noqa: F401
97
+ line("comfy_api", "ok", "node schema available")
98
+ except ImportError:
99
+ line("comfy_api", "absent", "run this from inside ComfyUI's python, or "
100
+ "ignore if you only want the checks above")
101
+ print(" " + " " * 22 + "clip.generate() is checked at run time; it needs")
102
+ print(" " + " " * 22 + "ComfyUI v0.15.0 or newer and a Gemma encoder.")
103
+
104
+ print("\n own tests")
105
+ result = os.system(f'"{sys.executable}" '
106
+ f'"{os.path.join(os.path.dirname(os.path.abspath(__file__)), "tests", "run_all.py")}"'
107
+ + (" >nul 2>&1" if os.name == "nt" else " >/dev/null 2>&1"))
108
+ line("test suite", "ok" if result == 0 else "FAILED",
109
+ "tests/run_all.py" if result == 0 else "run it directly to see why")
110
+
111
+ print()
custom_nodes/ComfyUI-Nugget/speech.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transcription, and the VRAM choreography around it.
2
+
3
+ Ordering matters more than it looks. ComfyUI's model manager is lazy: the CLIP
4
+ object arrives at the node before ``execute`` runs, but the weights only move to
5
+ the GPU when something first calls ``tokenize`` or ``generate``. So "run Whisper
6
+ before the model loads" is not about node order -- it is about not touching the
7
+ clip until Whisper has been released. This module is therefore called first and
8
+ told to free what it needs, which also evicts anything left resident from a
9
+ previous run of the graph.
10
+
11
+ The device is chosen from free VRAM rather than fixed, and every GPU path falls
12
+ back to CPU. faster-whisper is built on CTranslate2, which has no ROCm and no
13
+ Metal backend, so AMD and Apple Silicon users are on CPU whatever the numbers
14
+ say. Its CUDA path also needs ``nvidia-cublas-cu12`` and ``nvidia-cudnn-cu12``
15
+ installed separately, and a missing cuDNN throws at model construction -- by far
16
+ the most common way this fails on an otherwise healthy machine.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ from collections import OrderedDict
24
+ from dataclasses import dataclass, field
25
+
26
+ try:
27
+ from . import interrupt
28
+ except ImportError: # direct import in the test suite
29
+ import interrupt
30
+
31
+ log = logging.getLogger("Nugget")
32
+
33
+ GIB = 1024 ** 3
34
+
35
+ # Rough resident cost of large-v3 plus working space, by compute type.
36
+ NEEDS = {"float16": 4.5 * GIB, "int8_float16": 2.5 * GIB}
37
+
38
+ # Below this the language guess is not worth reporting. Music beds and room tone
39
+ # routinely come back as confident-sounding nonsense, and a wrong language tag is
40
+ # worse than no tag because everything downstream trusts it.
41
+ LANGUAGE_FLOOR = 0.5
42
+
43
+ # Transcripts, keyed on the file and the settings that change them. Nudging
44
+ # `detail` re-runs the vision passes, which is the point -- but it should not
45
+ # also re-run a two-minute transcription that cannot have changed. Small, because
46
+ # entries hold text and a handful of clips is all anyone iterates on at once.
47
+ _CACHE: "OrderedDict[tuple, Speech]" = OrderedDict()
48
+ CACHE_SIZE = 8
49
+
50
+
51
+ def _key(wav_path: str, model_size: str, language: str):
52
+ """Identity of the audio plus the settings that affect the result.
53
+
54
+ Size and mtime rather than a hash of the bytes: the WAV is extracted fresh
55
+ each run to a new temporary file, so its own path is useless as an identity
56
+ and hashing megabytes to save seconds is a poor trade.
57
+ """
58
+ try:
59
+ stat = os.stat(wav_path)
60
+ return (stat.st_size, round(stat.st_mtime, 3), model_size, language)
61
+ except OSError:
62
+ return None
63
+
64
+
65
+ # Whisper reports ISO 639-1 codes. Nothing downstream benefits from "ja" over
66
+ # "Japanese" -- least of all a prompt writer that has to put the language name
67
+ # into a dialogue tag -- so the code is expanded here once.
68
+ LANGUAGE_NAMES = {
69
+ "af": "Afrikaans", "am": "Amharic", "ar": "Arabic", "as": "Assamese",
70
+ "az": "Azerbaijani", "ba": "Bashkir", "be": "Belarusian", "bg": "Bulgarian",
71
+ "bn": "Bengali", "bo": "Tibetan", "br": "Breton", "bs": "Bosnian",
72
+ "ca": "Catalan", "cs": "Czech", "cy": "Welsh", "da": "Danish",
73
+ "de": "German", "el": "Greek", "en": "English", "es": "Spanish",
74
+ "et": "Estonian", "eu": "Basque", "fa": "Persian", "fi": "Finnish",
75
+ "fo": "Faroese", "fr": "French", "gl": "Galician", "gu": "Gujarati",
76
+ "ha": "Hausa", "haw": "Hawaiian", "he": "Hebrew", "hi": "Hindi",
77
+ "hr": "Croatian", "ht": "Haitian Creole", "hu": "Hungarian",
78
+ "hy": "Armenian", "id": "Indonesian", "is": "Icelandic", "it": "Italian",
79
+ "ja": "Japanese", "jw": "Javanese", "ka": "Georgian", "kk": "Kazakh",
80
+ "km": "Khmer", "kn": "Kannada", "ko": "Korean", "la": "Latin",
81
+ "lb": "Luxembourgish", "ln": "Lingala", "lo": "Lao", "lt": "Lithuanian",
82
+ "lv": "Latvian", "mg": "Malagasy", "mi": "Maori", "mk": "Macedonian",
83
+ "ml": "Malayalam", "mn": "Mongolian", "mr": "Marathi", "ms": "Malay",
84
+ "mt": "Maltese", "my": "Burmese", "ne": "Nepali", "nl": "Dutch",
85
+ "nn": "Norwegian Nynorsk", "no": "Norwegian", "oc": "Occitan",
86
+ "pa": "Punjabi", "pl": "Polish", "ps": "Pashto", "pt": "Portuguese",
87
+ "ro": "Romanian", "ru": "Russian", "sa": "Sanskrit", "sd": "Sindhi",
88
+ "si": "Sinhala", "sk": "Slovak", "sl": "Slovenian", "sn": "Shona",
89
+ "so": "Somali", "sq": "Albanian", "sr": "Serbian", "su": "Sundanese",
90
+ "sv": "Swedish", "sw": "Swahili", "ta": "Tamil", "te": "Telugu",
91
+ "tg": "Tajik", "th": "Thai", "tk": "Turkmen", "tl": "Tagalog",
92
+ "tr": "Turkish", "tt": "Tatar", "uk": "Ukrainian", "ur": "Urdu",
93
+ "uz": "Uzbek", "vi": "Vietnamese", "yi": "Yiddish", "yo": "Yoruba",
94
+ "yue": "Cantonese", "zh": "Chinese",
95
+ }
96
+
97
+
98
+ def language_code(name: str) -> str:
99
+ """"Japanese" -> "ja". The node shows names; Whisper wants codes."""
100
+ if not name or name.lower() in ("auto", ""):
101
+ return "auto"
102
+ lowered = name.strip().lower()
103
+ if lowered == "filipino":
104
+ lowered = "tagalog"
105
+ for code, label in LANGUAGE_NAMES.items():
106
+ if label.lower() == lowered:
107
+ return code
108
+ return lowered if len(lowered) <= 3 else "auto"
109
+
110
+
111
+ def language_name(code: str) -> str:
112
+ """"ja" -> "Japanese". Unknown codes come back unchanged rather than blank."""
113
+ if not code:
114
+ return ""
115
+ return LANGUAGE_NAMES.get(code.strip().lower(), code)
116
+
117
+
118
+ @dataclass
119
+ class Speech:
120
+ segments: list = field(default_factory=list) # [(start, end, text), ...]
121
+ language: str = ""
122
+ probability: float = 0.0
123
+ note: str = ""
124
+
125
+ @property
126
+ def spoken(self) -> bool:
127
+ return bool(self.segments)
128
+
129
+ @property
130
+ def language_full(self) -> str:
131
+ return language_name(self.language)
132
+
133
+ def text(self) -> str:
134
+ return " ".join(t for _, _, t in self.segments).strip()
135
+
136
+ def during(self, start: float, end: float) -> list:
137
+ """Segments overlapping a window, for folding speech into a shot."""
138
+ return [s for s in self.segments if s[1] > start and s[0] < end]
139
+
140
+
141
+ def available() -> bool:
142
+ try:
143
+ import faster_whisper # noqa: F401
144
+ return True
145
+ except ImportError:
146
+ return False
147
+
148
+
149
+ def install_hint() -> str:
150
+ """The exact command for THIS install, not a generic package name.
151
+
152
+ Nearly every report of "I installed it and it still says missing" is the same
153
+ mistake: pip ran against system Python while ComfyUI runs its own. Printing
154
+ the interpreter that is actually executing removes the guesswork.
155
+ """
156
+ import sys
157
+
158
+ executable = sys.executable or "python"
159
+ if "python_embeded" in executable.replace("\\", "/"):
160
+ # Windows portable build.
161
+ return (f'"{executable}" -m pip install faster-whisper')
162
+ return f'"{executable}" -m pip install faster-whisper'
163
+
164
+
165
+ _DLL_DIRS_ADDED = False
166
+
167
+
168
+ def enable_cuda_libraries() -> list[str]:
169
+ """Put the pip-installed CUDA DLLs on Windows' search path, carefully.
170
+
171
+ This is why "Library cublas64_12.dll is not found or cannot be loaded"
172
+ happens on a machine where the package is installed and importable: pip puts
173
+ the DLL in ``site-packages/nvidia/...``, and since Python 3.8 that directory
174
+ is not searched unless ``os.add_dll_directory`` is called for it.
175
+
176
+ Two things this must not do, both learned the hard way.
177
+
178
+ It must not prepend those folders to ``PATH``. That changes DLL resolution
179
+ for the whole process, including torch's own CUDA libraries, and a process
180
+ holding two different CUDA runtimes does not raise an exception -- it dies,
181
+ taking ComfyUI with it.
182
+
183
+ It must not register torch's ``lib`` folder. Torch has already loaded what it
184
+ needs; pointing other libraries at the same folder only invites the version
185
+ mix above.
186
+
187
+ A no-op off Windows, where the loader follows RPATH and finds these anyway.
188
+ """
189
+ global _DLL_DIRS_ADDED
190
+ if _DLL_DIRS_ADDED or os.name != "nt":
191
+ return []
192
+ _DLL_DIRS_ADDED = True
193
+
194
+ import site
195
+
196
+ bases = {b for b in (*site.getsitepackages(), site.getusersitepackages()) if b}
197
+ added = []
198
+ for base in bases:
199
+ nvidia = os.path.join(base, "nvidia")
200
+ if not os.path.isdir(nvidia):
201
+ continue
202
+ for where, _, files in os.walk(nvidia):
203
+ if not any(f.lower().endswith(".dll") for f in files):
204
+ continue
205
+ try:
206
+ os.add_dll_directory(where)
207
+ added.append(where)
208
+ except (OSError, AttributeError):
209
+ pass
210
+
211
+ if added:
212
+ log.info("[Nugget] registered %d CUDA library folder(s)",
213
+ len(added))
214
+ return added
215
+
216
+
217
+ def torch_cuda_major() -> int:
218
+ """Which CUDA the installed torch was built against, or 0."""
219
+ try:
220
+ import torch
221
+ if getattr(torch.version, "hip", None):
222
+ return 0
223
+ version = getattr(torch.version, "cuda", "") or ""
224
+ return int(version.split(".")[0]) if version[:1].isdigit() else 0
225
+ except Exception:
226
+ return 0
227
+
228
+
229
+ def _plan() -> tuple[str, str]:
230
+ """(device, compute_type), decided from what the GPU actually has spare.
231
+
232
+ Everything here is best-effort: if the ComfyUI memory API is not importable
233
+ or the device is not CUDA, CPU is the answer and that is fine.
234
+ """
235
+ try:
236
+ import torch
237
+ except ImportError:
238
+ return "cpu", "int8"
239
+
240
+ if not (hasattr(torch, "cuda") and torch.cuda.is_available()):
241
+ # Covers CPU-only builds and Apple Silicon. CTranslate2 has no MPS
242
+ # backend, so there is nothing to gain from checking for one.
243
+ return "cpu", "int8"
244
+
245
+ # CTranslate2 is built against CUDA 12. Running it beside a torch built for
246
+ # a different CUDA means two runtimes in one process, which crashes the
247
+ # process rather than raising -- and on a 20-second clip the GPU saves a few
248
+ # seconds. Not worth it: use the CPU and say why.
249
+ major = torch_cuda_major()
250
+ if major and major != 12:
251
+ log.info("[Nugget] torch is built for CUDA %d.x while "
252
+ "faster-whisper needs CUDA 12; transcribing on the CPU to "
253
+ "avoid mixing CUDA runtimes in one process", major)
254
+ return "cpu", "int8"
255
+
256
+ try:
257
+ from comfy import model_management
258
+
259
+ device = model_management.get_torch_device()
260
+ # Ask ComfyUI to make room. On a re-queue the text encoder is often
261
+ # still resident from the last run, so "before the model loads" is not
262
+ # automatic -- it has to be evicted.
263
+ try:
264
+ model_management.free_memory(NEEDS["float16"], device)
265
+ except Exception:
266
+ pass
267
+ free = model_management.get_free_memory(device)
268
+ except Exception:
269
+ try:
270
+ free, _ = torch.cuda.mem_get_info()
271
+ except Exception:
272
+ return "cpu", "int8"
273
+
274
+ if free >= NEEDS["float16"]:
275
+ return "cuda", "float16"
276
+ if free >= NEEDS["int8_float16"]:
277
+ return "cuda", "int8_float16"
278
+ return "cpu", "int8"
279
+
280
+
281
+ def transcribe(wav_path: str | None, model_size: str = "large-v3",
282
+ language: str = "auto", progress=None) -> Speech:
283
+ """Transcribe a 16 kHz mono WAV. Never raises -- failures come back as a note."""
284
+ if model_size in ("off", "none", "", None):
285
+ return Speech(note="transcription turned off")
286
+
287
+ if not wav_path:
288
+ return Speech(note="no audio track")
289
+
290
+ if not available():
291
+ return Speech(note="faster-whisper is not installed, so there is no "
292
+ "transcript. Install it into the Python ComfyUI is "
293
+ "running, not your system Python:\n "
294
+ + install_hint())
295
+
296
+ key = _key(wav_path, model_size, language)
297
+ if key is not None and key in _CACHE:
298
+ _CACHE.move_to_end(key)
299
+ log.info("[Nugget] reusing cached transcript")
300
+ return _CACHE[key]
301
+
302
+ from faster_whisper import WhisperModel
303
+
304
+ device, compute = _plan()
305
+ if device == "cuda":
306
+ enable_cuda_libraries()
307
+ if progress:
308
+ progress(f"transcribing with {model_size} on {device}")
309
+
310
+ model = None
311
+ try:
312
+ try:
313
+ model = WhisperModel(model_size, device=device, compute_type=compute)
314
+ except Exception as exc:
315
+ if device == "cpu":
316
+ raise
317
+ # Almost always a missing cuDNN/cuBLAS wheel. Slower beats broken.
318
+ log.warning("[Nugget] %s on GPU failed (%s); using CPU",
319
+ model_size, exc)
320
+ if progress:
321
+ progress(f"GPU unavailable for {model_size}, using CPU")
322
+ device, compute = "cpu", "int8"
323
+ model = WhisperModel(model_size, device=device, compute_type=compute)
324
+
325
+ segments, info = model.transcribe(
326
+ wav_path,
327
+ language=None if language in ("auto", "", None) else language,
328
+ # Silero VAD. Without it large-v3 invents dialogue over music and
329
+ # room tone -- subtitle credits, "thank you for watching", repeated
330
+ # phrases. Non-negotiable for arbitrary user footage.
331
+ vad_filter=True,
332
+ # Stops the decoder feeding its own output back as context, which is
333
+ # what turns one hallucinated line into twenty.
334
+ condition_on_previous_text=False,
335
+ )
336
+
337
+ rows = []
338
+ for segment in segments:
339
+ # faster-whisper yields as it decodes, so this is the only place a
340
+ # cancel can be noticed during transcription -- and transcription is
341
+ # the longest uninterruptible stretch in the whole node.
342
+ interrupt.check()
343
+ text = (segment.text or "").strip()
344
+ if text:
345
+ rows.append((float(segment.start), float(segment.end), text))
346
+
347
+ detected = getattr(info, "language", "") or ""
348
+ probability = float(getattr(info, "language_probability", 0.0) or 0.0)
349
+
350
+ if not rows:
351
+ result = Speech(note="no speech detected")
352
+ elif language in ("auto", "", None) and probability < LANGUAGE_FLOOR:
353
+ result = Speech(rows, detected, probability,
354
+ note=f"language uncertain ({probability:.0%}); the audio "
355
+ f"may be music rather than speech")
356
+ else:
357
+ result = Speech(rows, detected, probability)
358
+
359
+ if key is not None:
360
+ _CACHE[key] = result
361
+ while len(_CACHE) > CACHE_SIZE:
362
+ _CACHE.popitem(last=False)
363
+ return result
364
+
365
+ except interrupt.Interrupted:
366
+ # A cancel is not a failure to be reported as a note -- it has to
367
+ # propagate, or the run carries on into the vision passes.
368
+ raise
369
+ except Exception as exc:
370
+ log.warning("[Nugget] transcription failed: %s", exc)
371
+ text = str(exc)
372
+ if "cublas" in text.lower() or "cudnn" in text.lower():
373
+ text = (f"{text}\n The CUDA libraries are installed but not on the "
374
+ f"DLL search path, or missing. Run install.py in this folder.")
375
+ return Speech(note=f"transcription failed: {text}")
376
+ finally:
377
+ del model
378
+ try:
379
+ import torch
380
+ if hasattr(torch, "cuda") and torch.cuda.is_available():
381
+ torch.cuda.empty_cache()
382
+ except Exception:
383
+ pass
custom_nodes/ComfyUI-Nugget/tests/run_all.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run every suite in the pack.
3
+
4
+ python tests/run_all.py
5
+ """
6
+ import os
7
+ import subprocess
8
+ import sys
9
+
10
+ HERE = os.path.dirname(os.path.abspath(__file__))
11
+ SUITES = ["test_registration.py", "test_transcribe.py", "test_image.py",
12
+ "test_prompt.py"]
13
+
14
+ failed = []
15
+ for suite in SUITES:
16
+ path = os.path.join(HERE, suite)
17
+ if not os.path.exists(path):
18
+ continue
19
+ print("\n" + "=" * 66)
20
+ print(" %s" % suite)
21
+ print("=" * 66)
22
+ if subprocess.call([sys.executable, path]) != 0:
23
+ failed.append(suite)
24
+
25
+ print("\n" + "=" * 66)
26
+ print(" ALL SUITES PASSED" if not failed else " FAILED: %s" % ", ".join(failed))
27
+ print("=" * 66)
28
+ sys.exit(1 if failed else 0)
custom_nodes/ComfyUI-Nugget/tests/test_image.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Offline tests for the image nodes. No ComfyUI, no GPU.
3
+
4
+ python tests/test_image.py
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import types
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ import torch # noqa: E402
14
+
15
+
16
+ def _stub():
17
+ """Stand in for comfy.utils and comfy_api so the module imports alone."""
18
+ comfy = types.ModuleType("comfy")
19
+ utils = types.ModuleType("comfy.utils")
20
+
21
+ def common_upscale(s, w, h, method, crop):
22
+ mode = {"lanczos": "bicubic", "bicubic": "bicubic", "area": "area",
23
+ "bilinear": "bilinear", "nearest-exact": "nearest-exact"}[method]
24
+ kw = {} if mode in ("area", "nearest-exact") else {"align_corners": False}
25
+ return torch.nn.functional.interpolate(s, size=(h, w), mode=mode, **kw)
26
+
27
+ utils.common_upscale = common_upscale
28
+ comfy.utils = utils
29
+ sys.modules.setdefault("comfy", comfy)
30
+ sys.modules.setdefault("comfy.utils", utils)
31
+
32
+ api = types.ModuleType("comfy_api")
33
+ latest = types.ModuleType("comfy_api.latest")
34
+
35
+ class _Any:
36
+ Input = staticmethod(lambda *a, **k: None)
37
+ Output = staticmethod(lambda *a, **k: None)
38
+
39
+ class io:
40
+ Image = Mask = Int = String = Boolean = Combo = Float = Video = Clip = _Any
41
+ Schema = staticmethod(lambda *a, **k: None)
42
+ NodeOutput = tuple
43
+
44
+ class ComfyNode:
45
+ pass
46
+
47
+ latest.io = io
48
+ latest.ComfyExtension = object
49
+ api.latest = latest
50
+ sys.modules.setdefault("comfy_api", api)
51
+ sys.modules.setdefault("comfy_api.latest", latest)
52
+
53
+
54
+ _stub()
55
+ import nodes_image as N # noqa: E402
56
+
57
+ FAILED = []
58
+
59
+
60
+ def check(name, got, want):
61
+ ok = got == want
62
+ print(" %-54s %s" % (name, "ok" if ok else "FAIL"))
63
+ if not ok:
64
+ print(" got %r\n want %r" % (got, want))
65
+ FAILED.append(name)
66
+
67
+
68
+ def frames(sizes, channels=3):
69
+ return [torch.rand(h, w, channels) for w, h in sizes]
70
+
71
+
72
+ def bars(fr, cw, ch, **kw):
73
+ opts = dict(scale_method="lanczos", allow_upscale=True, pad_color="black",
74
+ pad_hex="#000000", position="center")
75
+ opts.update(kw)
76
+ _, masks = N.compose(fr, cw, ch, opts["scale_method"], opts["allow_upscale"],
77
+ opts["pad_color"], opts["pad_hex"], opts["position"])
78
+ return [round(100 * (1 - float(masks[i].mean()))) for i in range(masks.shape[0])]
79
+
80
+
81
+ print("\nthe canvas must not invent padding")
82
+ # Clamping width and height independently destroys the union box's aspect: a
83
+ # lone 1920x1080 against a 1024 ceiling used to come back a 1024x1024 square,
84
+ # 44% of it bars, with nothing to reconcile against.
85
+ for name, sizes in (("a single 16:9 image", [(1920, 1080)]),
86
+ ("a single tall portrait", [(2000, 3000)]),
87
+ ("three identical portraits", [(832, 1216)] * 3),
88
+ ("three identical landscapes", [(1216, 832)] * 3),
89
+ ("an extreme 40:1 panorama", [(4000, 100)])):
90
+ fr = frames(sizes)
91
+ w, h = N.canvas_for(fr, 1024, 1024)
92
+ check("%s needs no padding" % name, max(bars(fr, w, h)), 0)
93
+
94
+ check("the canvas keeps the source aspect",
95
+ N.canvas_for(frames([(1920, 1080)]), 1024, 1024), (1024, 576))
96
+
97
+ print("\nthe ceiling is still a ceiling")
98
+ w, h = N.canvas_for(frames([(4000, 3000)]), 1024, 1024)
99
+ check("neither axis exceeds it", (w <= 1024, h <= 1024), (True, True))
100
+ check("tiny inputs are not upscaled to the ceiling",
101
+ N.canvas_for(frames([(64, 64)]), 16384, 16384), (64, 64))
102
+ check("an asymmetric ceiling is respected",
103
+ N.canvas_for(frames([(2000, 2000)]), 512, 256), (256, 256))
104
+
105
+ print("\nmixed shapes pad rather than crop")
106
+ fr = frames([(832, 1216), (1216, 832)])
107
+ w, h = N.canvas_for(fr, 1024, 1024)
108
+ padded = bars(fr, w, h)
109
+ check("both images are padded, neither cropped", all(0 < b < 100 for b in padded), True)
110
+ _, masks = N.compose(fr, w, h, "lanczos", True, "black", "#000000", "center")
111
+ check("every pixel of each source survives",
112
+ all(float(masks[i].sum()) > 0 for i in range(masks.shape[0])), True)
113
+
114
+ print("\ncollect")
115
+ check("a batch on one socket is flattened",
116
+ len(N.collect({"image_1": torch.rand(4, 64, 64, 3)})), 4)
117
+ check("gaps between sockets are skipped",
118
+ len(N.collect({"image_1": None, "image_3": torch.rand(1, 64, 64, 3)})), 1)
119
+ check("RGB and RGBA are harmonised",
120
+ N.collect({"image_1": torch.rand(1, 8, 8, 3),
121
+ "image_2": torch.rand(1, 8, 8, 4)})[0].shape[2], 4)
122
+ try:
123
+ N.collect({"image_1": None})
124
+ check("nothing connected raises", False, True)
125
+ except ValueError as exc:
126
+ check("nothing connected raises", True, True)
127
+ check("and the message mentions bypass", "bypassed" in str(exc), True)
128
+
129
+ print("\npad colours and placement")
130
+ fr = frames([(100, 50)])
131
+ img, _ = N.compose(fr, 100, 100, "lanczos", True, "white", "#000000", "center")
132
+ check("white bars are white", round(float(img[0, 0, 0, 0])), 1)
133
+ img, _ = N.compose(fr, 100, 100, "lanczos", True, "custom hex", "#0000ff", "center")
134
+ check("custom hex reaches the blue channel", round(float(img[0, 0, 0, 2])), 1)
135
+ grey = N.compose(frames([(100, 50)], channels=1), 100, 100, "lanczos", True,
136
+ "custom hex", "#0000ff", "center")[0]
137
+ check("greyscale pads by luminance, not the red channel",
138
+ float(grey[0, 0, 0, 0]) > 0.0, True)
139
+
140
+ _, m_top = N.compose(fr, 100, 100, "lanczos", True, "black", "#000000", "top")
141
+ check("top places content at the top edge", float(m_top[0][0].mean()), 1.0)
142
+ _, m_bot = N.compose(fr, 100, 100, "lanczos", True, "black", "#000000", "bottom")
143
+ check("bottom places content at the bottom edge", float(m_bot[0][-1].mean()), 1.0)
144
+
145
+ print("\nno upscaling when asked")
146
+ small = frames([(50, 50)])
147
+ img, masks = N.compose(small, 512, 512, "lanczos", False, "black", "#000000", "center")
148
+ check("a small image keeps its native size", int(masks[0].sum()), 2500)
149
+ check("but the canvas is still the size asked for", tuple(img.shape[1:3]), (512, 512))
150
+
151
+ print("\npadding report")
152
+ check("a uniform set reports no padding",
153
+ "no padding" in N.padding_report(
154
+ N.compose(frames([(100, 100)]), 100, 100, "lanczos", True,
155
+ "black", "#000000", "center")[1]), True)
156
+ check("a padded set reports a percentage",
157
+ "padding" in N.padding_report(
158
+ N.compose(frames([(100, 50)]), 100, 100, "lanczos", True,
159
+ "black", "#000000", "center")[1]), True)
160
+
161
+ print("\ndtype and device survive")
162
+ half = [torch.rand(64, 64, 3).half()]
163
+ out, _ = N.compose(half, 32, 32, "lanczos", True, "black", "#000000", "center")
164
+ check("float16 in, float16 out", out.dtype, torch.float16)
165
+ check("mixed devices are harmonised without raising",
166
+ len(N.collect({"image_1": torch.rand(1, 8, 8, 3),
167
+ "image_2": torch.rand(1, 8, 8, 3)})), 2)
168
+
169
+ print("\ngaps from bypassed images")
170
+ # Bypassing a Load Image leaves the link in place and delivers None, so the
171
+ # canvas still shows four sockets wired while only two images arrive. Skipping
172
+ # the hole is the only option, but it renumbers everything after it.
173
+ import logging as _logging
174
+
175
+
176
+ class _Catch(_logging.Handler):
177
+ def __init__(self):
178
+ super().__init__()
179
+ self.lines = []
180
+
181
+ def emit(self, record):
182
+ self.lines.append(record.getMessage())
183
+
184
+
185
+ def _collect_with_log(images):
186
+ logger = _logging.getLogger("Nugget")
187
+ grab = _Catch()
188
+ was, logger.disabled = logger.disabled, False
189
+ logger.addHandler(grab)
190
+ try:
191
+ frames = N.collect(images)
192
+ finally:
193
+ logger.removeHandler(grab)
194
+ logger.disabled = was
195
+ return frames, " ".join(grab.lines)
196
+
197
+
198
+ def _img(n=64):
199
+ return torch.rand(1, n, n, 3)
200
+
201
+
202
+ gapped = {"image_1": _img(), "image_2": None, "image_3": None, "image_4": _img(128)}
203
+ frames, said = _collect_with_log(gapped)
204
+ check("a gap does not raise", len(frames), 2)
205
+ check("the surviving images are the ones that were wired",
206
+ [int(f.shape[0]) for f in frames], [64, 128])
207
+ check("the renumbering is reported", "renumbered" in said, True)
208
+ check("it names the empty sockets", "image_2, image_3" in said, True)
209
+ check("it spells out the new numbering", "image_4 is picture 2" in said, True)
210
+
211
+ _, quiet = _collect_with_log({"image_1": _img(), "image_2": _img(), "image_3": _img()})
212
+ check("a contiguous set says nothing", quiet, "")
213
+
214
+ _, tail = _collect_with_log({"image_1": _img(), "image_2": _img(), "image_4": None})
215
+ check("a trailing empty socket is not a gap", tail, "")
216
+
217
+ print("\nevery image bypassed")
218
+ try:
219
+ N.collect({f"image_{i}": None for i in range(1, 5)})
220
+ check("the batch node refuses, since it must output an IMAGE", False, True)
221
+ except ValueError as exc:
222
+ check("the batch node refuses, since it must output an IMAGE", True, True)
223
+ check("and explains that bypass is the likely cause",
224
+ "bypassed" in str(exc), True)
225
+
226
+ print("\n%s" % ("ALL PASSED" if not FAILED else "%d FAILED: %s" % (len(FAILED), FAILED)))
227
+ sys.exit(1 if FAILED else 0)
custom_nodes/ComfyUI-Nugget/tests/test_prompt.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Offline tests for the prompt node. No ComfyUI, no GPU, no model."""
3
+
4
+ import os
5
+ import sys
6
+ import types
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
+
10
+ import torch # noqa: E402
11
+
12
+
13
+ def _stub():
14
+ comfy = types.ModuleType("comfy")
15
+ utils = types.ModuleType("comfy.utils")
16
+
17
+ def common_upscale(s, w, h, method, crop):
18
+ return torch.nn.functional.interpolate(s, size=(h, w), mode="bicubic",
19
+ align_corners=False)
20
+
21
+ utils.common_upscale = common_upscale
22
+ comfy.utils = utils
23
+ sys.modules.setdefault("comfy", comfy)
24
+ sys.modules.setdefault("comfy.utils", utils)
25
+
26
+ api = types.ModuleType("comfy_api")
27
+ latest = types.ModuleType("comfy_api.latest")
28
+
29
+ class _Any:
30
+ Input = staticmethod(lambda *a, **k: None)
31
+ Output = staticmethod(lambda *a, **k: None)
32
+
33
+ class io:
34
+ Image = Mask = Int = String = Boolean = Combo = Float = _Any
35
+ Video = Clip = Audio = _Any
36
+ Schema = staticmethod(lambda *a, **k: None)
37
+ NodeOutput = tuple
38
+
39
+ class ComfyNode:
40
+ pass
41
+
42
+ latest.io = io
43
+ latest.ComfyExtension = object
44
+ api.latest = latest
45
+ sys.modules.setdefault("comfy_api", api)
46
+ sys.modules.setdefault("comfy_api.latest", latest)
47
+
48
+
49
+ _stub()
50
+ import llm # noqa: E402
51
+ import nodes_prompt as P # noqa: E402
52
+
53
+ FAILED = []
54
+
55
+
56
+ def check(name, got, want):
57
+ ok = got == want
58
+ print(" %-56s %s" % (name, "ok" if ok else "FAIL"))
59
+ if not ok:
60
+ print(" got %r\n want %r" % (got, want))
61
+ FAILED.append(name)
62
+
63
+
64
+ def frames(sizes):
65
+ return [torch.rand(h, w, 3) for w, h in sizes]
66
+
67
+
68
+ print("\nmax image size caps, it does not stretch or upscale")
69
+ out = P.cap(frames([(832, 1216)]), 1024)
70
+ check("a tall image is capped on its long side", tuple(out[0].shape[:2]), (1024, 701))
71
+ out = P.cap(frames([(1920, 1080)]), 1024)
72
+ check("a wide image is capped on its long side", tuple(out[0].shape[:2]), (576, 1024))
73
+ out = P.cap(frames([(200, 200)]), 1024)
74
+ check("a small image is left alone", tuple(out[0].shape[:2]), (200, 200))
75
+ src = frames([(1000, 500)])
76
+ out = P.cap(src, 500)
77
+ check("the aspect ratio is preserved exactly",
78
+ round(out[0].shape[1] / out[0].shape[0], 3), 2.0)
79
+
80
+ print("\nimages keep their own shapes -- nothing is made to match")
81
+ mixed = P.cap(frames([(832, 1216), (1216, 832), (200, 200)]), 1024)
82
+ check("a mixed set stays mixed", P.uniform(mixed), False)
83
+ check("no image was cropped",
84
+ [tuple(f.shape[:2]) for f in mixed], [(1024, 701), (701, 1024), (200, 200)])
85
+
86
+ # The whole point of the cap: references that agree on shape come out identical,
87
+ # so even the fallback below has nothing to pad.
88
+ same = P.cap(frames([(832, 1216)] * 3), 1024)
89
+ check("a uniform set becomes identical sizes", P.uniform(same), True)
90
+
91
+ print("\nthe list payload")
92
+ batches = P.as_batches(same)
93
+ check("each image is its own batch of one", [tuple(b.shape) for b in batches],
94
+ [(1, 1024, 701, 3)] * 3)
95
+ check("no images gives no payload", P.as_batches([]), [])
96
+
97
+ print("\nthe padding fallback, for builds that need one tensor")
98
+ stacked = llm._stack_padded(P.as_batches(mixed))
99
+ check("a mixed set stacks onto the bounding canvas",
100
+ tuple(stacked.shape), (3, 1024, 1024, 3))
101
+ uniform_stack = llm._stack_padded(P.as_batches(same))
102
+ check("a uniform set needs no bars at all",
103
+ tuple(uniform_stack.shape), (3, 1024, 701, 3))
104
+ # Bars are black, so a padded corner is zero while the content is not.
105
+ corner = float(stacked[2, 0, 0].sum())
106
+ check("padding is black", corner, 0.0)
107
+ check("content survives padding", float(stacked[0].sum()) > 0, True)
108
+
109
+ print("\n_batch dispatches on what it is given")
110
+ check("None stays None", llm._batch(None), None)
111
+ check("an empty list is None", llm._batch([]), None)
112
+ one = torch.rand(1, 8, 8, 3)
113
+ check("a lone tensor passes through", llm._batch([one]).shape, one.shape)
114
+ check("a bare tensor passes through", llm._batch(one).shape, one.shape)
115
+ many = llm._batch([torch.rand(1, 8, 8, 3), torch.rand(1, 4, 4, 3)])
116
+ check("several tensors stay a list", isinstance(many, list), True)
117
+ check("and keep their own shapes",
118
+ [tuple(t.shape[1:3]) for t in many], [(8, 8), (4, 4)])
119
+
120
+ print("\nsampling on/off actually reaches the model")
121
+ # The widget used to be inferred from temperature, so switching sampling on with
122
+ # temperature at zero decoded greedily -- the control said one thing and the
123
+ # model did another.
124
+ _seen = {}
125
+
126
+
127
+ class _Inner:
128
+ def generate(self, *a, **k):
129
+ pass
130
+
131
+
132
+ class _Clip:
133
+ cond_stage_model = _Inner()
134
+
135
+ def tokenize(self, *a, **k):
136
+ return "T"
137
+
138
+ def generate(self, tokens, **kw):
139
+ _seen.clear()
140
+ _seen.update(kw)
141
+ return "G"
142
+
143
+ def decode(self, g):
144
+ return "text."
145
+
146
+
147
+ def _run(**kw):
148
+ llm.ask(_Clip(), "p", None, 100, 42, **kw)
149
+ return _seen
150
+
151
+
152
+ check("sampling off decodes greedily",
153
+ _run(sample=False, temperature=0.7)["do_sample"], False)
154
+ check("sampling on samples",
155
+ _run(sample=True, temperature=0.7)["do_sample"], True)
156
+ check("sampling on with temperature 0 still samples",
157
+ _run(sample=True, temperature=0.0)["do_sample"], True)
158
+ check("and its temperature is floored, not left at zero",
159
+ _run(sample=True, temperature=0.0)["temperature"] > 0, True)
160
+ check("the filters are inert when greedy",
161
+ (_run(sample=False)["top_k"], _run(sample=False)["top_p"]), (0, 1.0))
162
+ check("the filters engage when sampling",
163
+ _run(sample=True, temperature=0.7)["top_k"], 64)
164
+
165
+ # A repetition penalty reshapes the logits before the pick, so it applies to
166
+ # greedy decoding too. It used to be dropped whenever sampling was off.
167
+ check("repetition penalty applies when greedy",
168
+ _run(sample=False, repetition_penalty=1.15)["repetition_penalty"], 1.15)
169
+ check("repetition penalty applies when sampling",
170
+ _run(sample=True, temperature=0.7,
171
+ repetition_penalty=1.15)["repetition_penalty"], 1.15)
172
+ check("its default is off", _run(sample=False)["repetition_penalty"], 1.0)
173
+
174
+ check("callers that pass no sample flag stay greedy",
175
+ _run(temperature=0.0)["do_sample"], False)
176
+ check("and a bare temperature still turns sampling on",
177
+ _run(temperature=0.8)["do_sample"], True)
178
+
179
+ print("\ndescription detail")
180
+ check("off appends nothing", P.DETAIL["off"][0], "")
181
+ check("it is named for images", "img_description_detail" in open(
182
+ os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
183
+ "nodes_prompt.py")).read(), True)
184
+ check("off does not raise the budget", P.DETAIL["off"][1], 0)
185
+ for _level in ("brief", "normal", "detailed"):
186
+ check("%s appends an instruction" % _level, bool(P.DETAIL[_level][0]), True)
187
+ check("the budget grows with the level",
188
+ P.DETAIL["brief"][1] < P.DETAIL["normal"][1] < P.DETAIL["detailed"][1], True)
189
+ check("every level is offered", sorted(P.DETAIL),
190
+ ["brief", "detailed", "normal", "off"])
191
+
192
+ print("\nsampling control degrades rather than disappearing")
193
+ # DynamicCombo is recent and its nested-input API has been churning. If it is
194
+ # missing or its signature has moved, the node must still register with plain
195
+ # widgets -- a cosmetic feature is not worth losing the node over.
196
+ _inputs = P.sampling_input()
197
+ check("it returns at least one input", len(_inputs) >= 1, True)
198
+ check("and reports which shape it used", isinstance(P.DYNAMIC_SAMPLING, bool), True)
199
+
200
+ print("\n%s" % ("ALL PASSED" if not FAILED else "%d FAILED: %s" % (len(FAILED), FAILED)))
201
+ sys.exit(1 if FAILED else 0)
custom_nodes/ComfyUI-Nugget/tests/test_registration.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Static checks on how the pack registers itself.
3
+
4
+ python tests/test_registration.py
5
+
6
+ These parse the source rather than importing it, so they run without ComfyUI --
7
+ which matters, because every failure they catch is one that makes nodes silently
8
+ vanish from the menu rather than raise anything visible.
9
+ """
10
+
11
+ import ast
12
+ import os
13
+ import sys
14
+
15
+ HERE = os.path.dirname(os.path.abspath(__file__))
16
+ ROOT = os.path.dirname(HERE)
17
+
18
+ FAILED = []
19
+
20
+
21
+ def check(name, got, want):
22
+ ok = got == want
23
+ print(" %-56s %s" % (name, "ok" if ok else "FAIL"))
24
+ if not ok:
25
+ print(" got %r\n want %r" % (got, want))
26
+ FAILED.append(name)
27
+
28
+
29
+ def tree(filename):
30
+ with open(os.path.join(ROOT, filename)) as fh:
31
+ return ast.parse(fh.read())
32
+
33
+
34
+ NODE_MODULES = ["nodes_transcribe.py", "nodes_motion.py", "nodes_prompt.py",
35
+ "nodes_image.py"]
36
+
37
+ print("\nevery name in NODES is a class defined in the same file")
38
+ # A find-and-replace across the package once rewrote `NODES = [VideoAutoTranscribe]`
39
+ # into `NODES = [Nugget]`, because that string was byte-identical to the log
40
+ # prefix being replaced. The module then raised NameError on import, the
41
+ # defensive loader swallowed it, and the node simply was not in the menu.
42
+ for filename in NODE_MODULES:
43
+ t = tree(filename)
44
+ defined = {n.name for n in ast.walk(t) if isinstance(n, ast.ClassDef)}
45
+ listed = []
46
+ for node in t.body:
47
+ if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "NODES":
48
+ listed = [e.id for e in node.value.elts if isinstance(e, ast.Name)]
49
+ check("%s declares NODES" % filename, bool(listed), True)
50
+ check("%s: every entry exists as a class" % filename,
51
+ [n for n in listed if n not in defined], [])
52
+
53
+ print("\nnode identity is complete and unique")
54
+ ids, names, categories = [], [], []
55
+ for filename in NODE_MODULES:
56
+ for call in ast.walk(tree(filename)):
57
+ if not (isinstance(call, ast.Call) and getattr(call.func, "attr", "") == "Schema"):
58
+ continue
59
+ kw = {k.arg: getattr(k.value, "value", None) for k in call.keywords}
60
+ ids.append(kw.get("node_id"))
61
+ names.append(kw.get("display_name"))
62
+ categories.append(kw.get("category"))
63
+
64
+ check("five nodes are declared", len(ids), 5)
65
+ check("no node is missing an id", [i for i in ids if not i], [])
66
+ check("no node is missing a display name", [n for n in names if not n], [])
67
+ check("node ids are unique", len(set(ids)), len(ids))
68
+ check("display names are unique", len(set(names)), len(names))
69
+ check("every node sits under Nugget", sorted(set(categories)), ["Nugget"])
70
+
71
+ print("\n__init__ registers the modern way")
72
+ init = tree("__init__.py")
73
+ assigned = {n.targets[0].id for n in ast.walk(init)
74
+ if isinstance(n, ast.Assign) and hasattr(n.targets[0], "id")}
75
+ # ComfyUI's loader checks NODE_CLASS_MAPPINGS first and only falls through to
76
+ # comfy_entrypoint if it is absent, so defining both registers nothing useful.
77
+ check("NODE_CLASS_MAPPINGS is not defined", "NODE_CLASS_MAPPINGS" in assigned, False)
78
+ check("WEB_DIRECTORY is defined", "WEB_DIRECTORY" in assigned, True)
79
+ check("comfy_entrypoint is defined",
80
+ any(isinstance(n, ast.AsyncFunctionDef) and n.name == "comfy_entrypoint"
81
+ for n in ast.walk(init)), True)
82
+ check("each node module is imported separately",
83
+ len([n for n in ast.walk(init)
84
+ if isinstance(n, ast.Try)]) >= 1, True)
85
+
86
+ print("\nthe browser extension matches a real node id")
87
+ js = os.path.join(ROOT, "web", "js", "nugget_batch_nocrop.js")
88
+ check("the js file is present", os.path.exists(js), True)
89
+ if os.path.exists(js):
90
+ with open(js) as fh:
91
+ source = fh.read()
92
+ referenced = [i for i in ids if i and ('"%s"' % i) in source]
93
+ check("it binds to a node that exists", bool(referenced), True)
94
+
95
+ print("\nno undefined names anywhere in the package")
96
+ # Two shipped bugs were undefined names that Python only raises when the line
97
+ # runs: `NODES = [Nugget]` after a find-and-replace, and a stale `shot_frames`
98
+ # in a log call that fired only after the first generation finished. Neither is
99
+ # a syntax error, so nothing caught them before a user did.
100
+ try:
101
+ from pyflakes.api import check as _pyflakes_check
102
+ from pyflakes.reporter import Reporter
103
+ import io as _io
104
+
105
+ _out, _err = _io.StringIO(), _io.StringIO()
106
+ _reporter = Reporter(_out, _err)
107
+ _problems = []
108
+ for _f in sorted(os.listdir(ROOT)):
109
+ if not _f.endswith(".py"):
110
+ continue
111
+ with open(os.path.join(ROOT, _f)) as _fh:
112
+ _source = _fh.read()
113
+ _out.truncate(0), _out.seek(0)
114
+ _pyflakes_check(_source, _f, _reporter)
115
+ for _line in _out.getvalue().splitlines():
116
+ # Unused imports are style, not breakage.
117
+ if "imported but unused" in _line or "unable to detect" in _line:
118
+ continue
119
+ _problems.append(_line)
120
+ check("pyflakes reports no problems", _problems, [])
121
+ except ImportError:
122
+ print(" skipped -- pip install pyflakes to run this check")
123
+
124
+ print("\nnothing on the node destroys ComfyUI's cache")
125
+ # ComfyUI skips a node whose inputs are unchanged. A widget that randomises
126
+ # itself on every queue makes every queue a cache miss, so re-running to try a
127
+ # different sampler seed re-transcribed the whole video for nothing.
128
+ import re as _re3
129
+ # A self-randomising seed is a cache miss on every queue. That is only worth
130
+ # paying where the seed actually changes the output -- so the prompt node may
131
+ # have one (it can sample), and the transcriber may not (it is always greedy,
132
+ # so a re-run costs minutes and returns identical text).
133
+ for _f in ("nodes_transcribe.py", "nodes_image.py"):
134
+ with open(os.path.join(ROOT, _f)) as _fh:
135
+ _src2 = _fh.read()
136
+ _live = [l.strip() for l in _src2.splitlines()
137
+ if "control_after_generate" in l and not l.strip().startswith("#")]
138
+ check("%s has no self-randomising widget" % _f, _live, [])
139
+
140
+ with open(os.path.join(ROOT, "nodes_prompt.py")) as _fh:
141
+ _psrc = _fh.read()
142
+ check("the prompt node's seed warns that randomize costs a re-run",
143
+ "identical answer" in _psrc, True)
144
+
145
+ print("\nthe prompt node's widget order is predictable")
146
+ # ComfyUI sorts advanced widgets to the end, so a node that mixes them has a
147
+ # saved widget order that no longer matches the schema. Every value in a
148
+ # hand-written workflow then shifts along by one -- which is how sampling_mode
149
+ # ended up reading a temperature of zero and quietly decoding greedily.
150
+ with open(os.path.join(ROOT, "nodes_prompt.py")) as _fh:
151
+ _psrc2 = _fh.read()
152
+ _adv = [l.strip() for l in _psrc2.splitlines()
153
+ if "advanced=True" in l and not l.strip().startswith("#")]
154
+ check("no advanced widgets on the prompt node", _adv, [])
155
+
156
+
157
+ print("\nthe auto-expanding sockets extension covers the right nodes")
158
+ # A node declaring image_1..image_N needs the browser extension, or all nine
159
+ # sockets show at once. A node with a single `image` input must not be bound,
160
+ # or the extension would delete a socket it does not understand.
161
+ import re as _re4
162
+
163
+ with open(os.path.join(ROOT, "web", "js", "nugget_batch_nocrop.js")) as _fh:
164
+ _js = _fh.read()
165
+ _m = _re4.search(r"NODES = new Set\(\[(.*?)\]\)", _js, _re4.S)
166
+ check("the extension declares a node list", bool(_m), True)
167
+ _covered = set(_re4.findall(r'"(\w+)"', _m.group(1))) if _m else set()
168
+
169
+ _needs = set()
170
+ for _f in NODE_MODULES:
171
+ _src3 = open(os.path.join(ROOT, _f)).read()
172
+ for _call in ast.walk(ast.parse(_src3)):
173
+ if not (isinstance(_call, ast.Call)
174
+ and getattr(_call.func, "attr", "") == "Schema"):
175
+ continue
176
+ _kw = {k.arg: k.value for k in _call.keywords}
177
+ _nid = getattr(_kw.get("node_id"), "value", None)
178
+ _ins = ast.dump(_kw.get("inputs")) if "inputs" in _kw else ""
179
+ # image_1 appears either literally or as an f-string range
180
+ if '"image_1"' in _ins or "image_" in _ins:
181
+ _needs.add(_nid)
182
+
183
+ check("every multi-image node is bound", sorted(_needs - _covered), [])
184
+ check("no single-image node is bound", sorted(_covered - _needs), [])
185
+
186
+ print("\nthe declared widget order matches the schema")
187
+ # Positional widgets_values in a saved workflow are matched against this order.
188
+ # Inserting a widget without updating saved workflows shifts every value after
189
+ # it -- which has silently broken sampling_mode once and img_description_detail
190
+ # once. WIDGET_ORDER is the thing a workflow is written against, so it has to
191
+ # stay in step with the schema.
192
+ import ast as _ast
193
+ import re as _re5
194
+
195
+ with open(os.path.join(ROOT, "nodes_prompt.py")) as _fh:
196
+ _psrc3 = _fh.read()
197
+
198
+ _declared = _re5.search(r"WIDGET_ORDER = \[(.*?)\]", _psrc3, _re5.S)
199
+ check("WIDGET_ORDER is declared", bool(_declared), True)
200
+ _order = _re5.findall(r'"(\w+)"', _declared.group(1)) if _declared else []
201
+
202
+ # Everything in WIDGET_ORDER must exist as an input somewhere in the module.
203
+ _inputs = set(_re5.findall(r'io\.\w+\.Input\(\s*\n?\s*"(\w+)"', _psrc3))
204
+ check("every declared widget exists in the schema",
205
+ [w for w in _order if w not in _inputs], [])
206
+
207
+ # And the widget-ish inputs must all be declared, so nothing is forgotten.
208
+ _widgetish = {w for w in _inputs
209
+ if w not in {"clip"} and not w.startswith("image_")}
210
+ check("every schema widget is declared in WIDGET_ORDER",
211
+ sorted(_widgetish - set(_order)), [])
212
+
213
+ check("img description detail is first", _order[0] if _order else None,
214
+ "img_description_detail")
215
+
216
+ print("\nnothing defeats ComfyUI's cache")
217
+ # ComfyUI skips a node whose inputs are unchanged. It decides that, not the node
218
+ # -- so "fixed" does not make a node refuse to run, it keeps the inputs stable so
219
+ # the node is never asked. Anything here that varies between runs, or that tells
220
+ # ComfyUI to always re-execute, breaks that.
221
+ import re as _re6
222
+
223
+ _FORCES_RERUN = ("IS_CHANGED", "fingerprint_inputs", "NOT_IDEMPOTENT",
224
+ "is_output_node", "OUTPUT_NODE")
225
+ for _f in ("nodes_transcribe.py", "nodes_prompt.py", "nodes_image.py"):
226
+ with open(os.path.join(ROOT, _f)) as _fh:
227
+ _body = "\n".join(l for l in _fh if not l.strip().startswith("#"))
228
+ for _marker in _FORCES_RERUN:
229
+ check("%s does not declare %s" % (_f, _marker), _marker in _body, False)
230
+
231
+ # A varying value must not reach an output, or a cached result would differ
232
+ # from a fresh one. The clock is used for timing logs, which is fine -- what
233
+ # matters is whether anything derived from it is returned.
234
+ def _time_backed_names(tree):
235
+ names = set()
236
+ for node in ast.walk(tree):
237
+ if not isinstance(node, ast.Assign):
238
+ continue
239
+ src = ast.dump(node.value)
240
+ if "attr='time'" in src or "'monotonic'" in src or "'perf_counter'" in src:
241
+ for target in node.targets:
242
+ if isinstance(target, ast.Name):
243
+ names.add(target.id)
244
+ return names
245
+
246
+
247
+ for _f in ("nodes_transcribe.py", "nodes_prompt.py"):
248
+ with open(os.path.join(ROOT, _f)) as _fh:
249
+ _tree = ast.parse(_fh.read())
250
+ _clocky = _time_backed_names(_tree)
251
+ _returned = set()
252
+ for _node in ast.walk(_tree):
253
+ if (isinstance(_node, ast.Call)
254
+ and getattr(_node.func, "attr", "") == "NodeOutput"):
255
+ for _arg in _node.args:
256
+ _returned |= {n.id for n in ast.walk(_arg)
257
+ if isinstance(n, ast.Name)}
258
+ check("%s returns nothing derived from the clock" % _f,
259
+ sorted(_clocky & _returned), [])
260
+ check("%s does not use uuid or random" % _f,
261
+ bool(_re6.search(r"\buuid\b|\brandom\.",
262
+ open(os.path.join(ROOT, _f)).read())), False)
263
+
264
+
265
+ print("\nlong loops can be cancelled")
266
+ _LONG = {"speech.py": "interrupt.check()",
267
+ "video.py": "interrupt.check()"}
268
+ for _f, _needle in _LONG.items():
269
+ with open(os.path.join(ROOT, _f)) as _fh:
270
+ _src = _fh.read()
271
+ check("%s polls for cancellation" % _f, _needle in _src, True)
272
+
273
+ with open(os.path.join(ROOT, "speech.py")) as _fh:
274
+ _speech = _fh.read()
275
+ # A broad `except Exception` around transcription would turn a cancel into a
276
+ # note and let the run continue into the vision passes.
277
+ check("transcription re-raises a cancel instead of noting it",
278
+ "except interrupt.Interrupted:" in _speech and
279
+ _speech.index("except interrupt.Interrupted:") <
280
+ _speech.index("except Exception as exc:\n log.warning"), True)
281
+
282
+ print("\n%s" % ("ALL PASSED" if not FAILED else "%d FAILED: %s" % (len(FAILED), FAILED)))
283
+ sys.exit(1 if FAILED else 0)
custom_nodes/ComfyUI-Nugget/tests/test_transcribe.py ADDED
@@ -0,0 +1,715 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Offline tests. No ComfyUI, no GPU, no model.
3
+
4
+ python tests/test_videoautotranscribe.py
5
+
6
+ The video tests need PyAV and build their own clip, so nothing external is
7
+ downloaded and nothing depends on a file being in the right place.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+
13
+ import torch # noqa: E402
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+
17
+ import detect # noqa: E402
18
+ import llm # noqa: E402
19
+ import prompts # noqa: E402
20
+ import speech # noqa: E402
21
+
22
+ FAILED = []
23
+
24
+
25
+ def check(name, got, want):
26
+ ok = got == want
27
+ print(" %-52s %s" % (name, "ok" if ok else "FAIL"))
28
+ if not ok:
29
+ print(" got %r\n want %r" % (got, want))
30
+ FAILED.append(name)
31
+
32
+
33
+ def flat(scores_spikes, n=240, fps=12.0, floor=0.002):
34
+ """A flat score series with spikes at the given (index, value) positions."""
35
+ series = [(i / fps, floor) for i in range(n)]
36
+ for index, value in scores_spikes:
37
+ series[index] = (series[index][0], value)
38
+ return series
39
+
40
+
41
+ print("\nnon-maximum suppression -- the bug this pack exists to avoid")
42
+ # A weak candidate just before a strong one. Accepting in time order keeps the
43
+ # weak one and rejects the strong one; NMS must do the opposite.
44
+ series = flat([(48, 0.10), (56, 0.55)]) # 4.00s weak, 4.67s strong
45
+ cuts = detect.find_cuts(series, "normal", 1.0, 0.0, 20.0)
46
+ check("keeps the strong cut, not the earlier weak one", cuts, [56 / 12.0])
47
+
48
+ series = flat([(60, 0.50), (120, 0.50), (180, 0.50)])
49
+ check("three well-spaced cuts all survive",
50
+ detect.find_cuts(series, "normal", 1.0, 0.0, 20.0),
51
+ [5.0, 10.0, 15.0])
52
+
53
+ print("\nboundaries")
54
+ series = flat([(3, 0.60), (120, 0.50)]) # 0.25s in, then 10s
55
+ check("head sliver suppressed by min_shot",
56
+ detect.find_cuts(series, "normal", 1.0, 0.0, 20.0), [10.0])
57
+
58
+ series = flat([(120, 0.50), (236, 0.60)]) # 10s, then 19.67s of a 20s clip
59
+ check("tail sliver suppressed (fade to black)",
60
+ detect.find_cuts(series, "normal", 1.0, 0.0, 20.0), [10.0])
61
+
62
+ print("\nflash suppression")
63
+ pair = flat([(60, 0.50), (62, 0.48)]) # 2 frames apart, similar scores
64
+ check("a flash pair is dropped entirely",
65
+ detect.find_cuts(pair, "normal", 1.0, 0.0, 20.0), [])
66
+ uneven = flat([(60, 0.50), (62, 0.05)]) # close but very different
67
+ check("a cut followed by minor activity is kept",
68
+ detect.find_cuts(uneven, "normal", 1.0, 0.0, 20.0), [5.0])
69
+
70
+ print("\nshot building")
71
+ shots = detect.build_shots([3.0, 7.0], 0.0, 10.0)
72
+ check("cuts become contiguous shots",
73
+ [(s.start, s.end) for s in shots], [(0.0, 3.0), (3.0, 7.0), (7.0, 10.0)])
74
+ check("shots are numbered from 1", [s.index for s in shots], [1, 2, 3])
75
+ check("no cuts -> exactly one shot",
76
+ [(s.start, s.end) for s in detect.build_shots([], 0.0, 10.0)], [(0.0, 10.0)])
77
+ check("cuts outside the region are ignored",
78
+ len(detect.build_shots([-5.0, 3.0, 99.0], 0.0, 10.0)), 2)
79
+ shots = detect.build_shots([3.0, 7.0], 0.0, 10.0)
80
+ check("shots tile with no gap",
81
+ all(abs(shots[i].end - shots[i + 1].start) < 1e-9
82
+ for i in range(len(shots) - 1)), True)
83
+
84
+ print("\nsensitivity ordering")
85
+ weak = flat([(120, 0.02)])
86
+ counts = [len(detect.find_cuts(weak, s, 1.0, 0.0, 20.0))
87
+ for s in ("low", "normal", "high")]
88
+ check("high finds at least as much as low", counts[2] >= counts[0], True)
89
+
90
+ print("\ntoken stripping -- the <think>/<|image> leak")
91
+ check("reasoning block removed",
92
+ llm.tidy("<think>hmm</think>Real answer."), "Real answer.")
93
+ check("truncated reasoning yields nothing, not the trace",
94
+ llm.tidy("<think>hmm, let me consider"), "")
95
+ check("text before an unclosed think tag survives",
96
+ llm.tidy("Answer.<think>now let me reconsi"), "Answer.")
97
+ check("gemma 4 markers removed",
98
+ llm.tidy("<|turn>model\n<|channel>final\nText.<turn|>"), "Text.")
99
+ check("gemma 3 markers removed",
100
+ llm.tidy("<start_of_turn>model\nText.<end_of_turn>"), "Text.")
101
+ check("image placeholders removed",
102
+ llm.tidy("<|image><|image|><image|>\n\nText."), "Text.")
103
+ check("the observed runaway collapses to the answer",
104
+ llm.tidy("Answer." + "\n<think>\n</think>\n\n<|image>" * 30), "Answer.")
105
+ check("clean text is untouched", llm.tidy("Just text."), "Just text.")
106
+ check("empty stays empty", llm.tidy(""), "")
107
+
108
+ print("\ntimestamps")
109
+ check("MM:SS.mmm", prompts.clock(8.828), "00:08.828")
110
+ check("under a second", prompts.clock(0.12), "00:00.120")
111
+ check("past a minute", prompts.clock(75.5), "01:15.500")
112
+ check("negative clamps to zero", prompts.clock(-3.0), "00:00.000")
113
+
114
+ print("\nassembly")
115
+ sp = speech.Speech([(1.0, 2.0, "Hello there."), (9.0, 9.5, "Later.")], "en", 0.98)
116
+ shots = detect.build_shots([5.0], 0.0, 10.0)
117
+ text = prompts.assemble("Warm and bright.", "a man in a green jacket",
118
+ [(shots[0], [(0.0, 5.0, "a man stands.")]),
119
+ (shots[1], [(5.0, 10.0, "he turns.")])], sp)
120
+ check("overview present", "overview: Warm and bright." in text, True)
121
+ check("subjects present", "a man in a green jacket" in text, True)
122
+ check("shot headers carry real timestamps",
123
+ "[Shot 1] From 00:00.000 to 00:05.000," in text, True)
124
+ check("speech lands in the shot it happens in",
125
+ text.index("Hello there.") < text.index("[Shot 2]"), True)
126
+ check("later speech lands in the later shot",
127
+ text.index("Later.") > text.index("[Shot 2]"), True)
128
+ check("language reported in full", "speech in English" in text, True)
129
+
130
+ silent = speech.Speech(note="no speech detected")
131
+ quiet = prompts.assemble("Look.", "NONE",
132
+ [(shots[0], [(0.0, 5.0, "a man stands.")])], silent)
133
+ check("no-speech note shown", "audio: no speech detected" in quiet, True)
134
+ check("NONE subjects omitted", "subjects:" not in quiet, True)
135
+ check("transcript falls back to the note",
136
+ prompts.transcript_text(silent), "no speech detected")
137
+ check("transcript carries timings",
138
+ prompts.transcript_text(sp).splitlines()[0],
139
+ "[00:00:01.000 - 00:00:02.000] Hello there.".replace("00:00:0", "00:0"))
140
+
141
+ print("\nadaptive frame budget")
142
+ check("a short shot is not over-sampled", prompts.frames_for(0.4, "normal"), 3)
143
+ check("a medium shot scales with length", prompts.frames_for(4.0, "normal"), 6)
144
+ check("a long shot is capped", prompts.frames_for(60.0, "normal"), 9)
145
+ check("brief caps lower than normal",
146
+ prompts.frames_for(60.0, "brief") < prompts.frames_for(60.0, "normal"), True)
147
+ check("detailed caps higher than normal",
148
+ prompts.frames_for(60.0, "detailed") > prompts.frames_for(60.0, "normal"), True)
149
+ check("never returns zero", prompts.frames_for(0.0, "brief") >= 2, True)
150
+
151
+ print("\nsample times stay inside the shot")
152
+ times = prompts.sample_times(4.8, 8.828, 5)
153
+ check("first is after the cut", times[0] > 4.8, True)
154
+ check("last is before the next cut", times[-1] < 8.828, True)
155
+ check("count honoured", len(times), 5)
156
+
157
+ print("\ncontext frames cover every shot")
158
+ many = detect.build_shots([1.0, 9.0, 9.6], 0.0, 20.0) # incl. a 0.6s sliver
159
+ ctx = prompts.context_times(many, 8)
160
+ check("one frame per shot at minimum",
161
+ all(any(s.start <= t <= s.end for t in ctx) for s in many), True)
162
+ check("budget respected", len(ctx) <= 8, True)
163
+ check("sorted for a single decode pass", ctx == sorted(ctx), True)
164
+ check("no shots -> no frames", prompts.context_times([], 8), [])
165
+
166
+ print("\nspeech is assigned to exactly one shot")
167
+ shots2 = detect.build_shots([4.8], 0.0, 10.0)
168
+ spanning = speech.Speech([(4.5, 5.9, "crosses the cut")], "en", 0.9)
169
+ placed = prompts.assign_speech(spanning, shots2)
170
+ check("a line crossing a cut is not duplicated",
171
+ sum(len(v) for v in placed.values()), 1)
172
+ check("it lands in the shot it mostly occupies", len(placed[2]), 1)
173
+ early = speech.Speech([(0.1, 4.0, "before the cut")], "en", 0.9)
174
+ check("a line before the cut lands in shot 1",
175
+ len(prompts.assign_speech(early, shots2)[1]), 1)
176
+ check("every shot gets a key",
177
+ sorted(prompts.assign_speech(early, shots2).keys()), [1, 2])
178
+
179
+ print("\nassembly does not duplicate spanning speech")
180
+ text2 = prompts.assemble("Look.", "NONE",
181
+ [(shots2[0], [(0.0, 4.8, "first.")]),
182
+ (shots2[1], [(4.8, 10.0, "second.")])], spanning)
183
+ check("spoken line appears exactly once", text2.count("crosses the cut"), 1)
184
+
185
+ print("\nbeats -- long shots get described in pieces")
186
+ long_shot = detect.build_shots([], 0.0, 20.0)[0]
187
+ check("a 20s shot is split at normal", len(prompts.beats_for(long_shot, "normal")), 3)
188
+ check("detailed splits it further",
189
+ len(prompts.beats_for(long_shot, "detailed")) >
190
+ len(prompts.beats_for(long_shot, "normal")), True)
191
+ check("brief splits it less",
192
+ len(prompts.beats_for(long_shot, "brief")) <
193
+ len(prompts.beats_for(long_shot, "normal")), True)
194
+
195
+ short_shot = detect.build_shots([], 0.0, 4.0)[0]
196
+ check("a short shot stays whole", len(prompts.beats_for(short_shot, "normal")), 1)
197
+ edge = detect.build_shots([], 0.0, 7.5)[0]
198
+ check("just over the limit is not split into a sliver",
199
+ len(prompts.beats_for(edge, "normal")), 1)
200
+
201
+ beats = prompts.beats_for(long_shot, "normal")
202
+ check("beats tile the shot with no gap",
203
+ all(abs(beats[i][1] - beats[i + 1][0]) < 1e-9 for i in range(len(beats) - 1)), True)
204
+ check("beats cover the whole shot", (beats[0][0], beats[-1][1]), (0.0, 20.0))
205
+ check("a 20s shot now gets many more frames than before",
206
+ sum(prompts.frames_for(e - s2, "normal") for s2, e in beats) > 20, True)
207
+
208
+ print("\nframe grid")
209
+ try:
210
+ from PIL import Image
211
+ import video as vm
212
+ tiles = [Image.new("RGB", (200, 150), (i * 40, 0, 0)) for i in range(6)]
213
+ out = vm.to_grid(tiles, 768)
214
+ check("six frames become one image", len(out), 1)
215
+ check("grid respects the requested width", out[0].size[0] <= 768, True)
216
+ check("grid keeps the source aspect ratio",
217
+ abs(out[0].size[1] / out[0].size[0] - (2 * 150) / (3 * 200)) < 0.02, True)
218
+ check("a single frame is passed through", len(vm.to_grid(tiles[:1], 768)), 1)
219
+ check("no frames -> nothing", vm.to_grid([], 768), [])
220
+ check("grid is one image where separate would be six",
221
+ (len(vm.to_grid(tiles, 768)), len(tiles)), (1, 6))
222
+ except ImportError:
223
+ print(" skipped (PIL unavailable)")
224
+
225
+ print("\nmulti-beat assembly")
226
+ sp3 = speech.Speech(note="no speech detected")
227
+ multi = prompts.assemble("Look.", "NONE",
228
+ [(long_shot, [(0.0, 10.0, "he walks."),
229
+ (10.0, 20.0, "he stops.")])], sp3)
230
+ check("multi-beat shot uses a header line",
231
+ "[Shot 1] From 00:00.000 to 00:20.000:" in multi, True)
232
+ check("each beat carries its own times", "00:00.000 - 00:10.000" in multi, True)
233
+ check("second beat present", "00:10.000 - 00:20.000 he stops." in multi, True)
234
+ single = prompts.assemble("Look.", "NONE",
235
+ [(short_shot, [(0.0, 4.0, "he waits.")])], sp3)
236
+ check("single-beat shot stays inline",
237
+ "[Shot 1] From 00:00.000 to 00:04.000, he waits." in single, True)
238
+
239
+ print("\nframe size is a ceiling, not a target")
240
+ try:
241
+ from PIL import Image
242
+ import video as vm
243
+ tiles = [Image.new("RGB", (100, 75), (0, 0, 0)) for _ in range(4)]
244
+ big = vm.to_grid(tiles, 1024)
245
+ check("a grid is not blown up past its source",
246
+ big[0].size[0] <= 100 * 2, True)
247
+ small = vm.to_grid(tiles, 120)
248
+ check("a grid still honours a smaller request", small[0].size[0] <= 120, True)
249
+ except ImportError:
250
+ print(" skipped (PIL unavailable)")
251
+
252
+ print("\ninstall hint names the running interpreter")
253
+ hint = speech.install_hint()
254
+ check("mentions the package", "faster-whisper" in hint, True)
255
+ check("names an interpreter, not bare pip", "-m pip" in hint, True)
256
+
257
+
258
+ print("\ncamera movement is measured, not guessed")
259
+ try:
260
+ import numpy as np
261
+ import camera
262
+
263
+ rng = np.random.default_rng(3)
264
+ base = rng.random((camera.SIZE, camera.SIZE))
265
+
266
+ def track(shift_x, shift_y, steps=8):
267
+ rows = []
268
+ frame = base
269
+ for i in range(steps):
270
+ nxt = np.roll(np.roll(base, shift_x * (i + 1), axis=1),
271
+ shift_y * (i + 1), axis=0)
272
+ dx, dy, dz = camera.step(frame, nxt)
273
+ rows.append((i * 0.1, dx, dy, dz))
274
+ frame = nxt
275
+ return rows
276
+
277
+ # Content sliding right means the camera swung left, and vice versa.
278
+ check("content moving right reads as a left pan",
279
+ "pans left" in camera.summarise(track(6, 0), 0.0, 10.0), True)
280
+ check("content moving left reads as a right pan",
281
+ "pans right" in camera.summarise(track(-6, 0), 0.0, 10.0), True)
282
+ check("content moving down reads as a tilt up",
283
+ "tilts up" in camera.summarise(track(0, 6), 0.0, 10.0), True)
284
+ check("content moving up reads as a tilt down",
285
+ "tilts down" in camera.summarise(track(0, -6), 0.0, 10.0), True)
286
+ check("a still camera reads as locked off",
287
+ "locked off" in camera.summarise(track(0, 0), 0.0, 10.0), True)
288
+ check("a diagonal move reports both axes",
289
+ camera.summarise(track(6, 6), 0.0, 10.0).count(" and "), 1)
290
+ check("an empty window says nothing", camera.summarise([], 0.0, 10.0), "")
291
+ check("one sample is not enough",
292
+ camera.summarise([(0.0, 9, 9, 0)], 0.0, 10.0), "")
293
+ # A moving stretch early, a still stretch later: asking about the later
294
+ # window must not pick up the earlier movement.
295
+ mixed = [(t, dx, dy, dz) for t, dx, dy, dz in track(9, 0)]
296
+ mixed += [(5.0 + i * 0.1, 0, 0, 0) for i in range(8)]
297
+ check("only motion inside the window counts",
298
+ "locked off" in camera.summarise(mixed, 5.0, 6.0), True)
299
+ check("the earlier window still reads as moving",
300
+ "pans" in camera.summarise(mixed, 0.0, 1.0), True)
301
+ except ImportError:
302
+ print(" skipped (numpy unavailable)")
303
+
304
+ print("\noutput sections")
305
+ sp4 = speech.Speech([(1.0, 2.0, "Hi.")], "en", 0.97)
306
+ sh4 = detect.build_shots([5.0], 0.0, 10.0)
307
+ part = prompts.sections("Warm light.", "a man in a beanie",
308
+ [(sh4[0], [(0.0, 5.0, "he waits.")]),
309
+ (sh4[1], [(5.0, 10.0, "he leaves.")])], sp4)
310
+ check("four sections available",
311
+ sorted(part.keys()), ["audio", "overview", "shots", "subjects"])
312
+ check("overview is bare, no label", part["overview"], "Warm light.")
313
+ check("subjects are bare, no label", part["subjects"], "a man in a beanie")
314
+ check("shots block has both shots",
315
+ part["shots"].count("[Shot "), 2)
316
+ check("shots block carries no overview", "Warm light." in part["shots"], False)
317
+ check("audio summarises the speech", "speech in English" in part["audio"], True)
318
+ full = prompts.assemble("Warm light.", "a man in a beanie",
319
+ [(sh4[0], [(0.0, 5.0, "he waits.")]),
320
+ (sh4[1], [(5.0, 10.0, "he leaves.")])], sp4)
321
+ check("full description contains every section",
322
+ all(x in full for x in ("overview:", "characters identified:", "shots:", "audio:")), True)
323
+ check("NONE subjects leaves the section empty",
324
+ prompts.sections("o", "NONE", [(sh4[0], [(0.0, 5.0, "x")])], sp4)["subjects"], "")
325
+
326
+ print("\nedge guard for fades")
327
+ series = flat([(120, 0.50), (232, 0.60)]) # 10s, then 19.3s of a 20s clip
328
+ check("a fade near the end is not a shot, even at min_shot 0.5",
329
+ detect.find_cuts(series, "normal", 0.5, 0.0, 20.0), [10.0])
330
+ check("a real cut well inside is kept at min_shot 0.5",
331
+ detect.find_cuts(flat([(60, 0.5), (90, 0.5)]), "normal", 0.5, 0.0, 20.0),
332
+ [5.0, 7.5])
333
+
334
+ print("\nsubject list cleaning")
335
+ check("NONE means nothing", prompts.clean_subjects("NONE"), "")
336
+ check("lowercase none too", prompts.clean_subjects("none"), "")
337
+ check("empty stays empty", prompts.clean_subjects(""), "")
338
+ check("bullets stripped",
339
+ prompts.clean_subjects("- a tabby cat\n- a red truck"), "a tabby cat\na red truck")
340
+ check("numbering stripped",
341
+ prompts.clean_subjects("1. a man in a jacket\n2. a dog"), "a man in a jacket\na dog")
342
+ check("legacy Man A labels stripped",
343
+ prompts.clean_subjects("Man A - beanie and jacket"), "beanie and jacket")
344
+ check("non-people subjects survive intact",
345
+ prompts.clean_subjects("a red pickup truck"), "a red pickup truck")
346
+ check("blank lines dropped",
347
+ prompts.clean_subjects("a cat\n\n\na dog"), "a cat\na dog")
348
+
349
+ print("\nsubjects reach the shot prompt")
350
+ filled = prompts.SHOT.format(layout="Six frames.", span=4.0, sentences="two sentences",
351
+ subjects=prompts.SUBJECTS_KNOWN.format(subjects="a tabby cat"),
352
+ camera=prompts.CAMERA_FACT.format(motion="the camera pans left"))
353
+ check("subject list is in the prompt", "a tabby cat" in filled, True)
354
+ check("camera fact is in the prompt", "the camera pans left" in filled, True)
355
+ bare = prompts.SHOT.format(layout="Six frames.", span=4.0, sentences="two sentences",
356
+ subjects="", camera="")
357
+ check("both are optional", "{" not in bare, True)
358
+
359
+ print("\nduplicate characters are merged")
360
+ same = prompts.clean_subjects(
361
+ "a man in a green jacket\n"
362
+ "a man in an olive puffer jacket and black beanie\n"
363
+ "a man wearing a dark beanie and green puffer jacket")
364
+ check("three framings of one man collapse to one",
365
+ len([r for r in same.splitlines() if r]), 1)
366
+ check("the fullest description is the one kept", len(same) > 30, True)
367
+ check("exact repeats collapse",
368
+ prompts.clean_subjects("a tabby cat\na tabby cat"), "a tabby cat")
369
+ check("a man and a woman stay separate",
370
+ len(prompts.clean_subjects(
371
+ "a man in a green jacket\na woman in a red dress").splitlines()), 2)
372
+ check("a man and a dog stay separate",
373
+ len(prompts.clean_subjects(
374
+ "a man in a blue coat\na golden retriever").splitlines()), 2)
375
+ check("two men in clearly different clothes stay separate",
376
+ len(prompts.clean_subjects(
377
+ "a man in a yellow raincoat\na man in a grey suit").splitlines()), 2)
378
+ check("trailing punctuation stripped",
379
+ prompts.clean_subjects("a tabby cat."), "a tabby cat")
380
+ check("Character A prefix stripped",
381
+ prompts.clean_subjects("Character A: a tabby cat"), "a tabby cat")
382
+
383
+ print("\ncharacters label")
384
+ sp5 = speech.Speech(note="no speech detected")
385
+ sh5 = detect.build_shots([], 0.0, 5.0)
386
+ out5 = prompts.assemble("Look.", "a man in a green jacket",
387
+ [(sh5[0], [(0.0, 5.0, "he waits.")])], sp5)
388
+ check("section is labelled 'characters identified:'",
389
+ "characters identified:" in out5, True)
390
+ check("old 'cast:' label is gone", "cast:" not in out5, True)
391
+ check("old 'subjects:' label is gone", "\nsubjects:" not in out5, True)
392
+
393
+ print("\nwindows cuda dll registration")
394
+ check("enable_cuda_libraries is a no-op off windows",
395
+ speech.enable_cuda_libraries() if os.name != "nt" else [], [])
396
+ check("it is idempotent", speech.enable_cuda_libraries(), [])
397
+
398
+ print("\nwrong CLIP model is caught before any work")
399
+ class _SD1ClipModel: pass
400
+ class _Gemma:
401
+ def generate(self, *a, **k): pass
402
+ class _Wrapper:
403
+ def __init__(self, inner): self.cond_stage_model = inner
404
+ def tokenize(self, *a, **k): pass
405
+ def generate(self, *a, **k): pass
406
+ def decode(self, *a, **k): pass
407
+
408
+ try:
409
+ llm.check(_Wrapper(_SD1ClipModel()))
410
+ check("an SD encoder is rejected", False, True)
411
+ except llm.LLMError as exc:
412
+ check("an SD encoder is rejected", True, True)
413
+ check("the message names what was loaded",
414
+ "Stable Diffusion 1.x" in str(exc), True)
415
+ check("the message says what to do",
416
+ "text_encoders" in str(exc), True)
417
+
418
+ try:
419
+ llm.check(_Wrapper(_Gemma()))
420
+ check("a generative model passes", True, True)
421
+ except llm.LLMError:
422
+ check("a generative model passes", False, True)
423
+
424
+ try:
425
+ llm.check(None)
426
+ check("no clip is rejected", False, True)
427
+ except llm.LLMError as exc:
428
+ check("no clip is rejected", "No CLIP connected" in str(exc), True)
429
+
430
+ print("\nlanguage names, not codes")
431
+ check("code to name", speech.language_name("ja"), "Japanese")
432
+ check("name to code", speech.language_code("Japanese"), "ja")
433
+ check("auto stays auto", speech.language_code("auto"), "auto")
434
+ check("cantonese has its own code", speech.language_code("Cantonese"), "yue")
435
+ check("unknown code passes through", speech.language_name("xx"), "xx")
436
+ check("empty code gives empty", speech.language_name(""), "")
437
+ check("Speech exposes the full name",
438
+ speech.Speech([(0, 1, "x")], "ja", 0.9).language_full, "Japanese")
439
+ sp6 = speech.Speech([(0, 1, "x")], "fr", 0.95)
440
+ sh6 = detect.build_shots([], 0.0, 5.0)
441
+ out6 = prompts.sections("o", "NONE", [(sh6[0], [(0.0, 5.0, "x")])], sp6)
442
+ check("assembled audio line spells the language out",
443
+ "speech in French" in out6["audio"], True)
444
+ check("no bare two-letter code in the output",
445
+ " in fr " in out6["audio"], False)
446
+
447
+ print("\nqwen3-vl markers are stripped too")
448
+ check("im_start / im_end removed",
449
+ llm.tidy("<|im_start|>assistant\nA man walks.<|im_end|>"), "A man walks.")
450
+ check("vision placeholders removed",
451
+ llm.tidy("<|vision_start|><|image_pad|><|vision_end|>A cat."), "A cat.")
452
+ check("endoftext removed", llm.tidy("Done.<|endoftext|>"), "Done.")
453
+ check("gemma markers still removed",
454
+ llm.tidy("<|turn>model\n<|channel>final\nText.<turn|>"), "Text.")
455
+
456
+ print("\nprompt_max_length")
457
+ for level, expected in (("brief", 160), ("normal", 300), ("detailed", 500)):
458
+ check(f"0 keeps {level} exactly as before",
459
+ prompts.token_budget(level, 0)["shot"], expected)
460
+ check("0 keeps the overview budget", prompts.token_budget("normal", 0)["overview"], 120)
461
+ check("0 keeps the character budget",
462
+ prompts.token_budget("normal", 0)["characters"], 200)
463
+ check("negative is treated as 0",
464
+ prompts.token_budget("normal", -5)["shot"], 300)
465
+
466
+ big = prompts.token_budget("normal", 800)
467
+ check("an override sets the shot budget", big["shot"], 800)
468
+ check("the overview scales with it", big["overview"], 200)
469
+ check("the character list scales with it", big["characters"], 400)
470
+ small = prompts.token_budget("normal", 100)
471
+ check("a small override never shrinks the setup passes below their floors",
472
+ (small["overview"], small["characters"]), (120, 200))
473
+ check("but it does set the shot budget", small["shot"], 100)
474
+
475
+ print("\ntruncation detection")
476
+ check("a finished sentence is not truncated",
477
+ llm.looks_truncated("A man walks across the snow at sunset."), False)
478
+ check("stopping mid-sentence is truncated",
479
+ llm.looks_truncated("A man walks across the snow and then he begins to"), True)
480
+ check("a closing quote counts as finished",
481
+ llm.looks_truncated('He turns and says \"we are nearly there.\"'), False)
482
+ check("short text is never flagged", llm.looks_truncated("Short."), False)
483
+ check("empty is never flagged", llm.looks_truncated(""), False)
484
+ check("None is safe", llm.looks_truncated(None), False)
485
+
486
+ print("\nbypassed video is skipped, not an error")
487
+ try:
488
+ import video as vm2
489
+ check("None means missing", vm2.missing(None), True)
490
+ check("empty string means missing", vm2.missing(""), True)
491
+ check("whitespace means missing", vm2.missing(" "), True)
492
+ check("a real path is not missing", vm2.missing("clip.mp4"), False)
493
+
494
+ class _RealVideo:
495
+ def save_to(self, path): pass
496
+ class _Junk:
497
+ pass
498
+ check("a real VIDEO object is not missing", vm2.missing(_RealVideo()), False)
499
+
500
+ # Bypass does not reliably give None -- ComfyUI passes through whatever was
501
+ # on the bypassed node's inputs, so widget values can land here.
502
+ check("an int from a widget is missing", vm2.missing(42), True)
503
+ check("a float is missing", vm2.missing(1.5), True)
504
+ check("a bool is missing", vm2.missing(False), True)
505
+ check("a list is missing", vm2.missing(["x"]), True)
506
+ check("a dict is missing", vm2.missing({"a": 1}), True)
507
+ check("an object with no file handle is missing", vm2.missing(_Junk()), True)
508
+
509
+ # The six outputs a skipped run returns must join cleanly onto a prompt --
510
+ # any placeholder text would end up inside it.
511
+ blank = ("", "", "", "", "", "")
512
+ check("skipped outputs are all empty", all(x == "" for x in blank), True)
513
+ check("skipped outputs join to nothing", "".join(blank), "")
514
+ except ImportError:
515
+ print(" skipped (PyAV unavailable)")
516
+
517
+ print("\nCUDA runtime mixing is refused, not risked")
518
+ check("speech reports torch's cuda major", isinstance(speech.torch_cuda_major(), int), True)
519
+ import inspect as _inspect
520
+ _src = _inspect.getsource(speech.enable_cuda_libraries)
521
+ check("it never touches PATH", "environ" in _src, False)
522
+ check("it never registers torch's lib folder",
523
+ "torch" in _src.split('\"\"\"')[2], False)
524
+ check("registering twice is a no-op", speech.enable_cuda_libraries(), [])
525
+
526
+ print("\ntranscription can be turned off")
527
+ check("off returns a note, not an error",
528
+ speech.transcribe("/nonexistent.wav", "off").note, "transcription turned off")
529
+ check("off never claims speech", speech.transcribe("/x.wav", "off").spoken, False)
530
+ check("off leaves the language empty", speech.transcribe("/x.wav", "off").language, "")
531
+ check("none is treated the same",
532
+ speech.transcribe("/x.wav", "none").note, "transcription turned off")
533
+ off = speech.transcribe("/x.wav", "off")
534
+ check("transcript output explains itself",
535
+ prompts.transcript_text(off), "transcription turned off")
536
+
537
+ print("\nprompts contain nothing a model can copy as content")
538
+ import re as _re
539
+ # A few-shot example in the character prompt was being reproduced verbatim: every
540
+ # video came back with "a tabby cat" in it, whatever was actually on screen. Once
541
+ # a phantom reaches the character list it is asserted as fact in every shot
542
+ # description afterwards, so nothing copyable may live in these prompts.
543
+ for _name in ("OVERVIEW", "SUBJECTS", "SHOT", "GRID_NOTE", "SUBJECTS_KNOWN",
544
+ "CAMERA_FACT", "CONTINUES"):
545
+ _p = getattr(prompts, _name)
546
+ _lines = [l.strip() for l in _p.splitlines() if l.strip()]
547
+ _suspect = [l for l in _lines
548
+ if _re.match(r"^(a|an|the)\s+\w+", l, _re.I)
549
+ and not l.endswith((".", ":", "?"))]
550
+ check("%s has no bare example line" % _name, _suspect, [])
551
+
552
+ for _word in ("tabby", "puffer", "beanie", "retriever", "pickup"):
553
+ check("no '%s' in the character prompt" % _word,
554
+ _word in prompts.SUBJECTS.lower(), False)
555
+
556
+ print("\nplaceholder echoes never reach the shot passes")
557
+ check("an unfilled placeholder is dropped",
558
+ prompts.clean_subjects("<what they are> <the details>"), "")
559
+ check("a placeholder among real entries is dropped",
560
+ prompts.clean_subjects("a woman in a red dress\n<what they are> <details>"),
561
+ "a woman in a red dress")
562
+ check("a real subject survives",
563
+ prompts.clean_subjects("a woman in a leopard-print bodysuit"),
564
+ "a woman in a leopard-print bodysuit")
565
+ check("an animal that is really there still survives",
566
+ prompts.clean_subjects("a tabby cat"), "a tabby cat")
567
+
568
+ print("\nnothing in this pack censors model output")
569
+ # Censorship is handled outside this node. Anything filtered here would be
570
+ # filtered twice, so every transformation that touches model output must be
571
+ # lossless with respect to content -- it may only remove control tokens and
572
+ # formatting artefacts.
573
+ _STRONG = [
574
+ "A nude woman lies on a bed, her body fully visible in harsh light.",
575
+ 'He screams "get the fuck out of my house" and slams the door.',
576
+ "Blood sprays across the wall as the knife enters his chest.",
577
+ "She strips off her underwear and straddles him, breathing hard.",
578
+ "The severed head rolls into frame, eyes still open.",
579
+ "Two men fight; one is stabbed repeatedly and bleeds out on the floor.",
580
+ ]
581
+ for _text in _STRONG:
582
+ check("tidy() leaves %r untouched" % (_text[:34] + "..."),
583
+ llm.tidy(_text), _text)
584
+
585
+ for _subject in ("a naked man covered in blood",
586
+ "a topless woman with a scar across her chest",
587
+ "a bleeding man holding a severed arm"):
588
+ check("clean_subjects() leaves %r untouched" % (_subject[:30] + "..."),
589
+ prompts.clean_subjects(_subject), _subject)
590
+
591
+ # Control tokens still go, and only control tokens.
592
+ check("control tokens are still stripped from explicit text",
593
+ llm.tidy("<|im_start|>assistant\nA nude figure, bleeding.<|im_end|>"),
594
+ "A nude figure, bleeding.")
595
+
596
+ print("\nno prompt steers the model toward sanitised output")
597
+ import re as _re2
598
+ _STEER = _re2.compile(
599
+ r"appropriate|tasteful|family.friendly|suitable for|offend|polite|decent|"
600
+ r"modest|discreet|refrain|censor|clean up|tone down|avoid describing",
601
+ _re2.I)
602
+ for _name in ("OVERVIEW", "SUBJECTS", "SHOT", "GRID_NOTE", "SUBJECTS_KNOWN",
603
+ "CAMERA_FACT", "CONTINUES"):
604
+ check("%s does not steer the model" % _name,
605
+ _STEER.findall(getattr(prompts, _name)), [])
606
+
607
+ print("\nno word list exists anywhere")
608
+ import os as _os
609
+ _src = ""
610
+ for _f in sorted(_os.listdir(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))):
611
+ if _f.endswith(".py"):
612
+ with open(_os.path.join(
613
+ _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), _f)) as _fh:
614
+ _src += _fh.read()
615
+ for _term in ("blocklist", "blacklist", "banned", "profanity", "censor",
616
+ "redact", "nsfw"):
617
+ check("the package contains no %s" % _term, _term in _src.lower(), False)
618
+
619
+ print("\nsilence has to survive an audio encoder")
620
+ # A one-sample waveform passed to MiniMax H3's audio VAE reaches a conv1d with
621
+ # kernel 7 as six timesteps and raises "Kernel size can't be greater than actual
622
+ # input size". Silence must therefore have real length, not merely exist.
623
+ try:
624
+ import video as vmod
625
+
626
+ quiet = vmod.silence(0.0)
627
+ check("silence is never shorter than the floor",
628
+ quiet["waveform"].shape[2] >= quiet["sample_rate"], True)
629
+ check("it is stereo and batched", quiet["waveform"].shape[:2], (1, 2))
630
+
631
+ matched = vmod.silence(4.56)
632
+ check("it matches the clip duration when given one",
633
+ round(matched["waveform"].shape[2] / matched["sample_rate"], 2), 4.56)
634
+
635
+ def survives(audio):
636
+ """Six halvings, as the H3 audio encoder does, then a kernel of 7."""
637
+ n = audio["waveform"].shape[2]
638
+ for _ in range(6):
639
+ n = (n + 1) // 2
640
+ return n >= 7
641
+
642
+ check("a one-sample waveform would not survive the encoder",
643
+ survives({"waveform": torch.zeros((1, 2, 1)), "sample_rate": 44100}), False)
644
+ for _seconds in (0.0, 0.1, 1.0, 4.56, 20.0):
645
+ check("%.2fs of silence survives the encoder" % _seconds,
646
+ survives(vmod.silence(_seconds)), True)
647
+ except ImportError:
648
+ print(" skipped (PyAV unavailable)")
649
+
650
+ print("\ntruncation is only reported when the budget was the limit")
651
+ _fragment = "A woman in a leopard print bodysuit dances against a wall and then she"
652
+ check("a short fragment is not blamed on a large budget",
653
+ llm.looks_truncated(_fragment, 1600), False)
654
+ check("but it is flagged against a budget it could have filled",
655
+ llm.looks_truncated(_fragment, 20), True)
656
+ check("a reply that really filled the budget is flagged",
657
+ llm.looks_truncated("word " * 1200, 1600), True)
658
+ check("a finished sentence is never flagged",
659
+ llm.looks_truncated("She turns and walks away.", 20), False)
660
+ check("with no budget given, the old behaviour holds",
661
+ llm.looks_truncated(_fragment), True)
662
+
663
+ print("\nspeech helpers")
664
+ check("during() overlaps, not just contains",
665
+ len(sp.during(1.5, 3.0)), 1)
666
+ check("during() excludes non-overlapping", len(sp.during(3.0, 8.0)), 0)
667
+ check("spoken flag", (sp.spoken, silent.spoken), (True, False))
668
+
669
+ print("\nend to end on a generated clip (PyAV)")
670
+ try:
671
+ import numpy as np
672
+ import av
673
+ import tempfile
674
+ import video as video_mod
675
+
676
+ path = os.path.join(tempfile.gettempdir(), "videoautotranscribe_selftest.mp4")
677
+ truth = [2.0, 4.5]
678
+ plan = [(2.0, (20, 40, 160)), (2.5, (210, 190, 80)), (2.5, (30, 140, 70))]
679
+
680
+ out = av.open(path, "w")
681
+ st = out.add_stream("libx264", rate=24)
682
+ st.width, st.height, st.pix_fmt = 320, 240, "yuv420p"
683
+ st.options = {"crf": "18"}
684
+ for seconds, colour in plan:
685
+ rng = np.random.default_rng(len(colour) + colour[0])
686
+ tex = rng.integers(0, 50, (240, 320, 3), dtype=np.uint8)
687
+ for i in range(int(seconds * 24)):
688
+ arr = np.clip(np.roll(tex, (i * 5) % 320, axis=1).astype(np.int16)
689
+ + np.array(colour, np.int16), 0, 255).astype(np.uint8)
690
+ out.mux(st.encode(av.VideoFrame.from_ndarray(arr, format="rgb24")))
691
+ out.mux(st.encode(None))
692
+ out.close()
693
+
694
+ clip = video_mod.open_clip(path)
695
+ check("duration read", round(clip.duration, 1), 7.0)
696
+ scores = video_mod.scene_scores(clip)
697
+ check("frames scored", len(scores) > 50, True)
698
+
699
+ found = detect.find_cuts(scores, "normal", 1.0, 0.0, clip.duration)
700
+ hits = sum(1 for t in truth if any(abs(g - t) < 0.2 for g in found))
701
+ check("both planted cuts found (%s)" % [round(f, 2) for f in found],
702
+ (hits, len(found)), (2, 2))
703
+
704
+ frames = video_mod.frames_at(clip, [0.5, 3.0, 6.0], width=128)
705
+ check("frames returned in order", [round(t, 1) for t, _ in frames], [0.5, 3.0, 6.0])
706
+ check("frames are resized", frames[0][1].size[0], 128)
707
+ check("no audio track detected", clip.has_audio, False)
708
+ check("silent clip transcribes to a note",
709
+ speech.transcribe(None).note, "no audio track")
710
+ os.remove(path)
711
+ except ImportError as exc:
712
+ print(" skipped (PyAV/numpy unavailable: %s)" % exc)
713
+
714
+ print("\n%s" % ("ALL PASSED" if not FAILED else "%d FAILED: %s" % (len(FAILED), FAILED)))
715
+ sys.exit(1 if FAILED else 0)
custom_nodes/ComfyUI-Nugget/video.py ADDED
@@ -0,0 +1,648 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reading the video with PyAV.
2
+
3
+ PyAV ships with ComfyUI's own video support, so if ``LoadVideo`` works in a graph
4
+ this module works. No ffmpeg binary is required and none is looked for.
5
+
6
+ Scene scoring compares colour histograms of tiny thumbnails rather than the
7
+ pixels themselves. That choice is deliberate: a mean pixel difference reacts
8
+ strongly to camera movement, so a crane or a whip pan scores like a cut. A
9
+ histogram barely moves when the camera pans across a scene, but changes sharply
10
+ when the scene itself is replaced. Measured on real footage, switching from
11
+ pixel difference to histogram difference widened the gap between the weakest
12
+ true cut and the strongest false positive from 1.5x to 2.4x.
13
+
14
+ A cut is a global change, so 64x64 is plenty to see one and keeps a 20-second
15
+ clip under two seconds of decode.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import math
21
+ import os
22
+ import tempfile
23
+ import wave
24
+ from dataclasses import dataclass
25
+
26
+ import numpy as np
27
+
28
+ try:
29
+ from . import camera, interrupt
30
+ except ImportError: # direct import for tests
31
+ import camera
32
+ import interrupt
33
+
34
+ log = logging.getLogger("Nugget")
35
+
36
+ # Thumbnail edge used for scene scoring. Bigger buys nothing: a cut changes the
37
+ # whole frame, and small thumbnails suppress the per-pixel noise that makes
38
+ # handheld footage look like a cut.
39
+ THUMB = 64
40
+
41
+ # Frames scored per second. A hard cut lands between two adjacent decoded
42
+ # frames, so this is the resolution of the reported cut time. 12 is a good
43
+ # trade: 80ms accuracy, and a 20s clip is 240 thumbnails.
44
+ SCORE_FPS = 12.0
45
+
46
+
47
+ class NoSource(RuntimeError):
48
+ """There is nothing on the video input that names a file.
49
+
50
+ Kept apart from VideoError because the two mean opposite things. A
51
+ VideoError is a real file that will not decode and the user needs to know.
52
+ This is the branch being switched off, which is not a fault and must not
53
+ stop the run.
54
+ """
55
+
56
+
57
+ class VideoError(RuntimeError):
58
+ pass
59
+
60
+
61
+ def _av():
62
+ try:
63
+ import av
64
+ except ImportError as exc: # pragma: no cover - environment dependent
65
+ raise VideoError(
66
+ "PyAV is not available. It normally ships with ComfyUI's video "
67
+ "support -- if LoadVideo works in your graph, this should too."
68
+ ) from exc
69
+ return av
70
+
71
+
72
+ @dataclass
73
+ class Clip:
74
+ path: str
75
+ duration: float
76
+ fps: float
77
+ declared_fps: float
78
+ width: int
79
+ height: int
80
+ has_audio: bool
81
+ temporary: bool = False
82
+
83
+ @property
84
+ def variable_fps(self) -> bool:
85
+ """Is the container lying about the frame rate?
86
+
87
+ Phone video, screen recordings and a lot of AI output are variable frame
88
+ rate. It does not stop anything here working -- every time is read from
89
+ the packet's own PTS -- but it is worth surfacing, because a clip tagged
90
+ 60fps that actually runs at 22 will confuse anything downstream that
91
+ trusts the tag.
92
+ """
93
+ if not self.declared_fps or not self.fps:
94
+ return False
95
+ return abs(self.declared_fps - self.fps) / max(self.fps, 1e-6) > 0.05
96
+
97
+
98
+ def _existing(path) -> str:
99
+ """The string as a path on disk, or empty if it is not one.
100
+
101
+ Bypass hands the downstream node whatever was on the bypassed node's
102
+ inputs, and for Load Video that is its widget value -- a bare filename such
103
+ as "clip.mp4". It is deliberately not resolved against ComfyUI's input
104
+ folder: the only way a bare name arrives here is a bypassed loader, and
105
+ resolving it would make bypass quietly transcribe the file the user just
106
+ switched off.
107
+ """
108
+ text = (path or "").strip() if isinstance(path, str) else ""
109
+ return text if text and os.path.exists(text) else ""
110
+
111
+
112
+ def missing(video) -> bool:
113
+ """Is there no video to work on?
114
+
115
+ A bypassed Load Video node still connects, but its VIDEO output becomes
116
+ None -- bypass passes through inputs of a matching type, and Load Video has
117
+ no VIDEO input to pass through. So None here means "the user switched this
118
+ branch off", not "the graph is broken", and it should be treated as a
119
+ request to do nothing rather than as an error.
120
+ """
121
+ if video is None:
122
+ return True
123
+ if isinstance(video, str):
124
+ return not _existing(video)
125
+ # Bypass does not always yield None. ComfyUI passes through a reference to
126
+ # whatever was on the bypassed node's inputs, so a widget value can arrive
127
+ # here in place of a video -- a filename, a number, a tensor. Anything that
128
+ # cannot name a file on disk is treated as "nothing to do" rather than as an
129
+ # error, because it means the user switched the branch off.
130
+ if isinstance(video, (int, float, bool)):
131
+ return True
132
+ if isinstance(video, (list, tuple, dict, set)):
133
+ return True
134
+ usable = ("save_to", "_VideoFromFile__file", "_file", "file", "path",
135
+ "filename", "get_stream_source", "get_components")
136
+ return not any(hasattr(video, name) for name in usable)
137
+
138
+
139
+ def _source_path(video) -> tuple[str, bool]:
140
+ """A path on disk for whatever ComfyUI handed us.
141
+
142
+ ComfyUI's VIDEO objects have exposed the underlying file under several
143
+ attribute names across versions, so try the known ones before falling back
144
+ to saving a copy.
145
+ """
146
+ if isinstance(video, str):
147
+ found = _existing(video)
148
+ if not found:
149
+ raise NoSource(f"there is no file at {video!r}")
150
+ return found, False
151
+ for attr in ("_VideoFromFile__file", "_file", "file", "path", "filename"):
152
+ value = getattr(video, attr, None)
153
+ if isinstance(value, str) and os.path.exists(value):
154
+ return value, False
155
+ save = getattr(video, "save_to", None)
156
+ if callable(save):
157
+ handle, path = tempfile.mkstemp(suffix=".mp4", prefix="videoautotranscribe_")
158
+ os.close(handle)
159
+ save(path)
160
+ return path, True
161
+ raise NoSource(
162
+ "nothing on the video input names a file on disk. Connect a Load "
163
+ "Video node to the `video` input."
164
+ )
165
+
166
+
167
+ def measured_duration(path: str) -> float:
168
+ """The real end of the video, read from the stream rather than the header.
169
+
170
+ Some containers simply do not carry a duration: fragmented and faststart-less
171
+ MP4, a lot of WebM/Matroska, MPEG-TS, anything still being written, and most
172
+ of what image and video generators emit. ``container.duration`` is None,
173
+ ``stream.duration`` is None, and every arithmetic that follows silently uses
174
+ zero -- which is how a clip ends up reported as one shot running from
175
+ 00:00.000 to 00:00.000.
176
+
177
+ Demuxing is used rather than decoding: packets carry the timestamps and cost
178
+ a fraction of a decode. PTS is not monotonic when there are B-frames, so the
179
+ maximum is taken rather than the last value seen. Decoding is the fallback,
180
+ for the rare container whose packets have no PTS at all.
181
+ """
182
+ av = _av()
183
+ container = av.open(path)
184
+ try:
185
+ stream = container.streams.video[0]
186
+ base = stream.time_base
187
+ last = 0.0
188
+ for packet in container.demux(stream):
189
+ if packet.pts is None:
190
+ continue
191
+ scale = packet.time_base or base
192
+ if scale is None:
193
+ continue
194
+ end = float(packet.pts * scale)
195
+ if packet.duration:
196
+ end += float(packet.duration * scale)
197
+ last = max(last, end)
198
+ if last > 0:
199
+ return last
200
+ except Exception as exc:
201
+ log.debug("[Nugget] could not demux for duration: %s", exc)
202
+ finally:
203
+ container.close()
204
+
205
+ # No usable packet timestamps. Decode and take the last frame's own time,
206
+ # plus one frame so the final frame is inside the clip rather than on its
207
+ # boundary.
208
+ container = av.open(path)
209
+ try:
210
+ stream = container.streams.video[0]
211
+ stream.thread_type = "AUTO"
212
+ last = 0.0
213
+ count = 0
214
+ for frame in container.decode(stream):
215
+ count += 1
216
+ if frame.pts is not None and stream.time_base:
217
+ last = max(last, float(frame.pts * stream.time_base))
218
+ if last > 0:
219
+ rate = float(stream.average_rate or 0) or float(stream.base_rate or 0)
220
+ return last + (1.0 / rate if rate else 0.0)
221
+ if count:
222
+ rate = float(stream.average_rate or 0) or float(stream.base_rate or 0)
223
+ if rate:
224
+ return count / rate
225
+ return 0.0
226
+ except Exception as exc:
227
+ log.debug("[Nugget] could not decode for duration: %s", exc)
228
+ return 0.0
229
+ finally:
230
+ container.close()
231
+
232
+
233
+ def open_clip(video) -> Clip:
234
+ av = _av()
235
+ path, temporary = _source_path(video)
236
+ try:
237
+ container = av.open(path)
238
+ except Exception as exc:
239
+ raise VideoError(
240
+ f"Could not open the video: {exc}. If this is HEVC or AV1, your "
241
+ f"PyAV build may not include that decoder."
242
+ ) from exc
243
+ try:
244
+ streams = container.streams
245
+ if not streams.video:
246
+ raise VideoError("That file has no video stream.")
247
+ stream = streams.video[0]
248
+ declared = float(stream.average_rate or 0) or float(stream.base_rate or 0)
249
+ duration = 0.0
250
+ if container.duration:
251
+ duration = float(container.duration) / av.time_base
252
+ elif stream.duration and stream.time_base:
253
+ duration = float(stream.duration * stream.time_base)
254
+ frames = int(stream.frames or 0)
255
+ # Third fallback: the frame count and the declared rate. Free when the
256
+ # container bothered to record a frame count, and right for CFR.
257
+ if duration <= 0 and frames and declared:
258
+ duration = frames / declared
259
+ # Read everything else off the streams while the container is still
260
+ # open; the measuring pass below reopens the file and this one is gone.
261
+ width = int(stream.codec_context.width or 0)
262
+ height = int(stream.codec_context.height or 0)
263
+ has_audio = bool(streams.audio)
264
+ finally:
265
+ container.close()
266
+
267
+ # Last resort: read the timestamps out of the stream itself. This is the
268
+ # branch that stops a header with no duration turning into a shot that runs
269
+ # from 00:00.000 to 00:00.000 -- every cut time, beat and frame sample is
270
+ # derived from this number, so zero here makes the whole run meaningless
271
+ # while still appearing to succeed.
272
+ if duration <= 0:
273
+ duration = measured_duration(path)
274
+ if duration > 0:
275
+ log.info("[Nugget] the container declares no duration; measured "
276
+ "%.2fs from the stream itself", duration)
277
+
278
+ if duration <= 0:
279
+ raise VideoError(
280
+ "That file declares no duration and none could be measured from "
281
+ "it, so there are no timestamps to describe against. Re-encode it "
282
+ "(any ordinary MP4 export will do) and try again.")
283
+
284
+ real = (frames / duration) if (frames and duration) else declared
285
+ return Clip(
286
+ path=path,
287
+ duration=duration,
288
+ fps=real or declared or 24.0,
289
+ declared_fps=declared,
290
+ width=width,
291
+ height=height,
292
+ has_audio=has_audio,
293
+ temporary=temporary,
294
+ )
295
+
296
+
297
+ def scene_scores(clip: Clip, progress=None) -> list[tuple[float, float]]:
298
+ """Just the cut scores. See ``scan`` -- this drops the motion track."""
299
+ return scan(clip, progress)[0]
300
+
301
+
302
+ def scan(clip: Clip, progress=None):
303
+ """One decode pass, two results: ``(scores, motion)``.
304
+
305
+ ``scores`` is [(time, score), ...] for cut detection. ``motion`` is
306
+ [(time, dx, dy, zoom), ...] for camera movement. Both come out of the same
307
+ walk through the file, because decoding twice to compute two things about
308
+ the same frames is a waste of the slowest part of the run.
309
+
310
+ The score is the total variation distance between consecutive frames' colour
311
+ histograms -- 32 luma bins plus 16 per RGB channel, normalised, halved so the
312
+ result runs 0..1. See the module docstring for why histograms rather than
313
+ pixel differences.
314
+ """
315
+ av = _av()
316
+ container = av.open(clip.path)
317
+ try:
318
+ stream = container.streams.video[0]
319
+ stream.thread_type = "AUTO"
320
+ scores: list[tuple[float, float]] = []
321
+ motion: list[tuple[float, float, float, float]] = []
322
+ previous = None
323
+ previous_grey = None
324
+ next_wanted = 0.0
325
+ gap = 1.0 / SCORE_FPS
326
+ weights = np.array([0.299, 0.587, 0.114], dtype=np.float32)
327
+ for frame in container.decode(stream):
328
+ if frame.pts is None:
329
+ continue
330
+ when = float(frame.pts * stream.time_base)
331
+ if when + 1e-6 < next_wanted:
332
+ continue
333
+ next_wanted = when + gap
334
+ small = frame.reformat(width=THUMB, height=THUMB, format="rgb24")
335
+ rgb = small.to_ndarray().astype(np.float32) / 255.0
336
+ parts = [np.histogram(rgb @ weights, bins=32, range=(0.0, 1.0))[0]]
337
+ for channel in range(3):
338
+ parts.append(np.histogram(rgb[:, :, channel], bins=16,
339
+ range=(0.0, 1.0))[0])
340
+ current = np.concatenate(parts).astype(np.float32)
341
+ total = current.sum()
342
+ if total > 0:
343
+ current /= total
344
+ if previous is not None:
345
+ scores.append((when, float(np.abs(current - previous).sum() / 2.0)))
346
+ # Camera movement, from a larger greyscale copy of the same frame.
347
+ flat = frame.reformat(width=camera.SIZE, height=camera.SIZE,
348
+ format="gray").to_ndarray().astype(np.float64) / 255.0
349
+ if previous_grey is not None:
350
+ dx, dy, dz = camera.step(previous_grey, flat)
351
+ motion.append((when, dx, dy, dz))
352
+ previous, previous_grey = current, flat
353
+ if len(scores) % 40 == 0:
354
+ interrupt.check()
355
+ if progress and clip.duration:
356
+ progress(f"scanning {when / clip.duration * 100:.0f}%")
357
+ return scores, motion
358
+ finally:
359
+ container.close()
360
+
361
+
362
+ def frames_at(clip: Clip, times: list[float], width: int = 512):
363
+ """PIL images at the requested times, in order.
364
+
365
+ One sequential decode rather than a seek per frame. Seeking lands on the
366
+ nearest keyframe, and on a clip with keyframes 5 seconds apart that means
367
+ the frame you get is not the frame you asked for.
368
+
369
+ ``width`` is a ceiling, never a target. Asking for 768 from a 640-wide source
370
+ gives 640, because upscaling invents no detail while costing real vision
371
+ tokens and real time -- the model would pay for 768 pixels of a 640-pixel
372
+ picture.
373
+ """
374
+ from PIL import Image
375
+ av = _av()
376
+ wanted = sorted(set(round(max(0.0, t), 3) for t in times))
377
+ if not wanted:
378
+ return []
379
+ container = av.open(clip.path)
380
+ try:
381
+ stream = container.streams.video[0]
382
+ stream.thread_type = "AUTO"
383
+ # Never exceed the source. See the docstring.
384
+ if clip.width:
385
+ width = min(int(width), int(clip.width))
386
+ width = max(2, int(width) - int(width) % 2)
387
+ height = 0
388
+ if clip.width and clip.height:
389
+ height = max(2, int(round(width * clip.height / clip.width)))
390
+ height -= height % 2
391
+ out: list[tuple[float, Image.Image]] = []
392
+ cursor = 0
393
+ previous = None
394
+ seen = 0
395
+ for frame in container.decode(stream):
396
+ seen += 1
397
+ if seen % 120 == 0:
398
+ interrupt.check()
399
+ if frame.pts is None:
400
+ continue
401
+ when = float(frame.pts * stream.time_base)
402
+ while cursor < len(wanted) and when >= wanted[cursor]:
403
+ # Whichever of this frame and the last is nearer the target.
404
+ pick = frame
405
+ if previous is not None:
406
+ if abs(previous[0] - wanted[cursor]) < abs(when - wanted[cursor]):
407
+ pick = previous[1]
408
+ small = pick.reformat(width=width, height=height or None,
409
+ format="rgb24")
410
+ out.append((wanted[cursor], Image.fromarray(small.to_ndarray())))
411
+ cursor += 1
412
+ if cursor >= len(wanted):
413
+ break
414
+ previous = (when, frame)
415
+ # Anything past the last decodable frame gets that frame.
416
+ while cursor < len(wanted) and previous is not None:
417
+ small = previous[1].reformat(width=width, height=height or None,
418
+ format="rgb24")
419
+ out.append((wanted[cursor], Image.fromarray(small.to_ndarray())))
420
+ cursor += 1
421
+ return out
422
+ finally:
423
+ container.close()
424
+
425
+
426
+ def to_grid(images, width: int, columns: int = 0):
427
+ """Composite frames into one image, read left to right then top to bottom.
428
+
429
+ This trades spatial detail for temporal resolution, and for describing motion
430
+ that is the right trade. Showing six frames as one grid costs roughly what
431
+ one image costs, so a long shot can be covered densely instead of sampled
432
+ thinly -- and the model sees the progression laid out at once rather than as
433
+ a sequence it has to hold in mind.
434
+
435
+ The approach is from IG-VLM (arXiv 2403.18406), which found image-pretrained
436
+ VLMs read grids zero-shot with no video training, and Video Panels (arXiv
437
+ 2509.23724), which confirmed it across five benchmarks and several model
438
+ families. Both note the layout should be near-square, so the panels stay as
439
+ large as possible.
440
+ """
441
+ from PIL import Image
442
+ if not images:
443
+ return []
444
+ if len(images) == 1:
445
+ return list(images)
446
+ count = len(images)
447
+ if columns <= 0:
448
+ columns = int(math.ceil(math.sqrt(count)))
449
+ rows = int(math.ceil(count / columns))
450
+ # The grid is capped by the frames it is built from as well as by `width`:
451
+ # blowing a 320-wide frame up to fill a 1024 grid cell adds nothing.
452
+ width = min(int(width), images[0].width * columns)
453
+ cell_w = max(2, int(width) // columns)
454
+ aspect = images[0].height / max(1, images[0].width)
455
+ cell_h = max(2, int(cell_w * aspect))
456
+ sheet = Image.new("RGB", (cell_w * columns, cell_h * rows), (0, 0, 0))
457
+ for index, image in enumerate(images):
458
+ sheet.paste(image.resize((cell_w, cell_h), Image.LANCZOS),
459
+ ((index % columns) * cell_w, (index // columns) * cell_h))
460
+ return [sheet]
461
+
462
+
463
+ # A full-resolution frame tensor is float32, so a minute of 1080p is tens of
464
+ # gigabytes. Refuse past this rather than filling RAM and dying without a
465
+ # message -- the caller can lower the resolution or shorten the clip.
466
+ MAX_FRAME_BYTES = 6 * 1024 ** 3
467
+
468
+ # Some containers -- WebM/VP9 is the notorious one -- report the timebase
469
+ # precision as the frame rate. A 1ms-tick WebM comes out of PyAV with
470
+ # base_rate == 1000, and average_rate == 0/0 when the total frame count is
471
+ # unknown, so open_clip falls back to base_rate and clip.fps ends up at 1000.
472
+ # The RAM estimate then multiplies duration * 1000 and refuses a plainly
473
+ # innocent clip. Cap the fps we use for the sanity check at a value no real
474
+ # footage exceeds for any meaningful duration (240 covers slo-mo cameras and
475
+ # then some). Decoding itself does not use fps -- frames come one at a time
476
+ # regardless -- so this only affects the estimate. The returned fps is
477
+ # corrected below from the actual decoded frame count.
478
+ FPS_CAP_FOR_ESTIMATE = 240.0
479
+
480
+
481
+ def decode_all(clip: Clip):
482
+ """Every frame as a ``(B, H, W, C)`` float tensor, plus the real fps.
483
+
484
+ This is what feeds the node's ``images`` and ``frame_count`` outputs, so it
485
+ replaces a separate Get Video Components in the graph. Timestamps come from
486
+ each frame's own PTS, so a variable frame rate does not shift anything.
487
+ """
488
+ import torch
489
+ av = _av()
490
+ estimate = 0
491
+ if clip.width and clip.height and clip.duration and clip.fps:
492
+ fps_estimate = min(float(clip.fps), FPS_CAP_FOR_ESTIMATE)
493
+ estimate = int(clip.width * clip.height * 3 * 4
494
+ * clip.duration * fps_estimate)
495
+ if estimate > MAX_FRAME_BYTES:
496
+ raise VideoError(
497
+ f"That clip would need about {estimate / 1024 ** 3:.0f} GB of RAM "
498
+ f"as raw frames ({clip.width}x{clip.height}, {clip.duration:.0f}s "
499
+ f"at up to {int(fps_estimate)} fps). Shorten it or scale it down "
500
+ f"first.")
501
+ container = av.open(clip.path)
502
+ try:
503
+ stream = container.streams.video[0]
504
+ stream.thread_type = "AUTO"
505
+ frames = []
506
+ count = 0
507
+ for frame in container.decode(stream):
508
+ count += 1
509
+ if count % 120 == 0:
510
+ interrupt.check()
511
+ rgb = frame.reformat(format="rgb24").to_ndarray()
512
+ frames.append(torch.from_numpy(rgb.astype(np.float32) / 255.0))
513
+ if not frames:
514
+ raise VideoError("No frames could be decoded from that video.")
515
+ # Once decoding is done, the actual count and the duration are the
516
+ # last word on the real fps -- more reliable than either metadata
517
+ # field, and the fix for the "declared fps is really the container
518
+ # tick rate" case described above the constants. Only trust it if
519
+ # both numbers are usable; otherwise fall through to the metadata.
520
+ real_fps = float(clip.fps or 24.0)
521
+ if clip.duration and count:
522
+ measured_fps = count / float(clip.duration)
523
+ if 0.5 < measured_fps < FPS_CAP_FOR_ESTIMATE:
524
+ real_fps = measured_fps
525
+ return torch.stack(frames, dim=0), real_fps, count
526
+ finally:
527
+ container.close()
528
+
529
+
530
+ # Silence has to be long enough to survive an audio encoder, not merely
531
+ # non-empty. H3's audio VAE downsamples hard and then runs a conv1d with a
532
+ # kernel of 7; a one-sample waveform reaches that layer as 6 timesteps and
533
+ # raises "Kernel size can't be greater than actual input size". Anything under
534
+ # about a second risks the same thing in some encoder, so silence is generated
535
+ # at the clip's own length with a floor.
536
+ SILENCE_FLOOR = 1.0 # seconds
537
+
538
+
539
+ def silence(seconds: float = 0.0, rate: int = 44100):
540
+ """A silent stereo waveform in ComfyUI's AUDIO shape."""
541
+ import torch
542
+ length = max(int(rate * max(seconds, SILENCE_FLOOR)), int(rate))
543
+ return {"waveform": torch.zeros((1, 2, length)), "sample_rate": int(rate)}
544
+
545
+
546
+ def audio_tensor(clip: Clip):
547
+ """The soundtrack in ComfyUI's AUDIO shape, or silence if there is none.
548
+
549
+ ComfyUI expects ``{"waveform": (batch, channels, samples), "sample_rate":
550
+ int}``. Silence rather than None keeps a downstream node working on a clip
551
+ with no audio track -- and it matches the clip's duration, so anything that
552
+ lines audio up against video still lines up.
553
+ """
554
+ av = _av()
555
+ if not clip.has_audio:
556
+ log.info("[Nugget] no audio track; returning %.2fs of silence",
557
+ max(clip.duration, SILENCE_FLOOR))
558
+ return silence(clip.duration)
559
+ container = av.open(clip.path)
560
+ try:
561
+ if not container.streams.audio:
562
+ return silence(clip.duration)
563
+ stream = container.streams.audio[0]
564
+ rate = int(stream.rate or 44100)
565
+ resampler = av.audio.resampler.AudioResampler(
566
+ format="fltp", layout="stereo", rate=rate)
567
+ chunks = []
568
+ for frame in container.decode(stream):
569
+ for resampled in resampler.resample(frame):
570
+ chunks.append(resampled.to_ndarray())
571
+ for resampled in resampler.resample(None):
572
+ chunks.append(resampled.to_ndarray())
573
+ if not chunks:
574
+ return silence(clip.duration, rate)
575
+ import torch
576
+ data = np.concatenate(chunks, axis=1).astype(np.float32)
577
+ # A track that exists but is unusably short would fail the same way a
578
+ # missing one does, so pad it rather than hand on something too small.
579
+ if data.shape[1] < int(rate * SILENCE_FLOOR):
580
+ pad = np.zeros((data.shape[0], int(rate * SILENCE_FLOOR) - data.shape[1]),
581
+ dtype=np.float32)
582
+ data = np.concatenate((data, pad), axis=1)
583
+ return {"waveform": torch.from_numpy(data).unsqueeze(0),
584
+ "sample_rate": rate}
585
+ except Exception as exc:
586
+ log.warning("[Nugget] could not read the audio track: %s", exc)
587
+ return silence(clip.duration)
588
+ finally:
589
+ container.close()
590
+
591
+
592
+ def extract_audio(clip: Clip) -> str | None:
593
+ """16 kHz mono WAV for the speech model, or None if there is no audio.
594
+
595
+ 16 kHz mono is what Whisper resamples to internally anyway, so doing it here
596
+ costs nothing and keeps the temporary file small.
597
+ """
598
+ if not clip.has_audio:
599
+ return None
600
+ av = _av()
601
+ container = av.open(clip.path)
602
+ try:
603
+ if not container.streams.audio:
604
+ return None
605
+ stream = container.streams.audio[0]
606
+ resampler = av.audio.resampler.AudioResampler(
607
+ format="s16", layout="mono", rate=16000)
608
+ chunks = []
609
+ for frame in container.decode(stream):
610
+ for resampled in resampler.resample(frame):
611
+ chunks.append(resampled.to_ndarray().reshape(-1))
612
+ for resampled in resampler.resample(None): # flush
613
+ chunks.append(resampled.to_ndarray().reshape(-1))
614
+ if not chunks:
615
+ return None
616
+ samples = np.concatenate(chunks).astype(np.int16)
617
+ handle, path = tempfile.mkstemp(suffix=".wav", prefix="videoautotranscribe_")
618
+ os.close(handle)
619
+ # If anything from here to `return path` raises, the temp file is
620
+ # already on disk and will leak. Clean it up on any failure.
621
+ try:
622
+ with wave.open(path, "wb") as out:
623
+ out.setnchannels(1)
624
+ out.setsampwidth(2)
625
+ out.setframerate(16000)
626
+ out.writeframes(samples.tobytes())
627
+ except Exception:
628
+ try:
629
+ os.remove(path)
630
+ except OSError:
631
+ pass
632
+ raise
633
+ return path
634
+ except Exception as exc:
635
+ log.warning("[Nugget] could not extract audio: %s", exc)
636
+ return None
637
+ finally:
638
+ container.close()
639
+
640
+
641
+ def cleanup(*paths) -> None:
642
+ for path in paths:
643
+ if not path:
644
+ continue
645
+ try:
646
+ os.remove(path)
647
+ except OSError:
648
+ pass
custom_nodes/ComfyUI-Nugget/web/js/nugget_batch_nocrop.js ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ // Auto-expanding, self-compacting image sockets. No inputcount widget.
4
+ //
5
+ // Applies to every Nugget node with image_1..image_N inputs. Nothing here cares
6
+ // where those sockets sit in the input list, so a node with a clip and a prompt
7
+ // ahead of them works the same as one that starts with image_1.
8
+ //
9
+ // Two rules:
10
+ // 1. Compact - connected images always occupy image_1..image_N with no holes.
11
+ // Disconnect image_2 of three and image_3's link slides down into image_2.
12
+ // This keeps the socket numbers lined up with <Picture N> in the H3 prompt,
13
+ // and guarantees image_1 (a required input) is never the empty one.
14
+ // 2. Grow - keep exactly one empty trailing socket, up to image_9.
15
+ //
16
+ // Compaction relinks, so it must run against the live graph link table.
17
+
18
+ const MAX_INPUTS = 9;
19
+ const PREFIX = "image_";
20
+
21
+ // Widget-backed inputs render in the widget area rather than as sockets, so a
22
+ // socket appended to the end of the input list still appears in the right place
23
+ // on the canvas.
24
+ const NODES = new Set(["ImageBatchMultiNoCrop", "NuggetGeneratePrompt"]);
25
+
26
+ function slotIndex(name) {
27
+ const m = new RegExp(`^${PREFIX}(\\d+)$`).exec(name || "");
28
+ return m ? parseInt(m[1], 10) : null;
29
+ }
30
+
31
+ /** Image sockets, ascending by slot number, with their real input index. */
32
+ function imageSockets(node) {
33
+ return (node.inputs || [])
34
+ .map((inp, idx) => ({ inp, idx, i: slotIndex(inp.name) }))
35
+ .filter((o) => o.i !== null)
36
+ .sort((a, b) => a.i - b.i);
37
+ }
38
+
39
+ /**
40
+ * Slide every connection down so they are contiguous from image_1.
41
+ * Returns true if anything moved.
42
+ */
43
+ function compact(node, graph) {
44
+ const sockets = imageSockets(node);
45
+
46
+ // Capture the origin of each live link, in slot order.
47
+ const origins = [];
48
+ let needsCompacting = false;
49
+ let expected = 1;
50
+
51
+ for (const { inp, i } of sockets) {
52
+ if (inp.link == null) continue;
53
+ const link = graph.links?.[inp.link];
54
+ if (!link) continue;
55
+ origins.push({ id: link.origin_id, slot: link.origin_slot });
56
+ if (i !== expected) needsCompacting = true;
57
+ expected++;
58
+ }
59
+
60
+ if (!needsCompacting || origins.length === 0) return false;
61
+
62
+ // Drop every image link, then lay them back down from image_1 up.
63
+ for (const { idx } of sockets) {
64
+ if (node.inputs[idx]?.link != null) node.disconnectInput(idx);
65
+ }
66
+
67
+ for (let k = 0; k < origins.length; k++) {
68
+ const name = `${PREFIX}${k + 1}`;
69
+ if (!node.inputs.some((inp) => inp.name === name)) {
70
+ node.addInput(name, "IMAGE", { shape: 7 });
71
+ }
72
+ const targetIdx = node.inputs.findIndex((inp) => inp.name === name);
73
+ const originNode = graph.getNodeById?.(origins[k].id);
74
+ if (originNode && targetIdx >= 0) {
75
+ originNode.connect(origins[k].slot, node, targetIdx);
76
+ }
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /** Add/remove trailing sockets so exactly one spare follows the last link. */
82
+ function resize(node) {
83
+ let highestConnected = 0;
84
+ for (const { inp, i } of imageSockets(node)) {
85
+ if (inp.link != null) highestConnected = Math.max(highestConnected, i);
86
+ }
87
+ const wanted = Math.min(highestConnected + 1, MAX_INPUTS);
88
+
89
+ for (let i = 2; i <= wanted; i++) {
90
+ if (!node.inputs.some((inp) => inp.name === `${PREFIX}${i}`)) {
91
+ node.addInput(`${PREFIX}${i}`, "IMAGE", { shape: 7 });
92
+ }
93
+ }
94
+
95
+ // Highest index first so splicing does not shift the ones still to remove.
96
+ const doomed = imageSockets(node)
97
+ .filter(({ inp, i }) => i > wanted && inp.link == null)
98
+ .sort((a, b) => b.idx - a.idx);
99
+
100
+ for (const { idx } of doomed) node.removeInput(idx);
101
+ return doomed.length > 0;
102
+ }
103
+
104
+ function sync(node) {
105
+ if (node._nuggetSyncing) return;
106
+ const graph = node.graph || app.graph;
107
+ if (!graph || !node.inputs) return;
108
+
109
+ node._nuggetSyncing = true;
110
+ try {
111
+ const moved = compact(node, graph);
112
+ const resized = resize(node);
113
+ if (moved || resized) {
114
+ node.setSize(node.computeSize());
115
+ graph.setDirtyCanvas?.(true, true);
116
+ }
117
+ } finally {
118
+ node._nuggetSyncing = false;
119
+ }
120
+ }
121
+
122
+ app.registerExtension({
123
+ name: "nugget.autoexpand.images",
124
+ async beforeRegisterNodeDef(nodeType, nodeData) {
125
+ if (!NODES.has(nodeData.name)) return;
126
+
127
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
128
+ nodeType.prototype.onNodeCreated = function () {
129
+ const r = onNodeCreated?.apply(this, arguments);
130
+ requestAnimationFrame(() => sync(this));
131
+ return r;
132
+ };
133
+
134
+ // Fires on every link made or broken. The link table is not settled
135
+ // until after the handler returns, hence the deferral.
136
+ const onConnectionsChange = nodeType.prototype.onConnectionsChange;
137
+ nodeType.prototype.onConnectionsChange = function () {
138
+ const r = onConnectionsChange?.apply(this, arguments);
139
+ requestAnimationFrame(() => sync(this));
140
+ return r;
141
+ };
142
+
143
+ const onConfigure = nodeType.prototype.onConfigure;
144
+ nodeType.prototype.onConfigure = function () {
145
+ const r = onConfigure?.apply(this, arguments);
146
+ requestAnimationFrame(() => sync(this));
147
+ return r;
148
+ };
149
+ },
150
+ });
custom_nodes/ComfyUI-Nugget/web/js/nugget_transcribe_ui.js ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+
3
+ // Compact / everything view for Video Auto Transcriber.
4
+ //
5
+ // The show widget on the Python side is decoration only. Everything the user
6
+ // actually sees change lives here: two widgets and five output sockets get
7
+ // hidden when show is "compact", and re-appear when it flips to "everything".
8
+ //
9
+ // Widget hiding is easy -- swap the type to "hidden" and shrink computeSize
10
+ // to nothing. LiteGraph respects both.
11
+ //
12
+ // Output hiding is not. LiteGraph draws every slot in node.outputs, so a
13
+ // `hidden` flag on the slot object is not enough on its own -- the socket
14
+ // still shows. The reliable way is to actually remove the slot and put it
15
+ // back later. That means preserving any connections across the round trip:
16
+ // when we hide, we record where each link went; when we restore, we re-add
17
+ // the output and reconnect it. Anything the user did while it was hidden
18
+ // (impossible in the UI, since the socket wasn't there) can't stomp on it.
19
+ //
20
+ // The user's answer to "which outputs matter to me" is expressed by what they
21
+ // actually connected. So we make one concession: an output with a live link
22
+ // stays visible even in compact mode. If it was worth wiring up, it's worth
23
+ // keeping on screen. Only unconnected hideable outputs disappear.
24
+
25
+ const NODE = "VideoAutoTranscribe";
26
+ const SHOW_WIDGET = "show";
27
+
28
+ // Widgets to hide in compact view. Names match the Python schema.
29
+ const COMPACT_HIDE_WIDGETS = ["mode", "max_frame_size", "frame_layout"];
30
+
31
+ // Outputs to hide in compact view. Names match the display_name in the
32
+ // Python schema. Match by display_name, then by name, then by
33
+ // localized_name -- ComfyUI has used all three keys at various points and
34
+ // the schema's display_name may land in any of them depending on version.
35
+ const COMPACT_HIDE_OUTPUTS = [
36
+ "overview",
37
+ "characters identified",
38
+ "shots",
39
+ "audio transcription",
40
+ "audio language",
41
+ ];
42
+
43
+ // --- widget helpers ----------------------------------------------------
44
+
45
+ function findWidget(node, name) {
46
+ return (node.widgets || []).find((w) => w.name === name);
47
+ }
48
+
49
+ function hideWidget(w) {
50
+ if (w._nugHidden) return;
51
+ w._nugHidden = true;
52
+ w._nugOrigType = w.type;
53
+ w._nugOrigCompute = w.computeSize;
54
+ w.type = "hidden";
55
+ // -4 exactly cancels the widget spacer LiteGraph inserts between rows,
56
+ // so a hidden widget leaves no gap behind on the node.
57
+ w.computeSize = () => [0, -4];
58
+ }
59
+
60
+ function showWidget(w) {
61
+ if (!w._nugHidden) return;
62
+ w._nugHidden = false;
63
+ w.type = w._nugOrigType;
64
+ w.computeSize = w._nugOrigCompute;
65
+ delete w._nugOrigType;
66
+ delete w._nugOrigCompute;
67
+ }
68
+
69
+ // --- output helpers ----------------------------------------------------
70
+
71
+ function outputMatches(output, name) {
72
+ if (!output) return false;
73
+ return output.name === name
74
+ || output.localized_name === name
75
+ || output.label === name;
76
+ }
77
+
78
+ function outputHasLinks(output) {
79
+ return Array.isArray(output?.links) && output.links.length > 0;
80
+ }
81
+
82
+ /**
83
+ * Remember an output's spec and its live links, then remove it from the node.
84
+ * The stash is keyed by the display name, which is what we match against when
85
+ * restoring.
86
+ */
87
+ function stashOutput(node, name) {
88
+ const idx = (node.outputs || []).findIndex((o) => outputMatches(o, name));
89
+ if (idx < 0) return false;
90
+
91
+ const output = node.outputs[idx];
92
+
93
+ // Skip outputs the user has actually wired up. Removing them would
94
+ // silently disconnect their downstream node, which is worse than
95
+ // leaving a socket visible in compact mode.
96
+ if (outputHasLinks(output)) return false;
97
+
98
+ node._nugStashed = node._nugStashed || {};
99
+ if (node._nugStashed[name]) return false; // already stashed
100
+
101
+ node._nugStashed[name] = {
102
+ // Keep every field ComfyUI might read on the way back.
103
+ spec: {
104
+ name: output.name,
105
+ type: output.type,
106
+ localized_name: output.localized_name,
107
+ label: output.label,
108
+ },
109
+ // Where this output sat before, so restore can put it back in the
110
+ // same visual slot rather than pushing it to the bottom.
111
+ origIndex: idx,
112
+ };
113
+ node.removeOutput(idx);
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Put a stashed output back on the node. LiteGraph only supports appending,
119
+ * so if the output belongs earlier in the list we splice it into place after
120
+ * calling addOutput, then patch node.outputs to match the intended order.
121
+ */
122
+ function restoreOutput(node, name) {
123
+ const stash = node._nugStashed?.[name];
124
+ if (!stash) return false;
125
+
126
+ // If it's somehow already back (e.g. the workflow was reloaded), just
127
+ // clear the stash marker.
128
+ if ((node.outputs || []).some((o) => outputMatches(o, name))) {
129
+ delete node._nugStashed[name];
130
+ return false;
131
+ }
132
+
133
+ // addOutput appends to the end.
134
+ const displayName = stash.spec.label
135
+ || stash.spec.localized_name
136
+ || stash.spec.name;
137
+ node.addOutput(displayName, stash.spec.type);
138
+ const appended = node.outputs.length - 1;
139
+ const output = node.outputs[appended];
140
+ if (stash.spec.name) output.name = stash.spec.name;
141
+ if (stash.spec.localized_name) output.localized_name = stash.spec.localized_name;
142
+
143
+ // Move it to its original position if possible. LiteGraph indexes links
144
+ // and internal draw data by array position, so we only reorder when
145
+ // there are no links to break -- which is always, because we refused
146
+ // to stash a linked output in the first place.
147
+ const target = Math.min(stash.origIndex, node.outputs.length - 1);
148
+ if (target !== appended) {
149
+ const [moved] = node.outputs.splice(appended, 1);
150
+ node.outputs.splice(target, 0, moved);
151
+ }
152
+
153
+ delete node._nugStashed[name];
154
+ return true;
155
+ }
156
+
157
+ // --- apply -------------------------------------------------------------
158
+
159
+ function applyShow(node) {
160
+ if (node._nugApplying) return;
161
+ node._nugApplying = true;
162
+ try {
163
+ const showW = findWidget(node, SHOW_WIDGET);
164
+ const compact = (showW?.value ?? "compact") === "compact";
165
+
166
+ let changed = false;
167
+
168
+ // Widgets
169
+ for (const w of node.widgets || []) {
170
+ if (!COMPACT_HIDE_WIDGETS.includes(w.name)) continue;
171
+ if (compact) {
172
+ if (!w._nugHidden) { hideWidget(w); changed = true; }
173
+ } else {
174
+ if (w._nugHidden) { showWidget(w); changed = true; }
175
+ }
176
+ }
177
+
178
+ // Outputs
179
+ for (const name of COMPACT_HIDE_OUTPUTS) {
180
+ if (compact) {
181
+ if (stashOutput(node, name)) changed = true;
182
+ } else {
183
+ if (restoreOutput(node, name)) changed = true;
184
+ }
185
+ }
186
+
187
+ if (changed) {
188
+ // computeSize wants to grow the node to whatever the widget
189
+ // count would demand. Take the max with the current size so a
190
+ // switch back to everything expands, but a switch to compact
191
+ // does not shrink below what the user has already resized to.
192
+ const [w, h] = node.computeSize();
193
+ const cur = node.size || [w, h];
194
+ node.setSize([Math.max(w, cur[0]), Math.max(h, cur[1])]);
195
+ node.graph?.setDirtyCanvas?.(true, true);
196
+ }
197
+ } finally {
198
+ node._nugApplying = false;
199
+ }
200
+ }
201
+
202
+ // --- registration ------------------------------------------------------
203
+
204
+ app.registerExtension({
205
+ name: "nugget.transcribe.compactview",
206
+ async beforeRegisterNodeDef(nodeType, nodeData) {
207
+ if (nodeData.name !== NODE) return;
208
+
209
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
210
+ nodeType.prototype.onNodeCreated = function () {
211
+ const r = onNodeCreated?.apply(this, arguments);
212
+
213
+ // Intercept the show widget's callback so any change (widget
214
+ // click, right-click menu, keyboard) re-applies the view.
215
+ const showW = findWidget(this, SHOW_WIDGET);
216
+ if (showW) {
217
+ const origCallback = showW.callback;
218
+ showW.callback = (value, ...rest) => {
219
+ const ret = origCallback?.call(showW, value, ...rest);
220
+ // Defer so LiteGraph has finished writing the new value.
221
+ requestAnimationFrame(() => applyShow(this));
222
+ return ret;
223
+ };
224
+ }
225
+
226
+ requestAnimationFrame(() => applyShow(this));
227
+ return r;
228
+ };
229
+
230
+ // A workflow load restores widget values through onConfigure. The
231
+ // widgets are back to their saved state by the time this fires, so
232
+ // this is the right spot to re-apply the view.
233
+ const onConfigure = nodeType.prototype.onConfigure;
234
+ nodeType.prototype.onConfigure = function () {
235
+ const r = onConfigure?.apply(this, arguments);
236
+ requestAnimationFrame(() => applyShow(this));
237
+ return r;
238
+ };
239
+ },
240
+ });