File size: 5,469 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
# SPDX-License-Identifier: GPL-3.0-or-later
#
# ComfyUI-Koolook — shared version-token helpers.
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
#
# This file is part of ComfyUI-Koolook, licensed under GPL-3.0-or-later.
# See the LICENSE file at the repo root for the full text.
"""Single source of truth for Koolook's ``vNNN`` output-versioning convention.

Both ``EasyAIPipeline`` and ``Easy_VideoCombine`` stamp a version token into
their output paths/filenames. Centralising the rule here keeps the token
identical across every node so a single "global version" source (a wired
STRING from one interface node) propagates consistently.

Two entry points:

- :func:`normalize_version_token` — clean a wired/typed STRING into a safe
  single filename component (or ``""``).
- :func:`resolve_version_token` — apply the full precedence rule used by
  nodes that also carry a legacy INT ``version`` widget.
"""
from __future__ import annotations

import os
import re

# Same frontend-quirk sentinels the node modules already defend against: an
# untouched STRING widget can arrive at the backend as the literal string
# "undefined" / "null" / "None" instead of "".
_SENTINEL_STRINGS = ("undefined", "null", "none")

# Typed into a version field to request filesystem auto-detection of the next
# free version instead of a literal token. Nodes detect this and call
# ``next_version_token`` with their own output directory + name.
_AUTO_VERSION_TOKENS = ("auto", "next")


def is_auto_version(value) -> bool:
    """True when a version field requests auto-detection (``auto`` / ``next``)."""
    return normalize_version_token(value).lower() in _AUTO_VERSION_TOKENS


def normalize_version_token(value) -> str:
    """Clean a wired/typed version string into a safe single token.

    Returns ``""`` for empty / whitespace-only / sentinel input. Otherwise
    the value is used **verbatim** — ``v001``, ``final``, ``take_3`` all pass
    through unchanged — after defensive cleanup:

    - control characters (newline/CR/tab) stripped (Text Multiline can leak a
      stray paragraph break),
    - surrounding whitespace and a matched pair of surrounding quotes removed
      (Explorer "Copy as path" style pastes),
    - frontend sentinels (``undefined``/``null``/``none``) collapsed to ``""``,
    - path separators flattened to ``_`` — a version token is a single
      filename component and must never introduce a subfolder.
    """
    if value is None:
        return ""
    s = str(value).replace("\r", "").replace("\n", "").replace("\t", "").strip()
    if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
        s = s[1:-1].strip()
    if s.lower() in _SENTINEL_STRINGS:
        return ""
    # A version token names one path component, never a path of its own.
    return s.replace("/", "_").replace("\\", "_")


def resolve_version_token(version, disable_versioning: bool = False) -> str:
    """Resolve the version token to stamp into a path/filename.

    One field, whether typed into the widget or wired in as a STRING:

    - ``disable_versioning`` true   -> ``""`` (the master off-switch).
    - empty / sentinel              -> ``""``.
    - a bare integer (``"2"``)      -> ``"v002"`` (back-compat with the old
      INT widget, and a convenience when typing a number).
    - anything else (``"v001"``,
      ``"final"``, a wired token)   -> used **verbatim**.
    """
    if disable_versioning:
        return ""
    token = normalize_version_token(version)
    if not token:
        return ""
    if token.isdigit():
        return f"v{int(token):03d}"
    return token


def next_version_token(
    directory,
    name,
    version_prefix: str = "v",
    padding: int = 3,
    start: int = 1,
) -> str:
    """Return the next free ``<prefix>NNN`` token for ``name`` in ``directory``.

    Scans ``directory`` for entries (files *and* subfolders) of the form
    ``<name>_<prefix><digits>`` -- e.g. ``bearMask_v002.png``,
    ``bearMask_v002.0001.exr``, or a ``bearMask_v003/`` sequence folder -- and
    returns the highest detected version plus one, zero-padded to ``padding``.

    A missing or empty directory (or no matching entries) yields ``start``
    (default ``v001``). Permission errors and other unexpected filesystem
    failures surface to the caller so a broken output mount cannot silently
    become ``v001``. Matching is on the **exact** base name, so a different
    shot's versions never bump this one. An empty ``name`` matches bare
    ``<prefix>NNN`` tokens. Whatever follows the digits (extension, frame
    number, ``_suffix``) is ignored.
    """
    prefix = normalize_version_token(version_prefix) or "v"
    try:
        pad = max(1, int(padding))
    except (TypeError, ValueError):
        pad = 3
    try:
        start_int = int(start)
    except (TypeError, ValueError):
        start_int = 1

    base = normalize_version_token(name)
    lead = f"{re.escape(base)}_" if base else ""
    pattern = re.compile(rf"^{lead}{re.escape(prefix)}(\d+)")

    highest: int | None = None
    try:
        entries = os.listdir(directory) if directory else []
    except FileNotFoundError:
        entries = []
    for entry in entries:
        match = pattern.match(entry)
        if match:
            value = int(match.group(1))
            highest = value if highest is None else max(highest, value)

    nxt = start_int if highest is None else highest + 1
    return f"{prefix}{nxt:0{pad}d}"