Files
Ewald 843ba2d823 Add 16-bit PNG, EXR, AVIF and AV1/WebM/MKV output to Majoor save nodes
Save Image gains format, input_color_space and crf options backed by the core Save Image (Advanced) encoders. Save Video gains mp4/mkv/webm with H.264 or AV1 and keeps the source bit depth and HDR colour space. Both nodes return their frames, and the video sidecar PNG now honours --disable-metadata.
2026-10-01 12:18:20 +02:00

1730 lines
65 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_api.latest._input_impl.video_types import set_video_color_properties as _set_video_color_properties # type: ignore[import-untyped]
except ImportError:
_set_video_color_properties = None
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 _metadata_items(
prompt: Any | None,
extra_pnginfo: dict | None,
generation_time_ms: int,
geninfo_override: Any | None = None,
unique_id: Any | None = None,
) -> dict[str, str]:
"""Text metadata shared by PNG text chunks and video container tags."""
items: dict[str, str] = {}
if prompt is not None:
items["prompt"] = json.dumps(prompt)
if extra_pnginfo is not None:
for key in extra_pnginfo:
items[key] = json.dumps(extra_pnginfo[key])
override_payload = _coerce_geninfo_override_payload(geninfo_override)
if override_payload is not None:
items["majoor_geninfo"] = json.dumps(override_payload)
items.update(_resolve_execution_metadata(prompt, extra_pnginfo, unique_id))
items["generation_time_ms"] = str(generation_time_ms)
items["CreationTime"] = datetime.datetime.now().isoformat(" ")[:19]
return items
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()
for key, value in _metadata_items(prompt, extra_pnginfo, generation_time_ms, geninfo_override, unique_id).items():
metadata.add_text(key, value)
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]})
_IMAGE_FORMATS = ["png", "png 16-bit", "exr", "avif"]
_IMAGE_COLOR_SPACES = {
"png": ("sRGB",),
"png 16-bit": ("sRGB",),
"exr": ("sRGB", "HDR", "linear"),
"avif": ("sRGB", "HDR", "HDR PQ"),
}
def _png_bytes_with_text(png_bytes: bytes, items: dict[str, str], text_chunk: Any) -> bytes:
"""Insert text chunks right after IHDR (same layout as the core PNG injector)."""
ihdr_end = 8 + 8 + int.from_bytes(png_bytes[8:12], "big") + 4
chunks = b"".join(text_chunk(key, value) for key, value in items.items())
return png_bytes[:ihdr_end] + chunks + png_bytes[ihdr_end:]
def _native_metadata(
prompt: Any | None,
extra_pnginfo: dict | None,
generation_time_ms: int,
geninfo_override: Any | None,
) -> dict[str, Any]:
"""Unserialized metadata for the core EXR/AVIF injectors, which JSON-encode each value."""
extras: dict[str, Any] = dict(extra_pnginfo or {})
override_payload = _coerce_geninfo_override_payload(geninfo_override)
if override_payload is not None:
extras["majoor_geninfo"] = override_payload
extras["generation_time_ms"] = generation_time_ms
extras["CreationTime"] = datetime.datetime.now().isoformat(" ")[:19]
return extras
def _save_core_format(
images: torch.Tensor,
file_format: str,
color_space: str,
crf: int,
output_folder: str,
filename: str,
subfolder: str,
counter: int,
prompt: Any | None,
extra_pnginfo: dict | None,
generation_time_ms: int,
geninfo_override: Any | None,
unique_id: Any | None,
progress: Any,
) -> list[dict[str, str]]:
"""Save 16-bit PNG / EXR / AVIF with ComfyUI's own encoders."""
try:
# Imported lazily: comfy_extras imports ComfyUI's `nodes`, which is not ready when custom nodes load.
from comfy_extras.nodes_images import ( # type: ignore[import-untyped]
_encode_image as core_encode_image,
_png_text_chunk as core_png_text_chunk,
_save_avif as core_save_avif,
inject_exr_metadata as core_inject_exr_metadata,
)
except ImportError as exc:
raise RuntimeError(f"{file_format} output requires a ComfyUI version with Save Image (Advanced)") from exc
if color_space not in _IMAGE_COLOR_SPACES[file_format]:
raise ValueError(f"{file_format} does not support input_color_space '{color_space}'")
write_metadata = not args.disable_metadata
native = _native_metadata(prompt, extra_pnginfo, generation_time_ms, geninfo_override)
extension = "png" if file_format == "png 16-bit" else file_format
results: list[dict[str, str]] = []
for batch_number, image in enumerate(images):
name = filename.replace("%batch_num%", str(batch_number))
file = f"{name}_{counter:05}_.{extension}"
path = os.path.join(output_folder, file)
if file_format == "avif":
avif_metadata = None
if write_metadata:
avif_metadata = {"prompt": prompt, **native} if prompt is not None else native
core_save_avif(image.unsqueeze(0), path, "auto", color_space, crf, metadata=avif_metadata)
else:
bit_depth = "16-bit" if file_format == "png 16-bit" else "32-bit float"
encoded = core_encode_image(image, extension, bit_depth, color_space)
if write_metadata and file_format == "exr":
encoded = core_inject_exr_metadata(encoded, prompt, native, color_space)
elif write_metadata:
items = _metadata_items(prompt, extra_pnginfo, generation_time_ms, geninfo_override, unique_id)
encoded = _png_bytes_with_text(encoded, items, core_png_text_chunk)
with open(path, "wb") as f:
f.write(encoded)
results.append({"filename": file, "subfolder": subfolder, "type": "output"})
counter += 1
progress.update(1)
return results
# ---------------------------------------------------------------------------
# MajoorSaveImage
# ---------------------------------------------------------------------------
class MajoorSaveImage(IO.ComfyNode):
"""
Save images to the ComfyUI output directory with **generation_time_ms**
persisted in the PNG text metadata.
Behaves like the built-in *SaveImage* node (including the pass-through
``images`` output) 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.",
),
IO.Combo.Input(
"format",
options=_IMAGE_FORMATS,
optional=True,
default="png",
tooltip="File format. PNG 16-bit, EXR and AVIF are written with ComfyUI's Save Image (Advanced) encoders.",
),
IO.Combo.Input(
"input_color_space",
options=["sRGB", "HDR", "HDR PQ", "linear"],
optional=True,
default="sRGB",
advanced=True,
tooltip="Color space of the input images (EXR: sRGB, HDR, linear; AVIF: sRGB, HDR, HDR PQ). Ignored for PNG.",
),
IO.Int.Input(
"crf",
optional=True,
default=18,
min=1,
max=63,
advanced=True,
tooltip="AVIF quality (lower = higher quality). Ignored for other formats.",
),
],
outputs=[IO.Image.Output(display_name="images")],
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,
format: str = "png",
input_color_space: str = "sRGB",
crf: int = 18,
) -> 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))
if format != "png":
results = _save_core_format(
images, format, input_color_space, crf, full_output_folder, filename, subfolder, counter,
prompt, extra_pnginfo, gen_time, geninfo_override, unique_id, progress,
)
return IO.NodeOutput(images, ui={"images": results})
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(images, ui={"images": results})
# ---------------------------------------------------------------------------
# MajoorSaveVideo
# ---------------------------------------------------------------------------
# format label -> (container extension, codec); gif/webp go through Pillow.
_VIDEO_FORMAT_SPECS: dict[str, tuple[str, str]] = {
"mp4 (h264)": ("mp4", "h264"),
"mp4 (av1)": ("mp4", "av1"),
"mkv (h264)": ("mkv", "h264"),
"mkv (av1)": ("mkv", "av1"),
"webm (av1)": ("webm", "av1"),
}
_SUPPORTED_VIDEO_FORMATS = [*_VIDEO_FORMAT_SPECS, "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, int, str] | None:
"""Return (images, fps, audio, bit_depth, color_space) 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
bit_depth = 8
color_space = "sRGB"
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
bit_depth = int(video.get_bit_depth())
color_space = video.get_color_space()
if color_space not in ("sRGB", "HDR", "HDR PQ"):
color_space = "sRGB"
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, bit_depth, color_space
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 a video container."""
if args.disable_metadata:
return {}
return _metadata_items(prompt, extra_pnginfo, generation_time_ms, geninfo_override, unique_id)
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_video(
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,
bit_depth: int = 8,
color_space: str = "sRGB",
container_ext: str = "mp4",
codec: str = "h264",
) -> None:
"""Encode frames + optional audio into an MP4/MKV/WebM container via PyAV."""
is_10bit = bit_depth >= 10
is_hdr = color_space != "sRGB" and _set_video_color_properties is not None
pix_fmt = "yuv420p10le" if is_10bit else "yuv420p"
dst_colorspace = 9 if is_hdr else 1 # FFmpeg NCL: BT.2020 = 9, BT.709 = 1
is_webm = container_ext == "webm"
fps_fraction = Fraction(round(fps * 1000), 1000)
audio_info = _prepare_audio(audio_input, fps_fraction, num_frames)
open_options = {"movflags": "use_metadata_tags"} if container_ext == "mp4" else None
with av.open(out_path, mode="w", options=open_options) as container:
for key, value in container_meta.items():
container.metadata[key] = value
stream = container.add_stream("libsvtav1" if codec == "av1" else "libx264", rate=fps_fraction)
stream.width = resolved_images.shape[2]
stream.height = resolved_images.shape[1]
stream.pix_fmt = pix_fmt
stream.bit_rate = 0
stream.options = {"crf": str(min(crf, 51) if codec == "h264" else crf)}
if is_hdr:
_set_video_color_properties(stream.codec_context, color_space)
else:
if codec == "h264":
stream.options.update(
{
"colorprim": "bt709",
"transfer": "bt709",
"colormatrix": "bt709",
"color_range": "tv",
}
)
# FFmpeg enum values: BT.709 = 1; MPEG/TV limited range = 1.
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
audio_resampler = None
if audio_info is not None:
waveform, sample_rate, layout = audio_info
audio_rate = 48000 if is_webm else sample_rate
audio_stream = container.add_stream("libopus" if is_webm else "aac", rate=audio_rate, layout=layout)
if audio_rate != sample_rate:
audio_resampler = av.audio.resampler.AudioResampler(format="fltp", layout=layout, rate=audio_rate)
progress = progress or _make_progress_bar(num_frames)
for frame_tensor in resolved_images:
if is_10bit:
img = (frame_tensor.float() * 65535).clamp(0, 65535).cpu().numpy().astype(np.uint16)
video_frame = av.VideoFrame.from_ndarray(img[..., :3], format="rgb48le")
else:
img = (frame_tensor * 255).clamp(0, 255).byte().cpu().numpy()
video_frame = av.VideoFrame.from_ndarray(img[..., :3], format="rgb24")
video_frame = video_frame.reformat(format=pix_fmt, dst_colorspace=dst_colorspace)
if is_hdr:
_set_video_color_properties(video_frame, color_space)
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
frames = [audio_frame] if audio_resampler is None else audio_resampler.resample(audio_frame)
for frame in frames:
for packet in audio_stream.encode(frame):
container.mux(packet)
if audio_resampler is not None:
for frame in audio_resampler.resample(None):
for packet in audio_stream.encode(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 / MKV / WebM output (H.264 or AV1) the node uses **PyAV** (same as
ComfyUI's native SaveVideo) and writes all metadata – including
``generation_time_ms`` – directly into the container so it persists
long-term. 10-bit and HDR (HLG / PQ) videos keep their depth and color space.
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=[IO.Image.Output(display_name="images")],
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(images, ui={"videos": []})
resolved_images, resolved_fps, resolved_audio, bit_depth, color_space = 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 = (
None if args.disable_metadata
else _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(resolved_images, ui=_build_video_ui(out_file, subfolder, output_type, out_file)["ui"])
# --- MP4 / MKV / WebM via PyAV ---
container_ext, codec = _VIDEO_FORMAT_SPECS[format]
container_meta = _build_container_metadata(prompt, extra_pnginfo, gen_time, geninfo_override, unique_id)
out_file = f"{filename}_{counter:05}_.{container_ext}"
out_path = os.path.join(full_output_folder, out_file)
progress = _make_progress_bar(num_frames)
_encode_video(
out_path,
resolved_images,
resolved_fps,
crf,
container_meta,
resolved_audio,
num_frames,
progress,
bit_depth,
color_space,
container_ext,
codec,
)
return IO.NodeOutput(resolved_images, 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()