File size: 19,595 Bytes
0c85e62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
# SPDX-License-Identifier: GPL-3.0-or-later
#
# ComfyUI-Koolook - loop status utilities
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).

"""Loop-body status pass-through nodes."""

from __future__ import annotations

import copy
import json
import logging
from pathlib import Path
import re
import threading
import traceback
import urllib.error
import urllib.parse
import urllib.request


LOGGER = logging.getLogger(__name__)
MAX_AUTO_QUEUE_DEPTH = 1000
DEFAULT_SERVER_URL = "http://127.0.0.1:8188"
_ACTIVE_QUEUE_KEYS: set[str] = set()
_ACTIVE_QUEUE_KEYS_LOCK = threading.Lock()


class AnyType(str):
    """ComfyUI wildcard type that compares as compatible with any socket."""

    def __ne__(self, _other):
        return False


ANY_TYPE = AnyType("*")


def _format_write_path(filepath: str, frame: int) -> str:
    text = str(filepath or "").strip()
    return re.sub(r"%0?(\d*)d", lambda m: f"{frame:0{m.group(1) or '0'}d}", text)


def build_status(label: str, index: int, total: int, filepath: str = "") -> str:
    total = max(1, int(total))
    frame = int(index)
    position = max(1, min(total, frame + 1))
    label = str(label or "loop").strip() or "loop"
    path = _format_write_path(filepath, frame)
    if path:
        return f"{label}: {position}/{total} frame {frame} -> {path}"
    return f"{label}: {position}/{total} frame {frame}"


def infer_index_node_id(prompt: dict | None, unique_id) -> str:
    if not isinstance(prompt, dict) or unique_id is None:
        return ""
    node = prompt.get(str(unique_id))
    if not isinstance(node, dict):
        return ""
    inputs = node.get("inputs")
    if not isinstance(inputs, dict):
        return ""
    link = inputs.get("index")
    if isinstance(link, (list, tuple)) and link:
        return str(link[0])
    return ""


_infer_index_node_id = infer_index_node_id


def _prompt_has_node(prompt: dict | None, node_id: str) -> bool:
    return isinstance(prompt, dict) and isinstance(prompt.get(str(node_id)), dict)


def _describe_prompt_node(prompt: dict | None, node_id: str) -> str:
    if not isinstance(prompt, dict):
        return f"node {node_id}"
    node = prompt.get(str(node_id))
    if not isinstance(node, dict):
        return f"node {node_id}"
    class_type = str(node.get("class_type") or node.get("type") or "").strip()
    title = str(node.get("_meta", {}).get("title") or node.get("title") or "").strip()
    if title and title != class_type:
        prefix = f"{title} ({class_type})" if class_type else title
    else:
        prefix = class_type
    return f"{prefix} node {node_id}" if prefix else f"node {node_id}"


def resolve_index_node_id(
    prompt: dict | None,
    unique_id,
    configured_index_node_id: str,
    fallback_id: str = "",
) -> tuple[str, str]:
    """Pick the frame-index node to advance and a human note about the choice.

    The connected ``index`` input is the authoritative, deterministic source. An
    explicit ``configured_index_node_id`` that exists in the prompt is honored as
    a power-user override; otherwise the node feeding the connected ``index``
    input wins, so a stale or mis-shifted id self-heals. ``fallback_id`` (e.g. a
    numeric label recovered from a shifted save) is a last resort, used only when
    nothing else resolves, so it never overrides real wiring. Returns
    ``("", "")`` when nothing resolves so the caller can raise synchronously with
    an actionable message.
    """
    configured = str(configured_index_node_id or "").strip()
    inferred = infer_index_node_id(prompt, unique_id)
    if configured and _prompt_has_node(prompt, configured):
        return configured, f"using configured {_describe_prompt_node(prompt, configured)}"
    if inferred:
        if configured and configured != inferred:
            return (
                inferred,
                "configured index node "
                f"{configured!r} is not in this prompt; using connected "
                f"{_describe_prompt_node(prompt, inferred)}",
            )
        return inferred, f"using connected {_describe_prompt_node(prompt, inferred)}"
    if configured:
        return configured, f"using configured node {configured}"
    fallback = str(fallback_id or "").strip()
    if fallback:
        return fallback, f"using recovered node {fallback}"
    return "", ""


