Upload folder using huggingface_hub (part 8)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +18 -0
- ComfyUI/custom_nodes/koolook/k_easy_wan22_prompt.py +120 -0
- ComfyUI/custom_nodes/koolook/k_loop_status.py +502 -0
- ComfyUI/custom_nodes/koolook/k_publish_contract.py +219 -0
- ComfyUI/custom_nodes/koolook/k_video_combine.py +907 -0
- ComfyUI/custom_nodes/koolook/k_video_load.py +349 -0
- ComfyUI/custom_nodes/koolook/koolook_install_guard.py +146 -0
- ComfyUI/custom_nodes/koolook/koolook_routes.py +1161 -0
- ComfyUI/custom_nodes/koolook/koolook_setup_runner.py +1010 -0
- ComfyUI/custom_nodes/koolook/koolook_setups.py +2032 -0
- ComfyUI/custom_nodes/koolook/koolook_versioning.py +139 -0
- ComfyUI/custom_nodes/koolook/pyproject.toml +77 -0
- ComfyUI/custom_nodes/koolook/scripts/bootstrap_test_env.ps1 +80 -0
- ComfyUI/custom_nodes/koolook/scripts/bootstrap_test_env.sh +102 -0
- ComfyUI/custom_nodes/koolook/scripts/loop_audio.config.json +60 -0
- ComfyUI/custom_nodes/koolook/scripts/loop_audio.py +1630 -0
- ComfyUI/custom_nodes/koolook/scripts/make_card.py +801 -0
- ComfyUI/custom_nodes/koolook/scripts/make_card_audio.py +831 -0
- ComfyUI/custom_nodes/koolook/scripts/make_silent_wav.py +98 -0
- ComfyUI/custom_nodes/koolook/scripts/run_loop_demo_api_test.py +227 -0
- ComfyUI/custom_nodes/koolook/scripts/sync_to_dev.py +370 -0
- ComfyUI/custom_nodes/koolook/scripts/sync_to_dev_audio.py +235 -0
- ComfyUI/custom_nodes/koolook/scripts/validate_workflow.py +298 -0
- ComfyUI/custom_nodes/koolook/scripts/watch_cards.py +95 -0
- ComfyUI/custom_nodes/koolook/tests/forks/__init__.py +0 -0
- ComfyUI/custom_nodes/koolook/tests/forks/test_whatdreamscost_audio_transcript.py +167 -0
- ComfyUI/custom_nodes/koolook/tests/forks/test_whatdreamscost_v1_3_2_relay_overrides.py +216 -0
- ComfyUI/custom_nodes/koolook/tests/forks/test_whatdreamscost_v1_3_9_relay_overrides.py +236 -0
- ComfyUI/custom_nodes/koolook/tests/js/test_ai_pipeline_preview.mjs +216 -0
- ComfyUI/custom_nodes/koolook/tests/js/test_draft_guard.mjs +373 -0
- ComfyUI/custom_nodes/koolook/tests/js/test_update_check.mjs +89 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/__init__.py +0 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_ai_pipeline.py +773 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_image_batch.py +575 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_utility.py +54 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_video_combine.py +594 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_video_load.py +372 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/test_koolook_versioning.py +67 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/test_loop_status.py +512 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/test_next_version.py +104 -0
- ComfyUI/custom_nodes/koolook/tests/nodes/test_publish_contract.py +93 -0
- ComfyUI/custom_nodes/koolook/tests/scripts/__init__.py +0 -0
- ComfyUI/custom_nodes/koolook/tests/scripts/test_bootstrap_constraints.py +108 -0
- ComfyUI/custom_nodes/koolook/tests/scripts/test_loop_audio.py +1069 -0
- ComfyUI/custom_nodes/koolook/tests/scripts/test_sync_to_dev.py +100 -0
- ComfyUI/custom_nodes/koolook/tests/scripts/test_sync_to_dev_audio.py +99 -0
- ComfyUI/custom_nodes/koolook/tests/scripts/test_validate_workflow.py +186 -0
- ComfyUI/custom_nodes/koolook/tests/server/test_browse_list_children.py +90 -0
- ComfyUI/custom_nodes/koolook/tests/server/test_listing_autosave_mtime.py +84 -0
- ComfyUI/custom_nodes/koolook/tests/server/test_published_setup_api.py +664 -0
.gitattributes
CHANGED
|
@@ -86,3 +86,21 @@ ComfyUI/custom_nodes/koolook/docs/automations/LTX-2.3/audio-lipsync/runs/run-001
|
|
| 86 |
ComfyUI/custom_nodes/koolook/docs/automations/LTX-2.3/audio-lipsync/runs/run-008_bear_2x-fr_audiofile_k-dir_upstream_audio-off/card.png filter=lfs diff=lfs merge=lfs -text
|
| 87 |
ComfyUI/custom_nodes/koolook/docs/automations/LTX-2.3/audio-lipsync/runs/run-009_bear_3x-fr_audiofile_k-dir_koolook_audio-off/card.png filter=lfs diff=lfs merge=lfs -text
|
| 88 |
ComfyUI/custom_nodes/koolook/docs/designs/sidebar-icon-proposals.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
ComfyUI/custom_nodes/koolook/docs/automations/LTX-2.3/audio-lipsync/runs/run-008_bear_2x-fr_audiofile_k-dir_upstream_audio-off/card.png filter=lfs diff=lfs merge=lfs -text
|
| 87 |
ComfyUI/custom_nodes/koolook/docs/automations/LTX-2.3/audio-lipsync/runs/run-009_bear_3x-fr_audiofile_k-dir_koolook_audio-off/card.png filter=lfs diff=lfs merge=lfs -text
|
| 88 |
ComfyUI/custom_nodes/koolook/docs/designs/sidebar-icon-proposals.png filter=lfs diff=lfs merge=lfs -text
|
| 89 |
+
ComfyUI/custom_nodes/koolook/web/guide/img/screenshots/auto-save-pre-load.png filter=lfs diff=lfs merge=lfs -text
|
| 90 |
+
ComfyUI/custom_nodes/koolook/web/guide/img/screenshots/snapshot-transfer-compare.png filter=lfs diff=lfs merge=lfs -text
|
| 91 |
+
ComfyUI/custom_nodes/koolook/web/guide/img/screenshots/snapshot-transfer-copy-folder.png filter=lfs diff=lfs merge=lfs -text
|
| 92 |
+
ComfyUI/custom_nodes/koolook/web/guide/img/screenshots/snapshot-transfer-expanded.png filter=lfs diff=lfs merge=lfs -text
|
| 93 |
+
ComfyUI/custom_nodes/rgthree-comfy/docs/rgthree_advanced.png filter=lfs diff=lfs merge=lfs -text
|
| 94 |
+
ComfyUI/custom_nodes/rgthree-comfy/docs/rgthree_advanced_metadata.png filter=lfs diff=lfs merge=lfs -text
|
| 95 |
+
ComfyUI/custom_nodes/rgthree-comfy/docs/rgthree_context.png filter=lfs diff=lfs merge=lfs -text
|
| 96 |
+
ComfyUI/custom_nodes/rgthree-comfy/docs/rgthree_context_metadata.png filter=lfs diff=lfs merge=lfs -text
|
| 97 |
+
ComfyUI/input/1.png filter=lfs diff=lfs merge=lfs -text
|
| 98 |
+
ComfyUI/input/whatdreamscost/1.png filter=lfs diff=lfs merge=lfs -text
|
| 99 |
+
ComfyUI/input/whatdreamscost/2.png filter=lfs diff=lfs merge=lfs -text
|
| 100 |
+
ComfyUI/input/whatdreamscost/3.png filter=lfs diff=lfs merge=lfs -text
|
| 101 |
+
ComfyUI/input/whatdreamscost/4.png filter=lfs diff=lfs merge=lfs -text
|
| 102 |
+
ComfyUI/input/whatdreamscost/5.png filter=lfs diff=lfs merge=lfs -text
|
| 103 |
+
ComfyUI/input/whatdreamscost/Late[[:space:]]night[[:space:]]trap.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 104 |
+
ComfyUI/models/text_encoders/mmproj-BF16.gguf filter=lfs diff=lfs merge=lfs -text
|
| 105 |
+
ComfyUI/models/unet/ltx-2.3-22b-dev-Q4_K_M.gguf filter=lfs diff=lfs merge=lfs -text
|
| 106 |
+
ComfyUI/output/video/ComfyUI_00001_.mp4 filter=lfs diff=lfs merge=lfs -text
|
ComfyUI/custom_nodes/koolook/k_easy_wan22_prompt.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
class EasyWan22Prompt:
|
| 5 |
+
"""
|
| 6 |
+
Wan 2.2 Easy Prompt node for ComfyUI.
|
| 7 |
+
Loads dynamic inputs from config.json, adds a body text input, and outputs two strings (combined prompt and fields only) plus optional CONDITIONING.
|
| 8 |
+
Includes an optional CLIP input to encode the combined prompt into CONDITIONING, allowing use as a positive prompt node.
|
| 9 |
+
"""
|
| 10 |
+
def __init__(self):
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
@classmethod
|
| 14 |
+
def INPUT_TYPES(cls):
|
| 15 |
+
# Get the directory of this script to locate config.json
|
| 16 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
| 17 |
+
config_path = os.path.join(script_dir, 'config.json')
|
| 18 |
+
|
| 19 |
+
# Load the JSON config with error handling
|
| 20 |
+
try:
|
| 21 |
+
with open(config_path, 'r') as f:
|
| 22 |
+
config = json.load(f)
|
| 23 |
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
| 24 |
+
print(f"Error loading config.json: {e}. Using dummy config.")
|
| 25 |
+
config = {
|
| 26 |
+
"category": "Koolook/Wan_Video",
|
| 27 |
+
"node_name": "Wan_2.2",
|
| 28 |
+
"fields": [],
|
| 29 |
+
"output_type": "STRING"
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
# Build the INPUT_TYPES dict dynamically from config fields
|
| 33 |
+
input_types = {
|
| 34 |
+
"required": {},
|
| 35 |
+
"optional": {
|
| 36 |
+
"clip": ("CLIP",)
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
for field in config['fields']:
|
| 40 |
+
name = field['name']
|
| 41 |
+
if field['type'] == 'combo':
|
| 42 |
+
options = field['options']
|
| 43 |
+
defaults = {
|
| 44 |
+
"default": field.get('default', options[0]),
|
| 45 |
+
"lazy": True,
|
| 46 |
+
"tooltip": f"Optional Wan 2.2 prompt fragment for {name.replace('_', ' ')}. Choose 'none' to omit it.",
|
| 47 |
+
}
|
| 48 |
+
input_types["required"][name] = (options, defaults)
|
| 49 |
+
elif field['type'] == 'string':
|
| 50 |
+
defaults = {
|
| 51 |
+
"default": field.get('default', ""),
|
| 52 |
+
"multiline": True,
|
| 53 |
+
"lazy": True,
|
| 54 |
+
"tooltip": f"Optional Wan 2.2 prompt text for {name.replace('_', ' ')}.",
|
| 55 |
+
}
|
| 56 |
+
input_types["required"][name] = ("STRING", defaults)
|
| 57 |
+
|
| 58 |
+
# Add the body text input at the end (appears at the bottom)
|
| 59 |
+
input_types["required"]["body"] = ("STRING", {
|
| 60 |
+
"multiline": True,
|
| 61 |
+
"default": "",
|
| 62 |
+
"lazy": True,
|
| 63 |
+
"tooltip": "Main free-text prompt. Appended after selected dropdown fragments.",
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
return input_types
|
| 67 |
+
|
| 68 |
+
RETURN_TYPES = ("STRING", "STRING", "CONDITIONING",)
|
| 69 |
+
RETURN_NAMES = ("combined_prompt", "fields_only", "conditioning",)
|
| 70 |
+
FUNCTION = "execute"
|
| 71 |
+
CATEGORY = "Koolook/Wan_Video"
|
| 72 |
+
OUTPUT_NODE = False
|
| 73 |
+
|
| 74 |
+
def check_lazy_status(self, **kwargs):
|
| 75 |
+
# Evaluate all inputs if needed
|
| 76 |
+
return list(kwargs.keys())
|
| 77 |
+
|
| 78 |
+
def execute(self, body, clip=None, **kwargs):
|
| 79 |
+
# Collect selected options (values only), excluding body and skipping "none"
|
| 80 |
+
prompt_parts = [value for key, value in kwargs.items() if value and value != "none"]
|
| 81 |
+
fields_str = ", ".join(prompt_parts)
|
| 82 |
+
|
| 83 |
+
# Combined: fields options + body (with comma if both present)
|
| 84 |
+
combined = fields_str
|
| 85 |
+
if body:
|
| 86 |
+
if combined:
|
| 87 |
+
combined += ", " + body
|
| 88 |
+
else:
|
| 89 |
+
combined = body
|
| 90 |
+
|
| 91 |
+
# Conditioning: Encode the combined prompt if clip is provided, else empty list
|
| 92 |
+
if clip is not None:
|
| 93 |
+
tokens = clip.tokenize(combined)
|
| 94 |
+
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
|
| 95 |
+
conditioning = [[cond, {"pooled_output": pooled}]]
|
| 96 |
+
else:
|
| 97 |
+
conditioning = []
|
| 98 |
+
|
| 99 |
+
return (combined, fields_str, conditioning,)
|
| 100 |
+
|
| 101 |
+
# Optional: IS_CHANGED for re-execution if config changes
|
| 102 |
+
@classmethod
|
| 103 |
+
def IS_CHANGED(cls, *args, **kwargs):
|
| 104 |
+
# Hash the config.json to re-execute if it changes
|
| 105 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
| 106 |
+
config_path = os.path.join(script_dir, 'config.json')
|
| 107 |
+
try:
|
| 108 |
+
with open(config_path, 'rb') as f:
|
| 109 |
+
return hash(f.read())
|
| 110 |
+
except FileNotFoundError:
|
| 111 |
+
return "dummy"
|
| 112 |
+
|
| 113 |
+
# Individual node mappings
|
| 114 |
+
NODE_CLASS_MAPPINGS = {
|
| 115 |
+
"EasyWan22Prompt": EasyWan22Prompt
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 119 |
+
"EasyWan22Prompt": "Wan 2.2 Easy Prompt (Koolook)"
|
| 120 |
+
}
|
ComfyUI/custom_nodes/koolook/k_loop_status.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
| 2 |
+
#
|
| 3 |
+
# ComfyUI-Koolook - loop status utilities
|
| 4 |
+
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
|
| 5 |
+
|
| 6 |
+
"""Loop-body status pass-through nodes."""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import copy
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import re
|
| 15 |
+
import threading
|
| 16 |
+
import traceback
|
| 17 |
+
import urllib.error
|
| 18 |
+
import urllib.parse
|
| 19 |
+
import urllib.request
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
LOGGER = logging.getLogger(__name__)
|
| 23 |
+
MAX_AUTO_QUEUE_DEPTH = 1000
|
| 24 |
+
DEFAULT_SERVER_URL = "http://127.0.0.1:8188"
|
| 25 |
+
_ACTIVE_QUEUE_KEYS: set[str] = set()
|
| 26 |
+
_ACTIVE_QUEUE_KEYS_LOCK = threading.Lock()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class AnyType(str):
|
| 30 |
+
"""ComfyUI wildcard type that compares as compatible with any socket."""
|
| 31 |
+
|
| 32 |
+
def __ne__(self, _other):
|
| 33 |
+
return False
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
ANY_TYPE = AnyType("*")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _format_write_path(filepath: str, frame: int) -> str:
|
| 40 |
+
text = str(filepath or "").strip()
|
| 41 |
+
return re.sub(r"%0?(\d*)d", lambda m: f"{frame:0{m.group(1) or '0'}d}", text)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_status(label: str, index: int, total: int, filepath: str = "") -> str:
|
| 45 |
+
total = max(1, int(total))
|
| 46 |
+
frame = int(index)
|
| 47 |
+
position = max(1, min(total, frame + 1))
|
| 48 |
+
label = str(label or "loop").strip() or "loop"
|
| 49 |
+
path = _format_write_path(filepath, frame)
|
| 50 |
+
if path:
|
| 51 |
+
return f"{label}: {position}/{total} frame {frame} -> {path}"
|
| 52 |
+
return f"{label}: {position}/{total} frame {frame}"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def infer_index_node_id(prompt: dict | None, unique_id) -> str:
|
| 56 |
+
if not isinstance(prompt, dict) or unique_id is None:
|
| 57 |
+
return ""
|
| 58 |
+
node = prompt.get(str(unique_id))
|
| 59 |
+
if not isinstance(node, dict):
|
| 60 |
+
return ""
|
| 61 |
+
inputs = node.get("inputs")
|
| 62 |
+
if not isinstance(inputs, dict):
|
| 63 |
+
return ""
|
| 64 |
+
link = inputs.get("index")
|
| 65 |
+
if isinstance(link, (list, tuple)) and link:
|
| 66 |
+
return str(link[0])
|
| 67 |
+
return ""
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
_infer_index_node_id = infer_index_node_id
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _prompt_has_node(prompt: dict | None, node_id: str) -> bool:
|
| 74 |
+
return isinstance(prompt, dict) and isinstance(prompt.get(str(node_id)), dict)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _describe_prompt_node(prompt: dict | None, node_id: str) -> str:
|
| 78 |
+
if not isinstance(prompt, dict):
|
| 79 |
+
return f"node {node_id}"
|
| 80 |
+
node = prompt.get(str(node_id))
|
| 81 |
+
if not isinstance(node, dict):
|
| 82 |
+
return f"node {node_id}"
|
| 83 |
+
class_type = str(node.get("class_type") or node.get("type") or "").strip()
|
| 84 |
+
title = str(node.get("_meta", {}).get("title") or node.get("title") or "").strip()
|
| 85 |
+
if title and title != class_type:
|
| 86 |
+
prefix = f"{title} ({class_type})" if class_type else title
|
| 87 |
+
else:
|
| 88 |
+
prefix = class_type
|
| 89 |
+
return f"{prefix} node {node_id}" if prefix else f"node {node_id}"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def resolve_index_node_id(
|
| 93 |
+
prompt: dict | None,
|
| 94 |
+
unique_id,
|
| 95 |
+
configured_index_node_id: str,
|
| 96 |
+
fallback_id: str = "",
|
| 97 |
+
) -> tuple[str, str]:
|
| 98 |
+
"""Pick the frame-index node to advance and a human note about the choice.
|
| 99 |
+
|
| 100 |
+
The connected ``index`` input is the authoritative, deterministic source. An
|
| 101 |
+
explicit ``configured_index_node_id`` that exists in the prompt is honored as
|
| 102 |
+
a power-user override; otherwise the node feeding the connected ``index``
|
| 103 |
+
input wins, so a stale or mis-shifted id self-heals. ``fallback_id`` (e.g. a
|
| 104 |
+
numeric label recovered from a shifted save) is a last resort, used only when
|
| 105 |
+
nothing else resolves, so it never overrides real wiring. Returns
|
| 106 |
+
``("", "")`` when nothing resolves so the caller can raise synchronously with
|
| 107 |
+
an actionable message.
|
| 108 |
+
"""
|
| 109 |
+
configured = str(configured_index_node_id or "").strip()
|
| 110 |
+
inferred = infer_index_node_id(prompt, unique_id)
|
| 111 |
+
if configured and _prompt_has_node(prompt, configured):
|
| 112 |
+
return configured, f"using configured {_describe_prompt_node(prompt, configured)}"
|
| 113 |
+
if inferred:
|
| 114 |
+
if configured and configured != inferred:
|
| 115 |
+
return (
|
| 116 |
+
inferred,
|
| 117 |
+
"configured index node "
|
| 118 |
+
f"{configured!r} is not in this prompt; using connected "
|
| 119 |
+
f"{_describe_prompt_node(prompt, inferred)}",
|
| 120 |
+
)
|
| 121 |
+
return inferred, f"using connected {_describe_prompt_node(prompt, inferred)}"
|
| 122 |
+
if configured:
|
| 123 |
+
return configured, f"using configured node {configured}"
|
| 124 |
+
fallback = str(fallback_id or "").strip()
|
| 125 |
+
if fallback:
|
| 126 |
+
return fallback, f"using recovered node {fallback}"
|
| 127 |
+
return "", ""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _as_bool(value) -> bool:
|
| 131 |
+
"""Coerce saved widget values (incl. string booleans) to ``bool``.
|
| 132 |
+
|
| 133 |
+
ComfyUI can persist a boolean widget as the string ``"true"``/``"false"``;
|
| 134 |
+
``bool("false")`` is truthy, so a naive cast would auto-queue when the user
|
| 135 |
+
saved the toggle off.
|
| 136 |
+
"""
|
| 137 |
+
if isinstance(value, str):
|
| 138 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 139 |
+
return bool(value)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _get_json(url: str, timeout: float = 10) -> dict:
|
| 143 |
+
_validate_http_url(url)
|
| 144 |
+
with urllib.request.urlopen(url, timeout=timeout) as response: # nosec B310
|
| 145 |
+
body = response.read().decode("utf-8", errors="replace")
|
| 146 |
+
return json.loads(body) if body else {}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _validate_http_url(url: str) -> None:
|
| 150 |
+
parsed = urllib.parse.urlsplit(str(url or ""))
|
| 151 |
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 152 |
+
raise RuntimeError(f"Only http(s) ComfyUI server URLs are allowed: {url!r}")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _compose_server_url(host: str | None, port) -> str:
|
| 156 |
+
"""Join a detected host/port into a connectable ``http://`` URL.
|
| 157 |
+
|
| 158 |
+
ComfyUI's bare ``--listen`` binds to all IPv4 and IPv6 interfaces as a
|
| 159 |
+
comma-joined host value, so the host is split and any bind-all member means
|
| 160 |
+
we connect over loopback. IPv6 literals are bracketed so ``urllib`` can parse
|
| 161 |
+
the ``host:port`` netloc (``::1`` -> ``http://[::1]:port``).
|
| 162 |
+
"""
|
| 163 |
+
members = [member.strip() for member in str(host or "").split(",") if member.strip()]
|
| 164 |
+
bind_all = {"0.0.0.0", "::", "*"} # nosec B104
|
| 165 |
+
if any(member in bind_all for member in members):
|
| 166 |
+
host = "127.0.0.1"
|
| 167 |
+
else:
|
| 168 |
+
host = members[0] if members else "127.0.0.1"
|
| 169 |
+
if ":" in host and not host.startswith("["):
|
| 170 |
+
host = f"[{host}]"
|
| 171 |
+
return f"http://{host}:{port}"
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _detect_local_server_url() -> str | None:
|
| 175 |
+
"""Best-effort URL of the ComfyUI server hosting this node.
|
| 176 |
+
|
| 177 |
+
The node default points at ComfyUI's default port (8188), but installs
|
| 178 |
+
launched with ``--port`` (or ``--listen``) bind elsewhere — probing the
|
| 179 |
+
stale default is actively refused and aborts the loop. Prefer the address
|
| 180 |
+
the running server actually bound to. Returns ``None`` when neither the
|
| 181 |
+
parsed CLI args nor the running ``PromptServer`` can be inspected (e.g. a
|
| 182 |
+
headless unit test), so the caller can fall back to the literal default.
|
| 183 |
+
"""
|
| 184 |
+
port = None
|
| 185 |
+
host = None
|
| 186 |
+
try:
|
| 187 |
+
from comfy.cli_args import args # type: ignore[import-not-found]
|
| 188 |
+
|
| 189 |
+
port = getattr(args, "port", None)
|
| 190 |
+
host = getattr(args, "listen", None)
|
| 191 |
+
except Exception: # pragma: no cover - depends on ComfyUI runtime
|
| 192 |
+
LOGGER.debug("comfy.cli_args server detection failed", exc_info=True)
|
| 193 |
+
if not port:
|
| 194 |
+
try:
|
| 195 |
+
from server import PromptServer # type: ignore[import-not-found]
|
| 196 |
+
|
| 197 |
+
instance = PromptServer.instance
|
| 198 |
+
port = port or getattr(instance, "port", None)
|
| 199 |
+
host = host or getattr(instance, "address", None)
|
| 200 |
+
except Exception: # pragma: no cover - depends on ComfyUI runtime
|
| 201 |
+
LOGGER.debug("PromptServer server detection failed", exc_info=True)
|
| 202 |
+
if not port:
|
| 203 |
+
return None
|
| 204 |
+
return _compose_server_url(host, port)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _resolve_server_url(server_url: str) -> str:
|
| 208 |
+
"""Return the URL to queue against, auto-detecting for the ``auto`` default.
|
| 209 |
+
|
| 210 |
+
``auto`` (the widget default), an empty value, or the legacy baked-in
|
| 211 |
+
``DEFAULT_SERVER_URL`` all mean "detect the running server" — replaced with
|
| 212 |
+
the server's real address when it can be detected, falling back to the
|
| 213 |
+
default otherwise. Any other value is an explicit URL used verbatim.
|
| 214 |
+
"""
|
| 215 |
+
server_url = str(server_url or "").strip()
|
| 216 |
+
if server_url and server_url.lower() != "auto" and server_url != DEFAULT_SERVER_URL:
|
| 217 |
+
return server_url
|
| 218 |
+
detected = _detect_local_server_url()
|
| 219 |
+
if detected and detected != server_url:
|
| 220 |
+
print(f"[Koolook Loop Status] resolved server_url to {detected}")
|
| 221 |
+
return detected or DEFAULT_SERVER_URL
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _probe_server(server_url: str) -> None:
|
| 225 |
+
_get_json(f"{server_url.rstrip('/')}/system_stats", timeout=10)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _post_prompt(server_url: str, prompt: dict) -> dict:
|
| 229 |
+
_validate_http_url(server_url)
|
| 230 |
+
data = json.dumps({"prompt": prompt}).encode("utf-8")
|
| 231 |
+
req = urllib.request.Request(
|
| 232 |
+
f"{server_url.rstrip('/')}/prompt",
|
| 233 |
+
data=data,
|
| 234 |
+
headers={"Content-Type": "application/json"},
|
| 235 |
+
method="POST",
|
| 236 |
+
)
|
| 237 |
+
with urllib.request.urlopen(req, timeout=30) as response: # nosec B310
|
| 238 |
+
body = response.read().decode("utf-8", errors="replace")
|
| 239 |
+
payload = json.loads(body) if body else {}
|
| 240 |
+
if payload.get("error") or not payload.get("prompt_id"):
|
| 241 |
+
raise RuntimeError(f"ComfyUI rejected child prompt: {payload}")
|
| 242 |
+
print(f"[Koolook Loop Status] queued next prompt: {body}")
|
| 243 |
+
return payload
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _abort_marker_path(filepath: str, frame: int) -> Path:
|
| 247 |
+
expected = Path(_format_write_path(filepath, frame))
|
| 248 |
+
parent = expected.parent if str(expected.parent) else Path.cwd()
|
| 249 |
+
return parent / f"_loop_aborted_at_frame_{frame}.txt"
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _write_abort_marker(filepath: str, frame: int, exc: BaseException) -> None:
|
| 253 |
+
marker = _abort_marker_path(filepath, frame)
|
| 254 |
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
| 255 |
+
marker.write_text(
|
| 256 |
+
"Koolook Loop Status failed to queue the next prompt.\n\n"
|
| 257 |
+
f"Frame: {frame}\n"
|
| 258 |
+
f"Error: {exc}\n\n"
|
| 259 |
+
f"{traceback.format_exc()}",
|
| 260 |
+
encoding="utf-8",
|
| 261 |
+
)
|
| 262 |
+
print(f"[Koolook Loop Status] wrote abort marker: {marker}")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _queue_next_prompt(
|
| 266 |
+
*,
|
| 267 |
+
prompt: dict,
|
| 268 |
+
index_node_id: str,
|
| 269 |
+
next_index: int,
|
| 270 |
+
server_url: str,
|
| 271 |
+
queue_key: str,
|
| 272 |
+
filepath: str,
|
| 273 |
+
remaining_auto_queue_depth: int,
|
| 274 |
+
) -> None:
|
| 275 |
+
try:
|
| 276 |
+
child = copy.deepcopy(prompt)
|
| 277 |
+
node = child.get(str(index_node_id))
|
| 278 |
+
if not isinstance(node, dict):
|
| 279 |
+
raise RuntimeError(f"index node id {index_node_id!r} is not in prompt")
|
| 280 |
+
inputs = node.setdefault("inputs", {})
|
| 281 |
+
inputs["value"] = int(next_index)
|
| 282 |
+
status_node = child.get(str(queue_key.split(":", 1)[0]))
|
| 283 |
+
if isinstance(status_node, dict):
|
| 284 |
+
status_inputs = status_node.setdefault("inputs", {})
|
| 285 |
+
status_inputs["remaining_auto_queue_depth"] = int(remaining_auto_queue_depth)
|
| 286 |
+
_post_prompt(server_url, child)
|
| 287 |
+
except Exception as exc:
|
| 288 |
+
LOGGER.exception("Koolook Loop Status failed to queue next prompt")
|
| 289 |
+
print(f"[Koolook Loop Status] failed to queue next prompt: {exc}")
|
| 290 |
+
_write_abort_marker(filepath, next_index, exc)
|
| 291 |
+
finally:
|
| 292 |
+
with _ACTIVE_QUEUE_KEYS_LOCK:
|
| 293 |
+
_ACTIVE_QUEUE_KEYS.discard(queue_key)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
class KoolookLoopStatus:
|
| 297 |
+
"""Print per-iteration loop progress and pass the value through."""
|
| 298 |
+
|
| 299 |
+
@classmethod
|
| 300 |
+
def INPUT_TYPES(cls):
|
| 301 |
+
return {
|
| 302 |
+
"required": {
|
| 303 |
+
"value": (
|
| 304 |
+
ANY_TYPE,
|
| 305 |
+
{
|
| 306 |
+
"tooltip": "Payload to pass through unchanged while this node reports loop progress.",
|
| 307 |
+
},
|
| 308 |
+
),
|
| 309 |
+
"index": (
|
| 310 |
+
"INT",
|
| 311 |
+
{
|
| 312 |
+
"default": 0,
|
| 313 |
+
"min": 0,
|
| 314 |
+
"max": 999999,
|
| 315 |
+
"tooltip": "Current zero-based frame index. Wire this from the easy int node that drives the loop.",
|
| 316 |
+
},
|
| 317 |
+
),
|
| 318 |
+
"total": (
|
| 319 |
+
"INT",
|
| 320 |
+
{
|
| 321 |
+
"default": 1,
|
| 322 |
+
"min": 1,
|
| 323 |
+
"max": 100000,
|
| 324 |
+
"tooltip": "Total frames/prompts in this loop. Auto-queue stops when index + 1 reaches total.",
|
| 325 |
+
},
|
| 326 |
+
),
|
| 327 |
+
},
|
| 328 |
+
"optional": {
|
| 329 |
+
"filepath": (
|
| 330 |
+
"STRING",
|
| 331 |
+
{
|
| 332 |
+
"default": "",
|
| 333 |
+
"multiline": True,
|
| 334 |
+
"tooltip": "Optional sequence path for the printed status line. %04d-style frame tokens are previewed with the current index.",
|
| 335 |
+
},
|
| 336 |
+
),
|
| 337 |
+
"label": (
|
| 338 |
+
"STRING",
|
| 339 |
+
{
|
| 340 |
+
"default": "loop",
|
| 341 |
+
"multiline": False,
|
| 342 |
+
"tooltip": "Short label printed before progress, for example EXR_SAFE or plate_pass.",
|
| 343 |
+
},
|
| 344 |
+
),
|
| 345 |
+
"auto_queue_next": (
|
| 346 |
+
"BOOLEAN",
|
| 347 |
+
{
|
| 348 |
+
"default": False,
|
| 349 |
+
"tooltip": "Queue the next prompt automatically after this frame, advancing the connected index node.",
|
| 350 |
+
},
|
| 351 |
+
),
|
| 352 |
+
"index_node_id": (
|
| 353 |
+
"STRING",
|
| 354 |
+
{
|
| 355 |
+
"default": "",
|
| 356 |
+
"multiline": False,
|
| 357 |
+
"tooltip": "Advanced override for the frame-index node id. Leave blank; the connected index input is normally detected automatically.",
|
| 358 |
+
},
|
| 359 |
+
),
|
| 360 |
+
"server_url": (
|
| 361 |
+
"STRING",
|
| 362 |
+
{
|
| 363 |
+
"default": "auto",
|
| 364 |
+
"multiline": False,
|
| 365 |
+
"tooltip": "ComfyUI server used for auto-queue. Leave auto to detect the running server and port.",
|
| 366 |
+
},
|
| 367 |
+
),
|
| 368 |
+
"max_auto_queue_depth": (
|
| 369 |
+
"INT",
|
| 370 |
+
{
|
| 371 |
+
"default": 100,
|
| 372 |
+
"min": 1,
|
| 373 |
+
"max": MAX_AUTO_QUEUE_DEPTH,
|
| 374 |
+
"tooltip": "Safety cap for child prompts this run may chain, preventing accidental runaway loops.",
|
| 375 |
+
},
|
| 376 |
+
),
|
| 377 |
+
"remaining_auto_queue_depth": (
|
| 378 |
+
"INT",
|
| 379 |
+
{
|
| 380 |
+
"default": -1,
|
| 381 |
+
"min": -1,
|
| 382 |
+
"max": MAX_AUTO_QUEUE_DEPTH,
|
| 383 |
+
"tooltip": "Internal countdown carried into child prompts. Leave at -1 in normal canvas use.",
|
| 384 |
+
},
|
| 385 |
+
),
|
| 386 |
+
},
|
| 387 |
+
"hidden": {
|
| 388 |
+
"prompt": "PROMPT",
|
| 389 |
+
"unique_id": "UNIQUE_ID",
|
| 390 |
+
},
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
RETURN_TYPES = (ANY_TYPE, "STRING")
|
| 394 |
+
RETURN_NAMES = ("value", "status")
|
| 395 |
+
FUNCTION = "report"
|
| 396 |
+
CATEGORY = "Koolook/Loop"
|
| 397 |
+
|
| 398 |
+
def report(
|
| 399 |
+
self,
|
| 400 |
+
value,
|
| 401 |
+
index,
|
| 402 |
+
total,
|
| 403 |
+
filepath="",
|
| 404 |
+
label="loop",
|
| 405 |
+
auto_queue_next=False,
|
| 406 |
+
index_node_id="",
|
| 407 |
+
server_url="auto",
|
| 408 |
+
max_auto_queue_depth=100,
|
| 409 |
+
remaining_auto_queue_depth=-1,
|
| 410 |
+
prompt=None,
|
| 411 |
+
unique_id=None,
|
| 412 |
+
):
|
| 413 |
+
frame = int(index)
|
| 414 |
+
total = max(1, int(total))
|
| 415 |
+
next_index = frame + 1
|
| 416 |
+
index_node_id = str(index_node_id or "").strip()
|
| 417 |
+
label = str(label or "").strip()
|
| 418 |
+
numeric_label_id = ""
|
| 419 |
+
if not index_node_id and label.isdigit():
|
| 420 |
+
numeric_label_id = label
|
| 421 |
+
label = "EXR_SAFE"
|
| 422 |
+
print(
|
| 423 |
+
"[Koolook Loop Status] numeric label looks like a node id; using "
|
| 424 |
+
"EXR_SAFE as the label and keeping the number only as a fallback id"
|
| 425 |
+
)
|
| 426 |
+
# The connected `index` input is the authoritative frame-index source: an
|
| 427 |
+
# explicit index_node_id that exists wins as a power-user override, else
|
| 428 |
+
# the connected node is used (self-healing a stale/mis-shifted id). A
|
| 429 |
+
# numeric label is a last-resort fallback that never overrides the wiring.
|
| 430 |
+
# `index_note` records which node was used.
|
| 431 |
+
index_node_id, index_note = resolve_index_node_id(
|
| 432 |
+
prompt, unique_id, index_node_id, fallback_id=numeric_label_id
|
| 433 |
+
)
|
| 434 |
+
max_depth = max(1, min(int(max_auto_queue_depth), MAX_AUTO_QUEUE_DEPTH))
|
| 435 |
+
remaining_depth = int(remaining_auto_queue_depth)
|
| 436 |
+
if remaining_depth < 0:
|
| 437 |
+
remaining_depth = max_depth
|
| 438 |
+
should_queue = _as_bool(auto_queue_next) and next_index < total
|
| 439 |
+
if should_queue:
|
| 440 |
+
if total - frame - 1 > max_depth:
|
| 441 |
+
raise RuntimeError(
|
| 442 |
+
f"Refusing to auto-queue {total - frame - 1} remaining prompts; "
|
| 443 |
+
f"max_auto_queue_depth is {max_depth}."
|
| 444 |
+
)
|
| 445 |
+
if remaining_depth <= 0:
|
| 446 |
+
raise RuntimeError("Auto-queue depth exhausted before loop completed.")
|
| 447 |
+
if not isinstance(prompt, dict):
|
| 448 |
+
raise RuntimeError("Koolook Loop Status needs hidden PROMPT data.")
|
| 449 |
+
if unique_id is None:
|
| 450 |
+
raise RuntimeError("Koolook Loop Status needs hidden UNIQUE_ID data.")
|
| 451 |
+
if not index_node_id:
|
| 452 |
+
raise RuntimeError("Set index_node_id to the easy int frame index node.")
|
| 453 |
+
if index_node_id not in prompt:
|
| 454 |
+
raise RuntimeError(
|
| 455 |
+
f"index_node_id {index_node_id!r} is not a node in this workflow. "
|
| 456 |
+
"Connect the loop status node's index input to your easy int "
|
| 457 |
+
"frame-index node, or set index_node_id to that node's id."
|
| 458 |
+
)
|
| 459 |
+
server_url = _resolve_server_url(server_url)
|
| 460 |
+
if not server_url:
|
| 461 |
+
raise RuntimeError("Set server_url to the running ComfyUI server.")
|
| 462 |
+
try:
|
| 463 |
+
_probe_server(server_url)
|
| 464 |
+
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
| 465 |
+
raise RuntimeError(f"ComfyUI server is not reachable: {server_url}") from exc
|
| 466 |
+
status = build_status(label or "loop", index, total, filepath)
|
| 467 |
+
print(f"[Koolook Loop Status] {status}")
|
| 468 |
+
if should_queue and index_note:
|
| 469 |
+
print(f"[Koolook Loop Status] {index_note}")
|
| 470 |
+
if should_queue:
|
| 471 |
+
queue_key = f"{unique_id or 'loop-status'}:{frame}->{next_index}"
|
| 472 |
+
queued = False
|
| 473 |
+
with _ACTIVE_QUEUE_KEYS_LOCK:
|
| 474 |
+
if queue_key not in _ACTIVE_QUEUE_KEYS:
|
| 475 |
+
_ACTIVE_QUEUE_KEYS.add(queue_key)
|
| 476 |
+
queued = True
|
| 477 |
+
if queued:
|
| 478 |
+
thread = threading.Thread(
|
| 479 |
+
target=_queue_next_prompt,
|
| 480 |
+
kwargs={
|
| 481 |
+
"prompt": prompt,
|
| 482 |
+
"index_node_id": index_node_id,
|
| 483 |
+
"next_index": next_index,
|
| 484 |
+
"server_url": server_url,
|
| 485 |
+
"queue_key": queue_key,
|
| 486 |
+
"filepath": str(filepath or ""),
|
| 487 |
+
"remaining_auto_queue_depth": remaining_depth - 1,
|
| 488 |
+
},
|
| 489 |
+
daemon=True,
|
| 490 |
+
)
|
| 491 |
+
thread.start()
|
| 492 |
+
print(f"[Koolook Loop Status] queued next index {next_index}/{total - 1}")
|
| 493 |
+
return (value, status)
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
NODE_CLASS_MAPPINGS = {
|
| 497 |
+
"Koolook_LoopStatus": KoolookLoopStatus,
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 501 |
+
"Koolook_LoopStatus": "Koolook Loop Status",
|
| 502 |
+
}
|
ComfyUI/custom_nodes/koolook/k_publish_contract.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
| 2 |
+
#
|
| 3 |
+
# ComfyUI-Koolook - publish contract nodes
|
| 4 |
+
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
|
| 5 |
+
"""Controlled nodes for declaring externally published setup fields."""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
INPUT_MODES: tuple[str, ...] = ("EXR", "QT", "Img", "Prompt")
|
| 11 |
+
INPUT_MODE_TO_INDEX = {name: index for index, name in enumerate(INPUT_MODES)}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _resolve_mode_index(mode) -> int:
|
| 15 |
+
if isinstance(mode, bool):
|
| 16 |
+
return INPUT_MODE_TO_INDEX["Img"]
|
| 17 |
+
if isinstance(mode, int) and 0 <= mode < len(INPUT_MODES):
|
| 18 |
+
return mode
|
| 19 |
+
text = str(mode).strip()
|
| 20 |
+
if text.isdigit():
|
| 21 |
+
index = int(text)
|
| 22 |
+
if 0 <= index < len(INPUT_MODES):
|
| 23 |
+
return index
|
| 24 |
+
return INPUT_MODE_TO_INDEX.get(text, INPUT_MODE_TO_INDEX["Img"])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Koolook_PublishInput:
|
| 28 |
+
"""App-facing setup inputs with stable output names for maintainers."""
|
| 29 |
+
|
| 30 |
+
@classmethod
|
| 31 |
+
def INPUT_TYPES(cls):
|
| 32 |
+
return {
|
| 33 |
+
"required": {
|
| 34 |
+
"mode": (
|
| 35 |
+
list(INPUT_MODES),
|
| 36 |
+
{
|
| 37 |
+
"default": "Img",
|
| 38 |
+
"tooltip": "Source mode exposed to the external app. The switch output keeps this mode's stable numeric index.",
|
| 39 |
+
},
|
| 40 |
+
),
|
| 41 |
+
"sequence_folder": (
|
| 42 |
+
"STRING",
|
| 43 |
+
{
|
| 44 |
+
"default": "",
|
| 45 |
+
"multiline": True,
|
| 46 |
+
"tooltip": "Directory path for sequence sources such as EXR or numbered image frames.",
|
| 47 |
+
},
|
| 48 |
+
),
|
| 49 |
+
"qt_file": (
|
| 50 |
+
"STRING",
|
| 51 |
+
{
|
| 52 |
+
"default": "",
|
| 53 |
+
"multiline": True,
|
| 54 |
+
"tooltip": "Full path to one QuickTime/video file, including directory and filename.",
|
| 55 |
+
},
|
| 56 |
+
),
|
| 57 |
+
"single_file": (
|
| 58 |
+
"STRING",
|
| 59 |
+
{
|
| 60 |
+
"default": "",
|
| 61 |
+
"multiline": True,
|
| 62 |
+
"tooltip": "Full path to one still image or single source file.",
|
| 63 |
+
},
|
| 64 |
+
),
|
| 65 |
+
"prompt": (
|
| 66 |
+
"STRING",
|
| 67 |
+
{
|
| 68 |
+
"default": "",
|
| 69 |
+
"multiline": True,
|
| 70 |
+
"tooltip": "Prompt text field for setups that route a text prompt through the published app surface.",
|
| 71 |
+
},
|
| 72 |
+
),
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "INT")
|
| 77 |
+
RETURN_NAMES = ("sequence_folder", "qt_file", "single_file", "prompt", "switch")
|
| 78 |
+
CATEGORY = "Koolook/Publish"
|
| 79 |
+
FUNCTION = "run"
|
| 80 |
+
DESCRIPTION = "Declare source inputs for a workflow published to an external app."
|
| 81 |
+
|
| 82 |
+
def run(
|
| 83 |
+
self,
|
| 84 |
+
mode: str,
|
| 85 |
+
sequence_folder: str,
|
| 86 |
+
qt_file: str,
|
| 87 |
+
single_file: str,
|
| 88 |
+
prompt: str,
|
| 89 |
+
):
|
| 90 |
+
return (
|
| 91 |
+
sequence_folder,
|
| 92 |
+
qt_file,
|
| 93 |
+
single_file,
|
| 94 |
+
prompt,
|
| 95 |
+
_resolve_mode_index(mode),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class Koolook_PublishOutput:
|
| 100 |
+
"""App-facing setup output controls."""
|
| 101 |
+
|
| 102 |
+
@classmethod
|
| 103 |
+
def INPUT_TYPES(cls):
|
| 104 |
+
return {
|
| 105 |
+
"required": {
|
| 106 |
+
"folder": (
|
| 107 |
+
"STRING",
|
| 108 |
+
{
|
| 109 |
+
"default": "",
|
| 110 |
+
"multiline": True,
|
| 111 |
+
"tooltip": "Destination folder exposed to the external app and wired into downstream writer/path nodes.",
|
| 112 |
+
},
|
| 113 |
+
),
|
| 114 |
+
"name": (
|
| 115 |
+
"STRING",
|
| 116 |
+
{
|
| 117 |
+
"default": "",
|
| 118 |
+
"multiline": False,
|
| 119 |
+
"tooltip": "Base output name exposed to the external app.",
|
| 120 |
+
},
|
| 121 |
+
),
|
| 122 |
+
"version": (
|
| 123 |
+
"STRING",
|
| 124 |
+
{
|
| 125 |
+
"default": "1",
|
| 126 |
+
"multiline": False,
|
| 127 |
+
"tooltip": "Output version token or number exposed to the external app.",
|
| 128 |
+
},
|
| 129 |
+
),
|
| 130 |
+
},
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
| 134 |
+
RETURN_NAMES = ("folder", "name", "version")
|
| 135 |
+
CATEGORY = "Koolook/Publish"
|
| 136 |
+
FUNCTION = "run"
|
| 137 |
+
DESCRIPTION = "Declare output folder, name, and version controls for a published setup."
|
| 138 |
+
|
| 139 |
+
def run(self, folder: str, name: str, version: str):
|
| 140 |
+
return (folder, name, version)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class Koolook_PublishResult:
|
| 144 |
+
"""Resolved setup result surfaced back to the external app."""
|
| 145 |
+
|
| 146 |
+
@classmethod
|
| 147 |
+
def INPUT_TYPES(cls):
|
| 148 |
+
return {
|
| 149 |
+
"required": {
|
| 150 |
+
"result": (
|
| 151 |
+
"STRING",
|
| 152 |
+
{
|
| 153 |
+
"default": "",
|
| 154 |
+
"multiline": True,
|
| 155 |
+
"tooltip": "Resolved result path, folder, or status string to show after the external app run finishes.",
|
| 156 |
+
},
|
| 157 |
+
),
|
| 158 |
+
},
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
RETURN_TYPES = ("STRING",)
|
| 162 |
+
RETURN_NAMES = ("result",)
|
| 163 |
+
CATEGORY = "Koolook/Publish"
|
| 164 |
+
FUNCTION = "run"
|
| 165 |
+
OUTPUT_NODE = True
|
| 166 |
+
DESCRIPTION = "Report a resolved setup result string back to the published setup runner."
|
| 167 |
+
|
| 168 |
+
def run(self, result: str):
|
| 169 |
+
return {"ui": {"text": [result]}, "result": (result,)}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class Koolook_PublishRouter:
|
| 173 |
+
"""Switch-aligned payload router for externally published setup outputs."""
|
| 174 |
+
|
| 175 |
+
@classmethod
|
| 176 |
+
def INPUT_TYPES(cls):
|
| 177 |
+
return {
|
| 178 |
+
"required": {
|
| 179 |
+
"selector": (
|
| 180 |
+
"INT",
|
| 181 |
+
{
|
| 182 |
+
"default": INPUT_MODE_TO_INDEX["Img"],
|
| 183 |
+
"min": 0,
|
| 184 |
+
"max": len(INPUT_MODES) - 1,
|
| 185 |
+
"tooltip": "Switch index from Koolook Publish Input. Published setup runs keep only the matching writer branch.",
|
| 186 |
+
},
|
| 187 |
+
),
|
| 188 |
+
"payload": (
|
| 189 |
+
"*",
|
| 190 |
+
{
|
| 191 |
+
"tooltip": "Main workflow payload to send to the switch-aligned writer branches.",
|
| 192 |
+
},
|
| 193 |
+
),
|
| 194 |
+
},
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
RETURN_TYPES = ("*", "*", "*", "*")
|
| 198 |
+
RETURN_NAMES = INPUT_MODES
|
| 199 |
+
CATEGORY = "Koolook/Publish"
|
| 200 |
+
FUNCTION = "route"
|
| 201 |
+
DESCRIPTION = "Route one payload into switch-aligned writer branches for published setup runs."
|
| 202 |
+
|
| 203 |
+
def route(self, selector: int, payload):
|
| 204 |
+
return (payload, payload, payload, payload)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
NODE_CLASS_MAPPINGS = {
|
| 208 |
+
"Koolook_PublishInput": Koolook_PublishInput,
|
| 209 |
+
"Koolook_PublishOutput": Koolook_PublishOutput,
|
| 210 |
+
"Koolook_PublishResult": Koolook_PublishResult,
|
| 211 |
+
"Koolook_PublishRouter": Koolook_PublishRouter,
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 215 |
+
"Koolook_PublishInput": "Koolook Publish Input",
|
| 216 |
+
"Koolook_PublishOutput": "Koolook Publish Output",
|
| 217 |
+
"Koolook_PublishResult": "Koolook Publish Result",
|
| 218 |
+
"Koolook_PublishRouter": "Koolook Publish Router",
|
| 219 |
+
}
|
ComfyUI/custom_nodes/koolook/k_video_combine.py
ADDED
|
@@ -0,0 +1,907 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
| 2 |
+
#
|
| 3 |
+
# ComfyUI-Koolook — Easy Video Combine
|
| 4 |
+
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
|
| 5 |
+
#
|
| 6 |
+
# This file is part of ComfyUI-Koolook, licensed under GPL-3.0-or-later.
|
| 7 |
+
# See the LICENSE file at the repo root for the full text.
|
| 8 |
+
#
|
| 9 |
+
# Runtime composition over Kosinkadink/ComfyUI-VideoHelperSuite (GPL-3.0).
|
| 10 |
+
# No VHS source is copied here — Easy_VideoCombine subclasses
|
| 11 |
+
# VHS_VideoCombine and scoped-patches folder_paths.get_save_image_path
|
| 12 |
+
# for the duration of one combine_video() call, letting upstream's
|
| 13 |
+
# encoder run untouched while the output is redirected to an absolute
|
| 14 |
+
# path on disk. If VHS isn't installed alongside Koolook, the node is
|
| 15 |
+
# simply not registered.
|
| 16 |
+
#
|
| 17 |
+
# The os.path.isabs(filename_prefix) discrimination pattern is borrowed
|
| 18 |
+
# from spacepxl/ComfyUI-HQ-Image-Save's SaveEXR (MIT) — same idea: one
|
| 19 |
+
# field carries both relative (sandboxed) and absolute (direct) paths
|
| 20 |
+
# so no new pin is needed on the canvas. See forks/THIRD_PARTY.md for
|
| 21 |
+
# the full attribution + change log.
|
| 22 |
+
"""Path-aware Video Combine for ComfyUI-Koolook.
|
| 23 |
+
|
| 24 |
+
Exposes ``Easy_VideoCombine`` (display name *Easy Video Combine
|
| 25 |
+
(Koolook)*, category ``Koolook/Video``). Identical to VHS's
|
| 26 |
+
``Video Combine`` except an absolute ``filename_prefix`` writes
|
| 27 |
+
directly to that location, and an optional ``create_path_if_missing``
|
| 28 |
+
toggle controls whether the parent directory is auto-created.
|
| 29 |
+
"""
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import datetime
|
| 33 |
+
import json
|
| 34 |
+
import os
|
| 35 |
+
import re
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
from typing import Optional, Tuple
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
from .koolook_versioning import (
|
| 41 |
+
is_auto_version,
|
| 42 |
+
next_version_token,
|
| 43 |
+
resolve_version_token,
|
| 44 |
+
)
|
| 45 |
+
except ImportError: # pragma: no cover - standalone (pytest / tooling)
|
| 46 |
+
from koolook_versioning import (
|
| 47 |
+
is_auto_version,
|
| 48 |
+
next_version_token,
|
| 49 |
+
resolve_version_token,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
import folder_paths # ComfyUI core; always present at runtime.
|
| 54 |
+
_KOLOOK_VIDEO_FORMATS_DIR = Path(__file__).resolve().parent / "video_formats"
|
| 55 |
+
if _KOLOOK_VIDEO_FORMATS_DIR.exists():
|
| 56 |
+
_paths, _exts = folder_paths.folder_names_and_paths.get(
|
| 57 |
+
"VHS_video_formats",
|
| 58 |
+
((), {".json"}),
|
| 59 |
+
)
|
| 60 |
+
_format_path = str(_KOLOOK_VIDEO_FORMATS_DIR)
|
| 61 |
+
if _format_path not in _paths:
|
| 62 |
+
folder_paths.folder_names_and_paths["VHS_video_formats"] = (
|
| 63 |
+
tuple(_paths) + (_format_path,),
|
| 64 |
+
set(_exts) | {".json"},
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Reach the VHS VideoCombine class through ComfyUI's global node
|
| 68 |
+
# registry rather than `from videohelpersuite.nodes import ...`.
|
| 69 |
+
# VHS uses relative imports internally (`from .logger import ...`,
|
| 70 |
+
# `from .utils import ...`) which only resolve correctly when VHS
|
| 71 |
+
# is loaded through ComfyUI's custom-node loader, not through a
|
| 72 |
+
# plain absolute import. The global registry is the documented
|
| 73 |
+
# cross-pack interface — ComfyUI populates it as each pack loads,
|
| 74 |
+
# and Koolook loads after VHS in every install we've seen because
|
| 75 |
+
# `comfyui-videohelpersuite` sorts before `koolook` (and even an
|
| 76 |
+
# uppercase `ComfyUI-VideoHelperSuite` sorts before
|
| 77 |
+
# `ComfyUI-Koolook` if both are present). If for some reason VHS
|
| 78 |
+
# isn't loaded yet, the lookup returns None and we self-skip with
|
| 79 |
+
# a clear message.
|
| 80 |
+
import nodes as _comfy_nodes_module
|
| 81 |
+
_VHS_VideoCombine = _comfy_nodes_module.NODE_CLASS_MAPPINGS.get("VHS_VideoCombine")
|
| 82 |
+
if _VHS_VideoCombine is None:
|
| 83 |
+
raise RuntimeError(
|
| 84 |
+
"VHS_VideoCombine not present in ComfyUI's NODE_CLASS_MAPPINGS "
|
| 85 |
+
"at Koolook load time. Either ComfyUI-VideoHelperSuite isn't "
|
| 86 |
+
"installed, or it loaded after Koolook (unexpected — check "
|
| 87 |
+
"custom_nodes folder names)."
|
| 88 |
+
)
|
| 89 |
+
_VHS_AVAILABLE = True
|
| 90 |
+
_VHS_IMPORT_ERROR: Optional[BaseException] = None
|
| 91 |
+
except (ImportError, RuntimeError) as _exc:
|
| 92 |
+
_VHS_AVAILABLE = False
|
| 93 |
+
_VHS_IMPORT_ERROR = _exc
|
| 94 |
+
_VHS_VideoCombine = None # type: ignore[assignment]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _resolve_abs_target(
|
| 98 |
+
filename_prefix: str,
|
| 99 |
+
create_path_if_missing: bool,
|
| 100 |
+
) -> Optional[Tuple[str, str]]:
|
| 101 |
+
"""Discriminate relative vs absolute ``filename_prefix``.
|
| 102 |
+
|
| 103 |
+
Returns ``None`` when the prefix is relative — caller should let
|
| 104 |
+
upstream's sandboxed flow handle it unchanged. Returns
|
| 105 |
+
``(abs_dir, abs_basename)`` when the prefix is absolute; upstream's
|
| 106 |
+
counter pattern will append ``"_<counter:05>.<ext>"`` to
|
| 107 |
+
``abs_basename`` inside ``abs_dir``.
|
| 108 |
+
|
| 109 |
+
Raises ``FileNotFoundError`` if the parent directory does not exist
|
| 110 |
+
and ``create_path_if_missing`` is ``False`` — a typo in a render
|
| 111 |
+
path should surface as a clear error, not silently spawn a stray
|
| 112 |
+
directory tree. Raises ``ValueError`` if the absolute prefix has no
|
| 113 |
+
filename component (e.g. a bare drive root like ``"C:\\"``).
|
| 114 |
+
"""
|
| 115 |
+
if not os.path.isabs(filename_prefix):
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
normed = os.path.normpath(filename_prefix)
|
| 119 |
+
if os.path.isdir(normed):
|
| 120 |
+
abs_dir = normed
|
| 121 |
+
abs_base = os.path.basename(normed.rstrip("/\\"))
|
| 122 |
+
if abs_base == "":
|
| 123 |
+
raise ValueError(
|
| 124 |
+
f"filename_prefix '{filename_prefix}' is an absolute directory "
|
| 125 |
+
f"but has no usable folder name for the filename root."
|
| 126 |
+
)
|
| 127 |
+
return abs_dir, abs_base
|
| 128 |
+
|
| 129 |
+
abs_dir = os.path.dirname(normed)
|
| 130 |
+
abs_base = os.path.basename(normed)
|
| 131 |
+
|
| 132 |
+
if abs_base == "":
|
| 133 |
+
raise ValueError(
|
| 134 |
+
f"filename_prefix '{filename_prefix}' is an absolute path but "
|
| 135 |
+
f"has no filename component. Append a filename root, e.g. "
|
| 136 |
+
f"'{normed.rstrip(os.sep)}{os.sep}clipname'."
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
if not os.path.exists(abs_dir):
|
| 140 |
+
if create_path_if_missing:
|
| 141 |
+
os.makedirs(abs_dir, exist_ok=True)
|
| 142 |
+
else:
|
| 143 |
+
raise FileNotFoundError(
|
| 144 |
+
f"Directory does not exist: {abs_dir}. Enable "
|
| 145 |
+
f"'create_path_if_missing' on the Easy Video Combine node "
|
| 146 |
+
f"to auto-create it."
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
return abs_dir, abs_base
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
_SENTINEL_STRINGS = ("undefined", "null", "none")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _normalize_text_input(value) -> str:
|
| 156 |
+
"""Coerce a ComfyUI STRING widget value to a clean Python string.
|
| 157 |
+
|
| 158 |
+
ComfyUI's frontend has a long-running quirk: when a STRING input
|
| 159 |
+
with an empty default sits untouched, the value sometimes arrives
|
| 160 |
+
at the backend as the literal string ``"undefined"`` (or ``"null"``
|
| 161 |
+
/ ``"None"``) instead of ``""``. Treating those as valid input
|
| 162 |
+
causes nonsensical ``undefined/`` subdirectories on disk.
|
| 163 |
+
|
| 164 |
+
Also defends against:
|
| 165 |
+
- Surrounding quotes (users paste paths from Explorer with quotes).
|
| 166 |
+
- Trailing whitespace including newlines (WAS Text Multiline can
|
| 167 |
+
leave a trailing newline when the user pressed Enter at the end).
|
| 168 |
+
- Embedded sentinel components separated by a newline (a multi-line
|
| 169 |
+
Text Multiline node with a second sentinel-looking line).
|
| 170 |
+
|
| 171 |
+
Any sentinel string (case-insensitive, post-strip) becomes ``""``;
|
| 172 |
+
everything else is the cleaned value.
|
| 173 |
+
"""
|
| 174 |
+
if value is None:
|
| 175 |
+
return ""
|
| 176 |
+
s = str(value).strip().strip('"').strip("'").strip()
|
| 177 |
+
# If a newline split the value across lines, take the first
|
| 178 |
+
# non-empty non-sentinel line.
|
| 179 |
+
if "\n" in s or "\r" in s:
|
| 180 |
+
for line in s.replace("\r", "\n").split("\n"):
|
| 181 |
+
line = line.strip()
|
| 182 |
+
if line and line.lower() not in _SENTINEL_STRINGS:
|
| 183 |
+
s = line
|
| 184 |
+
break
|
| 185 |
+
else:
|
| 186 |
+
s = ""
|
| 187 |
+
if s.lower() in _SENTINEL_STRINGS:
|
| 188 |
+
return ""
|
| 189 |
+
return s
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _normalize_bool_input(value, default: bool = False) -> bool:
|
| 193 |
+
"""Coerce ComfyUI widget values to a real bool.
|
| 194 |
+
|
| 195 |
+
This is intentionally stricter than Python truthiness. A corrupted
|
| 196 |
+
saved workflow can shift a format-widget value such as ``"hq"`` into
|
| 197 |
+
the ``pingpong`` slot; ``bool("hq")`` would enable ping-pong and
|
| 198 |
+
double the video. Unknown strings fall back to ``default``.
|
| 199 |
+
"""
|
| 200 |
+
if isinstance(value, bool):
|
| 201 |
+
return value
|
| 202 |
+
if value is None:
|
| 203 |
+
return default
|
| 204 |
+
if isinstance(value, (int, float)):
|
| 205 |
+
return bool(value)
|
| 206 |
+
s = str(value).strip().lower()
|
| 207 |
+
if s in ("true", "1", "yes", "on"):
|
| 208 |
+
return True
|
| 209 |
+
if s in ("false", "0", "no", "off", ""):
|
| 210 |
+
return False
|
| 211 |
+
return default
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _strip_sentinel_components(path: str) -> str:
|
| 215 |
+
"""Remove any path component that's exactly a sentinel string.
|
| 216 |
+
|
| 217 |
+
Belt-and-suspenders defense: if anything upstream of us managed
|
| 218 |
+
to inject ``undefined`` / ``null`` / ``None`` as a path component
|
| 219 |
+
(despite the per-field normalization above), drop it before the
|
| 220 |
+
path reaches the filesystem. Preserves the rest of the path,
|
| 221 |
+
including the drive root on Windows.
|
| 222 |
+
|
| 223 |
+
Returns the path unchanged if no sentinels found, so this is a
|
| 224 |
+
cheap pass for the common case.
|
| 225 |
+
"""
|
| 226 |
+
if not path:
|
| 227 |
+
return path
|
| 228 |
+
# Split on whichever separator is present, defaulting to os.sep.
|
| 229 |
+
sep = "\\" if "\\" in path else "/"
|
| 230 |
+
parts = path.split(sep)
|
| 231 |
+
cleaned = [
|
| 232 |
+
p for p in parts
|
| 233 |
+
if p == "" or p.lower() not in _SENTINEL_STRINGS
|
| 234 |
+
]
|
| 235 |
+
if len(cleaned) == len(parts):
|
| 236 |
+
return path
|
| 237 |
+
return sep.join(cleaned)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _compose_prefix(filename_prefix: str, output_directory: str) -> str:
|
| 241 |
+
"""Combine ``output_directory`` + ``filename_prefix`` into one path-prefix.
|
| 242 |
+
|
| 243 |
+
When ``output_directory`` is empty, ``filename_prefix`` is returned
|
| 244 |
+
unchanged — it retains the overloaded "relative-or-absolute prefix"
|
| 245 |
+
semantics for back-compat with users who like the one-field flow.
|
| 246 |
+
|
| 247 |
+
When ``output_directory`` is set, only the basename of
|
| 248 |
+
``filename_prefix`` becomes the filename root (any path components
|
| 249 |
+
the user accidentally typed there are stripped, so they can change
|
| 250 |
+
the name without re-typing the directory). The directory keeps its
|
| 251 |
+
shape (absolute or relative), so the downstream
|
| 252 |
+
:func:`_resolve_abs_target` discrimination still works on the
|
| 253 |
+
composed prefix.
|
| 254 |
+
"""
|
| 255 |
+
if not output_directory:
|
| 256 |
+
return filename_prefix
|
| 257 |
+
name_root = os.path.basename(filename_prefix.rstrip("/\\")) or "AnimateDiff"
|
| 258 |
+
clean_dir = output_directory.rstrip("/\\")
|
| 259 |
+
return clean_dir + os.sep + name_root
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _display_format_name(format_name: str) -> str:
|
| 263 |
+
"""Hide the JSON suffix VHS exposes for external Koolook presets."""
|
| 264 |
+
if format_name.startswith("video/koolook-") and format_name.endswith(".json"):
|
| 265 |
+
return format_name[:-5]
|
| 266 |
+
return format_name
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _runtime_format_name(format_name: str) -> str:
|
| 270 |
+
"""Map cleaned Koolook display names back to VHS's external file names."""
|
| 271 |
+
if format_name.startswith("video/koolook-") and not format_name.endswith(".json"):
|
| 272 |
+
return format_name + ".json"
|
| 273 |
+
return format_name
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _metadata_sidecar_path(output_files: list[str]) -> Optional[str]:
|
| 277 |
+
"""Pick the JSON sidecar path from VHS's output file list."""
|
| 278 |
+
if not output_files:
|
| 279 |
+
return None
|
| 280 |
+
return os.path.splitext(output_files[0])[0] + ".json"
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _add_metadata_json_sidecar(
|
| 284 |
+
result,
|
| 285 |
+
metadata_payload: dict,
|
| 286 |
+
save_metadata_json: bool,
|
| 287 |
+
save_metadata_png: bool,
|
| 288 |
+
):
|
| 289 |
+
"""Write a workflow metadata JSON next to VHS's output and return result."""
|
| 290 |
+
if not save_metadata_json or not isinstance(result, dict):
|
| 291 |
+
return result
|
| 292 |
+
try:
|
| 293 |
+
output_files = result.get("result", ((None, []),))[0][1]
|
| 294 |
+
if not isinstance(output_files, list):
|
| 295 |
+
return result
|
| 296 |
+
json_path = _metadata_sidecar_path(output_files)
|
| 297 |
+
if not json_path:
|
| 298 |
+
return result
|
| 299 |
+
Path(json_path).write_text(
|
| 300 |
+
json.dumps(metadata_payload, indent=2, sort_keys=True) + "\n",
|
| 301 |
+
encoding="utf-8",
|
| 302 |
+
)
|
| 303 |
+
if save_metadata_png:
|
| 304 |
+
output_files.append(json_path)
|
| 305 |
+
elif output_files and output_files[0].lower().endswith(".png"):
|
| 306 |
+
output_files[0] = json_path
|
| 307 |
+
else:
|
| 308 |
+
output_files.insert(0, json_path)
|
| 309 |
+
ui = result.get("ui", {})
|
| 310 |
+
gifs = ui.get("gifs", [])
|
| 311 |
+
if gifs and isinstance(gifs[0], dict) and gifs[0].get("workflow", "").endswith(".png"):
|
| 312 |
+
gifs[0]["workflow"] = os.path.basename(json_path)
|
| 313 |
+
except Exception as exc:
|
| 314 |
+
print(f"[Easy_VideoCombine] metadata JSON sidecar skipped: {exc!r}")
|
| 315 |
+
return result
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _remove_audio_suffix_from_result(result, keep_silent_intermediate: bool):
|
| 319 |
+
"""Rename VHS's final ``-audio`` mux output to the clean base name."""
|
| 320 |
+
if keep_silent_intermediate or not isinstance(result, dict):
|
| 321 |
+
return result
|
| 322 |
+
try:
|
| 323 |
+
output_files = result.get("result", ((None, []),))[0][1]
|
| 324 |
+
if not isinstance(output_files, list) or not output_files:
|
| 325 |
+
return result
|
| 326 |
+
final_path = Path(output_files[-1])
|
| 327 |
+
if final_path.stem.endswith("-audio"):
|
| 328 |
+
clean_path = final_path.with_name(final_path.stem[:-6] + final_path.suffix)
|
| 329 |
+
if clean_path.exists() and clean_path != final_path:
|
| 330 |
+
clean_path.unlink()
|
| 331 |
+
final_path.replace(clean_path)
|
| 332 |
+
output_files[-1] = str(clean_path)
|
| 333 |
+
ui = result.get("ui", {})
|
| 334 |
+
gifs = ui.get("gifs", [])
|
| 335 |
+
if gifs and isinstance(gifs[0], dict):
|
| 336 |
+
gifs[0]["filename"] = clean_path.name
|
| 337 |
+
gifs[0]["fullpath"] = str(clean_path)
|
| 338 |
+
output_files[:] = [
|
| 339 |
+
path for path in output_files
|
| 340 |
+
if not isinstance(path, str) or os.path.exists(path)
|
| 341 |
+
]
|
| 342 |
+
deduped = []
|
| 343 |
+
for path in output_files:
|
| 344 |
+
if path not in deduped:
|
| 345 |
+
deduped.append(path)
|
| 346 |
+
output_files[:] = deduped
|
| 347 |
+
except Exception as exc:
|
| 348 |
+
print(f"[Easy_VideoCombine] audio suffix cleanup skipped: {exc!r}")
|
| 349 |
+
return result
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# VHS appends a zero-padded counter to every output (e.g. ``clip_00001.mp4``).
|
| 353 |
+
# Strict-version mode strips it so the filename is exactly ``<root>_<token>``.
|
| 354 |
+
_COUNTER_RE = re.compile(r"_\d{5}$")
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _build_sidecar_workflow(prompt, extra_pnginfo, creation_time):
|
| 358 |
+
"""Build the JSON sidecar as a **drag-loadable ComfyUI workflow**.
|
| 359 |
+
|
| 360 |
+
ComfyUI restores a workflow from the litegraph graph at the TOP LEVEL of
|
| 361 |
+
the JSON (``nodes`` / ``links`` / ``last_node_id`` ...). That graph is
|
| 362 |
+
carried in ``extra_pnginfo['workflow']``, so we write it directly —
|
| 363 |
+
dropping the sidecar onto the canvas reloads the graph, exactly like
|
| 364 |
+
ComfyUI's own "Save". ``CreationTime`` is tucked into the graph's
|
| 365 |
+
``extra`` so it is preserved without breaking loadability.
|
| 366 |
+
|
| 367 |
+
Falls back to ``{CreationTime, prompt}`` when no litegraph graph is
|
| 368 |
+
available (e.g. the headless / API ``/prompt`` path, which carries only
|
| 369 |
+
the API-format prompt, not a positioned graph) so the sidecar is never
|
| 370 |
+
empty — just not drag-loadable in that case.
|
| 371 |
+
"""
|
| 372 |
+
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
|
| 373 |
+
if isinstance(workflow, dict) and "nodes" in workflow:
|
| 374 |
+
graph = dict(workflow)
|
| 375 |
+
extra = dict(graph.get("extra") or {})
|
| 376 |
+
extra.setdefault("CreationTime", creation_time)
|
| 377 |
+
graph["extra"] = extra
|
| 378 |
+
return graph
|
| 379 |
+
payload = {"CreationTime": creation_time}
|
| 380 |
+
if prompt is not None:
|
| 381 |
+
payload["prompt"] = prompt
|
| 382 |
+
return payload
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _append_version_to_prefix(prefix: str, token: str) -> str:
|
| 386 |
+
"""Suffix the path-prefix's filename root with ``_<token>``.
|
| 387 |
+
|
| 388 |
+
``effective_prefix`` always ends in a filename component at the call site
|
| 389 |
+
(``_compose_prefix`` guarantees a name root, never a bare directory), so the
|
| 390 |
+
token attaches directly to the end — e.g. ``E:/renders/clip`` ->
|
| 391 |
+
``E:/renders/clip_v001``.
|
| 392 |
+
"""
|
| 393 |
+
if not token:
|
| 394 |
+
return prefix
|
| 395 |
+
return f"{prefix}_{token}"
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _auto_version_scan_target(effective_prefix: str) -> tuple[str, str]:
|
| 399 |
+
"""Return ``(directory, name)`` matching where VHS will write the prefix."""
|
| 400 |
+
name = os.path.basename(effective_prefix.rstrip("/\\"))
|
| 401 |
+
directory = os.path.dirname(effective_prefix)
|
| 402 |
+
if os.path.isabs(effective_prefix):
|
| 403 |
+
return directory, name
|
| 404 |
+
output_root = folder_paths.get_output_directory()
|
| 405 |
+
return os.path.normpath(os.path.join(output_root, directory)), name
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def _coerce_version_input(raw):
|
| 409 |
+
"""Defend the ``version`` field against a stale boolean.
|
| 410 |
+
|
| 411 |
+
Removing the old ``keep_silent_intermediate`` BOOLEAN widget shifts a saved
|
| 412 |
+
workflow's stored value into the new same-position ``version`` STRING slot
|
| 413 |
+
(widgets serialize by index — see docs/maintainers/node-versioning.md). A
|
| 414 |
+
leftover bool would otherwise become the literal token ``"False"`` / ``"True"``
|
| 415 |
+
and silently force strict mode (``clip_False.mp4``). Treat any boolean — or
|
| 416 |
+
its string form — as "no version" so old workflows fall back to the legacy
|
| 417 |
+
counter, exactly as before.
|
| 418 |
+
"""
|
| 419 |
+
if isinstance(raw, bool):
|
| 420 |
+
return ""
|
| 421 |
+
if str(raw).strip().lower() in ("true", "false"):
|
| 422 |
+
return ""
|
| 423 |
+
return raw
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _write_sidecar(json_path: str, metadata_payload: dict) -> bool:
|
| 427 |
+
"""Write the metadata JSON sidecar to ``json_path``. True on success."""
|
| 428 |
+
try:
|
| 429 |
+
Path(json_path).write_text(
|
| 430 |
+
json.dumps(metadata_payload, indent=2, sort_keys=True) + "\n",
|
| 431 |
+
encoding="utf-8",
|
| 432 |
+
)
|
| 433 |
+
return True
|
| 434 |
+
except Exception as exc:
|
| 435 |
+
print(f"[Easy_VideoCombine] metadata JSON sidecar skipped: {exc!r}")
|
| 436 |
+
return False
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def _strip_counter_path(path_str: str, enable_overwrite: bool) -> str:
|
| 440 |
+
"""Rename a rendered video to drop VHS's trailing ``_NNNNN`` counter.
|
| 441 |
+
|
| 442 |
+
Collision: if the counter-free name already exists and ``enable_overwrite``
|
| 443 |
+
is False, keep the counter (return the original path) so nothing is
|
| 444 |
+
clobbered; if True, replace it. Returns the resulting path string.
|
| 445 |
+
"""
|
| 446 |
+
p = Path(path_str)
|
| 447 |
+
stripped = _COUNTER_RE.sub("", p.stem)
|
| 448 |
+
if stripped == p.stem:
|
| 449 |
+
return path_str
|
| 450 |
+
candidate = p.with_name(stripped + p.suffix)
|
| 451 |
+
if candidate == p:
|
| 452 |
+
return path_str
|
| 453 |
+
if candidate.exists():
|
| 454 |
+
if not enable_overwrite:
|
| 455 |
+
print(
|
| 456 |
+
f"[Easy_VideoCombine] {candidate.name} exists; kept counter "
|
| 457 |
+
f"suffix to avoid overwrite (enable_overwrite to replace)."
|
| 458 |
+
)
|
| 459 |
+
return path_str
|
| 460 |
+
candidate.unlink()
|
| 461 |
+
print(f"[Easy_VideoCombine] replaced existing {candidate.name}.")
|
| 462 |
+
p.replace(candidate)
|
| 463 |
+
return str(candidate)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def _finalize_strict_version_output(
|
| 467 |
+
result,
|
| 468 |
+
enable_overwrite: bool,
|
| 469 |
+
metadata_payload: dict,
|
| 470 |
+
save_metadata_json: bool,
|
| 471 |
+
save_metadata_png: bool = False,
|
| 472 |
+
):
|
| 473 |
+
"""Strict-version finalize: strip VHS's ``_NNNNN`` counter from the final
|
| 474 |
+
video so the name is exactly ``<root>_<token>.<ext>``, align a kept
|
| 475 |
+
metadata PNG to the same stem, and write the JSON sidecar beside them.
|
| 476 |
+
|
| 477 |
+
Collision is handled in :func:`_strip_counter_path` (lossless unless
|
| 478 |
+
``enable_overwrite``); the sidecar always matches the video's final name.
|
| 479 |
+
"""
|
| 480 |
+
if not isinstance(result, dict):
|
| 481 |
+
return result
|
| 482 |
+
try:
|
| 483 |
+
output_files = result.get("result", ((None, []),))[0][1]
|
| 484 |
+
if not isinstance(output_files, list) or not output_files:
|
| 485 |
+
return result
|
| 486 |
+
|
| 487 |
+
new_video = _strip_counter_path(output_files[-1], enable_overwrite)
|
| 488 |
+
if new_video != output_files[-1]:
|
| 489 |
+
output_files[-1] = new_video
|
| 490 |
+
ui = result.get("ui", {})
|
| 491 |
+
gifs = ui.get("gifs", [])
|
| 492 |
+
if gifs and isinstance(gifs[0], dict):
|
| 493 |
+
gifs[0]["filename"] = os.path.basename(new_video)
|
| 494 |
+
gifs[0]["fullpath"] = new_video
|
| 495 |
+
video_stem_path = os.path.splitext(new_video)[0]
|
| 496 |
+
|
| 497 |
+
# Keep the metadata PNG (when requested) under the video's stem so the
|
| 498 |
+
# PNG/JSON/video trio share one name; otherwise it's dropped below.
|
| 499 |
+
if (
|
| 500 |
+
save_metadata_png
|
| 501 |
+
and isinstance(output_files[0], str)
|
| 502 |
+
and output_files[0].lower().endswith(".png")
|
| 503 |
+
and output_files[0] != new_video
|
| 504 |
+
):
|
| 505 |
+
png = Path(output_files[0])
|
| 506 |
+
aligned = png.with_name(os.path.basename(video_stem_path) + png.suffix)
|
| 507 |
+
if aligned != png:
|
| 508 |
+
try:
|
| 509 |
+
if aligned.exists():
|
| 510 |
+
aligned.unlink()
|
| 511 |
+
png.replace(aligned)
|
| 512 |
+
output_files[0] = str(aligned)
|
| 513 |
+
except OSError as exc:
|
| 514 |
+
print(f"[Easy_VideoCombine] metadata PNG rename skipped: {exc!r}")
|
| 515 |
+
|
| 516 |
+
if save_metadata_json:
|
| 517 |
+
json_path = video_stem_path + ".json"
|
| 518 |
+
if _write_sidecar(json_path, metadata_payload):
|
| 519 |
+
if save_metadata_png:
|
| 520 |
+
if json_path not in output_files:
|
| 521 |
+
output_files.append(json_path)
|
| 522 |
+
elif (
|
| 523 |
+
isinstance(output_files[0], str)
|
| 524 |
+
and output_files[0].lower().endswith(".png")
|
| 525 |
+
):
|
| 526 |
+
output_files[0] = json_path
|
| 527 |
+
elif json_path not in output_files:
|
| 528 |
+
output_files.insert(0, json_path)
|
| 529 |
+
ui = result.get("ui", {})
|
| 530 |
+
gifs = ui.get("gifs", [])
|
| 531 |
+
if (
|
| 532 |
+
gifs
|
| 533 |
+
and isinstance(gifs[0], dict)
|
| 534 |
+
and str(gifs[0].get("workflow", "")).endswith(".png")
|
| 535 |
+
):
|
| 536 |
+
gifs[0]["workflow"] = os.path.basename(json_path)
|
| 537 |
+
except Exception as exc:
|
| 538 |
+
print(f"[Easy_VideoCombine] strict-version finalize skipped: {exc!r}")
|
| 539 |
+
return result
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def _finalize_output(
|
| 543 |
+
result,
|
| 544 |
+
version_token: str,
|
| 545 |
+
enable_overwrite: bool,
|
| 546 |
+
metadata_payload: dict,
|
| 547 |
+
save_metadata_json: bool,
|
| 548 |
+
save_metadata_png: bool,
|
| 549 |
+
):
|
| 550 |
+
"""Route to strict-version finalize (token set -> counter stripped) or the
|
| 551 |
+
legacy sidecar attach (token empty -> VHS counter kept)."""
|
| 552 |
+
if version_token:
|
| 553 |
+
return _finalize_strict_version_output(
|
| 554 |
+
result, enable_overwrite, metadata_payload, save_metadata_json, save_metadata_png
|
| 555 |
+
)
|
| 556 |
+
return _add_metadata_json_sidecar(
|
| 557 |
+
result, metadata_payload, save_metadata_json, save_metadata_png
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
_VIDEO_OUTPUT_EXTENSIONS = {
|
| 562 |
+
".avi",
|
| 563 |
+
".gif",
|
| 564 |
+
".m4v",
|
| 565 |
+
".mkv",
|
| 566 |
+
".mov",
|
| 567 |
+
".mp4",
|
| 568 |
+
".webm",
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def _final_video_path_from_result(result) -> str:
|
| 573 |
+
"""Return the final video path from VHS's mixed output-file list.
|
| 574 |
+
|
| 575 |
+
VHS/Koolook sidecars can sit before or after the rendered clip. If more
|
| 576 |
+
than one video-like path appears, the later one is treated as final because
|
| 577 |
+
VHS appends muxed/renamed outputs after earlier intermediates.
|
| 578 |
+
"""
|
| 579 |
+
if not isinstance(result, dict):
|
| 580 |
+
return ""
|
| 581 |
+
try:
|
| 582 |
+
output_files = result.get("result", ((None, []),))[0][1]
|
| 583 |
+
except (IndexError, TypeError):
|
| 584 |
+
return ""
|
| 585 |
+
if not isinstance(output_files, list):
|
| 586 |
+
return ""
|
| 587 |
+
string_paths = [path for path in output_files if isinstance(path, str)]
|
| 588 |
+
for path in reversed(string_paths):
|
| 589 |
+
if Path(path).suffix.lower() in _VIDEO_OUTPUT_EXTENSIONS:
|
| 590 |
+
return path
|
| 591 |
+
return string_paths[-1] if string_paths else ""
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def _final_json_path_from_result(result, video_path: str = "") -> str:
|
| 595 |
+
"""Return the JSON sidecar path from VHS/Koolook's output-file list.
|
| 596 |
+
|
| 597 |
+
Normal combine runs include the JSON in ``result``. The video-stem fallback
|
| 598 |
+
matches Koolook's current sidecar convention and is only for defensive
|
| 599 |
+
best-effort output when upstream omits the JSON path from the list.
|
| 600 |
+
"""
|
| 601 |
+
if isinstance(result, dict):
|
| 602 |
+
try:
|
| 603 |
+
output_files = result.get("result", ((None, []),))[0][1]
|
| 604 |
+
except (IndexError, TypeError):
|
| 605 |
+
output_files = []
|
| 606 |
+
if isinstance(output_files, list):
|
| 607 |
+
for path in output_files:
|
| 608 |
+
if isinstance(path, str) and Path(path).suffix.lower() == ".json":
|
| 609 |
+
return os.path.normpath(path)
|
| 610 |
+
if video_path:
|
| 611 |
+
return os.path.splitext(os.path.normpath(video_path))[0] + ".json"
|
| 612 |
+
return ""
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def _video_path_parts(path: str) -> Tuple[str, str, str]:
|
| 616 |
+
"""Return ``(full_path, directory, filename)`` for loader wiring."""
|
| 617 |
+
if not path:
|
| 618 |
+
return ("", "", "")
|
| 619 |
+
normed = os.path.normpath(path)
|
| 620 |
+
return (normed, os.path.dirname(normed), os.path.basename(normed))
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def _append_video_path_outputs(result):
|
| 624 |
+
"""Append clean string outputs without disturbing VHS's original output."""
|
| 625 |
+
video_path, video_directory, video_name = _video_path_parts(_final_video_path_from_result(result))
|
| 626 |
+
json_path = _final_json_path_from_result(result, video_path)
|
| 627 |
+
if not isinstance(result, dict):
|
| 628 |
+
return {
|
| 629 |
+
"result": (result, video_path, video_directory, video_name, json_path),
|
| 630 |
+
}
|
| 631 |
+
existing = result.get("result", tuple())
|
| 632 |
+
if not isinstance(existing, tuple):
|
| 633 |
+
existing = (existing,)
|
| 634 |
+
result["result"] = existing + (video_path, video_directory, video_name, json_path)
|
| 635 |
+
return result
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
if _VHS_AVAILABLE:
|
| 639 |
+
class Easy_VideoCombine(_VHS_VideoCombine):
|
| 640 |
+
"""Video Combine variant with absolute-path output."""
|
| 641 |
+
|
| 642 |
+
CATEGORY = "Koolook/Video"
|
| 643 |
+
DESCRIPTION = "Video Combine variant with absolute-path output."
|
| 644 |
+
RETURN_TYPES = tuple(getattr(_VHS_VideoCombine, "RETURN_TYPES", ("VHS_FILENAMES",))) + (
|
| 645 |
+
"STRING",
|
| 646 |
+
"STRING",
|
| 647 |
+
"STRING",
|
| 648 |
+
"STRING",
|
| 649 |
+
)
|
| 650 |
+
RETURN_NAMES = tuple(getattr(_VHS_VideoCombine, "RETURN_NAMES", ("Filenames",))) + (
|
| 651 |
+
"video_path",
|
| 652 |
+
"video_directory",
|
| 653 |
+
"video_name",
|
| 654 |
+
"json_path",
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
@classmethod
|
| 658 |
+
def INPUT_TYPES(cls):
|
| 659 |
+
types = _VHS_VideoCombine.INPUT_TYPES()
|
| 660 |
+
format_options, format_config = types["required"]["format"]
|
| 661 |
+
cleaned_format_options = []
|
| 662 |
+
cleaned_format_widgets = {}
|
| 663 |
+
format_widgets = dict(format_config.get("formats", {}))
|
| 664 |
+
for option in format_options:
|
| 665 |
+
cleaned = _display_format_name(option)
|
| 666 |
+
cleaned_format_options.append(cleaned)
|
| 667 |
+
if option in format_widgets:
|
| 668 |
+
cleaned_format_widgets[cleaned] = format_widgets[option]
|
| 669 |
+
for option, widgets in format_widgets.items():
|
| 670 |
+
cleaned_format_widgets.setdefault(_display_format_name(option), widgets)
|
| 671 |
+
format_options = cleaned_format_options
|
| 672 |
+
format_config = {**format_config, "formats": cleaned_format_widgets}
|
| 673 |
+
if "video/ProRes" in format_options:
|
| 674 |
+
types["required"]["format"] = (
|
| 675 |
+
format_options,
|
| 676 |
+
{**format_config, "default": "video/ProRes"},
|
| 677 |
+
)
|
| 678 |
+
types["required"]["filename_prefix"] = (
|
| 679 |
+
"STRING",
|
| 680 |
+
{
|
| 681 |
+
"default": "AnimateDiff",
|
| 682 |
+
"tooltip": "Filename root. Counter and extension are appended.",
|
| 683 |
+
},
|
| 684 |
+
)
|
| 685 |
+
# Upstream VHS uses save_output to choose ComfyUI output/ vs temp/.
|
| 686 |
+
# Easy_VideoCombine is an explicit file-writer, and absolute-path
|
| 687 |
+
# mode ignores that distinction, so hiding it avoids a misleading
|
| 688 |
+
# toggle while preserving the persistent-output behavior.
|
| 689 |
+
types["required"].pop("save_output", None)
|
| 690 |
+
types["optional"]["output_directory"] = (
|
| 691 |
+
"STRING",
|
| 692 |
+
{
|
| 693 |
+
"default": "",
|
| 694 |
+
"tooltip": "Absolute or relative output directory.",
|
| 695 |
+
},
|
| 696 |
+
)
|
| 697 |
+
types["optional"]["create_path_if_missing"] = (
|
| 698 |
+
"BOOLEAN",
|
| 699 |
+
{
|
| 700 |
+
"default": False,
|
| 701 |
+
"tooltip": "Auto-create the output directory if missing.",
|
| 702 |
+
},
|
| 703 |
+
)
|
| 704 |
+
types["optional"]["save_metadata_png"] = (
|
| 705 |
+
"BOOLEAN",
|
| 706 |
+
{
|
| 707 |
+
"default": False,
|
| 708 |
+
"tooltip": "Save the first-frame PNG with embedded workflow metadata.",
|
| 709 |
+
},
|
| 710 |
+
)
|
| 711 |
+
types["optional"]["save_metadata_json"] = (
|
| 712 |
+
"BOOLEAN",
|
| 713 |
+
{
|
| 714 |
+
"default": True,
|
| 715 |
+
"tooltip": "Save workflow metadata as a JSON sidecar.",
|
| 716 |
+
},
|
| 717 |
+
)
|
| 718 |
+
types["optional"]["version"] = (
|
| 719 |
+
"STRING",
|
| 720 |
+
{
|
| 721 |
+
"default": "",
|
| 722 |
+
"tooltip": (
|
| 723 |
+
"Strict version token (e.g. 'v001'), typically wired "
|
| 724 |
+
"from a global version node. When set, the output is "
|
| 725 |
+
"named exactly <prefix>_<version>.<ext> with NO automatic "
|
| 726 |
+
"_NNNNN counter. Leave empty to keep VHS's automatic "
|
| 727 |
+
"counter (the casual default)."
|
| 728 |
+
),
|
| 729 |
+
},
|
| 730 |
+
)
|
| 731 |
+
types["optional"]["enable_overwrite"] = (
|
| 732 |
+
"BOOLEAN",
|
| 733 |
+
{
|
| 734 |
+
"default": False,
|
| 735 |
+
"tooltip": (
|
| 736 |
+
"Strict-version mode only. If the versioned filename "
|
| 737 |
+
"already exists: on = replace it; off = keep VHS's "
|
| 738 |
+
"counter for this render so nothing is overwritten."
|
| 739 |
+
),
|
| 740 |
+
},
|
| 741 |
+
)
|
| 742 |
+
return types
|
| 743 |
+
|
| 744 |
+
def combine_video(self, *args, **kwargs):
|
| 745 |
+
# Defensive normalization — the frontend occasionally sends
|
| 746 |
+
# the literal string "undefined" for STRING widgets with
|
| 747 |
+
# empty defaults. Coerce those to "" before any path
|
| 748 |
+
# composition so we never create an `undefined/` subdir.
|
| 749 |
+
filename_prefix = _normalize_text_input(
|
| 750 |
+
kwargs.get("filename_prefix", "AnimateDiff")
|
| 751 |
+
) or "AnimateDiff"
|
| 752 |
+
kwargs["filename_prefix"] = filename_prefix
|
| 753 |
+
output_directory = _normalize_text_input(
|
| 754 |
+
kwargs.pop("output_directory", "")
|
| 755 |
+
)
|
| 756 |
+
kwargs["pingpong"] = _normalize_bool_input(
|
| 757 |
+
kwargs.get("pingpong", False),
|
| 758 |
+
default=False,
|
| 759 |
+
)
|
| 760 |
+
if "format" in kwargs:
|
| 761 |
+
kwargs["format"] = _runtime_format_name(kwargs["format"])
|
| 762 |
+
kwargs["save_output"] = True
|
| 763 |
+
create_path_if_missing = _normalize_bool_input(
|
| 764 |
+
kwargs.pop("create_path_if_missing", False),
|
| 765 |
+
default=False,
|
| 766 |
+
)
|
| 767 |
+
save_metadata_png = _normalize_bool_input(
|
| 768 |
+
kwargs.pop("save_metadata_png", False),
|
| 769 |
+
default=False,
|
| 770 |
+
)
|
| 771 |
+
save_metadata_json = _normalize_bool_input(
|
| 772 |
+
kwargs.pop("save_metadata_json", True),
|
| 773 |
+
default=True,
|
| 774 |
+
)
|
| 775 |
+
keep_silent_intermediate = _normalize_bool_input(
|
| 776 |
+
kwargs.pop("keep_silent_intermediate", False),
|
| 777 |
+
default=False,
|
| 778 |
+
)
|
| 779 |
+
enable_overwrite = _normalize_bool_input(
|
| 780 |
+
kwargs.pop("enable_overwrite", False),
|
| 781 |
+
default=False,
|
| 782 |
+
)
|
| 783 |
+
# Strict versioning: a wired/typed token replaces VHS's automatic
|
| 784 |
+
# _NNNNN counter with a deterministic <prefix>_<token>.<ext> name.
|
| 785 |
+
# Empty -> the counter stays (the casual, non-professional default).
|
| 786 |
+
# The token is resolved below, once the output dir + name root are
|
| 787 |
+
# known (so `auto`/`next` can scan that folder).
|
| 788 |
+
raw_version = _coerce_version_input(kwargs.pop("version", ""))
|
| 789 |
+
metadata_payload = _build_sidecar_workflow(
|
| 790 |
+
kwargs.get("prompt"),
|
| 791 |
+
kwargs.get("extra_pnginfo"),
|
| 792 |
+
datetime.datetime.now().isoformat(" ")[:19],
|
| 793 |
+
)
|
| 794 |
+
|
| 795 |
+
# Inject VHS's hidden extra_options flags so upstream handles
|
| 796 |
+
# metadata PNG and silent-intermediate cleanup according to
|
| 797 |
+
# Koolook's explicit toggles. Copy extra_pnginfo before mutating
|
| 798 |
+
# so other nodes referencing the same dict aren't affected.
|
| 799 |
+
extra_pnginfo = dict(kwargs.get("extra_pnginfo") or {})
|
| 800 |
+
workflow = dict(extra_pnginfo.get("workflow") or {})
|
| 801 |
+
extra = dict(workflow.get("extra") or {})
|
| 802 |
+
extra["VHS_MetadataImage"] = bool(save_metadata_png)
|
| 803 |
+
extra["VHS_KeepIntermediate"] = bool(keep_silent_intermediate)
|
| 804 |
+
workflow["extra"] = extra
|
| 805 |
+
extra_pnginfo["workflow"] = workflow
|
| 806 |
+
kwargs["extra_pnginfo"] = extra_pnginfo
|
| 807 |
+
|
| 808 |
+
# Split-mode: directory + name combined into one effective
|
| 809 |
+
# prefix that the existing isabs discrimination handles
|
| 810 |
+
# uniformly. When output_directory is empty this is a no-op.
|
| 811 |
+
effective_prefix = _compose_prefix(filename_prefix, output_directory)
|
| 812 |
+
# Belt-and-suspenders: drop any sentinel components that
|
| 813 |
+
# slipped past per-field normalization (e.g. a path that
|
| 814 |
+
# already carried `\undefined\` as a literal segment).
|
| 815 |
+
effective_prefix = _strip_sentinel_components(effective_prefix)
|
| 816 |
+
# Resolve the version token now that the output dir + name root are
|
| 817 |
+
# known. `auto`/`next` scans that directory for existing
|
| 818 |
+
# <name>_vNNN outputs and picks the next free one; otherwise the
|
| 819 |
+
# typed/wired token is used verbatim.
|
| 820 |
+
if is_auto_version(raw_version):
|
| 821 |
+
scan_dir, scan_name = _auto_version_scan_target(effective_prefix)
|
| 822 |
+
version_token = next_version_token(
|
| 823 |
+
scan_dir,
|
| 824 |
+
scan_name,
|
| 825 |
+
)
|
| 826 |
+
else:
|
| 827 |
+
version_token = resolve_version_token(raw_version)
|
| 828 |
+
# Strict versioning bakes the token into the filename root; VHS
|
| 829 |
+
# then appends its counter, which _finalize_output strips back off.
|
| 830 |
+
if version_token:
|
| 831 |
+
effective_prefix = _append_version_to_prefix(effective_prefix, version_token)
|
| 832 |
+
if effective_prefix != filename_prefix:
|
| 833 |
+
kwargs["filename_prefix"] = effective_prefix
|
| 834 |
+
filename_prefix = effective_prefix
|
| 835 |
+
|
| 836 |
+
target = _resolve_abs_target(filename_prefix, create_path_if_missing)
|
| 837 |
+
if target is None:
|
| 838 |
+
result = super().combine_video(*args, **kwargs)
|
| 839 |
+
result = _remove_audio_suffix_from_result(
|
| 840 |
+
result,
|
| 841 |
+
keep_silent_intermediate,
|
| 842 |
+
)
|
| 843 |
+
result = _finalize_output(
|
| 844 |
+
result,
|
| 845 |
+
version_token,
|
| 846 |
+
enable_overwrite,
|
| 847 |
+
metadata_payload,
|
| 848 |
+
save_metadata_json,
|
| 849 |
+
save_metadata_png,
|
| 850 |
+
)
|
| 851 |
+
return _append_video_path_outputs(result)
|
| 852 |
+
|
| 853 |
+
abs_dir, abs_base = target
|
| 854 |
+
|
| 855 |
+
# VHS calls folder_paths.get_save_image_path() exactly once
|
| 856 |
+
# in combine_video(). Patch it to return our absolute target
|
| 857 |
+
# so the rest of upstream's pipeline (counter scan, metadata
|
| 858 |
+
# PNG, ffmpeg encode, audio mux, batch manager) writes into
|
| 859 |
+
# abs_dir/abs_base_*. The patch is scoped to one super() call
|
| 860 |
+
# and restored in finally to guarantee no leak on exception.
|
| 861 |
+
#
|
| 862 |
+
# Concurrency note: ComfyUI executes nodes sequentially within
|
| 863 |
+
# a workflow and serializes workflows at the prompt level, so
|
| 864 |
+
# no other node observes the patched function during the call.
|
| 865 |
+
original_get_save_path = folder_paths.get_save_image_path
|
| 866 |
+
|
| 867 |
+
def patched_get_save_path(prefix, output_dir, *_a, **_kw):
|
| 868 |
+
# VHS expects:
|
| 869 |
+
# (full_output_folder, filename, counter_seed, subfolder, prefix)
|
| 870 |
+
# Upstream re-scans full_output_folder for the real
|
| 871 |
+
# counter, so counter_seed (0) is ignored. subfolder=""
|
| 872 |
+
# makes the UI preview tile bypass the relative-to-
|
| 873 |
+
# output-dir link Comfy builds for the /view endpoint.
|
| 874 |
+
return (abs_dir, abs_base, 0, "", abs_base)
|
| 875 |
+
|
| 876 |
+
try:
|
| 877 |
+
folder_paths.get_save_image_path = patched_get_save_path
|
| 878 |
+
result = super().combine_video(*args, **kwargs)
|
| 879 |
+
result = _remove_audio_suffix_from_result(
|
| 880 |
+
result,
|
| 881 |
+
keep_silent_intermediate,
|
| 882 |
+
)
|
| 883 |
+
result = _finalize_output(
|
| 884 |
+
result,
|
| 885 |
+
version_token,
|
| 886 |
+
enable_overwrite,
|
| 887 |
+
metadata_payload,
|
| 888 |
+
save_metadata_json,
|
| 889 |
+
save_metadata_png,
|
| 890 |
+
)
|
| 891 |
+
return _append_video_path_outputs(result)
|
| 892 |
+
finally:
|
| 893 |
+
folder_paths.get_save_image_path = original_get_save_path
|
| 894 |
+
|
| 895 |
+
NODE_CLASS_MAPPINGS = {"Easy_VideoCombine": Easy_VideoCombine}
|
| 896 |
+
NODE_DISPLAY_NAME_MAPPINGS = {"Easy_VideoCombine": "Easy Video Combine (Koolook)"}
|
| 897 |
+
else:
|
| 898 |
+
print(
|
| 899 |
+
f"[Koolook] Easy_VideoCombine skipped: "
|
| 900 |
+
f"ComfyUI-VideoHelperSuite not importable ({_VHS_IMPORT_ERROR}). "
|
| 901 |
+
f"Install VHS from ComfyUI-Manager to enable this node."
|
| 902 |
+
)
|
| 903 |
+
NODE_CLASS_MAPPINGS = {}
|
| 904 |
+
NODE_DISPLAY_NAME_MAPPINGS = {}
|
| 905 |
+
|
| 906 |
+
|
| 907 |
+
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
ComfyUI/custom_nodes/koolook/k_video_load.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
| 2 |
+
#
|
| 3 |
+
# ComfyUI-Koolook - Easy Load Video
|
| 4 |
+
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
|
| 5 |
+
#
|
| 6 |
+
# Runtime composition over Kosinkadink/ComfyUI-VideoHelperSuite (GPL-3.0).
|
| 7 |
+
# No VHS source is copied here. Easy_LoadVideo subclasses VHS_LoadVideoPath
|
| 8 |
+
# and composes a split input_path + video filename into the path string that
|
| 9 |
+
# upstream already knows how to load.
|
| 10 |
+
"""Path-aware Video Helper Suite loader for ComfyUI-Koolook."""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
import folder_paths # ComfyUI core; present at runtime.
|
| 18 |
+
import nodes as _comfy_nodes_module
|
| 19 |
+
|
| 20 |
+
_VHS_LoadVideoPath = _comfy_nodes_module.NODE_CLASS_MAPPINGS.get("VHS_LoadVideoPath")
|
| 21 |
+
if _VHS_LoadVideoPath is None:
|
| 22 |
+
raise RuntimeError(
|
| 23 |
+
"VHS_LoadVideoPath not present in ComfyUI's NODE_CLASS_MAPPINGS "
|
| 24 |
+
"at Koolook load time. Either ComfyUI-VideoHelperSuite isn't "
|
| 25 |
+
"installed, or it loaded after Koolook."
|
| 26 |
+
)
|
| 27 |
+
# VHS LoadVideoPath wraps the shared decoder with path validation that
|
| 28 |
+
# rejects absolute/local handoff paths. Existing local videos can use the
|
| 29 |
+
# same shared decoder directly; fail loudly if a future VHS refactor moves it.
|
| 30 |
+
_VHS_LOAD_VIDEO_FN = _VHS_LoadVideoPath.load_video.__globals__.get("load_video")
|
| 31 |
+
if _VHS_LOAD_VIDEO_FN is None:
|
| 32 |
+
raise RuntimeError("VHS shared load_video function not found.")
|
| 33 |
+
_VHS_AVAILABLE = True
|
| 34 |
+
_VHS_IMPORT_ERROR: Optional[BaseException] = None
|
| 35 |
+
except (ImportError, RuntimeError) as _exc:
|
| 36 |
+
_VHS_AVAILABLE = False
|
| 37 |
+
_VHS_IMPORT_ERROR = _exc
|
| 38 |
+
_VHS_LoadVideoPath = None # type: ignore[assignment]
|
| 39 |
+
_VHS_LOAD_VIDEO_FN = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# Returned by _compose_input_video_path when input_path is an existing folder
|
| 43 |
+
# with no filename -- typically the unselected branch of an upstream mode switch
|
| 44 |
+
# (e.g. an EXR sequence dir handed to the video loader). load_video turns this
|
| 45 |
+
# into an empty, correctly-shaped result instead of crashing the whole prompt.
|
| 46 |
+
EMPTY_BRANCH_SENTINEL = "\x00__koolook_empty_video_branch__"
|
| 47 |
+
|
| 48 |
+
_SENTINEL_STRINGS = ("undefined", "null", "none")
|
| 49 |
+
_VIDEO_EXTENSIONS = {
|
| 50 |
+
".avi",
|
| 51 |
+
".gif",
|
| 52 |
+
".m4v",
|
| 53 |
+
".mkv",
|
| 54 |
+
".mov",
|
| 55 |
+
".mp4",
|
| 56 |
+
".webm",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _normalize_text_input(value) -> str:
|
| 61 |
+
"""Coerce a ComfyUI STRING widget value to a clean Python string."""
|
| 62 |
+
if value is None:
|
| 63 |
+
return ""
|
| 64 |
+
s = str(value).strip().strip('"').strip("'").strip()
|
| 65 |
+
if "\n" in s or "\r" in s:
|
| 66 |
+
for line in s.replace("\r", "\n").split("\n"):
|
| 67 |
+
line = line.strip()
|
| 68 |
+
if line and line.lower() not in _SENTINEL_STRINGS:
|
| 69 |
+
s = line
|
| 70 |
+
break
|
| 71 |
+
else:
|
| 72 |
+
s = ""
|
| 73 |
+
if s.lower() in _SENTINEL_STRINGS:
|
| 74 |
+
return ""
|
| 75 |
+
return s
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _clean_text_lines(value) -> list[str]:
|
| 79 |
+
"""Return non-empty, non-sentinel lines from a connected text widget."""
|
| 80 |
+
if value is None:
|
| 81 |
+
return []
|
| 82 |
+
s = str(value).strip().strip('"').strip("'").strip()
|
| 83 |
+
lines = []
|
| 84 |
+
for line in s.replace("\r", "\n").split("\n"):
|
| 85 |
+
line = line.strip().strip('"').strip("'").strip()
|
| 86 |
+
if line and line.lower() not in _SENTINEL_STRINGS:
|
| 87 |
+
lines.append(line)
|
| 88 |
+
return lines
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _input_root() -> str:
|
| 92 |
+
try:
|
| 93 |
+
return folder_paths.get_input_directory()
|
| 94 |
+
except Exception:
|
| 95 |
+
return os.getcwd()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _basename_any_sep(path: str) -> str:
|
| 99 |
+
return path.rstrip("/\\").replace("\\", "/").rsplit("/", 1)[-1]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _looks_like_video_file(path: str) -> bool:
|
| 103 |
+
return os.path.splitext(_basename_any_sep(path))[1].lower() in _VIDEO_EXTENSIONS
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _strip_outer_quotes(path: str) -> str:
|
| 107 |
+
return path.strip().strip('"').strip("'").strip()
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _is_existing_local_video_path(path: str) -> bool:
|
| 111 |
+
clean = _strip_outer_quotes(path)
|
| 112 |
+
return _looks_like_video_file(clean) and os.path.isfile(clean)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _input_path_is_existing_dir(path: str, input_root: str | None = None) -> bool:
|
| 116 |
+
"""True when ``path`` resolves to a real directory on disk.
|
| 117 |
+
|
| 118 |
+
Absolute dirs are checked directly; relative dirs are rooted under ComfyUI's
|
| 119 |
+
input directory, matching how the loader resolves relative paths elsewhere.
|
| 120 |
+
"""
|
| 121 |
+
candidate = _existing_local_path_candidate(path, input_root)
|
| 122 |
+
return bool(candidate) and os.path.isdir(candidate)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _existing_local_path_candidate(path: str, input_root: str | None = None) -> str:
|
| 126 |
+
clean = _strip_outer_quotes(path)
|
| 127 |
+
if not clean:
|
| 128 |
+
return ""
|
| 129 |
+
if os.path.isabs(clean):
|
| 130 |
+
return os.path.normpath(clean)
|
| 131 |
+
root = input_root if input_root is not None else _input_root()
|
| 132 |
+
return os.path.normpath(os.path.join(root, clean))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _normalize_path_input(value, input_root: str | None = None) -> str:
|
| 136 |
+
"""Normalize a path input while preserving wrapped full paths.
|
| 137 |
+
|
| 138 |
+
Multiline text can mean either "directory + filename" or a single path
|
| 139 |
+
wrapped by a text node. Prefer candidates that exist on disk, then candidates
|
| 140 |
+
that at least look like video files, and otherwise keep the first line.
|
| 141 |
+
"""
|
| 142 |
+
lines = _clean_text_lines(value)
|
| 143 |
+
if not lines:
|
| 144 |
+
return ""
|
| 145 |
+
if len(lines) == 1:
|
| 146 |
+
return lines[0]
|
| 147 |
+
|
| 148 |
+
joined_text = "".join(lines)
|
| 149 |
+
joined_path = os.path.join(lines[0].rstrip("/\\"), *lines[1:])
|
| 150 |
+
candidates = [joined_text, joined_path]
|
| 151 |
+
|
| 152 |
+
for candidate in candidates:
|
| 153 |
+
resolved = _existing_local_path_candidate(candidate, input_root)
|
| 154 |
+
if os.path.isfile(resolved):
|
| 155 |
+
return candidate
|
| 156 |
+
|
| 157 |
+
for candidate in candidates:
|
| 158 |
+
if _looks_like_video_file(candidate):
|
| 159 |
+
return candidate
|
| 160 |
+
|
| 161 |
+
return lines[0]
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _compose_input_video_path(
|
| 165 |
+
video: str,
|
| 166 |
+
input_path: str,
|
| 167 |
+
input_root: str | None = None,
|
| 168 |
+
) -> str:
|
| 169 |
+
"""Resolve split ``input_path`` + ``video`` into one loader path.
|
| 170 |
+
|
| 171 |
+
Empty ``input_path`` is pass-through, so users can still paste a full path
|
| 172 |
+
or URL into ``video`` exactly like upstream VHS_LoadVideoPath.
|
| 173 |
+
|
| 174 |
+
When ``input_path`` is set, ``video`` is treated as a filename field:
|
| 175 |
+
path components are stripped, then the name is joined under the directory.
|
| 176 |
+
Absolute directories are used directly; relative directories are rooted in
|
| 177 |
+
ComfyUI's input directory.
|
| 178 |
+
|
| 179 |
+
If ``input_path`` itself points at a video file and ``video`` is empty,
|
| 180 |
+
that full file path is passed through. This lets
|
| 181 |
+
``Easy_VideoCombine.video_path`` wire directly into ``input_path``. If a
|
| 182 |
+
multiline text node wraps that path, the lines are joined back together
|
| 183 |
+
before validation/loading.
|
| 184 |
+
|
| 185 |
+
When ``input_path`` is empty and ``video`` contains multiple non-empty
|
| 186 |
+
lines, the first line is treated as the directory and the second as the
|
| 187 |
+
filename; later lines are ignored. This matches connected text blocks that
|
| 188 |
+
package a path/name pair for loader handoff.
|
| 189 |
+
"""
|
| 190 |
+
raw_video_lines = _clean_text_lines(video)
|
| 191 |
+
input_path = _normalize_path_input(input_path, input_root)
|
| 192 |
+
if not input_path and len(raw_video_lines) >= 2:
|
| 193 |
+
input_path = raw_video_lines[0]
|
| 194 |
+
video = raw_video_lines[1]
|
| 195 |
+
else:
|
| 196 |
+
video = _normalize_text_input(video)
|
| 197 |
+
if not input_path:
|
| 198 |
+
return video
|
| 199 |
+
|
| 200 |
+
clean_dir = input_path.rstrip("/\\")
|
| 201 |
+
if not video and _looks_like_video_file(clean_dir):
|
| 202 |
+
if os.path.isabs(clean_dir):
|
| 203 |
+
return os.path.normpath(clean_dir)
|
| 204 |
+
root = input_root if input_root is not None else _input_root()
|
| 205 |
+
return os.path.normpath(os.path.join(root, clean_dir))
|
| 206 |
+
|
| 207 |
+
name = _basename_any_sep(video) or video
|
| 208 |
+
if not name:
|
| 209 |
+
# input_path is a directory with no filename. If that folder actually
|
| 210 |
+
# exists it is almost always the unselected branch of an upstream mode
|
| 211 |
+
# switch -- e.g. an EXR sequence dir routed into the video loader. Signal
|
| 212 |
+
# a graceful empty so the loader returns nothing instead of crashing the
|
| 213 |
+
# whole prompt (a lenient sequence loader would just ignore it). A path
|
| 214 |
+
# that does not exist is a genuine mistake and still raises.
|
| 215 |
+
if _input_path_is_existing_dir(clean_dir, input_root):
|
| 216 |
+
return EMPTY_BRANCH_SENTINEL
|
| 217 |
+
raise ValueError("video must include a filename when input_path is set.")
|
| 218 |
+
|
| 219 |
+
if os.path.isabs(clean_dir):
|
| 220 |
+
return os.path.normpath(os.path.join(clean_dir, name))
|
| 221 |
+
|
| 222 |
+
root = input_root if input_root is not None else _input_root()
|
| 223 |
+
return os.path.normpath(os.path.join(root, clean_dir, name))
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _empty_value_for_type(type_name: str):
|
| 227 |
+
"""Build an empty, correctly-typed output value for a VHS return slot.
|
| 228 |
+
|
| 229 |
+
``torch`` is imported lazily so the path-composition logic stays importable
|
| 230 |
+
(and unit-testable) without torch installed; it is always present at runtime
|
| 231 |
+
inside ComfyUI.
|
| 232 |
+
"""
|
| 233 |
+
name = str(type_name)
|
| 234 |
+
if name in ("IMAGE", "MASK", "LATENT"):
|
| 235 |
+
import torch
|
| 236 |
+
|
| 237 |
+
if name == "IMAGE":
|
| 238 |
+
return torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
| 239 |
+
if name == "MASK":
|
| 240 |
+
return torch.zeros((1, 64, 64), dtype=torch.float32)
|
| 241 |
+
return {"samples": torch.zeros((1, 4, 8, 8), dtype=torch.float32)}
|
| 242 |
+
if name == "AUDIO":
|
| 243 |
+
import torch
|
| 244 |
+
|
| 245 |
+
return {"waveform": torch.zeros((1, 2, 1), dtype=torch.float32), "sample_rate": 44100}
|
| 246 |
+
if name == "INT":
|
| 247 |
+
return 0
|
| 248 |
+
if name == "FLOAT":
|
| 249 |
+
return 0.0
|
| 250 |
+
if name == "STRING":
|
| 251 |
+
return ""
|
| 252 |
+
if name == "VHS_VIDEOINFO":
|
| 253 |
+
return {}
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _empty_load_video_result(return_types) -> tuple:
|
| 258 |
+
return tuple(_empty_value_for_type(t) for t in return_types)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
if _VHS_AVAILABLE:
|
| 262 |
+
class Easy_LoadVideo(_VHS_LoadVideoPath):
|
| 263 |
+
"""VHS Load Video Path variant with split directory + filename fields."""
|
| 264 |
+
|
| 265 |
+
CATEGORY = "Koolook/Video"
|
| 266 |
+
DESCRIPTION = "Load Video variant with split input path and filename fields."
|
| 267 |
+
|
| 268 |
+
@classmethod
|
| 269 |
+
def INPUT_TYPES(cls):
|
| 270 |
+
types = _VHS_LoadVideoPath.INPUT_TYPES()
|
| 271 |
+
required = dict(types.get("required", {}))
|
| 272 |
+
required.pop("video", None)
|
| 273 |
+
types["required"] = {
|
| 274 |
+
"input_path": (
|
| 275 |
+
"STRING",
|
| 276 |
+
{
|
| 277 |
+
"default": "",
|
| 278 |
+
"tooltip": (
|
| 279 |
+
"Folder containing the video. Absolute paths load "
|
| 280 |
+
"directly; relative paths start at ComfyUI's input folder."
|
| 281 |
+
),
|
| 282 |
+
},
|
| 283 |
+
),
|
| 284 |
+
"video": (
|
| 285 |
+
"STRING",
|
| 286 |
+
{
|
| 287 |
+
"default": "",
|
| 288 |
+
"tooltip": "Video filename, or a full path/URL when input_path is empty.",
|
| 289 |
+
},
|
| 290 |
+
),
|
| 291 |
+
**required,
|
| 292 |
+
}
|
| 293 |
+
return types
|
| 294 |
+
|
| 295 |
+
def load_video(self, *args, **kwargs):
|
| 296 |
+
composed = _compose_input_video_path(
|
| 297 |
+
kwargs.get("video", ""),
|
| 298 |
+
kwargs.pop("input_path", ""),
|
| 299 |
+
)
|
| 300 |
+
if composed == EMPTY_BRANCH_SENTINEL:
|
| 301 |
+
# Unselected branch (existing folder, no filename): return an
|
| 302 |
+
# empty, correctly-shaped result so the prompt does not crash.
|
| 303 |
+
print(
|
| 304 |
+
"[Easy_LoadVideo] input_path resolved to an existing folder "
|
| 305 |
+
"with no video filename; returning an empty branch result. "
|
| 306 |
+
"If this is the selected video branch, set the video filename."
|
| 307 |
+
)
|
| 308 |
+
return _empty_load_video_result(self.RETURN_TYPES)
|
| 309 |
+
kwargs["video"] = composed
|
| 310 |
+
if _is_existing_local_video_path(kwargs["video"]):
|
| 311 |
+
kwargs["video"] = _strip_outer_quotes(kwargs["video"])
|
| 312 |
+
return _VHS_LOAD_VIDEO_FN(*args, **kwargs)
|
| 313 |
+
return super().load_video(*args, **kwargs)
|
| 314 |
+
|
| 315 |
+
@classmethod
|
| 316 |
+
def IS_CHANGED(cls, video, input_path="", **kwargs):
|
| 317 |
+
resolved = _compose_input_video_path(video, input_path)
|
| 318 |
+
if resolved == EMPTY_BRANCH_SENTINEL:
|
| 319 |
+
return EMPTY_BRANCH_SENTINEL
|
| 320 |
+
return _VHS_LoadVideoPath.IS_CHANGED(resolved, **kwargs)
|
| 321 |
+
|
| 322 |
+
@classmethod
|
| 323 |
+
def VALIDATE_INPUTS(cls, video, input_path="", **kwargs):
|
| 324 |
+
# Linked inputs are unresolved during ComfyUI validation; execution
|
| 325 |
+
# receives the real value after the upstream text node runs.
|
| 326 |
+
if video is None or input_path is None:
|
| 327 |
+
return True
|
| 328 |
+
resolved = _compose_input_video_path(video, input_path)
|
| 329 |
+
# An existing-folder branch validates fine; it degrades to empty at
|
| 330 |
+
# run time rather than blocking the whole prompt.
|
| 331 |
+
if resolved == EMPTY_BRANCH_SENTINEL:
|
| 332 |
+
return True
|
| 333 |
+
if _is_existing_local_video_path(resolved):
|
| 334 |
+
return True
|
| 335 |
+
return _VHS_LoadVideoPath.VALIDATE_INPUTS(resolved)
|
| 336 |
+
|
| 337 |
+
NODE_CLASS_MAPPINGS = {"Easy_LoadVideo": Easy_LoadVideo}
|
| 338 |
+
NODE_DISPLAY_NAME_MAPPINGS = {"Easy_LoadVideo": "Easy Load Video (Koolook)"}
|
| 339 |
+
else:
|
| 340 |
+
print(
|
| 341 |
+
f"[Koolook] Easy_LoadVideo skipped: "
|
| 342 |
+
f"ComfyUI-VideoHelperSuite not importable ({_VHS_IMPORT_ERROR}). "
|
| 343 |
+
f"Install VHS from ComfyUI-Manager to enable this node."
|
| 344 |
+
)
|
| 345 |
+
NODE_CLASS_MAPPINGS = {}
|
| 346 |
+
NODE_DISPLAY_NAME_MAPPINGS = {}
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
ComfyUI/custom_nodes/koolook/koolook_install_guard.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Duplicate-install detection (#162).
|
| 2 |
+
|
| 3 |
+
The Comfy Registry / ComfyUI-Manager install path creates
|
| 4 |
+
``custom_nodes/koolook/`` (derived from ``[project].name`` in our
|
| 5 |
+
``pyproject.toml``), while a ``git clone`` checkout typically lands as
|
| 6 |
+
``custom_nodes/ComfyUI-Koolook/``. A user who has both — common when a
|
| 7 |
+
Manager install gets shadowed by a dev clone — boots ComfyUI with two
|
| 8 |
+
parallel Koolook plugins. Both register the same ``/koolook/presets/*``
|
| 9 |
+
server routes, the same Kforge Labs sidebar tab, and write to the same
|
| 10 |
+
``/userdata/koolook_workflows.json`` file. The late-loaded plugin
|
| 11 |
+
silently overwrites the early loader's state and the user's workflow
|
| 12 |
+
store corrupts invisibly.
|
| 13 |
+
|
| 14 |
+
This module is the detection + resolution layer. It is intentionally
|
| 15 |
+
free of ComfyUI / aiohttp imports so it can be unit-tested with just
|
| 16 |
+
the standard library — see ``tests/test_install_guard.py``.
|
| 17 |
+
|
| 18 |
+
Resolution strategy: pick the alphabetically-first folder name as the
|
| 19 |
+
winner. Deterministic across all installs (so both copies agree on the
|
| 20 |
+
outcome without needing to coordinate at runtime), independent of
|
| 21 |
+
ComfyUI's load order. The non-winning install registers nothing — no
|
| 22 |
+
nodes, no routes, no sidebar — and prints a critical message naming
|
| 23 |
+
both paths so the user can resolve the duplicate manually.
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def detect_duplicate_koolook_installs(here: Path) -> list[Path]:
|
| 31 |
+
"""Return every sibling directory under ``here.parent`` that also
|
| 32 |
+
contains a ``koolook_routes.py`` marker file (and is not ``here``).
|
| 33 |
+
|
| 34 |
+
The marker file is unique to Koolook installs; a sibling Comfy
|
| 35 |
+
custom node that happens to share a folder name won't match. The
|
| 36 |
+
list is sorted by folder name (case-insensitive) so the order is
|
| 37 |
+
stable for log output and for ``pick_winning_install``.
|
| 38 |
+
"""
|
| 39 |
+
parent = here.parent
|
| 40 |
+
siblings: list[Path] = []
|
| 41 |
+
try:
|
| 42 |
+
entries = list(parent.iterdir())
|
| 43 |
+
except OSError:
|
| 44 |
+
# ``custom_nodes/`` unreadable — would also break the rest of
|
| 45 |
+
# ComfyUI. Fall through to "no siblings detected"; the duplicate
|
| 46 |
+
# symptom this guard catches only manifests when sibling
|
| 47 |
+
# iteration works in the first place.
|
| 48 |
+
return siblings
|
| 49 |
+
for entry in entries:
|
| 50 |
+
try:
|
| 51 |
+
if entry == here or not entry.is_dir():
|
| 52 |
+
continue
|
| 53 |
+
if (entry / "koolook_routes.py").is_file():
|
| 54 |
+
siblings.append(entry)
|
| 55 |
+
except OSError:
|
| 56 |
+
# A sibling we can't stat into (restrictive perms, a broken
|
| 57 |
+
# mount, a special dir) is not our concern — skip it rather
|
| 58 |
+
# than let one unreadable neighbour raise PermissionError up
|
| 59 |
+
# into ``__init__.py`` and abort the whole plugin import. A
|
| 60 |
+
# real duplicate always carries a readable ``koolook_routes.py``.
|
| 61 |
+
continue
|
| 62 |
+
siblings.sort(key=lambda p: p.name.lower())
|
| 63 |
+
return siblings
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def read_pyproject_version(install_dir: Path) -> str:
|
| 67 |
+
"""Best-effort extract of the ``version = "..."`` line from a
|
| 68 |
+
sibling's ``pyproject.toml``. Returns ``"?"`` when the file is
|
| 69 |
+
missing or unparseable — the critical log is still useful with
|
| 70 |
+
just the paths.
|
| 71 |
+
|
| 72 |
+
Deliberately a manual scan instead of ``tomllib`` to avoid pulling
|
| 73 |
+
a Python 3.11+ requirement into the install-time guard. The format
|
| 74 |
+
is regular enough that a one-line ``startswith("version")`` parser
|
| 75 |
+
is robust to the few real-world variations (single vs double quotes,
|
| 76 |
+
spaces around ``=``).
|
| 77 |
+
"""
|
| 78 |
+
pyproj = install_dir / "pyproject.toml"
|
| 79 |
+
if not pyproj.is_file():
|
| 80 |
+
return "?"
|
| 81 |
+
try:
|
| 82 |
+
for line in pyproj.read_text(encoding="utf-8").splitlines():
|
| 83 |
+
stripped = line.strip()
|
| 84 |
+
# Match ``version = "..."`` and ``version="..."`` but not
|
| 85 |
+
# ``versioning = "..."`` or other near-collisions.
|
| 86 |
+
if not stripped.startswith("version"):
|
| 87 |
+
continue
|
| 88 |
+
after_keyword = stripped[len("version"):].lstrip()
|
| 89 |
+
if not after_keyword.startswith("="):
|
| 90 |
+
continue
|
| 91 |
+
value = after_keyword[1:].strip()
|
| 92 |
+
# Strip a trailing inline comment (``# ...``) before the
|
| 93 |
+
# quote-stripping in case a pyproject.toml gets clever.
|
| 94 |
+
if " #" in value:
|
| 95 |
+
value = value.split(" #", 1)[0].rstrip()
|
| 96 |
+
return value.strip('"').strip("'")
|
| 97 |
+
except (OSError, ValueError):
|
| 98 |
+
# OSError: unreadable file. ValueError (incl. UnicodeDecodeError):
|
| 99 |
+
# a non-UTF-8 / binary pyproject.toml. Either way the version is
|
| 100 |
+
# simply unknown — degrade to "?", never raise into the import.
|
| 101 |
+
pass
|
| 102 |
+
return "?"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def pick_winning_install(here: Path, siblings: list[Path]) -> Path:
|
| 106 |
+
"""Alphabetical-by-folder-name resolution. Stable across both
|
| 107 |
+
installs so neither needs to coordinate at runtime; the loser
|
| 108 |
+
figures out it's the loser by comparing this result against its
|
| 109 |
+
own ``__file__``."""
|
| 110 |
+
return sorted([here, *siblings], key=lambda p: p.name.lower())[0]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def build_duplicate_report(here: Path, siblings: list[Path]) -> tuple[bool, str]:
|
| 114 |
+
"""Produce the critical-log message for a duplicate-install
|
| 115 |
+
situation. Returns ``(is_winning, message)`` where ``is_winning``
|
| 116 |
+
is ``True`` when ``here`` was the alphabetically-first folder.
|
| 117 |
+
|
| 118 |
+
Pure function — no I/O. Caller decides where to surface the message
|
| 119 |
+
(``print()`` in ``__init__.py``, a logger in a test, etc.).
|
| 120 |
+
"""
|
| 121 |
+
winner = pick_winning_install(here, siblings)
|
| 122 |
+
is_winning = winner == here
|
| 123 |
+
here_version = read_pyproject_version(here)
|
| 124 |
+
sibling_lines = "\n".join(
|
| 125 |
+
f" - {s} (version: {read_pyproject_version(s)})"
|
| 126 |
+
for s in siblings
|
| 127 |
+
)
|
| 128 |
+
header = (
|
| 129 |
+
"[Koolook] CRITICAL: duplicate ComfyUI-Koolook installations detected.\n"
|
| 130 |
+
f" This install: {here} (version: {here_version})\n"
|
| 131 |
+
f" Other install(s):\n{sibling_lines}\n"
|
| 132 |
+
f" Active install (alphabetical winner): {winner}\n"
|
| 133 |
+
" Both copies register the same /koolook/presets/* routes, the same\n"
|
| 134 |
+
" Kforge Labs sidebar tab, and write to the same\n"
|
| 135 |
+
" /userdata/koolook_workflows.json file. Running both silently\n"
|
| 136 |
+
" corrupts your workflow store on every restart. Remove one of\n"
|
| 137 |
+
" the directories above and restart ComfyUI."
|
| 138 |
+
)
|
| 139 |
+
if is_winning:
|
| 140 |
+
return True, header
|
| 141 |
+
return False, (
|
| 142 |
+
header + "\n"
|
| 143 |
+
f"[Koolook] this install ({here.name}) is the non-winning duplicate; "
|
| 144 |
+
"skipping node + route registration. Only the winning install above "
|
| 145 |
+
"will serve Kforge Labs this session."
|
| 146 |
+
)
|
ComfyUI/custom_nodes/koolook/koolook_routes.py
ADDED
|
@@ -0,0 +1,1161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP routes for the Kforge Labs preset library.
|
| 2 |
+
|
| 3 |
+
Exposes ``/koolook/presets/*`` endpoints over ComfyUI's aiohttp server. The
|
| 4 |
+
preset directory is configurable via the ``KFORGELABS_PRESETS`` env var; if
|
| 5 |
+
unset, falls back to ``<comfyui-userdata>/koolook-presets/`` so users who
|
| 6 |
+
don't care about cross-machine sharing get a sensible default.
|
| 7 |
+
|
| 8 |
+
Endpoints:
|
| 9 |
+
GET /koolook/presets/info
|
| 10 |
+
GET /koolook/presets/list
|
| 11 |
+
GET /koolook/presets/file?name=<name>
|
| 12 |
+
POST /koolook/presets/file?name=<name>
|
| 13 |
+
DELETE /koolook/presets/file?name=<name>
|
| 14 |
+
|
| 15 |
+
Path-traversal protection: filenames are restricted to a whitelist
|
| 16 |
+
``^[A-Za-z0-9 _.()\\-]+\\.json$``. Anything else returns 400. We enforce at
|
| 17 |
+
the server boundary so the client cannot smuggle ``../`` or absolute paths
|
| 18 |
+
even if the JS layer is compromised.
|
| 19 |
+
|
| 20 |
+
The configured directory is auto-created on first save (parent must already
|
| 21 |
+
exist — for facility shares mounted via NFS/SMB the mount point itself
|
| 22 |
+
must be present before saves succeed).
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
import re
|
| 29 |
+
import shutil
|
| 30 |
+
import string
|
| 31 |
+
import subprocess
|
| 32 |
+
import sys
|
| 33 |
+
import tempfile
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
from aiohttp import web
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from .koolook_setups import (
|
| 40 |
+
DEFAULT_SETUPS_FILENAME,
|
| 41 |
+
DEFAULT_SETUPS_SUBDIR,
|
| 42 |
+
FileSetupStorage,
|
| 43 |
+
PublishedSetupRegistry,
|
| 44 |
+
SAMPLE_SETUPS_PATH,
|
| 45 |
+
default_storage_path,
|
| 46 |
+
)
|
| 47 |
+
from .koolook_setup_runner import (
|
| 48 |
+
AiohttpComfyClient,
|
| 49 |
+
InMemorySetupRunStore,
|
| 50 |
+
PublishedSetupRunner,
|
| 51 |
+
SetupRunError,
|
| 52 |
+
)
|
| 53 |
+
except ImportError: # pragma: no cover - standalone test/import context
|
| 54 |
+
from koolook_setups import (
|
| 55 |
+
DEFAULT_SETUPS_FILENAME,
|
| 56 |
+
DEFAULT_SETUPS_SUBDIR,
|
| 57 |
+
FileSetupStorage,
|
| 58 |
+
PublishedSetupRegistry,
|
| 59 |
+
SAMPLE_SETUPS_PATH,
|
| 60 |
+
default_storage_path,
|
| 61 |
+
)
|
| 62 |
+
from koolook_setup_runner import (
|
| 63 |
+
AiohttpComfyClient,
|
| 64 |
+
InMemorySetupRunStore,
|
| 65 |
+
PublishedSetupRunner,
|
| 66 |
+
SetupRunError,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
ENV_VAR = "KFORGELABS_PRESETS"
|
| 70 |
+
DEFAULT_SUBDIR = "koolook-presets"
|
| 71 |
+
SETTINGS_FILENAME = "koolook-settings.json"
|
| 72 |
+
SETTINGS_KEY_LIBRARY_PATH = "libraryPath"
|
| 73 |
+
|
| 74 |
+
# Single-segment filename, ending in `.json`. No path separators, no `..`,
|
| 75 |
+
# no leading dot. Allows letters, digits, spaces, and a small set of safe
|
| 76 |
+
# punctuation (underscore, period, parentheses, hyphen).
|
| 77 |
+
_FILENAME_RE = re.compile(r"^[A-Za-z0-9 _.()\-]+\.json$")
|
| 78 |
+
|
| 79 |
+
# Same charset as filenames but without the `.json` extension — for the
|
| 80 |
+
# optional `dir` query param that scopes preset operations into a per-preset
|
| 81 |
+
# auto-save subfolder (`<preset>_autosave/`). Single segment only; no path
|
| 82 |
+
# separators (otherwise the symlink-escape check would have to scan multiple
|
| 83 |
+
# levels). The library structure is deliberately one level deep.
|
| 84 |
+
_DIRNAME_RE = re.compile(r"^[A-Za-z0-9 _.()\-]+$")
|
| 85 |
+
|
| 86 |
+
# Filenames the list endpoint should hide from the user-facing Load list.
|
| 87 |
+
# Currently used to suppress legacy flat-file autosaves left over from the
|
| 88 |
+
# previous autosave layout, in case a user has already accumulated some
|
| 89 |
+
# before upgrading to the per-preset-subfolder layout. Cheap defensive
|
| 90 |
+
# filter; can be removed in a future major if we choose to migrate.
|
| 91 |
+
_HIDDEN_LIST_PREFIXES = ("_autosave_",)
|
| 92 |
+
_WEB_DIR = Path(__file__).resolve().parent / "web"
|
| 93 |
+
_REPO_ROOT = Path(__file__).resolve().parent
|
| 94 |
+
_GITHUB_RELEASES_URL = "https://github.com/malkuthro/ComfyUI-Koolook/releases"
|
| 95 |
+
_GITHUB_LATEST_RELEASE_API_URL = (
|
| 96 |
+
"https://api.github.com/repos/malkuthro/ComfyUI-Koolook/releases/latest"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _read_pack_version() -> str:
|
| 101 |
+
"""Read the installed Koolook pack version from ``pyproject.toml``.
|
| 102 |
+
|
| 103 |
+
Kept local and dependency-free so the ComfyUI runtime does not need package
|
| 104 |
+
metadata installed. Returns ``"unknown"`` if the file is unavailable or
|
| 105 |
+
malformed; the frontend treats that as "do not show update notice".
|
| 106 |
+
"""
|
| 107 |
+
try:
|
| 108 |
+
text = (_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
| 109 |
+
except OSError:
|
| 110 |
+
return "unknown"
|
| 111 |
+
match = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)["\']', text)
|
| 112 |
+
return match.group(1).strip() if match else "unknown"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _latest_autosave_mtime(autosave_dir: Path, named_mtime: float) -> float | None:
|
| 116 |
+
"""Returns the newest recovery-file mtime inside ``autosave_dir`` if
|
| 117 |
+
strictly newer than ``named_mtime``, else ``None``.
|
| 118 |
+
|
| 119 |
+
Scans ``periodic.json`` and every ``pre_load_*.json``. Powers the Load
|
| 120 |
+
dialog's "newer auto-save available" affordance — the dialog needs to
|
| 121 |
+
surface the freshest recovery file regardless of which mechanism wrote
|
| 122 |
+
it (the periodic timer vs. an explicit pre-load capture).
|
| 123 |
+
"""
|
| 124 |
+
newest: float | None = None
|
| 125 |
+
try:
|
| 126 |
+
st = (autosave_dir / "periodic.json").stat()
|
| 127 |
+
newest = st.st_mtime
|
| 128 |
+
except OSError:
|
| 129 |
+
pass
|
| 130 |
+
try:
|
| 131 |
+
for entry in autosave_dir.iterdir():
|
| 132 |
+
if not entry.is_file():
|
| 133 |
+
continue
|
| 134 |
+
if not entry.name.startswith("pre_load_") or not entry.name.endswith(".json"):
|
| 135 |
+
continue
|
| 136 |
+
try:
|
| 137 |
+
pst = entry.stat()
|
| 138 |
+
except OSError:
|
| 139 |
+
continue
|
| 140 |
+
if newest is None or pst.st_mtime > newest:
|
| 141 |
+
newest = pst.st_mtime
|
| 142 |
+
except OSError:
|
| 143 |
+
pass
|
| 144 |
+
if newest is not None and newest > named_mtime:
|
| 145 |
+
return newest
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _resolve_default_dir() -> Path:
|
| 150 |
+
"""Resolve the default preset directory under ComfyUI's user folder.
|
| 151 |
+
|
| 152 |
+
Uses ``folder_paths.get_user_directory()`` (provided by ComfyUI core)
|
| 153 |
+
so we don't hardcode the userdata layout — a custom-installed Comfy
|
| 154 |
+
with a non-standard user dir still gets a sensible default.
|
| 155 |
+
"""
|
| 156 |
+
try:
|
| 157 |
+
import folder_paths
|
| 158 |
+
|
| 159 |
+
user_dir = folder_paths.get_user_directory()
|
| 160 |
+
return Path(user_dir) / DEFAULT_SUBDIR
|
| 161 |
+
except Exception: # pragma: no cover - fallback for unusual setups
|
| 162 |
+
return Path.cwd() / "user" / "default" / DEFAULT_SUBDIR
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _settings_file_path() -> Path:
|
| 166 |
+
"""Returns the path to ``koolook-settings.json`` under ComfyUI's user dir.
|
| 167 |
+
|
| 168 |
+
The settings file is per-install (so each ComfyUI on a workstation has
|
| 169 |
+
its own preferred library path). Stored as JSON with shape:
|
| 170 |
+
{ "libraryPath": "<absolute-path-or-empty>" }
|
| 171 |
+
"""
|
| 172 |
+
try:
|
| 173 |
+
import folder_paths
|
| 174 |
+
|
| 175 |
+
return Path(folder_paths.get_user_directory()) / SETTINGS_FILENAME
|
| 176 |
+
except Exception: # pragma: no cover
|
| 177 |
+
return Path.cwd() / "user" / "default" / SETTINGS_FILENAME
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _read_settings() -> dict:
|
| 181 |
+
"""Read the settings JSON. Returns ``{}`` if missing or unreadable.
|
| 182 |
+
|
| 183 |
+
Soft-failure on read: the in-UI Settings panel can still rewrite a
|
| 184 |
+
fresh file even if the existing one is corrupt, and the rest of the
|
| 185 |
+
plugin continues to function (just falls through to env var / default).
|
| 186 |
+
"""
|
| 187 |
+
path = _settings_file_path()
|
| 188 |
+
try:
|
| 189 |
+
if not path.is_file():
|
| 190 |
+
return {}
|
| 191 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 192 |
+
except (OSError, ValueError):
|
| 193 |
+
return {}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _write_settings(data: dict) -> None:
|
| 197 |
+
"""Persist the settings JSON atomically.
|
| 198 |
+
|
| 199 |
+
Writes to ``<settings>.tmp`` then ``os.replace`` to the final name —
|
| 200 |
+
so an interrupted process or concurrent write can't leave a truncated
|
| 201 |
+
file that ``_read_settings`` would silently revert to ``{}`` on next
|
| 202 |
+
load (silently dropping the user's saved ``libraryPath``).
|
| 203 |
+
|
| 204 |
+
Raises ``web.HTTPInternalServerError`` on failure.
|
| 205 |
+
"""
|
| 206 |
+
path = _settings_file_path()
|
| 207 |
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
| 208 |
+
try:
|
| 209 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 210 |
+
tmp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
| 211 |
+
os.replace(str(tmp_path), str(path))
|
| 212 |
+
except OSError as exc:
|
| 213 |
+
# Best-effort cleanup of the tmp file so it doesn't accumulate.
|
| 214 |
+
try:
|
| 215 |
+
if tmp_path.exists():
|
| 216 |
+
tmp_path.unlink()
|
| 217 |
+
except OSError:
|
| 218 |
+
pass
|
| 219 |
+
raise web.HTTPInternalServerError(
|
| 220 |
+
reason=f"Could not write settings file: {exc}"
|
| 221 |
+
) from exc
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _configured_dir() -> tuple[Path, str]:
|
| 225 |
+
"""Return ``(dir_path, source)`` where source is one of
|
| 226 |
+
``"settings"``, ``"env"``, ``"default"``.
|
| 227 |
+
|
| 228 |
+
Resolution order (highest priority first):
|
| 229 |
+
1. ``libraryPath`` field in the settings file (set via Settings panel)
|
| 230 |
+
2. ``KFORGELABS_PRESETS`` env var (deployment / facility config)
|
| 231 |
+
3. Built-in default ``<comfyui-userdata>/koolook-presets/``
|
| 232 |
+
|
| 233 |
+
Reads each source on every call rather than caching at module-load,
|
| 234 |
+
so a Settings panel save or a relauncher with a new env value picks
|
| 235 |
+
up immediately.
|
| 236 |
+
"""
|
| 237 |
+
settings = _read_settings()
|
| 238 |
+
settings_path = settings.get(SETTINGS_KEY_LIBRARY_PATH, "")
|
| 239 |
+
if isinstance(settings_path, str) and settings_path.strip():
|
| 240 |
+
return Path(settings_path.strip()).expanduser(), "settings"
|
| 241 |
+
env = os.environ.get(ENV_VAR, "").strip()
|
| 242 |
+
if env:
|
| 243 |
+
return Path(env).expanduser(), "env"
|
| 244 |
+
return _resolve_default_dir(), "default"
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _published_setups_path() -> Path:
|
| 248 |
+
"""Storage path for published setups: a sibling of the configured snapshot
|
| 249 |
+
library, so they follow the same ``libraryPath`` / ``KFORGELABS_PRESETS``
|
| 250 |
+
location instead of a fixed user-dir folder (issue #227)."""
|
| 251 |
+
base, _ = _configured_dir()
|
| 252 |
+
return base.parent / DEFAULT_SETUPS_SUBDIR / DEFAULT_SETUPS_FILENAME
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _migrate_legacy_published_setups(legacy: Path, target: Path) -> None:
|
| 256 |
+
"""One-time, non-destructive copy of a pre-relocation registry from the old
|
| 257 |
+
fixed user-dir path (``legacy``) to ``target`` when ``target`` has none
|
| 258 |
+
yet. Leaves ``legacy`` in place; logs and swallows copy failures so a
|
| 259 |
+
permission hiccup can't take down setup serving.
|
| 260 |
+
|
| 261 |
+
The copy goes through a temp file + atomic ``os.replace`` (same pattern as
|
| 262 |
+
``FileSetupStorage.save_setups``) so an interrupted run can never leave a
|
| 263 |
+
partial ``setups.json`` at ``target`` — a partial file would otherwise
|
| 264 |
+
shadow the intact legacy registry on every later boot."""
|
| 265 |
+
if target == legacy or target.exists() or not legacy.is_file():
|
| 266 |
+
return
|
| 267 |
+
tmp_name = None
|
| 268 |
+
try:
|
| 269 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 270 |
+
tmp_fd, tmp_name = tempfile.mkstemp(
|
| 271 |
+
prefix=f"{target.stem}.",
|
| 272 |
+
suffix=".tmp",
|
| 273 |
+
dir=str(target.parent),
|
| 274 |
+
)
|
| 275 |
+
os.close(tmp_fd)
|
| 276 |
+
shutil.copy2(legacy, tmp_name)
|
| 277 |
+
os.replace(tmp_name, target)
|
| 278 |
+
tmp_name = None
|
| 279 |
+
print(f"[Koolook] migrated published setups: {legacy} -> {target}")
|
| 280 |
+
except OSError as exc:
|
| 281 |
+
print(f"[Koolook] could not migrate published setups from {legacy}: {exc}")
|
| 282 |
+
finally:
|
| 283 |
+
if tmp_name and os.path.exists(tmp_name):
|
| 284 |
+
try:
|
| 285 |
+
os.unlink(tmp_name)
|
| 286 |
+
except OSError:
|
| 287 |
+
pass
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _valid_setups_registry_file(path: Path) -> bool:
|
| 291 |
+
"""True when ``path`` parses as a published-setups document (a bare list or
|
| 292 |
+
a ``{"setups": [...]}`` mapping — the shapes ``FileSetupStorage`` accepts).
|
| 293 |
+
Used to decide whether a relocated registry file is trustworthy: existence
|
| 294 |
+
alone is not proof a migration completed."""
|
| 295 |
+
try:
|
| 296 |
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
| 297 |
+
except (OSError, ValueError):
|
| 298 |
+
return False
|
| 299 |
+
if isinstance(raw, dict):
|
| 300 |
+
raw = raw.get("setups", [])
|
| 301 |
+
return isinstance(raw, list)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _default_published_setup_registry() -> PublishedSetupRegistry:
|
| 305 |
+
"""Default registry factory — stores beside the snapshot library and
|
| 306 |
+
migrates any legacy user-dir registry on first use.
|
| 307 |
+
|
| 308 |
+
Primary selection is corruption-aware: if migration could not produce the
|
| 309 |
+
new file, or the file at the new path exists but does not parse while the
|
| 310 |
+
legacy file is intact, keep reading the legacy file in place — neither the
|
| 311 |
+
bundled-sample fallback nor an empty catalog may mask real, stranded
|
| 312 |
+
setups. The unreadable file is left untouched as evidence."""
|
| 313 |
+
target = _published_setups_path()
|
| 314 |
+
legacy = default_storage_path()
|
| 315 |
+
_migrate_legacy_published_setups(legacy, target)
|
| 316 |
+
primary = target
|
| 317 |
+
if target != legacy and legacy.is_file():
|
| 318 |
+
if not target.exists():
|
| 319 |
+
primary = legacy
|
| 320 |
+
elif not _valid_setups_registry_file(target) and _valid_setups_registry_file(legacy):
|
| 321 |
+
print(
|
| 322 |
+
f"[Koolook] relocated published-setups file at {target} is "
|
| 323 |
+
f"unreadable; serving intact legacy registry {legacy} instead"
|
| 324 |
+
)
|
| 325 |
+
primary = legacy
|
| 326 |
+
return PublishedSetupRegistry(
|
| 327 |
+
FileSetupStorage(primary, fallback_path=SAMPLE_SETUPS_PATH)
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def _browse_roots() -> list[Path]:
|
| 332 |
+
"""Return filesystem roots the in-app browser can start from."""
|
| 333 |
+
if os.name == "nt":
|
| 334 |
+
roots = []
|
| 335 |
+
for letter in string.ascii_uppercase:
|
| 336 |
+
root = Path(f"{letter}:\\")
|
| 337 |
+
if root.exists():
|
| 338 |
+
roots.append(root)
|
| 339 |
+
return roots
|
| 340 |
+
return [Path("/")]
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _browse_start_path(raw_path: str) -> Path:
|
| 344 |
+
"""Resolve the requested browse path or fall back to the library dir."""
|
| 345 |
+
if raw_path:
|
| 346 |
+
return Path(raw_path).expanduser()
|
| 347 |
+
|
| 348 |
+
base, _ = _configured_dir()
|
| 349 |
+
if base.exists():
|
| 350 |
+
return base
|
| 351 |
+
if base.parent.exists():
|
| 352 |
+
return base.parent
|
| 353 |
+
try:
|
| 354 |
+
return Path.home()
|
| 355 |
+
except RuntimeError: # pragma: no cover - unusual host config
|
| 356 |
+
return Path.cwd()
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _list_child_dirs(path: Path) -> list[dict]:
|
| 360 |
+
out = []
|
| 361 |
+
for entry in path.iterdir():
|
| 362 |
+
try:
|
| 363 |
+
if not entry.is_dir():
|
| 364 |
+
continue
|
| 365 |
+
if entry.name == "_unsaved_autosave" or entry.name.endswith("_autosave"):
|
| 366 |
+
continue
|
| 367 |
+
out.append({"name": entry.name, "path": str(entry)})
|
| 368 |
+
except OSError:
|
| 369 |
+
continue
|
| 370 |
+
out.sort(key=lambda r: r["name"].lower())
|
| 371 |
+
return out
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def _list_child_files(path: Path) -> list[dict]:
|
| 375 |
+
"""List child *.json files in ``path``.
|
| 376 |
+
|
| 377 |
+
Powers the folder picker's "yes, this is the folder I expected"
|
| 378 |
+
affordance (mockup section 6) — files are rendered greyed so the user
|
| 379 |
+
can visually confirm the listing without selecting one. Restricted to
|
| 380 |
+
JSON because the picker is exclusively used to choose a snapshot
|
| 381 |
+
library folder; surfacing non-snapshot files would be misleading
|
| 382 |
+
(they cannot become preset library content).
|
| 383 |
+
"""
|
| 384 |
+
out = []
|
| 385 |
+
for entry in path.iterdir():
|
| 386 |
+
try:
|
| 387 |
+
if not entry.is_file():
|
| 388 |
+
continue
|
| 389 |
+
if not entry.name.lower().endswith(".json"):
|
| 390 |
+
continue
|
| 391 |
+
except OSError:
|
| 392 |
+
continue
|
| 393 |
+
out.append({"name": entry.name})
|
| 394 |
+
out.sort(key=lambda r: r["name"].lower())
|
| 395 |
+
return out
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _validate_new_dir_name(name: object) -> str:
|
| 399 |
+
"""Validate a user-created folder name for the browse picker."""
|
| 400 |
+
if not isinstance(name, str):
|
| 401 |
+
raise web.HTTPBadRequest(reason="Folder name must be a string.")
|
| 402 |
+
cleaned = name.strip()
|
| 403 |
+
if (
|
| 404 |
+
not cleaned
|
| 405 |
+
or cleaned in {".", ".."}
|
| 406 |
+
or "/" in cleaned
|
| 407 |
+
or "\\" in cleaned
|
| 408 |
+
or "\x00" in cleaned
|
| 409 |
+
):
|
| 410 |
+
raise web.HTTPBadRequest(reason="Folder name must be a single folder segment.")
|
| 411 |
+
return cleaned
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _validate_filename(name: object) -> str:
|
| 415 |
+
"""Whitelist the filename or raise 400.
|
| 416 |
+
|
| 417 |
+
Returning the validated string is convenient for direct
|
| 418 |
+
``base / _validate_filename(...)`` use at call sites.
|
| 419 |
+
"""
|
| 420 |
+
if not isinstance(name, str) or not _FILENAME_RE.match(name):
|
| 421 |
+
raise web.HTTPBadRequest(
|
| 422 |
+
reason=(
|
| 423 |
+
"Invalid filename. Allowed characters: letters, digits, "
|
| 424 |
+
"space, underscore, period, parentheses, hyphen. Must end "
|
| 425 |
+
"in .json."
|
| 426 |
+
)
|
| 427 |
+
)
|
| 428 |
+
return name
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def _validate_dirname(name: str) -> str:
|
| 432 |
+
"""Whitelist a single-segment subfolder name or raise 400.
|
| 433 |
+
|
| 434 |
+
Same charset as filenames but without the `.json` extension. Used by
|
| 435 |
+
the autosave subfolder routing — a request with ``?dir=<x>`` lands its
|
| 436 |
+
file inside ``<library>/<x>/`` instead of the library root.
|
| 437 |
+
"""
|
| 438 |
+
if not isinstance(name, str) or not _DIRNAME_RE.match(name):
|
| 439 |
+
raise web.HTTPBadRequest(
|
| 440 |
+
reason=(
|
| 441 |
+
"Invalid dir. Allowed characters: letters, digits, space, "
|
| 442 |
+
"underscore, period, parentheses, hyphen. No path "
|
| 443 |
+
"separators, no extension."
|
| 444 |
+
)
|
| 445 |
+
)
|
| 446 |
+
return name
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def _resolve_within_base(base: Path, name: str) -> Path:
|
| 450 |
+
"""Compute ``base / name`` and verify the resolved path stays under
|
| 451 |
+
``base`` after symlinks are followed. Defense-in-depth against a
|
| 452 |
+
symlink planted inside the configured library dir (plausible on a
|
| 453 |
+
facility shared mount with weak permissions) redirecting reads/writes
|
| 454 |
+
off-share.
|
| 455 |
+
|
| 456 |
+
Raises ``web.HTTPBadRequest`` on any escape (broken or hostile symlink).
|
| 457 |
+
Returns the joined path on success — note this is the un-resolved path
|
| 458 |
+
so the caller can still see the intended filename in error messages,
|
| 459 |
+
but the ``is_relative_to`` check has already been applied to the
|
| 460 |
+
resolved form.
|
| 461 |
+
"""
|
| 462 |
+
file_path = base / name
|
| 463 |
+
try:
|
| 464 |
+
resolved_file = file_path.resolve(strict=False)
|
| 465 |
+
resolved_base = base.resolve(strict=False)
|
| 466 |
+
except OSError as exc:
|
| 467 |
+
raise web.HTTPInternalServerError(
|
| 468 |
+
reason=f"Could not resolve preset path: {exc}"
|
| 469 |
+
) from exc
|
| 470 |
+
try:
|
| 471 |
+
resolved_file.relative_to(resolved_base)
|
| 472 |
+
except ValueError as exc:
|
| 473 |
+
raise web.HTTPBadRequest(
|
| 474 |
+
reason=(
|
| 475 |
+
"Preset path escapes the library directory (likely a symlink "
|
| 476 |
+
"planted in the library). Refusing to proceed."
|
| 477 |
+
)
|
| 478 |
+
) from exc
|
| 479 |
+
return file_path
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def _resolve_target(lib_base: Path, subdir: str, name: str) -> tuple[Path, Path]:
|
| 483 |
+
"""Resolve ``lib_base[/subdir]/name`` for a preset operation, with the
|
| 484 |
+
symlink-escape check grounded at ``lib_base`` (NOT at the subdir) so a
|
| 485 |
+
hostile symlink inside an auto-save subfolder can't redirect writes
|
| 486 |
+
outside the library.
|
| 487 |
+
|
| 488 |
+
Returns ``(target_dir, file_path)`` — the caller may need ``target_dir``
|
| 489 |
+
to mkdir before writing. ``file_path`` is the un-resolved join, same as
|
| 490 |
+
``_resolve_within_base``.
|
| 491 |
+
|
| 492 |
+
Raises ``web.HTTPBadRequest`` on escape, ``web.HTTPInternalServerError``
|
| 493 |
+
on resolution failure.
|
| 494 |
+
"""
|
| 495 |
+
if subdir:
|
| 496 |
+
target_dir = lib_base / subdir
|
| 497 |
+
else:
|
| 498 |
+
target_dir = lib_base
|
| 499 |
+
file_path = target_dir / name
|
| 500 |
+
try:
|
| 501 |
+
resolved_file = file_path.resolve(strict=False)
|
| 502 |
+
resolved_lib = lib_base.resolve(strict=False)
|
| 503 |
+
except OSError as exc:
|
| 504 |
+
raise web.HTTPInternalServerError(
|
| 505 |
+
reason=f"Could not resolve preset path: {exc}"
|
| 506 |
+
) from exc
|
| 507 |
+
try:
|
| 508 |
+
resolved_file.relative_to(resolved_lib)
|
| 509 |
+
except ValueError as exc:
|
| 510 |
+
raise web.HTTPBadRequest(
|
| 511 |
+
reason=(
|
| 512 |
+
"Preset path escapes the library directory (likely a symlink "
|
| 513 |
+
"planted in the library or subfolder). Refusing to proceed."
|
| 514 |
+
)
|
| 515 |
+
) from exc
|
| 516 |
+
return target_dir, file_path
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def _open_dir_in_file_manager(target_dir: Path) -> None:
|
| 520 |
+
"""Open ``target_dir`` in the OS file manager. Shared by the snapshot
|
| 521 |
+
and published-setup reveal routes. Raises ``web.HTTPNotFound`` when the
|
| 522 |
+
directory is absent and ``web.HTTPInternalServerError`` if the launcher
|
| 523 |
+
cannot start. Subprocess args are passed list-form (no shell), so a
|
| 524 |
+
controlled path can't trigger shell-metacharacter interpretation.
|
| 525 |
+
"""
|
| 526 |
+
if not target_dir.exists() or not target_dir.is_dir():
|
| 527 |
+
raise web.HTTPNotFound(reason=f"Path does not exist on disk: {target_dir}")
|
| 528 |
+
try:
|
| 529 |
+
if sys.platform == "darwin":
|
| 530 |
+
subprocess.Popen(["open", str(target_dir)])
|
| 531 |
+
elif sys.platform == "win32":
|
| 532 |
+
subprocess.Popen(["explorer.exe", str(target_dir)])
|
| 533 |
+
else:
|
| 534 |
+
subprocess.Popen(["xdg-open", str(target_dir)])
|
| 535 |
+
except OSError as exc:
|
| 536 |
+
raise web.HTTPInternalServerError(
|
| 537 |
+
reason=f"Could not open path in file manager: {exc}"
|
| 538 |
+
) from exc
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def register_routes(routes, setup_registry_factory=None, setup_runner_factory=None) -> None:
|
| 542 |
+
"""Attach the preset endpoints to the given aiohttp ``RouteTableDef``.
|
| 543 |
+
|
| 544 |
+
Called once from ``__init__.py`` at custom-node load time. Splitting
|
| 545 |
+
registration from the route handlers keeps this file unit-testable
|
| 546 |
+
without an aiohttp app fixture.
|
| 547 |
+
"""
|
| 548 |
+
if setup_registry_factory is None:
|
| 549 |
+
setup_registry_factory = _default_published_setup_registry
|
| 550 |
+
run_store = InMemorySetupRunStore()
|
| 551 |
+
|
| 552 |
+
def _default_setup_runner(request, registry: PublishedSetupRegistry) -> PublishedSetupRunner:
|
| 553 |
+
base_url = f"{request.scheme}://{request.host}"
|
| 554 |
+
return PublishedSetupRunner(registry, AiohttpComfyClient(base_url), run_store)
|
| 555 |
+
|
| 556 |
+
def _setup_runner(request, registry: PublishedSetupRegistry):
|
| 557 |
+
if setup_runner_factory is not None:
|
| 558 |
+
return setup_runner_factory()
|
| 559 |
+
return _default_setup_runner(request, registry)
|
| 560 |
+
|
| 561 |
+
def _log_setup_diagnostics(registry: PublishedSetupRegistry) -> None:
|
| 562 |
+
for diagnostic in registry.diagnostics:
|
| 563 |
+
print(f"[Koolook] published setup skipped: {diagnostic}")
|
| 564 |
+
|
| 565 |
+
def _web_asset_response(filename: str, content_type: str):
|
| 566 |
+
path = _WEB_DIR / filename
|
| 567 |
+
try:
|
| 568 |
+
return web.Response(
|
| 569 |
+
text=path.read_text(encoding="utf-8"),
|
| 570 |
+
content_type=content_type,
|
| 571 |
+
# These assets are dev-synced in place; without no-store the
|
| 572 |
+
# browser caches them and keeps running stale JS/HTML after an
|
| 573 |
+
# update, which looks like "the fix didn't land". Always revalidate.
|
| 574 |
+
headers={"Cache-Control": "no-store"},
|
| 575 |
+
)
|
| 576 |
+
except OSError as exc:
|
| 577 |
+
raise web.HTTPNotFound(reason=f"Koolook web asset '{filename}' not found.") from exc
|
| 578 |
+
|
| 579 |
+
@routes.get("/koolook/setup_runner_simulator.html")
|
| 580 |
+
async def setup_runner_simulator_html(_request):
|
| 581 |
+
return _web_asset_response("setup_runner_simulator.html", "text/html")
|
| 582 |
+
|
| 583 |
+
@routes.get("/koolook/setup_runner_simulator.js")
|
| 584 |
+
async def setup_runner_simulator_js(_request):
|
| 585 |
+
return _web_asset_response("setup_runner_simulator.js", "application/javascript")
|
| 586 |
+
|
| 587 |
+
@routes.get("/koolook/api/version")
|
| 588 |
+
async def get_koolook_version(_request):
|
| 589 |
+
return web.json_response(
|
| 590 |
+
{
|
| 591 |
+
"version": _read_pack_version(),
|
| 592 |
+
"releasesUrl": _GITHUB_RELEASES_URL,
|
| 593 |
+
"latestReleaseApiUrl": _GITHUB_LATEST_RELEASE_API_URL,
|
| 594 |
+
}
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
@routes.get("/koolook/api/setups")
|
| 598 |
+
async def list_published_setups(_request):
|
| 599 |
+
registry: PublishedSetupRegistry = setup_registry_factory()
|
| 600 |
+
rows = registry.listSetups()
|
| 601 |
+
_log_setup_diagnostics(registry)
|
| 602 |
+
return web.json_response(rows)
|
| 603 |
+
|
| 604 |
+
@routes.get("/koolook/api/setups/{setup_id}")
|
| 605 |
+
async def get_published_setup(request):
|
| 606 |
+
registry: PublishedSetupRegistry = setup_registry_factory()
|
| 607 |
+
setup_id = request.match_info["setup_id"]
|
| 608 |
+
setup = registry.getSetup(setup_id)
|
| 609 |
+
_log_setup_diagnostics(registry)
|
| 610 |
+
if setup is None:
|
| 611 |
+
raise web.HTTPNotFound(reason=f"Published setup '{setup_id}' not found.")
|
| 612 |
+
return web.json_response(setup)
|
| 613 |
+
|
| 614 |
+
@routes.post("/koolook/api/setups")
|
| 615 |
+
async def publish_setup(request):
|
| 616 |
+
try:
|
| 617 |
+
payload = await request.json()
|
| 618 |
+
except Exception as exc:
|
| 619 |
+
return web.json_response(
|
| 620 |
+
{"ok": False, "errors": [f"Body must be JSON: {exc}"]},
|
| 621 |
+
status=400,
|
| 622 |
+
)
|
| 623 |
+
if not isinstance(payload, dict):
|
| 624 |
+
return web.json_response(
|
| 625 |
+
{"ok": False, "errors": ["Body must be a JSON object."]},
|
| 626 |
+
status=400,
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
registry: PublishedSetupRegistry = setup_registry_factory()
|
| 630 |
+
result = registry.publishSetup(
|
| 631 |
+
visualGraph=payload.get("visualGraph"),
|
| 632 |
+
metadata=payload.get("metadata"),
|
| 633 |
+
inputContract=payload.get("inputContract"),
|
| 634 |
+
outputContract=payload.get("outputContract"),
|
| 635 |
+
source=payload.get("source"),
|
| 636 |
+
apiPrompt=payload.get("apiPrompt"),
|
| 637 |
+
)
|
| 638 |
+
if not result.valid:
|
| 639 |
+
return web.json_response(
|
| 640 |
+
{"ok": False, "errors": result.diagnostics},
|
| 641 |
+
status=400,
|
| 642 |
+
)
|
| 643 |
+
response = {"ok": True, "setup": result.setup}
|
| 644 |
+
storage_path = registry.storage_path
|
| 645 |
+
if storage_path is not None:
|
| 646 |
+
response["storagePath"] = str(storage_path)
|
| 647 |
+
return web.json_response(response)
|
| 648 |
+
|
| 649 |
+
@routes.post("/koolook/api/setups/{setup_id}/run")
|
| 650 |
+
async def run_published_setup(request):
|
| 651 |
+
try:
|
| 652 |
+
payload = await request.json()
|
| 653 |
+
except Exception as exc:
|
| 654 |
+
return web.json_response(
|
| 655 |
+
{"ok": False, "errors": [f"Body must be JSON: {exc}"]},
|
| 656 |
+
status=400,
|
| 657 |
+
)
|
| 658 |
+
if not isinstance(payload, dict):
|
| 659 |
+
return web.json_response(
|
| 660 |
+
{"ok": False, "errors": ["Body must be a JSON object."]},
|
| 661 |
+
status=400,
|
| 662 |
+
)
|
| 663 |
+
registry: PublishedSetupRegistry = setup_registry_factory()
|
| 664 |
+
runner = _setup_runner(request, registry)
|
| 665 |
+
try:
|
| 666 |
+
result = await runner.runSetup(
|
| 667 |
+
request.match_info["setup_id"],
|
| 668 |
+
payload.get("inputs", {}),
|
| 669 |
+
)
|
| 670 |
+
except SetupRunError as exc:
|
| 671 |
+
return web.json_response(
|
| 672 |
+
{"ok": False, "errors": exc.errors},
|
| 673 |
+
status=exc.status_code,
|
| 674 |
+
)
|
| 675 |
+
return web.json_response({"ok": True, "run": result})
|
| 676 |
+
|
| 677 |
+
@routes.get("/koolook/api/runs/{run_id}")
|
| 678 |
+
async def get_published_setup_run(request):
|
| 679 |
+
registry: PublishedSetupRegistry = setup_registry_factory()
|
| 680 |
+
runner = _setup_runner(request, registry)
|
| 681 |
+
try:
|
| 682 |
+
result = await runner.getRun(request.match_info["run_id"])
|
| 683 |
+
except SetupRunError as exc:
|
| 684 |
+
return web.json_response(
|
| 685 |
+
{"ok": False, "errors": exc.errors},
|
| 686 |
+
status=exc.status_code,
|
| 687 |
+
)
|
| 688 |
+
return web.json_response({"ok": True, "run": result})
|
| 689 |
+
|
| 690 |
+
@routes.get("/koolook/presets/info")
|
| 691 |
+
async def info(_request):
|
| 692 |
+
base, source = _configured_dir()
|
| 693 |
+
exists = base.exists() and base.is_dir()
|
| 694 |
+
writable = exists and os.access(str(base), os.W_OK)
|
| 695 |
+
return web.json_response(
|
| 696 |
+
{
|
| 697 |
+
"path": str(base),
|
| 698 |
+
"source": source, # "settings" | "env" | "default"
|
| 699 |
+
"isDefault": source == "default",
|
| 700 |
+
"envVar": ENV_VAR,
|
| 701 |
+
"exists": exists,
|
| 702 |
+
"writable": writable,
|
| 703 |
+
}
|
| 704 |
+
)
|
| 705 |
+
|
| 706 |
+
@routes.get("/koolook/presets/settings")
|
| 707 |
+
async def get_settings(_request):
|
| 708 |
+
"""Return the saved-in-UI library path (if any) plus the resolved
|
| 709 |
+
path the server actually uses right now. The UI's Settings dialog
|
| 710 |
+
renders the saved value as the editable field and the resolved
|
| 711 |
+
value as the read-only "currently in effect" line."""
|
| 712 |
+
settings = _read_settings()
|
| 713 |
+
saved = settings.get(SETTINGS_KEY_LIBRARY_PATH, "")
|
| 714 |
+
if not isinstance(saved, str):
|
| 715 |
+
saved = ""
|
| 716 |
+
resolved, source = _configured_dir()
|
| 717 |
+
return web.json_response(
|
| 718 |
+
{
|
| 719 |
+
"savedLibraryPath": saved,
|
| 720 |
+
"resolvedPath": str(resolved),
|
| 721 |
+
"source": source,
|
| 722 |
+
"envVar": ENV_VAR,
|
| 723 |
+
}
|
| 724 |
+
)
|
| 725 |
+
|
| 726 |
+
@routes.get("/koolook/presets/browse")
|
| 727 |
+
async def browse_dirs(request):
|
| 728 |
+
"""List child directories for the Settings dialog's path picker.
|
| 729 |
+
|
| 730 |
+
With ``?files=1`` also returns child ``*.json`` files — used by the
|
| 731 |
+
redesigned folder picker (issue #137, mockup section 6) to render a
|
| 732 |
+
greyed "this is the folder I expected" affordance below the
|
| 733 |
+
directories. Default response shape stays directory-only so any
|
| 734 |
+
existing callers (legacy Settings dialog) continue to work
|
| 735 |
+
unchanged.
|
| 736 |
+
|
| 737 |
+
The selected path is still saved through the existing settings
|
| 738 |
+
endpoint, so the library-path resolution and write checks stay
|
| 739 |
+
centralized.
|
| 740 |
+
"""
|
| 741 |
+
path = _browse_start_path(request.query.get("path", "").strip())
|
| 742 |
+
include_files = request.query.get("files", "").strip() in {"1", "true", "yes"}
|
| 743 |
+
try:
|
| 744 |
+
resolved = path.resolve(strict=False)
|
| 745 |
+
except OSError as exc:
|
| 746 |
+
raise web.HTTPBadRequest(reason=f"Could not resolve path: {exc}") from exc
|
| 747 |
+
if not resolved.exists() or not resolved.is_dir():
|
| 748 |
+
raise web.HTTPBadRequest(reason=f"Directory does not exist: {resolved}")
|
| 749 |
+
|
| 750 |
+
parent = resolved.parent if resolved.parent != resolved else None
|
| 751 |
+
roots = [{"name": str(root), "path": str(root)} for root in _browse_roots()]
|
| 752 |
+
try:
|
| 753 |
+
dirs = _list_child_dirs(resolved)
|
| 754 |
+
files = _list_child_files(resolved) if include_files else []
|
| 755 |
+
except OSError as exc:
|
| 756 |
+
raise web.HTTPInternalServerError(
|
| 757 |
+
reason=f"Could not list directories: {exc}"
|
| 758 |
+
) from exc
|
| 759 |
+
body = {
|
| 760 |
+
"path": str(resolved),
|
| 761 |
+
"parentPath": str(parent) if parent else "",
|
| 762 |
+
"roots": roots,
|
| 763 |
+
"dirs": dirs,
|
| 764 |
+
}
|
| 765 |
+
if include_files:
|
| 766 |
+
body["files"] = files
|
| 767 |
+
return web.json_response(body)
|
| 768 |
+
|
| 769 |
+
@routes.post("/koolook/presets/browse/new-folder")
|
| 770 |
+
async def create_browse_dir(request):
|
| 771 |
+
"""Create one child folder under the current browse location.
|
| 772 |
+
|
| 773 |
+
Threat model: this picker is intentionally filesystem-wide so users
|
| 774 |
+
can choose a snapshot library outside the current Koolook library.
|
| 775 |
+
The route's boundary is the ComfyUI process OS user, not the preset
|
| 776 |
+
library root. We still reject multi-segment names and symlink escapes
|
| 777 |
+
relative to the selected parent so a request cannot turn "create this
|
| 778 |
+
child folder here" into "create something elsewhere."
|
| 779 |
+
"""
|
| 780 |
+
try:
|
| 781 |
+
payload = await request.json()
|
| 782 |
+
except Exception as exc:
|
| 783 |
+
raise web.HTTPBadRequest(reason=f"Body must be JSON: {exc}") from exc
|
| 784 |
+
if not isinstance(payload, dict):
|
| 785 |
+
raise web.HTTPBadRequest(reason="Body must be a JSON object.")
|
| 786 |
+
|
| 787 |
+
parent = _browse_start_path(str(payload.get("parentPath", "")).strip())
|
| 788 |
+
name = _validate_new_dir_name(payload.get("name", ""))
|
| 789 |
+
try:
|
| 790 |
+
resolved_parent = parent.resolve(strict=False)
|
| 791 |
+
except OSError as exc:
|
| 792 |
+
raise web.HTTPBadRequest(reason=f"Could not resolve parent: {exc}") from exc
|
| 793 |
+
if not resolved_parent.exists() or not resolved_parent.is_dir():
|
| 794 |
+
raise web.HTTPBadRequest(reason=f"Parent folder does not exist: {resolved_parent}")
|
| 795 |
+
|
| 796 |
+
child = resolved_parent / name
|
| 797 |
+
try:
|
| 798 |
+
resolved_child = child.resolve(strict=False)
|
| 799 |
+
except OSError as exc:
|
| 800 |
+
raise web.HTTPBadRequest(reason=f"Could not resolve new folder: {exc}") from exc
|
| 801 |
+
try:
|
| 802 |
+
resolved_child.relative_to(resolved_parent)
|
| 803 |
+
except ValueError as exc:
|
| 804 |
+
raise web.HTTPBadRequest(reason="New folder escapes the selected parent.") from exc
|
| 805 |
+
if resolved_child.exists():
|
| 806 |
+
raise web.HTTPBadRequest(reason=f"Folder already exists: {resolved_child}")
|
| 807 |
+
|
| 808 |
+
try:
|
| 809 |
+
child.mkdir()
|
| 810 |
+
except OSError as exc:
|
| 811 |
+
raise web.HTTPInternalServerError(
|
| 812 |
+
reason=f"Could not create folder: {exc}"
|
| 813 |
+
) from exc
|
| 814 |
+
return web.json_response({"path": str(child)})
|
| 815 |
+
|
| 816 |
+
@routes.post("/koolook/presets/settings")
|
| 817 |
+
async def post_settings(request):
|
| 818 |
+
"""Persist a saved library path. Body is JSON ``{libraryPath: <str>}``.
|
| 819 |
+
|
| 820 |
+
Empty string or missing field clears the override (the server then
|
| 821 |
+
falls back to env-var or the built-in default).
|
| 822 |
+
"""
|
| 823 |
+
try:
|
| 824 |
+
payload = await request.json()
|
| 825 |
+
except Exception as exc:
|
| 826 |
+
raise web.HTTPBadRequest(
|
| 827 |
+
reason=f"Settings body must be JSON: {exc}"
|
| 828 |
+
) from exc
|
| 829 |
+
if not isinstance(payload, dict):
|
| 830 |
+
raise web.HTTPBadRequest(reason="Settings body must be a JSON object.")
|
| 831 |
+
new_path = payload.get(SETTINGS_KEY_LIBRARY_PATH, "")
|
| 832 |
+
if not isinstance(new_path, str):
|
| 833 |
+
raise web.HTTPBadRequest(
|
| 834 |
+
reason=f"`{SETTINGS_KEY_LIBRARY_PATH}` must be a string."
|
| 835 |
+
)
|
| 836 |
+
new_path = new_path.strip()
|
| 837 |
+
# Read-modify-write so unrelated keys (future settings) survive.
|
| 838 |
+
existing = _read_settings()
|
| 839 |
+
if new_path:
|
| 840 |
+
existing[SETTINGS_KEY_LIBRARY_PATH] = new_path
|
| 841 |
+
else:
|
| 842 |
+
# Empty string = clear the override; remove the key entirely
|
| 843 |
+
# so the resolution chain falls through cleanly to env / default.
|
| 844 |
+
existing.pop(SETTINGS_KEY_LIBRARY_PATH, None)
|
| 845 |
+
_write_settings(existing)
|
| 846 |
+
# Echo the new resolved state so the UI can update without a refetch.
|
| 847 |
+
resolved, source = _configured_dir()
|
| 848 |
+
return web.json_response(
|
| 849 |
+
{
|
| 850 |
+
"savedLibraryPath": new_path,
|
| 851 |
+
"resolvedPath": str(resolved),
|
| 852 |
+
"source": source,
|
| 853 |
+
}
|
| 854 |
+
)
|
| 855 |
+
|
| 856 |
+
@routes.get("/koolook/presets/list")
|
| 857 |
+
async def list_presets(request):
|
| 858 |
+
"""List preset files. With ``?dir=<x>`` lists files inside that
|
| 859 |
+
subfolder (used by autosave management); without dir, lists root
|
| 860 |
+
and HIDES anything matching ``_HIDDEN_LIST_PREFIXES`` (legacy flat
|
| 861 |
+
autosaves) plus any subdirectories (the per-preset autosave folders
|
| 862 |
+
— those should never appear in the user-facing Load list).
|
| 863 |
+
"""
|
| 864 |
+
base, _is_default = _configured_dir()
|
| 865 |
+
subdir_q = request.query.get("dir", "").strip()
|
| 866 |
+
if subdir_q:
|
| 867 |
+
_validate_dirname(subdir_q)
|
| 868 |
+
target_dir, _ = _resolve_target(base, subdir_q, "_listing.json")
|
| 869 |
+
target_dir = target_dir # named for clarity
|
| 870 |
+
else:
|
| 871 |
+
target_dir = base
|
| 872 |
+
if not target_dir.exists() or not target_dir.is_dir():
|
| 873 |
+
return web.json_response([])
|
| 874 |
+
out = []
|
| 875 |
+
try:
|
| 876 |
+
for entry in target_dir.iterdir():
|
| 877 |
+
if not entry.is_file() or not entry.name.lower().endswith(".json"):
|
| 878 |
+
continue
|
| 879 |
+
# Only filter legacy autosave flat files at the LIBRARY ROOT.
|
| 880 |
+
# Inside autosave subfolders we want to see everything (so
|
| 881 |
+
# the rotation pruner can list + delete its own files).
|
| 882 |
+
if not subdir_q and any(
|
| 883 |
+
entry.name.startswith(p) for p in _HIDDEN_LIST_PREFIXES
|
| 884 |
+
):
|
| 885 |
+
continue
|
| 886 |
+
try:
|
| 887 |
+
stat = entry.stat()
|
| 888 |
+
except OSError:
|
| 889 |
+
continue
|
| 890 |
+
row = {
|
| 891 |
+
"name": entry.name,
|
| 892 |
+
"mtime": stat.st_mtime,
|
| 893 |
+
"size": stat.st_size,
|
| 894 |
+
}
|
| 895 |
+
# Library-root rows only: scan the matching
|
| 896 |
+
# `<base>_autosave/` subfolder for the newest recovery file
|
| 897 |
+
# (``periodic.json`` and every ``pre_load_*.json``) and
|
| 898 |
+
# surface its mtime when strictly newer than the named
|
| 899 |
+
# file. Powers the Load dialog's inline "Newer auto-save
|
| 900 |
+
# available — restore?" affordance — the autosave UX needs
|
| 901 |
+
# the system to proactively offer the recovery copy when
|
| 902 |
+
# the user's last manual save is stale, rather than
|
| 903 |
+
# burying it inside the Recovery disclosure. Issue #137:
|
| 904 |
+
# before this we only considered ``periodic.json``, which
|
| 905 |
+
# missed cases where the user's most recent Load wrote a
|
| 906 |
+
# fresher ``pre_load_*.json`` snapshot. A handful of stats
|
| 907 |
+
# per row, no reads — still cheap on large libraries.
|
| 908 |
+
if not subdir_q:
|
| 909 |
+
base_name = entry.name[: -len(".json")]
|
| 910 |
+
autosave_dir = target_dir / f"{base_name}_autosave"
|
| 911 |
+
newest = _latest_autosave_mtime(autosave_dir, stat.st_mtime)
|
| 912 |
+
if newest is not None:
|
| 913 |
+
row["latestAutosaveMtime"] = newest
|
| 914 |
+
out.append(row)
|
| 915 |
+
except OSError as exc:
|
| 916 |
+
raise web.HTTPInternalServerError(
|
| 917 |
+
reason=f"Could not list preset library: {exc}"
|
| 918 |
+
) from exc
|
| 919 |
+
# Sort alphabetical (case-insensitive) at the server so every client
|
| 920 |
+
# sees the same order regardless of filesystem iteration order.
|
| 921 |
+
out.sort(key=lambda r: r["name"].lower())
|
| 922 |
+
return web.json_response(out)
|
| 923 |
+
|
| 924 |
+
@routes.get("/koolook/presets/autosaves/list")
|
| 925 |
+
async def list_autosaves(_request):
|
| 926 |
+
"""Walk every ``*_autosave/`` subdir (and ``_unsaved_autosave/``)
|
| 927 |
+
under the library and return a flat list of recovery snapshot
|
| 928 |
+
files. Powers the Load dialog's recovery section so a user can
|
| 929 |
+
restore from a pre-load or periodic auto-save without leaving
|
| 930 |
+
the UI.
|
| 931 |
+
|
| 932 |
+
Why a dedicated endpoint instead of recursing the existing
|
| 933 |
+
``list``? The user-facing list MUST hide subdirs (otherwise the
|
| 934 |
+
Load list gets cluttered with autosave files that aren't
|
| 935 |
+
directly user-restorable). This endpoint is the explicit "show
|
| 936 |
+
me everything in autosave folders" API — separated cleanly so
|
| 937 |
+
the boundary is grep-able and adding more subdir-scoped views
|
| 938 |
+
later (timeline / cross-preset comparison / etc.) doesn't have
|
| 939 |
+
to push more flags into the main list endpoint.
|
| 940 |
+
|
| 941 |
+
Sort: subdir alphabetical, then mtime descending within each
|
| 942 |
+
subdir (newest recovery point first — matches what users
|
| 943 |
+
expect when scanning for "the file just before I broke things").
|
| 944 |
+
"""
|
| 945 |
+
base, _ = _configured_dir()
|
| 946 |
+
if not base.exists() or not base.is_dir():
|
| 947 |
+
return web.json_response([])
|
| 948 |
+
out = []
|
| 949 |
+
try:
|
| 950 |
+
for subdir_entry in base.iterdir():
|
| 951 |
+
if not subdir_entry.is_dir():
|
| 952 |
+
continue
|
| 953 |
+
subdir_name = subdir_entry.name
|
| 954 |
+
# Match the autosave naming convention exactly so a user-
|
| 955 |
+
# created subdirectory called "my random folder" isn't
|
| 956 |
+
# treated as a recovery source.
|
| 957 |
+
is_unsaved = subdir_name == "_unsaved_autosave"
|
| 958 |
+
is_named = subdir_name.endswith("_autosave") and len(subdir_name) > len("_autosave")
|
| 959 |
+
if not (is_unsaved or is_named):
|
| 960 |
+
continue
|
| 961 |
+
for f in subdir_entry.iterdir():
|
| 962 |
+
if not f.is_file() or not f.name.lower().endswith(".json"):
|
| 963 |
+
continue
|
| 964 |
+
try:
|
| 965 |
+
stat = f.stat()
|
| 966 |
+
except OSError:
|
| 967 |
+
continue
|
| 968 |
+
out.append(
|
| 969 |
+
{
|
| 970 |
+
"dir": subdir_name,
|
| 971 |
+
"name": f.name,
|
| 972 |
+
"mtime": stat.st_mtime,
|
| 973 |
+
"size": stat.st_size,
|
| 974 |
+
}
|
| 975 |
+
)
|
| 976 |
+
except OSError as exc:
|
| 977 |
+
raise web.HTTPInternalServerError(
|
| 978 |
+
reason=f"Could not list autosave folders: {exc}"
|
| 979 |
+
) from exc
|
| 980 |
+
out.sort(key=lambda r: (r["dir"].lower(), -r["mtime"]))
|
| 981 |
+
return web.json_response(out)
|
| 982 |
+
|
| 983 |
+
@routes.get("/koolook/presets/file")
|
| 984 |
+
async def get_preset(request):
|
| 985 |
+
"""Serve preset JSON for ``?name=<name>[&dir=<subdir>]``; HEAD
|
| 986 |
+
short-circuits before the file read.
|
| 987 |
+
|
| 988 |
+
aiohttp's ``add_get`` auto-registers a HEAD route by default. We
|
| 989 |
+
can't opt out via ``allow_head=False`` because ComfyUI's
|
| 990 |
+
mirror-to-/api code in ``server.py`` blindly forwards
|
| 991 |
+
``RouteDef.kwargs`` into ``RouteTableDef.route(method, path)
|
| 992 |
+
(handler, **kwargs)`` whose inner closure rejects unknown
|
| 993 |
+
kwargs, crashing startup. So this single handler owns both
|
| 994 |
+
methods, branching on ``request.method`` so a ``presetExists``
|
| 995 |
+
HEAD probe doesn't have to read multi-MB JSON over
|
| 996 |
+
Dropbox/iCloud/NFS-mounted libraries just to answer 200/404.
|
| 997 |
+
"""
|
| 998 |
+
name = _validate_filename(request.query.get("name"))
|
| 999 |
+
subdir = request.query.get("dir", "").strip()
|
| 1000 |
+
if subdir:
|
| 1001 |
+
_validate_dirname(subdir)
|
| 1002 |
+
base, _ = _configured_dir()
|
| 1003 |
+
_, file_path = _resolve_target(base, subdir, name)
|
| 1004 |
+
if not file_path.is_file():
|
| 1005 |
+
raise web.HTTPNotFound(reason=f"Preset '{name}' not found.")
|
| 1006 |
+
if request.method == "HEAD":
|
| 1007 |
+
return web.Response(status=200)
|
| 1008 |
+
try:
|
| 1009 |
+
content = file_path.read_text(encoding="utf-8")
|
| 1010 |
+
except OSError as exc:
|
| 1011 |
+
raise web.HTTPInternalServerError(
|
| 1012 |
+
reason=f"Could not read preset: {exc}"
|
| 1013 |
+
) from exc
|
| 1014 |
+
return web.Response(text=content, content_type="application/json")
|
| 1015 |
+
|
| 1016 |
+
@routes.post("/koolook/presets/file")
|
| 1017 |
+
async def post_preset(request):
|
| 1018 |
+
name = _validate_filename(request.query.get("name"))
|
| 1019 |
+
subdir = request.query.get("dir", "").strip()
|
| 1020 |
+
if subdir:
|
| 1021 |
+
_validate_dirname(subdir)
|
| 1022 |
+
base, _ = _configured_dir()
|
| 1023 |
+
if not base.parent.exists():
|
| 1024 |
+
raise web.HTTPInternalServerError(
|
| 1025 |
+
reason=(
|
| 1026 |
+
f"Preset library parent directory does not exist: "
|
| 1027 |
+
f"{base.parent}. Create it (or fix the {ENV_VAR} env "
|
| 1028 |
+
f"var) and retry."
|
| 1029 |
+
)
|
| 1030 |
+
)
|
| 1031 |
+
target_dir, file_path = _resolve_target(base, subdir, name)
|
| 1032 |
+
try:
|
| 1033 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 1034 |
+
except OSError as exc:
|
| 1035 |
+
raise web.HTTPInternalServerError(
|
| 1036 |
+
reason=f"Could not create preset directory: {exc}"
|
| 1037 |
+
) from exc
|
| 1038 |
+
body = await request.read()
|
| 1039 |
+
# Atomic write — `tempfile.mkstemp` opens a uniquely-named temp
|
| 1040 |
+
# file inside the already-validated `target_dir` using
|
| 1041 |
+
# `O_CREAT | O_EXCL` semantics, then `os.replace` swaps it onto
|
| 1042 |
+
# the final name. A crash or dropped connection mid-write can
|
| 1043 |
+
# no longer truncate the existing good file: readers see either
|
| 1044 |
+
# the old or the new content, never a half-written byte stream.
|
| 1045 |
+
#
|
| 1046 |
+
# Why mkstemp instead of a fixed `<file>.json.tmp` name? An
|
| 1047 |
+
# attacker (or accidental user) with write access to the library
|
| 1048 |
+
# could plant `<file>.json.tmp` as a symlink pointing outside
|
| 1049 |
+
# the library. A naive `Path.write_bytes` would FOLLOW that
|
| 1050 |
+
# symlink — past the path-traversal guard — and write the
|
| 1051 |
+
# request body to wherever the symlink pointed, before
|
| 1052 |
+
# `os.replace` swapped the symlink off. `mkstemp` defeats that
|
| 1053 |
+
# in two ways: (1) `O_EXCL` aborts if anything (including a
|
| 1054 |
+
# symlink) already exists at the chosen name, and (2) the name
|
| 1055 |
+
# itself is random so the attacker can't pre-plant anything
|
| 1056 |
+
# there. The `.tmp` suffix keeps the transient file invisible
|
| 1057 |
+
# to `/koolook/presets/list` and `/koolook/presets/autosaves/list`
|
| 1058 |
+
# (both filter to `.json`).
|
| 1059 |
+
try:
|
| 1060 |
+
tmp_fd, tmp_name = tempfile.mkstemp(
|
| 1061 |
+
prefix=f"{file_path.stem}.",
|
| 1062 |
+
suffix=".tmp",
|
| 1063 |
+
dir=str(target_dir),
|
| 1064 |
+
)
|
| 1065 |
+
except OSError as exc:
|
| 1066 |
+
raise web.HTTPInternalServerError(
|
| 1067 |
+
reason=f"Could not create preset temp file: {exc}"
|
| 1068 |
+
) from exc
|
| 1069 |
+
try:
|
| 1070 |
+
with os.fdopen(tmp_fd, "wb") as f:
|
| 1071 |
+
f.write(body)
|
| 1072 |
+
os.replace(tmp_name, str(file_path))
|
| 1073 |
+
except OSError as exc:
|
| 1074 |
+
try:
|
| 1075 |
+
if os.path.exists(tmp_name):
|
| 1076 |
+
os.unlink(tmp_name)
|
| 1077 |
+
except OSError:
|
| 1078 |
+
pass
|
| 1079 |
+
raise web.HTTPInternalServerError(
|
| 1080 |
+
reason=f"Could not write preset: {exc}"
|
| 1081 |
+
) from exc
|
| 1082 |
+
return web.json_response({"ok": True, "name": name, "dir": subdir})
|
| 1083 |
+
|
| 1084 |
+
@routes.delete("/koolook/presets/file")
|
| 1085 |
+
async def delete_preset(request):
|
| 1086 |
+
name = _validate_filename(request.query.get("name"))
|
| 1087 |
+
subdir = request.query.get("dir", "").strip()
|
| 1088 |
+
if subdir:
|
| 1089 |
+
_validate_dirname(subdir)
|
| 1090 |
+
base, _ = _configured_dir()
|
| 1091 |
+
_, file_path = _resolve_target(base, subdir, name)
|
| 1092 |
+
if not file_path.is_file():
|
| 1093 |
+
raise web.HTTPNotFound(reason=f"Preset '{name}' not found.")
|
| 1094 |
+
try:
|
| 1095 |
+
file_path.unlink()
|
| 1096 |
+
except OSError as exc:
|
| 1097 |
+
raise web.HTTPInternalServerError(
|
| 1098 |
+
reason=f"Could not delete preset: {exc}"
|
| 1099 |
+
) from exc
|
| 1100 |
+
return web.json_response({"ok": True, "name": name, "dir": subdir})
|
| 1101 |
+
|
| 1102 |
+
@routes.post("/koolook/presets/reveal")
|
| 1103 |
+
async def reveal_preset_folder(request):
|
| 1104 |
+
"""Open the preset library (or an autosave subfolder) in the OS
|
| 1105 |
+
file manager. Optional ``?dir=<subdir>`` deep-links to a
|
| 1106 |
+
per-preset autosave folder so the Recovery section can drop the
|
| 1107 |
+
user inside the right `<preset>_autosave/` directory.
|
| 1108 |
+
|
| 1109 |
+
Path-traversal is grounded at the configured library base via
|
| 1110 |
+
``_resolve_target``; subdir names go through ``_validate_dirname``.
|
| 1111 |
+
Subprocess args are passed list-form (no shell), so a controlled
|
| 1112 |
+
path can't trigger shell-metacharacter interpretation even if a
|
| 1113 |
+
future check loosens the dirname charset.
|
| 1114 |
+
"""
|
| 1115 |
+
base, _ = _configured_dir()
|
| 1116 |
+
subdir_q = request.query.get("dir", "").strip()
|
| 1117 |
+
if subdir_q:
|
| 1118 |
+
_validate_dirname(subdir_q)
|
| 1119 |
+
target_dir, _ = _resolve_target(base, subdir_q, "_listing.json")
|
| 1120 |
+
else:
|
| 1121 |
+
target_dir = base
|
| 1122 |
+
_open_dir_in_file_manager(target_dir)
|
| 1123 |
+
return web.json_response({"ok": True, "path": str(target_dir)})
|
| 1124 |
+
|
| 1125 |
+
@routes.post("/koolook/api/setups/reveal")
|
| 1126 |
+
async def reveal_published_setup_folder(_request):
|
| 1127 |
+
"""Open the published-setups directory (where ``Publish setup``
|
| 1128 |
+
writes ``setups.json``) in the OS file manager. Distinct from the
|
| 1129 |
+
snapshot-library reveal above: the publish success card's Open
|
| 1130 |
+
folder action must land in the registry folder, not the snapshot
|
| 1131 |
+
library.
|
| 1132 |
+
"""
|
| 1133 |
+
registry: PublishedSetupRegistry = setup_registry_factory()
|
| 1134 |
+
storage_path = registry.storage_path
|
| 1135 |
+
if storage_path is None:
|
| 1136 |
+
raise web.HTTPNotFound(
|
| 1137 |
+
reason="Published setup storage path is unavailable."
|
| 1138 |
+
)
|
| 1139 |
+
target_dir = storage_path.parent
|
| 1140 |
+
_open_dir_in_file_manager(target_dir)
|
| 1141 |
+
return web.json_response({"ok": True, "path": str(target_dir)})
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
+
def install() -> bool:
|
| 1145 |
+
"""Hook the routes into ComfyUI's running PromptServer.
|
| 1146 |
+
|
| 1147 |
+
Returns ``True`` on success, ``False`` if PromptServer wasn't reachable
|
| 1148 |
+
(very early import, headless test, etc.). The caller logs the failure
|
| 1149 |
+
so the rest of Koolook still loads — the snapshot feature just won't
|
| 1150 |
+
work in that session.
|
| 1151 |
+
"""
|
| 1152 |
+
try:
|
| 1153 |
+
from server import PromptServer # type: ignore[import-not-found]
|
| 1154 |
+
except Exception:
|
| 1155 |
+
return False
|
| 1156 |
+
try:
|
| 1157 |
+
register_routes(PromptServer.instance.routes)
|
| 1158 |
+
except Exception as exc: # pragma: no cover - defensive
|
| 1159 |
+
print(f"[Koolook] failed to register preset routes: {exc}")
|
| 1160 |
+
return False
|
| 1161 |
+
return True
|
ComfyUI/custom_nodes/koolook/koolook_setup_runner.py
ADDED
|
@@ -0,0 +1,1010 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Execution boundary for published Koolook workflow setups."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from copy import deepcopy
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
from .koolook_setups import PublishedSetupRegistry
|
| 10 |
+
except ImportError: # pragma: no cover - standalone test/import context
|
| 11 |
+
from koolook_setups import PublishedSetupRegistry
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SetupRunError(Exception):
|
| 15 |
+
"""API-facing setup runner error with stable HTTP status semantics."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, status_code: int, errors: list[str]) -> None:
|
| 18 |
+
super().__init__("; ".join(errors))
|
| 19 |
+
self.status_code = status_code
|
| 20 |
+
self.errors = errors
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class RunRecord:
|
| 25 |
+
run_id: str
|
| 26 |
+
setup_id: str
|
| 27 |
+
prompt_id: str
|
| 28 |
+
status: str
|
| 29 |
+
inputs: dict[str, Any]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class InMemorySetupRunStore:
|
| 33 |
+
"""Small run-id mapping store for a running ComfyUI process."""
|
| 34 |
+
|
| 35 |
+
def __init__(self) -> None:
|
| 36 |
+
self._next_id = 1
|
| 37 |
+
self._records: dict[str, RunRecord] = {}
|
| 38 |
+
|
| 39 |
+
def create(self, *, setup_id: str, prompt_id: str, inputs: dict[str, Any] | None = None) -> RunRecord:
|
| 40 |
+
run_id = f"run-{self._next_id:06d}"
|
| 41 |
+
self._next_id += 1
|
| 42 |
+
record = RunRecord(
|
| 43 |
+
run_id=run_id,
|
| 44 |
+
setup_id=setup_id,
|
| 45 |
+
prompt_id=prompt_id,
|
| 46 |
+
status="queued",
|
| 47 |
+
inputs=deepcopy(inputs) if isinstance(inputs, dict) else {},
|
| 48 |
+
)
|
| 49 |
+
self._records[run_id] = record
|
| 50 |
+
return record
|
| 51 |
+
|
| 52 |
+
def get(self, run_id: str) -> RunRecord | None:
|
| 53 |
+
return self._records.get(run_id)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class AiohttpComfyClient:
|
| 57 |
+
"""HTTP adapter for the managed ComfyUI server."""
|
| 58 |
+
|
| 59 |
+
def __init__(self, base_url: str) -> None:
|
| 60 |
+
self._base_url = base_url.rstrip("/")
|
| 61 |
+
|
| 62 |
+
async def queue_prompt(self, prompt: dict) -> dict:
|
| 63 |
+
import aiohttp
|
| 64 |
+
|
| 65 |
+
async with aiohttp.ClientSession() as session:
|
| 66 |
+
async with session.post(f"{self._base_url}/prompt", json={"prompt": prompt}) as response:
|
| 67 |
+
try:
|
| 68 |
+
payload = await response.json()
|
| 69 |
+
except Exception:
|
| 70 |
+
payload = {"error": await response.text()}
|
| 71 |
+
if response.status >= 400:
|
| 72 |
+
detail = payload.get("error") if isinstance(payload, dict) else payload
|
| 73 |
+
raise SetupRunError(
|
| 74 |
+
502,
|
| 75 |
+
[f"ComfyUI queue request failed with HTTP {response.status}: {detail}"],
|
| 76 |
+
)
|
| 77 |
+
return payload if isinstance(payload, dict) else {}
|
| 78 |
+
|
| 79 |
+
async def get_history(self, prompt_id: str) -> dict:
|
| 80 |
+
import aiohttp
|
| 81 |
+
|
| 82 |
+
async with aiohttp.ClientSession() as session:
|
| 83 |
+
async with session.get(f"{self._base_url}/history/{prompt_id}") as response:
|
| 84 |
+
try:
|
| 85 |
+
payload = await response.json()
|
| 86 |
+
except Exception:
|
| 87 |
+
payload = {"error": await response.text()}
|
| 88 |
+
if response.status >= 400:
|
| 89 |
+
detail = payload.get("error") if isinstance(payload, dict) else payload
|
| 90 |
+
raise SetupRunError(
|
| 91 |
+
502,
|
| 92 |
+
[f"ComfyUI history request failed with HTTP {response.status}: {detail}"],
|
| 93 |
+
)
|
| 94 |
+
return payload if isinstance(payload, dict) else {}
|
| 95 |
+
|
| 96 |
+
async def get_queue(self) -> dict:
|
| 97 |
+
import aiohttp
|
| 98 |
+
|
| 99 |
+
async with aiohttp.ClientSession() as session:
|
| 100 |
+
async with session.get(f"{self._base_url}/queue") as response:
|
| 101 |
+
try:
|
| 102 |
+
payload = await response.json()
|
| 103 |
+
except Exception:
|
| 104 |
+
payload = {"error": await response.text()}
|
| 105 |
+
if response.status >= 400:
|
| 106 |
+
detail = payload.get("error") if isinstance(payload, dict) else payload
|
| 107 |
+
raise SetupRunError(
|
| 108 |
+
502,
|
| 109 |
+
[f"ComfyUI queue status request failed with HTTP {response.status}: {detail}"],
|
| 110 |
+
)
|
| 111 |
+
return payload if isinstance(payload, dict) else {}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class PublishedSetupRunner:
|
| 115 |
+
"""Runs callable published setups without exposing ComfyUI internals."""
|
| 116 |
+
|
| 117 |
+
def __init__(
|
| 118 |
+
self,
|
| 119 |
+
registry: PublishedSetupRegistry,
|
| 120 |
+
comfy_client: Any,
|
| 121 |
+
run_store: InMemorySetupRunStore | None = None,
|
| 122 |
+
) -> None:
|
| 123 |
+
self._registry = registry
|
| 124 |
+
self._comfy_client = comfy_client
|
| 125 |
+
self._run_store = run_store or InMemorySetupRunStore()
|
| 126 |
+
|
| 127 |
+
async def runSetup(self, setup_id: str, inputs: dict[str, Any]) -> dict[str, Any]:
|
| 128 |
+
setup = self._registry.getSetup(setup_id)
|
| 129 |
+
if setup is None:
|
| 130 |
+
raise SetupRunError(404, [f"Published setup '{setup_id}' not found."])
|
| 131 |
+
if not isinstance(inputs, dict):
|
| 132 |
+
raise SetupRunError(400, ["inputs must be a JSON object"])
|
| 133 |
+
|
| 134 |
+
api_prompt = setup.get("apiPrompt")
|
| 135 |
+
if not isinstance(api_prompt, dict) or setup.get("validation", {}).get("status") != "valid":
|
| 136 |
+
raise SetupRunError(400, [f"Published setup '{setup_id}' is not callable."])
|
| 137 |
+
|
| 138 |
+
prompt = deepcopy(api_prompt)
|
| 139 |
+
input_fields = _declared_input_fields(setup)
|
| 140 |
+
errors = _validate_run_inputs(input_fields, inputs)
|
| 141 |
+
errors.extend(_validate_execution_map_inputs(setup, inputs))
|
| 142 |
+
if errors:
|
| 143 |
+
raise SetupRunError(400, errors)
|
| 144 |
+
|
| 145 |
+
for key, value in inputs.items():
|
| 146 |
+
field = input_fields[key]
|
| 147 |
+
target = field["target"]
|
| 148 |
+
prompt[str(target["node"])]["inputs"][str(target["input"])] = _prompt_value_for_field(field, value)
|
| 149 |
+
|
| 150 |
+
mapped_prompt = _prune_prompt_for_execution_map(setup, prompt, inputs)
|
| 151 |
+
prompt = (
|
| 152 |
+
mapped_prompt
|
| 153 |
+
if mapped_prompt is not None
|
| 154 |
+
else _prune_prompt_for_selected_app_results(setup, prompt, inputs)
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
queued = await self._comfy_client.queue_prompt(prompt)
|
| 159 |
+
except SetupRunError:
|
| 160 |
+
raise
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
raise SetupRunError(502, [f"ComfyUI queue request failed: {exc}"]) from exc
|
| 163 |
+
|
| 164 |
+
prompt_id = queued.get("prompt_id") if isinstance(queued, dict) else None
|
| 165 |
+
if not isinstance(prompt_id, str) or not prompt_id:
|
| 166 |
+
raise SetupRunError(502, ["ComfyUI queue response did not include prompt_id"])
|
| 167 |
+
|
| 168 |
+
record = self._run_store.create(setup_id=setup_id, prompt_id=prompt_id, inputs=inputs)
|
| 169 |
+
return {"runId": record.run_id, "promptId": record.prompt_id, "status": record.status}
|
| 170 |
+
|
| 171 |
+
async def getRun(self, run_id: str) -> dict[str, Any]:
|
| 172 |
+
record = self._run_store.get(run_id)
|
| 173 |
+
if record is None:
|
| 174 |
+
raise SetupRunError(404, [f"Koolook run '{run_id}' not found."])
|
| 175 |
+
|
| 176 |
+
setup = self._registry.getSetup(record.setup_id)
|
| 177 |
+
if setup is None:
|
| 178 |
+
raise SetupRunError(404, [f"Published setup '{record.setup_id}' not found."])
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
history = await self._comfy_client.get_history(record.prompt_id)
|
| 182 |
+
except SetupRunError:
|
| 183 |
+
raise
|
| 184 |
+
except Exception as exc:
|
| 185 |
+
raise SetupRunError(502, [f"ComfyUI history request failed: {exc}"]) from exc
|
| 186 |
+
|
| 187 |
+
history_entry = history.get(record.prompt_id) if isinstance(history, dict) else None
|
| 188 |
+
if isinstance(history_entry, dict):
|
| 189 |
+
status = _status_from_history(history_entry)
|
| 190 |
+
return {
|
| 191 |
+
"runId": record.run_id,
|
| 192 |
+
"setupId": record.setup_id,
|
| 193 |
+
"promptId": record.prompt_id,
|
| 194 |
+
"status": status,
|
| 195 |
+
"comfyStatus": history_entry.get("status", {}),
|
| 196 |
+
"outputs": _summarize_outputs(
|
| 197 |
+
setup,
|
| 198 |
+
history_entry.get("outputs", {}),
|
| 199 |
+
record.inputs,
|
| 200 |
+
_history_prompt(history_entry),
|
| 201 |
+
),
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
queue = await self._comfy_client.get_queue()
|
| 206 |
+
except SetupRunError:
|
| 207 |
+
raise
|
| 208 |
+
except Exception as exc:
|
| 209 |
+
raise SetupRunError(502, [f"ComfyUI queue status request failed: {exc}"]) from exc
|
| 210 |
+
return {
|
| 211 |
+
"runId": record.run_id,
|
| 212 |
+
"setupId": record.setup_id,
|
| 213 |
+
"promptId": record.prompt_id,
|
| 214 |
+
"status": _status_from_queue(record.prompt_id, queue),
|
| 215 |
+
"outputs": [],
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _declared_input_fields(setup: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
| 220 |
+
fields: dict[str, dict[str, Any]] = {}
|
| 221 |
+
for field in setup.get("inputContract", {}).get("inputs", []):
|
| 222 |
+
if not isinstance(field, dict):
|
| 223 |
+
continue
|
| 224 |
+
key = field.get("key")
|
| 225 |
+
target = field.get("target")
|
| 226 |
+
if isinstance(key, str) and key and isinstance(target, dict):
|
| 227 |
+
fields[key] = field
|
| 228 |
+
app = setup.get("setupSurface", {}).get("app", {})
|
| 229 |
+
if isinstance(app, dict):
|
| 230 |
+
for field in app.get("inputs", []):
|
| 231 |
+
if not isinstance(field, dict):
|
| 232 |
+
continue
|
| 233 |
+
key = field.get("key")
|
| 234 |
+
target = field.get("target")
|
| 235 |
+
if isinstance(key, str) and key and isinstance(target, dict):
|
| 236 |
+
fields.setdefault(key, field)
|
| 237 |
+
for field in app.get("outputs", []):
|
| 238 |
+
if not isinstance(field, dict):
|
| 239 |
+
continue
|
| 240 |
+
key = field.get("key")
|
| 241 |
+
target = field.get("target")
|
| 242 |
+
if isinstance(key, str) and key and isinstance(target, dict):
|
| 243 |
+
fields.setdefault(key, field)
|
| 244 |
+
switch = app.get("switch")
|
| 245 |
+
if isinstance(switch, dict):
|
| 246 |
+
key = switch.get("key")
|
| 247 |
+
target = switch.get("target")
|
| 248 |
+
if isinstance(key, str) and key and isinstance(target, dict):
|
| 249 |
+
fields.setdefault(key, switch)
|
| 250 |
+
return fields
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _validate_run_inputs(input_fields: dict[str, dict[str, Any]], inputs: dict[str, Any]) -> list[str]:
|
| 254 |
+
errors: list[str] = []
|
| 255 |
+
for key in inputs:
|
| 256 |
+
if key not in input_fields:
|
| 257 |
+
errors.append(f"input '{key}' is not declared by this setup")
|
| 258 |
+
continue
|
| 259 |
+
field = input_fields[key]
|
| 260 |
+
if field.get("key") == "switch" and _switch_option_value(field, inputs[key]) is None:
|
| 261 |
+
errors.append(_switch_value_error(key, field))
|
| 262 |
+
for key, field in input_fields.items():
|
| 263 |
+
if field.get("required") is True and key not in inputs:
|
| 264 |
+
errors.append(f"required input '{key}' is missing")
|
| 265 |
+
return errors
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _prompt_value_for_field(field: dict[str, Any], value: Any) -> Any:
|
| 269 |
+
if field.get("key") != "switch":
|
| 270 |
+
return value
|
| 271 |
+
selected_value = _switch_option_value(field, value)
|
| 272 |
+
for option in field.get("options", []):
|
| 273 |
+
if not isinstance(option, dict):
|
| 274 |
+
continue
|
| 275 |
+
if option.get("value") == selected_value and isinstance(option.get("label"), str):
|
| 276 |
+
return option["label"]
|
| 277 |
+
return value
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _switch_option_value(switch: dict[str, Any], selected: Any) -> int | None:
|
| 281 |
+
if isinstance(selected, bool):
|
| 282 |
+
return None
|
| 283 |
+
for option in switch.get("options", []):
|
| 284 |
+
if not isinstance(option, dict):
|
| 285 |
+
continue
|
| 286 |
+
value = option.get("value")
|
| 287 |
+
if isinstance(value, bool) or not isinstance(value, int):
|
| 288 |
+
continue
|
| 289 |
+
if selected == value:
|
| 290 |
+
return value
|
| 291 |
+
if isinstance(selected, str) and (selected == str(value) or selected == option.get("label")):
|
| 292 |
+
return value
|
| 293 |
+
return None
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _switch_value_error(key: str, switch: dict[str, Any]) -> str:
|
| 297 |
+
choices: list[str] = []
|
| 298 |
+
for option in switch.get("options", []):
|
| 299 |
+
if not isinstance(option, dict):
|
| 300 |
+
continue
|
| 301 |
+
value = option.get("value")
|
| 302 |
+
label = option.get("label")
|
| 303 |
+
if isinstance(value, bool) or not isinstance(value, int):
|
| 304 |
+
continue
|
| 305 |
+
choices.append(f"{value} ({label})" if isinstance(label, str) and label else str(value))
|
| 306 |
+
if choices:
|
| 307 |
+
return f"input '{key}' must be one of: {', '.join(choices)}"
|
| 308 |
+
return f"input '{key}' has no valid switch options"
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _status_from_history(history_entry: dict[str, Any]) -> str:
|
| 312 |
+
status = history_entry.get("status")
|
| 313 |
+
if not isinstance(status, dict):
|
| 314 |
+
return "succeeded"
|
| 315 |
+
status_text = str(status.get("status_str", "")).lower()
|
| 316 |
+
if status.get("completed") is True and status_text not in {"error", "failed", "failure"}:
|
| 317 |
+
return "succeeded"
|
| 318 |
+
return "failed"
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _status_from_queue(prompt_id: str, queue: Any) -> str:
|
| 322 |
+
if not isinstance(queue, dict):
|
| 323 |
+
return "lost"
|
| 324 |
+
if _queue_contains_prompt(queue.get("queue_running", []), prompt_id):
|
| 325 |
+
return "running"
|
| 326 |
+
if _queue_contains_prompt(queue.get("queue_pending", []), prompt_id):
|
| 327 |
+
return "queued"
|
| 328 |
+
return "lost"
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def _queue_contains_prompt(entries: Any, prompt_id: str) -> bool:
|
| 332 |
+
if not isinstance(entries, list):
|
| 333 |
+
return False
|
| 334 |
+
for entry in entries:
|
| 335 |
+
if isinstance(entry, dict) and entry.get("prompt_id") == prompt_id:
|
| 336 |
+
return True
|
| 337 |
+
if isinstance(entry, list) and prompt_id in entry:
|
| 338 |
+
return True
|
| 339 |
+
return False
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _summarize_outputs(
|
| 343 |
+
setup: dict[str, Any],
|
| 344 |
+
raw_outputs: Any,
|
| 345 |
+
run_inputs: dict[str, Any] | None = None,
|
| 346 |
+
history_prompt: dict[str, Any] | None = None,
|
| 347 |
+
) -> list[dict[str, Any]]:
|
| 348 |
+
output_items = _flatten_history_outputs(raw_outputs)
|
| 349 |
+
summaries = []
|
| 350 |
+
for output in setup.get("outputContract", {}).get("outputs", []):
|
| 351 |
+
if not isinstance(output, dict):
|
| 352 |
+
continue
|
| 353 |
+
summaries.append(
|
| 354 |
+
{
|
| 355 |
+
"key": output.get("key", ""),
|
| 356 |
+
"label": output.get("label", output.get("key", "")),
|
| 357 |
+
"type": output.get("type", ""),
|
| 358 |
+
"items": [
|
| 359 |
+
item
|
| 360 |
+
for item in output_items
|
| 361 |
+
if _history_output_matches_contract(item, str(output.get("type", "")))
|
| 362 |
+
],
|
| 363 |
+
}
|
| 364 |
+
)
|
| 365 |
+
summaries.extend(_summarize_app_surface_outputs(setup, output_items, run_inputs or {}, history_prompt))
|
| 366 |
+
return summaries
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _summarize_app_surface_outputs(
|
| 370 |
+
setup: dict[str, Any],
|
| 371 |
+
output_items: list[dict[str, Any]],
|
| 372 |
+
run_inputs: dict[str, Any],
|
| 373 |
+
history_prompt: dict[str, Any] | None = None,
|
| 374 |
+
) -> list[dict[str, Any]]:
|
| 375 |
+
app = setup.get("setupSurface", {}).get("app", {})
|
| 376 |
+
if not isinstance(app, dict):
|
| 377 |
+
return []
|
| 378 |
+
summaries: list[dict[str, Any]] = []
|
| 379 |
+
for field_type, fields in (("output", app.get("outputs")), ("result", app.get("results"))):
|
| 380 |
+
if not isinstance(fields, list):
|
| 381 |
+
continue
|
| 382 |
+
for field in fields:
|
| 383 |
+
if not isinstance(field, dict):
|
| 384 |
+
continue
|
| 385 |
+
target = field.get("target")
|
| 386 |
+
target_node = str(target.get("node")) if isinstance(target, dict) else ""
|
| 387 |
+
items = [
|
| 388 |
+
item
|
| 389 |
+
for item in output_items
|
| 390 |
+
if target_node and item.get("nodeId") == target_node
|
| 391 |
+
]
|
| 392 |
+
if field_type == "result" and not items:
|
| 393 |
+
items = _selected_switch_result_items(setup, field, output_items, run_inputs)
|
| 394 |
+
if field_type == "result" and not items and history_prompt is not None:
|
| 395 |
+
items = _execution_map_writer_result_items(setup, history_prompt, run_inputs)
|
| 396 |
+
summary = {
|
| 397 |
+
"key": field.get("key", ""),
|
| 398 |
+
"label": field.get("label", field.get("key", "")),
|
| 399 |
+
"type": field_type,
|
| 400 |
+
"visible": field.get("visible", True),
|
| 401 |
+
"target": target if isinstance(target, dict) else {},
|
| 402 |
+
"default": field.get("default"),
|
| 403 |
+
"items": items,
|
| 404 |
+
}
|
| 405 |
+
summaries.append(summary)
|
| 406 |
+
return summaries
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def _history_prompt(history_entry: dict[str, Any]) -> dict[str, Any] | None:
|
| 410 |
+
prompt_record = history_entry.get("prompt")
|
| 411 |
+
if (
|
| 412 |
+
isinstance(prompt_record, list)
|
| 413 |
+
and len(prompt_record) >= 3
|
| 414 |
+
and isinstance(prompt_record[2], dict)
|
| 415 |
+
):
|
| 416 |
+
return prompt_record[2]
|
| 417 |
+
return None
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def _execution_map_writer_result_items(
|
| 421 |
+
setup: dict[str, Any],
|
| 422 |
+
prompt: dict[str, Any],
|
| 423 |
+
run_inputs: dict[str, Any],
|
| 424 |
+
) -> list[dict[str, Any]]:
|
| 425 |
+
writer_nodes = _selected_execution_map_writer_nodes(setup, run_inputs)
|
| 426 |
+
items: list[dict[str, Any]] = []
|
| 427 |
+
for writer_node in writer_nodes:
|
| 428 |
+
node = prompt.get(writer_node)
|
| 429 |
+
if not isinstance(node, dict):
|
| 430 |
+
continue
|
| 431 |
+
inputs = node.get("inputs")
|
| 432 |
+
if not isinstance(inputs, dict):
|
| 433 |
+
continue
|
| 434 |
+
value = _resolve_prompt_value(prompt, inputs.get("filepath"))
|
| 435 |
+
if isinstance(value, str) and value:
|
| 436 |
+
items.append({"nodeId": writer_node, "kind": "text", "value": value})
|
| 437 |
+
return items
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def _selected_execution_map_writer_nodes(
|
| 441 |
+
setup: dict[str, Any],
|
| 442 |
+
run_inputs: dict[str, Any],
|
| 443 |
+
) -> list[str]:
|
| 444 |
+
execution_map = setup.get("executionMap")
|
| 445 |
+
if not isinstance(execution_map, dict) or execution_map.get("version") != 1:
|
| 446 |
+
return []
|
| 447 |
+
routers = execution_map.get("routers")
|
| 448 |
+
if not isinstance(routers, list):
|
| 449 |
+
return []
|
| 450 |
+
out: list[str] = []
|
| 451 |
+
for router in routers:
|
| 452 |
+
if not isinstance(router, dict):
|
| 453 |
+
continue
|
| 454 |
+
selected_value = _execution_map_selected_value(setup, router, run_inputs)
|
| 455 |
+
branches = router.get("branches")
|
| 456 |
+
branch = branches.get(str(selected_value)) if isinstance(branches, dict) else None
|
| 457 |
+
writer_nodes = branch.get("writerNodes") if isinstance(branch, dict) else None
|
| 458 |
+
if isinstance(writer_nodes, list):
|
| 459 |
+
out.extend(str(node_id) for node_id in writer_nodes)
|
| 460 |
+
return list(dict.fromkeys(out))
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def _selected_switch_result_items(
|
| 464 |
+
setup: dict[str, Any],
|
| 465 |
+
result_field: dict[str, Any],
|
| 466 |
+
output_items: list[dict[str, Any]],
|
| 467 |
+
run_inputs: dict[str, Any],
|
| 468 |
+
) -> list[dict[str, Any]]:
|
| 469 |
+
selected_branch = _selected_result_branch_ref(setup, result_field, run_inputs)
|
| 470 |
+
if selected_branch is None:
|
| 471 |
+
return []
|
| 472 |
+
selected_node = str(selected_branch[0])
|
| 473 |
+
return [item for item in output_items if item.get("nodeId") == selected_node]
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def _prune_prompt_for_execution_map(
|
| 477 |
+
setup: dict[str, Any],
|
| 478 |
+
prompt: dict[str, Any],
|
| 479 |
+
run_inputs: dict[str, Any],
|
| 480 |
+
) -> dict[str, Any] | None:
|
| 481 |
+
execution_map = setup.get("executionMap")
|
| 482 |
+
if not isinstance(execution_map, dict) or execution_map.get("version") != 1:
|
| 483 |
+
return None
|
| 484 |
+
routers = execution_map.get("routers")
|
| 485 |
+
if not isinstance(routers, list):
|
| 486 |
+
return None
|
| 487 |
+
|
| 488 |
+
roots: set[str] = set()
|
| 489 |
+
for router in routers:
|
| 490 |
+
if not isinstance(router, dict):
|
| 491 |
+
continue
|
| 492 |
+
router_node = str(router.get("node", ""))
|
| 493 |
+
selected_value = _execution_map_selected_value(setup, router, run_inputs)
|
| 494 |
+
if selected_value is None:
|
| 495 |
+
continue
|
| 496 |
+
branches = router.get("branches")
|
| 497 |
+
branch = branches.get(str(selected_value)) if isinstance(branches, dict) else None
|
| 498 |
+
if not isinstance(branch, dict):
|
| 499 |
+
continue
|
| 500 |
+
writer_nodes = branch.get("writerNodes")
|
| 501 |
+
if isinstance(writer_nodes, list):
|
| 502 |
+
roots.update(str(node_id) for node_id in writer_nodes if str(node_id) in prompt)
|
| 503 |
+
if not writer_nodes and router_node in prompt:
|
| 504 |
+
roots.add(router_node)
|
| 505 |
+
if not roots:
|
| 506 |
+
return None
|
| 507 |
+
|
| 508 |
+
return _prune_prompt_from_roots(
|
| 509 |
+
prompt,
|
| 510 |
+
roots,
|
| 511 |
+
_selected_app_switches(setup, prompt, run_inputs),
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def _execution_map_selected_value(
|
| 516 |
+
setup: dict[str, Any],
|
| 517 |
+
router: dict[str, Any],
|
| 518 |
+
run_inputs: dict[str, Any],
|
| 519 |
+
) -> int | None:
|
| 520 |
+
key = router.get("switchKey")
|
| 521 |
+
app = setup.get("setupSurface", {}).get("app", {})
|
| 522 |
+
switch = app.get("switch") if isinstance(app, dict) else None
|
| 523 |
+
if isinstance(switch, dict) and switch.get("key") == key:
|
| 524 |
+
return _selected_switch_value(switch, run_inputs)
|
| 525 |
+
selected = run_inputs.get(key) if isinstance(key, str) else None
|
| 526 |
+
if isinstance(selected, bool):
|
| 527 |
+
return None
|
| 528 |
+
if isinstance(selected, int):
|
| 529 |
+
return selected
|
| 530 |
+
if isinstance(selected, str) and selected.isdigit():
|
| 531 |
+
return int(selected)
|
| 532 |
+
return None
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def _validate_execution_map_inputs(setup: dict[str, Any], run_inputs: dict[str, Any]) -> list[str]:
|
| 536 |
+
execution_map = setup.get("executionMap")
|
| 537 |
+
if not isinstance(execution_map, dict) or execution_map.get("version") != 1:
|
| 538 |
+
return []
|
| 539 |
+
routers = execution_map.get("routers")
|
| 540 |
+
if not isinstance(routers, list):
|
| 541 |
+
return []
|
| 542 |
+
api_prompt = setup.get("apiPrompt")
|
| 543 |
+
prompt_node_ids = set(api_prompt) if isinstance(api_prompt, dict) else set()
|
| 544 |
+
app = setup.get("setupSurface", {}).get("app", {})
|
| 545 |
+
switch = app.get("switch") if isinstance(app, dict) else None
|
| 546 |
+
errors: list[str] = []
|
| 547 |
+
for router in routers:
|
| 548 |
+
if not isinstance(router, dict):
|
| 549 |
+
continue
|
| 550 |
+
selected_value = _execution_map_selected_value(setup, router, run_inputs)
|
| 551 |
+
key = router.get("switchKey")
|
| 552 |
+
if selected_value is None:
|
| 553 |
+
if isinstance(switch, dict) and switch.get("key") == key and isinstance(key, str):
|
| 554 |
+
if key not in run_inputs:
|
| 555 |
+
errors.append(_switch_value_error(key, switch))
|
| 556 |
+
elif isinstance(key, str):
|
| 557 |
+
errors.append(f"input '{key}' must select a valid execution branch")
|
| 558 |
+
continue
|
| 559 |
+
branches = router.get("branches")
|
| 560 |
+
if not isinstance(branches, dict) or str(selected_value) not in branches:
|
| 561 |
+
errors.append(f"input '{key}' selects branch {selected_value}, but this setup has no execution branch for it")
|
| 562 |
+
continue
|
| 563 |
+
branch = branches.get(str(selected_value))
|
| 564 |
+
writer_nodes = branch.get("writerNodes") if isinstance(branch, dict) else None
|
| 565 |
+
if isinstance(writer_nodes, list) and writer_nodes:
|
| 566 |
+
missing_writer_nodes = [
|
| 567 |
+
str(node_id)
|
| 568 |
+
for node_id in writer_nodes
|
| 569 |
+
if str(node_id) not in prompt_node_ids
|
| 570 |
+
]
|
| 571 |
+
if missing_writer_nodes:
|
| 572 |
+
errors.append(
|
| 573 |
+
f"execution map branch {selected_value} for switch '{key}' references "
|
| 574 |
+
f"writer node(s) not present in the prompt: {', '.join(missing_writer_nodes)}"
|
| 575 |
+
)
|
| 576 |
+
elif not writer_nodes and str(router.get("node", "")) not in prompt_node_ids:
|
| 577 |
+
errors.append(
|
| 578 |
+
f"execution map branch {selected_value} for switch '{key}' has no writer nodes "
|
| 579 |
+
"and its router node is not present in the prompt"
|
| 580 |
+
)
|
| 581 |
+
return errors
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def _prune_prompt_for_selected_app_results(
|
| 585 |
+
setup: dict[str, Any],
|
| 586 |
+
prompt: dict[str, Any],
|
| 587 |
+
run_inputs: dict[str, Any],
|
| 588 |
+
) -> dict[str, Any]:
|
| 589 |
+
result_switches = _selected_result_switches(setup, prompt, run_inputs)
|
| 590 |
+
if not result_switches:
|
| 591 |
+
return prompt
|
| 592 |
+
selected_switches = _selected_app_switches(setup, prompt, run_inputs)
|
| 593 |
+
|
| 594 |
+
roots = {
|
| 595 |
+
node_id
|
| 596 |
+
for node_id, _selected_value in result_switches.values()
|
| 597 |
+
if node_id in prompt
|
| 598 |
+
}
|
| 599 |
+
for field in _app_result_fields(setup):
|
| 600 |
+
target = field.get("target")
|
| 601 |
+
if isinstance(target, dict) and str(target.get("node")) in prompt:
|
| 602 |
+
roots.add(str(target["node"]))
|
| 603 |
+
|
| 604 |
+
selected_branch_nodes = {
|
| 605 |
+
str(prompt[switch_node_id]["inputs"][f"value{selected_value}"][0])
|
| 606 |
+
for switch_node_id, (_node_id, selected_value) in result_switches.items()
|
| 607 |
+
if (
|
| 608 |
+
switch_node_id in prompt
|
| 609 |
+
and isinstance(prompt[switch_node_id], dict)
|
| 610 |
+
and isinstance(prompt[switch_node_id].get("inputs"), dict)
|
| 611 |
+
and _is_api_ref(prompt[switch_node_id]["inputs"].get(f"value{selected_value}"))
|
| 612 |
+
)
|
| 613 |
+
}
|
| 614 |
+
roots.update(
|
| 615 |
+
_selected_output_descendant_roots(
|
| 616 |
+
prompt,
|
| 617 |
+
selected_branch_nodes,
|
| 618 |
+
_setup_output_surface_node_ids(setup),
|
| 619 |
+
)
|
| 620 |
+
)
|
| 621 |
+
if not roots:
|
| 622 |
+
return prompt
|
| 623 |
+
|
| 624 |
+
return _prune_prompt_from_roots(prompt, roots, selected_switches)
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def _prune_prompt_from_roots(
|
| 628 |
+
prompt: dict[str, Any],
|
| 629 |
+
roots: set[str],
|
| 630 |
+
selected_switches: dict[str, tuple[str, int]],
|
| 631 |
+
) -> dict[str, Any]:
|
| 632 |
+
keep: set[str] = set()
|
| 633 |
+
|
| 634 |
+
def visit(node_id: str) -> None:
|
| 635 |
+
if node_id in keep or node_id not in prompt:
|
| 636 |
+
return
|
| 637 |
+
keep.add(node_id)
|
| 638 |
+
node = prompt.get(node_id)
|
| 639 |
+
if not isinstance(node, dict):
|
| 640 |
+
return
|
| 641 |
+
inputs = node.get("inputs")
|
| 642 |
+
if not isinstance(inputs, dict):
|
| 643 |
+
return
|
| 644 |
+
selected_value = selected_switches.get(node_id, (None, None))[1]
|
| 645 |
+
for input_name, value in inputs.items():
|
| 646 |
+
if (
|
| 647 |
+
selected_value is not None
|
| 648 |
+
and input_name.startswith("value")
|
| 649 |
+
and input_name != f"value{selected_value}"
|
| 650 |
+
):
|
| 651 |
+
continue
|
| 652 |
+
for ref in _api_refs(value):
|
| 653 |
+
visit(str(ref[0]))
|
| 654 |
+
|
| 655 |
+
for root in roots:
|
| 656 |
+
visit(root)
|
| 657 |
+
if not keep:
|
| 658 |
+
return prompt
|
| 659 |
+
pruned = {node_id: deepcopy(node) for node_id, node in prompt.items() if node_id in keep}
|
| 660 |
+
for node_id, (_switch_node_id, selected_value) in selected_switches.items():
|
| 661 |
+
node = pruned.get(node_id)
|
| 662 |
+
if not isinstance(node, dict):
|
| 663 |
+
continue
|
| 664 |
+
inputs = node.get("inputs")
|
| 665 |
+
if not isinstance(inputs, dict):
|
| 666 |
+
continue
|
| 667 |
+
node["inputs"] = {
|
| 668 |
+
key: value
|
| 669 |
+
for key, value in inputs.items()
|
| 670 |
+
if not key.startswith("value") or key == f"value{selected_value}"
|
| 671 |
+
}
|
| 672 |
+
return pruned
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def _selected_result_switches(
|
| 676 |
+
setup: dict[str, Any],
|
| 677 |
+
api_prompt: dict[str, Any],
|
| 678 |
+
run_inputs: dict[str, Any],
|
| 679 |
+
) -> dict[str, tuple[str, int]]:
|
| 680 |
+
app = setup.get("setupSurface", {}).get("app", {})
|
| 681 |
+
if not isinstance(app, dict):
|
| 682 |
+
return {}
|
| 683 |
+
switch = app.get("switch")
|
| 684 |
+
if not isinstance(switch, dict):
|
| 685 |
+
return {}
|
| 686 |
+
selected_value = _selected_switch_value(switch, run_inputs)
|
| 687 |
+
if selected_value is None:
|
| 688 |
+
return {}
|
| 689 |
+
selected: dict[str, tuple[str, int]] = {}
|
| 690 |
+
for field in _app_result_fields(setup):
|
| 691 |
+
result_node_id = _result_switch_node_id(setup, field, run_inputs, api_prompt)
|
| 692 |
+
if result_node_id is not None:
|
| 693 |
+
selected[result_node_id] = (result_node_id, selected_value)
|
| 694 |
+
return selected
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
def _selected_app_switches(
|
| 698 |
+
setup: dict[str, Any],
|
| 699 |
+
api_prompt: dict[str, Any],
|
| 700 |
+
run_inputs: dict[str, Any],
|
| 701 |
+
) -> dict[str, tuple[str, int]]:
|
| 702 |
+
app = setup.get("setupSurface", {}).get("app", {})
|
| 703 |
+
if not isinstance(app, dict):
|
| 704 |
+
return {}
|
| 705 |
+
switch = app.get("switch")
|
| 706 |
+
if not isinstance(switch, dict):
|
| 707 |
+
return {}
|
| 708 |
+
switch_target = switch.get("target")
|
| 709 |
+
if not isinstance(switch_target, dict):
|
| 710 |
+
return {}
|
| 711 |
+
selected_value = _selected_switch_value(switch, run_inputs)
|
| 712 |
+
if selected_value is None:
|
| 713 |
+
return {}
|
| 714 |
+
|
| 715 |
+
selected: dict[str, tuple[str, int]] = {}
|
| 716 |
+
for node_id, node in api_prompt.items():
|
| 717 |
+
if not isinstance(node, dict):
|
| 718 |
+
continue
|
| 719 |
+
inputs = node.get("inputs")
|
| 720 |
+
if not isinstance(inputs, dict):
|
| 721 |
+
continue
|
| 722 |
+
selector_ref = _switch_selector_ref(inputs)
|
| 723 |
+
if not _selector_matches_switch_target(selector_ref, switch_target, api_prompt):
|
| 724 |
+
continue
|
| 725 |
+
if _is_api_ref(inputs.get(f"value{selected_value}")):
|
| 726 |
+
selected[str(node_id)] = (str(node_id), selected_value)
|
| 727 |
+
return selected
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
def _selected_output_descendant_roots(
|
| 731 |
+
api_prompt: dict[str, Any],
|
| 732 |
+
selected_branch_nodes: set[str],
|
| 733 |
+
output_surface_node_ids: set[str],
|
| 734 |
+
) -> set[str]:
|
| 735 |
+
if not selected_branch_nodes or not output_surface_node_ids:
|
| 736 |
+
return set()
|
| 737 |
+
children_by_node = _prompt_children_by_node(api_prompt)
|
| 738 |
+
roots: set[str] = set()
|
| 739 |
+
seen: set[str] = set()
|
| 740 |
+
pending = list(selected_branch_nodes)
|
| 741 |
+
while pending:
|
| 742 |
+
node_id = pending.pop()
|
| 743 |
+
if node_id in seen:
|
| 744 |
+
continue
|
| 745 |
+
seen.add(node_id)
|
| 746 |
+
for child_id in children_by_node.get(node_id, set()):
|
| 747 |
+
if child_id in output_surface_node_ids and child_id not in selected_branch_nodes:
|
| 748 |
+
roots.add(child_id)
|
| 749 |
+
pending.append(child_id)
|
| 750 |
+
return roots
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
def _prompt_children_by_node(api_prompt: dict[str, Any]) -> dict[str, set[str]]:
|
| 754 |
+
children: dict[str, set[str]] = {}
|
| 755 |
+
for node_id, node in api_prompt.items():
|
| 756 |
+
if not isinstance(node, dict):
|
| 757 |
+
continue
|
| 758 |
+
inputs = node.get("inputs")
|
| 759 |
+
if not isinstance(inputs, dict):
|
| 760 |
+
continue
|
| 761 |
+
for value in inputs.values():
|
| 762 |
+
for ref in _api_refs(value):
|
| 763 |
+
children.setdefault(str(ref[0]), set()).add(str(node_id))
|
| 764 |
+
return children
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
def _setup_output_surface_node_ids(setup: dict[str, Any]) -> set[str]:
|
| 768 |
+
setup_surface = setup.get("setupSurface", {})
|
| 769 |
+
if not isinstance(setup_surface, dict):
|
| 770 |
+
return set()
|
| 771 |
+
output_ids: set[str] = set()
|
| 772 |
+
outputs = setup_surface.get("outputs")
|
| 773 |
+
if not isinstance(outputs, list):
|
| 774 |
+
return output_ids
|
| 775 |
+
for group in outputs:
|
| 776 |
+
if not isinstance(group, dict) or not isinstance(group.get("nodes"), list):
|
| 777 |
+
continue
|
| 778 |
+
for node in group["nodes"]:
|
| 779 |
+
if isinstance(node, dict) and node.get("id") is not None:
|
| 780 |
+
output_ids.add(str(node["id"]))
|
| 781 |
+
return output_ids
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
def _app_result_fields(setup: dict[str, Any]) -> list[dict[str, Any]]:
|
| 785 |
+
app = setup.get("setupSurface", {}).get("app", {})
|
| 786 |
+
if not isinstance(app, dict) or not isinstance(app.get("results"), list):
|
| 787 |
+
return []
|
| 788 |
+
return [field for field in app["results"] if isinstance(field, dict)]
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
def _result_switch_node_id(
|
| 792 |
+
setup: dict[str, Any],
|
| 793 |
+
result_field: dict[str, Any],
|
| 794 |
+
run_inputs: dict[str, Any],
|
| 795 |
+
api_prompt: dict[str, Any] | None = None,
|
| 796 |
+
) -> str | None:
|
| 797 |
+
app = setup.get("setupSurface", {}).get("app", {})
|
| 798 |
+
if not isinstance(app, dict):
|
| 799 |
+
return None
|
| 800 |
+
switch = app.get("switch")
|
| 801 |
+
if not isinstance(switch, dict):
|
| 802 |
+
return None
|
| 803 |
+
selected_value = _selected_switch_value(switch, run_inputs)
|
| 804 |
+
if selected_value is None:
|
| 805 |
+
return None
|
| 806 |
+
|
| 807 |
+
api_prompt = api_prompt if isinstance(api_prompt, dict) else setup.get("apiPrompt")
|
| 808 |
+
if not isinstance(api_prompt, dict):
|
| 809 |
+
return None
|
| 810 |
+
target = result_field.get("target")
|
| 811 |
+
if not isinstance(target, dict):
|
| 812 |
+
return None
|
| 813 |
+
result_node = api_prompt.get(str(target.get("node")))
|
| 814 |
+
if not isinstance(result_node, dict):
|
| 815 |
+
return None
|
| 816 |
+
result_inputs = result_node.get("inputs")
|
| 817 |
+
if not isinstance(result_inputs, dict):
|
| 818 |
+
return None
|
| 819 |
+
result_ref = result_inputs.get(str(target.get("input")))
|
| 820 |
+
if not _is_api_ref(result_ref):
|
| 821 |
+
return None
|
| 822 |
+
|
| 823 |
+
switch_node_id = str(result_ref[0])
|
| 824 |
+
switch_node = api_prompt.get(switch_node_id)
|
| 825 |
+
if not isinstance(switch_node, dict):
|
| 826 |
+
return None
|
| 827 |
+
switch_inputs = switch_node.get("inputs")
|
| 828 |
+
if not isinstance(switch_inputs, dict):
|
| 829 |
+
return None
|
| 830 |
+
selector_ref = _switch_selector_ref(switch_inputs)
|
| 831 |
+
switch_target = switch.get("target")
|
| 832 |
+
if isinstance(switch_target, dict) and not _selector_matches_switch_target(
|
| 833 |
+
selector_ref,
|
| 834 |
+
switch_target,
|
| 835 |
+
api_prompt,
|
| 836 |
+
):
|
| 837 |
+
return None
|
| 838 |
+
if not _is_api_ref(switch_inputs.get(f"value{selected_value}")):
|
| 839 |
+
return None
|
| 840 |
+
return switch_node_id
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
def _selected_result_branch_ref(
|
| 844 |
+
setup: dict[str, Any],
|
| 845 |
+
result_field: dict[str, Any],
|
| 846 |
+
run_inputs: dict[str, Any],
|
| 847 |
+
) -> list[Any] | None:
|
| 848 |
+
api_prompt = setup.get("apiPrompt")
|
| 849 |
+
if not isinstance(api_prompt, dict):
|
| 850 |
+
return None
|
| 851 |
+
switch_node_id = _result_switch_node_id(setup, result_field, run_inputs, api_prompt)
|
| 852 |
+
if switch_node_id is None:
|
| 853 |
+
return None
|
| 854 |
+
switch_node = api_prompt.get(switch_node_id)
|
| 855 |
+
if not isinstance(switch_node, dict):
|
| 856 |
+
return None
|
| 857 |
+
switch_inputs = switch_node.get("inputs")
|
| 858 |
+
if not isinstance(switch_inputs, dict):
|
| 859 |
+
return None
|
| 860 |
+
switch = setup.get("setupSurface", {}).get("app", {}).get("switch")
|
| 861 |
+
selected_value = _selected_switch_value(switch, run_inputs) if isinstance(switch, dict) else None
|
| 862 |
+
if selected_value is None:
|
| 863 |
+
return None
|
| 864 |
+
branch_ref = switch_inputs.get(f"value{selected_value}")
|
| 865 |
+
return branch_ref if _is_api_ref(branch_ref) else None
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
def _selected_switch_value(switch: dict[str, Any], run_inputs: dict[str, Any]) -> int | None:
|
| 869 |
+
key = switch.get("key")
|
| 870 |
+
selected = run_inputs.get(key) if isinstance(key, str) and key in run_inputs else switch.get("default")
|
| 871 |
+
return _switch_option_value(switch, selected)
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
def _selector_matches_switch_target(selector_ref: Any, switch_target: dict[str, Any], api_prompt: dict[str, Any]) -> bool:
|
| 875 |
+
if not _is_api_ref(selector_ref):
|
| 876 |
+
return False
|
| 877 |
+
source_node_id = str(selector_ref[0])
|
| 878 |
+
target_node_id = str(switch_target.get("node"))
|
| 879 |
+
if source_node_id != target_node_id:
|
| 880 |
+
return False
|
| 881 |
+
source_node = api_prompt.get(source_node_id)
|
| 882 |
+
if not isinstance(source_node, dict):
|
| 883 |
+
return False
|
| 884 |
+
outputs = _publish_input_output_slots(source_node)
|
| 885 |
+
return outputs.get("switch") == int(selector_ref[1])
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
def _switch_selector_ref(switch_inputs: dict[str, Any]) -> Any:
|
| 889 |
+
if "select" in switch_inputs:
|
| 890 |
+
return switch_inputs.get("select")
|
| 891 |
+
return switch_inputs.get("index")
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
def _publish_input_output_slots(api_node: dict[str, Any]) -> dict[str, int]:
|
| 895 |
+
if api_node.get("class_type") != "Koolook_PublishInput":
|
| 896 |
+
return {}
|
| 897 |
+
return {
|
| 898 |
+
"sequence_folder": 0,
|
| 899 |
+
"qt_file": 1,
|
| 900 |
+
"single_file": 2,
|
| 901 |
+
"prompt": 3,
|
| 902 |
+
"switch": 4,
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
def _resolve_prompt_value(prompt: dict[str, Any], value: Any) -> Any:
|
| 907 |
+
if not _is_api_ref(value):
|
| 908 |
+
return value
|
| 909 |
+
node_id = str(value[0])
|
| 910 |
+
output_index = int(value[1])
|
| 911 |
+
node = prompt.get(node_id)
|
| 912 |
+
if not isinstance(node, dict):
|
| 913 |
+
return None
|
| 914 |
+
class_type = node.get("class_type")
|
| 915 |
+
inputs = node.get("inputs")
|
| 916 |
+
if not isinstance(inputs, dict):
|
| 917 |
+
return None
|
| 918 |
+
if class_type == "Koolook_PublishOutput":
|
| 919 |
+
values = (
|
| 920 |
+
inputs.get("folder"),
|
| 921 |
+
inputs.get("name"),
|
| 922 |
+
inputs.get("version"),
|
| 923 |
+
)
|
| 924 |
+
return values[output_index] if output_index < len(values) else None
|
| 925 |
+
if class_type == "EasyAIPipeline":
|
| 926 |
+
if "WRITE_file_path" in inputs:
|
| 927 |
+
values = (
|
| 928 |
+
inputs.get("WRITE_file_path"),
|
| 929 |
+
inputs.get("output_name", ""),
|
| 930 |
+
inputs.get("version_string", ""),
|
| 931 |
+
inputs.get("output_directory", ""),
|
| 932 |
+
inputs.get("shot_duration", 0),
|
| 933 |
+
inputs.get("seed_value", 0),
|
| 934 |
+
inputs.get("shot_name", ""),
|
| 935 |
+
)
|
| 936 |
+
return values[output_index] if output_index < len(values) else None
|
| 937 |
+
resolved_inputs = {
|
| 938 |
+
key: _resolve_prompt_value(prompt, input_value)
|
| 939 |
+
for key, input_value in inputs.items()
|
| 940 |
+
}
|
| 941 |
+
try:
|
| 942 |
+
try:
|
| 943 |
+
from .k_ai_pipeline import build_pipeline_outputs
|
| 944 |
+
except ImportError: # pragma: no cover - standalone test/import context
|
| 945 |
+
from k_ai_pipeline import build_pipeline_outputs
|
| 946 |
+
|
| 947 |
+
values = build_pipeline_outputs(
|
| 948 |
+
**resolved_inputs,
|
| 949 |
+
create_directory=False,
|
| 950 |
+
check_overwrite=False,
|
| 951 |
+
)
|
| 952 |
+
except Exception:
|
| 953 |
+
return None
|
| 954 |
+
return values[output_index] if output_index < len(values) else None
|
| 955 |
+
return None
|
| 956 |
+
|
| 957 |
+
|
| 958 |
+
def _is_api_ref(value: Any) -> bool:
|
| 959 |
+
return (
|
| 960 |
+
isinstance(value, list)
|
| 961 |
+
and len(value) == 2
|
| 962 |
+
and isinstance(value[0], (str, int))
|
| 963 |
+
and not isinstance(value[1], bool)
|
| 964 |
+
and isinstance(value[1], int)
|
| 965 |
+
)
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
def _api_refs(value: Any) -> list[list[Any]]:
|
| 969 |
+
if _is_api_ref(value):
|
| 970 |
+
return [value]
|
| 971 |
+
if isinstance(value, dict):
|
| 972 |
+
refs: list[list[Any]] = []
|
| 973 |
+
for child in value.values():
|
| 974 |
+
refs.extend(_api_refs(child))
|
| 975 |
+
return refs
|
| 976 |
+
if isinstance(value, list):
|
| 977 |
+
refs = []
|
| 978 |
+
for child in value:
|
| 979 |
+
refs.extend(_api_refs(child))
|
| 980 |
+
return refs
|
| 981 |
+
return []
|
| 982 |
+
|
| 983 |
+
|
| 984 |
+
def _flatten_history_outputs(raw_outputs: Any) -> list[dict[str, Any]]:
|
| 985 |
+
if not isinstance(raw_outputs, dict):
|
| 986 |
+
return []
|
| 987 |
+
items: list[dict[str, Any]] = []
|
| 988 |
+
for node_id, node_outputs in raw_outputs.items():
|
| 989 |
+
if not isinstance(node_outputs, dict):
|
| 990 |
+
continue
|
| 991 |
+
for kind, values in node_outputs.items():
|
| 992 |
+
if not isinstance(values, list):
|
| 993 |
+
continue
|
| 994 |
+
for value in values:
|
| 995 |
+
item = {"nodeId": str(node_id), "kind": str(kind)}
|
| 996 |
+
if isinstance(value, dict):
|
| 997 |
+
item.update(value)
|
| 998 |
+
else:
|
| 999 |
+
item["value"] = value
|
| 1000 |
+
items.append(item)
|
| 1001 |
+
return items
|
| 1002 |
+
|
| 1003 |
+
|
| 1004 |
+
def _history_output_matches_contract(item: dict[str, Any], contract_type: str) -> bool:
|
| 1005 |
+
kind = str(item.get("kind", "")).lower()
|
| 1006 |
+
if contract_type == "video":
|
| 1007 |
+
return kind in {"video", "videos", "animated"}
|
| 1008 |
+
if contract_type == "image":
|
| 1009 |
+
return kind in {"image", "images"}
|
| 1010 |
+
return True
|
ComfyUI/custom_nodes/koolook/koolook_setups.py
ADDED
|
@@ -0,0 +1,2032 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Published workflow setup registry boundary.
|
| 2 |
+
|
| 3 |
+
This module owns the server-side shape for externally callable Koolook
|
| 4 |
+
setups. Callers use this boundary instead of reading sidebar workflow or
|
| 5 |
+
snapshot storage directly.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from copy import deepcopy
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
import json
|
| 13 |
+
import math
|
| 14 |
+
import os
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
import re
|
| 17 |
+
import tempfile
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
DEFAULT_SETUPS_SUBDIR = "koolook-published-setups"
|
| 22 |
+
DEFAULT_SETUPS_FILENAME = "setups.json"
|
| 23 |
+
SAMPLE_SETUPS_PATH = (
|
| 24 |
+
Path(__file__).resolve().parent / "web" / "published_setups_sample.json"
|
| 25 |
+
)
|
| 26 |
+
SUPPORTED_SCHEMA_VERSION = 1
|
| 27 |
+
SETUP_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
| 28 |
+
VALIDATION_STATUSES = {"valid", "draft", "invalid"}
|
| 29 |
+
PUBLISH_INPUT_CLASS = "Koolook_PublishInput"
|
| 30 |
+
PUBLISH_OUTPUT_CLASS = "Koolook_PublishOutput"
|
| 31 |
+
PUBLISH_RESULT_CLASS = "Koolook_PublishResult"
|
| 32 |
+
PUBLISH_ROUTER_CLASS = "Koolook_PublishRouter"
|
| 33 |
+
VISUAL_ONLY_API_NODE_TYPES = {"Label (rgthree)", "Note"}
|
| 34 |
+
SINK_ONLY_API_NODE_TYPES = {"SetNode"}
|
| 35 |
+
PUBLISH_INPUT_FIELDS = (
|
| 36 |
+
("sequence_folder", "Sequence folder", True),
|
| 37 |
+
("qt_file", "QT file", True),
|
| 38 |
+
("single_file", "Single file", True),
|
| 39 |
+
)
|
| 40 |
+
# The prompt is an always-on field, independent of the EXR/QT/Img source switch
|
| 41 |
+
# (an external user picks a source AND can describe the shot). The author's
|
| 42 |
+
# prompt-widget text becomes a placeholder hint; the submitted default is empty
|
| 43 |
+
# so an untouched hint is never sent as the real prompt. Mirror of the JS in
|
| 44 |
+
# web/sidebar/published_surface.js -- keep the two in sync.
|
| 45 |
+
PUBLISH_PROMPT_HELP = "Describe the shot in one simple line: subject + action + setting."
|
| 46 |
+
PUBLISH_INPUT_MODES = (
|
| 47 |
+
(0, "EXR", "sequence_folder"),
|
| 48 |
+
(1, "QT", "qt_file"),
|
| 49 |
+
(2, "Img", "single_file"),
|
| 50 |
+
(3, "Prompt", "prompt"),
|
| 51 |
+
)
|
| 52 |
+
PUBLISH_OUTPUT_FIELDS = (
|
| 53 |
+
("folder", "Output folder", True),
|
| 54 |
+
("name", "Output name", True),
|
| 55 |
+
("version", "Version", True),
|
| 56 |
+
)
|
| 57 |
+
PUBLISH_RESULT_FIELDS = (
|
| 58 |
+
("result", "Result", True),
|
| 59 |
+
)
|
| 60 |
+
WIDGET_ONLY_INPUTS_BY_CLASS = {
|
| 61 |
+
"Text Multiline": ("text",),
|
| 62 |
+
"Koolook_PublishInput": ("mode", "sequence_folder", "qt_file", "single_file", "prompt"),
|
| 63 |
+
"Koolook_PublishOutput": ("folder", "name", "version"),
|
| 64 |
+
"Koolook_PublishResult": ("result",),
|
| 65 |
+
"Koolook_PublishRouter": (),
|
| 66 |
+
"EasyAIPipeline": (
|
| 67 |
+
"shot_duration",
|
| 68 |
+
"seed_value",
|
| 69 |
+
"instruction",
|
| 70 |
+
"base_directory_path",
|
| 71 |
+
"extension",
|
| 72 |
+
"shot_name",
|
| 73 |
+
"ai_method",
|
| 74 |
+
"version",
|
| 75 |
+
"disable_versioning",
|
| 76 |
+
"enable_overwrite",
|
| 77 |
+
"no_subfolders",
|
| 78 |
+
),
|
| 79 |
+
}
|
| 80 |
+
WIDGET_ONLY_INPUT_DEFAULTS = {
|
| 81 |
+
"EasyAIPipeline": {
|
| 82 |
+
"no_subfolders": False,
|
| 83 |
+
},
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
REQUIRED_SETUP_KEYS = (
|
| 87 |
+
"schemaVersion",
|
| 88 |
+
"id",
|
| 89 |
+
"version",
|
| 90 |
+
"updatedAt",
|
| 91 |
+
"metadata",
|
| 92 |
+
"visualGraph",
|
| 93 |
+
"apiPrompt",
|
| 94 |
+
"inputContract",
|
| 95 |
+
"outputContract",
|
| 96 |
+
"source",
|
| 97 |
+
"validation",
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@dataclass(frozen=True)
|
| 102 |
+
class ValidationResult:
|
| 103 |
+
valid: bool
|
| 104 |
+
setup: dict[str, Any]
|
| 105 |
+
diagnostics: list[str]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@dataclass(frozen=True)
|
| 109 |
+
class ApiPromptConversionResult:
|
| 110 |
+
api_prompt: dict[str, Any] | None
|
| 111 |
+
diagnostics: list[str]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class StaticSetupStorage:
|
| 115 |
+
"""In-memory setup storage adapter for tests and seeded fixtures."""
|
| 116 |
+
|
| 117 |
+
def __init__(self, records: list[dict[str, Any]]) -> None:
|
| 118 |
+
self._records = records
|
| 119 |
+
self.diagnostics: list[str] = []
|
| 120 |
+
|
| 121 |
+
def load_setups(self) -> list[dict[str, Any]]:
|
| 122 |
+
return list(self._records)
|
| 123 |
+
|
| 124 |
+
def save_setups(self, records: list[dict[str, Any]]) -> None:
|
| 125 |
+
self._records = list(records)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class FileSetupStorage:
|
| 129 |
+
"""JSON-file storage adapter for published setup records."""
|
| 130 |
+
|
| 131 |
+
def __init__(self, path: Path, fallback_path: Path | None = None) -> None:
|
| 132 |
+
self.path = path
|
| 133 |
+
self.fallback_path = fallback_path
|
| 134 |
+
self.diagnostics: list[str] = []
|
| 135 |
+
|
| 136 |
+
def load_setups(self) -> list[dict[str, Any]]:
|
| 137 |
+
self.diagnostics = []
|
| 138 |
+
path = self.path
|
| 139 |
+
if not path.is_file() and self.fallback_path is not None:
|
| 140 |
+
path = self.fallback_path
|
| 141 |
+
self.diagnostics.append(
|
| 142 |
+
f"{self.path}: missing; using bundled sample {self.fallback_path}"
|
| 143 |
+
)
|
| 144 |
+
if not path.is_file():
|
| 145 |
+
return []
|
| 146 |
+
try:
|
| 147 |
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
| 148 |
+
except (OSError, ValueError) as exc:
|
| 149 |
+
self.diagnostics.append(f"{path}: could not read published setups: {exc}")
|
| 150 |
+
return []
|
| 151 |
+
if isinstance(raw, dict):
|
| 152 |
+
raw = raw.get("setups", [])
|
| 153 |
+
if not isinstance(raw, list):
|
| 154 |
+
self.diagnostics.append(
|
| 155 |
+
f"{path}: published setup storage must be a list or {{setups: [...]}}"
|
| 156 |
+
)
|
| 157 |
+
return []
|
| 158 |
+
return [item for item in raw if isinstance(item, dict)]
|
| 159 |
+
|
| 160 |
+
def load_primary_setups(self) -> list[dict[str, Any]]:
|
| 161 |
+
self.diagnostics = []
|
| 162 |
+
if not self.path.is_file():
|
| 163 |
+
return []
|
| 164 |
+
try:
|
| 165 |
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
| 166 |
+
except (OSError, ValueError) as exc:
|
| 167 |
+
self.diagnostics.append(f"{self.path}: could not read published setups: {exc}")
|
| 168 |
+
return []
|
| 169 |
+
if isinstance(raw, dict):
|
| 170 |
+
raw = raw.get("setups", [])
|
| 171 |
+
if not isinstance(raw, list):
|
| 172 |
+
self.diagnostics.append(
|
| 173 |
+
f"{self.path}: published setup storage must be a list or {{setups: [...]}}"
|
| 174 |
+
)
|
| 175 |
+
return []
|
| 176 |
+
return [item for item in raw if isinstance(item, dict)]
|
| 177 |
+
|
| 178 |
+
def save_setups(self, records: list[dict[str, Any]]) -> None:
|
| 179 |
+
self.diagnostics = []
|
| 180 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 181 |
+
payload = {"setups": records}
|
| 182 |
+
tmp_fd, tmp_name = tempfile.mkstemp(
|
| 183 |
+
prefix=f"{self.path.stem}.",
|
| 184 |
+
suffix=".tmp",
|
| 185 |
+
dir=str(self.path.parent),
|
| 186 |
+
)
|
| 187 |
+
try:
|
| 188 |
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
| 189 |
+
json.dump(payload, f, indent=2)
|
| 190 |
+
f.write("\n")
|
| 191 |
+
os.replace(tmp_name, self.path)
|
| 192 |
+
except OSError:
|
| 193 |
+
try:
|
| 194 |
+
if os.path.exists(tmp_name):
|
| 195 |
+
os.unlink(tmp_name)
|
| 196 |
+
except OSError:
|
| 197 |
+
pass
|
| 198 |
+
raise
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class PublishedSetupRegistry:
|
| 202 |
+
"""Read-only registry facade for published setup catalog consumers."""
|
| 203 |
+
|
| 204 |
+
def __init__(self, storage: StaticSetupStorage | FileSetupStorage) -> None:
|
| 205 |
+
self._storage = storage
|
| 206 |
+
self.diagnostics: list[str] = []
|
| 207 |
+
|
| 208 |
+
@property
|
| 209 |
+
def storage_path(self) -> Path | None:
|
| 210 |
+
"""Filesystem path of the backing ``setups.json`` when the storage
|
| 211 |
+
adapter is file-backed; ``None`` for in-memory storage. Lets callers
|
| 212 |
+
surface where a publish landed without reaching into the adapter."""
|
| 213 |
+
path = getattr(self._storage, "path", None)
|
| 214 |
+
return path if isinstance(path, Path) else None
|
| 215 |
+
|
| 216 |
+
def _valid_setups(self) -> list[dict[str, Any]]:
|
| 217 |
+
self.diagnostics = []
|
| 218 |
+
out: list[dict[str, Any]] = []
|
| 219 |
+
for raw in self._storage.load_setups():
|
| 220 |
+
result = validate_setup(raw)
|
| 221 |
+
if result.valid:
|
| 222 |
+
out.append(result.setup)
|
| 223 |
+
continue
|
| 224 |
+
setup_id = raw.get("id", "<unknown>") if isinstance(raw, dict) else "<unknown>"
|
| 225 |
+
for diagnostic in result.diagnostics:
|
| 226 |
+
self.diagnostics.append(f"{setup_id}: {diagnostic}")
|
| 227 |
+
self.diagnostics.extend(getattr(self._storage, "diagnostics", []))
|
| 228 |
+
out.sort(key=lambda setup: str(setup["metadata"]["title"]).lower())
|
| 229 |
+
return out
|
| 230 |
+
|
| 231 |
+
def listSetups(self) -> list[dict[str, Any]]:
|
| 232 |
+
return [_catalog_summary(setup) for setup in self._valid_setups()]
|
| 233 |
+
|
| 234 |
+
def getSetup(self, setup_id: str) -> dict[str, Any] | None:
|
| 235 |
+
for setup in self._valid_setups():
|
| 236 |
+
if setup["id"] == setup_id:
|
| 237 |
+
return setup
|
| 238 |
+
return None
|
| 239 |
+
|
| 240 |
+
def publishSetup(
|
| 241 |
+
self,
|
| 242 |
+
*,
|
| 243 |
+
visualGraph: Any,
|
| 244 |
+
metadata: Any,
|
| 245 |
+
inputContract: Any,
|
| 246 |
+
outputContract: Any,
|
| 247 |
+
source: Any,
|
| 248 |
+
apiPrompt: Any = None,
|
| 249 |
+
) -> ValidationResult:
|
| 250 |
+
diagnostics = []
|
| 251 |
+
for name, value in (
|
| 252 |
+
("visualGraph", visualGraph),
|
| 253 |
+
("metadata", metadata),
|
| 254 |
+
("inputContract", inputContract),
|
| 255 |
+
("outputContract", outputContract),
|
| 256 |
+
("source", source),
|
| 257 |
+
):
|
| 258 |
+
if not isinstance(value, dict):
|
| 259 |
+
diagnostics.append(f"{name} must be an object")
|
| 260 |
+
if diagnostics:
|
| 261 |
+
return ValidationResult(False, {}, diagnostics)
|
| 262 |
+
|
| 263 |
+
if apiPrompt is None:
|
| 264 |
+
conversion = _convert_visual_graph_to_api_prompt(visualGraph)
|
| 265 |
+
if conversion.diagnostics:
|
| 266 |
+
return ValidationResult(False, {}, conversion.diagnostics)
|
| 267 |
+
api_prompt = conversion.api_prompt
|
| 268 |
+
elif isinstance(apiPrompt, dict):
|
| 269 |
+
api_prompt = deepcopy(apiPrompt)
|
| 270 |
+
else:
|
| 271 |
+
return ValidationResult(False, {}, ["apiPrompt must be an object when present"])
|
| 272 |
+
surface_geometry_diagnostics = []
|
| 273 |
+
if _uses_group_authored_surface(inputContract, outputContract):
|
| 274 |
+
surface_geometry_diagnostics = _validate_setup_surface_geometry(visualGraph)
|
| 275 |
+
if surface_geometry_diagnostics:
|
| 276 |
+
return ValidationResult(False, {}, surface_geometry_diagnostics)
|
| 277 |
+
setup = _build_draft_setup(
|
| 278 |
+
visualGraph=visualGraph,
|
| 279 |
+
apiPrompt=api_prompt,
|
| 280 |
+
metadata=metadata,
|
| 281 |
+
inputContract=inputContract,
|
| 282 |
+
outputContract=outputContract,
|
| 283 |
+
source=source,
|
| 284 |
+
)
|
| 285 |
+
result = validate_setup(setup)
|
| 286 |
+
diagnostics = list(result.diagnostics)
|
| 287 |
+
if result.valid:
|
| 288 |
+
setup = result.setup
|
| 289 |
+
diagnostics.extend(_validate_contract_targets(visualGraph, inputContract))
|
| 290 |
+
diagnostics.extend(_validate_api_prompt_contract_targets(setup.get("apiPrompt"), inputContract))
|
| 291 |
+
diagnostics.extend(_validate_setup_surface(setup, inputContract, outputContract))
|
| 292 |
+
if diagnostics:
|
| 293 |
+
return ValidationResult(False, {}, diagnostics)
|
| 294 |
+
|
| 295 |
+
load_for_write = getattr(self._storage, "load_primary_setups", self._storage.load_setups)
|
| 296 |
+
records = [
|
| 297 |
+
existing
|
| 298 |
+
for existing in load_for_write()
|
| 299 |
+
if existing.get("id") != setup["id"]
|
| 300 |
+
]
|
| 301 |
+
records.append(setup)
|
| 302 |
+
self._storage.save_setups(records)
|
| 303 |
+
return ValidationResult(True, setup, [])
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def validate_setup(value: Any) -> ValidationResult:
|
| 307 |
+
"""Validate one published setup object through the public schema boundary."""
|
| 308 |
+
diagnostics: list[str] = []
|
| 309 |
+
if not isinstance(value, dict):
|
| 310 |
+
return ValidationResult(False, {}, ["setup must be a JSON object"])
|
| 311 |
+
setup = deepcopy(value)
|
| 312 |
+
|
| 313 |
+
for key in REQUIRED_SETUP_KEYS:
|
| 314 |
+
if key not in value:
|
| 315 |
+
diagnostics.append(f"missing required field: {key}")
|
| 316 |
+
|
| 317 |
+
if diagnostics:
|
| 318 |
+
return ValidationResult(False, {}, diagnostics)
|
| 319 |
+
|
| 320 |
+
_validate_scalar(setup, "id", str, diagnostics)
|
| 321 |
+
if isinstance(setup.get("id"), str) and not SETUP_ID_RE.match(setup["id"]):
|
| 322 |
+
diagnostics.append("id must be stable URL-safe text")
|
| 323 |
+
_validate_scalar(setup, "schemaVersion", int, diagnostics)
|
| 324 |
+
if setup.get("schemaVersion") != SUPPORTED_SCHEMA_VERSION:
|
| 325 |
+
diagnostics.append(
|
| 326 |
+
f"unsupported schemaVersion: {setup.get('schemaVersion')}; "
|
| 327 |
+
f"expected {SUPPORTED_SCHEMA_VERSION}"
|
| 328 |
+
)
|
| 329 |
+
_validate_scalar(setup, "version", (int, str), diagnostics)
|
| 330 |
+
_validate_iso_timestamp(setup.get("updatedAt"), diagnostics)
|
| 331 |
+
_validate_metadata(setup.get("metadata"), diagnostics)
|
| 332 |
+
_validate_mapping(setup.get("visualGraph"), "visualGraph", diagnostics)
|
| 333 |
+
if setup.get("apiPrompt") is not None:
|
| 334 |
+
_validate_mapping(setup.get("apiPrompt"), "apiPrompt", diagnostics)
|
| 335 |
+
if isinstance(setup.get("visualGraph"), dict) and isinstance(setup.get("apiPrompt"), dict):
|
| 336 |
+
conversion = None
|
| 337 |
+
if _api_prompt_needs_visual_normalization(setup["apiPrompt"], setup["visualGraph"]):
|
| 338 |
+
conversion = _convert_visual_graph_to_api_prompt(setup["visualGraph"])
|
| 339 |
+
if conversion is not None and conversion.diagnostics:
|
| 340 |
+
diagnostics.extend(conversion.diagnostics)
|
| 341 |
+
elif conversion is not None and conversion.api_prompt is not None:
|
| 342 |
+
setup["apiPrompt"] = conversion.api_prompt
|
| 343 |
+
_validate_contract(setup.get("inputContract"), "inputContract", "inputs", diagnostics)
|
| 344 |
+
_validate_contract(setup.get("outputContract"), "outputContract", "outputs", diagnostics)
|
| 345 |
+
_validate_persisted_setup_surface(setup, diagnostics)
|
| 346 |
+
_validate_source(setup.get("source"), diagnostics)
|
| 347 |
+
_validate_validation(setup.get("validation"), diagnostics)
|
| 348 |
+
if (
|
| 349 |
+
isinstance(setup.get("validation"), dict)
|
| 350 |
+
and setup["validation"].get("status") == "valid"
|
| 351 |
+
and setup.get("apiPrompt") is None
|
| 352 |
+
):
|
| 353 |
+
diagnostics.append("validation.status valid requires apiPrompt")
|
| 354 |
+
|
| 355 |
+
return ValidationResult(
|
| 356 |
+
valid=not diagnostics,
|
| 357 |
+
setup=setup if not diagnostics else {},
|
| 358 |
+
diagnostics=diagnostics,
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def _build_draft_setup(
|
| 363 |
+
*,
|
| 364 |
+
visualGraph: dict[str, Any],
|
| 365 |
+
apiPrompt: dict[str, Any] | None,
|
| 366 |
+
metadata: dict[str, Any],
|
| 367 |
+
inputContract: dict[str, Any],
|
| 368 |
+
outputContract: dict[str, Any],
|
| 369 |
+
source: dict[str, Any],
|
| 370 |
+
) -> dict[str, Any]:
|
| 371 |
+
title = metadata.get("title", "")
|
| 372 |
+
setup_id = metadata.get("id", "")
|
| 373 |
+
setup_surface = _infer_setup_surface(visualGraph)
|
| 374 |
+
setup = {
|
| 375 |
+
"schemaVersion": SUPPORTED_SCHEMA_VERSION,
|
| 376 |
+
"id": setup_id,
|
| 377 |
+
"version": 1,
|
| 378 |
+
"updatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
| 379 |
+
"metadata": {
|
| 380 |
+
"title": title,
|
| 381 |
+
"description": metadata.get("description", ""),
|
| 382 |
+
"category": metadata.get("category", ""),
|
| 383 |
+
"tags": metadata.get("tags", []),
|
| 384 |
+
"previewImage": metadata.get("previewImage", ""),
|
| 385 |
+
},
|
| 386 |
+
"visualGraph": visualGraph,
|
| 387 |
+
"apiPrompt": apiPrompt,
|
| 388 |
+
"inputContract": inputContract,
|
| 389 |
+
"outputContract": outputContract,
|
| 390 |
+
"setupSurface": setup_surface,
|
| 391 |
+
"source": source,
|
| 392 |
+
"validation": {
|
| 393 |
+
"status": "valid" if apiPrompt is not None else "draft",
|
| 394 |
+
"diagnostics": [] if apiPrompt is not None else ["API prompt conversion pending."],
|
| 395 |
+
},
|
| 396 |
+
}
|
| 397 |
+
execution_map = _build_execution_map(setup)
|
| 398 |
+
if execution_map is not None:
|
| 399 |
+
setup["executionMap"] = execution_map
|
| 400 |
+
return setup
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def _infer_setup_surface(visual_graph: dict[str, Any]) -> dict[str, Any]:
|
| 404 |
+
return {
|
| 405 |
+
"sourceInputs": _nodes_in_named_groups(visual_graph, "Koolook Input"),
|
| 406 |
+
"outputs": _nodes_in_named_groups(visual_graph, "Koolook Output"),
|
| 407 |
+
"controls": [],
|
| 408 |
+
"app": _infer_app_surface(visual_graph),
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _infer_app_surface(visual_graph: dict[str, Any]) -> dict[str, Any]:
|
| 413 |
+
input_nodes = _nodes_in_group(visual_graph, "Koolook Input")
|
| 414 |
+
output_nodes = _nodes_in_group(visual_graph, "Koolook Output")
|
| 415 |
+
input_node = _first_node_of_type(input_nodes, PUBLISH_INPUT_CLASS)
|
| 416 |
+
output_node = _first_node_of_type(output_nodes, PUBLISH_OUTPUT_CLASS)
|
| 417 |
+
result_node = _first_node_of_type(output_nodes, PUBLISH_RESULT_CLASS)
|
| 418 |
+
inputs = _publish_input_fields(input_node)
|
| 419 |
+
prompt = _publish_prompt_field(input_node)
|
| 420 |
+
if prompt is not None:
|
| 421 |
+
inputs.append(prompt)
|
| 422 |
+
outputs = _publish_output_fields(output_node)
|
| 423 |
+
app: dict[str, Any] = {
|
| 424 |
+
"inputs": inputs,
|
| 425 |
+
"outputs": outputs,
|
| 426 |
+
"results": _publish_result_fields(result_node),
|
| 427 |
+
}
|
| 428 |
+
if input_node is not None:
|
| 429 |
+
app["switch"] = _publish_input_switch(input_node, inputs)
|
| 430 |
+
return app
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def _first_node_of_type(
|
| 434 |
+
nodes: list[dict[str, Any]],
|
| 435 |
+
class_type: str,
|
| 436 |
+
) -> dict[str, Any] | None:
|
| 437 |
+
for node in nodes:
|
| 438 |
+
if node.get("type") == class_type:
|
| 439 |
+
return node
|
| 440 |
+
return None
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def _publish_input_fields(node: dict[str, Any] | None) -> list[dict[str, Any]]:
|
| 444 |
+
if node is None:
|
| 445 |
+
return []
|
| 446 |
+
return [
|
| 447 |
+
{
|
| 448 |
+
"key": key,
|
| 449 |
+
"label": label,
|
| 450 |
+
"visible": visible,
|
| 451 |
+
"target": {
|
| 452 |
+
"node": str(node.get("id")),
|
| 453 |
+
"input": key,
|
| 454 |
+
},
|
| 455 |
+
"default": _app_widget_value(node, key),
|
| 456 |
+
}
|
| 457 |
+
for key, label, visible in PUBLISH_INPUT_FIELDS
|
| 458 |
+
]
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def _publish_output_fields(node: dict[str, Any] | None) -> list[dict[str, Any]]:
|
| 462 |
+
if node is None:
|
| 463 |
+
return []
|
| 464 |
+
return [
|
| 465 |
+
{
|
| 466 |
+
"key": key,
|
| 467 |
+
"label": label,
|
| 468 |
+
"visible": visible,
|
| 469 |
+
"target": {
|
| 470 |
+
"node": str(node.get("id")),
|
| 471 |
+
"input": key,
|
| 472 |
+
},
|
| 473 |
+
"default": _app_widget_value(node, key),
|
| 474 |
+
}
|
| 475 |
+
for key, label, visible in PUBLISH_OUTPUT_FIELDS
|
| 476 |
+
]
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _publish_result_fields(node: dict[str, Any] | None) -> list[dict[str, Any]]:
|
| 480 |
+
if node is None:
|
| 481 |
+
return []
|
| 482 |
+
return [
|
| 483 |
+
{
|
| 484 |
+
"key": key,
|
| 485 |
+
"label": label,
|
| 486 |
+
"visible": visible,
|
| 487 |
+
"target": {
|
| 488 |
+
"node": str(node.get("id")),
|
| 489 |
+
"input": key,
|
| 490 |
+
},
|
| 491 |
+
"default": _app_widget_value(node, key),
|
| 492 |
+
}
|
| 493 |
+
for key, label, visible in PUBLISH_RESULT_FIELDS
|
| 494 |
+
]
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def _publish_prompt_field(node: dict[str, Any] | None) -> dict[str, Any] | None:
|
| 498 |
+
if node is None:
|
| 499 |
+
return None
|
| 500 |
+
hint = _app_widget_value(node, "prompt")
|
| 501 |
+
return {
|
| 502 |
+
"key": "prompt",
|
| 503 |
+
"label": "Prompt",
|
| 504 |
+
"visible": True,
|
| 505 |
+
"standalone": True,
|
| 506 |
+
"multiline": True,
|
| 507 |
+
"target": {"node": str(node.get("id")), "input": "prompt"},
|
| 508 |
+
"default": "",
|
| 509 |
+
"placeholder": hint if isinstance(hint, str) else "",
|
| 510 |
+
"help": PUBLISH_PROMPT_HELP,
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def _publish_input_switch(
|
| 515 |
+
node: dict[str, Any],
|
| 516 |
+
inputs: list[dict[str, Any]],
|
| 517 |
+
) -> dict[str, Any]:
|
| 518 |
+
inputs_by_key = {item["key"]: item for item in inputs}
|
| 519 |
+
return {
|
| 520 |
+
"key": "switch",
|
| 521 |
+
"label": "Input type",
|
| 522 |
+
"visible": True,
|
| 523 |
+
"target": {"node": str(node.get("id")), "input": "mode"},
|
| 524 |
+
"default": _publish_input_mode_index(_app_widget_value(node, "mode")),
|
| 525 |
+
"options": [
|
| 526 |
+
{
|
| 527 |
+
"value": value,
|
| 528 |
+
"label": label,
|
| 529 |
+
# Standalone fields (e.g. the always-on prompt) are not source
|
| 530 |
+
# modes, so they never appear as a switch option.
|
| 531 |
+
"visible": bool(inputs_by_key.get(input_key, {}).get("visible"))
|
| 532 |
+
and not inputs_by_key.get(input_key, {}).get("standalone"),
|
| 533 |
+
"input": input_key,
|
| 534 |
+
}
|
| 535 |
+
for value, label, input_key in PUBLISH_INPUT_MODES
|
| 536 |
+
],
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def _publish_input_mode_index(value: Any) -> int:
|
| 541 |
+
if isinstance(value, int):
|
| 542 |
+
return value
|
| 543 |
+
for mode_value, label, _input_key in PUBLISH_INPUT_MODES:
|
| 544 |
+
if str(value).strip().lower() == label.lower():
|
| 545 |
+
return mode_value
|
| 546 |
+
return 2
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def _build_execution_map(setup: dict[str, Any]) -> dict[str, Any] | None:
|
| 550 |
+
api_prompt = setup.get("apiPrompt")
|
| 551 |
+
if not isinstance(api_prompt, dict):
|
| 552 |
+
return None
|
| 553 |
+
app = setup.get("setupSurface", {}).get("app", {})
|
| 554 |
+
switch = app.get("switch") if isinstance(app, dict) else None
|
| 555 |
+
switch_target = switch.get("target") if isinstance(switch, dict) else None
|
| 556 |
+
if not isinstance(switch_target, dict):
|
| 557 |
+
return None
|
| 558 |
+
output_surface_ids = _setup_output_surface_node_ids(setup)
|
| 559 |
+
if not output_surface_ids:
|
| 560 |
+
return None
|
| 561 |
+
routers: list[dict[str, Any]] = []
|
| 562 |
+
for node_id, node in api_prompt.items():
|
| 563 |
+
if not isinstance(node, dict) or node.get("class_type") != PUBLISH_ROUTER_CLASS:
|
| 564 |
+
continue
|
| 565 |
+
inputs = node.get("inputs")
|
| 566 |
+
if not isinstance(inputs, dict):
|
| 567 |
+
continue
|
| 568 |
+
selector_ref = inputs.get("selector")
|
| 569 |
+
payload_ref = inputs.get("payload")
|
| 570 |
+
if not _api_ref_matches_target_output(selector_ref, switch_target, api_prompt, "switch"):
|
| 571 |
+
continue
|
| 572 |
+
branches: dict[str, dict[str, Any]] = {}
|
| 573 |
+
for value, label, _input_key in PUBLISH_INPUT_MODES:
|
| 574 |
+
branches[str(value)] = {
|
| 575 |
+
"label": label,
|
| 576 |
+
"output": value,
|
| 577 |
+
"writerNodes": _writer_nodes_for_router_output(
|
| 578 |
+
api_prompt,
|
| 579 |
+
str(node_id),
|
| 580 |
+
value,
|
| 581 |
+
output_surface_ids,
|
| 582 |
+
),
|
| 583 |
+
}
|
| 584 |
+
router: dict[str, Any] = {
|
| 585 |
+
"node": str(node_id),
|
| 586 |
+
"switchKey": str(switch.get("key", "switch")),
|
| 587 |
+
"selector": {"node": str(selector_ref[0]), "output": int(selector_ref[1])},
|
| 588 |
+
"branches": branches,
|
| 589 |
+
}
|
| 590 |
+
if _is_api_ref(payload_ref):
|
| 591 |
+
router["payload"] = {"node": str(payload_ref[0]), "output": int(payload_ref[1])}
|
| 592 |
+
routers.append(router)
|
| 593 |
+
if not routers:
|
| 594 |
+
return None
|
| 595 |
+
routers.sort(key=lambda item: item["node"])
|
| 596 |
+
return {"version": 1, "routers": routers}
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def _writer_nodes_for_router_output(
|
| 600 |
+
api_prompt: dict[str, Any],
|
| 601 |
+
router_node_id: str,
|
| 602 |
+
router_output: int,
|
| 603 |
+
output_surface_ids: set[str],
|
| 604 |
+
) -> list[str]:
|
| 605 |
+
children = _prompt_children_by_ref(api_prompt)
|
| 606 |
+
out: list[str] = []
|
| 607 |
+
seen: set[str] = set()
|
| 608 |
+
pending = list(children.get((router_node_id, router_output), []))
|
| 609 |
+
while pending:
|
| 610 |
+
node_id = pending.pop(0)
|
| 611 |
+
if node_id in seen:
|
| 612 |
+
continue
|
| 613 |
+
seen.add(node_id)
|
| 614 |
+
if node_id in output_surface_ids and node_id != router_node_id:
|
| 615 |
+
out.append(node_id)
|
| 616 |
+
for child_ids in _children_for_node(children, node_id):
|
| 617 |
+
pending.extend(child_ids)
|
| 618 |
+
return sorted(dict.fromkeys(out))
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
def _children_for_node(
|
| 622 |
+
children: dict[tuple[str, int], list[str]],
|
| 623 |
+
node_id: str,
|
| 624 |
+
) -> list[list[str]]:
|
| 625 |
+
return [
|
| 626 |
+
child_ids
|
| 627 |
+
for (source_node_id, _source_slot), child_ids in children.items()
|
| 628 |
+
if source_node_id == node_id
|
| 629 |
+
]
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def _prompt_children_by_ref(api_prompt: dict[str, Any]) -> dict[tuple[str, int], list[str]]:
|
| 633 |
+
children: dict[tuple[str, int], list[str]] = {}
|
| 634 |
+
for node_id, node in api_prompt.items():
|
| 635 |
+
if not isinstance(node, dict):
|
| 636 |
+
continue
|
| 637 |
+
inputs = node.get("inputs")
|
| 638 |
+
if not isinstance(inputs, dict):
|
| 639 |
+
continue
|
| 640 |
+
for value in inputs.values():
|
| 641 |
+
for ref in _api_refs(value):
|
| 642 |
+
children.setdefault((str(ref[0]), int(ref[1])), []).append(str(node_id))
|
| 643 |
+
return children
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def _setup_output_surface_node_ids(setup: dict[str, Any]) -> set[str]:
|
| 647 |
+
surface = setup.get("setupSurface")
|
| 648 |
+
if not isinstance(surface, dict) or not isinstance(surface.get("outputs"), list):
|
| 649 |
+
return set()
|
| 650 |
+
out: set[str] = set()
|
| 651 |
+
for group in surface["outputs"]:
|
| 652 |
+
if not isinstance(group, dict) or not isinstance(group.get("nodes"), list):
|
| 653 |
+
continue
|
| 654 |
+
for node in group["nodes"]:
|
| 655 |
+
if isinstance(node, dict) and node.get("id") is not None:
|
| 656 |
+
out.add(str(node["id"]))
|
| 657 |
+
return out
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def _api_ref_matches_target_output(
|
| 661 |
+
value: Any,
|
| 662 |
+
target: dict[str, Any],
|
| 663 |
+
api_prompt: dict[str, Any],
|
| 664 |
+
output_name: str,
|
| 665 |
+
) -> bool:
|
| 666 |
+
if not _is_api_ref(value):
|
| 667 |
+
return False
|
| 668 |
+
node_id = str(value[0])
|
| 669 |
+
if node_id != str(target.get("node")):
|
| 670 |
+
return False
|
| 671 |
+
node = api_prompt.get(node_id)
|
| 672 |
+
if not isinstance(node, dict):
|
| 673 |
+
return False
|
| 674 |
+
return _publish_input_output_slots(node).get(output_name) == int(value[1])
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
def _publish_input_output_slots(api_node: dict[str, Any]) -> dict[str, int]:
|
| 678 |
+
if api_node.get("class_type") != PUBLISH_INPUT_CLASS:
|
| 679 |
+
return {}
|
| 680 |
+
return {
|
| 681 |
+
"sequence_folder": 0,
|
| 682 |
+
"qt_file": 1,
|
| 683 |
+
"single_file": 2,
|
| 684 |
+
"prompt": 3,
|
| 685 |
+
"switch": 4,
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def _is_api_ref(value: Any) -> bool:
|
| 690 |
+
return (
|
| 691 |
+
isinstance(value, list)
|
| 692 |
+
and len(value) == 2
|
| 693 |
+
and isinstance(value[0], (str, int))
|
| 694 |
+
and not isinstance(value[1], bool)
|
| 695 |
+
and isinstance(value[1], int)
|
| 696 |
+
)
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def _api_refs(value: Any) -> list[list[Any]]:
|
| 700 |
+
if _is_api_ref(value):
|
| 701 |
+
return [value]
|
| 702 |
+
if isinstance(value, dict):
|
| 703 |
+
refs: list[list[Any]] = []
|
| 704 |
+
for child in value.values():
|
| 705 |
+
refs.extend(_api_refs(child))
|
| 706 |
+
return refs
|
| 707 |
+
if isinstance(value, list):
|
| 708 |
+
refs = []
|
| 709 |
+
for child in value:
|
| 710 |
+
refs.extend(_api_refs(child))
|
| 711 |
+
return refs
|
| 712 |
+
return []
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def _app_widget_value(node: dict[str, Any], key: str) -> Any:
|
| 716 |
+
widget_values = node.get("widgets_values")
|
| 717 |
+
if isinstance(widget_values, dict):
|
| 718 |
+
return widget_values.get(key)
|
| 719 |
+
if isinstance(widget_values, list):
|
| 720 |
+
widget_names = WIDGET_ONLY_INPUTS_BY_CLASS.get(str(node.get("type")), ())
|
| 721 |
+
for index, name in enumerate(widget_names):
|
| 722 |
+
if name == key and index < len(widget_values):
|
| 723 |
+
return widget_values[index]
|
| 724 |
+
return None
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
def _validate_setup_surface(
|
| 728 |
+
setup: dict[str, Any],
|
| 729 |
+
input_contract: dict[str, Any],
|
| 730 |
+
output_contract: dict[str, Any],
|
| 731 |
+
) -> list[str]:
|
| 732 |
+
if not _uses_group_authored_surface(input_contract, output_contract):
|
| 733 |
+
return []
|
| 734 |
+
surface = setup.get("setupSurface")
|
| 735 |
+
diagnostics: list[str] = []
|
| 736 |
+
if not isinstance(surface, dict) or not surface.get("sourceInputs"):
|
| 737 |
+
diagnostics.append("setupSurface.sourceInputs requires a non-empty Koolook Input group")
|
| 738 |
+
if not isinstance(surface, dict) or not surface.get("outputs"):
|
| 739 |
+
diagnostics.append("setupSurface.outputs requires a non-empty Koolook Output group")
|
| 740 |
+
return diagnostics
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
def _validate_persisted_setup_surface(
|
| 744 |
+
setup: dict[str, Any],
|
| 745 |
+
diagnostics: list[str],
|
| 746 |
+
) -> None:
|
| 747 |
+
surface = setup.get("setupSurface")
|
| 748 |
+
input_contract = setup.get("inputContract")
|
| 749 |
+
output_contract = setup.get("outputContract")
|
| 750 |
+
group_authored = (
|
| 751 |
+
isinstance(input_contract, dict)
|
| 752 |
+
and isinstance(output_contract, dict)
|
| 753 |
+
and _uses_group_authored_surface(input_contract, output_contract)
|
| 754 |
+
)
|
| 755 |
+
if surface is None:
|
| 756 |
+
if group_authored:
|
| 757 |
+
diagnostics.append("setupSurface must be a JSON object for group-authored setups")
|
| 758 |
+
return
|
| 759 |
+
if not isinstance(surface, dict):
|
| 760 |
+
diagnostics.append("setupSurface must be a JSON object")
|
| 761 |
+
return
|
| 762 |
+
for key in ("sourceInputs", "outputs", "controls"):
|
| 763 |
+
if key in surface and not isinstance(surface[key], list):
|
| 764 |
+
diagnostics.append(f"setupSurface.{key} must be a list")
|
| 765 |
+
if group_authored:
|
| 766 |
+
if not isinstance(surface.get("sourceInputs"), list) or not surface["sourceInputs"]:
|
| 767 |
+
diagnostics.append("setupSurface.sourceInputs requires a non-empty Koolook Input group")
|
| 768 |
+
if not isinstance(surface.get("outputs"), list) or not surface["outputs"]:
|
| 769 |
+
diagnostics.append("setupSurface.outputs requires a non-empty Koolook Output group")
|
| 770 |
+
for key in ("sourceInputs", "outputs", "controls"):
|
| 771 |
+
items = surface.get(key)
|
| 772 |
+
if isinstance(items, list):
|
| 773 |
+
_validate_surface_entries(items, f"setupSurface.{key}", diagnostics)
|
| 774 |
+
if "app" in surface:
|
| 775 |
+
_validate_surface_app(
|
| 776 |
+
surface["app"],
|
| 777 |
+
"setupSurface.app",
|
| 778 |
+
diagnostics,
|
| 779 |
+
setup.get("visualGraph"),
|
| 780 |
+
setup.get("apiPrompt"),
|
| 781 |
+
)
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
def _validate_surface_entries(
|
| 785 |
+
entries: list[Any],
|
| 786 |
+
path: str,
|
| 787 |
+
diagnostics: list[str],
|
| 788 |
+
) -> None:
|
| 789 |
+
for entry_index, entry in enumerate(entries):
|
| 790 |
+
entry_path = f"{path}[{entry_index}]"
|
| 791 |
+
if not isinstance(entry, dict):
|
| 792 |
+
diagnostics.append(f"{entry_path} must be a JSON object")
|
| 793 |
+
continue
|
| 794 |
+
if not isinstance(entry.get("group"), str) or not entry["group"].strip():
|
| 795 |
+
diagnostics.append(f"{entry_path}.group must be non-empty text")
|
| 796 |
+
nodes = entry.get("nodes")
|
| 797 |
+
if not isinstance(nodes, list):
|
| 798 |
+
diagnostics.append(f"{entry_path}.nodes must be a list")
|
| 799 |
+
continue
|
| 800 |
+
for node_index, node in enumerate(nodes):
|
| 801 |
+
node_path = f"{entry_path}.nodes[{node_index}]"
|
| 802 |
+
if not isinstance(node, dict):
|
| 803 |
+
diagnostics.append(f"{node_path} must be a JSON object")
|
| 804 |
+
continue
|
| 805 |
+
for field in ("id", "type", "title"):
|
| 806 |
+
if not isinstance(node.get(field), str) or not node[field].strip():
|
| 807 |
+
diagnostics.append(f"{node_path}.{field} must be non-empty text")
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def _validate_surface_app(
|
| 811 |
+
app: Any,
|
| 812 |
+
path: str,
|
| 813 |
+
diagnostics: list[str],
|
| 814 |
+
visual_graph: Any,
|
| 815 |
+
api_prompt: Any,
|
| 816 |
+
) -> None:
|
| 817 |
+
if not isinstance(app, dict):
|
| 818 |
+
diagnostics.append(f"{path} must be a JSON object")
|
| 819 |
+
return
|
| 820 |
+
node_by_id = _visual_node_index(visual_graph)
|
| 821 |
+
api_prompt = api_prompt if isinstance(api_prompt, dict) else None
|
| 822 |
+
input_keys: set[str] = set()
|
| 823 |
+
for key in ("inputs", "outputs", "results"):
|
| 824 |
+
value = app.get(key)
|
| 825 |
+
if value is None:
|
| 826 |
+
continue
|
| 827 |
+
if not isinstance(value, list):
|
| 828 |
+
diagnostics.append(f"{path}.{key} must be a list")
|
| 829 |
+
continue
|
| 830 |
+
keys = _validate_surface_app_fields(
|
| 831 |
+
value,
|
| 832 |
+
f"{path}.{key}",
|
| 833 |
+
diagnostics,
|
| 834 |
+
node_by_id,
|
| 835 |
+
api_prompt,
|
| 836 |
+
)
|
| 837 |
+
if key == "inputs":
|
| 838 |
+
input_keys = keys
|
| 839 |
+
if "switch" in app:
|
| 840 |
+
_validate_surface_app_switch(
|
| 841 |
+
app["switch"],
|
| 842 |
+
f"{path}.switch",
|
| 843 |
+
diagnostics,
|
| 844 |
+
node_by_id,
|
| 845 |
+
api_prompt,
|
| 846 |
+
input_keys,
|
| 847 |
+
)
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def _validate_surface_app_fields(
|
| 851 |
+
fields: list[Any],
|
| 852 |
+
path: str,
|
| 853 |
+
diagnostics: list[str],
|
| 854 |
+
node_by_id: dict[str, dict[str, Any]],
|
| 855 |
+
api_prompt: dict[str, Any] | None,
|
| 856 |
+
) -> set[str]:
|
| 857 |
+
keys: set[str] = set()
|
| 858 |
+
for field_index, field in enumerate(fields):
|
| 859 |
+
field_path = f"{path}[{field_index}]"
|
| 860 |
+
if not isinstance(field, dict):
|
| 861 |
+
diagnostics.append(f"{field_path} must be a JSON object")
|
| 862 |
+
continue
|
| 863 |
+
_validate_non_empty_text(field.get("key"), f"{field_path}.key", diagnostics)
|
| 864 |
+
if isinstance(field.get("key"), str) and field["key"].strip():
|
| 865 |
+
keys.add(field["key"])
|
| 866 |
+
_validate_non_empty_text(field.get("label"), f"{field_path}.label", diagnostics)
|
| 867 |
+
if "visible" in field and not isinstance(field["visible"], bool):
|
| 868 |
+
diagnostics.append(f"{field_path}.visible must be a boolean")
|
| 869 |
+
_validate_surface_app_target(
|
| 870 |
+
field.get("target"),
|
| 871 |
+
f"{field_path}.target",
|
| 872 |
+
diagnostics,
|
| 873 |
+
node_by_id,
|
| 874 |
+
api_prompt,
|
| 875 |
+
)
|
| 876 |
+
return keys
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
def _validate_surface_app_switch(
|
| 880 |
+
switch: Any,
|
| 881 |
+
path: str,
|
| 882 |
+
diagnostics: list[str],
|
| 883 |
+
node_by_id: dict[str, dict[str, Any]],
|
| 884 |
+
api_prompt: dict[str, Any] | None,
|
| 885 |
+
input_keys: set[str],
|
| 886 |
+
) -> None:
|
| 887 |
+
if not isinstance(switch, dict):
|
| 888 |
+
diagnostics.append(f"{path} must be a JSON object")
|
| 889 |
+
return
|
| 890 |
+
_validate_non_empty_text(switch.get("key"), f"{path}.key", diagnostics)
|
| 891 |
+
_validate_non_empty_text(switch.get("label"), f"{path}.label", diagnostics)
|
| 892 |
+
if "visible" in switch and not isinstance(switch["visible"], bool):
|
| 893 |
+
diagnostics.append(f"{path}.visible must be a boolean")
|
| 894 |
+
_validate_surface_app_target(
|
| 895 |
+
switch.get("target"),
|
| 896 |
+
f"{path}.target",
|
| 897 |
+
diagnostics,
|
| 898 |
+
node_by_id,
|
| 899 |
+
api_prompt,
|
| 900 |
+
)
|
| 901 |
+
default = switch.get("default")
|
| 902 |
+
if default is not None and (isinstance(default, bool) or not isinstance(default, int)):
|
| 903 |
+
diagnostics.append(f"{path}.default must be an integer")
|
| 904 |
+
options = switch.get("options")
|
| 905 |
+
if not isinstance(options, list):
|
| 906 |
+
diagnostics.append(f"{path}.options must be a list")
|
| 907 |
+
return
|
| 908 |
+
for option_index, option in enumerate(options):
|
| 909 |
+
option_path = f"{path}.options[{option_index}]"
|
| 910 |
+
if not isinstance(option, dict):
|
| 911 |
+
diagnostics.append(f"{option_path} must be a JSON object")
|
| 912 |
+
continue
|
| 913 |
+
if isinstance(option.get("value"), bool) or not isinstance(option.get("value"), int):
|
| 914 |
+
diagnostics.append(f"{option_path}.value must be an integer")
|
| 915 |
+
_validate_non_empty_text(option.get("label"), f"{option_path}.label", diagnostics)
|
| 916 |
+
_validate_non_empty_text(option.get("input"), f"{option_path}.input", diagnostics)
|
| 917 |
+
if (
|
| 918 |
+
isinstance(option.get("input"), str)
|
| 919 |
+
and option["input"].strip()
|
| 920 |
+
and input_keys
|
| 921 |
+
and option["input"] not in input_keys
|
| 922 |
+
):
|
| 923 |
+
diagnostics.append(f"{option_path}.input must match a setupSurface.app.inputs key")
|
| 924 |
+
if "visible" in option and not isinstance(option["visible"], bool):
|
| 925 |
+
diagnostics.append(f"{option_path}.visible must be a boolean")
|
| 926 |
+
|
| 927 |
+
|
| 928 |
+
def _validate_surface_app_target(
|
| 929 |
+
target: Any,
|
| 930 |
+
path: str,
|
| 931 |
+
diagnostics: list[str],
|
| 932 |
+
node_by_id: dict[str, dict[str, Any]],
|
| 933 |
+
api_prompt: dict[str, Any] | None,
|
| 934 |
+
) -> None:
|
| 935 |
+
if not isinstance(target, dict):
|
| 936 |
+
diagnostics.append(f"{path} must be a JSON object")
|
| 937 |
+
return
|
| 938 |
+
_validate_non_empty_text(target.get("node"), f"{path}.node", diagnostics)
|
| 939 |
+
_validate_non_empty_text(target.get("input"), f"{path}.input", diagnostics)
|
| 940 |
+
node_id = target.get("node")
|
| 941 |
+
input_name = target.get("input")
|
| 942 |
+
if not isinstance(node_id, str) or not node_id.strip():
|
| 943 |
+
return
|
| 944 |
+
if not isinstance(input_name, str) or not input_name.strip():
|
| 945 |
+
return
|
| 946 |
+
node = node_by_id.get(node_id)
|
| 947 |
+
if node is None:
|
| 948 |
+
diagnostics.append(f"{path}.node not found in visualGraph")
|
| 949 |
+
return
|
| 950 |
+
if not _visual_node_has_input(node, input_name):
|
| 951 |
+
diagnostics.append(f"{path}.input not found in visualGraph node")
|
| 952 |
+
if api_prompt is None:
|
| 953 |
+
return
|
| 954 |
+
api_node = api_prompt.get(node_id)
|
| 955 |
+
if not isinstance(api_node, dict):
|
| 956 |
+
diagnostics.append(f"{path}.node not found in apiPrompt")
|
| 957 |
+
return
|
| 958 |
+
api_inputs = api_node.get("inputs")
|
| 959 |
+
if not isinstance(api_inputs, dict) or input_name not in api_inputs:
|
| 960 |
+
diagnostics.append(f"{path}.input not found in apiPrompt")
|
| 961 |
+
|
| 962 |
+
|
| 963 |
+
def _validate_non_empty_text(value: Any, path: str, diagnostics: list[str]) -> None:
|
| 964 |
+
if not isinstance(value, str) or not value.strip():
|
| 965 |
+
diagnostics.append(f"{path} must be non-empty text")
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
def _visual_node_index(visual_graph: Any) -> dict[str, dict[str, Any]]:
|
| 969 |
+
nodes = visual_graph.get("nodes") if isinstance(visual_graph, dict) else None
|
| 970 |
+
if not isinstance(nodes, list):
|
| 971 |
+
return {}
|
| 972 |
+
out: dict[str, dict[str, Any]] = {}
|
| 973 |
+
for node in nodes:
|
| 974 |
+
if not isinstance(node, dict):
|
| 975 |
+
continue
|
| 976 |
+
node_id = node.get("id")
|
| 977 |
+
if node_id is not None:
|
| 978 |
+
out[str(node_id)] = node
|
| 979 |
+
return out
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
def _visual_node_has_input(node: dict[str, Any], input_name: str) -> bool:
|
| 983 |
+
inputs = node.get("inputs")
|
| 984 |
+
if isinstance(inputs, list) and any(
|
| 985 |
+
isinstance(port, dict) and str(port.get("name", "")) == input_name
|
| 986 |
+
for port in inputs
|
| 987 |
+
):
|
| 988 |
+
return True
|
| 989 |
+
class_type = node.get("type")
|
| 990 |
+
widget_names = WIDGET_ONLY_INPUTS_BY_CLASS.get(str(class_type), ())
|
| 991 |
+
return input_name in widget_names
|
| 992 |
+
|
| 993 |
+
|
| 994 |
+
def _uses_group_authored_surface(
|
| 995 |
+
input_contract: dict[str, Any],
|
| 996 |
+
output_contract: dict[str, Any],
|
| 997 |
+
) -> bool:
|
| 998 |
+
inputs = input_contract.get("inputs")
|
| 999 |
+
outputs = output_contract.get("outputs")
|
| 1000 |
+
return inputs == [] and outputs == []
|
| 1001 |
+
|
| 1002 |
+
|
| 1003 |
+
def _nodes_in_named_groups(visual_graph: dict[str, Any], group_title: str) -> list[dict[str, Any]]:
|
| 1004 |
+
groups = _nodes_in_group_sections(visual_graph, group_title)
|
| 1005 |
+
return [
|
| 1006 |
+
{"group": group_title, "nodes": [_surface_node_summary(node) for node in group_nodes]}
|
| 1007 |
+
for group_nodes in groups
|
| 1008 |
+
if group_nodes
|
| 1009 |
+
]
|
| 1010 |
+
|
| 1011 |
+
|
| 1012 |
+
def _nodes_in_group(visual_graph: dict[str, Any], group_title: str) -> list[dict[str, Any]]:
|
| 1013 |
+
groups = _nodes_in_group_sections(visual_graph, group_title)
|
| 1014 |
+
return [node for group_nodes in groups for node in group_nodes]
|
| 1015 |
+
|
| 1016 |
+
|
| 1017 |
+
def _nodes_in_group_sections(
|
| 1018 |
+
visual_graph: dict[str, Any],
|
| 1019 |
+
group_title: str,
|
| 1020 |
+
) -> list[list[dict[str, Any]]]:
|
| 1021 |
+
groups = visual_graph.get("groups")
|
| 1022 |
+
nodes = visual_graph.get("nodes")
|
| 1023 |
+
if not isinstance(groups, list) or not isinstance(nodes, list):
|
| 1024 |
+
return []
|
| 1025 |
+
|
| 1026 |
+
node_rects = [
|
| 1027 |
+
(node, _rect_from_node(node))
|
| 1028 |
+
for node in nodes
|
| 1029 |
+
if isinstance(node, dict)
|
| 1030 |
+
]
|
| 1031 |
+
out: list[list[dict[str, Any]]] = []
|
| 1032 |
+
for group in groups:
|
| 1033 |
+
if not isinstance(group, dict) or group.get("title") != group_title:
|
| 1034 |
+
continue
|
| 1035 |
+
group_rect = _rect_from_group(group)
|
| 1036 |
+
group_nodes = [
|
| 1037 |
+
node
|
| 1038 |
+
for node, node_rect in node_rects
|
| 1039 |
+
if _rects_overlap(group_rect, node_rect)
|
| 1040 |
+
]
|
| 1041 |
+
if group_nodes:
|
| 1042 |
+
out.append(sorted(group_nodes, key=lambda node: (_rect_from_node(node)["y"], _rect_from_node(node)["x"])))
|
| 1043 |
+
return out
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
+
def _validate_setup_surface_geometry(visual_graph: dict[str, Any]) -> list[str]:
|
| 1047 |
+
diagnostics: list[str] = []
|
| 1048 |
+
groups = visual_graph.get("groups")
|
| 1049 |
+
if isinstance(groups, list):
|
| 1050 |
+
for group_index, group in enumerate(groups):
|
| 1051 |
+
if not isinstance(group, dict) or group.get("title") not in {"Koolook Input", "Koolook Output"}:
|
| 1052 |
+
continue
|
| 1053 |
+
bounding = group.get("bounding")
|
| 1054 |
+
pos = group.get("pos")
|
| 1055 |
+
size = group.get("size")
|
| 1056 |
+
if bounding is not None:
|
| 1057 |
+
if (
|
| 1058 |
+
not isinstance(bounding, list)
|
| 1059 |
+
or len(bounding) < 4
|
| 1060 |
+
or not all(_is_number(value) for value in bounding[:4])
|
| 1061 |
+
):
|
| 1062 |
+
diagnostics.append(
|
| 1063 |
+
f"visualGraph.groups[{group_index}].bounding must contain numeric "
|
| 1064 |
+
"x, y, width, height"
|
| 1065 |
+
)
|
| 1066 |
+
continue
|
| 1067 |
+
if pos is not None or size is not None:
|
| 1068 |
+
if (
|
| 1069 |
+
not isinstance(pos, list)
|
| 1070 |
+
or len(pos) < 2
|
| 1071 |
+
or not all(_is_number(value) for value in pos[:2])
|
| 1072 |
+
or not isinstance(size, list)
|
| 1073 |
+
or len(size) < 2
|
| 1074 |
+
or not all(_is_number(value) for value in size[:2])
|
| 1075 |
+
):
|
| 1076 |
+
diagnostics.append(
|
| 1077 |
+
f"visualGraph.groups[{group_index}].pos/size must contain numeric "
|
| 1078 |
+
"x, y, width, height"
|
| 1079 |
+
)
|
| 1080 |
+
continue
|
| 1081 |
+
diagnostics.append(
|
| 1082 |
+
f"visualGraph.groups[{group_index}] must define bounding or pos/size "
|
| 1083 |
+
"for setup surface inference"
|
| 1084 |
+
)
|
| 1085 |
+
nodes = visual_graph.get("nodes")
|
| 1086 |
+
if isinstance(nodes, list):
|
| 1087 |
+
for node_index, node in enumerate(nodes):
|
| 1088 |
+
if not isinstance(node, dict):
|
| 1089 |
+
continue
|
| 1090 |
+
pos = node.get("pos")
|
| 1091 |
+
if pos is not None and (
|
| 1092 |
+
not isinstance(pos, list)
|
| 1093 |
+
or len(pos) < 2
|
| 1094 |
+
or not all(_is_number(value) for value in pos[:2])
|
| 1095 |
+
):
|
| 1096 |
+
diagnostics.append(
|
| 1097 |
+
f"visualGraph.nodes[{node_index}].pos must contain numeric x and y "
|
| 1098 |
+
"for setup surface inference"
|
| 1099 |
+
)
|
| 1100 |
+
size = node.get("size")
|
| 1101 |
+
if size is not None and (
|
| 1102 |
+
not isinstance(size, list)
|
| 1103 |
+
or len(size) < 2
|
| 1104 |
+
or not all(_is_number(value) for value in size[:2])
|
| 1105 |
+
):
|
| 1106 |
+
diagnostics.append(
|
| 1107 |
+
f"visualGraph.nodes[{node_index}].size must contain numeric width and height "
|
| 1108 |
+
"for setup surface inference"
|
| 1109 |
+
)
|
| 1110 |
+
return diagnostics
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
+
def _surface_node_summary(node: dict[str, Any]) -> dict[str, str]:
|
| 1114 |
+
node_type = node.get("type")
|
| 1115 |
+
node_title = node.get("title")
|
| 1116 |
+
return {
|
| 1117 |
+
"id": str(node.get("id")),
|
| 1118 |
+
"type": node_type if isinstance(node_type, str) else "",
|
| 1119 |
+
"title": node_title if isinstance(node_title, str) and node_title else (
|
| 1120 |
+
node_type if isinstance(node_type, str) else str(node.get("id"))
|
| 1121 |
+
),
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
|
| 1125 |
+
def _rect_from_group(group: dict[str, Any]) -> dict[str, float] | None:
|
| 1126 |
+
bounding = group.get("bounding")
|
| 1127 |
+
if isinstance(bounding, list) and len(bounding) >= 4:
|
| 1128 |
+
return {
|
| 1129 |
+
"x": _number_or_default(bounding[0], 0),
|
| 1130 |
+
"y": _number_or_default(bounding[1], 0),
|
| 1131 |
+
"w": _number_or_default(bounding[2], 0),
|
| 1132 |
+
"h": _number_or_default(bounding[3], 0),
|
| 1133 |
+
}
|
| 1134 |
+
pos = group.get("pos")
|
| 1135 |
+
size = group.get("size")
|
| 1136 |
+
if isinstance(pos, list) and isinstance(size, list) and len(pos) >= 2 and len(size) >= 2:
|
| 1137 |
+
return {
|
| 1138 |
+
"x": _number_or_default(pos[0], 0),
|
| 1139 |
+
"y": _number_or_default(pos[1], 0),
|
| 1140 |
+
"w": _number_or_default(size[0], 0),
|
| 1141 |
+
"h": _number_or_default(size[1], 0),
|
| 1142 |
+
}
|
| 1143 |
+
return None
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
def _rect_from_node(node: dict[str, Any]) -> dict[str, float] | None:
|
| 1147 |
+
pos = node.get("pos")
|
| 1148 |
+
if not isinstance(pos, list) or len(pos) < 2:
|
| 1149 |
+
return None
|
| 1150 |
+
size = node.get("size")
|
| 1151 |
+
if not isinstance(size, list) or len(size) < 2:
|
| 1152 |
+
size = [200, 100]
|
| 1153 |
+
return {
|
| 1154 |
+
"x": _number_or_default(pos[0], 0),
|
| 1155 |
+
"y": _number_or_default(pos[1], 0),
|
| 1156 |
+
"w": _number_or_default(size[0], 200),
|
| 1157 |
+
"h": _number_or_default(size[1], 100),
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
|
| 1161 |
+
def _rects_overlap(a: dict[str, float] | None, b: dict[str, float] | None) -> bool:
|
| 1162 |
+
if not a or not b or a["w"] <= 0 or a["h"] <= 0 or b["w"] <= 0 or b["h"] <= 0:
|
| 1163 |
+
return False
|
| 1164 |
+
return (
|
| 1165 |
+
a["x"] < b["x"] + b["w"]
|
| 1166 |
+
and a["x"] + a["w"] > b["x"]
|
| 1167 |
+
and a["y"] < b["y"] + b["h"]
|
| 1168 |
+
and a["y"] + a["h"] > b["y"]
|
| 1169 |
+
)
|
| 1170 |
+
|
| 1171 |
+
|
| 1172 |
+
def _number_or_default(value: Any, default: float) -> float:
|
| 1173 |
+
return value if isinstance(value, int | float) else default
|
| 1174 |
+
|
| 1175 |
+
|
| 1176 |
+
def _is_number(value: Any) -> bool:
|
| 1177 |
+
return not isinstance(value, bool) and isinstance(value, int | float) and math.isfinite(value)
|
| 1178 |
+
|
| 1179 |
+
|
| 1180 |
+
def _convert_visual_graph_to_api_prompt(visual_graph: dict[str, Any]) -> ApiPromptConversionResult:
|
| 1181 |
+
nodes = visual_graph.get("nodes")
|
| 1182 |
+
if not isinstance(nodes, list):
|
| 1183 |
+
return ApiPromptConversionResult(None, ["visualGraph.nodes must be a list"])
|
| 1184 |
+
links = _visual_link_index(visual_graph.get("links"))
|
| 1185 |
+
if links.diagnostics:
|
| 1186 |
+
return ApiPromptConversionResult(None, links.diagnostics)
|
| 1187 |
+
|
| 1188 |
+
node_indexes_by_id: dict[str, int] = {}
|
| 1189 |
+
node_ids = set[str]()
|
| 1190 |
+
for node_index, node in enumerate(nodes):
|
| 1191 |
+
if not isinstance(node, dict):
|
| 1192 |
+
return ApiPromptConversionResult(None, [f"visualGraph.nodes[{node_index}] must be an object"])
|
| 1193 |
+
node_id = node.get("id")
|
| 1194 |
+
class_type = node.get("type")
|
| 1195 |
+
if node_id is None or not isinstance(class_type, str) or not class_type.strip():
|
| 1196 |
+
return ApiPromptConversionResult(
|
| 1197 |
+
None,
|
| 1198 |
+
[f"visualGraph.nodes[{node_index}].type must be non-empty text"],
|
| 1199 |
+
)
|
| 1200 |
+
node_id_key = str(node_id)
|
| 1201 |
+
previous_node_index = node_indexes_by_id.get(node_id_key)
|
| 1202 |
+
if previous_node_index is not None:
|
| 1203 |
+
return ApiPromptConversionResult(
|
| 1204 |
+
None,
|
| 1205 |
+
[
|
| 1206 |
+
f"visualGraph.nodes[{node_index}].id duplicates "
|
| 1207 |
+
f"visualGraph.nodes[{previous_node_index}].id"
|
| 1208 |
+
],
|
| 1209 |
+
)
|
| 1210 |
+
node_indexes_by_id[node_id_key] = node_index
|
| 1211 |
+
node_ids.add(node_id_key)
|
| 1212 |
+
|
| 1213 |
+
node_by_id = {str(node["id"]): node for node in nodes}
|
| 1214 |
+
subgraphs_by_id = _subgraph_definitions_by_id(visual_graph)
|
| 1215 |
+
api_prompt: dict[str, Any] = {}
|
| 1216 |
+
subgraph_output_refs: dict[str, dict[int, Any]] = {}
|
| 1217 |
+
skipped_node_ids: set[str] = set()
|
| 1218 |
+
expanded_node_ids: set[str] = set()
|
| 1219 |
+
diagnostics: list[str] = []
|
| 1220 |
+
|
| 1221 |
+
def resolve_outer_link(link_id: Any, seen: set[str] | None = None) -> Any:
|
| 1222 |
+
link_id_key = str(link_id)
|
| 1223 |
+
seen = set() if seen is None else seen
|
| 1224 |
+
if link_id_key in seen:
|
| 1225 |
+
diagnostics.append(f"visualGraph.links[{link_id_key}] has a reroute cycle")
|
| 1226 |
+
return None
|
| 1227 |
+
seen.add(link_id_key)
|
| 1228 |
+
link = links.links.get(link_id_key)
|
| 1229 |
+
if link is None:
|
| 1230 |
+
diagnostics.append(f"visualGraph.links[{link_id_key}] is missing")
|
| 1231 |
+
return None
|
| 1232 |
+
origin_id = str(link["origin_id"])
|
| 1233 |
+
if _is_module_sentinel_id(link["origin_id"]):
|
| 1234 |
+
diagnostics.append(f"visualGraph.links[{link_id_key}] uses unsupported module graph sentinel node")
|
| 1235 |
+
return None
|
| 1236 |
+
if not _is_non_negative_int(link["origin_slot"]):
|
| 1237 |
+
diagnostics.append(
|
| 1238 |
+
f"visualGraph.links[{link_id_key}].origin_slot must be a non-negative integer"
|
| 1239 |
+
)
|
| 1240 |
+
return None
|
| 1241 |
+
origin_slot = int(link["origin_slot"])
|
| 1242 |
+
origin_node = node_by_id.get(origin_id)
|
| 1243 |
+
if origin_node is None:
|
| 1244 |
+
diagnostics.append(f"visualGraph.links[{link_id_key}].origin_id not found in visualGraph")
|
| 1245 |
+
return None
|
| 1246 |
+
if origin_node.get("type") == "Reroute":
|
| 1247 |
+
reroute_input = _first_linked_input(origin_node)
|
| 1248 |
+
if reroute_input is None:
|
| 1249 |
+
diagnostics.append(f"visualGraph.nodes[{node_indexes_by_id[origin_id]}] Reroute is unconnected")
|
| 1250 |
+
return None
|
| 1251 |
+
return resolve_outer_link(reroute_input, seen)
|
| 1252 |
+
if origin_id in subgraph_output_refs:
|
| 1253 |
+
ref = subgraph_output_refs[origin_id].get(origin_slot)
|
| 1254 |
+
if ref is None:
|
| 1255 |
+
diagnostics.append(
|
| 1256 |
+
f"visualGraph.links[{link_id_key}] references missing subgraph output {origin_slot}"
|
| 1257 |
+
)
|
| 1258 |
+
return ref
|
| 1259 |
+
if origin_id in skipped_node_ids or origin_id in expanded_node_ids:
|
| 1260 |
+
diagnostics.append(
|
| 1261 |
+
f"visualGraph.links[{link_id_key}] references non-executable node {origin_id}"
|
| 1262 |
+
)
|
| 1263 |
+
return None
|
| 1264 |
+
return [origin_id, origin_slot]
|
| 1265 |
+
|
| 1266 |
+
def add_node_api(
|
| 1267 |
+
*,
|
| 1268 |
+
node: dict[str, Any],
|
| 1269 |
+
node_index: int,
|
| 1270 |
+
node_id_key: str,
|
| 1271 |
+
class_type: str,
|
| 1272 |
+
link_resolver,
|
| 1273 |
+
) -> None:
|
| 1274 |
+
node_inputs = node.get("inputs")
|
| 1275 |
+
if node_inputs is None:
|
| 1276 |
+
node_inputs = []
|
| 1277 |
+
elif not isinstance(node_inputs, list):
|
| 1278 |
+
diagnostics.append(f"visualGraph.nodes[{node_index}].inputs must be a list when present")
|
| 1279 |
+
return
|
| 1280 |
+
|
| 1281 |
+
raw_widget_values = node.get("widgets_values")
|
| 1282 |
+
widget_values_by_name = raw_widget_values if isinstance(raw_widget_values, dict) else {}
|
| 1283 |
+
widget_values = raw_widget_values if isinstance(raw_widget_values, list) else []
|
| 1284 |
+
if not widget_values and not widget_values_by_name:
|
| 1285 |
+
widget_values = _proxy_widget_values_from_subgraph(visual_graph, node)
|
| 1286 |
+
widget_index = 0
|
| 1287 |
+
api_inputs: dict[str, Any] = {}
|
| 1288 |
+
for input_index, input_port in enumerate(node_inputs):
|
| 1289 |
+
if not isinstance(input_port, dict):
|
| 1290 |
+
diagnostics.append(f"visualGraph.nodes[{node_index}].inputs must contain objects")
|
| 1291 |
+
return
|
| 1292 |
+
name = input_port.get("name")
|
| 1293 |
+
if not isinstance(name, str) or not name.strip():
|
| 1294 |
+
if class_type != "Reroute" or not isinstance(name, str):
|
| 1295 |
+
diagnostics.append(f"visualGraph.nodes[{node_index}].inputs must have named ports")
|
| 1296 |
+
return
|
| 1297 |
+
link_id = input_port.get("link")
|
| 1298 |
+
if link_id is not None:
|
| 1299 |
+
link = links.links.get(str(link_id))
|
| 1300 |
+
if link is not None and link["target_id"] is not None and str(link["target_id"]) != node_id_key:
|
| 1301 |
+
diagnostics.append(
|
| 1302 |
+
f"visualGraph.links[{link_id}].target does not match "
|
| 1303 |
+
f"visualGraph.nodes[{node_index}].inputs[{input_index}]"
|
| 1304 |
+
)
|
| 1305 |
+
return
|
| 1306 |
+
if (
|
| 1307 |
+
link is not None
|
| 1308 |
+
and link["target_slot"] is not None
|
| 1309 |
+
and (
|
| 1310 |
+
not _is_non_negative_int(link["target_slot"])
|
| 1311 |
+
or int(link["target_slot"]) != input_index
|
| 1312 |
+
)
|
| 1313 |
+
):
|
| 1314 |
+
diagnostics.append(
|
| 1315 |
+
f"visualGraph.links[{link_id}].target does not match "
|
| 1316 |
+
f"visualGraph.nodes[{node_index}].inputs[{input_index}]"
|
| 1317 |
+
)
|
| 1318 |
+
return
|
| 1319 |
+
resolved = link_resolver(link_id)
|
| 1320 |
+
if resolved is None:
|
| 1321 |
+
return
|
| 1322 |
+
api_inputs[name] = resolved
|
| 1323 |
+
continue
|
| 1324 |
+
widget = input_port.get("widget")
|
| 1325 |
+
if isinstance(widget, dict):
|
| 1326 |
+
if name in widget_values_by_name:
|
| 1327 |
+
api_inputs[name] = widget_values_by_name[name]
|
| 1328 |
+
continue
|
| 1329 |
+
if widget_index >= len(widget_values):
|
| 1330 |
+
diagnostics.append(
|
| 1331 |
+
f"visualGraph.nodes[{node_index}].widgets_values is missing a value "
|
| 1332 |
+
f"for input {name}"
|
| 1333 |
+
)
|
| 1334 |
+
return
|
| 1335 |
+
api_inputs[name] = widget_values[widget_index]
|
| 1336 |
+
widget_index += 1
|
| 1337 |
+
|
| 1338 |
+
if not node_inputs:
|
| 1339 |
+
widget_only_diagnostics = _apply_widget_only_inputs(
|
| 1340 |
+
class_type,
|
| 1341 |
+
widget_values,
|
| 1342 |
+
api_inputs,
|
| 1343 |
+
node_index,
|
| 1344 |
+
)
|
| 1345 |
+
diagnostics.extend(widget_only_diagnostics)
|
| 1346 |
+
if widget_only_diagnostics:
|
| 1347 |
+
return
|
| 1348 |
+
api_prompt[node_id_key] = {"class_type": class_type, "inputs": api_inputs}
|
| 1349 |
+
|
| 1350 |
+
def expand_subgraph_node(node: dict[str, Any], node_index: int) -> None:
|
| 1351 |
+
node_id_key = str(node["id"])
|
| 1352 |
+
subgraph = subgraphs_by_id.get(str(node["type"]))
|
| 1353 |
+
if subgraph is None:
|
| 1354 |
+
return
|
| 1355 |
+
subgraph_links = _visual_link_index(subgraph.get("links"))
|
| 1356 |
+
if subgraph_links.diagnostics:
|
| 1357 |
+
diagnostics.extend(subgraph_links.diagnostics)
|
| 1358 |
+
return
|
| 1359 |
+
subgraph_nodes = subgraph.get("nodes")
|
| 1360 |
+
if not isinstance(subgraph_nodes, list):
|
| 1361 |
+
diagnostics.append(f"visualGraph.definitions.subgraphs[{node['type']}].nodes must be a list")
|
| 1362 |
+
return
|
| 1363 |
+
subgraph_inputs = subgraph.get("inputs")
|
| 1364 |
+
if not isinstance(subgraph_inputs, list):
|
| 1365 |
+
subgraph_inputs = []
|
| 1366 |
+
subgraph_nodes_by_id = {
|
| 1367 |
+
str(subgraph_node.get("id")): subgraph_node
|
| 1368 |
+
for subgraph_node in subgraph_nodes
|
| 1369 |
+
if isinstance(subgraph_node, dict) and subgraph_node.get("id") is not None
|
| 1370 |
+
}
|
| 1371 |
+
wrapper_inputs = _api_inputs_for_wrapper_node(
|
| 1372 |
+
visual_graph,
|
| 1373 |
+
node,
|
| 1374 |
+
node_index,
|
| 1375 |
+
links,
|
| 1376 |
+
resolve_outer_link,
|
| 1377 |
+
diagnostics,
|
| 1378 |
+
)
|
| 1379 |
+
skipped_internal_ids: set[str] = set()
|
| 1380 |
+
for internal_node in subgraph_nodes:
|
| 1381 |
+
if not isinstance(internal_node, dict):
|
| 1382 |
+
continue
|
| 1383 |
+
internal_id = str(internal_node.get("id"))
|
| 1384 |
+
if _should_omit_api_node(internal_node):
|
| 1385 |
+
skipped_internal_ids.add(internal_id)
|
| 1386 |
+
|
| 1387 |
+
def resolve_subgraph_link(link_id: Any, seen: set[str] | None = None) -> Any:
|
| 1388 |
+
link_id_key = str(link_id)
|
| 1389 |
+
seen = set() if seen is None else seen
|
| 1390 |
+
if link_id_key in seen:
|
| 1391 |
+
diagnostics.append(f"visualGraph.definitions.subgraphs[{node['type']}].links[{link_id_key}] has a reroute cycle")
|
| 1392 |
+
return None
|
| 1393 |
+
seen.add(link_id_key)
|
| 1394 |
+
link = subgraph_links.links.get(link_id_key)
|
| 1395 |
+
if link is None:
|
| 1396 |
+
diagnostics.append(f"visualGraph.definitions.subgraphs[{node['type']}].links[{link_id_key}] is missing")
|
| 1397 |
+
return None
|
| 1398 |
+
origin_id = link["origin_id"]
|
| 1399 |
+
if not _is_non_negative_int(link["origin_slot"]):
|
| 1400 |
+
diagnostics.append(
|
| 1401 |
+
f"visualGraph.definitions.subgraphs[{node['type']}].links[{link_id_key}].origin_slot "
|
| 1402 |
+
"must be a non-negative integer"
|
| 1403 |
+
)
|
| 1404 |
+
return None
|
| 1405 |
+
origin_slot = int(link["origin_slot"])
|
| 1406 |
+
if _is_module_sentinel_id(origin_id):
|
| 1407 |
+
if int(origin_id) != -10:
|
| 1408 |
+
diagnostics.append(
|
| 1409 |
+
f"visualGraph.definitions.subgraphs[{node['type']}].links[{link_id_key}] "
|
| 1410 |
+
"uses unsupported module graph sentinel node"
|
| 1411 |
+
)
|
| 1412 |
+
return None
|
| 1413 |
+
if origin_slot >= len(subgraph_inputs):
|
| 1414 |
+
diagnostics.append(
|
| 1415 |
+
f"visualGraph.definitions.subgraphs[{node['type']}].links[{link_id_key}] "
|
| 1416 |
+
"references missing subgraph input"
|
| 1417 |
+
)
|
| 1418 |
+
return None
|
| 1419 |
+
input_name = subgraph_inputs[origin_slot].get("name")
|
| 1420 |
+
if not isinstance(input_name, str) or input_name not in wrapper_inputs:
|
| 1421 |
+
return None
|
| 1422 |
+
return wrapper_inputs[input_name]
|
| 1423 |
+
origin_id_key = str(origin_id)
|
| 1424 |
+
origin_node = subgraph_nodes_by_id.get(origin_id_key)
|
| 1425 |
+
if origin_node is None:
|
| 1426 |
+
diagnostics.append(
|
| 1427 |
+
f"visualGraph.definitions.subgraphs[{node['type']}].links[{link_id_key}].origin_id not found"
|
| 1428 |
+
)
|
| 1429 |
+
return None
|
| 1430 |
+
if origin_node.get("type") == "Reroute":
|
| 1431 |
+
reroute_input = _first_linked_input(origin_node)
|
| 1432 |
+
if reroute_input is None:
|
| 1433 |
+
diagnostics.append(
|
| 1434 |
+
f"visualGraph.definitions.subgraphs[{node['type']}].nodes[{origin_id_key}] Reroute is unconnected"
|
| 1435 |
+
)
|
| 1436 |
+
return None
|
| 1437 |
+
return resolve_subgraph_link(reroute_input, seen)
|
| 1438 |
+
if origin_id_key in skipped_internal_ids:
|
| 1439 |
+
diagnostics.append(
|
| 1440 |
+
f"visualGraph.definitions.subgraphs[{node['type']}].links[{link_id_key}] "
|
| 1441 |
+
f"references non-executable node {origin_id_key}"
|
| 1442 |
+
)
|
| 1443 |
+
return None
|
| 1444 |
+
return [f"{node_id_key}:{origin_id_key}", origin_slot]
|
| 1445 |
+
|
| 1446 |
+
for internal_index, internal_node in enumerate(subgraph_nodes):
|
| 1447 |
+
if not isinstance(internal_node, dict):
|
| 1448 |
+
diagnostics.append(
|
| 1449 |
+
f"visualGraph.definitions.subgraphs[{node['type']}].nodes[{internal_index}] must be an object"
|
| 1450 |
+
)
|
| 1451 |
+
return
|
| 1452 |
+
internal_class_type = internal_node.get("type")
|
| 1453 |
+
internal_id = internal_node.get("id")
|
| 1454 |
+
if internal_id is None or not isinstance(internal_class_type, str) or not internal_class_type.strip():
|
| 1455 |
+
diagnostics.append(
|
| 1456 |
+
f"visualGraph.definitions.subgraphs[{node['type']}].nodes[{internal_index}].type "
|
| 1457 |
+
"must be non-empty text"
|
| 1458 |
+
)
|
| 1459 |
+
return
|
| 1460 |
+
if str(internal_class_type) in subgraphs_by_id:
|
| 1461 |
+
diagnostics.append(
|
| 1462 |
+
f"visualGraph.definitions.subgraphs[{node['type']}].nodes[{internal_index}] "
|
| 1463 |
+
"nested subgraphs are not supported"
|
| 1464 |
+
)
|
| 1465 |
+
return
|
| 1466 |
+
if str(internal_id) in skipped_internal_ids:
|
| 1467 |
+
continue
|
| 1468 |
+
add_node_api(
|
| 1469 |
+
node=internal_node,
|
| 1470 |
+
node_index=node_index,
|
| 1471 |
+
node_id_key=f"{node_id_key}:{internal_id}",
|
| 1472 |
+
class_type=internal_class_type,
|
| 1473 |
+
link_resolver=resolve_subgraph_link,
|
| 1474 |
+
)
|
| 1475 |
+
if diagnostics:
|
| 1476 |
+
return
|
| 1477 |
+
|
| 1478 |
+
output_refs: dict[int, Any] = {}
|
| 1479 |
+
for link_id, link in subgraph_links.links.items():
|
| 1480 |
+
if not _is_module_sentinel_id(link["target_id"]) or int(link["target_id"]) != -20:
|
| 1481 |
+
continue
|
| 1482 |
+
target_slot = link.get("target_slot")
|
| 1483 |
+
if not _is_non_negative_int(target_slot):
|
| 1484 |
+
continue
|
| 1485 |
+
ref = resolve_subgraph_link(link_id)
|
| 1486 |
+
if ref is not None:
|
| 1487 |
+
output_refs[int(target_slot)] = ref
|
| 1488 |
+
subgraph_output_refs[node_id_key] = output_refs
|
| 1489 |
+
expanded_node_ids.add(node_id_key)
|
| 1490 |
+
|
| 1491 |
+
for node_index, node in enumerate(nodes):
|
| 1492 |
+
node_id_key = str(node["id"])
|
| 1493 |
+
class_type = node["type"]
|
| 1494 |
+
if _should_omit_api_node(node):
|
| 1495 |
+
skipped_node_ids.add(node_id_key)
|
| 1496 |
+
continue
|
| 1497 |
+
if class_type == "Reroute":
|
| 1498 |
+
skipped_node_ids.add(node_id_key)
|
| 1499 |
+
continue
|
| 1500 |
+
if class_type in subgraphs_by_id:
|
| 1501 |
+
expand_subgraph_node(node, node_index)
|
| 1502 |
+
if diagnostics:
|
| 1503 |
+
return ApiPromptConversionResult(None, diagnostics)
|
| 1504 |
+
continue
|
| 1505 |
+
add_node_api(
|
| 1506 |
+
node=node,
|
| 1507 |
+
node_index=node_index,
|
| 1508 |
+
node_id_key=node_id_key,
|
| 1509 |
+
class_type=class_type,
|
| 1510 |
+
link_resolver=resolve_outer_link,
|
| 1511 |
+
)
|
| 1512 |
+
if diagnostics:
|
| 1513 |
+
return ApiPromptConversionResult(None, diagnostics)
|
| 1514 |
+
|
| 1515 |
+
return ApiPromptConversionResult(api_prompt, diagnostics)
|
| 1516 |
+
|
| 1517 |
+
|
| 1518 |
+
def _api_inputs_for_wrapper_node(
|
| 1519 |
+
visual_graph: dict[str, Any],
|
| 1520 |
+
node: dict[str, Any],
|
| 1521 |
+
node_index: int,
|
| 1522 |
+
links: VisualLinkIndex,
|
| 1523 |
+
link_resolver,
|
| 1524 |
+
diagnostics: list[str],
|
| 1525 |
+
) -> dict[str, Any]:
|
| 1526 |
+
node_inputs = node.get("inputs")
|
| 1527 |
+
if node_inputs is None:
|
| 1528 |
+
return {}
|
| 1529 |
+
if not isinstance(node_inputs, list):
|
| 1530 |
+
diagnostics.append(f"visualGraph.nodes[{node_index}].inputs must be a list when present")
|
| 1531 |
+
return {}
|
| 1532 |
+
raw_widget_values = node.get("widgets_values")
|
| 1533 |
+
widget_values_by_name = raw_widget_values if isinstance(raw_widget_values, dict) else {}
|
| 1534 |
+
widget_values = raw_widget_values if isinstance(raw_widget_values, list) else []
|
| 1535 |
+
widget_index = 0
|
| 1536 |
+
values: dict[str, Any] = {}
|
| 1537 |
+
for input_index, input_port in enumerate(node_inputs):
|
| 1538 |
+
if not isinstance(input_port, dict):
|
| 1539 |
+
diagnostics.append(f"visualGraph.nodes[{node_index}].inputs must contain objects")
|
| 1540 |
+
return values
|
| 1541 |
+
name = input_port.get("name")
|
| 1542 |
+
if not isinstance(name, str) or not name.strip():
|
| 1543 |
+
diagnostics.append(f"visualGraph.nodes[{node_index}].inputs must have named ports")
|
| 1544 |
+
return values
|
| 1545 |
+
link_id = input_port.get("link")
|
| 1546 |
+
if link_id is not None:
|
| 1547 |
+
link = links.links.get(str(link_id))
|
| 1548 |
+
if link is not None and link["target_id"] is not None and str(link["target_id"]) != str(node.get("id")):
|
| 1549 |
+
diagnostics.append(
|
| 1550 |
+
f"visualGraph.links[{link_id}].target does not match "
|
| 1551 |
+
f"visualGraph.nodes[{node_index}].inputs[{input_index}]"
|
| 1552 |
+
)
|
| 1553 |
+
return values
|
| 1554 |
+
resolved = link_resolver(link_id)
|
| 1555 |
+
if resolved is not None:
|
| 1556 |
+
values[name] = resolved
|
| 1557 |
+
continue
|
| 1558 |
+
widget = input_port.get("widget")
|
| 1559 |
+
if not isinstance(widget, dict):
|
| 1560 |
+
continue
|
| 1561 |
+
if name in widget_values_by_name:
|
| 1562 |
+
values[name] = widget_values_by_name[name]
|
| 1563 |
+
continue
|
| 1564 |
+
if widget_index < len(widget_values):
|
| 1565 |
+
values[name] = widget_values[widget_index]
|
| 1566 |
+
widget_index += 1
|
| 1567 |
+
proxy_values = _proxy_widget_values_from_subgraph(visual_graph, node)
|
| 1568 |
+
proxy_index = 0
|
| 1569 |
+
for input_port in node_inputs:
|
| 1570 |
+
if proxy_index >= len(proxy_values) or not isinstance(input_port, dict):
|
| 1571 |
+
continue
|
| 1572 |
+
name = input_port.get("name")
|
| 1573 |
+
if isinstance(name, str) and name not in values and input_port.get("widget"):
|
| 1574 |
+
values[name] = proxy_values[proxy_index]
|
| 1575 |
+
if input_port.get("widget"):
|
| 1576 |
+
proxy_index += 1
|
| 1577 |
+
return values
|
| 1578 |
+
|
| 1579 |
+
|
| 1580 |
+
def _api_prompt_needs_visual_normalization(
|
| 1581 |
+
api_prompt: dict[str, Any],
|
| 1582 |
+
visual_graph: dict[str, Any],
|
| 1583 |
+
) -> bool:
|
| 1584 |
+
subgraph_ids = set(_subgraph_definitions_by_id(visual_graph))
|
| 1585 |
+
for node in api_prompt.values():
|
| 1586 |
+
if not isinstance(node, dict):
|
| 1587 |
+
continue
|
| 1588 |
+
class_type = node.get("class_type")
|
| 1589 |
+
if not isinstance(class_type, str):
|
| 1590 |
+
continue
|
| 1591 |
+
if class_type == "Reroute":
|
| 1592 |
+
return True
|
| 1593 |
+
if class_type in VISUAL_ONLY_API_NODE_TYPES:
|
| 1594 |
+
return True
|
| 1595 |
+
if class_type in SINK_ONLY_API_NODE_TYPES:
|
| 1596 |
+
return True
|
| 1597 |
+
if class_type in subgraph_ids:
|
| 1598 |
+
return True
|
| 1599 |
+
return False
|
| 1600 |
+
|
| 1601 |
+
|
| 1602 |
+
def _subgraph_definitions_by_id(visual_graph: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
| 1603 |
+
definitions = visual_graph.get("definitions")
|
| 1604 |
+
if not isinstance(definitions, dict):
|
| 1605 |
+
return {}
|
| 1606 |
+
subgraphs = definitions.get("subgraphs")
|
| 1607 |
+
if not isinstance(subgraphs, list):
|
| 1608 |
+
return {}
|
| 1609 |
+
return {
|
| 1610 |
+
str(subgraph.get("id")): subgraph
|
| 1611 |
+
for subgraph in subgraphs
|
| 1612 |
+
if isinstance(subgraph, dict) and subgraph.get("id") is not None
|
| 1613 |
+
}
|
| 1614 |
+
|
| 1615 |
+
|
| 1616 |
+
def _should_omit_api_node(node: dict[str, Any]) -> bool:
|
| 1617 |
+
class_type = str(node.get("type", ""))
|
| 1618 |
+
if class_type in VISUAL_ONLY_API_NODE_TYPES:
|
| 1619 |
+
return True
|
| 1620 |
+
if class_type in SINK_ONLY_API_NODE_TYPES:
|
| 1621 |
+
return not _node_has_linked_outputs(node)
|
| 1622 |
+
return False
|
| 1623 |
+
|
| 1624 |
+
|
| 1625 |
+
def _node_has_linked_outputs(node: dict[str, Any]) -> bool:
|
| 1626 |
+
outputs = node.get("outputs")
|
| 1627 |
+
if not isinstance(outputs, list):
|
| 1628 |
+
return False
|
| 1629 |
+
for output in outputs:
|
| 1630 |
+
if not isinstance(output, dict):
|
| 1631 |
+
continue
|
| 1632 |
+
links = output.get("links")
|
| 1633 |
+
if isinstance(links, list) and links:
|
| 1634 |
+
return True
|
| 1635 |
+
return False
|
| 1636 |
+
|
| 1637 |
+
|
| 1638 |
+
def _first_linked_input(node: dict[str, Any]) -> Any | None:
|
| 1639 |
+
node_inputs = node.get("inputs")
|
| 1640 |
+
if not isinstance(node_inputs, list):
|
| 1641 |
+
return None
|
| 1642 |
+
for input_port in node_inputs:
|
| 1643 |
+
if not isinstance(input_port, dict):
|
| 1644 |
+
continue
|
| 1645 |
+
link_id = input_port.get("link")
|
| 1646 |
+
if link_id is not None:
|
| 1647 |
+
return link_id
|
| 1648 |
+
return None
|
| 1649 |
+
|
| 1650 |
+
|
| 1651 |
+
def _apply_widget_only_inputs(
|
| 1652 |
+
class_type: str,
|
| 1653 |
+
widget_values: list[Any],
|
| 1654 |
+
api_inputs: dict[str, Any],
|
| 1655 |
+
node_index: int,
|
| 1656 |
+
) -> list[str]:
|
| 1657 |
+
if api_inputs:
|
| 1658 |
+
return []
|
| 1659 |
+
widget_names = WIDGET_ONLY_INPUTS_BY_CLASS.get(class_type)
|
| 1660 |
+
if not widget_names:
|
| 1661 |
+
return []
|
| 1662 |
+
defaults = WIDGET_ONLY_INPUT_DEFAULTS.get(class_type, {})
|
| 1663 |
+
diagnostics: list[str] = []
|
| 1664 |
+
for index, name in enumerate(widget_names):
|
| 1665 |
+
if index < len(widget_values):
|
| 1666 |
+
value = widget_values[index]
|
| 1667 |
+
if name == "no_subfolders" and not isinstance(value, bool):
|
| 1668 |
+
value = defaults.get(name, False)
|
| 1669 |
+
api_inputs[name] = value
|
| 1670 |
+
elif name in defaults:
|
| 1671 |
+
api_inputs[name] = defaults[name]
|
| 1672 |
+
else:
|
| 1673 |
+
diagnostics.append(
|
| 1674 |
+
f"visualGraph.nodes[{node_index}].widgets_values is missing a value "
|
| 1675 |
+
f"for widget-only input {name}"
|
| 1676 |
+
)
|
| 1677 |
+
return diagnostics
|
| 1678 |
+
|
| 1679 |
+
|
| 1680 |
+
def _proxy_widget_values_from_subgraph(
|
| 1681 |
+
visual_graph: dict[str, Any],
|
| 1682 |
+
node: dict[str, Any],
|
| 1683 |
+
) -> list[Any]:
|
| 1684 |
+
properties = node.get("properties")
|
| 1685 |
+
if not isinstance(properties, dict):
|
| 1686 |
+
return []
|
| 1687 |
+
proxy_widgets = properties.get("proxyWidgets")
|
| 1688 |
+
if not isinstance(proxy_widgets, list):
|
| 1689 |
+
return []
|
| 1690 |
+
subgraph = _subgraph_definition(visual_graph, str(node.get("type", "")))
|
| 1691 |
+
if subgraph is None:
|
| 1692 |
+
return []
|
| 1693 |
+
subgraph_nodes = subgraph.get("nodes")
|
| 1694 |
+
if not isinstance(subgraph_nodes, list):
|
| 1695 |
+
return []
|
| 1696 |
+
|
| 1697 |
+
nodes_by_id = {
|
| 1698 |
+
str(subgraph_node.get("id")): subgraph_node
|
| 1699 |
+
for subgraph_node in subgraph_nodes
|
| 1700 |
+
if isinstance(subgraph_node, dict) and subgraph_node.get("id") is not None
|
| 1701 |
+
}
|
| 1702 |
+
values: list[Any] = []
|
| 1703 |
+
for proxy_widget in proxy_widgets:
|
| 1704 |
+
if not isinstance(proxy_widget, list) or len(proxy_widget) < 2:
|
| 1705 |
+
continue
|
| 1706 |
+
source_node = nodes_by_id.get(str(proxy_widget[0]))
|
| 1707 |
+
widget_name = proxy_widget[1]
|
| 1708 |
+
if not isinstance(source_node, dict) or not isinstance(widget_name, str):
|
| 1709 |
+
continue
|
| 1710 |
+
found, value = _node_widget_value(source_node, widget_name)
|
| 1711 |
+
if found:
|
| 1712 |
+
values.append(value)
|
| 1713 |
+
return values
|
| 1714 |
+
|
| 1715 |
+
|
| 1716 |
+
def _subgraph_definition(
|
| 1717 |
+
visual_graph: dict[str, Any],
|
| 1718 |
+
subgraph_id: str,
|
| 1719 |
+
) -> dict[str, Any] | None:
|
| 1720 |
+
definitions = visual_graph.get("definitions")
|
| 1721 |
+
if not isinstance(definitions, dict):
|
| 1722 |
+
return None
|
| 1723 |
+
subgraphs = definitions.get("subgraphs")
|
| 1724 |
+
if not isinstance(subgraphs, list):
|
| 1725 |
+
return None
|
| 1726 |
+
for subgraph in subgraphs:
|
| 1727 |
+
if isinstance(subgraph, dict) and subgraph.get("id") == subgraph_id:
|
| 1728 |
+
return subgraph
|
| 1729 |
+
return None
|
| 1730 |
+
|
| 1731 |
+
|
| 1732 |
+
def _node_widget_value(
|
| 1733 |
+
node: dict[str, Any],
|
| 1734 |
+
widget_name: str,
|
| 1735 |
+
) -> tuple[bool, Any]:
|
| 1736 |
+
inputs = node.get("inputs")
|
| 1737 |
+
widget_values = node.get("widgets_values")
|
| 1738 |
+
if not isinstance(inputs, list) or not isinstance(widget_values, list):
|
| 1739 |
+
return False, None
|
| 1740 |
+
|
| 1741 |
+
widget_index = 0
|
| 1742 |
+
for input_port in inputs:
|
| 1743 |
+
if not isinstance(input_port, dict):
|
| 1744 |
+
continue
|
| 1745 |
+
widget = input_port.get("widget")
|
| 1746 |
+
if not isinstance(widget, dict):
|
| 1747 |
+
continue
|
| 1748 |
+
if widget.get("name") == widget_name:
|
| 1749 |
+
if widget_index >= len(widget_values):
|
| 1750 |
+
return False, None
|
| 1751 |
+
return True, widget_values[widget_index]
|
| 1752 |
+
widget_index += 1
|
| 1753 |
+
return False, None
|
| 1754 |
+
|
| 1755 |
+
|
| 1756 |
+
@dataclass(frozen=True)
|
| 1757 |
+
class VisualLinkIndex:
|
| 1758 |
+
links: dict[str, dict[str, Any]]
|
| 1759 |
+
diagnostics: list[str]
|
| 1760 |
+
|
| 1761 |
+
|
| 1762 |
+
def _visual_link_index(value: Any) -> VisualLinkIndex:
|
| 1763 |
+
links: dict[str, dict[str, Any]] = {}
|
| 1764 |
+
if not isinstance(value, list):
|
| 1765 |
+
return VisualLinkIndex(links, [])
|
| 1766 |
+
link_indexes_by_id: dict[str, int] = {}
|
| 1767 |
+
for link_index, item in enumerate(value):
|
| 1768 |
+
if isinstance(item, dict):
|
| 1769 |
+
link_id = item.get("id")
|
| 1770 |
+
origin_id = item.get("origin_id")
|
| 1771 |
+
origin_slot = item.get("origin_slot")
|
| 1772 |
+
target_id = item.get("target_id")
|
| 1773 |
+
target_slot = item.get("target_slot")
|
| 1774 |
+
elif isinstance(item, list) and len(item) >= 5:
|
| 1775 |
+
link_id = item[0]
|
| 1776 |
+
origin_id = item[1]
|
| 1777 |
+
origin_slot = item[2]
|
| 1778 |
+
target_id = item[3]
|
| 1779 |
+
target_slot = item[4]
|
| 1780 |
+
elif isinstance(item, list) and len(item) >= 3:
|
| 1781 |
+
link_id = item[0]
|
| 1782 |
+
origin_id = item[1]
|
| 1783 |
+
origin_slot = item[2]
|
| 1784 |
+
target_id = None
|
| 1785 |
+
target_slot = None
|
| 1786 |
+
else:
|
| 1787 |
+
continue
|
| 1788 |
+
if link_id is None or origin_id is None or origin_slot is None:
|
| 1789 |
+
continue
|
| 1790 |
+
link_id_key = str(link_id)
|
| 1791 |
+
previous_link_index = link_indexes_by_id.get(link_id_key)
|
| 1792 |
+
if previous_link_index is not None:
|
| 1793 |
+
return VisualLinkIndex(
|
| 1794 |
+
links,
|
| 1795 |
+
[
|
| 1796 |
+
f"visualGraph.links[{link_index}].id duplicates "
|
| 1797 |
+
f"visualGraph.links[{previous_link_index}].id"
|
| 1798 |
+
],
|
| 1799 |
+
)
|
| 1800 |
+
link_indexes_by_id[link_id_key] = link_index
|
| 1801 |
+
links[link_id_key] = {
|
| 1802 |
+
"origin_id": origin_id,
|
| 1803 |
+
"origin_slot": origin_slot,
|
| 1804 |
+
"target_id": target_id,
|
| 1805 |
+
"target_slot": target_slot,
|
| 1806 |
+
}
|
| 1807 |
+
return VisualLinkIndex(links, [])
|
| 1808 |
+
|
| 1809 |
+
|
| 1810 |
+
def _is_module_sentinel_id(value: Any) -> bool:
|
| 1811 |
+
try:
|
| 1812 |
+
return int(value) < 0
|
| 1813 |
+
except (TypeError, ValueError):
|
| 1814 |
+
return False
|
| 1815 |
+
|
| 1816 |
+
|
| 1817 |
+
def _is_non_negative_int(value: Any) -> bool:
|
| 1818 |
+
return isinstance(value, int) and value >= 0
|
| 1819 |
+
|
| 1820 |
+
|
| 1821 |
+
def _validate_contract_targets(
|
| 1822 |
+
visual_graph: dict[str, Any],
|
| 1823 |
+
input_contract: dict[str, Any],
|
| 1824 |
+
) -> list[str]:
|
| 1825 |
+
diagnostics: list[str] = []
|
| 1826 |
+
nodes = visual_graph.get("nodes") if isinstance(visual_graph, dict) else None
|
| 1827 |
+
node_by_id: dict[str, dict[str, Any]] = {}
|
| 1828 |
+
if isinstance(nodes, list):
|
| 1829 |
+
for node in nodes:
|
| 1830 |
+
if not isinstance(node, dict):
|
| 1831 |
+
continue
|
| 1832 |
+
node_id = node.get("id")
|
| 1833 |
+
if node_id is not None:
|
| 1834 |
+
node_by_id[str(node_id)] = node
|
| 1835 |
+
inputs = input_contract.get("inputs") if isinstance(input_contract, dict) else None
|
| 1836 |
+
if not isinstance(inputs, list):
|
| 1837 |
+
return diagnostics
|
| 1838 |
+
for index, item in enumerate(inputs):
|
| 1839 |
+
if not isinstance(item, dict):
|
| 1840 |
+
continue
|
| 1841 |
+
target = item.get("target")
|
| 1842 |
+
if not isinstance(target, dict):
|
| 1843 |
+
continue
|
| 1844 |
+
node_id = str(target.get("node", ""))
|
| 1845 |
+
input_name = str(target.get("input", ""))
|
| 1846 |
+
node = node_by_id.get(node_id)
|
| 1847 |
+
if node is None:
|
| 1848 |
+
diagnostics.append(
|
| 1849 |
+
f"inputContract.inputs[{index}].target.node not found in visualGraph"
|
| 1850 |
+
)
|
| 1851 |
+
continue
|
| 1852 |
+
node_inputs = node.get("inputs")
|
| 1853 |
+
if not isinstance(node_inputs, list):
|
| 1854 |
+
continue
|
| 1855 |
+
if not any(
|
| 1856 |
+
isinstance(port, dict) and str(port.get("name", "")) == input_name
|
| 1857 |
+
for port in node_inputs
|
| 1858 |
+
):
|
| 1859 |
+
diagnostics.append(
|
| 1860 |
+
f"inputContract.inputs[{index}].target.input not found in visualGraph node"
|
| 1861 |
+
)
|
| 1862 |
+
return diagnostics
|
| 1863 |
+
|
| 1864 |
+
|
| 1865 |
+
def _validate_api_prompt_contract_targets(
|
| 1866 |
+
api_prompt: dict[str, Any] | None,
|
| 1867 |
+
input_contract: dict[str, Any],
|
| 1868 |
+
) -> list[str]:
|
| 1869 |
+
diagnostics: list[str] = []
|
| 1870 |
+
if api_prompt is None:
|
| 1871 |
+
return diagnostics
|
| 1872 |
+
inputs = input_contract.get("inputs") if isinstance(input_contract, dict) else None
|
| 1873 |
+
if not isinstance(inputs, list):
|
| 1874 |
+
return diagnostics
|
| 1875 |
+
for index, item in enumerate(inputs):
|
| 1876 |
+
if not isinstance(item, dict):
|
| 1877 |
+
continue
|
| 1878 |
+
target = item.get("target")
|
| 1879 |
+
if not isinstance(target, dict):
|
| 1880 |
+
continue
|
| 1881 |
+
node_id = str(target.get("node", ""))
|
| 1882 |
+
input_name = str(target.get("input", ""))
|
| 1883 |
+
api_node = api_prompt.get(node_id)
|
| 1884 |
+
if not isinstance(api_node, dict):
|
| 1885 |
+
diagnostics.append(
|
| 1886 |
+
f"inputContract.inputs[{index}].target.node not found in generated apiPrompt"
|
| 1887 |
+
)
|
| 1888 |
+
continue
|
| 1889 |
+
api_inputs = api_node.get("inputs")
|
| 1890 |
+
if not isinstance(api_inputs, dict) or input_name not in api_inputs:
|
| 1891 |
+
diagnostics.append(
|
| 1892 |
+
f"inputContract.inputs[{index}].target.input not found in generated apiPrompt"
|
| 1893 |
+
)
|
| 1894 |
+
return diagnostics
|
| 1895 |
+
|
| 1896 |
+
|
| 1897 |
+
def default_storage_path() -> Path:
|
| 1898 |
+
try:
|
| 1899 |
+
import folder_paths
|
| 1900 |
+
|
| 1901 |
+
user_dir = folder_paths.get_user_directory()
|
| 1902 |
+
return Path(user_dir) / DEFAULT_SETUPS_SUBDIR / DEFAULT_SETUPS_FILENAME
|
| 1903 |
+
except Exception: # pragma: no cover - fallback for non-Comfy contexts
|
| 1904 |
+
return Path.cwd() / "user" / "default" / DEFAULT_SETUPS_SUBDIR / DEFAULT_SETUPS_FILENAME
|
| 1905 |
+
|
| 1906 |
+
|
| 1907 |
+
def default_registry() -> PublishedSetupRegistry:
|
| 1908 |
+
return PublishedSetupRegistry(
|
| 1909 |
+
FileSetupStorage(default_storage_path(), fallback_path=SAMPLE_SETUPS_PATH)
|
| 1910 |
+
)
|
| 1911 |
+
|
| 1912 |
+
|
| 1913 |
+
def _validate_scalar(
|
| 1914 |
+
mapping: dict[str, Any],
|
| 1915 |
+
key: str,
|
| 1916 |
+
expected_type: type | tuple[type, ...],
|
| 1917 |
+
diagnostics: list[str],
|
| 1918 |
+
) -> None:
|
| 1919 |
+
if not isinstance(mapping.get(key), expected_type):
|
| 1920 |
+
diagnostics.append(f"{key} has invalid type")
|
| 1921 |
+
|
| 1922 |
+
|
| 1923 |
+
def _validate_mapping(value: Any, path: str, diagnostics: list[str]) -> None:
|
| 1924 |
+
if not isinstance(value, dict):
|
| 1925 |
+
diagnostics.append(f"{path} must be a JSON object")
|
| 1926 |
+
|
| 1927 |
+
|
| 1928 |
+
def _validate_iso_timestamp(value: Any, diagnostics: list[str]) -> None:
|
| 1929 |
+
if not isinstance(value, str):
|
| 1930 |
+
diagnostics.append("updatedAt must be an ISO timestamp")
|
| 1931 |
+
return
|
| 1932 |
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
| 1933 |
+
try:
|
| 1934 |
+
datetime.fromisoformat(normalized)
|
| 1935 |
+
except ValueError:
|
| 1936 |
+
diagnostics.append("updatedAt must be an ISO timestamp")
|
| 1937 |
+
|
| 1938 |
+
|
| 1939 |
+
def _validate_metadata(value: Any, diagnostics: list[str]) -> None:
|
| 1940 |
+
if not isinstance(value, dict):
|
| 1941 |
+
diagnostics.append("metadata must be a JSON object")
|
| 1942 |
+
return
|
| 1943 |
+
if not isinstance(value.get("title"), str) or not value["title"].strip():
|
| 1944 |
+
diagnostics.append("metadata.title must be non-empty text")
|
| 1945 |
+
if "description" in value and not isinstance(value["description"], str):
|
| 1946 |
+
diagnostics.append("metadata.description must be text")
|
| 1947 |
+
if "category" in value and not isinstance(value["category"], str):
|
| 1948 |
+
diagnostics.append("metadata.category must be text")
|
| 1949 |
+
if "tags" in value and not _is_string_list(value["tags"]):
|
| 1950 |
+
diagnostics.append("metadata.tags must be a list of text")
|
| 1951 |
+
if "previewImage" in value and not isinstance(value["previewImage"], str):
|
| 1952 |
+
diagnostics.append("metadata.previewImage must be text")
|
| 1953 |
+
|
| 1954 |
+
|
| 1955 |
+
def _validate_contract(value: Any, path: str, items_key: str, diagnostics: list[str]) -> None:
|
| 1956 |
+
if not isinstance(value, dict):
|
| 1957 |
+
diagnostics.append(f"{path} must be a JSON object")
|
| 1958 |
+
return
|
| 1959 |
+
items = value.get(items_key)
|
| 1960 |
+
if not isinstance(items, list):
|
| 1961 |
+
diagnostics.append(f"{path}.{items_key} must be a list")
|
| 1962 |
+
return
|
| 1963 |
+
for index, item in enumerate(items):
|
| 1964 |
+
item_path = f"{path}.{items_key}[{index}]"
|
| 1965 |
+
if not isinstance(item, dict):
|
| 1966 |
+
diagnostics.append(f"{item_path} must be a JSON object")
|
| 1967 |
+
continue
|
| 1968 |
+
for key in ("key", "type"):
|
| 1969 |
+
if not isinstance(item.get(key), str) or not item[key].strip():
|
| 1970 |
+
diagnostics.append(f"{item_path}.{key} must be non-empty text")
|
| 1971 |
+
if "label" in item and not isinstance(item["label"], str):
|
| 1972 |
+
diagnostics.append(f"{item_path}.label must be text")
|
| 1973 |
+
if "required" in item and not isinstance(item["required"], bool):
|
| 1974 |
+
diagnostics.append(f"{item_path}.required must be true or false")
|
| 1975 |
+
if path == "inputContract":
|
| 1976 |
+
target = item.get("target")
|
| 1977 |
+
if not isinstance(target, dict):
|
| 1978 |
+
diagnostics.append(f"{item_path}.target must be a JSON object")
|
| 1979 |
+
else:
|
| 1980 |
+
for key in ("node", "input"):
|
| 1981 |
+
if not isinstance(target.get(key), str) or not target[key].strip():
|
| 1982 |
+
diagnostics.append(f"{item_path}.target.{key} must be non-empty text")
|
| 1983 |
+
|
| 1984 |
+
|
| 1985 |
+
def _validate_source(value: Any, diagnostics: list[str]) -> None:
|
| 1986 |
+
if not isinstance(value, dict):
|
| 1987 |
+
diagnostics.append("source must be a JSON object")
|
| 1988 |
+
return
|
| 1989 |
+
for key in ("kind", "path"):
|
| 1990 |
+
if not isinstance(value.get(key), str) or not value[key].strip():
|
| 1991 |
+
diagnostics.append(f"source.{key} must be non-empty text")
|
| 1992 |
+
|
| 1993 |
+
|
| 1994 |
+
def _validate_validation(value: Any, diagnostics: list[str]) -> None:
|
| 1995 |
+
if not isinstance(value, dict):
|
| 1996 |
+
diagnostics.append("validation must be a JSON object")
|
| 1997 |
+
return
|
| 1998 |
+
if value.get("status") not in VALIDATION_STATUSES:
|
| 1999 |
+
diagnostics.append("validation.status must be valid, draft, or invalid")
|
| 2000 |
+
if "diagnostics" in value and not _is_string_list(value["diagnostics"]):
|
| 2001 |
+
diagnostics.append("validation.diagnostics must be a list of text")
|
| 2002 |
+
|
| 2003 |
+
|
| 2004 |
+
def _is_string_list(value: Any) -> bool:
|
| 2005 |
+
return isinstance(value, list) and all(isinstance(item, str) for item in value)
|
| 2006 |
+
|
| 2007 |
+
|
| 2008 |
+
def _catalog_summary(setup: dict[str, Any]) -> dict[str, Any]:
|
| 2009 |
+
return {
|
| 2010 |
+
"id": setup["id"],
|
| 2011 |
+
"version": setup["version"],
|
| 2012 |
+
"updatedAt": setup["updatedAt"],
|
| 2013 |
+
"metadata": setup["metadata"],
|
| 2014 |
+
"validation": setup["validation"],
|
| 2015 |
+
"inputSummary": [
|
| 2016 |
+
{
|
| 2017 |
+
"key": item["key"],
|
| 2018 |
+
"label": item.get("label", item["key"]),
|
| 2019 |
+
"type": item["type"],
|
| 2020 |
+
"required": bool(item.get("required", False)),
|
| 2021 |
+
}
|
| 2022 |
+
for item in setup["inputContract"].get("inputs", [])
|
| 2023 |
+
],
|
| 2024 |
+
"outputSummary": [
|
| 2025 |
+
{
|
| 2026 |
+
"key": item["key"],
|
| 2027 |
+
"label": item.get("label", item["key"]),
|
| 2028 |
+
"type": item["type"],
|
| 2029 |
+
}
|
| 2030 |
+
for item in setup["outputContract"].get("outputs", [])
|
| 2031 |
+
],
|
| 2032 |
+
}
|
ComfyUI/custom_nodes/koolook/koolook_versioning.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
| 2 |
+
#
|
| 3 |
+
# ComfyUI-Koolook — shared version-token helpers.
|
| 4 |
+
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
|
| 5 |
+
#
|
| 6 |
+
# This file is part of ComfyUI-Koolook, licensed under GPL-3.0-or-later.
|
| 7 |
+
# See the LICENSE file at the repo root for the full text.
|
| 8 |
+
"""Single source of truth for Koolook's ``vNNN`` output-versioning convention.
|
| 9 |
+
|
| 10 |
+
Both ``EasyAIPipeline`` and ``Easy_VideoCombine`` stamp a version token into
|
| 11 |
+
their output paths/filenames. Centralising the rule here keeps the token
|
| 12 |
+
identical across every node so a single "global version" source (a wired
|
| 13 |
+
STRING from one interface node) propagates consistently.
|
| 14 |
+
|
| 15 |
+
Two entry points:
|
| 16 |
+
|
| 17 |
+
- :func:`normalize_version_token` — clean a wired/typed STRING into a safe
|
| 18 |
+
single filename component (or ``""``).
|
| 19 |
+
- :func:`resolve_version_token` — apply the full precedence rule used by
|
| 20 |
+
nodes that also carry a legacy INT ``version`` widget.
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import os
|
| 25 |
+
import re
|
| 26 |
+
|
| 27 |
+
# Same frontend-quirk sentinels the node modules already defend against: an
|
| 28 |
+
# untouched STRING widget can arrive at the backend as the literal string
|
| 29 |
+
# "undefined" / "null" / "None" instead of "".
|
| 30 |
+
_SENTINEL_STRINGS = ("undefined", "null", "none")
|
| 31 |
+
|
| 32 |
+
# Typed into a version field to request filesystem auto-detection of the next
|
| 33 |
+
# free version instead of a literal token. Nodes detect this and call
|
| 34 |
+
# ``next_version_token`` with their own output directory + name.
|
| 35 |
+
_AUTO_VERSION_TOKENS = ("auto", "next")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def is_auto_version(value) -> bool:
|
| 39 |
+
"""True when a version field requests auto-detection (``auto`` / ``next``)."""
|
| 40 |
+
return normalize_version_token(value).lower() in _AUTO_VERSION_TOKENS
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def normalize_version_token(value) -> str:
|
| 44 |
+
"""Clean a wired/typed version string into a safe single token.
|
| 45 |
+
|
| 46 |
+
Returns ``""`` for empty / whitespace-only / sentinel input. Otherwise
|
| 47 |
+
the value is used **verbatim** — ``v001``, ``final``, ``take_3`` all pass
|
| 48 |
+
through unchanged — after defensive cleanup:
|
| 49 |
+
|
| 50 |
+
- control characters (newline/CR/tab) stripped (Text Multiline can leak a
|
| 51 |
+
stray paragraph break),
|
| 52 |
+
- surrounding whitespace and a matched pair of surrounding quotes removed
|
| 53 |
+
(Explorer "Copy as path" style pastes),
|
| 54 |
+
- frontend sentinels (``undefined``/``null``/``none``) collapsed to ``""``,
|
| 55 |
+
- path separators flattened to ``_`` — a version token is a single
|
| 56 |
+
filename component and must never introduce a subfolder.
|
| 57 |
+
"""
|
| 58 |
+
if value is None:
|
| 59 |
+
return ""
|
| 60 |
+
s = str(value).replace("\r", "").replace("\n", "").replace("\t", "").strip()
|
| 61 |
+
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
|
| 62 |
+
s = s[1:-1].strip()
|
| 63 |
+
if s.lower() in _SENTINEL_STRINGS:
|
| 64 |
+
return ""
|
| 65 |
+
# A version token names one path component, never a path of its own.
|
| 66 |
+
return s.replace("/", "_").replace("\\", "_")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def resolve_version_token(version, disable_versioning: bool = False) -> str:
|
| 70 |
+
"""Resolve the version token to stamp into a path/filename.
|
| 71 |
+
|
| 72 |
+
One field, whether typed into the widget or wired in as a STRING:
|
| 73 |
+
|
| 74 |
+
- ``disable_versioning`` true -> ``""`` (the master off-switch).
|
| 75 |
+
- empty / sentinel -> ``""``.
|
| 76 |
+
- a bare integer (``"2"``) -> ``"v002"`` (back-compat with the old
|
| 77 |
+
INT widget, and a convenience when typing a number).
|
| 78 |
+
- anything else (``"v001"``,
|
| 79 |
+
``"final"``, a wired token) -> used **verbatim**.
|
| 80 |
+
"""
|
| 81 |
+
if disable_versioning:
|
| 82 |
+
return ""
|
| 83 |
+
token = normalize_version_token(version)
|
| 84 |
+
if not token:
|
| 85 |
+
return ""
|
| 86 |
+
if token.isdigit():
|
| 87 |
+
return f"v{int(token):03d}"
|
| 88 |
+
return token
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def next_version_token(
|
| 92 |
+
directory,
|
| 93 |
+
name,
|
| 94 |
+
version_prefix: str = "v",
|
| 95 |
+
padding: int = 3,
|
| 96 |
+
start: int = 1,
|
| 97 |
+
) -> str:
|
| 98 |
+
"""Return the next free ``<prefix>NNN`` token for ``name`` in ``directory``.
|
| 99 |
+
|
| 100 |
+
Scans ``directory`` for entries (files *and* subfolders) of the form
|
| 101 |
+
``<name>_<prefix><digits>`` -- e.g. ``bearMask_v002.png``,
|
| 102 |
+
``bearMask_v002.0001.exr``, or a ``bearMask_v003/`` sequence folder -- and
|
| 103 |
+
returns the highest detected version plus one, zero-padded to ``padding``.
|
| 104 |
+
|
| 105 |
+
A missing or empty directory (or no matching entries) yields ``start``
|
| 106 |
+
(default ``v001``). Permission errors and other unexpected filesystem
|
| 107 |
+
failures surface to the caller so a broken output mount cannot silently
|
| 108 |
+
become ``v001``. Matching is on the **exact** base name, so a different
|
| 109 |
+
shot's versions never bump this one. An empty ``name`` matches bare
|
| 110 |
+
``<prefix>NNN`` tokens. Whatever follows the digits (extension, frame
|
| 111 |
+
number, ``_suffix``) is ignored.
|
| 112 |
+
"""
|
| 113 |
+
prefix = normalize_version_token(version_prefix) or "v"
|
| 114 |
+
try:
|
| 115 |
+
pad = max(1, int(padding))
|
| 116 |
+
except (TypeError, ValueError):
|
| 117 |
+
pad = 3
|
| 118 |
+
try:
|
| 119 |
+
start_int = int(start)
|
| 120 |
+
except (TypeError, ValueError):
|
| 121 |
+
start_int = 1
|
| 122 |
+
|
| 123 |
+
base = normalize_version_token(name)
|
| 124 |
+
lead = f"{re.escape(base)}_" if base else ""
|
| 125 |
+
pattern = re.compile(rf"^{lead}{re.escape(prefix)}(\d+)")
|
| 126 |
+
|
| 127 |
+
highest: int | None = None
|
| 128 |
+
try:
|
| 129 |
+
entries = os.listdir(directory) if directory else []
|
| 130 |
+
except FileNotFoundError:
|
| 131 |
+
entries = []
|
| 132 |
+
for entry in entries:
|
| 133 |
+
match = pattern.match(entry)
|
| 134 |
+
if match:
|
| 135 |
+
value = int(match.group(1))
|
| 136 |
+
highest = value if highest is None else max(highest, value)
|
| 137 |
+
|
| 138 |
+
nxt = start_int if highest is None else highest + 1
|
| 139 |
+
return f"{prefix}{nxt:0{pad}d}"
|
ComfyUI/custom_nodes/koolook/pyproject.toml
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "koolook"
|
| 3 |
+
description = "This repository provides custom nodes for ComfyUI tailored for VFX and AI image/video generation workflows"
|
| 4 |
+
version = "0.4.4"
|
| 5 |
+
license = {file = "LICENSE"}
|
| 6 |
+
# classifiers = [
|
| 7 |
+
# # For OS-independent nodes (works on all operating systems)
|
| 8 |
+
# "Operating System :: OS Independent",
|
| 9 |
+
#
|
| 10 |
+
# # OR for OS-specific nodes, specify the supported systems:
|
| 11 |
+
# "Operating System :: Microsoft :: Windows", # Windows specific
|
| 12 |
+
# "Operating System :: POSIX :: Linux", # Linux specific
|
| 13 |
+
# "Operating System :: MacOS", # macOS specific
|
| 14 |
+
#
|
| 15 |
+
# # GPU Accelerator support. Pick the ones that are supported by your extension.
|
| 16 |
+
# "Environment :: GPU :: NVIDIA CUDA", # NVIDIA CUDA support
|
| 17 |
+
# "Environment :: GPU :: AMD ROCm", # AMD ROCm support
|
| 18 |
+
# "Environment :: GPU :: Intel Arc", # Intel Arc support
|
| 19 |
+
# "Environment :: NPU :: Huawei Ascend", # Huawei Ascend support
|
| 20 |
+
# "Environment :: GPU :: Apple Metal", # Apple Metal support
|
| 21 |
+
# ]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
[project.urls]
|
| 25 |
+
Repository = "https://github.com/malkuthro/ComfyUI-Koolook"
|
| 26 |
+
# Used by Comfy Registry https://registry.comfy.org
|
| 27 |
+
Documentation = "https://www.kforgelabs.com/comfyui-projects/comfyui-koolook/"
|
| 28 |
+
"Bug Tracker" = "https://github.com/malkuthro/ComfyUI-Koolook/issues"
|
| 29 |
+
|
| 30 |
+
[tool.comfy]
|
| 31 |
+
PublisherId = "kforgelabs"
|
| 32 |
+
DisplayName = "ComfyUI-Koolook"
|
| 33 |
+
Icon = ""
|
| 34 |
+
includes = []
|
| 35 |
+
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
|
| 36 |
+
|
| 37 |
+
[tool.ruff]
|
| 38 |
+
extend-exclude = [".venv", "forks", "upscaler_FIX", "nuke_CAM_exporter"]
|
| 39 |
+
|
| 40 |
+
[tool.setuptools]
|
| 41 |
+
py-modules = [
|
| 42 |
+
"k_ai_pipeline",
|
| 43 |
+
"k_easy_image_batch",
|
| 44 |
+
"k_easy_pattern",
|
| 45 |
+
"k_easy_resize",
|
| 46 |
+
"k_easy_track",
|
| 47 |
+
"k_easy_wan22_prompt",
|
| 48 |
+
"k_loop_status",
|
| 49 |
+
"k_video_combine",
|
| 50 |
+
"k_video_load",
|
| 51 |
+
"koolook_install_guard",
|
| 52 |
+
"koolook_routes",
|
| 53 |
+
"koolook_setups",
|
| 54 |
+
"koolook_versioning",
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
[tool.ruff.lint.per-file-ignores]
|
| 58 |
+
"k_easy_resize.py" = ["F841"]
|
| 59 |
+
|
| 60 |
+
[tool.pytest.ini_options]
|
| 61 |
+
# ``testpaths`` scopes discovery to ``tests/``. ``pythonpath`` makes the repo
|
| 62 |
+
# root importable so tests can ``import koolook_routes`` without an editable
|
| 63 |
+
# install. ``importmode=importlib`` avoids the legacy ``sys.path.prepend``
|
| 64 |
+
# import dance so re-running individual files works cleanly.
|
| 65 |
+
testpaths = ["tests"]
|
| 66 |
+
pythonpath = ["."]
|
| 67 |
+
addopts = ["--import-mode=importlib"]
|
| 68 |
+
|
| 69 |
+
[project.optional-dependencies]
|
| 70 |
+
# Test deps for ``pytest`` runs. CI mirrors this list directly because this
|
| 71 |
+
# ComfyUI custom node is not installed as a package in the workflow.
|
| 72 |
+
# Locally use ``.venv/bin/pip install -e '.[test]'`` from the repo root.
|
| 73 |
+
# ``aiohttp`` is a runtime dep of ``koolook_routes`` and must be importable
|
| 74 |
+
# even when only a pure helper is under test. The lower bound is pinned to
|
| 75 |
+
# the CVE-2026-54273..54280 fix line (>=3.14.1) so the declared range can't
|
| 76 |
+
# resolve a vulnerable aiohttp; the lock (constraints-test.txt) matches.
|
| 77 |
+
test = ["pytest>=8", "aiohttp>=3.14.1", "pip-audit>=2.7", "Pillow>=10"]
|
ComfyUI/custom_nodes/koolook/scripts/bootstrap_test_env.ps1
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Bootstrap a repo-local .venv with the LOCKED, AUDITED test dependency set.
|
| 2 |
+
#
|
| 3 |
+
# * Reproducible -- installs against constraints-test.txt when present (a
|
| 4 |
+
# pinned resolve of `.[test]` + its full transitive closure). Pass
|
| 5 |
+
# -Relock (with -Force) to re-resolve and rewrite that lock; commit the
|
| 6 |
+
# diff as the dependency-change review surface.
|
| 7 |
+
# * Verified -- runs pip-audit after install; a known CVE fails the
|
| 8 |
+
# bootstrap (exit 1). Pass -NoAudit to skip (e.g. offline).
|
| 9 |
+
# * Idempotent -- no-op if .venv already exists. Pass -Force to recreate.
|
| 10 |
+
#
|
| 11 |
+
# Usage: scripts\bootstrap_test_env.ps1 [-Force] [-Relock] [-NoAudit]
|
| 12 |
+
|
| 13 |
+
param(
|
| 14 |
+
[switch]$Force,
|
| 15 |
+
[switch]$Relock,
|
| 16 |
+
[switch]$NoAudit
|
| 17 |
+
)
|
| 18 |
+
$ErrorActionPreference = "Stop"
|
| 19 |
+
|
| 20 |
+
$Constraints = "constraints-test.txt"
|
| 21 |
+
|
| 22 |
+
if (Test-Path .venv) {
|
| 23 |
+
if (-not $Force) {
|
| 24 |
+
if ($Relock) {
|
| 25 |
+
Write-Host "-Relock requires -Force (the lock is rewritten from a fresh resolve); nothing was changed."
|
| 26 |
+
}
|
| 27 |
+
Write-Host ".venv already exists. Pass -Force to recreate."
|
| 28 |
+
exit 0
|
| 29 |
+
}
|
| 30 |
+
Write-Host "Removing existing .venv ..."
|
| 31 |
+
Remove-Item -Recurse -Force .venv
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
Write-Host "Creating .venv ..."
|
| 35 |
+
python -m venv .venv
|
| 36 |
+
|
| 37 |
+
Write-Host "Upgrading pip + setuptools ..."
|
| 38 |
+
.\.venv\Scripts\python -m pip install --quiet --upgrade pip setuptools
|
| 39 |
+
|
| 40 |
+
if ((Test-Path $Constraints) -and (-not $Relock)) {
|
| 41 |
+
Write-Host "Installing project + test extras (locked via $Constraints) ..."
|
| 42 |
+
.\.venv\Scripts\python -m pip install --quiet -e ".[test]" -c $Constraints
|
| 43 |
+
} else {
|
| 44 |
+
Write-Host "Resolving + installing project + test extras ..."
|
| 45 |
+
.\.venv\Scripts\python -m pip install --quiet -e ".[test]"
|
| 46 |
+
Write-Host "Writing locked set to $Constraints ..."
|
| 47 |
+
@'
|
| 48 |
+
# Locked test dependency set for ComfyUI-Koolook.
|
| 49 |
+
#
|
| 50 |
+
# Pinned resolve of the `[test]` extras in pyproject.toml plus their full
|
| 51 |
+
# transitive closure. The bootstrap scripts install with `-c
|
| 52 |
+
# constraints-test.txt`, so every fresh .venv is reproducible and
|
| 53 |
+
# pip-audit-verifiable.
|
| 54 |
+
#
|
| 55 |
+
# DO NOT hand-edit the version pins. To change the set: edit the `[test]`
|
| 56 |
+
# extras in pyproject.toml, then regenerate this file with
|
| 57 |
+
# bash scripts/bootstrap_test_env.sh --force --relock (POSIX)
|
| 58 |
+
# scripts\bootstrap_test_env.ps1 -Force -Relock (Windows)
|
| 59 |
+
# and commit the diff -- that diff is the dependency-change review surface.
|
| 60 |
+
#
|
| 61 |
+
'@ | Out-File -Encoding ascii $Constraints
|
| 62 |
+
.\.venv\Scripts\python -m pip list --format=freeze --exclude pip --exclude setuptools --exclude wheel --exclude koolook | Out-File -Encoding ascii -Append $Constraints
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
if (-not $NoAudit) {
|
| 66 |
+
Write-Host "Auditing installed set (pip-audit) ..."
|
| 67 |
+
.\.venv\Scripts\pip-audit --skip-editable
|
| 68 |
+
if ($LASTEXITCODE -ne 0) {
|
| 69 |
+
Write-Host ""
|
| 70 |
+
Write-Host "BLOCKER: pip-audit did not pass -- a known vulnerability was found,"
|
| 71 |
+
Write-Host "or the audit could not complete. Review the output above."
|
| 72 |
+
Write-Host "To bootstrap anyway (e.g. offline), re-run with -NoAudit."
|
| 73 |
+
exit 1
|
| 74 |
+
}
|
| 75 |
+
Write-Host "pip-audit: no known vulnerabilities."
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
Write-Host ""
|
| 79 |
+
Write-Host "Test env ready. Run tests with:"
|
| 80 |
+
Write-Host " .\.venv\Scripts\python -m pytest"
|
ComfyUI/custom_nodes/koolook/scripts/bootstrap_test_env.sh
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Bootstrap a repo-local .venv with the LOCKED, AUDITED test dependency set.
|
| 3 |
+
#
|
| 4 |
+
# * Reproducible -- installs against constraints-test.txt when present (a
|
| 5 |
+
# pinned resolve of `.[test]` + its full transitive closure). Pass
|
| 6 |
+
# --relock (with --force) to re-resolve and rewrite that lock; commit
|
| 7 |
+
# the diff as the dependency-change review surface.
|
| 8 |
+
# * Verified -- runs pip-audit after install; a known CVE fails the
|
| 9 |
+
# bootstrap (exit 1). Pass --no-audit to skip (e.g. offline).
|
| 10 |
+
# * Idempotent -- no-op if .venv already exists. Pass --force to recreate.
|
| 11 |
+
#
|
| 12 |
+
# Usage: bash scripts/bootstrap_test_env.sh [--force] [--relock] [--no-audit]
|
| 13 |
+
|
| 14 |
+
set -euo pipefail
|
| 15 |
+
|
| 16 |
+
FORCE=""
|
| 17 |
+
RELOCK=""
|
| 18 |
+
AUDIT="1"
|
| 19 |
+
for arg in "$@"; do
|
| 20 |
+
case "$arg" in
|
| 21 |
+
--force) FORCE="1" ;;
|
| 22 |
+
--relock) RELOCK="1" ;;
|
| 23 |
+
--no-audit) AUDIT="" ;;
|
| 24 |
+
*) echo "Unknown argument: $arg" >&2
|
| 25 |
+
echo "Usage: bash scripts/bootstrap_test_env.sh [--force] [--relock] [--no-audit]" >&2
|
| 26 |
+
exit 2 ;;
|
| 27 |
+
esac
|
| 28 |
+
done
|
| 29 |
+
|
| 30 |
+
CONSTRAINTS="constraints-test.txt"
|
| 31 |
+
|
| 32 |
+
PYTHON_BIN="${PYTHON:-}"
|
| 33 |
+
if [ -z "$PYTHON_BIN" ]; then
|
| 34 |
+
if command -v python3 >/dev/null 2>&1; then
|
| 35 |
+
PYTHON_BIN="python3"
|
| 36 |
+
elif command -v python >/dev/null 2>&1; then
|
| 37 |
+
PYTHON_BIN="python"
|
| 38 |
+
else
|
| 39 |
+
echo "Could not find python3 or python on PATH." >&2
|
| 40 |
+
exit 1
|
| 41 |
+
fi
|
| 42 |
+
fi
|
| 43 |
+
|
| 44 |
+
if [ -d .venv ]; then
|
| 45 |
+
if [ -z "$FORCE" ]; then
|
| 46 |
+
if [ -n "$RELOCK" ]; then
|
| 47 |
+
echo "--relock requires --force (the lock is rewritten from a fresh resolve); nothing was changed." >&2
|
| 48 |
+
fi
|
| 49 |
+
echo ".venv already exists. Pass --force to recreate."
|
| 50 |
+
exit 0
|
| 51 |
+
fi
|
| 52 |
+
echo "Removing existing .venv ..."
|
| 53 |
+
rm -rf .venv
|
| 54 |
+
fi
|
| 55 |
+
|
| 56 |
+
echo "Creating .venv ..."
|
| 57 |
+
"$PYTHON_BIN" -m venv .venv
|
| 58 |
+
|
| 59 |
+
echo "Upgrading pip + setuptools ..."
|
| 60 |
+
.venv/bin/python -m pip install --quiet --upgrade pip setuptools
|
| 61 |
+
|
| 62 |
+
if [ -f "$CONSTRAINTS" ] && [ -z "$RELOCK" ]; then
|
| 63 |
+
echo "Installing project + test extras (locked via $CONSTRAINTS) ..."
|
| 64 |
+
.venv/bin/python -m pip install --quiet -e '.[test]' -c "$CONSTRAINTS"
|
| 65 |
+
else
|
| 66 |
+
echo "Resolving + installing project + test extras ..."
|
| 67 |
+
.venv/bin/python -m pip install --quiet -e '.[test]'
|
| 68 |
+
echo "Writing locked set to $CONSTRAINTS ..."
|
| 69 |
+
cat > "$CONSTRAINTS" <<'EOF'
|
| 70 |
+
# Locked test dependency set for ComfyUI-Koolook.
|
| 71 |
+
#
|
| 72 |
+
# Pinned resolve of the `[test]` extras in pyproject.toml plus their full
|
| 73 |
+
# transitive closure. The bootstrap scripts install with `-c
|
| 74 |
+
# constraints-test.txt`, so every fresh .venv is reproducible and
|
| 75 |
+
# pip-audit-verifiable.
|
| 76 |
+
#
|
| 77 |
+
# DO NOT hand-edit the version pins. To change the set: edit the `[test]`
|
| 78 |
+
# extras in pyproject.toml, then regenerate this file with
|
| 79 |
+
# bash scripts/bootstrap_test_env.sh --force --relock (POSIX)
|
| 80 |
+
# scripts\bootstrap_test_env.ps1 -Force -Relock (Windows)
|
| 81 |
+
# and commit the diff -- that diff is the dependency-change review surface.
|
| 82 |
+
#
|
| 83 |
+
EOF
|
| 84 |
+
.venv/bin/python -m pip list --format=freeze --exclude pip --exclude setuptools --exclude wheel --exclude koolook >> "$CONSTRAINTS"
|
| 85 |
+
fi
|
| 86 |
+
|
| 87 |
+
if [ -n "$AUDIT" ]; then
|
| 88 |
+
echo "Auditing installed set (pip-audit) ..."
|
| 89 |
+
if .venv/bin/pip-audit --skip-editable; then
|
| 90 |
+
echo "pip-audit: no known vulnerabilities."
|
| 91 |
+
else
|
| 92 |
+
echo "" >&2
|
| 93 |
+
echo "BLOCKER: pip-audit did not pass -- a known vulnerability was found," >&2
|
| 94 |
+
echo "or the audit could not complete. Review the output above." >&2
|
| 95 |
+
echo "To bootstrap anyway (e.g. offline), re-run with --no-audit." >&2
|
| 96 |
+
exit 1
|
| 97 |
+
fi
|
| 98 |
+
fi
|
| 99 |
+
|
| 100 |
+
echo ""
|
| 101 |
+
echo "Test env ready. Run tests with:"
|
| 102 |
+
echo " .venv/bin/python -m pytest"
|
ComfyUI/custom_nodes/koolook/scripts/loop_audio.config.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_doc": "Settings consumed by scripts/loop_audio.py. Underscore-prefixed keys are ignored by the loader and may carry inline documentation (same convention as the LTXDirector relay_overrides widget). Schema definitions live in docs/reference/glossary.md > 'Loop config (per-module)'.",
|
| 3 |
+
|
| 4 |
+
"job_name": "audio-lipsync",
|
| 5 |
+
"_job_name": "Human-readable identifier for this iteration loop. Surfaces in chat reports and card titles.",
|
| 6 |
+
|
| 7 |
+
"module_path": "docs/automations/LTX-2.3/audio-lipsync",
|
| 8 |
+
"_module_path": "Module root, relative to the repo root. The runs/ subfolder under this path is where snapshot folders + log.md live.",
|
| 9 |
+
|
| 10 |
+
"comfyui_workflows_subpath": "user/default/workflows",
|
| 11 |
+
"_comfyui_workflows_subpath": "Path within the ComfyUI install where the maintainer's workflow JSONs live. The ComfyUI install root is derived from KOLOOK_COMFYUI_DEV_PATH (walk up two levels from custom_nodes/<koolook>). Override by setting KOLOOK_COMFYUI_WORKFLOWS_DIR in .env.",
|
| 12 |
+
|
| 13 |
+
"workflow_pattern": "LTX-23-audio_tests*.json",
|
| 14 |
+
"_workflow_pattern": "Glob picked from the workflows dir. Newest mtime wins. Should match the 'Setup name' the maintainer pins on the canvas.",
|
| 15 |
+
|
| 16 |
+
"skip_filename_substring": "loop",
|
| 17 |
+
"_skip_filename_substring": "Any JSON whose stem contains this substring (case-insensitive) is excluded — these are post-card outputs, never iteration inputs. Convention matches scripts/make_card.py.",
|
| 18 |
+
|
| 19 |
+
"tracked_multilines": {
|
| 20 |
+
"working_folder": [
|
| 21 |
+
"global [ path ] - working folder",
|
| 22 |
+
"working_folder",
|
| 23 |
+
"working folder"
|
| 24 |
+
],
|
| 25 |
+
"name": [
|
| 26 |
+
"global [ base name ]",
|
| 27 |
+
"name"
|
| 28 |
+
],
|
| 29 |
+
"relay_overrides": [
|
| 30 |
+
"relay_overrides"
|
| 31 |
+
],
|
| 32 |
+
"overlay - info": [
|
| 33 |
+
"overlay - info"
|
| 34 |
+
],
|
| 35 |
+
"overlay - feedback": [
|
| 36 |
+
"overlay - feedback"
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
"_tracked_multilines": "Semantic capture keys mapped to prioritized lowercase title substrings. Matching is case-insensitive and treats underscores as spaces, so old titles and the v02 GLOBAL labels can coexist. For each key, earlier aliases win when multiple Text Multiline titles match.",
|
| 40 |
+
|
| 41 |
+
"tracked_setup_variables": {
|
| 42 |
+
"input_path_exr": [
|
| 43 |
+
"input path [ exr ]"
|
| 44 |
+
],
|
| 45 |
+
"version": [
|
| 46 |
+
"global [ version ]",
|
| 47 |
+
"version [ global ]"
|
| 48 |
+
],
|
| 49 |
+
"run_offset": [
|
| 50 |
+
"global [ run offset ]"
|
| 51 |
+
]
|
| 52 |
+
},
|
| 53 |
+
"_tracked_setup_variables": "Additional setup-card variables captured into notes.md for audit. Aliases are exact normalized title matches. Reads source value nodes only (Text Multiline and Primitive*), not GetNode/SetNode relay plumbing. The BASE note is a checklist/reminder, not a value, so derived BASE facts are calculated by the script instead. GLOBAL [ run ] is calculated inside the workflow from version + run offset; the capture records the inputs instead of reimplementing that graph math.",
|
| 54 |
+
|
| 55 |
+
"fork_to_track": "forks/whatdreamscost_koolook/versions/v1_3_9",
|
| 56 |
+
"_fork_to_track": "Repo-relative path of the fork files this module iterates on. Recorded in patch_state.txt of each run snapshot + diffed for the 'fork dir status' line.",
|
| 57 |
+
|
| 58 |
+
"render_card": true,
|
| 59 |
+
"_render_card": "When true, loop_audio.py invokes scripts/make_card_audio.render_audio_card at end-of-snapshot, writing card.png into the new run-NNN_ folder and copying it to the external cards folder as <Output name>_runNNN_card.png. Set false to skip the PIL dependency."
|
| 60 |
+
}
|
ComfyUI/custom_nodes/koolook/scripts/loop_audio.py
ADDED
|
@@ -0,0 +1,1630 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
``loop-audio`` — record one iteration of the LTX 2.3 audio-lipsync loop.
|
| 4 |
+
|
| 5 |
+
End-to-end automation for one render cycle: locate the workflow the
|
| 6 |
+
maintainer just saved inside ComfyUI, snapshot the workflow JSON +
|
| 7 |
+
relay-overrides + fork state + notes into a numbered ``runs/run-NNN_``
|
| 8 |
+
folder under this module, render the audio-lipsync card alongside, and
|
| 9 |
+
append a row to ``runs/log.md``. No manual steps.
|
| 10 |
+
|
| 11 |
+
Settings — *which* module, *which* workflow filename to look for,
|
| 12 |
+
*which* multiline titles to extract, *which* fork dir to pin — all live
|
| 13 |
+
in :file:`loop_audio.config.json` next to this script. Edit the config,
|
| 14 |
+
not the Python, when the convention shifts.
|
| 15 |
+
|
| 16 |
+
USER-INITIATED ONLY. Same rule as ``dev-sync`` and ``dev-sync-audio``
|
| 17 |
+
(see project ``CLAUDE.md``). Trigger phrase: ``loop-audio``.
|
| 18 |
+
|
| 19 |
+
Discovery chain:
|
| 20 |
+
|
| 21 |
+
KOLOOK_COMFYUI_DEV_PATH ── walk up two levels ──► <ComfyUI>/
|
| 22 |
+
└── <comfyui_workflows_subpath from config> ── glob workflow_pattern ──►
|
| 23 |
+
newest mtime (excluding skip_filename_substring) ──► workflow.json
|
| 24 |
+
|
| 25 |
+
Snapshot folder layout (always created):
|
| 26 |
+
|
| 27 |
+
<module_path>/runs/run-NNN_<auto-label>/
|
| 28 |
+
├── runNNN_workflow.json copy of the workflow at submission
|
| 29 |
+
├── relay_overrides.txt RELAY_OVERRIDES multiline body
|
| 30 |
+
├── patch_state.txt MAIN sha + last dev-sync-audio + fork-dir status
|
| 31 |
+
├── metadata.json structured run/setup/director/repo metadata
|
| 32 |
+
├── notes.md feedback + scores + mechanical interp
|
| 33 |
+
└── card.png when render_card=true in config
|
| 34 |
+
|
| 35 |
+
Plus one row appended to <module_path>/runs/log.md.
|
| 36 |
+
|
| 37 |
+
Usage:
|
| 38 |
+
|
| 39 |
+
python scripts/loop_audio.py
|
| 40 |
+
python scripts/loop_audio.py --dry-run
|
| 41 |
+
python scripts/loop_audio.py --label <override>
|
| 42 |
+
python scripts/loop_audio.py --no-log
|
| 43 |
+
python scripts/loop_audio.py --no-card
|
| 44 |
+
python scripts/loop_audio.py --workflow <explicit-path>
|
| 45 |
+
python scripts/loop_audio.py --config <explicit-config-path>
|
| 46 |
+
|
| 47 |
+
Exit codes:
|
| 48 |
+
|
| 49 |
+
0 snapshot landed (or would have, in --dry-run)
|
| 50 |
+
2 KOLOOK_COMFYUI_DEV_PATH unset / ComfyUI workflows dir missing / config missing
|
| 51 |
+
3 no workflow matching the configured pattern
|
| 52 |
+
4 config JSON malformed
|
| 53 |
+
"""
|
| 54 |
+
from __future__ import annotations
|
| 55 |
+
|
| 56 |
+
import argparse
|
| 57 |
+
import hashlib
|
| 58 |
+
import json
|
| 59 |
+
import os
|
| 60 |
+
import re
|
| 61 |
+
import shutil
|
| 62 |
+
import subprocess
|
| 63 |
+
import sys
|
| 64 |
+
import tomllib
|
| 65 |
+
from datetime import date
|
| 66 |
+
from pathlib import Path
|
| 67 |
+
from typing import Any, Optional
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 71 |
+
DEFAULT_CONFIG_PATH = Path(__file__).resolve().with_suffix(".config.json")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# --------------------------------------------------------------------------
|
| 75 |
+
# Config — read once into a small dataclass-ish dict. Underscore-prefixed
|
| 76 |
+
# keys are ignored (same convention as the LTXDirector relay_overrides
|
| 77 |
+
# widget — keeps inline documentation co-located with the values).
|
| 78 |
+
# --------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
REQUIRED_CONFIG_KEYS = (
|
| 82 |
+
"job_name",
|
| 83 |
+
"module_path",
|
| 84 |
+
"comfyui_workflows_subpath",
|
| 85 |
+
"workflow_pattern",
|
| 86 |
+
"skip_filename_substring",
|
| 87 |
+
"tracked_multilines",
|
| 88 |
+
"fork_to_track",
|
| 89 |
+
"render_card",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def load_config(path: Path) -> dict[str, Any]:
|
| 94 |
+
if not path.is_file():
|
| 95 |
+
print(
|
| 96 |
+
f"Config not found: {path}\n"
|
| 97 |
+
f"Each loop script defaults to <script>.config.json beside it; "
|
| 98 |
+
f"pass --config to point elsewhere.",
|
| 99 |
+
file=sys.stderr,
|
| 100 |
+
)
|
| 101 |
+
sys.exit(2)
|
| 102 |
+
try:
|
| 103 |
+
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
| 104 |
+
except json.JSONDecodeError as exc:
|
| 105 |
+
print(f"Malformed config {path}: {exc}", file=sys.stderr)
|
| 106 |
+
sys.exit(4)
|
| 107 |
+
if not isinstance(raw, dict):
|
| 108 |
+
print(f"Config root must be an object: {path}", file=sys.stderr)
|
| 109 |
+
sys.exit(4)
|
| 110 |
+
cfg = {k: v for k, v in raw.items() if not k.startswith("_")}
|
| 111 |
+
missing = [k for k in REQUIRED_CONFIG_KEYS if k not in cfg]
|
| 112 |
+
if missing:
|
| 113 |
+
print(
|
| 114 |
+
f"Config {path} is missing required keys: {missing}",
|
| 115 |
+
file=sys.stderr,
|
| 116 |
+
)
|
| 117 |
+
sys.exit(4)
|
| 118 |
+
return cfg
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# --------------------------------------------------------------------------
|
| 122 |
+
# .env loader (minimal — same shape as scripts/sync_to_dev.py's helper).
|
| 123 |
+
# --------------------------------------------------------------------------
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def load_dotenv(env_path: Path) -> None:
|
| 127 |
+
if not env_path.exists():
|
| 128 |
+
return
|
| 129 |
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
| 130 |
+
line = line.strip()
|
| 131 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 132 |
+
continue
|
| 133 |
+
key, _, value = line.partition("=")
|
| 134 |
+
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def find_dotenv() -> Optional[Path]:
|
| 138 |
+
"""Find .env starting at this worktree; fall back to the main repo
|
| 139 |
+
root via git's common-dir when running from a worktree. Mirrors
|
| 140 |
+
scripts/make_card.py so both scripts behave the same when invoked
|
| 141 |
+
from a fresh worktree where the maintainer hasn't copied the .env
|
| 142 |
+
over yet."""
|
| 143 |
+
direct = REPO_ROOT / ".env"
|
| 144 |
+
if direct.exists():
|
| 145 |
+
return direct
|
| 146 |
+
git_marker = REPO_ROOT / ".git"
|
| 147 |
+
if not git_marker.is_file():
|
| 148 |
+
return None
|
| 149 |
+
try:
|
| 150 |
+
content = git_marker.read_text(encoding="utf-8").strip()
|
| 151 |
+
except OSError:
|
| 152 |
+
return None
|
| 153 |
+
if not content.startswith("gitdir:"):
|
| 154 |
+
return None
|
| 155 |
+
gitdir = Path(content.split(":", 1)[1].strip())
|
| 156 |
+
if "worktrees" not in gitdir.parts:
|
| 157 |
+
return None
|
| 158 |
+
idx = gitdir.parts.index("worktrees")
|
| 159 |
+
main_repo_root = Path(*gitdir.parts[:idx]).parent
|
| 160 |
+
candidate = main_repo_root / ".env"
|
| 161 |
+
return candidate if candidate.exists() else None
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# --------------------------------------------------------------------------
|
| 165 |
+
# Workflow discovery — turn config + env into a concrete JSON path.
|
| 166 |
+
# --------------------------------------------------------------------------
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def resolve_workflows_dir(cfg: dict[str, Any]) -> Path:
|
| 170 |
+
"""``KOLOOK_COMFYUI_WORKFLOWS_DIR`` wins when set (lets the maintainer
|
| 171 |
+
point the loop at any workflow library). Otherwise derive
|
| 172 |
+
``<ComfyUI>/<comfyui_workflows_subpath from config>`` from
|
| 173 |
+
``KOLOOK_COMFYUI_DEV_PATH`` by walking up two levels from the
|
| 174 |
+
``custom_nodes/<koolook>`` leaf."""
|
| 175 |
+
override = os.environ.get("KOLOOK_COMFYUI_WORKFLOWS_DIR")
|
| 176 |
+
if override:
|
| 177 |
+
p = Path(override).expanduser()
|
| 178 |
+
if not p.is_dir():
|
| 179 |
+
print(
|
| 180 |
+
f"KOLOOK_COMFYUI_WORKFLOWS_DIR points at a non-dir: {p}",
|
| 181 |
+
file=sys.stderr,
|
| 182 |
+
)
|
| 183 |
+
sys.exit(2)
|
| 184 |
+
return p
|
| 185 |
+
|
| 186 |
+
dev = os.environ.get("KOLOOK_COMFYUI_DEV_PATH")
|
| 187 |
+
if not dev:
|
| 188 |
+
print(
|
| 189 |
+
"Neither KOLOOK_COMFYUI_WORKFLOWS_DIR nor "
|
| 190 |
+
"KOLOOK_COMFYUI_DEV_PATH is set in .env. Set one (see .env.example).",
|
| 191 |
+
file=sys.stderr,
|
| 192 |
+
)
|
| 193 |
+
sys.exit(2)
|
| 194 |
+
leaf = Path(dev).expanduser()
|
| 195 |
+
workflows = leaf.parent.parent / cfg["comfyui_workflows_subpath"]
|
| 196 |
+
if not workflows.is_dir():
|
| 197 |
+
print(
|
| 198 |
+
f"Workflows dir not found: {workflows}\n"
|
| 199 |
+
f"(derived from KOLOOK_COMFYUI_DEV_PATH={dev} + "
|
| 200 |
+
f"config.comfyui_workflows_subpath={cfg['comfyui_workflows_subpath']})",
|
| 201 |
+
file=sys.stderr,
|
| 202 |
+
)
|
| 203 |
+
sys.exit(2)
|
| 204 |
+
return workflows
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def find_workflow(workflows_dir: Path, cfg: dict[str, Any]) -> Path:
|
| 208 |
+
skip = cfg["skip_filename_substring"].lower()
|
| 209 |
+
candidates = [
|
| 210 |
+
p for p in workflows_dir.glob(cfg["workflow_pattern"])
|
| 211 |
+
if skip not in p.stem.lower()
|
| 212 |
+
]
|
| 213 |
+
if not candidates:
|
| 214 |
+
print(
|
| 215 |
+
f"No workflow JSON matching {cfg['workflow_pattern']!r} in "
|
| 216 |
+
f"{workflows_dir}",
|
| 217 |
+
file=sys.stderr,
|
| 218 |
+
)
|
| 219 |
+
sys.exit(3)
|
| 220 |
+
return max(candidates, key=lambda p: p.stat().st_mtime)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# --------------------------------------------------------------------------
|
| 224 |
+
# Workflow content extraction.
|
| 225 |
+
# --------------------------------------------------------------------------
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
DIRECTOR_TYPE = "LTXDirector__koolook"
|
| 229 |
+
LEGACY_DIRECTOR_TYPES = ("LTXDirector__koolook_v1_3_2",)
|
| 230 |
+
UPSTREAM_DIRECTOR_TYPE = "LTXDirector"
|
| 231 |
+
DIRECTOR_TYPES = (DIRECTOR_TYPE, *LEGACY_DIRECTOR_TYPES, UPSTREAM_DIRECTOR_TYPE)
|
| 232 |
+
KOOLOOK_UPSTREAM_PIN = (
|
| 233 |
+
REPO_ROOT
|
| 234 |
+
/ "forks"
|
| 235 |
+
/ "whatdreamscost_koolook"
|
| 236 |
+
/ "versions"
|
| 237 |
+
/ "v1_3_9"
|
| 238 |
+
/ "UPSTREAM_PIN.yaml"
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# Koolook Director widget order — verified empirically against saved
|
| 242 |
+
# workflow JSON. The Comfy frontend serialises widgets in their original
|
| 243 |
+
# (upstream) order even after the Koolook fork reordered them in the
|
| 244 |
+
# schema; the only new widget — `relay_overrides` — is appended at the
|
| 245 |
+
# end. So the saved widgets_values matches the legacy LTXDirector order
|
| 246 |
+
# plus one extra slot.
|
| 247 |
+
#
|
| 248 |
+
# MIRROR: this dict is also documented in
|
| 249 |
+
# docs/automations/LTX-2.3/audio-lipsync/reading-graph.schema.yaml under
|
| 250 |
+
# `source_families.director_node.widget_order` (and indirectly under
|
| 251 |
+
# `state_dict.director_derived`). Update both when this dict changes,
|
| 252 |
+
# or the YAML drifts silently.
|
| 253 |
+
DIRECTOR_WIDX = {
|
| 254 |
+
"global_prompt": 0,
|
| 255 |
+
"duration_frames": 1,
|
| 256 |
+
"duration_seconds": 2,
|
| 257 |
+
"timeline_data": 3,
|
| 258 |
+
"local_prompts": 4,
|
| 259 |
+
"segment_lengths": 5,
|
| 260 |
+
"epsilon": 6,
|
| 261 |
+
"guide_strength": 7,
|
| 262 |
+
"use_custom_audio": 8,
|
| 263 |
+
"frame_rate": 9,
|
| 264 |
+
"display_mode": 10,
|
| 265 |
+
"custom_width": 11,
|
| 266 |
+
"custom_height": 12,
|
| 267 |
+
"resize_method": 13,
|
| 268 |
+
"divisible_by": 14,
|
| 269 |
+
"img_compression": 15,
|
| 270 |
+
"relay_overrides": 16,
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _normalize_title(s: str) -> str:
|
| 275 |
+
return " ".join(s.replace("_", " ").lower().split())
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _normalize_capture_key(s: str) -> str:
|
| 279 |
+
return s.strip().lower()
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _normalize_alias_map(
|
| 283 |
+
tracked_titles: list[str] | dict[str, Any],
|
| 284 |
+
) -> dict[str, list[str]]:
|
| 285 |
+
if isinstance(tracked_titles, dict):
|
| 286 |
+
out: dict[str, list[str]] = {}
|
| 287 |
+
for key, aliases in tracked_titles.items():
|
| 288 |
+
if isinstance(aliases, str):
|
| 289 |
+
alias_list = [aliases]
|
| 290 |
+
else:
|
| 291 |
+
alias_list = list(aliases or [])
|
| 292 |
+
out[_normalize_capture_key(str(key))] = [
|
| 293 |
+
_normalize_title(str(alias))
|
| 294 |
+
for alias in alias_list
|
| 295 |
+
if str(alias).strip()
|
| 296 |
+
]
|
| 297 |
+
return out
|
| 298 |
+
return {
|
| 299 |
+
_normalize_capture_key(str(needle)): [_normalize_title(str(needle))]
|
| 300 |
+
for needle in tracked_titles
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def normalize_tracked_multilines(
|
| 305 |
+
tracked_titles: list[str] | dict[str, Any],
|
| 306 |
+
) -> dict[str, list[str]]:
|
| 307 |
+
"""Return semantic capture keys mapped to title aliases.
|
| 308 |
+
|
| 309 |
+
Older configs used a flat list where key == title substring. Newer
|
| 310 |
+
configs use {"semantic_key": ["preferred title", "fallback title"]}.
|
| 311 |
+
"""
|
| 312 |
+
return _normalize_alias_map(tracked_titles)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def extract_multilines(
|
| 316 |
+
nodes: list[dict], tracked_titles: list[str] | dict[str, Any]
|
| 317 |
+
) -> dict[str, list[str]]:
|
| 318 |
+
"""Collect bodies by semantic key.
|
| 319 |
+
|
| 320 |
+
The canvas legitimately ships duplicates for some keys — working-folder
|
| 321 |
+
paths can have a project-mount and a local-mirror copy, for example — so
|
| 322 |
+
we keep all hits and let the caller pick which to use. Alias lists are
|
| 323 |
+
priority ordered; if two aliases for the same semantic key match
|
| 324 |
+
different nodes, preferred aliases appear first in the captured list.
|
| 325 |
+
"""
|
| 326 |
+
tracked = normalize_tracked_multilines(tracked_titles)
|
| 327 |
+
captured: dict[str, list[tuple[int, int, str]]] = {
|
| 328 |
+
key: [] for key in tracked
|
| 329 |
+
}
|
| 330 |
+
candidates: list[tuple[int, int, str, str]] = []
|
| 331 |
+
for key, aliases in tracked.items():
|
| 332 |
+
for priority, alias in enumerate(aliases):
|
| 333 |
+
# Sort longest-first within the same priority so a future short
|
| 334 |
+
# alias cannot shadow a more specific one.
|
| 335 |
+
candidates.append((priority, -len(alias), key, alias))
|
| 336 |
+
candidates.sort()
|
| 337 |
+
|
| 338 |
+
for order, n in enumerate(nodes):
|
| 339 |
+
if n.get("type") != "Text Multiline":
|
| 340 |
+
continue
|
| 341 |
+
title = _normalize_title(n.get("title") or "")
|
| 342 |
+
body = (n.get("widgets_values") or [""])[0] or ""
|
| 343 |
+
for priority, _, key, alias in candidates:
|
| 344 |
+
if alias in title:
|
| 345 |
+
captured[key].append((priority, order, body))
|
| 346 |
+
break
|
| 347 |
+
return {
|
| 348 |
+
key: [body for _, _, body in sorted(values)]
|
| 349 |
+
for key, values in captured.items()
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
SETUP_VALUE_NODE_TYPES = {
|
| 354 |
+
"Text Multiline",
|
| 355 |
+
"PrimitiveBoolean",
|
| 356 |
+
"PrimitiveFloat",
|
| 357 |
+
"PrimitiveInt",
|
| 358 |
+
"PrimitiveString",
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def node_widget_value(n: dict) -> str:
|
| 363 |
+
values = n.get("widgets_values")
|
| 364 |
+
if values is None:
|
| 365 |
+
return ""
|
| 366 |
+
if isinstance(values, list):
|
| 367 |
+
if not values:
|
| 368 |
+
return ""
|
| 369 |
+
value = values[0]
|
| 370 |
+
else:
|
| 371 |
+
value = values
|
| 372 |
+
if isinstance(value, (dict, list)):
|
| 373 |
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
| 374 |
+
return str(value)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def extract_setup_variables(
|
| 378 |
+
nodes: list[dict], tracked_titles: list[str] | dict[str, Any]
|
| 379 |
+
) -> dict[str, list[str]]:
|
| 380 |
+
"""Collect setup-card variable values from source nodes.
|
| 381 |
+
|
| 382 |
+
This deliberately ignores GetNode/SetNode relay plumbing. For v02,
|
| 383 |
+
version/run are PrimitiveInt nodes while the other setup values are
|
| 384 |
+
Text Multiline nodes.
|
| 385 |
+
"""
|
| 386 |
+
tracked = _normalize_alias_map(tracked_titles)
|
| 387 |
+
captured: dict[str, list[tuple[int, int, str]]] = {
|
| 388 |
+
key: [] for key in tracked
|
| 389 |
+
}
|
| 390 |
+
candidates: list[tuple[int, int, str, str]] = []
|
| 391 |
+
for key, aliases in tracked.items():
|
| 392 |
+
for priority, alias in enumerate(aliases):
|
| 393 |
+
candidates.append((priority, -len(alias), key, alias))
|
| 394 |
+
candidates.sort()
|
| 395 |
+
|
| 396 |
+
for order, n in enumerate(nodes):
|
| 397 |
+
if n.get("type") not in SETUP_VALUE_NODE_TYPES:
|
| 398 |
+
continue
|
| 399 |
+
title = _normalize_title(n.get("title") or "")
|
| 400 |
+
value = node_widget_value(n)
|
| 401 |
+
for priority, _, key, alias in candidates:
|
| 402 |
+
if alias == title:
|
| 403 |
+
captured[key].append((priority, order, value))
|
| 404 |
+
break
|
| 405 |
+
return {
|
| 406 |
+
key: [value for _, _, value in sorted(values)]
|
| 407 |
+
for key, values in captured.items()
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def first_multiline(ml: dict[str, list[str]], needle: str) -> str:
|
| 412 |
+
return (ml.get(needle) or [""])[0]
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def _parse_int(value: str) -> Optional[int]:
|
| 416 |
+
try:
|
| 417 |
+
return int(str(value).strip())
|
| 418 |
+
except (TypeError, ValueError):
|
| 419 |
+
return None
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def widget_value_by_name(node: dict, name: str, default: Any = "") -> Any:
|
| 423 |
+
values = node.get("widgets_values")
|
| 424 |
+
if isinstance(values, dict):
|
| 425 |
+
value = values.get(name)
|
| 426 |
+
return default if value is None else value
|
| 427 |
+
if not isinstance(values, list):
|
| 428 |
+
return default
|
| 429 |
+
|
| 430 |
+
index = 0
|
| 431 |
+
for inp in node.get("inputs") or []:
|
| 432 |
+
if not isinstance(inp, dict) or "widget" not in inp:
|
| 433 |
+
continue
|
| 434 |
+
widget = inp.get("widget") or {}
|
| 435 |
+
widget_name = widget.get("name") if isinstance(widget, dict) else None
|
| 436 |
+
if widget_name == name and index < len(values):
|
| 437 |
+
value = values[index]
|
| 438 |
+
return default if value is None else value
|
| 439 |
+
index += 1
|
| 440 |
+
return default
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def output_suffix_from_workflow(nodes: list[dict]) -> str:
|
| 444 |
+
"""Best-effort QuickTime suffix from the active VideoCombine format.
|
| 445 |
+
|
| 446 |
+
The OUT settings group builds the name stem from the base name plus
|
| 447 |
+
format suffix; the combine node then appends the version. If the
|
| 448 |
+
format is unclear, h264 is the current setup default.
|
| 449 |
+
"""
|
| 450 |
+
for n in nodes:
|
| 451 |
+
if n.get("type") != "Easy_VideoCombine":
|
| 452 |
+
continue
|
| 453 |
+
fmt = str(widget_value_by_name(n, "format") or "")
|
| 454 |
+
values = n.get("widgets_values")
|
| 455 |
+
if not fmt and isinstance(values, list) and len(values) > 3:
|
| 456 |
+
fmt = str(values[3] or "")
|
| 457 |
+
fmt_l = fmt.lower()
|
| 458 |
+
if "prores" in fmt_l:
|
| 459 |
+
return "ProRes"
|
| 460 |
+
if "h264" in fmt_l or "mp4" in fmt_l:
|
| 461 |
+
return "h264"
|
| 462 |
+
return "h264"
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def expected_output_tracking(
|
| 466 |
+
nodes: list[dict],
|
| 467 |
+
multilines: dict[str, list[str]],
|
| 468 |
+
setup_variables: dict[str, list[str]],
|
| 469 |
+
) -> dict[str, str]:
|
| 470 |
+
"""Expected external render target for the current setup.
|
| 471 |
+
|
| 472 |
+
This deliberately uses the current setup variables rather than
|
| 473 |
+
``easy showAnything`` readouts, which may still contain the previous
|
| 474 |
+
ComfyUI render's filenames.
|
| 475 |
+
"""
|
| 476 |
+
folder = pick_existing_path(multilines.get("working_folder") or [])
|
| 477 |
+
base_name = first_multiline(multilines, "name").strip()
|
| 478 |
+
version = _parse_int(first_multiline(setup_variables, "version"))
|
| 479 |
+
suffix = output_suffix_from_workflow(nodes)
|
| 480 |
+
stem_parts = [part for part in (base_name, suffix) if part]
|
| 481 |
+
stem = "_".join(stem_parts)
|
| 482 |
+
version_tag = f"v{version:03d}" if version is not None else ""
|
| 483 |
+
if stem and version_tag:
|
| 484 |
+
stem = f"{stem}_{version_tag}"
|
| 485 |
+
return {
|
| 486 |
+
"folder": folder,
|
| 487 |
+
"name": stem,
|
| 488 |
+
"version_tag": version_tag,
|
| 489 |
+
"format_suffix": suffix,
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def delivery_card_filename(
|
| 494 |
+
output_tracking: dict[str, str],
|
| 495 |
+
run_number: Optional[int] = None,
|
| 496 |
+
) -> Optional[str]:
|
| 497 |
+
name = (output_tracking.get("name") or "").strip()
|
| 498 |
+
if not name:
|
| 499 |
+
return None
|
| 500 |
+
if run_number is not None:
|
| 501 |
+
return f"{name}_run{run_number:03d}_card.png"
|
| 502 |
+
return f"{name}_card.png"
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def delivery_card_path(
|
| 506 |
+
output_tracking: dict[str, str],
|
| 507 |
+
run_number: Optional[int] = None,
|
| 508 |
+
) -> Optional[Path]:
|
| 509 |
+
folder = (output_tracking.get("folder") or "").strip()
|
| 510 |
+
filename = delivery_card_filename(output_tracking, run_number)
|
| 511 |
+
if not folder or not filename:
|
| 512 |
+
return None
|
| 513 |
+
return Path(folder) / "cards" / filename
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def copy_delivery_card(
|
| 517 |
+
card_path: Path,
|
| 518 |
+
output_tracking: dict[str, str],
|
| 519 |
+
run_number: Optional[int] = None,
|
| 520 |
+
*,
|
| 521 |
+
overwrite: bool = False,
|
| 522 |
+
) -> str:
|
| 523 |
+
delivery_path = delivery_card_path(output_tracking, run_number)
|
| 524 |
+
if delivery_path is None:
|
| 525 |
+
return "(not configured)"
|
| 526 |
+
try:
|
| 527 |
+
delivery_path.parent.mkdir(parents=True, exist_ok=True)
|
| 528 |
+
if delivery_path.exists() and not overwrite:
|
| 529 |
+
return f"exists (left in place: {delivery_path})"
|
| 530 |
+
shutil.copy2(card_path, delivery_path)
|
| 531 |
+
except OSError as exc:
|
| 532 |
+
return f"failed ({exc})"
|
| 533 |
+
return str(delivery_path)
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def scrub_path_for_metadata(value: str) -> str:
|
| 537 |
+
"""Store a portable fingerprint instead of full workstation paths."""
|
| 538 |
+
raw = str(value or "").strip()
|
| 539 |
+
if not raw:
|
| 540 |
+
return ""
|
| 541 |
+
if re.match(r"^[A-Za-z]:[/\\]", raw) or raw.startswith(("/", "\\\\")):
|
| 542 |
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
|
| 543 |
+
tail = Path(raw.replace("\\", "/")).name or "(folder)"
|
| 544 |
+
return f"{tail} [path-sha256:{digest}]"
|
| 545 |
+
return raw
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def _scrub_path_fragments(value: str) -> str:
|
| 549 |
+
def repl(match: re.Match[str]) -> str:
|
| 550 |
+
return scrub_path_for_metadata(match.group(0))
|
| 551 |
+
|
| 552 |
+
value = re.sub(
|
| 553 |
+
r"(?<![A-Za-z])(?:[A-Za-z]:[/\\]{1,2}|\\\\)[^\"'`,\]\s<>]+",
|
| 554 |
+
repl,
|
| 555 |
+
value,
|
| 556 |
+
)
|
| 557 |
+
return re.sub(r"<PROJECTS>/[^\"'`,\]\s<>]+", "<PROJECTS>/[path-redacted]", value)
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def sanitize_workflow_for_archive(value: Any) -> Any:
|
| 561 |
+
"""Redact absolute workstation paths before committing workflow snapshots."""
|
| 562 |
+
if isinstance(value, dict):
|
| 563 |
+
return {
|
| 564 |
+
key: sanitize_workflow_for_archive(item)
|
| 565 |
+
for key, item in value.items()
|
| 566 |
+
}
|
| 567 |
+
if isinstance(value, list):
|
| 568 |
+
return [sanitize_workflow_for_archive(item) for item in value]
|
| 569 |
+
if isinstance(value, str):
|
| 570 |
+
stripped = value.strip()
|
| 571 |
+
if re.match(r"^[A-Za-z]:[/\\]", stripped) or stripped.startswith(("/", "\\\\")):
|
| 572 |
+
redacted = scrub_path_for_metadata(stripped)
|
| 573 |
+
if value != stripped:
|
| 574 |
+
return value.replace(stripped, redacted)
|
| 575 |
+
return redacted
|
| 576 |
+
return _scrub_path_fragments(value)
|
| 577 |
+
return value
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def write_archived_workflow(wf: dict[str, Any], path: Path) -> None:
|
| 581 |
+
sanitized = sanitize_workflow_for_archive(wf)
|
| 582 |
+
path.write_text(
|
| 583 |
+
json.dumps(sanitized, ensure_ascii=False, separators=(",", ":")) + "\n",
|
| 584 |
+
encoding="utf-8",
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
def pick_existing_path(candidates: list[str]) -> str:
|
| 589 |
+
"""Pick the first candidate that resolves to a real directory on
|
| 590 |
+
this machine. Falls back to the first non-empty candidate so the
|
| 591 |
+
path still appears on the card even if its drive isn't mounted
|
| 592 |
+
here (worktree-on-N, render-target-on-W, etc.)."""
|
| 593 |
+
for c in candidates:
|
| 594 |
+
cleaned = c.strip().strip('"\'').rstrip("/\\")
|
| 595 |
+
if cleaned and Path(cleaned).is_dir():
|
| 596 |
+
return cleaned
|
| 597 |
+
for c in candidates:
|
| 598 |
+
cleaned = c.strip().strip('"\'').rstrip("/\\")
|
| 599 |
+
if cleaned:
|
| 600 |
+
return cleaned
|
| 601 |
+
return ""
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
def wrap_path(path: str, max_chars: int = 40) -> list[str]:
|
| 605 |
+
"""Wrap a filesystem path onto multiple lines, breaking only on
|
| 606 |
+
`/` or `\\` separators so directory names stay whole. Used by the
|
| 607 |
+
card renderer so long mount paths don't bleed past the card edge."""
|
| 608 |
+
if not path:
|
| 609 |
+
return [""]
|
| 610 |
+
raw = path.replace("/", "\\")
|
| 611 |
+
parts = [p for p in raw.split("\\") if p]
|
| 612 |
+
if not parts:
|
| 613 |
+
return [path]
|
| 614 |
+
lines: list[str] = []
|
| 615 |
+
cur = ""
|
| 616 |
+
for i, p in enumerate(parts):
|
| 617 |
+
token = p + ("\\" if i < len(parts) - 1 else "")
|
| 618 |
+
if cur and len(cur) + len(token) > max_chars:
|
| 619 |
+
lines.append(cur)
|
| 620 |
+
cur = token
|
| 621 |
+
else:
|
| 622 |
+
cur = cur + token
|
| 623 |
+
if cur:
|
| 624 |
+
lines.append(cur)
|
| 625 |
+
return lines
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def _source_node_for_link(
|
| 629 |
+
link_id: Optional[int],
|
| 630 |
+
nodes_by_id: dict[int, dict],
|
| 631 |
+
links_by_id: dict[int, list],
|
| 632 |
+
*,
|
| 633 |
+
seen: Optional[set[int]] = None,
|
| 634 |
+
) -> Optional[dict]:
|
| 635 |
+
if link_id is None:
|
| 636 |
+
return None
|
| 637 |
+
if seen is None:
|
| 638 |
+
seen = set()
|
| 639 |
+
if link_id in seen:
|
| 640 |
+
return None
|
| 641 |
+
seen.add(link_id)
|
| 642 |
+
link = links_by_id.get(link_id)
|
| 643 |
+
if not link or len(link) < 3:
|
| 644 |
+
return None
|
| 645 |
+
origin = nodes_by_id.get(int(link[1]))
|
| 646 |
+
if not origin:
|
| 647 |
+
return None
|
| 648 |
+
if origin.get("type") == "Reroute":
|
| 649 |
+
inputs = origin.get("inputs") or []
|
| 650 |
+
if inputs:
|
| 651 |
+
return _source_node_for_link(
|
| 652 |
+
inputs[0].get("link"), nodes_by_id, links_by_id, seen=seen
|
| 653 |
+
)
|
| 654 |
+
return origin
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
def extract_director(nodes: list[dict], links: Optional[list] = None) -> Optional[dict]:
|
| 658 |
+
"""Return the active Director node dict (or None).
|
| 659 |
+
|
| 660 |
+
Prefer the Director that feeds an ``LTXDirectorGuide.guide_data`` input.
|
| 661 |
+
A/B comparison canvases can keep both upstream and Koolook Directors on
|
| 662 |
+
the graph; canvas presence alone is not enough. If no active guide wiring
|
| 663 |
+
is visible, fall back to the old Koolook-first selection.
|
| 664 |
+
"""
|
| 665 |
+
if links:
|
| 666 |
+
nodes_by_id = {int(n["id"]): n for n in nodes if "id" in n}
|
| 667 |
+
links_by_id = {int(link[0]): link for link in links if link}
|
| 668 |
+
for guide in (n for n in nodes if n.get("type") == "LTXDirectorGuide"):
|
| 669 |
+
for inp in guide.get("inputs") or []:
|
| 670 |
+
if inp.get("name") != "guide_data":
|
| 671 |
+
continue
|
| 672 |
+
source = _source_node_for_link(
|
| 673 |
+
inp.get("link"), nodes_by_id, links_by_id
|
| 674 |
+
)
|
| 675 |
+
if source and source.get("type") in DIRECTOR_TYPES:
|
| 676 |
+
return source
|
| 677 |
+
for dtype in DIRECTOR_TYPES:
|
| 678 |
+
for n in nodes:
|
| 679 |
+
if n.get("type") == dtype:
|
| 680 |
+
return n
|
| 681 |
+
return None
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def director_type(node: Optional[dict]) -> str:
|
| 685 |
+
return node.get("type") if node else "(missing)"
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
def director_flavor(node: Optional[dict]) -> str:
|
| 689 |
+
dtype = director_type(node)
|
| 690 |
+
if dtype in (DIRECTOR_TYPE, *LEGACY_DIRECTOR_TYPES):
|
| 691 |
+
return "Koolook"
|
| 692 |
+
if dtype == UPSTREAM_DIRECTOR_TYPE:
|
| 693 |
+
return "Original upstream"
|
| 694 |
+
return "(missing)"
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
def detect_upstream_whatdreamscost_version() -> str:
|
| 698 |
+
"""Return the installed upstream WhatDreamsCost version, if available."""
|
| 699 |
+
dev = os.environ.get("KOLOOK_COMFYUI_DEV_PATH", "")
|
| 700 |
+
if not dev:
|
| 701 |
+
return ""
|
| 702 |
+
custom_nodes = Path(dev).expanduser().parent
|
| 703 |
+
pyproject = custom_nodes / "WhatDreamsCost-ComfyUI" / "pyproject.toml"
|
| 704 |
+
if not pyproject.is_file():
|
| 705 |
+
return ""
|
| 706 |
+
try:
|
| 707 |
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8-sig"))
|
| 708 |
+
except (OSError, tomllib.TOMLDecodeError):
|
| 709 |
+
return ""
|
| 710 |
+
version = data.get("project", {}).get("version", "")
|
| 711 |
+
return str(version).strip()
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def detect_koolook_whatdreamscost_pin() -> str:
|
| 715 |
+
"""Return the Koolook fork's pinned upstream version tag."""
|
| 716 |
+
if not KOOLOOK_UPSTREAM_PIN.is_file():
|
| 717 |
+
return ""
|
| 718 |
+
try:
|
| 719 |
+
for line in KOOLOOK_UPSTREAM_PIN.read_text(encoding="utf-8").splitlines():
|
| 720 |
+
match = re.match(r'\s*source_ref:\s*"?(v[0-9]+(?:\.[0-9]+)+)', line)
|
| 721 |
+
if match:
|
| 722 |
+
return match.group(1)
|
| 723 |
+
except OSError:
|
| 724 |
+
return ""
|
| 725 |
+
version_dir = KOOLOOK_UPSTREAM_PIN.parent.name
|
| 726 |
+
if version_dir.startswith("v"):
|
| 727 |
+
return version_dir.replace("_", ".")
|
| 728 |
+
return ""
|
| 729 |
+
|
| 730 |
+
|
| 731 |
+
def director_pin_tag(
|
| 732 |
+
node: Optional[dict],
|
| 733 |
+
upstream_whatdreamscost_version: str = "",
|
| 734 |
+
) -> str:
|
| 735 |
+
dtype = director_type(node)
|
| 736 |
+
if dtype in (DIRECTOR_TYPE, *LEGACY_DIRECTOR_TYPES):
|
| 737 |
+
return detect_koolook_whatdreamscost_pin() or "(unknown Koolook pin)"
|
| 738 |
+
if dtype == UPSTREAM_DIRECTOR_TYPE:
|
| 739 |
+
if upstream_whatdreamscost_version:
|
| 740 |
+
return f"v{upstream_whatdreamscost_version}"
|
| 741 |
+
return "(unknown upstream pin)"
|
| 742 |
+
return "(missing)"
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
def is_koolook_director(node: Optional[dict]) -> bool:
|
| 746 |
+
return director_type(node) in (DIRECTOR_TYPE, *LEGACY_DIRECTOR_TYPES)
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def director_widget(node: Optional[dict], key: str) -> Any:
|
| 750 |
+
if not node:
|
| 751 |
+
return None
|
| 752 |
+
wv = node.get("widgets_values") or []
|
| 753 |
+
idx = DIRECTOR_WIDX.get(key)
|
| 754 |
+
if isinstance(wv, dict):
|
| 755 |
+
named = widget_value_by_name(node, key, default=None)
|
| 756 |
+
if named is not None:
|
| 757 |
+
return named
|
| 758 |
+
if director_type(node) in DIRECTOR_TYPES and isinstance(wv, list):
|
| 759 |
+
if idx is not None and idx < len(wv):
|
| 760 |
+
return wv[idx]
|
| 761 |
+
return None
|
| 762 |
+
named = widget_value_by_name(node, key, default=None)
|
| 763 |
+
if named is not None:
|
| 764 |
+
return named
|
| 765 |
+
if isinstance(wv, list) and idx is not None and idx < len(wv):
|
| 766 |
+
return wv[idx]
|
| 767 |
+
if idx is None or idx >= len(wv):
|
| 768 |
+
return None
|
| 769 |
+
return wv[idx]
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
def display_numeric_widget(value: Any, label: str = "unknown") -> str:
|
| 773 |
+
"""Render numeric Director widgets without accepting stale booleans."""
|
| 774 |
+
if isinstance(value, bool) or value is None:
|
| 775 |
+
return f"(unknown {label})"
|
| 776 |
+
if isinstance(value, (int, float)):
|
| 777 |
+
return str(value)
|
| 778 |
+
text = str(value).strip()
|
| 779 |
+
if not text:
|
| 780 |
+
return f"(unknown {label})"
|
| 781 |
+
try:
|
| 782 |
+
float(text)
|
| 783 |
+
except ValueError:
|
| 784 |
+
return f"(unknown {label})"
|
| 785 |
+
return text
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
def metadata_numeric_widget(value: Any) -> Any:
|
| 789 |
+
"""Store numeric Director widgets without bool-as-number coercion."""
|
| 790 |
+
if isinstance(value, bool) or value is None:
|
| 791 |
+
return None
|
| 792 |
+
if isinstance(value, (int, float)):
|
| 793 |
+
return value
|
| 794 |
+
text = str(value).strip()
|
| 795 |
+
if not text:
|
| 796 |
+
return None
|
| 797 |
+
try:
|
| 798 |
+
parsed = float(text)
|
| 799 |
+
except ValueError:
|
| 800 |
+
return None
|
| 801 |
+
return int(parsed) if parsed.is_integer() else parsed
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
def is_input_wired(node: Optional[dict], input_name: str) -> Optional[bool]:
|
| 805 |
+
"""True iff a named input socket on the Director has a non-null
|
| 806 |
+
link. None when the node or input isn't found."""
|
| 807 |
+
if not node:
|
| 808 |
+
return None
|
| 809 |
+
for inp in node.get("inputs") or []:
|
| 810 |
+
if inp.get("name") == input_name:
|
| 811 |
+
return inp.get("link") is not None
|
| 812 |
+
return None
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
def linked_input_value(
|
| 816 |
+
nodes: list[dict],
|
| 817 |
+
links: list,
|
| 818 |
+
target_node: Optional[dict],
|
| 819 |
+
input_name: str,
|
| 820 |
+
) -> str:
|
| 821 |
+
"""Return the value feeding a linked input socket, not a nearby note.
|
| 822 |
+
|
| 823 |
+
This keeps card/log state aligned with execution: a multiline
|
| 824 |
+
``RELAY_OVERRIDES`` note only counts when its value is actually wired into
|
| 825 |
+
the active Director input. Reroutes and simple display/pass-through nodes
|
| 826 |
+
are followed one hop chain at a time so tidy canvas plumbing still works.
|
| 827 |
+
"""
|
| 828 |
+
if not target_node:
|
| 829 |
+
return ""
|
| 830 |
+
input_link = None
|
| 831 |
+
for inp in target_node.get("inputs") or []:
|
| 832 |
+
if inp.get("name") == input_name:
|
| 833 |
+
input_link = inp.get("link")
|
| 834 |
+
break
|
| 835 |
+
if input_link is None:
|
| 836 |
+
return ""
|
| 837 |
+
|
| 838 |
+
nodes_by_id = {n.get("id"): n for n in nodes}
|
| 839 |
+
links_by_id = {link[0]: link for link in links if isinstance(link, list) and link}
|
| 840 |
+
current_link = input_link
|
| 841 |
+
seen: set[int] = set()
|
| 842 |
+
while current_link is not None and current_link not in seen:
|
| 843 |
+
seen.add(current_link)
|
| 844 |
+
link = links_by_id.get(current_link)
|
| 845 |
+
if not link or len(link) < 3:
|
| 846 |
+
return ""
|
| 847 |
+
source = nodes_by_id.get(link[1])
|
| 848 |
+
if not source:
|
| 849 |
+
return ""
|
| 850 |
+
source_type = source.get("type")
|
| 851 |
+
if source_type in {"Reroute", "easy showAnything"}:
|
| 852 |
+
source_inputs = source.get("inputs") or []
|
| 853 |
+
current_link = source_inputs[0].get("link") if source_inputs else None
|
| 854 |
+
continue
|
| 855 |
+
return node_widget_value(source)
|
| 856 |
+
return ""
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
def active_relay_overrides(
|
| 860 |
+
nodes: list[dict],
|
| 861 |
+
links: list,
|
| 862 |
+
director_node: Optional[dict],
|
| 863 |
+
) -> str:
|
| 864 |
+
if not is_koolook_director(director_node):
|
| 865 |
+
return ""
|
| 866 |
+
return linked_input_value(nodes, links, director_node, "relay_overrides")
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
def _coerce_segment_numeric(seg: dict) -> dict:
|
| 870 |
+
"""Coerce a timeline segment's numeric fields (start, length,
|
| 871 |
+
trimStart) to int. The upstream Director itself does this
|
| 872 |
+
defensively at ltx_director.py:232-234 — the saved JSON sometimes
|
| 873 |
+
contains string values for these fields, and downstream
|
| 874 |
+
arithmetic (`start / fps`, `<` comparisons) breaks silently
|
| 875 |
+
without coercion. Failures collapse to 0 so a malformed segment
|
| 876 |
+
can't crash the loop."""
|
| 877 |
+
out = dict(seg)
|
| 878 |
+
for k in ("start", "length", "trimStart"):
|
| 879 |
+
if k in out:
|
| 880 |
+
try:
|
| 881 |
+
out[k] = int(float(out[k]))
|
| 882 |
+
except (TypeError, ValueError):
|
| 883 |
+
print(
|
| 884 |
+
f"WARNING: timeline segment {k}={out[k]!r} is not numeric; "
|
| 885 |
+
"using 0 for loop/card extraction.",
|
| 886 |
+
file=sys.stderr,
|
| 887 |
+
)
|
| 888 |
+
out[k] = 0
|
| 889 |
+
return out
|
| 890 |
+
|
| 891 |
+
|
| 892 |
+
def parse_timeline(director_node: Optional[dict]) -> dict[str, list]:
|
| 893 |
+
"""Parse the timeline_data JSON widget into a dict of segments +
|
| 894 |
+
audioSegments. Both default to empty lists on any error so the
|
| 895 |
+
rest of the pipeline can iterate without further guards.
|
| 896 |
+
|
| 897 |
+
Per-segment numeric fields are coerced via _coerce_segment_numeric
|
| 898 |
+
so downstream arithmetic stays type-safe regardless of what shape
|
| 899 |
+
the Comfy frontend saved into the JSON."""
|
| 900 |
+
raw = director_widget(director_node, "timeline_data") or ""
|
| 901 |
+
if not raw:
|
| 902 |
+
return {"segments": [], "audioSegments": []}
|
| 903 |
+
try:
|
| 904 |
+
tl = json.loads(raw)
|
| 905 |
+
except json.JSONDecodeError:
|
| 906 |
+
return {"segments": [], "audioSegments": []}
|
| 907 |
+
if not isinstance(tl, dict):
|
| 908 |
+
return {"segments": [], "audioSegments": []}
|
| 909 |
+
return {
|
| 910 |
+
"segments": [
|
| 911 |
+
_coerce_segment_numeric(s)
|
| 912 |
+
for s in (tl.get("segments") or [])
|
| 913 |
+
if isinstance(s, dict)
|
| 914 |
+
],
|
| 915 |
+
"audioSegments": [
|
| 916 |
+
_coerce_segment_numeric(s)
|
| 917 |
+
for s in (tl.get("audioSegments") or [])
|
| 918 |
+
if isinstance(s, dict)
|
| 919 |
+
],
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
|
| 923 |
+
def derive_audio_state(
|
| 924 |
+
director_node: Optional[dict], timeline: dict[str, list]
|
| 925 |
+
) -> str:
|
| 926 |
+
"""Reduce director presence plus three structural audio signals —
|
| 927 |
+
audio_vae wiring, use_custom_audio toggle, audioSegments count — to one label that
|
| 928 |
+
mirrors how the Director would actually behave at runtime
|
| 929 |
+
(see forks/.../ltx_director.py: audio_vae None gates everything;
|
| 930 |
+
then use_custom_audio chooses between the encoded path and the
|
| 931 |
+
empty/model-gen path).
|
| 932 |
+
|
| 933 |
+
Five distinct outcomes — "(no director)" is split off so a
|
| 934 |
+
missing-director workflow doesn't get the same label as a
|
| 935 |
+
director-present-but-VAE-unwired one, which silently collapsed
|
| 936 |
+
pre-fix because is_input_wired(None, …) also returns None."""
|
| 937 |
+
if director_node is None:
|
| 938 |
+
return "(no director)"
|
| 939 |
+
vae_wired = is_input_wired(director_node, "audio_vae")
|
| 940 |
+
use_custom = director_widget(director_node, "use_custom_audio")
|
| 941 |
+
audio_segs = timeline.get("audioSegments") or []
|
| 942 |
+
# vae_wired can be None (input socket missing from this node's
|
| 943 |
+
# schema, e.g. an older workflow before audio_vae existed) OR
|
| 944 |
+
# False (socket present but unwired). Both functionally mean
|
| 945 |
+
# "no audio latent produced", so collapse them here — but only
|
| 946 |
+
# AFTER the director-missing case has been split off above.
|
| 947 |
+
if not vae_wired:
|
| 948 |
+
return "off (no VAE)"
|
| 949 |
+
if use_custom is True:
|
| 950 |
+
return "custom" if audio_segs else "custom (empty)"
|
| 951 |
+
return "model-gen"
|
| 952 |
+
|
| 953 |
+
|
| 954 |
+
def video_segment_has_audio(video_seg: dict, audio_segs: list[dict]) -> bool:
|
| 955 |
+
"""Does an audioSegments[] entry overlap this video segment's time
|
| 956 |
+
range? Matches how _build_combined_audio aligns the audio waveform
|
| 957 |
+
onto the global timeline."""
|
| 958 |
+
v_start = video_seg.get("start", 0)
|
| 959 |
+
v_end = v_start + video_seg.get("length", 0)
|
| 960 |
+
for a in audio_segs:
|
| 961 |
+
a_start = a.get("start", 0)
|
| 962 |
+
a_end = a_start + a.get("length", 0)
|
| 963 |
+
if a_start < v_end and a_end > v_start:
|
| 964 |
+
return True
|
| 965 |
+
return False
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
def segment_prompt_mode(segments: list[dict]) -> str:
|
| 969 |
+
"""Classify whether timeline segments share one prompt or vary.
|
| 970 |
+
|
| 971 |
+
This is a structural prompt check only. It normalizes whitespace but
|
| 972 |
+
does not inspect prompt meaning, so it stays inside the card's source
|
| 973 |
+
rule: Director timeline JSON in, small capture label out.
|
| 974 |
+
"""
|
| 975 |
+
if not segments:
|
| 976 |
+
return "none"
|
| 977 |
+
prompts = [
|
| 978 |
+
" ".join(str(seg.get("prompt") or "").split())
|
| 979 |
+
for seg in segments
|
| 980 |
+
]
|
| 981 |
+
if any(not prompt for prompt in prompts):
|
| 982 |
+
return "missing"
|
| 983 |
+
if len(prompts) == 1:
|
| 984 |
+
return "single"
|
| 985 |
+
return "same" if len(set(prompts)) == 1 else "per-segment"
|
| 986 |
+
|
| 987 |
+
|
| 988 |
+
SCORE_PAT = re.compile(
|
| 989 |
+
r"^\s*(?P<axis>motion|sync|sharp(?:ness)?)\s*[:=]?\s*"
|
| 990 |
+
r"(?P<n>\d+)(?:\s*/\s*5)?\s*$",
|
| 991 |
+
re.IGNORECASE,
|
| 992 |
+
)
|
| 993 |
+
|
| 994 |
+
|
| 995 |
+
def parse_feedback(body: str) -> tuple[dict[str, Optional[int]], list[str]]:
|
| 996 |
+
scores: dict[str, Optional[int]] = {
|
| 997 |
+
"motion": None, "sync": None, "sharp": None,
|
| 998 |
+
}
|
| 999 |
+
text_lines: list[str] = []
|
| 1000 |
+
for line in body.splitlines():
|
| 1001 |
+
m = SCORE_PAT.match(line)
|
| 1002 |
+
if m:
|
| 1003 |
+
axis = m.group("axis").lower()
|
| 1004 |
+
axis = "sharp" if axis.startswith("sharp") else axis
|
| 1005 |
+
scores[axis] = int(m.group("n"))
|
| 1006 |
+
elif line.strip():
|
| 1007 |
+
text_lines.append(line.strip())
|
| 1008 |
+
return scores, text_lines
|
| 1009 |
+
|
| 1010 |
+
|
| 1011 |
+
# --------------------------------------------------------------------------
|
| 1012 |
+
# Repo / fork state.
|
| 1013 |
+
# --------------------------------------------------------------------------
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
def short_sha() -> str:
|
| 1017 |
+
try:
|
| 1018 |
+
r = subprocess.run(
|
| 1019 |
+
["git", "rev-parse", "--short", "HEAD"],
|
| 1020 |
+
cwd=str(REPO_ROOT),
|
| 1021 |
+
capture_output=True, text=True, timeout=2,
|
| 1022 |
+
)
|
| 1023 |
+
return r.stdout.strip() if r.returncode == 0 else "unknown"
|
| 1024 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 1025 |
+
return "unknown"
|
| 1026 |
+
|
| 1027 |
+
|
| 1028 |
+
def fork_dir_status(fork_rel: str) -> str:
|
| 1029 |
+
try:
|
| 1030 |
+
r = subprocess.run(
|
| 1031 |
+
["git", "status", "--short", "--", fork_rel],
|
| 1032 |
+
cwd=str(REPO_ROOT),
|
| 1033 |
+
capture_output=True, text=True, timeout=3,
|
| 1034 |
+
)
|
| 1035 |
+
if r.returncode != 0:
|
| 1036 |
+
return f"(git status failed: {r.stderr.strip()[:80]})"
|
| 1037 |
+
return r.stdout.strip() or "clean (matches HEAD)"
|
| 1038 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 1039 |
+
return "(git unreachable)"
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
def read_dev_build_json() -> dict[str, str]:
|
| 1043 |
+
dev = os.environ.get("KOLOOK_COMFYUI_DEV_PATH", "")
|
| 1044 |
+
if not dev:
|
| 1045 |
+
return {}
|
| 1046 |
+
p = Path(dev) / "web" / "_dev_build.json"
|
| 1047 |
+
if not p.is_file():
|
| 1048 |
+
return {}
|
| 1049 |
+
try:
|
| 1050 |
+
return json.loads(p.read_text(encoding="utf-8-sig"))
|
| 1051 |
+
except (OSError, json.JSONDecodeError):
|
| 1052 |
+
return {}
|
| 1053 |
+
|
| 1054 |
+
|
| 1055 |
+
# --------------------------------------------------------------------------
|
| 1056 |
+
# Run folder naming.
|
| 1057 |
+
# --------------------------------------------------------------------------
|
| 1058 |
+
|
| 1059 |
+
|
| 1060 |
+
def next_run_number(runs_dir: Path) -> int:
|
| 1061 |
+
if not runs_dir.is_dir():
|
| 1062 |
+
return 1
|
| 1063 |
+
nums: list[int] = []
|
| 1064 |
+
for child in runs_dir.iterdir():
|
| 1065 |
+
if not child.is_dir() or not child.name.startswith("run-"):
|
| 1066 |
+
continue
|
| 1067 |
+
m = re.match(r"run-(\d+)", child.name)
|
| 1068 |
+
if m:
|
| 1069 |
+
nums.append(int(m.group(1)))
|
| 1070 |
+
log_path = runs_dir / "log.md"
|
| 1071 |
+
if log_path.is_file():
|
| 1072 |
+
try:
|
| 1073 |
+
for line in log_path.read_text(encoding="utf-8").splitlines():
|
| 1074 |
+
m = re.match(r"\|\s*(\d{3,})\s*\|", line)
|
| 1075 |
+
if m:
|
| 1076 |
+
nums.append(int(m.group(1)))
|
| 1077 |
+
except OSError:
|
| 1078 |
+
pass
|
| 1079 |
+
return max(nums) + 1 if nums else 1
|
| 1080 |
+
|
| 1081 |
+
|
| 1082 |
+
_LABEL_BAD = re.compile(r"[^a-z0-9._-]+")
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
def autogen_label(
|
| 1086 |
+
name: str,
|
| 1087 |
+
director_node: Optional[dict],
|
| 1088 |
+
relay_overrides_raw: str,
|
| 1089 |
+
) -> str:
|
| 1090 |
+
slug = _LABEL_BAD.sub("-", name.lower()).strip("-") or "unnamed"
|
| 1091 |
+
dtype = director_type(director_node)
|
| 1092 |
+
if dtype == UPSTREAM_DIRECTOR_TYPE:
|
| 1093 |
+
director_tag = "upstream"
|
| 1094 |
+
elif is_koolook_director(director_node):
|
| 1095 |
+
director_tag = "koolook"
|
| 1096 |
+
else:
|
| 1097 |
+
director_tag = "missing"
|
| 1098 |
+
use_custom = director_widget(director_node, "use_custom_audio")
|
| 1099 |
+
audio_tag = "audio-on" if use_custom is True else "audio-off"
|
| 1100 |
+
knob_tag = ""
|
| 1101 |
+
if relay_overrides_raw.strip():
|
| 1102 |
+
try:
|
| 1103 |
+
opts = json.loads(relay_overrides_raw)
|
| 1104 |
+
if isinstance(opts, dict) and "video_strength" in opts:
|
| 1105 |
+
knob_tag = f"_vstr{opts['video_strength']}"
|
| 1106 |
+
except (json.JSONDecodeError, TypeError):
|
| 1107 |
+
pass
|
| 1108 |
+
label = f"{slug}_{director_tag}_{audio_tag}{knob_tag}"
|
| 1109 |
+
if len(label) > 60:
|
| 1110 |
+
label = label[:60].rstrip("-_")
|
| 1111 |
+
return label
|
| 1112 |
+
|
| 1113 |
+
|
| 1114 |
+
# --------------------------------------------------------------------------
|
| 1115 |
+
# Snapshot artifact writers.
|
| 1116 |
+
# --------------------------------------------------------------------------
|
| 1117 |
+
|
| 1118 |
+
|
| 1119 |
+
def render_relay_overrides_txt(
|
| 1120 |
+
raw: str, director_node: Optional[dict]
|
| 1121 |
+
) -> str:
|
| 1122 |
+
body = raw.strip() if raw and raw.strip() else "(empty — upstream defaults)"
|
| 1123 |
+
missing_note = ""
|
| 1124 |
+
if director_node is None:
|
| 1125 |
+
missing_note = (
|
| 1126 |
+
"\n\n# WARNING: no supported LTXDirector node found in "
|
| 1127 |
+
"the workflow — the relay_overrides value above isn't being "
|
| 1128 |
+
"consumed by anything. Add the Koolook Director to make it active.\n"
|
| 1129 |
+
)
|
| 1130 |
+
elif not is_koolook_director(director_node):
|
| 1131 |
+
missing_note = (
|
| 1132 |
+
"\n\n# NOTE: this value is INERT for this render — the Director "
|
| 1133 |
+
f"node in the workflow is upstream `{director_type(director_node)}`, "
|
| 1134 |
+
"which has no `relay_overrides` input. Swap to "
|
| 1135 |
+
"`LTXDirector__koolook` to make this active.\n"
|
| 1136 |
+
)
|
| 1137 |
+
return f"{body}\n{missing_note}"
|
| 1138 |
+
|
| 1139 |
+
|
| 1140 |
+
def render_patch_state(build: dict[str, str], fork_status: str) -> str:
|
| 1141 |
+
return (
|
| 1142 |
+
f"# Fork state at submission\n\n"
|
| 1143 |
+
f"MAIN SHA : {short_sha()}\n"
|
| 1144 |
+
f"Last dev-sync-audio : {build.get('commit', '(no _dev_build.json)')}"
|
| 1145 |
+
f" ({build.get('synced_at', '?')})\n"
|
| 1146 |
+
f"Sync scope tag : {build.get('scope', '(none)')}\n"
|
| 1147 |
+
f"Sync worktree : {build.get('worktree', '?')}\n"
|
| 1148 |
+
f"Fork dir status : {fork_status}\n"
|
| 1149 |
+
)
|
| 1150 |
+
|
| 1151 |
+
|
| 1152 |
+
def card_metadata(
|
| 1153 |
+
nnn: int,
|
| 1154 |
+
label: str,
|
| 1155 |
+
wf_path: Path,
|
| 1156 |
+
multilines: dict[str, list[str]],
|
| 1157 |
+
setup_variables: dict[str, list[str]],
|
| 1158 |
+
output_tracking: dict[str, str],
|
| 1159 |
+
director_node: Optional[dict],
|
| 1160 |
+
timeline: dict[str, list],
|
| 1161 |
+
audio_src: str,
|
| 1162 |
+
build: dict[str, str],
|
| 1163 |
+
fork_status: str,
|
| 1164 |
+
upstream_whatdreamscost_version: str,
|
| 1165 |
+
) -> dict[str, Any]:
|
| 1166 |
+
segments = timeline.get("segments") or []
|
| 1167 |
+
audio_segments = timeline.get("audioSegments") or []
|
| 1168 |
+
setup_input_path = first_multiline(setup_variables, "input_path_exr")
|
| 1169 |
+
output_folder = output_tracking.get("folder", "")
|
| 1170 |
+
return {
|
| 1171 |
+
"schema": "koolook.audio_loop.card_metadata.v1",
|
| 1172 |
+
"run": {
|
| 1173 |
+
"capture_number": f"{nnn:03d}",
|
| 1174 |
+
"label": label,
|
| 1175 |
+
"date": date.today().isoformat(),
|
| 1176 |
+
"workflow": f"run{nnn:03d}_workflow.json",
|
| 1177 |
+
"source_workflow": wf_path.name,
|
| 1178 |
+
"archived_workflow": f"run{nnn:03d}_workflow.json",
|
| 1179 |
+
"setup_name": wf_path.stem,
|
| 1180 |
+
},
|
| 1181 |
+
"setup": {
|
| 1182 |
+
"base_name": first_multiline(multilines, "name").strip(),
|
| 1183 |
+
"working_folder": scrub_path_for_metadata(output_folder),
|
| 1184 |
+
"input_path_exr": scrub_path_for_metadata(setup_input_path),
|
| 1185 |
+
"global_version": first_multiline(setup_variables, "version"),
|
| 1186 |
+
"global_run_offset": first_multiline(setup_variables, "run_offset"),
|
| 1187 |
+
"relay_overrides": first_multiline(multilines, "relay_overrides").strip(),
|
| 1188 |
+
},
|
| 1189 |
+
"output": {
|
| 1190 |
+
"folder": scrub_path_for_metadata(output_folder),
|
| 1191 |
+
"name": output_tracking.get("name", ""),
|
| 1192 |
+
"version_tag": output_tracking.get("version_tag", ""),
|
| 1193 |
+
"format_suffix": output_tracking.get("format_suffix", ""),
|
| 1194 |
+
},
|
| 1195 |
+
"director": {
|
| 1196 |
+
"type": director_type(director_node),
|
| 1197 |
+
"flavor": director_flavor(director_node),
|
| 1198 |
+
"pin_tag": director_pin_tag(
|
| 1199 |
+
director_node, upstream_whatdreamscost_version
|
| 1200 |
+
),
|
| 1201 |
+
"upstream_whatdreamscost_version": upstream_whatdreamscost_version,
|
| 1202 |
+
"audio_src": audio_src,
|
| 1203 |
+
"epsilon": director_widget(director_node, "epsilon"),
|
| 1204 |
+
"duration_frames": director_widget(director_node, "duration_frames"),
|
| 1205 |
+
"duration_seconds": director_widget(director_node, "duration_seconds"),
|
| 1206 |
+
"frame_rate": metadata_numeric_widget(
|
| 1207 |
+
director_widget(director_node, "frame_rate")
|
| 1208 |
+
),
|
| 1209 |
+
"segment_prompt_mode": segment_prompt_mode(segments),
|
| 1210 |
+
"video_segments": len(segments),
|
| 1211 |
+
"audio_segments": len(audio_segments),
|
| 1212 |
+
},
|
| 1213 |
+
"repo": repo_metadata_from_build(build, fork_status),
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
|
| 1217 |
+
def repo_metadata_from_build(build: dict[str, str], fork_status: str) -> dict[str, str]:
|
| 1218 |
+
return {
|
| 1219 |
+
"main_sha": short_sha(),
|
| 1220 |
+
"sync_script": "sync_to_dev_audio.py",
|
| 1221 |
+
"last_dev_sync_audio": build.get("commit", ""),
|
| 1222 |
+
"last_dev_sync_at": build.get("synced_at", ""),
|
| 1223 |
+
"sync_scope_tag": build.get("scope", ""),
|
| 1224 |
+
"sync_worktree": build.get("worktree", ""),
|
| 1225 |
+
"fork_dir_status": fork_status,
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
|
| 1229 |
+
def parse_dev_sync_audio_line(value: str) -> tuple[str, str]:
|
| 1230 |
+
"""Split 'sha (date)' patch_state text into schema fields."""
|
| 1231 |
+
raw = str(value or "").strip()
|
| 1232 |
+
match = re.match(r"(.+?)\s+\(([^()]*)\)\s*$", raw)
|
| 1233 |
+
if not match:
|
| 1234 |
+
return raw, ""
|
| 1235 |
+
return match.group(1).strip(), match.group(2).strip()
|
| 1236 |
+
|
| 1237 |
+
|
| 1238 |
+
def _notes_value(value: str) -> str:
|
| 1239 |
+
cleaned = value.strip()
|
| 1240 |
+
if not cleaned:
|
| 1241 |
+
return "`(missing)`"
|
| 1242 |
+
if "\n" not in cleaned:
|
| 1243 |
+
return f"`{cleaned}`"
|
| 1244 |
+
indented = "\n".join(f" {line}" for line in cleaned.splitlines())
|
| 1245 |
+
return f"\n{indented}"
|
| 1246 |
+
|
| 1247 |
+
|
| 1248 |
+
def render_setup_variables_md(
|
| 1249 |
+
nnn: int,
|
| 1250 |
+
wf_path: Path,
|
| 1251 |
+
multilines: dict[str, list[str]],
|
| 1252 |
+
setup_variables: dict[str, list[str]],
|
| 1253 |
+
director_node: Optional[dict],
|
| 1254 |
+
timeline: dict[str, list],
|
| 1255 |
+
output_tracking: dict[str, str],
|
| 1256 |
+
*,
|
| 1257 |
+
commit_sha: str = "",
|
| 1258 |
+
) -> str:
|
| 1259 |
+
segments = timeline.get("segments") or []
|
| 1260 |
+
audio_segs = timeline.get("audioSegments") or []
|
| 1261 |
+
dur_f = director_widget(director_node, "duration_frames")
|
| 1262 |
+
dur_s = director_widget(director_node, "duration_seconds")
|
| 1263 |
+
fps = display_numeric_widget(
|
| 1264 |
+
director_widget(director_node, "frame_rate"), "fps"
|
| 1265 |
+
)
|
| 1266 |
+
epsilon = director_widget(director_node, "epsilon")
|
| 1267 |
+
relay_overrides = first_multiline(multilines, "relay_overrides")
|
| 1268 |
+
if relay_overrides.strip() and not is_koolook_director(director_node):
|
| 1269 |
+
relay_overrides = (
|
| 1270 |
+
f"{relay_overrides} (inert: active Director is upstream LTXDirector)"
|
| 1271 |
+
)
|
| 1272 |
+
upstream_whatdreamscost_version = detect_upstream_whatdreamscost_version()
|
| 1273 |
+
rows = [
|
| 1274 |
+
("Capture run number", f"{nnn:03d} (from runs folder/log)"),
|
| 1275 |
+
("Setup name", wf_path.stem),
|
| 1276 |
+
("Image segments", f"{len(segments)} video / {len(audio_segs)} audio"),
|
| 1277 |
+
("Prompts / similarity", segment_prompt_mode(segments)),
|
| 1278 |
+
("Commit No.", commit_sha or short_sha()),
|
| 1279 |
+
("LTX-Director (flavour)", director_flavor(director_node)),
|
| 1280 |
+
(
|
| 1281 |
+
"Director pin tag",
|
| 1282 |
+
director_pin_tag(director_node, upstream_whatdreamscost_version),
|
| 1283 |
+
),
|
| 1284 |
+
("Audio src", derive_audio_state(director_node, timeline)),
|
| 1285 |
+
("epsilon", "" if epsilon is None else str(epsilon)),
|
| 1286 |
+
("Duration", f"{dur_f} frames @ {fps} fps ({dur_s} sec)"),
|
| 1287 |
+
("RELAY_OVERRIDES", relay_overrides),
|
| 1288 |
+
(
|
| 1289 |
+
"GLOBAL [ path ] - working folder",
|
| 1290 |
+
scrub_path_for_metadata(first_multiline(multilines, "working_folder")),
|
| 1291 |
+
),
|
| 1292 |
+
("GLOBAL [ base name ]", first_multiline(multilines, "name")),
|
| 1293 |
+
(
|
| 1294 |
+
"INPUT Path [ EXR ]",
|
| 1295 |
+
scrub_path_for_metadata(first_multiline(setup_variables, "input_path_exr")),
|
| 1296 |
+
),
|
| 1297 |
+
("GLOBAL [ version ]", first_multiline(setup_variables, "version")),
|
| 1298 |
+
("GLOBAL [ run offset ]", first_multiline(setup_variables, "run_offset")),
|
| 1299 |
+
("Output folder", scrub_path_for_metadata(output_tracking.get("folder", ""))),
|
| 1300 |
+
("Output name", output_tracking.get("name", "")),
|
| 1301 |
+
("OVERLAY - INFO", "(captured verbatim above)"),
|
| 1302 |
+
("OVERLAY - FEEDBACK", "(captured verbatim above)"),
|
| 1303 |
+
]
|
| 1304 |
+
body = "\n".join(
|
| 1305 |
+
f"- {label}: {_notes_value(value)}" for label, value in rows
|
| 1306 |
+
)
|
| 1307 |
+
return f"## SETUP variables (captured)\n\n{body}\n\n"
|
| 1308 |
+
|
| 1309 |
+
|
| 1310 |
+
def render_notes_md(
|
| 1311 |
+
nnn: int,
|
| 1312 |
+
wf_path: Path,
|
| 1313 |
+
multilines: dict[str, list[str]],
|
| 1314 |
+
setup_variables: dict[str, list[str]],
|
| 1315 |
+
director_node: Optional[dict],
|
| 1316 |
+
timeline: dict[str, list],
|
| 1317 |
+
audio_src: str,
|
| 1318 |
+
info_body: str,
|
| 1319 |
+
feedback_lines: list[str],
|
| 1320 |
+
scores: dict[str, Optional[int]],
|
| 1321 |
+
output_tracking: dict[str, str],
|
| 1322 |
+
*,
|
| 1323 |
+
commit_sha: str = "",
|
| 1324 |
+
) -> str:
|
| 1325 |
+
"""Notes pulled exclusively from the two source families the card
|
| 1326 |
+
is allowed to read: the OVERLAY-* multilines and the active
|
| 1327 |
+
Director node. No widget-scraping of BasicScheduler / KSamplerSelect /
|
| 1328 |
+
RandomNoise / CFGGuider — those don't define what this loop sweeps."""
|
| 1329 |
+
info_indented = (
|
| 1330 |
+
"\n".join(
|
| 1331 |
+
f" {line}" if line else ""
|
| 1332 |
+
for line in info_body.splitlines()
|
| 1333 |
+
)
|
| 1334 |
+
if info_body.strip()
|
| 1335 |
+
else " (none)"
|
| 1336 |
+
)
|
| 1337 |
+
feedback_body = "\n".join(feedback_lines) if feedback_lines else "(none)"
|
| 1338 |
+
scores_line = " · ".join(
|
| 1339 |
+
f"{k}: {v if v is not None else '?'}/5"
|
| 1340 |
+
for k, v in scores.items()
|
| 1341 |
+
)
|
| 1342 |
+
|
| 1343 |
+
if director_node is None:
|
| 1344 |
+
director_kind = (
|
| 1345 |
+
"**missing** — no `LTXDirector__koolook` node on the "
|
| 1346 |
+
"canvas; this render didn't run through the Koolook fork."
|
| 1347 |
+
)
|
| 1348 |
+
elif not is_koolook_director(director_node):
|
| 1349 |
+
director_kind = (
|
| 1350 |
+
f"**upstream** `{director_type(director_node)}` "
|
| 1351 |
+
"(Koolook variant NOT wired — relay_overrides + per-segment "
|
| 1352 |
+
"sigma are INERT this render)"
|
| 1353 |
+
)
|
| 1354 |
+
else:
|
| 1355 |
+
director_kind = (
|
| 1356 |
+
f"{director_flavor(director_node)} (`{director_type(director_node)}`)"
|
| 1357 |
+
)
|
| 1358 |
+
|
| 1359 |
+
epsilon = director_widget(director_node, "epsilon")
|
| 1360 |
+
dur_f = director_widget(director_node, "duration_frames")
|
| 1361 |
+
dur_s = director_widget(director_node, "duration_seconds")
|
| 1362 |
+
fps = display_numeric_widget(
|
| 1363 |
+
director_widget(director_node, "frame_rate"), "fps"
|
| 1364 |
+
)
|
| 1365 |
+
segments = timeline.get("segments") or []
|
| 1366 |
+
audio_segs = timeline.get("audioSegments") or []
|
| 1367 |
+
prompt_mode = segment_prompt_mode(segments)
|
| 1368 |
+
|
| 1369 |
+
return (
|
| 1370 |
+
f"# Run notes\n\n"
|
| 1371 |
+
f"## Maintainer feedback (OVERLAY - FEEDBACK, verbatim)\n\n"
|
| 1372 |
+
f"{feedback_body}\n\n"
|
| 1373 |
+
f"**Scores:** {scores_line}\n\n"
|
| 1374 |
+
f"## OVERLAY - INFO (verbatim)\n\n"
|
| 1375 |
+
f"{info_indented}\n\n"
|
| 1376 |
+
f"{render_setup_variables_md(nnn, wf_path, multilines, setup_variables, director_node, timeline, output_tracking, commit_sha=commit_sha)}"
|
| 1377 |
+
f"## Director node — structural state\n\n"
|
| 1378 |
+
f"- Variant: {director_kind}\n"
|
| 1379 |
+
f"- Audio src: {audio_src}\n"
|
| 1380 |
+
f"- ε (epsilon): {epsilon!r}\n"
|
| 1381 |
+
f"- Duration: {dur_f} frames @ {fps} fps ({dur_s} sec)\n"
|
| 1382 |
+
f"- Segments: {len(segments)} video / "
|
| 1383 |
+
f"{len(audio_segs)} audio\n"
|
| 1384 |
+
f"- Segment prompt mode: {prompt_mode}\n"
|
| 1385 |
+
)
|
| 1386 |
+
|
| 1387 |
+
|
| 1388 |
+
def render_log_row(
|
| 1389 |
+
nnn: int,
|
| 1390 |
+
director_node: Optional[dict],
|
| 1391 |
+
relay_overrides_raw: str,
|
| 1392 |
+
audio_src: str,
|
| 1393 |
+
timeline: dict[str, list],
|
| 1394 |
+
scores: dict[str, Optional[int]],
|
| 1395 |
+
feedback_lines: list[str],
|
| 1396 |
+
) -> str:
|
| 1397 |
+
"""Rolling-table row aligned with the card's data-source rule —
|
| 1398 |
+
multilines + active Director only. Scheduler/sampler columns are
|
| 1399 |
+
deliberately absent (they aren't what this loop sweeps)."""
|
| 1400 |
+
director_cell = director_type(director_node)
|
| 1401 |
+
relay_cell = (
|
| 1402 |
+
f"`{relay_overrides_raw.strip()}`"
|
| 1403 |
+
if relay_overrides_raw.strip()
|
| 1404 |
+
else "(empty → defaults)"
|
| 1405 |
+
)
|
| 1406 |
+
segments = timeline.get("segments") or []
|
| 1407 |
+
audio_segments = timeline.get("audioSegments") or []
|
| 1408 |
+
seg_cell = f"{len(segments)}v/{len(audio_segments)}a"
|
| 1409 |
+
# `or '?'` would map a legitimate 0/5 to '?' because 0 is falsy;
|
| 1410 |
+
# explicit None-check preserves the score the maintainer typed.
|
| 1411 |
+
# Parallels render_audio_card._s(): the log uses bare digits, while
|
| 1412 |
+
# the card uses N/5 labels.
|
| 1413 |
+
def _score(v: Optional[int]) -> str:
|
| 1414 |
+
return str(v) if v is not None else "?"
|
| 1415 |
+
score_cell = (
|
| 1416 |
+
f"M{_score(scores.get('motion'))}·S{_score(scores.get('sync'))}·"
|
| 1417 |
+
f"Sh{_score(scores.get('sharp'))}"
|
| 1418 |
+
)
|
| 1419 |
+
notes_cell = (
|
| 1420 |
+
" ".join(feedback_lines)[:120] if feedback_lines else "(none)"
|
| 1421 |
+
).replace("|", "\\|")
|
| 1422 |
+
return (
|
| 1423 |
+
f"| {nnn:03d} | {date.today().isoformat()} | `{director_cell}` "
|
| 1424 |
+
f"| {relay_cell} | {audio_src} | {seg_cell} "
|
| 1425 |
+
f"| {score_cell} | {notes_cell} |\n"
|
| 1426 |
+
)
|
| 1427 |
+
|
| 1428 |
+
|
| 1429 |
+
# --------------------------------------------------------------------------
|
| 1430 |
+
# Driver.
|
| 1431 |
+
# --------------------------------------------------------------------------
|
| 1432 |
+
|
| 1433 |
+
|
| 1434 |
+
def _build_state_for_card(
|
| 1435 |
+
nnn: int, label: str, wf_path: Path,
|
| 1436 |
+
multilines: dict[str, list[str]],
|
| 1437 |
+
output_tracking: dict[str, str],
|
| 1438 |
+
metadata: dict[str, Any],
|
| 1439 |
+
director_node: Optional[dict],
|
| 1440 |
+
timeline: dict[str, list],
|
| 1441 |
+
audio_src: str,
|
| 1442 |
+
scores: dict[str, Optional[int]], feedback_lines: list[str],
|
| 1443 |
+
) -> dict[str, Any]:
|
| 1444 |
+
"""Card state — strict subset matching the agreed source families:
|
| 1445 |
+
five tracked multilines + the active Director node's own values.
|
| 1446 |
+
No git, no _dev_build.json, no scheduler scrape.
|
| 1447 |
+
|
| 1448 |
+
Director's duration_frames / duration_seconds widgets are NOT
|
| 1449 |
+
included — the card dropped the Duration row in favour of
|
| 1450 |
+
per-segment time ranges, and notes.md reads them straight from
|
| 1451 |
+
`director_node` via `director_widget`. Keeping them out of state
|
| 1452 |
+
prevents the consumed-but-unused asymmetry the review caught."""
|
| 1453 |
+
return {
|
| 1454 |
+
"run_number": nnn,
|
| 1455 |
+
"run_label": label,
|
| 1456 |
+
"date": date.today().isoformat(),
|
| 1457 |
+
"workflow_name": f"run{nnn:03d}_workflow.json",
|
| 1458 |
+
"source_workflow_name": wf_path.name,
|
| 1459 |
+
"name": first_multiline(multilines, "name").strip() or "(unnamed)",
|
| 1460 |
+
"relay_overrides_raw": first_multiline(multilines, "relay_overrides"),
|
| 1461 |
+
"info_body": first_multiline(multilines, "overlay - info").rstrip(),
|
| 1462 |
+
"feedback_lines": feedback_lines,
|
| 1463 |
+
"scores": scores,
|
| 1464 |
+
"work_folder": pick_existing_path(
|
| 1465 |
+
multilines.get("working_folder") or []
|
| 1466 |
+
),
|
| 1467 |
+
"output_folder": output_tracking.get("folder", ""),
|
| 1468 |
+
"output_name": output_tracking.get("name", ""),
|
| 1469 |
+
"metadata": metadata,
|
| 1470 |
+
"director_node": director_node,
|
| 1471 |
+
"director_variant": director_type(director_node),
|
| 1472 |
+
"director_flavor": director_flavor(director_node),
|
| 1473 |
+
"director_pin_tag": (metadata.get("director") or {}).get("pin_tag", ""),
|
| 1474 |
+
"audio_src": audio_src,
|
| 1475 |
+
"epsilon": director_widget(director_node, "epsilon"),
|
| 1476 |
+
"frame_rate": metadata_numeric_widget(
|
| 1477 |
+
director_widget(director_node, "frame_rate")
|
| 1478 |
+
),
|
| 1479 |
+
"segments": timeline.get("segments") or [],
|
| 1480 |
+
"audio_segments": timeline.get("audioSegments") or [],
|
| 1481 |
+
"segment_prompt_mode": segment_prompt_mode(
|
| 1482 |
+
timeline.get("segments") or []
|
| 1483 |
+
),
|
| 1484 |
+
}
|
| 1485 |
+
|
| 1486 |
+
|
| 1487 |
+
def main() -> int:
|
| 1488 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 1489 |
+
p.add_argument("--config", type=Path, default=DEFAULT_CONFIG_PATH,
|
| 1490 |
+
help="Loop config JSON (default: alongside this script).")
|
| 1491 |
+
p.add_argument("--dry-run", action="store_true",
|
| 1492 |
+
help="Print what would be captured. No files written.")
|
| 1493 |
+
p.add_argument("--label", default=None,
|
| 1494 |
+
help="Override the auto-generated run label suffix.")
|
| 1495 |
+
p.add_argument("--no-log", action="store_true",
|
| 1496 |
+
help="Skip the log.md row append.")
|
| 1497 |
+
p.add_argument("--no-card", action="store_true",
|
| 1498 |
+
help="Skip the card render even if config.render_card is true.")
|
| 1499 |
+
p.add_argument("--workflow", type=Path, default=None,
|
| 1500 |
+
help="Explicit workflow JSON path (skip auto-discover).")
|
| 1501 |
+
args = p.parse_args()
|
| 1502 |
+
|
| 1503 |
+
cfg = load_config(args.config)
|
| 1504 |
+
env_file = find_dotenv()
|
| 1505 |
+
if env_file is not None:
|
| 1506 |
+
load_dotenv(env_file)
|
| 1507 |
+
|
| 1508 |
+
module_dir = REPO_ROOT / cfg["module_path"]
|
| 1509 |
+
runs_dir = module_dir / "runs"
|
| 1510 |
+
log_path = runs_dir / "log.md"
|
| 1511 |
+
|
| 1512 |
+
if args.workflow:
|
| 1513 |
+
wf_path = args.workflow.expanduser().resolve()
|
| 1514 |
+
if not wf_path.is_file():
|
| 1515 |
+
print(f"workflow not found: {wf_path}", file=sys.stderr)
|
| 1516 |
+
return 3
|
| 1517 |
+
else:
|
| 1518 |
+
wf_path = find_workflow(resolve_workflows_dir(cfg), cfg)
|
| 1519 |
+
|
| 1520 |
+
with wf_path.open(encoding="utf-8-sig") as f:
|
| 1521 |
+
wf = json.load(f)
|
| 1522 |
+
nodes = wf.get("nodes") or []
|
| 1523 |
+
|
| 1524 |
+
multilines = extract_multilines(nodes, cfg["tracked_multilines"])
|
| 1525 |
+
setup_variables = extract_setup_variables(
|
| 1526 |
+
nodes, cfg.get("tracked_setup_variables", {})
|
| 1527 |
+
)
|
| 1528 |
+
output_tracking = expected_output_tracking(nodes, multilines, setup_variables)
|
| 1529 |
+
director_node = extract_director(nodes, wf.get("links") or [])
|
| 1530 |
+
timeline = parse_timeline(director_node)
|
| 1531 |
+
audio_src = derive_audio_state(director_node, timeline)
|
| 1532 |
+
scores, feedback_lines = parse_feedback(
|
| 1533 |
+
first_multiline(multilines, "overlay - feedback")
|
| 1534 |
+
)
|
| 1535 |
+
|
| 1536 |
+
name = first_multiline(multilines, "name").strip()
|
| 1537 |
+
relay_overrides_raw = active_relay_overrides(
|
| 1538 |
+
nodes, wf.get("links") or [], director_node
|
| 1539 |
+
)
|
| 1540 |
+
active_multilines = dict(multilines)
|
| 1541 |
+
active_multilines["relay_overrides"] = [relay_overrides_raw]
|
| 1542 |
+
info_body = first_multiline(multilines, "overlay - info").rstrip()
|
| 1543 |
+
label = args.label or autogen_label(
|
| 1544 |
+
name, director_node, relay_overrides_raw
|
| 1545 |
+
)
|
| 1546 |
+
nnn = next_run_number(runs_dir)
|
| 1547 |
+
run_dir = runs_dir / f"run-{nnn:03d}_{label}"
|
| 1548 |
+
build = read_dev_build_json()
|
| 1549 |
+
fork_status = fork_dir_status(cfg["fork_to_track"])
|
| 1550 |
+
upstream_whatdreamscost_version = detect_upstream_whatdreamscost_version()
|
| 1551 |
+
metadata = card_metadata(
|
| 1552 |
+
nnn, label, wf_path, active_multilines, setup_variables, output_tracking,
|
| 1553 |
+
director_node, timeline, audio_src, build, fork_status,
|
| 1554 |
+
upstream_whatdreamscost_version,
|
| 1555 |
+
)
|
| 1556 |
+
|
| 1557 |
+
# Two-line chat-report header (matches dev-sync convention).
|
| 1558 |
+
print(f"{short_sha()} - {REPO_ROOT.name}")
|
| 1559 |
+
print(
|
| 1560 |
+
f"loop-{cfg['job_name']} run-{nnn:03d} workflow={wf_path.name} "
|
| 1561 |
+
f"director={director_type(director_node)} "
|
| 1562 |
+
f"audio={audio_src}"
|
| 1563 |
+
)
|
| 1564 |
+
|
| 1565 |
+
if args.dry_run:
|
| 1566 |
+
print(f" (dry-run) would write: {run_dir.relative_to(REPO_ROOT)}")
|
| 1567 |
+
print(f" (dry-run) would append row to: {log_path.relative_to(REPO_ROOT)}")
|
| 1568 |
+
return 0
|
| 1569 |
+
|
| 1570 |
+
run_dir.mkdir(parents=True, exist_ok=False)
|
| 1571 |
+
write_archived_workflow(wf, run_dir / f"run{nnn:03d}_workflow.json")
|
| 1572 |
+
(run_dir / "relay_overrides.txt").write_text(
|
| 1573 |
+
render_relay_overrides_txt(relay_overrides_raw, director_node),
|
| 1574 |
+
encoding="utf-8",
|
| 1575 |
+
)
|
| 1576 |
+
(run_dir / "patch_state.txt").write_text(
|
| 1577 |
+
render_patch_state(build, fork_status),
|
| 1578 |
+
encoding="utf-8",
|
| 1579 |
+
)
|
| 1580 |
+
(run_dir / "metadata.json").write_text(
|
| 1581 |
+
json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
| 1582 |
+
encoding="utf-8",
|
| 1583 |
+
)
|
| 1584 |
+
(run_dir / "notes.md").write_text(
|
| 1585 |
+
render_notes_md(
|
| 1586 |
+
nnn, wf_path, active_multilines, setup_variables,
|
| 1587 |
+
director_node, timeline, audio_src,
|
| 1588 |
+
info_body, feedback_lines, scores, output_tracking,
|
| 1589 |
+
),
|
| 1590 |
+
encoding="utf-8",
|
| 1591 |
+
)
|
| 1592 |
+
|
| 1593 |
+
card_status = "skipped"
|
| 1594 |
+
delivery_status = "skipped"
|
| 1595 |
+
if cfg["render_card"] and not args.no_card:
|
| 1596 |
+
try:
|
| 1597 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 1598 |
+
from make_card_audio import render_audio_card # type: ignore[import-not-found]
|
| 1599 |
+
|
| 1600 |
+
state = _build_state_for_card(
|
| 1601 |
+
nnn, label, wf_path,
|
| 1602 |
+
active_multilines, output_tracking, metadata, director_node, timeline,
|
| 1603 |
+
audio_src, scores, feedback_lines,
|
| 1604 |
+
)
|
| 1605 |
+
card_path = render_audio_card(state, run_dir / "card.png")
|
| 1606 |
+
card_status = "rendered"
|
| 1607 |
+
delivery_status = copy_delivery_card(card_path, output_tracking, nnn)
|
| 1608 |
+
except ImportError as exc:
|
| 1609 |
+
card_status = f"skipped ({exc})"
|
| 1610 |
+
except OSError as exc:
|
| 1611 |
+
card_status = f"failed ({exc})"
|
| 1612 |
+
|
| 1613 |
+
if not args.no_log:
|
| 1614 |
+
row = render_log_row(
|
| 1615 |
+
nnn, director_node, relay_overrides_raw, audio_src,
|
| 1616 |
+
timeline, scores, feedback_lines,
|
| 1617 |
+
)
|
| 1618 |
+
with log_path.open("a", encoding="utf-8") as f:
|
| 1619 |
+
f.write(row)
|
| 1620 |
+
|
| 1621 |
+
print(f" wrote: {run_dir.relative_to(REPO_ROOT)}")
|
| 1622 |
+
print(f" card: {card_status}")
|
| 1623 |
+
print(f" card delivery: {delivery_status}")
|
| 1624 |
+
if not args.no_log:
|
| 1625 |
+
print(f" logged: {log_path.relative_to(REPO_ROOT)}")
|
| 1626 |
+
return 0
|
| 1627 |
+
|
| 1628 |
+
|
| 1629 |
+
if __name__ == "__main__":
|
| 1630 |
+
sys.exit(main())
|
ComfyUI/custom_nodes/koolook/scripts/make_card.py
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Render an experiment-tracking card PNG from a ComfyUI workflow JSON.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python scripts/make_card.py <workflow.json> [output.png]
|
| 5 |
+
|
| 6 |
+
Reads the LTX Director run/base context from the workflow JSON, paints a
|
| 7 |
+
self-contained card matching the look of
|
| 8 |
+
docs/investigations/experiments/combined-card.html, and writes it to PNG.
|
| 9 |
+
|
| 10 |
+
PNG can then be loaded into a ComfyUI workflow via a LoadImage node and
|
| 11 |
+
composited alongside the rendered video, or dropped straight into an NLE.
|
| 12 |
+
|
| 13 |
+
Tested against LTX_Director_4k_v0[1-3].json.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _load_dotenv(env_path: Path) -> None:
|
| 26 |
+
"""Minimal `.env` loader. Mirrors scripts/sync_to_dev.py — no python-dotenv dep."""
|
| 27 |
+
if not env_path.exists():
|
| 28 |
+
return
|
| 29 |
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
| 30 |
+
line = line.strip()
|
| 31 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 32 |
+
continue
|
| 33 |
+
key, value = line.split("=", 1)
|
| 34 |
+
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _find_dotenv() -> Path | None:
|
| 38 |
+
"""Find .env in the current worktree first, then fall back to the main repo
|
| 39 |
+
root (resolved via git's common-dir) when running from a worktree.
|
| 40 |
+
Returns the first existing .env found, or None."""
|
| 41 |
+
repo_root = Path(__file__).resolve().parent.parent
|
| 42 |
+
direct = repo_root / ".env"
|
| 43 |
+
if direct.exists():
|
| 44 |
+
return direct
|
| 45 |
+
# Try the main repo root via git's common-dir.
|
| 46 |
+
git_marker = repo_root / ".git"
|
| 47 |
+
common_dir = None
|
| 48 |
+
if git_marker.is_file():
|
| 49 |
+
# Worktree marker file contains: "gitdir: <path-to>/<main>/.git/worktrees/<name>"
|
| 50 |
+
try:
|
| 51 |
+
content = git_marker.read_text(encoding="utf-8").strip()
|
| 52 |
+
if content.startswith("gitdir:"):
|
| 53 |
+
gitdir = Path(content.split(":", 1)[1].strip())
|
| 54 |
+
# walk up from .git/worktrees/<name>/ to find <main repo>/
|
| 55 |
+
if "worktrees" in gitdir.parts:
|
| 56 |
+
idx = gitdir.parts.index("worktrees")
|
| 57 |
+
common_dir = Path(*gitdir.parts[:idx]) # ends at <main>/.git
|
| 58 |
+
main_root = common_dir.parent
|
| 59 |
+
candidate = main_root / ".env"
|
| 60 |
+
if candidate.exists():
|
| 61 |
+
return candidate
|
| 62 |
+
except Exception:
|
| 63 |
+
pass
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# Resolve repo root and load .env once on import — same pattern as sync_to_dev.py.
|
| 68 |
+
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 69 |
+
_env_file = _find_dotenv()
|
| 70 |
+
if _env_file is not None:
|
| 71 |
+
_load_dotenv(_env_file)
|
| 72 |
+
|
| 73 |
+
# ---------- palette + geometry (kept in sync with combined-card.html) ----------
|
| 74 |
+
W = 540 # slim vertical card — sits beside a video
|
| 75 |
+
PAD_X = 28
|
| 76 |
+
PAD_TOP = 28
|
| 77 |
+
PAD_BOTTOM = 28
|
| 78 |
+
# Palette mirrored from docs/designs/snapshot-dialogs.html
|
| 79 |
+
BG_OUTER = (14, 14, 14) # --bg
|
| 80 |
+
BG_CARD = (21, 21, 21) # --panel
|
| 81 |
+
BG_SECTION = (26, 26, 31) # --panel-soft (each section gets this)
|
| 82 |
+
BORDER = (48, 47, 47) # --border
|
| 83 |
+
BORDER_STR = (79, 79, 84) # --border-strong
|
| 84 |
+
TEXT = (249, 250, 251) # --text
|
| 85 |
+
MUTED = (143, 149, 156) # --muted
|
| 86 |
+
DIM = (200, 204, 209) # close to --text but slightly down
|
| 87 |
+
HEADER_GREY = (201, 204, 209) # snapshot dialog H2 colour
|
| 88 |
+
ACCENT_RUN = (255, 184, 77) # --amber (PHASE 1 / changeable)
|
| 89 |
+
ACCENT_BASE = (109, 180, 255) # --sky (frozen base)
|
| 90 |
+
ACCENT_OUT = (123, 207, 128) # --green (post-render)
|
| 91 |
+
NOTE_BG = (12, 12, 12)
|
| 92 |
+
RADIUS = 14
|
| 93 |
+
SECTION_RADIUS = 8
|
| 94 |
+
|
| 95 |
+
# ---------- font loading ----------
|
| 96 |
+
WIN_FONTS = Path(r"C:/Windows/Fonts")
|
| 97 |
+
def load_font(filenames: list[str], size: int) -> ImageFont.FreeTypeFont:
|
| 98 |
+
for fn in filenames:
|
| 99 |
+
for p in (WIN_FONTS / fn, Path("/Library/Fonts") / fn, Path("/usr/share/fonts") / fn):
|
| 100 |
+
if p.exists():
|
| 101 |
+
return ImageFont.truetype(str(p), size)
|
| 102 |
+
return ImageFont.load_default()
|
| 103 |
+
|
| 104 |
+
F_TITLE = load_font(["segoeuib.ttf", "Arial Bold.ttf"], 28)
|
| 105 |
+
F_SUB = load_font(["segoeui.ttf", "Arial.ttf"], 17)
|
| 106 |
+
F_H2 = load_font(["segoeuib.ttf", "Arial Bold.ttf"], 16)
|
| 107 |
+
F_TAG = load_font(["segoeuib.ttf", "Arial Bold.ttf"], 12) # FEEDBACK / OUTCOME sub-labels
|
| 108 |
+
F_SECTION = load_font(["segoeuib.ttf", "Arial Bold.ttf"], 17) # main section titles (PHASE 1 etc.)
|
| 109 |
+
F_MONO = load_font(["consola.ttf", "Menlo.ttc", "DejaVuSansMono.ttf"], 19)
|
| 110 |
+
F_NOTE = load_font(["segoeuii.ttf", "Arial Italic.ttf"], 18)
|
| 111 |
+
|
| 112 |
+
# ---------- workflow extraction ----------
|
| 113 |
+
VIDEO_EXTS = (".mp4", ".mov", ".mkv", ".webm", ".avi")
|
| 114 |
+
LOG_NAME = "iterations.md"
|
| 115 |
+
AI_SUBDIR = "_AI" # AI-managed artifacts live here, separate from user content
|
| 116 |
+
CARD_NAME = "card.png"
|
| 117 |
+
# Files containing this marker (case-insensitive) in their basename are
|
| 118 |
+
# post-loop outputs (the card already composited with the video) — skip
|
| 119 |
+
# them in auto-discovery so the script never picks its own output as input.
|
| 120 |
+
SKIP_MARKER = "loop"
|
| 121 |
+
|
| 122 |
+
def _is_loop_output(p: Path) -> bool:
|
| 123 |
+
return SKIP_MARKER in p.stem.lower()
|
| 124 |
+
LOG_HEADER = (
|
| 125 |
+
"# LTX Director — iterations log\n\n"
|
| 126 |
+
"Append-only. Newest at bottom. Generated by `scripts/make_card.py`.\n\n"
|
| 127 |
+
"| # | When | Run | Format | Denoise | JSON | Video | Δ from base | Feedback |\n"
|
| 128 |
+
"| - | ---- | --- | ------ | ------- | ---- | ----- | ----------- | -------- |\n"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def log_iteration(folder: Path, json_path: Path, data: dict) -> None:
|
| 133 |
+
"""Append one row to <folder>/_AI/iterations.md. Dedupe by (json filename + json mtime).
|
| 134 |
+
Markdown table — newest at bottom. Creates the file + the _AI subdir on first call."""
|
| 135 |
+
ai_dir = folder / AI_SUBDIR
|
| 136 |
+
ai_dir.mkdir(exist_ok=True)
|
| 137 |
+
log_path = ai_dir / LOG_NAME
|
| 138 |
+
json_mtime = json_path.stat().st_mtime
|
| 139 |
+
json_mtime_iso = time.strftime("%Y-%m-%d %H:%M", time.localtime(json_mtime))
|
| 140 |
+
fingerprint = f"{json_path.name}@{int(json_mtime)}"
|
| 141 |
+
|
| 142 |
+
existing = ""
|
| 143 |
+
last_fp = ""
|
| 144 |
+
next_idx = 1
|
| 145 |
+
if log_path.exists():
|
| 146 |
+
existing = log_path.read_text(encoding="utf-8")
|
| 147 |
+
rows = [ln for ln in existing.splitlines()
|
| 148 |
+
if ln.startswith("| ") and not ln.startswith("| -")
|
| 149 |
+
and not ln.startswith("| #")]
|
| 150 |
+
next_idx = len(rows) + 1
|
| 151 |
+
if rows:
|
| 152 |
+
# last row's hidden HTML-comment fingerprint, if present
|
| 153 |
+
tail_comment_re = re.compile(r"<!--fp:([^>]+)-->")
|
| 154 |
+
for ln in reversed(rows):
|
| 155 |
+
m = tail_comment_re.search(ln)
|
| 156 |
+
if m:
|
| 157 |
+
last_fp = m.group(1)
|
| 158 |
+
break
|
| 159 |
+
|
| 160 |
+
if last_fp == fingerprint:
|
| 161 |
+
# Same JSON, same mtime — already logged. Skip.
|
| 162 |
+
return
|
| 163 |
+
|
| 164 |
+
def trunc(s, n):
|
| 165 |
+
s = (s or "").replace("\n", " ").strip()
|
| 166 |
+
return (s[: n - 1] + "…") if len(s) > n else s
|
| 167 |
+
|
| 168 |
+
row = (
|
| 169 |
+
f"| {next_idx} "
|
| 170 |
+
f"| {json_mtime_iso} "
|
| 171 |
+
f"| {data.get('run_label', '?')} "
|
| 172 |
+
f"| {data.get('format', '?')} "
|
| 173 |
+
f"| {data.get('denoise', '?')} "
|
| 174 |
+
f"| `{json_path.name}` "
|
| 175 |
+
f"| `{data.get('render_output', '—')}` "
|
| 176 |
+
f"| {trunc(data.get('note', ''), 40)} "
|
| 177 |
+
f"| {trunc(data.get('feedback', ''), 40)} "
|
| 178 |
+
f"|<!--fp:{fingerprint}-->\n"
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
if not existing:
|
| 182 |
+
log_path.write_text(LOG_HEADER + row, encoding="utf-8")
|
| 183 |
+
else:
|
| 184 |
+
with log_path.open("a", encoding="utf-8") as f:
|
| 185 |
+
f.write(row)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def load_workflow(path: Path) -> dict:
|
| 189 |
+
"""Load a ComfyUI workflow JSON. Accepts two formats:
|
| 190 |
+
1. raw editor JSON (top-level 'nodes' + 'links' keys), or
|
| 191 |
+
2. metadata bundle ({"CreationTime": ..., "prompt": ..., "workflow": {...}})
|
| 192 |
+
— the format ComfyUI writes alongside saved outputs.
|
| 193 |
+
"""
|
| 194 |
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
| 195 |
+
if "nodes" in raw:
|
| 196 |
+
return raw
|
| 197 |
+
if "workflow" in raw and isinstance(raw["workflow"], dict) and "nodes" in raw["workflow"]:
|
| 198 |
+
return raw["workflow"]
|
| 199 |
+
raise SystemExit(f"Unrecognised JSON format in {path} — no 'nodes' key found.")
|
| 200 |
+
|
| 201 |
+
def find_newest_video(folder: Path):
|
| 202 |
+
"""Return (Path, mtime) for the newest video file in folder, or (None, None).
|
| 203 |
+
Skips files marked as loop outputs (see SKIP_MARKER)."""
|
| 204 |
+
candidates = [p for p in folder.iterdir()
|
| 205 |
+
if p.is_file()
|
| 206 |
+
and p.suffix.lower() in VIDEO_EXTS
|
| 207 |
+
and not _is_loop_output(p)]
|
| 208 |
+
if not candidates:
|
| 209 |
+
return None, None
|
| 210 |
+
best = max(candidates, key=lambda p: p.stat().st_mtime)
|
| 211 |
+
return best, best.stat().st_mtime
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def find_comfyui_log_path():
|
| 215 |
+
"""Resolve the ComfyUI log path. Priority:
|
| 216 |
+
1. `KOLOOK_COMFYUI_LOG` env var (explicit override).
|
| 217 |
+
2. Inferred from `KOLOOK_COMFYUI_DEV_PATH` — the dev path points at
|
| 218 |
+
`<comfyui-root>/custom_nodes/<koolook>`, so the log sits at
|
| 219 |
+
`<comfyui-root>/user/comfyui.log`.
|
| 220 |
+
Returns Path or None."""
|
| 221 |
+
override = os.environ.get("KOLOOK_COMFYUI_LOG", "").strip()
|
| 222 |
+
if override:
|
| 223 |
+
p = Path(override)
|
| 224 |
+
return p if p.exists() else None
|
| 225 |
+
dev = os.environ.get("KOLOOK_COMFYUI_DEV_PATH", "").strip()
|
| 226 |
+
if not dev:
|
| 227 |
+
return None
|
| 228 |
+
candidate = Path(dev).parent.parent / "user" / "comfyui.log"
|
| 229 |
+
return candidate if candidate.exists() else None
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
_LOG_DUR_PATTERNS = [
|
| 233 |
+
# "Prompt executed in 18.21 seconds"
|
| 234 |
+
(re.compile(r"Prompt executed in ([\d.]+) seconds"),
|
| 235 |
+
lambda m: float(m.group(1))),
|
| 236 |
+
# "Prompt executed in 00:10:33"
|
| 237 |
+
(re.compile(r"Prompt executed in (\d+):(\d+):(\d+)"),
|
| 238 |
+
lambda m: int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3))),
|
| 239 |
+
]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def render_time_from_log(log_path: Path):
|
| 243 |
+
"""Return the duration of the most recent render that actually wrote
|
| 244 |
+
frames. Locates the latest `saving images: 100%` line in the Comfy log
|
| 245 |
+
and pairs it with the next `Prompt executed in X` line — that's the
|
| 246 |
+
completed EXR-producing render. Skips trivial follow-up prompts that
|
| 247 |
+
happen between the render finishing and the user invoking /make-card.
|
| 248 |
+
Returns duration in seconds, or None."""
|
| 249 |
+
if not log_path or not log_path.exists():
|
| 250 |
+
return None
|
| 251 |
+
text = log_path.read_text(encoding="utf-8", errors="replace")
|
| 252 |
+
saves = list(re.finditer(r"saving images: 100%", text))
|
| 253 |
+
if not saves:
|
| 254 |
+
return None
|
| 255 |
+
after = saves[-1].end()
|
| 256 |
+
candidates = []
|
| 257 |
+
for pat, to_seconds in _LOG_DUR_PATTERNS:
|
| 258 |
+
m = pat.search(text, after)
|
| 259 |
+
if m:
|
| 260 |
+
candidates.append((m.start(), to_seconds(m)))
|
| 261 |
+
if not candidates:
|
| 262 |
+
return None
|
| 263 |
+
# Take the first 'Prompt executed in X' line after the save (smallest start).
|
| 264 |
+
candidates.sort(key=lambda x: x[0])
|
| 265 |
+
return candidates[0][1]
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def exr_sequence_duration(folder: Path):
|
| 269 |
+
"""Return (duration_seconds, version_dir_name) for the most recent EXR
|
| 270 |
+
sequence in the working folder. Treats render time as the wall-clock
|
| 271 |
+
span between the first and last frame in the version subdirectory of
|
| 272 |
+
the newest EXR — accurate even when the JSON is saved after rendering.
|
| 273 |
+
Returns (None, None) if no EXR sequence is found."""
|
| 274 |
+
all_exrs = list(folder.rglob("*.exr"))
|
| 275 |
+
if not all_exrs:
|
| 276 |
+
return None, None
|
| 277 |
+
newest = max(all_exrs, key=lambda p: p.stat().st_mtime)
|
| 278 |
+
seq_dir = newest.parent
|
| 279 |
+
seq_mtimes = sorted(p.stat().st_mtime for p in seq_dir.glob("*.exr"))
|
| 280 |
+
if len(seq_mtimes) < 2:
|
| 281 |
+
return None, seq_dir.name
|
| 282 |
+
return seq_mtimes[-1] - seq_mtimes[0], seq_dir.name
|
| 283 |
+
|
| 284 |
+
def fmt_duration(seconds: float) -> str:
|
| 285 |
+
s = int(round(seconds))
|
| 286 |
+
if s < 60:
|
| 287 |
+
return f"{s}s"
|
| 288 |
+
m, s = divmod(s, 60)
|
| 289 |
+
if m < 60:
|
| 290 |
+
return f"{m}m {s:02d}s"
|
| 291 |
+
h, m = divmod(m, 60)
|
| 292 |
+
return f"{h}h {m:02d}m"
|
| 293 |
+
|
| 294 |
+
def find(wf, t):
|
| 295 |
+
return [n for n in wf["nodes"] if n.get("type") == t]
|
| 296 |
+
|
| 297 |
+
def widgets(n): return n.get("widgets_values", []) or []
|
| 298 |
+
|
| 299 |
+
def resolve_input(wf, node, name):
|
| 300 |
+
"""Return the upstream node feeding a named input, or None."""
|
| 301 |
+
links = {link[0]: link for link in wf["links"]}
|
| 302 |
+
by_id = {n["id"]: n for n in wf["nodes"]}
|
| 303 |
+
for inp in node.get("inputs", []) or []:
|
| 304 |
+
if inp.get("name") == name and inp.get("link") is not None:
|
| 305 |
+
link = links.get(inp["link"])
|
| 306 |
+
if link:
|
| 307 |
+
return by_id.get(link[1])
|
| 308 |
+
return None
|
| 309 |
+
|
| 310 |
+
def follow_setnode(wf, getnode):
|
| 311 |
+
"""GetNode -> the value-producing source via SetNode of the same variable."""
|
| 312 |
+
var = widgets(getnode)[0] if widgets(getnode) else None
|
| 313 |
+
if not var:
|
| 314 |
+
return None
|
| 315 |
+
for n in wf["nodes"]:
|
| 316 |
+
if n.get("type") == "SetNode" and widgets(n)[:1] == [var]:
|
| 317 |
+
# SetNode's incoming link is the actual value source
|
| 318 |
+
links = {link[0]: link for link in wf["links"]}
|
| 319 |
+
by_id = {n["id"]: n for n in wf["nodes"]}
|
| 320 |
+
for inp in n.get("inputs", []) or []:
|
| 321 |
+
if inp.get("link") is not None:
|
| 322 |
+
link = links.get(inp["link"])
|
| 323 |
+
if link:
|
| 324 |
+
src = by_id.get(link[1])
|
| 325 |
+
# If it's a switch, resolve the selected value
|
| 326 |
+
if src and src.get("type") == "easy anythingIndexSwitch":
|
| 327 |
+
idx_node = resolve_input(wf, src, "index")
|
| 328 |
+
idx = widgets(idx_node)[0] if idx_node and widgets(idx_node) else 0
|
| 329 |
+
val_node = resolve_input(wf, src, f"value{idx}")
|
| 330 |
+
return val_node
|
| 331 |
+
return src
|
| 332 |
+
return None
|
| 333 |
+
|
| 334 |
+
def res_label(w, h):
|
| 335 |
+
return {(3744,2112):"4K", (3840,2160):"4K", (2560,1440):"2K",
|
| 336 |
+
(1920,1080):"HD", (1280,720):"HD"}.get((w,h), f"{w}x{h}")
|
| 337 |
+
|
| 338 |
+
def extract(wf):
|
| 339 |
+
"""Pull every field we need to draw the card."""
|
| 340 |
+
d = find(wf, "LTXDirector")[0]
|
| 341 |
+
dw = widgets(d)
|
| 342 |
+
# LTXDirector widget order (verified in v01-v03):
|
| 343 |
+
# 0 global_prompt, 1 dur_frames, 2 dur_sec, 3 timeline_data, 4 local_prompts,
|
| 344 |
+
# 5 segment_lengths, 6 epsilon, 7 guide_strength, 8 use_custom_audio,
|
| 345 |
+
# 9 frame_rate, 10 display_mode, 11 custom_width, 12 custom_height,
|
| 346 |
+
# 13 resize_method, 14 divisible_by, 15 img_compression
|
| 347 |
+
epsilon = dw[6]
|
| 348 |
+
seg_lengths_str = dw[5]
|
| 349 |
+
fps = dw[9]
|
| 350 |
+
use_custom_aud = dw[8]
|
| 351 |
+
dur_frames = dw[1]
|
| 352 |
+
dur_seconds = dw[2]
|
| 353 |
+
|
| 354 |
+
# custom_width / height may be widget value OR fed by GetNode chain
|
| 355 |
+
cw = resolve_input(wf, d, "custom_width")
|
| 356 |
+
ch = resolve_input(wf, d, "custom_height")
|
| 357 |
+
if cw and cw.get("type") == "GetNode":
|
| 358 |
+
cw = follow_setnode(wf, cw)
|
| 359 |
+
if ch and ch.get("type") == "GetNode":
|
| 360 |
+
ch = follow_setnode(wf, ch)
|
| 361 |
+
width = widgets(cw)[0] if cw and widgets(cw) else dw[11]
|
| 362 |
+
height = widgets(ch)[0] if ch and widgets(ch) else dw[12]
|
| 363 |
+
|
| 364 |
+
# parse timeline_data segments
|
| 365 |
+
try:
|
| 366 |
+
tl = json.loads(dw[3]) if dw[3] else {}
|
| 367 |
+
segments = tl.get("segments", [])
|
| 368 |
+
except Exception:
|
| 369 |
+
segments = []
|
| 370 |
+
seg_lengths = [int(x.strip()) for x in seg_lengths_str.split(",") if x.strip()]
|
| 371 |
+
|
| 372 |
+
# phase 1 chain — first BasicScheduler with denoise == 1.0, else first one
|
| 373 |
+
schedulers = find(wf, "BasicScheduler")
|
| 374 |
+
p1 = next((s for s in schedulers if widgets(s)[2] == 1), schedulers[0] if schedulers else None)
|
| 375 |
+
sched_name, steps, denoise = (widgets(p1) if p1 else ("?", "?", "?"))
|
| 376 |
+
|
| 377 |
+
# Director Guide scale_by per phase — pick the one fed by LTXDirector (phase 1)
|
| 378 |
+
guides = find(wf, "LTXDirectorGuide")
|
| 379 |
+
p1_guide = None
|
| 380 |
+
for g in guides:
|
| 381 |
+
latent_src = resolve_input(wf, g, "latent")
|
| 382 |
+
if latent_src and latent_src.get("type") == "LTXDirector":
|
| 383 |
+
p1_guide = g
|
| 384 |
+
break
|
| 385 |
+
if p1_guide is None and guides:
|
| 386 |
+
p1_guide = guides[0]
|
| 387 |
+
mult = widgets(p1_guide)[0] if p1_guide else "?"
|
| 388 |
+
|
| 389 |
+
# Pull three named Text Multiline nodes from the workflow:
|
| 390 |
+
# - "OVERLAY - INFO" (or any title containing INFO) -> base notes (Δ from baseline)
|
| 391 |
+
# - "OVERLAY - FEEDBACK" / "FEEDBACK" / "OBSERVATIONS" -> video feedback + scores
|
| 392 |
+
# - "Working_Folder_PATH" / "OUT_working_folder" -> authoritative output folder
|
| 393 |
+
note, feedback, work_folder = "", "", ""
|
| 394 |
+
for n in wf["nodes"]:
|
| 395 |
+
if n.get("type") != "Text Multiline":
|
| 396 |
+
continue
|
| 397 |
+
title = (n.get("title") or "").upper()
|
| 398 |
+
ws = widgets(n)
|
| 399 |
+
if not (ws and isinstance(ws[0], str)):
|
| 400 |
+
continue
|
| 401 |
+
text = ws[0]
|
| 402 |
+
if not note and ("INFO" in title or ("OVERLAY" in title and "FEEDBACK" not in title)):
|
| 403 |
+
for marker in ("BASE (notes):", "BASE:"):
|
| 404 |
+
if marker in text:
|
| 405 |
+
note = text.split(marker, 1)[1].strip()
|
| 406 |
+
break
|
| 407 |
+
if not feedback and ("FEEDBACK" in title or "OBSERVATION" in title or "VIDEO" in title):
|
| 408 |
+
feedback = text.strip()
|
| 409 |
+
if not work_folder and ("WORKING_FOLDER" in title or "WORK_FOLDER" in title
|
| 410 |
+
or "OUT_WORKING" in title or "WORKING-FOLDER" in title):
|
| 411 |
+
work_folder = text.strip().strip("\"' ").rstrip("\\/")
|
| 412 |
+
|
| 413 |
+
# Parse scores out of the feedback text. Pattern: "motion: 4/5" / "sync 3" / "sharpness: 5/5".
|
| 414 |
+
scores = {"motion": None, "sync": None, "sharp": None}
|
| 415 |
+
feedback_lines = []
|
| 416 |
+
score_re = re.compile(
|
| 417 |
+
r"^\s*(motion|sync|sharp(?:ness)?|sharpness)\s*[:=]?\s*(\d+)\s*(?:/\s*\d+)?\s*$",
|
| 418 |
+
re.IGNORECASE,
|
| 419 |
+
)
|
| 420 |
+
for line in feedback.splitlines():
|
| 421 |
+
m = score_re.match(line)
|
| 422 |
+
if m:
|
| 423 |
+
key = m.group(1).lower()
|
| 424 |
+
if key.startswith("sharp"):
|
| 425 |
+
key = "sharp"
|
| 426 |
+
scores[key] = int(m.group(2))
|
| 427 |
+
elif line.strip():
|
| 428 |
+
feedback_lines.append(line.rstrip())
|
| 429 |
+
feedback = "\n".join(feedback_lines).strip()
|
| 430 |
+
|
| 431 |
+
# model stack
|
| 432 |
+
ckpt = (widgets(find(wf, "CheckpointLoaderSimple")[0]) if find(wf, "CheckpointLoaderSimple") else [""])[0]
|
| 433 |
+
lora_nodes = find(wf, "LoraLoaderModelOnly")
|
| 434 |
+
lora = ""
|
| 435 |
+
if lora_nodes:
|
| 436 |
+
lw = widgets(lora_nodes[0])
|
| 437 |
+
lora_name = lw[0].replace("\\", "/").split("/")[-1].replace(".safetensors", "")
|
| 438 |
+
lora_strength = lw[1] if len(lw) > 1 else ""
|
| 439 |
+
lora = f"{lora_name} @ {lora_strength}"
|
| 440 |
+
clip_nodes = find(wf, "DualCLIPLoader")
|
| 441 |
+
clip = ""
|
| 442 |
+
if clip_nodes:
|
| 443 |
+
cw_ = widgets(clip_nodes[0])
|
| 444 |
+
clip = cw_[0].replace("\\", "/").split("/")[-1].replace(".safetensors", "")
|
| 445 |
+
vaes = find(wf, "VAELoaderKJ")
|
| 446 |
+
video_vae = audio_vae = ""
|
| 447 |
+
for v in vaes:
|
| 448 |
+
n = widgets(v)[0].replace(".safetensors", "")
|
| 449 |
+
if "video" in n:
|
| 450 |
+
video_vae = n
|
| 451 |
+
elif "audio" in n:
|
| 452 |
+
audio_vae = n
|
| 453 |
+
|
| 454 |
+
# seed
|
| 455 |
+
rn = find(wf, "RandomNoise")
|
| 456 |
+
seed = widgets(rn[0])[0] if rn else "?"
|
| 457 |
+
|
| 458 |
+
return {
|
| 459 |
+
"format": res_label(width, height),
|
| 460 |
+
"resolution": f"{width} × {height}",
|
| 461 |
+
"mult": mult,
|
| 462 |
+
"scheduler": sched_name,
|
| 463 |
+
"steps": steps,
|
| 464 |
+
"denoise": denoise,
|
| 465 |
+
"note": note,
|
| 466 |
+
"feedback": feedback,
|
| 467 |
+
"scores": scores,
|
| 468 |
+
"work_folder_from_wf": work_folder,
|
| 469 |
+
"epsilon": epsilon,
|
| 470 |
+
"seed": seed,
|
| 471 |
+
"fps": fps,
|
| 472 |
+
"dur_frames": dur_frames,
|
| 473 |
+
"dur_seconds": dur_seconds,
|
| 474 |
+
"segments": segments,
|
| 475 |
+
"seg_lengths": seg_lengths,
|
| 476 |
+
"use_custom_audio": use_custom_aud,
|
| 477 |
+
"ckpt": Path(ckpt).name.replace(".safetensors", ""),
|
| 478 |
+
"lora": lora,
|
| 479 |
+
"clip": Path(clip).name,
|
| 480 |
+
"video_vae": video_vae,
|
| 481 |
+
"audio_vae": audio_vae,
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
# ---------- drawing helpers ----------
|
| 485 |
+
def draw_kv_row(draw, x, y, key, val, key_w):
|
| 486 |
+
draw.text((x, y), key, font=F_MONO, fill=MUTED)
|
| 487 |
+
draw.text((x + key_w, y), str(val), font=F_MONO, fill=TEXT)
|
| 488 |
+
return y + 26
|
| 489 |
+
|
| 490 |
+
def draw_h2(draw, x, y, label, color):
|
| 491 |
+
draw.text((x, y), label.upper(), font=F_H2, fill=color)
|
| 492 |
+
return y + 30
|
| 493 |
+
|
| 494 |
+
def wrap_text(text, max_chars):
|
| 495 |
+
"""Naive word-wrap to fit max_chars per line."""
|
| 496 |
+
out = []
|
| 497 |
+
for raw_line in text.split("\n"):
|
| 498 |
+
if not raw_line.strip():
|
| 499 |
+
out.append("")
|
| 500 |
+
continue
|
| 501 |
+
words, cur = raw_line.split(), ""
|
| 502 |
+
for w in words:
|
| 503 |
+
if len(cur) + len(w) + 1 <= max_chars:
|
| 504 |
+
cur = (cur + " " + w).strip()
|
| 505 |
+
else:
|
| 506 |
+
if cur:
|
| 507 |
+
out.append(cur)
|
| 508 |
+
cur = w
|
| 509 |
+
if cur:
|
| 510 |
+
out.append(cur)
|
| 511 |
+
return out
|
| 512 |
+
|
| 513 |
+
def draw_text_box(draw, x, y, width, label, content, accent, max_lines=3):
|
| 514 |
+
"""Draw a left-accented note box with a header label + wrapped content lines."""
|
| 515 |
+
char_per_line = 42
|
| 516 |
+
lines = wrap_text(content, char_per_line)[:max_lines]
|
| 517 |
+
box_h = 26 + 22 * max(1, len(lines)) + 14
|
| 518 |
+
draw.rounded_rectangle(
|
| 519 |
+
[x - 12, y, x + width, y + box_h],
|
| 520 |
+
radius=4, fill=NOTE_BG,
|
| 521 |
+
)
|
| 522 |
+
draw.rectangle([x - 12, y, x - 8, y + box_h], fill=accent)
|
| 523 |
+
draw.text((x + 4, y + 8), label, font=F_H2, fill=accent)
|
| 524 |
+
line_y = y + 34
|
| 525 |
+
for line in lines:
|
| 526 |
+
draw.text((x + 4, line_y), line, font=F_NOTE, fill=DIM)
|
| 527 |
+
line_y += 22
|
| 528 |
+
return y + box_h + 4
|
| 529 |
+
|
| 530 |
+
def draw_section(draw, x, y, width, accent, label, body_lines_height,
|
| 531 |
+
bg=BG_SECTION, border=BORDER):
|
| 532 |
+
"""Draw a section panel header + return (content_x, content_y, content_w, end_y).
|
| 533 |
+
Body content is drawn by the caller starting at (content_x, content_y)."""
|
| 534 |
+
HEADER_H = 36
|
| 535 |
+
section_h = HEADER_H + body_lines_height + 14
|
| 536 |
+
draw.rounded_rectangle(
|
| 537 |
+
[x, y, x + width, y + section_h],
|
| 538 |
+
radius=SECTION_RADIUS, fill=bg, outline=border, width=1,
|
| 539 |
+
)
|
| 540 |
+
# header label — bigger, no side bar, accent colour
|
| 541 |
+
draw.text((x + 14, y + 10), label.upper(), font=F_SECTION, fill=accent)
|
| 542 |
+
return x + 14, y + HEADER_H, width - 28, y + section_h + 10
|
| 543 |
+
|
| 544 |
+
def section_body_rows(num_rows, extra=0):
|
| 545 |
+
"""Pixel height needed for N kv rows plus optional extra padding."""
|
| 546 |
+
return num_rows * 26 + extra
|
| 547 |
+
|
| 548 |
+
def render(data: dict, out_path: Path) -> None:
|
| 549 |
+
# Oversized canvas; crop after laying out.
|
| 550 |
+
canvas_h = 2000
|
| 551 |
+
img = Image.new("RGB", (W, canvas_h), BG_OUTER)
|
| 552 |
+
draw = ImageDraw.Draw(img)
|
| 553 |
+
|
| 554 |
+
inset = 18
|
| 555 |
+
# outer card
|
| 556 |
+
draw.rounded_rectangle(
|
| 557 |
+
[inset, inset, W - inset, canvas_h - inset],
|
| 558 |
+
radius=RADIUS, fill=BG_CARD, outline=BORDER, width=1,
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
x = inset + PAD_X
|
| 562 |
+
y = inset + PAD_TOP
|
| 563 |
+
inner_w = W - 2 * inset - 2 * PAD_X
|
| 564 |
+
|
| 565 |
+
# ---------- HEADER ----------
|
| 566 |
+
import datetime
|
| 567 |
+
today = datetime.date.today().isoformat()
|
| 568 |
+
ver = data.get("run_label", "?")
|
| 569 |
+
stage_label = "single-stage" if data.get("denoise", 1) == 1 else "two-stage"
|
| 570 |
+
res_label = data.get("format", "")
|
| 571 |
+
title_line = f"Run {ver or '?'} — {stage_label} {res_label}".strip()
|
| 572 |
+
sub_line = f"{today} · {data.get('json_name', '')}"
|
| 573 |
+
draw.text((x, y), title_line, font=F_TITLE, fill=TEXT)
|
| 574 |
+
y += 38
|
| 575 |
+
draw.text((x, y), sub_line, font=F_SUB, fill=MUTED)
|
| 576 |
+
y += 30
|
| 577 |
+
# hairline divider under header
|
| 578 |
+
draw.line([(x, y), (x + inner_w, y)], fill=BORDER, width=1)
|
| 579 |
+
y += 18
|
| 580 |
+
|
| 581 |
+
key_w = 122
|
| 582 |
+
|
| 583 |
+
# ---------- PHASE 1 ----------
|
| 584 |
+
body_h = section_body_rows(5)
|
| 585 |
+
cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_RUN, "Phase 1", body_h)
|
| 586 |
+
cy = draw_kv_row(draw, cx, cy, "Format", f"{data['format']} · {data['resolution']}", key_w)
|
| 587 |
+
cy = draw_kv_row(draw, cx, cy, "Mult", data["mult"], key_w)
|
| 588 |
+
cy = draw_kv_row(draw, cx, cy, "Scheduler", data["scheduler"], key_w)
|
| 589 |
+
cy = draw_kv_row(draw, cx, cy, "Steps", data["steps"], key_w)
|
| 590 |
+
cy = draw_kv_row(draw, cx, cy, "Denoise", data["denoise"], key_w)
|
| 591 |
+
y = end_y
|
| 592 |
+
|
| 593 |
+
# ---------- BASE NOTES ----------
|
| 594 |
+
note_text = data["note"] or "(no changes)"
|
| 595 |
+
note_lines = wrap_text(note_text, 42)[:4]
|
| 596 |
+
body_h = max(28, 24 * len(note_lines)) + 4
|
| 597 |
+
cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_RUN,
|
| 598 |
+
"Base · notes (Δ this run)", body_h)
|
| 599 |
+
for line in note_lines:
|
| 600 |
+
draw.text((cx, cy), line, font=F_NOTE, fill=DIM)
|
| 601 |
+
cy += 24
|
| 602 |
+
y = end_y
|
| 603 |
+
|
| 604 |
+
# ---------- BASE · LOCKED ----------
|
| 605 |
+
body_h = section_body_rows(5)
|
| 606 |
+
cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_BASE, "Base · locked", body_h)
|
| 607 |
+
cy = draw_kv_row(draw, cx, cy, "Sampler", "euler", key_w)
|
| 608 |
+
cy = draw_kv_row(draw, cx, cy, "CFG", "1.0", key_w)
|
| 609 |
+
cy = draw_kv_row(draw, cx, cy, "Seed", f"{data['seed']} (fixed)", key_w)
|
| 610 |
+
cy = draw_kv_row(draw, cx, cy, "epsilon", data["epsilon"], key_w)
|
| 611 |
+
cy = draw_kv_row(draw, cx, cy, "Audio src", "model-gen"
|
| 612 |
+
if not data["use_custom_audio"] else "custom", key_w)
|
| 613 |
+
y = end_y
|
| 614 |
+
|
| 615 |
+
# ---------- BASE · SCENE ----------
|
| 616 |
+
seg_rows = min(len(data["segments"]), 6)
|
| 617 |
+
body_h = section_body_rows(2 + seg_rows, extra=4)
|
| 618 |
+
cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_BASE, "Base · scene", body_h)
|
| 619 |
+
cy = draw_kv_row(draw, cx, cy, "Duration",
|
| 620 |
+
f"{data['dur_frames']} f · {data['dur_seconds']} s @ {data['fps']} fps", key_w)
|
| 621 |
+
cy = draw_kv_row(draw, cx, cy, "Segments",
|
| 622 |
+
f"{len(data['segments'])} × {data['seg_lengths'][0] if data['seg_lengths'] else '?'} f", key_w)
|
| 623 |
+
for i, seg in enumerate(data["segments"][:seg_rows]):
|
| 624 |
+
start_s = seg.get("start", 0) / data["fps"]
|
| 625 |
+
end_s = (seg.get("start", 0) + seg.get("length", 0)) / data["fps"]
|
| 626 |
+
has_prompt = "[x]" if seg.get("prompt") else "[ ]"
|
| 627 |
+
has_audio = "[x]" if "Audio:" in (seg.get("prompt") or "") else "[ ]"
|
| 628 |
+
has_kf = "[x]" if seg.get("imageFile") else "[ ]"
|
| 629 |
+
all_set = (has_prompt == has_audio == has_kf == "[x]")
|
| 630 |
+
fill_col = ACCENT_OUT if all_set else TEXT
|
| 631 |
+
draw.text((cx, cy), f"{i+1})", font=F_MONO, fill=ACCENT_BASE)
|
| 632 |
+
draw.text((cx + 32, cy), f"{start_s:.0f}-{end_s:.0f}s", font=F_MONO, fill=DIM)
|
| 633 |
+
draw.text((cx + 110, cy), f"P{has_prompt} A{has_audio} K{has_kf}",
|
| 634 |
+
font=F_MONO, fill=fill_col)
|
| 635 |
+
cy += 26
|
| 636 |
+
y = end_y
|
| 637 |
+
|
| 638 |
+
# ---------- POST-RENDER ----------
|
| 639 |
+
feedback_text = data["feedback"] or "(add observations)"
|
| 640 |
+
feedback_lines = wrap_text(feedback_text, 42)[:5]
|
| 641 |
+
body_h = (
|
| 642 |
+
section_body_rows(2) # render time + output
|
| 643 |
+
+ 8 # spacer
|
| 644 |
+
+ 20 # FEEDBACK sublabel
|
| 645 |
+
+ max(24, 22 * len(feedback_lines))
|
| 646 |
+
+ 14 # spacer before outcome
|
| 647 |
+
+ 20 # OUTCOME sublabel
|
| 648 |
+
+ 26 # outcome scores row
|
| 649 |
+
)
|
| 650 |
+
cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_OUT, "Post-render", body_h)
|
| 651 |
+
cy = draw_kv_row(draw, cx, cy, "Render time", data.get("render_duration", "?"), key_w)
|
| 652 |
+
cy = draw_kv_row(draw, cx, cy, "Output", data.get("render_output", "?")[:36], key_w)
|
| 653 |
+
cy += 8
|
| 654 |
+
|
| 655 |
+
# FEEDBACK sublabel + body
|
| 656 |
+
draw.text((cx, cy), "FEEDBACK", font=F_TAG, fill=ACCENT_OUT)
|
| 657 |
+
cy += 20
|
| 658 |
+
for line in feedback_lines:
|
| 659 |
+
draw.text((cx, cy), line, font=F_NOTE, fill=DIM)
|
| 660 |
+
cy += 22
|
| 661 |
+
cy += 6
|
| 662 |
+
|
| 663 |
+
# OUTCOME sublabel + scores (auto-filled from FEEDBACK parsing)
|
| 664 |
+
draw.text((cx, cy), "OUTCOME", font=F_TAG, fill=ACCENT_OUT)
|
| 665 |
+
cy += 20
|
| 666 |
+
s = data.get("scores", {}) or {}
|
| 667 |
+
def _s(v): return f"{v}/5" if v is not None else "?/5"
|
| 668 |
+
draw.text((cx, cy),
|
| 669 |
+
f"Motion {_s(s.get('motion'))} Sync {_s(s.get('sync'))} Sharp {_s(s.get('sharp'))}",
|
| 670 |
+
font=F_MONO, fill=TEXT)
|
| 671 |
+
y = end_y
|
| 672 |
+
|
| 673 |
+
# ---------- final crop ----------
|
| 674 |
+
final_h = y + PAD_BOTTOM + inset
|
| 675 |
+
final = Image.new("RGB", (W, final_h), BG_OUTER)
|
| 676 |
+
draw2 = ImageDraw.Draw(final)
|
| 677 |
+
draw2.rounded_rectangle(
|
| 678 |
+
[inset, inset, W - inset, final_h - inset],
|
| 679 |
+
radius=RADIUS, fill=BG_CARD, outline=BORDER, width=1,
|
| 680 |
+
)
|
| 681 |
+
content = img.crop((inset + 1, inset + 1, W - inset - 1, final_h - inset - 1))
|
| 682 |
+
final.paste(content, (inset + 1, inset + 1))
|
| 683 |
+
final.save(out_path)
|
| 684 |
+
print(f"wrote {out_path} ({W}×{final_h})")
|
| 685 |
+
|
| 686 |
+
# ---------- CLI ----------
|
| 687 |
+
def _autodiscover_json() -> Path:
|
| 688 |
+
"""Find the newest *.json in $KOLOOK_AUTOMATIONS_WORK_DIR. Fails clearly."""
|
| 689 |
+
folder = os.environ.get("KOLOOK_AUTOMATIONS_WORK_DIR", "").strip()
|
| 690 |
+
if not folder:
|
| 691 |
+
raise SystemExit(
|
| 692 |
+
"KOLOOK_AUTOMATIONS_WORK_DIR not set. Add it to `.env` (see `.env.example`) "
|
| 693 |
+
"or pass the JSON path explicitly: `make_card.py <workflow.json>`."
|
| 694 |
+
)
|
| 695 |
+
p = Path(folder)
|
| 696 |
+
if not p.is_dir():
|
| 697 |
+
raise SystemExit(f"KOLOOK_AUTOMATIONS_WORK_DIR does not exist on disk: {p}")
|
| 698 |
+
candidates = sorted(
|
| 699 |
+
(j for j in p.glob("*.json") if not _is_loop_output(j)),
|
| 700 |
+
key=lambda x: x.stat().st_mtime,
|
| 701 |
+
reverse=True,
|
| 702 |
+
)
|
| 703 |
+
if not candidates:
|
| 704 |
+
raise SystemExit(f"No *.json files found in {p}. Export a workflow from ComfyUI first.")
|
| 705 |
+
return candidates[0]
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def main():
|
| 709 |
+
if len(sys.argv) >= 2:
|
| 710 |
+
wf_path = Path(sys.argv[1])
|
| 711 |
+
else:
|
| 712 |
+
wf_path = _autodiscover_json()
|
| 713 |
+
print(f"auto-discovered: {wf_path}")
|
| 714 |
+
wf = load_workflow(wf_path)
|
| 715 |
+
data = extract(wf)
|
| 716 |
+
data["json_name"] = wf_path.name
|
| 717 |
+
data["json_stem"] = wf_path.stem
|
| 718 |
+
|
| 719 |
+
# Output folder priority (first existing wins):
|
| 720 |
+
# 1. Working_Folder_PATH Text Multiline inside the workflow JSON.
|
| 721 |
+
# 2. KOLOOK_AUTOMATIONS_WORK_DIR env var (from .env).
|
| 722 |
+
# 3. The JSON's own parent directory.
|
| 723 |
+
candidates = []
|
| 724 |
+
wf_folder = data.get("work_folder_from_wf") or ""
|
| 725 |
+
if wf_folder:
|
| 726 |
+
candidates.append(("Working_Folder_PATH node", Path(wf_folder)))
|
| 727 |
+
env_folder = os.environ.get("KOLOOK_AUTOMATIONS_WORK_DIR", "").strip()
|
| 728 |
+
if env_folder:
|
| 729 |
+
candidates.append(("KOLOOK_AUTOMATIONS_WORK_DIR", Path(env_folder)))
|
| 730 |
+
candidates.append(("JSON parent directory", wf_path.parent))
|
| 731 |
+
|
| 732 |
+
folder = None
|
| 733 |
+
for label, p in candidates:
|
| 734 |
+
try:
|
| 735 |
+
p = p.resolve()
|
| 736 |
+
except Exception:
|
| 737 |
+
continue
|
| 738 |
+
if p.is_dir():
|
| 739 |
+
folder = p
|
| 740 |
+
break
|
| 741 |
+
if folder is None:
|
| 742 |
+
# Last-resort fallback — use the unresolved JSON parent.
|
| 743 |
+
folder = wf_path.parent
|
| 744 |
+
|
| 745 |
+
# Output path: stable filename in the _AI subfolder of the working folder.
|
| 746 |
+
# Keeps managed artifacts (card.png + iterations.md) separated from user
|
| 747 |
+
# content (JSONs, MP4s) so the maintainer can wipe one without the other.
|
| 748 |
+
if len(sys.argv) > 2:
|
| 749 |
+
out_path = Path(sys.argv[2])
|
| 750 |
+
else:
|
| 751 |
+
ai_dir = folder / AI_SUBDIR
|
| 752 |
+
ai_dir.mkdir(exist_ok=True)
|
| 753 |
+
out_path = ai_dir / CARD_NAME
|
| 754 |
+
video, vmtime = find_newest_video(folder)
|
| 755 |
+
# Render duration priority:
|
| 756 |
+
# 1. ComfyUI's own `Prompt executed in X` log line — authoritative
|
| 757 |
+
# wall-time for the model run. Skips trivial follow-up prompts.
|
| 758 |
+
# 2. EXR sequence first-to-last-frame span.
|
| 759 |
+
# 3. JSON-vs-MP4 mtime delta (last resort).
|
| 760 |
+
# Disk mtimes alone are unreliable: ComfyUI writes JSON + MP4 together
|
| 761 |
+
# at end-of-render, and the maintainer often assembles MP4s separately.
|
| 762 |
+
json_mtime = wf_path.stat().st_mtime
|
| 763 |
+
log_path = find_comfyui_log_path()
|
| 764 |
+
log_duration = render_time_from_log(log_path) if log_path else None
|
| 765 |
+
if log_duration:
|
| 766 |
+
data["render_duration"] = fmt_duration(log_duration) + " (Comfy log)"
|
| 767 |
+
data["render_log_path"] = str(log_path)
|
| 768 |
+
else:
|
| 769 |
+
exr_span, exr_version = exr_sequence_duration(folder)
|
| 770 |
+
if exr_span is not None and exr_span > 0:
|
| 771 |
+
suffix = f" ({exr_version})" if exr_version else ""
|
| 772 |
+
data["render_duration"] = fmt_duration(exr_span) + suffix
|
| 773 |
+
elif video is not None:
|
| 774 |
+
# Fall back to MP4 mtime delta only if no EXRs are present.
|
| 775 |
+
same_stem = video.stem.startswith(wf_path.stem) or wf_path.stem.startswith(video.stem)
|
| 776 |
+
delta = vmtime - json_mtime
|
| 777 |
+
if same_stem:
|
| 778 |
+
data["render_duration"] = "—"
|
| 779 |
+
elif delta >= 0:
|
| 780 |
+
data["render_duration"] = fmt_duration(delta)
|
| 781 |
+
else:
|
| 782 |
+
data["render_duration"] = "pending"
|
| 783 |
+
else:
|
| 784 |
+
data["render_duration"] = "pending"
|
| 785 |
+
|
| 786 |
+
data["render_output"] = video.name if video is not None else "(no video yet)"
|
| 787 |
+
|
| 788 |
+
# run_label: prefer v01/v04 style, else fall back to ComfyUI sequence number
|
| 789 |
+
stem = wf_path.stem
|
| 790 |
+
m = re.search(r"(?:^|[_\-])v(\d{1,4})(?:[_\-]|$)", stem)
|
| 791 |
+
if m:
|
| 792 |
+
data["run_label"] = f"v{m.group(1)}"
|
| 793 |
+
else:
|
| 794 |
+
m = re.search(r"_(\d{4,6})(?:_|$)", stem)
|
| 795 |
+
data["run_label"] = f"#{int(m.group(1))}" if m else "?"
|
| 796 |
+
|
| 797 |
+
render(data, out_path)
|
| 798 |
+
log_iteration(folder, wf_path, data)
|
| 799 |
+
|
| 800 |
+
if __name__ == "__main__":
|
| 801 |
+
main()
|
ComfyUI/custom_nodes/koolook/scripts/make_card_audio.py
ADDED
|
@@ -0,0 +1,831 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Audio-lipsync card renderer — vertical PIL card scoped to the
|
| 4 |
+
``docs/automations/LTX-2.3/audio-lipsync/`` iteration loop.
|
| 5 |
+
|
| 6 |
+
Sibling to ``scripts/make_card.py`` (base-1step). Shares palette,
|
| 7 |
+
font fallback chain, and section primitives so the two families read
|
| 8 |
+
as a set, but the data sources are deliberately narrower:
|
| 9 |
+
|
| 10 |
+
Two — and only two — source families feed this card:
|
| 11 |
+
|
| 12 |
+
1. The five ``Text Multiline`` nodes tracked by the loop config
|
| 13 |
+
(name / relay_overrides / overlay - info / overlay - feedback /
|
| 14 |
+
working_folder).
|
| 15 |
+
2. The active ``LTXDirector`` node's own widget values and
|
| 16 |
+
input-socket wiring (Koolook for modified runs, upstream original
|
| 17 |
+
for A/B comparison; epsilon, frame_rate, timeline_data segments +
|
| 18 |
+
audioSegments, use_custom_audio toggle, audio_vae link state).
|
| 19 |
+
|
| 20 |
+
Notably absent: BasicScheduler / KSamplerSelect / RandomNoise /
|
| 21 |
+
CFGGuider widget scrapes, ``_dev_build.json`` fork-state, ``git status``
|
| 22 |
+
output. Those don't define what this loop sweeps. Adding them would
|
| 23 |
+
push the card into showing values the maintainer's curated multiline
|
| 24 |
+
notes don't claim to summarise.
|
| 25 |
+
|
| 26 |
+
Card sections (top to bottom):
|
| 27 |
+
|
| 28 |
+
HEADER run-NNN — {name} · date · job · workflow filename
|
| 29 |
+
KNOB STATE relay_overrides (the per-render knob)
|
| 30 |
+
BASE / RUN source workflow · copied-from workflow · working folder
|
| 31 |
+
DIRECTOR READINGS Director · pin tag · epsilon · Audio src
|
| 32 |
+
SEGMENTS segment list with time ranges + coverage rows
|
| 33 |
+
POST-RENDER feedback body + outcome scores
|
| 34 |
+
|
| 35 |
+
Two entry points:
|
| 36 |
+
|
| 37 |
+
1. From loop_audio.py at end-of-snapshot:
|
| 38 |
+
|
| 39 |
+
from scripts.make_card_audio import render_audio_card
|
| 40 |
+
render_audio_card(state, run_dir / "card.png")
|
| 41 |
+
|
| 42 |
+
2. Standalone, against an existing run folder (re-render after a
|
| 43 |
+
layout tweak):
|
| 44 |
+
|
| 45 |
+
python scripts/make_card_audio.py <run_dir>
|
| 46 |
+
"""
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import argparse
|
| 50 |
+
import json
|
| 51 |
+
import re
|
| 52 |
+
import sys
|
| 53 |
+
from pathlib import Path
|
| 54 |
+
from typing import Any
|
| 55 |
+
|
| 56 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 57 |
+
from PIL.PngImagePlugin import PngInfo
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# --------------------------------------------------------------------------
|
| 61 |
+
# Palette + geometry — kept in lockstep with scripts/make_card.py.
|
| 62 |
+
# --------------------------------------------------------------------------
|
| 63 |
+
|
| 64 |
+
W = 540
|
| 65 |
+
PAD_X = 28
|
| 66 |
+
PAD_TOP = 28
|
| 67 |
+
PAD_BOTTOM = 28
|
| 68 |
+
BG_OUTER = (14, 14, 14)
|
| 69 |
+
BG_CARD = (21, 21, 21)
|
| 70 |
+
BG_SECTION = (26, 26, 31)
|
| 71 |
+
BORDER = (48, 47, 47)
|
| 72 |
+
TEXT = (249, 250, 251)
|
| 73 |
+
MUTED = (143, 149, 156)
|
| 74 |
+
DIM = (200, 204, 209)
|
| 75 |
+
ACCENT_RUN = (255, 184, 77) # amber — per-render knob state
|
| 76 |
+
ACCENT_BASE = (109, 180, 255) # sky — locked / scene
|
| 77 |
+
ACCENT_OUT = (123, 207, 128) # green — post-render outcome
|
| 78 |
+
NOTE_BG = (12, 12, 12)
|
| 79 |
+
RADIUS = 14
|
| 80 |
+
SECTION_RADIUS = 8
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# --------------------------------------------------------------------------
|
| 84 |
+
# Fonts — same fallback chain + same sizes as scripts/make_card.py.
|
| 85 |
+
# --------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
WIN_FONTS = Path(r"C:/Windows/Fonts")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _load_font(filenames: list[str], size: int) -> ImageFont.FreeTypeFont:
|
| 91 |
+
for fn in filenames:
|
| 92 |
+
for p in (WIN_FONTS / fn, Path("/Library/Fonts") / fn,
|
| 93 |
+
Path("/usr/share/fonts") / fn):
|
| 94 |
+
if p.exists():
|
| 95 |
+
try:
|
| 96 |
+
return ImageFont.truetype(str(p), size)
|
| 97 |
+
except OSError:
|
| 98 |
+
continue
|
| 99 |
+
return ImageFont.load_default()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
F_TITLE = _load_font(["segoeuib.ttf", "Arial Bold.ttf"], 28)
|
| 103 |
+
F_SUB = _load_font(["segoeui.ttf", "Arial.ttf"], 17)
|
| 104 |
+
F_H2 = _load_font(["segoeuib.ttf", "Arial Bold.ttf"], 16)
|
| 105 |
+
F_TAG = _load_font(["segoeuib.ttf", "Arial Bold.ttf"], 12)
|
| 106 |
+
F_SECTION = _load_font(["segoeuib.ttf", "Arial Bold.ttf"], 17)
|
| 107 |
+
F_MONO = _load_font(["consola.ttf", "Menlo.ttc", "DejaVuSansMono.ttf"], 19)
|
| 108 |
+
F_NOTE = _load_font(["segoeuii.ttf", "Arial Italic.ttf"], 18)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# --------------------------------------------------------------------------
|
| 112 |
+
# Drawing primitives — copied 1:1 from scripts/make_card.py.
|
| 113 |
+
# --------------------------------------------------------------------------
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _wrap_text(text: str, max_chars: int, keep_blank_lines: bool = False) -> list[str]:
|
| 117 |
+
out: list[str] = []
|
| 118 |
+
for raw_line in text.split("\n"):
|
| 119 |
+
if not raw_line.strip():
|
| 120 |
+
if keep_blank_lines:
|
| 121 |
+
out.append("")
|
| 122 |
+
continue
|
| 123 |
+
words = raw_line.split()
|
| 124 |
+
cur = ""
|
| 125 |
+
for w in words:
|
| 126 |
+
if len(cur) + len(w) + 1 <= max_chars:
|
| 127 |
+
cur = (cur + " " + w).strip()
|
| 128 |
+
else:
|
| 129 |
+
if cur:
|
| 130 |
+
out.append(cur)
|
| 131 |
+
cur = w
|
| 132 |
+
if cur:
|
| 133 |
+
out.append(cur)
|
| 134 |
+
return out
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _draw_kv_row(
|
| 138 |
+
draw: ImageDraw.ImageDraw, x: int, y: int,
|
| 139 |
+
key: str, val: str, key_w: int, max_w: int | None = None,
|
| 140 |
+
) -> int:
|
| 141 |
+
draw.text((x, y), key, font=F_MONO, fill=MUTED)
|
| 142 |
+
value = str(val)
|
| 143 |
+
if max_w is not None:
|
| 144 |
+
value = _trim_middle_to_width(draw, value, F_MONO, max_w)
|
| 145 |
+
draw.text((x + key_w, y), value, font=F_MONO, fill=TEXT)
|
| 146 |
+
return y + 26
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _trim_to_width(
|
| 150 |
+
draw: ImageDraw.ImageDraw,
|
| 151 |
+
text: str,
|
| 152 |
+
font: ImageFont.FreeTypeFont,
|
| 153 |
+
max_w: int,
|
| 154 |
+
) -> str:
|
| 155 |
+
if draw.textbbox((0, 0), text, font=font)[2] <= max_w:
|
| 156 |
+
return text
|
| 157 |
+
ellipsis = "..."
|
| 158 |
+
lo = 0
|
| 159 |
+
hi = len(text)
|
| 160 |
+
while lo < hi:
|
| 161 |
+
mid = (lo + hi + 1) // 2
|
| 162 |
+
candidate = text[:mid].rstrip() + ellipsis
|
| 163 |
+
if draw.textbbox((0, 0), candidate, font=font)[2] <= max_w:
|
| 164 |
+
lo = mid
|
| 165 |
+
else:
|
| 166 |
+
hi = mid - 1
|
| 167 |
+
return text[:lo].rstrip() + ellipsis
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _trim_middle_to_width(
|
| 171 |
+
draw: ImageDraw.ImageDraw,
|
| 172 |
+
text: str,
|
| 173 |
+
font: ImageFont.FreeTypeFont,
|
| 174 |
+
max_w: int,
|
| 175 |
+
) -> str:
|
| 176 |
+
if draw.textbbox((0, 0), text, font=font)[2] <= max_w:
|
| 177 |
+
return text
|
| 178 |
+
ellipsis = "..."
|
| 179 |
+
keep_tail = min(18, max(8, len(text) // 3))
|
| 180 |
+
tail = text[-keep_tail:]
|
| 181 |
+
lo = 0
|
| 182 |
+
hi = max(0, len(text) - keep_tail)
|
| 183 |
+
while lo < hi:
|
| 184 |
+
mid = (lo + hi + 1) // 2
|
| 185 |
+
candidate = text[:mid].rstrip("_- ") + ellipsis + tail
|
| 186 |
+
if draw.textbbox((0, 0), candidate, font=font)[2] <= max_w:
|
| 187 |
+
lo = mid
|
| 188 |
+
else:
|
| 189 |
+
hi = mid - 1
|
| 190 |
+
return text[:lo].rstrip("_- ") + ellipsis + tail
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _draw_text_box(
|
| 194 |
+
draw: ImageDraw.ImageDraw, x: int, y: int, width: int,
|
| 195 |
+
label: str, content: str, accent: tuple[int, int, int],
|
| 196 |
+
max_lines: int = 4, char_per_line: int = 42,
|
| 197 |
+
keep_blank_lines: bool = False,
|
| 198 |
+
) -> int:
|
| 199 |
+
"""Left-accented note block — colored 4-px bar on the left, label
|
| 200 |
+
in the accent colour, italic body text."""
|
| 201 |
+
lines = _wrap_text(content, char_per_line, keep_blank_lines=keep_blank_lines)[:max_lines]
|
| 202 |
+
box_h = 28 + 22 * max(1, len(lines)) + 14
|
| 203 |
+
draw.rounded_rectangle(
|
| 204 |
+
[x, y, x + width, y + box_h],
|
| 205 |
+
radius=SECTION_RADIUS, fill=BG_SECTION, outline=BORDER, width=1,
|
| 206 |
+
)
|
| 207 |
+
draw.text((x + 12, y + 8), label.upper(), font=F_H2, fill=accent)
|
| 208 |
+
line_y = y + 36
|
| 209 |
+
for line in lines:
|
| 210 |
+
draw.text((x + 12, line_y), line, font=F_NOTE, fill=DIM)
|
| 211 |
+
line_y += 22
|
| 212 |
+
return y + box_h + 10
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _draw_header_box(
|
| 216 |
+
draw: ImageDraw.ImageDraw,
|
| 217 |
+
x: int,
|
| 218 |
+
y: int,
|
| 219 |
+
width: int,
|
| 220 |
+
run_line: str,
|
| 221 |
+
module_name: str,
|
| 222 |
+
name_line: str,
|
| 223 |
+
) -> int:
|
| 224 |
+
box_h = 118
|
| 225 |
+
draw.rounded_rectangle(
|
| 226 |
+
[x, y, x + width, y + box_h],
|
| 227 |
+
radius=SECTION_RADIUS, fill=BG_SECTION, outline=BORDER, width=1,
|
| 228 |
+
)
|
| 229 |
+
tx = x + 14
|
| 230 |
+
ty = y + 14
|
| 231 |
+
run_id, _, run_date = run_line.partition(" - ")
|
| 232 |
+
draw.text((tx, ty), run_id, font=F_H2, fill=ACCENT_RUN)
|
| 233 |
+
run_w = draw.textbbox((0, 0), run_id + " ", font=F_H2)[2]
|
| 234 |
+
if run_date:
|
| 235 |
+
draw.text((tx + run_w, ty), run_date, font=F_SUB, fill=MUTED)
|
| 236 |
+
ty += 27
|
| 237 |
+
module_label = "Module:"
|
| 238 |
+
draw.text((tx, ty), module_label, font=F_SUB, fill=ACCENT_BASE)
|
| 239 |
+
label_w = draw.textbbox((0, 0), module_label + " ", font=F_SUB)[2]
|
| 240 |
+
draw.text((tx + label_w, ty), module_name, font=F_SUB, fill=TEXT)
|
| 241 |
+
ty += 31
|
| 242 |
+
draw.text((tx, ty), name_line, font=F_TITLE, fill=TEXT)
|
| 243 |
+
return y + box_h + 10
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _draw_section(
|
| 247 |
+
draw: ImageDraw.ImageDraw, x: int, y: int, width: int,
|
| 248 |
+
accent: tuple[int, int, int], label: str, body_h: int,
|
| 249 |
+
bg: tuple[int, int, int] = BG_SECTION,
|
| 250 |
+
border: tuple[int, int, int] = BORDER,
|
| 251 |
+
) -> tuple[int, int, int, int]:
|
| 252 |
+
"""Subtle panel with an accent-coloured uppercase label at the top.
|
| 253 |
+
Returns (content_x, content_y, content_w, end_y) so the caller
|
| 254 |
+
stacks rows directly underneath."""
|
| 255 |
+
HEADER_H = 36
|
| 256 |
+
section_h = HEADER_H + body_h + 14
|
| 257 |
+
draw.rounded_rectangle(
|
| 258 |
+
[x, y, x + width, y + section_h],
|
| 259 |
+
radius=SECTION_RADIUS, fill=bg, outline=border, width=1,
|
| 260 |
+
)
|
| 261 |
+
draw.text((x + 14, y + 10), label.upper(), font=F_SECTION, fill=accent)
|
| 262 |
+
return x + 14, y + HEADER_H, width - 28, y + section_h + 10
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _section_body_rows(num_rows: int, extra: int = 0) -> int:
|
| 266 |
+
return num_rows * 26 + extra
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _draw_score_chip(
|
| 270 |
+
draw: ImageDraw.ImageDraw,
|
| 271 |
+
x: int,
|
| 272 |
+
y: int,
|
| 273 |
+
width: int,
|
| 274 |
+
label: str,
|
| 275 |
+
value: Any,
|
| 276 |
+
) -> None:
|
| 277 |
+
draw.rounded_rectangle(
|
| 278 |
+
[x, y, x + width, y + 50],
|
| 279 |
+
radius=6, fill=BG_CARD, outline=BORDER, width=1,
|
| 280 |
+
)
|
| 281 |
+
label_w = draw.textbbox((0, 0), label, font=F_TAG)[2]
|
| 282 |
+
draw.text((x + (width - label_w) // 2, y + 7), label, font=F_TAG, fill=ACCENT_OUT)
|
| 283 |
+
score = f"{value}/5" if value is not None else "?/5"
|
| 284 |
+
score_w = draw.textbbox((0, 0), score, font=F_MONO)[2]
|
| 285 |
+
draw.text((x + (width - score_w) // 2, y + 24), score, font=F_MONO, fill=TEXT)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# --------------------------------------------------------------------------
|
| 289 |
+
# Renderer — pure state-in / PNG-out. The ``state`` dict is built by
|
| 290 |
+
# loop_audio._build_state_for_card. All values come from the two source
|
| 291 |
+
# families documented at the top of this module.
|
| 292 |
+
# --------------------------------------------------------------------------
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# Shared helpers live in loop_audio so the renderer and extractor agree
|
| 296 |
+
# byte-for-byte on path-wrapping rules and audio-overlap geometry.
|
| 297 |
+
# Importing here keeps the standalone CLI working (it adds scripts/ to
|
| 298 |
+
# sys.path before invoking render_audio_card).
|
| 299 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 300 |
+
from loop_audio import ( # type: ignore[import-not-found] # noqa: E402
|
| 301 |
+
copy_delivery_card,
|
| 302 |
+
video_segment_has_audio,
|
| 303 |
+
metadata_numeric_widget,
|
| 304 |
+
parse_dev_sync_audio_line,
|
| 305 |
+
repo_metadata_from_build,
|
| 306 |
+
render_notes_md,
|
| 307 |
+
scrub_path_for_metadata,
|
| 308 |
+
wrap_path,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _audio_source_label(audio_src: str) -> str:
|
| 313 |
+
labels = {
|
| 314 |
+
"custom": "custom audio ON",
|
| 315 |
+
"custom (empty)": "custom audio ON (empty)",
|
| 316 |
+
"model-gen": "model audio",
|
| 317 |
+
"off (no VAE)": "audio OFF",
|
| 318 |
+
"(no director)": "no director",
|
| 319 |
+
}
|
| 320 |
+
return labels.get(audio_src, audio_src)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _read_existing_metadata(run_dir: Path) -> dict[str, Any]:
|
| 324 |
+
path = run_dir / "metadata.json"
|
| 325 |
+
if not path.is_file():
|
| 326 |
+
return {}
|
| 327 |
+
try:
|
| 328 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 329 |
+
except (OSError, json.JSONDecodeError):
|
| 330 |
+
return {}
|
| 331 |
+
return data if isinstance(data, dict) else {}
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def _preserved_run_date(run_dir: Path, existing_metadata: dict[str, Any]) -> str:
|
| 335 |
+
existing_date = (existing_metadata.get("run") or {}).get("date", "")
|
| 336 |
+
if isinstance(existing_date, str) and existing_date.strip():
|
| 337 |
+
return existing_date.strip()
|
| 338 |
+
wf = next(run_dir.glob("run*_workflow.json"), None)
|
| 339 |
+
if wf is not None:
|
| 340 |
+
try:
|
| 341 |
+
from datetime import datetime
|
| 342 |
+
return datetime.fromtimestamp(wf.stat().st_mtime).date().isoformat()
|
| 343 |
+
except OSError:
|
| 344 |
+
pass
|
| 345 |
+
from datetime import date
|
| 346 |
+
return date.today().isoformat()
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def render_audio_card(state: dict[str, Any], out_path: Path) -> Path:
|
| 350 |
+
"""Render the audio-lipsync card PNG. ``state`` keys consumed:
|
| 351 |
+
|
| 352 |
+
run_number, run_label, date, workflow_name
|
| 353 |
+
name, relay_overrides_raw, info_body, feedback_lines, scores,
|
| 354 |
+
work_folder, output_folder, output_name
|
| 355 |
+
director_variant, director_flavor, audio_src, epsilon, frame_rate,
|
| 356 |
+
segments, audio_segments, segment_prompt_mode
|
| 357 |
+
"""
|
| 358 |
+
name = state.get("name") or "(unnamed)"
|
| 359 |
+
relay = (state.get("relay_overrides_raw") or "").strip()
|
| 360 |
+
info_body = (state.get("info_body") or "").rstrip()
|
| 361 |
+
feedback_lines = state.get("feedback_lines") or []
|
| 362 |
+
scores = state.get("scores") or {}
|
| 363 |
+
work_folder = state.get("work_folder") or ""
|
| 364 |
+
output_folder = state.get("output_folder") or ""
|
| 365 |
+
output_name = state.get("output_name") or ""
|
| 366 |
+
metadata = state.get("metadata") or {}
|
| 367 |
+
director_variant = state.get("director_variant") or "(missing)"
|
| 368 |
+
director_flavor = state.get("director_flavor") or director_variant
|
| 369 |
+
director_pin_tag = state.get("director_pin_tag") or (
|
| 370 |
+
(metadata.get("director") or {}).get("pin_tag", "")
|
| 371 |
+
)
|
| 372 |
+
audio_src = state.get("audio_src") or "?"
|
| 373 |
+
epsilon = state.get("epsilon")
|
| 374 |
+
fps = state.get("frame_rate")
|
| 375 |
+
segments = state.get("segments") or []
|
| 376 |
+
audio_segs = state.get("audio_segments") or []
|
| 377 |
+
prompt_mode = state.get("segment_prompt_mode") or "none"
|
| 378 |
+
run_meta = metadata.get("run") or {}
|
| 379 |
+
# NOTE: duration_frames / duration_seconds intentionally not read here
|
| 380 |
+
# — the dropped "Duration" row used them; segment time ranges convey
|
| 381 |
+
# the same info now. They stay in the state dict for notes.md.
|
| 382 |
+
|
| 383 |
+
canvas_h = 2400
|
| 384 |
+
img = Image.new("RGB", (W, canvas_h), BG_OUTER)
|
| 385 |
+
draw = ImageDraw.Draw(img)
|
| 386 |
+
inset = 18
|
| 387 |
+
draw.rounded_rectangle(
|
| 388 |
+
[inset, inset, W - inset, canvas_h - inset],
|
| 389 |
+
radius=RADIUS, fill=BG_CARD, outline=BORDER, width=1,
|
| 390 |
+
)
|
| 391 |
+
x = inset + PAD_X
|
| 392 |
+
y = inset + PAD_TOP
|
| 393 |
+
inner_w = W - 2 * inset - 2 * PAD_X
|
| 394 |
+
# Wider key column so multi-word labels (relay_overrides, Working
|
| 395 |
+
# folder) don't collide with their values.
|
| 396 |
+
key_w = 178
|
| 397 |
+
|
| 398 |
+
# ----- HEADER -----
|
| 399 |
+
module_name = state.get("module_name") or "audio-lipsync"
|
| 400 |
+
run_line = f"Run {state['run_number']:03d} - {state['date']}"
|
| 401 |
+
name_line = _trim_to_width(draw, str(name), F_TITLE, inner_w)
|
| 402 |
+
y = _draw_header_box(draw, x, y, inner_w, run_line, module_name, name_line)
|
| 403 |
+
|
| 404 |
+
# ----- BASE / RUN -----
|
| 405 |
+
path_lines = wrap_path(work_folder, max_chars=40) if work_folder else []
|
| 406 |
+
output_lines = wrap_path(output_folder, max_chars=40) if output_folder else []
|
| 407 |
+
body_h = 0
|
| 408 |
+
if output_name:
|
| 409 |
+
body_h += 52
|
| 410 |
+
source_workflow = state.get("source_workflow_name") or run_meta.get("workflow")
|
| 411 |
+
if source_workflow:
|
| 412 |
+
source_workflow = str(Path(str(source_workflow)).with_suffix(""))
|
| 413 |
+
source_workflow_lines = (
|
| 414 |
+
wrap_path(str(source_workflow), max_chars=40) if source_workflow else []
|
| 415 |
+
)
|
| 416 |
+
if source_workflow_lines:
|
| 417 |
+
body_h += 26 + 26 * len(source_workflow_lines) + 4
|
| 418 |
+
if path_lines:
|
| 419 |
+
body_h += 26 + 26 * len(path_lines) + 4
|
| 420 |
+
if output_lines:
|
| 421 |
+
body_h += 26 + 26 * len(output_lines) + 4
|
| 422 |
+
cx, cy, cw, end_y = _draw_section(
|
| 423 |
+
draw, x, y, inner_w, ACCENT_BASE, "Base / run", body_h,
|
| 424 |
+
)
|
| 425 |
+
if source_workflow_lines:
|
| 426 |
+
draw.text((cx, cy), "Copied from", font=F_MONO, fill=MUTED)
|
| 427 |
+
cy += 26
|
| 428 |
+
for line in source_workflow_lines:
|
| 429 |
+
draw.text((cx + 12, cy), line, font=F_MONO, fill=TEXT)
|
| 430 |
+
cy += 26
|
| 431 |
+
cy += 4
|
| 432 |
+
if output_name:
|
| 433 |
+
draw.text((cx, cy), "Output name", font=F_MONO, fill=MUTED)
|
| 434 |
+
cy += 26
|
| 435 |
+
draw.text(
|
| 436 |
+
(cx + 12, cy),
|
| 437 |
+
_trim_middle_to_width(draw, output_name, F_MONO, cw - 12),
|
| 438 |
+
font=F_MONO,
|
| 439 |
+
fill=TEXT,
|
| 440 |
+
)
|
| 441 |
+
cy += 26
|
| 442 |
+
if path_lines:
|
| 443 |
+
draw.text((cx, cy), "Working folder", font=F_MONO, fill=MUTED)
|
| 444 |
+
cy += 26
|
| 445 |
+
for line in path_lines:
|
| 446 |
+
draw.text((cx + 12, cy), line, font=F_MONO, fill=TEXT)
|
| 447 |
+
cy += 26
|
| 448 |
+
if output_lines:
|
| 449 |
+
draw.text((cx, cy), "Output folder", font=F_MONO, fill=MUTED)
|
| 450 |
+
cy += 26
|
| 451 |
+
for line in output_lines:
|
| 452 |
+
draw.text((cx + 12, cy), line, font=F_MONO, fill=TEXT)
|
| 453 |
+
cy += 26
|
| 454 |
+
y = end_y
|
| 455 |
+
|
| 456 |
+
# ----- DIRECTOR READINGS -----
|
| 457 |
+
seg_rows = min(len(segments), 6)
|
| 458 |
+
indent_seg = 18
|
| 459 |
+
director_rows = 5 if epsilon is not None else 4
|
| 460 |
+
body_h = 26 * director_rows + 26 * 3 + 26 * seg_rows + 8 + 26 * 3 + 4
|
| 461 |
+
cx, cy, cw, end_y = _draw_section(
|
| 462 |
+
draw, x, y, inner_w, ACCENT_BASE, "Director readings", body_h,
|
| 463 |
+
)
|
| 464 |
+
cy = _draw_kv_row(
|
| 465 |
+
draw, cx, cy, "Director", str(director_flavor),
|
| 466 |
+
key_w, cw - key_w,
|
| 467 |
+
)
|
| 468 |
+
cy = _draw_kv_row(
|
| 469 |
+
draw, cx, cy, "Pin tag",
|
| 470 |
+
_trim_middle_to_width(draw, str(director_pin_tag), F_MONO, cw - key_w),
|
| 471 |
+
key_w, cw - key_w,
|
| 472 |
+
)
|
| 473 |
+
if epsilon is not None:
|
| 474 |
+
cy = _draw_kv_row(draw, cx, cy, "epsilon", str(epsilon), key_w)
|
| 475 |
+
cy = _draw_kv_row(draw, cx, cy, "Audio", _audio_source_label(audio_src), key_w)
|
| 476 |
+
cy = _draw_kv_row(draw, cx, cy, "Frame rate", str(fps or "?"), key_w)
|
| 477 |
+
cy = _draw_kv_row(draw, cx, cy, "Image segments", f"({len(segments)})", key_w)
|
| 478 |
+
cy = _draw_kv_row(draw, cx, cy, "Audio segments", f"({len(audio_segs)})", key_w)
|
| 479 |
+
cy = _draw_kv_row(draw, cx, cy, "Prompt mode", prompt_mode, key_w)
|
| 480 |
+
|
| 481 |
+
visible = segments[:seg_rows]
|
| 482 |
+
all_have_prompt = bool(visible) and all((s.get("prompt") or "") for s in visible)
|
| 483 |
+
all_have_audio = bool(visible) and all(
|
| 484 |
+
video_segment_has_audio(s, audio_segs) for s in visible
|
| 485 |
+
)
|
| 486 |
+
all_have_keyframe = bool(visible) and all(s.get("imageFile") for s in visible)
|
| 487 |
+
|
| 488 |
+
for i, seg in enumerate(visible):
|
| 489 |
+
start = seg.get("start", 0)
|
| 490 |
+
length = seg.get("length", 0)
|
| 491 |
+
prompt = seg.get("prompt") or ""
|
| 492 |
+
has_p = bool(prompt)
|
| 493 |
+
has_a = video_segment_has_audio(seg, audio_segs)
|
| 494 |
+
has_k = bool(seg.get("imageFile"))
|
| 495 |
+
header_col = ACCENT_OUT if (has_p and has_a and has_k) else ACCENT_BASE
|
| 496 |
+
if fps:
|
| 497 |
+
start_s = start / fps
|
| 498 |
+
end_s = (start + length) / fps
|
| 499 |
+
header = f"{i+1}) {start_s:.0f} to {end_s:.0f} seconds"
|
| 500 |
+
else:
|
| 501 |
+
header = f"{i+1}) frames {start}-{start + length} (no fps)"
|
| 502 |
+
draw.text((cx + indent_seg, cy), header, font=F_MONO, fill=header_col)
|
| 503 |
+
cy += 26
|
| 504 |
+
cy += 8
|
| 505 |
+
|
| 506 |
+
for label, present in (
|
| 507 |
+
("Prompt", all_have_prompt),
|
| 508 |
+
("Audio", all_have_audio),
|
| 509 |
+
("Keyframe", all_have_keyframe),
|
| 510 |
+
):
|
| 511 |
+
mark = "[x]" if present else "[ ]"
|
| 512 |
+
mark_col = ACCENT_OUT if present else MUTED
|
| 513 |
+
draw.text((cx, cy), label, font=F_MONO, fill=MUTED)
|
| 514 |
+
draw.text((cx + key_w, cy), mark, font=F_MONO, fill=mark_col)
|
| 515 |
+
cy += 26
|
| 516 |
+
y = end_y
|
| 517 |
+
|
| 518 |
+
# ----- AMBER NOTES -----
|
| 519 |
+
relay_disp = relay if relay else "(empty -> defaults)"
|
| 520 |
+
if relay and director_variant == "LTXDirector":
|
| 521 |
+
relay_disp = f"{relay}\n\nINERT: active Director is upstream LTXDirector."
|
| 522 |
+
y = _draw_text_box(
|
| 523 |
+
draw, x, y, inner_w, "Knob state",
|
| 524 |
+
relay_disp, ACCENT_RUN, max_lines=6, char_per_line=42,
|
| 525 |
+
keep_blank_lines=True,
|
| 526 |
+
)
|
| 527 |
+
note_text = info_body if info_body.strip() else "(no note this render)"
|
| 528 |
+
y = _draw_text_box(
|
| 529 |
+
draw, x, y, inner_w, "Base notes",
|
| 530 |
+
note_text, ACCENT_RUN, max_lines=18, char_per_line=42,
|
| 531 |
+
keep_blank_lines=True,
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
# ----- POST-RENDER (green) — FEEDBACK + OUTCOME -----
|
| 535 |
+
feedback_text = "\n".join(feedback_lines) if feedback_lines else "(no feedback)"
|
| 536 |
+
fb_lines = _wrap_text(feedback_text, 42)[:6]
|
| 537 |
+
body_h = (20 + max(24, 22 * len(fb_lines)) + 14 + 20 + 50)
|
| 538 |
+
cx, cy, cw, end_y = _draw_section(
|
| 539 |
+
draw, x, y, inner_w, ACCENT_OUT, "Post-render", body_h,
|
| 540 |
+
)
|
| 541 |
+
draw.text((cx, cy), "FEEDBACK", font=F_TAG, fill=ACCENT_OUT)
|
| 542 |
+
cy += 20
|
| 543 |
+
for line in fb_lines:
|
| 544 |
+
draw.text((cx, cy), line, font=F_NOTE, fill=DIM)
|
| 545 |
+
cy += 22
|
| 546 |
+
cy += 6
|
| 547 |
+
draw.text((cx, cy), "OUTCOME", font=F_TAG, fill=ACCENT_OUT)
|
| 548 |
+
cy += 20
|
| 549 |
+
|
| 550 |
+
gap = 8
|
| 551 |
+
chip_w = (cw - 2 * gap) // 3
|
| 552 |
+
for idx, (label, key) in enumerate((
|
| 553 |
+
("Motion", "motion"),
|
| 554 |
+
("Sync", "sync"),
|
| 555 |
+
("Sharp", "sharp"),
|
| 556 |
+
)):
|
| 557 |
+
_draw_score_chip(
|
| 558 |
+
draw, cx + idx * (chip_w + gap), cy,
|
| 559 |
+
chip_w, label, scores.get(key),
|
| 560 |
+
)
|
| 561 |
+
y = end_y
|
| 562 |
+
|
| 563 |
+
# ----- crop to actual content + save -----
|
| 564 |
+
final_h = y + PAD_BOTTOM + inset
|
| 565 |
+
img = img.crop((0, 0, W, final_h))
|
| 566 |
+
out_draw = ImageDraw.Draw(img)
|
| 567 |
+
out_draw.rounded_rectangle(
|
| 568 |
+
[inset, inset, W - inset, final_h - inset],
|
| 569 |
+
radius=RADIUS, outline=BORDER, width=1,
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 573 |
+
pnginfo = None
|
| 574 |
+
metadata = state.get("metadata")
|
| 575 |
+
if metadata:
|
| 576 |
+
pnginfo = PngInfo()
|
| 577 |
+
pnginfo.add_text(
|
| 578 |
+
"koolook_audio_loop",
|
| 579 |
+
json.dumps(metadata, ensure_ascii=False, sort_keys=True),
|
| 580 |
+
)
|
| 581 |
+
img.save(str(out_path), pnginfo=pnginfo)
|
| 582 |
+
return out_path
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
# --------------------------------------------------------------------------
|
| 586 |
+
# Standalone CLI — re-render a card from an existing run folder.
|
| 587 |
+
# --------------------------------------------------------------------------
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
def _rebuild_state_from_run_dir(run_dir: Path) -> dict[str, Any]:
|
| 591 |
+
"""Reconstruct the loop_audio state dict from an on-disk run
|
| 592 |
+
folder. Reads the run's frozen runNNN_workflow.json and walks it through
|
| 593 |
+
the same extraction helpers loop_audio.py uses live."""
|
| 594 |
+
from loop_audio import ( # type: ignore[import-not-found]
|
| 595 |
+
active_relay_overrides,
|
| 596 |
+
derive_audio_state, detect_upstream_whatdreamscost_version,
|
| 597 |
+
director_flavor, director_pin_tag, director_type, director_widget,
|
| 598 |
+
expected_output_tracking, extract_director, extract_multilines,
|
| 599 |
+
extract_setup_variables, find_dotenv,
|
| 600 |
+
first_multiline, load_config, load_dotenv,
|
| 601 |
+
parse_feedback, parse_timeline, pick_existing_path,
|
| 602 |
+
segment_prompt_mode,
|
| 603 |
+
DEFAULT_CONFIG_PATH,
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
+
name_parts = run_dir.name.split("_", 1)
|
| 607 |
+
m = re.match(r"run-(\d+)$", name_parts[0])
|
| 608 |
+
nnn = int(m.group(1)) if m else 0
|
| 609 |
+
label = name_parts[1] if len(name_parts) > 1 else ""
|
| 610 |
+
tagged_workflow = run_dir / f"run{nnn:03d}_workflow.json"
|
| 611 |
+
wf_path = tagged_workflow if tagged_workflow.is_file() else run_dir / "workflow.json"
|
| 612 |
+
if not wf_path.is_file():
|
| 613 |
+
print(f"missing run workflow JSON in {run_dir}", file=sys.stderr)
|
| 614 |
+
sys.exit(2)
|
| 615 |
+
with wf_path.open(encoding="utf-8-sig") as f:
|
| 616 |
+
wf = json.load(f)
|
| 617 |
+
nodes = wf.get("nodes") or []
|
| 618 |
+
|
| 619 |
+
# Load env so any env-var lookups inside the helpers (none right
|
| 620 |
+
# now, but a defensive match for make_card.py's pattern) work.
|
| 621 |
+
env_file = find_dotenv()
|
| 622 |
+
if env_file is not None:
|
| 623 |
+
load_dotenv(env_file)
|
| 624 |
+
|
| 625 |
+
cfg = load_config(DEFAULT_CONFIG_PATH)
|
| 626 |
+
multilines = extract_multilines(nodes, cfg["tracked_multilines"])
|
| 627 |
+
setup_variables = extract_setup_variables(
|
| 628 |
+
nodes, cfg.get("tracked_setup_variables", {})
|
| 629 |
+
)
|
| 630 |
+
output_tracking = expected_output_tracking(nodes, multilines, setup_variables)
|
| 631 |
+
director_node = extract_director(nodes, wf.get("links") or [])
|
| 632 |
+
timeline = parse_timeline(director_node)
|
| 633 |
+
audio_src = derive_audio_state(director_node, timeline)
|
| 634 |
+
relay_overrides_raw = active_relay_overrides(
|
| 635 |
+
nodes, wf.get("links") or [], director_node
|
| 636 |
+
)
|
| 637 |
+
active_multilines = dict(multilines)
|
| 638 |
+
active_multilines["relay_overrides"] = [relay_overrides_raw]
|
| 639 |
+
upstream_whatdreamscost_version = detect_upstream_whatdreamscost_version()
|
| 640 |
+
pin_tag = director_pin_tag(director_node, upstream_whatdreamscost_version)
|
| 641 |
+
existing_metadata = _read_existing_metadata(run_dir)
|
| 642 |
+
run_date = _preserved_run_date(run_dir, existing_metadata)
|
| 643 |
+
scores, feedback_lines = parse_feedback(
|
| 644 |
+
first_multiline(multilines, "overlay - feedback")
|
| 645 |
+
)
|
| 646 |
+
patch_meta: dict[str, str] = {}
|
| 647 |
+
patch_path = run_dir / "patch_state.txt"
|
| 648 |
+
if patch_path.is_file():
|
| 649 |
+
for line in patch_path.read_text(encoding="utf-8").splitlines():
|
| 650 |
+
if ":" not in line:
|
| 651 |
+
continue
|
| 652 |
+
key, value = line.split(":", 1)
|
| 653 |
+
patch_meta[key.strip()] = value.strip()
|
| 654 |
+
last_dev_sync_audio, last_dev_sync_at = parse_dev_sync_audio_line(
|
| 655 |
+
patch_meta.get("Last dev-sync-audio", "")
|
| 656 |
+
)
|
| 657 |
+
repo_build = {
|
| 658 |
+
"commit": last_dev_sync_audio,
|
| 659 |
+
"synced_at": last_dev_sync_at,
|
| 660 |
+
"scope": patch_meta.get("Sync scope tag", ""),
|
| 661 |
+
"worktree": patch_meta.get("Sync worktree", ""),
|
| 662 |
+
}
|
| 663 |
+
source_workflow_name = ""
|
| 664 |
+
notes_path = run_dir / "notes.md"
|
| 665 |
+
if notes_path.is_file():
|
| 666 |
+
for line in notes_path.read_text(encoding="utf-8").splitlines():
|
| 667 |
+
match = re.match(r"- Setup name:\s+`([^`]+)`", line)
|
| 668 |
+
if match:
|
| 669 |
+
source_workflow_name = f"{match.group(1)}.json"
|
| 670 |
+
break
|
| 671 |
+
|
| 672 |
+
workflow_name = wf_path.name
|
| 673 |
+
state = {
|
| 674 |
+
"run_number": nnn,
|
| 675 |
+
"run_label": label,
|
| 676 |
+
"date": run_date,
|
| 677 |
+
"workflow_name": workflow_name,
|
| 678 |
+
"source_workflow_name": source_workflow_name,
|
| 679 |
+
"name": first_multiline(multilines, "name").strip() or "(unnamed)",
|
| 680 |
+
"relay_overrides_raw": relay_overrides_raw,
|
| 681 |
+
"info_body": first_multiline(multilines, "overlay - info").rstrip(),
|
| 682 |
+
"feedback_lines": feedback_lines,
|
| 683 |
+
"scores": scores,
|
| 684 |
+
"work_folder": pick_existing_path(
|
| 685 |
+
multilines.get("working_folder") or []
|
| 686 |
+
),
|
| 687 |
+
"output_folder": output_tracking.get("folder", ""),
|
| 688 |
+
"output_name": output_tracking.get("name", ""),
|
| 689 |
+
"output_tracking": output_tracking,
|
| 690 |
+
"metadata": {
|
| 691 |
+
"schema": "koolook.audio_loop.card_metadata.v1",
|
| 692 |
+
"source": "rebuilt from frozen run folder",
|
| 693 |
+
"run": {
|
| 694 |
+
"capture_number": f"{nnn:03d}",
|
| 695 |
+
"label": label,
|
| 696 |
+
"date": run_date,
|
| 697 |
+
"workflow": workflow_name,
|
| 698 |
+
"source_workflow": source_workflow_name,
|
| 699 |
+
"archived_workflow": workflow_name,
|
| 700 |
+
"setup_name": (
|
| 701 |
+
Path(source_workflow_name).stem
|
| 702 |
+
if source_workflow_name else wf_path.stem
|
| 703 |
+
),
|
| 704 |
+
},
|
| 705 |
+
"setup": {
|
| 706 |
+
"base_name": first_multiline(multilines, "name").strip(),
|
| 707 |
+
"working_folder": scrub_path_for_metadata(output_tracking.get("folder", "")),
|
| 708 |
+
"input_path_exr": scrub_path_for_metadata(
|
| 709 |
+
first_multiline(setup_variables, "input_path_exr")
|
| 710 |
+
),
|
| 711 |
+
"global_version": first_multiline(setup_variables, "version"),
|
| 712 |
+
"global_run_offset": first_multiline(setup_variables, "run_offset"),
|
| 713 |
+
"relay_overrides": relay_overrides_raw.strip(),
|
| 714 |
+
},
|
| 715 |
+
"output": {
|
| 716 |
+
**output_tracking,
|
| 717 |
+
"folder": scrub_path_for_metadata(output_tracking.get("folder", "")),
|
| 718 |
+
},
|
| 719 |
+
"director": {
|
| 720 |
+
"type": director_type(director_node),
|
| 721 |
+
"flavor": director_flavor(director_node),
|
| 722 |
+
"pin_tag": pin_tag,
|
| 723 |
+
"upstream_whatdreamscost_version": upstream_whatdreamscost_version,
|
| 724 |
+
"audio_src": audio_src,
|
| 725 |
+
"epsilon": director_widget(director_node, "epsilon"),
|
| 726 |
+
"duration_frames": director_widget(director_node, "duration_frames"),
|
| 727 |
+
"duration_seconds": director_widget(director_node, "duration_seconds"),
|
| 728 |
+
"frame_rate": metadata_numeric_widget(
|
| 729 |
+
director_widget(director_node, "frame_rate")
|
| 730 |
+
),
|
| 731 |
+
"segment_prompt_mode": segment_prompt_mode(
|
| 732 |
+
timeline.get("segments") or []
|
| 733 |
+
),
|
| 734 |
+
"video_segments": len(timeline.get("segments") or []),
|
| 735 |
+
"audio_segments": len(timeline.get("audioSegments") or []),
|
| 736 |
+
},
|
| 737 |
+
"repo": {
|
| 738 |
+
**repo_metadata_from_build(
|
| 739 |
+
repo_build,
|
| 740 |
+
patch_meta.get("Fork dir status", ""),
|
| 741 |
+
),
|
| 742 |
+
"main_sha": patch_meta.get("MAIN SHA", ""),
|
| 743 |
+
},
|
| 744 |
+
},
|
| 745 |
+
"director_node": director_node,
|
| 746 |
+
"director_variant": director_type(director_node),
|
| 747 |
+
"director_flavor": director_flavor(director_node),
|
| 748 |
+
"director_pin_tag": pin_tag,
|
| 749 |
+
"audio_src": audio_src,
|
| 750 |
+
"epsilon": director_widget(director_node, "epsilon"),
|
| 751 |
+
"frame_rate": metadata_numeric_widget(
|
| 752 |
+
director_widget(director_node, "frame_rate")
|
| 753 |
+
),
|
| 754 |
+
"segments": timeline.get("segments") or [],
|
| 755 |
+
"audio_segments": timeline.get("audioSegments") or [],
|
| 756 |
+
"segment_prompt_mode": segment_prompt_mode(
|
| 757 |
+
timeline.get("segments") or []
|
| 758 |
+
),
|
| 759 |
+
}
|
| 760 |
+
state["notes_md"] = render_notes_md(
|
| 761 |
+
nnn,
|
| 762 |
+
Path(source_workflow_name) if source_workflow_name else wf_path,
|
| 763 |
+
active_multilines,
|
| 764 |
+
setup_variables,
|
| 765 |
+
director_node,
|
| 766 |
+
timeline,
|
| 767 |
+
audio_src,
|
| 768 |
+
state["info_body"],
|
| 769 |
+
feedback_lines,
|
| 770 |
+
scores,
|
| 771 |
+
output_tracking,
|
| 772 |
+
commit_sha=patch_meta.get("MAIN SHA", ""),
|
| 773 |
+
)
|
| 774 |
+
return state
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
def main() -> int:
|
| 778 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 779 |
+
p.add_argument("run_dir", type=Path)
|
| 780 |
+
p.add_argument("--dry-run", action="store_true")
|
| 781 |
+
p.add_argument(
|
| 782 |
+
"--no-delivery",
|
| 783 |
+
action="store_true",
|
| 784 |
+
help="Skip copying the rendered card to the external cards folder.",
|
| 785 |
+
)
|
| 786 |
+
args = p.parse_args()
|
| 787 |
+
|
| 788 |
+
run_dir = args.run_dir.expanduser().resolve()
|
| 789 |
+
if not run_dir.is_dir():
|
| 790 |
+
print(f"run folder not found: {run_dir}", file=sys.stderr)
|
| 791 |
+
return 2
|
| 792 |
+
|
| 793 |
+
# sys.path already pointed at scripts/ by the module-level import of
|
| 794 |
+
# wrap_path/video_segment_has_audio above; no need to repeat it here.
|
| 795 |
+
state = _rebuild_state_from_run_dir(run_dir)
|
| 796 |
+
|
| 797 |
+
out = run_dir / "card.png"
|
| 798 |
+
if args.dry_run:
|
| 799 |
+
print(f"(dry-run) would write: {out}")
|
| 800 |
+
return 0
|
| 801 |
+
render_audio_card(state, out)
|
| 802 |
+
metadata_path = run_dir / "metadata.json"
|
| 803 |
+
notes_path = run_dir / "notes.md"
|
| 804 |
+
metadata_path.write_text(
|
| 805 |
+
json.dumps(
|
| 806 |
+
state.get("metadata") or {},
|
| 807 |
+
ensure_ascii=False,
|
| 808 |
+
indent=2,
|
| 809 |
+
sort_keys=True,
|
| 810 |
+
) + "\n",
|
| 811 |
+
encoding="utf-8",
|
| 812 |
+
)
|
| 813 |
+
notes_path.write_text(state.get("notes_md") or "", encoding="utf-8")
|
| 814 |
+
if args.no_delivery:
|
| 815 |
+
delivery_status = "(skipped)"
|
| 816 |
+
else:
|
| 817 |
+
delivery_status = copy_delivery_card(
|
| 818 |
+
out,
|
| 819 |
+
state.get("output_tracking") or {},
|
| 820 |
+
state.get("run_number"),
|
| 821 |
+
overwrite=True,
|
| 822 |
+
)
|
| 823 |
+
print(f"wrote: {out}")
|
| 824 |
+
print(f"wrote: {metadata_path}")
|
| 825 |
+
print(f"wrote: {notes_path}")
|
| 826 |
+
print(f"delivered: {delivery_status}")
|
| 827 |
+
return 0
|
| 828 |
+
|
| 829 |
+
|
| 830 |
+
if __name__ == "__main__":
|
| 831 |
+
sys.exit(main())
|
ComfyUI/custom_nodes/koolook/scripts/make_silent_wav.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Write a silent WAV file for LTX-2.3 (and other audio-required) graphs.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python scripts/make_silent_wav.py [outfile] [--seconds N]
|
| 5 |
+
[--rate HZ] [--channels N]
|
| 6 |
+
|
| 7 |
+
Produces a stereo, 44.1 kHz, 16-bit silent track by default — the same
|
| 8 |
+
waveform format ComfyUI's AUDIO type expects, i.e. what the LTX Director
|
| 9 |
+
generates internally (`torch.zeros((1, 2, samples))` @ 44100). Drop the
|
| 10 |
+
output into ComfyUI's `input/` folder and load it with the stock
|
| 11 |
+
`LoadAudio` node when you need to satisfy a required AUDIO input but have
|
| 12 |
+
no real audio to feed.
|
| 13 |
+
|
| 14 |
+
Make it at least as long as your clip; LTX will trim/loop to fit.
|
| 15 |
+
|
| 16 |
+
Examples:
|
| 17 |
+
# 10s silent.wav in the current folder (defaults)
|
| 18 |
+
python scripts/make_silent_wav.py
|
| 19 |
+
|
| 20 |
+
# 30s, written straight into a ComfyUI input dir
|
| 21 |
+
python scripts/make_silent_wav.py "C:/ComfyUI/input/silent.wav" --seconds 30
|
| 22 |
+
|
| 23 |
+
Exit codes:
|
| 24 |
+
0 File written.
|
| 25 |
+
2 Bad arguments.
|
| 26 |
+
|
| 27 |
+
stdlib only, no third-party dependencies.
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
import argparse
|
| 31 |
+
import sys
|
| 32 |
+
import wave
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def make_silent_wav(
|
| 37 |
+
path: Path, seconds: float, rate: int, channels: int, sampwidth: int = 2
|
| 38 |
+
) -> int:
|
| 39 |
+
"""Write `seconds` of silence to `path`. Returns the byte count written."""
|
| 40 |
+
frames = int(round(seconds * rate))
|
| 41 |
+
data = b"\x00" * (frames * channels * sampwidth)
|
| 42 |
+
with wave.open(str(path), "wb") as w:
|
| 43 |
+
w.setnchannels(channels)
|
| 44 |
+
w.setsampwidth(sampwidth)
|
| 45 |
+
w.setframerate(rate)
|
| 46 |
+
w.writeframes(data)
|
| 47 |
+
return len(data)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def main(argv: list[str] | None = None) -> int:
|
| 51 |
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
| 52 |
+
parser.add_argument(
|
| 53 |
+
"outfile",
|
| 54 |
+
nargs="?",
|
| 55 |
+
default="silent.wav",
|
| 56 |
+
help="Output path (default: silent.wav in the current folder).",
|
| 57 |
+
)
|
| 58 |
+
parser.add_argument(
|
| 59 |
+
"--seconds",
|
| 60 |
+
type=float,
|
| 61 |
+
default=10.0,
|
| 62 |
+
help="Duration in seconds; make it >= your clip length (default: 10).",
|
| 63 |
+
)
|
| 64 |
+
parser.add_argument(
|
| 65 |
+
"--rate",
|
| 66 |
+
type=int,
|
| 67 |
+
default=44100,
|
| 68 |
+
help="Sample rate in Hz (default: 44100 — what LTX expects).",
|
| 69 |
+
)
|
| 70 |
+
parser.add_argument(
|
| 71 |
+
"--channels",
|
| 72 |
+
type=int,
|
| 73 |
+
default=2,
|
| 74 |
+
help="Channel count: 2=stereo, 1=mono (default: 2).",
|
| 75 |
+
)
|
| 76 |
+
args = parser.parse_args(argv)
|
| 77 |
+
|
| 78 |
+
if args.seconds <= 0:
|
| 79 |
+
parser.error("--seconds must be positive")
|
| 80 |
+
if args.rate <= 0:
|
| 81 |
+
parser.error("--rate must be positive")
|
| 82 |
+
if args.channels not in (1, 2):
|
| 83 |
+
parser.error("--channels must be 1 or 2")
|
| 84 |
+
|
| 85 |
+
out = Path(args.outfile)
|
| 86 |
+
if out.parent and not out.parent.exists():
|
| 87 |
+
parser.error(f"output directory does not exist: {out.parent}")
|
| 88 |
+
|
| 89 |
+
written = make_silent_wav(out, args.seconds, args.rate, args.channels)
|
| 90 |
+
print(
|
| 91 |
+
f"Wrote {out} — {args.seconds:g}s, {args.rate} Hz, "
|
| 92 |
+
f"{args.channels}ch, 16-bit ({written:,} bytes of silence)."
|
| 93 |
+
)
|
| 94 |
+
return 0
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
sys.exit(main())
|
ComfyUI/custom_nodes/koolook/scripts/run_loop_demo_api_test.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run the loop demo against a local ComfyUI server.
|
| 2 |
+
|
| 3 |
+
This is a repo-local validation harness for
|
| 4 |
+
`docs/automations/loop-demo/LOOP_demo_pipeline.json`. It submits an API-format version
|
| 5 |
+
of the queue-controller demo graph to a running ComfyUI server and verifies
|
| 6 |
+
that four EXR files are written across four prompt executions.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import json
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import time
|
| 15 |
+
import urllib.error
|
| 16 |
+
import urllib.parse
|
| 17 |
+
import urllib.request
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _validate_http_url(url: str) -> None:
|
| 21 |
+
parsed = urllib.parse.urlsplit(str(url or ""))
|
| 22 |
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 23 |
+
raise RuntimeError(f"Only http(s) ComfyUI server URLs are allowed: {url!r}")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _post_json(url: str, payload: dict) -> dict:
|
| 27 |
+
_validate_http_url(url)
|
| 28 |
+
request = urllib.request.Request(
|
| 29 |
+
url,
|
| 30 |
+
data=json.dumps(payload).encode("utf-8"),
|
| 31 |
+
headers={"Content-Type": "application/json"},
|
| 32 |
+
)
|
| 33 |
+
with urllib.request.urlopen(request, timeout=30) as response: # nosec B310
|
| 34 |
+
return json.load(response)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _get_json(url: str, timeout: float = 15) -> dict:
|
| 38 |
+
_validate_http_url(url)
|
| 39 |
+
with urllib.request.urlopen(url, timeout=timeout) as response: # nosec B310
|
| 40 |
+
return json.load(response)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _assert_inside(parent: Path, child: Path) -> None:
|
| 44 |
+
parent = parent.resolve()
|
| 45 |
+
child = child.resolve()
|
| 46 |
+
if parent != child and parent not in child.parents:
|
| 47 |
+
raise RuntimeError(f"Refusing to touch path outside repo: {child}")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _dump_server_debug(server: str) -> None:
|
| 51 |
+
for endpoint in ("queue", "history?max_items=20"):
|
| 52 |
+
try:
|
| 53 |
+
payload = _get_json(f"{server}/{endpoint}", timeout=10)
|
| 54 |
+
except Exception as exc:
|
| 55 |
+
print(f"Could not read /{endpoint}: {exc}")
|
| 56 |
+
continue
|
| 57 |
+
print(f"/{endpoint}:")
|
| 58 |
+
print(json.dumps(payload, indent=2)[:8000])
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def build_prompt(output_base: Path) -> dict:
|
| 62 |
+
"""Build the API prompt equivalent of the current loop demo.
|
| 63 |
+
|
| 64 |
+
The canvas workflow contains a ComfyUI subgraph node. The API prompt below
|
| 65 |
+
flattens that demo subgraph to its current single inner node,
|
| 66 |
+
`EasyResize_Koolook`, so the prompt can be submitted directly to `/prompt`.
|
| 67 |
+
"""
|
| 68 |
+
return {
|
| 69 |
+
"12": {"class_type": "easy int", "inputs": {"value": 4}},
|
| 70 |
+
"10": {
|
| 71 |
+
"class_type": "Text Multiline",
|
| 72 |
+
"inputs": {"text": str(output_base) + "\\"},
|
| 73 |
+
},
|
| 74 |
+
"9": {
|
| 75 |
+
"class_type": "Easy_Pattern",
|
| 76 |
+
"inputs": {
|
| 77 |
+
"batch_size": ["12", 0],
|
| 78 |
+
"width": 512,
|
| 79 |
+
"height": 512,
|
| 80 |
+
"bg_color_mode": "Custom",
|
| 81 |
+
"show_text": True,
|
| 82 |
+
"text_color_mode": "White",
|
| 83 |
+
"start_from": 1,
|
| 84 |
+
"step": 1,
|
| 85 |
+
"font_size": 256,
|
| 86 |
+
"position": "center",
|
| 87 |
+
"zero_pad": 0,
|
| 88 |
+
"bg_color": "#C71585",
|
| 89 |
+
"text_color": "#FFFFFF",
|
| 90 |
+
"prefix": "",
|
| 91 |
+
"suffix": "",
|
| 92 |
+
},
|
| 93 |
+
},
|
| 94 |
+
"8": {
|
| 95 |
+
"class_type": "EasyAIPipeline",
|
| 96 |
+
"inputs": {
|
| 97 |
+
"shot_duration": 81,
|
| 98 |
+
"seed_value": 453453453,
|
| 99 |
+
"instruction": "Place your base folder path in the FIELD below",
|
| 100 |
+
"base_directory_path": ["10", 0],
|
| 101 |
+
"extension": ".%04d.exr",
|
| 102 |
+
"shot_name": "faceLoop",
|
| 103 |
+
"ai_method": "",
|
| 104 |
+
"version": "1",
|
| 105 |
+
"disable_versioning": False,
|
| 106 |
+
"enable_overwrite": False,
|
| 107 |
+
"no_subfolders": True,
|
| 108 |
+
},
|
| 109 |
+
},
|
| 110 |
+
"22": {"class_type": "easy int", "inputs": {"value": 0}},
|
| 111 |
+
"3": {
|
| 112 |
+
"class_type": "ImageFromBatch",
|
| 113 |
+
"inputs": {"image": ["9", 0], "batch_index": ["22", 0], "length": 1},
|
| 114 |
+
},
|
| 115 |
+
"19": {
|
| 116 |
+
"class_type": "EasyResize_Koolook",
|
| 117 |
+
"inputs": {
|
| 118 |
+
"image": ["3", 0],
|
| 119 |
+
"base_on": "Width",
|
| 120 |
+
"base_size": 512,
|
| 121 |
+
"aspect_ratio": "16:9",
|
| 122 |
+
"divisible_by": 32,
|
| 123 |
+
"upscale_method": "nearest-exact",
|
| 124 |
+
"keep_proportion": "stretch",
|
| 125 |
+
"crop_position": "center",
|
| 126 |
+
"pad_color_mode": "Black",
|
| 127 |
+
"panel_color_mode": "Black",
|
| 128 |
+
"device": "cpu",
|
| 129 |
+
"pad_color": "0, 0, 0",
|
| 130 |
+
"panel_color": "0, 0, 0",
|
| 131 |
+
"invert_composed_MASK": False,
|
| 132 |
+
},
|
| 133 |
+
},
|
| 134 |
+
"21": {
|
| 135 |
+
"class_type": "Koolook_LoopStatus",
|
| 136 |
+
"inputs": {
|
| 137 |
+
"value": ["19", 0],
|
| 138 |
+
"index": ["22", 0],
|
| 139 |
+
"total": ["12", 0],
|
| 140 |
+
"filepath": ["8", 0],
|
| 141 |
+
"label": "EXR_SAFE",
|
| 142 |
+
"auto_queue_next": True,
|
| 143 |
+
"index_node_id": "22",
|
| 144 |
+
"server_url": "http://127.0.0.1:8188",
|
| 145 |
+
"max_auto_queue_depth": 100,
|
| 146 |
+
"remaining_auto_queue_depth": -1,
|
| 147 |
+
},
|
| 148 |
+
},
|
| 149 |
+
"4": {
|
| 150 |
+
"class_type": "SaveEXRFrames",
|
| 151 |
+
"inputs": {
|
| 152 |
+
"images": ["21", 0],
|
| 153 |
+
"filepath": ["8", 0],
|
| 154 |
+
"tonemap": "linear",
|
| 155 |
+
"start_frame": ["22", 0],
|
| 156 |
+
"overwrite": True,
|
| 157 |
+
"save_workflow": "none",
|
| 158 |
+
},
|
| 159 |
+
},
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def run(server: str, output_base: Path, repo_root: Path) -> int:
|
| 164 |
+
_assert_inside(repo_root, output_base)
|
| 165 |
+
run_output = output_base / f"run-{int(time.time() * 1000)}"
|
| 166 |
+
_assert_inside(repo_root, run_output)
|
| 167 |
+
run_output.mkdir(parents=True, exist_ok=True)
|
| 168 |
+
|
| 169 |
+
system = _get_json(f"{server}/system_stats")
|
| 170 |
+
print(f"ComfyUI {system['system']['comfyui_version']} at {server}")
|
| 171 |
+
|
| 172 |
+
payload = {
|
| 173 |
+
"prompt": build_prompt(run_output.resolve()),
|
| 174 |
+
"client_id": "koolook-loop-test",
|
| 175 |
+
}
|
| 176 |
+
try:
|
| 177 |
+
queued = _post_json(f"{server}/prompt", payload)
|
| 178 |
+
except urllib.error.HTTPError as exc:
|
| 179 |
+
print(exc.read().decode("utf-8", errors="replace"))
|
| 180 |
+
return 1
|
| 181 |
+
|
| 182 |
+
prompt_id = queued["prompt_id"]
|
| 183 |
+
print(f"queued {prompt_id}")
|
| 184 |
+
for _ in range(240):
|
| 185 |
+
time.sleep(0.5)
|
| 186 |
+
history = _get_json(f"{server}/history/{prompt_id}")
|
| 187 |
+
if prompt_id in history:
|
| 188 |
+
status = history[prompt_id].get("status", {})
|
| 189 |
+
print(json.dumps(status, indent=2))
|
| 190 |
+
if status.get("status_str") != "success":
|
| 191 |
+
return 1
|
| 192 |
+
break
|
| 193 |
+
else:
|
| 194 |
+
print("Timed out waiting for ComfyUI history.")
|
| 195 |
+
return 1
|
| 196 |
+
|
| 197 |
+
files = []
|
| 198 |
+
for _ in range(240):
|
| 199 |
+
files = sorted(run_output.rglob("*.exr"))
|
| 200 |
+
if len(files) >= 4:
|
| 201 |
+
break
|
| 202 |
+
time.sleep(0.5)
|
| 203 |
+
for file in files:
|
| 204 |
+
print(file)
|
| 205 |
+
if len(files) != 4:
|
| 206 |
+
print(f"Expected 4 EXRs, found {len(files)}.")
|
| 207 |
+
_dump_server_debug(server)
|
| 208 |
+
return 1
|
| 209 |
+
print("OK: wrote 4 EXR frames.")
|
| 210 |
+
return 0
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def main() -> int:
|
| 214 |
+
repo_root = Path(__file__).resolve().parents[1]
|
| 215 |
+
parser = argparse.ArgumentParser()
|
| 216 |
+
parser.add_argument("--server", default="http://127.0.0.1:8188")
|
| 217 |
+
parser.add_argument(
|
| 218 |
+
"--output-base",
|
| 219 |
+
type=Path,
|
| 220 |
+
default=repo_root / ".tmp" / "comfy-loop-test-output",
|
| 221 |
+
)
|
| 222 |
+
args = parser.parse_args()
|
| 223 |
+
return run(args.server.rstrip("/"), args.output_base, repo_root)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
if __name__ == "__main__":
|
| 227 |
+
raise SystemExit(main())
|
ComfyUI/custom_nodes/koolook/scripts/sync_to_dev.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Copy runtime-relevant Koolook files into a live ComfyUI custom_nodes
|
| 4 |
+
folder so a fix can be tested without a tag-and-publish round-trip.
|
| 5 |
+
|
| 6 |
+
USER-INITIATED ONLY. This script overwrites a live ComfyUI install.
|
| 7 |
+
Agents must NEVER run it automatically - not after a commit, not after
|
| 8 |
+
a PR merge or ship-pr, not at session end, not from any "task complete"
|
| 9 |
+
cleanup. The maintainer typically has multiple parallel sessions across
|
| 10 |
+
worktrees, and an unsolicited sync from one silently destroys what
|
| 11 |
+
another is reviewing. See project CLAUDE.md `dev-sync` section for the
|
| 12 |
+
full policy. Run only on the explicit user trigger phrase.
|
| 13 |
+
|
| 14 |
+
The target path is read from the KOLOOK_COMFYUI_DEV_PATH environment
|
| 15 |
+
variable (loaded from `.env` at the repo root if present). The variable
|
| 16 |
+
is intentionally kept out of the committed tree - see `.env.example`.
|
| 17 |
+
|
| 18 |
+
`KOLOOK_COMFYUI_DEV_PATH` should point at the eventual Koolook
|
| 19 |
+
subdirectory inside `custom_nodes/`, NOT at the `custom_nodes/` parent.
|
| 20 |
+
Target ``custom_nodes/koolook/`` — that's where ComfyUI-Manager and the
|
| 21 |
+
Comfy Registry install (derived from ``[project].name`` in
|
| 22 |
+
``pyproject.toml``), so dev-sync overwrites the Manager install in place.
|
| 23 |
+
Targeting ``custom_nodes/ComfyUI-Koolook/`` instead spawns a parallel
|
| 24 |
+
install; ``__init__.py``'s duplicate-install guard logs a critical
|
| 25 |
+
message and disables the non-winning copy (issue #162).
|
| 26 |
+
|
| 27 |
+
Example layouts:
|
| 28 |
+
macOS: /Volumes/Data/ComfyUI/custom_nodes/koolook
|
| 29 |
+
Windows: C:/ComfyUI_portable/ComfyUI/custom_nodes/koolook
|
| 30 |
+
|
| 31 |
+
Usage:
|
| 32 |
+
python scripts/sync_to_dev.py # copy files
|
| 33 |
+
python scripts/sync_to_dev.py --dry-run # show what would copy
|
| 34 |
+
python scripts/sync_to_dev.py --init # first-run: create the
|
| 35 |
+
# target folder if missing
|
| 36 |
+
# (parent custom_nodes/ must
|
| 37 |
+
# already exist), then sync
|
| 38 |
+
|
| 39 |
+
After copying Python files, restart ComfyUI manually so custom-node modules
|
| 40 |
+
are re-imported. This script only copies files.
|
| 41 |
+
|
| 42 |
+
Exit codes:
|
| 43 |
+
0 success
|
| 44 |
+
2 KOLOOK_COMFYUI_DEV_PATH unset, parent missing, or target missing
|
| 45 |
+
(without --init)
|
| 46 |
+
3 --init refused: parent is not an existing directory or doesn't
|
| 47 |
+
resemble a ComfyUI custom_nodes/ folder
|
| 48 |
+
|
| 49 |
+
This script never reaches outside the repo, never deletes anything in
|
| 50 |
+
the source, and only touches paths under the configured target. It does
|
| 51 |
+
overwrite files in the target - that's the point.
|
| 52 |
+
"""
|
| 53 |
+
from __future__ import annotations
|
| 54 |
+
|
| 55 |
+
import argparse
|
| 56 |
+
import json
|
| 57 |
+
import os
|
| 58 |
+
import shutil
|
| 59 |
+
import subprocess
|
| 60 |
+
import sys
|
| 61 |
+
from datetime import datetime
|
| 62 |
+
from pathlib import Path
|
| 63 |
+
|
| 64 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _get_short_sha() -> str | None:
|
| 68 |
+
"""Best-effort short commit SHA of the source tree being synced.
|
| 69 |
+
Returns ``None`` if git isn't reachable or the call fails - the
|
| 70 |
+
summary line just omits the SHA in that case.
|
| 71 |
+
"""
|
| 72 |
+
try:
|
| 73 |
+
r = subprocess.run(
|
| 74 |
+
["git", "rev-parse", "--short", "HEAD"],
|
| 75 |
+
cwd=str(REPO_ROOT),
|
| 76 |
+
capture_output=True,
|
| 77 |
+
text=True,
|
| 78 |
+
timeout=2,
|
| 79 |
+
)
|
| 80 |
+
if r.returncode == 0:
|
| 81 |
+
sha = r.stdout.strip()
|
| 82 |
+
if sha:
|
| 83 |
+
return sha
|
| 84 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 85 |
+
pass
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _get_worktree_name() -> str:
|
| 90 |
+
"""Returns the basename of the source tree being synced - useful when
|
| 91 |
+
the maintainer is running multiple parallel ComfyUI installs and
|
| 92 |
+
needs to know which checkout fed the most recent sync.
|
| 93 |
+
|
| 94 |
+
For a worktree at ``.../ComfyUI-Koolook/.claude/worktrees/foo`` this
|
| 95 |
+
returns ``foo``; for the main repo at ``.../ComfyUI-Koolook`` it
|
| 96 |
+
returns ``ComfyUI-Koolook``. Either is informative enough to
|
| 97 |
+
disambiguate."""
|
| 98 |
+
return REPO_ROOT.name
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def build_line() -> str:
|
| 102 |
+
"""Composes the two-piece header line consumed by the chat-report
|
| 103 |
+
convention defined in project CLAUDE.md:
|
| 104 |
+
|
| 105 |
+
<short-sha> - <worktree-name>
|
| 106 |
+
|
| 107 |
+
SHA falls back to ``unknown`` if git is unreachable (we always need
|
| 108 |
+
SOMETHING in slot 1 - the line shape is part of the convention).
|
| 109 |
+
Worktree name comes from ``REPO_ROOT.name`` and is always present.
|
| 110 |
+
|
| 111 |
+
Public - consumed by scoped per-module wrappers like
|
| 112 |
+
``sync_to_dev_audio.py`` so every dev-sync variant emits the same
|
| 113 |
+
chat-report header.
|
| 114 |
+
"""
|
| 115 |
+
sha = _get_short_sha() or "unknown"
|
| 116 |
+
return f"{sha} - {_get_worktree_name()}"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# Backwards-compatible alias (the function was private until the audio
|
| 120 |
+
# wrapper landed). Drop after the next release cycle once we're sure no
|
| 121 |
+
# downstream caller imports the underscore name.
|
| 122 |
+
_build_line = build_line
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def write_build_info(target: Path, scope: str | None) -> None:
|
| 126 |
+
"""Drop a tiny JSON next to the sidebar JS so the in-browser footer
|
| 127 |
+
can render `dev <sha> * <time>` (and an italic <scope> on a second
|
| 128 |
+
line) - same identifier the chat report quotes, but visible in the
|
| 129 |
+
running ComfyUI itself. Absent on registry installs (the file is
|
| 130 |
+
only written by this dev script), so the footer stays empty there.
|
| 131 |
+
|
| 132 |
+
Best-effort: missing git -> omit `commit` field; no `--scope` -> omit
|
| 133 |
+
`scope`; the timestamp alone is still useful for the maintainer to
|
| 134 |
+
eyeball "did my last sync land in this browser tab?\""""
|
| 135 |
+
info: dict[str, str] = {
|
| 136 |
+
"synced_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
| 137 |
+
"worktree": _get_worktree_name(),
|
| 138 |
+
}
|
| 139 |
+
sha = _get_short_sha()
|
| 140 |
+
if sha:
|
| 141 |
+
info["commit"] = sha
|
| 142 |
+
if scope:
|
| 143 |
+
info["scope"] = scope
|
| 144 |
+
out = target / "web" / "_dev_build.json"
|
| 145 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 146 |
+
out.write_text(json.dumps(info, indent=2) + "\n", encoding="utf-8")
|
| 147 |
+
|
| 148 |
+
# Files / dirs ComfyUI loads at runtime, plus the package metadata ComfyUI
|
| 149 |
+
# Manager reads when showing the installed custom-node version. Anything
|
| 150 |
+
# outside this list (CI, docs, .claude/, .github/, .cursor/, CHANGELOG, LICENSE,
|
| 151 |
+
# README, fork manifest YAML, etc.) does not affect what ComfyUI executes or
|
| 152 |
+
# how the dev install is identified, and is intentionally skipped.
|
| 153 |
+
RUNTIME_PATHS: tuple[str, ...] = (
|
| 154 |
+
"pyproject.toml",
|
| 155 |
+
"__init__.py",
|
| 156 |
+
"config.json",
|
| 157 |
+
"k_ai_pipeline.py",
|
| 158 |
+
"k_easy_image_batch.py",
|
| 159 |
+
"k_easy_pattern.py",
|
| 160 |
+
"k_easy_resize.py",
|
| 161 |
+
"k_easy_track.py",
|
| 162 |
+
"k_easy_utility.py",
|
| 163 |
+
"k_easy_wan22_prompt.py",
|
| 164 |
+
"k_loop_status.py",
|
| 165 |
+
"k_publish_contract.py",
|
| 166 |
+
"k_video_combine.py",
|
| 167 |
+
"k_video_load.py",
|
| 168 |
+
"koolook_install_guard.py",
|
| 169 |
+
"koolook_routes.py",
|
| 170 |
+
"koolook_setup_runner.py",
|
| 171 |
+
"koolook_setups.py",
|
| 172 |
+
"koolook_versioning.py",
|
| 173 |
+
"forks",
|
| 174 |
+
"video_formats",
|
| 175 |
+
"web",
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# When copying directories, exclude these subpaths - they are dev-only
|
| 179 |
+
# metadata that ComfyUI doesn't need and which can churn unnecessarily.
|
| 180 |
+
DIR_EXCLUDES: tuple[str, ...] = (
|
| 181 |
+
"__pycache__",
|
| 182 |
+
"UPSTREAM_PIN.yaml",
|
| 183 |
+
"THIRD_PARTY.md",
|
| 184 |
+
"forks_manifest.yaml",
|
| 185 |
+
"README.md",
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def load_dotenv(env_path: Path) -> None:
|
| 190 |
+
"""Minimal `.env` loader. No dependency on python-dotenv."""
|
| 191 |
+
if not env_path.exists():
|
| 192 |
+
return
|
| 193 |
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
| 194 |
+
line = line.strip()
|
| 195 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 196 |
+
continue
|
| 197 |
+
key, _, value = line.partition("=")
|
| 198 |
+
# Don't overwrite anything already in the environment.
|
| 199 |
+
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _ignore(_dir: str, names: list[str]) -> list[str]:
|
| 203 |
+
return [n for n in names if n in DIR_EXCLUDES]
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def sync(
|
| 207 |
+
target: Path,
|
| 208 |
+
dry_run: bool,
|
| 209 |
+
verbose: bool,
|
| 210 |
+
paths: tuple[str, ...] = RUNTIME_PATHS,
|
| 211 |
+
) -> int:
|
| 212 |
+
"""Copy each entry in ``paths`` (relative to the repo root) to the
|
| 213 |
+
matching subpath under ``target``. Directories are recursively
|
| 214 |
+
copied with ``DIR_EXCLUDES`` filtered out and the previous dest
|
| 215 |
+
subtree removed first; files are overwritten in place.
|
| 216 |
+
|
| 217 |
+
The optional ``paths`` argument lets scoped wrappers (e.g.
|
| 218 |
+
``sync_to_dev_audio.py``) reuse this function with a smaller set -
|
| 219 |
+
just the subtree their automation module touches. The default is
|
| 220 |
+
the full ``RUNTIME_PATHS`` (every file ComfyUI loads at runtime).
|
| 221 |
+
"""
|
| 222 |
+
copied = 0
|
| 223 |
+
for rel in paths:
|
| 224 |
+
src = REPO_ROOT / rel
|
| 225 |
+
if not src.exists():
|
| 226 |
+
continue
|
| 227 |
+
dst = target / rel
|
| 228 |
+
if dry_run:
|
| 229 |
+
if verbose:
|
| 230 |
+
print(f"would copy: {rel}")
|
| 231 |
+
copied += 1
|
| 232 |
+
continue
|
| 233 |
+
if src.is_dir():
|
| 234 |
+
if dst.exists():
|
| 235 |
+
shutil.rmtree(dst)
|
| 236 |
+
shutil.copytree(src, dst, ignore=_ignore)
|
| 237 |
+
else:
|
| 238 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 239 |
+
shutil.copy2(src, dst)
|
| 240 |
+
if verbose:
|
| 241 |
+
print(f"copied: {rel}")
|
| 242 |
+
copied += 1
|
| 243 |
+
return copied
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _looks_like_custom_nodes(parent: Path) -> bool:
|
| 247 |
+
"""Heuristic: parent is named `custom_nodes` OR sits inside a
|
| 248 |
+
directory named `ComfyUI`. A bit conservative - saves the user from
|
| 249 |
+
a `KOLOOK_COMFYUI_DEV_PATH` typo that would otherwise create a fresh
|
| 250 |
+
`ComfyUI-Koolook/` somewhere unexpected on disk."""
|
| 251 |
+
if parent.name == "custom_nodes":
|
| 252 |
+
return True
|
| 253 |
+
grandparent = parent.parent
|
| 254 |
+
return grandparent.name.lower() == "comfyui" and parent.is_dir()
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def ensure_target(target: Path, init: bool) -> int | None:
|
| 258 |
+
"""Validate the target. Returns an exit code (2 or 3) on error,
|
| 259 |
+
or None on success (with target now guaranteed to exist as a dir)."""
|
| 260 |
+
if target.exists():
|
| 261 |
+
if not target.is_dir():
|
| 262 |
+
print(f"target is not a directory: {target}", file=sys.stderr)
|
| 263 |
+
return 2
|
| 264 |
+
return None
|
| 265 |
+
# Target is missing.
|
| 266 |
+
if not init:
|
| 267 |
+
print(
|
| 268 |
+
f"target does not exist: {target}\n"
|
| 269 |
+
f"first time on this machine? re-run with --init to create it.",
|
| 270 |
+
file=sys.stderr,
|
| 271 |
+
)
|
| 272 |
+
return 2
|
| 273 |
+
parent = target.parent
|
| 274 |
+
if not parent.exists() or not parent.is_dir():
|
| 275 |
+
print(
|
| 276 |
+
f"--init refused: parent does not exist: {parent}\n"
|
| 277 |
+
f"check KOLOOK_COMFYUI_DEV_PATH - the *parent* (typically a "
|
| 278 |
+
f"ComfyUI custom_nodes/ folder) must already be in place.",
|
| 279 |
+
file=sys.stderr,
|
| 280 |
+
)
|
| 281 |
+
return 3
|
| 282 |
+
if not _looks_like_custom_nodes(parent):
|
| 283 |
+
print(
|
| 284 |
+
f"--init refused: parent doesn't look like a ComfyUI "
|
| 285 |
+
f"custom_nodes/ folder: {parent}\n"
|
| 286 |
+
f"expected the parent to be named 'custom_nodes' or to sit "
|
| 287 |
+
f"inside a 'ComfyUI' directory. If this really is your "
|
| 288 |
+
f"ComfyUI install, create the target manually with "
|
| 289 |
+
f"`mkdir -p \"{target}\"` and re-run without --init.",
|
| 290 |
+
file=sys.stderr,
|
| 291 |
+
)
|
| 292 |
+
return 3
|
| 293 |
+
target.mkdir(parents=False, exist_ok=False)
|
| 294 |
+
print(f"created target: {target}")
|
| 295 |
+
return None
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def main() -> int:
|
| 299 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 300 |
+
parser.add_argument(
|
| 301 |
+
"--dry-run",
|
| 302 |
+
action="store_true",
|
| 303 |
+
help="Print what would be copied without touching the target.",
|
| 304 |
+
)
|
| 305 |
+
parser.add_argument(
|
| 306 |
+
"--init",
|
| 307 |
+
action="store_true",
|
| 308 |
+
help=(
|
| 309 |
+
"Create the target directory if it doesn't exist. The parent "
|
| 310 |
+
"(typically a ComfyUI custom_nodes/ folder) must already be "
|
| 311 |
+
"in place. Use on first-run only."
|
| 312 |
+
),
|
| 313 |
+
)
|
| 314 |
+
parser.add_argument(
|
| 315 |
+
"--verbose",
|
| 316 |
+
"-v",
|
| 317 |
+
action="store_true",
|
| 318 |
+
help=(
|
| 319 |
+
"Print each file/dir as it's copied. Default output is just "
|
| 320 |
+
"the one-line build summary."
|
| 321 |
+
),
|
| 322 |
+
)
|
| 323 |
+
parser.add_argument(
|
| 324 |
+
"--scope",
|
| 325 |
+
type=str,
|
| 326 |
+
default=None,
|
| 327 |
+
help=(
|
| 328 |
+
"Short (<=10 word) description of what this build is about - "
|
| 329 |
+
"the same scope summary that goes in the chat report's second "
|
| 330 |
+
"line. Persisted in `web/_dev_build.json` and rendered in the "
|
| 331 |
+
"Kforge Labs sidebar footer (italic, second line, below the "
|
| 332 |
+
"`dev <sha> * <time>` identifier) so the maintainer can "
|
| 333 |
+
"correlate live ComfyUI state with chat history when juggling "
|
| 334 |
+
"multiple parallel worktree sessions. Optional - when absent, "
|
| 335 |
+
"the footer renders just identifier + timestamp."
|
| 336 |
+
),
|
| 337 |
+
)
|
| 338 |
+
args = parser.parse_args()
|
| 339 |
+
|
| 340 |
+
load_dotenv(REPO_ROOT / ".env")
|
| 341 |
+
|
| 342 |
+
target_str = os.environ.get("KOLOOK_COMFYUI_DEV_PATH")
|
| 343 |
+
if not target_str:
|
| 344 |
+
print(
|
| 345 |
+
"KOLOOK_COMFYUI_DEV_PATH not set. Add it to .env "
|
| 346 |
+
"(see .env.example).",
|
| 347 |
+
file=sys.stderr,
|
| 348 |
+
)
|
| 349 |
+
return 2
|
| 350 |
+
|
| 351 |
+
target = Path(target_str).expanduser()
|
| 352 |
+
err = ensure_target(target, init=args.init)
|
| 353 |
+
if err is not None:
|
| 354 |
+
return err
|
| 355 |
+
|
| 356 |
+
n = sync(target, dry_run=args.dry_run, verbose=args.verbose)
|
| 357 |
+
verb = "would sync" if args.dry_run else "synced"
|
| 358 |
+
# Two-line summary - see project CLAUDE.md `dev-sync` section for the
|
| 359 |
+
# chat-report convention that consumes this output. Header first so
|
| 360 |
+
# the maintainer's eye lands on the build identifier before the
|
| 361 |
+
# mechanical sync details.
|
| 362 |
+
print(build_line())
|
| 363 |
+
print(f"{verb} {n} entries -> {target}")
|
| 364 |
+
if not args.dry_run:
|
| 365 |
+
write_build_info(target, args.scope)
|
| 366 |
+
return 0
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
if __name__ == "__main__":
|
| 370 |
+
sys.exit(main())
|
ComfyUI/custom_nodes/koolook/scripts/sync_to_dev_audio.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Scoped variant of ``dev-sync`` for the LTX 2.3 audio-lipsync automation.
|
| 4 |
+
|
| 5 |
+
Copies only what that automation's iteration loop touches:
|
| 6 |
+
|
| 7 |
+
- forks/whatdreamscost_koolook/ the Koolook fork of LTXDirector +
|
| 8 |
+
its modified prompt_relay + the
|
| 9 |
+
verbatim-vendored patches.py
|
| 10 |
+
- __init__.py root loader (registers the fork's
|
| 11 |
+
node mappings; needs to be re-synced
|
| 12 |
+
when the fork's NODE_CLASS_MAPPINGS
|
| 13 |
+
set changes)
|
| 14 |
+
- web/whatdreamscost_koolook/ Director timeline-editor extension
|
| 15 |
+
- web/koolook_draft_guard.js global Comfy draft-quota guard
|
| 16 |
+
(formerly embedded in the Director
|
| 17 |
+
extension; shipped so a scoped sync
|
| 18 |
+
never strands a guard-less install)
|
| 19 |
+
|
| 20 |
+
It also ships ``koolook_install_guard.py`` + ``koolook_versioning.py`` — the
|
| 21 |
+
loader gates ``__init__.py`` imports at load — so a scoped sync never leaves
|
| 22 |
+
the loader pointing at a module the target lacks (#198 / #183).
|
| 23 |
+
|
| 24 |
+
Everything else in the live install - ``forks/radiance_koolook/``, the
|
| 25 |
+
root ``k_*.py`` nodes, unrelated ``web/`` assets, ``video_formats/`` -
|
| 26 |
+
is left untouched.
|
| 27 |
+
|
| 28 |
+
USER-INITIATED ONLY. Same rule as plain ``dev-sync`` (see project
|
| 29 |
+
``CLAUDE.md`` -> ``dev-sync`` section). Never run automatically:
|
| 30 |
+
|
| 31 |
+
* after a commit
|
| 32 |
+
* after a PR merge or ``/ship-pr``
|
| 33 |
+
* at session end / wrap-up
|
| 34 |
+
* on hook completion or "task complete" cleanup
|
| 35 |
+
* from any agent skill that doesn't explicitly require it
|
| 36 |
+
|
| 37 |
+
Run only on the explicit user trigger phrase ``dev-sync-audio`` (or
|
| 38 |
+
``copy audio fork``, ``sync audio``, etc.). The maintainer typically has
|
| 39 |
+
multiple parallel sessions across worktrees; an unsolicited sync from one
|
| 40 |
+
silently destroys what another is reviewing.
|
| 41 |
+
|
| 42 |
+
The target path comes from ``KOLOOK_COMFYUI_DEV_PATH`` in ``.env`` -
|
| 43 |
+
identical to plain ``dev-sync``. No new env var.
|
| 44 |
+
|
| 45 |
+
Usage:
|
| 46 |
+
python scripts/sync_to_dev_audio.py
|
| 47 |
+
python scripts/sync_to_dev_audio.py --dry-run
|
| 48 |
+
python scripts/sync_to_dev_audio.py --scope "vstr=10 trial"
|
| 49 |
+
|
| 50 |
+
Exit codes mirror ``sync_to_dev.py`` exactly:
|
| 51 |
+
0 success
|
| 52 |
+
2 KOLOOK_COMFYUI_DEV_PATH unset or target missing without --init
|
| 53 |
+
3 --init refused
|
| 54 |
+
|
| 55 |
+
After copying Python files, restart ComfyUI manually so custom nodes are
|
| 56 |
+
re-imported. This scoped sync only copies files.
|
| 57 |
+
"""
|
| 58 |
+
from __future__ import annotations
|
| 59 |
+
|
| 60 |
+
import argparse
|
| 61 |
+
import os
|
| 62 |
+
import shutil
|
| 63 |
+
import sys
|
| 64 |
+
from pathlib import Path
|
| 65 |
+
|
| 66 |
+
# Reuse the dev-sync infrastructure for the .env loader, target validation,
|
| 67 |
+
# and chat-report shape. This scoped wrapper differs only in which paths get
|
| 68 |
+
# copied, and it never tries to manage the running ComfyUI process.
|
| 69 |
+
_SCRIPT_DIR = Path(__file__).resolve().parent
|
| 70 |
+
if str(_SCRIPT_DIR) not in sys.path:
|
| 71 |
+
sys.path.insert(0, str(_SCRIPT_DIR))
|
| 72 |
+
import sync_to_dev as _dev # noqa: E402
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
AUDIO_PATHS: tuple[str, ...] = (
|
| 76 |
+
"__init__.py",
|
| 77 |
+
# __init__.py imports these at load, before any node group, and a missing
|
| 78 |
+
# gate is catastrophic (not per-group-guarded): the install guard's
|
| 79 |
+
# absolute-import fallback raises uncaught, and the koolook_versioning
|
| 80 |
+
# context probe mislabels its ImportError as a non-Comfy context and
|
| 81 |
+
# registers nothing. Ship them with __init__.py so a scoped sync never
|
| 82 |
+
# leaves the loader pointing at a module the target lacks (#198 / #183).
|
| 83 |
+
"koolook_install_guard.py",
|
| 84 |
+
"koolook_versioning.py",
|
| 85 |
+
"forks/whatdreamscost_koolook",
|
| 86 |
+
"web/whatdreamscost_koolook",
|
| 87 |
+
# The Comfy draft-quota guard used to be embedded in the Director web
|
| 88 |
+
# extension above; it is global now. Ship it with every scoped audio
|
| 89 |
+
# sync so replacing web/whatdreamscost_koolook/ can never leave a dev
|
| 90 |
+
# install without the guard.
|
| 91 |
+
"web/koolook_draft_guard.js",
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
STALE_AUDIO_PATHS: tuple[str, ...] = (
|
| 95 |
+
"web/whatdreamscost_koolook_v1_3_2",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def target_is_repo_root(target: Path) -> bool:
|
| 100 |
+
"""Return True when the configured dev target is this source repo."""
|
| 101 |
+
try:
|
| 102 |
+
return target.resolve() == _dev.REPO_ROOT.resolve()
|
| 103 |
+
except OSError:
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def remove_stale_paths(target: Path, *, dry_run: bool, verbose: bool) -> int:
|
| 108 |
+
"""Remove old audio-sync paths that were renamed.
|
| 109 |
+
|
| 110 |
+
The v1.3.9 upgrade moved the Director web extension from a versioned
|
| 111 |
+
folder to a stable one. Existing dev installs can still have the old
|
| 112 |
+
folder on disk, causing ComfyUI to load two timeline extensions for
|
| 113 |
+
legacy workflows. Keep this scoped to explicit known paths.
|
| 114 |
+
"""
|
| 115 |
+
removed = 0
|
| 116 |
+
for rel in STALE_AUDIO_PATHS:
|
| 117 |
+
stale = target / rel
|
| 118 |
+
if not stale.exists() and not stale.is_symlink():
|
| 119 |
+
continue
|
| 120 |
+
if dry_run:
|
| 121 |
+
if verbose:
|
| 122 |
+
print(f"would remove stale: {rel}")
|
| 123 |
+
removed += 1
|
| 124 |
+
continue
|
| 125 |
+
if stale.is_symlink() or stale.is_file():
|
| 126 |
+
stale.unlink()
|
| 127 |
+
elif stale.is_dir():
|
| 128 |
+
shutil.rmtree(stale)
|
| 129 |
+
else:
|
| 130 |
+
stale.unlink()
|
| 131 |
+
if verbose:
|
| 132 |
+
print(f"removed stale: {rel}")
|
| 133 |
+
removed += 1
|
| 134 |
+
return removed
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _find_dotenv() -> Path | None:
|
| 138 |
+
direct = _dev.REPO_ROOT / ".env"
|
| 139 |
+
if direct.exists():
|
| 140 |
+
return direct
|
| 141 |
+
git_marker = _dev.REPO_ROOT / ".git"
|
| 142 |
+
if not git_marker.is_file():
|
| 143 |
+
return None
|
| 144 |
+
try:
|
| 145 |
+
content = git_marker.read_text(encoding="utf-8").strip()
|
| 146 |
+
except OSError:
|
| 147 |
+
return None
|
| 148 |
+
if not content.startswith("gitdir:"):
|
| 149 |
+
return None
|
| 150 |
+
gitdir = Path(content.split(":", 1)[1].strip())
|
| 151 |
+
if "worktrees" not in gitdir.parts:
|
| 152 |
+
return None
|
| 153 |
+
idx = gitdir.parts.index("worktrees")
|
| 154 |
+
main_repo_root = Path(*gitdir.parts[:idx]).parent
|
| 155 |
+
candidate = main_repo_root / ".env"
|
| 156 |
+
return candidate if candidate.exists() else None
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def main() -> int:
|
| 160 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 161 |
+
parser.add_argument(
|
| 162 |
+
"--dry-run",
|
| 163 |
+
action="store_true",
|
| 164 |
+
help="Print what would be copied without touching the target.",
|
| 165 |
+
)
|
| 166 |
+
parser.add_argument(
|
| 167 |
+
"--init",
|
| 168 |
+
action="store_true",
|
| 169 |
+
help=(
|
| 170 |
+
"Create the target directory if it doesn't exist. The parent "
|
| 171 |
+
"(typically a ComfyUI custom_nodes/ folder) must already be "
|
| 172 |
+
"in place. Use on first-run only."
|
| 173 |
+
),
|
| 174 |
+
)
|
| 175 |
+
parser.add_argument(
|
| 176 |
+
"--verbose",
|
| 177 |
+
"-v",
|
| 178 |
+
action="store_true",
|
| 179 |
+
help="Print each file/dir as it's copied.",
|
| 180 |
+
)
|
| 181 |
+
parser.add_argument(
|
| 182 |
+
"--scope",
|
| 183 |
+
type=str,
|
| 184 |
+
default="audio-lipsync fork edit",
|
| 185 |
+
help=(
|
| 186 |
+
"Short (<=10 word) description for the chat report's second line "
|
| 187 |
+
"and the in-browser footer in `web/_dev_build.json`. Defaults to "
|
| 188 |
+
"'audio-lipsync fork edit' so the build identifier always names "
|
| 189 |
+
"this module even when the maintainer doesn't pass a scope."
|
| 190 |
+
),
|
| 191 |
+
)
|
| 192 |
+
args = parser.parse_args()
|
| 193 |
+
|
| 194 |
+
env_path = _find_dotenv()
|
| 195 |
+
if env_path is not None:
|
| 196 |
+
_dev.load_dotenv(env_path)
|
| 197 |
+
|
| 198 |
+
target_str = os.environ.get("KOLOOK_COMFYUI_DEV_PATH")
|
| 199 |
+
if not target_str:
|
| 200 |
+
print(
|
| 201 |
+
"KOLOOK_COMFYUI_DEV_PATH not set. Add it to .env (see .env.example).",
|
| 202 |
+
file=sys.stderr,
|
| 203 |
+
)
|
| 204 |
+
return 2
|
| 205 |
+
|
| 206 |
+
target = Path(target_str).expanduser()
|
| 207 |
+
if target_is_repo_root(target):
|
| 208 |
+
print(
|
| 209 |
+
"KOLOOK_COMFYUI_DEV_PATH points at this source repo; refusing dev-sync.",
|
| 210 |
+
file=sys.stderr,
|
| 211 |
+
)
|
| 212 |
+
return 2
|
| 213 |
+
|
| 214 |
+
err = _dev.ensure_target(target, init=args.init)
|
| 215 |
+
if err is not None:
|
| 216 |
+
return err
|
| 217 |
+
|
| 218 |
+
n = _dev.sync(
|
| 219 |
+
target,
|
| 220 |
+
dry_run=args.dry_run,
|
| 221 |
+
verbose=args.verbose,
|
| 222 |
+
paths=AUDIO_PATHS,
|
| 223 |
+
)
|
| 224 |
+
removed = remove_stale_paths(target, dry_run=args.dry_run, verbose=args.verbose)
|
| 225 |
+
verb = "would sync" if args.dry_run else "synced"
|
| 226 |
+
print(_dev.build_line())
|
| 227 |
+
stale_note = f"; removed {removed} stale" if removed else ""
|
| 228 |
+
print(f"{verb} {n} entries{stale_note} -> {target} (dev-sync-audio)")
|
| 229 |
+
if not args.dry_run:
|
| 230 |
+
_dev.write_build_info(target, args.scope)
|
| 231 |
+
return 0
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
sys.exit(main())
|
ComfyUI/custom_nodes/koolook/scripts/validate_workflow.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validate a ComfyUI workflow JSON for internal consistency.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python scripts/validate_workflow.py <workflow.json> [--quiet]
|
| 5 |
+
|
| 6 |
+
Checks performed:
|
| 7 |
+
- The file parses as JSON.
|
| 8 |
+
- Every link referenced by node inputs/outputs is declared in the top-level
|
| 9 |
+
`links` table.
|
| 10 |
+
- Every entry in the `links` table is referenced by both endpoints (no
|
| 11 |
+
orphans).
|
| 12 |
+
- For each link, the source/destination nodes and slots exist and the slot
|
| 13 |
+
type matches the declared link type.
|
| 14 |
+
- `last_node_id >= max(node ids)` and `last_link_id >= max(link ids)`.
|
| 15 |
+
- No duplicate node IDs or link IDs.
|
| 16 |
+
|
| 17 |
+
Exit codes:
|
| 18 |
+
0 All checks passed.
|
| 19 |
+
1 Problems found (printed to stderr).
|
| 20 |
+
2 File not found or not valid JSON.
|
| 21 |
+
|
| 22 |
+
This catches the class of bugs that come from hand-editing workflow JSONs
|
| 23 |
+
(e.g. when an agent re-wires a graph to build a slim variant). Run it after
|
| 24 |
+
any non-trivial edit before re-loading the workflow into ComfyUI — Comfy's
|
| 25 |
+
loader will sometimes silently drop or mis-wire the bad parts, producing a
|
| 26 |
+
graph that looks fine but behaves wrong.
|
| 27 |
+
|
| 28 |
+
stdlib only, no third-party dependencies.
|
| 29 |
+
"""
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
import argparse
|
| 32 |
+
import json
|
| 33 |
+
import sys
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _sanitize_slots(
|
| 38 |
+
node: dict, key: str, problems: list[str]
|
| 39 |
+
) -> list[dict]:
|
| 40 |
+
"""Return a list-of-dicts view of node[key], appending a problem for any
|
| 41 |
+
malformed entry. Non-list `node[key]` → reported and treated as empty.
|
| 42 |
+
Non-dict entries → reported and dropped.
|
| 43 |
+
"""
|
| 44 |
+
slots = node.get(key)
|
| 45 |
+
if slots is None:
|
| 46 |
+
return []
|
| 47 |
+
if not isinstance(slots, list):
|
| 48 |
+
problems.append(
|
| 49 |
+
f"node {node.get('id')!r}: {key!r} must be a list, got {type(slots).__name__}"
|
| 50 |
+
)
|
| 51 |
+
return []
|
| 52 |
+
cleaned: list[dict] = []
|
| 53 |
+
for j, slot in enumerate(slots):
|
| 54 |
+
if not isinstance(slot, dict):
|
| 55 |
+
problems.append(
|
| 56 |
+
f"node {node.get('id')!r}: {key}[{j}] must be an object, got {type(slot).__name__}"
|
| 57 |
+
)
|
| 58 |
+
continue
|
| 59 |
+
cleaned.append(slot)
|
| 60 |
+
return cleaned
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _slot_type_matches(slot_type, link_type) -> bool:
|
| 64 |
+
"""ComfyUI wildcard sockets (`*`) accept any concrete link type."""
|
| 65 |
+
return slot_type == link_type or slot_type == "*"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def validate(data) -> list[str]:
|
| 69 |
+
"""Return a list of problem strings; empty list means OK.
|
| 70 |
+
|
| 71 |
+
Defensive: every shape assumption is guarded so that malformed-but-valid
|
| 72 |
+
JSON produces problem lines, never an uncaught exception. Top-level not
|
| 73 |
+
being an object is the only short-circuit return (nothing else is safe
|
| 74 |
+
to do without a dict).
|
| 75 |
+
"""
|
| 76 |
+
problems: list[str] = []
|
| 77 |
+
|
| 78 |
+
# Top-level must be a JSON object.
|
| 79 |
+
if not isinstance(data, dict):
|
| 80 |
+
return [
|
| 81 |
+
f"top-level JSON must be an object, got {type(data).__name__}"
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
# `nodes` and `links` arrays — must be lists; non-list reported and skipped.
|
| 85 |
+
raw_nodes = data.get("nodes") or []
|
| 86 |
+
if not isinstance(raw_nodes, list):
|
| 87 |
+
problems.append(
|
| 88 |
+
f"'nodes' must be a list, got {type(raw_nodes).__name__}"
|
| 89 |
+
)
|
| 90 |
+
raw_nodes = []
|
| 91 |
+
|
| 92 |
+
raw_links = data.get("links") or []
|
| 93 |
+
if not isinstance(raw_links, list):
|
| 94 |
+
problems.append(
|
| 95 |
+
f"'links' must be a list, got {type(raw_links).__name__}"
|
| 96 |
+
)
|
| 97 |
+
raw_links = []
|
| 98 |
+
|
| 99 |
+
# Filter to well-shaped node entries, recording each bad one.
|
| 100 |
+
nodes: list[dict] = []
|
| 101 |
+
for i, n in enumerate(raw_nodes):
|
| 102 |
+
if not isinstance(n, dict):
|
| 103 |
+
problems.append(
|
| 104 |
+
f"nodes[{i}]: must be an object, got {type(n).__name__}"
|
| 105 |
+
)
|
| 106 |
+
continue
|
| 107 |
+
nodes.append(n)
|
| 108 |
+
|
| 109 |
+
# Filter to well-shaped link entries. Each must be a 6-element list.
|
| 110 |
+
links: list[list] = []
|
| 111 |
+
for i, link in enumerate(raw_links):
|
| 112 |
+
if not isinstance(link, list):
|
| 113 |
+
problems.append(
|
| 114 |
+
f"links[{i}]: must be a list of 6 elements, got {type(link).__name__}"
|
| 115 |
+
)
|
| 116 |
+
continue
|
| 117 |
+
if len(link) != 6:
|
| 118 |
+
problems.append(
|
| 119 |
+
f"links[{i}]: malformed — expected 6 elements "
|
| 120 |
+
f"[id, src, src_slot, dst, dst_slot, type], got {len(link)}"
|
| 121 |
+
)
|
| 122 |
+
continue
|
| 123 |
+
links.append(link)
|
| 124 |
+
|
| 125 |
+
# Pre-sanitize each node's inputs/outputs into list-of-dict views so
|
| 126 |
+
# downstream code can index safely. Caches per-node so problems aren't
|
| 127 |
+
# double-reported when a node is touched by multiple links.
|
| 128 |
+
sanitized_io: dict[int, dict[str, list[dict]]] = {}
|
| 129 |
+
for n in nodes:
|
| 130 |
+
sanitized_io[id(n)] = {
|
| 131 |
+
"inputs": _sanitize_slots(n, "inputs", problems),
|
| 132 |
+
"outputs": _sanitize_slots(n, "outputs", problems),
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
# Duplicate IDs (over good entries).
|
| 136 |
+
node_ids = [n.get("id") for n in nodes]
|
| 137 |
+
link_ids = [link[0] for link in links]
|
| 138 |
+
dup_nodes = sorted({i for i in node_ids if node_ids.count(i) > 1})
|
| 139 |
+
dup_links = sorted({i for i in link_ids if link_ids.count(i) > 1})
|
| 140 |
+
if dup_nodes:
|
| 141 |
+
problems.append(f"Duplicate node IDs: {dup_nodes}")
|
| 142 |
+
if dup_links:
|
| 143 |
+
problems.append(f"Duplicate link IDs: {dup_links}")
|
| 144 |
+
|
| 145 |
+
# Header counters should bound observed IDs (Comfy uses these to assign
|
| 146 |
+
# the next id when adding nodes; if they lag, the next add can collide).
|
| 147 |
+
int_node_ids = [i for i in node_ids if isinstance(i, int)]
|
| 148 |
+
int_link_ids = [i for i in link_ids if isinstance(i, int)]
|
| 149 |
+
if int_node_ids:
|
| 150 |
+
last_node_id = data.get("last_node_id", -1)
|
| 151 |
+
if not isinstance(last_node_id, int) or last_node_id < max(int_node_ids):
|
| 152 |
+
problems.append(
|
| 153 |
+
f"last_node_id={last_node_id!r} < max(node id)={max(int_node_ids)}"
|
| 154 |
+
)
|
| 155 |
+
if int_link_ids:
|
| 156 |
+
last_link_id = data.get("last_link_id", -1)
|
| 157 |
+
if not isinstance(last_link_id, int) or last_link_id < max(int_link_ids):
|
| 158 |
+
problems.append(
|
| 159 |
+
f"last_link_id={last_link_id!r} < max(link id)={max(int_link_ids)}"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Cross-reference link declarations vs node IO references.
|
| 163 |
+
declared = set(int_link_ids)
|
| 164 |
+
referenced: set[int] = set()
|
| 165 |
+
for n in nodes:
|
| 166 |
+
io = sanitized_io[id(n)]
|
| 167 |
+
for inp in io["inputs"]:
|
| 168 |
+
link_val = inp.get("link")
|
| 169 |
+
if isinstance(link_val, int):
|
| 170 |
+
referenced.add(link_val)
|
| 171 |
+
for out in io["outputs"]:
|
| 172 |
+
out_links = out.get("links") or []
|
| 173 |
+
if not isinstance(out_links, list):
|
| 174 |
+
problems.append(
|
| 175 |
+
f"node {n.get('id')!r}: output.links must be a list, "
|
| 176 |
+
f"got {type(out_links).__name__}"
|
| 177 |
+
)
|
| 178 |
+
continue
|
| 179 |
+
for lk in out_links:
|
| 180 |
+
if isinstance(lk, int):
|
| 181 |
+
referenced.add(lk)
|
| 182 |
+
|
| 183 |
+
for missing in sorted(referenced - declared):
|
| 184 |
+
problems.append(
|
| 185 |
+
f"link {missing}: referenced by a node but not in the links table"
|
| 186 |
+
)
|
| 187 |
+
for orphan in sorted(declared - referenced):
|
| 188 |
+
problems.append(
|
| 189 |
+
f"link {orphan}: declared in the links table but unused by any node"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Per-link endpoint validation.
|
| 193 |
+
# Build node_by_id over int ids only — non-int ids can't match a link's
|
| 194 |
+
# src/dst (which are ints in the link tuple), and treating them as keys
|
| 195 |
+
# would mask bad data.
|
| 196 |
+
node_by_id = {n.get("id"): n for n in nodes if isinstance(n.get("id"), int)}
|
| 197 |
+
for link in links:
|
| 198 |
+
lid, src, src_slot, dst, dst_slot, ltype = link
|
| 199 |
+
src_node = node_by_id.get(src)
|
| 200 |
+
dst_node = node_by_id.get(dst)
|
| 201 |
+
if src_node is None:
|
| 202 |
+
problems.append(f"link {lid}: src node {src!r} missing")
|
| 203 |
+
continue
|
| 204 |
+
if dst_node is None:
|
| 205 |
+
problems.append(f"link {lid}: dst node {dst!r} missing")
|
| 206 |
+
continue
|
| 207 |
+
src_outs = sanitized_io[id(src_node)]["outputs"]
|
| 208 |
+
dst_ins = sanitized_io[id(dst_node)]["inputs"]
|
| 209 |
+
|
| 210 |
+
# Slot index bounds + type checks.
|
| 211 |
+
if not isinstance(src_slot, int) or src_slot < 0 or src_slot >= len(src_outs):
|
| 212 |
+
problems.append(
|
| 213 |
+
f"link {lid}: src slot {src_slot!r} out of bounds on node {src} "
|
| 214 |
+
f"(has {len(src_outs)} outputs)"
|
| 215 |
+
)
|
| 216 |
+
elif not _slot_type_matches(src_outs[src_slot].get("type"), ltype):
|
| 217 |
+
problems.append(
|
| 218 |
+
f"link {lid}: src type mismatch — link declares {ltype!r}, "
|
| 219 |
+
f"slot is {src_outs[src_slot].get('type')!r}"
|
| 220 |
+
)
|
| 221 |
+
if not isinstance(dst_slot, int) or dst_slot < 0 or dst_slot >= len(dst_ins):
|
| 222 |
+
problems.append(
|
| 223 |
+
f"link {lid}: dst slot {dst_slot!r} out of bounds on node {dst} "
|
| 224 |
+
f"(has {len(dst_ins)} inputs)"
|
| 225 |
+
)
|
| 226 |
+
elif not _slot_type_matches(dst_ins[dst_slot].get("type"), ltype):
|
| 227 |
+
problems.append(
|
| 228 |
+
f"link {lid}: dst type mismatch — link declares {ltype!r}, "
|
| 229 |
+
f"slot is {dst_ins[dst_slot].get('type')!r}"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Endpoint cross-check: node IO must reference this link id symmetrically.
|
| 233 |
+
if isinstance(src_slot, int) and 0 <= src_slot < len(src_outs):
|
| 234 |
+
out_links = src_outs[src_slot].get("links") or []
|
| 235 |
+
if isinstance(out_links, list) and lid not in out_links:
|
| 236 |
+
problems.append(
|
| 237 |
+
f"link {lid}: missing from node {src} output[{src_slot}].links={out_links}"
|
| 238 |
+
)
|
| 239 |
+
if isinstance(dst_slot, int) and 0 <= dst_slot < len(dst_ins):
|
| 240 |
+
in_link = dst_ins[dst_slot].get("link")
|
| 241 |
+
if in_link != lid:
|
| 242 |
+
problems.append(
|
| 243 |
+
f"link {lid}: node {dst} input[{dst_slot}].link={in_link} "
|
| 244 |
+
f"does not match"
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
return problems
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def main(argv: list[str] | None = None) -> int:
|
| 251 |
+
parser = argparse.ArgumentParser(
|
| 252 |
+
description=__doc__.splitlines()[0],
|
| 253 |
+
)
|
| 254 |
+
parser.add_argument(
|
| 255 |
+
"path",
|
| 256 |
+
type=Path,
|
| 257 |
+
help="Path to the workflow JSON file.",
|
| 258 |
+
)
|
| 259 |
+
parser.add_argument(
|
| 260 |
+
"-q",
|
| 261 |
+
"--quiet",
|
| 262 |
+
action="store_true",
|
| 263 |
+
help="Suppress the OK summary; only print problems.",
|
| 264 |
+
)
|
| 265 |
+
args = parser.parse_args(argv)
|
| 266 |
+
|
| 267 |
+
if not args.path.exists():
|
| 268 |
+
print(f"error: file not found: {args.path}", file=sys.stderr)
|
| 269 |
+
return 2
|
| 270 |
+
|
| 271 |
+
try:
|
| 272 |
+
data = json.loads(args.path.read_text(encoding="utf-8"))
|
| 273 |
+
except json.JSONDecodeError as exc:
|
| 274 |
+
print(f"error: {args.path.name}: JSON parse error: {exc}", file=sys.stderr)
|
| 275 |
+
return 2
|
| 276 |
+
|
| 277 |
+
problems = validate(data)
|
| 278 |
+
if problems:
|
| 279 |
+
print(
|
| 280 |
+
f"FAIL: {args.path.name} ({len(problems)} problem(s))",
|
| 281 |
+
file=sys.stderr,
|
| 282 |
+
)
|
| 283 |
+
for p in problems:
|
| 284 |
+
print(f" - {p}", file=sys.stderr)
|
| 285 |
+
return 1
|
| 286 |
+
|
| 287 |
+
if not args.quiet:
|
| 288 |
+
print(
|
| 289 |
+
f"OK: {args.path.name} — "
|
| 290 |
+
f"{len(data.get('nodes') or [])} nodes, "
|
| 291 |
+
f"{len(data.get('links') or [])} links, "
|
| 292 |
+
f"{len(data.get('groups') or [])} groups"
|
| 293 |
+
)
|
| 294 |
+
return 0
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
if __name__ == "__main__":
|
| 298 |
+
sys.exit(main())
|
ComfyUI/custom_nodes/koolook/scripts/watch_cards.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Watch the make-card working folder; auto-render a card whenever a JSON
|
| 2 |
+
is added or saved.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/watch_cards.py # uses .claude/skills/make-card/work-folder.txt
|
| 6 |
+
python scripts/watch_cards.py <folder> # override
|
| 7 |
+
python scripts/watch_cards.py --interval 1 # poll every 1 s (default 2)
|
| 8 |
+
|
| 9 |
+
Run it once in a terminal at the start of a session and forget about it.
|
| 10 |
+
Each time ComfyUI (or you) drops a new workflow JSON into the folder, a
|
| 11 |
+
matching `<stem>_card.png` is rendered next to it within a couple of
|
| 12 |
+
seconds. Skips JSONs whose card is already up to date.
|
| 13 |
+
|
| 14 |
+
Stop with Ctrl+C.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
import subprocess
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 23 |
+
MAKE_CARD = ROOT / "scripts" / "make_card.py"
|
| 24 |
+
DEFAULT_CFG = ROOT / ".claude" / "skills" / "make-card" / "work-folder.txt"
|
| 25 |
+
PYTHON_EXE = sys.executable or "python"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def resolve_folder(argv: list[str]) -> Path:
|
| 29 |
+
args = [a for a in argv if not a.startswith("-")]
|
| 30 |
+
if args:
|
| 31 |
+
return Path(args[0]).expanduser().resolve()
|
| 32 |
+
if DEFAULT_CFG.exists():
|
| 33 |
+
return Path(DEFAULT_CFG.read_text(encoding="utf-8").strip()).resolve()
|
| 34 |
+
raise SystemExit(f"No folder argument and no config at {DEFAULT_CFG}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def needs_render(json_path: Path) -> Path | None:
|
| 38 |
+
"""Return the target PNG path if it's missing or older than the JSON.
|
| 39 |
+
Target lives in <working folder>/_AI/card.png — matches make_card.py."""
|
| 40 |
+
ai_dir = json_path.parent / "_AI"
|
| 41 |
+
png = ai_dir / "card.png"
|
| 42 |
+
if not png.exists() or png.stat().st_mtime < json_path.stat().st_mtime:
|
| 43 |
+
return png
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def render(json_path: Path, png_path: Path) -> bool:
|
| 48 |
+
print(f"[{time.strftime('%H:%M:%S')}] rendering {json_path.name} ...", flush=True)
|
| 49 |
+
# Let make_card.py compute its own output path (writes to _AI/card.png).
|
| 50 |
+
# Passing only the JSON keeps the watcher and the script in lockstep.
|
| 51 |
+
r = subprocess.run(
|
| 52 |
+
[PYTHON_EXE, str(MAKE_CARD), str(json_path)],
|
| 53 |
+
capture_output=True, text=True,
|
| 54 |
+
)
|
| 55 |
+
if r.returncode == 0:
|
| 56 |
+
print(f" -> {png_path.relative_to(png_path.parent.parent)}", flush=True)
|
| 57 |
+
return True
|
| 58 |
+
print(f" !! make_card.py failed (rc={r.returncode})\n{r.stderr}", flush=True)
|
| 59 |
+
return False
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def main():
|
| 63 |
+
interval = 2.0
|
| 64 |
+
if "--interval" in sys.argv:
|
| 65 |
+
i = sys.argv.index("--interval")
|
| 66 |
+
interval = float(sys.argv[i + 1])
|
| 67 |
+
folder = resolve_folder(sys.argv[1:])
|
| 68 |
+
if not folder.is_dir():
|
| 69 |
+
raise SystemExit(f"Folder does not exist: {folder}")
|
| 70 |
+
|
| 71 |
+
print(f"watching {folder} (every {interval}s, Ctrl+C to stop)", flush=True)
|
| 72 |
+
seen_mtimes: dict[Path, float] = {}
|
| 73 |
+
# Skip post-loop outputs — the user's composited card+video saves a
|
| 74 |
+
# JSON with "loop" in its name; treating it as input would feed the
|
| 75 |
+
# script its own output.
|
| 76 |
+
SKIP_MARKER = "loop"
|
| 77 |
+
try:
|
| 78 |
+
while True:
|
| 79 |
+
for json_path in folder.glob("*.json"):
|
| 80 |
+
if SKIP_MARKER in json_path.stem.lower():
|
| 81 |
+
continue
|
| 82 |
+
mtime = json_path.stat().st_mtime
|
| 83 |
+
if seen_mtimes.get(json_path) == mtime:
|
| 84 |
+
continue
|
| 85 |
+
target = needs_render(json_path)
|
| 86 |
+
if target is not None:
|
| 87 |
+
render(json_path, target)
|
| 88 |
+
seen_mtimes[json_path] = mtime
|
| 89 |
+
time.sleep(interval)
|
| 90 |
+
except KeyboardInterrupt:
|
| 91 |
+
print("\nstopped.", flush=True)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
main()
|
ComfyUI/custom_nodes/koolook/tests/forks/__init__.py
ADDED
|
File without changes
|
ComfyUI/custom_nodes/koolook/tests/forks/test_whatdreamscost_audio_transcript.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Koolook's Director-level audio transcript hook."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import importlib.util
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
import types
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _install_import_stubs(monkeypatch):
|
| 14 |
+
class _ComfyNode:
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
class _IoStub(types.SimpleNamespace):
|
| 18 |
+
def Custom(self, name):
|
| 19 |
+
return name
|
| 20 |
+
|
| 21 |
+
def __getattr__(self, _name):
|
| 22 |
+
return types.SimpleNamespace(
|
| 23 |
+
Input=lambda *args, **kwargs: None,
|
| 24 |
+
Output=lambda *args, **kwargs: None,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
comfy = types.ModuleType("comfy")
|
| 28 |
+
comfy.model_management = types.ModuleType("comfy.model_management")
|
| 29 |
+
comfy.ldm = types.ModuleType("comfy.ldm")
|
| 30 |
+
comfy.ldm.modules = types.ModuleType("comfy.ldm.modules")
|
| 31 |
+
comfy.ldm.modules.attention = types.ModuleType("comfy.ldm.modules.attention")
|
| 32 |
+
|
| 33 |
+
comfy_api = types.ModuleType("comfy_api")
|
| 34 |
+
comfy_api.latest = types.ModuleType("comfy_api.latest")
|
| 35 |
+
comfy_api.latest.io = _IoStub(
|
| 36 |
+
ComfyNode=_ComfyNode,
|
| 37 |
+
NodeOutput=lambda *args: args,
|
| 38 |
+
Schema=lambda *args, **kwargs: None,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
torch = types.ModuleType("torch")
|
| 42 |
+
torch.Tensor = object
|
| 43 |
+
torch.float32 = object()
|
| 44 |
+
numpy = types.ModuleType("numpy")
|
| 45 |
+
numpy.float32 = object()
|
| 46 |
+
pil = types.ModuleType("PIL")
|
| 47 |
+
pil.Image = object()
|
| 48 |
+
monkeypatch.setitem(sys.modules, "torch", torch)
|
| 49 |
+
monkeypatch.setitem(sys.modules, "numpy", numpy)
|
| 50 |
+
monkeypatch.setitem(sys.modules, "PIL", pil)
|
| 51 |
+
monkeypatch.setitem(sys.modules, "PIL.Image", pil.Image)
|
| 52 |
+
monkeypatch.setitem(sys.modules, "av", types.ModuleType("av"))
|
| 53 |
+
monkeypatch.setitem(sys.modules, "folder_paths", types.ModuleType("folder_paths"))
|
| 54 |
+
monkeypatch.setitem(sys.modules, "comfy", comfy)
|
| 55 |
+
monkeypatch.setitem(sys.modules, "comfy.model_management", comfy.model_management)
|
| 56 |
+
monkeypatch.setitem(sys.modules, "comfy.ldm", comfy.ldm)
|
| 57 |
+
monkeypatch.setitem(sys.modules, "comfy.ldm.modules", comfy.ldm.modules)
|
| 58 |
+
monkeypatch.setitem(sys.modules, "comfy.ldm.modules.attention", comfy.ldm.modules.attention)
|
| 59 |
+
monkeypatch.setitem(sys.modules, "comfy_api", comfy_api)
|
| 60 |
+
monkeypatch.setitem(sys.modules, "comfy_api.latest", comfy_api.latest)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _load_director(monkeypatch):
|
| 64 |
+
_install_import_stubs(monkeypatch)
|
| 65 |
+
repo_root = Path(__file__).resolve().parents[2]
|
| 66 |
+
version_dir = repo_root / "forks" / "whatdreamscost_koolook" / "versions" / "v1_3_9"
|
| 67 |
+
package_names = [
|
| 68 |
+
("forks", repo_root / "forks"),
|
| 69 |
+
("forks.whatdreamscost_koolook", repo_root / "forks" / "whatdreamscost_koolook"),
|
| 70 |
+
("forks.whatdreamscost_koolook.versions", repo_root / "forks" / "whatdreamscost_koolook" / "versions"),
|
| 71 |
+
("forks.whatdreamscost_koolook.versions.v1_3_9", version_dir),
|
| 72 |
+
]
|
| 73 |
+
for name, path in package_names:
|
| 74 |
+
package = types.ModuleType(name)
|
| 75 |
+
package.__path__ = [str(path)]
|
| 76 |
+
monkeypatch.setitem(sys.modules, name, package)
|
| 77 |
+
|
| 78 |
+
module_name = "forks.whatdreamscost_koolook.versions.v1_3_9.ltx_director"
|
| 79 |
+
spec = importlib.util.spec_from_file_location(module_name, version_dir / "ltx_director.py")
|
| 80 |
+
assert spec and spec.loader
|
| 81 |
+
director = importlib.util.module_from_spec(spec)
|
| 82 |
+
monkeypatch.setitem(sys.modules, module_name, director)
|
| 83 |
+
spec.loader.exec_module(director)
|
| 84 |
+
return director
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_audio_transcript_json_preserves_existing_timeline_segments(monkeypatch):
|
| 88 |
+
director = _load_director(monkeypatch)
|
| 89 |
+
transcript = {
|
| 90 |
+
"phrases": [
|
| 91 |
+
{"start": 0.0, "end": 0.5, "text": "What?"},
|
| 92 |
+
{"start": 1.06, "end": 1.74, "text": "What's going on?"},
|
| 93 |
+
{"start": 1.96, "end": 2.82, "text": "This is made by AI."},
|
| 94 |
+
]
|
| 95 |
+
}
|
| 96 |
+
timeline_data, local_prompts, segment_lengths = director._apply_audio_transcript_json(
|
| 97 |
+
json.dumps(
|
| 98 |
+
{
|
| 99 |
+
"segments": [
|
| 100 |
+
{
|
| 101 |
+
"id": "old",
|
| 102 |
+
"type": "image",
|
| 103 |
+
"start": 0,
|
| 104 |
+
"length": 120,
|
| 105 |
+
"imageFile": "bear.png",
|
| 106 |
+
"prompt": "old prompt",
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
"audioSegments": [{"id": "audio_001", "audioFile": "line.mp3"}],
|
| 110 |
+
}
|
| 111 |
+
),
|
| 112 |
+
"old local",
|
| 113 |
+
"120",
|
| 114 |
+
json.dumps(transcript),
|
| 115 |
+
duration_frames=120,
|
| 116 |
+
frame_rate=24.0,
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
timeline = json.loads(timeline_data)
|
| 120 |
+
assert segment_lengths == "12,13,17,5,21,52"
|
| 121 |
+
assert 'says "What?"' in local_prompts
|
| 122 |
+
assert 'says "What\'s going on?"' in local_prompts
|
| 123 |
+
assert 'says "This is made by AI."' in local_prompts
|
| 124 |
+
assert timeline["audioSegments"] == [{"id": "audio_001", "audioFile": "line.mp3"}]
|
| 125 |
+
assert timeline["segments"] == [
|
| 126 |
+
{
|
| 127 |
+
"id": "old",
|
| 128 |
+
"type": "image",
|
| 129 |
+
"start": 0,
|
| 130 |
+
"length": 120,
|
| 131 |
+
"imageFile": "bear.png",
|
| 132 |
+
"prompt": "old prompt",
|
| 133 |
+
}
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_audio_transcript_json_builds_segments_when_timeline_has_none(monkeypatch):
|
| 138 |
+
director = _load_director(monkeypatch)
|
| 139 |
+
transcript = {"phrases": [{"start": 0.0, "end": 0.5, "text": "What?"}]}
|
| 140 |
+
|
| 141 |
+
timeline_data, local_prompts, segment_lengths = director._apply_audio_transcript_json(
|
| 142 |
+
json.dumps({"audioSegments": [{"id": "audio_001", "audioFile": "line.mp3"}]}),
|
| 143 |
+
"old local",
|
| 144 |
+
"120",
|
| 145 |
+
json.dumps(transcript),
|
| 146 |
+
duration_frames=24,
|
| 147 |
+
frame_rate=24.0,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
timeline = json.loads(timeline_data)
|
| 151 |
+
assert segment_lengths == "12,12"
|
| 152 |
+
assert 'says "What?"' in local_prompts
|
| 153 |
+
assert [segment["start"] for segment in timeline["segments"]] == [0, 12]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_audio_transcript_json_rejects_empty_phrases(monkeypatch):
|
| 157 |
+
director = _load_director(monkeypatch)
|
| 158 |
+
|
| 159 |
+
with pytest.raises(ValueError, match="phrases must not be empty"):
|
| 160 |
+
director._apply_audio_transcript_json(
|
| 161 |
+
"{}",
|
| 162 |
+
"",
|
| 163 |
+
"",
|
| 164 |
+
json.dumps({"phrases": []}),
|
| 165 |
+
duration_frames=24,
|
| 166 |
+
frame_rate=24.0,
|
| 167 |
+
)
|
ComfyUI/custom_nodes/koolook/tests/forks/test_whatdreamscost_v1_3_2_relay_overrides.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the LTXDirector ``relay_overrides`` widget parser.
|
| 2 |
+
|
| 3 |
+
These guard the failure modes flagged in the PR #178 verified review
|
| 4 |
+
(H1 — values were never type-checked; H2 — malformed JSON silently fell
|
| 5 |
+
back to upstream defaults and polluted A/B iteration logs). The parser
|
| 6 |
+
lives in
|
| 7 |
+
``forks/whatdreamscost_koolook/versions/v1_3_2/_relay_overrides.py`` —
|
| 8 |
+
deliberately stdlib-only so we can import it here without pulling in
|
| 9 |
+
ComfyUI's ``comfy_api`` runtime (which the rest of ``ltx_director.py``
|
| 10 |
+
needs and which can't run inside pytest).
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import importlib.util
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Load the parser module by file path. We avoid the normal package import
|
| 23 |
+
# (``from forks.whatdreamscost_koolook... import ...``) because walking
|
| 24 |
+
# into that package triggers ``forks/whatdreamscost_koolook/versions/
|
| 25 |
+
# v1_3_2/__init__.py``, which imports ``ltx_director`` → ``comfy_api`` —
|
| 26 |
+
# ComfyUI-only and unavailable inside pytest. The parser module itself
|
| 27 |
+
# is pure stdlib + logging, so loading it in isolation is fine.
|
| 28 |
+
_RELAY_PATH = (
|
| 29 |
+
Path(__file__).resolve().parents[2]
|
| 30 |
+
/ "forks"
|
| 31 |
+
/ "whatdreamscost_koolook"
|
| 32 |
+
/ "versions"
|
| 33 |
+
/ "v1_3_2"
|
| 34 |
+
/ "_relay_overrides.py"
|
| 35 |
+
)
|
| 36 |
+
_spec = importlib.util.spec_from_file_location(
|
| 37 |
+
"whatdreamscost_v1_3_2_relay_overrides", _RELAY_PATH
|
| 38 |
+
)
|
| 39 |
+
_mod = importlib.util.module_from_spec(_spec)
|
| 40 |
+
_spec.loader.exec_module(_mod)
|
| 41 |
+
|
| 42 |
+
RELAY_OVERRIDE_KEYS = _mod.RELAY_OVERRIDE_KEYS
|
| 43 |
+
parse_relay_overrides = _mod.parse_relay_overrides
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# Silent fallback: only the empty/whitespace case should return None.
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pytest.mark.parametrize("blank", ["", " ", "\n", "\t \n"])
|
| 52 |
+
def test_empty_or_whitespace_means_use_upstream_defaults(blank):
|
| 53 |
+
assert parse_relay_overrides(blank) is None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# Happy path: known knobs with numeric values get coerced to float.
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_single_known_knob():
|
| 62 |
+
out = parse_relay_overrides('{"video_strength": 10.0}')
|
| 63 |
+
assert out == {"video_strength": 10.0}
|
| 64 |
+
assert isinstance(out["video_strength"], float)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_all_known_knobs():
|
| 68 |
+
payload = {
|
| 69 |
+
"video_strength": 2.5,
|
| 70 |
+
"video_window_scale": 0.7,
|
| 71 |
+
"audio_strength": 0.5,
|
| 72 |
+
"audio_window_scale": 1.2,
|
| 73 |
+
"audio_epsilon": 0.001,
|
| 74 |
+
}
|
| 75 |
+
assert parse_relay_overrides(json.dumps(payload)) == payload
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@pytest.mark.parametrize(
|
| 79 |
+
"raw,expected",
|
| 80 |
+
[
|
| 81 |
+
('{"video_strength": 10}', 10.0), # int coerced to float
|
| 82 |
+
('{"video_strength": "10"}', 10.0), # numeric string coerced
|
| 83 |
+
('{"video_strength": "10.5"}', 10.5), # decimal string coerced
|
| 84 |
+
('{"video_strength": 1e2}', 100.0), # scientific notation
|
| 85 |
+
('{"video_strength": -1.5}', -1.5), # negatives flow through
|
| 86 |
+
],
|
| 87 |
+
)
|
| 88 |
+
def test_numeric_values_are_coerced_to_float(raw, expected):
|
| 89 |
+
out = parse_relay_overrides(raw)
|
| 90 |
+
assert out == {"video_strength": expected}
|
| 91 |
+
assert isinstance(out["video_strength"], float)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Comment keys: underscore-prefixed entries are silently dropped.
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_underscore_prefixed_keys_are_dropped_silently(caplog):
|
| 100 |
+
raw = '{"_doc": "trial 3", "_owner": "daisy", "video_strength": 5}'
|
| 101 |
+
with caplog.at_level(logging.WARNING):
|
| 102 |
+
out = parse_relay_overrides(raw)
|
| 103 |
+
assert out == {"video_strength": 5.0}
|
| 104 |
+
# No warning was emitted for the comment keys.
|
| 105 |
+
assert "unknown keys" not in caplog.text
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_only_comment_keys_returns_none():
|
| 109 |
+
assert parse_relay_overrides('{"_doc": "no overrides yet"}') is None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# Unknown keys: warn-and-drop, don't silently misroute the operator's
|
| 114 |
+
# typo into the default-render bucket.
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_unknown_key_logs_warning_and_drops_it(caplog):
|
| 119 |
+
raw = '{"viedo_strength": 10, "video_strength": 2.5}'
|
| 120 |
+
with caplog.at_level(logging.WARNING):
|
| 121 |
+
out = parse_relay_overrides(raw)
|
| 122 |
+
assert out == {"video_strength": 2.5}
|
| 123 |
+
assert "unknown keys" in caplog.text.lower()
|
| 124 |
+
assert "viedo_strength" in caplog.text
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_only_unknown_keys_returns_none(caplog):
|
| 128 |
+
with caplog.at_level(logging.WARNING):
|
| 129 |
+
out = parse_relay_overrides('{"bogus": 1, "also_bogus": 2}')
|
| 130 |
+
assert out is None
|
| 131 |
+
assert "unknown keys" in caplog.text.lower()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
# H2 — malformed JSON / non-object JSON must raise (not silently use
|
| 136 |
+
# upstream defaults, which previously polluted A/B comparisons).
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@pytest.mark.parametrize(
|
| 141 |
+
"raw",
|
| 142 |
+
[
|
| 143 |
+
'{"video_strength":10.0', # missing closing brace
|
| 144 |
+
'{"video_strength": 10,}', # trailing comma
|
| 145 |
+
'{video_strength: 10}', # unquoted key
|
| 146 |
+
"not json at all",
|
| 147 |
+
],
|
| 148 |
+
)
|
| 149 |
+
def test_malformed_json_raises(raw):
|
| 150 |
+
with pytest.raises(ValueError, match="not valid JSON"):
|
| 151 |
+
parse_relay_overrides(raw)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@pytest.mark.parametrize(
|
| 155 |
+
"raw",
|
| 156 |
+
[
|
| 157 |
+
'[1, 2, 3]', # array
|
| 158 |
+
'"video_strength"', # string
|
| 159 |
+
"42", # number
|
| 160 |
+
"true", # bool
|
| 161 |
+
"null", # null
|
| 162 |
+
],
|
| 163 |
+
)
|
| 164 |
+
def test_non_object_json_raises(raw):
|
| 165 |
+
with pytest.raises(ValueError, match="must be a JSON object"):
|
| 166 |
+
parse_relay_overrides(raw)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ---------------------------------------------------------------------------
|
| 170 |
+
# H1 — a known knob with a non-numeric value must raise at parse time,
|
| 171 |
+
# not blow up deep inside the sampler arithmetic.
|
| 172 |
+
# ---------------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@pytest.mark.parametrize(
|
| 176 |
+
"raw",
|
| 177 |
+
[
|
| 178 |
+
'{"video_strength": "ten"}',
|
| 179 |
+
'{"video_strength": true}',
|
| 180 |
+
'{"video_strength": null}',
|
| 181 |
+
'{"video_strength": [1, 2]}',
|
| 182 |
+
'{"video_strength": {"nested": 1}}',
|
| 183 |
+
'{"audio_epsilon": "very small"}',
|
| 184 |
+
],
|
| 185 |
+
)
|
| 186 |
+
def test_non_numeric_known_knob_raises(raw):
|
| 187 |
+
with pytest.raises(ValueError, match="must be a number"):
|
| 188 |
+
parse_relay_overrides(raw)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_error_message_names_offending_knob_and_shows_example():
|
| 192 |
+
"""The error message must let the operator fix the widget without
|
| 193 |
+
consulting upstream Prompt-Relay source."""
|
| 194 |
+
with pytest.raises(ValueError) as exc:
|
| 195 |
+
parse_relay_overrides('{"video_window_scale": "narrow"}')
|
| 196 |
+
msg = str(exc.value)
|
| 197 |
+
assert "video_window_scale" in msg
|
| 198 |
+
assert "Example:" in msg
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
# Allowlist sanity: documented keys match implementation.
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_documented_knob_set_matches_implementation():
|
| 207 |
+
"""If anyone adds a knob in build_segments without updating the
|
| 208 |
+
allowlist, this test fails — preventing silent typos-disguised-as-
|
| 209 |
+
knobs that the H1 fix is meant to catch."""
|
| 210 |
+
assert RELAY_OVERRIDE_KEYS == {
|
| 211 |
+
"video_strength": float,
|
| 212 |
+
"video_window_scale": float,
|
| 213 |
+
"audio_strength": float,
|
| 214 |
+
"audio_window_scale": float,
|
| 215 |
+
"audio_epsilon": float,
|
| 216 |
+
}
|
ComfyUI/custom_nodes/koolook/tests/forks/test_whatdreamscost_v1_3_9_relay_overrides.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the LTXDirector ``relay_overrides`` widget parser.
|
| 2 |
+
|
| 3 |
+
These guard the failure modes flagged in the PR #185 verified review
|
| 4 |
+
(H1 — values were never type-checked; H2 — malformed JSON silently fell
|
| 5 |
+
back to upstream defaults and polluted A/B iteration logs). The parser
|
| 6 |
+
lives in
|
| 7 |
+
``forks/whatdreamscost_koolook/versions/v1_3_9/_relay_overrides.py`` —
|
| 8 |
+
deliberately stdlib-only so we can import it here without pulling in
|
| 9 |
+
ComfyUI's ``comfy_api`` runtime (which the rest of ``ltx_director.py``
|
| 10 |
+
needs and which can't run inside pytest).
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import importlib.util
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Load the parser module by file path. We avoid the normal package import
|
| 23 |
+
# (``from forks.whatdreamscost_koolook... import ...``) because walking
|
| 24 |
+
# into that package triggers ``forks/whatdreamscost_koolook/versions/
|
| 25 |
+
# v1_3_9/__init__.py``, which imports ``ltx_director`` → ``comfy_api`` —
|
| 26 |
+
# ComfyUI-only and unavailable inside pytest. The parser module itself
|
| 27 |
+
# is pure stdlib + logging, so loading it in isolation is fine.
|
| 28 |
+
_RELAY_PATH = (
|
| 29 |
+
Path(__file__).resolve().parents[2]
|
| 30 |
+
/ "forks"
|
| 31 |
+
/ "whatdreamscost_koolook"
|
| 32 |
+
/ "versions"
|
| 33 |
+
/ "v1_3_9"
|
| 34 |
+
/ "_relay_overrides.py"
|
| 35 |
+
)
|
| 36 |
+
_spec = importlib.util.spec_from_file_location(
|
| 37 |
+
"whatdreamscost_v1_3_9_relay_overrides", _RELAY_PATH
|
| 38 |
+
)
|
| 39 |
+
_mod = importlib.util.module_from_spec(_spec)
|
| 40 |
+
_spec.loader.exec_module(_mod)
|
| 41 |
+
|
| 42 |
+
RELAY_OVERRIDE_KEYS = _mod.RELAY_OVERRIDE_KEYS
|
| 43 |
+
parse_relay_overrides = _mod.parse_relay_overrides
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# Silent fallback: only the empty/whitespace case should return None.
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pytest.mark.parametrize("blank", ["", " ", "\n", "\t \n"])
|
| 52 |
+
def test_empty_or_whitespace_means_use_upstream_defaults(blank):
|
| 53 |
+
assert parse_relay_overrides(blank) is None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# Happy path: known knobs with numeric values get coerced to float.
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_single_known_knob():
|
| 62 |
+
out = parse_relay_overrides('{"video_strength": 10.0}')
|
| 63 |
+
assert out == {"video_strength": 10.0}
|
| 64 |
+
assert isinstance(out["video_strength"], float)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_all_known_knobs():
|
| 68 |
+
payload = {
|
| 69 |
+
"video_strength": 2.5,
|
| 70 |
+
"video_window_scale": 0.7,
|
| 71 |
+
"audio_strength": 0.5,
|
| 72 |
+
"audio_window_scale": 1.2,
|
| 73 |
+
"audio_epsilon": 0.001,
|
| 74 |
+
}
|
| 75 |
+
assert parse_relay_overrides(json.dumps(payload)) == payload
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@pytest.mark.parametrize(
|
| 79 |
+
"raw,expected",
|
| 80 |
+
[
|
| 81 |
+
('{"video_strength": 10}', 10.0), # int coerced to float
|
| 82 |
+
('{"video_strength": "10"}', 10.0), # numeric string coerced
|
| 83 |
+
('{"video_strength": "10.5"}', 10.5), # decimal string coerced
|
| 84 |
+
('{"video_strength": 1e2}', 100.0), # scientific notation
|
| 85 |
+
('{"video_strength": -1.5}', -1.5), # negatives flow through
|
| 86 |
+
],
|
| 87 |
+
)
|
| 88 |
+
def test_numeric_values_are_coerced_to_float(raw, expected):
|
| 89 |
+
out = parse_relay_overrides(raw)
|
| 90 |
+
assert out == {"video_strength": expected}
|
| 91 |
+
assert isinstance(out["video_strength"], float)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@pytest.mark.parametrize(
|
| 95 |
+
"raw",
|
| 96 |
+
[
|
| 97 |
+
"video_strength: 10.0",
|
| 98 |
+
'"video_strength": 10.0',
|
| 99 |
+
"video_strength = 10.0",
|
| 100 |
+
"{\nvideo_strength: 10.0,\nvideo_window_scale: 0.75\n}",
|
| 101 |
+
],
|
| 102 |
+
)
|
| 103 |
+
def test_text_multiline_key_value_blocks_are_accepted(raw):
|
| 104 |
+
out = parse_relay_overrides(raw)
|
| 105 |
+
assert out["video_strength"] == 10.0
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_stacked_json_objects_are_merged():
|
| 109 |
+
raw = '{"video_strength": 10.0}\n{"video_window_scale": 0.75}'
|
| 110 |
+
assert parse_relay_overrides(raw) == {
|
| 111 |
+
"video_strength": 10.0,
|
| 112 |
+
"video_window_scale": 0.75,
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# Comment keys: underscore-prefixed entries are silently dropped.
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_underscore_prefixed_keys_are_dropped_silently(caplog):
|
| 122 |
+
raw = '{"_doc": "trial 3", "_owner": "daisy", "video_strength": 5}'
|
| 123 |
+
with caplog.at_level(logging.WARNING):
|
| 124 |
+
out = parse_relay_overrides(raw)
|
| 125 |
+
assert out == {"video_strength": 5.0}
|
| 126 |
+
# No warning was emitted for the comment keys.
|
| 127 |
+
assert "unknown keys" not in caplog.text
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_only_comment_keys_returns_none():
|
| 131 |
+
assert parse_relay_overrides('{"_doc": "no overrides yet"}') is None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
# Unknown keys: warn-and-drop, don't silently misroute the operator's
|
| 136 |
+
# typo into the default-render bucket.
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_unknown_key_logs_warning_and_drops_it(caplog):
|
| 141 |
+
raw = '{"viedo_strength": 10, "video_strength": 2.5}'
|
| 142 |
+
with caplog.at_level(logging.WARNING):
|
| 143 |
+
out = parse_relay_overrides(raw)
|
| 144 |
+
assert out == {"video_strength": 2.5}
|
| 145 |
+
assert "unknown keys" in caplog.text.lower()
|
| 146 |
+
assert "viedo_strength" in caplog.text
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_only_unknown_keys_returns_none(caplog):
|
| 150 |
+
with caplog.at_level(logging.WARNING):
|
| 151 |
+
out = parse_relay_overrides('{"bogus": 1, "also_bogus": 2}')
|
| 152 |
+
assert out is None
|
| 153 |
+
assert "unknown keys" in caplog.text.lower()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
# H2 — malformed JSON / non-object JSON must raise (not silently use
|
| 158 |
+
# upstream defaults, which previously polluted A/B comparisons).
|
| 159 |
+
# ---------------------------------------------------------------------------
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@pytest.mark.parametrize(
|
| 163 |
+
"raw",
|
| 164 |
+
[
|
| 165 |
+
'{"video_strength":10.0', # missing closing brace
|
| 166 |
+
"not json at all",
|
| 167 |
+
],
|
| 168 |
+
)
|
| 169 |
+
def test_malformed_json_raises(raw):
|
| 170 |
+
with pytest.raises(ValueError, match="not valid JSON or a supported"):
|
| 171 |
+
parse_relay_overrides(raw)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@pytest.mark.parametrize(
|
| 175 |
+
"raw",
|
| 176 |
+
[
|
| 177 |
+
'[1, 2, 3]', # array
|
| 178 |
+
'"video_strength"', # string
|
| 179 |
+
"42", # number
|
| 180 |
+
"true", # bool
|
| 181 |
+
"null", # null
|
| 182 |
+
],
|
| 183 |
+
)
|
| 184 |
+
def test_non_object_json_raises(raw):
|
| 185 |
+
with pytest.raises(ValueError, match="must be a JSON object"):
|
| 186 |
+
parse_relay_overrides(raw)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ---------------------------------------------------------------------------
|
| 190 |
+
# H1 — a known knob with a non-numeric value must raise at parse time,
|
| 191 |
+
# not blow up deep inside the sampler arithmetic.
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@pytest.mark.parametrize(
|
| 196 |
+
"raw",
|
| 197 |
+
[
|
| 198 |
+
'{"video_strength": "ten"}',
|
| 199 |
+
'{"video_strength": true}',
|
| 200 |
+
'{"video_strength": null}',
|
| 201 |
+
'{"video_strength": [1, 2]}',
|
| 202 |
+
'{"video_strength": {"nested": 1}}',
|
| 203 |
+
'{"audio_epsilon": "very small"}',
|
| 204 |
+
],
|
| 205 |
+
)
|
| 206 |
+
def test_non_numeric_known_knob_raises(raw):
|
| 207 |
+
with pytest.raises(ValueError, match="must be a number"):
|
| 208 |
+
parse_relay_overrides(raw)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_error_message_names_offending_knob_and_shows_example():
|
| 212 |
+
"""The error message must let the operator fix the widget without
|
| 213 |
+
consulting upstream Prompt-Relay source."""
|
| 214 |
+
with pytest.raises(ValueError) as exc:
|
| 215 |
+
parse_relay_overrides('{"video_window_scale": "narrow"}')
|
| 216 |
+
msg = str(exc.value)
|
| 217 |
+
assert "video_window_scale" in msg
|
| 218 |
+
assert "Example:" in msg
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
# Allowlist sanity: documented keys match implementation.
|
| 223 |
+
# ---------------------------------------------------------------------------
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def test_documented_knob_set_matches_implementation():
|
| 227 |
+
"""If anyone adds a knob in build_segments without updating the
|
| 228 |
+
allowlist, this test fails — preventing silent typos-disguised-as-
|
| 229 |
+
knobs that the H1 fix is meant to catch."""
|
| 230 |
+
assert RELAY_OVERRIDE_KEYS == {
|
| 231 |
+
"video_strength": float,
|
| 232 |
+
"video_window_scale": float,
|
| 233 |
+
"audio_strength": float,
|
| 234 |
+
"audio_window_scale": float,
|
| 235 |
+
"audio_epsilon": float,
|
| 236 |
+
}
|
ComfyUI/custom_nodes/koolook/tests/js/test_ai_pipeline_preview.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from "node:assert/strict";
|
| 2 |
+
import fs from "node:fs";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import vm from "node:vm";
|
| 5 |
+
|
| 6 |
+
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
| 7 |
+
const sourcePath = path.join(repoRoot, "web", "ai_pipeline.js");
|
| 8 |
+
const source = fs.readFileSync(sourcePath, "utf8")
|
| 9 |
+
.replace(/^import .*;\r?\n/gm, "");
|
| 10 |
+
|
| 11 |
+
const registeredExtensions = [];
|
| 12 |
+
const context = {
|
| 13 |
+
bulletproofStringWidget(widget, fallback = "") {
|
| 14 |
+
if (!widget) return;
|
| 15 |
+
if (widget.value == null) widget.value = fallback;
|
| 16 |
+
else if (typeof widget.value !== "string") widget.value = String(widget.value);
|
| 17 |
+
},
|
| 18 |
+
app: {
|
| 19 |
+
graph: null,
|
| 20 |
+
registerExtension(extension) {
|
| 21 |
+
registeredExtensions.push(extension);
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
ComfyWidgets: {
|
| 25 |
+
STRING(node) {
|
| 26 |
+
const previewWidget = {
|
| 27 |
+
value: "",
|
| 28 |
+
inputEl: { readOnly: false, style: {} },
|
| 29 |
+
};
|
| 30 |
+
node.widgets.push(previewWidget);
|
| 31 |
+
return {
|
| 32 |
+
widget: previewWidget,
|
| 33 |
+
};
|
| 34 |
+
},
|
| 35 |
+
},
|
| 36 |
+
globalThis: {},
|
| 37 |
+
};
|
| 38 |
+
context.globalThis = context;
|
| 39 |
+
|
| 40 |
+
vm.runInNewContext(source, context, { filename: sourcePath });
|
| 41 |
+
|
| 42 |
+
const extension = registeredExtensions.find((entry) => entry.name === "koolook.ai_pipeline");
|
| 43 |
+
assert.ok(extension, "AI pipeline extension should register");
|
| 44 |
+
|
| 45 |
+
function widget(name, value) {
|
| 46 |
+
return { name, value, inputEl: { readOnly: false, style: {} } };
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function makeGraph(nodes, links) {
|
| 50 |
+
return {
|
| 51 |
+
_nodes: nodes,
|
| 52 |
+
_links: Object.fromEntries(links.map((link) => [link.id, link])),
|
| 53 |
+
getLink(id) {
|
| 54 |
+
return this._links[id] ?? null;
|
| 55 |
+
},
|
| 56 |
+
getNodeById(id) {
|
| 57 |
+
return this._nodes.find((node) => node.id === id) ?? null;
|
| 58 |
+
},
|
| 59 |
+
};
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function makePipelineNode(graph) {
|
| 63 |
+
const buttons = [];
|
| 64 |
+
return {
|
| 65 |
+
graph,
|
| 66 |
+
widgets: [
|
| 67 |
+
widget("shot_duration", 81),
|
| 68 |
+
widget("seed_value", 453453453),
|
| 69 |
+
widget("instruction", "Place your base folder path in the FIELD below"),
|
| 70 |
+
widget("base_directory_path", "e:/G-Drive-BaconX/Jobs/Jeep_Animals/ComfyUI_LTX23"),
|
| 71 |
+
widget("extension", ".%04d.exr"),
|
| 72 |
+
widget("shot_name", "stale_widget_name"),
|
| 73 |
+
widget("ai_method", ""),
|
| 74 |
+
widget("version", "001"),
|
| 75 |
+
widget("disable_versioning", false),
|
| 76 |
+
widget("enable_overwrite", false),
|
| 77 |
+
widget("no_subfolders", false),
|
| 78 |
+
],
|
| 79 |
+
inputs: [
|
| 80 |
+
{ name: "shot_duration", link: null, widget: { name: "shot_duration" } },
|
| 81 |
+
{ name: "seed_value", link: null, widget: { name: "seed_value" } },
|
| 82 |
+
{ name: "instruction", link: null, widget: { name: "instruction" } },
|
| 83 |
+
{ name: "base_directory_path", link: null, widget: { name: "base_directory_path" } },
|
| 84 |
+
{ name: "extension", link: null, widget: { name: "extension" } },
|
| 85 |
+
{ name: "shot_name", link: 13, widget: { name: "shot_name" } },
|
| 86 |
+
{ name: "ai_method", link: null, widget: { name: "ai_method" } },
|
| 87 |
+
{ name: "version", link: 14, widget: { name: "version" } },
|
| 88 |
+
{ name: "disable_versioning", link: null, widget: { name: "disable_versioning" } },
|
| 89 |
+
{ name: "enable_overwrite", link: null, widget: { name: "enable_overwrite" } },
|
| 90 |
+
{ name: "no_subfolders", link: null, widget: { name: "no_subfolders" } },
|
| 91 |
+
],
|
| 92 |
+
outputs: [],
|
| 93 |
+
addWidget(type, name, value, callback) {
|
| 94 |
+
const button = { type, name, value, callback };
|
| 95 |
+
buttons.push(button);
|
| 96 |
+
return button;
|
| 97 |
+
},
|
| 98 |
+
setDirtyCanvas() {},
|
| 99 |
+
buttons,
|
| 100 |
+
};
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const namePart = {
|
| 104 |
+
id: 1,
|
| 105 |
+
type: "Text Multiline",
|
| 106 |
+
widgets_values: ["Bear_3x-FR_w.Audio"],
|
| 107 |
+
outputs: [{ name: "STRING", type: "STRING", links: [11] }],
|
| 108 |
+
};
|
| 109 |
+
const suffixPart = {
|
| 110 |
+
id: 2,
|
| 111 |
+
type: "Text Multiline",
|
| 112 |
+
widgets_values: ["EXR"],
|
| 113 |
+
outputs: [{ name: "STRING", type: "STRING", links: [12] }],
|
| 114 |
+
};
|
| 115 |
+
const concat = {
|
| 116 |
+
id: 3,
|
| 117 |
+
type: "Text Concatenate",
|
| 118 |
+
widgets_values: ["_", "true"],
|
| 119 |
+
inputs: [
|
| 120 |
+
{ name: "text_a", type: "STRING", link: 11 },
|
| 121 |
+
{ name: "text_b", type: "STRING", link: 12 },
|
| 122 |
+
{ name: "text_c", type: "STRING", link: null },
|
| 123 |
+
{ name: "text_d", type: "STRING", link: null },
|
| 124 |
+
],
|
| 125 |
+
outputs: [{ name: "STRING", type: "STRING", links: [13] }],
|
| 126 |
+
};
|
| 127 |
+
const versionSource = {
|
| 128 |
+
id: 4,
|
| 129 |
+
type: "Text Multiline",
|
| 130 |
+
widgets_values: ["003"],
|
| 131 |
+
outputs: [{ name: "STRING", type: "STRING", links: [14] }],
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
const graph = makeGraph(
|
| 135 |
+
[namePart, suffixPart, concat, versionSource],
|
| 136 |
+
[
|
| 137 |
+
{ id: 11, origin_id: 1, origin_slot: 0, target_id: 3, target_slot: 0, type: "STRING" },
|
| 138 |
+
{ id: 12, origin_id: 2, origin_slot: 0, target_id: 3, target_slot: 1, type: "STRING" },
|
| 139 |
+
{ id: 13, origin_id: 3, origin_slot: 0, target_id: 99, target_slot: 5, type: "STRING" },
|
| 140 |
+
{ id: 14, origin_id: 4, origin_slot: 0, target_id: 99, target_slot: 7, type: "STRING" },
|
| 141 |
+
],
|
| 142 |
+
);
|
| 143 |
+
context.app.graph = graph;
|
| 144 |
+
|
| 145 |
+
function previewFilePath() {
|
| 146 |
+
const nodeType = function EasyAIPipelineNode() {};
|
| 147 |
+
extension.beforeRegisterNodeDef(nodeType, { name: "EasyAIPipeline" });
|
| 148 |
+
const node = makePipelineNode(graph);
|
| 149 |
+
nodeType.prototype.onNodeCreated.call(node);
|
| 150 |
+
const button = node.buttons.find((entry) => entry.name === "Get output file path");
|
| 151 |
+
assert.ok(button, "preview file-path button should exist");
|
| 152 |
+
button.callback();
|
| 153 |
+
return node.widgets.find((entry) => entry.inputEl?.readOnly && entry.inputEl?.style?.height === "100px").value;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const expected = "e:/G-Drive-BaconX/Jobs/Jeep_Animals/ComfyUI_LTX23/Bear_3x-FR_w.Audio_EXR/v003/Bear_3x-FR_w.Audio_EXR_v003.%04d.exr";
|
| 157 |
+
|
| 158 |
+
const first = previewFilePath();
|
| 159 |
+
const second = previewFilePath();
|
| 160 |
+
|
| 161 |
+
assert.equal(first, expected);
|
| 162 |
+
assert.equal(second, expected);
|
| 163 |
+
assert.ok(!first.includes("/_/"), "Text Concatenate delimiter must not replace the shot name");
|
| 164 |
+
assert.ok(!first.includes("__"), "empty fields must not multiply underscores");
|
| 165 |
+
assert.ok(!first.includes("vv003"), "already-normalized version tokens must not get an extra v");
|
| 166 |
+
|
| 167 |
+
{
|
| 168 |
+
const publishOutput = {
|
| 169 |
+
id: 10,
|
| 170 |
+
type: "Koolook_PublishOutput",
|
| 171 |
+
widgets: [
|
| 172 |
+
widget("folder", "/Volumes/Data/G-Drive-BaconX/Jobs/OndtBlod/001_0035/ai/publish-OUT"),
|
| 173 |
+
widget("name", "publish-OUT"),
|
| 174 |
+
widget("version", "1"),
|
| 175 |
+
],
|
| 176 |
+
outputs: [
|
| 177 |
+
{ name: "folder", type: "STRING", links: [30] },
|
| 178 |
+
{ name: "name", type: "STRING", links: [] },
|
| 179 |
+
{ name: "version", type: "STRING", links: [] },
|
| 180 |
+
],
|
| 181 |
+
};
|
| 182 |
+
const reroute = {
|
| 183 |
+
id: 11,
|
| 184 |
+
type: "Reroute",
|
| 185 |
+
inputs: [{ name: "", type: "STRING", link: 30 }],
|
| 186 |
+
outputs: [{ name: "", type: "STRING", links: [31] }],
|
| 187 |
+
};
|
| 188 |
+
const publishGraph = makeGraph(
|
| 189 |
+
[publishOutput, reroute],
|
| 190 |
+
[
|
| 191 |
+
{ id: 30, origin_id: 10, origin_slot: 0, target_id: 11, target_slot: 0, type: "STRING" },
|
| 192 |
+
{ id: 31, origin_id: 11, origin_slot: 0, target_id: 99, target_slot: 3, type: "STRING" },
|
| 193 |
+
],
|
| 194 |
+
);
|
| 195 |
+
context.app.graph = publishGraph;
|
| 196 |
+
|
| 197 |
+
const nodeType = function EasyAIPipelineNode() {};
|
| 198 |
+
extension.beforeRegisterNodeDef(nodeType, { name: "EasyAIPipeline" });
|
| 199 |
+
const node = makePipelineNode(publishGraph);
|
| 200 |
+
node.inputs.find((input) => input.name === "base_directory_path").link = 31;
|
| 201 |
+
node.inputs.find((input) => input.name === "shot_name").link = null;
|
| 202 |
+
node.inputs.find((input) => input.name === "version").link = null;
|
| 203 |
+
node.widgets.find((entry) => entry.name === "shot_name").value = "mask";
|
| 204 |
+
node.widgets.find((entry) => entry.name === "version").value = "1";
|
| 205 |
+
node.widgets.find((entry) => entry.name === "no_subfolders").value = true;
|
| 206 |
+
nodeType.prototype.onNodeCreated.call(node);
|
| 207 |
+
const button = node.buttons.find((entry) => entry.name === "Get output directory path");
|
| 208 |
+
assert.ok(button, "preview directory button should exist");
|
| 209 |
+
button.callback();
|
| 210 |
+
|
| 211 |
+
const preview = node.widgets.find((entry) => entry.inputEl?.readOnly && entry.inputEl?.style?.height === "100px").value;
|
| 212 |
+
assert.equal(
|
| 213 |
+
preview,
|
| 214 |
+
"/Volumes/Data/G-Drive-BaconX/Jobs/OndtBlod/001_0035/ai/publish-OUT/v001",
|
| 215 |
+
);
|
| 216 |
+
}
|
ComfyUI/custom_nodes/koolook/tests/js/test_draft_guard.mjs
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Behavior tests for web/koolook_draft_guard.js — the generation-agnostic
|
| 2 |
+
// guard around ComfyUI's browser-side workflow draft storage.
|
| 3 |
+
//
|
| 4 |
+
// Black-box: each scenario builds a fake quota-enforcing localStorage,
|
| 5 |
+
// seeds it with draft keys from one or more storage generations, loads the
|
| 6 |
+
// guard source in an isolated vm context (which runs the boot prune and
|
| 7 |
+
// installs the setItem wrapper, exactly like a real page load), then
|
| 8 |
+
// asserts on the resulting storage state / thrown errors / toasts.
|
| 9 |
+
//
|
| 10 |
+
// Storage generations modelled here (see the guard's header comment):
|
| 11 |
+
// V1 unsuffixed Comfy.Workflow.Drafts / Comfy.Workflow.DraftOrder
|
| 12 |
+
// V1 per-workspace ...Drafts:<ws> / ...DraftOrder:<ws>
|
| 13 |
+
// V2 (1.44+) Comfy.Workflow.DraftIndex.v2:<ws> + Comfy.Workflow.Draft.v2:<ws>:<hash>
|
| 14 |
+
//
|
| 15 |
+
// Run: node tests/js/test_draft_guard.mjs
|
| 16 |
+
|
| 17 |
+
import assert from "node:assert/strict";
|
| 18 |
+
import fs from "node:fs";
|
| 19 |
+
import path from "node:path";
|
| 20 |
+
import vm from "node:vm";
|
| 21 |
+
|
| 22 |
+
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
| 23 |
+
const sourcePath = path.join(repoRoot, "web", "koolook_draft_guard.js");
|
| 24 |
+
const source = fs.readFileSync(sourcePath, "utf8");
|
| 25 |
+
|
| 26 |
+
const V1_DRAFTS = "Comfy.Workflow.Drafts";
|
| 27 |
+
const V1_ORDER = "Comfy.Workflow.DraftOrder";
|
| 28 |
+
const v2Index = (ws) => `Comfy.Workflow.DraftIndex.v2:${ws}`;
|
| 29 |
+
const v2Payload = (ws, hash) => `Comfy.Workflow.Draft.v2:${ws}:${hash}`;
|
| 30 |
+
|
| 31 |
+
function makeLocalStorage(initialQuota = Infinity) {
|
| 32 |
+
const map = new Map();
|
| 33 |
+
let quota = initialQuota;
|
| 34 |
+
const used = () => {
|
| 35 |
+
let total = 0;
|
| 36 |
+
for (const [k, v] of map) total += k.length + v.length;
|
| 37 |
+
return total;
|
| 38 |
+
};
|
| 39 |
+
return {
|
| 40 |
+
getItem(k) {
|
| 41 |
+
k = String(k);
|
| 42 |
+
return map.has(k) ? map.get(k) : null;
|
| 43 |
+
},
|
| 44 |
+
setItem(k, v) {
|
| 45 |
+
k = String(k);
|
| 46 |
+
v = String(v);
|
| 47 |
+
const current = map.has(k) ? k.length + map.get(k).length : 0;
|
| 48 |
+
if (used() - current + k.length + v.length > quota) {
|
| 49 |
+
throw new DOMException("Simulated quota exceeded", "QuotaExceededError");
|
| 50 |
+
}
|
| 51 |
+
map.set(k, v);
|
| 52 |
+
},
|
| 53 |
+
removeItem(k) {
|
| 54 |
+
map.delete(String(k));
|
| 55 |
+
},
|
| 56 |
+
key(i) {
|
| 57 |
+
return [...map.keys()][i] ?? null;
|
| 58 |
+
},
|
| 59 |
+
get length() {
|
| 60 |
+
return map.size;
|
| 61 |
+
},
|
| 62 |
+
__map: map,
|
| 63 |
+
__used: used,
|
| 64 |
+
__setQuota(n) {
|
| 65 |
+
quota = n;
|
| 66 |
+
},
|
| 67 |
+
};
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function loadGuard(store, { toasts = [], warns = [], context = null } = {}) {
|
| 71 |
+
const ctx = context ?? {
|
| 72 |
+
localStorage: store,
|
| 73 |
+
DOMException,
|
| 74 |
+
setTimeout: () => 0,
|
| 75 |
+
document: {
|
| 76 |
+
createElement: () => ({ textContent: "", style: {}, remove() {} }),
|
| 77 |
+
body: { appendChild: (el) => toasts.push(el.textContent) },
|
| 78 |
+
},
|
| 79 |
+
console: {
|
| 80 |
+
warn: (...args) => warns.push(args.map(String).join(" ")),
|
| 81 |
+
log() {},
|
| 82 |
+
error() {},
|
| 83 |
+
},
|
| 84 |
+
};
|
| 85 |
+
ctx.globalThis = ctx;
|
| 86 |
+
vm.runInNewContext(source, ctx, { filename: sourcePath });
|
| 87 |
+
return ctx;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function seedV1(store, suffix, entries) {
|
| 91 |
+
const drafts = {};
|
| 92 |
+
const order = [];
|
| 93 |
+
for (const [p, data, updatedAt] of entries) {
|
| 94 |
+
drafts[p] = { data, updatedAt, name: p, isTemporary: false };
|
| 95 |
+
order.push(p);
|
| 96 |
+
}
|
| 97 |
+
store.setItem(V1_DRAFTS + suffix, JSON.stringify(drafts));
|
| 98 |
+
store.setItem(V1_ORDER + suffix, JSON.stringify(order));
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function seedV2(store, ws, entries) {
|
| 102 |
+
const index = { v: 2, updatedAt: 1, order: [], entries: {} };
|
| 103 |
+
for (const [hash, data, updatedAt] of entries) {
|
| 104 |
+
index.order.push(hash);
|
| 105 |
+
index.entries[hash] = { name: hash, isTemporary: false, updatedAt, path: `workflows/${hash}.json` };
|
| 106 |
+
store.setItem(v2Payload(ws, hash), JSON.stringify({ data, updatedAt }));
|
| 107 |
+
}
|
| 108 |
+
store.setItem(v2Index(ws), JSON.stringify(index));
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function v1Paths(store, suffix = "") {
|
| 112 |
+
const raw = store.getItem(V1_DRAFTS + suffix);
|
| 113 |
+
return raw === null ? null : Object.keys(JSON.parse(raw));
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// ---------------------------------------------------------------------------
|
| 117 |
+
// S1: wrapper installs once; a second load (stale copy scenario) is a no-op.
|
| 118 |
+
{
|
| 119 |
+
const store = makeLocalStorage();
|
| 120 |
+
const nativeSetItem = store.setItem;
|
| 121 |
+
const ctx = loadGuard(store);
|
| 122 |
+
assert.notEqual(store.setItem, nativeSetItem, "setItem should be wrapped after load");
|
| 123 |
+
const firstWrapper = store.setItem;
|
| 124 |
+
loadGuard(store, { context: ctx });
|
| 125 |
+
assert.equal(store.setItem, firstWrapper, "second install must not re-wrap (sentinel)");
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// ---------------------------------------------------------------------------
|
| 129 |
+
// S2: quota error on a NON-draft key is rethrown untouched; no draft evicted.
|
| 130 |
+
{
|
| 131 |
+
const store = makeLocalStorage();
|
| 132 |
+
seedV1(store, "", [["wf-a", "x".repeat(200), 10]]);
|
| 133 |
+
const toasts = [];
|
| 134 |
+
loadGuard(store, { toasts });
|
| 135 |
+
store.__setQuota(store.__used() + 50);
|
| 136 |
+
assert.throws(
|
| 137 |
+
() => store.setItem("koolook.workflows.fallback.v1", "y".repeat(5000)),
|
| 138 |
+
(err) => err instanceof DOMException && err.name === "QuotaExceededError",
|
| 139 |
+
"non-draft quota error must propagate",
|
| 140 |
+
);
|
| 141 |
+
assert.deepEqual(v1Paths(store), ["wf-a"], "draft entries must not be touched");
|
| 142 |
+
assert.equal(toasts.length, 0, "no toast for non-draft failures");
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// ---------------------------------------------------------------------------
|
| 146 |
+
// S3: the live bug — a V2 payload write is rescued by evicting old V1 drafts.
|
| 147 |
+
{
|
| 148 |
+
const store = makeLocalStorage();
|
| 149 |
+
seedV1(store, "", [
|
| 150 |
+
["wf-old", "x".repeat(300), 10],
|
| 151 |
+
["wf-new", "y".repeat(300), 20],
|
| 152 |
+
]);
|
| 153 |
+
seedV2(store, "personal", [["aaaa1111", "z".repeat(100), 30]]);
|
| 154 |
+
const toasts = [];
|
| 155 |
+
loadGuard(store, { toasts });
|
| 156 |
+
|
| 157 |
+
const key = v2Payload("personal", "bbbb2222");
|
| 158 |
+
const value = JSON.stringify({ data: "w".repeat(220), updatedAt: 40 });
|
| 159 |
+
store.__setQuota(store.__used() + key.length + value.length - 1);
|
| 160 |
+
|
| 161 |
+
store.setItem(key, value); // must not throw
|
| 162 |
+
assert.equal(store.getItem(key), value, "new V2 payload must be written");
|
| 163 |
+
assert.deepEqual(v1Paths(store), ["wf-new"], "oldest V1 draft must be evicted first");
|
| 164 |
+
assert.ok(
|
| 165 |
+
store.getItem(v2Payload("personal", "aaaa1111")) !== null,
|
| 166 |
+
"newer V2 payload must survive",
|
| 167 |
+
);
|
| 168 |
+
assert.equal(toasts.length, 1, "one rescue toast per page session");
|
| 169 |
+
assert.match(toasts[0], /removed the oldest/i);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// ---------------------------------------------------------------------------
|
| 173 |
+
// S4: eviction picks the OLDEST draft across generations (V2 older than V1).
|
| 174 |
+
{
|
| 175 |
+
const store = makeLocalStorage();
|
| 176 |
+
seedV1(store, "", [["wf-v1", "x".repeat(150), 100]]);
|
| 177 |
+
seedV2(store, "personal", [
|
| 178 |
+
["old00001", "a".repeat(150), 50],
|
| 179 |
+
["new00001", "b".repeat(150), 200],
|
| 180 |
+
]);
|
| 181 |
+
loadGuard(store);
|
| 182 |
+
|
| 183 |
+
const key = v2Payload("personal", "cccc3333");
|
| 184 |
+
const value = JSON.stringify({ data: "c".repeat(60), updatedAt: 300 });
|
| 185 |
+
store.__setQuota(store.__used() + key.length + value.length - 1);
|
| 186 |
+
|
| 187 |
+
store.setItem(key, value);
|
| 188 |
+
assert.equal(store.getItem(v2Payload("personal", "old00001")), null, "oldest (V2, age 50) evicted");
|
| 189 |
+
assert.deepEqual(v1Paths(store), ["wf-v1"], "younger V1 draft survives");
|
| 190 |
+
assert.ok(store.getItem(v2Payload("personal", "new00001")) !== null, "younger V2 payload survives");
|
| 191 |
+
const index = JSON.parse(store.getItem(v2Index("personal")));
|
| 192 |
+
assert.ok(!index.order.includes("old00001"), "V2 index order drops the evicted hash");
|
| 193 |
+
assert.ok(!("old00001" in index.entries), "V2 index entries drop the evicted hash");
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// ---------------------------------------------------------------------------
|
| 197 |
+
// S5: boot prune deletes suffixed-V1 families superseded by a V2 index,
|
| 198 |
+
// keeps families that are still that workspace's only draft store.
|
| 199 |
+
{
|
| 200 |
+
const store = makeLocalStorage();
|
| 201 |
+
seedV1(store, "", [["wf-root", "r".repeat(50), 5]]);
|
| 202 |
+
seedV1(store, ":wsA", [["wf-a", "a".repeat(50), 5]]);
|
| 203 |
+
seedV2(store, "wsA", [["aaaa0001", "p".repeat(50), 6]]);
|
| 204 |
+
seedV1(store, ":wsB", [["wf-b", "b".repeat(50), 5]]);
|
| 205 |
+
loadGuard(store);
|
| 206 |
+
|
| 207 |
+
assert.equal(store.getItem(V1_DRAFTS + ":wsA"), null, "migrated V1 family is dead weight");
|
| 208 |
+
assert.equal(store.getItem(V1_ORDER + ":wsA"), null, "migrated V1 order key removed too");
|
| 209 |
+
assert.notEqual(store.getItem(V1_DRAFTS + ":wsB"), null, "unmigrated workspace keeps its V1 family");
|
| 210 |
+
assert.deepEqual(v1Paths(store), ["wf-root"], "unsuffixed V1 family untouched");
|
| 211 |
+
assert.notEqual(store.getItem(v2Index("wsA")), null, "V2 index untouched");
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// ---------------------------------------------------------------------------
|
| 215 |
+
// S6: boot prune drops oversized entries (per-entry cap) in both generations.
|
| 216 |
+
{
|
| 217 |
+
const store = makeLocalStorage();
|
| 218 |
+
seedV1(store, "", [
|
| 219 |
+
["wf-huge", "h".repeat(800_000), 10],
|
| 220 |
+
["wf-ok", "o".repeat(100), 20],
|
| 221 |
+
]);
|
| 222 |
+
seedV2(store, "personal", [
|
| 223 |
+
["huge0001", "H".repeat(800_000), 10],
|
| 224 |
+
["fine0001", "f".repeat(100), 20],
|
| 225 |
+
]);
|
| 226 |
+
loadGuard(store);
|
| 227 |
+
|
| 228 |
+
assert.deepEqual(v1Paths(store), ["wf-ok"], "oversized V1 entry dropped, sibling kept");
|
| 229 |
+
assert.equal(store.getItem(v2Payload("personal", "huge0001")), null, "oversized V2 payload dropped");
|
| 230 |
+
assert.notEqual(store.getItem(v2Payload("personal", "fine0001")), null, "small V2 payload kept");
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// ---------------------------------------------------------------------------
|
| 234 |
+
// S7: boot prune enforces the total draft budget, oldest first.
|
| 235 |
+
{
|
| 236 |
+
const store = makeLocalStorage();
|
| 237 |
+
seedV2(store, "personal", [
|
| 238 |
+
["aged0001", "a".repeat(700_000), 1],
|
| 239 |
+
["aged0002", "b".repeat(700_000), 2],
|
| 240 |
+
["aged0003", "c".repeat(700_000), 3],
|
| 241 |
+
]);
|
| 242 |
+
const warns = [];
|
| 243 |
+
loadGuard(store, { warns });
|
| 244 |
+
|
| 245 |
+
assert.equal(store.getItem(v2Payload("personal", "aged0001")), null, "oldest payload evicted");
|
| 246 |
+
assert.notEqual(store.getItem(v2Payload("personal", "aged0002")), null, "second-oldest survives");
|
| 247 |
+
assert.notEqual(store.getItem(v2Payload("personal", "aged0003")), null, "newest survives");
|
| 248 |
+
assert.ok(
|
| 249 |
+
warns.some((w) => w.includes("[Koolook draft-guard]")),
|
| 250 |
+
"prune logs what it did",
|
| 251 |
+
);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// ---------------------------------------------------------------------------
|
| 255 |
+
// S8: a corrupt draft key is removed without touching other generations.
|
| 256 |
+
{
|
| 257 |
+
const store = makeLocalStorage();
|
| 258 |
+
store.setItem(V1_DRAFTS, "{not json");
|
| 259 |
+
store.setItem(V1_ORDER, "[]");
|
| 260 |
+
seedV2(store, "personal", [["good0001", "g".repeat(100), 10]]);
|
| 261 |
+
loadGuard(store);
|
| 262 |
+
|
| 263 |
+
assert.equal(store.getItem(V1_DRAFTS), null, "corrupt V1 blob removed");
|
| 264 |
+
assert.equal(store.getItem(V1_ORDER), null, "its order twin removed");
|
| 265 |
+
assert.notEqual(store.getItem(v2Payload("personal", "good0001")), null, "V2 untouched");
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// ---------------------------------------------------------------------------
|
| 269 |
+
// S9a: unknown future draft keys are NOT evicted while structured candidates
|
| 270 |
+
// remain...
|
| 271 |
+
{
|
| 272 |
+
const store = makeLocalStorage();
|
| 273 |
+
seedV1(store, "", [
|
| 274 |
+
["wf-old", "x".repeat(300), 10],
|
| 275 |
+
["wf-new", "y".repeat(300), 20],
|
| 276 |
+
]);
|
| 277 |
+
store.setItem("Comfy.Workflow.DraftCache.v3:future", "F".repeat(120));
|
| 278 |
+
loadGuard(store);
|
| 279 |
+
|
| 280 |
+
const key = v2Payload("personal", "dddd4444");
|
| 281 |
+
const value = JSON.stringify({ data: "d".repeat(80), updatedAt: 99 });
|
| 282 |
+
store.__setQuota(store.__used() + key.length + value.length - 1);
|
| 283 |
+
store.setItem(key, value);
|
| 284 |
+
|
| 285 |
+
assert.notEqual(store.getItem("Comfy.Workflow.DraftCache.v3:future"), null, "future key kept");
|
| 286 |
+
assert.deepEqual(v1Paths(store), ["wf-new"], "structured candidate evicted instead");
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// S9b: ...but ARE evicted as a last resort, so a future frontend rename
|
| 290 |
+
// cannot brick draft saves again.
|
| 291 |
+
{
|
| 292 |
+
const store = makeLocalStorage();
|
| 293 |
+
store.setItem("Comfy.Workflow.DraftCache.v3:future", "F".repeat(400));
|
| 294 |
+
loadGuard(store);
|
| 295 |
+
|
| 296 |
+
const key = v2Payload("personal", "eeee5555");
|
| 297 |
+
const value = JSON.stringify({ data: "e".repeat(200), updatedAt: 99 });
|
| 298 |
+
store.__setQuota(store.__used() + key.length + value.length - 1);
|
| 299 |
+
store.setItem(key, value);
|
| 300 |
+
|
| 301 |
+
assert.equal(store.getItem("Comfy.Workflow.DraftCache.v3:future"), null, "future key evicted last-resort");
|
| 302 |
+
assert.equal(store.getItem(key), value, "write rescued");
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// ---------------------------------------------------------------------------
|
| 306 |
+
// S10: when nothing can be evicted, the original quota error propagates and
|
| 307 |
+
// the user gets the "still full" toast.
|
| 308 |
+
{
|
| 309 |
+
const store = makeLocalStorage();
|
| 310 |
+
store.setItem("someone.elses.data", "Z".repeat(500));
|
| 311 |
+
const toasts = [];
|
| 312 |
+
loadGuard(store, { toasts });
|
| 313 |
+
store.__setQuota(store.__used() + 20);
|
| 314 |
+
|
| 315 |
+
const key = v2Payload("personal", "ffff6666");
|
| 316 |
+
assert.throws(
|
| 317 |
+
() => store.setItem(key, "f".repeat(100)),
|
| 318 |
+
(err) => err instanceof DOMException && err.name === "QuotaExceededError",
|
| 319 |
+
"unrescuable write rethrows the quota error",
|
| 320 |
+
);
|
| 321 |
+
// A second failing write in the same session still throws but must NOT
|
| 322 |
+
// re-toast — otherwise a genuinely-full origin gets per-edit toast spam.
|
| 323 |
+
assert.throws(
|
| 324 |
+
() => store.setItem(key, "f".repeat(100)),
|
| 325 |
+
(err) => err instanceof DOMException && err.name === "QuotaExceededError",
|
| 326 |
+
"repeat unrescuable write still rethrows",
|
| 327 |
+
);
|
| 328 |
+
assert.equal(
|
| 329 |
+
toasts.filter((t) => /still full/i.test(t)).length,
|
| 330 |
+
1,
|
| 331 |
+
"still-full toast is latched once per session",
|
| 332 |
+
);
|
| 333 |
+
assert.notEqual(store.getItem("someone.elses.data"), null, "non-draft data never touched");
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
// ---------------------------------------------------------------------------
|
| 337 |
+
// S11: a pre-set sentinel (older guard copy already installed) skips the
|
| 338 |
+
// wrapper but the boot prune still runs.
|
| 339 |
+
{
|
| 340 |
+
const store = makeLocalStorage();
|
| 341 |
+
store.__koolookDraftQuotaGuardInstalled = true;
|
| 342 |
+
const nativeSetItem = store.setItem;
|
| 343 |
+
seedV1(store, ":wsA", [["wf-a", "a".repeat(50), 5]]);
|
| 344 |
+
seedV2(store, "wsA", [["aaaa0001", "p".repeat(50), 6]]);
|
| 345 |
+
loadGuard(store);
|
| 346 |
+
|
| 347 |
+
assert.equal(store.setItem, nativeSetItem, "wrapper must not double-install");
|
| 348 |
+
assert.equal(store.getItem(V1_DRAFTS + ":wsA"), null, "boot prune still runs");
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
// ---------------------------------------------------------------------------
|
| 352 |
+
// S12: when the failing write IS a draft container (V1 blob growing), prefer
|
| 353 |
+
// freeing OTHER keys — rewriting the target key smaller cannot help,
|
| 354 |
+
// because the retry overwrites it with the same big value anyway.
|
| 355 |
+
{
|
| 356 |
+
const store = makeLocalStorage();
|
| 357 |
+
seedV1(store, "", [["wf-keep", "k".repeat(200), 5]]); // older than the V2 entry
|
| 358 |
+
seedV2(store, "personal", [["gggg7777", "g".repeat(200), 50]]);
|
| 359 |
+
loadGuard(store);
|
| 360 |
+
|
| 361 |
+
const grown = JSON.stringify({
|
| 362 |
+
"wf-keep": { data: "k".repeat(200), updatedAt: 5, name: "wf-keep", isTemporary: false },
|
| 363 |
+
"wf-added": { data: "n".repeat(120), updatedAt: 60, name: "wf-added", isTemporary: false },
|
| 364 |
+
});
|
| 365 |
+
const delta = grown.length - store.getItem(V1_DRAFTS).length;
|
| 366 |
+
store.__setQuota(store.__used() + delta - 1);
|
| 367 |
+
|
| 368 |
+
store.setItem(V1_DRAFTS, grown);
|
| 369 |
+
assert.equal(store.getItem(V1_DRAFTS), grown, "grown V1 blob written intact");
|
| 370 |
+
assert.equal(store.getItem(v2Payload("personal", "gggg7777")), null, "space freed from another key");
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
console.log("test_draft_guard.mjs: all scenarios passed");
|
ComfyUI/custom_nodes/koolook/tests/js/test_update_check.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Behavior tests for the sidebar update-available check.
|
| 2 |
+
|
| 3 |
+
import assert from "node:assert/strict";
|
| 4 |
+
import path from "node:path";
|
| 5 |
+
|
| 6 |
+
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
| 7 |
+
const updateCheck = await import(
|
| 8 |
+
`file:///${path.join(repoRoot, "web", "sidebar", "update_check.js").replaceAll("\\", "/")}`
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
function jsonResponse(body, ok = true) {
|
| 12 |
+
return {
|
| 13 |
+
ok,
|
| 14 |
+
async json() {
|
| 15 |
+
return body;
|
| 16 |
+
},
|
| 17 |
+
};
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
assert.equal(updateCheck.compareSemver("0.4.1", "0.4.0"), 1);
|
| 21 |
+
assert.equal(updateCheck.compareSemver("v0.4.0", "0.4.0"), 0);
|
| 22 |
+
assert.equal(updateCheck.compareSemver("0.3.9", "0.4.0"), -1);
|
| 23 |
+
assert.equal(updateCheck.compareSemver("0.4.0", "0.4.0-rc.1"), 1);
|
| 24 |
+
assert.equal(updateCheck.compareSemver("not-a-version", "0.4.0"), 0);
|
| 25 |
+
|
| 26 |
+
{
|
| 27 |
+
const calls = [];
|
| 28 |
+
const update = await updateCheck.checkForUpdate(async (url) => {
|
| 29 |
+
calls.push(String(url));
|
| 30 |
+
if (calls.length === 1) {
|
| 31 |
+
assert.ok(calls[0].startsWith("/koolook/api/version?_="));
|
| 32 |
+
return jsonResponse({
|
| 33 |
+
version: "0.4.0",
|
| 34 |
+
latestReleaseApiUrl: "https://api.example.test/latest",
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
assert.equal(url, "https://api.example.test/latest");
|
| 38 |
+
return jsonResponse({
|
| 39 |
+
tag_name: "v0.4.1",
|
| 40 |
+
html_url: "https://github.com/malkuthro/ComfyUI-Koolook/releases/tag/v0.4.1",
|
| 41 |
+
});
|
| 42 |
+
});
|
| 43 |
+
assert.deepEqual(update, {
|
| 44 |
+
current: "0.4.0",
|
| 45 |
+
latest: "0.4.1",
|
| 46 |
+
url: "https://github.com/malkuthro/ComfyUI-Koolook/releases/tag/v0.4.1",
|
| 47 |
+
});
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
{
|
| 51 |
+
let calls = 0;
|
| 52 |
+
const update = await updateCheck.checkForUpdate(async () => {
|
| 53 |
+
calls += 1;
|
| 54 |
+
if (calls === 1) return jsonResponse({ version: "0.4.0" });
|
| 55 |
+
return jsonResponse({
|
| 56 |
+
tag_name: "v0.4.1",
|
| 57 |
+
html_url: "https://github.example.test/releases/v0.4.1",
|
| 58 |
+
});
|
| 59 |
+
});
|
| 60 |
+
assert.deepEqual(update, {
|
| 61 |
+
current: "0.4.0",
|
| 62 |
+
latest: "0.4.1",
|
| 63 |
+
url: "https://github.com/malkuthro/ComfyUI-Koolook/releases/tag/v0.4.1",
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
{
|
| 68 |
+
const update = await updateCheck.checkForUpdate(async () => jsonResponse({ version: "0.4.0" }));
|
| 69 |
+
assert.equal(update, null);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
{
|
| 73 |
+
let calls = 0;
|
| 74 |
+
const update = await updateCheck.checkForUpdate(async () => {
|
| 75 |
+
calls += 1;
|
| 76 |
+
if (calls === 1) return jsonResponse({ version: "0.4.0" });
|
| 77 |
+
return jsonResponse({ tag_name: "v0.4.0", html_url: "https://example.test/current" });
|
| 78 |
+
});
|
| 79 |
+
assert.equal(update, null);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
{
|
| 83 |
+
const update = await updateCheck.checkForUpdate(async () => {
|
| 84 |
+
throw new Error("offline");
|
| 85 |
+
});
|
| 86 |
+
assert.equal(update, null);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
console.log("test_update_check.mjs: all scenarios passed");
|
ComfyUI/custom_nodes/koolook/tests/nodes/__init__.py
ADDED
|
File without changes
|
ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_ai_pipeline.py
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Path-input hardening for ``EasyAIPipeline.generate_pipeline``.
|
| 2 |
+
|
| 3 |
+
The base directory widget accepts whatever the user pastes, and real users
|
| 4 |
+
paste in wildly different shapes: Windows Explorer's address bar ends paths
|
| 5 |
+
in ``\\``, ``Shift+Right-click → Copy as path`` wraps the result in ``"..."``,
|
| 6 |
+
Linux/Mac habits append ``/``, sloppy copy-paste leaves stray whitespace,
|
| 7 |
+
and so on.
|
| 8 |
+
|
| 9 |
+
Every variation has to land in the same canonical ``output_directory``. If
|
| 10 |
+
a trailing separator leaks through, some downstream save nodes split on
|
| 11 |
+
``/``, stringify the empty tail as ``"undefined"``, and write into
|
| 12 |
+
``…/undefined/…`` on disk — a phantom folder that the user never asked
|
| 13 |
+
for. These tests pin both the pure ``_normalize_base_path`` helper and the
|
| 14 |
+
end-to-end ``generate_pipeline`` behavior so the bug can't regress.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
|
| 22 |
+
from k_ai_pipeline import (
|
| 23 |
+
EasyAIPipeline,
|
| 24 |
+
_normalize_base_path,
|
| 25 |
+
_normalize_text_input,
|
| 26 |
+
_sanitize_segment,
|
| 27 |
+
_strip_sentinel_components,
|
| 28 |
+
_strip_control_chars,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# _normalize_base_path — pure helper, no filesystem dependency
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TestNormalizeBasePath:
|
| 38 |
+
def test_clean_input_unchanged(self):
|
| 39 |
+
assert _normalize_base_path("n:\\foo\\bar") == "n:\\foo\\bar"
|
| 40 |
+
assert _normalize_base_path("/home/user/proj") == "/home/user/proj"
|
| 41 |
+
|
| 42 |
+
def test_trailing_backslash_stripped(self):
|
| 43 |
+
assert _normalize_base_path("n:\\foo\\bar\\") == "n:\\foo\\bar"
|
| 44 |
+
|
| 45 |
+
def test_trailing_forward_slash_stripped(self):
|
| 46 |
+
assert _normalize_base_path("/n/foo/bar/") == "/n/foo/bar"
|
| 47 |
+
|
| 48 |
+
def test_multiple_trailing_separators_stripped(self):
|
| 49 |
+
assert _normalize_base_path("n:\\foo\\bar\\\\") == "n:\\foo\\bar"
|
| 50 |
+
assert _normalize_base_path("/n/foo/bar///") == "/n/foo/bar"
|
| 51 |
+
|
| 52 |
+
def test_mixed_trailing_separators_stripped(self):
|
| 53 |
+
"""A path can end in a mix of forward and backward slashes if the
|
| 54 |
+
user copy-pasted from two different sources."""
|
| 55 |
+
assert _normalize_base_path("n:/foo/bar\\/\\") == "n:/foo/bar"
|
| 56 |
+
|
| 57 |
+
def test_surrounding_whitespace_stripped(self):
|
| 58 |
+
assert _normalize_base_path(" n:\\foo\\bar ") == "n:\\foo\\bar"
|
| 59 |
+
|
| 60 |
+
def test_surrounding_double_quotes_stripped(self):
|
| 61 |
+
"""``Shift+Right-click → Copy as path`` in Windows Explorer wraps
|
| 62 |
+
the result in double quotes."""
|
| 63 |
+
assert _normalize_base_path('"n:\\foo\\bar"') == "n:\\foo\\bar"
|
| 64 |
+
|
| 65 |
+
def test_surrounding_single_quotes_stripped(self):
|
| 66 |
+
assert _normalize_base_path("'n:/foo/bar'") == "n:/foo/bar"
|
| 67 |
+
|
| 68 |
+
def test_quotes_and_inner_trailing_separator_combined(self):
|
| 69 |
+
"""A user might paste a quote-wrapped path that also carries the
|
| 70 |
+
Explorer trailing backslash — both layers should peel off."""
|
| 71 |
+
assert _normalize_base_path('"n:\\foo\\bar\\"') == "n:\\foo\\bar"
|
| 72 |
+
assert _normalize_base_path(' "n:/foo/bar/" ') == "n:/foo/bar"
|
| 73 |
+
|
| 74 |
+
def test_drive_root_preserved(self):
|
| 75 |
+
"""``C:\\`` (drive root) must NOT collapse to ``C:`` — the latter
|
| 76 |
+
means "current directory on C: drive", not the drive root itself.
|
| 77 |
+
Same for ``n:/``."""
|
| 78 |
+
assert _normalize_base_path("C:\\") == "C:\\"
|
| 79 |
+
assert _normalize_base_path("n:/") == "n:/"
|
| 80 |
+
|
| 81 |
+
def test_unmatched_quotes_passed_through(self):
|
| 82 |
+
"""Only strip quotes when BOTH ends match. A single dangling quote
|
| 83 |
+
is probably part of the path, not a wrapper."""
|
| 84 |
+
assert _normalize_base_path('"n:\\foo') == '"n:\\foo'
|
| 85 |
+
assert _normalize_base_path("n:\\foo'") == "n:\\foo'"
|
| 86 |
+
assert _normalize_base_path("\"n:\\foo'") == "\"n:\\foo'"
|
| 87 |
+
|
| 88 |
+
def test_empty_string_passes_through(self):
|
| 89 |
+
assert _normalize_base_path("") == ""
|
| 90 |
+
assert _normalize_base_path(" ") == ""
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
# _normalize_text_input - sentinel strings from ComfyUI widgets
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class TestNormalizeTextInput:
|
| 99 |
+
def test_empty_inputs(self):
|
| 100 |
+
assert _normalize_text_input("") == ""
|
| 101 |
+
assert _normalize_text_input(" ") == ""
|
| 102 |
+
assert _normalize_text_input(None) == ""
|
| 103 |
+
|
| 104 |
+
def test_frontend_sentinel_strings_become_empty(self):
|
| 105 |
+
assert _normalize_text_input("undefined") == ""
|
| 106 |
+
assert _normalize_text_input("Undefined") == ""
|
| 107 |
+
assert _normalize_text_input("UNDEFINED") == ""
|
| 108 |
+
assert _normalize_text_input("null") == ""
|
| 109 |
+
assert _normalize_text_input("None") == ""
|
| 110 |
+
assert _normalize_text_input(" undefined ") == ""
|
| 111 |
+
|
| 112 |
+
def test_real_values_pass_through(self):
|
| 113 |
+
assert _normalize_text_input("LTX-Director-2K") == "LTX-Director-2K"
|
| 114 |
+
assert _normalize_text_input("undefined_path") == "undefined_path"
|
| 115 |
+
assert _normalize_text_input("my_null_clip") == "my_null_clip"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class TestStripSentinelComponents:
|
| 119 |
+
def test_strips_exact_sentinel_path_component(self):
|
| 120 |
+
assert _strip_sentinel_components("n:/base/undefined/shot") == "n:/base/shot"
|
| 121 |
+
assert _strip_sentinel_components("n:\\base\\undefined\\shot") == "n:\\base\\shot"
|
| 122 |
+
|
| 123 |
+
def test_strips_other_frontend_sentinels(self):
|
| 124 |
+
assert _strip_sentinel_components("n:/base/null/shot") == "n:/base/shot"
|
| 125 |
+
assert _strip_sentinel_components("n:/base/None/shot") == "n:/base/shot"
|
| 126 |
+
|
| 127 |
+
def test_keeps_real_substring_values(self):
|
| 128 |
+
assert _strip_sentinel_components("n:/base/undefined_path/shot") == "n:/base/undefined_path/shot"
|
| 129 |
+
assert _strip_sentinel_components("n:/base/my_null_clip/shot") == "n:/base/my_null_clip/shot"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
# generate_pipeline — end-to-end behavior with realistic inputs
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _run(base_directory_path: str, **overrides):
|
| 138 |
+
"""Run ``EasyAIPipeline.generate_pipeline`` with sensible defaults and
|
| 139 |
+
return ``(file_path, output_name, version_string, output_directory)``,
|
| 140 |
+
which is what the bug-area assertions actually care about."""
|
| 141 |
+
defaults = dict(
|
| 142 |
+
shot_duration=81,
|
| 143 |
+
seed_value=453453453,
|
| 144 |
+
instruction="(ignored)",
|
| 145 |
+
base_directory_path=base_directory_path,
|
| 146 |
+
extension=".%04d.exr",
|
| 147 |
+
shot_name="LTX-Director-2k_base",
|
| 148 |
+
ai_method="",
|
| 149 |
+
version=1,
|
| 150 |
+
disable_versioning=True,
|
| 151 |
+
enable_overwrite=False,
|
| 152 |
+
no_subfolders=False,
|
| 153 |
+
)
|
| 154 |
+
defaults.update(overrides)
|
| 155 |
+
result = EasyAIPipeline().generate_pipeline(**defaults)
|
| 156 |
+
file_path, output_name, version_string, output_directory = result[:4]
|
| 157 |
+
return file_path, output_name, version_string, output_directory
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# Variations the user might realistically paste into the widget. Each must
|
| 161 |
+
# canonicalise to the same output_directory as the bare ``{base}`` form.
|
| 162 |
+
PASTE_VARIANTS = [
|
| 163 |
+
"{base}", # clean
|
| 164 |
+
"{base}\\", # Explorer address-bar trailing backslash
|
| 165 |
+
"{base}/", # POSIX trailing slash
|
| 166 |
+
"{base}\\\\", # accidental double backslash
|
| 167 |
+
"{base}//", # accidental double forward slash
|
| 168 |
+
"{base}\\/", # mixed trailing separators
|
| 169 |
+
" {base} ", # sloppy whitespace
|
| 170 |
+
" {base}\\ ", # whitespace + trailing backslash
|
| 171 |
+
'"{base}"', # Shift+Right-click → Copy as path
|
| 172 |
+
'"{base}\\"', # quoted AND trailing backslash
|
| 173 |
+
"'{base}'", # single-quoted
|
| 174 |
+
]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@pytest.mark.parametrize("variant", PASTE_VARIANTS)
|
| 178 |
+
def test_output_directory_canonical_across_paste_variations(tmp_path: Path, variant: str):
|
| 179 |
+
"""No matter how the user pastes the base path, ``output_directory``
|
| 180 |
+
must be identical to the clean-input form. This is the contract that
|
| 181 |
+
prevents downstream save nodes from receiving subtly different paths
|
| 182 |
+
based on irrelevant whitespace/quoting/trailing-slash differences."""
|
| 183 |
+
base = tmp_path / "bear"
|
| 184 |
+
base.mkdir()
|
| 185 |
+
canonical = str(base).replace("\\", "/")
|
| 186 |
+
|
| 187 |
+
_, _, _, out = _run(variant.format(base=base))
|
| 188 |
+
|
| 189 |
+
expected = f"{canonical}/LTX-Director-2k_base"
|
| 190 |
+
assert out == expected, f"variant={variant!r} produced {out!r}, expected {expected!r}"
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@pytest.mark.parametrize("variant", PASTE_VARIANTS)
|
| 194 |
+
def test_output_directory_never_has_trailing_separator(tmp_path: Path, variant: str):
|
| 195 |
+
"""Belt-and-suspenders: regardless of which variation the user pastes,
|
| 196 |
+
``output_directory`` (slot 3 of the returns tuple) must never end with
|
| 197 |
+
``/`` or ``\\``. A trailing separator here is exactly what triggers the
|
| 198 |
+
downstream ``undefined/`` phantom-folder bug."""
|
| 199 |
+
base = tmp_path / "bear"
|
| 200 |
+
base.mkdir()
|
| 201 |
+
|
| 202 |
+
_, _, _, out = _run(variant.format(base=base))
|
| 203 |
+
|
| 204 |
+
assert not out.endswith(("/", "\\")), f"variant={variant!r} → {out!r}"
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@pytest.mark.parametrize("variant", PASTE_VARIANTS)
|
| 208 |
+
def test_write_file_path_clean_across_paste_variations(tmp_path: Path, variant: str):
|
| 209 |
+
"""``WRITE_file_path`` (slot 0) is the string downstream savers actually
|
| 210 |
+
consume. It must never contain ``//``, ``undefined``, or a trailing
|
| 211 |
+
separator regardless of how the base was pasted."""
|
| 212 |
+
base = tmp_path / "bear"
|
| 213 |
+
base.mkdir()
|
| 214 |
+
|
| 215 |
+
file_path, _, _, _ = _run(variant.format(base=base))
|
| 216 |
+
|
| 217 |
+
assert "//" not in file_path, f"variant={variant!r} → {file_path!r}"
|
| 218 |
+
assert "undefined" not in file_path, f"variant={variant!r} → {file_path!r}"
|
| 219 |
+
assert not file_path.endswith(("/", "\\")), f"variant={variant!r} → {file_path!r}"
|
| 220 |
+
assert file_path.endswith(".%04d.exr"), f"variant={variant!r} → {file_path!r}"
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_no_subfolders_also_canonicalises_trailing(tmp_path: Path):
|
| 224 |
+
"""With ``no_subfolders=True``, ``output_directory`` IS the (normalised)
|
| 225 |
+
base path. The trailing-sep strip must still apply, otherwise no-subfolders
|
| 226 |
+
mode would still leak ``undefined/`` downstream."""
|
| 227 |
+
base = tmp_path / "bear"
|
| 228 |
+
base.mkdir()
|
| 229 |
+
canonical = str(base).replace("\\", "/")
|
| 230 |
+
|
| 231 |
+
_, _, _, with_slash = _run(f"{base}\\", no_subfolders=True)
|
| 232 |
+
_, _, _, without_slash = _run(str(base), no_subfolders=True)
|
| 233 |
+
|
| 234 |
+
assert with_slash == without_slash == canonical
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def test_trailing_base_with_undefined_shot_name_does_not_create_phantom_folder():
|
| 238 |
+
"""Exact maintainer regression: trailing separator on the base path plus
|
| 239 |
+
a frontend sentinel in shot_name used to yield
|
| 240 |
+
base/undefined/LTX-Director-2K/... instead of base/LTX-Director-2K/....
|
| 241 |
+
"""
|
| 242 |
+
base = r"n:\TRK_sync_BIG_N\JOBS\baconx\Jeep_Animals\bear\ComfyUI-working-folder"
|
| 243 |
+
|
| 244 |
+
file_path, name, _, output_directory = _run(
|
| 245 |
+
base + "\\",
|
| 246 |
+
shot_name="undefined",
|
| 247 |
+
ai_method="LTX-Director-2K",
|
| 248 |
+
disable_versioning=True,
|
| 249 |
+
no_subfolders=False,
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
expected_dir = "n:/TRK_sync_BIG_N/JOBS/baconx/Jeep_Animals/bear/ComfyUI-working-folder/LTX-Director-2K"
|
| 253 |
+
assert output_directory == expected_dir
|
| 254 |
+
assert name == "LTX-Director-2K.%04d.exr"
|
| 255 |
+
assert file_path == f"{expected_dir}/LTX-Director-2K.%04d.exr"
|
| 256 |
+
assert "/undefined/" not in file_path
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def test_undefined_component_in_backend_base_path_is_stripped():
|
| 260 |
+
"""When base_directory_path is connected, the backend may receive the
|
| 261 |
+
executed upstream value rather than the visible widget value the preview
|
| 262 |
+
button can inspect. Strip sentinel components from the whole path too.
|
| 263 |
+
"""
|
| 264 |
+
base = "n:/TRK_sync_BIG_N/JOBS/baconx/Jeep_Animals/bear/ComfyUI-working-folder/undefined"
|
| 265 |
+
|
| 266 |
+
file_path, name, _, output_directory = _run(
|
| 267 |
+
base,
|
| 268 |
+
shot_name="LTX-Director-2K",
|
| 269 |
+
ai_method="",
|
| 270 |
+
version=1,
|
| 271 |
+
disable_versioning=False,
|
| 272 |
+
no_subfolders=False,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
expected_dir = "n:/TRK_sync_BIG_N/JOBS/baconx/Jeep_Animals/bear/ComfyUI-working-folder/LTX-Director-2K/v001"
|
| 276 |
+
assert output_directory == expected_dir
|
| 277 |
+
assert name == "LTX-Director-2K_v001.%04d.exr"
|
| 278 |
+
assert file_path == f"{expected_dir}/LTX-Director-2K_v001.%04d.exr"
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def test_blank_ai_method_yields_no_dangling_underscore(tmp_path: Path):
|
| 282 |
+
"""Companion regression for the same session's fix — verifies the
|
| 283 |
+
empty-piece filter so ``ai_method=""`` doesn't produce ``shot_.exr``."""
|
| 284 |
+
base = tmp_path / "bear"
|
| 285 |
+
base.mkdir()
|
| 286 |
+
|
| 287 |
+
_, name, _, _ = _run(str(base), shot_name="shot", ai_method="", extension=".exr")
|
| 288 |
+
|
| 289 |
+
assert name == "shot.exr"
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_blank_ai_method_with_versioning_skips_double_underscore(tmp_path: Path):
|
| 293 |
+
"""Same rule with versioning on: a blank ``ai_method`` between
|
| 294 |
+
``shot_name`` and ``vNNN`` must not produce ``shot__v003.exr``."""
|
| 295 |
+
base = tmp_path / "bear"
|
| 296 |
+
base.mkdir()
|
| 297 |
+
|
| 298 |
+
_, name, _, _ = _run(
|
| 299 |
+
str(base), shot_name="shot", ai_method="",
|
| 300 |
+
version=3, disable_versioning=False, extension=".exr",
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
assert name == "shot_v003.exr"
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def test_blank_ai_method_does_not_create_phantom_subfolder(tmp_path: Path):
|
| 307 |
+
"""With ``ai_method`` blank and subfolders enabled, the directory chain
|
| 308 |
+
is just ``base/shot_name`` — no empty middle level."""
|
| 309 |
+
base = tmp_path / "bear"
|
| 310 |
+
base.mkdir()
|
| 311 |
+
canonical = str(base).replace("\\", "/")
|
| 312 |
+
|
| 313 |
+
_, _, _, out = _run(str(base), shot_name="shot", ai_method="")
|
| 314 |
+
|
| 315 |
+
assert out == f"{canonical}/shot"
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# ---------------------------------------------------------------------------
|
| 319 |
+
# _sanitize_segment — path-escape regression coverage
|
| 320 |
+
# ---------------------------------------------------------------------------
|
| 321 |
+
#
|
| 322 |
+
# os.path.join('n:/safe', '/oops') returns '/oops' (or 'C:/oops' on Windows) —
|
| 323 |
+
# its 'last absolute path wins' rule means an absolute-looking shot_name or
|
| 324 |
+
# ai_method could escape the user's intended base_directory_path entirely.
|
| 325 |
+
# A reviewer caught this regression in PR #156 (the original lstrip('/') was
|
| 326 |
+
# dropped during the refactor). These tests pin the sanitizer so it can't
|
| 327 |
+
# come back.
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
class TestSanitizeSegment:
|
| 331 |
+
def test_clean_input_unchanged(self):
|
| 332 |
+
assert _sanitize_segment("shot_v1") == "shot_v1"
|
| 333 |
+
assert _sanitize_segment("RTX-upscale") == "RTX-upscale"
|
| 334 |
+
|
| 335 |
+
def test_leading_forward_slash_stripped(self):
|
| 336 |
+
assert _sanitize_segment("/oops") == "oops"
|
| 337 |
+
|
| 338 |
+
def test_leading_backslash_stripped(self):
|
| 339 |
+
assert _sanitize_segment("\\oops") == "oops"
|
| 340 |
+
|
| 341 |
+
def test_multiple_leading_separators_stripped(self):
|
| 342 |
+
assert _sanitize_segment("///oops") == "oops"
|
| 343 |
+
assert _sanitize_segment("\\\\\\oops") == "oops"
|
| 344 |
+
assert _sanitize_segment("/\\/oops") == "oops"
|
| 345 |
+
|
| 346 |
+
def test_drive_prefix_handled(self):
|
| 347 |
+
"""Drive-like prefixes are stripped on every host so browser preview
|
| 348 |
+
and backend runtime agree. ``base_directory_path`` still preserves
|
| 349 |
+
drive roots; this only applies to joined path segments."""
|
| 350 |
+
assert _sanitize_segment("C:/Windows") == "Windows"
|
| 351 |
+
assert _sanitize_segment("C:\\Windows") == "Windows"
|
| 352 |
+
|
| 353 |
+
def test_empty_string_passes_through(self):
|
| 354 |
+
assert _sanitize_segment("") == ""
|
| 355 |
+
|
| 356 |
+
def test_only_separators_collapses_to_empty(self):
|
| 357 |
+
assert _sanitize_segment("///") == ""
|
| 358 |
+
assert _sanitize_segment("\\") == ""
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
@pytest.mark.parametrize("malicious", [
|
| 362 |
+
"/oops",
|
| 363 |
+
"\\oops",
|
| 364 |
+
"C:/Windows/junk",
|
| 365 |
+
"/etc/passwd",
|
| 366 |
+
])
|
| 367 |
+
def test_absolute_shot_name_cannot_escape_base(tmp_path: Path, malicious: str):
|
| 368 |
+
"""The reviewer-flagged blocker in PR #156: absolute ``shot_name`` values
|
| 369 |
+
used to leak through ``os.path.join`` and replace the base path entirely.
|
| 370 |
+
After ``_sanitize_segment`` they get stripped down to relative form, so
|
| 371 |
+
``output_directory`` stays under the user's intended base."""
|
| 372 |
+
base = tmp_path / "safe"
|
| 373 |
+
base.mkdir()
|
| 374 |
+
canonical_base = str(base).replace("\\", "/")
|
| 375 |
+
|
| 376 |
+
_, _, _, out = _run(str(base), shot_name=malicious)
|
| 377 |
+
|
| 378 |
+
assert out.startswith(canonical_base), (
|
| 379 |
+
f"shot_name={malicious!r} escaped base — out={out!r}, base={canonical_base!r}"
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
@pytest.mark.parametrize("malicious", [
|
| 384 |
+
"/oops",
|
| 385 |
+
"\\oops",
|
| 386 |
+
"C:/Windows/junk",
|
| 387 |
+
"/etc/passwd",
|
| 388 |
+
])
|
| 389 |
+
def test_absolute_ai_method_cannot_escape_base(tmp_path: Path, malicious: str):
|
| 390 |
+
"""Same escape vector through ``ai_method`` (the middle segment). The
|
| 391 |
+
sanitizer applies symmetrically."""
|
| 392 |
+
base = tmp_path / "safe"
|
| 393 |
+
base.mkdir()
|
| 394 |
+
canonical_base = str(base).replace("\\", "/")
|
| 395 |
+
|
| 396 |
+
_, _, _, out = _run(str(base), shot_name="shot", ai_method=malicious)
|
| 397 |
+
|
| 398 |
+
assert out.startswith(canonical_base), (
|
| 399 |
+
f"ai_method={malicious!r} escaped base — out={out!r}, base={canonical_base!r}"
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def test_absolute_shot_name_does_not_leak_into_filename(tmp_path: Path):
|
| 404 |
+
"""The filename builder uses the sanitized segments too, so a leading
|
| 405 |
+
separator can't produce ``/shot_v001.exr`` (which on some platforms is
|
| 406 |
+
interpreted as a path, on others is just an invalid filename)."""
|
| 407 |
+
base = tmp_path / "safe"
|
| 408 |
+
base.mkdir()
|
| 409 |
+
|
| 410 |
+
file_path, name, _, _ = _run(str(base), shot_name="/oops")
|
| 411 |
+
|
| 412 |
+
assert not name.startswith(("/", "\\")), f"filename leaked separator: {name!r}"
|
| 413 |
+
assert "/oops" not in name, f"filename retained absolute prefix: {name!r}"
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def test_drive_prefixed_shot_name_matches_frontend_preview_on_all_hosts(tmp_path: Path):
|
| 417 |
+
"""Regression for PR #156 review: JS preview always strips ``C:`` from
|
| 418 |
+
shot_name/ai_method segments. Python must do the same even on POSIX hosts
|
| 419 |
+
or the preview and runtime paths diverge for the same workflow."""
|
| 420 |
+
base = tmp_path / "safe"
|
| 421 |
+
base.mkdir()
|
| 422 |
+
canonical = str(base).replace("\\", "/")
|
| 423 |
+
|
| 424 |
+
file_path, name, _, output_directory = _run(
|
| 425 |
+
str(base),
|
| 426 |
+
shot_name="C:/Windows",
|
| 427 |
+
ai_method="v2v",
|
| 428 |
+
version=1,
|
| 429 |
+
disable_versioning=False,
|
| 430 |
+
extension=".exr",
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
assert output_directory == f"{canonical}/Windows/v2v/v001"
|
| 434 |
+
assert name == "Windows_v2v_v001.exr"
|
| 435 |
+
assert file_path == f"{canonical}/Windows/v2v/v001/Windows_v2v_v001.exr"
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def test_no_subfolders_flattens_embedded_separators_in_shot_name(tmp_path: Path):
|
| 439 |
+
"""Real-world maintainer report: chained EasyAIPipeline workflow where the
|
| 440 |
+
upstream node feeds ``shot_name = "job/shot"``. With ``no_subfolders=true``,
|
| 441 |
+
embedded separators used to silently re-create subfolders via the filename
|
| 442 |
+
concat — output was ``base/job/shot.ext`` instead of the flat ``base/<flat>.ext``
|
| 443 |
+
the toggle promises. After fix: separators in shot_name flatten to ``_`` in
|
| 444 |
+
the filename, while the directory stays at ``base`` (toggle honored)."""
|
| 445 |
+
base = tmp_path / "bear"
|
| 446 |
+
base.mkdir()
|
| 447 |
+
canonical = str(base).replace("\\", "/")
|
| 448 |
+
|
| 449 |
+
file_path, name, _, output_directory = _run(
|
| 450 |
+
str(base),
|
| 451 |
+
shot_name="ComfyUI-working-folder/LTX-Director-2K",
|
| 452 |
+
no_subfolders=True,
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
assert output_directory == canonical, f"no_subfolders=True should keep dir=base; got {output_directory!r}"
|
| 456 |
+
assert name == "ComfyUI-working-folder_LTX-Director-2K.%04d.exr", f"unexpected name: {name!r}"
|
| 457 |
+
assert "/" not in name and "\\" not in name, f"separator in filename: {name!r}"
|
| 458 |
+
assert file_path == f"{canonical}/ComfyUI-working-folder_LTX-Director-2K.%04d.exr"
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def test_filename_never_contains_path_separators(tmp_path: Path):
|
| 462 |
+
"""Belt-and-suspenders: regardless of toggle state or input, the filename
|
| 463 |
+
portion of WRITE_file_path must never contain `/` or `\\` — those are
|
| 464 |
+
invalid in filenames on every OS. Subfolder semantics live in the
|
| 465 |
+
directory portion, not the filename."""
|
| 466 |
+
base = tmp_path / "safe"
|
| 467 |
+
base.mkdir()
|
| 468 |
+
|
| 469 |
+
for cfg in [
|
| 470 |
+
dict(shot_name="Project/v1", ai_method="", no_subfolders=True),
|
| 471 |
+
dict(shot_name="Project/v1", ai_method="", no_subfolders=False),
|
| 472 |
+
dict(shot_name="a/b/c", ai_method="d/e", no_subfolders=True),
|
| 473 |
+
dict(shot_name="a/b/c", ai_method="d/e", no_subfolders=False),
|
| 474 |
+
dict(shot_name="back\\slash", ai_method="", no_subfolders=True),
|
| 475 |
+
]:
|
| 476 |
+
file_path, name, _, _ = _run(str(base), **cfg)
|
| 477 |
+
assert "/" not in name and "\\" not in name, (
|
| 478 |
+
f"separator leaked into filename for cfg={cfg!r}: name={name!r}"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def test_no_subfolders_false_still_uses_slashes_as_subfolders(tmp_path: Path):
|
| 483 |
+
"""With ``no_subfolders=False`` (default), embedded slashes in shot_name
|
| 484 |
+
intentionally become nested subfolders — this is how users organize
|
| 485 |
+
output into project/shot hierarchies. The filename stays flat regardless."""
|
| 486 |
+
base = tmp_path / "bear"
|
| 487 |
+
base.mkdir()
|
| 488 |
+
canonical = str(base).replace("\\", "/")
|
| 489 |
+
|
| 490 |
+
_, name, _, output_directory = _run(
|
| 491 |
+
str(base),
|
| 492 |
+
shot_name="Project/v1",
|
| 493 |
+
ai_method="upscale",
|
| 494 |
+
version=3, disable_versioning=False,
|
| 495 |
+
no_subfolders=False,
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
assert output_directory == f"{canonical}/Project/v1/upscale/v003"
|
| 499 |
+
assert name == "Project_v1_upscale_v003.%04d.exr"
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def test_no_subfolders_true_with_versioning_keeps_version_folder(tmp_path: Path):
|
| 503 |
+
"""Maintainer-clarified behavior for ``no_subfolders=True``: shot_name and
|
| 504 |
+
ai_method drop out of the directory path entirely (they only appear in
|
| 505 |
+
the filename), but the version folder (``v###``) IS still added when
|
| 506 |
+
``disable_versioning`` is off. Versioned outputs stay organised under
|
| 507 |
+
``base/v###/`` even with the toggle on."""
|
| 508 |
+
base = tmp_path / "bear"
|
| 509 |
+
base.mkdir()
|
| 510 |
+
canonical = str(base).replace("\\", "/")
|
| 511 |
+
|
| 512 |
+
file_path, name, _, output_directory = _run(
|
| 513 |
+
str(base),
|
| 514 |
+
shot_name="shot",
|
| 515 |
+
ai_method="upscale",
|
| 516 |
+
version=5, disable_versioning=False,
|
| 517 |
+
no_subfolders=True,
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
assert output_directory == f"{canonical}/v005", (
|
| 521 |
+
f"Expected base/v005 (only version folder); got {output_directory!r}"
|
| 522 |
+
)
|
| 523 |
+
# Filename still gets all the pieces joined with underscores
|
| 524 |
+
assert name == "shot_upscale_v005.%04d.exr"
|
| 525 |
+
assert file_path == f"{canonical}/v005/shot_upscale_v005.%04d.exr"
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def test_no_subfolders_true_without_versioning_is_flat_base(tmp_path: Path):
|
| 529 |
+
"""Companion to the above: ``no_subfolders=True`` + ``disable_versioning=True``
|
| 530 |
+
means truly flat output — just ``base/<filename>``, no subfolders at all."""
|
| 531 |
+
base = tmp_path / "bear"
|
| 532 |
+
base.mkdir()
|
| 533 |
+
canonical = str(base).replace("\\", "/")
|
| 534 |
+
|
| 535 |
+
_, name, _, output_directory = _run(
|
| 536 |
+
str(base),
|
| 537 |
+
shot_name="shot",
|
| 538 |
+
ai_method="upscale",
|
| 539 |
+
version=5, disable_versioning=True,
|
| 540 |
+
no_subfolders=True,
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
assert output_directory == canonical
|
| 544 |
+
assert name == "shot_upscale.%04d.exr"
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
# ---------------------------------------------------------------------------
|
| 548 |
+
# Control-char stripping — newlines from upstream Text Multiline widgets
|
| 549 |
+
# ---------------------------------------------------------------------------
|
| 550 |
+
#
|
| 551 |
+
# An upstream node (e.g. WAS's `Text Multiline`) can feed strings that contain
|
| 552 |
+
# embedded newlines / tabs / carriage returns. Those can't legally appear in
|
| 553 |
+
# any filesystem path — they'd silently break the actual file save, and the
|
| 554 |
+
# preview widget rendered them as visually broken multi-line output. Strip
|
| 555 |
+
# at the helper level so they never reach the path build.
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
class TestStripControlChars:
|
| 559 |
+
def test_strips_newline(self):
|
| 560 |
+
assert _strip_control_chars("foo\nbar") == "foobar"
|
| 561 |
+
|
| 562 |
+
def test_strips_carriage_return(self):
|
| 563 |
+
assert _strip_control_chars("foo\rbar") == "foobar"
|
| 564 |
+
|
| 565 |
+
def test_strips_tab(self):
|
| 566 |
+
assert _strip_control_chars("foo\tbar") == "foobar"
|
| 567 |
+
|
| 568 |
+
def test_strips_all_mixed(self):
|
| 569 |
+
assert _strip_control_chars("foo\r\n\tbar\nbaz") == "foobarbaz"
|
| 570 |
+
|
| 571 |
+
def test_preserves_spaces(self):
|
| 572 |
+
"""Spaces are valid in path segments; only newline/CR/tab get stripped."""
|
| 573 |
+
assert _strip_control_chars("foo bar") == "foo bar"
|
| 574 |
+
|
| 575 |
+
def test_empty_passes_through(self):
|
| 576 |
+
assert _strip_control_chars("") == ""
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def test_shot_name_with_embedded_newline_does_not_break_path(tmp_path: Path):
|
| 580 |
+
"""The maintainer-reported case: upstream feeds shot_name with a stray
|
| 581 |
+
newline (e.g. paragraph break in a Text Multiline). Pre-fix, this leaked
|
| 582 |
+
through to the preview AND would have broken the save with a literal
|
| 583 |
+
line-break in the path. Fix: _sanitize_segment strips control chars at
|
| 584 |
+
the source."""
|
| 585 |
+
base = tmp_path / "bear"
|
| 586 |
+
base.mkdir()
|
| 587 |
+
|
| 588 |
+
file_path, name, _, output_directory = _run(
|
| 589 |
+
str(base),
|
| 590 |
+
shot_name="ComfyUI-working-folder/LTX-Director-2K\n_0",
|
| 591 |
+
no_subfolders=True,
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
assert "\n" not in file_path, f"newline leaked into file_path: {file_path!r}"
|
| 595 |
+
assert "\n" not in name, f"newline leaked into name: {name!r}"
|
| 596 |
+
assert "\n" not in output_directory, f"newline leaked into output_directory: {output_directory!r}"
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def test_base_path_with_embedded_newline_normalised(tmp_path: Path):
|
| 600 |
+
"""Same defense at the base_directory_path layer — Text Multiline
|
| 601 |
+
sometimes appends a trailing \\n that survived the previous fix."""
|
| 602 |
+
base = tmp_path / "bear"
|
| 603 |
+
base.mkdir()
|
| 604 |
+
canonical = str(base).replace("\\", "/")
|
| 605 |
+
|
| 606 |
+
_, _, _, output_directory = _run(
|
| 607 |
+
f"{base}\n",
|
| 608 |
+
shot_name="shot",
|
| 609 |
+
no_subfolders=True,
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
assert "\n" not in output_directory
|
| 613 |
+
assert output_directory == canonical
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def test_extension_with_embedded_newline_stripped(tmp_path: Path):
|
| 617 |
+
"""Extension is the third path-bound string input; stray newlines there
|
| 618 |
+
were also leaking into the filename."""
|
| 619 |
+
base = tmp_path / "bear"
|
| 620 |
+
base.mkdir()
|
| 621 |
+
|
| 622 |
+
_, name, _, _ = _run(
|
| 623 |
+
str(base),
|
| 624 |
+
shot_name="shot",
|
| 625 |
+
extension=".exr\n",
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
assert "\n" not in name
|
| 629 |
+
assert name == "shot.exr"
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
@pytest.mark.parametrize("dirty,expected", [
|
| 633 |
+
(".%04d.exr ", ".%04d.exr"), # trailing space
|
| 634 |
+
(" .%04d.exr", ".%04d.exr"), # leading space
|
| 635 |
+
(" .%04d.exr ", ".%04d.exr"), # both
|
| 636 |
+
(".%04d .exr", ".%04d.exr"), # internal space
|
| 637 |
+
(".%04d.exr\t", ".%04d.exr"), # trailing tab
|
| 638 |
+
(".%04d.exr\xa0", ".%04d.exr"), # non-breaking space (common copy-paste invisible char)
|
| 639 |
+
])
|
| 640 |
+
def test_extension_whitespace_stripped(tmp_path: Path, dirty: str, expected: str):
|
| 641 |
+
"""Maintainer-reported: ComfyUI-HQ-Image-Save's ``save_images`` validates
|
| 642 |
+
via ``os.path.splitext(filepath)[1].lower() != '.exr'``. A single trailing
|
| 643 |
+
space on the extension widget made splitext return ``.exr `` (with space),
|
| 644 |
+
which failed the strict equality and aborted the save. Extension must have
|
| 645 |
+
NO whitespace at all (internal or surrounding) — it's a strict suffix
|
| 646 |
+
field, not a label."""
|
| 647 |
+
base = tmp_path / "bear"
|
| 648 |
+
base.mkdir()
|
| 649 |
+
|
| 650 |
+
_, name, _, _ = _run(str(base), shot_name="shot", extension=dirty)
|
| 651 |
+
|
| 652 |
+
assert name == f"shot{expected}", f"dirty={dirty!r}: got name={name!r}"
|
| 653 |
+
# And the HQ-Image-Save check would pass:
|
| 654 |
+
import os
|
| 655 |
+
assert os.path.splitext(name)[1].lower() == ".exr"
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
@pytest.mark.parametrize("dirty,clean", [
|
| 659 |
+
(" shot ", "shot"), # surrounding spaces
|
| 660 |
+
("shot\t", "shot"), # trailing tab
|
| 661 |
+
("\tshot", "shot"), # leading tab
|
| 662 |
+
])
|
| 663 |
+
def test_shot_name_surrounding_whitespace_stripped(tmp_path: Path, dirty: str, clean: str):
|
| 664 |
+
"""Surrounding whitespace on shot_name (from sloppy upstream feeds) used
|
| 665 |
+
to leak into the path and filename. ``_sanitize_segment`` now strips
|
| 666 |
+
surrounding whitespace as well as control chars."""
|
| 667 |
+
base = tmp_path / "safe"
|
| 668 |
+
base.mkdir()
|
| 669 |
+
canonical = str(base).replace("\\", "/")
|
| 670 |
+
|
| 671 |
+
_, name, _, output_directory = _run(str(base), shot_name=dirty, no_subfolders=False)
|
| 672 |
+
|
| 673 |
+
assert name == f"{clean}.%04d.exr"
|
| 674 |
+
assert output_directory == f"{canonical}/{clean}"
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
# ---------------------------------------------------------------------------
|
| 678 |
+
# version — single STRING field (typed, or wired from a global version node)
|
| 679 |
+
# ---------------------------------------------------------------------------
|
| 680 |
+
#
|
| 681 |
+
# The version field is a STRING: a bare number becomes vNNN (back-compat with
|
| 682 |
+
# the old integer widget), any other token is used verbatim, and
|
| 683 |
+
# disable_versioning is the master off-switch. A global version node wires
|
| 684 |
+
# straight into this one field — there is no second input.
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
class TestStringVersionField:
|
| 688 |
+
def test_string_token_used_verbatim(self, tmp_path: Path):
|
| 689 |
+
"""A non-numeric token like ``final`` passes through verbatim into BOTH
|
| 690 |
+
output_directory and filename — not coerced into vNNN."""
|
| 691 |
+
base = tmp_path / "bear"
|
| 692 |
+
base.mkdir()
|
| 693 |
+
canonical = str(base).replace("\\", "/")
|
| 694 |
+
|
| 695 |
+
file_path, name, version_string, output_directory = _run(
|
| 696 |
+
str(base), shot_name="shot", ai_method="", extension=".exr",
|
| 697 |
+
version="final", disable_versioning=False,
|
| 698 |
+
)
|
| 699 |
+
|
| 700 |
+
assert version_string == "final"
|
| 701 |
+
assert output_directory == f"{canonical}/shot/final"
|
| 702 |
+
assert name == "shot_final.exr"
|
| 703 |
+
assert file_path == f"{canonical}/shot/final/shot_final.exr"
|
| 704 |
+
|
| 705 |
+
def test_explicit_vnnn_token_used_verbatim(self, tmp_path: Path):
|
| 706 |
+
base = tmp_path / "bear"
|
| 707 |
+
base.mkdir()
|
| 708 |
+
canonical = str(base).replace("\\", "/")
|
| 709 |
+
|
| 710 |
+
_, name, version_string, output_directory = _run(
|
| 711 |
+
str(base), shot_name="shot", ai_method="", extension=".exr",
|
| 712 |
+
version="v007", disable_versioning=False,
|
| 713 |
+
)
|
| 714 |
+
|
| 715 |
+
assert version_string == "v007"
|
| 716 |
+
assert output_directory == f"{canonical}/shot/v007"
|
| 717 |
+
assert name == "shot_v007.exr"
|
| 718 |
+
|
| 719 |
+
def test_bare_number_becomes_vnnn(self, tmp_path: Path):
|
| 720 |
+
"""Convenience + back-compat: a bare integer string formats as vNNN."""
|
| 721 |
+
base = tmp_path / "bear"
|
| 722 |
+
base.mkdir()
|
| 723 |
+
|
| 724 |
+
_, name, version_string, _ = _run(
|
| 725 |
+
str(base), shot_name="shot", ai_method="", extension=".exr",
|
| 726 |
+
version="2", disable_versioning=False,
|
| 727 |
+
)
|
| 728 |
+
|
| 729 |
+
assert version_string == "v002"
|
| 730 |
+
assert name == "shot_v002.exr"
|
| 731 |
+
|
| 732 |
+
def test_integer_value_still_supported(self, tmp_path: Path):
|
| 733 |
+
"""A pre-existing workflow stored version as an INT; it arrives as a
|
| 734 |
+
number and must still format as vNNN despite the INT->STRING change."""
|
| 735 |
+
base = tmp_path / "bear"
|
| 736 |
+
base.mkdir()
|
| 737 |
+
|
| 738 |
+
_, name, version_string, _ = _run(
|
| 739 |
+
str(base), shot_name="shot", ai_method="", extension=".exr",
|
| 740 |
+
version=5, disable_versioning=False,
|
| 741 |
+
)
|
| 742 |
+
|
| 743 |
+
assert version_string == "v005"
|
| 744 |
+
assert name == "shot_v005.exr"
|
| 745 |
+
|
| 746 |
+
def test_disable_versioning_is_master_off(self, tmp_path: Path):
|
| 747 |
+
"""disable_versioning drops the version regardless of the field value."""
|
| 748 |
+
base = tmp_path / "bear"
|
| 749 |
+
base.mkdir()
|
| 750 |
+
canonical = str(base).replace("\\", "/")
|
| 751 |
+
|
| 752 |
+
_, name, version_string, output_directory = _run(
|
| 753 |
+
str(base), shot_name="shot", ai_method="", extension=".exr",
|
| 754 |
+
version="v007", disable_versioning=True,
|
| 755 |
+
)
|
| 756 |
+
|
| 757 |
+
assert version_string == ""
|
| 758 |
+
assert output_directory == f"{canonical}/shot"
|
| 759 |
+
assert name == "shot.exr"
|
| 760 |
+
|
| 761 |
+
def test_empty_version_yields_no_token(self, tmp_path: Path):
|
| 762 |
+
base = tmp_path / "bear"
|
| 763 |
+
base.mkdir()
|
| 764 |
+
canonical = str(base).replace("\\", "/")
|
| 765 |
+
|
| 766 |
+
_, name, version_string, output_directory = _run(
|
| 767 |
+
str(base), shot_name="shot", ai_method="", extension=".exr",
|
| 768 |
+
version="", disable_versioning=False,
|
| 769 |
+
)
|
| 770 |
+
|
| 771 |
+
assert version_string == ""
|
| 772 |
+
assert output_directory == f"{canonical}/shot"
|
| 773 |
+
assert name == "shot.exr"
|
ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_image_batch.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
| 2 |
+
"""Tests for ``easy_ImageBatch`` insert modes and schema.
|
| 3 |
+
|
| 4 |
+
The node imports ``torch`` at module load, but its *planners*
|
| 5 |
+
(``plan_offset_placements`` for insert placement, ``plan_source_base_fill``
|
| 6 |
+
for the insert-over-source background) are pure Python and never touch torch
|
| 7 |
+
— they just compute which frame lands at which output index. CI installs
|
| 8 |
+
only ``pytest`` / ``aiohttp`` / ``Pillow`` (no torch), so we stub the module
|
| 9 |
+
to import the node torch-free and test the placement logic directly.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import sys
|
| 14 |
+
import types
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
|
| 18 |
+
# Must run before importing the node module (its top-level ``import torch``).
|
| 19 |
+
# ``setdefault`` keeps a real torch if one happens to be installed.
|
| 20 |
+
sys.modules.setdefault("torch", types.ModuleType("torch"))
|
| 21 |
+
|
| 22 |
+
# Enrich the stub with the handful of constructors ``create_batch`` uses so the
|
| 23 |
+
# *behaviour* (layer compositing, tie-break, passthrough) can be exercised
|
| 24 |
+
# torch-free — not just the pure planners. ``_FakeTensor`` wraps a flat list of
|
| 25 |
+
# frame-identity tags, so a test can assert *which* frame landed at each output
|
| 26 |
+
# index. Skipped automatically if a real torch is installed.
|
| 27 |
+
_torch = sys.modules["torch"]
|
| 28 |
+
_FAKE_TORCH = not hasattr(_torch, "__version__")
|
| 29 |
+
if _FAKE_TORCH:
|
| 30 |
+
class _FakeTensor:
|
| 31 |
+
"""Minimal stand-in: a list of opaque per-frame tags + an H/W/C shape."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, data, hwc=(2, 2, 3)):
|
| 34 |
+
self._data = list(data)
|
| 35 |
+
self._hwc = tuple(hwc)
|
| 36 |
+
self.device = "cpu"
|
| 37 |
+
self.dtype = "float32"
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def shape(self):
|
| 41 |
+
return (len(self._data),) + self._hwc
|
| 42 |
+
|
| 43 |
+
def __len__(self):
|
| 44 |
+
return len(self._data)
|
| 45 |
+
|
| 46 |
+
def __getitem__(self, key):
|
| 47 |
+
if isinstance(key, int):
|
| 48 |
+
return self._data[key]
|
| 49 |
+
if isinstance(key, slice):
|
| 50 |
+
return _FakeTensor(self._data[key], self._hwc)
|
| 51 |
+
if isinstance(key, (list, tuple)):
|
| 52 |
+
return _FakeTensor([self._data[i] for i in key], self._hwc)
|
| 53 |
+
raise TypeError(key)
|
| 54 |
+
|
| 55 |
+
def __setitem__(self, key, value):
|
| 56 |
+
if isinstance(key, int):
|
| 57 |
+
self._data[key] = value
|
| 58 |
+
else:
|
| 59 |
+
raise TypeError(key)
|
| 60 |
+
|
| 61 |
+
def __rsub__(self, other): # 1.0 - alpha; identity is fine for these tests
|
| 62 |
+
return self
|
| 63 |
+
|
| 64 |
+
def _fake_full(shape, fill_value, device=None, dtype=None):
|
| 65 |
+
return _FakeTensor([("placeholder", fill_value)] * shape[0], shape[1:])
|
| 66 |
+
|
| 67 |
+
def _fake_ones(shape, device=None, dtype=None):
|
| 68 |
+
return _FakeTensor([1.0] * shape[0], shape[1:])
|
| 69 |
+
|
| 70 |
+
_torch.float32 = "float32"
|
| 71 |
+
_torch.full = _fake_full
|
| 72 |
+
_torch.ones = _fake_ones
|
| 73 |
+
else: # pragma: no cover - real torch present, skip the integration tests
|
| 74 |
+
_FakeTensor = None
|
| 75 |
+
|
| 76 |
+
_GRAY = ("placeholder", 0.5) # what _fake_full tags an unfilled frame with
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _source(n):
|
| 80 |
+
"""A fake source_batch whose frame k (0-based) is the tag ``f"src{k+1}"``."""
|
| 81 |
+
return _FakeTensor([f"src{k + 1}" for k in range(n)])
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
requires_fake_torch = pytest.mark.skipif(
|
| 85 |
+
not _FAKE_TORCH, reason="behaviour tests rely on the fake-tensor stub"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
from k_easy_image_batch import ( # noqa: E402 (import after stub)
|
| 89 |
+
easy_ImageBatch,
|
| 90 |
+
parse_frame_tokens,
|
| 91 |
+
plan_offset_placements,
|
| 92 |
+
plan_slot_overwrites,
|
| 93 |
+
plan_source_base_fill,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _frames(plan):
|
| 98 |
+
"""VFX frame numbers placed, in order."""
|
| 99 |
+
return [vfx for _src, _out, vfx in plan.placements]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_basic_offset_maps_packed_frames_to_listed_positions():
|
| 103 |
+
# 5 processed frames, list 1,10,19,30,40, full 41-frame window (cut=1).
|
| 104 |
+
plan = plan_offset_placements([1, 10, 19, 30, 40], source_count=5, total_frames=41, cut_start_frame=1)
|
| 105 |
+
assert plan.placements == [
|
| 106 |
+
(0, 0, 1),
|
| 107 |
+
(1, 9, 10),
|
| 108 |
+
(2, 18, 19),
|
| 109 |
+
(3, 29, 30),
|
| 110 |
+
(4, 39, 40),
|
| 111 |
+
]
|
| 112 |
+
assert plan.outside_cut == []
|
| 113 |
+
assert plan.unused_source == 0
|
| 114 |
+
assert plan.missing_positions == []
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_pairing_is_by_ascending_position_not_list_order():
|
| 118 |
+
# Out-of-order list still pairs source[0] -> lowest position, etc., so it
|
| 119 |
+
# round-trips with selected_frames (which is emitted ascending).
|
| 120 |
+
plan = plan_offset_placements([40, 1, 19], source_count=3, total_frames=41, cut_start_frame=1)
|
| 121 |
+
assert _frames(plan) == [1, 19, 40]
|
| 122 |
+
assert [src for src, _out, _vfx in plan.placements] == [0, 1, 2]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_more_source_frames_than_positions_reports_unused():
|
| 126 |
+
plan = plan_offset_placements([1, 10, 19], source_count=5, total_frames=41, cut_start_frame=1)
|
| 127 |
+
assert _frames(plan) == [1, 10, 19]
|
| 128 |
+
assert plan.unused_source == 2
|
| 129 |
+
assert plan.missing_positions == []
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_fewer_source_frames_than_positions_reports_missing():
|
| 133 |
+
plan = plan_offset_placements([1, 10, 19, 30], source_count=2, total_frames=41, cut_start_frame=1)
|
| 134 |
+
assert _frames(plan) == [1, 10]
|
| 135 |
+
assert plan.unused_source == 0
|
| 136 |
+
assert plan.missing_positions == [19, 30]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_positions_outside_cut_window_are_dropped():
|
| 140 |
+
# output index = vfx - cut_start_frame; window [0, 20).
|
| 141 |
+
# frame 1 -> -9 (out), 15 -> 5 (in), 40 -> 30 (out).
|
| 142 |
+
plan = plan_offset_placements([1, 15, 40], source_count=3, total_frames=20, cut_start_frame=10)
|
| 143 |
+
assert _frames(plan) == [15]
|
| 144 |
+
assert plan.outside_cut == [1, 40]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_duplicate_positions_are_deduped():
|
| 148 |
+
plan = plan_offset_placements([10, 10, 19], source_count=3, total_frames=41, cut_start_frame=1)
|
| 149 |
+
assert _frames(plan) == [10, 19]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_empty_position_list_places_nothing():
|
| 153 |
+
plan = plan_offset_placements([], source_count=5, total_frames=41, cut_start_frame=1)
|
| 154 |
+
assert plan.placements == []
|
| 155 |
+
assert plan.unused_source == 5
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_cut_start_offsets_output_indices():
|
| 159 |
+
# cut_start_frame=41: frame 41 -> idx 0, 63 -> idx 22.
|
| 160 |
+
plan = plan_offset_placements([41, 63], source_count=2, total_frames=81, cut_start_frame=41)
|
| 161 |
+
assert plan.placements == [(0, 0, 41), (1, 22, 63)]
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_source_base_fill_full_window_maps_one_to_one():
|
| 165 |
+
# cut_start=1: output i -> source i (source_batch[0] is VFX frame 1).
|
| 166 |
+
placements, fallback = plan_source_base_fill(total_frames=5, cut_start_frame=1, base_len=10)
|
| 167 |
+
assert placements == [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)]
|
| 168 |
+
assert fallback == 0
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_source_base_fill_offsets_with_cut_start():
|
| 172 |
+
# cut_start=41: output 0 -> source 40 (VFX frame 41), etc.
|
| 173 |
+
placements, fallback = plan_source_base_fill(total_frames=3, cut_start_frame=41, base_len=121)
|
| 174 |
+
assert placements == [(0, 40), (1, 41), (2, 42)]
|
| 175 |
+
assert fallback == 0
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_source_base_fill_beyond_source_falls_back_to_placeholder():
|
| 179 |
+
# base only covers 2 frames; the remaining 3 cut frames stay placeholder.
|
| 180 |
+
placements, fallback = plan_source_base_fill(total_frames=5, cut_start_frame=1, base_len=2)
|
| 181 |
+
assert placements == [(0, 0), (1, 1)]
|
| 182 |
+
assert fallback == 3
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_source_base_fill_cut_starts_past_source_end():
|
| 186 |
+
# Cut window starts beyond the source entirely → all placeholder.
|
| 187 |
+
placements, fallback = plan_source_base_fill(total_frames=4, cut_start_frame=50, base_len=10)
|
| 188 |
+
assert placements == []
|
| 189 |
+
assert fallback == 4
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_slot_overwrites_basic_placement():
|
| 193 |
+
# image1 @ VFX frame 4, cut_start 1 -> output index 3.
|
| 194 |
+
placements, outside = plan_slot_overwrites([(0, 4)], total_frames=24, cut_start_frame=1)
|
| 195 |
+
assert placements == [(0, 3, 4)]
|
| 196 |
+
assert outside == []
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_slot_overwrites_higher_slot_wins_collision():
|
| 200 |
+
# image1 (idx 0) and image3 (idx 2) both target VFX frame 10 -> the
|
| 201 |
+
# higher-numbered slot wins, one winner per output index.
|
| 202 |
+
placements, outside = plan_slot_overwrites(
|
| 203 |
+
[(0, 10), (2, 10)], total_frames=24, cut_start_frame=1
|
| 204 |
+
)
|
| 205 |
+
assert placements == [(2, 9, 10)]
|
| 206 |
+
assert outside == []
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def test_slot_overwrites_higher_slot_wins_regardless_of_input_order():
|
| 210 |
+
# Same collision, supplied highest-first: result must still pick image3.
|
| 211 |
+
placements, _ = plan_slot_overwrites(
|
| 212 |
+
[(2, 10), (0, 10)], total_frames=24, cut_start_frame=1
|
| 213 |
+
)
|
| 214 |
+
assert placements == [(2, 9, 10)]
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_slot_overwrites_outside_cut_reported():
|
| 218 |
+
# cut_start 10: frame 2 -> output -8 (outside); frame 15 -> 5 (in).
|
| 219 |
+
placements, outside = plan_slot_overwrites(
|
| 220 |
+
[(0, 2), (1, 15)], total_frames=20, cut_start_frame=10
|
| 221 |
+
)
|
| 222 |
+
assert placements == [(1, 5, 15)]
|
| 223 |
+
assert outside == [2]
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def test_slot_overwrites_cut_start_offsets_output_indices():
|
| 227 |
+
placements, _ = plan_slot_overwrites(
|
| 228 |
+
[(0, 41), (1, 63)], total_frames=81, cut_start_frame=41
|
| 229 |
+
)
|
| 230 |
+
assert placements == [(0, 0, 41), (1, 22, 63)]
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def test_slot_overwrites_sorted_by_output_index():
|
| 234 |
+
# Distinct frames, ascending by output index regardless of slot order.
|
| 235 |
+
placements, _ = plan_slot_overwrites(
|
| 236 |
+
[(0, 20), (1, 5)], total_frames=24, cut_start_frame=1
|
| 237 |
+
)
|
| 238 |
+
assert [out for _s, out, _v in placements] == [4, 19]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def test_slot_overwrites_empty_input_places_nothing():
|
| 242 |
+
placements, outside = plan_slot_overwrites([], total_frames=10, cut_start_frame=1)
|
| 243 |
+
assert placements == []
|
| 244 |
+
assert outside == []
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def test_input_types_exposes_keyframes_insert_with_tooltip():
|
| 248 |
+
spec = easy_ImageBatch.INPUT_TYPES()
|
| 249 |
+
assert "keyframes_insert" in spec["optional"], "insert-mode input must be declared"
|
| 250 |
+
# `keyframe_batch` is retained as a deprecated alias so workflows saved on
|
| 251 |
+
# v0.4.1/v0.4.2 keep loading.
|
| 252 |
+
assert "keyframe_batch" in spec["optional"]
|
| 253 |
+
assert "deprecated" in spec["optional"]["keyframe_batch"][1]["tooltip"].lower()
|
| 254 |
+
entry = spec["optional"]["keyframes_insert"]
|
| 255 |
+
assert entry[0] == "IMAGE"
|
| 256 |
+
tooltip = entry[1]["tooltip"].lower()
|
| 257 |
+
assert "insert" in tooltip
|
| 258 |
+
assert "source_frames" in tooltip
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def test_input_types_exposes_width_height_fallback_at_end():
|
| 262 |
+
spec = easy_ImageBatch.INPUT_TYPES()
|
| 263 |
+
# Fallback-only and declared OPTIONAL so they append to the END of the
|
| 264 |
+
# widget list — keeping positional widgets_values of older saved workflows
|
| 265 |
+
# aligned (inserting them earlier shifts placeholder_color/source_frames).
|
| 266 |
+
for key in ("width", "height"):
|
| 267 |
+
assert key in spec["optional"], f"{key} fallback widget must be optional"
|
| 268 |
+
assert key not in spec["required"]
|
| 269 |
+
assert spec["optional"][key][0] == "INT"
|
| 270 |
+
assert spec["optional"][key][1]["default"] == 512
|
| 271 |
+
# They must be the last two optional keys (= last two widgets).
|
| 272 |
+
assert list(spec["optional"])[-2:] == ["width", "height"]
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def test_registration_exports_unchanged():
|
| 276 |
+
from k_easy_image_batch import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
| 277 |
+
|
| 278 |
+
assert NODE_CLASS_MAPPINGS["easy_ImageBatch"] is easy_ImageBatch
|
| 279 |
+
assert NODE_DISPLAY_NAME_MAPPINGS["easy_ImageBatch"] == "Easy Image Batch (Koolook)"
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# --- Behaviour (layer compositing) tests, run against the fake-tensor stub ---
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
@requires_fake_torch
|
| 286 |
+
def test_select_slot_overwrites_list_pick_at_same_frame():
|
| 287 |
+
# source pick at frame 5 AND image1 wired at frame 5 -> the slot wins.
|
| 288 |
+
node = easy_ImageBatch()
|
| 289 |
+
image_batch, _alpha, selected, frames = node.create_batch(
|
| 290 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 291 |
+
invert_alpha=False, source_frames="5",
|
| 292 |
+
image1_frame=5, source_batch=_source(24), image1=_FakeTensor(["slotA"]),
|
| 293 |
+
)
|
| 294 |
+
assert image_batch[4] == "slotA" # output index 4 == VFX frame 5
|
| 295 |
+
assert frames == "5"
|
| 296 |
+
assert len(selected) == 1
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@requires_fake_torch
|
| 300 |
+
def test_select_higher_slot_wins_at_same_frame():
|
| 301 |
+
# image1 and image2 both target frame 10 -> the higher-numbered slot wins.
|
| 302 |
+
node = easy_ImageBatch()
|
| 303 |
+
image_batch, _alpha, _sel, _frames = node.create_batch(
|
| 304 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 305 |
+
invert_alpha=False, source_frames="",
|
| 306 |
+
image1_frame=10, image1=_FakeTensor(["slot1"]),
|
| 307 |
+
image2=_FakeTensor(["slot2"]), image2_frame=10,
|
| 308 |
+
)
|
| 309 |
+
assert image_batch[9] == "slot2"
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@requires_fake_torch
|
| 313 |
+
def test_insert_slot_composites_on_top_of_inserts():
|
| 314 |
+
# Inserts ins1/ins2/ins3 land at frames 5/7/20; image1 @ frame 5 wins there.
|
| 315 |
+
node = easy_ImageBatch()
|
| 316 |
+
image_batch, _alpha, _sel, _frames = node.create_batch(
|
| 317 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 318 |
+
invert_alpha=False, source_frames="5 7 20",
|
| 319 |
+
image1_frame=5, keyframes_insert=_FakeTensor(["ins1", "ins2", "ins3"]),
|
| 320 |
+
image1=_FakeTensor(["slotA"]),
|
| 321 |
+
)
|
| 322 |
+
assert image_batch[4] == "slotA" # slot beats the insert at frame 5
|
| 323 |
+
assert image_batch[6] == "ins2" # other inserts untouched
|
| 324 |
+
assert image_batch[19] == "ins3"
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
@requires_fake_torch
|
| 328 |
+
def test_select_empty_list_passes_source_through_with_no_phantom_picks():
|
| 329 |
+
# EXE 2: source connected, empty list, no slots wired (defaults 5/9/13/17).
|
| 330 |
+
# Whole batch is the source passthrough; nothing is "placed".
|
| 331 |
+
node = easy_ImageBatch()
|
| 332 |
+
image_batch, _alpha, selected, frames = node.create_batch(
|
| 333 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 334 |
+
invert_alpha=False, source_frames="",
|
| 335 |
+
image1_frame=5, source_batch=_source(24),
|
| 336 |
+
image2_frame=9, image3_frame=13, image4_frame=17,
|
| 337 |
+
)
|
| 338 |
+
assert [image_batch[i] for i in range(24)] == [f"src{k + 1}" for k in range(24)]
|
| 339 |
+
assert frames == "" # background is never counted as placed
|
| 340 |
+
assert len(selected) == 0
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
@requires_fake_torch
|
| 344 |
+
def test_select_nonempty_list_ignores_unwired_slot_defaults():
|
| 345 |
+
# EXE 3: only the listed picks are placed; the unwired slots' default
|
| 346 |
+
# imageN_frame values (9/13/17) do NOT pull phantom frames from source.
|
| 347 |
+
node = easy_ImageBatch()
|
| 348 |
+
image_batch, _alpha, _sel, frames = node.create_batch(
|
| 349 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 350 |
+
invert_alpha=False, source_frames="5 8 20",
|
| 351 |
+
image1_frame=5, source_batch=_source(24),
|
| 352 |
+
image2_frame=9, image3_frame=13, image4_frame=17,
|
| 353 |
+
)
|
| 354 |
+
assert frames == "5, 8, 20"
|
| 355 |
+
assert image_batch[4] == "src5"
|
| 356 |
+
assert image_batch[7] == "src8"
|
| 357 |
+
assert image_batch[19] == "src20"
|
| 358 |
+
assert image_batch[8] == _GRAY # frame 9 stays placeholder, no phantom pick
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
@requires_fake_torch
|
| 362 |
+
def test_selected_image_batch_is_picks_plus_slots_slots_win_overlap():
|
| 363 |
+
# selected_image_batch = union of multiline picks AND slot overwrites,
|
| 364 |
+
# ascending by frame, with the slot winning where they overlap.
|
| 365 |
+
node = easy_ImageBatch()
|
| 366 |
+
_img, _alpha, selected, frames = node.create_batch(
|
| 367 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 368 |
+
invert_alpha=False, source_frames="5 8",
|
| 369 |
+
image1_frame=5, source_batch=_source(24),
|
| 370 |
+
image1=_FakeTensor(["slotA"]), # overlaps the pick at frame 5
|
| 371 |
+
image2=_FakeTensor(["slotB"]), image2_frame=12, # slot-only position
|
| 372 |
+
)
|
| 373 |
+
assert frames == "5, 8, 12"
|
| 374 |
+
assert [selected[i] for i in range(len(selected))] == ["slotA", "src8", "slotB"]
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
@requires_fake_torch
|
| 378 |
+
def test_selected_image_batch_includes_inserts_and_slots_in_insert_mode():
|
| 379 |
+
# Same rule under insert mode: selected = inserts + slot overwrites.
|
| 380 |
+
node = easy_ImageBatch()
|
| 381 |
+
_img, _alpha, selected, frames = node.create_batch(
|
| 382 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 383 |
+
invert_alpha=False, source_frames="5 7 20",
|
| 384 |
+
image1_frame=4, keyframes_insert=_FakeTensor(["ins1", "ins2", "ins3"]),
|
| 385 |
+
image1=_FakeTensor(["slotA"]), # slot-only position at frame 4
|
| 386 |
+
)
|
| 387 |
+
assert frames == "4, 5, 7, 20"
|
| 388 |
+
assert [selected[i] for i in range(len(selected))] == ["slotA", "ins1", "ins2", "ins3"]
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
# --- Feature: range syntax in the frame list (parse_frame_tokens) ---
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def test_parse_frame_tokens_expands_inclusive_ranges():
|
| 395 |
+
values, bad = parse_frame_tokens("1-5, 7, 9, 14-17")
|
| 396 |
+
assert values == [1, 2, 3, 4, 5, 7, 9, 14, 15, 16, 17]
|
| 397 |
+
assert bad == []
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def test_parse_frame_tokens_mixed_separators():
|
| 401 |
+
values, bad = parse_frame_tokens("1-3 7\n10")
|
| 402 |
+
assert values == [1, 2, 3, 7, 10]
|
| 403 |
+
assert bad == []
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def test_parse_frame_tokens_single_value_range():
|
| 407 |
+
values, bad = parse_frame_tokens("7-7")
|
| 408 |
+
assert values == [7]
|
| 409 |
+
assert bad == []
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def test_parse_frame_tokens_descending_range_is_bad():
|
| 413 |
+
values, bad = parse_frame_tokens("5-1, 8")
|
| 414 |
+
assert values == [8]
|
| 415 |
+
assert bad == ["5-1"]
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def test_parse_frame_tokens_non_integer_tokens_are_bad():
|
| 419 |
+
values, bad = parse_frame_tokens("3, foo, 5-x, 9")
|
| 420 |
+
assert values == [3, 9]
|
| 421 |
+
assert bad == ["foo", "5-x"]
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
@requires_fake_torch
|
| 425 |
+
def test_select_accepts_range_syntax_in_list():
|
| 426 |
+
node = easy_ImageBatch()
|
| 427 |
+
_img, _alpha, _sel, frames = node.create_batch(
|
| 428 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 429 |
+
invert_alpha=False, source_frames="1-3, 5",
|
| 430 |
+
image1_frame=4, source_batch=_source(24),
|
| 431 |
+
)
|
| 432 |
+
assert frames == "1, 2, 3, 5"
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@requires_fake_torch
|
| 436 |
+
def test_insert_accepts_range_syntax_in_positions():
|
| 437 |
+
node = easy_ImageBatch()
|
| 438 |
+
_img, _alpha, _sel, frames = node.create_batch(
|
| 439 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 440 |
+
invert_alpha=False, source_frames="5-7",
|
| 441 |
+
image1_frame=4, keyframes_insert=_FakeTensor(["a", "b", "c"]),
|
| 442 |
+
)
|
| 443 |
+
assert frames == "5, 6, 7"
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
# --- Feature: passthrough shorter than output -> keep + gap-as-selection ---
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
@requires_fake_torch
|
| 450 |
+
def test_passthrough_shorter_source_keeps_covered_and_selects_gap():
|
| 451 |
+
# source = 17 frames, output = 24: frames 1-17 are kept (alpha 0/black),
|
| 452 |
+
# frames 18-24 are the gap (alpha 1/white) and become the selection.
|
| 453 |
+
node = easy_ImageBatch()
|
| 454 |
+
image_batch, alpha, selected, frames = node.create_batch(
|
| 455 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 456 |
+
invert_alpha=False, source_frames="",
|
| 457 |
+
image1_frame=4, source_batch=_source(17),
|
| 458 |
+
)
|
| 459 |
+
# image: 1-17 source, 18-24 placeholder
|
| 460 |
+
assert image_batch[16] == "src17"
|
| 461 |
+
assert image_batch[17] == _GRAY
|
| 462 |
+
# alpha (inpaint): covered black (0.0), gap white (1.0)
|
| 463 |
+
assert alpha[0] == 0.0 and alpha[16] == 0.0
|
| 464 |
+
assert alpha[17] == 1.0 and alpha[23] == 1.0
|
| 465 |
+
# selection = the gap frames 18..24 (to inpaint)
|
| 466 |
+
assert frames == "18, 19, 20, 21, 22, 23, 24"
|
| 467 |
+
assert len(selected) == 7
|
| 468 |
+
assert selected[0] == _GRAY
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
@requires_fake_torch
|
| 472 |
+
def test_passthrough_full_coverage_selects_nothing():
|
| 473 |
+
# source >= output: no gap -> alpha all black (kept), selection empty.
|
| 474 |
+
node = easy_ImageBatch()
|
| 475 |
+
image_batch, alpha, selected, frames = node.create_batch(
|
| 476 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 477 |
+
invert_alpha=False, source_frames="",
|
| 478 |
+
image1_frame=4, source_batch=_source(24),
|
| 479 |
+
)
|
| 480 |
+
assert [image_batch[i] for i in range(24)] == [f"src{k + 1}" for k in range(24)]
|
| 481 |
+
assert alpha[0] == 0.0 and alpha[23] == 0.0
|
| 482 |
+
assert frames == ""
|
| 483 |
+
assert len(selected) == 0
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
@requires_fake_torch
|
| 487 |
+
def test_passthrough_slot_in_gap_counts_as_kept_not_gap():
|
| 488 |
+
# A slot filling a gap frame becomes kept content, excluded from the
|
| 489 |
+
# inpaint selection.
|
| 490 |
+
node = easy_ImageBatch()
|
| 491 |
+
_img, alpha, _sel, frames = node.create_batch(
|
| 492 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 493 |
+
invert_alpha=False, source_frames="",
|
| 494 |
+
image1_frame=20, source_batch=_source(17), image1=_FakeTensor(["slotA"]),
|
| 495 |
+
)
|
| 496 |
+
assert alpha[19] == 0.0 # frame 20 filled by slot -> kept
|
| 497 |
+
assert frames == "18, 19, 21, 22, 23, 24" # gap excludes frame 20
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
@requires_fake_torch
|
| 501 |
+
def test_passthrough_gap_with_cut_start_offset():
|
| 502 |
+
# cut_start=41, total=10 -> output is VFX 41..50. source has 45 frames, so
|
| 503 |
+
# VFX 41..45 are covered (output 0..4) and 46..50 are the gap.
|
| 504 |
+
node = easy_ImageBatch()
|
| 505 |
+
image_batch, alpha, _sel, frames = node.create_batch(
|
| 506 |
+
total_frames=10, cut_start_frame=41, placeholder_color="Gray",
|
| 507 |
+
invert_alpha=False, source_frames="",
|
| 508 |
+
image1_frame=4, source_batch=_source(45),
|
| 509 |
+
)
|
| 510 |
+
assert image_batch[0] == "src41" and image_batch[4] == "src45"
|
| 511 |
+
assert image_batch[5] == _GRAY # VFX 46 = gap
|
| 512 |
+
assert alpha[0] == 0.0 and alpha[4] == 0.0 # covered kept
|
| 513 |
+
assert alpha[5] == 1.0 and alpha[9] == 1.0 # gap to inpaint
|
| 514 |
+
assert frames == "46, 47, 48, 49, 50" # gap selection
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
# --- Review-round fixes: deprecated alias + input guards ---
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def test_parse_frame_tokens_rejects_overlong_range():
|
| 521 |
+
values, bad = parse_frame_tokens("1-99999999, 7")
|
| 522 |
+
assert values == [7]
|
| 523 |
+
assert bad == ["1-99999999"]
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def test_parse_frame_tokens_allows_large_bounded_range():
|
| 527 |
+
values, bad = parse_frame_tokens("1-100")
|
| 528 |
+
assert len(values) == 100 and bad == []
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def test_parse_frame_tokens_range_span_boundary():
|
| 532 |
+
# exactly _MAX_RANGE_SPAN (8192) frames is allowed; one more is rejected.
|
| 533 |
+
ok, bad = parse_frame_tokens("1-8192")
|
| 534 |
+
assert len(ok) == 8192 and bad == []
|
| 535 |
+
over, bad_over = parse_frame_tokens("1-8193")
|
| 536 |
+
assert over == [] and bad_over == ["1-8193"]
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
@requires_fake_torch
|
| 540 |
+
def test_keyframe_batch_alias_routes_to_insert_mode():
|
| 541 |
+
# A workflow saved with the deprecated alias still behaves like an insert.
|
| 542 |
+
node = easy_ImageBatch()
|
| 543 |
+
_img, _alpha, _sel, frames = node.create_batch(
|
| 544 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 545 |
+
invert_alpha=False, source_frames="5 7 20",
|
| 546 |
+
image1_frame=4, keyframe_batch=_FakeTensor(["a", "b", "c"]),
|
| 547 |
+
)
|
| 548 |
+
assert frames == "5, 7, 20"
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
@requires_fake_torch
|
| 552 |
+
def test_keyframes_insert_wins_over_deprecated_alias():
|
| 553 |
+
node = easy_ImageBatch()
|
| 554 |
+
_img, _alpha, selected, _frames = node.create_batch(
|
| 555 |
+
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
| 556 |
+
invert_alpha=False, source_frames="5",
|
| 557 |
+
image1_frame=4,
|
| 558 |
+
keyframes_insert=_FakeTensor(["new"]), keyframe_batch=_FakeTensor(["old"]),
|
| 559 |
+
)
|
| 560 |
+
assert selected[0] == "new"
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
@requires_fake_torch
|
| 564 |
+
def test_nonempty_list_without_image_source_warns_and_returns_clean(capsys):
|
| 565 |
+
# source_frames set but no source_batch / imageN -> clean placeholder batch,
|
| 566 |
+
# with a warning that the list was ignored (no silent drop).
|
| 567 |
+
node = easy_ImageBatch()
|
| 568 |
+
image_batch, _alpha, selected, frames = node.create_batch(
|
| 569 |
+
total_frames=5, cut_start_frame=1, placeholder_color="Gray",
|
| 570 |
+
invert_alpha=False, source_frames="2 3",
|
| 571 |
+
image1_frame=4, width=8, height=8,
|
| 572 |
+
)
|
| 573 |
+
assert len(image_batch) == 5
|
| 574 |
+
assert frames == "" and len(selected) == 0
|
| 575 |
+
assert "source_frames is set but no image source" in capsys.readouterr().out
|
ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_utility.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
| 2 |
+
"""Tests for Easy_Utility — the dispatchable converter node."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from k_easy_utility import Easy_Utility
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def node():
|
| 12 |
+
return Easy_Utility()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.mark.parametrize(
|
| 16 |
+
"int_value,prefix,pad_width,expected",
|
| 17 |
+
[
|
| 18 |
+
(1, "v", 3, "v001"),
|
| 19 |
+
(0, "v", 3, "v000"),
|
| 20 |
+
(42, "v", 3, "v042"),
|
| 21 |
+
(999, "v", 3, "v999"),
|
| 22 |
+
(1000, "v", 3, "v1000"), # widens past pad_width when needed
|
| 23 |
+
(5, "", 2, "05"), # no prefix
|
| 24 |
+
(7, "shot_", 4, "shot_0007"),
|
| 25 |
+
(1, "v", 0, "v1"), # pad_width=0 → no padding
|
| 26 |
+
],
|
| 27 |
+
)
|
| 28 |
+
def test_int_to_padded_string(node, int_value, prefix, pad_width, expected):
|
| 29 |
+
(out,) = node.run("int_to_padded_string", int_value, prefix, pad_width)
|
| 30 |
+
assert out == expected
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_unknown_mode_returns_stringified_int(node):
|
| 34 |
+
(out,) = node.run("nonexistent_mode", 42, "v", 3)
|
| 35 |
+
assert out == "42"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_registration_exports():
|
| 39 |
+
from k_easy_utility import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
| 40 |
+
|
| 41 |
+
assert "Easy_Utility" in NODE_CLASS_MAPPINGS
|
| 42 |
+
assert NODE_CLASS_MAPPINGS["Easy_Utility"] is Easy_Utility
|
| 43 |
+
assert NODE_DISPLAY_NAME_MAPPINGS["Easy_Utility"] == "Easy Utility (Koolook)"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_widget_defaults_produce_bare_padded_digits():
|
| 47 |
+
"""Default widget values should yield ``001`` (no prefix) — the
|
| 48 |
+
EasyAIPipeline / Easy_VideoCombine consumers add ``v`` themselves
|
| 49 |
+
via ``koolook_versioning.resolve_version_token``."""
|
| 50 |
+
spec = Easy_Utility.INPUT_TYPES()["required"]
|
| 51 |
+
defaults = {name: opts[1]["default"] for name, opts in spec.items()}
|
| 52 |
+
n = Easy_Utility()
|
| 53 |
+
(out,) = n.run(**defaults)
|
| 54 |
+
assert out == "001"
|
ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_video_combine.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Easy_VideoCombine's absolute-path discrimination.
|
| 2 |
+
|
| 3 |
+
The full VHS encode pipeline needs ffmpeg + torch + a live ComfyUI
|
| 4 |
+
process to exercise meaningfully, so we don't drive ``combine_video()``
|
| 5 |
+
end-to-end here. The behavior worth pinning at unit-test scope is the
|
| 6 |
+
relative-vs-absolute branch and the ``create_path_if_missing`` toggle —
|
| 7 |
+
both live in the pure helper :func:`_resolve_abs_target`, which doesn't
|
| 8 |
+
need VHS to be installed.
|
| 9 |
+
|
| 10 |
+
Importing ``k_video_combine`` works regardless of VHS availability: the
|
| 11 |
+
module's top-level try/except shape leaves ``_resolve_abs_target``
|
| 12 |
+
defined either way (only the class registration is gated).
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
import k_video_combine
|
| 22 |
+
from k_video_combine import (
|
| 23 |
+
_add_metadata_json_sidecar,
|
| 24 |
+
_append_video_path_outputs,
|
| 25 |
+
_append_version_to_prefix,
|
| 26 |
+
_auto_version_scan_target,
|
| 27 |
+
_build_sidecar_workflow,
|
| 28 |
+
_coerce_version_input,
|
| 29 |
+
_compose_prefix,
|
| 30 |
+
_display_format_name,
|
| 31 |
+
_final_json_path_from_result,
|
| 32 |
+
_final_video_path_from_result,
|
| 33 |
+
_finalize_strict_version_output,
|
| 34 |
+
_metadata_sidecar_path,
|
| 35 |
+
_normalize_bool_input,
|
| 36 |
+
_normalize_text_input,
|
| 37 |
+
_remove_audio_suffix_from_result,
|
| 38 |
+
_resolve_abs_target,
|
| 39 |
+
_runtime_format_name,
|
| 40 |
+
_video_path_parts,
|
| 41 |
+
)
|
| 42 |
+
from k_video_load import _compose_input_video_path
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_relative_prefix_returns_none() -> None:
|
| 46 |
+
"""Relative prefixes are upstream's domain — helper signals passthrough."""
|
| 47 |
+
assert _resolve_abs_target("AnimateDiff", create_path_if_missing=False) is None
|
| 48 |
+
assert _resolve_abs_target("renders/clip01", create_path_if_missing=True) is None
|
| 49 |
+
# A path with `..` is still relative — sandbox stays in force.
|
| 50 |
+
assert _resolve_abs_target("../escape", create_path_if_missing=False) is None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_absolute_prefix_existing_dir(tmp_path: Path) -> None:
|
| 54 |
+
"""Absolute prefix with existing parent returns (dir, basename)."""
|
| 55 |
+
prefix = str(tmp_path / "clipname")
|
| 56 |
+
result = _resolve_abs_target(prefix, create_path_if_missing=False)
|
| 57 |
+
|
| 58 |
+
assert result is not None
|
| 59 |
+
abs_dir, abs_base = result
|
| 60 |
+
assert Path(abs_dir) == tmp_path
|
| 61 |
+
assert abs_base == "clipname"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_absolute_prefix_missing_dir_raises_when_create_off(tmp_path: Path) -> None:
|
| 65 |
+
"""Missing parent + create_path_if_missing=False -> FileNotFoundError.
|
| 66 |
+
|
| 67 |
+
A typo in a render path should fail loudly, not silently spawn
|
| 68 |
+
directories the user didn't intend.
|
| 69 |
+
"""
|
| 70 |
+
prefix = str(tmp_path / "nested" / "deeper" / "clip")
|
| 71 |
+
|
| 72 |
+
with pytest.raises(FileNotFoundError, match="Directory does not exist"):
|
| 73 |
+
_resolve_abs_target(prefix, create_path_if_missing=False)
|
| 74 |
+
|
| 75 |
+
assert not (tmp_path / "nested").exists() # No silent mkdir.
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_absolute_prefix_missing_dir_created_when_toggled(tmp_path: Path) -> None:
|
| 79 |
+
"""Missing parent + create_path_if_missing=True -> mkdir + resolve."""
|
| 80 |
+
parent = tmp_path / "nested" / "deeper"
|
| 81 |
+
prefix = str(parent / "clip")
|
| 82 |
+
|
| 83 |
+
result = _resolve_abs_target(prefix, create_path_if_missing=True)
|
| 84 |
+
|
| 85 |
+
assert result is not None
|
| 86 |
+
abs_dir, abs_base = result
|
| 87 |
+
assert parent.exists()
|
| 88 |
+
assert Path(abs_dir) == parent
|
| 89 |
+
assert abs_base == "clip"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_absolute_prefix_with_trailing_separator(tmp_path: Path) -> None:
|
| 93 |
+
"""Existing directory paths work with a final slash."""
|
| 94 |
+
renders_dir = tmp_path / "renders"
|
| 95 |
+
renders_dir.mkdir()
|
| 96 |
+
prefix = str(renders_dir) + os.sep
|
| 97 |
+
|
| 98 |
+
result = _resolve_abs_target(prefix, create_path_if_missing=False)
|
| 99 |
+
|
| 100 |
+
assert result is not None
|
| 101 |
+
abs_dir, abs_base = result
|
| 102 |
+
assert Path(abs_dir) == renders_dir
|
| 103 |
+
assert abs_base == "renders"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_absolute_existing_directory_without_trailing_separator(tmp_path: Path) -> None:
|
| 107 |
+
"""Pasted directory paths work with or without a final slash."""
|
| 108 |
+
renders_dir = tmp_path / "renders"
|
| 109 |
+
renders_dir.mkdir()
|
| 110 |
+
|
| 111 |
+
result = _resolve_abs_target(str(renders_dir), create_path_if_missing=False)
|
| 112 |
+
|
| 113 |
+
assert result is not None
|
| 114 |
+
abs_dir, abs_base = result
|
| 115 |
+
assert Path(abs_dir) == renders_dir
|
| 116 |
+
assert abs_base == "renders"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_absolute_prefix_with_no_filename_component_raises(tmp_path: Path) -> None:
|
| 120 |
+
"""An absolute path with empty basename has no filename root to
|
| 121 |
+
append the counter to — surface as a clear ValueError instead of
|
| 122 |
+
silently writing files named ``_00001.mp4``."""
|
| 123 |
+
root = tmp_path.anchor # "/" on POSIX, "C:\\" on Windows.
|
| 124 |
+
assert os.path.isabs(root)
|
| 125 |
+
|
| 126 |
+
with pytest.raises(ValueError, match="no usable folder name"):
|
| 127 |
+
_resolve_abs_target(root, create_path_if_missing=False)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# =============================================================================
|
| 131 |
+
# _compose_prefix: split-mode output_directory + filename_prefix.
|
| 132 |
+
# =============================================================================
|
| 133 |
+
|
| 134 |
+
def test_compose_empty_output_directory_passes_through() -> None:
|
| 135 |
+
"""No output_directory -> filename_prefix returned unchanged."""
|
| 136 |
+
assert _compose_prefix("AnimateDiff", "") == "AnimateDiff"
|
| 137 |
+
assert _compose_prefix("E:/renders/clip", "") == "E:/renders/clip"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_compose_absolute_directory_with_name() -> None:
|
| 141 |
+
"""Absolute output_directory + name -> joined absolute prefix."""
|
| 142 |
+
composed = _compose_prefix("clip_v003", "E:/renders/shot01")
|
| 143 |
+
assert composed == "E:/renders/shot01" + os.sep + "clip_v003"
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_compose_relative_directory_with_name() -> None:
|
| 147 |
+
"""Relative output_directory + name -> joined relative prefix.
|
| 148 |
+
|
| 149 |
+
Stays relative so the downstream isabs discrimination passes it
|
| 150 |
+
through to upstream's sandbox.
|
| 151 |
+
"""
|
| 152 |
+
composed = _compose_prefix("clip", "shots/v003")
|
| 153 |
+
assert composed == "shots/v003" + os.sep + "clip"
|
| 154 |
+
assert not os.path.isabs(composed)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_compose_strips_path_components_from_filename_prefix() -> None:
|
| 158 |
+
"""When output_directory is set, only the basename of filename_prefix
|
| 159 |
+
becomes the file root — users can change the name without retyping
|
| 160 |
+
the directory."""
|
| 161 |
+
composed = _compose_prefix("subdir/clip_v003", "E:/renders/shot01")
|
| 162 |
+
assert composed == "E:/renders/shot01" + os.sep + "clip_v003"
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_compose_trims_trailing_separator_from_directory() -> None:
|
| 166 |
+
"""Trailing slashes on output_directory don't double-up in the join."""
|
| 167 |
+
composed_unix = _compose_prefix("clip", "E:/renders/shot01/")
|
| 168 |
+
composed_win = _compose_prefix("clip", "E:\\renders\\shot01\\")
|
| 169 |
+
assert composed_unix == "E:/renders/shot01" + os.sep + "clip"
|
| 170 |
+
assert composed_win == "E:\\renders\\shot01" + os.sep + "clip"
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_compose_empty_name_falls_back_to_default() -> None:
|
| 174 |
+
"""If filename_prefix's basename is empty (e.g. user typed just '/'),
|
| 175 |
+
fall back to AnimateDiff rather than producing a path ending in
|
| 176 |
+
just a separator."""
|
| 177 |
+
composed = _compose_prefix("/", "E:/renders/shot01")
|
| 178 |
+
assert composed == "E:/renders/shot01" + os.sep + "AnimateDiff"
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# =============================================================================
|
| 182 |
+
# _normalize_text_input: guard against the ComfyUI frontend "undefined" quirk.
|
| 183 |
+
# A STRING widget left untouched can arrive at the backend as the literal
|
| 184 |
+
# string "undefined" (or "null" / "None") instead of an empty value.
|
| 185 |
+
# Without normalization that would slip into _compose_prefix and produce a
|
| 186 |
+
# nonsensical `undefined/` subdirectory on disk.
|
| 187 |
+
# =============================================================================
|
| 188 |
+
|
| 189 |
+
def test_normalize_empty_inputs() -> None:
|
| 190 |
+
assert _normalize_text_input("") == ""
|
| 191 |
+
assert _normalize_text_input(" ") == ""
|
| 192 |
+
assert _normalize_text_input(None) == ""
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_normalize_undefined_sentinel_strings() -> None:
|
| 196 |
+
"""All case variants of the frontend-quirk strings become empty."""
|
| 197 |
+
assert _normalize_text_input("undefined") == ""
|
| 198 |
+
assert _normalize_text_input("Undefined") == ""
|
| 199 |
+
assert _normalize_text_input("UNDEFINED") == ""
|
| 200 |
+
assert _normalize_text_input("null") == ""
|
| 201 |
+
assert _normalize_text_input("None") == ""
|
| 202 |
+
assert _normalize_text_input(" undefined ") == ""
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_normalize_passes_through_real_values() -> None:
|
| 206 |
+
assert _normalize_text_input("E:/renders/shot01") == "E:/renders/shot01"
|
| 207 |
+
assert _normalize_text_input("clip_v003") == "clip_v003"
|
| 208 |
+
# Substring matches don't trigger — only exact match (post-strip).
|
| 209 |
+
assert _normalize_text_input("undefined_path") == "undefined_path"
|
| 210 |
+
assert _normalize_text_input("my_null_clip") == "my_null_clip"
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_normalize_prevents_undefined_subdir_in_compose() -> None:
|
| 214 |
+
"""Regression test for the bug: output_directory='undefined' was
|
| 215 |
+
being treated as a real directory, producing an `undefined/`
|
| 216 |
+
subfolder when the user typed an absolute path in filename_prefix."""
|
| 217 |
+
normalized_dir = _normalize_text_input("undefined")
|
| 218 |
+
composed = _compose_prefix("E:/renders/clip_v003", normalized_dir)
|
| 219 |
+
# With normalization, no output_directory -> filename_prefix passes through.
|
| 220 |
+
assert composed == "E:/renders/clip_v003"
|
| 221 |
+
assert "undefined" not in composed
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# =============================================================================
|
| 225 |
+
# _normalize_bool_input: guard against dynamic widget restore drift.
|
| 226 |
+
# If format-specific widgets are restored by position instead of by name, a
|
| 227 |
+
# value like "hq" can land in pingpong. Python truthiness would treat that as
|
| 228 |
+
# True and VHS would double the video by ping-ponging the frame sequence.
|
| 229 |
+
# =============================================================================
|
| 230 |
+
|
| 231 |
+
def test_normalize_bool_input_known_values() -> None:
|
| 232 |
+
assert _normalize_bool_input(True) is True
|
| 233 |
+
assert _normalize_bool_input(False) is False
|
| 234 |
+
assert _normalize_bool_input("true") is True
|
| 235 |
+
assert _normalize_bool_input("1") is True
|
| 236 |
+
assert _normalize_bool_input("on") is True
|
| 237 |
+
assert _normalize_bool_input("false") is False
|
| 238 |
+
assert _normalize_bool_input("0") is False
|
| 239 |
+
assert _normalize_bool_input("off") is False
|
| 240 |
+
assert _normalize_bool_input("") is False
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def test_normalize_bool_input_unknown_string_uses_default() -> None:
|
| 244 |
+
assert _normalize_bool_input("hq", default=False) is False
|
| 245 |
+
assert _normalize_bool_input("hq", default=True) is True
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def test_metadata_sidecar_path_replaces_vhs_png_sidecar(tmp_path: Path) -> None:
|
| 249 |
+
png = tmp_path / "clip_00001.png"
|
| 250 |
+
assert _metadata_sidecar_path([str(png)]) == str(tmp_path / "clip_00001.json")
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def test_add_metadata_json_sidecar_replaces_disabled_png_entry(tmp_path: Path) -> None:
|
| 254 |
+
png = tmp_path / "clip_00001.png"
|
| 255 |
+
mp4 = tmp_path / "clip_00001.mp4"
|
| 256 |
+
result = {
|
| 257 |
+
"ui": {"gifs": [{"workflow": png.name}]},
|
| 258 |
+
"result": ((True, [str(png), str(mp4)]),),
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
out = _add_metadata_json_sidecar(
|
| 262 |
+
result,
|
| 263 |
+
{"prompt": {"1": {"class_type": "Example"}}},
|
| 264 |
+
save_metadata_json=True,
|
| 265 |
+
save_metadata_png=False,
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
json_path = tmp_path / "clip_00001.json"
|
| 269 |
+
assert json_path.exists()
|
| 270 |
+
assert out["result"][0][1] == [str(json_path), str(mp4)]
|
| 271 |
+
assert out["ui"]["gifs"][0]["workflow"] == json_path.name
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def test_remove_audio_suffix_renames_final_and_drops_missing_intermediate(tmp_path: Path) -> None:
|
| 275 |
+
png = tmp_path / "clip_00001.png"
|
| 276 |
+
silent = tmp_path / "clip_00001.mp4"
|
| 277 |
+
audio = tmp_path / "clip_00001-audio.mp4"
|
| 278 |
+
png.write_text("png")
|
| 279 |
+
audio.write_text("video")
|
| 280 |
+
result = {
|
| 281 |
+
"ui": {"gifs": [{"filename": audio.name, "fullpath": str(audio)}]},
|
| 282 |
+
"result": ((True, [str(png), str(silent), str(audio)]),),
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
out = _remove_audio_suffix_from_result(result, keep_silent_intermediate=False)
|
| 286 |
+
|
| 287 |
+
clean = tmp_path / "clip_00001.mp4"
|
| 288 |
+
assert clean.exists()
|
| 289 |
+
assert not audio.exists()
|
| 290 |
+
assert out["result"][0][1] == [str(png), str(clean)]
|
| 291 |
+
assert out["ui"]["gifs"][0]["filename"] == clean.name
|
| 292 |
+
assert out["ui"]["gifs"][0]["fullpath"] == str(clean)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def test_remove_audio_suffix_replaces_silent_intermediate(tmp_path: Path) -> None:
|
| 296 |
+
silent = tmp_path / "clip_00001.mp4"
|
| 297 |
+
audio = tmp_path / "clip_00001-audio.mp4"
|
| 298 |
+
silent.write_bytes(b"silent")
|
| 299 |
+
audio.write_bytes(b"with_audio")
|
| 300 |
+
result = {
|
| 301 |
+
"ui": {"gifs": [{"filename": audio.name, "fullpath": str(audio)}]},
|
| 302 |
+
"result": ((True, [str(silent), str(audio)]),),
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
out = _remove_audio_suffix_from_result(result, keep_silent_intermediate=False)
|
| 306 |
+
|
| 307 |
+
assert silent.exists()
|
| 308 |
+
assert silent.read_bytes() == b"with_audio"
|
| 309 |
+
assert not audio.exists()
|
| 310 |
+
assert out["result"][0][1] == [str(silent)]
|
| 311 |
+
assert out["ui"]["gifs"][0]["filename"] == silent.name
|
| 312 |
+
assert out["ui"]["gifs"][0]["fullpath"] == str(silent)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def test_koolook_format_display_hides_json_suffix() -> None:
|
| 316 |
+
assert _display_format_name("video/koolook-ASTRA-h264.json") == "video/koolook-ASTRA-h264"
|
| 317 |
+
assert _display_format_name("video/ProRes") == "video/ProRes"
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def test_koolook_format_runtime_restores_json_suffix() -> None:
|
| 321 |
+
assert _runtime_format_name("video/koolook-ASTRA-h264") == "video/koolook-ASTRA-h264.json"
|
| 322 |
+
assert _runtime_format_name("video/ProRes") == "video/ProRes"
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# =============================================================================
|
| 326 |
+
# Strict versioning: a wired/typed version token replaces VHS's _NNNNN counter
|
| 327 |
+
# with a deterministic <root>_<token>.<ext> name (the "professional" mode the
|
| 328 |
+
# maintainer asked for). Empty token keeps the legacy counter.
|
| 329 |
+
# =============================================================================
|
| 330 |
+
|
| 331 |
+
def test_append_version_to_filename_root() -> None:
|
| 332 |
+
assert _append_version_to_prefix("AnimateDiff", "v001") == "AnimateDiff_v001"
|
| 333 |
+
assert _append_version_to_prefix("E:/renders/clip", "v001") == "E:/renders/clip_v001"
|
| 334 |
+
assert _append_version_to_prefix("E:\\renders\\clip", "v001") == "E:\\renders\\clip_v001"
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def test_append_version_no_token_is_noop() -> None:
|
| 338 |
+
assert _append_version_to_prefix("clip", "") == "clip"
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def test_auto_version_scan_target_for_relative_prefix_uses_comfy_output_dir(
|
| 342 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 343 |
+
tmp_path: Path,
|
| 344 |
+
) -> None:
|
| 345 |
+
output_dir = tmp_path / "comfy-output"
|
| 346 |
+
monkeypatch.setattr(
|
| 347 |
+
k_video_combine,
|
| 348 |
+
"folder_paths",
|
| 349 |
+
type("FolderPaths", (), {"get_output_directory": staticmethod(lambda: str(output_dir))}),
|
| 350 |
+
raising=False,
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
scan_dir, name = _auto_version_scan_target("AnimateDiff")
|
| 354 |
+
|
| 355 |
+
assert Path(scan_dir) == output_dir
|
| 356 |
+
assert name == "AnimateDiff"
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def test_auto_version_scan_target_for_relative_subfolder_uses_comfy_output_dir(
|
| 360 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 361 |
+
tmp_path: Path,
|
| 362 |
+
) -> None:
|
| 363 |
+
output_dir = tmp_path / "comfy-output"
|
| 364 |
+
monkeypatch.setattr(
|
| 365 |
+
k_video_combine,
|
| 366 |
+
"folder_paths",
|
| 367 |
+
type("FolderPaths", (), {"get_output_directory": staticmethod(lambda: str(output_dir))}),
|
| 368 |
+
raising=False,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
scan_dir, name = _auto_version_scan_target("renders/AnimateDiff")
|
| 372 |
+
|
| 373 |
+
assert Path(scan_dir) == output_dir / "renders"
|
| 374 |
+
assert name == "AnimateDiff"
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def test_auto_version_scan_target_does_not_fallback_to_cwd(
|
| 378 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 379 |
+
) -> None:
|
| 380 |
+
def fail_output_dir():
|
| 381 |
+
raise RuntimeError("output directory unavailable")
|
| 382 |
+
|
| 383 |
+
monkeypatch.setattr(
|
| 384 |
+
k_video_combine,
|
| 385 |
+
"folder_paths",
|
| 386 |
+
type("FolderPaths", (), {"get_output_directory": staticmethod(fail_output_dir)}),
|
| 387 |
+
raising=False,
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
with pytest.raises(RuntimeError, match="output directory unavailable"):
|
| 391 |
+
_auto_version_scan_target("AnimateDiff")
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def test_strict_version_strips_counter_and_writes_matching_sidecar(tmp_path: Path) -> None:
|
| 395 |
+
"""The headline contract: <root>_v001_00001.mp4 -> <root>_v001.mp4 with a
|
| 396 |
+
matching <root>_v001.json beside it."""
|
| 397 |
+
video = tmp_path / "clip_v001_00001.mp4"
|
| 398 |
+
video.write_bytes(b"render")
|
| 399 |
+
result = {
|
| 400 |
+
"ui": {"gifs": [{"filename": video.name, "fullpath": str(video)}]},
|
| 401 |
+
"result": ((True, [str(video)]),),
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
out = _finalize_strict_version_output(
|
| 405 |
+
result,
|
| 406 |
+
enable_overwrite=False,
|
| 407 |
+
metadata_payload={"CreationTime": "t", "prompt": {"1": {}}},
|
| 408 |
+
save_metadata_json=True,
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
clean = tmp_path / "clip_v001.mp4"
|
| 412 |
+
sidecar = tmp_path / "clip_v001.json"
|
| 413 |
+
assert clean.exists() and not video.exists()
|
| 414 |
+
assert sidecar.exists()
|
| 415 |
+
files = out["result"][0][1]
|
| 416 |
+
assert files[-1] == str(clean)
|
| 417 |
+
assert str(sidecar) in files
|
| 418 |
+
assert out["ui"]["gifs"][0]["filename"] == clean.name
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def test_strict_version_keeps_counter_on_collision_without_overwrite(tmp_path: Path) -> None:
|
| 422 |
+
"""Lossless default: re-rendering an existing version keeps the counter so
|
| 423 |
+
the prior file is never clobbered; the sidecar matches the kept name."""
|
| 424 |
+
existing = tmp_path / "clip_v001.mp4"
|
| 425 |
+
existing.write_bytes(b"old")
|
| 426 |
+
video = tmp_path / "clip_v001_00002.mp4"
|
| 427 |
+
video.write_bytes(b"new")
|
| 428 |
+
result = {"result": ((True, [str(video)]),)}
|
| 429 |
+
|
| 430 |
+
out = _finalize_strict_version_output(
|
| 431 |
+
result,
|
| 432 |
+
enable_overwrite=False,
|
| 433 |
+
metadata_payload={"CreationTime": "t"},
|
| 434 |
+
save_metadata_json=True,
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
assert existing.read_bytes() == b"old"
|
| 438 |
+
assert video.exists()
|
| 439 |
+
assert (tmp_path / "clip_v001_00002.json").exists()
|
| 440 |
+
assert out["result"][0][1][-1] == str(video)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def test_strict_version_replaces_on_collision_with_overwrite(tmp_path: Path) -> None:
|
| 444 |
+
"""Deterministic by opt-in: enable_overwrite replaces the existing version."""
|
| 445 |
+
existing = tmp_path / "clip_v001.mp4"
|
| 446 |
+
existing.write_bytes(b"old")
|
| 447 |
+
video = tmp_path / "clip_v001_00002.mp4"
|
| 448 |
+
video.write_bytes(b"new")
|
| 449 |
+
result = {"result": ((True, [str(video)]),)}
|
| 450 |
+
|
| 451 |
+
out = _finalize_strict_version_output(
|
| 452 |
+
result,
|
| 453 |
+
enable_overwrite=True,
|
| 454 |
+
metadata_payload={"CreationTime": "t"},
|
| 455 |
+
save_metadata_json=True,
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
clean = tmp_path / "clip_v001.mp4"
|
| 459 |
+
assert clean.read_bytes() == b"new"
|
| 460 |
+
assert not video.exists()
|
| 461 |
+
assert out["result"][0][1][-1] == str(clean)
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def test_sidecar_is_drag_loadable_workflow_on_canvas() -> None:
|
| 465 |
+
"""Canvas run: the sidecar IS the litegraph graph at top level, so dropping
|
| 466 |
+
it into ComfyUI reloads the workflow (not a metadata wrapper)."""
|
| 467 |
+
wf = {"last_node_id": 5, "nodes": [{"id": 1}], "links": []}
|
| 468 |
+
out = _build_sidecar_workflow({"1": {"class_type": "X"}}, {"workflow": wf}, "2026-05-29 01:00:00")
|
| 469 |
+
assert out["nodes"] == [{"id": 1}]
|
| 470 |
+
assert out["links"] == []
|
| 471 |
+
assert out["last_node_id"] == 5
|
| 472 |
+
assert out["extra"]["CreationTime"] == "2026-05-29 01:00:00"
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def test_sidecar_preserves_existing_workflow_extra() -> None:
|
| 476 |
+
wf = {"nodes": [], "extra": {"foo": "bar"}}
|
| 477 |
+
out = _build_sidecar_workflow(None, {"workflow": wf}, "t")
|
| 478 |
+
assert out["extra"] == {"foo": "bar", "CreationTime": "t"}
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def test_sidecar_falls_back_when_no_graph_available() -> None:
|
| 482 |
+
"""Headless/API: no litegraph graph -> keep prompt + timestamp so the
|
| 483 |
+
sidecar is never empty (just not drag-loadable in that case)."""
|
| 484 |
+
out = _build_sidecar_workflow({"1": {"class_type": "X"}}, None, "2026-05-29 01:00:00")
|
| 485 |
+
assert out == {"CreationTime": "2026-05-29 01:00:00", "prompt": {"1": {"class_type": "X"}}}
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def test_sidecar_empty_inputs_still_timestamped() -> None:
|
| 489 |
+
out = _build_sidecar_workflow(None, None, "2026-05-29 01:00:00")
|
| 490 |
+
assert out == {"CreationTime": "2026-05-29 01:00:00"}
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def test_coerce_version_input_drops_stale_boolean() -> None:
|
| 494 |
+
"""Back-compat guard: the removed keep_silent_intermediate BOOL must never
|
| 495 |
+
become a version token via the positional widget shift."""
|
| 496 |
+
assert _coerce_version_input(False) == ""
|
| 497 |
+
assert _coerce_version_input(True) == ""
|
| 498 |
+
assert _coerce_version_input("false") == ""
|
| 499 |
+
assert _coerce_version_input("True") == ""
|
| 500 |
+
assert _coerce_version_input("v001") == "v001"
|
| 501 |
+
assert _coerce_version_input("") == ""
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def test_strict_version_with_metadata_png_aligns_and_keeps_png(tmp_path: Path) -> None:
|
| 505 |
+
"""strict + save_metadata_png: the PNG is renamed to the video's stem and
|
| 506 |
+
kept (not dropped), so video/png/json share one name."""
|
| 507 |
+
png = tmp_path / "clip_v001_00001.png"
|
| 508 |
+
png.write_bytes(b"img")
|
| 509 |
+
video = tmp_path / "clip_v001_00001.mp4"
|
| 510 |
+
video.write_bytes(b"vid")
|
| 511 |
+
result = {
|
| 512 |
+
"ui": {"gifs": [{"workflow": png.name}]},
|
| 513 |
+
"result": ((True, [str(png), str(video)]),),
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
out = _finalize_strict_version_output(
|
| 517 |
+
result,
|
| 518 |
+
enable_overwrite=False,
|
| 519 |
+
metadata_payload={"CreationTime": "t"},
|
| 520 |
+
save_metadata_json=True,
|
| 521 |
+
save_metadata_png=True,
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
files = out["result"][0][1]
|
| 525 |
+
assert (tmp_path / "clip_v001.mp4").exists()
|
| 526 |
+
assert (tmp_path / "clip_v001.png").exists()
|
| 527 |
+
assert (tmp_path / "clip_v001.json").exists()
|
| 528 |
+
assert str(tmp_path / "clip_v001.mp4") in files
|
| 529 |
+
assert str(tmp_path / "clip_v001.png") in files
|
| 530 |
+
assert str(tmp_path / "clip_v001.json") in files
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def test_final_video_path_prefers_video_over_sidecars(tmp_path: Path) -> None:
|
| 534 |
+
png = tmp_path / "clip_v001.png"
|
| 535 |
+
video = tmp_path / "clip_v001.mp4"
|
| 536 |
+
json_path = tmp_path / "clip_v001.json"
|
| 537 |
+
result = {"result": ((True, [str(png), str(video), str(json_path)]),)}
|
| 538 |
+
|
| 539 |
+
assert _final_video_path_from_result(result) == str(video)
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def test_final_json_path_reads_sidecar_from_output_list(tmp_path: Path) -> None:
|
| 543 |
+
video = tmp_path / "clip_v001.mp4"
|
| 544 |
+
json_path = tmp_path / "clip_v001.json"
|
| 545 |
+
result = {"result": ((True, [str(json_path), str(video)]),)}
|
| 546 |
+
|
| 547 |
+
assert _final_json_path_from_result(result, str(video)) == os.path.normpath(str(json_path))
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def test_final_json_path_falls_back_to_video_stem(tmp_path: Path) -> None:
|
| 551 |
+
video = tmp_path / "clip_v001.mp4"
|
| 552 |
+
|
| 553 |
+
assert _final_json_path_from_result({"result": ((True, [str(video)]),)}, str(video)) == str(
|
| 554 |
+
tmp_path / "clip_v001.json"
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def test_video_path_parts_returns_loader_friendly_strings(tmp_path: Path) -> None:
|
| 559 |
+
video = tmp_path / "clip_v001.mp4"
|
| 560 |
+
|
| 561 |
+
full_path, directory, name = _video_path_parts(str(video))
|
| 562 |
+
|
| 563 |
+
assert full_path == os.path.normpath(str(video))
|
| 564 |
+
assert directory == os.path.normpath(str(tmp_path))
|
| 565 |
+
assert name == "clip_v001.mp4"
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def test_append_video_path_outputs_preserves_original_filenames_output(tmp_path: Path) -> None:
|
| 569 |
+
video = tmp_path / "clip_v001.mp4"
|
| 570 |
+
json_path = tmp_path / "clip_v001.json"
|
| 571 |
+
result = {"ui": {"gifs": []}, "result": ((True, [str(json_path), str(video)]),)}
|
| 572 |
+
|
| 573 |
+
out = _append_video_path_outputs(result)
|
| 574 |
+
|
| 575 |
+
assert out["result"][0] == (True, [str(json_path), str(video)])
|
| 576 |
+
assert out["result"][1:] == (
|
| 577 |
+
os.path.normpath(str(video)),
|
| 578 |
+
os.path.normpath(str(tmp_path)),
|
| 579 |
+
"clip_v001.mp4",
|
| 580 |
+
os.path.normpath(str(json_path)),
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def test_combine_clean_outputs_round_trip_into_easy_load_video(tmp_path: Path) -> None:
|
| 585 |
+
video = tmp_path / "clip_v001.mp4"
|
| 586 |
+
json_path = tmp_path / "clip_v001.json"
|
| 587 |
+
result = {"result": ((True, [str(json_path), str(video)]),)}
|
| 588 |
+
|
| 589 |
+
out = _append_video_path_outputs(result)
|
| 590 |
+
video_path, video_directory, video_name, sidecar_path = out["result"][1:]
|
| 591 |
+
|
| 592 |
+
assert sidecar_path == os.path.normpath(str(json_path))
|
| 593 |
+
assert _compose_input_video_path(video_name, video_directory) == video_path
|
| 594 |
+
assert _compose_input_video_path(video_path, "") == video_path
|
ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_video_load.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Easy_LoadVideo's split input path composition."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import importlib
|
| 5 |
+
import sys
|
| 6 |
+
import types
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
import k_video_load
|
| 12 |
+
from k_video_load import (
|
| 13 |
+
EMPTY_BRANCH_SENTINEL,
|
| 14 |
+
_compose_input_video_path,
|
| 15 |
+
_is_existing_local_video_path,
|
| 16 |
+
_normalize_path_input,
|
| 17 |
+
_normalize_text_input,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_empty_input_path_passes_video_through() -> None:
|
| 22 |
+
assert _compose_input_video_path("E:/plates/shot01.mp4", "") == "E:/plates/shot01.mp4"
|
| 23 |
+
assert _compose_input_video_path("https://example.test/clip.mp4", "") == "https://example.test/clip.mp4"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_empty_input_path_accepts_directory_and_filename_lines() -> None:
|
| 27 |
+
root = Path.cwd().anchor
|
| 28 |
+
absolute_dir = str(Path(root) / "projects" / "shot01")
|
| 29 |
+
composed = _compose_input_video_path(
|
| 30 |
+
f"{absolute_dir}\nplate_main.mp4",
|
| 31 |
+
"",
|
| 32 |
+
)
|
| 33 |
+
assert Path(composed) == Path(absolute_dir) / "plate_main.mp4"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_absolute_input_path_joins_filename() -> None:
|
| 37 |
+
# Use a real tmp_path so the absolute-path branch is platform-neutral.
|
| 38 |
+
root = Path.cwd().anchor
|
| 39 |
+
absolute_dir = str(Path(root) / "projects" / "shot01")
|
| 40 |
+
composed = _compose_input_video_path(
|
| 41 |
+
"plate_main.mp4",
|
| 42 |
+
absolute_dir,
|
| 43 |
+
)
|
| 44 |
+
assert Path(composed) == Path(absolute_dir) / "plate_main.mp4"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_input_path_accepts_full_video_path_when_video_empty() -> None:
|
| 48 |
+
root = Path.cwd().anchor
|
| 49 |
+
video_path = str(Path(root) / "projects" / "shot01" / "plate_main.mp4")
|
| 50 |
+
|
| 51 |
+
assert Path(_compose_input_video_path("", video_path)) == Path(video_path)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_existing_local_video_path_detection(tmp_path: Path) -> None:
|
| 55 |
+
video = tmp_path / "plate_main.mp4"
|
| 56 |
+
video.write_bytes(b"fake")
|
| 57 |
+
|
| 58 |
+
assert _is_existing_local_video_path(str(video)) is True
|
| 59 |
+
assert _is_existing_local_video_path(str(tmp_path / "plate_main.json")) is False
|
| 60 |
+
assert _is_existing_local_video_path(str(tmp_path / "missing.mp4")) is False
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_relative_input_path_accepts_full_video_path_when_video_empty(tmp_path: Path) -> None:
|
| 64 |
+
composed = _compose_input_video_path(
|
| 65 |
+
"",
|
| 66 |
+
"shots/shot01/plate_main.mp4",
|
| 67 |
+
input_root=str(tmp_path),
|
| 68 |
+
)
|
| 69 |
+
assert Path(composed) == tmp_path / "shots" / "shot01" / "plate_main.mp4"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_wrapped_full_video_path_in_input_path_rejoins_before_loading(tmp_path: Path) -> None:
|
| 73 |
+
folder = tmp_path / "Runs-Audio-File"
|
| 74 |
+
folder.mkdir()
|
| 75 |
+
video = folder / "Bear_2x-FR_AudioFile_K-Dir_h264_v002.mp4"
|
| 76 |
+
video.write_bytes(b"fake")
|
| 77 |
+
wrapped = str(video).replace("Runs-Audio-File", "Runs-Audio-\nFile")
|
| 78 |
+
|
| 79 |
+
composed = _compose_input_video_path("", wrapped)
|
| 80 |
+
|
| 81 |
+
assert Path(composed) == video
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_input_path_accepts_directory_and_filename_lines_when_video_empty(tmp_path: Path) -> None:
|
| 85 |
+
video = tmp_path / "plate_main.mp4"
|
| 86 |
+
video.write_bytes(b"fake")
|
| 87 |
+
|
| 88 |
+
composed = _compose_input_video_path("", f"{tmp_path}\nplate_main.mp4")
|
| 89 |
+
|
| 90 |
+
assert Path(composed) == video
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_normalize_path_input_prefers_existing_wrapped_path(tmp_path: Path) -> None:
|
| 94 |
+
folder = tmp_path / "Runs-Audio-File"
|
| 95 |
+
folder.mkdir()
|
| 96 |
+
video = folder / "clip.mov"
|
| 97 |
+
video.write_bytes(b"fake")
|
| 98 |
+
|
| 99 |
+
assert _normalize_path_input(str(video).replace("Runs-Audio-File", "Runs-Audio-\nFile")) == str(video)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_relative_input_path_roots_under_comfy_input_dir(tmp_path: Path) -> None:
|
| 103 |
+
composed = _compose_input_video_path(
|
| 104 |
+
"plate_main.mp4",
|
| 105 |
+
"shots/shot01",
|
| 106 |
+
input_root=str(tmp_path),
|
| 107 |
+
)
|
| 108 |
+
assert Path(composed) == tmp_path / "shots" / "shot01" / "plate_main.mp4"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_input_path_strips_video_path_components(tmp_path: Path) -> None:
|
| 112 |
+
composed = _compose_input_video_path(
|
| 113 |
+
"accidental/subdir/plate_main.mp4",
|
| 114 |
+
"shots/shot01",
|
| 115 |
+
input_root=str(tmp_path),
|
| 116 |
+
)
|
| 117 |
+
assert Path(composed) == tmp_path / "shots" / "shot01" / "plate_main.mp4"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_input_path_strips_windows_video_path_components(tmp_path: Path) -> None:
|
| 121 |
+
composed = _compose_input_video_path(
|
| 122 |
+
"accidental\\subdir\\plate_main.mp4",
|
| 123 |
+
"shots/shot01",
|
| 124 |
+
input_root=str(tmp_path),
|
| 125 |
+
)
|
| 126 |
+
assert Path(composed) == tmp_path / "shots" / "shot01" / "plate_main.mp4"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_input_path_requires_filename() -> None:
|
| 130 |
+
# A non-existent path with no filename is a genuine mistake and still errors.
|
| 131 |
+
with pytest.raises(ValueError, match="video must include a filename"):
|
| 132 |
+
_compose_input_video_path("", "/no/such/projects/shot01")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_existing_directory_input_path_degrades_to_empty_sentinel(tmp_path: Path) -> None:
|
| 136 |
+
# A real folder (e.g. an EXR sequence dir) handed to the video loader is the
|
| 137 |
+
# unselected branch of an upstream mode switch. Instead of crashing the whole
|
| 138 |
+
# prompt, _compose_input_video_path signals a graceful empty so the loader can
|
| 139 |
+
# return nothing rather than raise -- mirroring how lenient sequence loaders
|
| 140 |
+
# ignore a wrong-type path.
|
| 141 |
+
seq_dir = tmp_path / "exr_seq"
|
| 142 |
+
seq_dir.mkdir()
|
| 143 |
+
assert _compose_input_video_path("", str(seq_dir)) == EMPTY_BRANCH_SENTINEL
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_relative_existing_directory_input_path_degrades(tmp_path: Path) -> None:
|
| 147 |
+
(tmp_path / "shots" / "shot01").mkdir(parents=True)
|
| 148 |
+
assert (
|
| 149 |
+
_compose_input_video_path("", "shots/shot01", input_root=str(tmp_path))
|
| 150 |
+
== EMPTY_BRANCH_SENTINEL
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_normalize_text_input_handles_frontend_sentinels() -> None:
|
| 155 |
+
assert _normalize_text_input("undefined") == ""
|
| 156 |
+
assert _normalize_text_input("None") == ""
|
| 157 |
+
assert _normalize_text_input(" plate_main.mp4\nundefined") == "plate_main.mp4"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_easy_load_video_calls_vhs_loader_directly_for_existing_full_path(
|
| 161 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 162 |
+
tmp_path: Path,
|
| 163 |
+
) -> None:
|
| 164 |
+
calls = []
|
| 165 |
+
|
| 166 |
+
def fake_shared_loader(**kwargs):
|
| 167 |
+
calls.append(kwargs)
|
| 168 |
+
return ("loaded", kwargs["video"])
|
| 169 |
+
|
| 170 |
+
class FakeVHSLoadVideoPath:
|
| 171 |
+
@classmethod
|
| 172 |
+
def INPUT_TYPES(cls):
|
| 173 |
+
return {
|
| 174 |
+
"required": {
|
| 175 |
+
"video": ("STRING", {}),
|
| 176 |
+
"force_rate": ("FLOAT", {}),
|
| 177 |
+
},
|
| 178 |
+
"optional": {},
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
def load_video(self, **kwargs):
|
| 182 |
+
raise AssertionError("strict VHS LoadVideoPath.load_video should be bypassed")
|
| 183 |
+
|
| 184 |
+
@classmethod
|
| 185 |
+
def IS_CHANGED(cls, video, **kwargs):
|
| 186 |
+
return video
|
| 187 |
+
|
| 188 |
+
@classmethod
|
| 189 |
+
def VALIDATE_INPUTS(cls, video):
|
| 190 |
+
return f"strict validator rejected {video}"
|
| 191 |
+
|
| 192 |
+
FakeVHSLoadVideoPath.load_video.__globals__["load_video"] = fake_shared_loader
|
| 193 |
+
fake_nodes = types.SimpleNamespace(
|
| 194 |
+
NODE_CLASS_MAPPINGS={"VHS_LoadVideoPath": FakeVHSLoadVideoPath}
|
| 195 |
+
)
|
| 196 |
+
fake_folder_paths = types.SimpleNamespace(get_input_directory=lambda: str(tmp_path))
|
| 197 |
+
monkeypatch.setitem(sys.modules, "nodes", fake_nodes)
|
| 198 |
+
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
| 199 |
+
loaded = importlib.reload(k_video_load)
|
| 200 |
+
|
| 201 |
+
video = tmp_path / "plate_main.mp4"
|
| 202 |
+
video.write_bytes(b"fake")
|
| 203 |
+
result = loaded.Easy_LoadVideo().load_video(
|
| 204 |
+
input_path=str(video),
|
| 205 |
+
video="",
|
| 206 |
+
force_rate=0,
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
assert result == ("loaded", str(video))
|
| 210 |
+
assert calls[-1]["video"] == str(video)
|
| 211 |
+
assert loaded.Easy_LoadVideo.VALIDATE_INPUTS("", str(video)) is True
|
| 212 |
+
|
| 213 |
+
monkeypatch.delitem(sys.modules, "nodes")
|
| 214 |
+
monkeypatch.delitem(sys.modules, "folder_paths")
|
| 215 |
+
importlib.reload(k_video_load)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def test_easy_load_video_validation_defers_when_input_path_is_linked(
|
| 219 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 220 |
+
tmp_path: Path,
|
| 221 |
+
) -> None:
|
| 222 |
+
calls = []
|
| 223 |
+
|
| 224 |
+
class FakeVHSLoadVideoPath:
|
| 225 |
+
@classmethod
|
| 226 |
+
def INPUT_TYPES(cls):
|
| 227 |
+
return {"required": {"video": ("STRING", {})}, "optional": {}}
|
| 228 |
+
|
| 229 |
+
def load_video(self, **kwargs):
|
| 230 |
+
return ("loaded", kwargs["video"])
|
| 231 |
+
|
| 232 |
+
@classmethod
|
| 233 |
+
def IS_CHANGED(cls, video, **kwargs):
|
| 234 |
+
return video
|
| 235 |
+
|
| 236 |
+
@classmethod
|
| 237 |
+
def VALIDATE_INPUTS(cls, video):
|
| 238 |
+
calls.append(video)
|
| 239 |
+
return f"strict validator rejected {video}"
|
| 240 |
+
|
| 241 |
+
FakeVHSLoadVideoPath.load_video.__globals__["load_video"] = lambda **kwargs: (
|
| 242 |
+
"loaded",
|
| 243 |
+
kwargs["video"],
|
| 244 |
+
)
|
| 245 |
+
fake_nodes = types.SimpleNamespace(
|
| 246 |
+
NODE_CLASS_MAPPINGS={"VHS_LoadVideoPath": FakeVHSLoadVideoPath}
|
| 247 |
+
)
|
| 248 |
+
fake_folder_paths = types.SimpleNamespace(get_input_directory=lambda: str(tmp_path))
|
| 249 |
+
monkeypatch.setitem(sys.modules, "nodes", fake_nodes)
|
| 250 |
+
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
| 251 |
+
loaded = importlib.reload(k_video_load)
|
| 252 |
+
|
| 253 |
+
assert loaded.Easy_LoadVideo.VALIDATE_INPUTS(
|
| 254 |
+
video="",
|
| 255 |
+
input_path=None,
|
| 256 |
+
force_rate=0,
|
| 257 |
+
custom_width=0,
|
| 258 |
+
custom_height=0,
|
| 259 |
+
frame_load_cap=0,
|
| 260 |
+
skip_first_frames=0,
|
| 261 |
+
select_every_nth=1,
|
| 262 |
+
format="AnimateDiff",
|
| 263 |
+
unique_id="627",
|
| 264 |
+
) is True
|
| 265 |
+
assert calls == []
|
| 266 |
+
|
| 267 |
+
monkeypatch.delitem(sys.modules, "nodes")
|
| 268 |
+
monkeypatch.delitem(sys.modules, "folder_paths")
|
| 269 |
+
importlib.reload(k_video_load)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def test_easy_load_video_rejoins_wrapped_input_path_before_direct_vhs_loader(
|
| 273 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 274 |
+
tmp_path: Path,
|
| 275 |
+
) -> None:
|
| 276 |
+
calls = []
|
| 277 |
+
|
| 278 |
+
def fake_shared_loader(**kwargs):
|
| 279 |
+
calls.append(kwargs)
|
| 280 |
+
return ("loaded", kwargs["video"])
|
| 281 |
+
|
| 282 |
+
class FakeVHSLoadVideoPath:
|
| 283 |
+
@classmethod
|
| 284 |
+
def INPUT_TYPES(cls):
|
| 285 |
+
return {"required": {"video": ("STRING", {})}, "optional": {}}
|
| 286 |
+
|
| 287 |
+
def load_video(self, **kwargs):
|
| 288 |
+
raise AssertionError("strict VHS LoadVideoPath.load_video should be bypassed")
|
| 289 |
+
|
| 290 |
+
@classmethod
|
| 291 |
+
def IS_CHANGED(cls, video, **kwargs):
|
| 292 |
+
return video
|
| 293 |
+
|
| 294 |
+
@classmethod
|
| 295 |
+
def VALIDATE_INPUTS(cls, video):
|
| 296 |
+
return f"strict validator rejected {video}"
|
| 297 |
+
|
| 298 |
+
FakeVHSLoadVideoPath.load_video.__globals__["load_video"] = fake_shared_loader
|
| 299 |
+
fake_nodes = types.SimpleNamespace(
|
| 300 |
+
NODE_CLASS_MAPPINGS={"VHS_LoadVideoPath": FakeVHSLoadVideoPath}
|
| 301 |
+
)
|
| 302 |
+
fake_folder_paths = types.SimpleNamespace(get_input_directory=lambda: str(tmp_path))
|
| 303 |
+
monkeypatch.setitem(sys.modules, "nodes", fake_nodes)
|
| 304 |
+
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
| 305 |
+
loaded = importlib.reload(k_video_load)
|
| 306 |
+
|
| 307 |
+
folder = tmp_path / "Runs-Audio-File"
|
| 308 |
+
folder.mkdir()
|
| 309 |
+
video = folder / "Bear_2x-FR_AudioFile_K-Dir_h264_v002.mp4"
|
| 310 |
+
video.write_bytes(b"fake")
|
| 311 |
+
wrapped = str(video).replace("Runs-Audio-File", "Runs-Audio-\nFile")
|
| 312 |
+
|
| 313 |
+
result = loaded.Easy_LoadVideo().load_video(input_path=wrapped, video="")
|
| 314 |
+
|
| 315 |
+
assert result == ("loaded", str(video))
|
| 316 |
+
assert calls[-1]["video"] == str(video)
|
| 317 |
+
|
| 318 |
+
monkeypatch.delitem(sys.modules, "nodes")
|
| 319 |
+
monkeypatch.delitem(sys.modules, "folder_paths")
|
| 320 |
+
importlib.reload(k_video_load)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def test_easy_load_video_returns_empty_result_for_existing_directory(
|
| 324 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 325 |
+
tmp_path: Path,
|
| 326 |
+
capsys: pytest.CaptureFixture[str],
|
| 327 |
+
) -> None:
|
| 328 |
+
# When the loader is handed an existing folder (the unselected EXR branch of
|
| 329 |
+
# an upstream switch), it must return an empty, correctly-shaped result
|
| 330 |
+
# instead of raising -- so the dead branch never crashes the prompt.
|
| 331 |
+
class FakeVHSLoadVideoPath:
|
| 332 |
+
RETURN_TYPES = ("IMAGE", "INT", "AUDIO", "VHS_VIDEOINFO")
|
| 333 |
+
|
| 334 |
+
@classmethod
|
| 335 |
+
def INPUT_TYPES(cls):
|
| 336 |
+
return {"required": {"video": ("STRING", {})}, "optional": {}}
|
| 337 |
+
|
| 338 |
+
def load_video(self, **kwargs):
|
| 339 |
+
raise AssertionError("loader must not run for an empty/unselected branch")
|
| 340 |
+
|
| 341 |
+
FakeVHSLoadVideoPath.load_video.__globals__["load_video"] = lambda **kwargs: (
|
| 342 |
+
_ for _ in ()
|
| 343 |
+
).throw(AssertionError("shared loader must not run for an empty branch"))
|
| 344 |
+
fake_nodes = types.SimpleNamespace(
|
| 345 |
+
NODE_CLASS_MAPPINGS={"VHS_LoadVideoPath": FakeVHSLoadVideoPath}
|
| 346 |
+
)
|
| 347 |
+
fake_folder_paths = types.SimpleNamespace(get_input_directory=lambda: str(tmp_path))
|
| 348 |
+
fake_torch = types.SimpleNamespace(
|
| 349 |
+
zeros=lambda *shape, **kw: ("zeros", shape),
|
| 350 |
+
float32="float32",
|
| 351 |
+
)
|
| 352 |
+
monkeypatch.setitem(sys.modules, "nodes", fake_nodes)
|
| 353 |
+
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
| 354 |
+
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
| 355 |
+
loaded = importlib.reload(k_video_load)
|
| 356 |
+
|
| 357 |
+
seq_dir = tmp_path / "exr_seq"
|
| 358 |
+
seq_dir.mkdir()
|
| 359 |
+
result = loaded.Easy_LoadVideo().load_video(input_path=str(seq_dir), video="")
|
| 360 |
+
captured = capsys.readouterr()
|
| 361 |
+
|
| 362 |
+
assert isinstance(result, tuple) and len(result) == 4
|
| 363 |
+
assert result[0] == ("zeros", ((1, 64, 64, 3),)) # empty IMAGE (shape passed as one arg)
|
| 364 |
+
assert result[1] == 0 # frame_count
|
| 365 |
+
assert result[2]["sample_rate"] == 44100 # empty AUDIO
|
| 366 |
+
assert result[3] == {} # empty VHS_VIDEOINFO
|
| 367 |
+
assert "existing folder with no video filename" in captured.out
|
| 368 |
+
|
| 369 |
+
monkeypatch.delitem(sys.modules, "nodes")
|
| 370 |
+
monkeypatch.delitem(sys.modules, "folder_paths")
|
| 371 |
+
monkeypatch.delitem(sys.modules, "torch")
|
| 372 |
+
importlib.reload(k_video_load)
|
ComfyUI/custom_nodes/koolook/tests/nodes/test_koolook_versioning.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the shared ``koolook_versioning`` helpers.
|
| 2 |
+
|
| 3 |
+
These pin the single source of truth for the ``vNNN`` convention shared by
|
| 4 |
+
``EasyAIPipeline`` and ``Easy_VideoCombine``. The load-bearing contract is
|
| 5 |
+
that a wired/typed version STRING wins verbatim over the legacy INT widget
|
| 6 |
+
and over ``disable_versioning`` — that's what lets one "global version"
|
| 7 |
+
source drive every node consistently.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from koolook_versioning import normalize_version_token, resolve_version_token
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestNormalizeVersionToken:
|
| 15 |
+
def test_empty_and_none(self):
|
| 16 |
+
assert normalize_version_token("") == ""
|
| 17 |
+
assert normalize_version_token(" ") == ""
|
| 18 |
+
assert normalize_version_token(None) == ""
|
| 19 |
+
|
| 20 |
+
def test_sentinels_become_empty(self):
|
| 21 |
+
assert normalize_version_token("undefined") == ""
|
| 22 |
+
assert normalize_version_token("Undefined") == ""
|
| 23 |
+
assert normalize_version_token("NULL") == ""
|
| 24 |
+
assert normalize_version_token("None") == ""
|
| 25 |
+
|
| 26 |
+
def test_verbatim_values_pass_through(self):
|
| 27 |
+
assert normalize_version_token("v001") == "v001"
|
| 28 |
+
assert normalize_version_token("final") == "final"
|
| 29 |
+
assert normalize_version_token("take_3") == "take_3"
|
| 30 |
+
# A substring match of a sentinel must NOT trigger.
|
| 31 |
+
assert normalize_version_token("v_undefined_3") == "v_undefined_3"
|
| 32 |
+
|
| 33 |
+
def test_surrounding_whitespace_and_quotes_stripped(self):
|
| 34 |
+
assert normalize_version_token(" v002 ") == "v002"
|
| 35 |
+
assert normalize_version_token('"v002"') == "v002"
|
| 36 |
+
assert normalize_version_token("'v002'") == "v002"
|
| 37 |
+
|
| 38 |
+
def test_control_chars_stripped(self):
|
| 39 |
+
assert normalize_version_token("v0\n01") == "v001"
|
| 40 |
+
assert normalize_version_token("v001\t") == "v001"
|
| 41 |
+
|
| 42 |
+
def test_path_separators_flattened(self):
|
| 43 |
+
assert normalize_version_token("v001/extra") == "v001_extra"
|
| 44 |
+
assert normalize_version_token("a\\b") == "a_b"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class TestResolveVersionToken:
|
| 48 |
+
def test_disable_versioning_is_master_off(self):
|
| 49 |
+
assert resolve_version_token("v007", disable_versioning=True) == ""
|
| 50 |
+
assert resolve_version_token("5", disable_versioning=True) == ""
|
| 51 |
+
|
| 52 |
+
def test_empty_or_sentinel_returns_empty(self):
|
| 53 |
+
assert resolve_version_token("") == ""
|
| 54 |
+
assert resolve_version_token(" ") == ""
|
| 55 |
+
assert resolve_version_token("undefined") == ""
|
| 56 |
+
|
| 57 |
+
def test_bare_number_becomes_vnnn(self):
|
| 58 |
+
# Back-compat with the old INT widget + convenience when typing.
|
| 59 |
+
assert resolve_version_token("3") == "v003"
|
| 60 |
+
assert resolve_version_token("12") == "v012"
|
| 61 |
+
# A saved workflow's stored INT arrives as a number, not a string.
|
| 62 |
+
assert resolve_version_token(5) == "v005"
|
| 63 |
+
|
| 64 |
+
def test_token_used_verbatim(self):
|
| 65 |
+
assert resolve_version_token("v001") == "v001"
|
| 66 |
+
assert resolve_version_token("final") == "final"
|
| 67 |
+
assert resolve_version_token("v2_take3") == "v2_take3"
|
ComfyUI/custom_nodes/koolook/tests/nodes/test_loop_status.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
| 2 |
+
"""Tests for the loop status pass-through node."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import urllib.parse
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
import k_loop_status
|
| 12 |
+
from k_loop_status import (
|
| 13 |
+
DEFAULT_SERVER_URL,
|
| 14 |
+
KoolookLoopStatus,
|
| 15 |
+
_post_prompt,
|
| 16 |
+
_resolve_server_url,
|
| 17 |
+
build_status,
|
| 18 |
+
infer_index_node_id,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.fixture(autouse=True)
|
| 23 |
+
def _clear_active_queue_keys():
|
| 24 |
+
"""Reset the module-global auto-queue dedup set between tests."""
|
| 25 |
+
k_loop_status._ACTIVE_QUEUE_KEYS.clear()
|
| 26 |
+
yield
|
| 27 |
+
k_loop_status._ACTIVE_QUEUE_KEYS.clear()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_build_status_formats_one_based_position_and_frame_path():
|
| 31 |
+
assert (
|
| 32 |
+
build_status("write", 2, 4, "N:/out/frame.%04d.exr")
|
| 33 |
+
== "write: 3/4 frame 2 -> N:/out/frame.0002.exr"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_build_status_formats_other_padded_frame_patterns():
|
| 38 |
+
assert (
|
| 39 |
+
build_status("write", 12, 20, "N:/out/frame.%05d.exr")
|
| 40 |
+
== "write: 13/20 frame 12 -> N:/out/frame.00012.exr"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_report_prints_status_and_passes_value_through(capsys):
|
| 45 |
+
value = object()
|
| 46 |
+
node = KoolookLoopStatus()
|
| 47 |
+
|
| 48 |
+
out_value, status = node.report(
|
| 49 |
+
value,
|
| 50 |
+
index=0,
|
| 51 |
+
total=4,
|
| 52 |
+
filepath="N:/out/frame.%04d.exr",
|
| 53 |
+
label="EXR_SAFE",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
assert out_value is value
|
| 57 |
+
assert status == "EXR_SAFE: 1/4 frame 0 -> N:/out/frame.0000.exr"
|
| 58 |
+
assert "[Koolook Loop Status] EXR_SAFE: 1/4" in capsys.readouterr().out
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_registration_exports():
|
| 62 |
+
from k_loop_status import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
| 63 |
+
|
| 64 |
+
assert NODE_CLASS_MAPPINGS["Koolook_LoopStatus"] is KoolookLoopStatus
|
| 65 |
+
assert NODE_DISPLAY_NAME_MAPPINGS["Koolook_LoopStatus"] == "Koolook Loop Status"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_input_types_include_queue_controller_settings():
|
| 69 |
+
optional = KoolookLoopStatus.INPUT_TYPES()["optional"]
|
| 70 |
+
hidden = KoolookLoopStatus.INPUT_TYPES()["hidden"]
|
| 71 |
+
|
| 72 |
+
assert "auto_queue_next" in optional
|
| 73 |
+
assert "index_node_id" in optional
|
| 74 |
+
assert "server_url" in optional
|
| 75 |
+
assert "max_auto_queue_depth" in optional
|
| 76 |
+
assert "remaining_auto_queue_depth" in optional
|
| 77 |
+
assert hidden["prompt"] == "PROMPT"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_infers_index_node_id_from_connected_index_input():
|
| 81 |
+
prompt = {"21": {"inputs": {"index": ["22", 0]}}}
|
| 82 |
+
|
| 83 |
+
assert infer_index_node_id(prompt, "21") == "22"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_numeric_label_is_treated_as_shifted_index_node_id(capsys):
|
| 87 |
+
node = KoolookLoopStatus()
|
| 88 |
+
|
| 89 |
+
_value, status = node.report(
|
| 90 |
+
"image",
|
| 91 |
+
0,
|
| 92 |
+
4,
|
| 93 |
+
label="22",
|
| 94 |
+
auto_queue_next=False,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
assert status == "EXR_SAFE: 1/4 frame 0"
|
| 98 |
+
assert "[Koolook Loop Status] EXR_SAFE: 1/4" in capsys.readouterr().out
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_depth_guard_raises_before_status_print(capsys):
|
| 102 |
+
node = KoolookLoopStatus()
|
| 103 |
+
|
| 104 |
+
with pytest.raises(RuntimeError, match="Refusing to auto-queue"):
|
| 105 |
+
node.report(
|
| 106 |
+
"image",
|
| 107 |
+
0,
|
| 108 |
+
5,
|
| 109 |
+
auto_queue_next=True,
|
| 110 |
+
index_node_id="22",
|
| 111 |
+
max_auto_queue_depth=2,
|
| 112 |
+
prompt={"21": {"inputs": {}}},
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
assert "[Koolook Loop Status]" not in capsys.readouterr().out
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_resolve_server_url_keeps_custom_value(monkeypatch):
|
| 119 |
+
monkeypatch.setattr(
|
| 120 |
+
k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:9999"
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
assert _resolve_server_url("http://10.0.0.5:7000") == "http://10.0.0.5:7000"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_resolve_server_url_overrides_default_with_detected_port(monkeypatch):
|
| 127 |
+
monkeypatch.setattr(
|
| 128 |
+
k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:8000"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
assert _resolve_server_url(DEFAULT_SERVER_URL) == "http://127.0.0.1:8000"
|
| 132 |
+
assert _resolve_server_url("") == "http://127.0.0.1:8000"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_resolve_server_url_falls_back_to_default_when_undetectable(monkeypatch):
|
| 136 |
+
monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)
|
| 137 |
+
|
| 138 |
+
assert _resolve_server_url(DEFAULT_SERVER_URL) == DEFAULT_SERVER_URL
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_resolve_server_url_blank_falls_back_to_default_when_undetectable(monkeypatch):
|
| 142 |
+
"""Blank input must behave like the default when detection is unavailable."""
|
| 143 |
+
monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)
|
| 144 |
+
|
| 145 |
+
assert _resolve_server_url("") == DEFAULT_SERVER_URL
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_resolve_server_url_auto_triggers_detection(monkeypatch):
|
| 149 |
+
"""The `auto` sentinel (the widget default) auto-detects, case-insensitively."""
|
| 150 |
+
monkeypatch.setattr(
|
| 151 |
+
k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:8000"
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
assert _resolve_server_url("auto") == "http://127.0.0.1:8000"
|
| 155 |
+
assert _resolve_server_url("AUTO") == "http://127.0.0.1:8000"
|
| 156 |
+
assert _resolve_server_url(" auto ") == "http://127.0.0.1:8000"
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def test_resolve_server_url_auto_falls_back_to_default_when_undetectable(monkeypatch):
|
| 160 |
+
monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)
|
| 161 |
+
|
| 162 |
+
assert _resolve_server_url("auto") == DEFAULT_SERVER_URL
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_server_url_widget_defaults_to_auto():
|
| 166 |
+
optional = KoolookLoopStatus.INPUT_TYPES()["optional"]
|
| 167 |
+
|
| 168 |
+
assert optional["server_url"][1]["default"] == "auto"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_compose_server_url_brackets_ipv6_literal():
|
| 172 |
+
assert k_loop_status._compose_server_url("::1", 8000) == "http://[::1]:8000"
|
| 173 |
+
assert (
|
| 174 |
+
k_loop_status._compose_server_url("2001:db8::1", 8000)
|
| 175 |
+
== "http://[2001:db8::1]:8000"
|
| 176 |
+
)
|
| 177 |
+
# Idempotent: an already-bracketed literal must not be double-bracketed.
|
| 178 |
+
assert k_loop_status._compose_server_url("[::1]", 8000) == "http://[::1]:8000"
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_compose_server_url_ipv6_result_passes_validation():
|
| 182 |
+
"""A bracketed IPv6 URL must parse through the pre-queue validator."""
|
| 183 |
+
url = k_loop_status._compose_server_url("::1", 8000)
|
| 184 |
+
|
| 185 |
+
k_loop_status._validate_http_url(url) # must not raise
|
| 186 |
+
assert urllib.parse.urlsplit(url).port == 8000
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_compose_server_url_remaps_bind_all_to_localhost():
|
| 190 |
+
assert k_loop_status._compose_server_url("0.0.0.0", 8000) == "http://127.0.0.1:8000" # nosec B104
|
| 191 |
+
assert k_loop_status._compose_server_url("::", 8000) == "http://127.0.0.1:8000"
|
| 192 |
+
# ComfyUI's bare `--listen` is the comma-joined "all IPv4 and IPv6" value.
|
| 193 |
+
assert (
|
| 194 |
+
k_loop_status._compose_server_url("0.0.0.0,::", 8000) # nosec B104
|
| 195 |
+
== "http://127.0.0.1:8000"
|
| 196 |
+
)
|
| 197 |
+
# Order-independent: a bind-all member anywhere in the list routes to loopback.
|
| 198 |
+
assert (
|
| 199 |
+
k_loop_status._compose_server_url("::,0.0.0.0", 8000) # nosec B104
|
| 200 |
+
== "http://127.0.0.1:8000"
|
| 201 |
+
)
|
| 202 |
+
# No bind-all member: the first concrete host is used verbatim.
|
| 203 |
+
assert (
|
| 204 |
+
k_loop_status._compose_server_url("10.0.0.5,192.168.1.9", 8000)
|
| 205 |
+
== "http://10.0.0.5:8000"
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def test_compose_server_url_keeps_ipv4_hostname_and_defaults_blank():
|
| 210 |
+
assert k_loop_status._compose_server_url("127.0.0.1", 8000) == "http://127.0.0.1:8000"
|
| 211 |
+
assert k_loop_status._compose_server_url("localhost", 8000) == "http://localhost:8000"
|
| 212 |
+
assert k_loop_status._compose_server_url("", 8000) == "http://127.0.0.1:8000"
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def test_probe_uses_detected_port_not_stale_default(monkeypatch):
|
| 216 |
+
"""Auto-queue on a non-default port must probe the running server."""
|
| 217 |
+
monkeypatch.setattr(
|
| 218 |
+
k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:8000"
|
| 219 |
+
)
|
| 220 |
+
probed = {}
|
| 221 |
+
monkeypatch.setattr(
|
| 222 |
+
k_loop_status, "_probe_server", lambda url: probed.update(url=url)
|
| 223 |
+
)
|
| 224 |
+
# Don't actually spawn the queue thread; we only care about the probe target.
|
| 225 |
+
class _NoopThread:
|
| 226 |
+
def __init__(self, *args, **kwargs):
|
| 227 |
+
pass
|
| 228 |
+
|
| 229 |
+
def start(self):
|
| 230 |
+
pass
|
| 231 |
+
|
| 232 |
+
monkeypatch.setattr(k_loop_status.threading, "Thread", _NoopThread)
|
| 233 |
+
|
| 234 |
+
node = KoolookLoopStatus()
|
| 235 |
+
node.report(
|
| 236 |
+
"image",
|
| 237 |
+
0,
|
| 238 |
+
4,
|
| 239 |
+
auto_queue_next=True,
|
| 240 |
+
index_node_id="22",
|
| 241 |
+
server_url=DEFAULT_SERVER_URL,
|
| 242 |
+
prompt={"21": {"inputs": {}}, "22": {"inputs": {}}},
|
| 243 |
+
unique_id="21",
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
assert probed["url"] == "http://127.0.0.1:8000"
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def test_status_only_does_not_resolve_server_url(monkeypatch):
|
| 250 |
+
"""With auto-queue off, the node must not detect/probe the server URL."""
|
| 251 |
+
called = {"detect": False}
|
| 252 |
+
|
| 253 |
+
def _flag_detect():
|
| 254 |
+
called["detect"] = True
|
| 255 |
+
return None
|
| 256 |
+
|
| 257 |
+
monkeypatch.setattr(k_loop_status, "_detect_local_server_url", _flag_detect)
|
| 258 |
+
|
| 259 |
+
KoolookLoopStatus().report("image", 0, 4, auto_queue_next=False)
|
| 260 |
+
|
| 261 |
+
assert called["detect"] is False
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def test_stale_index_node_id_self_heals_from_connected_index(monkeypatch):
|
| 265 |
+
"""A shifted index_node_id like '0' falls back to the connected index node."""
|
| 266 |
+
monkeypatch.setattr(
|
| 267 |
+
k_loop_status, "_detect_local_server_url", lambda: "http://127.0.0.1:8000"
|
| 268 |
+
)
|
| 269 |
+
monkeypatch.setattr(k_loop_status, "_probe_server", lambda url: None)
|
| 270 |
+
captured = {}
|
| 271 |
+
|
| 272 |
+
class _NoopThread:
|
| 273 |
+
def __init__(self, *args, **kwargs):
|
| 274 |
+
captured.update(kwargs.get("kwargs", {}))
|
| 275 |
+
|
| 276 |
+
def start(self):
|
| 277 |
+
pass
|
| 278 |
+
|
| 279 |
+
monkeypatch.setattr(k_loop_status.threading, "Thread", _NoopThread)
|
| 280 |
+
|
| 281 |
+
prompt = {
|
| 282 |
+
"21": {"inputs": {"index": ["22", 0]}},
|
| 283 |
+
"22": {"inputs": {"value": 0}},
|
| 284 |
+
}
|
| 285 |
+
_value, status = KoolookLoopStatus().report(
|
| 286 |
+
"image",
|
| 287 |
+
0,
|
| 288 |
+
4,
|
| 289 |
+
auto_queue_next=True,
|
| 290 |
+
index_node_id="0",
|
| 291 |
+
prompt=prompt,
|
| 292 |
+
unique_id="21",
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
assert status.startswith("loop: 1/4")
|
| 296 |
+
assert captured["index_node_id"] == "22"
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def test_unknown_index_node_id_raises_synchronously():
|
| 300 |
+
"""An index_node_id with no matching node and no connection fails up front."""
|
| 301 |
+
node = KoolookLoopStatus()
|
| 302 |
+
|
| 303 |
+
with pytest.raises(RuntimeError, match="is not a node in this workflow"):
|
| 304 |
+
node.report(
|
| 305 |
+
"image",
|
| 306 |
+
0,
|
| 307 |
+
4,
|
| 308 |
+
auto_queue_next=True,
|
| 309 |
+
index_node_id="0",
|
| 310 |
+
prompt={"21": {"inputs": {}}},
|
| 311 |
+
unique_id="21",
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def test_as_bool_coerces_saved_string_booleans():
|
| 316 |
+
assert k_loop_status._as_bool("true") is True
|
| 317 |
+
assert k_loop_status._as_bool(" True ") is True
|
| 318 |
+
assert k_loop_status._as_bool("1") is True
|
| 319 |
+
assert k_loop_status._as_bool("yes") is True
|
| 320 |
+
assert k_loop_status._as_bool("on") is True
|
| 321 |
+
assert k_loop_status._as_bool("false") is False
|
| 322 |
+
assert k_loop_status._as_bool("no") is False
|
| 323 |
+
assert k_loop_status._as_bool("off") is False
|
| 324 |
+
assert k_loop_status._as_bool("0") is False
|
| 325 |
+
assert k_loop_status._as_bool("") is False
|
| 326 |
+
assert k_loop_status._as_bool(True) is True
|
| 327 |
+
assert k_loop_status._as_bool(0) is False
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def test_resolve_index_node_id_prefers_configured_when_present():
|
| 331 |
+
prompt = {"543": {"class_type": "easy int", "inputs": {"value": 0}}}
|
| 332 |
+
|
| 333 |
+
node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "543")
|
| 334 |
+
|
| 335 |
+
assert node_id == "543"
|
| 336 |
+
assert "using configured easy int node 543" in note
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def test_resolve_index_node_id_falls_back_from_stale_manual_id():
|
| 340 |
+
prompt = {
|
| 341 |
+
"21": {"inputs": {"index": ["543", 0]}},
|
| 342 |
+
"543": {"class_type": "easy int", "inputs": {"value": 0}},
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "22")
|
| 346 |
+
|
| 347 |
+
assert node_id == "543"
|
| 348 |
+
assert "configured index node '22' is not in this prompt" in note
|
| 349 |
+
assert "easy int node 543" in note
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def test_resolve_index_node_id_infers_when_blank():
|
| 353 |
+
prompt = {"21": {"inputs": {"index": ["543", 0]}}, "543": {"inputs": {}}}
|
| 354 |
+
|
| 355 |
+
node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "")
|
| 356 |
+
|
| 357 |
+
assert node_id == "543"
|
| 358 |
+
assert note.startswith("using connected")
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def test_resolve_index_node_id_returns_empty_when_unresolvable():
|
| 362 |
+
assert k_loop_status.resolve_index_node_id({"21": {"inputs": {}}}, "21", "") == ("", "")
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def test_describe_prompt_node_without_class_type_is_not_doubled():
|
| 366 |
+
assert k_loop_status._describe_prompt_node({"22": {"inputs": {}}}, "22") == "node 22"
|
| 367 |
+
assert (
|
| 368 |
+
k_loop_status._describe_prompt_node({"22": {"_meta": {"title": "Frame"}}}, "22")
|
| 369 |
+
== "Frame node 22"
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def test_resolve_index_node_id_fallback_does_not_override_connected():
|
| 374 |
+
"""A recovered numeric label is last-resort; the connected wire still wins."""
|
| 375 |
+
prompt = {
|
| 376 |
+
"21": {"inputs": {"index": ["543", 0]}},
|
| 377 |
+
"543": {"class_type": "easy int", "inputs": {"value": 0}},
|
| 378 |
+
"22": {"class_type": "easy int", "inputs": {"value": 0}},
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "", fallback_id="22")
|
| 382 |
+
|
| 383 |
+
assert node_id == "543"
|
| 384 |
+
assert "connected easy int node 543" in note
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def test_resolve_index_node_id_uses_fallback_when_nothing_else_resolves():
|
| 388 |
+
node_id, note = k_loop_status.resolve_index_node_id(None, None, "", fallback_id="22")
|
| 389 |
+
|
| 390 |
+
assert node_id == "22"
|
| 391 |
+
assert "recovered node 22" in note
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def test_numeric_label_does_not_override_connected_index(monkeypatch):
|
| 395 |
+
"""User scenario: a node id stuck in `label` must not beat the wired index."""
|
| 396 |
+
monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)
|
| 397 |
+
monkeypatch.setattr(k_loop_status, "_probe_server", lambda url: None)
|
| 398 |
+
captured = {}
|
| 399 |
+
|
| 400 |
+
class _NoopThread:
|
| 401 |
+
def __init__(self, *args, **kwargs):
|
| 402 |
+
captured.update(kwargs.get("kwargs", {}))
|
| 403 |
+
|
| 404 |
+
def start(self):
|
| 405 |
+
pass
|
| 406 |
+
|
| 407 |
+
monkeypatch.setattr(k_loop_status.threading, "Thread", _NoopThread)
|
| 408 |
+
|
| 409 |
+
prompt = {
|
| 410 |
+
"21": {"inputs": {"index": ["543", 0]}},
|
| 411 |
+
"543": {"class_type": "easy int", "inputs": {"value": 0}},
|
| 412 |
+
"22": {"class_type": "easy int", "inputs": {"value": 0}},
|
| 413 |
+
}
|
| 414 |
+
_value, status = KoolookLoopStatus().report(
|
| 415 |
+
"image",
|
| 416 |
+
0,
|
| 417 |
+
2,
|
| 418 |
+
label="22",
|
| 419 |
+
auto_queue_next=True,
|
| 420 |
+
prompt=prompt,
|
| 421 |
+
unique_id="21",
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
assert captured["index_node_id"] == "543"
|
| 425 |
+
assert status.startswith("EXR_SAFE: 1/2")
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def test_resolve_index_node_id_explicit_override_beats_connected_wire():
|
| 429 |
+
"""An explicit, valid index_node_id is a power-user override and wins over the wire."""
|
| 430 |
+
prompt = {
|
| 431 |
+
"21": {"inputs": {"index": ["543", 0]}},
|
| 432 |
+
"543": {"class_type": "easy int", "inputs": {"value": 0}},
|
| 433 |
+
"99": {"class_type": "easy int", "inputs": {"value": 0}},
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
node_id, note = k_loop_status.resolve_index_node_id(prompt, "21", "99")
|
| 437 |
+
|
| 438 |
+
assert node_id == "99"
|
| 439 |
+
assert "using configured easy int node 99" in note
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def test_string_false_auto_queue_does_not_queue():
|
| 443 |
+
"""A saved 'false' string must not auto-queue (bool('false') is truthy)."""
|
| 444 |
+
node = KoolookLoopStatus()
|
| 445 |
+
|
| 446 |
+
_value, status = node.report(
|
| 447 |
+
"image",
|
| 448 |
+
0,
|
| 449 |
+
4,
|
| 450 |
+
auto_queue_next="false",
|
| 451 |
+
index_node_id="22",
|
| 452 |
+
prompt={"21": {"inputs": {}}},
|
| 453 |
+
unique_id="21",
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
assert status == "loop: 1/4 frame 0"
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def test_string_true_auto_queue_logs_detected_index_node(monkeypatch, capsys):
|
| 460 |
+
"""Saved 'true' enables auto-queue; the chosen index node class/id is logged."""
|
| 461 |
+
monkeypatch.setattr(k_loop_status, "_detect_local_server_url", lambda: None)
|
| 462 |
+
monkeypatch.setattr(k_loop_status, "_probe_server", lambda url: None)
|
| 463 |
+
captured = {}
|
| 464 |
+
|
| 465 |
+
class _NoopThread:
|
| 466 |
+
def __init__(self, *args, **kwargs):
|
| 467 |
+
captured.update(kwargs.get("kwargs", {}))
|
| 468 |
+
|
| 469 |
+
def start(self):
|
| 470 |
+
pass
|
| 471 |
+
|
| 472 |
+
monkeypatch.setattr(k_loop_status.threading, "Thread", _NoopThread)
|
| 473 |
+
|
| 474 |
+
prompt = {
|
| 475 |
+
"21": {"inputs": {"index": ["543", 0]}},
|
| 476 |
+
"543": {"class_type": "easy int", "inputs": {"value": 0}},
|
| 477 |
+
}
|
| 478 |
+
_value, status = KoolookLoopStatus().report(
|
| 479 |
+
"image",
|
| 480 |
+
0,
|
| 481 |
+
2,
|
| 482 |
+
auto_queue_next="true",
|
| 483 |
+
index_node_id="22",
|
| 484 |
+
prompt=prompt,
|
| 485 |
+
unique_id="21",
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
assert status.startswith("loop: 1/2")
|
| 489 |
+
assert captured["index_node_id"] == "543"
|
| 490 |
+
assert "easy int node 543" in capsys.readouterr().out
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def test_post_prompt_rejects_error_payload(monkeypatch):
|
| 494 |
+
class Response:
|
| 495 |
+
def __enter__(self):
|
| 496 |
+
return self
|
| 497 |
+
|
| 498 |
+
def __exit__(self, *_args):
|
| 499 |
+
return False
|
| 500 |
+
|
| 501 |
+
def read(self):
|
| 502 |
+
return json.dumps({"error": "bad prompt"}).encode("utf-8")
|
| 503 |
+
|
| 504 |
+
monkeypatch.setattr("urllib.request.urlopen", lambda *_args, **_kwargs: Response())
|
| 505 |
+
|
| 506 |
+
with pytest.raises(RuntimeError, match="rejected child prompt"):
|
| 507 |
+
_post_prompt("http://127.0.0.1:8188", {})
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def test_post_prompt_rejects_non_http_url():
|
| 511 |
+
with pytest.raises(RuntimeError, match=r"Only http\(s\)"):
|
| 512 |
+
_post_prompt("file:///tmp/comfy.sock", {})
|
ComfyUI/custom_nodes/koolook/tests/nodes/test_next_version.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for filesystem-aware output version auto-detection."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from koolook_versioning import next_version_token
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_missing_directory_returns_start(tmp_path: Path) -> None:
|
| 12 |
+
missing = tmp_path / "does_not_exist"
|
| 13 |
+
assert next_version_token(str(missing), "bearMask") == "v001"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_empty_directory_returns_start(tmp_path: Path) -> None:
|
| 17 |
+
assert next_version_token(str(tmp_path), "bearMask") == "v001"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_unreadable_directory_error_is_not_hidden(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 21 |
+
def blocked(_directory):
|
| 22 |
+
raise PermissionError("no access")
|
| 23 |
+
|
| 24 |
+
monkeypatch.setattr("koolook_versioning.os.listdir", blocked)
|
| 25 |
+
|
| 26 |
+
with pytest.raises(PermissionError, match="no access"):
|
| 27 |
+
next_version_token("blocked-output", "bearMask")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_next_after_existing_versioned_files(tmp_path: Path) -> None:
|
| 31 |
+
(tmp_path / "bearMask_v001.png").write_bytes(b"x")
|
| 32 |
+
(tmp_path / "bearMask_v002.png").write_bytes(b"x")
|
| 33 |
+
assert next_version_token(str(tmp_path), "bearMask") == "v003"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_next_ignores_other_names(tmp_path: Path) -> None:
|
| 37 |
+
(tmp_path / "bearMask_v005.png").write_bytes(b"x")
|
| 38 |
+
(tmp_path / "otherShot_v009.png").write_bytes(b"x")
|
| 39 |
+
# Exact base-name match only -- otherShot must not bump bearMask.
|
| 40 |
+
assert next_version_token(str(tmp_path), "bearMask") == "v006"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_next_matches_versioned_subfolders(tmp_path: Path) -> None:
|
| 44 |
+
(tmp_path / "bearMask_v003").mkdir()
|
| 45 |
+
(tmp_path / "bearMask_v007").mkdir()
|
| 46 |
+
assert next_version_token(str(tmp_path), "bearMask") == "v008"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_next_handles_exr_sequence_names(tmp_path: Path) -> None:
|
| 50 |
+
(tmp_path / "bearMask_v002.0001.exr").write_bytes(b"x")
|
| 51 |
+
(tmp_path / "bearMask_v002.0002.exr").write_bytes(b"x")
|
| 52 |
+
assert next_version_token(str(tmp_path), "bearMask") == "v003"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_padding_and_prefix_are_configurable(tmp_path: Path) -> None:
|
| 56 |
+
(tmp_path / "clip_take2.mov").write_bytes(b"x")
|
| 57 |
+
assert (
|
| 58 |
+
next_version_token(str(tmp_path), "clip", version_prefix="take", padding=1)
|
| 59 |
+
== "take3"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_empty_name_matches_bare_version_tokens(tmp_path: Path) -> None:
|
| 64 |
+
(tmp_path / "v004.png").write_bytes(b"x")
|
| 65 |
+
assert next_version_token(str(tmp_path), "") == "v005"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_is_auto_version_detects_keywords() -> None:
|
| 69 |
+
from koolook_versioning import is_auto_version
|
| 70 |
+
|
| 71 |
+
for yes in ("auto", "AUTO", " next ", "Next", '"auto"'):
|
| 72 |
+
assert is_auto_version(yes) is True
|
| 73 |
+
for no in ("", "v001", "3", "final", "automatic", None):
|
| 74 |
+
assert is_auto_version(no) is False
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_ai_pipeline_auto_version_picks_next(tmp_path: Path) -> None:
|
| 78 |
+
from k_ai_pipeline import build_pipeline_outputs
|
| 79 |
+
|
| 80 |
+
# Existing version folders v001/v002 under the base (no_subfolders mode).
|
| 81 |
+
(tmp_path / "v001").mkdir()
|
| 82 |
+
(tmp_path / "v002").mkdir()
|
| 83 |
+
out = build_pipeline_outputs(
|
| 84 |
+
81, 1, "", str(tmp_path), ".png", "bearMask", "", "auto",
|
| 85 |
+
False, False, True,
|
| 86 |
+
create_directory=False, check_overwrite=False,
|
| 87 |
+
)
|
| 88 |
+
file_path, name, version_str = out[0], out[1], out[2]
|
| 89 |
+
assert version_str == "v003"
|
| 90 |
+
assert name == "bearMask_v003.png"
|
| 91 |
+
assert file_path.endswith("/v003/bearMask_v003.png")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_ai_pipeline_auto_version_first_run_is_v001(tmp_path: Path) -> None:
|
| 95 |
+
from k_ai_pipeline import build_pipeline_outputs
|
| 96 |
+
|
| 97 |
+
out = build_pipeline_outputs(
|
| 98 |
+
81, 1, "", str(tmp_path), ".png", "bearMask", "", "auto",
|
| 99 |
+
False, False, True,
|
| 100 |
+
create_directory=False, check_overwrite=False,
|
| 101 |
+
)
|
| 102 |
+
assert out[2] == "v001"
|
| 103 |
+
|
| 104 |
+
|
ComfyUI/custom_nodes/koolook/tests/nodes/test_publish_contract.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Koolook publish contract nodes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from k_publish_contract import (
|
| 6 |
+
Koolook_PublishInput,
|
| 7 |
+
Koolook_PublishOutput,
|
| 8 |
+
Koolook_PublishResult,
|
| 9 |
+
Koolook_PublishRouter,
|
| 10 |
+
NODE_CLASS_MAPPINGS,
|
| 11 |
+
NODE_DISPLAY_NAME_MAPPINGS,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_publish_contract_nodes_register() -> None:
|
| 16 |
+
assert NODE_CLASS_MAPPINGS["Koolook_PublishInput"] is Koolook_PublishInput
|
| 17 |
+
assert NODE_CLASS_MAPPINGS["Koolook_PublishOutput"] is Koolook_PublishOutput
|
| 18 |
+
assert NODE_CLASS_MAPPINGS["Koolook_PublishResult"] is Koolook_PublishResult
|
| 19 |
+
assert NODE_CLASS_MAPPINGS["Koolook_PublishRouter"] is Koolook_PublishRouter
|
| 20 |
+
assert NODE_DISPLAY_NAME_MAPPINGS["Koolook_PublishInput"] == "Koolook Publish Input"
|
| 21 |
+
assert NODE_DISPLAY_NAME_MAPPINGS["Koolook_PublishOutput"] == "Koolook Publish Output"
|
| 22 |
+
assert NODE_DISPLAY_NAME_MAPPINGS["Koolook_PublishResult"] == "Koolook Publish Result"
|
| 23 |
+
assert NODE_DISPLAY_NAME_MAPPINGS["Koolook_PublishRouter"] == "Koolook Publish Router"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_publish_input_exposes_stable_fields_and_switch_output() -> None:
|
| 27 |
+
spec = Koolook_PublishInput.INPUT_TYPES()["required"]
|
| 28 |
+
|
| 29 |
+
assert list(spec) == ["mode", "sequence_folder", "qt_file", "single_file", "prompt"]
|
| 30 |
+
assert spec["mode"][0] == ["EXR", "QT", "Img", "Prompt"]
|
| 31 |
+
assert Koolook_PublishInput.RETURN_NAMES == (
|
| 32 |
+
"sequence_folder",
|
| 33 |
+
"qt_file",
|
| 34 |
+
"single_file",
|
| 35 |
+
"prompt",
|
| 36 |
+
"switch",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
result = Koolook_PublishInput().run(
|
| 40 |
+
mode="Img",
|
| 41 |
+
sequence_folder="/seq",
|
| 42 |
+
qt_file="/movie.mov",
|
| 43 |
+
single_file="/image.png",
|
| 44 |
+
prompt="hidden",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
assert result == ("/seq", "/movie.mov", "/image.png", "hidden", 2)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_publish_input_accepts_published_numeric_switch_values() -> None:
|
| 51 |
+
node = Koolook_PublishInput()
|
| 52 |
+
|
| 53 |
+
assert node.run(0, "/seq", "/movie.mov", "/image.png", "hidden")[-1] == 0
|
| 54 |
+
assert node.run("1", "/seq", "/movie.mov", "/image.png", "hidden")[-1] == 1
|
| 55 |
+
assert node.run(2, "/seq", "/movie.mov", "/image.png", "hidden")[-1] == 2
|
| 56 |
+
assert node.run("3", "/seq", "/movie.mov", "/image.png", "hidden")[-1] == 3
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_publish_output_exposes_stable_fields() -> None:
|
| 60 |
+
spec = Koolook_PublishOutput.INPUT_TYPES()["required"]
|
| 61 |
+
|
| 62 |
+
assert list(spec) == ["folder", "name", "version"]
|
| 63 |
+
assert Koolook_PublishOutput.RETURN_NAMES == ("folder", "name", "version")
|
| 64 |
+
assert Koolook_PublishOutput().run(
|
| 65 |
+
folder="/out",
|
| 66 |
+
name="mask",
|
| 67 |
+
version="1",
|
| 68 |
+
) == ("/out", "mask", "1")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_publish_result_exposes_resolved_result() -> None:
|
| 72 |
+
spec = Koolook_PublishResult.INPUT_TYPES()["required"]
|
| 73 |
+
|
| 74 |
+
assert list(spec) == ["result"]
|
| 75 |
+
assert Koolook_PublishResult.RETURN_NAMES == ("result",)
|
| 76 |
+
assert Koolook_PublishResult.OUTPUT_NODE is True
|
| 77 |
+
assert Koolook_PublishResult().run(result="/out/mask_v001.png") == {
|
| 78 |
+
"ui": {"text": ["/out/mask_v001.png"]},
|
| 79 |
+
"result": ("/out/mask_v001.png",),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_publish_router_exposes_switch_aligned_payload_outputs() -> None:
|
| 84 |
+
spec = Koolook_PublishRouter.INPUT_TYPES()["required"]
|
| 85 |
+
|
| 86 |
+
assert list(spec) == ["selector", "payload"]
|
| 87 |
+
assert Koolook_PublishRouter.RETURN_NAMES == ("EXR", "QT", "Img", "Prompt")
|
| 88 |
+
assert Koolook_PublishRouter().route(selector=2, payload="pixels") == (
|
| 89 |
+
"pixels",
|
| 90 |
+
"pixels",
|
| 91 |
+
"pixels",
|
| 92 |
+
"pixels",
|
| 93 |
+
)
|
ComfyUI/custom_nodes/koolook/tests/scripts/__init__.py
ADDED
|
File without changes
|
ComfyUI/custom_nodes/koolook/tests/scripts/test_bootstrap_constraints.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Guards on the locked test dependency set (``constraints-test.txt``).
|
| 2 |
+
|
| 3 |
+
The bootstrap scripts install the test extras against this pinned lock, so
|
| 4 |
+
every fresh ``.venv`` is reproducible and ``pip-audit``-verifiable. These
|
| 5 |
+
tests fail loudly if the lock drifts out of sync with the ``[test]`` extras
|
| 6 |
+
declared in ``pyproject.toml`` (e.g. an extra was added without re-locking).
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
if sys.version_info >= (3, 11):
|
| 15 |
+
import tomllib
|
| 16 |
+
else: # pragma: no cover - Python < 3.11 fallback
|
| 17 |
+
import tomli as tomllib
|
| 18 |
+
|
| 19 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 20 |
+
CONSTRAINTS = REPO_ROOT / "constraints-test.txt"
|
| 21 |
+
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _canonical(name: str) -> str:
|
| 25 |
+
"""PEP 503 normalised distribution name."""
|
| 26 |
+
return re.sub(r"[-_.]+", "-", name).strip().lower()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _pinned_names() -> dict[str, str]:
|
| 30 |
+
"""Map canonical distribution name -> exact version from the lock."""
|
| 31 |
+
pins: dict[str, str] = {}
|
| 32 |
+
for raw in CONSTRAINTS.read_text(encoding="utf-8").splitlines():
|
| 33 |
+
line = raw.strip()
|
| 34 |
+
if not line or line.startswith("#"):
|
| 35 |
+
continue
|
| 36 |
+
assert "==" in line, f"constraint not pinned with '==': {line!r}"
|
| 37 |
+
name, version = line.split("==", 1)
|
| 38 |
+
pins[_canonical(name)] = version.strip()
|
| 39 |
+
return pins
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _test_extra_names() -> list[str]:
|
| 43 |
+
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
|
| 44 |
+
extras = data["project"]["optional-dependencies"]["test"]
|
| 45 |
+
return [_canonical(re.split(r"[<>=!~;\[ ]", spec, maxsplit=1)[0]) for spec in extras]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_constraints_file_exists_and_nonempty():
|
| 49 |
+
assert CONSTRAINTS.is_file(), "constraints-test.txt is missing"
|
| 50 |
+
assert _pinned_names(), "constraints-test.txt has no pinned entries"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_no_editable_or_self_package_leaked():
|
| 54 |
+
text = CONSTRAINTS.read_text(encoding="utf-8")
|
| 55 |
+
assert "-e " not in text, "an editable install leaked into the lock"
|
| 56 |
+
assert "koolook" not in _pinned_names(), "the self package leaked into the lock"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_every_top_level_test_extra_is_pinned():
|
| 60 |
+
pins = _pinned_names()
|
| 61 |
+
missing = [name for name in _test_extra_names() if name not in pins]
|
| 62 |
+
assert not missing, (
|
| 63 |
+
f"these [test] extras are not pinned in constraints-test.txt: {missing}. "
|
| 64 |
+
"Regenerate the lock: bash scripts/bootstrap_test_env.sh --force --relock"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_no_duplicate_pins():
|
| 69 |
+
names: list[str] = []
|
| 70 |
+
for raw in CONSTRAINTS.read_text(encoding="utf-8").splitlines():
|
| 71 |
+
line = raw.strip()
|
| 72 |
+
if line and not line.startswith("#"):
|
| 73 |
+
names.append(_canonical(line.split("==", 1)[0]))
|
| 74 |
+
dupes = sorted({name for name in names if names.count(name) > 1})
|
| 75 |
+
assert not dupes, f"duplicate pins in constraints-test.txt: {dupes}"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_bootstrap_upgrades_setuptools_before_audit():
|
| 79 |
+
"""Fresh venvs should not fail audit on the ensurepip setuptools seed."""
|
| 80 |
+
ps1 = (REPO_ROOT / "scripts" / "bootstrap_test_env.ps1").read_text(encoding="utf-8")
|
| 81 |
+
sh = (REPO_ROOT / "scripts" / "bootstrap_test_env.sh").read_text(encoding="utf-8")
|
| 82 |
+
|
| 83 |
+
assert "--upgrade pip setuptools" in ps1
|
| 84 |
+
assert "--upgrade pip setuptools" in sh
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_bootstrap_relock_does_not_inspect_editable_git_metadata():
|
| 88 |
+
"""Relock should work from cross-drive git worktrees on Windows."""
|
| 89 |
+
ps1 = (REPO_ROOT / "scripts" / "bootstrap_test_env.ps1").read_text(encoding="utf-8")
|
| 90 |
+
sh = (REPO_ROOT / "scripts" / "bootstrap_test_env.sh").read_text(encoding="utf-8")
|
| 91 |
+
|
| 92 |
+
assert "pip list --format=freeze" in ps1
|
| 93 |
+
assert "pip list --format=freeze" in sh
|
| 94 |
+
assert "pip freeze --exclude-editable" not in ps1
|
| 95 |
+
assert "pip freeze --exclude-editable" not in sh
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_ci_audits_committed_lock_on_prs_and_schedule():
|
| 99 |
+
ci = (REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
| 100 |
+
|
| 101 |
+
assert "schedule:" in ci
|
| 102 |
+
assert "pip-audit -r constraints-test.txt" in ci
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_ci_pytest_installs_from_committed_lock():
|
| 106 |
+
ci = (REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
| 107 |
+
|
| 108 |
+
assert '-e ".[test]" -c constraints-test.txt' in ci
|
ComfyUI/custom_nodes/koolook/tests/scripts/test_loop_audio.py
ADDED
|
@@ -0,0 +1,1069 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure-function coverage for scripts/loop_audio.py.
|
| 2 |
+
|
| 3 |
+
Anchored by the PR #185 multi-agent review which surfaced three
|
| 4 |
+
silent-failure HIGHs on the audio-source-derivation path:
|
| 5 |
+
|
| 6 |
+
* derive_audio_state collapsed missing-Director and unwired-VAE
|
| 7 |
+
into the same label;
|
| 8 |
+
* extract_multilines used dict-insertion order, so a shorter needle
|
| 9 |
+
declared first could silently shadow a longer match;
|
| 10 |
+
* parse_timeline returned segments verbatim, so a string-valued
|
| 11 |
+
`start` field would crash the renderer at `start / fps` or quietly
|
| 12 |
+
break `<` comparisons in video_segment_has_audio.
|
| 13 |
+
|
| 14 |
+
These tests pin each fix plus the surrounding helpers so future edits
|
| 15 |
+
can't silently regress.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import importlib.util
|
| 20 |
+
import json
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import pytest
|
| 25 |
+
|
| 26 |
+
# Load the script directly — it lives under scripts/, not under a package.
|
| 27 |
+
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "loop_audio.py"
|
| 28 |
+
sys.path.insert(0, str(_SCRIPT.parent))
|
| 29 |
+
_spec = importlib.util.spec_from_file_location("loop_audio", _SCRIPT)
|
| 30 |
+
assert _spec is not None and _spec.loader is not None
|
| 31 |
+
loop_audio = importlib.util.module_from_spec(_spec)
|
| 32 |
+
_spec.loader.exec_module(loop_audio)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# --- helpers used by multiple tests -----------------------------------------
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _director(
|
| 39 |
+
*,
|
| 40 |
+
node_type: str = "LTXDirector__koolook",
|
| 41 |
+
use_custom_audio: bool = False,
|
| 42 |
+
audio_vae_link: int | None = None,
|
| 43 |
+
timeline: dict | None = None,
|
| 44 |
+
epsilon: float = 0.001,
|
| 45 |
+
fps: int = 24,
|
| 46 |
+
) -> dict:
|
| 47 |
+
"""Build a minimal Koolook Director node dict matching the saved
|
| 48 |
+
widget order (DIRECTOR_WIDX). Only fields the helpers under test
|
| 49 |
+
actually read are populated; the rest are placeholders."""
|
| 50 |
+
import json as _json
|
| 51 |
+
timeline_str = _json.dumps(timeline) if timeline is not None else ""
|
| 52 |
+
wv = [
|
| 53 |
+
"", # 0 global_prompt
|
| 54 |
+
120, # 1 duration_frames
|
| 55 |
+
5, # 2 duration_seconds
|
| 56 |
+
timeline_str, # 3 timeline_data
|
| 57 |
+
"", # 4 local_prompts
|
| 58 |
+
"120", # 5 segment_lengths
|
| 59 |
+
epsilon, # 6 epsilon
|
| 60 |
+
"1.00", # 7 guide_strength
|
| 61 |
+
use_custom_audio, # 8 use_custom_audio
|
| 62 |
+
fps, # 9 frame_rate
|
| 63 |
+
"seconds", # 10 display_mode
|
| 64 |
+
0, # 11 custom_width
|
| 65 |
+
0, # 12 custom_height
|
| 66 |
+
"maintain aspect ratio", # 13 resize_method
|
| 67 |
+
32, # 14 divisible_by
|
| 68 |
+
18, # 15 img_compression
|
| 69 |
+
"", # 16 relay_overrides
|
| 70 |
+
]
|
| 71 |
+
return {
|
| 72 |
+
"type": node_type,
|
| 73 |
+
"widgets_values": wv,
|
| 74 |
+
"inputs": [
|
| 75 |
+
{"name": "audio_vae", "link": audio_vae_link},
|
| 76 |
+
{"name": "use_custom_audio", "link": None},
|
| 77 |
+
],
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# --- extract_director — stable, legacy, and upstream IDs --------------------
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@pytest.mark.parametrize(
|
| 85 |
+
"node_type",
|
| 86 |
+
["LTXDirector__koolook", "LTXDirector__koolook_v1_3_2", "LTXDirector"],
|
| 87 |
+
)
|
| 88 |
+
def test_extract_director_accepts_supported_director_ids(node_type):
|
| 89 |
+
node = _director(node_type=node_type)
|
| 90 |
+
assert loop_audio.extract_director([node]) is node
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_extract_director_prefers_koolook_over_upstream():
|
| 94 |
+
upstream = _director(node_type="LTXDirector")
|
| 95 |
+
koolook = _director(node_type="LTXDirector__koolook")
|
| 96 |
+
assert loop_audio.extract_director([upstream, koolook]) is koolook
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_extract_director_prefers_guide_wired_upstream_over_idle_koolook():
|
| 100 |
+
upstream = {"id": 10, **_director(node_type="LTXDirector")}
|
| 101 |
+
upstream["outputs"] = [{"name": "guide_data", "links": [100]}]
|
| 102 |
+
koolook = {"id": 20, **_director(node_type="LTXDirector__koolook")}
|
| 103 |
+
koolook["outputs"] = [{"name": "guide_data", "links": []}]
|
| 104 |
+
reroute = {
|
| 105 |
+
"id": 30,
|
| 106 |
+
"type": "Reroute",
|
| 107 |
+
"inputs": [{"name": "", "link": 100}],
|
| 108 |
+
"outputs": [{"name": "", "links": [101]}],
|
| 109 |
+
}
|
| 110 |
+
guide = {
|
| 111 |
+
"id": 40,
|
| 112 |
+
"type": "LTXDirectorGuide",
|
| 113 |
+
"inputs": [{"name": "guide_data", "link": 101}],
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
assert (
|
| 117 |
+
loop_audio.extract_director(
|
| 118 |
+
[koolook, upstream, reroute, guide],
|
| 119 |
+
[
|
| 120 |
+
[100, 10, 4, 30, 0, "GUIDE_DATA"],
|
| 121 |
+
[101, 30, 0, 40, 4, "GUIDE_DATA"],
|
| 122 |
+
],
|
| 123 |
+
)
|
| 124 |
+
is upstream
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_director_widget_uses_saved_director_widget_order():
|
| 129 |
+
node = {
|
| 130 |
+
"type": "LTXDirector__koolook",
|
| 131 |
+
"inputs": [
|
| 132 |
+
{"name": "duration_frames", "widget": {"name": "duration_frames"}},
|
| 133 |
+
{"name": "epsilon", "widget": {"name": "epsilon"}},
|
| 134 |
+
{"name": "use_custom_audio", "widget": {"name": "use_custom_audio"}},
|
| 135 |
+
],
|
| 136 |
+
"widgets_values": [
|
| 137 |
+
"",
|
| 138 |
+
144,
|
| 139 |
+
6,
|
| 140 |
+
"",
|
| 141 |
+
"",
|
| 142 |
+
"",
|
| 143 |
+
0.004,
|
| 144 |
+
"1.00,1.00,1.00",
|
| 145 |
+
True,
|
| 146 |
+
24,
|
| 147 |
+
],
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
assert loop_audio.director_widget(node, "epsilon") == 0.004
|
| 151 |
+
assert loop_audio.director_widget(node, "use_custom_audio") is True
|
| 152 |
+
assert loop_audio.director_widget(node, "frame_rate") == 24
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_director_widget_keeps_legacy_positional_fallback():
|
| 156 |
+
node = _director(epsilon=0.002)
|
| 157 |
+
assert loop_audio.director_widget(node, "epsilon") == 0.002
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_active_relay_overrides_reads_only_wired_director_input():
|
| 161 |
+
director = {
|
| 162 |
+
"id": 10,
|
| 163 |
+
**_director(),
|
| 164 |
+
"inputs": [
|
| 165 |
+
{"name": "audio_vae", "link": 99},
|
| 166 |
+
{"name": "relay_overrides", "link": 20},
|
| 167 |
+
],
|
| 168 |
+
}
|
| 169 |
+
relay_note = _multiline("RELAY_OVERRIDES", '{"video_strength": 1.0}')
|
| 170 |
+
relay_note["id"] = 5
|
| 171 |
+
relay_note["outputs"] = [{"name": "STRING", "links": [20]}]
|
| 172 |
+
|
| 173 |
+
assert loop_audio.active_relay_overrides(
|
| 174 |
+
[director, relay_note],
|
| 175 |
+
[[20, 5, 0, 10, 16, "STRING"]],
|
| 176 |
+
director,
|
| 177 |
+
) == '{"video_strength": 1.0}'
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_active_relay_overrides_ignores_unwired_note():
|
| 181 |
+
director = {
|
| 182 |
+
"id": 10,
|
| 183 |
+
**_director(),
|
| 184 |
+
"inputs": [
|
| 185 |
+
{"name": "audio_vae", "link": 99},
|
| 186 |
+
{"name": "relay_overrides", "link": None},
|
| 187 |
+
],
|
| 188 |
+
}
|
| 189 |
+
relay_note = _multiline("RELAY_OVERRIDES", '{"video_strength": 1.0}')
|
| 190 |
+
relay_note["id"] = 5
|
| 191 |
+
|
| 192 |
+
assert loop_audio.active_relay_overrides(
|
| 193 |
+
[director, relay_note], [], director
|
| 194 |
+
) == ""
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@pytest.mark.parametrize(
|
| 198 |
+
"node_type, expected",
|
| 199 |
+
[
|
| 200 |
+
("LTXDirector__koolook", "Koolook"),
|
| 201 |
+
("LTXDirector__koolook_v1_3_2", "Koolook"),
|
| 202 |
+
("LTXDirector", "Original upstream"),
|
| 203 |
+
],
|
| 204 |
+
)
|
| 205 |
+
def test_director_flavor_labels_supported_directors(node_type, expected):
|
| 206 |
+
assert loop_audio.director_flavor(_director(node_type=node_type)) == expected
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@pytest.mark.parametrize(
|
| 210 |
+
"node_type, upstream_version, expected",
|
| 211 |
+
[
|
| 212 |
+
("LTXDirector__koolook", "", "v1.3.9"),
|
| 213 |
+
("LTXDirector__koolook_v1_3_2", "", "v1.3.9"),
|
| 214 |
+
("LTXDirector", "1.3.2", "v1.3.2"),
|
| 215 |
+
("LTXDirector", "", "(unknown upstream pin)"),
|
| 216 |
+
],
|
| 217 |
+
)
|
| 218 |
+
def test_director_pin_tag_labels_lock_version(node_type, upstream_version, expected):
|
| 219 |
+
assert (
|
| 220 |
+
loop_audio.director_pin_tag(
|
| 221 |
+
_director(node_type=node_type), upstream_version
|
| 222 |
+
)
|
| 223 |
+
== expected
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# --- derive_audio_state — 5 distinct states --------------------------------
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@pytest.mark.parametrize(
|
| 231 |
+
"director_node, timeline, expected",
|
| 232 |
+
[
|
| 233 |
+
# (no director) — split off so a missing-Director workflow can't
|
| 234 |
+
# silently look identical to a director-present-but-VAE-unwired
|
| 235 |
+
# one (PR #185 review HIGH-1).
|
| 236 |
+
(None, {"segments": [], "audioSegments": []}, "(no director)"),
|
| 237 |
+
# off (no VAE) — Director present, audio_vae socket unwired.
|
| 238 |
+
(
|
| 239 |
+
_director(audio_vae_link=None),
|
| 240 |
+
{"segments": [], "audioSegments": []},
|
| 241 |
+
"off (no VAE)",
|
| 242 |
+
),
|
| 243 |
+
# model-gen — VAE wired, use_custom_audio=False. audioSegments
|
| 244 |
+
# are ignored on this path.
|
| 245 |
+
(
|
| 246 |
+
_director(audio_vae_link=42, use_custom_audio=False),
|
| 247 |
+
{"segments": [], "audioSegments": []},
|
| 248 |
+
"model-gen",
|
| 249 |
+
),
|
| 250 |
+
# custom — VAE wired, use_custom_audio=True, audioSegments
|
| 251 |
+
# non-empty.
|
| 252 |
+
(
|
| 253 |
+
_director(audio_vae_link=42, use_custom_audio=True),
|
| 254 |
+
{"segments": [], "audioSegments": [{"start": 0, "length": 100}]},
|
| 255 |
+
"custom",
|
| 256 |
+
),
|
| 257 |
+
# custom (empty) — VAE wired, use_custom_audio=True, but no
|
| 258 |
+
# audioSegments uploaded.
|
| 259 |
+
(
|
| 260 |
+
_director(audio_vae_link=42, use_custom_audio=True),
|
| 261 |
+
{"segments": [], "audioSegments": []},
|
| 262 |
+
"custom (empty)",
|
| 263 |
+
),
|
| 264 |
+
],
|
| 265 |
+
)
|
| 266 |
+
def test_derive_audio_state_all_five_states(director_node, timeline, expected):
|
| 267 |
+
assert loop_audio.derive_audio_state(director_node, timeline) == expected
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# --- extract_multilines — longest-needle-wins guards future drift ----------
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _multiline(title: str, body: str) -> dict:
|
| 274 |
+
return {
|
| 275 |
+
"type": "Text Multiline",
|
| 276 |
+
"title": title,
|
| 277 |
+
"widgets_values": [body],
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def test_extract_multilines_keeps_all_hits_for_one_needle():
|
| 282 |
+
"""Working_Folder_PATH legitimately appears twice on the canvas
|
| 283 |
+
(project mount + local mirror). Both bodies must come back so the
|
| 284 |
+
caller can pick by reachability."""
|
| 285 |
+
nodes = [
|
| 286 |
+
_multiline("Working_Folder_PATH", "W:/projects/foo"),
|
| 287 |
+
_multiline("Working_Folder_PATH", "e:/local/foo"),
|
| 288 |
+
]
|
| 289 |
+
out = loop_audio.extract_multilines(nodes, ["working_folder"])
|
| 290 |
+
assert out["working_folder"] == ["W:/projects/foo", "e:/local/foo"]
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def test_extract_multilines_longest_needle_wins():
|
| 294 |
+
"""If a future config adds a short substring that's contained in
|
| 295 |
+
an existing longer one, the longer match should win — regardless
|
| 296 |
+
of declaration order. The pre-fix loop iterated in dict-insertion
|
| 297 |
+
order and would have routed both nodes to the shorter needle."""
|
| 298 |
+
nodes = [
|
| 299 |
+
_multiline("RELAY_OVERRIDES", "{video_strength: 10}"),
|
| 300 |
+
]
|
| 301 |
+
# Shorter "relay" declared FIRST in the config — pre-fix this would
|
| 302 |
+
# have shadowed "relay_overrides". Longest-first ordering routes
|
| 303 |
+
# the node to the more specific needle.
|
| 304 |
+
out = loop_audio.extract_multilines(
|
| 305 |
+
nodes, ["relay", "relay_overrides"]
|
| 306 |
+
)
|
| 307 |
+
assert out["relay_overrides"] == ["{video_strength: 10}"]
|
| 308 |
+
assert out["relay"] == []
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def test_extract_multilines_first_match_wins_per_node():
|
| 312 |
+
"""A node's title can match multiple needles. The loop should
|
| 313 |
+
record at most one match per node (the longest) to avoid
|
| 314 |
+
double-counting."""
|
| 315 |
+
nodes = [_multiline("NAME overlay - info combined", "one node")]
|
| 316 |
+
out = loop_audio.extract_multilines(
|
| 317 |
+
nodes, ["name", "overlay - info"]
|
| 318 |
+
)
|
| 319 |
+
# Both needles match the title, but the longer one wins and the
|
| 320 |
+
# per-node break keeps the same node from being counted twice.
|
| 321 |
+
assert out["name"] == []
|
| 322 |
+
assert out["overlay - info"] == ["one node"]
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def test_extract_multilines_accepts_prioritized_alias_map():
|
| 326 |
+
nodes = [
|
| 327 |
+
_multiline("NAME", "old setup name"),
|
| 328 |
+
_multiline("GLOBAL [ base name ]", "new base name"),
|
| 329 |
+
_multiline("GLOBAL [ path ] - working folder", "E:/runs"),
|
| 330 |
+
]
|
| 331 |
+
out = loop_audio.extract_multilines(
|
| 332 |
+
nodes,
|
| 333 |
+
{
|
| 334 |
+
"name": ["global [ base name ]", "name"],
|
| 335 |
+
"working_folder": [
|
| 336 |
+
"global [ path ] - working folder",
|
| 337 |
+
"working_folder",
|
| 338 |
+
],
|
| 339 |
+
},
|
| 340 |
+
)
|
| 341 |
+
assert out["name"] == ["new base name", "old setup name"]
|
| 342 |
+
assert out["working_folder"] == ["E:/runs"]
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def test_extract_setup_variables_reads_text_and_primitive_source_nodes():
|
| 346 |
+
nodes = [
|
| 347 |
+
_multiline("INPUT Path [ EXR ]", "W:/plates"),
|
| 348 |
+
{
|
| 349 |
+
"type": "PrimitiveInt",
|
| 350 |
+
"title": "GLOBAL [ version ]",
|
| 351 |
+
"widgets_values": [1, "fixed"],
|
| 352 |
+
},
|
| 353 |
+
{
|
| 354 |
+
"type": "PrimitiveInt",
|
| 355 |
+
"title": "GLOBAL [ run offset ]",
|
| 356 |
+
"widgets_values": [7, "fixed"],
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"type": "GetNode",
|
| 360 |
+
"title": "Get_GLOBAL [ version ]",
|
| 361 |
+
"widgets_values": ["GLOBAL [ version ]"],
|
| 362 |
+
},
|
| 363 |
+
{
|
| 364 |
+
"type": "SetNode",
|
| 365 |
+
"title": "Set_GLOBAL [ run ]",
|
| 366 |
+
"widgets_values": ["GLOBAL [ run ]"],
|
| 367 |
+
},
|
| 368 |
+
]
|
| 369 |
+
out = loop_audio.extract_setup_variables(
|
| 370 |
+
nodes,
|
| 371 |
+
{
|
| 372 |
+
"input_path_exr": ["input path [ exr ]"],
|
| 373 |
+
"version": ["global [ version ]"],
|
| 374 |
+
"run_offset": ["global [ run offset ]"],
|
| 375 |
+
},
|
| 376 |
+
)
|
| 377 |
+
assert out["input_path_exr"] == ["W:/plates"]
|
| 378 |
+
assert out["version"] == ["1"]
|
| 379 |
+
assert out["run_offset"] == ["7"]
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def test_expected_output_tracking_uses_current_setup_values():
|
| 383 |
+
nodes = [
|
| 384 |
+
{
|
| 385 |
+
"type": "Easy_VideoCombine",
|
| 386 |
+
"widgets_values": {
|
| 387 |
+
"format": "video/koolook-ASTRA-h264",
|
| 388 |
+
},
|
| 389 |
+
},
|
| 390 |
+
{
|
| 391 |
+
"type": "easy showAnything",
|
| 392 |
+
"widgets_values": [
|
| 393 |
+
'[true, ["E:/old/Previous_h264_v001.mp4"]]',
|
| 394 |
+
],
|
| 395 |
+
},
|
| 396 |
+
]
|
| 397 |
+
out = loop_audio.expected_output_tracking(
|
| 398 |
+
nodes,
|
| 399 |
+
{
|
| 400 |
+
"working_folder": ["E:/current/renders"],
|
| 401 |
+
"name": ["Bear_2x-FR_AudioFile-K_Dir"],
|
| 402 |
+
},
|
| 403 |
+
{"version": ["2"]},
|
| 404 |
+
)
|
| 405 |
+
assert out["folder"] == "E:/current/renders"
|
| 406 |
+
assert out["name"] == "Bear_2x-FR_AudioFile-K_Dir_h264_v002"
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def test_output_suffix_uses_named_widget_before_positional_fallback():
|
| 410 |
+
nodes = [
|
| 411 |
+
{
|
| 412 |
+
"type": "Easy_VideoCombine",
|
| 413 |
+
"inputs": [
|
| 414 |
+
{"name": "frame_rate", "widget": {"name": "frame_rate"}},
|
| 415 |
+
{"name": "format", "widget": {"name": "format"}},
|
| 416 |
+
{"name": "version", "widget": {"name": "version"}},
|
| 417 |
+
],
|
| 418 |
+
"widgets_values": [24, "video/ProRes", "999"],
|
| 419 |
+
},
|
| 420 |
+
]
|
| 421 |
+
assert loop_audio.output_suffix_from_workflow(nodes) == "ProRes"
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def test_output_suffix_keeps_legacy_positional_fallback():
|
| 425 |
+
nodes = [
|
| 426 |
+
{
|
| 427 |
+
"type": "Easy_VideoCombine",
|
| 428 |
+
"widgets_values": [24, 0, "upscaled", "video/koolook-ASTRA-h264"],
|
| 429 |
+
},
|
| 430 |
+
]
|
| 431 |
+
assert loop_audio.output_suffix_from_workflow(nodes) == "h264"
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def test_delivery_card_path_uses_output_folder_and_name():
|
| 435 |
+
out = loop_audio.delivery_card_path({
|
| 436 |
+
"folder": "E:/current/renders",
|
| 437 |
+
"name": "Bear_h264_v002",
|
| 438 |
+
})
|
| 439 |
+
assert out == Path("E:/current/renders") / "cards" / "Bear_h264_v002_card.png"
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def test_delivery_card_path_can_include_run_number():
|
| 443 |
+
out = loop_audio.delivery_card_path(
|
| 444 |
+
{
|
| 445 |
+
"folder": "E:/current/renders",
|
| 446 |
+
"name": "Bear_h264_v002",
|
| 447 |
+
},
|
| 448 |
+
7,
|
| 449 |
+
)
|
| 450 |
+
assert out == (
|
| 451 |
+
Path("E:/current/renders")
|
| 452 |
+
/ "cards"
|
| 453 |
+
/ "Bear_h264_v002_run007_card.png"
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def test_delivery_card_path_skips_when_output_is_unknown():
|
| 458 |
+
assert loop_audio.delivery_card_path({"folder": "E:/renders"}) is None
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def test_copy_delivery_card_reports_failure_without_raising(monkeypatch, tmp_path: Path):
|
| 462 |
+
def fail_copy(_src, _dst):
|
| 463 |
+
raise OSError("drive unavailable")
|
| 464 |
+
|
| 465 |
+
monkeypatch.setattr(loop_audio.shutil, "copy2", fail_copy)
|
| 466 |
+
|
| 467 |
+
status = loop_audio.copy_delivery_card(
|
| 468 |
+
tmp_path / "card.png",
|
| 469 |
+
{"folder": str(tmp_path), "name": "Bear_h264_v002"},
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
assert status == "failed (drive unavailable)"
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def test_copy_delivery_card_creates_missing_cards_folder(tmp_path: Path):
|
| 476 |
+
card = tmp_path / "source.png"
|
| 477 |
+
folder = tmp_path / "renders"
|
| 478 |
+
card.write_text("new", encoding="utf-8")
|
| 479 |
+
|
| 480 |
+
status = loop_audio.copy_delivery_card(
|
| 481 |
+
card,
|
| 482 |
+
{"folder": str(folder), "name": "Bear_h264_v002"},
|
| 483 |
+
7,
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
expected = folder / "cards" / "Bear_h264_v002_run007_card.png"
|
| 487 |
+
assert status == str(expected)
|
| 488 |
+
assert expected.read_text(encoding="utf-8") == "new"
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def test_copy_delivery_card_leaves_existing_file_in_place(tmp_path: Path):
|
| 492 |
+
card = tmp_path / "source.png"
|
| 493 |
+
folder = tmp_path / "renders"
|
| 494 |
+
existing = folder / "cards" / "Bear_h264_v002_card.png"
|
| 495 |
+
card.write_text("new", encoding="utf-8")
|
| 496 |
+
existing.parent.mkdir(parents=True)
|
| 497 |
+
existing.write_text("old", encoding="utf-8")
|
| 498 |
+
|
| 499 |
+
status = loop_audio.copy_delivery_card(
|
| 500 |
+
card,
|
| 501 |
+
{"folder": str(folder), "name": "Bear_h264_v002"},
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
assert status.startswith("exists (left in place:")
|
| 505 |
+
assert existing.read_text(encoding="utf-8") == "old"
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def test_copy_delivery_card_can_overwrite_existing_file(tmp_path: Path):
|
| 509 |
+
card = tmp_path / "source.png"
|
| 510 |
+
folder = tmp_path / "renders"
|
| 511 |
+
existing = folder / "cards" / "Bear_h264_v002_run007_card.png"
|
| 512 |
+
card.write_text("new", encoding="utf-8")
|
| 513 |
+
existing.parent.mkdir(parents=True)
|
| 514 |
+
existing.write_text("old", encoding="utf-8")
|
| 515 |
+
|
| 516 |
+
status = loop_audio.copy_delivery_card(
|
| 517 |
+
card,
|
| 518 |
+
{"folder": str(folder), "name": "Bear_h264_v002"},
|
| 519 |
+
7,
|
| 520 |
+
overwrite=True,
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
assert status == str(existing)
|
| 524 |
+
assert existing.read_text(encoding="utf-8") == "new"
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def test_card_metadata_scrubs_path_bearing_fields():
|
| 528 |
+
metadata = loop_audio.card_metadata(
|
| 529 |
+
4,
|
| 530 |
+
"label",
|
| 531 |
+
Path("run004_workflow.json"),
|
| 532 |
+
{"name": ["Bear"], "relay_overrides": ["{}"]},
|
| 533 |
+
{"input_path_exr": ["W:/projects/client_codename/shot/v003"]},
|
| 534 |
+
{
|
| 535 |
+
"folder": "E:/Jobs/Client/Comfy/Runs",
|
| 536 |
+
"name": "Bear_h264_v002",
|
| 537 |
+
"version_tag": "v002",
|
| 538 |
+
"format_suffix": "h264",
|
| 539 |
+
},
|
| 540 |
+
_director(),
|
| 541 |
+
{"segments": [], "audioSegments": []},
|
| 542 |
+
"custom",
|
| 543 |
+
{},
|
| 544 |
+
"clean",
|
| 545 |
+
"1.3.2",
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
encoded = json.dumps(metadata)
|
| 549 |
+
assert "W:/projects" not in encoded
|
| 550 |
+
assert "client_codename" not in encoded
|
| 551 |
+
assert "E:/Jobs" not in encoded
|
| 552 |
+
assert "path-sha256:" in encoded
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def test_card_metadata_does_not_store_boolean_frame_rate():
|
| 556 |
+
metadata = loop_audio.card_metadata(
|
| 557 |
+
4,
|
| 558 |
+
"label",
|
| 559 |
+
Path("run004_workflow.json"),
|
| 560 |
+
{"name": ["Bear"], "relay_overrides": ["{}"]},
|
| 561 |
+
{},
|
| 562 |
+
{"folder": "", "name": ""},
|
| 563 |
+
_director(fps=True),
|
| 564 |
+
{"segments": [], "audioSegments": []},
|
| 565 |
+
"model-gen",
|
| 566 |
+
{},
|
| 567 |
+
"clean",
|
| 568 |
+
"1.3.2",
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
assert metadata["director"]["frame_rate"] is None
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def test_sanitize_workflow_for_archive_redacts_absolute_paths():
|
| 575 |
+
workflow = {
|
| 576 |
+
"nodes": [
|
| 577 |
+
{
|
| 578 |
+
"widgets_values": [
|
| 579 |
+
"E:/Jobs/Client/Comfy/Runs",
|
| 580 |
+
"notes\nW:/projects/client_codename/shot/v003\nok",
|
| 581 |
+
'["e:\\\\G-Drive-BaconX\\\\Jobs\\\\Jeep_Animals\\\\render.json"]',
|
| 582 |
+
"<PROJECTS>/samsung_goat/vfx/assets",
|
| 583 |
+
"W:/projects/client_codename/shot/v003",
|
| 584 |
+
"relative/path/is-kept",
|
| 585 |
+
"https://example.com/kept",
|
| 586 |
+
]
|
| 587 |
+
}
|
| 588 |
+
]
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
sanitized = loop_audio.sanitize_workflow_for_archive(workflow)
|
| 592 |
+
encoded = json.dumps(sanitized)
|
| 593 |
+
|
| 594 |
+
assert "E:/Jobs" not in encoded
|
| 595 |
+
assert "W:/projects" not in encoded
|
| 596 |
+
assert "client_codename" not in encoded
|
| 597 |
+
assert "samsung_goat" not in encoded
|
| 598 |
+
assert "relative/path/is-kept" in encoded
|
| 599 |
+
assert "https://example.com/kept" in encoded
|
| 600 |
+
assert "path-sha256:" in encoded
|
| 601 |
+
|
| 602 |
+
|
| 603 |
+
def test_render_notes_scrubs_paths_and_rejects_boolean_fps():
|
| 604 |
+
notes = loop_audio.render_notes_md(
|
| 605 |
+
5,
|
| 606 |
+
Path("LTX-23-audio_tests_03.json"),
|
| 607 |
+
{
|
| 608 |
+
"name": ["Bear"],
|
| 609 |
+
"relay_overrides": [""],
|
| 610 |
+
"overlay - info": [""],
|
| 611 |
+
"overlay - feedback": [""],
|
| 612 |
+
"working_folder": ["E:/Jobs/Client/Comfy/Runs"],
|
| 613 |
+
},
|
| 614 |
+
{
|
| 615 |
+
"input_path_exr": ["W:/projects/client_codename/shot/v003"],
|
| 616 |
+
"version": ["1"],
|
| 617 |
+
"run_offset": ["0"],
|
| 618 |
+
},
|
| 619 |
+
_director(fps=True),
|
| 620 |
+
{"segments": [], "audioSegments": []},
|
| 621 |
+
"custom",
|
| 622 |
+
"",
|
| 623 |
+
[],
|
| 624 |
+
{"motion": None, "sync": None, "sharp": None},
|
| 625 |
+
{"folder": "E:/Jobs/Client/Comfy/Runs", "name": "Bear_h264_v001"},
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
assert "E:/Jobs" not in notes
|
| 629 |
+
assert "W:/projects" not in notes
|
| 630 |
+
assert "client_codename" not in notes
|
| 631 |
+
assert "path-sha256:" in notes
|
| 632 |
+
assert "True fps" not in notes
|
| 633 |
+
assert "(unknown fps)" in notes
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def test_audio_card_embeds_metadata_payload(tmp_path: Path):
|
| 637 |
+
from PIL import Image
|
| 638 |
+
|
| 639 |
+
from make_card_audio import render_audio_card
|
| 640 |
+
|
| 641 |
+
metadata = {
|
| 642 |
+
"schema": "koolook.audio_loop.card_metadata.v1",
|
| 643 |
+
"repo": {"main_sha": "abc1234"},
|
| 644 |
+
"output": {"name": "Bear_h264_v002"},
|
| 645 |
+
}
|
| 646 |
+
out = tmp_path / "card.png"
|
| 647 |
+
render_audio_card(
|
| 648 |
+
{
|
| 649 |
+
"run_number": 3,
|
| 650 |
+
"date": "2026-05-30",
|
| 651 |
+
"workflow_name": "workflow.json",
|
| 652 |
+
"name": "Bear",
|
| 653 |
+
"relay_overrides_raw": "{}",
|
| 654 |
+
"info_body": "",
|
| 655 |
+
"feedback_lines": [],
|
| 656 |
+
"scores": {},
|
| 657 |
+
"work_folder": "E:/renders",
|
| 658 |
+
"output_folder": "E:/renders",
|
| 659 |
+
"output_name": "Bear_h264_v002",
|
| 660 |
+
"director_flavor": "Koolook v1.3.9",
|
| 661 |
+
"audio_src": "custom",
|
| 662 |
+
"epsilon": 0.001,
|
| 663 |
+
"frame_rate": 24,
|
| 664 |
+
"segments": [],
|
| 665 |
+
"audio_segments": [],
|
| 666 |
+
"segment_prompt_mode": "none",
|
| 667 |
+
"metadata": metadata,
|
| 668 |
+
},
|
| 669 |
+
out,
|
| 670 |
+
)
|
| 671 |
+
embedded = json.loads(Image.open(out).info["koolook_audio_loop"])
|
| 672 |
+
assert embedded == metadata
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def test_rebuild_state_handles_non_numeric_run_dir_and_bom_workflow(tmp_path: Path):
|
| 676 |
+
from make_card_audio import _rebuild_state_from_run_dir
|
| 677 |
+
|
| 678 |
+
run_dir = tmp_path / "run-foo_label"
|
| 679 |
+
run_dir.mkdir()
|
| 680 |
+
(run_dir / "workflow.json").write_text(
|
| 681 |
+
json.dumps({"nodes": []}),
|
| 682 |
+
encoding="utf-8-sig",
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
state = _rebuild_state_from_run_dir(run_dir)
|
| 686 |
+
|
| 687 |
+
assert state["run_number"] == 0
|
| 688 |
+
assert state["run_label"] == "label"
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
def test_rebuild_state_preserves_date_and_splits_repo_sync_metadata(tmp_path: Path):
|
| 692 |
+
from make_card_audio import _rebuild_state_from_run_dir
|
| 693 |
+
|
| 694 |
+
run_dir = tmp_path / "run-005_label"
|
| 695 |
+
run_dir.mkdir()
|
| 696 |
+
(run_dir / "run005_workflow.json").write_text(
|
| 697 |
+
json.dumps({"nodes": [], "links": []}),
|
| 698 |
+
encoding="utf-8",
|
| 699 |
+
)
|
| 700 |
+
(run_dir / "metadata.json").write_text(
|
| 701 |
+
json.dumps({"run": {"date": "2026-05-01"}}),
|
| 702 |
+
encoding="utf-8",
|
| 703 |
+
)
|
| 704 |
+
(run_dir / "patch_state.txt").write_text(
|
| 705 |
+
"\n".join(
|
| 706 |
+
[
|
| 707 |
+
"MAIN SHA : abc1234",
|
| 708 |
+
"Last dev-sync-audio : def5678 (2026-05-02 11:22)",
|
| 709 |
+
"Sync scope tag : relay parser",
|
| 710 |
+
"Sync worktree : ComfyUI-Koolook",
|
| 711 |
+
"Fork dir status : clean",
|
| 712 |
+
]
|
| 713 |
+
),
|
| 714 |
+
encoding="utf-8",
|
| 715 |
+
)
|
| 716 |
+
|
| 717 |
+
state = _rebuild_state_from_run_dir(run_dir)
|
| 718 |
+
metadata = state["metadata"]
|
| 719 |
+
|
| 720 |
+
assert state["date"] == "2026-05-01"
|
| 721 |
+
assert metadata["run"]["date"] == "2026-05-01"
|
| 722 |
+
assert metadata["repo"]["last_dev_sync_audio"] == "def5678"
|
| 723 |
+
assert metadata["repo"]["last_dev_sync_at"] == "2026-05-02 11:22"
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def test_extract_multilines_ignores_non_text_multiline_nodes():
|
| 727 |
+
nodes = [
|
| 728 |
+
{"type": "LTXDirector__koolook_v1_3_2", "title": "NAME"},
|
| 729 |
+
_multiline("NAME", "real"),
|
| 730 |
+
]
|
| 731 |
+
out = loop_audio.extract_multilines(nodes, ["name"])
|
| 732 |
+
assert out["name"] == ["real"]
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
# --- parse_timeline — coerce numeric segment fields ------------------------
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
def test_parse_timeline_coerces_string_numerics():
|
| 739 |
+
"""The Comfy frontend sometimes saves numeric segment fields as
|
| 740 |
+
strings. parse_timeline must coerce them so downstream arithmetic
|
| 741 |
+
in the renderer (`start / fps`, `<` comparisons) stays type-safe
|
| 742 |
+
instead of crashing or doing lexical comparisons."""
|
| 743 |
+
node = _director(timeline={
|
| 744 |
+
"segments": [
|
| 745 |
+
{"id": "a", "start": "0", "length": "120", "prompt": "x"},
|
| 746 |
+
],
|
| 747 |
+
"audioSegments": [],
|
| 748 |
+
})
|
| 749 |
+
tl = loop_audio.parse_timeline(node)
|
| 750 |
+
seg = tl["segments"][0]
|
| 751 |
+
assert seg["start"] == 0
|
| 752 |
+
assert seg["length"] == 120
|
| 753 |
+
assert isinstance(seg["start"], int)
|
| 754 |
+
assert isinstance(seg["length"], int)
|
| 755 |
+
# Non-numeric fields pass through.
|
| 756 |
+
assert seg["prompt"] == "x"
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def test_parse_timeline_coerces_float_to_int():
|
| 760 |
+
node = _director(timeline={
|
| 761 |
+
"segments": [{"start": 1.7, "length": 12.4}],
|
| 762 |
+
"audioSegments": [],
|
| 763 |
+
})
|
| 764 |
+
tl = loop_audio.parse_timeline(node)
|
| 765 |
+
# int(float(...)) truncates toward 0, matching the upstream
|
| 766 |
+
# Director's _build_combined_audio coercion behavior.
|
| 767 |
+
assert tl["segments"][0]["start"] == 1
|
| 768 |
+
assert tl["segments"][0]["length"] == 12
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
def test_parse_timeline_collapses_bad_values_to_zero():
|
| 772 |
+
"""A malformed segment shouldn't crash the whole loop — the
|
| 773 |
+
offending field collapses to 0 and we keep going."""
|
| 774 |
+
node = _director(timeline={
|
| 775 |
+
"segments": [{"start": "not a number", "length": 100}],
|
| 776 |
+
"audioSegments": [],
|
| 777 |
+
})
|
| 778 |
+
tl = loop_audio.parse_timeline(node)
|
| 779 |
+
assert tl["segments"][0]["start"] == 0
|
| 780 |
+
assert tl["segments"][0]["length"] == 100
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
def test_parse_timeline_handles_malformed_json():
|
| 784 |
+
"""Invalid JSON in timeline_data must not raise — the helper
|
| 785 |
+
returns empty lists so the renderer falls back to "(N=0)" and
|
| 786 |
+
proceeds."""
|
| 787 |
+
bad = _director()
|
| 788 |
+
bad["widgets_values"][3] = "{not json"
|
| 789 |
+
assert loop_audio.parse_timeline(bad) == {
|
| 790 |
+
"segments": [], "audioSegments": [],
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
|
| 794 |
+
def test_parse_timeline_handles_missing_director():
|
| 795 |
+
assert loop_audio.parse_timeline(None) == {
|
| 796 |
+
"segments": [], "audioSegments": [],
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
def test_parse_timeline_drops_non_dict_segments():
|
| 801 |
+
node = _director(timeline={
|
| 802 |
+
"segments": [
|
| 803 |
+
{"start": 0, "length": 60}, # kept
|
| 804 |
+
"not a dict", # dropped
|
| 805 |
+
None, # dropped
|
| 806 |
+
],
|
| 807 |
+
"audioSegments": [],
|
| 808 |
+
})
|
| 809 |
+
tl = loop_audio.parse_timeline(node)
|
| 810 |
+
assert len(tl["segments"]) == 1
|
| 811 |
+
|
| 812 |
+
|
| 813 |
+
# --- video_segment_has_audio — overlap boundaries --------------------------
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
@pytest.mark.parametrize(
|
| 817 |
+
"video, audio_segs, expected",
|
| 818 |
+
[
|
| 819 |
+
# Same range — overlaps.
|
| 820 |
+
({"start": 0, "length": 100}, [{"start": 0, "length": 100}], True),
|
| 821 |
+
# Audio starts inside video — overlaps.
|
| 822 |
+
({"start": 0, "length": 100}, [{"start": 50, "length": 50}], True),
|
| 823 |
+
# Audio ends inside video — overlaps.
|
| 824 |
+
({"start": 50, "length": 100}, [{"start": 0, "length": 75}], True),
|
| 825 |
+
# Audio strictly before video — no overlap.
|
| 826 |
+
({"start": 100, "length": 50}, [{"start": 0, "length": 100}], False),
|
| 827 |
+
# Audio strictly after video — no overlap.
|
| 828 |
+
({"start": 0, "length": 50}, [{"start": 100, "length": 50}], False),
|
| 829 |
+
# Audio exactly touches video end — half-open intervals
|
| 830 |
+
# (a_start < v_end) treats this as NOT overlapping.
|
| 831 |
+
({"start": 0, "length": 50}, [{"start": 50, "length": 50}], False),
|
| 832 |
+
# Multiple audio segs — any one overlap is enough.
|
| 833 |
+
(
|
| 834 |
+
{"start": 0, "length": 50},
|
| 835 |
+
[{"start": 100, "length": 10}, {"start": 25, "length": 10}],
|
| 836 |
+
True,
|
| 837 |
+
),
|
| 838 |
+
# No audio segs — false.
|
| 839 |
+
({"start": 0, "length": 50}, [], False),
|
| 840 |
+
],
|
| 841 |
+
)
|
| 842 |
+
def test_video_segment_has_audio_boundaries(video, audio_segs, expected):
|
| 843 |
+
assert loop_audio.video_segment_has_audio(video, audio_segs) is expected
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
# --- segment_prompt_mode — same vs per-segment prompt check -----------------
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
@pytest.mark.parametrize(
|
| 850 |
+
"segments, expected",
|
| 851 |
+
[
|
| 852 |
+
([], "none"),
|
| 853 |
+
([{"prompt": "one prompt"}], "single"),
|
| 854 |
+
(
|
| 855 |
+
[{"prompt": "same prompt"}, {"prompt": "same prompt"}],
|
| 856 |
+
"same",
|
| 857 |
+
),
|
| 858 |
+
(
|
| 859 |
+
[{"prompt": "wide shot"}, {"prompt": "close up"}],
|
| 860 |
+
"per-segment",
|
| 861 |
+
),
|
| 862 |
+
(
|
| 863 |
+
[{"prompt": "wide shot"}, {"prompt": ""}],
|
| 864 |
+
"missing",
|
| 865 |
+
),
|
| 866 |
+
],
|
| 867 |
+
)
|
| 868 |
+
def test_segment_prompt_mode_classifies_prompt_sequence(segments, expected):
|
| 869 |
+
assert loop_audio.segment_prompt_mode(segments) == expected
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
# --- parse_feedback — score 0 must round-trip (PR #185 review MEDIUM-9) ----
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
def test_parse_feedback_extracts_scores_with_lines():
|
| 876 |
+
body = (
|
| 877 |
+
"Looking solid overall\n"
|
| 878 |
+
"Sync drifts in the last second\n"
|
| 879 |
+
"motion: 4/5\n"
|
| 880 |
+
"sync: 3/5\n"
|
| 881 |
+
"sharpness: 5/5\n"
|
| 882 |
+
)
|
| 883 |
+
scores, lines = loop_audio.parse_feedback(body)
|
| 884 |
+
assert scores == {"motion": 4, "sync": 3, "sharp": 5}
|
| 885 |
+
assert lines == ["Looking solid overall", "Sync drifts in the last second"]
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
def test_parse_feedback_preserves_zero_as_score():
|
| 889 |
+
"""0 is a legitimate score. Pre-fix the log row coerced it to '?'
|
| 890 |
+
via `or '?'`; parse_feedback itself stores 0 correctly — this test
|
| 891 |
+
pins that contract so a future refactor can't subtly inject `or 0`
|
| 892 |
+
semantics."""
|
| 893 |
+
scores, _ = loop_audio.parse_feedback("motion: 0/5\nsync: 0\nsharp: 0\n")
|
| 894 |
+
assert scores == {"motion": 0, "sync": 0, "sharp": 0}
|
| 895 |
+
|
| 896 |
+
|
| 897 |
+
def test_render_log_row_preserves_zero_scores():
|
| 898 |
+
row = loop_audio.render_log_row(
|
| 899 |
+
3,
|
| 900 |
+
_director(),
|
| 901 |
+
"",
|
| 902 |
+
"model-gen",
|
| 903 |
+
{"segments": [], "audioSegments": []},
|
| 904 |
+
{"motion": 0, "sync": 0, "sharp": 0},
|
| 905 |
+
[],
|
| 906 |
+
)
|
| 907 |
+
assert "M0·S0·Sh0" in row
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
def test_render_log_row_records_video_and_audio_segment_counts():
|
| 911 |
+
row = loop_audio.render_log_row(
|
| 912 |
+
3,
|
| 913 |
+
_director(),
|
| 914 |
+
"",
|
| 915 |
+
"custom",
|
| 916 |
+
{"segments": [{}, {}], "audioSegments": [{}, {}]},
|
| 917 |
+
{"motion": None, "sync": None, "sharp": None},
|
| 918 |
+
[],
|
| 919 |
+
)
|
| 920 |
+
assert "| custom | 2v/2a |" in row
|
| 921 |
+
|
| 922 |
+
|
| 923 |
+
def test_parse_feedback_accepts_sharpness_alias():
|
| 924 |
+
"""Both 'sharp' and 'sharpness' are accepted axis names. Both map
|
| 925 |
+
to the 'sharp' key."""
|
| 926 |
+
scores, _ = loop_audio.parse_feedback("sharpness: 4\n")
|
| 927 |
+
assert scores["sharp"] == 4
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
def test_parse_feedback_case_insensitive():
|
| 931 |
+
scores, _ = loop_audio.parse_feedback("MOTION: 3\nSync 4\n")
|
| 932 |
+
assert scores["motion"] == 3
|
| 933 |
+
assert scores["sync"] == 4
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
def test_parse_feedback_empty_body_returns_blank_scores():
|
| 937 |
+
scores, lines = loop_audio.parse_feedback("")
|
| 938 |
+
assert scores == {"motion": None, "sync": None, "sharp": None}
|
| 939 |
+
assert lines == []
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
# --- is_input_wired — None vs False semantics matter for derive_audio_state ---
|
| 943 |
+
|
| 944 |
+
|
| 945 |
+
def test_is_input_wired_none_when_director_missing():
|
| 946 |
+
assert loop_audio.is_input_wired(None, "audio_vae") is None
|
| 947 |
+
|
| 948 |
+
|
| 949 |
+
def test_is_input_wired_true_when_link_set():
|
| 950 |
+
node = {"inputs": [{"name": "audio_vae", "link": 42}]}
|
| 951 |
+
assert loop_audio.is_input_wired(node, "audio_vae") is True
|
| 952 |
+
|
| 953 |
+
|
| 954 |
+
def test_is_input_wired_false_when_link_null():
|
| 955 |
+
node = {"inputs": [{"name": "audio_vae", "link": None}]}
|
| 956 |
+
assert loop_audio.is_input_wired(node, "audio_vae") is False
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
def test_is_input_wired_none_when_input_socket_absent():
|
| 960 |
+
"""An older Director schema might not have the named socket at
|
| 961 |
+
all — same outcome as 'unwired' at runtime (no audio latent
|
| 962 |
+
produced), but a distinct value here so callers can tell."""
|
| 963 |
+
node = {"inputs": [{"name": "model", "link": 1}]}
|
| 964 |
+
assert loop_audio.is_input_wired(node, "audio_vae") is None
|
| 965 |
+
|
| 966 |
+
|
| 967 |
+
# --- wrap_path — long mount paths shouldn't bleed past the card edge -------
|
| 968 |
+
|
| 969 |
+
|
| 970 |
+
def test_wrap_path_breaks_on_separator():
|
| 971 |
+
out = loop_audio.wrap_path(
|
| 972 |
+
"e:/G-Drive-BaconX/Jobs/Jeep_Animals/ComfyUI_LTX23/Phase2",
|
| 973 |
+
max_chars=30,
|
| 974 |
+
)
|
| 975 |
+
# Joined with backslashes, never split mid-segment.
|
| 976 |
+
for line in out:
|
| 977 |
+
assert "/" not in line # normalised to backslash separators
|
| 978 |
+
joined = "".join(out)
|
| 979 |
+
assert joined == "e:\\G-Drive-BaconX\\Jobs\\Jeep_Animals\\ComfyUI_LTX23\\Phase2"
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
def test_wrap_path_returns_single_empty_string_for_empty_input():
|
| 983 |
+
assert loop_audio.wrap_path("") == [""]
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
def test_wrap_path_handles_single_segment_longer_than_max():
|
| 987 |
+
"""A directory name longer than max_chars still gets its own
|
| 988 |
+
line — we never split mid-name."""
|
| 989 |
+
out = loop_audio.wrap_path("verylongsingledirectoryname", max_chars=10)
|
| 990 |
+
assert len(out) == 1
|
| 991 |
+
assert out[0] == "verylongsingledirectoryname"
|
| 992 |
+
|
| 993 |
+
|
| 994 |
+
# --- pick_existing_path — picks reachable path, falls back to first ------
|
| 995 |
+
|
| 996 |
+
|
| 997 |
+
def test_pick_existing_path_prefers_real_directory(tmp_path):
|
| 998 |
+
real = tmp_path / "real"
|
| 999 |
+
real.mkdir()
|
| 1000 |
+
out = loop_audio.pick_existing_path([
|
| 1001 |
+
"Z:/never-exists",
|
| 1002 |
+
str(real),
|
| 1003 |
+
"Y:/also-never",
|
| 1004 |
+
])
|
| 1005 |
+
assert out == str(real)
|
| 1006 |
+
|
| 1007 |
+
|
| 1008 |
+
def test_pick_existing_path_falls_back_to_first_nonempty():
|
| 1009 |
+
out = loop_audio.pick_existing_path(["Z:/never-exists", "Y:/also-never"])
|
| 1010 |
+
assert out == "Z:/never-exists"
|
| 1011 |
+
|
| 1012 |
+
|
| 1013 |
+
def test_pick_existing_path_returns_empty_when_all_empty():
|
| 1014 |
+
assert loop_audio.pick_existing_path(["", " ", "\""]) == ""
|
| 1015 |
+
|
| 1016 |
+
|
| 1017 |
+
# --- first_multiline + autogen_label sanity checks ------------------------
|
| 1018 |
+
|
| 1019 |
+
|
| 1020 |
+
def test_first_multiline_returns_first_or_empty():
|
| 1021 |
+
assert loop_audio.first_multiline({"name": ["foo", "bar"]}, "name") == "foo"
|
| 1022 |
+
assert loop_audio.first_multiline({}, "name") == ""
|
| 1023 |
+
assert loop_audio.first_multiline({"name": []}, "name") == ""
|
| 1024 |
+
|
| 1025 |
+
|
| 1026 |
+
def test_autogen_label_when_director_missing():
|
| 1027 |
+
label = loop_audio.autogen_label("Bear_3x", None, "")
|
| 1028 |
+
assert "missing" in label
|
| 1029 |
+
assert "audio-off" in label
|
| 1030 |
+
|
| 1031 |
+
|
| 1032 |
+
def test_autogen_label_when_director_present():
|
| 1033 |
+
node = _director(use_custom_audio=True, audio_vae_link=42)
|
| 1034 |
+
label = loop_audio.autogen_label(
|
| 1035 |
+
"Bear_3x", node, '{"video_strength": 10.0}'
|
| 1036 |
+
)
|
| 1037 |
+
assert "koolook" in label
|
| 1038 |
+
assert "audio-on" in label
|
| 1039 |
+
assert "vstr10.0" in label
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
def test_autogen_label_when_director_is_upstream():
|
| 1043 |
+
label = loop_audio.autogen_label(
|
| 1044 |
+
"Bear_3x", _director(node_type="LTXDirector"), ""
|
| 1045 |
+
)
|
| 1046 |
+
assert "upstream" in label
|
| 1047 |
+
assert "audio-off" in label
|
| 1048 |
+
|
| 1049 |
+
|
| 1050 |
+
def test_relay_overrides_txt_marks_upstream_director_inert():
|
| 1051 |
+
txt = loop_audio.render_relay_overrides_txt(
|
| 1052 |
+
'{"video_strength": 10.0}',
|
| 1053 |
+
_director(node_type="LTXDirector"),
|
| 1054 |
+
)
|
| 1055 |
+
assert "INERT" in txt
|
| 1056 |
+
assert "LTXDirector" in txt
|
| 1057 |
+
|
| 1058 |
+
|
| 1059 |
+
def test_next_run_number_reads_folders_and_log(tmp_path):
|
| 1060 |
+
runs = tmp_path / "runs"
|
| 1061 |
+
runs.mkdir()
|
| 1062 |
+
(runs / "run-001_alpha").mkdir()
|
| 1063 |
+
(runs / "log.md").write_text(
|
| 1064 |
+
"| Run | Date |\n"
|
| 1065 |
+
"|---|---|\n"
|
| 1066 |
+
"| 002 | 2026-05-29 |\n",
|
| 1067 |
+
encoding="utf-8",
|
| 1068 |
+
)
|
| 1069 |
+
assert loop_audio.next_run_number(runs) == 3
|
ComfyUI/custom_nodes/koolook/tests/scripts/test_sync_to_dev.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Guards on which modules ``dev-sync`` ships, so the loader can never point
|
| 2 |
+
at a file the install lacks.
|
| 3 |
+
|
| 4 |
+
The #183 incident (root cause behind #198): a module that ``__init__.py``
|
| 5 |
+
imported but ``scripts/sync_to_dev.py``'s ``RUNTIME_PATHS`` never copied raised
|
| 6 |
+
``ImportError`` at startup. After the #198 refactor the blast radius of such a
|
| 7 |
+
gap depends on *where* the import sits — a per-group node import degrades to
|
| 8 |
+
"that node absent", but the context probe (``koolook_versioning``) and the
|
| 9 |
+
install guard gate *all* registration. So the real invariant is: every module
|
| 10 |
+
``__init__.py`` imports at load must be shipped by ``RUNTIME_PATHS``. This test
|
| 11 |
+
derives those imports from ``__init__.py`` itself (rather than a ``k_*.py``
|
| 12 |
+
glob, which misses the non-``k_`` gates) so it can't drift.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import ast
|
| 17 |
+
import importlib.util
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 21 |
+
_SCRIPT = REPO_ROOT / "scripts" / "sync_to_dev.py"
|
| 22 |
+
_spec = importlib.util.spec_from_file_location("koolook_sync_to_dev_under_test", _SCRIPT)
|
| 23 |
+
assert _spec is not None and _spec.loader is not None
|
| 24 |
+
sync_to_dev = importlib.util.module_from_spec(_spec)
|
| 25 |
+
_spec.loader.exec_module(sync_to_dev)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _init_load_time_module_deps() -> set[str]:
|
| 29 |
+
"""Top-level package modules/packages ``__init__.py`` imports at load.
|
| 30 |
+
|
| 31 |
+
Covers relative ``from .X import ...`` / ``from . import X`` statements and
|
| 32 |
+
the ``_merge_node_group(label, ".X")`` node-group imports. Returns the
|
| 33 |
+
first path segment of each (e.g. ``forks.radiance_koolook`` -> ``forks``),
|
| 34 |
+
the granularity ``RUNTIME_PATHS`` ships at. Absolute imports (the install
|
| 35 |
+
guard's out-of-package fallback) are level 0 and intentionally ignored.
|
| 36 |
+
"""
|
| 37 |
+
tree = ast.parse((REPO_ROOT / "__init__.py").read_text(encoding="utf-8"))
|
| 38 |
+
deps: set[str] = set()
|
| 39 |
+
for node in ast.walk(tree):
|
| 40 |
+
if isinstance(node, ast.ImportFrom) and (node.level or 0) >= 1:
|
| 41 |
+
if node.module: # from .pkg import ...
|
| 42 |
+
deps.add(node.module.split(".")[0])
|
| 43 |
+
else: # from . import a, b
|
| 44 |
+
deps.update(alias.name.split(".")[0] for alias in node.names)
|
| 45 |
+
elif (
|
| 46 |
+
isinstance(node, ast.Call)
|
| 47 |
+
and getattr(node.func, "id", None) == "_merge_node_group"
|
| 48 |
+
):
|
| 49 |
+
for arg in node.args:
|
| 50 |
+
if (
|
| 51 |
+
isinstance(arg, ast.Constant)
|
| 52 |
+
and isinstance(arg.value, str)
|
| 53 |
+
and arg.value.startswith(".")
|
| 54 |
+
):
|
| 55 |
+
deps.add(arg.value.lstrip(".").split(".")[0])
|
| 56 |
+
return deps
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _route_load_time_module_deps() -> set[str]:
|
| 60 |
+
tree = ast.parse((REPO_ROOT / "koolook_routes.py").read_text(encoding="utf-8"))
|
| 61 |
+
deps: set[str] = set()
|
| 62 |
+
for node in ast.walk(tree):
|
| 63 |
+
if isinstance(node, ast.ImportFrom):
|
| 64 |
+
if (node.level or 0) >= 1 and node.module:
|
| 65 |
+
deps.add(node.module.split(".")[0])
|
| 66 |
+
elif node.module and node.module.startswith("koolook_"):
|
| 67 |
+
deps.add(node.module.split(".")[0])
|
| 68 |
+
return deps
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_init_load_deps_are_shipped_by_runtime_paths():
|
| 72 |
+
runtime_paths = set(sync_to_dev.RUNTIME_PATHS)
|
| 73 |
+
deps = _init_load_time_module_deps() | _route_load_time_module_deps()
|
| 74 |
+
# Sanity: the parse actually found the imports (guards against a refactor
|
| 75 |
+
# that renames _merge_node_group and silently makes this test vacuous).
|
| 76 |
+
assert "koolook_versioning" in deps and "k_ai_pipeline" in deps, (
|
| 77 |
+
f"__init__.py import parse looks broken — only found: {sorted(deps)}"
|
| 78 |
+
)
|
| 79 |
+
# A single-file module ships as "<dep>.py"; a package (e.g. forks) ships as
|
| 80 |
+
# its top-level dir entry.
|
| 81 |
+
missing = sorted(
|
| 82 |
+
dep
|
| 83 |
+
for dep in deps
|
| 84 |
+
if f"{dep}.py" not in runtime_paths and dep not in runtime_paths
|
| 85 |
+
)
|
| 86 |
+
assert not missing, (
|
| 87 |
+
"__init__.py imports these at load but dev-sync RUNTIME_PATHS does not "
|
| 88 |
+
f"ship them: {missing}. Add them to RUNTIME_PATHS in "
|
| 89 |
+
"scripts/sync_to_dev.py so a plain dev-sync can't 404 the loader."
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_dev_sync_ships_manager_visible_package_metadata():
|
| 94 |
+
"""The live dev install should report the same pack version as the source.
|
| 95 |
+
|
| 96 |
+
ComfyUI Manager reads package metadata from ``pyproject.toml``. If dev-sync
|
| 97 |
+
skips it, the code can be current while the UI still reports an older
|
| 98 |
+
installed version.
|
| 99 |
+
"""
|
| 100 |
+
assert "pyproject.toml" in sync_to_dev.RUNTIME_PATHS
|
ComfyUI/custom_nodes/koolook/tests/scripts/test_sync_to_dev_audio.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Safety coverage for scripts/sync_to_dev_audio.py."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import importlib.util
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "sync_to_dev_audio.py"
|
| 11 |
+
_spec = importlib.util.spec_from_file_location("sync_to_dev_audio", _SCRIPT)
|
| 12 |
+
assert _spec is not None and _spec.loader is not None
|
| 13 |
+
sync_to_dev_audio = importlib.util.module_from_spec(_spec)
|
| 14 |
+
_spec.loader.exec_module(sync_to_dev_audio)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _stale_path(root: Path) -> Path:
|
| 18 |
+
return root / "web" / "whatdreamscost_koolook_v1_3_2"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_remove_stale_paths_removes_directory(tmp_path: Path) -> None:
|
| 22 |
+
stale = _stale_path(tmp_path)
|
| 23 |
+
stale.mkdir(parents=True)
|
| 24 |
+
(stale / "ltx_director.js").write_text("old extension\n", encoding="utf-8")
|
| 25 |
+
|
| 26 |
+
removed = sync_to_dev_audio.remove_stale_paths(
|
| 27 |
+
tmp_path,
|
| 28 |
+
dry_run=False,
|
| 29 |
+
verbose=False,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
assert removed == 1
|
| 33 |
+
assert not stale.exists()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_remove_stale_paths_dry_run_preserves_directory(tmp_path: Path) -> None:
|
| 37 |
+
stale = _stale_path(tmp_path)
|
| 38 |
+
stale.mkdir(parents=True)
|
| 39 |
+
|
| 40 |
+
removed = sync_to_dev_audio.remove_stale_paths(
|
| 41 |
+
tmp_path,
|
| 42 |
+
dry_run=True,
|
| 43 |
+
verbose=False,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
assert removed == 1
|
| 47 |
+
assert stale.exists()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_remove_stale_paths_idempotent_when_absent(tmp_path: Path) -> None:
|
| 51 |
+
removed = sync_to_dev_audio.remove_stale_paths(
|
| 52 |
+
tmp_path,
|
| 53 |
+
dry_run=False,
|
| 54 |
+
verbose=False,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
assert removed == 0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_remove_stale_paths_unlinks_symlink_without_touching_target(tmp_path: Path) -> None:
|
| 61 |
+
current = tmp_path / "web" / "whatdreamscost_koolook"
|
| 62 |
+
current.mkdir(parents=True)
|
| 63 |
+
keep = current / "ltx_director.js"
|
| 64 |
+
keep.write_text("current extension\n", encoding="utf-8")
|
| 65 |
+
stale = _stale_path(tmp_path)
|
| 66 |
+
try:
|
| 67 |
+
stale.symlink_to(current, target_is_directory=True)
|
| 68 |
+
except OSError as exc:
|
| 69 |
+
pytest.skip(f"symlink creation unavailable: {exc}")
|
| 70 |
+
|
| 71 |
+
removed = sync_to_dev_audio.remove_stale_paths(
|
| 72 |
+
tmp_path,
|
| 73 |
+
dry_run=False,
|
| 74 |
+
verbose=False,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
assert removed == 1
|
| 78 |
+
assert not stale.exists()
|
| 79 |
+
assert keep.read_text(encoding="utf-8") == "current extension\n"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_target_is_repo_root_detects_source_repo() -> None:
|
| 83 |
+
assert sync_to_dev_audio.target_is_repo_root(sync_to_dev_audio._dev.REPO_ROOT)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_audio_paths_ship_init_load_gates() -> None:
|
| 87 |
+
"""dev-sync-audio ships ``__init__.py``, so it must also ship the modules
|
| 88 |
+
``__init__`` imports at load before any node group. Those gates are not
|
| 89 |
+
per-group-guarded: a missing ``koolook_install_guard`` makes the absolute
|
| 90 |
+
import fallback raise uncaught (plugin dead), and a missing
|
| 91 |
+
``koolook_versioning`` makes the context probe mislabel its ImportError as
|
| 92 |
+
a non-Comfy context and register nothing (#198 / #183).
|
| 93 |
+
"""
|
| 94 |
+
audio_paths = set(sync_to_dev_audio.AUDIO_PATHS)
|
| 95 |
+
required = {"__init__.py", "koolook_install_guard.py", "koolook_versioning.py"}
|
| 96 |
+
missing = sorted(required - audio_paths)
|
| 97 |
+
assert not missing, (
|
| 98 |
+
f"dev-sync-audio ships __init__.py but not its load-time gate(s): {missing}"
|
| 99 |
+
)
|
ComfyUI/custom_nodes/koolook/tests/scripts/test_validate_workflow.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for scripts/validate_workflow.py.
|
| 2 |
+
|
| 3 |
+
Anchored by PR #170 review: the validator must report problems rather than
|
| 4 |
+
raise on malformed-but-valid JSON. These tests pin that behavior plus a
|
| 5 |
+
sampling of the existing happy-path and consistency checks so future edits
|
| 6 |
+
cannot silently regress the safety guarantees.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
import importlib.util
|
| 10 |
+
import json
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
# Load the script directly — it lives under scripts/, not under a package.
|
| 16 |
+
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "validate_workflow.py"
|
| 17 |
+
_spec = importlib.util.spec_from_file_location("validate_workflow", _SCRIPT)
|
| 18 |
+
assert _spec is not None and _spec.loader is not None
|
| 19 |
+
_module = importlib.util.module_from_spec(_spec)
|
| 20 |
+
_spec.loader.exec_module(_module)
|
| 21 |
+
validate = _module.validate
|
| 22 |
+
main = _module.main
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _minimal_clean() -> dict:
|
| 26 |
+
"""Smallest workflow that should validate cleanly: two nodes, one link."""
|
| 27 |
+
return {
|
| 28 |
+
"last_node_id": 2,
|
| 29 |
+
"last_link_id": 1,
|
| 30 |
+
"nodes": [
|
| 31 |
+
{
|
| 32 |
+
"id": 1,
|
| 33 |
+
"type": "Src",
|
| 34 |
+
"inputs": [],
|
| 35 |
+
"outputs": [
|
| 36 |
+
{"name": "out", "type": "FOO", "links": [1]},
|
| 37 |
+
],
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"id": 2,
|
| 41 |
+
"type": "Dst",
|
| 42 |
+
"inputs": [
|
| 43 |
+
{"name": "in", "type": "FOO", "link": 1},
|
| 44 |
+
],
|
| 45 |
+
"outputs": [],
|
| 46 |
+
},
|
| 47 |
+
],
|
| 48 |
+
"links": [[1, 1, 0, 2, 0, "FOO"]],
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# --- Happy path -------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
def test_minimal_clean_workflow_has_no_problems() -> None:
|
| 55 |
+
assert validate(_minimal_clean()) == []
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --- PR #170 review repros: malformed-but-valid JSON must not crash --------
|
| 59 |
+
|
| 60 |
+
@pytest.mark.parametrize("data", [None, 42, 3.14, "string", [1, 2], (1, 2)])
|
| 61 |
+
def test_top_level_not_object_reports_does_not_crash(data) -> None:
|
| 62 |
+
problems = validate(data)
|
| 63 |
+
assert problems, "expected at least one problem"
|
| 64 |
+
assert "top-level JSON must be an object" in problems[0]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_node_entry_not_dict_reports_does_not_crash() -> None:
|
| 68 |
+
# Reviewer's repro 1: nodes contains an int.
|
| 69 |
+
data = {"nodes": [1], "links": [], "last_node_id": 0, "last_link_id": 0}
|
| 70 |
+
problems = validate(data)
|
| 71 |
+
assert any("nodes[0]" in p and "object" in p for p in problems), problems
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_nodes_field_not_list_reports() -> None:
|
| 75 |
+
data = {"nodes": {"oops": "dict not list"}, "links": []}
|
| 76 |
+
problems = validate(data)
|
| 77 |
+
assert any("'nodes' must be a list" in p for p in problems), problems
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_links_field_not_list_reports() -> None:
|
| 81 |
+
data = {"nodes": [], "links": "not-a-list"}
|
| 82 |
+
problems = validate(data)
|
| 83 |
+
assert any("'links' must be a list" in p for p in problems), problems
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_inputs_field_not_list_reports() -> None:
|
| 87 |
+
data = _minimal_clean()
|
| 88 |
+
data["nodes"][1]["inputs"] = {"oops": "dict not list"}
|
| 89 |
+
problems = validate(data)
|
| 90 |
+
assert any("'inputs' must be a list" in p for p in problems), problems
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_input_entry_not_dict_reports() -> None:
|
| 94 |
+
data = _minimal_clean()
|
| 95 |
+
data["nodes"][1]["inputs"] = ["string-not-dict"]
|
| 96 |
+
problems = validate(data)
|
| 97 |
+
assert any("inputs[0]" in p and "object" in p for p in problems), problems
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_output_entry_not_dict_reports() -> None:
|
| 101 |
+
data = _minimal_clean()
|
| 102 |
+
data["nodes"][0]["outputs"] = [99]
|
| 103 |
+
problems = validate(data)
|
| 104 |
+
assert any("outputs[0]" in p and "object" in p for p in problems), problems
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_malformed_link_not_list_reports() -> None:
|
| 108 |
+
data = _minimal_clean()
|
| 109 |
+
data["links"].append("not-a-list")
|
| 110 |
+
problems = validate(data)
|
| 111 |
+
assert any("links[1]" in p for p in problems), problems
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_malformed_link_wrong_length_reports() -> None:
|
| 115 |
+
data = _minimal_clean()
|
| 116 |
+
data["links"].append([2, 1, 0]) # too short
|
| 117 |
+
problems = validate(data)
|
| 118 |
+
assert any("links[1]" in p and "6 elements" in p for p in problems), problems
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# --- Existing consistency checks -------------------------------------------
|
| 122 |
+
|
| 123 |
+
def test_link_to_missing_src_node_reports() -> None:
|
| 124 |
+
data = _minimal_clean()
|
| 125 |
+
data["links"] = [[1, 99, 0, 2, 0, "FOO"]]
|
| 126 |
+
data["nodes"][0]["outputs"][0]["links"] = [] # not referenced from anywhere now
|
| 127 |
+
problems = validate(data)
|
| 128 |
+
assert any("src node 99 missing" in p for p in problems), problems
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_type_mismatch_on_slot_reports() -> None:
|
| 132 |
+
data = _minimal_clean()
|
| 133 |
+
data["links"] = [[1, 1, 0, 2, 0, "BAR"]] # link declares BAR, slots are FOO
|
| 134 |
+
problems = validate(data)
|
| 135 |
+
assert any("type mismatch" in p for p in problems), problems
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_wildcard_destination_accepts_concrete_link_type() -> None:
|
| 139 |
+
data = _minimal_clean()
|
| 140 |
+
data["nodes"][1]["inputs"][0]["type"] = "*"
|
| 141 |
+
assert validate(data) == []
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_duplicate_node_ids_report() -> None:
|
| 145 |
+
data = _minimal_clean()
|
| 146 |
+
data["nodes"][1]["id"] = 1 # collision with first node
|
| 147 |
+
problems = validate(data)
|
| 148 |
+
assert any("Duplicate node IDs" in p for p in problems), problems
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_endpoint_ref_desync_reports() -> None:
|
| 152 |
+
data = _minimal_clean()
|
| 153 |
+
data["nodes"][0]["outputs"][0]["links"] = [] # source side forgot link 1
|
| 154 |
+
problems = validate(data)
|
| 155 |
+
assert any("missing from node 1 output[0]" in p for p in problems), problems
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_last_node_id_too_low_reports() -> None:
|
| 159 |
+
data = _minimal_clean()
|
| 160 |
+
data["last_node_id"] = 0 # but max is 2
|
| 161 |
+
problems = validate(data)
|
| 162 |
+
assert any("last_node_id" in p for p in problems), problems
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# --- CLI smoke (covers main() exit codes) ----------------------------------
|
| 166 |
+
|
| 167 |
+
def test_cli_returns_zero_on_clean_workflow(tmp_path: Path) -> None:
|
| 168 |
+
p = tmp_path / "clean.json"
|
| 169 |
+
p.write_text(json.dumps(_minimal_clean()), encoding="utf-8")
|
| 170 |
+
assert main([str(p), "--quiet"]) == 0
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_cli_returns_one_on_problems(tmp_path: Path) -> None:
|
| 174 |
+
p = tmp_path / "broken.json"
|
| 175 |
+
p.write_text(json.dumps({"nodes": [1], "links": []}), encoding="utf-8")
|
| 176 |
+
assert main([str(p), "--quiet"]) == 1
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_cli_returns_two_on_missing_file(tmp_path: Path) -> None:
|
| 180 |
+
assert main([str(tmp_path / "does-not-exist.json"), "--quiet"]) == 2
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_cli_returns_two_on_invalid_json(tmp_path: Path) -> None:
|
| 184 |
+
p = tmp_path / "bad.json"
|
| 185 |
+
p.write_text("{not valid", encoding="utf-8")
|
| 186 |
+
assert main([str(p), "--quiet"]) == 2
|
ComfyUI/custom_nodes/koolook/tests/server/test_browse_list_children.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for ``_list_child_dirs`` / ``_list_child_files`` — the listing
|
| 2 |
+
helpers that power the folder picker (issue #137, mockup section 6).
|
| 3 |
+
|
| 4 |
+
Folders are listed by name, sorted case-insensitively, with ``_autosave``
|
| 5 |
+
subfolders hidden (those are koolook-internal recovery dirs, not user-
|
| 6 |
+
selectable library locations). Files are listed JSON-only, also sorted
|
| 7 |
+
case-insensitively. Both helpers swallow per-entry ``OSError`` so a
|
| 8 |
+
broken symlink or unreadable dotfile in the middle of the listing
|
| 9 |
+
doesn't sink the whole response.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from koolook_routes import _list_child_dirs, _list_child_files
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_list_child_dirs_returns_sorted_dir_entries(tmp_path: Path) -> None:
|
| 19 |
+
(tmp_path / "zebra").mkdir()
|
| 20 |
+
(tmp_path / "Alpha").mkdir()
|
| 21 |
+
(tmp_path / "mango").mkdir()
|
| 22 |
+
|
| 23 |
+
rows = _list_child_dirs(tmp_path)
|
| 24 |
+
|
| 25 |
+
assert [r["name"] for r in rows] == ["Alpha", "mango", "zebra"]
|
| 26 |
+
for row in rows:
|
| 27 |
+
assert set(row) == {"name", "path"}
|
| 28 |
+
assert row["path"].endswith(row["name"])
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_list_child_dirs_hides_autosave_subfolders(tmp_path: Path) -> None:
|
| 32 |
+
"""Per-preset autosave dirs live under the library, but the picker
|
| 33 |
+
must not let users pick them as a library location — they're
|
| 34 |
+
koolook-internal recovery state. ``_unsaved_autosave`` (no preset
|
| 35 |
+
name prefix) is also hidden by the same rule."""
|
| 36 |
+
(tmp_path / "user-pick").mkdir()
|
| 37 |
+
(tmp_path / "MyPreset_autosave").mkdir()
|
| 38 |
+
(tmp_path / "_unsaved_autosave").mkdir()
|
| 39 |
+
(tmp_path / "other-pick").mkdir()
|
| 40 |
+
|
| 41 |
+
rows = _list_child_dirs(tmp_path)
|
| 42 |
+
|
| 43 |
+
assert [r["name"] for r in rows] == ["other-pick", "user-pick"]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_list_child_dirs_ignores_regular_files(tmp_path: Path) -> None:
|
| 47 |
+
(tmp_path / "subdir").mkdir()
|
| 48 |
+
(tmp_path / "note.txt").write_text("ignored")
|
| 49 |
+
(tmp_path / "preset.json").write_text("{}")
|
| 50 |
+
|
| 51 |
+
rows = _list_child_dirs(tmp_path)
|
| 52 |
+
|
| 53 |
+
assert [r["name"] for r in rows] == ["subdir"]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_list_child_files_returns_only_json_sorted(tmp_path: Path) -> None:
|
| 57 |
+
"""Files-for-context affordance restricts to ``*.json`` because the
|
| 58 |
+
picker is exclusively used to choose a snapshot library folder;
|
| 59 |
+
surfacing non-JSON files would mislead the user."""
|
| 60 |
+
(tmp_path / "Koolook_v03.json").write_text("{}")
|
| 61 |
+
(tmp_path / "starter.json").write_text("{}")
|
| 62 |
+
(tmp_path / "readme.md").write_text("ignored")
|
| 63 |
+
(tmp_path / "notes.txt").write_text("ignored")
|
| 64 |
+
# Subdirs are not files — should be ignored even if named *.json.
|
| 65 |
+
(tmp_path / "looks_like_a_file.json").mkdir()
|
| 66 |
+
|
| 67 |
+
rows = _list_child_files(tmp_path)
|
| 68 |
+
|
| 69 |
+
assert [r["name"] for r in rows] == ["Koolook_v03.json", "starter.json"]
|
| 70 |
+
for row in rows:
|
| 71 |
+
assert set(row) == {"name"}, "files-for-context entries carry name only"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_list_child_files_returns_empty_for_no_json(tmp_path: Path) -> None:
|
| 75 |
+
(tmp_path / "readme.md").write_text("ignored")
|
| 76 |
+
(tmp_path / "subdir").mkdir()
|
| 77 |
+
|
| 78 |
+
assert _list_child_files(tmp_path) == []
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_list_child_files_is_case_insensitive_on_extension(tmp_path: Path) -> None:
|
| 82 |
+
"""Spelling of ``.json`` shouldn't matter on a case-insensitive FS;
|
| 83 |
+
surface upper-case copies too so a user-renamed ``PRESET.JSON``
|
| 84 |
+
doesn't disappear from the affordance."""
|
| 85 |
+
(tmp_path / "lower.json").write_text("{}")
|
| 86 |
+
(tmp_path / "UPPER.JSON").write_text("{}")
|
| 87 |
+
|
| 88 |
+
rows = _list_child_files(tmp_path)
|
| 89 |
+
|
| 90 |
+
assert sorted(r["name"] for r in rows) == ["UPPER.JSON", "lower.json"]
|
ComfyUI/custom_nodes/koolook/tests/server/test_listing_autosave_mtime.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the listing row-augment that surfaces the newest recovery
|
| 2 |
+
mtime per named preset.
|
| 3 |
+
|
| 4 |
+
The Load dialog uses the augmented mtime to decide whether to flag a row
|
| 5 |
+
as "newer auto-save available". Before issue #137 the augmentation only
|
| 6 |
+
considered ``periodic.json``; pre-load files were hidden in the Recovery
|
| 7 |
+
disclosure even when newer. After the fix the helper returns the max
|
| 8 |
+
mtime across ``periodic.json`` and every ``pre_load_*.json`` so the row
|
| 9 |
+
flag reflects the freshest recovery file regardless of which capture
|
| 10 |
+
mechanism wrote it.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from koolook_routes import _latest_autosave_mtime
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _touch(path: Path, mtime: float) -> None:
|
| 21 |
+
"""Create ``path`` with empty contents and pin its mtime."""
|
| 22 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
path.write_text("{}")
|
| 24 |
+
os.utime(path, (mtime, mtime))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_returns_periodic_mtime_when_only_periodic_is_newer(tmp_path: Path) -> None:
|
| 28 |
+
autosave_dir = tmp_path / "foo_autosave"
|
| 29 |
+
_touch(autosave_dir / "periodic.json", mtime=2000.0)
|
| 30 |
+
|
| 31 |
+
assert _latest_autosave_mtime(autosave_dir, named_mtime=1000.0) == 2000.0
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_returns_newest_pre_load_mtime_when_only_pre_loads_exist(tmp_path: Path) -> None:
|
| 35 |
+
"""When no ``periodic.json`` exists but ``pre_load_*.json`` files do, the
|
| 36 |
+
helper returns the mtime of the newest pre_load file (if newer than the
|
| 37 |
+
named save). Pre_loads are written on every Load and rotated up to 5
|
| 38 |
+
deep, so any of them may be the freshest recovery point."""
|
| 39 |
+
autosave_dir = tmp_path / "foo_autosave"
|
| 40 |
+
_touch(autosave_dir / "pre_load_2024-01-01T10-00-00.json", mtime=1500.0)
|
| 41 |
+
_touch(autosave_dir / "pre_load_2024-01-02T10-00-00.json", mtime=3000.0)
|
| 42 |
+
_touch(autosave_dir / "pre_load_2024-01-01T11-00-00.json", mtime=2000.0)
|
| 43 |
+
|
| 44 |
+
assert _latest_autosave_mtime(autosave_dir, named_mtime=1000.0) == 3000.0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_returns_max_when_both_periodic_and_pre_loads_exist(tmp_path: Path) -> None:
|
| 48 |
+
"""When both file kinds exist, the helper returns the newest mtime across
|
| 49 |
+
them regardless of which kind produced it. Issue #137's whole point:
|
| 50 |
+
the row flag must reflect the freshest recovery point even when a
|
| 51 |
+
pre_load is newer than the latest periodic snapshot.
|
| 52 |
+
"""
|
| 53 |
+
autosave_dir = tmp_path / "foo_autosave"
|
| 54 |
+
_touch(autosave_dir / "periodic.json", mtime=2000.0)
|
| 55 |
+
_touch(autosave_dir / "pre_load_2024-01-02T10-00-00.json", mtime=4000.0)
|
| 56 |
+
_touch(autosave_dir / "pre_load_2024-01-01T10-00-00.json", mtime=1800.0)
|
| 57 |
+
|
| 58 |
+
assert _latest_autosave_mtime(autosave_dir, named_mtime=1000.0) == 4000.0
|
| 59 |
+
|
| 60 |
+
# And the symmetric case: periodic wins when newer than every pre_load.
|
| 61 |
+
_touch(autosave_dir / "periodic.json", mtime=5000.0)
|
| 62 |
+
assert _latest_autosave_mtime(autosave_dir, named_mtime=1000.0) == 5000.0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_returns_none_when_no_autosave_file_is_newer_than_named(tmp_path: Path) -> None:
|
| 66 |
+
"""Strictly-newer rule: when every autosave file's mtime is ``<=`` the
|
| 67 |
+
named save's mtime, the helper returns ``None`` so the row stays in
|
| 68 |
+
its normal state (no "newer auto-save" affordance fires).
|
| 69 |
+
"""
|
| 70 |
+
autosave_dir = tmp_path / "foo_autosave"
|
| 71 |
+
_touch(autosave_dir / "periodic.json", mtime=900.0)
|
| 72 |
+
_touch(autosave_dir / "pre_load_2024-01-01T10-00-00.json", mtime=800.0)
|
| 73 |
+
_touch(autosave_dir / "pre_load_2024-01-02T10-00-00.json", mtime=1000.0)
|
| 74 |
+
|
| 75 |
+
assert _latest_autosave_mtime(autosave_dir, named_mtime=1000.0) is None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_returns_none_when_autosave_dir_is_missing(tmp_path: Path) -> None:
|
| 79 |
+
"""No autosave dir at all (the common case for freshly-created presets):
|
| 80 |
+
the helper returns ``None`` without raising. Powers the library-root
|
| 81 |
+
listing pass where most rows won't have a sibling autosave dir.
|
| 82 |
+
"""
|
| 83 |
+
missing = tmp_path / "foo_autosave"
|
| 84 |
+
assert _latest_autosave_mtime(missing, named_mtime=1000.0) is None
|
ComfyUI/custom_nodes/koolook/tests/server/test_published_setup_api.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
from copy import deepcopy
|
| 6 |
+
|
| 7 |
+
from aiohttp import web
|
| 8 |
+
from aiohttp.test_utils import make_mocked_request
|
| 9 |
+
|
| 10 |
+
import koolook_routes
|
| 11 |
+
from koolook_setup_runner import PublishedSetupRunner
|
| 12 |
+
from koolook_setups import FileSetupStorage, PublishedSetupRegistry, StaticSetupStorage
|
| 13 |
+
from tests.server.test_published_setup_registry import _valid_setup
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_catalog_routes_expose_list_and_detail_contracts() -> None:
|
| 17 |
+
async def exercise() -> None:
|
| 18 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
|
| 19 |
+
app = _app_with_registry(registry)
|
| 20 |
+
|
| 21 |
+
list_response = await _handle(app, "GET", "/koolook/api/setups")
|
| 22 |
+
detail_response = await _handle(app, "GET", "/koolook/api/setups/ltx-director-demo")
|
| 23 |
+
missing_response = await _handle(app, "GET", "/koolook/api/setups/missing")
|
| 24 |
+
|
| 25 |
+
assert list_response.status == 200
|
| 26 |
+
assert detail_response.status == 200
|
| 27 |
+
assert missing_response.status == 404
|
| 28 |
+
|
| 29 |
+
rows = _json_body(list_response)
|
| 30 |
+
detail = _json_body(detail_response)
|
| 31 |
+
assert rows[0]["id"] == "ltx-director-demo"
|
| 32 |
+
assert "visualGraph" not in rows[0]
|
| 33 |
+
assert "apiPrompt" not in rows[0]
|
| 34 |
+
assert detail["visualGraph"] == _valid_setup()["visualGraph"]
|
| 35 |
+
assert detail["inputContract"] == _valid_setup()["inputContract"]
|
| 36 |
+
|
| 37 |
+
asyncio.run(exercise())
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_setup_runner_simulator_routes_serve_html_and_js() -> None:
|
| 41 |
+
async def exercise() -> None:
|
| 42 |
+
app = _app_with_registry(PublishedSetupRegistry(StaticSetupStorage([])))
|
| 43 |
+
|
| 44 |
+
html_response = await _handle(app, "GET", "/koolook/setup_runner_simulator.html")
|
| 45 |
+
js_response = await _handle(app, "GET", "/koolook/setup_runner_simulator.js")
|
| 46 |
+
|
| 47 |
+
assert html_response.status == 200
|
| 48 |
+
assert "Koolook Setup Runner Simulator" in html_response.text
|
| 49 |
+
assert "./setup_runner_simulator.js" in html_response.text
|
| 50 |
+
assert js_response.status == 200
|
| 51 |
+
assert "listPublishedSetups" in js_response.text
|
| 52 |
+
|
| 53 |
+
asyncio.run(exercise())
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_version_route_exposes_installed_version_and_release_urls() -> None:
|
| 57 |
+
async def exercise() -> None:
|
| 58 |
+
app = _app_with_registry(PublishedSetupRegistry(StaticSetupStorage([])))
|
| 59 |
+
|
| 60 |
+
response = await _handle(app, "GET", "/koolook/api/version")
|
| 61 |
+
|
| 62 |
+
assert response.status == 200
|
| 63 |
+
body = _json_body(response)
|
| 64 |
+
assert body["version"] == "0.4.4"
|
| 65 |
+
assert body["releasesUrl"] == "https://github.com/malkuthro/ComfyUI-Koolook/releases"
|
| 66 |
+
assert body["latestReleaseApiUrl"].endswith("/malkuthro/ComfyUI-Koolook/releases/latest")
|
| 67 |
+
|
| 68 |
+
asyncio.run(exercise())
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_catalog_list_omits_invalid_setups() -> None:
|
| 72 |
+
async def exercise() -> None:
|
| 73 |
+
invalid = deepcopy(_valid_setup())
|
| 74 |
+
invalid["id"] = "broken"
|
| 75 |
+
invalid.pop("metadata")
|
| 76 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([invalid, _valid_setup()]))
|
| 77 |
+
app = _app_with_registry(registry)
|
| 78 |
+
|
| 79 |
+
response = await _handle(app, "GET", "/koolook/api/setups")
|
| 80 |
+
rows = _json_body(response)
|
| 81 |
+
|
| 82 |
+
assert response.status == 200
|
| 83 |
+
assert [row["id"] for row in rows] == ["ltx-director-demo"]
|
| 84 |
+
assert registry.diagnostics == ["broken: missing required field: metadata"]
|
| 85 |
+
|
| 86 |
+
asyncio.run(exercise())
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_publish_route_persists_setup_and_catalog_returns_it() -> None:
|
| 90 |
+
async def exercise() -> None:
|
| 91 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([]))
|
| 92 |
+
app = _app_with_registry(registry)
|
| 93 |
+
payload = {
|
| 94 |
+
"visualGraph": {
|
| 95 |
+
"nodes": [
|
| 96 |
+
{
|
| 97 |
+
"id": 12,
|
| 98 |
+
"type": "Text Multiline",
|
| 99 |
+
"inputs": [{"name": "text", "widget": {"name": "text"}}],
|
| 100 |
+
"widgets_values": ["published prompt"],
|
| 101 |
+
}
|
| 102 |
+
],
|
| 103 |
+
"links": [],
|
| 104 |
+
},
|
| 105 |
+
"metadata": {
|
| 106 |
+
"id": "published-from-sidebar",
|
| 107 |
+
"title": "Published From Sidebar",
|
| 108 |
+
"description": "Published through the API.",
|
| 109 |
+
"category": "Video",
|
| 110 |
+
"tags": ["publish"],
|
| 111 |
+
"previewImage": "",
|
| 112 |
+
},
|
| 113 |
+
"inputContract": {
|
| 114 |
+
"inputs": [
|
| 115 |
+
{
|
| 116 |
+
"key": "prompt",
|
| 117 |
+
"label": "Prompt",
|
| 118 |
+
"type": "text",
|
| 119 |
+
"required": True,
|
| 120 |
+
"target": {"node": "12", "input": "text"},
|
| 121 |
+
}
|
| 122 |
+
]
|
| 123 |
+
},
|
| 124 |
+
"outputContract": {"outputs": [{"key": "preview", "type": "image"}]},
|
| 125 |
+
"source": {"kind": "sidebar-workflow", "path": "Demos/Published From Sidebar"},
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
publish_response = await _handle_json(app, "POST", "/koolook/api/setups", payload)
|
| 129 |
+
detail_response = await _handle(app, "GET", "/koolook/api/setups/published-from-sidebar")
|
| 130 |
+
|
| 131 |
+
assert publish_response.status == 200
|
| 132 |
+
body = _json_body(publish_response)
|
| 133 |
+
assert body["ok"] is True
|
| 134 |
+
assert body["setup"]["id"] == "published-from-sidebar"
|
| 135 |
+
detail = _json_body(detail_response)
|
| 136 |
+
assert detail["source"]["path"] == "Demos/Published From Sidebar"
|
| 137 |
+
assert detail["apiPrompt"] == {
|
| 138 |
+
"12": {"class_type": "Text Multiline", "inputs": {"text": "published prompt"}}
|
| 139 |
+
}
|
| 140 |
+
assert detail["validation"] == {"status": "valid", "diagnostics": []}
|
| 141 |
+
|
| 142 |
+
asyncio.run(exercise())
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_publish_route_returns_storage_path(tmp_path) -> None:
|
| 146 |
+
"""The publish response surfaces where the setup was saved on disk so
|
| 147 |
+
the sidebar can show / copy / open the registry location (issue #227)."""
|
| 148 |
+
|
| 149 |
+
async def exercise() -> None:
|
| 150 |
+
storage_path = tmp_path / "koolook-published-setups" / "setups.json"
|
| 151 |
+
registry = PublishedSetupRegistry(FileSetupStorage(storage_path))
|
| 152 |
+
app = _app_with_registry(registry)
|
| 153 |
+
payload = {
|
| 154 |
+
"visualGraph": {
|
| 155 |
+
"nodes": [
|
| 156 |
+
{
|
| 157 |
+
"id": 12,
|
| 158 |
+
"type": "Text Multiline",
|
| 159 |
+
"inputs": [{"name": "text", "widget": {"name": "text"}}],
|
| 160 |
+
"widgets_values": ["published prompt"],
|
| 161 |
+
}
|
| 162 |
+
],
|
| 163 |
+
"links": [],
|
| 164 |
+
},
|
| 165 |
+
"metadata": {
|
| 166 |
+
"id": "published-with-path",
|
| 167 |
+
"title": "Published With Path",
|
| 168 |
+
"description": "Surfaces its storage path.",
|
| 169 |
+
"category": "Video",
|
| 170 |
+
"tags": ["publish"],
|
| 171 |
+
"previewImage": "",
|
| 172 |
+
},
|
| 173 |
+
"inputContract": {
|
| 174 |
+
"inputs": [
|
| 175 |
+
{
|
| 176 |
+
"key": "prompt",
|
| 177 |
+
"label": "Prompt",
|
| 178 |
+
"type": "text",
|
| 179 |
+
"required": True,
|
| 180 |
+
"target": {"node": "12", "input": "text"},
|
| 181 |
+
}
|
| 182 |
+
]
|
| 183 |
+
},
|
| 184 |
+
"outputContract": {"outputs": [{"key": "preview", "type": "image"}]},
|
| 185 |
+
"source": {"kind": "sidebar-workflow", "path": "Demos/Published With Path"},
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
publish_response = await _handle_json(app, "POST", "/koolook/api/setups", payload)
|
| 189 |
+
|
| 190 |
+
assert publish_response.status == 200
|
| 191 |
+
body = _json_body(publish_response)
|
| 192 |
+
assert body["ok"] is True
|
| 193 |
+
assert body["setup"]["id"] == "published-with-path"
|
| 194 |
+
assert body["storagePath"] == str(storage_path)
|
| 195 |
+
|
| 196 |
+
asyncio.run(exercise())
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_reveal_published_setup_folder_opens_storage_dir(tmp_path, monkeypatch) -> None:
|
| 200 |
+
"""Open folder on the success card reveals the published-setups
|
| 201 |
+
directory (not the snapshot library) in the OS file manager."""
|
| 202 |
+
|
| 203 |
+
async def exercise() -> None:
|
| 204 |
+
storage_path = tmp_path / "koolook-published-setups" / "setups.json"
|
| 205 |
+
storage_path.parent.mkdir(parents=True)
|
| 206 |
+
registry = PublishedSetupRegistry(FileSetupStorage(storage_path))
|
| 207 |
+
app = _app_with_registry(registry)
|
| 208 |
+
|
| 209 |
+
opened: list = []
|
| 210 |
+
monkeypatch.setattr(
|
| 211 |
+
koolook_routes.subprocess,
|
| 212 |
+
"Popen",
|
| 213 |
+
lambda args, *a, **k: opened.append(args) or object(),
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
response = await _handle(app, "POST", "/koolook/api/setups/reveal")
|
| 217 |
+
|
| 218 |
+
assert response.status == 200
|
| 219 |
+
assert _json_body(response) == {"ok": True, "path": str(storage_path.parent)}
|
| 220 |
+
assert len(opened) == 1
|
| 221 |
+
# Launcher binary is platform-specific; the revealed path is the dir.
|
| 222 |
+
assert opened[0][-1] == str(storage_path.parent)
|
| 223 |
+
|
| 224 |
+
asyncio.run(exercise())
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_reveal_published_setup_folder_missing_dir_returns_404(tmp_path, monkeypatch) -> None:
|
| 228 |
+
"""Reveal fails cleanly (no file-manager spawn) when nothing has been
|
| 229 |
+
published yet, so the storage directory does not exist on disk."""
|
| 230 |
+
|
| 231 |
+
async def exercise() -> None:
|
| 232 |
+
storage_path = tmp_path / "koolook-published-setups" / "setups.json"
|
| 233 |
+
registry = PublishedSetupRegistry(FileSetupStorage(storage_path))
|
| 234 |
+
app = _app_with_registry(registry)
|
| 235 |
+
|
| 236 |
+
opened: list = []
|
| 237 |
+
monkeypatch.setattr(
|
| 238 |
+
koolook_routes.subprocess,
|
| 239 |
+
"Popen",
|
| 240 |
+
lambda args, *a, **k: opened.append(args) or object(),
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
response = await _handle(app, "POST", "/koolook/api/setups/reveal")
|
| 244 |
+
|
| 245 |
+
assert response.status == 404
|
| 246 |
+
assert opened == []
|
| 247 |
+
|
| 248 |
+
asyncio.run(exercise())
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def test_publish_route_preserves_supplied_api_prompt() -> None:
|
| 252 |
+
async def exercise() -> None:
|
| 253 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([]))
|
| 254 |
+
app = _app_with_registry(registry)
|
| 255 |
+
payload = {
|
| 256 |
+
"visualGraph": {
|
| 257 |
+
"nodes": [
|
| 258 |
+
{
|
| 259 |
+
"id": 12,
|
| 260 |
+
"type": "Text Multiline",
|
| 261 |
+
"inputs": [{"name": "text", "widget": {"name": "text"}}],
|
| 262 |
+
"widgets_values": ["fallback prompt"],
|
| 263 |
+
}
|
| 264 |
+
],
|
| 265 |
+
"links": [],
|
| 266 |
+
},
|
| 267 |
+
"apiPrompt": {
|
| 268 |
+
"12": {"class_type": "Text Multiline", "inputs": {"text": "comfy api prompt"}}
|
| 269 |
+
},
|
| 270 |
+
"metadata": {
|
| 271 |
+
"id": "published-with-api-prompt",
|
| 272 |
+
"title": "Published With API Prompt",
|
| 273 |
+
"description": "Published with Comfy's API workflow.",
|
| 274 |
+
},
|
| 275 |
+
"inputContract": {
|
| 276 |
+
"inputs": [
|
| 277 |
+
{
|
| 278 |
+
"key": "prompt",
|
| 279 |
+
"type": "text",
|
| 280 |
+
"target": {"node": "12", "input": "text"},
|
| 281 |
+
}
|
| 282 |
+
]
|
| 283 |
+
},
|
| 284 |
+
"outputContract": {"outputs": [{"key": "preview", "type": "image"}]},
|
| 285 |
+
"source": {"kind": "sidebar-workflow", "path": "Demos/API Prompt"},
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
publish_response = await _handle_json(app, "POST", "/koolook/api/setups", payload)
|
| 289 |
+
detail_response = await _handle(app, "GET", "/koolook/api/setups/published-with-api-prompt")
|
| 290 |
+
|
| 291 |
+
assert publish_response.status == 200
|
| 292 |
+
detail = _json_body(detail_response)
|
| 293 |
+
assert detail["apiPrompt"] == payload["apiPrompt"]
|
| 294 |
+
|
| 295 |
+
asyncio.run(exercise())
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def test_publish_route_rejects_invalid_contract_with_clear_error() -> None:
|
| 299 |
+
async def exercise() -> None:
|
| 300 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([]))
|
| 301 |
+
app = _app_with_registry(registry)
|
| 302 |
+
|
| 303 |
+
response = await _handle_json(
|
| 304 |
+
app,
|
| 305 |
+
"POST",
|
| 306 |
+
"/koolook/api/setups",
|
| 307 |
+
{
|
| 308 |
+
"visualGraph": {
|
| 309 |
+
"nodes": [
|
| 310 |
+
{
|
| 311 |
+
"id": 12,
|
| 312 |
+
"type": "Text Multiline",
|
| 313 |
+
"inputs": [{"name": "text", "widget": {"name": "text"}}],
|
| 314 |
+
"widgets_values": ["bad prompt"],
|
| 315 |
+
}
|
| 316 |
+
],
|
| 317 |
+
"links": [],
|
| 318 |
+
},
|
| 319 |
+
"metadata": {"id": "bad", "title": "Bad", "description": "Bad"},
|
| 320 |
+
"inputContract": {
|
| 321 |
+
"inputs": [{"key": "prompt", "type": "text", "target": {"node": "99", "input": "text"}}]
|
| 322 |
+
},
|
| 323 |
+
"outputContract": {"outputs": [{"key": "preview", "type": "image"}]},
|
| 324 |
+
"source": {"kind": "sidebar-workflow", "path": "Demos/Bad"},
|
| 325 |
+
},
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
assert response.status == 400
|
| 329 |
+
assert "target.node not found" in _json_body(response)["errors"][0]
|
| 330 |
+
assert registry.listSetups() == []
|
| 331 |
+
|
| 332 |
+
asyncio.run(exercise())
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def test_publish_route_rejects_missing_metadata_with_clear_error() -> None:
|
| 336 |
+
async def exercise() -> None:
|
| 337 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([]))
|
| 338 |
+
app = _app_with_registry(registry)
|
| 339 |
+
|
| 340 |
+
response = await _handle_json(
|
| 341 |
+
app,
|
| 342 |
+
"POST",
|
| 343 |
+
"/koolook/api/setups",
|
| 344 |
+
{
|
| 345 |
+
"visualGraph": {"nodes": []},
|
| 346 |
+
"inputContract": {"inputs": []},
|
| 347 |
+
"outputContract": {"outputs": []},
|
| 348 |
+
"source": {"kind": "sidebar-workflow", "path": "Demos/Missing Metadata"},
|
| 349 |
+
},
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
assert response.status == 400
|
| 353 |
+
assert "metadata must be an object" in _json_body(response)["errors"][0]
|
| 354 |
+
assert registry.listSetups() == []
|
| 355 |
+
|
| 356 |
+
asyncio.run(exercise())
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def test_run_route_queues_callable_setup_and_returns_stable_run_id() -> None:
|
| 360 |
+
async def exercise() -> None:
|
| 361 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
|
| 362 |
+
comfy = _FakeComfyClient()
|
| 363 |
+
app = _app_with_registry(
|
| 364 |
+
registry,
|
| 365 |
+
setup_runner_factory=lambda: PublishedSetupRunner(registry, comfy),
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
response = await _handle_json(
|
| 369 |
+
app,
|
| 370 |
+
"POST",
|
| 371 |
+
"/koolook/api/setups/ltx-director-demo/run",
|
| 372 |
+
{"inputs": {"prompt": "external prompt"}},
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
assert response.status == 200
|
| 376 |
+
assert _json_body(response) == {
|
| 377 |
+
"ok": True,
|
| 378 |
+
"run": {
|
| 379 |
+
"runId": "run-000001",
|
| 380 |
+
"promptId": "comfy-prompt-1",
|
| 381 |
+
"status": "queued",
|
| 382 |
+
},
|
| 383 |
+
}
|
| 384 |
+
assert comfy.submitted_prompts == [
|
| 385 |
+
{"12": {"class_type": "Text Multiline", "inputs": {"text": "external prompt"}}}
|
| 386 |
+
]
|
| 387 |
+
|
| 388 |
+
asyncio.run(exercise())
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def test_run_status_route_reports_succeeded_outputs_from_comfy_history() -> None:
|
| 392 |
+
async def exercise() -> None:
|
| 393 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
|
| 394 |
+
comfy = _FakeComfyClient(
|
| 395 |
+
history={
|
| 396 |
+
"comfy-prompt-1": {
|
| 397 |
+
"status": {"completed": True, "status_str": "success"},
|
| 398 |
+
"outputs": {
|
| 399 |
+
"20": {
|
| 400 |
+
"videos": [
|
| 401 |
+
{
|
| 402 |
+
"filename": "demo.mp4",
|
| 403 |
+
"subfolder": "koolook",
|
| 404 |
+
"type": "output",
|
| 405 |
+
}
|
| 406 |
+
]
|
| 407 |
+
}
|
| 408 |
+
},
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
)
|
| 412 |
+
runner = PublishedSetupRunner(registry, comfy)
|
| 413 |
+
app = _app_with_registry(registry, setup_runner_factory=lambda: runner)
|
| 414 |
+
|
| 415 |
+
run_response = await _handle_json(
|
| 416 |
+
app,
|
| 417 |
+
"POST",
|
| 418 |
+
"/koolook/api/setups/ltx-director-demo/run",
|
| 419 |
+
{"inputs": {"prompt": "external prompt"}},
|
| 420 |
+
)
|
| 421 |
+
run_id = _json_body(run_response)["run"]["runId"]
|
| 422 |
+
status_response = await _handle(app, "GET", f"/koolook/api/runs/{run_id}")
|
| 423 |
+
|
| 424 |
+
assert status_response.status == 200
|
| 425 |
+
assert _json_body(status_response) == {
|
| 426 |
+
"ok": True,
|
| 427 |
+
"run": {
|
| 428 |
+
"runId": "run-000001",
|
| 429 |
+
"setupId": "ltx-director-demo",
|
| 430 |
+
"promptId": "comfy-prompt-1",
|
| 431 |
+
"status": "succeeded",
|
| 432 |
+
"comfyStatus": {"completed": True, "status_str": "success"},
|
| 433 |
+
"outputs": [
|
| 434 |
+
{
|
| 435 |
+
"key": "video",
|
| 436 |
+
"label": "Video",
|
| 437 |
+
"type": "video",
|
| 438 |
+
"items": [
|
| 439 |
+
{
|
| 440 |
+
"nodeId": "20",
|
| 441 |
+
"kind": "videos",
|
| 442 |
+
"filename": "demo.mp4",
|
| 443 |
+
"subfolder": "koolook",
|
| 444 |
+
"type": "output",
|
| 445 |
+
}
|
| 446 |
+
],
|
| 447 |
+
}
|
| 448 |
+
],
|
| 449 |
+
},
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
asyncio.run(exercise())
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def test_run_status_route_reports_failed_state_from_comfy_history() -> None:
|
| 456 |
+
async def exercise() -> None:
|
| 457 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
|
| 458 |
+
comfy = _FakeComfyClient(
|
| 459 |
+
history={
|
| 460 |
+
"comfy-prompt-1": {
|
| 461 |
+
"status": {"completed": True, "status_str": "error"},
|
| 462 |
+
"outputs": {},
|
| 463 |
+
}
|
| 464 |
+
}
|
| 465 |
+
)
|
| 466 |
+
runner = PublishedSetupRunner(registry, comfy)
|
| 467 |
+
app = _app_with_registry(registry, setup_runner_factory=lambda: runner)
|
| 468 |
+
|
| 469 |
+
run_response = await _handle_json(
|
| 470 |
+
app,
|
| 471 |
+
"POST",
|
| 472 |
+
"/koolook/api/setups/ltx-director-demo/run",
|
| 473 |
+
{"inputs": {"prompt": "external prompt"}},
|
| 474 |
+
)
|
| 475 |
+
run_id = _json_body(run_response)["run"]["runId"]
|
| 476 |
+
status_response = await _handle(app, "GET", f"/koolook/api/runs/{run_id}")
|
| 477 |
+
|
| 478 |
+
assert status_response.status == 200
|
| 479 |
+
assert _json_body(status_response)["run"]["status"] == "failed"
|
| 480 |
+
|
| 481 |
+
asyncio.run(exercise())
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def test_run_route_returns_clear_errors_for_invalid_inputs() -> None:
|
| 485 |
+
async def exercise() -> None:
|
| 486 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
|
| 487 |
+
app = _app_with_registry(
|
| 488 |
+
registry,
|
| 489 |
+
setup_runner_factory=lambda: PublishedSetupRunner(registry, _FakeComfyClient()),
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
response = await _handle_json(
|
| 493 |
+
app,
|
| 494 |
+
"POST",
|
| 495 |
+
"/koolook/api/setups/ltx-director-demo/run",
|
| 496 |
+
{"inputs": {"prompt": "ok", "seed": 123}},
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
assert response.status == 400
|
| 500 |
+
assert _json_body(response) == {
|
| 501 |
+
"ok": False,
|
| 502 |
+
"errors": ["input 'seed' is not declared by this setup"],
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
asyncio.run(exercise())
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def test_run_route_returns_clear_error_for_non_callable_setup() -> None:
|
| 509 |
+
async def exercise() -> None:
|
| 510 |
+
setup = _valid_setup()
|
| 511 |
+
setup["apiPrompt"] = None
|
| 512 |
+
setup["validation"] = {"status": "draft", "diagnostics": ["API prompt conversion pending."]}
|
| 513 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
|
| 514 |
+
app = _app_with_registry(
|
| 515 |
+
registry,
|
| 516 |
+
setup_runner_factory=lambda: PublishedSetupRunner(registry, _FakeComfyClient()),
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
response = await _handle_json(
|
| 520 |
+
app,
|
| 521 |
+
"POST",
|
| 522 |
+
"/koolook/api/setups/ltx-director-demo/run",
|
| 523 |
+
{"inputs": {"prompt": "ok"}},
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
assert response.status == 400
|
| 527 |
+
assert _json_body(response) == {
|
| 528 |
+
"ok": False,
|
| 529 |
+
"errors": ["Published setup 'ltx-director-demo' is not callable."],
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
asyncio.run(exercise())
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def test_run_route_returns_clear_error_for_comfy_queue_failure() -> None:
|
| 536 |
+
async def exercise() -> None:
|
| 537 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
|
| 538 |
+
app = _app_with_registry(
|
| 539 |
+
registry,
|
| 540 |
+
setup_runner_factory=lambda: PublishedSetupRunner(
|
| 541 |
+
registry,
|
| 542 |
+
_FakeComfyClient(queue_error=RuntimeError("server down")),
|
| 543 |
+
),
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
response = await _handle_json(
|
| 547 |
+
app,
|
| 548 |
+
"POST",
|
| 549 |
+
"/koolook/api/setups/ltx-director-demo/run",
|
| 550 |
+
{"inputs": {"prompt": "ok"}},
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
assert response.status == 502
|
| 554 |
+
assert _json_body(response) == {
|
| 555 |
+
"ok": False,
|
| 556 |
+
"errors": ["ComfyUI queue request failed: server down"],
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
asyncio.run(exercise())
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def test_run_route_returns_clear_error_for_missing_setup() -> None:
|
| 563 |
+
async def exercise() -> None:
|
| 564 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
|
| 565 |
+
app = _app_with_registry(
|
| 566 |
+
registry,
|
| 567 |
+
setup_runner_factory=lambda: PublishedSetupRunner(registry, _FakeComfyClient()),
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
response = await _handle_json(
|
| 571 |
+
app,
|
| 572 |
+
"POST",
|
| 573 |
+
"/koolook/api/setups/missing/run",
|
| 574 |
+
{"inputs": {"prompt": "ok"}},
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
assert response.status == 404
|
| 578 |
+
assert _json_body(response) == {
|
| 579 |
+
"ok": False,
|
| 580 |
+
"errors": ["Published setup 'missing' not found."],
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
asyncio.run(exercise())
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def test_run_status_route_returns_clear_error_for_unknown_run_id() -> None:
|
| 587 |
+
async def exercise() -> None:
|
| 588 |
+
registry = PublishedSetupRegistry(StaticSetupStorage([_valid_setup()]))
|
| 589 |
+
app = _app_with_registry(
|
| 590 |
+
registry,
|
| 591 |
+
setup_runner_factory=lambda: PublishedSetupRunner(registry, _FakeComfyClient()),
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
response = await _handle(app, "GET", "/koolook/api/runs/run-missing")
|
| 595 |
+
|
| 596 |
+
assert response.status == 404
|
| 597 |
+
assert _json_body(response) == {
|
| 598 |
+
"ok": False,
|
| 599 |
+
"errors": ["Koolook run 'run-missing' not found."],
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
asyncio.run(exercise())
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
class _FakeComfyClient:
|
| 606 |
+
def __init__(self, *, history=None, queue=None, queue_error=None) -> None:
|
| 607 |
+
self.submitted_prompts: list[dict] = []
|
| 608 |
+
self.history = history or {}
|
| 609 |
+
self.queue = queue or {"queue_running": [], "queue_pending": []}
|
| 610 |
+
self.queue_error = queue_error
|
| 611 |
+
|
| 612 |
+
async def queue_prompt(self, prompt: dict) -> dict:
|
| 613 |
+
if self.queue_error is not None:
|
| 614 |
+
raise self.queue_error
|
| 615 |
+
self.submitted_prompts.append(prompt)
|
| 616 |
+
return {"prompt_id": "comfy-prompt-1"}
|
| 617 |
+
|
| 618 |
+
async def get_history(self, prompt_id: str) -> dict:
|
| 619 |
+
return {prompt_id: self.history[prompt_id]} if prompt_id in self.history else {}
|
| 620 |
+
|
| 621 |
+
async def get_queue(self) -> dict:
|
| 622 |
+
return self.queue
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def _app_with_registry(
|
| 626 |
+
registry: PublishedSetupRegistry,
|
| 627 |
+
*,
|
| 628 |
+
setup_runner_factory=None,
|
| 629 |
+
) -> web.Application:
|
| 630 |
+
routes = web.RouteTableDef()
|
| 631 |
+
koolook_routes.register_routes(
|
| 632 |
+
routes,
|
| 633 |
+
setup_registry_factory=lambda: registry,
|
| 634 |
+
setup_runner_factory=setup_runner_factory,
|
| 635 |
+
)
|
| 636 |
+
app = web.Application()
|
| 637 |
+
app.add_routes(routes)
|
| 638 |
+
return app
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
async def _handle(app: web.Application, method: str, path: str) -> web.Response:
|
| 642 |
+
request = make_mocked_request(method, path, app=app)
|
| 643 |
+
try:
|
| 644 |
+
return await app._handle(request)
|
| 645 |
+
except web.HTTPException as exc:
|
| 646 |
+
return exc
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
async def _handle_json(app: web.Application, method: str, path: str, payload) -> web.Response:
|
| 650 |
+
request = make_mocked_request(
|
| 651 |
+
method,
|
| 652 |
+
path,
|
| 653 |
+
app=app,
|
| 654 |
+
headers={"Content-Type": "application/json"},
|
| 655 |
+
)
|
| 656 |
+
request._read_bytes = json.dumps(payload).encode("utf-8")
|
| 657 |
+
try:
|
| 658 |
+
return await app._handle(request)
|
| 659 |
+
except web.HTTPException as exc:
|
| 660 |
+
return exc
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
def _json_body(response: web.Response):
|
| 664 |
+
return json.loads(response.text or "")
|