vidfom commited on
Commit
0c85e62
·
verified ·
1 Parent(s): e4ab0d4

Upload folder using huggingface_hub (part 8)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +18 -0
  2. ComfyUI/custom_nodes/koolook/k_easy_wan22_prompt.py +120 -0
  3. ComfyUI/custom_nodes/koolook/k_loop_status.py +502 -0
  4. ComfyUI/custom_nodes/koolook/k_publish_contract.py +219 -0
  5. ComfyUI/custom_nodes/koolook/k_video_combine.py +907 -0
  6. ComfyUI/custom_nodes/koolook/k_video_load.py +349 -0
  7. ComfyUI/custom_nodes/koolook/koolook_install_guard.py +146 -0
  8. ComfyUI/custom_nodes/koolook/koolook_routes.py +1161 -0
  9. ComfyUI/custom_nodes/koolook/koolook_setup_runner.py +1010 -0
  10. ComfyUI/custom_nodes/koolook/koolook_setups.py +2032 -0
  11. ComfyUI/custom_nodes/koolook/koolook_versioning.py +139 -0
  12. ComfyUI/custom_nodes/koolook/pyproject.toml +77 -0
  13. ComfyUI/custom_nodes/koolook/scripts/bootstrap_test_env.ps1 +80 -0
  14. ComfyUI/custom_nodes/koolook/scripts/bootstrap_test_env.sh +102 -0
  15. ComfyUI/custom_nodes/koolook/scripts/loop_audio.config.json +60 -0
  16. ComfyUI/custom_nodes/koolook/scripts/loop_audio.py +1630 -0
  17. ComfyUI/custom_nodes/koolook/scripts/make_card.py +801 -0
  18. ComfyUI/custom_nodes/koolook/scripts/make_card_audio.py +831 -0
  19. ComfyUI/custom_nodes/koolook/scripts/make_silent_wav.py +98 -0
  20. ComfyUI/custom_nodes/koolook/scripts/run_loop_demo_api_test.py +227 -0
  21. ComfyUI/custom_nodes/koolook/scripts/sync_to_dev.py +370 -0
  22. ComfyUI/custom_nodes/koolook/scripts/sync_to_dev_audio.py +235 -0
  23. ComfyUI/custom_nodes/koolook/scripts/validate_workflow.py +298 -0
  24. ComfyUI/custom_nodes/koolook/scripts/watch_cards.py +95 -0
  25. ComfyUI/custom_nodes/koolook/tests/forks/__init__.py +0 -0
  26. ComfyUI/custom_nodes/koolook/tests/forks/test_whatdreamscost_audio_transcript.py +167 -0
  27. ComfyUI/custom_nodes/koolook/tests/forks/test_whatdreamscost_v1_3_2_relay_overrides.py +216 -0
  28. ComfyUI/custom_nodes/koolook/tests/forks/test_whatdreamscost_v1_3_9_relay_overrides.py +236 -0
  29. ComfyUI/custom_nodes/koolook/tests/js/test_ai_pipeline_preview.mjs +216 -0
  30. ComfyUI/custom_nodes/koolook/tests/js/test_draft_guard.mjs +373 -0
  31. ComfyUI/custom_nodes/koolook/tests/js/test_update_check.mjs +89 -0
  32. ComfyUI/custom_nodes/koolook/tests/nodes/__init__.py +0 -0
  33. ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_ai_pipeline.py +773 -0
  34. ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_image_batch.py +575 -0
  35. ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_utility.py +54 -0
  36. ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_video_combine.py +594 -0
  37. ComfyUI/custom_nodes/koolook/tests/nodes/test_easy_video_load.py +372 -0
  38. ComfyUI/custom_nodes/koolook/tests/nodes/test_koolook_versioning.py +67 -0
  39. ComfyUI/custom_nodes/koolook/tests/nodes/test_loop_status.py +512 -0
  40. ComfyUI/custom_nodes/koolook/tests/nodes/test_next_version.py +104 -0
  41. ComfyUI/custom_nodes/koolook/tests/nodes/test_publish_contract.py +93 -0
  42. ComfyUI/custom_nodes/koolook/tests/scripts/__init__.py +0 -0
  43. ComfyUI/custom_nodes/koolook/tests/scripts/test_bootstrap_constraints.py +108 -0
  44. ComfyUI/custom_nodes/koolook/tests/scripts/test_loop_audio.py +1069 -0
  45. ComfyUI/custom_nodes/koolook/tests/scripts/test_sync_to_dev.py +100 -0
  46. ComfyUI/custom_nodes/koolook/tests/scripts/test_sync_to_dev_audio.py +99 -0
  47. ComfyUI/custom_nodes/koolook/tests/scripts/test_validate_workflow.py +186 -0
  48. ComfyUI/custom_nodes/koolook/tests/server/test_browse_list_children.py +90 -0
  49. ComfyUI/custom_nodes/koolook/tests/server/test_listing_autosave_mtime.py +84 -0
  50. 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 "")