Files
T
Ewald 94f212a261 fix(backend): log the remaining silently-swallowed exceptions (batch 5/final)
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.
2026-09-12 00:14:02 +02:00

1551 lines
57 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()