| |
| |
| |
| |
| |
| |
| |
| """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 |
|
|
| |
| |
| |
| _SENTINEL_STRINGS = ("undefined", "null", "none") |
|
|
| |
| |
| |
| _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 "" |
| |
| 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}" |
|
|