Completes the except Exception: pass -> observable-debug-log sweep. This batch covers the ~26 files that had no module-level logger yet: adds `logger = get_logger(__name__)` (mjr_am_backend.shared, or mjr_am_shared.log for files inside mjr_am_shared itself) alongside the existing import block, then applies the same pass -> logger.debug substitution as the earlier 4 batches. __init__.py and nodes.py already had a differently-named logger (_logger / _log) and just needed the substitution. mjr_am_shared/log.py is the one true special case: its silent except lives inside _ensure_correlation_filter(logger), which already takes the target logger as a parameter -- logging through that same parameter instead of adding a redundant module logger. Ran `ruff check --fix` after each batch to normalize import ordering, then verified every touched module still imports cleanly (import smoke test) in addition to the full pytest suite (1846 passed). This closes out the backend "silent exception" audit finding: every `except Exception: pass` in the codebase that could reasonably get a logger now has one.
1551 lines
57 KiB
Python
1551 lines
57 KiB
Python
"""
|
||
Majoor Assets Manager – ComfyUI custom nodes.
|
||
|
||
MajoorSaveImage : drop-in replacement for ComfyUI SaveImage that persists
|
||
``generation_time_ms`` (and any extra metadata) inside the
|
||
PNG text chunks so Majoor can index it later.
|
||
|
||
MajoorSaveVideo : saves a VIDEO *or* a batch of IMAGE frames as a video file
|
||
(MP4 h264) using PyAV – same approach as ComfyUI's native
|
||
``SaveVideo`` node – while embedding ``generation_time_ms``
|
||
directly in the MP4 container metadata alongside the full
|
||
prompt / workflow JSON.
|
||
Also supports GIF / WebP export via Pillow for animated
|
||
image formats.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime
|
||
import json
|
||
import logging
|
||
import math
|
||
import os
|
||
import re
|
||
import time
|
||
from fractions import Fraction
|
||
from functools import lru_cache
|
||
from typing import Any
|
||
|
||
import av # type: ignore[import-untyped]
|
||
import folder_paths # type: ignore[import-untyped]
|
||
import numpy as np
|
||
from comfy.cli_args import args # type: ignore[import-untyped]
|
||
from comfy_api.latest import IO, ComfyExtension # type: ignore[import-untyped]
|
||
from PIL import Image, ImageCms
|
||
from PIL.PngImagePlugin import PngInfo
|
||
|
||
try:
|
||
from comfy.utils import ProgressBar as _ComfyProgressBar # type: ignore[import-untyped]
|
||
except (ImportError, ModuleNotFoundError):
|
||
_ComfyProgressBar = None
|
||
|
||
try:
|
||
import torch # type: ignore[import-untyped]
|
||
except ModuleNotFoundError:
|
||
torch = None # type: ignore[assignment]
|
||
|
||
try:
|
||
from .mjr_am_backend.video_ui import build_video_ui as _build_video_ui
|
||
except ImportError:
|
||
from mjr_am_backend.video_ui import build_video_ui as _build_video_ui
|
||
|
||
_log = logging.getLogger("majoor_assets_manager.nodes")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _NullProgressBar:
|
||
"""ProgressBar-compatible fallback for tests and older ComfyUI builds."""
|
||
|
||
def update(self, value: int = 1) -> None:
|
||
del value
|
||
|
||
|
||
def _make_progress_bar(total: int) -> Any:
|
||
total = max(1, int(total))
|
||
if _ComfyProgressBar is None:
|
||
return _NullProgressBar()
|
||
try:
|
||
return _ComfyProgressBar(total)
|
||
except Exception:
|
||
_log.debug("ComfyUI ProgressBar is unavailable", exc_info=True)
|
||
return _NullProgressBar()
|
||
|
||
|
||
def _get_generation_time_ms() -> int:
|
||
"""
|
||
Compute elapsed time since the current prompt started.
|
||
|
||
Uses *mjr_am_backend.runtime_activity* (which hooks into the ComfyUI
|
||
prompt lifecycle) to know when the prompt was queued. Falls back to 0
|
||
if the runtime module is unavailable.
|
||
"""
|
||
try:
|
||
from mjr_am_backend.runtime_activity import _LOCK, _STATE
|
||
|
||
with _LOCK:
|
||
started = float(_STATE.get("last_started_at") or 0.0)
|
||
if started <= 0.0:
|
||
return 0
|
||
# last_started_at is time.monotonic(), so compare with same clock
|
||
return max(0, int(round((time.monotonic() - started) * 1000)))
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def _tensor_to_bytes(t: torch.Tensor) -> np.ndarray:
|
||
"""Convert a CHW/HWC float [0-1] tensor to uint8 HWC numpy array."""
|
||
_require_torch()
|
||
arr = 255.0 * t.cpu().numpy()
|
||
return np.clip(arr, 0, 255).astype(np.uint8)
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def _srgb_icc_profile() -> bytes:
|
||
"""Return a standard sRGB ICC profile suitable for Pillow PNG/JPEG saves."""
|
||
profile = ImageCms.createProfile("sRGB")
|
||
return ImageCms.ImageCmsProfile(profile).tobytes()
|
||
|
||
|
||
def _srgb_save_kwargs() -> dict[str, Any]:
|
||
"""Return reusable Pillow color-management options."""
|
||
return {"icc_profile": _srgb_icc_profile()}
|
||
|
||
|
||
def _require_torch() -> Any:
|
||
if torch is None:
|
||
raise RuntimeError("torch is required for Majoor image/video node execution")
|
||
return torch
|
||
|
||
|
||
_FILENAME_PLACEHOLDER_RE = re.compile(r"%([^%]+)%")
|
||
_DATE_FORMAT_TOKEN_RE = re.compile(r"dd?|MM?|hh?|mm?|ss?|yyy?y?")
|
||
|
||
|
||
def _format_date_placeholder(fmt: str, now: datetime.datetime) -> str:
|
||
"""Replicate the ComfyUI frontend ``formatDate`` token semantics.
|
||
|
||
Tokens: ``yyyy``/``yy`` (year), ``M``/``MM`` (month), ``d``/``dd`` (day),
|
||
``h``/``hh`` (hour), ``m``/``mm`` (minute), ``s``/``ss`` (second).
|
||
Doubled tokens are zero-padded to two digits.
|
||
"""
|
||
|
||
def _token(match: re.Match[str]) -> str:
|
||
text = match.group(0)
|
||
if text == "yyyy":
|
||
return str(now.year)
|
||
if text == "yy":
|
||
return str(now.year)[-2:]
|
||
values = {
|
||
"d": now.day,
|
||
"M": now.month,
|
||
"h": now.hour,
|
||
"m": now.minute,
|
||
"s": now.second,
|
||
}
|
||
value = values.get(text[0])
|
||
if value is None:
|
||
return text
|
||
return str(value).zfill(len(text))
|
||
|
||
return _DATE_FORMAT_TOKEN_RE.sub(_token, fmt)
|
||
|
||
|
||
def _resolve_filename_prefix_placeholders(filename_prefix: str) -> str:
|
||
"""Server-side fallback for ``%date:FORMAT%`` placeholders (issue #194).
|
||
|
||
ComfyUI's frontend only rewrites ``filename_prefix`` for its own core save
|
||
nodes, so custom nodes receive the placeholders verbatim. ``%width%``,
|
||
``%height%``, ``%year%``... are already handled by
|
||
``folder_paths.get_save_image_path``; only ``%date:...%`` needs resolving
|
||
here. ``%Node.widget%`` references are resolved by the bundled frontend
|
||
extension when a graph is available.
|
||
"""
|
||
now = datetime.datetime.now()
|
||
|
||
def _sub(match: re.Match[str]) -> str:
|
||
text = match.group(1)
|
||
if text.startswith("date:"):
|
||
return _format_date_placeholder(text[5:], now)
|
||
return match.group(0)
|
||
|
||
try:
|
||
return _FILENAME_PLACEHOLDER_RE.sub(_sub, str(filename_prefix))
|
||
except (TypeError, ValueError):
|
||
return filename_prefix
|
||
|
||
|
||
def _next_counter(directory: str, prefix: str) -> int:
|
||
"""Find the next available counter for *prefix_NNNNN* in *directory*."""
|
||
max_counter = 0
|
||
matcher = re.compile(rf"{re.escape(prefix)}_(\d+)\D*\..+", re.IGNORECASE)
|
||
try:
|
||
for entry in os.listdir(directory):
|
||
m = matcher.fullmatch(entry)
|
||
if m:
|
||
max_counter = max(max_counter, int(m.group(1)))
|
||
except FileNotFoundError:
|
||
pass
|
||
return max_counter + 1
|
||
|
||
|
||
def _build_metadata(
|
||
prompt: Any | None,
|
||
extra_pnginfo: dict | None,
|
||
generation_time_ms: int,
|
||
geninfo_override: Any | None = None,
|
||
unique_id: Any | None = None,
|
||
) -> PngInfo:
|
||
metadata = PngInfo()
|
||
if prompt is not None:
|
||
metadata.add_text("prompt", json.dumps(prompt))
|
||
if extra_pnginfo is not None:
|
||
for key in extra_pnginfo:
|
||
metadata.add_text(key, json.dumps(extra_pnginfo[key]))
|
||
override_payload = _coerce_geninfo_override_payload(geninfo_override)
|
||
if override_payload is not None:
|
||
metadata.add_text("majoor_geninfo", json.dumps(override_payload))
|
||
for key, value in _resolve_execution_metadata(prompt, extra_pnginfo, unique_id).items():
|
||
metadata.add_text(key, value)
|
||
metadata.add_text("generation_time_ms", str(generation_time_ms))
|
||
metadata.add_text(
|
||
"CreationTime",
|
||
datetime.datetime.now().isoformat(" ")[:19],
|
||
)
|
||
return metadata
|
||
|
||
|
||
def _runtime_active_prompt_id() -> str | None:
|
||
try:
|
||
from mjr_am_backend.runtime_activity import _LOCK, _STATE
|
||
|
||
with _LOCK:
|
||
return _clean_optional_text(_STATE.get("active_prompt_id"), max_len=255)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _workflow_id_from_extra_pnginfo(extra_pnginfo: dict | None) -> str | None:
|
||
if not isinstance(extra_pnginfo, dict):
|
||
return None
|
||
candidates: list[Any] = [extra_pnginfo.get("workflow_id")]
|
||
workflow = extra_pnginfo.get("workflow")
|
||
if isinstance(workflow, dict):
|
||
candidates.extend((workflow.get("id"), workflow.get("workflow_id")))
|
||
for value in candidates:
|
||
text = _clean_optional_text(value, max_len=255)
|
||
if text:
|
||
return text
|
||
return None
|
||
|
||
|
||
def _asset_id_from_context(prompt: Any | None, extra_pnginfo: dict | None) -> str | None:
|
||
candidates: list[Any] = []
|
||
if isinstance(extra_pnginfo, dict):
|
||
candidates.extend(
|
||
(
|
||
extra_pnginfo.get("asset_id"),
|
||
extra_pnginfo.get("assetId"),
|
||
)
|
||
)
|
||
if isinstance(prompt, dict):
|
||
candidates.extend((prompt.get("asset_id"), prompt.get("assetId")))
|
||
for value in candidates:
|
||
text = _clean_optional_text(value, max_len=255)
|
||
if text:
|
||
return text
|
||
return None
|
||
|
||
|
||
def _job_id_from_prompt(prompt: Any | None) -> str | None:
|
||
if isinstance(prompt, (list, tuple)) and len(prompt) >= 2:
|
||
text = _clean_optional_text(prompt[1], max_len=255)
|
||
if text:
|
||
return text
|
||
if isinstance(prompt, dict):
|
||
for key in ("prompt_id", "job_id"):
|
||
text = _clean_optional_text(prompt.get(key), max_len=255)
|
||
if text:
|
||
return text
|
||
return None
|
||
|
||
|
||
def _node_type_from_prompt(prompt: Any | None, unique_id: Any | None) -> str | None:
|
||
node_id = _clean_optional_text(unique_id, max_len=255)
|
||
if not node_id or not isinstance(prompt, dict):
|
||
return None
|
||
node = prompt.get(node_id)
|
||
if isinstance(node, dict):
|
||
for key in ("class_type", "type", "node_type"):
|
||
text = _clean_optional_text(node.get(key), max_len=255)
|
||
if text:
|
||
return text
|
||
return None
|
||
|
||
|
||
def _node_type_from_extra_pnginfo(extra_pnginfo: dict | None, unique_id: Any | None) -> str | None:
|
||
node_id = _clean_optional_text(unique_id, max_len=255)
|
||
if not node_id or not isinstance(extra_pnginfo, dict):
|
||
return None
|
||
workflow = extra_pnginfo.get("workflow")
|
||
if not isinstance(workflow, dict):
|
||
return None
|
||
for node in _iter_workflow_nodes(workflow):
|
||
if not isinstance(node, dict):
|
||
continue
|
||
current_id = _clean_optional_text(node.get("id"), max_len=255)
|
||
if current_id != node_id:
|
||
continue
|
||
for key in ("type", "class_type", "node_type"):
|
||
text = _clean_optional_text(node.get(key), max_len=255)
|
||
if text:
|
||
return text
|
||
return None
|
||
|
||
|
||
def _resolve_execution_metadata(
|
||
prompt: Any | None,
|
||
extra_pnginfo: dict | None,
|
||
unique_id: Any | None = None,
|
||
) -> dict[str, str]:
|
||
out: dict[str, str] = {}
|
||
asset_id = _asset_id_from_context(prompt, extra_pnginfo)
|
||
job_id = _runtime_active_prompt_id() or _job_id_from_prompt(prompt)
|
||
workflow_id = _workflow_id_from_extra_pnginfo(extra_pnginfo)
|
||
source_node_id = _clean_optional_text(unique_id, max_len=255)
|
||
source_node_type = _node_type_from_prompt(prompt, unique_id) or _node_type_from_extra_pnginfo(
|
||
extra_pnginfo,
|
||
unique_id,
|
||
)
|
||
if asset_id:
|
||
out["asset_id"] = asset_id
|
||
if job_id:
|
||
out["job_id"] = job_id
|
||
out["prompt_id"] = job_id
|
||
if workflow_id:
|
||
out["workflow_id"] = workflow_id
|
||
if source_node_id:
|
||
out["source_node_id"] = source_node_id
|
||
if source_node_type:
|
||
out["source_node_type"] = source_node_type
|
||
return out
|
||
|
||
|
||
def _clean_optional_text(value: Any, max_len: int = 20_000) -> str | None:
|
||
if value is None:
|
||
return None
|
||
text = str(value).strip()
|
||
if not text:
|
||
return None
|
||
return text[:max_len]
|
||
|
||
|
||
def _json_object_or_array(value: Any) -> Any | None:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, (dict, list)):
|
||
return value
|
||
text = _clean_optional_text(value)
|
||
if not text:
|
||
return None
|
||
try:
|
||
parsed = json.loads(text)
|
||
except Exception:
|
||
return None
|
||
return parsed if isinstance(parsed, (dict, list)) else None
|
||
|
||
|
||
def _coerce_geninfo_override_payload(value: Any | None) -> dict[str, Any] | None:
|
||
if value is None:
|
||
return None
|
||
payload = value
|
||
if isinstance(value, str):
|
||
parsed = _json_object_or_array(value)
|
||
payload = parsed if isinstance(parsed, dict) else None
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
if "majoor_geninfo" in payload and isinstance(payload.get("majoor_geninfo"), dict):
|
||
payload = payload["majoor_geninfo"]
|
||
if "MajoorOverride" in payload and isinstance(payload.get("MajoorOverride"), dict):
|
||
payload = payload["MajoorOverride"]
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
out = dict(payload)
|
||
out.setdefault("version", 1)
|
||
out.setdefault("mode", "override")
|
||
return out
|
||
|
||
|
||
def _assign_text(payload: dict[str, Any], key: str, value: Any) -> None:
|
||
text = _clean_optional_text(value)
|
||
if text is not None:
|
||
payload[key] = text
|
||
|
||
|
||
def _assign_numeric(payload: dict[str, Any], key: str, value: Any, caster: Any, *, default: Any = None) -> None:
|
||
if value is None or value == default or value == "":
|
||
return
|
||
try:
|
||
payload[key] = caster(value)
|
||
except Exception:
|
||
return
|
||
|
||
|
||
def _assign_json(payload: dict[str, Any], key: str, value: Any, expected_type: type) -> None:
|
||
parsed = _json_object_or_array(value)
|
||
if isinstance(parsed, expected_type):
|
||
payload[key] = parsed
|
||
|
||
|
||
def _assign_custom_info(payload: dict[str, Any], value: Any) -> None:
|
||
parsed = _json_object_or_array(value)
|
||
if isinstance(parsed, list):
|
||
payload["custom_info"] = parsed
|
||
return
|
||
text = _clean_optional_text(value)
|
||
if text is not None:
|
||
payload["custom_info"] = [{"title": "Custom Info", "content": text}]
|
||
|
||
|
||
def _build_geninfo_override_payload(
|
||
positive_prompt: str = "",
|
||
negative_prompt: str = "",
|
||
seed: int = -1,
|
||
steps: int = -1,
|
||
cfg: float = -1.0,
|
||
sampler: str = "",
|
||
scheduler: str = "",
|
||
model: str = "",
|
||
vae: str = "",
|
||
clip: str = "",
|
||
denoise: float = -1.0,
|
||
loras_json: str = "",
|
||
workflow_notes: str = "",
|
||
custom_info_json: str = "",
|
||
) -> dict[str, Any]:
|
||
payload: dict[str, Any] = {"version": 1, "mode": "override"}
|
||
_assign_text(payload, "prompt", positive_prompt)
|
||
_assign_text(payload, "negative_prompt", negative_prompt)
|
||
_assign_numeric(payload, "seed", seed, int, default=-1)
|
||
_assign_numeric(payload, "steps", steps, int, default=-1)
|
||
_assign_numeric(payload, "cfg", cfg, float, default=-1.0)
|
||
_assign_numeric(payload, "denoise", denoise, float, default=-1.0)
|
||
_assign_text(payload, "sampler", sampler)
|
||
_assign_text(payload, "scheduler", scheduler)
|
||
_assign_text(payload, "model", model)
|
||
_assign_text(payload, "vae", vae)
|
||
_assign_text(payload, "clip", clip)
|
||
_assign_json(payload, "loras", loras_json, list)
|
||
_assign_text(payload, "workflow_notes", workflow_notes)
|
||
_assign_custom_info(payload, custom_info_json)
|
||
return payload
|
||
|
||
|
||
def _geninfo_value(parsed: dict[str, Any] | None, key: str, value_key: str = "value") -> Any:
|
||
if not isinstance(parsed, dict):
|
||
return None
|
||
value = parsed.get(key)
|
||
if isinstance(value, dict):
|
||
return value.get(value_key) or value.get("value") or value.get("name")
|
||
return value
|
||
|
||
|
||
def _first_model_name(parsed: dict[str, Any] | None) -> str:
|
||
if not isinstance(parsed, dict):
|
||
return ""
|
||
checkpoint = parsed.get("checkpoint")
|
||
if isinstance(checkpoint, dict) and checkpoint.get("name"):
|
||
return str(checkpoint.get("name") or "").strip()
|
||
models = parsed.get("models")
|
||
if isinstance(models, dict):
|
||
for key in ("checkpoint", "unet", "model", "diffusion_model", "base"):
|
||
item = models.get(key)
|
||
if isinstance(item, dict) and item.get("name"):
|
||
return str(item.get("name") or "").strip()
|
||
for item in models.values():
|
||
if isinstance(item, dict) and item.get("name"):
|
||
return str(item.get("name") or "").strip()
|
||
return ""
|
||
|
||
|
||
def _loras_json_from_parsed(parsed: dict[str, Any] | None) -> str:
|
||
if not isinstance(parsed, dict):
|
||
return ""
|
||
loras = parsed.get("loras")
|
||
if not isinstance(loras, list) or not loras:
|
||
return ""
|
||
out: list[dict[str, Any]] = []
|
||
for item in loras:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = str(item.get("name") or "").strip()
|
||
if not name:
|
||
continue
|
||
entry: dict[str, Any] = {"name": name}
|
||
strength = item.get("strength", item.get("model_strength", item.get("strength_model")))
|
||
if strength is not None:
|
||
entry["strength"] = strength
|
||
out.append(entry)
|
||
return json.dumps(out) if out else ""
|
||
|
||
|
||
def _parsed_geninfo_to_override_payload(parsed: dict[str, Any] | None) -> dict[str, Any]:
|
||
return _build_geninfo_override_payload(
|
||
positive_prompt=str(_geninfo_value(parsed, "positive") or ""),
|
||
negative_prompt=str(_geninfo_value(parsed, "negative") or ""),
|
||
seed=int(_geninfo_value(parsed, "seed") or -1),
|
||
steps=int(_geninfo_value(parsed, "steps") or -1),
|
||
cfg=float(_geninfo_value(parsed, "cfg") or -1.0),
|
||
sampler=str(_geninfo_value(parsed, "sampler", "name") or ""),
|
||
scheduler=str(_geninfo_value(parsed, "scheduler", "name") or ""),
|
||
model=_first_model_name(parsed),
|
||
vae=str(_geninfo_value(parsed, "vae", "name") or ""),
|
||
clip=str(_geninfo_value(parsed, "clip", "name") or ""),
|
||
denoise=float(_geninfo_value(parsed, "denoise") or -1.0),
|
||
loras_json=_loras_json_from_parsed(parsed),
|
||
)
|
||
|
||
|
||
def _workflow_node_widget_map(node: dict[str, Any]) -> dict[str, Any]:
|
||
values = node.get("widgets_values")
|
||
widgets = values if isinstance(values, list) else []
|
||
inputs = node.get("inputs")
|
||
out: dict[str, Any] = {}
|
||
if isinstance(inputs, list):
|
||
widget_index = 0
|
||
for inp in inputs:
|
||
if not isinstance(inp, dict):
|
||
continue
|
||
widget_name = inp.get("widget", {}).get("name") if isinstance(inp.get("widget"), dict) else None
|
||
if inp.get("link") is None and widget_index < len(widgets):
|
||
name = str(widget_name or inp.get("name") or "").strip()
|
||
if name:
|
||
out[name] = widgets[widget_index]
|
||
widget_index += 1
|
||
if isinstance(values, dict):
|
||
out.update(values)
|
||
return out
|
||
|
||
|
||
def _iter_workflow_nodes(workflow: Any, definitions: dict[str, dict[str, Any]] | None = None, seen: set[str] | None = None):
|
||
if not isinstance(workflow, dict):
|
||
return
|
||
defs = definitions
|
||
if defs is None:
|
||
defs = {}
|
||
raw_defs = workflow.get("definitions", {}).get("subgraphs") if isinstance(workflow.get("definitions"), dict) else []
|
||
if isinstance(raw_defs, list):
|
||
for sg in raw_defs:
|
||
if isinstance(sg, dict) and sg.get("id") is not None:
|
||
defs[str(sg.get("id"))] = sg
|
||
active = seen or set()
|
||
for node in workflow.get("nodes", []) if isinstance(workflow.get("nodes"), list) else []:
|
||
if not isinstance(node, dict):
|
||
continue
|
||
yield node
|
||
node_type = str(node.get("type") or "")
|
||
subgraph = defs.get(node_type)
|
||
if subgraph and node_type not in active:
|
||
yield from _iter_workflow_nodes(subgraph, defs, {*active, node_type})
|
||
|
||
|
||
def _first_named_or_string_value(values: dict[str, Any], names: tuple[str, ...]) -> str:
|
||
for name in names:
|
||
value = values.get(name)
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()
|
||
for value in values.values():
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()
|
||
return ""
|
||
|
||
|
||
def _first_named_value(values: dict[str, Any], names: tuple[str, ...]) -> str:
|
||
for name in names:
|
||
value = values.get(name)
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()
|
||
return ""
|
||
|
||
|
||
def _apply_workflow_text_fallback(
|
||
out: dict[str, Any],
|
||
node_type: str,
|
||
values: dict[str, Any],
|
||
text_values: list[str],
|
||
) -> None:
|
||
if "prompt" not in out and ("textencode" in node_type or "text" in node_type):
|
||
text = _first_named_or_string_value(values, ("text", "prompt", "positive_prompt", "string"))
|
||
if text and "negative" not in node_type:
|
||
out["prompt"] = text
|
||
if "negative_prompt" not in out:
|
||
text = _first_named_value(values, ("negative", "negative_prompt", "negative_text"))
|
||
if text or ("negative" in node_type and text_values):
|
||
out["negative_prompt"] = text or text_values[0]
|
||
|
||
|
||
def _apply_workflow_model_fallback(
|
||
out: dict[str, Any],
|
||
node_type: str,
|
||
values: dict[str, Any],
|
||
) -> None:
|
||
if "model" not in out and any(token in node_type for token in ("checkpoint", "unet", "model")):
|
||
model = _first_named_or_string_value(values, ("ckpt_name", "unet_name", "model_name", "model"))
|
||
if model:
|
||
out["model"] = model
|
||
if "vae" not in out and "vae" in node_type:
|
||
vae = _first_named_or_string_value(values, ("vae_name", "vae"))
|
||
if vae:
|
||
out["vae"] = vae
|
||
if "clip" not in out and "clip" in node_type:
|
||
clip = _first_named_or_string_value(values, ("clip_name", "clip_name1", "clip_name2", "clip"))
|
||
if clip:
|
||
out["clip"] = clip
|
||
|
||
|
||
def _widget_value(values: dict[str, Any], widgets: list[Any], key: str, index: int, default: Any = None) -> Any:
|
||
return values.get(key, widgets[index] if len(widgets) > index else default)
|
||
|
||
|
||
def _set_nonnegative_number(
|
||
out: dict[str, Any],
|
||
key: str,
|
||
value: Any,
|
||
caster: type[int] | type[float],
|
||
) -> None:
|
||
if key in out or not isinstance(value, (int, float)) or value < 0:
|
||
return
|
||
out[key] = caster(value)
|
||
|
||
|
||
def _set_nonempty_string(out: dict[str, Any], key: str, value: Any) -> None:
|
||
if key in out or not isinstance(value, str) or not value.strip():
|
||
return
|
||
out[key] = value.strip()
|
||
|
||
|
||
def _apply_workflow_sampler_fallback(
|
||
out: dict[str, Any],
|
||
node_type: str,
|
||
values: dict[str, Any],
|
||
widgets: list[Any],
|
||
) -> None:
|
||
if "ksampler" not in node_type and "sampler" not in node_type:
|
||
return
|
||
_set_nonnegative_number(out, "seed", _widget_value(values, widgets, "seed", 0), int)
|
||
_set_nonnegative_number(out, "steps", _widget_value(values, widgets, "steps", 1), int)
|
||
_set_nonnegative_number(out, "cfg", _widget_value(values, widgets, "cfg", 2), float)
|
||
_set_nonempty_string(out, "sampler", _widget_value(values, widgets, "sampler_name", 3, ""))
|
||
_set_nonempty_string(out, "scheduler", _widget_value(values, widgets, "scheduler", 4, ""))
|
||
_set_nonnegative_number(out, "denoise", _widget_value(values, widgets, "denoise", 5), float)
|
||
|
||
|
||
def _workflow_context_fallback_payload(extra_pnginfo: dict | None) -> dict[str, Any]:
|
||
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
|
||
out: dict[str, Any] = {"version": 1, "mode": "override"}
|
||
if not isinstance(workflow, dict):
|
||
return out
|
||
for node in _iter_workflow_nodes(workflow):
|
||
node_type = str(node.get("type") or node.get("class_type") or "").lower()
|
||
if "majoorgeninfooverride" in node_type or "geninfooverride" in node_type:
|
||
continue
|
||
values = _workflow_node_widget_map(node)
|
||
widgets = node.get("widgets_values") if isinstance(node.get("widgets_values"), list) else []
|
||
text_values = [str(v).strip() for v in widgets if isinstance(v, str) and v.strip()]
|
||
|
||
_apply_workflow_text_fallback(out, node_type, values, text_values)
|
||
_apply_workflow_model_fallback(out, node_type, values)
|
||
_apply_workflow_sampler_fallback(out, node_type, values, widgets)
|
||
return out
|
||
|
||
|
||
def _merge_missing_override_fields(manual: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
|
||
merged = dict(fallback)
|
||
merged.update({k: v for k, v in manual.items() if k in ("version", "mode")})
|
||
for key, value in manual.items():
|
||
if key in ("version", "mode"):
|
||
continue
|
||
if value is None:
|
||
continue
|
||
if isinstance(value, str) and not value.strip():
|
||
continue
|
||
if isinstance(value, (int, float)) and value < 0:
|
||
continue
|
||
if isinstance(value, list) and not value:
|
||
continue
|
||
merged[key] = value
|
||
merged["version"] = 1
|
||
merged["mode"] = "override"
|
||
return merged
|
||
|
||
|
||
def _payload_has_meaningful_value(payload: dict[str, Any], key: str) -> bool:
|
||
value = payload.get(key)
|
||
if value is None:
|
||
return False
|
||
if isinstance(value, str):
|
||
return bool(value.strip())
|
||
if isinstance(value, (int, float)):
|
||
return value >= 0
|
||
if isinstance(value, list):
|
||
return bool(value)
|
||
return True
|
||
|
||
|
||
def _prefer_prompt_runtime_fields(payload: dict[str, Any], prompt_payload: dict[str, Any]) -> dict[str, Any]:
|
||
merged = dict(payload)
|
||
# ComfyUI mutates seed/control widgets before graphToPrompt. The prompt graph
|
||
# is therefore the executed truth; workflow JSON can still contain stale widget values.
|
||
for key in ("seed", "steps", "cfg", "denoise", "sampler", "scheduler"):
|
||
if _payload_has_meaningful_value(prompt_payload, key):
|
||
merged[key] = prompt_payload[key]
|
||
return merged
|
||
|
||
|
||
def _parse_context_geninfo(prompt: Any | None, extra_pnginfo: dict | None) -> dict[str, Any] | None:
|
||
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
|
||
try:
|
||
from mjr_am_backend.features.geninfo.parser import parse_geninfo_from_prompt
|
||
|
||
result = parse_geninfo_from_prompt(prompt, workflow=workflow)
|
||
return result.data if result.ok and isinstance(result.data, dict) else None
|
||
except Exception as exc:
|
||
_log.debug("Majoor Gen Info Override context parse failed: %s", exc, exc_info=True)
|
||
return None
|
||
|
||
|
||
def _coerce_runtime_seed(value: Any) -> int | None:
|
||
if isinstance(value, bool) or value is None or value == "":
|
||
return None
|
||
try:
|
||
seed = int(value)
|
||
except Exception:
|
||
return None
|
||
return seed if seed >= 0 else None
|
||
|
||
|
||
def _runtime_seed_from_link(nodes_by_id: dict[str, Any], link_or_value: Any) -> int | None:
|
||
seed = _coerce_runtime_seed(link_or_value)
|
||
if seed is not None:
|
||
return seed
|
||
try:
|
||
from mjr_am_backend.features.geninfo.graph_converter import _is_link
|
||
from mjr_am_backend.features.geninfo.prompt_tracer import _resolve_scalar_from_link
|
||
|
||
if _is_link(link_or_value):
|
||
return _coerce_runtime_seed(_resolve_scalar_from_link(nodes_by_id, link_or_value))
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _runtime_seed_from_node_inputs(nodes_by_id: dict[str, Any], inputs: dict[str, Any]) -> int | None:
|
||
for key in ("seed", "noise_seed", "rand_seed", "random_seed", "value", "int", "number"):
|
||
if key not in inputs:
|
||
continue
|
||
seed = _runtime_seed_from_link(nodes_by_id, inputs.get(key))
|
||
if seed is not None:
|
||
return seed
|
||
return None
|
||
|
||
|
||
def _runtime_sampler_payload_from_node(
|
||
nodes_by_id: dict[str, Any],
|
||
node_id: str,
|
||
node: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
try:
|
||
from mjr_am_backend.features.geninfo.graph_converter import _inputs, _lower, _node_type
|
||
from mjr_am_backend.features.geninfo.sampler_tracer import _is_advanced_sampler
|
||
from mjr_am_backend.features.geninfo.sampler_value_extractor import _extract_sampler_values
|
||
except Exception:
|
||
return {}
|
||
|
||
ins = _inputs(node)
|
||
if _lower(_node_type(node)).find("geninfooverride") >= 0:
|
||
return {}
|
||
try:
|
||
values = _extract_sampler_values(nodes_by_id, node, node_id, ins, _is_advanced_sampler(node), "high", {})
|
||
except Exception:
|
||
values = {}
|
||
|
||
out: dict[str, Any] = {"version": 1, "mode": "override"}
|
||
seed = _coerce_runtime_seed(values.get("seed_val"))
|
||
if seed is None:
|
||
seed = _runtime_seed_from_node_inputs(nodes_by_id, ins)
|
||
if seed is not None:
|
||
out["seed"] = seed
|
||
for src_key, dst_key, caster in (
|
||
("steps", "steps", int),
|
||
("cfg", "cfg", float),
|
||
("denoise", "denoise", float),
|
||
):
|
||
val = values.get(src_key)
|
||
if val is None:
|
||
val = ins.get(src_key)
|
||
try:
|
||
if val is not None and val != "":
|
||
out[dst_key] = caster(val)
|
||
except Exception:
|
||
_log.debug("_runtime_sampler_payload_from_node: suppressed exception", exc_info=True)
|
||
for src_key, dst_key in (("sampler_name", "sampler"), ("sampler", "sampler"), ("scheduler", "scheduler")):
|
||
val = values.get(src_key)
|
||
if val is None:
|
||
val = ins.get(src_key)
|
||
if isinstance(val, str) and val.strip() and dst_key not in out:
|
||
out[dst_key] = val.strip()
|
||
return out
|
||
|
||
|
||
def _workflow_context_start_ids(nodes_by_id: dict[str, Any]) -> list[str]:
|
||
try:
|
||
from mjr_am_backend.features.geninfo.graph_converter import (
|
||
_inputs,
|
||
_lower,
|
||
_node_type,
|
||
_walk_passthrough,
|
||
)
|
||
except Exception:
|
||
return []
|
||
|
||
start_ids: list[str] = []
|
||
for node in nodes_by_id.values():
|
||
if not isinstance(node, dict):
|
||
continue
|
||
node_type = _lower(_node_type(node))
|
||
if "majoorgeninfooverride" not in node_type and "geninfooverride" not in node_type:
|
||
continue
|
||
start_id = _walk_passthrough(nodes_by_id, _inputs(node).get("workflow_context"))
|
||
if start_id:
|
||
start_ids.append(start_id)
|
||
return start_ids
|
||
|
||
|
||
def _runtime_sampler_candidates(
|
||
nodes_by_id: dict[str, Any],
|
||
start_ids: list[str],
|
||
) -> list[tuple[int, str, dict[str, Any]]]:
|
||
try:
|
||
from mjr_am_backend.features.geninfo.graph_converter import (
|
||
_collect_upstream_nodes,
|
||
_inputs,
|
||
_lower,
|
||
_node_type,
|
||
)
|
||
from mjr_am_backend.features.geninfo.sampler_tracer import _is_sampler
|
||
except Exception:
|
||
return []
|
||
|
||
candidates: list[tuple[int, str, dict[str, Any]]] = []
|
||
for start_id in start_ids:
|
||
distances = _collect_upstream_nodes(nodes_by_id, start_id)
|
||
if not distances and start_id in nodes_by_id:
|
||
distances = {start_id: 0}
|
||
for node_id, distance in distances.items():
|
||
node = nodes_by_id.get(node_id)
|
||
if not isinstance(node, dict):
|
||
continue
|
||
node_type = _lower(_node_type(node))
|
||
if "majoorgeninfooverride" in node_type or "geninfooverride" in node_type:
|
||
continue
|
||
if _is_sampler(node) or any(key in _inputs(node) for key in ("seed", "noise_seed")):
|
||
candidates.append((distance, str(node_id), node))
|
||
return candidates
|
||
|
||
|
||
def _prompt_runtime_sampler_payload(prompt: Any | None, workflow: Any | None) -> dict[str, Any]:
|
||
"""Read executed sampler/noise values from ComfyUI's runtime PROMPT graph.
|
||
|
||
This intentionally starts at MajoorGenInfoOverride.workflow_context when it
|
||
is linked, so an override node connected behind VAE Decode captures the
|
||
sampler branch that produced that image instead of its own seed widget.
|
||
"""
|
||
out: dict[str, Any] = {"version": 1, "mode": "override"}
|
||
try:
|
||
from mjr_am_backend.features.geninfo.graph_converter import _normalize_graph_input
|
||
from mjr_am_backend.features.geninfo.sampler_tracer import _is_sampler
|
||
except Exception:
|
||
return out
|
||
|
||
nodes_by_id = _normalize_graph_input(prompt, workflow)
|
||
if not nodes_by_id:
|
||
return out
|
||
|
||
start_ids = _workflow_context_start_ids(nodes_by_id)
|
||
if not start_ids:
|
||
start_ids = [nid for nid, node in nodes_by_id.items() if isinstance(node, dict)]
|
||
|
||
candidates = _runtime_sampler_candidates(nodes_by_id, start_ids)
|
||
candidates.sort(key=lambda item: (0 if _is_sampler(item[2]) else 1, item[0]))
|
||
for _, node_id, node in candidates:
|
||
payload = _runtime_sampler_payload_from_node(nodes_by_id, node_id, node)
|
||
if _payload_has_meaningful_value(payload, "seed"):
|
||
return payload
|
||
for key in ("steps", "cfg", "denoise", "sampler", "scheduler"):
|
||
if key not in out and _payload_has_meaningful_value(payload, key):
|
||
out[key] = payload[key]
|
||
return out
|
||
|
||
|
||
class MajoorGenInfoOverride(IO.ComfyNode):
|
||
"""Build explicit Majoor geninfo metadata for Save Image/Video nodes."""
|
||
|
||
@classmethod
|
||
def define_schema(cls) -> IO.Schema:
|
||
return IO.Schema(
|
||
node_id="MajoorGenInfoOverride",
|
||
display_name="〽️ Majoor Gen Info Override",
|
||
category="Majoor",
|
||
description="Build explicit geninfo metadata consumed by Majoor Save Image/Video.",
|
||
inputs=[
|
||
IO.String.Input("positive_prompt", default="", multiline=True),
|
||
IO.String.Input("negative_prompt", default="", multiline=True),
|
||
IO.AnyType.Input(
|
||
"workflow_context",
|
||
optional=True,
|
||
tooltip="Optional dependency input. Connect a late workflow node, for example VAE Decode IMAGE, so this node runs with full graph context.",
|
||
),
|
||
IO.Int.Input(
|
||
"seed", optional=True, default=-1, min=-1, max=0xffffffffffffffff, control_after_generate=False,
|
||
),
|
||
IO.Int.Input("steps", optional=True, default=-1, min=-1),
|
||
IO.Float.Input("cfg", optional=True, default=-1.0, min=-1.0, step=0.1),
|
||
IO.String.Input("sampler", optional=True, default=""),
|
||
IO.String.Input("scheduler", optional=True, default=""),
|
||
IO.String.Input("model", optional=True, default=""),
|
||
IO.String.Input("vae", optional=True, default=""),
|
||
IO.String.Input("clip", optional=True, default=""),
|
||
IO.Float.Input("denoise", optional=True, default=-1.0, min=-1.0, max=1.0, step=0.01),
|
||
IO.String.Input(
|
||
"loras_json",
|
||
optional=True,
|
||
default="",
|
||
multiline=True,
|
||
tooltip="JSON array: [{\"name\":\"lora.safetensors\",\"strength\":0.8}]",
|
||
),
|
||
IO.String.Input("workflow_notes", optional=True, default="", multiline=True),
|
||
IO.String.Input(
|
||
"custom_info_json",
|
||
optional=True,
|
||
default="",
|
||
multiline=True,
|
||
tooltip="JSON array: [{\"title\":\"Notes\",\"content\":\"...\",\"color\":\"#4CAF50\"}]",
|
||
),
|
||
],
|
||
outputs=[IO.Custom("MAJOOR_GENINFO").Output(display_name="geninfo_override")],
|
||
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
|
||
)
|
||
|
||
@classmethod
|
||
def execute(
|
||
cls,
|
||
positive_prompt: str = "",
|
||
negative_prompt: str = "",
|
||
workflow_context: Any | None = None,
|
||
seed: int = -1,
|
||
steps: int = -1,
|
||
cfg: float = -1.0,
|
||
sampler: str = "",
|
||
scheduler: str = "",
|
||
model: str = "",
|
||
vae: str = "",
|
||
clip: str = "",
|
||
denoise: float = -1.0,
|
||
loras_json: str = "",
|
||
workflow_notes: str = "",
|
||
custom_info_json: str = "",
|
||
) -> IO.NodeOutput:
|
||
prompt = cls.hidden.prompt
|
||
extra_pnginfo = cls.hidden.extra_pnginfo
|
||
manual = _build_geninfo_override_payload(
|
||
positive_prompt=positive_prompt,
|
||
negative_prompt=negative_prompt,
|
||
seed=seed,
|
||
steps=steps,
|
||
cfg=cfg,
|
||
sampler=sampler,
|
||
scheduler=scheduler,
|
||
model=model,
|
||
vae=vae,
|
||
clip=clip,
|
||
denoise=denoise,
|
||
loras_json=loras_json,
|
||
workflow_notes=workflow_notes,
|
||
custom_info_json=custom_info_json,
|
||
)
|
||
parsed = _parse_context_geninfo(prompt, extra_pnginfo)
|
||
parsed_payload = _parsed_geninfo_to_override_payload(parsed)
|
||
runtime_payload = _prompt_runtime_sampler_payload(
|
||
prompt,
|
||
extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None,
|
||
)
|
||
fallback = _merge_missing_override_fields(
|
||
_workflow_context_fallback_payload(extra_pnginfo),
|
||
parsed_payload,
|
||
)
|
||
fallback = _prefer_prompt_runtime_fields(fallback, parsed_payload)
|
||
fallback = _prefer_prompt_runtime_fields(fallback, runtime_payload)
|
||
if workflow_context is not None and fallback.get("seed") is not None:
|
||
manual.pop("seed", None)
|
||
payload = _merge_missing_override_fields(manual, fallback)
|
||
if workflow_context is not None:
|
||
payload = _prefer_prompt_runtime_fields(payload, fallback)
|
||
for key in ("prompt", "negative_prompt", "model", "vae", "clip", "loras"):
|
||
if _payload_has_meaningful_value(fallback, key):
|
||
payload[key] = fallback[key]
|
||
if _payload_has_meaningful_value(runtime_payload, "seed"):
|
||
payload["seed"] = runtime_payload["seed"]
|
||
return IO.NodeOutput(payload, ui={"majoor_geninfo_override": [payload]})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# MajoorSaveImage
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class MajoorSaveImage(IO.ComfyNode):
|
||
"""
|
||
Save images to the ComfyUI output directory with **generation_time_ms**
|
||
persisted in the PNG text metadata.
|
||
|
||
Behaves identically to the built-in *SaveImage* node but adds an
|
||
optional ``generation_time_ms`` input. When left unconnected the
|
||
node automatically computes the time elapsed since the prompt started.
|
||
"""
|
||
|
||
@classmethod
|
||
def define_schema(cls) -> IO.Schema:
|
||
return IO.Schema(
|
||
node_id="MajoorSaveImage",
|
||
display_name="〽️ Majoor Save Image 💾",
|
||
category="Majoor",
|
||
description="Save images with generation_time_ms metadata for Majoor Assets Manager.",
|
||
inputs=[
|
||
IO.Image.Input("images", tooltip="The images to save."),
|
||
IO.String.Input(
|
||
"filename_prefix",
|
||
default="Majoor",
|
||
tooltip="Prefix for the saved file. Supports ComfyUI formatting placeholders.",
|
||
),
|
||
IO.Int.Input(
|
||
"generation_time_ms",
|
||
optional=True,
|
||
default=-1,
|
||
min=-1,
|
||
tooltip="Generation time in milliseconds. -1 = auto-detect from prompt lifecycle.",
|
||
),
|
||
IO.Custom("MAJOOR_GENINFO").Input(
|
||
"geninfo_override",
|
||
optional=True,
|
||
tooltip="Explicit geninfo override from Majoor Gen Info Override.",
|
||
),
|
||
],
|
||
outputs=[],
|
||
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo, IO.Hidden.unique_id],
|
||
is_output_node=True,
|
||
)
|
||
|
||
@classmethod
|
||
def execute(
|
||
cls,
|
||
images: torch.Tensor,
|
||
filename_prefix: str = "Majoor",
|
||
generation_time_ms: int = -1,
|
||
geninfo_override: Any | None = None,
|
||
) -> IO.NodeOutput:
|
||
prompt = cls.hidden.prompt
|
||
extra_pnginfo = cls.hidden.extra_pnginfo
|
||
unique_id = cls.hidden.unique_id
|
||
|
||
output_dir = folder_paths.get_output_directory()
|
||
output_type = "output"
|
||
compress_level = 4
|
||
|
||
filename_prefix = _resolve_filename_prefix_placeholders(filename_prefix)
|
||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||
folder_paths.get_save_image_path(
|
||
filename_prefix,
|
||
output_dir,
|
||
images[0].shape[1],
|
||
images[0].shape[0],
|
||
)
|
||
)
|
||
|
||
gen_time = generation_time_ms if generation_time_ms >= 0 else _get_generation_time_ms()
|
||
|
||
results: list[dict[str, str]] = []
|
||
progress = _make_progress_bar(len(images))
|
||
for batch_number, image in enumerate(images):
|
||
img = Image.fromarray(_tensor_to_bytes(image))
|
||
|
||
metadata: PngInfo | None = None
|
||
if not args.disable_metadata:
|
||
metadata = _build_metadata(prompt, extra_pnginfo, gen_time, geninfo_override, unique_id)
|
||
|
||
fname = filename.replace("%batch_num%", str(batch_number))
|
||
file = f"{fname}_{counter:05}_.png"
|
||
img.save(
|
||
os.path.join(full_output_folder, file),
|
||
pnginfo=metadata,
|
||
compress_level=compress_level,
|
||
icc_profile=_srgb_icc_profile(),
|
||
)
|
||
results.append(
|
||
{"filename": file, "subfolder": subfolder, "type": output_type}
|
||
)
|
||
counter += 1
|
||
progress.update(1)
|
||
|
||
return IO.NodeOutput(ui={"images": results})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# MajoorSaveVideo
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_SUPPORTED_VIDEO_FORMATS = [
|
||
"mp4 (h264)",
|
||
"gif",
|
||
"webp",
|
||
]
|
||
|
||
|
||
def _resolve_video_inputs(
|
||
video: Any | None,
|
||
images: torch.Tensor | None,
|
||
audio: dict | None,
|
||
frame_rate: float,
|
||
) -> tuple[torch.Tensor, float, dict | None] | None:
|
||
"""Return (images, fps, audio) or None when nothing to encode."""
|
||
torch_mod = _require_torch()
|
||
|
||
def _coerce_audio_input(candidate: Any | None) -> dict | None:
|
||
if candidate is None:
|
||
return None
|
||
if isinstance(candidate, dict):
|
||
return candidate if candidate.get("waveform") is not None else None
|
||
waveform = getattr(candidate, "waveform", None)
|
||
if waveform is not None:
|
||
return {
|
||
"waveform": waveform,
|
||
"sample_rate": getattr(candidate, "sample_rate", 44100),
|
||
}
|
||
if hasattr(candidate, "__getitem__"):
|
||
try:
|
||
waveform = candidate["waveform"]
|
||
if waveform is not None:
|
||
sample_rate = candidate["sample_rate"] if "sample_rate" in candidate else 44100
|
||
return {
|
||
"waveform": waveform,
|
||
"sample_rate": sample_rate,
|
||
}
|
||
except Exception:
|
||
_log.debug("_coerce_audio_input: suppressed exception", exc_info=True)
|
||
return None
|
||
|
||
resolved_images: torch.Tensor | None = None
|
||
resolved_audio: dict | None = audio
|
||
resolved_fps: float = frame_rate
|
||
|
||
if video is not None:
|
||
get_components = getattr(video, "get_components", None)
|
||
if callable(get_components):
|
||
components = get_components()
|
||
resolved_images = components.images
|
||
resolved_fps = float(components.frame_rate) if components.frame_rate else frame_rate
|
||
if resolved_audio is None and components.audio is not None:
|
||
resolved_audio = components.audio
|
||
else:
|
||
# Accept AUDIO payloads accidentally/explicitly routed to the video socket.
|
||
if resolved_audio is None:
|
||
resolved_audio = _coerce_audio_input(video)
|
||
if images is not None:
|
||
resolved_images = images
|
||
else:
|
||
return None
|
||
elif images is not None:
|
||
resolved_images = images
|
||
else:
|
||
return None
|
||
|
||
if resolved_images is None or (
|
||
isinstance(resolved_images, torch_mod.Tensor) and resolved_images.size(0) == 0
|
||
):
|
||
return None
|
||
return resolved_images, resolved_fps, resolved_audio
|
||
|
||
|
||
def _save_animated(
|
||
resolved_images: torch.Tensor,
|
||
fmt: str,
|
||
fps: float,
|
||
loop_count: int,
|
||
output_folder: str,
|
||
filename: str,
|
||
counter: int,
|
||
progress: Any | None = None,
|
||
) -> str:
|
||
"""Save GIF / WebP via Pillow. Returns the output filename."""
|
||
out_file = f"{filename}_{counter:05}.{fmt}"
|
||
out_path = os.path.join(output_folder, out_file)
|
||
num_frames = resolved_images.size(0)
|
||
frames: list[Image.Image] = []
|
||
progress = progress or _make_progress_bar(num_frames)
|
||
for index in range(num_frames):
|
||
frames.append(Image.fromarray(_tensor_to_bytes(resolved_images[index])))
|
||
progress.update(1)
|
||
save_kwargs: dict[str, Any] = {
|
||
"save_all": True,
|
||
"append_images": frames[1:],
|
||
"duration": round(1000 / fps),
|
||
"loop": loop_count,
|
||
}
|
||
if fmt == "gif":
|
||
save_kwargs["disposal"] = 2
|
||
if fmt == "webp":
|
||
save_kwargs["lossless"] = False
|
||
frames[0].save(out_path, format=fmt.upper(), **save_kwargs)
|
||
return out_file
|
||
|
||
|
||
def _build_container_metadata(
|
||
prompt: Any | None,
|
||
extra_pnginfo: dict | None,
|
||
generation_time_ms: int,
|
||
geninfo_override: Any | None = None,
|
||
unique_id: Any | None = None,
|
||
) -> dict[str, str]:
|
||
"""Build the metadata dict to embed into an MP4 container."""
|
||
meta: dict[str, str] = {}
|
||
if args.disable_metadata:
|
||
return meta
|
||
if prompt is not None:
|
||
meta["prompt"] = json.dumps(prompt)
|
||
if extra_pnginfo is not None:
|
||
for key in extra_pnginfo:
|
||
meta[key] = json.dumps(extra_pnginfo[key])
|
||
override_payload = _coerce_geninfo_override_payload(geninfo_override)
|
||
if override_payload is not None:
|
||
meta["majoor_geninfo"] = json.dumps(override_payload)
|
||
meta.update(_resolve_execution_metadata(prompt, extra_pnginfo, unique_id))
|
||
meta["generation_time_ms"] = str(generation_time_ms)
|
||
meta["CreationTime"] = datetime.datetime.now().isoformat(" ")[:19]
|
||
return meta
|
||
|
||
|
||
def _prepare_audio(
|
||
audio_input: Any | None,
|
||
fps_fraction: Fraction,
|
||
num_frames: int,
|
||
) -> tuple[Any, int, str] | None:
|
||
"""Extract waveform, sample_rate, layout from an AUDIO input. Returns None if no audio."""
|
||
|
||
def _extract_audio_payload(candidate: Any | None) -> tuple[Any | None, int]:
|
||
if candidate is None:
|
||
return None, 44100
|
||
|
||
if isinstance(candidate, dict):
|
||
if candidate.get("waveform") is not None:
|
||
return candidate.get("waveform"), int(candidate.get("sample_rate", 44100) or 44100)
|
||
for key in ("audio", "sound", "data"):
|
||
nested = candidate.get(key)
|
||
if nested is not None:
|
||
waveform, sr = _extract_audio_payload(nested)
|
||
if waveform is not None:
|
||
return waveform, sr
|
||
|
||
if isinstance(candidate, (list, tuple)):
|
||
for item in candidate:
|
||
waveform, sr = _extract_audio_payload(item)
|
||
if waveform is not None:
|
||
return waveform, sr
|
||
|
||
waveform = getattr(candidate, "waveform", None)
|
||
if waveform is not None:
|
||
return waveform, int(getattr(candidate, "sample_rate", 44100) or 44100)
|
||
|
||
if hasattr(candidate, "__getitem__"):
|
||
try:
|
||
waveform = candidate["waveform"]
|
||
sample_rate = candidate["sample_rate"] if "sample_rate" in candidate else 44100
|
||
if waveform is not None:
|
||
return waveform, int(sample_rate or 44100)
|
||
except Exception:
|
||
_log.debug("_extract_audio_payload: suppressed exception", exc_info=True)
|
||
|
||
return None, 44100
|
||
|
||
def _normalize_waveform_channels_samples(waveform: Any) -> torch.Tensor | None:
|
||
torch_mod = _require_torch()
|
||
if not torch_mod.is_tensor(waveform):
|
||
return None
|
||
|
||
w = waveform
|
||
if w.ndim == 1:
|
||
w = w.unsqueeze(0)
|
||
elif w.ndim == 3:
|
||
# Prefer first batch item.
|
||
w = w[0]
|
||
|
||
if w.ndim != 2:
|
||
return None
|
||
|
||
# Support both [channels, samples] and [samples, channels].
|
||
if w.shape[0] > 8 and w.shape[1] <= 8:
|
||
w = w.transpose(0, 1)
|
||
|
||
return w.contiguous()
|
||
|
||
if audio_input is None:
|
||
return None
|
||
|
||
waveform_raw, sample_rate = _extract_audio_payload(audio_input)
|
||
waveform = _normalize_waveform_channels_samples(waveform_raw)
|
||
if waveform is None:
|
||
return None
|
||
|
||
max_samples = math.ceil((float(sample_rate) / float(fps_fraction)) * num_frames)
|
||
trimmed = waveform[:, :max_samples]
|
||
channels = trimmed.shape[0]
|
||
layout = {1: "mono", 2: "stereo", 6: "5.1"}.get(channels, "stereo")
|
||
return trimmed, int(sample_rate), layout
|
||
|
||
|
||
def _encode_mp4(
|
||
out_path: str,
|
||
resolved_images: torch.Tensor,
|
||
fps: float,
|
||
crf: int,
|
||
container_meta: dict[str, str],
|
||
audio_input: Any | None,
|
||
num_frames: int,
|
||
progress: Any | None = None,
|
||
) -> None:
|
||
"""Encode frames + optional audio into an MP4 via PyAV."""
|
||
fps_fraction = Fraction(round(fps * 1000), 1000)
|
||
audio_info = _prepare_audio(audio_input, fps_fraction, num_frames)
|
||
|
||
with av.open(out_path, mode="w", options={"movflags": "use_metadata_tags"}) as container:
|
||
for key, value in container_meta.items():
|
||
container.metadata[key] = value
|
||
|
||
stream = container.add_stream("libx264", rate=fps_fraction)
|
||
stream.width = resolved_images.shape[2]
|
||
stream.height = resolved_images.shape[1]
|
||
stream.pix_fmt = "yuv420p"
|
||
stream.bit_rate = 0
|
||
stream.options = {
|
||
"crf": str(crf),
|
||
"colorprim": "bt709",
|
||
"transfer": "bt709",
|
||
"colormatrix": "bt709",
|
||
"color_range": "tv",
|
||
}
|
||
# FFmpeg enum values: BT.709 = 1; MPEG/TV limited range = 1.
|
||
# Set codec-context fields as well as x264 options so the MP4 stream
|
||
# remains correctly tagged across supported PyAV/FFmpeg versions.
|
||
stream.codec_context.color_primaries = 1
|
||
stream.codec_context.color_trc = 1
|
||
stream.codec_context.colorspace = 1
|
||
stream.codec_context.color_range = 1
|
||
|
||
audio_stream = None
|
||
if audio_info is not None:
|
||
waveform, sample_rate, layout = audio_info
|
||
audio_stream = container.add_stream("aac", rate=sample_rate, layout=layout)
|
||
|
||
progress = progress or _make_progress_bar(num_frames)
|
||
for frame_tensor in resolved_images:
|
||
img = (frame_tensor * 255).clamp(0, 255).byte().cpu().numpy()
|
||
video_frame = av.VideoFrame.from_ndarray(img, format="rgb24")
|
||
video_frame = video_frame.reformat(format="yuv420p", dst_colorspace="ITU709")
|
||
for packet in stream.encode(video_frame):
|
||
container.mux(packet)
|
||
progress.update(1)
|
||
for packet in stream.encode():
|
||
container.mux(packet)
|
||
|
||
if audio_stream is not None and audio_info is not None:
|
||
waveform, sample_rate, layout = audio_info
|
||
audio_frame = av.AudioFrame.from_ndarray(
|
||
waveform.float().cpu().contiguous().numpy(),
|
||
format="fltp",
|
||
layout=layout,
|
||
)
|
||
audio_frame.sample_rate = sample_rate
|
||
audio_frame.pts = 0
|
||
for packet in audio_stream.encode(audio_frame):
|
||
container.mux(packet)
|
||
for packet in audio_stream.encode():
|
||
container.mux(packet)
|
||
|
||
|
||
class MajoorSaveVideo(IO.ComfyNode):
|
||
"""
|
||
Save a VIDEO or a batch of IMAGE frames as a video file.
|
||
|
||
For MP4 output the node uses **PyAV** (same as ComfyUI's native SaveVideo)
|
||
and writes all metadata – including ``generation_time_ms`` – directly into
|
||
the MP4 container so it persists long-term.
|
||
|
||
For GIF / WebP the node uses Pillow, with a PNG sidecar for metadata.
|
||
"""
|
||
|
||
@classmethod
|
||
def define_schema(cls) -> IO.Schema:
|
||
return IO.Schema(
|
||
node_id="MajoorSaveVideo",
|
||
display_name="〽️ Majoor Save Video 🎬",
|
||
category="Majoor",
|
||
description="Save images or video with generation_time_ms metadata for Majoor Assets Manager.",
|
||
inputs=[
|
||
IO.String.Input("filename_prefix", default="MajoorVideo"),
|
||
IO.Combo.Input("format", options=_SUPPORTED_VIDEO_FORMATS, default="mp4 (h264)"),
|
||
IO.Image.Input("images", optional=True, tooltip="Batch of frames to encode as video."),
|
||
IO.AnyType.Input(
|
||
"video",
|
||
optional=True,
|
||
tooltip="A VIDEO input, or AUDIO routed into this socket (native SaveVideo style).",
|
||
),
|
||
IO.Float.Input(
|
||
"frame_rate",
|
||
optional=True,
|
||
default=24.0,
|
||
min=1.0,
|
||
max=120.0,
|
||
step=1.0,
|
||
tooltip="FPS – ignored when a VIDEO input already carries its own frame rate.",
|
||
),
|
||
IO.Int.Input("loop_count", optional=True, default=0, min=0, max=100, step=1),
|
||
IO.Int.Input(
|
||
"generation_time_ms",
|
||
optional=True,
|
||
default=-1,
|
||
min=-1,
|
||
tooltip="Generation time in ms. -1 = auto-detect.",
|
||
),
|
||
IO.Custom("MAJOOR_GENINFO").Input(
|
||
"geninfo_override",
|
||
optional=True,
|
||
tooltip="Explicit geninfo override from Majoor Gen Info Override.",
|
||
),
|
||
IO.Audio.Input("audio", optional=True, tooltip="Audio to mux into the video."),
|
||
IO.Int.Input(
|
||
"crf",
|
||
optional=True,
|
||
default=19,
|
||
min=0,
|
||
max=63,
|
||
tooltip="Constant Rate Factor (lower = higher quality).",
|
||
),
|
||
IO.Boolean.Input(
|
||
"save_first_frame",
|
||
optional=True,
|
||
default=True,
|
||
tooltip="Save a PNG sidecar of the first frame with full metadata.",
|
||
),
|
||
],
|
||
outputs=[],
|
||
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo, IO.Hidden.unique_id],
|
||
is_output_node=True,
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
|
||
@classmethod
|
||
def execute(
|
||
cls,
|
||
filename_prefix: str = "MajoorVideo",
|
||
format: str = "mp4 (h264)",
|
||
images: torch.Tensor | None = None,
|
||
video: Any | None = None,
|
||
frame_rate: float = 24.0,
|
||
loop_count: int = 0,
|
||
generation_time_ms: int = -1,
|
||
geninfo_override: Any | None = None,
|
||
audio: dict | None = None,
|
||
crf: int = 19,
|
||
save_first_frame: bool = True,
|
||
) -> IO.NodeOutput:
|
||
prompt = cls.hidden.prompt
|
||
extra_pnginfo = cls.hidden.extra_pnginfo
|
||
unique_id = cls.hidden.unique_id
|
||
|
||
output_dir = folder_paths.get_output_directory()
|
||
output_type = "output"
|
||
|
||
resolved = _resolve_video_inputs(video, images, audio, frame_rate)
|
||
if resolved is None:
|
||
return IO.NodeOutput(ui={"videos": []})
|
||
resolved_images, resolved_fps, resolved_audio = resolved
|
||
|
||
gen_time = generation_time_ms if generation_time_ms >= 0 else _get_generation_time_ms()
|
||
num_frames = resolved_images.size(0)
|
||
|
||
filename_prefix = _resolve_filename_prefix_placeholders(filename_prefix)
|
||
full_output_folder, filename, _counter, subfolder, _prefix = (
|
||
folder_paths.get_save_image_path(
|
||
filename_prefix,
|
||
output_dir,
|
||
resolved_images[0].shape[1],
|
||
resolved_images[0].shape[0],
|
||
)
|
||
)
|
||
counter = _next_counter(full_output_folder, filename)
|
||
|
||
# --- PNG sidecar with full metadata ---
|
||
png_metadata = _build_metadata(prompt, extra_pnginfo, gen_time, geninfo_override, unique_id)
|
||
|
||
sidecar_file: str | None = None
|
||
if save_first_frame:
|
||
sidecar_file = f"{filename}_{counter:05}.png"
|
||
Image.fromarray(_tensor_to_bytes(resolved_images[0])).save(
|
||
os.path.join(full_output_folder, sidecar_file),
|
||
pnginfo=png_metadata,
|
||
compress_level=4,
|
||
icc_profile=_srgb_icc_profile(),
|
||
)
|
||
|
||
# --- GIF / WebP via Pillow ---
|
||
if format in ("gif", "webp"):
|
||
progress = _make_progress_bar(num_frames)
|
||
out_file = _save_animated(
|
||
resolved_images, format, resolved_fps, loop_count,
|
||
full_output_folder, filename, counter, progress,
|
||
)
|
||
return IO.NodeOutput(ui=_build_video_ui(out_file, subfolder, output_type, out_file)["ui"])
|
||
|
||
# --- MP4 via PyAV ---
|
||
container_meta = _build_container_metadata(prompt, extra_pnginfo, gen_time, geninfo_override, unique_id)
|
||
out_file = f"{filename}_{counter:05}_.mp4"
|
||
out_path = os.path.join(full_output_folder, out_file)
|
||
|
||
progress = _make_progress_bar(num_frames)
|
||
_encode_mp4(
|
||
out_path,
|
||
resolved_images,
|
||
resolved_fps,
|
||
crf,
|
||
container_meta,
|
||
resolved_audio,
|
||
num_frames,
|
||
progress,
|
||
)
|
||
|
||
return IO.NodeOutput(ui=_build_video_ui(out_file, subfolder, output_type, sidecar_file)["ui"])
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Registration helpers (ComfyUI Nodes V3 extension entrypoint)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class MajoorAssetsManagerExtension(ComfyExtension):
|
||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||
return [MajoorGenInfoOverride, MajoorSaveImage, MajoorSaveVideo]
|
||
|
||
|
||
async def comfy_entrypoint() -> MajoorAssetsManagerExtension:
|
||
return MajoorAssetsManagerExtension()
|