Upload 42 files
Browse files- custom_nodes/ComfyUI-Nugget/README.md +525 -0
- custom_nodes/ComfyUI-Nugget/__init__.py +76 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/__init__.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/camera.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/detect.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/fingerprint.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/interrupt.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/llm.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/motion_prompts.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/nodes_image.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/nodes_motion.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/nodes_prompt.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/nodes_transcribe.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/prompts.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/recall.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/speech.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/__pycache__/video.cpython-313.pyc +0 -0
- custom_nodes/ComfyUI-Nugget/camera.py +123 -0
- custom_nodes/ComfyUI-Nugget/detect.py +167 -0
- custom_nodes/ComfyUI-Nugget/fingerprint.py +158 -0
- custom_nodes/ComfyUI-Nugget/install.bat +30 -0
- custom_nodes/ComfyUI-Nugget/install.py +496 -0
- custom_nodes/ComfyUI-Nugget/interrupt.py +39 -0
- custom_nodes/ComfyUI-Nugget/llm.py +277 -0
- custom_nodes/ComfyUI-Nugget/motion_prompts.py +450 -0
- custom_nodes/ComfyUI-Nugget/nodes_image.py +328 -0
- custom_nodes/ComfyUI-Nugget/nodes_motion.py +472 -0
- custom_nodes/ComfyUI-Nugget/nodes_prompt.py +411 -0
- custom_nodes/ComfyUI-Nugget/nodes_transcribe.py +642 -0
- custom_nodes/ComfyUI-Nugget/prompts.py +361 -0
- custom_nodes/ComfyUI-Nugget/pyproject.toml +21 -0
- custom_nodes/ComfyUI-Nugget/recall.py +149 -0
- custom_nodes/ComfyUI-Nugget/selftest.py +111 -0
- custom_nodes/ComfyUI-Nugget/speech.py +383 -0
- custom_nodes/ComfyUI-Nugget/tests/run_all.py +28 -0
- custom_nodes/ComfyUI-Nugget/tests/test_image.py +227 -0
- custom_nodes/ComfyUI-Nugget/tests/test_prompt.py +201 -0
- custom_nodes/ComfyUI-Nugget/tests/test_registration.py +283 -0
- custom_nodes/ComfyUI-Nugget/tests/test_transcribe.py +715 -0
- custom_nodes/ComfyUI-Nugget/video.py +648 -0
- custom_nodes/ComfyUI-Nugget/web/js/nugget_batch_nocrop.js +150 -0
- 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 |
+
});
|