def _as_bool(value) -> bool:
    """Coerce saved widget values (incl. string booleans) to ``bool``.

    ComfyUI can persist a boolean widget as the string ``"true"``/``"false"``;
    ``bool("false")`` is truthy, so a naive cast would auto-queue when the user
    saved the toggle off.
    """
    if isinstance(value, str):
        return value.strip().lower() in {"1", "true", "yes", "on"}
    return bool(value)


def _get_json(url: str, timeout: float = 10) -> dict:
    _validate_http_url(url)
    with urllib.request.urlopen(url, timeout=timeout) as response:  # nosec B310
        body = response.read().decode("utf-8", errors="replace")
    return json.loads(body) if body else {}


def _validate_http_url(url: str) -> None:
    parsed = urllib.parse.urlsplit(str(url or ""))
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise RuntimeError(f"Only http(s) ComfyUI server URLs are allowed: {url!r}")


def _compose_server_url(host: str | None, port) -> str:
    """Join a detected host/port into a connectable ``http://`` URL.

    ComfyUI's bare ``--listen`` binds to all IPv4 and IPv6 interfaces as a
    comma-joined host value, so the host is split and any bind-all member means
    we connect over loopback. IPv6 literals are bracketed so ``urllib`` can parse
    the ``host:port`` netloc (``::1`` -> ``http://[::1]:port``).
    """
    members = [member.strip() for member in str(host or "").split(",") if member.strip()]
    bind_all = {"0.0.0.0", "::", "*"}  # nosec B104
    if any(member in bind_all for member in members):
        host = "127.0.0.1"
    else:
        host = members[0] if members else "127.0.0.1"
    if ":" in host and not host.startswith("["):
        host = f"[{host}]"
    return f"http://{host}:{port}"


def _detect_local_server_url() -> str | None:
    """Best-effort URL of the ComfyUI server hosting this node.

    The node default points at ComfyUI's default port (8188), but installs
    launched with ``--port`` (or ``--listen``) bind elsewhere — probing the
    stale default is actively refused and aborts the loop. Prefer the address
    the running server actually bound to. Returns ``None`` when neither the
    parsed CLI args nor the running ``PromptServer`` can be inspected (e.g. a
    headless unit test), so the caller can fall back to the literal default.
    """
    port = None
    host = None
    try:
        from comfy.cli_args import args  # type: ignore[import-not-found]

        port = getattr(args, "port", None)
        host = getattr(args, "listen", None)
    except Exception:  # pragma: no cover - depends on ComfyUI runtime
        LOGGER.debug("comfy.cli_args server detection failed", exc_info=True)
    if not port:
        try:
            from server import PromptServer  # type: ignore[import-not-found]

            instance = PromptServer.instance
            port = port or getattr(instance, "port", None)
            host = host or getattr(instance, "address", None)
        except Exception:  # pragma: no cover - depends on ComfyUI runtime
            LOGGER.debug("PromptServer server detection failed", exc_info=True)
    if not port:
        return None
    return _compose_server_url(host, port)


def _resolve_server_url(server_url: str) -> str:
    """Return the URL to queue against, auto-detecting for the ``auto`` default.

    ``auto`` (the widget default), an empty value, or the legacy baked-in
    ``DEFAULT_SERVER_URL`` all mean "detect the running server" — replaced with
    the server's real address when it can be detected, falling back to the
    default otherwise. Any other value is an explicit URL used verbatim.
    """
    server_url = str(server_url or "").strip()
    if server_url and server_url.lower() != "auto" and server_url != DEFAULT_SERVER_URL:
        return server_url
    detected = _detect_local_server_url()
    if detected and detected != server_url:
        print(f"[Koolook Loop Status] resolved server_url to {detected}")
    return detected or DEFAULT_SERVER_URL


def _probe_server(server_url: str) -> None:
    _get_json(f"{server_url.rstrip('/')}/system_stats", timeout=10)


def _post_prompt(server_url: str, prompt: dict) -> dict:
    _validate_http_url(server_url)
    data = json.dumps({"prompt": prompt}).encode("utf-8")
    req = urllib.request.Request(
        f"{server_url.rstrip('/')}/prompt",
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as response:  # nosec B310
        body = response.read().decode("utf-8", errors="replace")
    payload = json.loads(body) if body else {}
    if payload.get("error") or not payload.get("prompt_id"):
        raise RuntimeError(f"ComfyUI rejected child prompt: {payload}")
    print(f"[Koolook Loop Status] queued next prompt: {body}")
    return payload


def _abort_marker_path(filepath: str, frame: int) -> Path:
    expected = Path(_format_write_path(filepath, frame))
    parent = expected.parent if str(expected.parent) else Path.cwd()
    return parent / f"_loop_aborted_at_frame_{frame}.txt"


def _write_abort_marker(filepath: str, frame: int, exc: BaseException) -> None:
    marker = _abort_marker_path(filepath, frame)
    marker.parent.mkdir(parents=True, exist_ok=True)
    marker.write_text(
        "Koolook Loop Status failed to queue the next prompt.\n\n"
        f"Frame: {frame}\n"
        f"Error: {exc}\n\n"
        f"{traceback.format_exc()}",
        encoding="utf-8",
    )
    print(f"[Koolook Loop Status] wrote abort marker: {marker}")


def _queue_next_prompt(
    *,
    prompt: dict,
    index_node_id: str,
    next_index: int,
    server_url: str,
    queue_key: str,
    filepath: str,
    remaining_auto_queue_depth: int,
) -> None:
    try:
        child = copy.deepcopy(prompt)
        node = child.get(str(index_node_id))
        if not isinstance(node, dict):
            raise RuntimeError(f"index node id {index_node_id!r} is not in prompt")
        inputs = node.setdefault("inputs", {})
        inputs["value"] = int(next_index)
        status_node = child.get(str(queue_key.split(":", 1)[0]))
        if isinstance(status_node, dict):
            status_inputs = status_node.setdefault("inputs", {})
            status_inputs["remaining_auto_queue_depth"] = int(remaining_auto_queue_depth)
        _post_prompt(server_url, child)
    except Exception as exc:
        LOGGER.exception("Koolook Loop Status failed to queue next prompt")
        print(f"[Koolook Loop Status] failed to queue next prompt: {exc}")
        _write_abort_marker(filepath, next_index, exc)
    finally:
        with _ACTIVE_QUEUE_KEYS_LOCK:
            _ACTIVE_QUEUE_KEYS.discard(queue_key)


class KoolookLoopStatus:
    """Print per-iteration loop progress and pass the value through."""

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "value": (
                    ANY_TYPE,
                    {
                        "tooltip": "Payload to pass through unchanged while this node reports loop progress.",
                    },
                ),
                "index": (
                    "INT",
                    {
                        "default": 0,
                        "min": 0,
                        "max": 999999,
                        "tooltip": "Current zero-based frame index. Wire this from the easy int node that drives the loop.",
                    },
                ),
                "total": (
                    "INT",
                    {
                        "default": 1,
                        "min": 1,
                        "max": 100000,
                        "tooltip": "Total frames/prompts in this loop. Auto-queue stops when index + 1 reaches total.",
                    },
                ),
            },
            "optional": {
                "filepath": (
                    "STRING",
                    {
                        "default": "",
                        "multiline": True,
                        "tooltip": "Optional sequence path for the printed status line. %04d-style frame tokens are previewed with the current index.",
                    },
                ),
                "label": (
                    "STRING",
                    {
                        "default": "loop",
                        "multiline": False,
                        "tooltip": "Short label printed before progress, for example EXR_SAFE or plate_pass.",
                    },
                ),
                "auto_queue_next": (
                    "BOOLEAN",
                    {
                        "default": False,
                        "tooltip": "Queue the next prompt automatically after this frame, advancing the connected index node.",
                    },
                ),
                "index_node_id": (
                    "STRING",
                    {
                        "default": "",
                        "multiline": False,
                        "tooltip": "Advanced override for the frame-index node id. Leave blank; the connected index input is normally detected automatically.",
                    },
                ),
                "server_url": (
                    "STRING",
                    {
                        "default": "auto",
                        "multiline": False,
                        "tooltip": "ComfyUI server used for auto-queue. Leave auto to detect the running server and port.",
                    },
                ),
                "max_auto_queue_depth": (
                    "INT",
                    {
                        "default": 100,
                        "min": 1,
                        "max": MAX_AUTO_QUEUE_DEPTH,
                        "tooltip": "Safety cap for child prompts this run may chain, preventing accidental runaway loops.",
                    },
                ),
                "remaining_auto_queue_depth": (
                    "INT",
                    {
                        "default": -1,
                        "min": -1,
                        "max": MAX_AUTO_QUEUE_DEPTH,
                        "tooltip": "Internal countdown carried into child prompts. Leave at -1 in normal canvas use.",
                    },
                ),
            },
            "hidden": {
                "prompt": "PROMPT",
                "unique_id": "UNIQUE_ID",
            },
        }

    RETURN_TYPES = (ANY_TYPE, "STRING")
    RETURN_NAMES = ("value", "status")
    FUNCTION = "report"
    CATEGORY = "Koolook/Loop"

    def report(
        self,
        value,
        index,
        total,
        filepath="",
        label="loop",
        auto_queue_next=False,
        index_node_id="",
        server_url="auto",
        max_auto_queue_depth=100,
        remaining_auto_queue_depth=-1,
        prompt=None,
        unique_id=None,
    ):
        frame = int(index)
        total = max(1, int(total))
        next_index = frame + 1
        index_node_id = str(index_node_id or "").strip()
        label = str(label or "").strip()
        numeric_label_id = ""
        if not index_node_id and label.isdigit():
            numeric_label_id = label
            label = "EXR_SAFE"
            print(
                "[Koolook Loop Status] numeric label looks like a node id; using "
                "EXR_SAFE as the label and keeping the number only as a fallback id"
            )
        # The connected `index` input is the authoritative frame-index source: an
        # explicit index_node_id that exists wins as a power-user override, else
        # the connected node is used (self-healing a stale/mis-shifted id). A
        # numeric label is a last-resort fallback that never overrides the wiring.
        # `index_note` records which node was used.
        index_node_id, index_note = resolve_index_node_id(
            prompt, unique_id, index_node_id, fallback_id=numeric_label_id
        )
        max_depth = max(1, min(int(max_auto_queue_depth), MAX_AUTO_QUEUE_DEPTH))
        remaining_depth = int(remaining_auto_queue_depth)
        if remaining_depth < 0:
            remaining_depth = max_depth
        should_queue = _as_bool(auto_queue_next) and next_index < total
        if should_queue:
            if total - frame - 1 > max_depth:
                raise RuntimeError(
                    f"Refusing to auto-queue {total - frame - 1} remaining prompts; "
                    f"max_auto_queue_depth is {max_depth}."
                )
            if remaining_depth <= 0:
                raise RuntimeError("Auto-queue depth exhausted before loop completed.")
            if not isinstance(prompt, dict):
                raise RuntimeError("Koolook Loop Status needs hidden PROMPT data.")
            if unique_id is None:
                raise RuntimeError("Koolook Loop Status needs hidden UNIQUE_ID data.")
            if not index_node_id:
                raise RuntimeError("Set index_node_id to the easy int frame index node.")
            if index_node_id not in prompt:
                raise RuntimeError(
                    f"index_node_id {index_node_id!r} is not a node in this workflow. "
                    "Connect the loop status node's index input to your easy int "
                    "frame-index node, or set index_node_id to that node's id."
                )
            server_url = _resolve_server_url(server_url)
            if not server_url:
                raise RuntimeError("Set server_url to the running ComfyUI server.")
            try:
                _probe_server(server_url)
            except (OSError, urllib.error.URLError, TimeoutError) as exc:
                raise RuntimeError(f"ComfyUI server is not reachable: {server_url}") from exc
        status = build_status(label or "loop", index, total, filepath)
        print(f"[Koolook Loop Status] {status}")
        if should_queue and index_note:
            print(f"[Koolook Loop Status] {index_note}")
        if should_queue:
            queue_key = f"{unique_id or 'loop-status'}:{frame}->{next_index}"
            queued = False
            with _ACTIVE_QUEUE_KEYS_LOCK:
                if queue_key not in _ACTIVE_QUEUE_KEYS:
                    _ACTIVE_QUEUE_KEYS.add(queue_key)
                    queued = True
            if queued:
                thread = threading.Thread(
                    target=_queue_next_prompt,
                    kwargs={
                        "prompt": prompt,
                        "index_node_id": index_node_id,
                        "next_index": next_index,
                        "server_url": server_url,
                        "queue_key": queue_key,
                        "filepath": str(filepath or ""),
                        "remaining_auto_queue_depth": remaining_depth - 1,
                    },
                    daemon=True,
                )
                thread.start()
                print(f"[Koolook Loop Status] queued next index {next_index}/{total - 1}")
        return (value, status)


NODE_CLASS_MAPPINGS = {
    "Koolook_LoopStatus": KoolookLoopStatus,
}

NODE_DISPLAY_NAME_MAPPINGS = {
    "Koolook_LoopStatus": "Koolook Loop Status",
}