feat(v3): migrate nodes to native Node 2.0 API and fix ComfyExtension entrypoint
- Refactor 26 nodes to io.ComfyNode with define_schema and execute\n- Implement ComfyExtension subclass with async comfy_entrypoint\n- Preserve majoor_imageops synthetic namespace loader to avoid nodes module collision\n- Update and validate unit, registry and frontend test suites\n- Rebuild frontend dist files
This commit is contained in:
+44
-363
@@ -1,30 +1,21 @@
|
||||
"""
|
||||
ComfyUI custom node entrypoint.
|
||||
|
||||
ComfyUI loads custom nodes via `importlib.util.spec_from_file_location()` with a synthetic module name
|
||||
that is not a Python package, so relative imports like `from .nodes.foo import ...` can fail.
|
||||
|
||||
To stay ComfyUI-proof, we create an internal package namespace and load node modules under it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from comfy_api.latest import io as _node20_io
|
||||
except Exception: # pragma: no cover - compatibility fallback when ComfyUI is older/missing stubs
|
||||
from comfy_api.latest import ComfyExtension
|
||||
except Exception: # pragma: no cover
|
||||
_node20_io = None
|
||||
ComfyExtension = object
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# Stable, collision-resistant package prefix for our internal imports.
|
||||
_PKG = "majoor_imageops"
|
||||
import importlib.util
|
||||
import types
|
||||
|
||||
_PKG = "majoor_imageops"
|
||||
|
||||
def _ensure_pkg(name: str, path: Path, file_hint: Path | None = None) -> types.ModuleType:
|
||||
mod = sys.modules.get(name)
|
||||
@@ -36,7 +27,6 @@ def _ensure_pkg(name: str, path: Path, file_hint: Path | None = None) -> types.M
|
||||
mod.__file__ = str(file_hint)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_module(mod_name: str, file_path: Path) -> types.ModuleType:
|
||||
existing = sys.modules.get(mod_name)
|
||||
if existing is not None:
|
||||
@@ -49,285 +39,10 @@ def _load_module(mod_name: str, file_path: Path) -> types.ModuleType:
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _humanize_node_id(node_id: str) -> str:
|
||||
label = re.sub(r"(?<!^)(?=[A-Z])", " ", str(node_id or "")).strip()
|
||||
return label.replace("Ajust", "Color Correct").strip()
|
||||
|
||||
|
||||
def _field_factory(kind: str):
|
||||
if _node20_io is None:
|
||||
return None
|
||||
direct = getattr(_node20_io, kind, None)
|
||||
if direct is not None:
|
||||
return direct
|
||||
fallback_map = {
|
||||
"Float": "Int",
|
||||
"Color": "String",
|
||||
"Bool": "Boolean",
|
||||
"Combo": "String",
|
||||
}
|
||||
return getattr(_node20_io, fallback_map.get(kind, "String"), None)
|
||||
|
||||
|
||||
def _custom_field_factory(type_name: str):
|
||||
if _node20_io is None:
|
||||
return None
|
||||
custom = getattr(_node20_io, "Custom", None)
|
||||
if callable(custom):
|
||||
try:
|
||||
return custom(type_name)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _make_schema_input(name: str, spec, optional: bool = False):
|
||||
if _node20_io is None:
|
||||
return None
|
||||
|
||||
raw_type = spec[0] if isinstance(spec, (tuple, list)) and spec else "STRING"
|
||||
opts = spec[1] if isinstance(spec, (tuple, list)) and len(spec) > 1 and isinstance(spec[1], dict) else {}
|
||||
kwargs = dict(opts)
|
||||
if optional:
|
||||
kwargs["optional"] = True
|
||||
|
||||
# Remap legacy INPUT_TYPES option keys that changed in the Node 2.0 API.
|
||||
# forceInput (legacy camelCase) -> force_input (new snake_case on WidgetInput).
|
||||
force_input_val = kwargs.pop("forceInput", None)
|
||||
# display: "slider"/"number" -> display_mode: NumberDisplay enum (Int/Float only).
|
||||
display_val = kwargs.pop("display", None)
|
||||
display_mode = None
|
||||
if display_val is not None:
|
||||
_nd = getattr(_node20_io, "NumberDisplay", None)
|
||||
if _nd is not None:
|
||||
try:
|
||||
display_mode = _nd(display_val)
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
if isinstance(raw_type, (list, tuple)) and not isinstance(raw_type, str):
|
||||
factory = _field_factory("Combo")
|
||||
if not factory:
|
||||
return None
|
||||
# Combo.Input uses 'options', not 'choices'.
|
||||
if force_input_val is not None:
|
||||
kwargs["force_input"] = force_input_val
|
||||
return factory.Input(name, options=list(raw_type), **kwargs)
|
||||
|
||||
type_name = str(raw_type or "STRING").upper()
|
||||
if type_name == "IMAGE,VIDEO" and getattr(_node20_io, "MultiType", None) is not None:
|
||||
existing = kwargs.pop("extra_dict", {}) or {}
|
||||
if force_input_val is not None:
|
||||
kwargs["extra_dict"] = {"forceInput": force_input_val, **existing}
|
||||
return _node20_io.MultiType.Input(name, types=[_node20_io.Image, _node20_io.Video], **kwargs)
|
||||
|
||||
if "," in type_name or type_name == "VIDEO":
|
||||
factory = _custom_field_factory(type_name) or _field_factory("Image")
|
||||
elif type_name == "COLOR" or ("COLOR" in name.lower() and type_name == "STRING"):
|
||||
factory = _field_factory("Color")
|
||||
elif type_name in {"BOOLEAN", "BOOL"}:
|
||||
factory = _field_factory("Boolean")
|
||||
elif type_name == "INT":
|
||||
factory = _field_factory("Int")
|
||||
elif type_name == "FLOAT":
|
||||
factory = _field_factory("Float")
|
||||
elif type_name == "MASK":
|
||||
factory = _field_factory("Mask")
|
||||
elif "IMAGE" in type_name or "VIDEO" in type_name:
|
||||
factory = _field_factory("Image")
|
||||
else:
|
||||
factory = _field_factory("String")
|
||||
|
||||
if not factory:
|
||||
return None
|
||||
|
||||
# For numeric widget types, forward the remapped display_mode.
|
||||
if display_mode is not None and type_name in {"INT", "FLOAT"}:
|
||||
kwargs["display_mode"] = display_mode
|
||||
|
||||
# Image/Mask use the base Input class (no force_input param).
|
||||
# Pass forceInput via extra_dict so it still appears in the serialised schema.
|
||||
if "IMAGE" in type_name or "VIDEO" in type_name or type_name == "MASK":
|
||||
if force_input_val is not None:
|
||||
existing = kwargs.pop("extra_dict", {}) or {}
|
||||
kwargs["extra_dict"] = {"forceInput": force_input_val, **existing}
|
||||
else:
|
||||
# All WidgetInput subclasses accept force_input directly.
|
||||
if force_input_val is not None:
|
||||
kwargs["force_input"] = force_input_val
|
||||
|
||||
return factory.Input(name, **kwargs)
|
||||
|
||||
|
||||
def _make_schema_output(name: str, output_type: str):
|
||||
if _node20_io is None:
|
||||
return None
|
||||
kind = str(output_type or "STRING").upper()
|
||||
if "," in kind or kind == "VIDEO":
|
||||
factory = _custom_field_factory(kind) or _field_factory("Image")
|
||||
elif kind == "MASK":
|
||||
factory = _field_factory("Mask")
|
||||
elif "IMAGE" in kind or "VIDEO" in kind:
|
||||
factory = _field_factory("Image")
|
||||
elif kind == "INT":
|
||||
factory = _field_factory("Int")
|
||||
elif kind == "FLOAT":
|
||||
factory = _field_factory("Float")
|
||||
elif kind in {"BOOLEAN", "BOOL"}:
|
||||
factory = _field_factory("Boolean")
|
||||
else:
|
||||
factory = _field_factory("String")
|
||||
return factory.Output(name, display_name=name) if factory else None
|
||||
|
||||
|
||||
|
||||
def _make_hidden_fields(hidden_spec: dict | None):
|
||||
if _node20_io is None or not hidden_spec:
|
||||
return []
|
||||
hidden_obj = getattr(_node20_io, "Hidden", None)
|
||||
if hidden_obj is None:
|
||||
return []
|
||||
out = []
|
||||
for name in hidden_spec:
|
||||
token = getattr(hidden_obj, name, None)
|
||||
if token is not None:
|
||||
out.append(token)
|
||||
return out
|
||||
|
||||
|
||||
def _build_legacy_schema(node_id: str, cls, display_name: str):
|
||||
if _node20_io is None:
|
||||
return None
|
||||
|
||||
legacy_inputs = cls.INPUT_TYPES() if callable(getattr(cls, "INPUT_TYPES", None)) else {}
|
||||
required = legacy_inputs.get("required", {}) if isinstance(legacy_inputs, dict) else {}
|
||||
optional = legacy_inputs.get("optional", {}) if isinstance(legacy_inputs, dict) else {}
|
||||
hidden = legacy_inputs.get("hidden", {}) if isinstance(legacy_inputs, dict) else {}
|
||||
|
||||
inputs = []
|
||||
for is_optional, group in ((False, required), (True, optional)):
|
||||
for input_name, input_spec in group.items():
|
||||
field = _make_schema_input(input_name, input_spec)
|
||||
if field is not None:
|
||||
if is_optional:
|
||||
# Newer ComfyUI Node 2.0 builds may return a frozen dict-like
|
||||
# field object that disallows attribute assignment. Try every
|
||||
# known shape so the schema always carries the optional flag.
|
||||
_set = False
|
||||
try:
|
||||
field.optional = True # dataclass / object form
|
||||
_set = True
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
if not _set:
|
||||
try:
|
||||
field["optional"] = True # dict form
|
||||
_set = True
|
||||
except (TypeError, KeyError):
|
||||
pass
|
||||
if not _set:
|
||||
# Fallback: rebuild the field with optional kwarg if supported.
|
||||
try:
|
||||
rebuilt = _make_schema_input(input_name, input_spec, optional=True)
|
||||
if rebuilt is not None:
|
||||
field = rebuilt
|
||||
except TypeError:
|
||||
pass
|
||||
inputs.append(field)
|
||||
|
||||
return_names = tuple(getattr(cls, "RETURN_NAMES", ()) or ())
|
||||
return_types = tuple(getattr(cls, "RETURN_TYPES", ()) or ())
|
||||
outputs = []
|
||||
for index, output_type in enumerate(return_types):
|
||||
name = return_names[index] if index < len(return_names) else f"output_{index + 1}"
|
||||
field = _make_schema_output(name, output_type)
|
||||
if field is not None:
|
||||
outputs.append(field)
|
||||
|
||||
# Only Append and Comp genuinely need accept_all_inputs=True (dynamic slot inputs).
|
||||
# Enabling it globally weakens V3 input validation on all other nodes for no gain.
|
||||
_DYNAMIC_INPUT_NODES = {"ImageOpsAppend", "ImageOpsComp"}
|
||||
schema_kwargs = {
|
||||
"node_id": node_id,
|
||||
"display_name": display_name,
|
||||
"category": getattr(cls, "CATEGORY", "image/imageops"),
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"accept_all_inputs": node_id in _DYNAMIC_INPUT_NODES,
|
||||
}
|
||||
hidden_fields = _make_hidden_fields(hidden)
|
||||
if hidden_fields:
|
||||
schema_kwargs["hidden"] = hidden_fields
|
||||
return _node20_io.Schema(**schema_kwargs)
|
||||
|
||||
|
||||
def _wrap_legacy_node20(node_id: str, cls, display_name: str):
|
||||
if callable(getattr(cls, "define_schema", None)) and callable(getattr(cls, "execute", None)):
|
||||
return cls
|
||||
if _node20_io is None:
|
||||
return cls
|
||||
|
||||
comfy_node_base = getattr(_node20_io, "ComfyNode", object)
|
||||
bases = (cls,) if not isinstance(comfy_node_base, type) or issubclass(cls, comfy_node_base) else (cls, comfy_node_base)
|
||||
|
||||
class Node20Compat(*bases):
|
||||
# v3 execution path uses FUNCTION to locate the callable; must point to
|
||||
# a classmethod so that getattr(cls, FUNCTION).__func__ works correctly.
|
||||
FUNCTION = "execute"
|
||||
|
||||
@classmethod
|
||||
def define_schema(inner_cls):
|
||||
return _build_legacy_schema(node_id, cls, display_name)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(inner_cls):
|
||||
# Explicitly override the legacy INPUT_TYPES() so that ComfyUI's v3
|
||||
# validation path receives a schema-derived dict with "COMBO" io_type
|
||||
# strings instead of raw lists. Without this, Python's MRO resolves
|
||||
# INPUT_TYPES to the legacy classmethod (which returns list-based Combo
|
||||
# values), and parse_class_inputs then tries `list_value in dict` which
|
||||
# raises TypeError: unhashable type: 'list'.
|
||||
schema = _build_legacy_schema(node_id, cls, display_name)
|
||||
# Older Comfy builds expose Schema.finalize(); newer builds drop it
|
||||
# because the Schema is finalised lazily. Guard so both shapes work.
|
||||
finalize = getattr(schema, "finalize", None)
|
||||
if callable(finalize):
|
||||
try:
|
||||
finalize()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return schema.get_v1_info(inner_cls).input
|
||||
except AttributeError:
|
||||
# Fall back to the legacy INPUT_TYPES from the original class so
|
||||
# the v1 validation path still has data even if the v3 schema
|
||||
# cannot be downgraded by this Comfy version.
|
||||
legacy = getattr(cls, "INPUT_TYPES", None)
|
||||
return legacy() if callable(legacy) else {"required": {}}
|
||||
|
||||
@classmethod
|
||||
def execute(inner_cls, **kwargs):
|
||||
fn_name = getattr(cls, "FUNCTION", None)
|
||||
if not fn_name:
|
||||
raise AttributeError(f"{node_id} is missing FUNCTION for Node 2.0 compatibility")
|
||||
instance = cls()
|
||||
fn = getattr(instance, fn_name)
|
||||
return fn(**kwargs)
|
||||
|
||||
Node20Compat.__name__ = cls.__name__
|
||||
Node20Compat.__qualname__ = cls.__qualname__
|
||||
Node20Compat.__module__ = cls.__module__
|
||||
Node20Compat.__doc__ = cls.__doc__
|
||||
return Node20Compat
|
||||
|
||||
|
||||
# Create internal package + nodes package so node files' relative imports work.
|
||||
_internal_package = _ensure_pkg(_PKG, BASE_DIR, BASE_DIR / "__init__.py")
|
||||
_ensure_pkg(f"{_PKG}.nodes", BASE_DIR / "nodes", BASE_DIR / "nodes" / "__init__.py")
|
||||
|
||||
_nodes_dir = BASE_DIR / "nodes"
|
||||
_load_module(f"{_PKG}.server", BASE_DIR / "server.py")
|
||||
|
||||
ImageOpsBlur = _load_module(f"{_PKG}.nodes.blur", _nodes_dir / "blur.py").ImageOpsBlur
|
||||
ImageOpsChannel = _load_module(f"{_PKG}.nodes.channel", _nodes_dir / "channel.py").ImageOpsChannel
|
||||
@@ -356,83 +71,49 @@ ImageOpsText = _load_module(f"{_PKG}.nodes.text", _nodes_dir / "text.py").ImageO
|
||||
ImageOpsFrameRange = _load_module(f"{_PKG}.nodes.frame_range", _nodes_dir / "frame_range.py").ImageOpsFrameRange
|
||||
ImageOpsAppend = _load_module(f"{_PKG}.nodes.append", _nodes_dir / "append.py").ImageOpsAppend
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ImageOpsBlur": ImageOpsBlur,
|
||||
"ImageOpsChannel": ImageOpsChannel,
|
||||
"ImageOpsCornerPin": ImageOpsCornerPin,
|
||||
"ImageOpsComp": ImageOpsComp,
|
||||
"ImageOpsCrop": ImageOpsCrop,
|
||||
"ImageOpsCropStitch": ImageOpsCropStitch,
|
||||
"ImageOpsDistort": ImageOpsDistort,
|
||||
"ImageOpsDraw": ImageOpsDraw,
|
||||
"ImageOpsTransform": ImageOpsTransform,
|
||||
"ImageOpsColorAjust": ImageOpsColorAjust,
|
||||
"ImageOpsInvert": ImageOpsInvert,
|
||||
"ImageOpsClamp": ImageOpsClamp,
|
||||
"ImageOpsMerge": ImageOpsMerge,
|
||||
"ImageOpsMaskConvert": ImageOpsMaskConvert,
|
||||
"ImageOpsNoise": ImageOpsNoise,
|
||||
"ImageOpsPadOut": ImageOpsPadOut,
|
||||
"ImageOpsPreview": ImageOpsPreview,
|
||||
"ImageOpsSpherize": ImageOpsSpherize,
|
||||
"ImageOpsConstant": ImageOpsConstant,
|
||||
"ImageOpsRamp": ImageOpsRamp,
|
||||
"ImageOpsGrain": ImageOpsGrain,
|
||||
"ImageOpsCameraShake": ImageOpsCameraShake,
|
||||
"ImageOpsKeyer": ImageOpsKeyer,
|
||||
"ImageOpsText": ImageOpsText,
|
||||
"ImageOpsFrameRange": ImageOpsFrameRange,
|
||||
"ImageOpsAppend": ImageOpsAppend,
|
||||
}
|
||||
NODES = [
|
||||
ImageOpsBlur,
|
||||
ImageOpsChannel,
|
||||
ImageOpsCornerPin,
|
||||
ImageOpsComp,
|
||||
ImageOpsCrop,
|
||||
ImageOpsCropStitch,
|
||||
ImageOpsDistort,
|
||||
ImageOpsDraw,
|
||||
ImageOpsTransform,
|
||||
ImageOpsColorAjust,
|
||||
ImageOpsInvert,
|
||||
ImageOpsClamp,
|
||||
ImageOpsMerge,
|
||||
ImageOpsMaskConvert,
|
||||
ImageOpsNoise,
|
||||
ImageOpsPadOut,
|
||||
ImageOpsPreview,
|
||||
ImageOpsSpherize,
|
||||
ImageOpsConstant,
|
||||
ImageOpsRamp,
|
||||
ImageOpsGrain,
|
||||
ImageOpsCameraShake,
|
||||
ImageOpsKeyer,
|
||||
ImageOpsText,
|
||||
ImageOpsFrameRange,
|
||||
ImageOpsAppend,
|
||||
]
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ImageOpsBlur": "〽️ ImageOps Blur",
|
||||
"ImageOpsChannel": "〽️ ImageOps Channels",
|
||||
"ImageOpsCornerPin": "〽️ ImageOps Corner Pin",
|
||||
"ImageOpsComp": "〽️ ImageOps Comp",
|
||||
"ImageOpsCrop": "〽️ ImageOps Resize/Crop",
|
||||
"ImageOpsCropStitch": "〽️ ImageOps Crop Stitch",
|
||||
"ImageOpsDistort": "〽️ ImageOps Distort",
|
||||
"ImageOpsDraw": "〽️ ImageOps Paint",
|
||||
"ImageOpsTransform": "〽️ ImageOps Transform",
|
||||
# Keep the legacy class key for workflow compatibility, but expose the
|
||||
# corrected node name in the UI.
|
||||
"ImageOpsColorAjust": "〽️ ImageOps Color Correct",
|
||||
"ImageOpsInvert": "〽️ ImageOps Invert",
|
||||
"ImageOpsClamp": "〽️ ImageOps Clamp",
|
||||
"ImageOpsMerge": "〽️ ImageOps Merge",
|
||||
"ImageOpsMaskConvert": "〽️ ImageOps Mask Convert",
|
||||
"ImageOpsNoise": "〽️ ImageOps Noise",
|
||||
"ImageOpsPadOut": "〽️ ImageOps PadOut",
|
||||
"ImageOpsPreview": "〽️ ImageOps Preview",
|
||||
"ImageOpsSpherize": "〽️ ImageOps Spherize",
|
||||
"ImageOpsConstant": "〽️ ImageOps Constant",
|
||||
"ImageOpsRamp": "〽️ ImageOps Ramp",
|
||||
"ImageOpsGrain": "〽️ ImageOps Grain",
|
||||
"ImageOpsCameraShake": "〽️ ImageOps Camera Shake",
|
||||
"ImageOpsKeyer": "〽️ ImageOps Keyer",
|
||||
"ImageOpsText": "〽️ ImageOps Text",
|
||||
"ImageOpsFrameRange": "〽️ ImageOps Frame Range",
|
||||
"ImageOpsAppend": "〽️ ImageOps Append",
|
||||
}
|
||||
if ComfyExtension is not object:
|
||||
class MajoorImageOpsExtension(ComfyExtension):
|
||||
async def get_node_list(self):
|
||||
return NODES
|
||||
|
||||
# V3NodeBase is imported through the private ``majoor_imageops`` namespace,
|
||||
# while ComfyUI loads this entrypoint under a synthetic module name. Expose the
|
||||
# root schema helpers on that private package so native V3 nodes can build their
|
||||
# schemas when /object_info asks for them. Without this bridge ComfyUI catches an
|
||||
# AttributeError and silently omits Channel, Clamp, Invert and MaskConvert.
|
||||
_internal_package._build_legacy_schema = _build_legacy_schema
|
||||
_internal_package.NODE_CLASS_MAPPINGS = NODE_CLASS_MAPPINGS
|
||||
_internal_package.NODE_DISPLAY_NAME_MAPPINGS = NODE_DISPLAY_NAME_MAPPINGS
|
||||
async def comfy_entrypoint():
|
||||
return MajoorImageOpsExtension()
|
||||
|
||||
for _node_id, _node_cls in list(NODE_CLASS_MAPPINGS.items()):
|
||||
_display = NODE_DISPLAY_NAME_MAPPINGS.get(_node_id, _humanize_node_id(_node_id))
|
||||
if _node_id in {"ImageOpsInvert", "ImageOpsClamp", "ImageOpsChannel", "ImageOpsMaskConvert"}:
|
||||
NODE_CLASS_MAPPINGS[_node_id] = _node_cls
|
||||
else:
|
||||
NODE_CLASS_MAPPINGS[_node_id] = _wrap_legacy_node20(_node_id, _node_cls, _display)
|
||||
# Fallback mappings for tests or extreme edge cases expecting legacy dictionary
|
||||
NODE_CLASS_MAPPINGS = {node.__name__: node for node in NODES}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {node.__name__: node.define_schema().display_name for node in NODES if hasattr(node, "define_schema")}
|
||||
|
||||
__all__ = [
|
||||
"comfy_entrypoint",
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
"WEB_DIRECTORY",
|
||||
|
||||
+58
-2
@@ -1,11 +1,67 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import {
|
||||
applyEffectToCanvas,
|
||||
blend,
|
||||
boolAny,
|
||||
compositeAt,
|
||||
compositeProcessedWithMask,
|
||||
fitCanvas,
|
||||
invertMaskCanvas,
|
||||
num,
|
||||
numAny,
|
||||
ops,
|
||||
premultLayerWithMask,
|
||||
renderCompPreview,
|
||||
resolveCompPreviewInputs,
|
||||
resolvePreviewMaskCanvas,
|
||||
strAny,
|
||||
w
|
||||
} from "./implementation.js";
|
||||
import { blendChannel01 } from "../shared/blend-modes.js";
|
||||
const blendOps = {
|
||||
merge: ops.merge,
|
||||
composite: ops.composite,
|
||||
comp: ops.comp
|
||||
};
|
||||
function merge(ctx, W, node, topCanvasOrInputs, _opts, frameIndex = 0) {
|
||||
const inputs = Array.isArray(topCanvasOrInputs) ? topCanvasOrInputs : [ctx.canvas, topCanvasOrInputs];
|
||||
const base = inputs[0] ?? ctx.canvas;
|
||||
const topCanvas = inputs[1] ?? null;
|
||||
if (!topCanvas) return fitCanvas(base, base.width || 1, base.height || 1);
|
||||
const mode = strAny(node, ["mode", "blend_mode"], "over", frameIndex);
|
||||
const foregroundFit = strAny(node, ["foreground_fit", "fit_mode"], "stretch", frameIndex);
|
||||
const blendSpace = strAny(node, ["blend_space", "color_space"], "linear", frameIndex);
|
||||
const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100, frameIndex) : numAny(node, ["mix", "factor", "fade_factor", "blend_factor", "start_level", "end_level"], 1, frameIndex);
|
||||
const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity;
|
||||
const merged = applyEffectToCanvas(base, (effectCtx, width, height) => {
|
||||
blend(effectCtx, width, height, topCanvas, mode, opacity, foregroundFit, blendSpace);
|
||||
});
|
||||
const effectMask = resolvePreviewMaskCanvas(node, base, inputs[2] ?? null, frameIndex);
|
||||
return effectMask ? compositeProcessedWithMask(base, merged, effectMask) : merged;
|
||||
}
|
||||
function composite(ctx, W, node, inputs) {
|
||||
const base = inputs[0] ?? ctx.canvas;
|
||||
const topInput = inputs[1] ?? null;
|
||||
if (!topInput) return base;
|
||||
const rawMask = inputs[2] ?? null;
|
||||
const maskInput = rawMask && boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(rawMask) : rawMask;
|
||||
const top = premultLayerWithMask(topInput, maskInput);
|
||||
const x = Math.round(numAny(node, ["x"], 0) + numAny(node, ["offset_x"], 0));
|
||||
const y = Math.round(numAny(node, ["y"], 0) + numAny(node, ["offset_y"], 0));
|
||||
const mode = strAny(node, ["mode", "blend_mode"], "over");
|
||||
const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100) : numAny(node, ["mix", "factor", "blend_factor", "start_level", "end_level"], 1);
|
||||
const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity;
|
||||
return compositeAt(base, top, mode, opacity, x, y, top.width || 1, top.height || 1);
|
||||
}
|
||||
function comp(ctx, W, node, inputs) {
|
||||
return renderCompPreview(
|
||||
node,
|
||||
resolveCompPreviewInputs(node, inputs)
|
||||
).canvas;
|
||||
}
|
||||
export {
|
||||
blendChannel01,
|
||||
blendOps
|
||||
blendOps,
|
||||
comp,
|
||||
composite,
|
||||
merge
|
||||
};
|
||||
|
||||
+276
-2
@@ -1,4 +1,39 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import { setWidgetValue } from "../shared/widgets.js";
|
||||
import {
|
||||
applyCrop,
|
||||
applyCropReformat,
|
||||
applyEffectToCanvas,
|
||||
applySpherize,
|
||||
applyTransform,
|
||||
boolAny,
|
||||
composeCropStitchPreview,
|
||||
computeMaskBounds,
|
||||
cropRectCanvas,
|
||||
extractMaskDrivenCrop,
|
||||
fitCanvas,
|
||||
flipCanvas,
|
||||
getCanvasDimensions,
|
||||
makeCanvas,
|
||||
normalizeFilterName,
|
||||
num,
|
||||
numAny,
|
||||
ops,
|
||||
parseHexColor,
|
||||
renderCornerPinCanvases,
|
||||
renderCropStitchCanvases,
|
||||
renderDistortCanvas,
|
||||
renderMaskedEffectPreview,
|
||||
renderPadOutCanvases,
|
||||
resizeWithMode,
|
||||
resolveResizeDimensions,
|
||||
rotateDiscrete,
|
||||
setResampleMode,
|
||||
smoothShakeValue,
|
||||
str,
|
||||
strAny,
|
||||
w,
|
||||
wAny
|
||||
} from "./implementation.js";
|
||||
export * from "../shared/geometry.js";
|
||||
const geometryOps = {
|
||||
crop: ops.crop,
|
||||
@@ -15,6 +50,245 @@ const geometryOps = {
|
||||
distort: ops.distort,
|
||||
spherize: ops.spherize
|
||||
};
|
||||
function crop(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const rawMask = inputs[1] ?? null;
|
||||
return renderMaskedEffectPreview(
|
||||
node,
|
||||
source,
|
||||
rawMask,
|
||||
(input) => applyEffectToCanvas(input, (effectCtx, width, height) => applyCrop(
|
||||
effectCtx,
|
||||
node,
|
||||
width,
|
||||
height,
|
||||
str(node, "aspect_ratio", "custom", frameIndex),
|
||||
num(node, "width", width, frameIndex),
|
||||
num(node, "height", height, frameIndex)
|
||||
)),
|
||||
{
|
||||
frameIndex,
|
||||
premultBeforeProcess: true,
|
||||
compositeWithBase: false
|
||||
}
|
||||
);
|
||||
}
|
||||
function cropStitch(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
if (inputs.length < 2) return inputs[0] ?? ctx.canvas;
|
||||
const rendered = renderCropStitchCanvases(node, inputs, frameIndex);
|
||||
return composeCropStitchPreview(rendered.image, rendered.crop, rendered.mask, rendered.bbox);
|
||||
}
|
||||
function padOut(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderPadOutCanvases(node, source, frameIndex).image;
|
||||
}
|
||||
function cornerPin(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderCornerPinCanvases(node, source, frameIndex).image;
|
||||
}
|
||||
function cropGeneric(ctx, W, node, inputs = [], inputInfos = []) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const sourceWidth = source.width || 1;
|
||||
const sourceHeight = source.height || 1;
|
||||
let targetWidth = Math.max(1, Math.round(numAny(node, ["width", "target_width", "base_resolution"], sourceWidth)));
|
||||
let targetHeight = Math.max(1, Math.round(numAny(node, ["height", "target_height", "base_resolution"], sourceHeight)));
|
||||
const maskInput = inputs[1] ?? null;
|
||||
if (maskInput) {
|
||||
if (!wAny(node, ["width", "target_width", "height", "target_height"]) && !!w(node, "base_resolution")) {
|
||||
const fittedMask = fitCanvas(maskInput, sourceWidth, sourceHeight);
|
||||
const bounds = computeMaskBounds(fittedMask);
|
||||
if (bounds) {
|
||||
const aspect = bounds.width / Math.max(1, bounds.height);
|
||||
const baseResolution = Math.max(1, Math.round(numAny(node, ["base_resolution"], Math.max(bounds.width, bounds.height))));
|
||||
if (aspect >= 1) {
|
||||
targetWidth = baseResolution;
|
||||
targetHeight = Math.max(1, Math.round(baseResolution / aspect));
|
||||
} else {
|
||||
targetHeight = baseResolution;
|
||||
targetWidth = Math.max(1, Math.round(baseResolution * aspect));
|
||||
}
|
||||
}
|
||||
}
|
||||
return extractMaskDrivenCrop(source, maskInput, numAny(node, ["padding"], 0), targetWidth, targetHeight);
|
||||
}
|
||||
const cropRegion = w(node, "crop_region")?.value;
|
||||
const bboxNode = inputInfos[1]?.upstreamNode ?? null;
|
||||
const x = Math.max(0, Math.round(cropRegion?.x ?? numAny(bboxNode ?? node, ["x", "x_offset"], 0)));
|
||||
const y = Math.max(0, Math.round(cropRegion?.y ?? numAny(bboxNode ?? node, ["y", "y_offset"], 0)));
|
||||
const width = Math.max(1, Math.round(cropRegion?.width ?? numAny(bboxNode ?? node, ["width", "crop_w"], sourceWidth)));
|
||||
const height = Math.max(1, Math.round(cropRegion?.height ?? numAny(bboxNode ?? node, ["height", "crop_h"], sourceHeight)));
|
||||
return cropRectCanvas(source, x, y, Math.min(width, sourceWidth - x), Math.min(height, sourceHeight - y));
|
||||
}
|
||||
function transform(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const rawMask = inputs[1] ?? null;
|
||||
const transformImage = (input) => {
|
||||
let working = input;
|
||||
const flip = strAny(node, ["flip", "mirror"], "none", frameIndex).toLowerCase();
|
||||
const flipMethod = strAny(node, ["flip_method"], "", frameIndex);
|
||||
const horizontal = flip === "horizontal" || flipMethod.startsWith("y");
|
||||
const vertical = flip === "vertical" || flipMethod.startsWith("x");
|
||||
working = flipCanvas(working, horizontal, vertical);
|
||||
const rotationLabel = strAny(node, ["rotation"], "", frameIndex);
|
||||
if (rotationLabel.startsWith("90")) working = rotateDiscrete(working, 1);
|
||||
else if (rotationLabel.startsWith("180")) working = rotateDiscrete(working, 2);
|
||||
else if (rotationLabel.startsWith("270")) working = rotateDiscrete(working, 3);
|
||||
const aspectRatio = numAny(node, ["aspect_ratio"], 1, frameIndex);
|
||||
if (Math.abs(aspectRatio - 1) > 1e-4) {
|
||||
const scaled = makeCanvas(working.width || 1, Math.max(1, Math.round((working.height || 1) * aspectRatio)));
|
||||
const sctx = scaled.getContext("2d", { willReadFrequently: true });
|
||||
setResampleMode(sctx, normalizeFilterName(strAny(node, ["upscale_method", "interpolation", "transform_method", "filter"], "bilinear", frameIndex)));
|
||||
sctx.drawImage(working, 0, 0, scaled.width, scaled.height);
|
||||
working = scaled;
|
||||
}
|
||||
const tx = numAny(node, ["translate_x", "x", "shift_x"], 0, frameIndex);
|
||||
const ty = numAny(node, ["translate_y", "y", "shift_y"], 0, frameIndex);
|
||||
const rot = numAny(node, ["rotate_deg", "rotate"], 0, frameIndex);
|
||||
const scale = numAny(node, ["scale"], 1, frameIndex);
|
||||
const filter = normalizeFilterName(strAny(node, ["filter", "upscale_method", "interpolation", "transform_method"], "bilinear", frameIndex));
|
||||
const expand = boolAny(node, ["expand"], false, frameIndex);
|
||||
const fillMode = strAny(node, ["fill_mode", "edge_mode"], "transparent", frameIndex);
|
||||
const fillColor = strAny(node, ["fill_color", "background_color", "color"], "#000000", frameIndex);
|
||||
return applyEffectToCanvas(working, (effectCtx, width, height) => {
|
||||
return applyTransform(effectCtx, width, height, tx, ty, rot, scale, filter, expand, fillMode, fillColor);
|
||||
});
|
||||
};
|
||||
return renderMaskedEffectPreview(
|
||||
node,
|
||||
source,
|
||||
rawMask,
|
||||
transformImage,
|
||||
{
|
||||
frameIndex,
|
||||
premultBeforeProcess: true,
|
||||
compositeWithBase: false
|
||||
}
|
||||
);
|
||||
}
|
||||
function cameraShake(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const transformImage = (input) => {
|
||||
const translate = Math.max(0, numAny(node, ["translate_px"], 12, frameIndex));
|
||||
const rotate = Math.max(0, numAny(node, ["rotate_deg"], 1.5, frameIndex));
|
||||
const zoom = Math.max(0, numAny(node, ["zoom"], 0.03, frameIndex));
|
||||
const smoothing = numAny(node, ["smoothing"], 0.65, frameIndex);
|
||||
const frequency = Math.max(0.01, numAny(node, ["shake_frequency", "frequency"], 1, frameIndex));
|
||||
const seed = Math.max(0, Math.round(numAny(node, ["seed"], 12345, frameIndex)));
|
||||
const tx = smoothShakeValue(seed + 11, frameIndex, 1, translate, smoothing, frequency);
|
||||
const ty = smoothShakeValue(seed + 23, frameIndex, 2, translate, smoothing, frequency);
|
||||
const rot = smoothShakeValue(seed + 37, frameIndex, 3, rotate, smoothing, frequency);
|
||||
const scale = Math.max(0.01, 1 + smoothShakeValue(seed + 53, frameIndex, 4, zoom, smoothing, frequency));
|
||||
return applyEffectToCanvas(input, (effectCtx, width, height) => {
|
||||
return applyTransform(
|
||||
effectCtx,
|
||||
width,
|
||||
height,
|
||||
tx,
|
||||
ty,
|
||||
rot,
|
||||
scale,
|
||||
strAny(node, ["filter"], "bilinear", frameIndex),
|
||||
false,
|
||||
strAny(node, ["fill_mode"], "mirror", frameIndex),
|
||||
strAny(node, ["fill_color"], "#000000", frameIndex)
|
||||
);
|
||||
});
|
||||
};
|
||||
return renderMaskedEffectPreview(node, source, inputs[1] ?? null, transformImage, {
|
||||
frameIndex,
|
||||
premultBeforeProcess: true,
|
||||
compositeWithBase: false
|
||||
});
|
||||
}
|
||||
function cropReformat(ctx, W, node) {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
applyCropReformat(
|
||||
ctx,
|
||||
width,
|
||||
height,
|
||||
numAny(node, ["x"], 0),
|
||||
numAny(node, ["y"], 0),
|
||||
numAny(node, ["crop_w", "width"], width),
|
||||
numAny(node, ["crop_h", "height"], height),
|
||||
numAny(node, ["padding"], 0),
|
||||
numAny(node, ["out_w", "target_width"], 0),
|
||||
numAny(node, ["out_h", "target_height"], 0),
|
||||
strAny(node, ["mode", "method"], "fit")
|
||||
);
|
||||
}
|
||||
function resize(ctx, W, node) {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
const resolved = resolveResizeDimensions(node, width, height);
|
||||
return resizeWithMode(ctx.canvas, resolved.width, resolved.height, resolved.filter, resolved.mode, resolved.fillColor, resolved.cropPosition);
|
||||
}
|
||||
function pad(ctx, W, node, inputs = []) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const top = Math.max(0, Math.round(numAny(node, ["top"], 0)));
|
||||
const bottom = Math.max(0, Math.round(numAny(node, ["bottom"], 0)));
|
||||
const left = Math.max(0, Math.round(numAny(node, ["left"], 0)));
|
||||
const right = Math.max(0, Math.round(numAny(node, ["right"], 0)));
|
||||
const output = makeCanvas((source.width || 1) + left + right, (source.height || 1) + top + bottom);
|
||||
const octx = output.getContext("2d", { willReadFrequently: true });
|
||||
octx.fillStyle = parseHexColor(strAny(node, ["color", "background_color", "pad_color", "padding_color"], "#808080"));
|
||||
octx.fillRect(0, 0, output.width, output.height);
|
||||
octx.drawImage(source, left, top, source.width || 1, source.height || 1);
|
||||
return output;
|
||||
}
|
||||
function flipRotate(ctx, W, node) {
|
||||
const flipMethod = strAny(node, ["flip_method"], "");
|
||||
const horizontal = flipMethod.startsWith("y");
|
||||
const vertical = flipMethod.startsWith("x");
|
||||
let working = flipCanvas(ctx.canvas, horizontal, vertical);
|
||||
const rotation = strAny(node, ["rotation"], "");
|
||||
if (rotation.startsWith("90")) working = rotateDiscrete(working, 1);
|
||||
else if (rotation.startsWith("180")) working = rotateDiscrete(working, 2);
|
||||
else if (rotation.startsWith("270")) working = rotateDiscrete(working, 3);
|
||||
return working;
|
||||
}
|
||||
function distort(ctx, W, node, inputs, frameIndex = 0) {
|
||||
return renderDistortCanvas(node, inputs, frameIndex).image;
|
||||
}
|
||||
function spherize(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
let source = inputs[0] ?? ctx.canvas;
|
||||
const sizeMode = strAny(node, ["size_mode"], "from_input", frameIndex).toLowerCase().trim();
|
||||
if (sizeMode === "custom") {
|
||||
const tw = Math.max(64, Math.round(numAny(node, ["width"], 512, frameIndex)));
|
||||
const th = Math.max(64, Math.round(numAny(node, ["height"], 512, frameIndex)));
|
||||
if (tw !== source.width || th !== source.height) {
|
||||
const resized = makeCanvas(tw, th);
|
||||
resized.getContext("2d", { willReadFrequently: true }).drawImage(source, 0, 0, tw, th);
|
||||
source = resized;
|
||||
}
|
||||
} else {
|
||||
const ww = w(node, "width");
|
||||
const hw = w(node, "height");
|
||||
setWidgetValue(ww, Math.max(64, source.width));
|
||||
setWidgetValue(hw, Math.max(64, source.height));
|
||||
}
|
||||
return applyEffectToCanvas(source, (effectCtx, width, height) => {
|
||||
applySpherize(
|
||||
effectCtx,
|
||||
width,
|
||||
height,
|
||||
strAny(node, ["mode"], "spherize", frameIndex),
|
||||
numAny(node, ["strength"], 1, frameIndex),
|
||||
boolAny(node, ["invert"], false, frameIndex)
|
||||
);
|
||||
});
|
||||
}
|
||||
export {
|
||||
geometryOps
|
||||
cameraShake,
|
||||
cornerPin,
|
||||
crop,
|
||||
cropGeneric,
|
||||
cropReformat,
|
||||
cropStitch,
|
||||
distort,
|
||||
flipRotate,
|
||||
geometryOps,
|
||||
pad,
|
||||
padOut,
|
||||
resize,
|
||||
spherize,
|
||||
transform
|
||||
};
|
||||
|
||||
+203
-468
@@ -1,11 +1,16 @@
|
||||
import { getOpsConstants, initOpsConstants } from "../constants.js";
|
||||
import { clampCropCenter, clampCropScale, computeCropRect, resolveCropAspectRatio } from "../crop.js";
|
||||
import { computeCompRect, getCompLayerOutputCorners, getCompSlots, hasCompLayerCornerPin, syncCompLayers } from "../comp.js";
|
||||
import { renderDrawPreview, resolveDrawOverlayCanvas } from "../draw.js";
|
||||
import { renderDrawPreview } from "../draw.js";
|
||||
import { acquireCanvas, releaseCanvas } from "../shared/canvas-pool.js";
|
||||
import { applyColorCorrectGL } from "../shared/webgl-color.js";
|
||||
import { setWidgetValue } from "../shared/widgets.js";
|
||||
import { blendChannel01 } from "../shared/blend-modes.js";
|
||||
import { crop, cropStitch, padOut, cornerPin, cropGeneric, transform, cameraShake, cropReformat, resize, pad, flipRotate, distort, spherize } from "./geometry.js";
|
||||
import { merge, composite, comp } from "./blend.js";
|
||||
import { channelApply, imageOpsMask } from "./masks.js";
|
||||
import { grain, text, keyer, stitch, constant, ramp, noise, draw, drawMask } from "./procedural.js";
|
||||
import { channelSplit, channelMerge } from "./video.js";
|
||||
initOpsConstants();
|
||||
function w(node, name) {
|
||||
return node?.widgets?.find((x) => x?.name === name) ?? null;
|
||||
@@ -404,9 +409,9 @@ function grainRandom01(seed, x, y, channel, frame) {
|
||||
v ^= v >>> 16;
|
||||
return (v >>> 0) / 4294967295;
|
||||
}
|
||||
function blendGrainValue(base, noise, amount, mode) {
|
||||
function blendGrainValue(base, noise2, amount, mode) {
|
||||
const normalized = String(mode || "add").toLowerCase().replace(/[-\s]+/g, "_");
|
||||
const top = Math.max(0, Math.min(1, 0.5 + noise * amount));
|
||||
const top = Math.max(0, Math.min(1, 0.5 + noise2 * amount));
|
||||
if (normalized === "overlay") {
|
||||
const blended = base <= 0.5 ? 2 * base * top : 1 - 2 * (1 - base) * (1 - top);
|
||||
return base * (1 - amount) + blended * amount;
|
||||
@@ -416,7 +421,7 @@ function blendGrainValue(base, noise, amount, mode) {
|
||||
const blended = top <= 0.5 ? base - (1 - 2 * top) * base * (1 - base) : base + (2 * top - 1) * (curve - base);
|
||||
return base * (1 - amount) + blended * amount;
|
||||
}
|
||||
return base + noise * amount;
|
||||
return base + noise2 * amount;
|
||||
}
|
||||
function renderGrainCanvas(node, source, rawMask, frameIndex) {
|
||||
const width = source.width || 1;
|
||||
@@ -442,8 +447,8 @@ function renderGrainCanvas(node, source, rawMask, frameIndex) {
|
||||
const monoNoise = grainRandom01(seed, x, y, 0, grainFrame) - 0.5;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const base = data[i + c] / 255;
|
||||
const noise = mono ? monoNoise : grainRandom01(seed, x, y, c, grainFrame) - 0.5;
|
||||
const grained = Math.max(0, Math.min(1, blendGrainValue(base, noise, amount, mode)));
|
||||
const noise2 = mono ? monoNoise : grainRandom01(seed, x, y, c, grainFrame) - 0.5;
|
||||
const grained = Math.max(0, Math.min(1, blendGrainValue(base, noise2, amount, mode)));
|
||||
const mixed = base * (1 - weight) + grained * weight;
|
||||
data[i + c] = Math.round(mixed * 255);
|
||||
}
|
||||
@@ -458,9 +463,9 @@ function renderTextCanvas(node, source, rawMask, frameIndex) {
|
||||
const output = makeCanvas(width, height);
|
||||
const octx = output.getContext("2d", { willReadFrequently: true });
|
||||
octx.drawImage(source, 0, 0, width, height);
|
||||
const text = resolveConnectedString(node, "text") ?? strAny(node, ["text"], "ImageOps Text", frameIndex);
|
||||
const text2 = resolveConnectedString(node, "text") ?? strAny(node, ["text"], "ImageOps Text", frameIndex);
|
||||
const opacity = Math.max(0, Math.min(1, numAny(node, ["opacity"], 1, frameIndex)));
|
||||
if (!text || opacity <= 0) return output;
|
||||
if (!text2 || opacity <= 0) return output;
|
||||
const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex);
|
||||
const layer = makeCanvas(width, height);
|
||||
const lctx = layer.getContext("2d", { willReadFrequently: true });
|
||||
@@ -477,7 +482,7 @@ function renderTextCanvas(node, source, rawMask, frameIndex) {
|
||||
lctx.fillStyle = parseHexColor(strAny(node, ["color"], "#ffffff", frameIndex));
|
||||
lctx.strokeStyle = parseHexColor(strAny(node, ["stroke_color"], "#000000", frameIndex));
|
||||
lctx.lineWidth = strokeWidth;
|
||||
const lines = String(text).split(/\r?\n/);
|
||||
const lines = String(text2).split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const ty = y + index * (fontSize + lineSpacing);
|
||||
if (strokeWidth > 0) lctx.strokeText(lines[index], x, ty);
|
||||
@@ -1422,13 +1427,13 @@ function applyGlow(ctx, W, H, threshold, intensity, blurPx) {
|
||||
function applyCropReformat(ctx, W, H, x, y, cw, ch, padding, outW, outH, mode) {
|
||||
const cropW = Math.max(1, Math.round(cw));
|
||||
const cropH = Math.max(1, Math.round(ch));
|
||||
const pad = Math.max(0, Math.round(padding));
|
||||
const pad2 = Math.max(0, Math.round(padding));
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = cropW + pad * 2;
|
||||
tmp.height = cropH + pad * 2;
|
||||
tmp.width = cropW + pad2 * 2;
|
||||
tmp.height = cropH + pad2 * 2;
|
||||
const tctx = tmp.getContext("2d", { willReadFrequently: true });
|
||||
tctx.clearRect(0, 0, tmp.width, tmp.height);
|
||||
tctx.drawImage(ctx.canvas, -Math.round(x) + pad, -Math.round(y) + pad);
|
||||
tctx.drawImage(ctx.canvas, -Math.round(x) + pad2, -Math.round(y) + pad2);
|
||||
const finalW = outW > 0 ? Math.round(outW) : tmp.width;
|
||||
const finalH = outH > 0 ? Math.round(outH) : tmp.height;
|
||||
const dst = document.createElement("canvas");
|
||||
@@ -1453,7 +1458,7 @@ function applyCrop(ctx, node, sourceWidth, sourceHeight, aspectRatio, outW, outH
|
||||
const finalW = Math.max(1, Math.round(outW));
|
||||
const finalH = Math.max(1, Math.round(outH));
|
||||
const ratio = resolveCropAspectRatio(aspectRatio, finalW, finalH);
|
||||
const crop = computeCropRect(
|
||||
const crop2 = computeCropRect(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
ratio,
|
||||
@@ -1468,10 +1473,10 @@ function applyCrop(ctx, node, sourceWidth, sourceHeight, aspectRatio, outW, outH
|
||||
octx.clearRect(0, 0, finalW, finalH);
|
||||
octx.drawImage(
|
||||
ctx.canvas,
|
||||
crop.x,
|
||||
crop.y,
|
||||
crop.cropWidth,
|
||||
crop.cropHeight,
|
||||
crop2.x,
|
||||
crop2.y,
|
||||
crop2.cropWidth,
|
||||
crop2.cropHeight,
|
||||
0,
|
||||
0,
|
||||
finalW,
|
||||
@@ -2490,8 +2495,8 @@ function resolveResizeDimensions(node, sourceWidth, sourceHeight) {
|
||||
const megapixels = numAny(node, ["megapixels"], 0);
|
||||
const size = Math.round(numAny(node, ["size", "longer_size", "shorter_size"], 0));
|
||||
const filter = normalizeFilterName(strAny(node, ["upscale_method", "interpolation", "transform_method", "filter"], "bilinear"));
|
||||
const crop = strAny(node, ["crop", "crop_method"], "disabled");
|
||||
const method = strAny(node, ["keep_proportion", "method", "resize_type"], crop === "center" ? "crop" : "stretch");
|
||||
const crop2 = strAny(node, ["crop", "crop_method"], "disabled");
|
||||
const method = strAny(node, ["keep_proportion", "method", "resize_type"], crop2 === "center" ? "crop" : "stretch");
|
||||
const keepProportion = boolAny(node, ["keep_proportion"], false);
|
||||
const fillColor = strAny(node, ["pad_color", "padding_color", "background_color", "color"], "#000000");
|
||||
const cropPosition = strAny(node, ["crop_position"], "center");
|
||||
@@ -2524,10 +2529,10 @@ function resolveResizeDimensions(node, sourceWidth, sourceHeight) {
|
||||
}
|
||||
let mode = "stretch";
|
||||
const normalizedMethod = String(method).toLowerCase();
|
||||
if (keepProportion && normalizedMethod === "stretch" && crop !== "center") mode = "fit";
|
||||
if (keepProportion && normalizedMethod === "stretch" && crop2 !== "center") mode = "fit";
|
||||
else if (normalizedMethod.includes("pad") || normalizedMethod.includes("pillarbox")) mode = "pad";
|
||||
else if (normalizedMethod.includes("keep proportion") || normalizedMethod === "resize" || normalizedMethod.includes("fit")) mode = "fit";
|
||||
else if (normalizedMethod.includes("fill") || normalizedMethod.includes("crop") || crop === "center") mode = "crop";
|
||||
else if (normalizedMethod.includes("fill") || normalizedMethod.includes("crop") || crop2 === "center") mode = "crop";
|
||||
return {
|
||||
width: Math.max(1, width),
|
||||
height: Math.max(1, height),
|
||||
@@ -2549,11 +2554,11 @@ function extractMaskDrivenCrop(source, maskCanvas, padding, targetWidth, targetH
|
||||
const fittedMask = fitCanvas(maskCanvas, source.width || 1, source.height || 1);
|
||||
const bounds = computeMaskBounds(fittedMask);
|
||||
if (!bounds) return resizeWithMode(source, targetWidth, targetHeight, "bicubic", "crop");
|
||||
const pad = Math.max(0, Math.round(padding));
|
||||
const x = Math.max(0, bounds.x - pad);
|
||||
const y = Math.max(0, bounds.y - pad);
|
||||
const right = Math.min(source.width, bounds.x + bounds.width + pad);
|
||||
const bottom = Math.min(source.height, bounds.y + bounds.height + pad);
|
||||
const pad2 = Math.max(0, Math.round(padding));
|
||||
const x = Math.max(0, bounds.x - pad2);
|
||||
const y = Math.max(0, bounds.y - pad2);
|
||||
const right = Math.min(source.width, bounds.x + bounds.width + pad2);
|
||||
const bottom = Math.min(source.height, bounds.y + bounds.height + pad2);
|
||||
const cropped = cropRectCanvas(source, x, y, Math.max(1, right - x), Math.max(1, bottom - y));
|
||||
return resizeWithMode(cropped, targetWidth, targetHeight, "bicubic", "crop");
|
||||
}
|
||||
@@ -2594,12 +2599,12 @@ function makeCropStitchRectMask(width, height, bbox) {
|
||||
}
|
||||
function renderCropStitchCanvases(node, inputs, frameIndex = 0) {
|
||||
const original = inputs[0] ?? makeCanvas(1, 1);
|
||||
const crop = inputs[1] ?? original;
|
||||
const crop2 = inputs[1] ?? original;
|
||||
const width = Math.max(1, original.width || 1);
|
||||
const height = Math.max(1, original.height || 1);
|
||||
const cropMaskInput = inputs[2] ?? null;
|
||||
const bbox = parseCropStitchBBox(node, width, height, frameIndex) ?? cropStitchBBoxFromMask(cropMaskInput, width, height);
|
||||
const fittedCrop = fitCanvas(crop, bbox.width, bbox.height);
|
||||
const fittedCrop = fitCanvas(crop2, bbox.width, bbox.height);
|
||||
const stitchLayer = makeCanvas(width, height);
|
||||
const layerCtx = stitchLayer.getContext("2d", { willReadFrequently: true });
|
||||
layerCtx.clearRect(0, 0, width, height);
|
||||
@@ -2641,7 +2646,7 @@ function drawCropStitchPanel(ctx, source, x, y, width, height, label, bbox) {
|
||||
ctx.fillText(label, x + 7, y + 10);
|
||||
ctx.restore();
|
||||
}
|
||||
function composeCropStitchPreview(image, crop, mask, bbox) {
|
||||
function composeCropStitchPreview(image, crop2, mask, bbox) {
|
||||
const maskPreview = maskCanvasToPreviewCanvas(mask);
|
||||
const mainW = Math.max(1, image.width || 1);
|
||||
const mainH = Math.max(1, image.height || 1);
|
||||
@@ -2653,7 +2658,7 @@ function composeCropStitchPreview(image, crop, mask, bbox) {
|
||||
octx.fillStyle = "#111111";
|
||||
octx.fillRect(0, 0, output.width, output.height);
|
||||
drawCropStitchPanel(octx, image, 0, 0, mainW, output.height, "Stitched", bbox);
|
||||
drawCropStitchPanel(octx, crop, mainW + gap, 0, sideW, sideH, "Edited crop");
|
||||
drawCropStitchPanel(octx, crop2, mainW + gap, 0, sideW, sideH, "Edited crop");
|
||||
drawCropStitchPanel(octx, maskPreview, mainW + gap, sideH + gap, sideW, sideH, "Crop mask");
|
||||
return output;
|
||||
}
|
||||
@@ -2998,156 +3003,13 @@ const ops = {
|
||||
ectx.putImageData(img, 0, 0);
|
||||
return extracted;
|
||||
},
|
||||
crop(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const rawMask = inputs[1] ?? null;
|
||||
return renderMaskedEffectPreview(
|
||||
node,
|
||||
source,
|
||||
rawMask,
|
||||
(input) => applyEffectToCanvas(input, (effectCtx, width, height) => applyCrop(
|
||||
effectCtx,
|
||||
node,
|
||||
width,
|
||||
height,
|
||||
str(node, "aspect_ratio", "custom", frameIndex),
|
||||
num(node, "width", width, frameIndex),
|
||||
num(node, "height", height, frameIndex)
|
||||
)),
|
||||
{
|
||||
frameIndex,
|
||||
premultBeforeProcess: true,
|
||||
compositeWithBase: false
|
||||
}
|
||||
);
|
||||
},
|
||||
cropStitch(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
if (inputs.length < 2) return inputs[0] ?? ctx.canvas;
|
||||
const rendered = renderCropStitchCanvases(node, inputs, frameIndex);
|
||||
return composeCropStitchPreview(rendered.image, rendered.crop, rendered.mask, rendered.bbox);
|
||||
},
|
||||
padOut(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderPadOutCanvases(node, source, frameIndex).image;
|
||||
},
|
||||
cornerPin(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderCornerPinCanvases(node, source, frameIndex).image;
|
||||
},
|
||||
cropGeneric(ctx, W, node, inputs = [], inputInfos = []) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const sourceWidth = source.width || 1;
|
||||
const sourceHeight = source.height || 1;
|
||||
let targetWidth = Math.max(1, Math.round(numAny(node, ["width", "target_width", "base_resolution"], sourceWidth)));
|
||||
let targetHeight = Math.max(1, Math.round(numAny(node, ["height", "target_height", "base_resolution"], sourceHeight)));
|
||||
const maskInput = inputs[1] ?? null;
|
||||
if (maskInput) {
|
||||
if (!wAny(node, ["width", "target_width", "height", "target_height"]) && !!w(node, "base_resolution")) {
|
||||
const fittedMask = fitCanvas(maskInput, sourceWidth, sourceHeight);
|
||||
const bounds = computeMaskBounds(fittedMask);
|
||||
if (bounds) {
|
||||
const aspect = bounds.width / Math.max(1, bounds.height);
|
||||
const baseResolution = Math.max(1, Math.round(numAny(node, ["base_resolution"], Math.max(bounds.width, bounds.height))));
|
||||
if (aspect >= 1) {
|
||||
targetWidth = baseResolution;
|
||||
targetHeight = Math.max(1, Math.round(baseResolution / aspect));
|
||||
} else {
|
||||
targetHeight = baseResolution;
|
||||
targetWidth = Math.max(1, Math.round(baseResolution * aspect));
|
||||
}
|
||||
}
|
||||
}
|
||||
return extractMaskDrivenCrop(source, maskInput, numAny(node, ["padding"], 0), targetWidth, targetHeight);
|
||||
}
|
||||
const cropRegion = w(node, "crop_region")?.value;
|
||||
const bboxNode = inputInfos[1]?.upstreamNode ?? null;
|
||||
const x = Math.max(0, Math.round(cropRegion?.x ?? numAny(bboxNode ?? node, ["x", "x_offset"], 0)));
|
||||
const y = Math.max(0, Math.round(cropRegion?.y ?? numAny(bboxNode ?? node, ["y", "y_offset"], 0)));
|
||||
const width = Math.max(1, Math.round(cropRegion?.width ?? numAny(bboxNode ?? node, ["width", "crop_w"], sourceWidth)));
|
||||
const height = Math.max(1, Math.round(cropRegion?.height ?? numAny(bboxNode ?? node, ["height", "crop_h"], sourceHeight)));
|
||||
return cropRectCanvas(source, x, y, Math.min(width, sourceWidth - x), Math.min(height, sourceHeight - y));
|
||||
},
|
||||
transform(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const rawMask = inputs[1] ?? null;
|
||||
const transformImage = (input) => {
|
||||
let working = input;
|
||||
const flip = strAny(node, ["flip", "mirror"], "none", frameIndex).toLowerCase();
|
||||
const flipMethod = strAny(node, ["flip_method"], "", frameIndex);
|
||||
const horizontal = flip === "horizontal" || flipMethod.startsWith("y");
|
||||
const vertical = flip === "vertical" || flipMethod.startsWith("x");
|
||||
working = flipCanvas(working, horizontal, vertical);
|
||||
const rotationLabel = strAny(node, ["rotation"], "", frameIndex);
|
||||
if (rotationLabel.startsWith("90")) working = rotateDiscrete(working, 1);
|
||||
else if (rotationLabel.startsWith("180")) working = rotateDiscrete(working, 2);
|
||||
else if (rotationLabel.startsWith("270")) working = rotateDiscrete(working, 3);
|
||||
const aspectRatio = numAny(node, ["aspect_ratio"], 1, frameIndex);
|
||||
if (Math.abs(aspectRatio - 1) > 1e-4) {
|
||||
const scaled = makeCanvas(working.width || 1, Math.max(1, Math.round((working.height || 1) * aspectRatio)));
|
||||
const sctx = scaled.getContext("2d", { willReadFrequently: true });
|
||||
setResampleMode(sctx, normalizeFilterName(strAny(node, ["upscale_method", "interpolation", "transform_method", "filter"], "bilinear", frameIndex)));
|
||||
sctx.drawImage(working, 0, 0, scaled.width, scaled.height);
|
||||
working = scaled;
|
||||
}
|
||||
const tx = numAny(node, ["translate_x", "x", "shift_x"], 0, frameIndex);
|
||||
const ty = numAny(node, ["translate_y", "y", "shift_y"], 0, frameIndex);
|
||||
const rot = numAny(node, ["rotate_deg", "rotate"], 0, frameIndex);
|
||||
const scale = numAny(node, ["scale"], 1, frameIndex);
|
||||
const filter = normalizeFilterName(strAny(node, ["filter", "upscale_method", "interpolation", "transform_method"], "bilinear", frameIndex));
|
||||
const expand = boolAny(node, ["expand"], false, frameIndex);
|
||||
const fillMode = strAny(node, ["fill_mode", "edge_mode"], "transparent", frameIndex);
|
||||
const fillColor = strAny(node, ["fill_color", "background_color", "color"], "#000000", frameIndex);
|
||||
return applyEffectToCanvas(working, (effectCtx, width, height) => {
|
||||
return applyTransform(effectCtx, width, height, tx, ty, rot, scale, filter, expand, fillMode, fillColor);
|
||||
});
|
||||
};
|
||||
return renderMaskedEffectPreview(
|
||||
node,
|
||||
source,
|
||||
rawMask,
|
||||
transformImage,
|
||||
{
|
||||
frameIndex,
|
||||
premultBeforeProcess: true,
|
||||
compositeWithBase: false
|
||||
}
|
||||
);
|
||||
},
|
||||
cameraShake(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const transformImage = (input) => {
|
||||
const translate = Math.max(0, numAny(node, ["translate_px"], 12, frameIndex));
|
||||
const rotate = Math.max(0, numAny(node, ["rotate_deg"], 1.5, frameIndex));
|
||||
const zoom = Math.max(0, numAny(node, ["zoom"], 0.03, frameIndex));
|
||||
const smoothing = numAny(node, ["smoothing"], 0.65, frameIndex);
|
||||
const frequency = Math.max(0.01, numAny(node, ["shake_frequency", "frequency"], 1, frameIndex));
|
||||
const seed = Math.max(0, Math.round(numAny(node, ["seed"], 12345, frameIndex)));
|
||||
const tx = smoothShakeValue(seed + 11, frameIndex, 1, translate, smoothing, frequency);
|
||||
const ty = smoothShakeValue(seed + 23, frameIndex, 2, translate, smoothing, frequency);
|
||||
const rot = smoothShakeValue(seed + 37, frameIndex, 3, rotate, smoothing, frequency);
|
||||
const scale = Math.max(0.01, 1 + smoothShakeValue(seed + 53, frameIndex, 4, zoom, smoothing, frequency));
|
||||
return applyEffectToCanvas(input, (effectCtx, width, height) => {
|
||||
return applyTransform(
|
||||
effectCtx,
|
||||
width,
|
||||
height,
|
||||
tx,
|
||||
ty,
|
||||
rot,
|
||||
scale,
|
||||
strAny(node, ["filter"], "bilinear", frameIndex),
|
||||
false,
|
||||
strAny(node, ["fill_mode"], "mirror", frameIndex),
|
||||
strAny(node, ["fill_color"], "#000000", frameIndex)
|
||||
);
|
||||
});
|
||||
};
|
||||
return renderMaskedEffectPreview(node, source, inputs[1] ?? null, transformImage, {
|
||||
frameIndex,
|
||||
premultBeforeProcess: true,
|
||||
compositeWithBase: false
|
||||
});
|
||||
},
|
||||
crop,
|
||||
cropStitch,
|
||||
padOut,
|
||||
cornerPin,
|
||||
cropGeneric,
|
||||
transform,
|
||||
cameraShake,
|
||||
levels(ctx, W, node, _opts) {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
applyLevels(
|
||||
@@ -3184,18 +3046,9 @@ const ops = {
|
||||
applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0, frameIndex), numAny(node, ["max_v", "max"], 1, frameIndex));
|
||||
});
|
||||
},
|
||||
grain(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderGrainCanvas(node, source, inputs[1] ?? null, frameIndex);
|
||||
},
|
||||
text(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderTextCanvas(node, source, inputs[1] ?? null, frameIndex);
|
||||
},
|
||||
keyer(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderKeyerCanvases(node, source, inputs[1] ?? null, frameIndex).image;
|
||||
},
|
||||
grain,
|
||||
text,
|
||||
keyer,
|
||||
sharpen(ctx, W, node) {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
applyUnsharp(ctx, width, height, numAny(node, ["amount", "strength", "factor"], 1));
|
||||
@@ -3208,292 +3061,174 @@ const ops = {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
applyGlow(ctx, width, height, numAny(node, ["threshold"], 0.8), numAny(node, ["intensity"], 0.75), Math.round(numAny(node, ["blur_px", "blur", "radius"], 6)));
|
||||
},
|
||||
cropReformat(ctx, W, node) {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
applyCropReformat(
|
||||
ctx,
|
||||
width,
|
||||
height,
|
||||
numAny(node, ["x"], 0),
|
||||
numAny(node, ["y"], 0),
|
||||
numAny(node, ["crop_w", "width"], width),
|
||||
numAny(node, ["crop_h", "height"], height),
|
||||
numAny(node, ["padding"], 0),
|
||||
numAny(node, ["out_w", "target_width"], 0),
|
||||
numAny(node, ["out_h", "target_height"], 0),
|
||||
strAny(node, ["mode", "method"], "fit")
|
||||
);
|
||||
},
|
||||
cropReformat,
|
||||
lumaKey(ctx, W, node) {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
applyLumaKey(ctx, width, height, numAny(node, ["low"], 0.1), numAny(node, ["high"], 0.9), numAny(node, ["softness"], 0.05));
|
||||
},
|
||||
merge(ctx, W, node, topCanvasOrInputs, _opts, frameIndex = 0) {
|
||||
const inputs = Array.isArray(topCanvasOrInputs) ? topCanvasOrInputs : [ctx.canvas, topCanvasOrInputs];
|
||||
const base = inputs[0] ?? ctx.canvas;
|
||||
const topCanvas = inputs[1] ?? null;
|
||||
if (!topCanvas) return fitCanvas(base, base.width || 1, base.height || 1);
|
||||
const mode = strAny(node, ["mode", "blend_mode"], "over", frameIndex);
|
||||
const foregroundFit = strAny(node, ["foreground_fit", "fit_mode"], "stretch", frameIndex);
|
||||
const blendSpace = strAny(node, ["blend_space", "color_space"], "linear", frameIndex);
|
||||
const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100, frameIndex) : numAny(node, ["mix", "factor", "fade_factor", "blend_factor", "start_level", "end_level"], 1, frameIndex);
|
||||
const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity;
|
||||
const merged = applyEffectToCanvas(base, (effectCtx, width, height) => {
|
||||
blend(effectCtx, width, height, topCanvas, mode, opacity, foregroundFit, blendSpace);
|
||||
});
|
||||
const effectMask = resolvePreviewMaskCanvas(node, base, inputs[2] ?? null, frameIndex);
|
||||
return effectMask ? compositeProcessedWithMask(base, merged, effectMask) : merged;
|
||||
},
|
||||
resize(ctx, W, node) {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
const resolved = resolveResizeDimensions(node, width, height);
|
||||
return resizeWithMode(ctx.canvas, resolved.width, resolved.height, resolved.filter, resolved.mode, resolved.fillColor, resolved.cropPosition);
|
||||
},
|
||||
pad(ctx, W, node, inputs = []) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const top = Math.max(0, Math.round(numAny(node, ["top"], 0)));
|
||||
const bottom = Math.max(0, Math.round(numAny(node, ["bottom"], 0)));
|
||||
const left = Math.max(0, Math.round(numAny(node, ["left"], 0)));
|
||||
const right = Math.max(0, Math.round(numAny(node, ["right"], 0)));
|
||||
const output = makeCanvas((source.width || 1) + left + right, (source.height || 1) + top + bottom);
|
||||
const octx = output.getContext("2d", { willReadFrequently: true });
|
||||
octx.fillStyle = parseHexColor(strAny(node, ["color", "background_color", "pad_color", "padding_color"], "#808080"));
|
||||
octx.fillRect(0, 0, output.width, output.height);
|
||||
octx.drawImage(source, left, top, source.width || 1, source.height || 1);
|
||||
return output;
|
||||
},
|
||||
flipRotate(ctx, W, node) {
|
||||
const flipMethod = strAny(node, ["flip_method"], "");
|
||||
const horizontal = flipMethod.startsWith("y");
|
||||
const vertical = flipMethod.startsWith("x");
|
||||
let working = flipCanvas(ctx.canvas, horizontal, vertical);
|
||||
const rotation = strAny(node, ["rotation"], "");
|
||||
if (rotation.startsWith("90")) working = rotateDiscrete(working, 1);
|
||||
else if (rotation.startsWith("180")) working = rotateDiscrete(working, 2);
|
||||
else if (rotation.startsWith("270")) working = rotateDiscrete(working, 3);
|
||||
return working;
|
||||
},
|
||||
merge,
|
||||
resize,
|
||||
pad,
|
||||
flipRotate,
|
||||
desaturate(ctx, W, node) {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
const factor = numAny(node, ["factor", "amount"], 1);
|
||||
applyDesaturate(ctx, width, height, factor);
|
||||
},
|
||||
composite(ctx, W, node, inputs) {
|
||||
const base = inputs[0] ?? ctx.canvas;
|
||||
const topInput = inputs[1] ?? null;
|
||||
if (!topInput) return base;
|
||||
const rawMask = inputs[2] ?? null;
|
||||
const maskInput = rawMask && boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(rawMask) : rawMask;
|
||||
const top = premultLayerWithMask(topInput, maskInput);
|
||||
const x = Math.round(numAny(node, ["x"], 0) + numAny(node, ["offset_x"], 0));
|
||||
const y = Math.round(numAny(node, ["y"], 0) + numAny(node, ["offset_y"], 0));
|
||||
const mode = strAny(node, ["mode", "blend_mode"], "over");
|
||||
const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100) : numAny(node, ["mix", "factor", "blend_factor", "start_level", "end_level"], 1);
|
||||
const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity;
|
||||
return compositeAt(base, top, mode, opacity, x, y, top.width || 1, top.height || 1);
|
||||
},
|
||||
stitch(ctx, W, node, inputs) {
|
||||
const first = inputs[0] ?? ctx.canvas;
|
||||
const second = inputs[1] ?? null;
|
||||
if (!second) return first;
|
||||
return stitchCanvases(
|
||||
first,
|
||||
second,
|
||||
strAny(node, ["direction"], "right"),
|
||||
numAny(node, ["spacing_width"], 0),
|
||||
strAny(node, ["spacing_color"], "black"),
|
||||
boolAny(node, ["match_image_size"], true)
|
||||
);
|
||||
},
|
||||
channelSplit(ctx, W, node, outputSlot) {
|
||||
return extractSplitChannelCanvas(ctx.canvas, outputSlot, strAny(node, ["mode"], "RGBA"));
|
||||
},
|
||||
channelMerge(ctx, W, node, inputs) {
|
||||
return mergeChannelInputs(inputs, strAny(node, ["mode"], "RGBA")) ?? (inputs[0] ?? ctx.canvas);
|
||||
},
|
||||
channelApply(ctx, W, node, inputs) {
|
||||
const base = fitCanvas(inputs[0] ?? ctx.canvas, (inputs[0] ?? ctx.canvas).width || 1, (inputs[0] ?? ctx.canvas).height || 1);
|
||||
const mask = inputs[1] ? fitCanvas(inputs[1], base.width, base.height) : null;
|
||||
if (!mask) return base;
|
||||
const bctx = base.getContext("2d", { willReadFrequently: true });
|
||||
const image = bctx.getImageData(0, 0, base.width, base.height);
|
||||
const data = image.data;
|
||||
const matte = mask.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, base.width, base.height).data;
|
||||
const channel = strAny(node, ["channel"], "A").toLowerCase();
|
||||
const channelIndex = channel === "g" || channel === "green" ? 1 : channel === "b" || channel === "blue" ? 2 : channel === "a" || channel === "alpha" ? 3 : 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const value = Math.round(clamp01(matte[i] / 255 * (matte[i + 3] / 255)) * 255);
|
||||
data[i + channelIndex] = value;
|
||||
}
|
||||
bctx.putImageData(image, 0, 0);
|
||||
return base;
|
||||
},
|
||||
comp(ctx, W, node, inputs) {
|
||||
return renderCompPreview(
|
||||
node,
|
||||
resolveCompPreviewInputs(node, inputs)
|
||||
).canvas;
|
||||
},
|
||||
distort(ctx, W, node, inputs, frameIndex = 0) {
|
||||
return renderDistortCanvas(node, inputs, frameIndex).image;
|
||||
},
|
||||
spherize(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
let source = inputs[0] ?? ctx.canvas;
|
||||
const sizeMode = strAny(node, ["size_mode"], "from_input", frameIndex).toLowerCase().trim();
|
||||
if (sizeMode === "custom") {
|
||||
const tw = Math.max(64, Math.round(numAny(node, ["width"], 512, frameIndex)));
|
||||
const th = Math.max(64, Math.round(numAny(node, ["height"], 512, frameIndex)));
|
||||
if (tw !== source.width || th !== source.height) {
|
||||
const resized = makeCanvas(tw, th);
|
||||
resized.getContext("2d", { willReadFrequently: true }).drawImage(source, 0, 0, tw, th);
|
||||
source = resized;
|
||||
}
|
||||
} else {
|
||||
const ww = w(node, "width");
|
||||
const hw = w(node, "height");
|
||||
setWidgetValue(ww, Math.max(64, source.width));
|
||||
setWidgetValue(hw, Math.max(64, source.height));
|
||||
}
|
||||
return applyEffectToCanvas(source, (effectCtx, width, height) => {
|
||||
applySpherize(
|
||||
effectCtx,
|
||||
width,
|
||||
height,
|
||||
strAny(node, ["mode"], "spherize", frameIndex),
|
||||
numAny(node, ["strength"], 1, frameIndex),
|
||||
boolAny(node, ["invert"], false, frameIndex)
|
||||
);
|
||||
});
|
||||
},
|
||||
constant(ctx, W, node) {
|
||||
return renderConstantCanvas(node, false);
|
||||
},
|
||||
ramp(ctx, W, node) {
|
||||
return renderRampCanvas(node, false);
|
||||
},
|
||||
noise(ctx, W, node, frameIndex = 0) {
|
||||
return renderNoiseCanvas(node, false, frameIndex, W);
|
||||
},
|
||||
async draw(ctx, W, node, inputs) {
|
||||
return await renderDrawPreview(node, inputs[0] ?? null);
|
||||
},
|
||||
async drawMask(ctx, W, node, inputs) {
|
||||
const base = inputs[0] ?? null;
|
||||
const width = base?.width || Math.max(1, Math.round(numAny(node, ["width"], 1024)));
|
||||
const height = base?.height || Math.max(1, Math.round(numAny(node, ["height"], 1024)));
|
||||
const overlay = await resolveDrawOverlayCanvas(node, width, height);
|
||||
const ow = overlay.width || 1;
|
||||
const oh = overlay.height || 1;
|
||||
const matte = makeCanvas(ow, oh);
|
||||
const mctx = matte.getContext("2d", { willReadFrequently: true });
|
||||
mctx.drawImage(overlay, 0, 0);
|
||||
const img = mctx.getImageData(0, 0, ow, oh);
|
||||
const data = img.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = a;
|
||||
}
|
||||
mctx.putImageData(img, 0, 0);
|
||||
const mask = markPreparedMaskCanvas(matte);
|
||||
return boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(mask) : mask;
|
||||
},
|
||||
imageOpsMask(ctx, W, node, cls, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const rawMask = inputs[1] ?? null;
|
||||
const resolvedMask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex);
|
||||
if (cls === "ImageOpsMaskConvert") {
|
||||
return boolAny(node, ["reverse"], false, frameIndex) ? imageToMaskPreviewCanvas(source, node, frameIndex) : buildMaskAlphaCanvas(source, source.width || 1, source.height || 1);
|
||||
}
|
||||
if (cls === "ImageOpsNoise") {
|
||||
return renderNoiseCanvas(node, true, frameIndex, W);
|
||||
}
|
||||
if (cls === "ImageOpsConstant") {
|
||||
return renderConstantCanvas(node, true);
|
||||
}
|
||||
if (cls === "ImageOpsRamp") {
|
||||
return renderRampCanvas(node, true);
|
||||
}
|
||||
if (cls === "ImageOpsDistort") {
|
||||
return renderDistortCanvas(node, inputs, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsBlur") {
|
||||
return resolvedMask ?? alphaMaskCanvas(source);
|
||||
}
|
||||
if (cls === "ImageOpsTransform") {
|
||||
return ops.transform(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex);
|
||||
}
|
||||
if (cls === "ImageOpsCrop") {
|
||||
return ops.crop(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex);
|
||||
}
|
||||
if (cls === "ImageOpsCropStitch") {
|
||||
return renderCropStitchCanvases(node, inputs, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsPadOut") {
|
||||
return renderPadOutCanvases(node, source, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsCornerPin") {
|
||||
return renderCornerPinCanvases(node, source, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsChannel") {
|
||||
const extracted = applyEffectToCanvas(source, (effectCtx, width, height) => {
|
||||
applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red", frameIndex));
|
||||
});
|
||||
return buildMaskAlphaCanvas(extracted, extracted.width || 1, extracted.height || 1);
|
||||
}
|
||||
if (cls === "ImageOpsClamp") {
|
||||
if (!resolvedMask) return alphaMaskCanvas(source);
|
||||
const lo = numAny(node, ["min_v", "min"], 0, frameIndex);
|
||||
const hi = numAny(node, ["max_v", "max"], 1, frameIndex);
|
||||
const mn = Math.round(clamp01(Math.min(lo, hi)) * 255);
|
||||
const mx = Math.round(clamp01(Math.max(lo, hi)) * 255);
|
||||
const clampMaskOut = makeCanvas(resolvedMask.width || 1, resolvedMask.height || 1);
|
||||
const clampMaskCtx = clampMaskOut.getContext("2d", { willReadFrequently: true });
|
||||
clampMaskCtx.drawImage(resolvedMask, 0, 0);
|
||||
const clampImg = clampMaskCtx.getImageData(0, 0, clampMaskOut.width, clampMaskOut.height);
|
||||
const clampData = clampImg.data;
|
||||
for (let ci = 0; ci < clampData.length; ci += 4) {
|
||||
clampData[ci] = 255;
|
||||
clampData[ci + 1] = 255;
|
||||
clampData[ci + 2] = 255;
|
||||
clampData[ci + 3] = Math.max(mn, Math.min(mx, clampData[ci + 3]));
|
||||
}
|
||||
clampMaskCtx.putImageData(clampImg, 0, 0);
|
||||
return markPreparedMaskCanvas(clampMaskOut);
|
||||
}
|
||||
if (cls === "ImageOpsKeyer") {
|
||||
return renderKeyerCanvases(node, source, rawMask, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsInvert") {
|
||||
const mask = resolvedMask ?? alphaMaskCanvas(source);
|
||||
return mask;
|
||||
}
|
||||
if (cls === "ImageOpsSpherize") {
|
||||
return renderSpherizeMaskCanvas(node, source, rawMask, frameIndex);
|
||||
}
|
||||
if (cls === "ImageOpsMerge") {
|
||||
const mergeMaskInput = inputs[2] ?? null;
|
||||
if (mergeMaskInput) {
|
||||
const mergeResolvedMask = resolvePreviewMaskCanvas(node, source, mergeMaskInput, frameIndex);
|
||||
if (mergeResolvedMask) return mergeResolvedMask;
|
||||
}
|
||||
const merged = ops.merge(ctx, W, node, inputs, void 0, frameIndex);
|
||||
return alphaMaskCanvas(merged);
|
||||
}
|
||||
if (cls === "ImageOpsComp") {
|
||||
const mask = alphaMaskCanvas(ops.comp(ctx, W, node, inputs));
|
||||
return boolAny(node, ["invert_mask"], false, frameIndex) ? invertMaskCanvas(mask) : mask;
|
||||
}
|
||||
if (cls === "ImageOpsColorAjust") {
|
||||
return resolvedMask ?? alphaMaskCanvas(source);
|
||||
}
|
||||
return resolvedMask ?? alphaMaskCanvas(source);
|
||||
}
|
||||
composite,
|
||||
stitch,
|
||||
channelSplit,
|
||||
channelMerge,
|
||||
channelApply,
|
||||
comp,
|
||||
distort,
|
||||
spherize,
|
||||
constant,
|
||||
ramp,
|
||||
noise,
|
||||
draw,
|
||||
drawMask,
|
||||
imageOpsMask
|
||||
};
|
||||
export {
|
||||
_spherizeMapFwd,
|
||||
_spherizeMapInv,
|
||||
alphaMaskCanvas,
|
||||
applyBlur,
|
||||
applyChannel,
|
||||
applyClamp,
|
||||
applyColorCorrect,
|
||||
applyColorCorrectReference,
|
||||
applyCrop,
|
||||
applyCropReformat,
|
||||
applyDesaturate,
|
||||
applyEdgeDetect,
|
||||
applyEffectToCanvas,
|
||||
applyGlow,
|
||||
applyHueSat,
|
||||
applyInvert,
|
||||
applyLevels,
|
||||
applyLumaKey,
|
||||
applyMaskConvertLevels,
|
||||
applyRampCurve,
|
||||
applySpherize,
|
||||
applyTransform,
|
||||
applyUnsharp,
|
||||
bilinearSample,
|
||||
blend,
|
||||
blendGrainValue,
|
||||
blurField,
|
||||
blurMaskAlphaCanvas,
|
||||
blurMaskCanvas,
|
||||
bool,
|
||||
boolAny,
|
||||
buildMaskAlphaCanvas,
|
||||
buildNoiseField,
|
||||
canvasHasVisibleTransparency,
|
||||
clamp01,
|
||||
compModeToCanvasOp,
|
||||
composeCropStitchPreview,
|
||||
compositeAt,
|
||||
compositeProcessedWithMask,
|
||||
computeMaskBounds,
|
||||
cropRectCanvas,
|
||||
cropStitchBBoxFromMask,
|
||||
cubicHermite,
|
||||
distortConnectedInputs,
|
||||
drawCropStitchPanel,
|
||||
emptyMaskCanvas,
|
||||
extractCanvasField,
|
||||
extractMaskDrivenCrop,
|
||||
extractSplitChannelCanvas,
|
||||
fitCanvas,
|
||||
fitMergeForeground,
|
||||
flipCanvas,
|
||||
getCanvasDimensions,
|
||||
getImageData,
|
||||
getPreferredInputIndexes,
|
||||
gradientDot3,
|
||||
grainRandom01,
|
||||
hexToRgb01,
|
||||
imageLikeInputName,
|
||||
imageToMaskPreviewCanvas,
|
||||
invert3x3,
|
||||
invertMaskAlphaCanvas,
|
||||
invertMaskCanvas,
|
||||
isPreparedMaskCanvas,
|
||||
linearToSrgb01,
|
||||
luma01,
|
||||
makeCanvas,
|
||||
makeCropStitchRectMask,
|
||||
markPreparedMaskCanvas,
|
||||
maskCanvasToPreviewCanvas,
|
||||
maskConvertSourceValue,
|
||||
mergeChannelInputs,
|
||||
neutralField,
|
||||
noiseFade,
|
||||
noiseHash3D,
|
||||
noiseLerp,
|
||||
normalizeAffineFillMode,
|
||||
normalizeBlendModeName,
|
||||
normalizeFilterName,
|
||||
normalizePreparedMaskCanvas,
|
||||
num,
|
||||
numAny,
|
||||
ops,
|
||||
parseCropStitchBBox,
|
||||
parseHexColor,
|
||||
parseKeyColors,
|
||||
premultLayerWithMask,
|
||||
prepareMaskCanvasInPlace,
|
||||
putImageData,
|
||||
reflectCoord,
|
||||
reflectCoordinate,
|
||||
renderCompPreview,
|
||||
renderDrawNodePreview
|
||||
renderConstantCanvas,
|
||||
renderCornerPinCanvases,
|
||||
renderCropStitchCanvases,
|
||||
renderDistortCanvas,
|
||||
renderDrawNodePreview,
|
||||
renderGrainCanvas,
|
||||
renderKeyerCanvases,
|
||||
renderMaskedEffectPreview,
|
||||
renderNoiseCanvas,
|
||||
renderNoiseFieldCanvas,
|
||||
renderPadOutCanvases,
|
||||
renderRampCanvas,
|
||||
renderSpherizeMaskCanvas,
|
||||
renderTextCanvas,
|
||||
resizeWithMode,
|
||||
resolveBlurRadiusPx,
|
||||
resolveCompPreviewInputs,
|
||||
resolveConnectedString,
|
||||
resolvePadOutGeometry,
|
||||
resolvePreviewMaskCanvas,
|
||||
resolveResizeDimensions,
|
||||
rgbToHsv01,
|
||||
rotateDiscrete,
|
||||
sampleChannel,
|
||||
sampleChannelBicubic,
|
||||
sampleChannelBilinear,
|
||||
sampleChannelNearest,
|
||||
sampleNoiseBasis,
|
||||
samplePerlinNoise,
|
||||
sampleValueNoise,
|
||||
sampleWhiteNoise,
|
||||
setResampleMode,
|
||||
shakeRandom,
|
||||
smoothRange01,
|
||||
smoothShakeValue,
|
||||
softKeyDistance,
|
||||
solveCornerPinInverseHomography,
|
||||
solveInverseHomographyFromCorners,
|
||||
solveLinear8x8,
|
||||
srgbToLinear01,
|
||||
stitchCanvases,
|
||||
str,
|
||||
strAny,
|
||||
w,
|
||||
wAny,
|
||||
warpCanvasToQuad,
|
||||
widgetScalarValue,
|
||||
wrapNoiseIndex
|
||||
};
|
||||
|
||||
+136
-1
@@ -1,8 +1,143 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import {
|
||||
alphaMaskCanvas,
|
||||
applyChannel,
|
||||
applyEffectToCanvas,
|
||||
boolAny,
|
||||
buildMaskAlphaCanvas,
|
||||
clamp01,
|
||||
fitCanvas,
|
||||
imageToMaskPreviewCanvas,
|
||||
invertMaskCanvas,
|
||||
makeCanvas,
|
||||
markPreparedMaskCanvas,
|
||||
numAny,
|
||||
ops,
|
||||
renderConstantCanvas,
|
||||
renderCornerPinCanvases,
|
||||
renderCropStitchCanvases,
|
||||
renderDistortCanvas,
|
||||
renderKeyerCanvases,
|
||||
renderNoiseCanvas,
|
||||
renderPadOutCanvases,
|
||||
renderRampCanvas,
|
||||
renderSpherizeMaskCanvas,
|
||||
resolvePreviewMaskCanvas,
|
||||
strAny
|
||||
} from "./implementation.js";
|
||||
const maskOps = {
|
||||
imageOpsMask: ops.imageOpsMask,
|
||||
channelApply: ops.channelApply
|
||||
};
|
||||
function channelApply(ctx, W, node, inputs) {
|
||||
const base = fitCanvas(inputs[0] ?? ctx.canvas, (inputs[0] ?? ctx.canvas).width || 1, (inputs[0] ?? ctx.canvas).height || 1);
|
||||
const mask = inputs[1] ? fitCanvas(inputs[1], base.width, base.height) : null;
|
||||
if (!mask) return base;
|
||||
const bctx = base.getContext("2d", { willReadFrequently: true });
|
||||
const image = bctx.getImageData(0, 0, base.width, base.height);
|
||||
const data = image.data;
|
||||
const matte = mask.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, base.width, base.height).data;
|
||||
const channel = strAny(node, ["channel"], "A").toLowerCase();
|
||||
const channelIndex = channel === "g" || channel === "green" ? 1 : channel === "b" || channel === "blue" ? 2 : channel === "a" || channel === "alpha" ? 3 : 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const value = Math.round(clamp01(matte[i] / 255 * (matte[i + 3] / 255)) * 255);
|
||||
data[i + channelIndex] = value;
|
||||
}
|
||||
bctx.putImageData(image, 0, 0);
|
||||
return base;
|
||||
}
|
||||
function imageOpsMask(ctx, W, node, cls, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const rawMask = inputs[1] ?? null;
|
||||
const resolvedMask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex);
|
||||
if (cls === "ImageOpsMaskConvert") {
|
||||
return boolAny(node, ["reverse"], false, frameIndex) ? imageToMaskPreviewCanvas(source, node, frameIndex) : buildMaskAlphaCanvas(source, source.width || 1, source.height || 1);
|
||||
}
|
||||
if (cls === "ImageOpsNoise") {
|
||||
return renderNoiseCanvas(node, true, frameIndex, W);
|
||||
}
|
||||
if (cls === "ImageOpsConstant") {
|
||||
return renderConstantCanvas(node, true);
|
||||
}
|
||||
if (cls === "ImageOpsRamp") {
|
||||
return renderRampCanvas(node, true);
|
||||
}
|
||||
if (cls === "ImageOpsDistort") {
|
||||
return renderDistortCanvas(node, inputs, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsBlur") {
|
||||
return resolvedMask ?? alphaMaskCanvas(source);
|
||||
}
|
||||
if (cls === "ImageOpsTransform") {
|
||||
return ops.transform(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex);
|
||||
}
|
||||
if (cls === "ImageOpsCrop") {
|
||||
return ops.crop(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex);
|
||||
}
|
||||
if (cls === "ImageOpsCropStitch") {
|
||||
return renderCropStitchCanvases(node, inputs, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsPadOut") {
|
||||
return renderPadOutCanvases(node, source, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsCornerPin") {
|
||||
return renderCornerPinCanvases(node, source, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsChannel") {
|
||||
const extracted = applyEffectToCanvas(source, (effectCtx, width, height) => {
|
||||
applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red", frameIndex));
|
||||
});
|
||||
return buildMaskAlphaCanvas(extracted, extracted.width || 1, extracted.height || 1);
|
||||
}
|
||||
if (cls === "ImageOpsClamp") {
|
||||
if (!resolvedMask) return alphaMaskCanvas(source);
|
||||
const lo = numAny(node, ["min_v", "min"], 0, frameIndex);
|
||||
const hi = numAny(node, ["max_v", "max"], 1, frameIndex);
|
||||
const mn = Math.round(clamp01(Math.min(lo, hi)) * 255);
|
||||
const mx = Math.round(clamp01(Math.max(lo, hi)) * 255);
|
||||
const clampMaskOut = makeCanvas(resolvedMask.width || 1, resolvedMask.height || 1);
|
||||
const clampMaskCtx = clampMaskOut.getContext("2d", { willReadFrequently: true });
|
||||
clampMaskCtx.drawImage(resolvedMask, 0, 0);
|
||||
const clampImg = clampMaskCtx.getImageData(0, 0, clampMaskOut.width, clampMaskOut.height);
|
||||
const clampData = clampImg.data;
|
||||
for (let ci = 0; ci < clampData.length; ci += 4) {
|
||||
clampData[ci] = 255;
|
||||
clampData[ci + 1] = 255;
|
||||
clampData[ci + 2] = 255;
|
||||
clampData[ci + 3] = Math.max(mn, Math.min(mx, clampData[ci + 3]));
|
||||
}
|
||||
clampMaskCtx.putImageData(clampImg, 0, 0);
|
||||
return markPreparedMaskCanvas(clampMaskOut);
|
||||
}
|
||||
if (cls === "ImageOpsKeyer") {
|
||||
return renderKeyerCanvases(node, source, rawMask, frameIndex).mask;
|
||||
}
|
||||
if (cls === "ImageOpsInvert") {
|
||||
const mask = resolvedMask ?? alphaMaskCanvas(source);
|
||||
return mask;
|
||||
}
|
||||
if (cls === "ImageOpsSpherize") {
|
||||
return renderSpherizeMaskCanvas(node, source, rawMask, frameIndex);
|
||||
}
|
||||
if (cls === "ImageOpsMerge") {
|
||||
const mergeMaskInput = inputs[2] ?? null;
|
||||
if (mergeMaskInput) {
|
||||
const mergeResolvedMask = resolvePreviewMaskCanvas(node, source, mergeMaskInput, frameIndex);
|
||||
if (mergeResolvedMask) return mergeResolvedMask;
|
||||
}
|
||||
const merged = ops.merge(ctx, W, node, inputs, void 0, frameIndex);
|
||||
return alphaMaskCanvas(merged);
|
||||
}
|
||||
if (cls === "ImageOpsComp") {
|
||||
const mask = alphaMaskCanvas(ops.comp(ctx, W, node, inputs));
|
||||
return boolAny(node, ["invert_mask"], false, frameIndex) ? invertMaskCanvas(mask) : mask;
|
||||
}
|
||||
if (cls === "ImageOpsColorAjust") {
|
||||
return resolvedMask ?? alphaMaskCanvas(source);
|
||||
}
|
||||
return resolvedMask ?? alphaMaskCanvas(source);
|
||||
}
|
||||
export {
|
||||
channelApply,
|
||||
imageOpsMask,
|
||||
maskOps
|
||||
};
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import { renderDrawPreview, resolveDrawOverlayCanvas } from "../draw.js";
|
||||
import {
|
||||
boolAny,
|
||||
invertMaskCanvas,
|
||||
makeCanvas,
|
||||
markPreparedMaskCanvas,
|
||||
numAny,
|
||||
ops,
|
||||
renderConstantCanvas,
|
||||
renderGrainCanvas,
|
||||
renderKeyerCanvases,
|
||||
renderNoiseCanvas,
|
||||
renderRampCanvas,
|
||||
renderTextCanvas,
|
||||
stitchCanvases,
|
||||
strAny
|
||||
} from "./implementation.js";
|
||||
const proceduralOps = {
|
||||
constant: ops.constant,
|
||||
ramp: ops.ramp,
|
||||
@@ -6,6 +22,75 @@ const proceduralOps = {
|
||||
grain: ops.grain,
|
||||
text: ops.text
|
||||
};
|
||||
function grain(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderGrainCanvas(node, source, inputs[1] ?? null, frameIndex);
|
||||
}
|
||||
function text(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderTextCanvas(node, source, inputs[1] ?? null, frameIndex);
|
||||
}
|
||||
function keyer(ctx, W, node, inputs = [], frameIndex = 0) {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderKeyerCanvases(node, source, inputs[1] ?? null, frameIndex).image;
|
||||
}
|
||||
function stitch(ctx, W, node, inputs) {
|
||||
const first = inputs[0] ?? ctx.canvas;
|
||||
const second = inputs[1] ?? null;
|
||||
if (!second) return first;
|
||||
return stitchCanvases(
|
||||
first,
|
||||
second,
|
||||
strAny(node, ["direction"], "right"),
|
||||
numAny(node, ["spacing_width"], 0),
|
||||
strAny(node, ["spacing_color"], "black"),
|
||||
boolAny(node, ["match_image_size"], true)
|
||||
);
|
||||
}
|
||||
function constant(ctx, W, node) {
|
||||
return renderConstantCanvas(node, false);
|
||||
}
|
||||
function ramp(ctx, W, node) {
|
||||
return renderRampCanvas(node, false);
|
||||
}
|
||||
function noise(ctx, W, node, frameIndex = 0) {
|
||||
return renderNoiseCanvas(node, false, frameIndex, W);
|
||||
}
|
||||
async function draw(ctx, W, node, inputs) {
|
||||
return await renderDrawPreview(node, inputs[0] ?? null);
|
||||
}
|
||||
async function drawMask(ctx, W, node, inputs) {
|
||||
const base = inputs[0] ?? null;
|
||||
const width = base?.width || Math.max(1, Math.round(numAny(node, ["width"], 1024)));
|
||||
const height = base?.height || Math.max(1, Math.round(numAny(node, ["height"], 1024)));
|
||||
const overlay = await resolveDrawOverlayCanvas(node, width, height);
|
||||
const ow = overlay.width || 1;
|
||||
const oh = overlay.height || 1;
|
||||
const matte = makeCanvas(ow, oh);
|
||||
const mctx = matte.getContext("2d", { willReadFrequently: true });
|
||||
mctx.drawImage(overlay, 0, 0);
|
||||
const img = mctx.getImageData(0, 0, ow, oh);
|
||||
const data = img.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = a;
|
||||
}
|
||||
mctx.putImageData(img, 0, 0);
|
||||
const mask = markPreparedMaskCanvas(matte);
|
||||
return boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(mask) : mask;
|
||||
}
|
||||
export {
|
||||
proceduralOps
|
||||
constant,
|
||||
draw,
|
||||
drawMask,
|
||||
grain,
|
||||
keyer,
|
||||
noise,
|
||||
proceduralOps,
|
||||
ramp,
|
||||
stitch,
|
||||
text
|
||||
};
|
||||
|
||||
+14
-1
@@ -1,4 +1,9 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import {
|
||||
extractSplitChannelCanvas,
|
||||
mergeChannelInputs,
|
||||
ops,
|
||||
strAny
|
||||
} from "./implementation.js";
|
||||
const videoOps = {
|
||||
draw: ops.draw,
|
||||
drawMask: ops.drawMask,
|
||||
@@ -7,6 +12,14 @@ const videoOps = {
|
||||
channelSplit: ops.channelSplit,
|
||||
channelMerge: ops.channelMerge
|
||||
};
|
||||
function channelSplit(ctx, W, node, outputSlot) {
|
||||
return extractSplitChannelCanvas(ctx.canvas, outputSlot, strAny(node, ["mode"], "RGBA"));
|
||||
}
|
||||
function channelMerge(ctx, W, node, inputs) {
|
||||
return mergeChannelInputs(inputs, strAny(node, ["mode"], "RGBA")) ?? (inputs[0] ?? ctx.canvas);
|
||||
}
|
||||
export {
|
||||
channelMerge,
|
||||
channelSplit,
|
||||
videoOps
|
||||
};
|
||||
|
||||
+29
-87
@@ -1,28 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _resize, _select_media_tensor
|
||||
from ._preview import build_node_preview_result
|
||||
from ._progress import start_progress
|
||||
|
||||
_JOIN_FIT_MODES = ["strict", "resize_to_first", "pad_to_max"]
|
||||
|
||||
_JOIN_FIT_MODES = ['strict', 'resize_to_first', 'pad_to_max']
|
||||
|
||||
def _sorted_clip_inputs(inputs: dict[str, Any]) -> list[tuple[int, Any]]:
|
||||
clips: list[tuple[int, Any]] = []
|
||||
legacy = [("image_a", 1), ("image_b", 2)]
|
||||
legacy = [('image_a', 1), ('image_b', 2)]
|
||||
for key, index in legacy:
|
||||
if inputs.get(key) is not None:
|
||||
clips.append((index, inputs[key]))
|
||||
for key, value in inputs.items():
|
||||
if value is None or not isinstance(key, str):
|
||||
continue
|
||||
match = re.fullmatch(r"image_(\d+)", key)
|
||||
match = re.fullmatch('image_(\\d+)', key)
|
||||
if match:
|
||||
clips.append((int(match.group(1)), value))
|
||||
dedup: dict[int, Any] = {}
|
||||
@@ -30,12 +26,11 @@ def _sorted_clip_inputs(inputs: dict[str, Any]) -> list[tuple[int, Any]]:
|
||||
dedup[index] = value
|
||||
return sorted(dedup.items(), key=lambda item: item[0])
|
||||
|
||||
|
||||
def _parse_trims(trims_json: str | dict | list | None) -> dict[int, tuple[int, int]]:
|
||||
if isinstance(trims_json, (dict, list)):
|
||||
parsed = trims_json
|
||||
else:
|
||||
raw = str(trims_json or "").strip()
|
||||
raw = str(trims_json or '').strip()
|
||||
if not raw:
|
||||
parsed = {}
|
||||
else:
|
||||
@@ -43,25 +38,24 @@ def _parse_trims(trims_json: str | dict | list | None) -> dict[int, tuple[int, i
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = {}
|
||||
source = parsed.get("clips", []) if isinstance(parsed, dict) else parsed
|
||||
source = parsed.get('clips', []) if isinstance(parsed, dict) else parsed
|
||||
trims: dict[int, tuple[int, int]] = {}
|
||||
if not isinstance(source, list):
|
||||
return trims
|
||||
for entry in source:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
slot = str(entry.get("slot") or "")
|
||||
match = re.search(r"(\d+)$", slot)
|
||||
slot = str(entry.get('slot') or '')
|
||||
match = re.search('(\\d+)$', slot)
|
||||
if not match:
|
||||
continue
|
||||
start_raw = entry.get("start", 0)
|
||||
end_raw = entry.get("end", -1)
|
||||
start_raw = entry.get('start', 0)
|
||||
end_raw = entry.get('end', -1)
|
||||
start = int(0 if start_raw is None else start_raw)
|
||||
end = int(-1 if end_raw is None else end_raw)
|
||||
trims[int(match.group(1))] = (start, end)
|
||||
return trims
|
||||
|
||||
|
||||
def _trim_clip(source: torch.Tensor, start: int, end: int) -> torch.Tensor:
|
||||
count = int(source.shape[0])
|
||||
if count <= 0:
|
||||
@@ -69,10 +63,9 @@ def _trim_clip(source: torch.Tensor, start: int, end: int) -> torch.Tensor:
|
||||
actual_start = max(0, min(int(start), count - 1))
|
||||
actual_end = count - 1 if int(end) < 0 else max(0, min(int(end), count - 1))
|
||||
if actual_end < actual_start:
|
||||
actual_start, actual_end = actual_end, actual_start
|
||||
actual_start, actual_end = (actual_end, actual_start)
|
||||
return source[actual_start:actual_end + 1]
|
||||
|
||||
|
||||
def _coerce_channels(tensor: torch.Tensor, target_channels: int) -> torch.Tensor:
|
||||
batch, h, w, channels = tensor.shape
|
||||
if channels == target_channels:
|
||||
@@ -95,7 +88,6 @@ def _coerce_channels(tensor: torch.Tensor, target_channels: int) -> torch.Tensor
|
||||
return torch.cat([tensor, padding], dim=-1)
|
||||
return tensor[..., :target_channels]
|
||||
|
||||
|
||||
def _pad_to_size(source: torch.Tensor, target_w: int, target_h: int) -> torch.Tensor:
|
||||
batch, source_h, source_w, channels = source.shape
|
||||
if source_w == target_w and source_h == target_h:
|
||||
@@ -108,67 +100,39 @@ def _pad_to_size(source: torch.Tensor, target_w: int, target_h: int) -> torch.Te
|
||||
out[:, top:top + source_h, left:left + source_w, :] = source
|
||||
return out
|
||||
|
||||
|
||||
def _align_pair(image_a: torch.Tensor, image_b: torch.Tensor, fit_mode: str) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
a_h, a_w = int(image_a.shape[1]), int(image_a.shape[2])
|
||||
b_h, b_w = int(image_b.shape[1]), int(image_b.shape[2])
|
||||
a_h, a_w = (int(image_a.shape[1]), int(image_a.shape[2]))
|
||||
b_h, b_w = (int(image_b.shape[1]), int(image_b.shape[2]))
|
||||
if a_w == b_w and a_h == b_h:
|
||||
return image_a, image_b
|
||||
|
||||
mode = str(fit_mode or "strict").strip().lower()
|
||||
if mode == "resize_to_first":
|
||||
return image_a, _resize(image_b, a_w, a_h, mode="bicubic", antialias=True)
|
||||
if mode == "pad_to_max":
|
||||
return (image_a, image_b)
|
||||
mode = str(fit_mode or 'strict').strip().lower()
|
||||
if mode == 'resize_to_first':
|
||||
return (image_a, _resize(image_b, a_w, a_h, mode='bicubic', antialias=True))
|
||||
if mode == 'pad_to_max':
|
||||
target_w = max(a_w, b_w)
|
||||
target_h = max(a_h, b_h)
|
||||
return _pad_to_size(image_a, target_w, target_h), _pad_to_size(image_b, target_w, target_h)
|
||||
return (_pad_to_size(image_a, target_w, target_h), _pad_to_size(image_b, target_w, target_h))
|
||||
raise ValueError(f'ImageOps Append requires matching dimensions in strict mode. image_a is {a_w}x{a_h}, image_b is {b_w}x{b_h}. Use resize_to_first or pad_to_max to align them.')
|
||||
|
||||
raise ValueError(
|
||||
"ImageOps Append requires matching dimensions in strict mode. "
|
||||
f"image_a is {a_w}x{a_h}, image_b is {b_w}x{b_h}. "
|
||||
"Use resize_to_first or pad_to_max to align them."
|
||||
)
|
||||
|
||||
|
||||
class ImageOpsAppend:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "INT", "INT", "INT")
|
||||
RETURN_NAMES = ("image", "frame_count", "width", "height")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsAppend(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"fit_mode": (_JOIN_FIT_MODES, {"default": "strict", "tooltip": "How to align two clips before concatenating their frame batches."}),
|
||||
"trims_json": ("STRING", {"default": '{"version":1,"clips":[]}', "multiline": False, "tooltip": "Managed by the Append preview controls."}),
|
||||
},
|
||||
"optional": {
|
||||
"image_1": (MEDIA_INPUT_TYPE, {"forceInput": True, "display_name": "Images/Video 1"}),
|
||||
"image_2": (MEDIA_INPUT_TYPE, {"forceInput": True, "display_name": "Images/Video 2"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsAppend', display_name='〽️ Image Ops Append', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.String.Input('fit_mode', default='strict', tooltip='How to align two clips before concatenating their frame batches.'), io.String.Input('trims_json', default='{"version":1,"clips":[]}', multiline=False, tooltip='Managed by the Append preview controls.'), io.String.Input('image_1', force_input=True, display_name='Images/Video 1', optional=True), io.String.Input('image_2', force_input=True, display_name='Images/Video 2', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Int.Output('frame_count', display_name='frame_count'), io.Int.Output('width', display_name='width'), io.Int.Output('height', display_name='height')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(self, bypass=False, fit_mode="strict", trims_json='{"version":1,"clips":[]}', unique_id=None, **inputs):
|
||||
@classmethod
|
||||
def execute(cls, bypass=False, fit_mode='strict', trims_json='{"version":1,"clips":[]}', unique_id=None, **inputs):
|
||||
from .core.media import ImageOpsMedia
|
||||
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
clips = _sorted_clip_inputs(inputs)
|
||||
if not clips:
|
||||
raise ValueError("ImageOps Append needs at least one connected image/video input.")
|
||||
|
||||
raise ValueError('ImageOps Append needs at least one connected image/video input.')
|
||||
trims = _parse_trims(trims_json)
|
||||
tensors: list[torch.Tensor] = []
|
||||
clip_metadata: list[dict[str, int]] = []
|
||||
|
||||
has_media = False
|
||||
fps = 24.0
|
||||
audio_list = []
|
||||
|
||||
for clip_index, value in clips:
|
||||
is_media = isinstance(value, ImageOpsMedia)
|
||||
if is_media:
|
||||
@@ -177,23 +141,13 @@ class ImageOpsAppend:
|
||||
has_media = True
|
||||
if value.audio is not None:
|
||||
audio_list.append(value.audio)
|
||||
|
||||
tensor = _select_media_tensor(value, None).float().clamp(0.0, 1.0)
|
||||
start, end = trims.get(clip_index, (0, -1))
|
||||
trimmed = _trim_clip(tensor, start, end)
|
||||
tensors.append(trimmed)
|
||||
clip_metadata.append({
|
||||
"slot": int(clip_index),
|
||||
"source_count": int(tensor.shape[0]),
|
||||
"trimmed_count": int(trimmed.shape[0]),
|
||||
"start": int(start),
|
||||
"end": int(end),
|
||||
})
|
||||
|
||||
# Coerce channels to the maximum channel count among connected inputs
|
||||
max_channels = max(int(t.shape[3]) for t in tensors)
|
||||
clip_metadata.append({'slot': int(clip_index), 'source_count': int(tensor.shape[0]), 'trimmed_count': int(trimmed.shape[0]), 'start': int(start), 'end': int(end)})
|
||||
max_channels = max((int(t.shape[3]) for t in tensors))
|
||||
tensors = [_coerce_channels(t, max_channels) for t in tensors]
|
||||
|
||||
if bool(bypass) or len(tensors) == 1:
|
||||
out_tensor = tensors[0]
|
||||
else:
|
||||
@@ -204,25 +158,13 @@ class ImageOpsAppend:
|
||||
aligned = [first] + [_align_pair(first, item, fit_mode)[1] for item in aligned[1:]]
|
||||
aligned.append(current)
|
||||
out_tensor = torch.cat(aligned, dim=0)
|
||||
|
||||
if has_media:
|
||||
# We skip proper audio concatenation for now since it requires exact sample rates and alignment,
|
||||
# we just take the first audio or None if there isn't any
|
||||
out_audio = audio_list[0] if audio_list else None
|
||||
out = ImageOpsMedia(frames=out_tensor, fps=fps, audio=out_audio)
|
||||
else:
|
||||
out = out_tensor
|
||||
|
||||
progress.finish()
|
||||
frame_count = int(out_tensor.shape[0])
|
||||
height = int(out_tensor.shape[1])
|
||||
width = int(out_tensor.shape[2])
|
||||
return build_node_preview_result(
|
||||
out_tensor,
|
||||
(out, frame_count, width, height),
|
||||
prefix="imageops_append",
|
||||
metadata={
|
||||
"imageops_append_frame_count": [frame_count],
|
||||
"imageops_append_clip_counts": [clip_metadata],
|
||||
},
|
||||
)
|
||||
return build_node_preview_result(out_tensor, (out, frame_count, width, height), prefix='imageops_append', metadata={'imageops_append_frame_count': [frame_count], 'imageops_append_clip_counts': [clip_metadata]})
|
||||
+15
-66
@@ -1,19 +1,8 @@
|
||||
from ._helpers import (
|
||||
_apply_mask_to_image,
|
||||
_apply_blur_with_mask_pair,
|
||||
_dispatch_blur,
|
||||
_gaussian_effective_radius,
|
||||
MEDIA_INPUT_TYPE,
|
||||
_prepare_effect_mask,
|
||||
_resolve_mask_output_source,
|
||||
_scalar,
|
||||
_select_media_tensor,
|
||||
)
|
||||
from comfy_api.latest import io
|
||||
from ._helpers import _apply_mask_to_image, _apply_blur_with_mask_pair, _dispatch_blur, _gaussian_effective_radius, MEDIA_INPUT_TYPE, _prepare_effect_mask, _resolve_mask_output_source, _scalar, _select_media_tensor
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
_BLUR_TYPES = ["gaussian", "box", "defocus", "surface"]
|
||||
|
||||
_BLUR_TYPES = ['gaussian', 'box', 'defocus', 'surface']
|
||||
|
||||
def _blur_is_noop(radius) -> bool:
|
||||
"""All blur types are no-ops when radius is zero for every frame."""
|
||||
@@ -21,81 +10,41 @@ def _blur_is_noop(radius) -> bool:
|
||||
r_len = len(radius)
|
||||
if r_len <= 0:
|
||||
return True
|
||||
return all(int(max(0, _scalar(radius, int, index=i))) <= 0 for i in range(r_len))
|
||||
return all((int(max(0, _scalar(radius, int, index=i))) <= 0 for i in range(r_len)))
|
||||
return int(max(0, _scalar(radius, int))) <= 0
|
||||
|
||||
|
||||
class ImageOpsBlur:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsBlur(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"blur_type": (_BLUR_TYPES, {"default": "gaussian"}),
|
||||
"radius": ("INT", {"default": 3, "min": 0, "max": 128, "step": 1,
|
||||
"tooltip": "Blur extent in pixels. 0 = no blur (disables all types)."}),
|
||||
"sigma": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 64.0, "step": 0.01, "round": 0.001,
|
||||
"tooltip": (
|
||||
"gaussian: Gaussian std-dev in pixels (0 = auto, radius/3). "
|
||||
"surface: colour-similarity threshold 0\u20131 (0 = auto \u22480.15). "
|
||||
"box / defocus: unused."
|
||||
)}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsBlur', display_name='〽️ Image Ops Blur', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.String.Input('blur_type', default='gaussian'), io.Int.Input('radius', default=3, min=0, max=128, step=1, tooltip='Blur extent in pixels. 0 = no blur (disables all types).'), io.Float.Input('sigma', default=0.0, min=0.0, max=64.0, step=0.01, round=0.001, tooltip='gaussian: Gaussian std-dev in pixels (0 = auto, radius/3). surface: colour-similarity threshold 0–1 (0 = auto ≈0.15). box / defocus: unused.'), io.Boolean.Input('invert_mask', default=False), io.String.Input('image', tooltip='Images/Video input.', force_input=True, display_name='Images/Video', optional=True), io.Mask.Input('mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(self, image=None, bypass=False, blur_type="gaussian", radius=3, sigma=0.0,
|
||||
invert_mask=False, mask=None, unique_id=None):
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, blur_type='gaussian', radius=3, sigma=0.0, invert_mask=False, mask=None, unique_id=None, **kwargs):
|
||||
source = _select_media_tensor(image, None)
|
||||
input_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask)
|
||||
output_mask_source = _resolve_mask_output_source(mask, source, invert_mask=invert_mask)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
|
||||
# Per-frame bypass: if all frames are bypassed skip entirely.
|
||||
if isinstance(bypass, (list, tuple)):
|
||||
all_bypassed = all(_scalar(bypass, bool, index=i) for i in range(max(len(bypass), 1)))
|
||||
all_bypassed = all((_scalar(bypass, bool, index=i) for i in range(max(len(bypass), 1))))
|
||||
else:
|
||||
all_bypassed = bool(bypass)
|
||||
if all_bypassed:
|
||||
progress.finish()
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_blur")
|
||||
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix='imageops_blur')
|
||||
if _blur_is_noop(radius):
|
||||
progress.finish()
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_blur")
|
||||
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix='imageops_blur')
|
||||
from .core.memory import check_budget
|
||||
if source is not None:
|
||||
check_budget(
|
||||
int(source.shape[0]), int(source.shape[1]), int(source.shape[2]),
|
||||
int(source.shape[3]), multiplier=3.0, label="ImageOps Blur",
|
||||
)
|
||||
bt = str(blur_type) if blur_type in _BLUR_TYPES else "gaussian"
|
||||
|
||||
check_budget(int(source.shape[0]), int(source.shape[1]), int(source.shape[2]), int(source.shape[3]), multiplier=3.0, label='ImageOps Blur')
|
||||
bt = str(blur_type) if blur_type in _BLUR_TYPES else 'gaussian'
|
||||
if input_mask is not None:
|
||||
# _apply_blur_with_mask_pair returns a blurred-mask alongside the
|
||||
# blurred image, but the user-facing MASK output should remain the
|
||||
# *original* prepared mask: the blur affects the image, not the mask
|
||||
# selection. The blurred mask is consumed locally to composite the
|
||||
# blurred result back over the source.
|
||||
result, blurred_mask = _apply_blur_with_mask_pair(source, input_mask, radius, sigma, blur_type=bt)
|
||||
result = _apply_mask_to_image(source, result, blurred_mask)
|
||||
output_mask = output_mask_source
|
||||
else:
|
||||
result = _dispatch_blur(source, radius, sigma, blur_type=bt)
|
||||
# Full-white mask (no mask input) is unchanged by blur — skip the extra conv pass.
|
||||
output_mask = output_mask_source
|
||||
progress.finish()
|
||||
return build_node_preview_result(result, (result, output_mask), prefix="imageops_blur")
|
||||
return build_node_preview_result(result, (result, output_mask), prefix='imageops_blur')
|
||||
+19
-126
@@ -1,31 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import math
|
||||
import random
|
||||
|
||||
import torch
|
||||
|
||||
from ._helpers import (
|
||||
MEDIA_INPUT_TYPE,
|
||||
_prepare_effect_mask,
|
||||
_resolve_mask_output_source,
|
||||
_scalar,
|
||||
_select_media_tensor,
|
||||
)
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _prepare_effect_mask, _resolve_mask_output_source, _scalar, _select_media_tensor
|
||||
from ._preview import build_node_preview_result
|
||||
from ._progress import start_progress
|
||||
from .transform import (
|
||||
_composite_fill,
|
||||
_make_fill_background,
|
||||
_normalize_fill_mode,
|
||||
_padding_mode_from_fill,
|
||||
_transform_batch_affine,
|
||||
_transform_mask_affine,
|
||||
_transform_masked_source,
|
||||
)
|
||||
|
||||
_FILL_MODES = ["transparent", "mirror", "stretch", "expand", "color"]
|
||||
|
||||
from .transform import _composite_fill, _make_fill_background, _normalize_fill_mode, _padding_mode_from_fill, _transform_batch_affine, _transform_mask_affine, _transform_masked_source
|
||||
_FILL_MODES = ['transparent', 'mirror', 'stretch', 'expand', 'color']
|
||||
|
||||
def _smooth_random_series(count: int, amount: float, seed: int, smoothing: float) -> list[float]:
|
||||
rng = random.Random(int(seed))
|
||||
@@ -38,10 +20,9 @@ def _smooth_random_series(count: int, amount: float, seed: int, smoothing: float
|
||||
values.append(current)
|
||||
return values
|
||||
|
||||
|
||||
def _resample_series(series: list[float], frequency: float) -> list[float]:
|
||||
freq = max(0.01, float(frequency))
|
||||
if abs(freq - 1.0) <= 1.0e-6:
|
||||
if abs(freq - 1.0) <= 1e-06:
|
||||
return series
|
||||
if not series:
|
||||
return series
|
||||
@@ -55,143 +36,55 @@ def _resample_series(series: list[float], frequency: float) -> list[float]:
|
||||
out.append(series[lo] * (1.0 - t) + series[hi] * t)
|
||||
return out
|
||||
|
||||
|
||||
def _shake_params(
|
||||
frames: int,
|
||||
translate_px: float,
|
||||
rotate_deg: float,
|
||||
zoom: float,
|
||||
seed: int,
|
||||
smoothing: float,
|
||||
shake_frequency: float,
|
||||
) -> tuple[list[float], list[float], list[float], list[float]]:
|
||||
def _shake_params(frames: int, translate_px: float, rotate_deg: float, zoom: float, seed: int, smoothing: float, shake_frequency: float) -> tuple[list[float], list[float], list[float], list[float]]:
|
||||
source_count = max(frames, int(math.ceil(frames * max(0.01, float(shake_frequency)))) + 2)
|
||||
tx = _resample_series(_smooth_random_series(source_count, float(translate_px), int(seed) + 11, smoothing), shake_frequency)[:frames]
|
||||
ty = _resample_series(_smooth_random_series(source_count, float(translate_px), int(seed) + 23, smoothing), shake_frequency)[:frames]
|
||||
rot = _resample_series(_smooth_random_series(source_count, float(rotate_deg), int(seed) + 37, smoothing), shake_frequency)[:frames]
|
||||
zoom_jitter = _resample_series(_smooth_random_series(source_count, float(zoom), int(seed) + 53, smoothing), shake_frequency)[:frames]
|
||||
scales = [max(0.01, 1.0 + value) for value in zoom_jitter]
|
||||
return tx, ty, rot, scales
|
||||
|
||||
return (tx, ty, rot, scales)
|
||||
|
||||
def _all_near_zero(*values) -> bool:
|
||||
for value in values:
|
||||
if abs(float(_scalar(value))) > 1.0e-8:
|
||||
if abs(float(_scalar(value))) > 1e-08:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ImageOpsCameraShake:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsCameraShake(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"translate_px": ("FLOAT", {"default": 12.0, "min": 0.0, "max": 512.0, "step": 0.1}),
|
||||
"rotate_deg": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 45.0, "step": 0.01}),
|
||||
"zoom": ("FLOAT", {"default": 0.03, "min": 0.0, "max": 1.0, "step": 0.001}),
|
||||
"smoothing": ("FLOAT", {"default": 0.65, "min": 0.0, "max": 0.98, "step": 0.01}),
|
||||
"shake_frequency": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 8.0, "step": 0.05, "round": 0.001, "tooltip": "How quickly the camera shake target changes. 1 = one target per frame; lower is slower, higher is more nervous."}),
|
||||
"frame_length": ("INT", {"default": 24, "min": 1, "max": 4096, "step": 1, "tooltip": "Number of output frames when shaking a still image."}),
|
||||
"fps": ("FLOAT", {"default": 12.0, "min": 1.0, "max": 120.0, "step": 0.1, "round": 0.001}),
|
||||
"seed": ("INT", {"default": 12345, "min": 0, "max": 0xffffffffffffffff}),
|
||||
"filter": (["nearest", "bilinear", "bicubic"], {"default": "bilinear"}),
|
||||
"fill_mode": (_FILL_MODES, {"default": "mirror"}),
|
||||
"fill_color": ("COLOR", {"default": "#000000"}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsCameraShake', display_name='〽️ Image Ops Camera Shake', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.Float.Input('translate_px', default=12.0, min=0.0, max=512.0, step=0.1), io.Float.Input('rotate_deg', default=1.5, min=0.0, max=45.0, step=0.01), io.Float.Input('zoom', default=0.03, min=0.0, max=1.0, step=0.001), io.Float.Input('smoothing', default=0.65, min=0.0, max=0.98, step=0.01), io.Float.Input('shake_frequency', default=1.0, min=0.05, max=8.0, step=0.05, round=0.001, tooltip='How quickly the camera shake target changes. 1 = one target per frame; lower is slower, higher is more nervous.'), io.Int.Input('frame_length', default=24, min=1, max=4096, step=1, tooltip='Number of output frames when shaking a still image.'), io.Float.Input('fps', default=12.0, min=1.0, max=120.0, step=0.1, round=0.001), io.Int.Input('seed', default=12345, min=0, max=18446744073709551615), io.String.Input('filter', default='bilinear'), io.String.Input('fill_mode', default='mirror'), io.Color.Input('fill_color', default='#000000'), io.Boolean.Input('invert_mask', default=False), io.String.Input('image', tooltip='Images/Video input.', force_input=True, display_name='Images/Video', optional=True), io.Mask.Input('mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(
|
||||
self,
|
||||
image=None,
|
||||
bypass=False,
|
||||
translate_px=12.0,
|
||||
rotate_deg=1.5,
|
||||
zoom=0.03,
|
||||
smoothing=0.65,
|
||||
shake_frequency=1.0,
|
||||
frame_length=24,
|
||||
fps=12.0,
|
||||
seed=12345,
|
||||
filter="bilinear",
|
||||
fill_mode="mirror",
|
||||
fill_color="#000000",
|
||||
invert_mask=False,
|
||||
video=None,
|
||||
mask=None,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, translate_px=12.0, rotate_deg=1.5, zoom=0.03, smoothing=0.65, shake_frequency=1.0, frame_length=24, fps=12.0, seed=12345, filter='bilinear', fill_mode='mirror', fill_color='#000000', invert_mask=False, video=None, mask=None, unique_id=None, **kwargs):
|
||||
source = _select_media_tensor(image, video).float().clamp(0.0, 1.0)
|
||||
preview_fps = max(1.0, _scalar(fps, float))
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
|
||||
if _scalar(bypass, bool) or _all_near_zero(translate_px, rotate_deg, zoom):
|
||||
progress.finish()
|
||||
output_mask_source = _resolve_mask_output_source(mask, source, invert_mask=invert_mask)
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_camerashake", fps=preview_fps)
|
||||
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix='imageops_camerashake', fps=preview_fps)
|
||||
frame_count = max(1, _scalar(frame_length, int))
|
||||
if frame_count > int(source.shape[0]):
|
||||
repeats = (frame_count + int(source.shape[0]) - 1) // max(1, int(source.shape[0]))
|
||||
source = source.repeat((repeats, 1, 1, 1))[:frame_count]
|
||||
input_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask)
|
||||
output_mask_source = _resolve_mask_output_source(mask, source, invert_mask=invert_mask)
|
||||
|
||||
frames = int(source.shape[0])
|
||||
tx, ty, rot, scales = _shake_params(
|
||||
frames,
|
||||
_scalar(translate_px),
|
||||
_scalar(rotate_deg),
|
||||
_scalar(zoom),
|
||||
_scalar(seed, int),
|
||||
_scalar(smoothing),
|
||||
_scalar(shake_frequency),
|
||||
)
|
||||
tx, ty, rot, scales = _shake_params(frames, _scalar(translate_px), _scalar(rotate_deg), _scalar(zoom), _scalar(seed, int), _scalar(smoothing), _scalar(shake_frequency))
|
||||
safe_fill = _normalize_fill_mode(fill_mode)
|
||||
padding = _padding_mode_from_fill(safe_fill)
|
||||
|
||||
if input_mask is not None:
|
||||
result, output_mask = _transform_masked_source(
|
||||
source,
|
||||
input_mask,
|
||||
filter,
|
||||
tx,
|
||||
ty,
|
||||
rot,
|
||||
scales,
|
||||
progress=progress,
|
||||
padding_mode=padding,
|
||||
)
|
||||
result, output_mask = _transform_masked_source(source, input_mask, filter, tx, ty, rot, scales, progress=progress, padding_mode=padding)
|
||||
result = _composite_fill(result, output_mask, _make_fill_background(source, safe_fill, fill_color))
|
||||
else:
|
||||
progress.update_absolute(0, total=2)
|
||||
result = _transform_batch_affine(source, filter, tx, ty, rot, scales, padding_mode=padding)
|
||||
output_mask = _transform_mask_affine(
|
||||
output_mask_source,
|
||||
filter,
|
||||
tx,
|
||||
ty,
|
||||
rot,
|
||||
scales,
|
||||
source.device,
|
||||
source.dtype,
|
||||
padding_mode="zeros",
|
||||
)
|
||||
output_mask = _transform_mask_affine(output_mask_source, filter, tx, ty, rot, scales, source.device, source.dtype, padding_mode='zeros')
|
||||
result = _composite_fill(result, output_mask, _make_fill_background(source, safe_fill, fill_color))
|
||||
if safe_fill != "transparent":
|
||||
if safe_fill != 'transparent':
|
||||
output_mask = result[..., 3].clamp(0.0, 1.0) if result.shape[-1] >= 4 else torch.ones_like(output_mask)
|
||||
progress.finish()
|
||||
return build_node_preview_result(result, (result.clamp(0.0, 1.0), output_mask.clamp(0.0, 1.0)), prefix="imageops_camerashake", fps=preview_fps)
|
||||
return build_node_preview_result(result, (result.clamp(0.0, 1.0), output_mask.clamp(0.0, 1.0)), prefix='imageops_camerashake', fps=preview_fps)
|
||||
+10
-33
@@ -1,48 +1,25 @@
|
||||
from ._helpers import (
|
||||
CHANNEL_OPTIONS,
|
||||
MEDIA_INPUT_TYPE,
|
||||
_alpha_mask_from_image,
|
||||
_channel_mask_to_image,
|
||||
_extract_channel_mask,
|
||||
_scalar,
|
||||
_select_media_tensor,
|
||||
)
|
||||
from comfy_api.latest import io
|
||||
from ._helpers import CHANNEL_OPTIONS, MEDIA_INPUT_TYPE, _alpha_mask_from_image, _channel_mask_to_image, _extract_channel_mask, _scalar, _select_media_tensor
|
||||
from .compat.comfy_v3 import V3NodeBase
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
|
||||
class ImageOpsChannel(V3NodeBase):
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsChannel(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"channel": (CHANNEL_OPTIONS, {"default": "Red"}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsChannel', display_name='〽️ Image Ops Channel', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.String.Input('channel', default='Red'), io.String.Input('image', tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', force_input=True, display_name='Images/Video', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(self, image=None, bypass=False, channel="Red", video=None, unique_id=None):
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, channel='Red', video=None, unique_id=None, **kwargs):
|
||||
source = _select_media_tensor(image, video)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
if _scalar(bypass, bool):
|
||||
output_mask = _alpha_mask_from_image(source)
|
||||
progress.finish()
|
||||
return build_node_preview_result(source, (source, output_mask), prefix="imageops_channel")
|
||||
|
||||
return build_node_preview_result(source, (source, output_mask), prefix='imageops_channel')
|
||||
extracted = _extract_channel_mask(source, channel)
|
||||
if str(_scalar(channel, str)).strip().lower() == "alpha":
|
||||
if str(_scalar(channel, str)).strip().lower() == 'alpha':
|
||||
result = source.clone()
|
||||
if result.shape[-1] < 4:
|
||||
result = _channel_mask_to_image(extracted, source)
|
||||
@@ -52,4 +29,4 @@ class ImageOpsChannel(V3NodeBase):
|
||||
else:
|
||||
result = _channel_mask_to_image(extracted, source)
|
||||
progress.finish()
|
||||
return build_node_preview_result(result, (result, extracted), prefix="imageops_channel")
|
||||
return build_node_preview_result(result, (result, extracted), prefix='imageops_channel')
|
||||
+9
-34
@@ -1,49 +1,24 @@
|
||||
from ._helpers import (
|
||||
MEDIA_INPUT_TYPE,
|
||||
_apply_clamp,
|
||||
_clamp_mask,
|
||||
_resolve_mask_output_source,
|
||||
_scalar,
|
||||
_select_media_tensor,
|
||||
)
|
||||
from comfy_api.latest import io
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _apply_clamp, _clamp_mask, _resolve_mask_output_source, _scalar, _select_media_tensor
|
||||
from .compat.comfy_v3 import V3NodeBase
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
class ImageOpsClamp(V3NodeBase):
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsClamp(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"min_v": ("FLOAT", {"default": 0.0, "min": -10.0, "max": 10.0, "step": 0.01, "round": 0.001,
|
||||
"tooltip": "Minimum output value. Values below this are clamped up."}),
|
||||
"max_v": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01, "round": 0.001,
|
||||
"tooltip": "Maximum output value. Values above this are clamped down."}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsClamp', display_name='〽️ Image Ops Clamp', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.Float.Input('min_v', default=0.0, min=-10.0, max=10.0, step=0.01, round=0.001, tooltip='Minimum output value. Values below this are clamped up.'), io.Float.Input('max_v', default=1.0, min=-10.0, max=10.0, step=0.01, round=0.001, tooltip='Maximum output value. Values above this are clamped down.'), io.Boolean.Input('invert_mask', default=False), io.String.Input('image', tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', force_input=True, display_name='Images/Video', optional=True), io.Mask.Input('mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(self, image=None, bypass=False, min_v=0.0, max_v=1.0, invert_mask=False, video=None, mask=None, unique_id=None):
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, min_v=0.0, max_v=1.0, invert_mask=False, video=None, mask=None, unique_id=None, **kwargs):
|
||||
src = _select_media_tensor(image, video)
|
||||
output_mask_source = _resolve_mask_output_source(mask, src, invert_mask=invert_mask)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
if _scalar(bypass, bool):
|
||||
progress.finish()
|
||||
return build_node_preview_result(src, (src, output_mask_source), prefix="imageops_clamp")
|
||||
return build_node_preview_result(src, (src, output_mask_source), prefix='imageops_clamp')
|
||||
out = _apply_clamp(src, min_v, max_v)
|
||||
output_mask = _clamp_mask(output_mask_source, min_v, max_v)
|
||||
progress.finish()
|
||||
return build_node_preview_result(out, (out, output_mask), prefix="imageops_clamp")
|
||||
return build_node_preview_result(out, (out, output_mask), prefix='imageops_clamp')
|
||||
+10
-151
@@ -1,165 +1,24 @@
|
||||
from ._helpers import (
|
||||
MEDIA_INPUT_TYPE,
|
||||
_apply_mask_to_image,
|
||||
_apply_color_adjust,
|
||||
_prepare_effect_mask,
|
||||
_resolve_mask_output_source,
|
||||
_scalar,
|
||||
_select_media_tensor,
|
||||
)
|
||||
from comfy_api.latest import io
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _apply_mask_to_image, _apply_color_adjust, _prepare_effect_mask, _resolve_mask_output_source, _scalar, _select_media_tensor
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
|
||||
class ImageOpsColorAjust:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsColorAjust(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"temperature": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 5.0, "display": "slider", "round": 0.001}),
|
||||
"tint": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 5.0, "display": "slider", "round": 0.001}),
|
||||
"hue": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0, "step": 5.0, "display": "slider", "round": 0.001}),
|
||||
"brightness": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 5.0, "display": "slider", "round": 0.001}),
|
||||
"contrast": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 5.0, "display": "slider", "round": 0.001}),
|
||||
"saturation": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 5.0, "display": "slider", "round": 0.001}),
|
||||
"vibrance": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 5.0, "display": "slider", "round": 0.001}),
|
||||
"gamma": ("FLOAT", {"default": 1.0, "min": 0.2, "max": 2.2, "step": 0.1, "display": "slider", "round": 0.001}),
|
||||
"shadows_hue": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0, "step": 1.0, "round": 0.001}),
|
||||
"shadows_amount": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"midtones_hue": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0, "step": 1.0, "round": 0.001}),
|
||||
"midtones_amount": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"highlights_hue": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0, "step": 1.0, "round": 0.001}),
|
||||
"highlights_amount": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
# Per-zone primaries. Each is added to the global value, weighted
|
||||
# by the corresponding luma mask (shadows / midtones / highlights),
|
||||
# so a zone slider at 0 (or 1.0 for gamma) is a perfect no-op.
|
||||
"shadows_temperature": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"shadows_tint": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"shadows_contrast": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"shadows_saturation": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"shadows_vibrance": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"shadows_gamma": ("FLOAT", {"default": 1.0, "min": 0.2, "max": 2.2, "step": 0.01, "round": 0.001}),
|
||||
"shadows_brightness": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"midtones_temperature": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"midtones_tint": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"midtones_contrast": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"midtones_saturation": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"midtones_vibrance": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"midtones_gamma": ("FLOAT", {"default": 1.0, "min": 0.2, "max": 2.2, "step": 0.01, "round": 0.001}),
|
||||
"midtones_brightness": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"highlights_temperature": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"highlights_tint": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"highlights_contrast": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"highlights_saturation": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"highlights_vibrance": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"highlights_gamma": ("FLOAT", {"default": 1.0, "min": 0.2, "max": 2.2, "step": 0.01, "round": 0.001}),
|
||||
"highlights_brightness": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0, "round": 0.001}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsColorAjust', display_name='〽️ Image Ops Color Color Correct', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.Float.Input('temperature', default=0.0, min=-100.0, max=100.0, step=5.0, display_mode=io.NumberDisplay('slider'), round=0.001), io.Float.Input('tint', default=0.0, min=-100.0, max=100.0, step=5.0, display_mode=io.NumberDisplay('slider'), round=0.001), io.Float.Input('hue', default=0.0, min=-180.0, max=180.0, step=5.0, display_mode=io.NumberDisplay('slider'), round=0.001), io.Float.Input('brightness', default=0.0, min=-100.0, max=100.0, step=5.0, display_mode=io.NumberDisplay('slider'), round=0.001), io.Float.Input('contrast', default=0.0, min=-100.0, max=100.0, step=5.0, display_mode=io.NumberDisplay('slider'), round=0.001), io.Float.Input('saturation', default=0.0, min=-100.0, max=100.0, step=5.0, display_mode=io.NumberDisplay('slider'), round=0.001), io.Float.Input('vibrance', default=0.0, min=-100.0, max=100.0, step=5.0, display_mode=io.NumberDisplay('slider'), round=0.001), io.Float.Input('gamma', default=1.0, min=0.2, max=2.2, step=0.1, display_mode=io.NumberDisplay('slider'), round=0.001), io.Float.Input('shadows_hue', default=0.0, min=-180.0, max=180.0, step=1.0, round=0.001), io.Float.Input('shadows_amount', default=0.0, min=0.0, max=100.0, step=1.0, round=0.001), io.Float.Input('midtones_hue', default=0.0, min=-180.0, max=180.0, step=1.0, round=0.001), io.Float.Input('midtones_amount', default=0.0, min=0.0, max=100.0, step=1.0, round=0.001), io.Float.Input('highlights_hue', default=0.0, min=-180.0, max=180.0, step=1.0, round=0.001), io.Float.Input('highlights_amount', default=0.0, min=0.0, max=100.0, step=1.0, round=0.001), io.Float.Input('shadows_temperature', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('shadows_tint', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('shadows_contrast', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('shadows_saturation', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('shadows_vibrance', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('shadows_gamma', default=1.0, min=0.2, max=2.2, step=0.01, round=0.001), io.Float.Input('shadows_brightness', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('midtones_temperature', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('midtones_tint', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('midtones_contrast', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('midtones_saturation', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('midtones_vibrance', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('midtones_gamma', default=1.0, min=0.2, max=2.2, step=0.01, round=0.001), io.Float.Input('midtones_brightness', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('highlights_temperature', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('highlights_tint', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('highlights_contrast', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('highlights_saturation', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('highlights_vibrance', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Float.Input('highlights_gamma', default=1.0, min=0.2, max=2.2, step=0.01, round=0.001), io.Float.Input('highlights_brightness', default=0.0, min=-100.0, max=100.0, step=1.0, round=0.001), io.Boolean.Input('invert_mask', default=False), io.String.Input('image', tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', force_input=True, display_name='Images/Video', optional=True), io.Mask.Input('mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(
|
||||
self,
|
||||
image=None,
|
||||
bypass=False,
|
||||
temperature=0.0,
|
||||
tint=0.0,
|
||||
hue=0.0,
|
||||
brightness=0.0,
|
||||
contrast=0.0,
|
||||
saturation=0.0,
|
||||
vibrance=0.0,
|
||||
gamma=1.0,
|
||||
shadows_hue=0.0,
|
||||
shadows_amount=0.0,
|
||||
midtones_hue=0.0,
|
||||
midtones_amount=0.0,
|
||||
highlights_hue=0.0,
|
||||
highlights_amount=0.0,
|
||||
shadows_temperature=0.0,
|
||||
shadows_tint=0.0,
|
||||
shadows_contrast=0.0,
|
||||
shadows_saturation=0.0,
|
||||
shadows_vibrance=0.0,
|
||||
shadows_gamma=1.0,
|
||||
shadows_brightness=0.0,
|
||||
midtones_temperature=0.0,
|
||||
midtones_tint=0.0,
|
||||
midtones_contrast=0.0,
|
||||
midtones_saturation=0.0,
|
||||
midtones_vibrance=0.0,
|
||||
midtones_gamma=1.0,
|
||||
midtones_brightness=0.0,
|
||||
highlights_temperature=0.0,
|
||||
highlights_tint=0.0,
|
||||
highlights_contrast=0.0,
|
||||
highlights_saturation=0.0,
|
||||
highlights_vibrance=0.0,
|
||||
highlights_gamma=1.0,
|
||||
highlights_brightness=0.0,
|
||||
invert_mask=False,
|
||||
video=None,
|
||||
mask=None,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, temperature=0.0, tint=0.0, hue=0.0, brightness=0.0, contrast=0.0, saturation=0.0, vibrance=0.0, gamma=1.0, shadows_hue=0.0, shadows_amount=0.0, midtones_hue=0.0, midtones_amount=0.0, highlights_hue=0.0, highlights_amount=0.0, shadows_temperature=0.0, shadows_tint=0.0, shadows_contrast=0.0, shadows_saturation=0.0, shadows_vibrance=0.0, shadows_gamma=1.0, shadows_brightness=0.0, midtones_temperature=0.0, midtones_tint=0.0, midtones_contrast=0.0, midtones_saturation=0.0, midtones_vibrance=0.0, midtones_gamma=1.0, midtones_brightness=0.0, highlights_temperature=0.0, highlights_tint=0.0, highlights_contrast=0.0, highlights_saturation=0.0, highlights_vibrance=0.0, highlights_gamma=1.0, highlights_brightness=0.0, invert_mask=False, video=None, mask=None, unique_id=None, **kwargs):
|
||||
source = _select_media_tensor(image, video)
|
||||
effect_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask)
|
||||
output_mask = _resolve_mask_output_source(mask, source, invert_mask=invert_mask)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
if _scalar(bypass, bool):
|
||||
progress.finish()
|
||||
return build_node_preview_result(source, (source, output_mask), prefix="imageops_color_ajust")
|
||||
result = _apply_color_adjust(
|
||||
source,
|
||||
temperature,
|
||||
tint,
|
||||
hue,
|
||||
brightness,
|
||||
contrast,
|
||||
saturation,
|
||||
vibrance,
|
||||
gamma,
|
||||
shadows_hue,
|
||||
shadows_amount,
|
||||
midtones_hue,
|
||||
midtones_amount,
|
||||
highlights_hue,
|
||||
highlights_amount,
|
||||
shadows_temperature=shadows_temperature,
|
||||
shadows_tint=shadows_tint,
|
||||
shadows_contrast=shadows_contrast,
|
||||
shadows_saturation=shadows_saturation,
|
||||
shadows_vibrance=shadows_vibrance,
|
||||
shadows_gamma=shadows_gamma,
|
||||
shadows_brightness=shadows_brightness,
|
||||
midtones_temperature=midtones_temperature,
|
||||
midtones_tint=midtones_tint,
|
||||
midtones_contrast=midtones_contrast,
|
||||
midtones_saturation=midtones_saturation,
|
||||
midtones_vibrance=midtones_vibrance,
|
||||
midtones_gamma=midtones_gamma,
|
||||
midtones_brightness=midtones_brightness,
|
||||
highlights_temperature=highlights_temperature,
|
||||
highlights_tint=highlights_tint,
|
||||
highlights_contrast=highlights_contrast,
|
||||
highlights_saturation=highlights_saturation,
|
||||
highlights_vibrance=highlights_vibrance,
|
||||
highlights_gamma=highlights_gamma,
|
||||
highlights_brightness=highlights_brightness,
|
||||
)
|
||||
return build_node_preview_result(source, (source, output_mask), prefix='imageops_color_ajust')
|
||||
result = _apply_color_adjust(source, temperature, tint, hue, brightness, contrast, saturation, vibrance, gamma, shadows_hue, shadows_amount, midtones_hue, midtones_amount, highlights_hue, highlights_amount, shadows_temperature=shadows_temperature, shadows_tint=shadows_tint, shadows_contrast=shadows_contrast, shadows_saturation=shadows_saturation, shadows_vibrance=shadows_vibrance, shadows_gamma=shadows_gamma, shadows_brightness=shadows_brightness, midtones_temperature=midtones_temperature, midtones_tint=midtones_tint, midtones_contrast=midtones_contrast, midtones_saturation=midtones_saturation, midtones_vibrance=midtones_vibrance, midtones_gamma=midtones_gamma, midtones_brightness=midtones_brightness, highlights_temperature=highlights_temperature, highlights_tint=highlights_tint, highlights_contrast=highlights_contrast, highlights_saturation=highlights_saturation, highlights_vibrance=highlights_vibrance, highlights_gamma=highlights_gamma, highlights_brightness=highlights_brightness)
|
||||
result = _apply_mask_to_image(source, result, effect_mask)
|
||||
progress.finish()
|
||||
return build_node_preview_result(result, (result, output_mask), prefix="imageops_color_ajust")
|
||||
return build_node_preview_result(result, (result, output_mask), prefix='imageops_color_ajust')
|
||||
+15
-81
@@ -1,13 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import torch
|
||||
|
||||
from ._helpers import _hex_to_rgb01, _resolve_aspect_ratio, _scalar, ASPECT_RATIO_PRESETS
|
||||
from ._preview import build_node_preview_result
|
||||
from ._progress import start_progress
|
||||
|
||||
_MODES = ["constant", "checkerboard"]
|
||||
|
||||
_MODES = ['constant', 'checkerboard']
|
||||
|
||||
def _constant_image(batch: int, height: int, width: int, color: str, alpha: float) -> torch.Tensor:
|
||||
rgb = torch.tensor(_hex_to_rgb01(color), dtype=torch.float32).view(1, 1, 1, 3)
|
||||
@@ -15,18 +12,7 @@ def _constant_image(batch: int, height: int, width: int, color: str, alpha: floa
|
||||
a = torch.full((batch, height, width, 1), float(alpha), dtype=torch.float32).clamp(0.0, 1.0)
|
||||
return torch.cat([image, a], dim=-1)
|
||||
|
||||
|
||||
def _checkerboard_image(
|
||||
batch: int,
|
||||
height: int,
|
||||
width: int,
|
||||
color_a: str,
|
||||
color_b: str,
|
||||
alpha: float,
|
||||
tile_size: int,
|
||||
offset_x: int,
|
||||
offset_y: int,
|
||||
) -> torch.Tensor:
|
||||
def _checkerboard_image(batch: int, height: int, width: int, color_a: str, color_b: str, alpha: float, tile_size: int, offset_x: int, offset_y: int) -> torch.Tensor:
|
||||
tile = max(1, int(tile_size))
|
||||
yy = (torch.arange(height, dtype=torch.int64).view(height, 1) + int(offset_y)) // tile
|
||||
xx = (torch.arange(width, dtype=torch.int64).view(1, width) + int(offset_x)) // tile
|
||||
@@ -38,88 +24,36 @@ def _checkerboard_image(
|
||||
a = torch.full((batch, height, width, 1), float(alpha), dtype=torch.float32).clamp(0.0, 1.0)
|
||||
return torch.cat([rgb, a], dim=-1)
|
||||
|
||||
|
||||
class ImageOpsConstant:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT")
|
||||
RETURN_NAMES = ("image", "mask", "width", "height", "frame_count")
|
||||
FUNCTION = "generate"
|
||||
class ImageOpsConstant(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"mode": (_MODES, {"default": "constant"}),
|
||||
"width": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
|
||||
"height": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
|
||||
"aspect_ratio": (list(ASPECT_RATIO_PRESETS.keys()), {"default": "custom"}),
|
||||
"frame_count": ("INT", {"default": 1, "min": 1, "max": 4096, "step": 1}),
|
||||
"color": ("COLOR", {"default": "#ffffff"}),
|
||||
"color_b": ("COLOR", {"default": "#000000"}),
|
||||
"alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"tile_size": ("INT", {"default": 64, "min": 1, "max": 2048, "step": 1}),
|
||||
"offset_x": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
|
||||
"offset_y": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsConstant', display_name='〽️ Image Ops Constant', category='image/imageops', inputs=[io.String.Input('mode', default='constant'), io.Int.Input('width', default=1024, min=1, max=8192, step=1), io.Int.Input('height', default=1024, min=1, max=8192, step=1), io.String.Input('aspect_ratio', default='custom'), io.Int.Input('frame_count', default=1, min=1, max=4096, step=1), io.Color.Input('color', default='#ffffff'), io.Color.Input('color_b', default='#000000'), io.Float.Input('alpha', default=1.0, min=0.0, max=1.0, step=0.01), io.Int.Input('tile_size', default=64, min=1, max=2048, step=1), io.Int.Input('offset_x', default=0, min=-8192, max=8192, step=1), io.Int.Input('offset_y', default=0, min=-8192, max=8192, step=1)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask'), io.Int.Output('width', display_name='width'), io.Int.Output('height', display_name='height'), io.Int.Output('frame_count', display_name='frame_count')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def generate(
|
||||
self,
|
||||
mode="constant",
|
||||
width=1024,
|
||||
height=1024,
|
||||
aspect_ratio="custom",
|
||||
frame_count=None,
|
||||
frame_length=None,
|
||||
batch_size=None,
|
||||
color="#ffffff",
|
||||
color_b="#000000",
|
||||
alpha=1.0,
|
||||
tile_size=64,
|
||||
offset_x=0,
|
||||
offset_y=0,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, mode='constant', width=1024, height=1024, aspect_ratio='custom', frame_count=None, frame_length=None, batch_size=None, color='#ffffff', color_b='#000000', alpha=1.0, tile_size=64, offset_x=0, offset_y=0, unique_id=None, **kwargs):
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
out_w = max(1, _scalar(width, int))
|
||||
out_h = max(1, _scalar(height, int))
|
||||
# Apply aspect ratio constraint when a preset is selected.
|
||||
ratio_str = _scalar(aspect_ratio, str) if isinstance(aspect_ratio, str) else "custom"
|
||||
ratio_str = _scalar(aspect_ratio, str) if isinstance(aspect_ratio, str) else 'custom'
|
||||
preset = ASPECT_RATIO_PRESETS.get(str(ratio_str).lower())
|
||||
if preset is not None and preset[1] > 0:
|
||||
# Constrain the smaller dimension to match the target ratio.
|
||||
target_ratio = float(preset[0]) / float(preset[1])
|
||||
src_ratio = float(out_w) / float(max(1, out_h))
|
||||
if src_ratio > target_ratio:
|
||||
out_w = max(1, int(round(out_h * target_ratio)))
|
||||
elif src_ratio < target_ratio:
|
||||
out_h = max(1, int(round(out_w / target_ratio)))
|
||||
frame_count_source = frame_count if frame_count is not None else (frame_length if frame_length is not None else (batch_size if batch_size is not None else 1))
|
||||
frame_count_source = frame_count if frame_count is not None else frame_length if frame_length is not None else batch_size if batch_size is not None else 1
|
||||
batch = max(1, _scalar(frame_count_source, int))
|
||||
opacity = max(0.0, min(1.0, _scalar(alpha)))
|
||||
normalized_mode = str(mode or "constant").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
|
||||
normalized_mode = str(mode or 'constant').strip().lower().replace('-', '_').replace(' ', '_')
|
||||
from .core.memory import check_budget
|
||||
check_budget(batch, out_h, out_w, 4, multiplier=1.5, label="ImageOps Constant")
|
||||
|
||||
if normalized_mode == "checkerboard":
|
||||
image = _checkerboard_image(
|
||||
batch,
|
||||
out_h,
|
||||
out_w,
|
||||
color,
|
||||
color_b,
|
||||
opacity,
|
||||
_scalar(tile_size, int),
|
||||
_scalar(offset_x, int),
|
||||
_scalar(offset_y, int),
|
||||
)
|
||||
check_budget(batch, out_h, out_w, 4, multiplier=1.5, label='ImageOps Constant')
|
||||
if normalized_mode == 'checkerboard':
|
||||
image = _checkerboard_image(batch, out_h, out_w, color, color_b, opacity, _scalar(tile_size, int), _scalar(offset_x, int), _scalar(offset_y, int))
|
||||
else:
|
||||
image = _constant_image(batch, out_h, out_w, color, opacity)
|
||||
|
||||
mask = image[..., 3].clamp(0.0, 1.0)
|
||||
progress.finish()
|
||||
return build_node_preview_result(image, (image, mask, out_w, out_h, batch), prefix="imageops_constant")
|
||||
return build_node_preview_result(image, (image, mask, out_w, out_h, batch), prefix='imageops_constant')
|
||||
+55
-172
@@ -1,59 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from ._helpers import EPSILON, MEDIA_INPUT_TYPE, _hex_to_rgb01, _invert_homography_batch, _param_tensor, _scalar, _select_media_tensor, _solve_homography_batch
|
||||
from ._preview import build_node_preview_result
|
||||
from ._progress import start_progress
|
||||
|
||||
_CORNER_PIN_FILTERS = ["nearest", "bilinear", "bicubic"]
|
||||
_CORNER_PIN_FILL_MODES = ["transparent", "mirror", "stretch", "expand", "color"]
|
||||
_CORNER_PIN_FILTERS = ['nearest', 'bilinear', 'bicubic']
|
||||
_CORNER_PIN_FILL_MODES = ['transparent', 'mirror', 'stretch', 'expand', 'color']
|
||||
_CORNER_PIN_SUPERSAMPLE_MAX = 4
|
||||
|
||||
|
||||
def _normalize_filter(filter_mode: str) -> str:
|
||||
normalized = str(filter_mode or "bilinear").strip().lower()
|
||||
return normalized if normalized in ("nearest", "bilinear", "bicubic") else "bilinear"
|
||||
normalized = str(filter_mode or 'bilinear').strip().lower()
|
||||
return normalized if normalized in ('nearest', 'bilinear', 'bicubic') else 'bilinear'
|
||||
|
||||
def _normalize_fill_mode(fill_mode: str | None, legacy_edge_mode: str | None=None) -> str:
|
||||
raw = fill_mode if fill_mode not in (None, '') else legacy_edge_mode
|
||||
normalized = str(raw or 'transparent').strip().lower().replace('-', '_').replace(' ', '_')
|
||||
if normalized in ('border', 'expand', 'edge', 'edge_extend', 'replicate', 'extend'):
|
||||
return 'expand'
|
||||
if normalized in ('reflect', 'reflection', 'mirror'):
|
||||
return 'mirror'
|
||||
if normalized in ('stretch', 'fill', 'cover'):
|
||||
return 'stretch'
|
||||
if normalized in ('color', 'colour', 'constant', 'solid'):
|
||||
return 'color'
|
||||
return 'transparent'
|
||||
|
||||
def _normalize_fill_mode(fill_mode: str | None, legacy_edge_mode: str | None = None) -> str:
|
||||
raw = fill_mode if fill_mode not in (None, "") else legacy_edge_mode
|
||||
normalized = str(raw or "transparent").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if normalized in ("border", "expand", "edge", "edge_extend", "replicate", "extend"):
|
||||
return "expand"
|
||||
if normalized in ("reflect", "reflection", "mirror"):
|
||||
return "mirror"
|
||||
if normalized in ("stretch", "fill", "cover"):
|
||||
return "stretch"
|
||||
if normalized in ("color", "colour", "constant", "solid"):
|
||||
return "color"
|
||||
return "transparent"
|
||||
|
||||
|
||||
def _normalize_padding(fill_mode: str | None, legacy_edge_mode: str | None = None) -> str:
|
||||
def _normalize_padding(fill_mode: str | None, legacy_edge_mode: str | None=None) -> str:
|
||||
normalized = _normalize_fill_mode(fill_mode, legacy_edge_mode)
|
||||
if normalized == "expand":
|
||||
return "border"
|
||||
if normalized == "mirror":
|
||||
return "reflection"
|
||||
return "zeros"
|
||||
|
||||
if normalized == 'expand':
|
||||
return 'border'
|
||||
if normalized == 'mirror':
|
||||
return 'reflection'
|
||||
return 'zeros'
|
||||
|
||||
def _normalize_supersample(value) -> int:
|
||||
return max(1, min(_CORNER_PIN_SUPERSAMPLE_MAX, int(round(float(value)))))
|
||||
|
||||
|
||||
def _make_fill_background(source: torch.Tensor, fill_mode, fill_color) -> torch.Tensor | None:
|
||||
normalized = _normalize_fill_mode(_scalar(fill_mode, str), None)
|
||||
if normalized == "stretch":
|
||||
if normalized == 'stretch':
|
||||
background = source.float().clamp(0.0, 1.0).clone()
|
||||
if background.shape[-1] >= 4:
|
||||
background[..., 3] = 1.0
|
||||
return background.to(device=source.device, dtype=source.dtype)
|
||||
if normalized != "color":
|
||||
if normalized != 'color':
|
||||
return None
|
||||
|
||||
batch, height, width, channels = source.shape
|
||||
background = torch.zeros((batch, height, width, channels), device=source.device, dtype=torch.float32)
|
||||
r, g, b = _hex_to_rgb01(_scalar(fill_color, str))
|
||||
@@ -67,15 +59,12 @@ def _make_fill_background(source: torch.Tensor, fill_mode, fill_color) -> torch.
|
||||
background[..., 3] = 1.0
|
||||
return background.to(device=source.device, dtype=source.dtype)
|
||||
|
||||
|
||||
def _composite_fill(result: torch.Tensor, coverage_mask: torch.Tensor, background: torch.Tensor | None) -> torch.Tensor:
|
||||
if background is None:
|
||||
return result
|
||||
|
||||
fg = result.float().clamp(0.0, 1.0)
|
||||
bg = background.float().clamp(0.0, 1.0)
|
||||
blend = coverage_mask.unsqueeze(-1).float().clamp(0.0, 1.0)
|
||||
|
||||
if fg.shape[-1] >= 4:
|
||||
fg_alpha = torch.maximum(fg[..., 3:4].clamp(0.0, 1.0), blend)
|
||||
bg_alpha = bg[..., 3:4].clamp(0.0, 1.0) if bg.shape[-1] >= 4 else torch.ones_like(fg_alpha)
|
||||
@@ -84,11 +73,9 @@ def _composite_fill(result: torch.Tensor, coverage_mask: torch.Tensor, backgroun
|
||||
safe_alpha = torch.where(out_alpha > EPSILON, out_alpha, torch.ones_like(out_alpha))
|
||||
out_rgb = torch.where(out_alpha > EPSILON, premul_rgb / safe_alpha, torch.zeros_like(premul_rgb))
|
||||
return torch.cat([out_rgb.clamp(0.0, 1.0), out_alpha.clamp(0.0, 1.0)], dim=-1).to(device=result.device, dtype=result.dtype)
|
||||
|
||||
return (fg * blend + bg * (1.0 - blend)).clamp(0.0, 1.0).to(device=result.device, dtype=result.dtype)
|
||||
|
||||
|
||||
def _build_corner_pin_grid(height: int, width: int, Hinv: torch.Tensor, supersample: int = 1) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
def _build_corner_pin_grid(height: int, width: int, Hinv: torch.Tensor, supersample: int=1) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
batch = int(Hinv.shape[0])
|
||||
device = Hinv.device
|
||||
dtype = Hinv.dtype
|
||||
@@ -96,47 +83,36 @@ def _build_corner_pin_grid(height: int, width: int, Hinv: torch.Tensor, supersam
|
||||
out_w = max(1, int(width) * int(supersample))
|
||||
ys = torch.linspace(0.0, float(max(0, height - 1)), out_h, device=device, dtype=dtype)
|
||||
xs = torch.linspace(0.0, float(max(0, width - 1)), out_w, device=device, dtype=dtype)
|
||||
yy, xx = torch.meshgrid(ys, xs, indexing="ij")
|
||||
|
||||
yy, xx = torch.meshgrid(ys, xs, indexing='ij')
|
||||
denom = Hinv[:, 2, 0].view(batch, 1, 1) * xx + Hinv[:, 2, 1].view(batch, 1, 1) * yy + Hinv[:, 2, 2].view(batch, 1, 1)
|
||||
safe_sign = torch.where(denom < 0.0, -torch.ones_like(denom), torch.ones_like(denom))
|
||||
denom = torch.where(torch.abs(denom) < 1e-8, safe_sign * 1e-8, denom)
|
||||
denom = torch.where(torch.abs(denom) < 1e-08, safe_sign * 1e-08, denom)
|
||||
src_x = (Hinv[:, 0, 0].view(batch, 1, 1) * xx + Hinv[:, 0, 1].view(batch, 1, 1) * yy + Hinv[:, 0, 2].view(batch, 1, 1)) / denom
|
||||
src_y = (Hinv[:, 1, 0].view(batch, 1, 1) * xx + Hinv[:, 1, 1].view(batch, 1, 1) * yy + Hinv[:, 1, 2].view(batch, 1, 1)) / denom
|
||||
|
||||
if width > 1:
|
||||
norm_x = (src_x / float(width - 1)) * 2.0 - 1.0
|
||||
norm_x = src_x / float(width - 1) * 2.0 - 1.0
|
||||
else:
|
||||
norm_x = torch.zeros_like(src_x)
|
||||
if height > 1:
|
||||
norm_y = (src_y / float(height - 1)) * 2.0 - 1.0
|
||||
norm_y = src_y / float(height - 1) * 2.0 - 1.0
|
||||
else:
|
||||
norm_y = torch.zeros_like(src_y)
|
||||
grid = torch.stack([norm_x, norm_y], dim=-1)
|
||||
|
||||
inside = (src_x >= 0.0) & (src_x <= float(width - 1)) & (src_y >= 0.0) & (src_y <= float(height - 1))
|
||||
return grid, inside.unsqueeze(1).to(dtype=dtype)
|
||||
|
||||
return (grid, inside.unsqueeze(1).to(dtype=dtype))
|
||||
|
||||
def _corner_pin_dst_points(batch: int, height: int, width: int, device, dtype, tl_x, tl_y, tr_x, tr_y, bl_x, bl_y, br_x, br_y) -> torch.Tensor:
|
||||
w = float(max(0, width - 1))
|
||||
h = float(max(0, height - 1))
|
||||
points = torch.stack([
|
||||
torch.stack([_param_tensor(tl_x, batch, device, dtype).view(batch) * w, _param_tensor(tl_y, batch, device, dtype).view(batch) * h], dim=1),
|
||||
torch.stack([_param_tensor(tr_x, batch, device, dtype).view(batch) * w, _param_tensor(tr_y, batch, device, dtype).view(batch) * h], dim=1),
|
||||
torch.stack([_param_tensor(bl_x, batch, device, dtype).view(batch) * w, _param_tensor(bl_y, batch, device, dtype).view(batch) * h], dim=1),
|
||||
torch.stack([_param_tensor(br_x, batch, device, dtype).view(batch) * w, _param_tensor(br_y, batch, device, dtype).view(batch) * h], dim=1),
|
||||
], dim=1)
|
||||
points = torch.stack([torch.stack([_param_tensor(tl_x, batch, device, dtype).view(batch) * w, _param_tensor(tl_y, batch, device, dtype).view(batch) * h], dim=1), torch.stack([_param_tensor(tr_x, batch, device, dtype).view(batch) * w, _param_tensor(tr_y, batch, device, dtype).view(batch) * h], dim=1), torch.stack([_param_tensor(bl_x, batch, device, dtype).view(batch) * w, _param_tensor(bl_y, batch, device, dtype).view(batch) * h], dim=1), torch.stack([_param_tensor(br_x, batch, device, dtype).view(batch) * w, _param_tensor(br_y, batch, device, dtype).view(batch) * h], dim=1)], dim=1)
|
||||
return points
|
||||
|
||||
|
||||
def _premultiply_alpha_for_warp(source: torch.Tensor) -> torch.Tensor:
|
||||
if source.shape[-1] < 4:
|
||||
return source
|
||||
alpha = source[..., 3:4].clamp(0.0, 1.0)
|
||||
return torch.cat([source[..., :3] * alpha, source[..., 3:]], dim=-1)
|
||||
|
||||
|
||||
def _unpremultiply_alpha_after_warp(source: torch.Tensor) -> torch.Tensor:
|
||||
if source.shape[-1] < 4:
|
||||
return source
|
||||
@@ -145,172 +121,79 @@ def _unpremultiply_alpha_after_warp(source: torch.Tensor) -> torch.Tensor:
|
||||
rgb = torch.where(alpha > EPSILON, source[..., :3] / safe_alpha, torch.zeros_like(source[..., :3]))
|
||||
return torch.cat([rgb.clamp(0.0, 1.0), source[..., 3:]], dim=-1)
|
||||
|
||||
|
||||
def _downsample_nchw(x: torch.Tensor, height: int, width: int, mode: str, antialias: bool = True) -> torch.Tensor:
|
||||
def _downsample_nchw(x: torch.Tensor, height: int, width: int, mode: str, antialias: bool=True) -> torch.Tensor:
|
||||
if int(x.shape[-2]) == int(height) and int(x.shape[-1]) == int(width):
|
||||
return x
|
||||
resize_mode = "area" if mode == "nearest" else mode
|
||||
kwargs = {"size": (int(height), int(width)), "mode": resize_mode}
|
||||
if resize_mode in ("bilinear", "bicubic"):
|
||||
kwargs["align_corners"] = False
|
||||
kwargs["antialias"] = bool(antialias)
|
||||
resize_mode = 'area' if mode == 'nearest' else mode
|
||||
kwargs = {'size': (int(height), int(width)), 'mode': resize_mode}
|
||||
if resize_mode in ('bilinear', 'bicubic'):
|
||||
kwargs['align_corners'] = False
|
||||
kwargs['antialias'] = bool(antialias)
|
||||
try:
|
||||
return F.interpolate(x, **kwargs)
|
||||
except TypeError:
|
||||
kwargs.pop("antialias", None)
|
||||
kwargs.pop('antialias', None)
|
||||
return F.interpolate(x, **kwargs)
|
||||
|
||||
|
||||
def _warp_corner_pin_group(input_nchw: torch.Tensor, Hinv: torch.Tensor, height: int, width: int, mode: str, padding: str, supersample: int) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
grid, valid = _build_corner_pin_grid(height, width, Hinv, supersample=supersample)
|
||||
warped = F.grid_sample(
|
||||
input_nchw,
|
||||
grid,
|
||||
mode=mode,
|
||||
padding_mode=padding,
|
||||
align_corners=True,
|
||||
)
|
||||
warped = F.grid_sample(input_nchw, grid, mode=mode, padding_mode=padding, align_corners=True)
|
||||
if supersample > 1:
|
||||
warped = _downsample_nchw(warped, height, width, mode, antialias=True)
|
||||
valid = _downsample_nchw(valid, height, width, "bilinear", antialias=True)
|
||||
return warped, valid.clamp(0.0, 1.0)
|
||||
valid = _downsample_nchw(valid, height, width, 'bilinear', antialias=True)
|
||||
return (warped, valid.clamp(0.0, 1.0))
|
||||
|
||||
|
||||
class ImageOpsCornerPin:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsCornerPin(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"tl_x": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.001}),
|
||||
"tl_y": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.001}),
|
||||
"tr_x": ("FLOAT", {"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.001}),
|
||||
"tr_y": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.001}),
|
||||
"bl_x": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.001}),
|
||||
"bl_y": ("FLOAT", {"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.001}),
|
||||
"br_x": ("FLOAT", {"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.001}),
|
||||
"br_y": ("FLOAT", {"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.001}),
|
||||
"filter": (_CORNER_PIN_FILTERS, {"default": "bilinear"}),
|
||||
"supersample": ("INT", {"default": 1, "min": 1, "max": _CORNER_PIN_SUPERSAMPLE_MAX, "step": 1, "tooltip": "Render at 2x-4x and downsample to reduce perspective aliasing."}),
|
||||
"fill_mode": (_CORNER_PIN_FILL_MODES, {"default": "transparent", "tooltip": "How to fill uncovered areas outside the pinned quad."}),
|
||||
"fill_color": ("COLOR", {"default": "#000000"}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsCornerPin', display_name='〽️ Image Ops Corner Pin', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.Float.Input('tl_x', default=0.0, min=-2.0, max=2.0, step=0.001), io.Float.Input('tl_y', default=0.0, min=-2.0, max=2.0, step=0.001), io.Float.Input('tr_x', default=1.0, min=-2.0, max=2.0, step=0.001), io.Float.Input('tr_y', default=0.0, min=-2.0, max=2.0, step=0.001), io.Float.Input('bl_x', default=0.0, min=-2.0, max=2.0, step=0.001), io.Float.Input('bl_y', default=1.0, min=-2.0, max=2.0, step=0.001), io.Float.Input('br_x', default=1.0, min=-2.0, max=2.0, step=0.001), io.Float.Input('br_y', default=1.0, min=-2.0, max=2.0, step=0.001), io.String.Input('filter', default='bilinear'), io.Int.Input('supersample', default=1, min=1, step=1, tooltip='Render at 2x-4x and downsample to reduce perspective aliasing.'), io.String.Input('fill_mode', default='transparent', tooltip='How to fill uncovered areas outside the pinned quad.'), io.Color.Input('fill_color', default='#000000'), io.Boolean.Input('invert_mask', default=False), io.String.Input('image', tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', force_input=True, display_name='Images/Video', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(
|
||||
self,
|
||||
image=None,
|
||||
bypass=False,
|
||||
tl_x=0.0,
|
||||
tl_y=0.0,
|
||||
tr_x=1.0,
|
||||
tr_y=0.0,
|
||||
bl_x=0.0,
|
||||
bl_y=1.0,
|
||||
br_x=1.0,
|
||||
br_y=1.0,
|
||||
filter="bilinear",
|
||||
supersample=1,
|
||||
fill_mode="transparent",
|
||||
fill_color="#000000",
|
||||
edge_mode="transparent",
|
||||
invert_mask=False,
|
||||
video=None,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, tl_x=0.0, tl_y=0.0, tr_x=1.0, tr_y=0.0, bl_x=0.0, bl_y=1.0, br_x=1.0, br_y=1.0, filter='bilinear', supersample=1, fill_mode='transparent', fill_color='#000000', edge_mode='transparent', invert_mask=False, video=None, unique_id=None, **kwargs):
|
||||
source = _select_media_tensor(image, video).float().clamp(0.0, 1.0)
|
||||
batch = int(source.shape[0])
|
||||
height = int(source.shape[1])
|
||||
width = int(source.shape[2])
|
||||
channels = int(source.shape[3])
|
||||
|
||||
resolved_fill_mode = _normalize_fill_mode(fill_mode, edge_mode)
|
||||
progress = start_progress(total=max(1, batch), unique_id=unique_id)
|
||||
|
||||
supersample_val = max(1, min(_CORNER_PIN_SUPERSAMPLE_MAX, _scalar(supersample, int)))
|
||||
from .core.memory import check_budget
|
||||
# supersample^2 amplifies memory; multiplier=4.0 covers supersampled warp workspace
|
||||
check_budget(batch, height * supersample_val, width * supersample_val, channels,
|
||||
multiplier=4.0, label="ImageOps CornerPin")
|
||||
|
||||
check_budget(batch, height * supersample_val, width * supersample_val, channels, multiplier=4.0, label='ImageOps CornerPin')
|
||||
if _scalar(bypass, bool):
|
||||
mask = torch.ones((batch, height, width), device=source.device, dtype=source.dtype)
|
||||
invert = _param_tensor(invert_mask, batch, source.device, source.dtype).view(batch, 1, 1)
|
||||
mask = torch.where(invert >= 0.5, 1.0 - mask, mask)
|
||||
progress.finish()
|
||||
return build_node_preview_result(source, (source, mask), prefix="imageops_cornerpin")
|
||||
|
||||
src_points = torch.tensor(
|
||||
[
|
||||
[0.0, 0.0],
|
||||
[float(width - 1), 0.0],
|
||||
[0.0, float(height - 1)],
|
||||
[float(width - 1), float(height - 1)],
|
||||
],
|
||||
device=source.device,
|
||||
dtype=source.dtype,
|
||||
)
|
||||
|
||||
dst_points = _corner_pin_dst_points(
|
||||
batch, height, width, source.device, source.dtype,
|
||||
tl_x, tl_y, tr_x, tr_y, bl_x, bl_y, br_x, br_y,
|
||||
)
|
||||
return build_node_preview_result(source, (source, mask), prefix='imageops_cornerpin')
|
||||
src_points = torch.tensor([[0.0, 0.0], [float(width - 1), 0.0], [0.0, float(height - 1)], [float(width - 1), float(height - 1)]], device=source.device, dtype=source.dtype)
|
||||
dst_points = _corner_pin_dst_points(batch, height, width, source.device, source.dtype, tl_x, tl_y, tr_x, tr_y, bl_x, bl_y, br_x, br_y)
|
||||
H, valid_h = _solve_homography_batch(src_points, dst_points)
|
||||
Hinv, valid_h = _invert_homography_batch(H, valid_h)
|
||||
|
||||
input_nchw = _premultiply_alpha_for_warp(source).permute(0, 3, 1, 2).contiguous()
|
||||
warped_nchw = torch.empty_like(input_nchw)
|
||||
valid_nchw = torch.empty((batch, 1, height, width), device=source.device, dtype=source.dtype)
|
||||
|
||||
groups: dict[tuple[str, str, int], list[int]] = {}
|
||||
for frame_index in range(batch):
|
||||
key = (
|
||||
_normalize_filter(_scalar(filter, str, index=frame_index)),
|
||||
_normalize_padding(_scalar(fill_mode, str, index=frame_index), _scalar(edge_mode, str, index=frame_index)),
|
||||
_normalize_supersample(_scalar(supersample, int, index=frame_index)),
|
||||
)
|
||||
key = (_normalize_filter(_scalar(filter, str, index=frame_index)), _normalize_padding(_scalar(fill_mode, str, index=frame_index), _scalar(edge_mode, str, index=frame_index)), _normalize_supersample(_scalar(supersample, int, index=frame_index)))
|
||||
groups.setdefault(key, []).append(frame_index)
|
||||
|
||||
for (mode, padding, sample_factor), indices in groups.items():
|
||||
index_tensor = torch.tensor(indices, device=source.device, dtype=torch.long)
|
||||
group_warped, group_valid = _warp_corner_pin_group(
|
||||
input_nchw.index_select(0, index_tensor),
|
||||
Hinv.index_select(0, index_tensor),
|
||||
height,
|
||||
width,
|
||||
mode,
|
||||
padding,
|
||||
sample_factor,
|
||||
)
|
||||
group_warped, group_valid = _warp_corner_pin_group(input_nchw.index_select(0, index_tensor), Hinv.index_select(0, index_tensor), height, width, mode, padding, sample_factor)
|
||||
warped_nchw.index_copy_(0, index_tensor, group_warped)
|
||||
valid_nchw.index_copy_(0, index_tensor, group_valid)
|
||||
progress.update(len(indices))
|
||||
|
||||
result = warped_nchw.permute(0, 2, 3, 1).contiguous().clamp(0.0, 1.0)
|
||||
result = _unpremultiply_alpha_after_warp(result).clamp(0.0, 1.0)
|
||||
mask = valid_nchw[:, 0, :, :].clamp(0.0, 1.0)
|
||||
if channels >= 4:
|
||||
mask = (mask * result[..., 3].clamp(0.0, 1.0)).clamp(0.0, 1.0)
|
||||
|
||||
result = _composite_fill(result, mask, _make_fill_background(source, resolved_fill_mode, fill_color))
|
||||
if resolved_fill_mode != "transparent":
|
||||
if resolved_fill_mode != 'transparent':
|
||||
mask = result[..., 3].clamp(0.0, 1.0) if channels >= 4 else torch.ones_like(mask)
|
||||
|
||||
mask = torch.where(valid_h.view(batch, 1, 1), mask, torch.zeros_like(mask))
|
||||
invert = _param_tensor(invert_mask, batch, source.device, source.dtype).view(batch, 1, 1)
|
||||
mask = torch.where(invert >= 0.5, 1.0 - mask, mask)
|
||||
|
||||
progress.finish()
|
||||
return build_node_preview_result(result, (result, mask), prefix="imageops_cornerpin")
|
||||
return build_node_preview_result(result, (result, mask), prefix='imageops_cornerpin')
|
||||
+19
-122
@@ -1,23 +1,12 @@
|
||||
from ._helpers import (
|
||||
ASPECT_RATIO_PRESETS,
|
||||
MEDIA_INPUT_TYPE,
|
||||
_apply_interactive_crop_resize,
|
||||
_apply_interactive_crop_resize_with_mask_pair,
|
||||
_compute_crop_box,
|
||||
_prepare_effect_mask,
|
||||
_resolve_mask_output_source,
|
||||
_scalar,
|
||||
_select_media_tensor,
|
||||
)
|
||||
from comfy_api.latest import io
|
||||
from ._helpers import ASPECT_RATIO_PRESETS, MEDIA_INPUT_TYPE, _apply_interactive_crop_resize, _apply_interactive_crop_resize_with_mask_pair, _compute_crop_box, _prepare_effect_mask, _resolve_mask_output_source, _scalar, _select_media_tensor
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
|
||||
def _list_param_length(*values):
|
||||
lengths = [len(value) for value in values if isinstance(value, (list, tuple))]
|
||||
return max(lengths) if lengths else 1
|
||||
|
||||
|
||||
def _is_noop_crop(source, width, height, aspect_ratio, crop_center_x, crop_center_y, crop_scale):
|
||||
"""Return True only when every frame's crop box maps to the full source and output size unchanged.
|
||||
|
||||
@@ -37,20 +26,11 @@ def _is_noop_crop(source, width, height, aspect_ratio, crop_center_x, crop_cente
|
||||
center_x = _scalar(crop_center_x, index=index)
|
||||
center_y = _scalar(crop_center_y, index=index)
|
||||
scale = _scalar(crop_scale, index=index)
|
||||
crop_x, crop_y, crop_w, crop_h = _compute_crop_box(
|
||||
source_w, source_h, ratio, target_w, target_h,
|
||||
center_x=center_x, center_y=center_y, scale=scale,
|
||||
)
|
||||
# A crop is a no-op only when the box covers the full source
|
||||
# AND the output size equals the source size.
|
||||
if (crop_x != 0 or crop_y != 0
|
||||
or crop_w != source_w or crop_h != source_h
|
||||
or target_w != source_w or target_h != source_h):
|
||||
crop_x, crop_y, crop_w, crop_h = _compute_crop_box(source_w, source_h, ratio, target_w, target_h, center_x=center_x, center_y=center_y, scale=scale)
|
||||
if crop_x != 0 or crop_y != 0 or crop_w != source_w or (crop_h != source_h) or (target_w != source_w) or (target_h != source_h):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _crop_bbox_metadata(source, width, height, aspect_ratio, crop_center_x, crop_center_y, crop_scale):
|
||||
if source is None or source.dim() != 4:
|
||||
return None
|
||||
@@ -65,118 +45,35 @@ def _crop_bbox_metadata(source, width, height, aspect_ratio, crop_center_x, crop
|
||||
center_x = _scalar(crop_center_x, index=index)
|
||||
center_y = _scalar(crop_center_y, index=index)
|
||||
scale = _scalar(crop_scale, index=index)
|
||||
crop_x, crop_y, crop_w, crop_h = _compute_crop_box(
|
||||
source_w,
|
||||
source_h,
|
||||
ratio,
|
||||
target_w,
|
||||
target_h,
|
||||
center_x=center_x,
|
||||
center_y=center_y,
|
||||
scale=scale,
|
||||
)
|
||||
frames.append({
|
||||
"frame": index,
|
||||
"x": crop_x,
|
||||
"y": crop_y,
|
||||
"width": crop_w,
|
||||
"height": crop_h,
|
||||
"source_width": source_w,
|
||||
"source_height": source_h,
|
||||
"target_width": target_w,
|
||||
"target_height": target_h,
|
||||
"aspect_ratio": ratio,
|
||||
"center_x": center_x,
|
||||
"center_y": center_y,
|
||||
"scale": scale,
|
||||
})
|
||||
return {
|
||||
"imageops_crop_bbox": {
|
||||
"type": "bbox",
|
||||
"coordinate_space": "source",
|
||||
"bbox": frames[0] if frames else None,
|
||||
"frames": frames,
|
||||
}
|
||||
}
|
||||
crop_x, crop_y, crop_w, crop_h = _compute_crop_box(source_w, source_h, ratio, target_w, target_h, center_x=center_x, center_y=center_y, scale=scale)
|
||||
frames.append({'frame': index, 'x': crop_x, 'y': crop_y, 'width': crop_w, 'height': crop_h, 'source_width': source_w, 'source_height': source_h, 'target_width': target_w, 'target_height': target_h, 'aspect_ratio': ratio, 'center_x': center_x, 'center_y': center_y, 'scale': scale})
|
||||
return {'imageops_crop_bbox': {'type': 'bbox', 'coordinate_space': 'source', 'bbox': frames[0] if frames else None, 'frames': frames}}
|
||||
|
||||
|
||||
class ImageOpsCrop:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsCrop(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
aspect_options = list(ASPECT_RATIO_PRESETS.keys())
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"aspect_ratio": (aspect_options, {"default": "1:1"}),
|
||||
"width": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
|
||||
"height": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
|
||||
"sync_dimensions": ("BOOLEAN", {"default": True, "label_on": "Linked", "label_off": "Free"}),
|
||||
"crop_center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.001}),
|
||||
"crop_center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.001}),
|
||||
"crop_scale": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 1.0, "step": 0.001}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"mask": ("MASK", {"tooltip": "Optional mask to crop with the image using identical geometry.", "display_name": "Mask"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsCrop', display_name='〽️ Image Ops Crop', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.String.Input('aspect_ratio', default='1:1'), io.Int.Input('width', default=1024, min=1, max=8192, step=1), io.Int.Input('height', default=1024, min=1, max=8192, step=1), io.Boolean.Input('sync_dimensions', default=True, label_on='Linked', label_off='Free'), io.Float.Input('crop_center_x', default=0.5, min=0.0, max=1.0, step=0.001), io.Float.Input('crop_center_y', default=0.5, min=0.0, max=1.0, step=0.001), io.Float.Input('crop_scale', default=1.0, min=0.05, max=1.0, step=0.001), io.String.Input('image', tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', force_input=True, display_name='Images/Video', optional=True), io.Mask.Input('mask', tooltip='Optional mask to crop with the image using identical geometry.', display_name='Mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(self, image=None, bypass=False, aspect_ratio="1:1", width=1024, height=1024,
|
||||
sync_dimensions=True, video=None, mask=None, crop_center_x=0.5, crop_center_y=0.5, crop_scale=1.0, unique_id=None):
|
||||
del sync_dimensions # UI-only link/free toggle used by the frontend preview controls.
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, aspect_ratio='1:1', width=1024, height=1024, sync_dimensions=True, video=None, mask=None, crop_center_x=0.5, crop_center_y=0.5, crop_scale=1.0, unique_id=None, **kwargs):
|
||||
del sync_dimensions
|
||||
source = _select_media_tensor(image, video)
|
||||
input_mask = _prepare_effect_mask(mask, source)
|
||||
output_mask_source = _resolve_mask_output_source(mask, source)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
|
||||
if _scalar(bypass, bool):
|
||||
progress.finish()
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_crop")
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix='imageops_crop')
|
||||
if _is_noop_crop(source, width, height, aspect_ratio, crop_center_x, crop_center_y, crop_scale):
|
||||
progress.finish()
|
||||
metadata = _crop_bbox_metadata(source, width, height, aspect_ratio, crop_center_x, crop_center_y, crop_scale)
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_crop", metadata=metadata)
|
||||
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix='imageops_crop', metadata=metadata)
|
||||
if input_mask is not None:
|
||||
result, output_mask = _apply_interactive_crop_resize_with_mask_pair(
|
||||
source,
|
||||
input_mask,
|
||||
width,
|
||||
height,
|
||||
aspect_ratio,
|
||||
center_x=crop_center_x,
|
||||
center_y=crop_center_y,
|
||||
scale=crop_scale,
|
||||
)
|
||||
result, output_mask = _apply_interactive_crop_resize_with_mask_pair(source, input_mask, width, height, aspect_ratio, center_x=crop_center_x, center_y=crop_center_y, scale=crop_scale)
|
||||
else:
|
||||
result = _apply_interactive_crop_resize(
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
aspect_ratio,
|
||||
center_x=crop_center_x,
|
||||
center_y=crop_center_y,
|
||||
scale=crop_scale,
|
||||
)
|
||||
output_mask = _apply_interactive_crop_resize(
|
||||
output_mask_source.unsqueeze(-1),
|
||||
width,
|
||||
height,
|
||||
aspect_ratio,
|
||||
center_x=crop_center_x,
|
||||
center_y=crop_center_y,
|
||||
scale=crop_scale,
|
||||
resize_mode="bilinear",
|
||||
antialias=True,
|
||||
)[..., 0]
|
||||
result = _apply_interactive_crop_resize(source, width, height, aspect_ratio, center_x=crop_center_x, center_y=crop_center_y, scale=crop_scale)
|
||||
output_mask = _apply_interactive_crop_resize(output_mask_source.unsqueeze(-1), width, height, aspect_ratio, center_x=crop_center_x, center_y=crop_center_y, scale=crop_scale, resize_mode='bilinear', antialias=True)[..., 0]
|
||||
progress.finish()
|
||||
metadata = _crop_bbox_metadata(source, width, height, aspect_ratio, crop_center_x, crop_center_y, crop_scale)
|
||||
return build_node_preview_result(result, (result, output_mask), prefix="imageops_crop", metadata=metadata)
|
||||
return build_node_preview_result(result, (result, output_mask), prefix='imageops_crop', metadata=metadata)
|
||||
+28
-75
@@ -1,13 +1,11 @@
|
||||
from comfy_api.latest import io
|
||||
import json
|
||||
|
||||
import torch
|
||||
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _alpha_mask_from_image, _coerce_media_to_tensor, _prepare_mask_tensor, _resize, _scalar
|
||||
from ._preview import build_node_preview_result
|
||||
from ._progress import start_progress
|
||||
from .core.batch import match_batch
|
||||
|
||||
|
||||
def _parse_bbox_frames(crop_bbox):
|
||||
if crop_bbox is None:
|
||||
return []
|
||||
@@ -15,7 +13,7 @@ def _parse_bbox_frames(crop_bbox):
|
||||
if isinstance(payload, (list, tuple)):
|
||||
payload = payload[0] if payload else None
|
||||
if isinstance(payload, bytes):
|
||||
payload = payload.decode("utf-8", errors="ignore")
|
||||
payload = payload.decode('utf-8', errors='ignore')
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
@@ -23,39 +21,36 @@ def _parse_bbox_frames(crop_bbox):
|
||||
return []
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
frames = payload.get("frames")
|
||||
frames = payload.get('frames')
|
||||
if isinstance(frames, list):
|
||||
return [frame for frame in frames if isinstance(frame, dict)]
|
||||
bbox = payload.get("bbox")
|
||||
bbox = payload.get('bbox')
|
||||
return [bbox] if isinstance(bbox, dict) else []
|
||||
|
||||
|
||||
def _bbox_from_frame(frame, source_w: int, source_h: int):
|
||||
try:
|
||||
x = int(round(float(frame.get("x", 0))))
|
||||
y = int(round(float(frame.get("y", 0))))
|
||||
w = int(round(float(frame.get("width", source_w))))
|
||||
h = int(round(float(frame.get("height", source_h))))
|
||||
x = int(round(float(frame.get('x', 0))))
|
||||
y = int(round(float(frame.get('y', 0))))
|
||||
w = int(round(float(frame.get('width', source_w))))
|
||||
h = int(round(float(frame.get('height', source_h))))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
w = max(1, min(source_w, w))
|
||||
h = max(1, min(source_h, h))
|
||||
x = max(0, min(source_w - w, x))
|
||||
y = max(0, min(source_h - h, y))
|
||||
return x, y, w, h
|
||||
|
||||
return (x, y, w, h)
|
||||
|
||||
def _bbox_from_mask(mask: torch.Tensor, frame_index: int, source_w: int, source_h: int):
|
||||
frame = mask[frame_index].detach() > 0.001
|
||||
ys, xs = torch.where(frame)
|
||||
if xs.numel() == 0 or ys.numel() == 0:
|
||||
return 0, 0, source_w, source_h
|
||||
return (0, 0, source_w, source_h)
|
||||
x0 = int(xs.min().item())
|
||||
x1 = int(xs.max().item()) + 1
|
||||
y0 = int(ys.min().item())
|
||||
y1 = int(ys.max().item()) + 1
|
||||
return x0, y0, max(1, x1 - x0), max(1, y1 - y0)
|
||||
|
||||
return (x0, y0, max(1, x1 - x0), max(1, y1 - y0))
|
||||
|
||||
def _blur_mask(mask: torch.Tensor, feather: int) -> torch.Tensor:
|
||||
radius = max(0, int(feather))
|
||||
@@ -66,7 +61,6 @@ def _blur_mask(mask: torch.Tensor, feather: int) -> torch.Tensor:
|
||||
x = torch.nn.functional.avg_pool2d(x, kernel_size=kernel, stride=1, padding=radius)
|
||||
return x.squeeze(1).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _match_channels(image: torch.Tensor, channels: int) -> torch.Tensor:
|
||||
current = int(image.shape[-1])
|
||||
if current == channels:
|
||||
@@ -78,95 +72,54 @@ def _match_channels(image: torch.Tensor, channels: int) -> torch.Tensor:
|
||||
pad[..., min(3 - current, pad.shape[-1] - 1)] = 1.0
|
||||
return torch.cat([image, pad], dim=-1)
|
||||
|
||||
|
||||
class ImageOpsCropStitch:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsCropStitch(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"original": (MEDIA_INPUT_TYPE, {"tooltip": "Original image/video before Resize/Crop.", "display_name": "Original"}),
|
||||
"crop": (MEDIA_INPUT_TYPE, {"tooltip": "Edited cropped image/video to stitch back.", "display_name": "Edited Crop"}),
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"feather": ("INT", {"default": 0, "min": 0, "max": 128, "step": 1, "tooltip": "Softens the crop mask edge before compositing."}),
|
||||
},
|
||||
"optional": {
|
||||
"crop_mask": ("MASK", {"tooltip": "Source-space mask from ImageOps Resize/Crop.", "display_name": "Crop Mask"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsCropStitch', display_name='〽️ Image Ops Crop Stitch', category='image/imageops', inputs=[io.String.Input('original', tooltip='Original image/video before Resize/Crop.', display_name='Original'), io.String.Input('crop', tooltip='Edited cropped image/video to stitch back.', display_name='Edited Crop'), io.Boolean.Input('bypass', default=False), io.Int.Input('feather', default=0, min=0, max=128, step=1, tooltip='Softens the crop mask edge before compositing.'), io.Mask.Input('crop_mask', tooltip='Source-space mask from ImageOps Resize/Crop.', display_name='Crop Mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(self, original, crop, bypass=False, feather=0, crop_mask=None, crop_bbox=None, unique_id=None):
|
||||
original = _coerce_media_to_tensor(original, "original").float().clamp(0.0, 1.0)
|
||||
crop = _coerce_media_to_tensor(crop, "crop").float().clamp(0.0, 1.0)
|
||||
@classmethod
|
||||
def execute(cls, original, crop, bypass=False, feather=0, crop_mask=None, crop_bbox=None, unique_id=None, **kwargs):
|
||||
original = _coerce_media_to_tensor(original, 'original').float().clamp(0.0, 1.0)
|
||||
crop = _coerce_media_to_tensor(crop, 'crop').float().clamp(0.0, 1.0)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
|
||||
if _scalar(bypass, bool):
|
||||
output_mask = _alpha_mask_from_image(original)
|
||||
progress.finish()
|
||||
return build_node_preview_result(original, (original, output_mask), prefix="imageops_crop_stitch")
|
||||
|
||||
original, crop = match_batch(
|
||||
original, crop,
|
||||
name_a="original", name_b="crop",
|
||||
policy="hold_last",
|
||||
)
|
||||
return build_node_preview_result(original, (original, output_mask), prefix='imageops_crop_stitch')
|
||||
original, crop = match_batch(original, crop, name_a='original', name_b='crop', policy='hold_last')
|
||||
batch = int(original.shape[0])
|
||||
source_h = int(original.shape[1])
|
||||
source_w = int(original.shape[2])
|
||||
|
||||
prepared_mask = _prepare_mask_tensor(
|
||||
crop_mask,
|
||||
batch=batch,
|
||||
height=source_h,
|
||||
width=source_w,
|
||||
device=original.device,
|
||||
dtype=original.dtype,
|
||||
)
|
||||
prepared_mask = _prepare_mask_tensor(crop_mask, batch=batch, height=source_h, width=source_w, device=original.device, dtype=original.dtype)
|
||||
bbox_frames = _parse_bbox_frames(crop_bbox)
|
||||
|
||||
out = original.clone()
|
||||
stitch_mask = torch.zeros((batch, source_h, source_w), device=original.device, dtype=original.dtype)
|
||||
|
||||
for index in range(batch):
|
||||
if bbox_frames:
|
||||
bbox = _bbox_from_frame(bbox_frames[min(index, len(bbox_frames) - 1)], source_w, source_h)
|
||||
elif prepared_mask is None:
|
||||
bbox = (0, 0, source_w, source_h)
|
||||
else:
|
||||
if prepared_mask is None:
|
||||
bbox = (0, 0, source_w, source_h)
|
||||
else:
|
||||
bbox = _bbox_from_mask(prepared_mask, index, source_w, source_h)
|
||||
bbox = _bbox_from_mask(prepared_mask, index, source_w, source_h)
|
||||
if bbox is None:
|
||||
bbox = (0, 0, source_w, source_h)
|
||||
x, y, w, h = bbox
|
||||
resized_crop = _resize(crop[index:index + 1], w, h, mode="bicubic", antialias=True)[0]
|
||||
|
||||
resized_crop = _resize(crop[index:index + 1], w, h, mode='bicubic', antialias=True)[0]
|
||||
if prepared_mask is None:
|
||||
region_mask = torch.ones((h, w), device=original.device, dtype=original.dtype)
|
||||
else:
|
||||
region_mask = prepared_mask[index:index + 1, y:y + h, x:x + w]
|
||||
region_mask = _resize(region_mask.unsqueeze(-1), w, h, mode="bilinear", antialias=True)[0, ..., 0]
|
||||
region_mask = _resize(region_mask.unsqueeze(-1), w, h, mode='bilinear', antialias=True)[0, ..., 0]
|
||||
if resized_crop.shape[-1] >= 4:
|
||||
region_mask = (region_mask * resized_crop[..., 3].clamp(0.0, 1.0)).clamp(0.0, 1.0)
|
||||
resized_crop = _match_channels(resized_crop, int(out.shape[-1]))
|
||||
|
||||
stitch_mask[index, y:y + h, x:x + w] = torch.maximum(stitch_mask[index, y:y + h, x:x + w], region_mask)
|
||||
blend = region_mask.unsqueeze(-1)
|
||||
out[index, y:y + h, x:x + w, :] = (
|
||||
out[index, y:y + h, x:x + w, :] * (1.0 - blend) + resized_crop.to(out.dtype) * blend
|
||||
)
|
||||
|
||||
out[index, y:y + h, x:x + w, :] = out[index, y:y + h, x:x + w, :] * (1.0 - blend) + resized_crop.to(out.dtype) * blend
|
||||
stitch_mask = _blur_mask(stitch_mask, _scalar(feather, int))
|
||||
if _scalar(feather, int) > 0:
|
||||
blend = stitch_mask.unsqueeze(-1)
|
||||
# Re-apply feather globally so softened edges blend with untouched original.
|
||||
out = original * (1.0 - blend) + out * blend
|
||||
|
||||
progress.finish()
|
||||
return build_node_preview_result(out.clamp(0.0, 1.0), (out.clamp(0.0, 1.0), stitch_mask.clamp(0.0, 1.0)), prefix="imageops_crop_stitch")
|
||||
return build_node_preview_result(out.clamp(0.0, 1.0), (out.clamp(0.0, 1.0), stitch_mask.clamp(0.0, 1.0)), prefix='imageops_crop_stitch')
|
||||
+38
-204
@@ -1,88 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from ._helpers import (
|
||||
EPSILON,
|
||||
LUMA_WEIGHTS,
|
||||
MEDIA_INPUT_TYPE,
|
||||
_alpha_mask_from_image,
|
||||
_apply_blur,
|
||||
_coerce_media_to_tensor,
|
||||
_extract_channel_mask,
|
||||
_match_image_to_reference,
|
||||
_prepare_effect_mask,
|
||||
_prepare_mask_tensor,
|
||||
_scalar,
|
||||
_select_media_tensor,
|
||||
)
|
||||
from ._helpers import EPSILON, LUMA_WEIGHTS, MEDIA_INPUT_TYPE, _alpha_mask_from_image, _apply_blur, _coerce_media_to_tensor, _extract_channel_mask, _match_image_to_reference, _prepare_effect_mask, _prepare_mask_tensor, _scalar, _select_media_tensor
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
_DISTORT_MAP_SOURCES = ["source_channel", "displacement_channel", "mask"]
|
||||
_DISTORT_CHANNELS = ["Red", "Green", "Blue", "Alpha", "Luma"]
|
||||
_DISTORT_FILTERS = ["nearest", "bilinear", "bicubic"]
|
||||
_DISTORT_EDGE_MODES = ["border", "reflection", "zeros"]
|
||||
|
||||
_DISTORT_MAP_SOURCES = ['source_channel', 'displacement_channel', 'mask']
|
||||
_DISTORT_CHANNELS = ['Red', 'Green', 'Blue', 'Alpha', 'Luma']
|
||||
_DISTORT_FILTERS = ['nearest', 'bilinear', 'bicubic']
|
||||
_DISTORT_EDGE_MODES = ['border', 'reflection', 'zeros']
|
||||
|
||||
def _extract_map_channel(image: torch.Tensor, channel: str) -> torch.Tensor:
|
||||
normalized = str(channel or "Red").strip().lower()
|
||||
if normalized == "luma":
|
||||
normalized = str(channel or 'Red').strip().lower()
|
||||
if normalized == 'luma':
|
||||
if image.shape[-1] >= 3:
|
||||
lr, lg, lb = LUMA_WEIGHTS
|
||||
return (
|
||||
image[..., 0].float() * lr
|
||||
+ image[..., 1].float() * lg
|
||||
+ image[..., 2].float() * lb
|
||||
).clamp(0.0, 1.0)
|
||||
return (image[..., 0].float() * lr + image[..., 1].float() * lg + image[..., 2].float() * lb).clamp(0.0, 1.0)
|
||||
return image[..., 0].float().clamp(0.0, 1.0)
|
||||
return _extract_channel_mask(image, channel)
|
||||
|
||||
|
||||
def _neutral_map(reference: torch.Tensor, centered_map) -> torch.Tensor:
|
||||
frames = []
|
||||
for frame_index in range(reference.shape[0]):
|
||||
value = 0.5 if _scalar(centered_map, bool, index=frame_index) else 0.0
|
||||
frames.append(
|
||||
torch.full(
|
||||
(1, reference.shape[1], reference.shape[2]),
|
||||
float(value),
|
||||
device=reference.device,
|
||||
dtype=reference.dtype,
|
||||
)
|
||||
)
|
||||
frames.append(torch.full((1, reference.shape[1], reference.shape[2]), float(value), device=reference.device, dtype=reference.dtype))
|
||||
return torch.cat(frames, dim=0)
|
||||
|
||||
|
||||
def _resolve_distortion_maps(
|
||||
source: torch.Tensor,
|
||||
map_source,
|
||||
x_channel,
|
||||
y_channel,
|
||||
centered_map,
|
||||
displacement=None,
|
||||
mask=None,
|
||||
):
|
||||
prepared_mask = _prepare_mask_tensor(
|
||||
mask,
|
||||
batch=source.shape[0],
|
||||
height=source.shape[1],
|
||||
width=source.shape[2],
|
||||
device=source.device,
|
||||
dtype=source.dtype,
|
||||
)
|
||||
def _resolve_distortion_maps(source: torch.Tensor, map_source, x_channel, y_channel, centered_map, displacement=None, mask=None):
|
||||
prepared_mask = _prepare_mask_tensor(mask, batch=source.shape[0], height=source.shape[1], width=source.shape[2], device=source.device, dtype=source.dtype)
|
||||
x_frames = []
|
||||
y_frames = []
|
||||
preview_mask_frames = []
|
||||
mask_source_frames: list[bool] = []
|
||||
|
||||
for frame_index in range(source.shape[0]):
|
||||
normalized = _scalar(map_source, str, index=frame_index).strip().lower()
|
||||
frame_source = source[frame_index:frame_index + 1]
|
||||
frame_displacement = displacement[frame_index:frame_index + 1] if displacement is not None else None
|
||||
|
||||
if normalized == "mask":
|
||||
if normalized == 'mask':
|
||||
mask_source_frames.append(True)
|
||||
if prepared_mask is None:
|
||||
neutral = _neutral_map(frame_source, centered_map=_scalar(centered_map, bool, index=frame_index))
|
||||
@@ -95,57 +49,29 @@ def _resolve_distortion_maps(
|
||||
y_frames.append(frame_mask)
|
||||
preview_mask_frames.append(frame_mask)
|
||||
continue
|
||||
|
||||
mask_source_frames.append(False)
|
||||
driver = frame_source
|
||||
if normalized == "displacement_channel" and frame_displacement is not None:
|
||||
if normalized == 'displacement_channel' and frame_displacement is not None:
|
||||
driver = frame_displacement
|
||||
x_frames.append(
|
||||
_extract_map_channel(driver, _scalar(x_channel, str, index=frame_index)).to(device=source.device, dtype=source.dtype)
|
||||
)
|
||||
y_frames.append(
|
||||
_extract_map_channel(driver, _scalar(y_channel, str, index=frame_index)).to(device=source.device, dtype=source.dtype)
|
||||
)
|
||||
x_frames.append(_extract_map_channel(driver, _scalar(x_channel, str, index=frame_index)).to(device=source.device, dtype=source.dtype))
|
||||
y_frames.append(_extract_map_channel(driver, _scalar(y_channel, str, index=frame_index)).to(device=source.device, dtype=source.dtype))
|
||||
preview_mask_frames.append(None)
|
||||
|
||||
preview_mask = None
|
||||
if any(frame is not None for frame in preview_mask_frames):
|
||||
preview_mask = torch.zeros(
|
||||
(source.shape[0], source.shape[1], source.shape[2]),
|
||||
device=source.device,
|
||||
dtype=source.dtype,
|
||||
)
|
||||
if any((frame is not None for frame in preview_mask_frames)):
|
||||
preview_mask = torch.zeros((source.shape[0], source.shape[1], source.shape[2]), device=source.device, dtype=source.dtype)
|
||||
for frame_index, frame in enumerate(preview_mask_frames):
|
||||
if frame is not None:
|
||||
preview_mask[frame_index:frame_index + 1] = frame
|
||||
return (torch.cat(x_frames, dim=0), torch.cat(y_frames, dim=0), preview_mask, mask_source_frames)
|
||||
|
||||
return torch.cat(x_frames, dim=0), torch.cat(y_frames, dim=0), preview_mask, mask_source_frames
|
||||
|
||||
|
||||
def _warp_image(
|
||||
source: torch.Tensor,
|
||||
x_map: torch.Tensor,
|
||||
y_map: torch.Tensor,
|
||||
strength_x,
|
||||
strength_y,
|
||||
centered_map,
|
||||
invert_map,
|
||||
filter_mode,
|
||||
edge_mode,
|
||||
progress=None,
|
||||
) -> torch.Tensor:
|
||||
def _warp_image(source: torch.Tensor, x_map: torch.Tensor, y_map: torch.Tensor, strength_x, strength_y, centered_map, invert_map, filter_mode, edge_mode, progress=None) -> torch.Tensor:
|
||||
image = source.float().permute(0, 3, 1, 2).contiguous()
|
||||
warped_frames = []
|
||||
height = int(source.shape[1])
|
||||
width = int(source.shape[2])
|
||||
base_y, base_x = torch.meshgrid(
|
||||
torch.linspace(-1.0, 1.0, height, device=source.device, dtype=source.dtype),
|
||||
torch.linspace(-1.0, 1.0, width, device=source.device, dtype=source.dtype),
|
||||
indexing="ij",
|
||||
)
|
||||
base_y, base_x = torch.meshgrid(torch.linspace(-1.0, 1.0, height, device=source.device, dtype=source.dtype), torch.linspace(-1.0, 1.0, width, device=source.device, dtype=source.dtype), indexing='ij')
|
||||
width_norm = 2.0 / float(max(1, width - 1))
|
||||
height_norm = 2.0 / float(max(1, height - 1))
|
||||
|
||||
for frame_index in range(source.shape[0]):
|
||||
frame_x = x_map[frame_index].float().clamp(0.0, 1.0)
|
||||
frame_y = y_map[frame_index].float().clamp(0.0, 1.0)
|
||||
@@ -155,113 +81,36 @@ def _warp_image(
|
||||
if _scalar(centered_map, bool, index=frame_index):
|
||||
frame_x = frame_x * 2.0 - 1.0
|
||||
frame_y = frame_y * 2.0 - 1.0
|
||||
|
||||
dx = frame_x * float(_scalar(strength_x, index=frame_index))
|
||||
dy = frame_y * float(_scalar(strength_y, index=frame_index))
|
||||
safe_filter = _scalar(filter_mode, str, index=frame_index).strip().lower()
|
||||
if safe_filter not in ("nearest", "bilinear", "bicubic"):
|
||||
safe_filter = "bilinear"
|
||||
if safe_filter not in ('nearest', 'bilinear', 'bicubic'):
|
||||
safe_filter = 'bilinear'
|
||||
safe_edge_mode = _scalar(edge_mode, str, index=frame_index).strip().lower()
|
||||
if safe_edge_mode not in ("border", "reflection", "zeros"):
|
||||
safe_edge_mode = "border"
|
||||
grid = torch.stack(
|
||||
[
|
||||
(base_x - dx * width_norm),
|
||||
(base_y - dy * height_norm),
|
||||
],
|
||||
dim=-1,
|
||||
).unsqueeze(0)
|
||||
|
||||
warped = F.grid_sample(
|
||||
image[frame_index:frame_index + 1],
|
||||
grid,
|
||||
mode=safe_filter,
|
||||
padding_mode=safe_edge_mode,
|
||||
align_corners=True,
|
||||
)
|
||||
if safe_edge_mode not in ('border', 'reflection', 'zeros'):
|
||||
safe_edge_mode = 'border'
|
||||
grid = torch.stack([base_x - dx * width_norm, base_y - dy * height_norm], dim=-1).unsqueeze(0)
|
||||
warped = F.grid_sample(image[frame_index:frame_index + 1], grid, mode=safe_filter, padding_mode=safe_edge_mode, align_corners=True)
|
||||
warped_frames.append(warped)
|
||||
if progress is not None:
|
||||
progress.update()
|
||||
return torch.cat(warped_frames, dim=0).permute(0, 2, 3, 1).contiguous().clamp(0.0, 1.0).to(device=source.device, dtype=source.dtype)
|
||||
|
||||
return (
|
||||
torch.cat(warped_frames, dim=0)
|
||||
.permute(0, 2, 3, 1)
|
||||
.contiguous()
|
||||
.clamp(0.0, 1.0)
|
||||
.to(device=source.device, dtype=source.dtype)
|
||||
)
|
||||
|
||||
|
||||
class ImageOpsDistort:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsDistort(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"map_source": (_DISTORT_MAP_SOURCES, {"default": "source_channel"}),
|
||||
"x_channel": (_DISTORT_CHANNELS, {"default": "Red"}),
|
||||
"y_channel": (_DISTORT_CHANNELS, {"default": "Green"}),
|
||||
"strength_x": ("FLOAT", {"default": 40.0, "min": -2048.0, "max": 2048.0, "step": 0.1, "round": 0.001}),
|
||||
"strength_y": ("FLOAT", {"default": 40.0, "min": -2048.0, "max": 2048.0, "step": 0.1, "round": 0.001}),
|
||||
"blur_map": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 200.0, "step": 0.1, "round": 0.001, "tooltip": "Gaussian blur radius (in pixels) applied to the distortion map(s) before warping. 0 = no blur."}),
|
||||
"centered_map": ("BOOLEAN", {"default": True}),
|
||||
"invert_map": ("BOOLEAN", {"default": False}),
|
||||
"filter": (_DISTORT_FILTERS, {"default": "bilinear"}),
|
||||
"edge_mode": (_DISTORT_EDGE_MODES, {"default": "border"}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Source image/video to distort.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"displacement": (MEDIA_INPUT_TYPE, {"tooltip": "Optional displacement image/video when map_source is displacement_channel.", "display_name": "Displacement"}),
|
||||
"mask": ("MASK", {"tooltip": "Used as the distortion map when map_source is mask, otherwise acts as an effect mask."}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsDistort', display_name='〽️ Image Ops Distort', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.String.Input('map_source', default='source_channel'), io.String.Input('x_channel', default='Red'), io.String.Input('y_channel', default='Green'), io.Float.Input('strength_x', default=40.0, min=-2048.0, max=2048.0, step=0.1, round=0.001), io.Float.Input('strength_y', default=40.0, min=-2048.0, max=2048.0, step=0.1, round=0.001), io.Float.Input('blur_map', default=0.0, min=0.0, max=200.0, step=0.1, round=0.001, tooltip='Gaussian blur radius (in pixels) applied to the distortion map(s) before warping. 0 = no blur.'), io.Boolean.Input('centered_map', default=True), io.Boolean.Input('invert_map', default=False), io.String.Input('filter', default='bilinear'), io.String.Input('edge_mode', default='border'), io.Boolean.Input('invert_mask', default=False), io.String.Input('image', tooltip='Source image/video to distort.', force_input=True, display_name='Images/Video', optional=True), io.String.Input('displacement', tooltip='Optional displacement image/video when map_source is displacement_channel.', display_name='Displacement', optional=True), io.Mask.Input('mask', tooltip='Used as the distortion map when map_source is mask, otherwise acts as an effect mask.', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(
|
||||
self,
|
||||
image=None,
|
||||
bypass=False,
|
||||
map_source="source_channel",
|
||||
x_channel="Red",
|
||||
y_channel="Green",
|
||||
strength_x=40.0,
|
||||
strength_y=40.0,
|
||||
blur_map=0.0,
|
||||
centered_map=True,
|
||||
invert_map=False,
|
||||
filter="bilinear",
|
||||
edge_mode="border",
|
||||
invert_mask=False,
|
||||
video=None,
|
||||
displacement=None,
|
||||
mask=None,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, map_source='source_channel', x_channel='Red', y_channel='Green', strength_x=40.0, strength_y=40.0, blur_map=0.0, centered_map=True, invert_map=False, filter='bilinear', edge_mode='border', invert_mask=False, video=None, displacement=None, mask=None, unique_id=None, **kwargs):
|
||||
source = _select_media_tensor(image, video).float().clamp(0.0, 1.0)
|
||||
displacement_tensor = None
|
||||
if displacement is not None:
|
||||
displacement_tensor = _coerce_media_to_tensor(displacement, "displacement").float().clamp(0.0, 1.0)
|
||||
displacement_tensor = _coerce_media_to_tensor(displacement, 'displacement').float().clamp(0.0, 1.0)
|
||||
displacement_tensor = _match_image_to_reference(displacement_tensor, source)
|
||||
|
||||
effect_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask)
|
||||
|
||||
x_map, y_map, driver_preview_mask, mask_source_frames = _resolve_distortion_maps(
|
||||
source,
|
||||
map_source=map_source,
|
||||
x_channel=x_channel,
|
||||
y_channel=y_channel,
|
||||
centered_map=centered_map,
|
||||
displacement=displacement_tensor,
|
||||
mask=mask,
|
||||
)
|
||||
x_map, y_map, driver_preview_mask, mask_source_frames = _resolve_distortion_maps(source, map_source=map_source, x_channel=x_channel, y_channel=y_channel, centered_map=centered_map, displacement=displacement_tensor, mask=mask)
|
||||
blur_radius = int(round(float(_scalar(blur_map, float))))
|
||||
if blur_radius > 0:
|
||||
x_map = _apply_blur(x_map.unsqueeze(-1), blur_radius, 0.0).squeeze(-1)
|
||||
@@ -273,7 +122,6 @@ class ImageOpsDistort:
|
||||
for frame_index, is_mask_source in enumerate(mask_source_frames):
|
||||
if is_mask_source:
|
||||
apply_effect_mask[frame_index:frame_index + 1] = 1.0
|
||||
|
||||
if _scalar(bypass, bool):
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
output_mask = _alpha_mask_from_image(source)
|
||||
@@ -290,22 +138,9 @@ class ImageOpsDistort:
|
||||
if is_mask_source:
|
||||
output_mask[frame_index:frame_index + 1] = preview_mask[frame_index:frame_index + 1].clamp(0.0, 1.0)
|
||||
progress.finish()
|
||||
return build_node_preview_result(source, (source, output_mask), prefix="imageops_distort")
|
||||
|
||||
return build_node_preview_result(source, (source, output_mask), prefix='imageops_distort')
|
||||
progress = start_progress(total=max(1, int(source.shape[0])), unique_id=unique_id)
|
||||
result = _warp_image(
|
||||
source,
|
||||
x_map=x_map,
|
||||
y_map=y_map,
|
||||
strength_x=strength_x,
|
||||
strength_y=strength_y,
|
||||
centered_map=centered_map,
|
||||
invert_map=invert_map,
|
||||
filter_mode=filter,
|
||||
edge_mode=edge_mode,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
result = _warp_image(source, x_map=x_map, y_map=y_map, strength_x=strength_x, strength_y=strength_y, centered_map=centered_map, invert_map=invert_map, filter_mode=filter, edge_mode=edge_mode, progress=progress)
|
||||
if apply_effect_mask is not None:
|
||||
result = source * (1.0 - apply_effect_mask.unsqueeze(-1)) + result * apply_effect_mask.unsqueeze(-1)
|
||||
output_mask = _alpha_mask_from_image(result)
|
||||
@@ -321,6 +156,5 @@ class ImageOpsDistort:
|
||||
for frame_index, is_mask_source in enumerate(mask_source_frames):
|
||||
if is_mask_source:
|
||||
output_mask[frame_index:frame_index + 1] = preview_mask[frame_index:frame_index + 1].clamp(0.0, 1.0)
|
||||
|
||||
progress.finish()
|
||||
return build_node_preview_result(result.clamp(0.0, 1.0), (result.clamp(0.0, 1.0), output_mask), prefix="imageops_distort")
|
||||
return build_node_preview_result(result.clamp(0.0, 1.0), (result.clamp(0.0, 1.0), output_mask), prefix='imageops_distort')
|
||||
+23
-102
@@ -1,173 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import torch
|
||||
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _coerce_media_to_tensor, _scalar
|
||||
from ._preview import build_node_preview_result
|
||||
from ._progress import start_progress
|
||||
|
||||
|
||||
def _clamp_int(value, low: int, high: int) -> int:
|
||||
return max(low, min(high, int(value)))
|
||||
|
||||
|
||||
def _timeline_indices(source_count: int, trim_start: int, trim_end: int) -> list[int]:
|
||||
if source_count <= 0:
|
||||
return []
|
||||
start = _clamp_int(trim_start, 0, source_count - 1)
|
||||
end = source_count - 1 if trim_end < 0 else _clamp_int(trim_end, 0, source_count - 1)
|
||||
if end < start:
|
||||
start, end = end, start
|
||||
start, end = (end, start)
|
||||
return list(range(start, end + 1))
|
||||
|
||||
|
||||
def _repeat_count(repeat: bool, repeat_mode: str, custom_frame_count: int, source_count: int) -> int:
|
||||
if not repeat:
|
||||
return 1
|
||||
mode = str(repeat_mode or "").strip().lower()
|
||||
if mode == "input_duration":
|
||||
mode = str(repeat_mode or '').strip().lower()
|
||||
if mode == 'input_duration':
|
||||
return max(0, int(source_count))
|
||||
return max(1, int(custom_frame_count))
|
||||
|
||||
|
||||
def _normalize_repeat_style(repeat_mode: str) -> str:
|
||||
mode = str(repeat_mode or "loop").strip().lower()
|
||||
if mode in {"bounce", "reverse", "loop"}:
|
||||
mode = str(repeat_mode or 'loop').strip().lower()
|
||||
if mode in {'bounce', 'reverse', 'loop'}:
|
||||
return mode
|
||||
return "loop"
|
||||
|
||||
return 'loop'
|
||||
|
||||
def _repeat_indices(indices: list[int], output_count: int, repeat_mode: str) -> list[int]:
|
||||
if not indices or output_count <= 0:
|
||||
return []
|
||||
|
||||
style = _normalize_repeat_style(repeat_mode)
|
||||
if style == "reverse":
|
||||
if style == 'reverse':
|
||||
pattern = list(reversed(indices))
|
||||
elif style == "bounce":
|
||||
elif style == 'bounce':
|
||||
pattern = indices if len(indices) <= 2 else indices + indices[-2:0:-1]
|
||||
else:
|
||||
pattern = indices
|
||||
|
||||
if not pattern:
|
||||
pattern = indices
|
||||
|
||||
pattern_count = len(pattern)
|
||||
return [pattern[i % pattern_count] for i in range(output_count)]
|
||||
|
||||
|
||||
class ImageOpsFrameRange:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "INT")
|
||||
RETURN_NAMES = ("image", "frame_count")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsFrameRange(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Image batch or video frames.", "display_name": "Image/Video"}),
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"trim_start": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}),
|
||||
"trim_end": ("INT", {"default": -1, "min": -1, "max": 10000000, "step": 1, "tooltip": "-1 means last input frame."}),
|
||||
"frame_hold": ("BOOLEAN", {"default": False}),
|
||||
"hold_frame": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}),
|
||||
"repeat": ("BOOLEAN", {"default": False}),
|
||||
"repeat_mode": (["loop", "bounce", "reverse", "input_duration", "custom_count", "freeze"], {"default": "loop"}),
|
||||
"custom_frame_count": ("INT", {"default": 24, "min": 1, "max": 10000000, "step": 1}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsFrameRange', display_name='〽️ Image Ops Frame Range', category='image/imageops', inputs=[io.String.Input('image', tooltip='Image batch or video frames.', display_name='Image/Video'), io.Boolean.Input('bypass', default=False), io.Int.Input('trim_start', default=0, min=0, max=10000000, step=1), io.Int.Input('trim_end', default=-1, min=-1, max=10000000, step=1, tooltip='-1 means last input frame.'), io.Boolean.Input('frame_hold', default=False), io.Int.Input('hold_frame', default=0, min=0, max=10000000, step=1), io.Boolean.Input('repeat', default=False), io.String.Input('repeat_mode', default='loop'), io.Int.Input('custom_frame_count', default=24, min=1, max=10000000, step=1)], outputs=[io.Image.Output('image', display_name='image'), io.Int.Output('frame_count', display_name='frame_count')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(
|
||||
self,
|
||||
image,
|
||||
bypass=False,
|
||||
trim_start=0,
|
||||
trim_end=-1,
|
||||
frame_hold=False,
|
||||
hold_frame=0,
|
||||
repeat=False,
|
||||
repeat_mode="loop",
|
||||
custom_frame_count=24,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, image, bypass=False, trim_start=0, trim_end=-1, frame_hold=False, hold_frame=0, repeat=False, repeat_mode='loop', custom_frame_count=24, unique_id=None, **kwargs):
|
||||
from .core.media import ImageOpsMedia
|
||||
|
||||
is_media = isinstance(image, ImageOpsMedia)
|
||||
media_obj = image if is_media else None
|
||||
|
||||
tensor = _coerce_media_to_tensor(image, "image")
|
||||
tensor = _coerce_media_to_tensor(image, 'image')
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
source_count = int(tensor.shape[0])
|
||||
|
||||
if _scalar(bypass, bool):
|
||||
progress.finish()
|
||||
return build_node_preview_result(
|
||||
image,
|
||||
(image, source_count),
|
||||
metadata={"imageops_frame_range_source_count": [source_count]},
|
||||
)
|
||||
|
||||
indices = _timeline_indices(
|
||||
source_count, _scalar(trim_start, int), _scalar(trim_end, int)
|
||||
)
|
||||
|
||||
repeat_mode_text = str(repeat_mode or "loop").strip().lower()
|
||||
repeat_uses_hold = repeat_mode_text in {"input_duration", "custom_count", "freeze"}
|
||||
|
||||
# freeze mode always locks to hold_frame, regardless of the frame_hold toggle.
|
||||
apply_hold = (
|
||||
(_scalar(frame_hold, bool) and (not _scalar(repeat, bool) or repeat_uses_hold))
|
||||
or (_scalar(repeat, bool) and repeat_mode_text == "freeze")
|
||||
)
|
||||
return build_node_preview_result(image, (image, source_count), metadata={'imageops_frame_range_source_count': [source_count]})
|
||||
indices = _timeline_indices(source_count, _scalar(trim_start, int), _scalar(trim_end, int))
|
||||
repeat_mode_text = str(repeat_mode or 'loop').strip().lower()
|
||||
repeat_uses_hold = repeat_mode_text in {'input_duration', 'custom_count', 'freeze'}
|
||||
apply_hold = _scalar(frame_hold, bool) and (not _scalar(repeat, bool) or repeat_uses_hold) or (_scalar(repeat, bool) and repeat_mode_text == 'freeze')
|
||||
if apply_hold and indices:
|
||||
hold_min = indices[0]
|
||||
hold_max = indices[-1]
|
||||
base_index = _clamp_int(_scalar(hold_frame, int), hold_min, hold_max)
|
||||
indices = [base_index]
|
||||
|
||||
repeat_enabled = _scalar(repeat, bool)
|
||||
if repeat_enabled and indices:
|
||||
output_count = _repeat_count(
|
||||
True,
|
||||
str(repeat_mode or "loop"),
|
||||
_scalar(custom_frame_count, int),
|
||||
source_count,
|
||||
)
|
||||
indices = _repeat_indices(indices, output_count, str(repeat_mode or "loop"))
|
||||
|
||||
output_count = _repeat_count(True, str(repeat_mode or 'loop'), _scalar(custom_frame_count, int), source_count)
|
||||
indices = _repeat_indices(indices, output_count, str(repeat_mode or 'loop'))
|
||||
if not indices:
|
||||
out_tensor = tensor[:1].clone()
|
||||
out_audio = media_obj.audio if media_obj and media_obj.audio is not None else None
|
||||
else:
|
||||
idx_tensor = torch.tensor(indices, device=tensor.device, dtype=torch.long)
|
||||
out_tensor = tensor[idx_tensor]
|
||||
|
||||
if media_obj and media_obj.audio is not None:
|
||||
# Approximate audio slicing assuming audio length matches video length linearly
|
||||
# For proper audio, we would need sample rate, but we just slice the audio tensor
|
||||
# along its first dimension (samples or frames). Let's assume audio is shape [B, ...]
|
||||
# Wait, audio in ComfyUI is usually a dict, but in ImageOpsMedia it's a tensor.
|
||||
# Actually, ComfyUI audio is a dict with waveform and sample_rate.
|
||||
# If we just keep it or slice it, we might need to know the audio shape.
|
||||
# For now, let's just keep the original audio without slicing since slicing audio
|
||||
# requires exact sample rates and alignment which we don't have here.
|
||||
# Just pass it through or clear it if repeating.
|
||||
out_audio = media_obj.audio.clone()
|
||||
else:
|
||||
out_audio = None
|
||||
|
||||
if is_media:
|
||||
out = ImageOpsMedia(frames=out_tensor, fps=media_obj.fps, audio=out_audio, metadata=dict(media_obj.metadata))
|
||||
else:
|
||||
out = out_tensor
|
||||
|
||||
progress.finish()
|
||||
return build_node_preview_result(
|
||||
out_tensor,
|
||||
(out, int(out_tensor.shape[0])),
|
||||
metadata={"imageops_frame_range_source_count": [source_count]},
|
||||
)
|
||||
return build_node_preview_result(out_tensor, (out, int(out_tensor.shape[0])), metadata={'imageops_frame_range_source_count': [source_count]})
|
||||
+9
-30
@@ -1,44 +1,23 @@
|
||||
from ._helpers import (
|
||||
MEDIA_INPUT_TYPE,
|
||||
_apply_invert,
|
||||
_resolve_mask_output_source,
|
||||
_scalar,
|
||||
_select_media_tensor,
|
||||
)
|
||||
from comfy_api.latest import io
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _apply_invert, _resolve_mask_output_source, _scalar, _select_media_tensor
|
||||
from .compat.comfy_v3 import V3NodeBase
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
class ImageOpsInvert(V3NodeBase):
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsInvert(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
"invert_alpha": ("BOOLEAN", {"default": False, "tooltip": "Also invert the alpha channel (only applies to RGBA images)."}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsInvert', display_name='〽️ Image Ops Invert', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.Boolean.Input('invert_mask', default=False), io.Boolean.Input('invert_alpha', default=False, tooltip='Also invert the alpha channel (only applies to RGBA images).'), io.String.Input('image', tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', force_input=True, display_name='Images/Video', optional=True), io.Mask.Input('mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(self, image=None, bypass=False, invert_mask=False, invert_alpha=False, video=None, mask=None, unique_id=None):
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, invert_mask=False, invert_alpha=False, video=None, mask=None, unique_id=None, **kwargs):
|
||||
src = _select_media_tensor(image, video)
|
||||
output_mask = _resolve_mask_output_source(mask, src, invert_mask=invert_mask)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
if _scalar(bypass, bool):
|
||||
progress.finish()
|
||||
return build_node_preview_result(src, (src, output_mask), prefix="imageops_invert")
|
||||
return build_node_preview_result(src, (src, output_mask), prefix='imageops_invert')
|
||||
out = _apply_invert(src, invert_alpha=_scalar(invert_alpha, bool))
|
||||
progress.finish()
|
||||
return build_node_preview_result(out, (out, output_mask), prefix="imageops_invert")
|
||||
return build_node_preview_result(out, (out, output_mask), prefix='imageops_invert')
|
||||
+12
-52
@@ -1,78 +1,38 @@
|
||||
from comfy_api.latest import io
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _apply_merge, _apply_mask_to_image, _coerce_media_to_tensor, _prepare_effect_mask, _resolve_mask_output_source, _scalar
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
|
||||
def _all_zero_mix(mix) -> bool:
|
||||
if isinstance(mix, (list, tuple)):
|
||||
if len(mix) == 0:
|
||||
return True
|
||||
return all(_scalar(mix, index=i) <= 0.0 for i in range(len(mix)))
|
||||
return all((_scalar(mix, index=i) <= 0.0 for i in range(len(mix))))
|
||||
return _scalar(mix) <= 0.0
|
||||
|
||||
|
||||
class ImageOpsMerge:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsMerge(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"A": (MEDIA_INPUT_TYPE, {"tooltip": "Background Images/Video input.", "display_name": "Background"}),
|
||||
"B": (MEDIA_INPUT_TYPE, {"tooltip": "Foreground Images/Video input.", "display_name": "Foreground"}),
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"mode": ([
|
||||
"over",
|
||||
"add",
|
||||
"subtract",
|
||||
"multiply",
|
||||
"screen",
|
||||
"overlay",
|
||||
"soft_light",
|
||||
"difference",
|
||||
"max",
|
||||
"min",
|
||||
"lighten",
|
||||
"darken",
|
||||
"color_dodge",
|
||||
"color_burn",
|
||||
"exclusion",
|
||||
"vivid_light",
|
||||
"pin_light",
|
||||
"hard_mix",
|
||||
], {"default": "over"}),
|
||||
"mix": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "round": 0.001}),
|
||||
"foreground_fit": (["stretch", "contain", "cover", "none"], {"default": "stretch"}),
|
||||
"blend_space": (["linear", "srgb"], {"default": "linear"}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"mask": ("MASK", {"tooltip": "Optional mask applied to the merged result", "display_name": "Mask"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsMerge', display_name='〽️ Image Ops Merge', category='image/imageops', inputs=[io.String.Input('A', tooltip='Background Images/Video input.', display_name='Background'), io.String.Input('B', tooltip='Foreground Images/Video input.', display_name='Foreground'), io.Boolean.Input('bypass', default=False), io.String.Input('mode', default='over'), io.Float.Input('mix', default=1.0, min=0.0, max=1.0, step=0.01, round=0.001), io.String.Input('foreground_fit', default='stretch'), io.String.Input('blend_space', default='linear'), io.Boolean.Input('invert_mask', default=False), io.Mask.Input('mask', tooltip='Optional mask applied to the merged result', display_name='Mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(self, A, B, bypass=False, mode="over", mix=1.0, foreground_fit="stretch", blend_space="linear", invert_mask=False, mask=None, unique_id=None):
|
||||
A = _coerce_media_to_tensor(A, "A")
|
||||
@classmethod
|
||||
def execute(cls, A, B, bypass=False, mode='over', mix=1.0, foreground_fit='stretch', blend_space='linear', invert_mask=False, mask=None, unique_id=None, **kwargs):
|
||||
A = _coerce_media_to_tensor(A, 'A')
|
||||
effect_mask = _prepare_effect_mask(mask, A, invert_mask=invert_mask)
|
||||
output_mask_source = _resolve_mask_output_source(mask, A, invert_mask=invert_mask)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
if _scalar(bypass, bool):
|
||||
output_mask = effect_mask if effect_mask is not None else output_mask_source
|
||||
progress.finish()
|
||||
return build_node_preview_result(A, (A, output_mask), prefix="imageops_merge")
|
||||
return build_node_preview_result(A, (A, output_mask), prefix='imageops_merge')
|
||||
if _all_zero_mix(mix):
|
||||
output_mask = effect_mask if effect_mask is not None else output_mask_source
|
||||
progress.finish()
|
||||
return build_node_preview_result(A, (A, output_mask), prefix="imageops_merge")
|
||||
B = _coerce_media_to_tensor(B, "B")
|
||||
return build_node_preview_result(A, (A, output_mask), prefix='imageops_merge')
|
||||
B = _coerce_media_to_tensor(B, 'B')
|
||||
out = _apply_merge(A, B, mode, mix, foreground_fit, blend_space)
|
||||
out = _apply_mask_to_image(A, out, effect_mask)
|
||||
output_mask = effect_mask if effect_mask is not None else output_mask_source
|
||||
progress.finish()
|
||||
return build_node_preview_result(out, (out, output_mask), prefix="imageops_merge")
|
||||
return build_node_preview_result(out, (out, output_mask), prefix='imageops_merge')
|
||||
+46
-249
@@ -1,63 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import torch
|
||||
|
||||
from ._helpers import _hex_to_rgb01, _scalar
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
_NOISE_BASIS = ["perlin", "value", "white"]
|
||||
_NOISE_FRACTAL_MODES = ["none", "fbm", "turbulence", "ridged"]
|
||||
_NOISE_COMPUTE_DEVICES = ["auto", "cpu", "cuda"]
|
||||
_EPSILON = 1.0e-6
|
||||
|
||||
_NOISE_BASIS = ['perlin', 'value', 'white']
|
||||
_NOISE_FRACTAL_MODES = ['none', 'fbm', 'turbulence', 'ridged']
|
||||
_NOISE_COMPUTE_DEVICES = ['auto', 'cpu', 'cuda']
|
||||
_EPSILON = 1e-06
|
||||
|
||||
def _fade(t: torch.Tensor) -> torch.Tensor:
|
||||
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||
|
||||
|
||||
def _lerp(a: torch.Tensor, b: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def _resolve_noise_device(preference: str = "auto") -> torch.device:
|
||||
normalized = str(preference or "auto").strip().lower()
|
||||
if normalized == "cuda" and not torch.cuda.is_available():
|
||||
return torch.device("cpu")
|
||||
if normalized in ("auto", "cuda") and torch.cuda.is_available():
|
||||
return torch.device("cuda")
|
||||
return torch.device("cpu")
|
||||
|
||||
def _resolve_noise_device(preference: str='auto') -> torch.device:
|
||||
normalized = str(preference or 'auto').strip().lower()
|
||||
if normalized == 'cuda' and (not torch.cuda.is_available()):
|
||||
return torch.device('cpu')
|
||||
if normalized in ('auto', 'cuda') and torch.cuda.is_available():
|
||||
return torch.device('cuda')
|
||||
return torch.device('cpu')
|
||||
|
||||
def _wrap_lattice(index: torch.Tensor, period: int) -> torch.Tensor:
|
||||
if period <= 0:
|
||||
return index
|
||||
return torch.remainder(index, int(period))
|
||||
|
||||
|
||||
def _hash_lattice3(ix, iy, iz, seed: int) -> torch.Tensor:
|
||||
"""Deterministic float hash that stays on the current torch device."""
|
||||
seed_term = float(int(seed) % 104729)
|
||||
value = (
|
||||
ix.to(torch.float32) * 127.1
|
||||
+ iy.to(torch.float32) * 311.7
|
||||
+ iz.to(torch.float32) * 74.7
|
||||
+ seed_term * 37.719
|
||||
)
|
||||
value = ix.to(torch.float32) * 127.1 + iy.to(torch.float32) * 311.7 + iz.to(torch.float32) * 74.7 + seed_term * 37.719
|
||||
hashed = torch.sin(value) * 43758.5453123
|
||||
return hashed - torch.floor(hashed)
|
||||
|
||||
|
||||
def _coordinate_axes(
|
||||
width: int,
|
||||
height: int,
|
||||
scale: float,
|
||||
offset_x: float,
|
||||
offset_y: float,
|
||||
offset_z: float,
|
||||
seamless: bool,
|
||||
device: torch.device,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, int]:
|
||||
def _coordinate_axes(width: int, height: int, scale: float, offset_x: float, offset_y: float, offset_z: float, seamless: bool, device: torch.device) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, int]:
|
||||
safe_scale = max(1.0, float(scale))
|
||||
if seamless:
|
||||
period_x = max(1, int(round(max(1, width) / safe_scale)))
|
||||
@@ -72,20 +50,9 @@ def _coordinate_axes(
|
||||
xs = (torch.arange(width, device=device, dtype=torch.float32) + float(offset_x)) / safe_scale
|
||||
ys = (torch.arange(height, device=device, dtype=torch.float32) + float(offset_y)) / safe_scale
|
||||
z = torch.tensor(float(offset_z) / safe_scale, device=device, dtype=torch.float32)
|
||||
return xs, ys, z, period_x, period_y
|
||||
return (xs, ys, z, period_x, period_y)
|
||||
|
||||
|
||||
def _value_noise(
|
||||
width: int,
|
||||
height: int,
|
||||
scale: float,
|
||||
offset_x: float,
|
||||
offset_y: float,
|
||||
offset_z: float,
|
||||
seed: int,
|
||||
seamless: bool,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
def _value_noise(width: int, height: int, scale: float, offset_x: float, offset_y: float, offset_z: float, seed: int, seamless: bool, device: torch.device) -> torch.Tensor:
|
||||
xs, ys, z, period_x, period_y = _coordinate_axes(width, height, scale, offset_x, offset_y, offset_z, seamless, device)
|
||||
xi = torch.floor(xs).to(torch.int64)
|
||||
yi = torch.floor(ys).to(torch.int64)
|
||||
@@ -93,7 +60,6 @@ def _value_noise(
|
||||
xf = xs - xi.to(torch.float32)
|
||||
yf = ys - yi.to(torch.float32)
|
||||
zf = z - zi.to(torch.float32)
|
||||
|
||||
xi0 = xi.view(1, -1)
|
||||
yi0 = yi.view(-1, 1)
|
||||
ix0 = _wrap_lattice(xi0, period_x)
|
||||
@@ -101,11 +67,9 @@ def _value_noise(
|
||||
iy0 = _wrap_lattice(yi0, period_y)
|
||||
iy1 = _wrap_lattice(yi0 + 1, period_y)
|
||||
zi1 = zi + 1
|
||||
|
||||
u = _fade(xf).view(1, -1)
|
||||
v = _fade(yf).view(-1, 1)
|
||||
w = _fade(zf)
|
||||
|
||||
v000 = _hash_lattice3(ix0, iy0, zi, seed) * 2.0 - 1.0
|
||||
v100 = _hash_lattice3(ix1, iy0, zi, seed) * 2.0 - 1.0
|
||||
v010 = _hash_lattice3(ix0, iy1, zi, seed) * 2.0 - 1.0
|
||||
@@ -114,7 +78,6 @@ def _value_noise(
|
||||
v101 = _hash_lattice3(ix1, iy0, zi1, seed) * 2.0 - 1.0
|
||||
v011 = _hash_lattice3(ix0, iy1, zi1, seed) * 2.0 - 1.0
|
||||
v111 = _hash_lattice3(ix1, iy1, zi1, seed) * 2.0 - 1.0
|
||||
|
||||
x00 = _lerp(v000, v100, u)
|
||||
x10 = _lerp(v010, v110, u)
|
||||
x01 = _lerp(v001, v101, u)
|
||||
@@ -122,48 +85,24 @@ def _value_noise(
|
||||
y0 = _lerp(x00, x10, v)
|
||||
y1 = _lerp(x01, x11, v)
|
||||
return _lerp(y0, y1, w).clamp(-1.0, 1.0)
|
||||
|
||||
|
||||
_GRAD3_GX = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, -1.0, 0.0, 0.0]
|
||||
_GRAD3_GY = [1.0, 1.0, -1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0]
|
||||
_GRAD3_GZ = [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 0.0, 0.0, 1.0, -1.0]
|
||||
|
||||
# Cache gradient tables per device to avoid repeated tensor creation in the hot path.
|
||||
_GRAD3_GX = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, -1.0, 0.0, 0.0]
|
||||
_GRAD3_GY = [1.0, 1.0, -1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0]
|
||||
_GRAD3_GZ = [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 0.0, 0.0, 1.0, -1.0]
|
||||
_GRAD3_CACHE: dict = {}
|
||||
|
||||
|
||||
def _get_grad3_tensors(device: torch.device):
|
||||
key = str(device)
|
||||
if key not in _GRAD3_CACHE:
|
||||
_GRAD3_CACHE[key] = (
|
||||
torch.tensor(_GRAD3_GX, dtype=torch.float32, device=device),
|
||||
torch.tensor(_GRAD3_GY, dtype=torch.float32, device=device),
|
||||
torch.tensor(_GRAD3_GZ, dtype=torch.float32, device=device),
|
||||
)
|
||||
_GRAD3_CACHE[key] = (torch.tensor(_GRAD3_GX, dtype=torch.float32, device=device), torch.tensor(_GRAD3_GY, dtype=torch.float32, device=device), torch.tensor(_GRAD3_GZ, dtype=torch.float32, device=device))
|
||||
return _GRAD3_CACHE[key]
|
||||
|
||||
|
||||
def _gradient_dot3(ix, iy, iz, dx, dy, dz, seed: int) -> torch.Tensor:
|
||||
# Single hash call + table lookup replaces 2× _hash_lattice3 + cos/sin/sqrt.
|
||||
# Eliminates ~3 vectorised trig/sqrt ops per lattice corner; with 8 corners ×
|
||||
# 5 octaves that is ~120 fewer tensor ops per Perlin frame.
|
||||
h = _hash_lattice3(ix, iy, iz, seed)
|
||||
gi = (h * 16.0).long().clamp_(0, 15)
|
||||
gx_t, gy_t, gz_t = _get_grad3_tensors(h.device)
|
||||
return gx_t[gi] * dx + gy_t[gi] * dy + gz_t[gi] * dz
|
||||
|
||||
|
||||
def _perlin_noise(
|
||||
width: int,
|
||||
height: int,
|
||||
scale: float,
|
||||
offset_x: float,
|
||||
offset_y: float,
|
||||
offset_z: float,
|
||||
seed: int,
|
||||
seamless: bool,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
def _perlin_noise(width: int, height: int, scale: float, offset_x: float, offset_y: float, offset_z: float, seed: int, seamless: bool, device: torch.device) -> torch.Tensor:
|
||||
xs, ys, z, period_x, period_y = _coordinate_axes(width, height, scale, offset_x, offset_y, offset_z, seamless, device)
|
||||
xi = torch.floor(xs).to(torch.int64)
|
||||
yi = torch.floor(ys).to(torch.int64)
|
||||
@@ -171,7 +110,6 @@ def _perlin_noise(
|
||||
xf = xs - xi.to(torch.float32)
|
||||
yf = ys - yi.to(torch.float32)
|
||||
zf = z - zi.to(torch.float32)
|
||||
|
||||
xi0 = xi.view(1, -1)
|
||||
yi0 = yi.view(-1, 1)
|
||||
ix0 = _wrap_lattice(xi0, period_x)
|
||||
@@ -179,14 +117,12 @@ def _perlin_noise(
|
||||
iy0 = _wrap_lattice(yi0, period_y)
|
||||
iy1 = _wrap_lattice(yi0 + 1, period_y)
|
||||
zi1 = zi + 1
|
||||
|
||||
dx0 = xf.view(1, -1)
|
||||
dy0 = yf.view(-1, 1)
|
||||
dz0 = zf
|
||||
dx1 = dx0 - 1.0
|
||||
dy1 = dy0 - 1.0
|
||||
dz1 = zf - 1.0
|
||||
|
||||
n000 = _gradient_dot3(ix0, iy0, zi, dx0, dy0, dz0, seed)
|
||||
n100 = _gradient_dot3(ix1, iy0, zi, dx1, dy0, dz0, seed)
|
||||
n010 = _gradient_dot3(ix0, iy1, zi, dx0, dy1, dz0, seed)
|
||||
@@ -195,11 +131,9 @@ def _perlin_noise(
|
||||
n101 = _gradient_dot3(ix1, iy0, zi1, dx1, dy0, dz1, seed)
|
||||
n011 = _gradient_dot3(ix0, iy1, zi1, dx0, dy1, dz1, seed)
|
||||
n111 = _gradient_dot3(ix1, iy1, zi1, dx1, dy1, dz1, seed)
|
||||
|
||||
u = _fade(xf).view(1, -1)
|
||||
v = _fade(yf).view(-1, 1)
|
||||
w = _fade(zf)
|
||||
|
||||
x00 = _lerp(n000, n100, u)
|
||||
x10 = _lerp(n010, n110, u)
|
||||
x01 = _lerp(n001, n101, u)
|
||||
@@ -208,17 +142,7 @@ def _perlin_noise(
|
||||
y1 = _lerp(x01, x11, v)
|
||||
return (_lerp(y0, y1, w) * 1.15470053838).clamp(-1.0, 1.0)
|
||||
|
||||
|
||||
def _white_noise(
|
||||
width: int,
|
||||
height: int,
|
||||
offset_x: float,
|
||||
offset_y: float,
|
||||
offset_z: float,
|
||||
seed: int,
|
||||
seamless: bool,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
def _white_noise(width: int, height: int, offset_x: float, offset_y: float, offset_z: float, seed: int, seamless: bool, device: torch.device) -> torch.Tensor:
|
||||
xs = torch.arange(width, device=device, dtype=torch.float32) + float(offset_x)
|
||||
ys = torch.arange(height, device=device, dtype=torch.float32) + float(offset_y)
|
||||
z = torch.tensor(float(offset_z), device=device, dtype=torch.float32)
|
||||
@@ -234,87 +158,52 @@ def _white_noise(
|
||||
n1 = _hash_lattice3(ix, iy, zi + 1, seed) * 2.0 - 1.0
|
||||
return _lerp(n0, n1, _fade(zf)).clamp(-1.0, 1.0)
|
||||
|
||||
|
||||
def _basis_noise(
|
||||
basis: str,
|
||||
width: int,
|
||||
height: int,
|
||||
scale: float,
|
||||
offset_x: float,
|
||||
offset_y: float,
|
||||
offset_z: float,
|
||||
seed: int,
|
||||
seamless: bool,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
normalized = str(basis or "perlin").strip().lower()
|
||||
if normalized == "value":
|
||||
def _basis_noise(basis: str, width: int, height: int, scale: float, offset_x: float, offset_y: float, offset_z: float, seed: int, seamless: bool, device: torch.device) -> torch.Tensor:
|
||||
normalized = str(basis or 'perlin').strip().lower()
|
||||
if normalized == 'value':
|
||||
return _value_noise(width, height, scale, offset_x, offset_y, offset_z, seed, seamless, device)
|
||||
if normalized == "white":
|
||||
if normalized == 'white':
|
||||
return _white_noise(width, height, offset_x, offset_y, offset_z, seed, seamless, device)
|
||||
return _perlin_noise(width, height, scale, offset_x, offset_y, offset_z, seed, seamless, device)
|
||||
|
||||
|
||||
def _synthesize_noise(
|
||||
basis: str,
|
||||
fractal_mode: str,
|
||||
width: int,
|
||||
height: int,
|
||||
scale: float,
|
||||
octaves: int,
|
||||
lacunarity: float,
|
||||
gain: float,
|
||||
offset_x: float,
|
||||
offset_y: float,
|
||||
offset_z: float,
|
||||
seed: int,
|
||||
seamless: bool,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
normalized_mode = str(fractal_mode or "none").strip().lower()
|
||||
if normalized_mode == "none":
|
||||
def _synthesize_noise(basis: str, fractal_mode: str, width: int, height: int, scale: float, octaves: int, lacunarity: float, gain: float, offset_x: float, offset_y: float, offset_z: float, seed: int, seamless: bool, device: torch.device) -> torch.Tensor:
|
||||
normalized_mode = str(fractal_mode or 'none').strip().lower()
|
||||
if normalized_mode == 'none':
|
||||
signed = _basis_noise(basis, width, height, scale, offset_x, offset_y, offset_z, seed, seamless, device)
|
||||
return ((signed * 0.5) + 0.5).clamp(0.0, 1.0)
|
||||
|
||||
return (signed * 0.5 + 0.5).clamp(0.0, 1.0)
|
||||
total = torch.zeros((height, width), device=device, dtype=torch.float32)
|
||||
amplitude = 1.0
|
||||
amplitude_sum = 0.0
|
||||
current_scale = max(1.0, float(scale))
|
||||
safe_lacunarity = max(1.01, float(lacunarity))
|
||||
safe_gain = max(0.0, float(gain))
|
||||
|
||||
for octave in range(max(1, int(octaves))):
|
||||
octave_seed = int(seed) + octave * 10007
|
||||
signed = _basis_noise(basis, width, height, current_scale, offset_x, offset_y, offset_z, octave_seed, seamless, device)
|
||||
if normalized_mode == "turbulence":
|
||||
if normalized_mode == 'turbulence':
|
||||
contribution = signed.abs()
|
||||
elif normalized_mode == "ridged":
|
||||
elif normalized_mode == 'ridged':
|
||||
contribution = 1.0 - signed.abs()
|
||||
else:
|
||||
contribution = signed
|
||||
|
||||
total = total + contribution * amplitude
|
||||
amplitude_sum += amplitude
|
||||
amplitude *= safe_gain
|
||||
current_scale = max(1.0, current_scale / safe_lacunarity)
|
||||
|
||||
if amplitude_sum <= _EPSILON:
|
||||
return torch.zeros((height, width), device=device, dtype=torch.float32)
|
||||
|
||||
total = total / amplitude_sum
|
||||
if normalized_mode == "fbm":
|
||||
return ((total * 0.5) + 0.5).clamp(0.0, 1.0)
|
||||
if normalized_mode == 'fbm':
|
||||
return (total * 0.5 + 0.5).clamp(0.0, 1.0)
|
||||
return total.clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _apply_tone(gray: torch.Tensor, contrast: float, invert: bool) -> torch.Tensor:
|
||||
toned = ((gray - 0.5) * float(contrast)) + 0.5
|
||||
toned = (gray - 0.5) * float(contrast) + 0.5
|
||||
toned = toned.clamp(0.0, 1.0)
|
||||
if invert:
|
||||
toned = 1.0 - toned
|
||||
return toned.clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _normalize_gray(gray: torch.Tensor) -> torch.Tensor:
|
||||
min_v = gray.amin()
|
||||
max_v = gray.amax()
|
||||
@@ -322,131 +211,39 @@ def _normalize_gray(gray: torch.Tensor) -> torch.Tensor:
|
||||
normalized = ((gray - min_v) / gray_range.clamp_min(_EPSILON)).clamp(0.0, 1.0)
|
||||
return torch.where(gray_range <= _EPSILON, torch.zeros_like(gray), normalized)
|
||||
|
||||
|
||||
def _colorize(gray: torch.Tensor, low_color: str, high_color: str) -> torch.Tensor:
|
||||
low = torch.tensor(_hex_to_rgb01(low_color), device=gray.device, dtype=gray.dtype).view(1, 1, 3)
|
||||
high = torch.tensor(_hex_to_rgb01(high_color), device=gray.device, dtype=gray.dtype).view(1, 1, 3)
|
||||
return low + gray.unsqueeze(-1) * (high - low)
|
||||
|
||||
|
||||
class ImageOpsNoise:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "generate"
|
||||
class ImageOpsNoise(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 64}),
|
||||
"height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 64}),
|
||||
"frame_length": ("INT", {"default": 1, "min": 1, "max": 256, "step": 1, "tooltip": "Number of frames to generate."}),
|
||||
"fps": ("FLOAT", {"default": 12.0, "min": 1.0, "max": 120.0, "step": 0.1, "round": 0.001}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}),
|
||||
"basis": (_NOISE_BASIS, {"default": "perlin"}),
|
||||
"fractal_mode": (_NOISE_FRACTAL_MODES, {"default": "fbm"}),
|
||||
"scale": ("FLOAT", {"default": 160.0, "min": 1.0, "max": 4096.0, "step": 1.0, "round": 0.01}),
|
||||
"octaves": ("INT", {"default": 5, "min": 1, "max": 12, "step": 1}),
|
||||
"gain": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "round": 0.001}),
|
||||
"offset_z": ("FLOAT", {"default": 0.0, "min": -65536.0, "max": 65536.0, "step": 1.0, "round": 0.01, "tooltip": "Static Z offset into the noise field."}),
|
||||
"animation_speed": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 512.0, "step": 0.1, "round": 0.01, "tooltip": "Z offset added per frame. Higher = faster animation. 0 = still image."}),
|
||||
"seamless": ("BOOLEAN", {"default": False, "tooltip": "Wrap X/Y lattice so the texture tiles seamlessly."}),
|
||||
"contrast": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.01, "round": 0.001}),
|
||||
"invert": ("BOOLEAN", {"default": False}),
|
||||
"low_color": ("COLOR", {"default": "#FFFFFF"}),
|
||||
"high_color": ("COLOR", {"default": "#FFFFFF"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsNoise', display_name='〽️ Image Ops Noise', category='image/imageops', inputs=[io.Int.Input('width', default=1024, min=64, max=8192, step=64), io.Int.Input('height', default=1024, min=64, max=8192, step=64), io.Int.Input('frame_length', default=1, min=1, max=256, step=1, tooltip='Number of frames to generate.'), io.Float.Input('fps', default=12.0, min=1.0, max=120.0, step=0.1, round=0.001), io.Int.Input('seed', default=0, min=0, max=2147483647, step=1), io.String.Input('basis', default='perlin'), io.String.Input('fractal_mode', default='fbm'), io.Float.Input('scale', default=160.0, min=1.0, max=4096.0, step=1.0, round=0.01), io.Int.Input('octaves', default=5, min=1, max=12, step=1), io.Float.Input('gain', default=0.5, min=0.0, max=1.0, step=0.01, round=0.001), io.Float.Input('offset_z', default=0.0, min=-65536.0, max=65536.0, step=1.0, round=0.01, tooltip='Static Z offset into the noise field.'), io.Float.Input('animation_speed', default=1.0, min=0.0, max=512.0, step=0.1, round=0.01, tooltip='Z offset added per frame. Higher = faster animation. 0 = still image.'), io.Boolean.Input('seamless', default=False, tooltip='Wrap X/Y lattice so the texture tiles seamlessly.'), io.Float.Input('contrast', default=1.0, min=0.0, max=4.0, step=0.01, round=0.001), io.Boolean.Input('invert', default=False), io.Color.Input('low_color', default='#FFFFFF'), io.Color.Input('high_color', default='#FFFFFF')], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def generate(
|
||||
self,
|
||||
width=1024,
|
||||
height=1024,
|
||||
frame_length=1,
|
||||
fps=12.0,
|
||||
seed=0,
|
||||
basis="perlin",
|
||||
fractal_mode="fbm",
|
||||
scale=160.0,
|
||||
octaves=5,
|
||||
gain=0.5,
|
||||
offset_z=0.0,
|
||||
animation_speed=1.0,
|
||||
seamless=False,
|
||||
contrast=1.0,
|
||||
invert=False,
|
||||
low_color="#FFFFFF",
|
||||
high_color="#FFFFFF",
|
||||
# legacy / compat params (ignored if present in old workflows)
|
||||
batch_size=None,
|
||||
seed_step=None,
|
||||
lacunarity=None,
|
||||
offset_x=None,
|
||||
offset_y=None,
|
||||
frame_offset_x=None,
|
||||
frame_offset_y=None,
|
||||
frame_offset_z=None,
|
||||
compute_device=None,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, width=1024, height=1024, frame_length=1, fps=12.0, seed=0, basis='perlin', fractal_mode='fbm', scale=160.0, octaves=5, gain=0.5, offset_z=0.0, animation_speed=1.0, seamless=False, contrast=1.0, invert=False, low_color='#FFFFFF', high_color='#FFFFFF', batch_size=None, seed_step=None, lacunarity=None, offset_x=None, offset_y=None, frame_offset_x=None, frame_offset_y=None, frame_offset_z=None, compute_device=None, unique_id=None, **kwargs):
|
||||
target_w = max(1, _scalar(width, int))
|
||||
target_h = max(1, _scalar(height, int))
|
||||
frame_count = max(1, _scalar(frame_length, int))
|
||||
preview_fps = max(1.0, _scalar(fps, float))
|
||||
device = _resolve_noise_device(_scalar(compute_device, str) if compute_device is not None else "auto")
|
||||
|
||||
# Memory preflight: prevent absurd allocations before starting generation.
|
||||
device = _resolve_noise_device(_scalar(compute_device, str) if compute_device is not None else 'auto')
|
||||
from .core.memory import check_budget
|
||||
check_budget(
|
||||
frame_count, target_h, target_w, 3,
|
||||
multiplier=2.0,
|
||||
label="ImageOps Noise",
|
||||
)
|
||||
|
||||
check_budget(frame_count, target_h, target_w, 3, multiplier=2.0, label='ImageOps Noise')
|
||||
progress = start_progress(total=frame_count, unique_id=unique_id)
|
||||
anim_speed = _scalar(animation_speed, float) if frame_offset_z is None else _scalar(frame_offset_z, float)
|
||||
|
||||
masks = []
|
||||
images = []
|
||||
for frame_index in range(frame_count):
|
||||
frame_seed = _scalar(seed, int, index=frame_index)
|
||||
frame_gray = _synthesize_noise(
|
||||
basis=_scalar(basis, str, index=frame_index),
|
||||
fractal_mode=_scalar(fractal_mode, str, index=frame_index),
|
||||
width=target_w,
|
||||
height=target_h,
|
||||
scale=_scalar(scale, float, index=frame_index),
|
||||
octaves=_scalar(octaves, int, index=frame_index),
|
||||
lacunarity=2.0,
|
||||
gain=_scalar(gain, float, index=frame_index),
|
||||
offset_x=0.0,
|
||||
offset_y=0.0,
|
||||
offset_z=_scalar(offset_z, float, index=frame_index) + frame_index * anim_speed,
|
||||
seed=frame_seed,
|
||||
seamless=_scalar(seamless, bool, index=frame_index),
|
||||
device=device,
|
||||
)
|
||||
frame_gray = _synthesize_noise(basis=_scalar(basis, str, index=frame_index), fractal_mode=_scalar(fractal_mode, str, index=frame_index), width=target_w, height=target_h, scale=_scalar(scale, float, index=frame_index), octaves=_scalar(octaves, int, index=frame_index), lacunarity=2.0, gain=_scalar(gain, float, index=frame_index), offset_x=0.0, offset_y=0.0, offset_z=_scalar(offset_z, float, index=frame_index) + frame_index * anim_speed, seed=frame_seed, seamless=_scalar(seamless, bool, index=frame_index), device=device)
|
||||
frame_gray = _normalize_gray(frame_gray)
|
||||
frame_gray = _apply_tone(
|
||||
frame_gray,
|
||||
contrast=_scalar(contrast, float, index=frame_index),
|
||||
invert=_scalar(invert, bool, index=frame_index),
|
||||
)
|
||||
frame_gray = _apply_tone(frame_gray, contrast=_scalar(contrast, float, index=frame_index), invert=_scalar(invert, bool, index=frame_index))
|
||||
masks.append(frame_gray.unsqueeze(0).clamp(0.0, 1.0).cpu())
|
||||
images.append(
|
||||
_colorize(
|
||||
frame_gray,
|
||||
low_color=_scalar(low_color, str, index=frame_index),
|
||||
high_color=_scalar(high_color, str, index=frame_index),
|
||||
).unsqueeze(0).cpu()
|
||||
)
|
||||
images.append(_colorize(frame_gray, low_color=_scalar(low_color, str, index=frame_index), high_color=_scalar(high_color, str, index=frame_index)).unsqueeze(0).cpu())
|
||||
progress.update()
|
||||
|
||||
mask = torch.cat(masks, dim=0)
|
||||
image = torch.cat(images, dim=0)
|
||||
progress.finish()
|
||||
return build_node_preview_result(image, (image, mask), prefix="imageops_noise", fps=preview_fps)
|
||||
return build_node_preview_result(image, (image, mask), prefix='imageops_noise', fps=preview_fps)
|
||||
+35
-138
@@ -1,56 +1,33 @@
|
||||
from comfy_api.latest import io
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _apply_blur, _hex_to_rgb01, _resize, _scalar, _select_media_tensor
|
||||
from ._preview import build_node_preview_result
|
||||
from ._progress import start_progress
|
||||
_PADOUT_FILL_MODES = ['constant', 'edge_extend', 'reflect', 'blurry']
|
||||
_PADOUT_TARGET_FORMATS = ['custom', '1:1', '16:9', '9:16', '4:3', '3:4']
|
||||
_TARGET_RATIOS = {'1:1': (1, 1), 'square': (1, 1), 'nearest_square': (1, 1), '16:9': (16, 9), '9:16': (9, 16), '4:3': (4, 3), '3:4': (3, 4)}
|
||||
|
||||
_PADOUT_FILL_MODES = ["constant", "edge_extend", "reflect", "blurry"]
|
||||
_PADOUT_TARGET_FORMATS = ["custom", "1:1", "16:9", "9:16", "4:3", "3:4"]
|
||||
_TARGET_RATIOS = {
|
||||
"1:1": (1, 1),
|
||||
"square": (1, 1),
|
||||
"nearest_square": (1, 1),
|
||||
"16:9": (16, 9),
|
||||
"9:16": (9, 16),
|
||||
"4:3": (4, 3),
|
||||
"3:4": (3, 4),
|
||||
}
|
||||
|
||||
|
||||
def _build_padout_mask(
|
||||
batch: int,
|
||||
height: int,
|
||||
width: int,
|
||||
top: int,
|
||||
left: int,
|
||||
source_h: int,
|
||||
source_w: int,
|
||||
device,
|
||||
dtype,
|
||||
) -> torch.Tensor:
|
||||
def _build_padout_mask(batch: int, height: int, width: int, top: int, left: int, source_h: int, source_w: int, device, dtype) -> torch.Tensor:
|
||||
mask = torch.ones((batch, height, width), device=device, dtype=dtype)
|
||||
mask[:, top:top + source_h, left:left + source_w] = 0.0
|
||||
return mask
|
||||
|
||||
|
||||
def _normalize_fill_mode(value: str) -> str:
|
||||
normalized = str(value or "constant").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if normalized in ("edge", "edge_extend", "replicate", "extend"):
|
||||
return "edge_extend"
|
||||
if normalized in ("blur", "blurry", "blurred"):
|
||||
return "blurry"
|
||||
if normalized in ("reflect", "reflection", "mirror"):
|
||||
return "reflect"
|
||||
return "constant"
|
||||
|
||||
normalized = str(value or 'constant').strip().lower().replace('-', '_').replace(' ', '_')
|
||||
if normalized in ('edge', 'edge_extend', 'replicate', 'extend'):
|
||||
return 'edge_extend'
|
||||
if normalized in ('blur', 'blurry', 'blurred'):
|
||||
return 'blurry'
|
||||
if normalized in ('reflect', 'reflection', 'mirror'):
|
||||
return 'reflect'
|
||||
return 'constant'
|
||||
|
||||
def _resolve_target_padding(source_w: int, source_h: int, left: int, top: int, right: int, bottom: int, target_format: str) -> tuple[int, int, int, int]:
|
||||
normalized = str(target_format or "custom").strip().lower().replace(" ", "_")
|
||||
normalized = str(target_format or 'custom').strip().lower().replace(' ', '_')
|
||||
ratio = _TARGET_RATIOS.get(normalized)
|
||||
if ratio is None:
|
||||
return left, top, right, bottom
|
||||
|
||||
return (left, top, right, bottom)
|
||||
out_w = max(1, int(source_w) + int(left) + int(right))
|
||||
out_h = max(1, int(source_h) + int(top) + int(bottom))
|
||||
ratio_w, ratio_h = ratio
|
||||
@@ -60,18 +37,11 @@ def _resolve_target_padding(source_w: int, source_h: int, left: int, top: int, r
|
||||
target_w = int((out_h * ratio_w + ratio_h - 1) // ratio_h)
|
||||
elif out_w * ratio_h > out_h * ratio_w:
|
||||
target_h = int((out_w * ratio_h + ratio_w - 1) // ratio_w)
|
||||
|
||||
extra_w = max(0, target_w - out_w)
|
||||
extra_h = max(0, target_h - out_h)
|
||||
extra_left = extra_w // 2
|
||||
extra_top = extra_h // 2
|
||||
return (
|
||||
int(left) + extra_left,
|
||||
int(top) + extra_top,
|
||||
int(right) + (extra_w - extra_left),
|
||||
int(bottom) + (extra_h - extra_top),
|
||||
)
|
||||
|
||||
return (int(left) + extra_left, int(top) + extra_top, int(right) + (extra_w - extra_left), int(bottom) + (extra_h - extra_top))
|
||||
|
||||
def _make_constant_pad(source: torch.Tensor, out_h: int, out_w: int, fill_color: str) -> torch.Tensor:
|
||||
batch, _, _, channels = source.shape
|
||||
@@ -87,9 +57,8 @@ def _make_constant_pad(source: torch.Tensor, out_h: int, out_w: int, fill_color:
|
||||
out[..., 3] = 1.0
|
||||
return out
|
||||
|
||||
|
||||
def _make_blurry_pad(source: torch.Tensor, out_h: int, out_w: int, blur_radius: int) -> torch.Tensor:
|
||||
background = _resize(source, out_w, out_h, mode="bicubic", antialias=True)
|
||||
background = _resize(source, out_w, out_h, mode='bicubic', antialias=True)
|
||||
radius = int(max(0, blur_radius))
|
||||
if radius > 0:
|
||||
background = _apply_blur(background, radius, max(1.0, radius / 3.0))
|
||||
@@ -98,129 +67,57 @@ def _make_blurry_pad(source: torch.Tensor, out_h: int, out_w: int, blur_radius:
|
||||
background[..., 3] = 1.0
|
||||
return background.clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _make_edge_pad(source: torch.Tensor, left: int, top: int, right: int, bottom: int, mode: str) -> torch.Tensor:
|
||||
if left == 0 and right == 0 and top == 0 and bottom == 0:
|
||||
if left == 0 and right == 0 and (top == 0) and (bottom == 0):
|
||||
return source.clone()
|
||||
pad_mode = "reflect" if mode == "reflect" else "replicate"
|
||||
pad_mode = 'reflect' if mode == 'reflect' else 'replicate'
|
||||
x = source.permute(0, 3, 1, 2).contiguous()
|
||||
if pad_mode == "reflect" and (
|
||||
left >= source.shape[2]
|
||||
or right >= source.shape[2]
|
||||
or top >= source.shape[1]
|
||||
or bottom >= source.shape[1]
|
||||
):
|
||||
pad_mode = "replicate"
|
||||
if pad_mode == 'reflect' and (left >= source.shape[2] or right >= source.shape[2] or top >= source.shape[1] or (bottom >= source.shape[1])):
|
||||
pad_mode = 'replicate'
|
||||
out = F.pad(x, (left, right, top, bottom), mode=pad_mode)
|
||||
return out.permute(0, 2, 3, 1).contiguous().clamp(0.0, 1.0)
|
||||
|
||||
|
||||
class ImageOpsPadOut:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT")
|
||||
RETURN_NAMES = ("image", "mask", "width", "height")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsPadOut(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"pad_left": ("INT", {"default": 128, "min": 0, "max": 4096, "step": 1}),
|
||||
"pad_top": ("INT", {"default": 128, "min": 0, "max": 4096, "step": 1}),
|
||||
"pad_right": ("INT", {"default": 128, "min": 0, "max": 4096, "step": 1}),
|
||||
"pad_bottom": ("INT", {"default": 128, "min": 0, "max": 4096, "step": 1}),
|
||||
"target_format": (_PADOUT_TARGET_FORMATS, {"default": "custom", "tooltip": "Add extra padding to hit a target aspect ratio without scaling the image."}),
|
||||
"fill_mode": (_PADOUT_FILL_MODES, {"default": "constant"}),
|
||||
"fill_color": ("COLOR", {"default": "#000000"}),
|
||||
"blur_radius": ("INT", {"default": 32, "min": 0, "max": 512, "step": 1, "tooltip": "Used by blurry padding."}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False, "tooltip": "By default the output mask marks the *padded* area as 1 and the original source as 0. Toggle to invert (source=1, padding=0)."}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsPadOut', display_name='〽️ Image Ops Pad Out', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.Int.Input('pad_left', default=128, min=0, max=4096, step=1), io.Int.Input('pad_top', default=128, min=0, max=4096, step=1), io.Int.Input('pad_right', default=128, min=0, max=4096, step=1), io.Int.Input('pad_bottom', default=128, min=0, max=4096, step=1), io.String.Input('target_format', default='custom', tooltip='Add extra padding to hit a target aspect ratio without scaling the image.'), io.String.Input('fill_mode', default='constant'), io.Color.Input('fill_color', default='#000000'), io.Int.Input('blur_radius', default=32, min=0, max=512, step=1, tooltip='Used by blurry padding.'), io.Boolean.Input('invert_mask', default=False, tooltip='By default the output mask marks the *padded* area as 1 and the original source as 0. Toggle to invert (source=1, padding=0).'), io.String.Input('image', tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', force_input=True, display_name='Images/Video', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask'), io.Int.Output('width', display_name='width'), io.Int.Output('height', display_name='height')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(
|
||||
self,
|
||||
image=None,
|
||||
bypass=False,
|
||||
pad_left=128,
|
||||
pad_top=128,
|
||||
pad_right=128,
|
||||
pad_bottom=128,
|
||||
target_format="custom",
|
||||
fill_mode="constant",
|
||||
fill_color="#000000",
|
||||
blur_radius=32,
|
||||
invert_mask=False,
|
||||
video=None,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, pad_left=128, pad_top=128, pad_right=128, pad_bottom=128, target_format='custom', fill_mode='constant', fill_color='#000000', blur_radius=32, invert_mask=False, video=None, unique_id=None, **kwargs):
|
||||
source = _select_media_tensor(image, video).float().clamp(0.0, 1.0)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
|
||||
batch = int(source.shape[0])
|
||||
source_h = int(source.shape[1])
|
||||
source_w = int(source.shape[2])
|
||||
|
||||
if _scalar(bypass, bool):
|
||||
mask = torch.zeros((batch, source_h, source_w), device=source.device, dtype=source.dtype)
|
||||
if _scalar(invert_mask, bool):
|
||||
mask = 1.0 - mask
|
||||
progress.finish()
|
||||
meta = {"imageops_padout_source": {"source_w": source_w, "source_h": source_h, "pad_left": 0, "pad_top": 0}}
|
||||
return build_node_preview_result(source, (source, mask, source_w, source_h), prefix="imageops_padout", metadata=meta)
|
||||
|
||||
meta = {'imageops_padout_source': {'source_w': source_w, 'source_h': source_h, 'pad_left': 0, 'pad_top': 0}}
|
||||
return build_node_preview_result(source, (source, mask, source_w, source_h), prefix='imageops_padout', metadata=meta)
|
||||
left = max(0, _scalar(pad_left, int))
|
||||
top = max(0, _scalar(pad_top, int))
|
||||
right = max(0, _scalar(pad_right, int))
|
||||
bottom = max(0, _scalar(pad_bottom, int))
|
||||
left, top, right, bottom = _resolve_target_padding(
|
||||
source_w,
|
||||
source_h,
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
_scalar(target_format, str),
|
||||
)
|
||||
|
||||
left, top, right, bottom = _resolve_target_padding(source_w, source_h, left, top, right, bottom, _scalar(target_format, str))
|
||||
out_w = source_w + left + right
|
||||
out_h = source_h + top + bottom
|
||||
|
||||
from .core.memory import check_budget
|
||||
check_budget(batch, out_h, out_w, int(source.shape[-1]), multiplier=2.0, label="ImageOps PadOut")
|
||||
|
||||
check_budget(batch, out_h, out_w, int(source.shape[-1]), multiplier=2.0, label='ImageOps PadOut')
|
||||
mode = _normalize_fill_mode(_scalar(fill_mode, str))
|
||||
if mode in ("edge_extend", "reflect"):
|
||||
if mode in ('edge_extend', 'reflect'):
|
||||
out = _make_edge_pad(source, left, top, right, bottom, mode)
|
||||
elif mode == "blurry":
|
||||
elif mode == 'blurry':
|
||||
out = _make_blurry_pad(source, out_h, out_w, _scalar(blur_radius, int))
|
||||
else:
|
||||
out = _make_constant_pad(source, out_h, out_w, _scalar(fill_color, str))
|
||||
|
||||
out[:, top:top + source_h, left:left + source_w, :] = source
|
||||
|
||||
stitch_mask = _build_padout_mask(
|
||||
batch=batch,
|
||||
height=out_h,
|
||||
width=out_w,
|
||||
top=top,
|
||||
left=left,
|
||||
source_h=source_h,
|
||||
source_w=source_w,
|
||||
device=source.device,
|
||||
dtype=source.dtype,
|
||||
).clamp(0.0, 1.0)
|
||||
|
||||
stitch_mask = _build_padout_mask(batch=batch, height=out_h, width=out_w, top=top, left=left, source_h=source_h, source_w=source_w, device=source.device, dtype=source.dtype).clamp(0.0, 1.0)
|
||||
mask = stitch_mask
|
||||
if _scalar(invert_mask, bool):
|
||||
mask = 1.0 - mask
|
||||
|
||||
progress.finish()
|
||||
meta = {"imageops_padout_source": {"source_w": source_w, "source_h": source_h, "pad_left": left, "pad_top": top}}
|
||||
return build_node_preview_result(out, (out, mask.clamp(0.0, 1.0), out_w, out_h), prefix="imageops_padout", metadata=meta)
|
||||
meta = {'imageops_padout_source': {'source_w': source_w, 'source_h': source_h, 'pad_left': left, 'pad_top': top}}
|
||||
return build_node_preview_result(out, (out, mask.clamp(0.0, 1.0), out_w, out_h), prefix='imageops_padout', metadata=meta)
|
||||
+43
-101
@@ -1,89 +1,61 @@
|
||||
from comfy_api.latest import io
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
import folder_paths
|
||||
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _alpha_mask_from_image, _coerce_mask_tensor, _mask_to_preview_image, _scalar, _select_media_tensor, _tensor_batch_to_pil_list, logger
|
||||
from ._progress import start_progress
|
||||
|
||||
|
||||
def _ensure_dir(p: str):
|
||||
os.makedirs(p, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def save_temp_images(images, prefix="imageops", ext="png", quality=95):
|
||||
def save_temp_images(images, prefix='imageops', ext='png', quality=95):
|
||||
temp_dir = _ensure_dir(folder_paths.get_temp_directory())
|
||||
subfolder = ""
|
||||
subfolder = ''
|
||||
pil_list = _tensor_batch_to_pil_list(images)
|
||||
|
||||
ui_items = []
|
||||
for idx, img in enumerate(pil_list):
|
||||
name = f"{prefix}_{uuid.uuid4().hex[:10]}_{idx:03d}.{ext}"
|
||||
name = f'{prefix}_{uuid.uuid4().hex[:10]}_{idx:03d}.{ext}'
|
||||
out_path = os.path.join(temp_dir, name)
|
||||
try:
|
||||
if ext.lower() in ("jpg", "jpeg"):
|
||||
img.convert("RGB").save(out_path, quality=_scalar(quality, int), optimize=True)
|
||||
elif ext.lower() == "webp":
|
||||
if ext.lower() in ('jpg', 'jpeg'):
|
||||
img.convert('RGB').save(out_path, quality=_scalar(quality, int), optimize=True)
|
||||
elif ext.lower() == 'webp':
|
||||
img.save(out_path, quality=_scalar(quality, int), method=6)
|
||||
else:
|
||||
img.save(out_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save temp image '{out_path}': {e}")
|
||||
continue
|
||||
|
||||
ui_items.append({"filename": name, "subfolder": subfolder, "type": "temp"})
|
||||
ui_items.append({'filename': name, 'subfolder': subfolder, 'type': 'temp'})
|
||||
return ui_items
|
||||
|
||||
|
||||
def save_temp_animated(images, prefix="imageops_anim", ext="webp", fps=12, quality=80):
|
||||
def save_temp_animated(images, prefix='imageops_anim', ext='webp', fps=12, quality=80):
|
||||
temp_dir = _ensure_dir(folder_paths.get_temp_directory())
|
||||
pil_list = _tensor_batch_to_pil_list(images)
|
||||
if not pil_list:
|
||||
return None
|
||||
|
||||
name = f"{prefix}_{uuid.uuid4().hex[:10]}.{ext}"
|
||||
name = f'{prefix}_{uuid.uuid4().hex[:10]}.{ext}'
|
||||
out_path = os.path.join(temp_dir, name)
|
||||
duration_ms = int(max(1, round(1000.0 / max(1.0, _scalar(fps)))))
|
||||
|
||||
try:
|
||||
if ext.lower() == "gif":
|
||||
pil_list[0].save(
|
||||
out_path,
|
||||
save_all=True,
|
||||
append_images=pil_list[1:],
|
||||
duration=duration_ms,
|
||||
loop=0,
|
||||
optimize=True,
|
||||
)
|
||||
if ext.lower() == 'gif':
|
||||
pil_list[0].save(out_path, save_all=True, append_images=pil_list[1:], duration=duration_ms, loop=0, optimize=True)
|
||||
else:
|
||||
pil_list[0].save(
|
||||
out_path,
|
||||
save_all=True,
|
||||
append_images=pil_list[1:],
|
||||
duration=duration_ms,
|
||||
loop=0,
|
||||
format="WEBP",
|
||||
quality=_scalar(quality, int),
|
||||
method=6,
|
||||
)
|
||||
pil_list[0].save(out_path, save_all=True, append_images=pil_list[1:], duration=duration_ms, loop=0, format='WEBP', quality=_scalar(quality, int), method=6)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save animated preview '{out_path}': {e}")
|
||||
return None
|
||||
return {'filename': name, 'subfolder': '', 'type': 'temp'}
|
||||
|
||||
return {"filename": name, "subfolder": "", "type": "temp"}
|
||||
|
||||
|
||||
def save_temp_strip(images, prefix="imageops_strip", ext="png", max_frames=16, tile_height=256, quality=95):
|
||||
def save_temp_strip(images, prefix='imageops_strip', ext='png', max_frames=16, tile_height=256, quality=95):
|
||||
temp_dir = _ensure_dir(folder_paths.get_temp_directory())
|
||||
pil_list = _tensor_batch_to_pil_list(images)
|
||||
if not pil_list:
|
||||
return None
|
||||
|
||||
frames = pil_list[: _scalar(max(1, _scalar(max_frames, int)), int)]
|
||||
frames = pil_list[:_scalar(max(1, _scalar(max_frames, int)), int)]
|
||||
resized = []
|
||||
for im in frames:
|
||||
try:
|
||||
@@ -97,99 +69,69 @@ def save_temp_strip(images, prefix="imageops_strip", ext="png", max_frames=16, t
|
||||
continue
|
||||
if not resized:
|
||||
return None
|
||||
|
||||
total_w = sum(im.size[0] for im in resized)
|
||||
total_w = sum((im.size[0] for im in resized))
|
||||
out_h = resized[0].size[1]
|
||||
strip = Image.new("RGB", (total_w, out_h), (0, 0, 0))
|
||||
strip = Image.new('RGB', (total_w, out_h), (0, 0, 0))
|
||||
x = 0
|
||||
for im in resized:
|
||||
strip.paste(im.convert("RGB"), (x, 0))
|
||||
strip.paste(im.convert('RGB'), (x, 0))
|
||||
x += im.size[0]
|
||||
|
||||
name = f"{prefix}_{uuid.uuid4().hex[:10]}.{ext}"
|
||||
name = f'{prefix}_{uuid.uuid4().hex[:10]}.{ext}'
|
||||
out_path = os.path.join(temp_dir, name)
|
||||
try:
|
||||
if ext.lower() in ("jpg", "jpeg"):
|
||||
if ext.lower() in ('jpg', 'jpeg'):
|
||||
strip.save(out_path, quality=_scalar(quality, int), optimize=True)
|
||||
else:
|
||||
strip.save(out_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save strip preview '{out_path}': {e}")
|
||||
return None
|
||||
return {'filename': name, 'subfolder': '', 'type': 'temp'}
|
||||
|
||||
return {"filename": name, "subfolder": "", "type": "temp"}
|
||||
|
||||
|
||||
class ImageOpsPreview:
|
||||
class ImageOpsPreview(io.ComfyNode):
|
||||
"""
|
||||
Preview bridge node:
|
||||
- previews IMAGE or MASK input
|
||||
- passes IMAGE through to IMAGE output
|
||||
- passes MASK through to MASK output
|
||||
"""
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "preview"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"preview_target": (["auto", "image", "mask"], {"default": "auto"}),
|
||||
"mode": (["images", "strip", "animated_webp", "animated_gif"], {"default": "images"}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsPreview', display_name='〽️ Image Ops Preview', category='image/imageops', inputs=[io.String.Input('preview_target', default='auto'), io.String.Input('mode', default='images'), io.String.Input('image', tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', force_input=True, display_name='Images/Video', optional=True), io.Mask.Input('mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo, io.Hidden.unique_id])
|
||||
|
||||
def preview(self, image=None, preview_target="auto", mode="images", mask=None, prompt=None, extra_pnginfo=None, unique_id=None):
|
||||
@classmethod
|
||||
def execute(cls, image=None, preview_target='auto', mode='images', mask=None, prompt=None, extra_pnginfo=None, unique_id=None, **kwargs):
|
||||
del prompt, extra_pnginfo
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
image_tensor = None
|
||||
if image is not None:
|
||||
image_tensor = _select_media_tensor(image, None)
|
||||
|
||||
mask_tensor = _coerce_mask_tensor(
|
||||
mask,
|
||||
device=image_tensor.device if image_tensor is not None else None,
|
||||
dtype=image_tensor.dtype if image_tensor is not None else torch.float32,
|
||||
)
|
||||
|
||||
mask_tensor = _coerce_mask_tensor(mask, device=image_tensor.device if image_tensor is not None else None, dtype=image_tensor.dtype if image_tensor is not None else torch.float32)
|
||||
if image_tensor is None and mask_tensor is None:
|
||||
progress.finish()
|
||||
blank_image = torch.zeros(1, 1, 1, 3)
|
||||
blank_mask = torch.zeros(1, 1, 1)
|
||||
return {"ui": {"images": []}, "result": (blank_image, blank_mask)}
|
||||
|
||||
return {'ui': {'images': []}, 'result': (blank_image, blank_mask)}
|
||||
output_image = image_tensor if image_tensor is not None else _mask_to_preview_image(mask_tensor)
|
||||
output_mask = mask_tensor if mask_tensor is not None else _alpha_mask_from_image(output_image)
|
||||
|
||||
target = str(preview_target or "auto").strip().lower()
|
||||
if target == "mask":
|
||||
target = str(preview_target or 'auto').strip().lower()
|
||||
if target == 'mask':
|
||||
preview_image = _mask_to_preview_image(output_mask, device=output_image.device, dtype=output_image.dtype)
|
||||
elif target == "image":
|
||||
elif target == 'image':
|
||||
preview_image = output_image
|
||||
else:
|
||||
preview_image = output_image if image_tensor is not None else _mask_to_preview_image(output_mask, device=output_image.device, dtype=output_image.dtype)
|
||||
|
||||
if mode == "strip":
|
||||
item = save_temp_strip(preview_image, prefix="imageops_preview", ext="png")
|
||||
ui = {"images": [item]} if item else {"images": save_temp_images(preview_image, prefix="imageops_preview")}
|
||||
elif mode == "animated_webp":
|
||||
item = save_temp_animated(preview_image, prefix="imageops_preview", ext="webp")
|
||||
ui = {"images": [item]} if item else {"images": save_temp_images(preview_image, prefix="imageops_preview")}
|
||||
elif mode == "animated_gif":
|
||||
item = save_temp_animated(preview_image, prefix="imageops_preview", ext="gif")
|
||||
ui = {"images": [item]} if item else {"images": save_temp_images(preview_image, prefix="imageops_preview")}
|
||||
if mode == 'strip':
|
||||
item = save_temp_strip(preview_image, prefix='imageops_preview', ext='png')
|
||||
ui = {'images': [item]} if item else {'images': save_temp_images(preview_image, prefix='imageops_preview')}
|
||||
elif mode == 'animated_webp':
|
||||
item = save_temp_animated(preview_image, prefix='imageops_preview', ext='webp')
|
||||
ui = {'images': [item]} if item else {'images': save_temp_images(preview_image, prefix='imageops_preview')}
|
||||
elif mode == 'animated_gif':
|
||||
item = save_temp_animated(preview_image, prefix='imageops_preview', ext='gif')
|
||||
ui = {'images': [item]} if item else {'images': save_temp_images(preview_image, prefix='imageops_preview')}
|
||||
else:
|
||||
ui = {"images": save_temp_images(preview_image, prefix="imageops_preview")}
|
||||
ui = {'images': save_temp_images(preview_image, prefix='imageops_preview')}
|
||||
progress.finish()
|
||||
return {"ui": ui, "result": (output_image, output_mask)}
|
||||
return {'ui': ui, 'result': (output_image, output_mask)}
|
||||
+20
-93
@@ -1,50 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import torch
|
||||
|
||||
from ._helpers import _hex_to_rgb01, _scalar
|
||||
from ._preview import build_node_preview_result
|
||||
from ._progress import start_progress
|
||||
|
||||
_RAMP_MODES = ["linear", "ease_in", "ease_out", "smoothstep"]
|
||||
_RAMP_SHAPES = ["linear", "radial"]
|
||||
|
||||
_RAMP_MODES = ['linear', 'ease_in', 'ease_out', 'smoothstep']
|
||||
_RAMP_SHAPES = ['linear', 'radial']
|
||||
|
||||
def _apply_ramp_curve(t: torch.Tensor, mode: str) -> torch.Tensor:
|
||||
normalized = str(mode or "linear").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if normalized == "ease_in":
|
||||
normalized = str(mode or 'linear').strip().lower().replace('-', '_').replace(' ', '_')
|
||||
if normalized == 'ease_in':
|
||||
return t * t
|
||||
if normalized == "ease_out":
|
||||
if normalized == 'ease_out':
|
||||
return 1.0 - (1.0 - t) * (1.0 - t)
|
||||
if normalized == "smoothstep":
|
||||
if normalized == 'smoothstep':
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
return t
|
||||
|
||||
|
||||
def _ramp_image(
|
||||
batch: int,
|
||||
height: int,
|
||||
width: int,
|
||||
color_a: str,
|
||||
color_b: str,
|
||||
alpha: float,
|
||||
start_x: float,
|
||||
start_y: float,
|
||||
end_x: float,
|
||||
end_y: float,
|
||||
ramp_shape: str,
|
||||
ramp_mode: str,
|
||||
invert: bool,
|
||||
) -> torch.Tensor:
|
||||
def _ramp_image(batch: int, height: int, width: int, color_a: str, color_b: str, alpha: float, start_x: float, start_y: float, end_x: float, end_y: float, ramp_shape: str, ramp_mode: str, invert: bool) -> torch.Tensor:
|
||||
xs = torch.linspace(0.0, 1.0, max(1, width), dtype=torch.float32).view(1, width)
|
||||
ys = torch.linspace(0.0, 1.0, max(1, height), dtype=torch.float32).view(height, 1)
|
||||
dx = float(end_x) - float(start_x)
|
||||
dy = float(end_y) - float(start_y)
|
||||
denom = dx * dx + dy * dy
|
||||
shape = str(ramp_shape or "linear").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if denom <= 1.0e-12:
|
||||
shape = str(ramp_shape or 'linear').strip().lower().replace('-', '_').replace(' ', '_')
|
||||
if denom <= 1e-12:
|
||||
t = torch.zeros((height, width), dtype=torch.float32)
|
||||
elif shape == "radial":
|
||||
elif shape == 'radial':
|
||||
radius = denom ** 0.5
|
||||
t = torch.sqrt((xs - float(start_x)) ** 2 + (ys - float(start_y)) ** 2) / radius
|
||||
t = t.clamp(0.0, 1.0)
|
||||
@@ -60,78 +42,23 @@ def _ramp_image(
|
||||
a = torch.full((batch, height, width, 1), float(alpha), dtype=torch.float32).clamp(0.0, 1.0)
|
||||
return torch.cat([rgb, a], dim=-1)
|
||||
|
||||
|
||||
class ImageOpsRamp:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT")
|
||||
RETURN_NAMES = ("image", "mask", "width", "height", "frame_count")
|
||||
FUNCTION = "generate"
|
||||
class ImageOpsRamp(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
|
||||
"height": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
|
||||
"frame_count": ("INT", {"default": 1, "min": 1, "max": 4096, "step": 1}),
|
||||
"color_a": ("COLOR", {"default": "#ffffff"}),
|
||||
"color_b": ("COLOR", {"default": "#000000"}),
|
||||
"alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"start_x": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 3.0, "step": 0.001}),
|
||||
"start_y": ("FLOAT", {"default": 0.5, "min": -2.0, "max": 3.0, "step": 0.001}),
|
||||
"end_x": ("FLOAT", {"default": 1.0, "min": -2.0, "max": 3.0, "step": 0.001}),
|
||||
"end_y": ("FLOAT", {"default": 0.5, "min": -2.0, "max": 3.0, "step": 0.001}),
|
||||
"ramp_shape": (_RAMP_SHAPES, {"default": "linear"}),
|
||||
"ramp_mode": (_RAMP_MODES, {"default": "linear"}),
|
||||
"invert": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsRamp', display_name='〽️ Image Ops Ramp', category='image/imageops', inputs=[io.Int.Input('width', default=1024, min=1, max=8192, step=1), io.Int.Input('height', default=1024, min=1, max=8192, step=1), io.Int.Input('frame_count', default=1, min=1, max=4096, step=1), io.Color.Input('color_a', default='#ffffff'), io.Color.Input('color_b', default='#000000'), io.Float.Input('alpha', default=1.0, min=0.0, max=1.0, step=0.01), io.Float.Input('start_x', default=0.0, min=-2.0, max=3.0, step=0.001), io.Float.Input('start_y', default=0.5, min=-2.0, max=3.0, step=0.001), io.Float.Input('end_x', default=1.0, min=-2.0, max=3.0, step=0.001), io.Float.Input('end_y', default=0.5, min=-2.0, max=3.0, step=0.001), io.String.Input('ramp_shape', default='linear'), io.String.Input('ramp_mode', default='linear'), io.Boolean.Input('invert', default=False)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask'), io.Int.Output('width', display_name='width'), io.Int.Output('height', display_name='height'), io.Int.Output('frame_count', display_name='frame_count')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def generate(
|
||||
self,
|
||||
width=1024,
|
||||
height=1024,
|
||||
frame_count=None,
|
||||
frame_length=None,
|
||||
batch_size=None,
|
||||
color_a="#ffffff",
|
||||
color_b="#000000",
|
||||
alpha=1.0,
|
||||
start_x=0.0,
|
||||
start_y=0.5,
|
||||
end_x=1.0,
|
||||
end_y=0.5,
|
||||
ramp_shape="linear",
|
||||
ramp_mode="linear",
|
||||
invert=False,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, width=1024, height=1024, frame_count=None, frame_length=None, batch_size=None, color_a='#ffffff', color_b='#000000', alpha=1.0, start_x=0.0, start_y=0.5, end_x=1.0, end_y=0.5, ramp_shape='linear', ramp_mode='linear', invert=False, unique_id=None, **kwargs):
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
out_w = max(1, _scalar(width, int))
|
||||
out_h = max(1, _scalar(height, int))
|
||||
frame_count_source = frame_count if frame_count is not None else (frame_length if frame_length is not None else (batch_size if batch_size is not None else 1))
|
||||
frame_count_source = frame_count if frame_count is not None else frame_length if frame_length is not None else batch_size if batch_size is not None else 1
|
||||
batch = max(1, _scalar(frame_count_source, int))
|
||||
opacity = max(0.0, min(1.0, _scalar(alpha)))
|
||||
from .core.memory import check_budget
|
||||
check_budget(batch, out_h, out_w, 4, multiplier=1.5, label="ImageOps Ramp")
|
||||
image = _ramp_image(
|
||||
batch,
|
||||
out_h,
|
||||
out_w,
|
||||
color_a,
|
||||
color_b,
|
||||
opacity,
|
||||
_scalar(start_x),
|
||||
_scalar(start_y),
|
||||
_scalar(end_x),
|
||||
_scalar(end_y),
|
||||
ramp_shape,
|
||||
ramp_mode,
|
||||
_scalar(invert, bool),
|
||||
)
|
||||
check_budget(batch, out_h, out_w, 4, multiplier=1.5, label='ImageOps Ramp')
|
||||
image = _ramp_image(batch, out_h, out_w, color_a, color_b, opacity, _scalar(start_x), _scalar(start_y), _scalar(end_x), _scalar(end_y), ramp_shape, ramp_mode, _scalar(invert, bool))
|
||||
mask = image[..., 3].clamp(0.0, 1.0)
|
||||
progress.finish()
|
||||
return build_node_preview_result(image, (image, mask, out_w, out_h, batch), prefix="imageops_ramp")
|
||||
return build_node_preview_result(image, (image, mask, out_w, out_h, batch), prefix='imageops_ramp')
|
||||
+81
-205
@@ -1,27 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import math
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from ._helpers import MEDIA_INPUT_TYPE, _prepare_effect_mask, _resize, _scalar, _select_media_tensor
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
_SPHERIZE_MODES = ['spherize', 'fisheye', 'defisheye', 'latlong', 'unlatlong']
|
||||
_SPHERIZE_EDGE_MODES = ['border', 'reflection', 'zeros']
|
||||
_SPHERIZE_FILTER_MODES = ['bilinear', 'bicubic', 'nearest']
|
||||
_SPHERIZE_SIZE_MODES = ['from_input', 'custom']
|
||||
|
||||
_SPHERIZE_MODES = ["spherize", "fisheye", "defisheye", "latlong", "unlatlong"]
|
||||
_SPHERIZE_EDGE_MODES = ["border", "reflection", "zeros"]
|
||||
_SPHERIZE_FILTER_MODES = ["bilinear", "bicubic", "nearest"]
|
||||
_SPHERIZE_SIZE_MODES = ["from_input", "custom"]
|
||||
|
||||
|
||||
def _spherize_frame(
|
||||
frame: torch.Tensor,
|
||||
mode: str,
|
||||
strength: float,
|
||||
invert: bool,
|
||||
filter_mode: str,
|
||||
edge_mode: str,
|
||||
) -> torch.Tensor:
|
||||
def _spherize_frame(frame: torch.Tensor, mode: str, strength: float, invert: bool, filter_mode: str, edge_mode: str) -> torch.Tensor:
|
||||
"""
|
||||
Apply spherize distortion to a single frame [1, H, W, C].
|
||||
|
||||
@@ -36,196 +26,113 @@ def _spherize_frame(
|
||||
B, H, W, C = frame.shape
|
||||
device = frame.device
|
||||
dtype = frame.dtype
|
||||
|
||||
# Normalised coords in [-1, 1]
|
||||
ys = torch.linspace(-1.0, 1.0, H, device=device, dtype=torch.float32)
|
||||
xs = torch.linspace(-1.0, 1.0, W, device=device, dtype=torch.float32)
|
||||
grid_y, grid_x = torch.meshgrid(ys, xs, indexing="ij") # [H, W]
|
||||
|
||||
grid_y, grid_x = torch.meshgrid(ys, xs, indexing='ij')
|
||||
s = float(strength)
|
||||
m = str(mode).strip().lower()
|
||||
inv = bool(invert)
|
||||
|
||||
def _apply_forward(gx: torch.Tensor, gy: torch.Tensor):
|
||||
r = torch.sqrt(gx * gx + gy * gy).clamp(max=1.4142135)
|
||||
|
||||
if m == "spherize":
|
||||
# barrel: map r → sin(r * pi/2). strength scales the effect.
|
||||
if m == 'spherize':
|
||||
t = (r * (math.pi * 0.5)).clamp(0.0, math.pi * 0.5)
|
||||
scale = torch.where(r > 1e-6, torch.sin(t) / r.clamp(min=1e-6), torch.ones_like(r))
|
||||
# blend between identity (s=0) and full warp (s=1)
|
||||
scale = torch.where(r > 1e-06, torch.sin(t) / r.clamp(min=1e-06), torch.ones_like(r))
|
||||
blend = scale * s + (1.0 - s)
|
||||
return gx * blend, gy * blend
|
||||
|
||||
elif m == "fisheye":
|
||||
if abs(s) <= 1e-6:
|
||||
return gx, gy
|
||||
# equidistant: angle from centre → r_out = r_in (already linear),
|
||||
# but source UV is r_in = sin(θ) while we want θ for the projection.
|
||||
# Forward: r_src = sin(r_dst * pi/2 * s) / (r_dst * pi/2 * s + 1e-6) * r_dst
|
||||
return (gx * blend, gy * blend)
|
||||
elif m == 'fisheye':
|
||||
if abs(s) <= 1e-06:
|
||||
return (gx, gy)
|
||||
angle = r * (math.pi * 0.5) * s
|
||||
r_src = torch.where(r > 1e-6, torch.sin(angle) / r.clamp(min=1e-6) * r, torch.zeros_like(r))
|
||||
scale = torch.where(r > 1e-6, r_src / r.clamp(min=1e-6), torch.ones_like(r))
|
||||
return gx * scale, gy * scale
|
||||
|
||||
elif m == "defisheye":
|
||||
if abs(s) <= 1e-6:
|
||||
return gx, gy
|
||||
# Inverse equidistant: flatten a fish-eye – expand centre outward.
|
||||
r_src = torch.where(r > 1e-06, torch.sin(angle) / r.clamp(min=1e-06) * r, torch.zeros_like(r))
|
||||
scale = torch.where(r > 1e-06, r_src / r.clamp(min=1e-06), torch.ones_like(r))
|
||||
return (gx * scale, gy * scale)
|
||||
elif m == 'defisheye':
|
||||
if abs(s) <= 1e-06:
|
||||
return (gx, gy)
|
||||
angle = r * (math.pi * 0.5) * s
|
||||
r_dst = torch.where(angle.abs() > 1e-6, torch.tan(angle.clamp(-1.5, 1.5)) / (math.pi * 0.5 * s + 1e-8), r)
|
||||
scale = torch.where(r > 1e-6, r_dst / r.clamp(min=1e-6), torch.ones_like(r))
|
||||
return gx * scale, gy * scale
|
||||
|
||||
elif m == "latlong":
|
||||
# Equirectangular → rectilinear (barrel-like: centre magnified).
|
||||
# Each output pixel is in rectilinear perspective space.
|
||||
# lon = atan(gx * fov_tan), normalised so corners reach ±1.
|
||||
fov_tan = max(s * 2.0, 1e-6)
|
||||
r_dst = torch.where(angle.abs() > 1e-06, torch.tan(angle.clamp(-1.5, 1.5)) / (math.pi * 0.5 * s + 1e-08), r)
|
||||
scale = torch.where(r > 1e-06, r_dst / r.clamp(min=1e-06), torch.ones_like(r))
|
||||
return (gx * scale, gy * scale)
|
||||
elif m == 'latlong':
|
||||
fov_tan = max(s * 2.0, 1e-06)
|
||||
atan_fov = float(torch.atan(torch.tensor(fov_tan)))
|
||||
lon = torch.atan(gx * fov_tan)
|
||||
lat = torch.atan(gy * fov_tan)
|
||||
return lon / atan_fov, lat / atan_fov
|
||||
|
||||
elif m == "unlatlong":
|
||||
# Rectilinear → equirectangular (pincushion-like: edges stretched).
|
||||
# Inverse of latlong: src = tan(gx * atan_fov) / fov_tan.
|
||||
fov_tan = max(s * 2.0, 1e-6)
|
||||
return (lon / atan_fov, lat / atan_fov)
|
||||
elif m == 'unlatlong':
|
||||
fov_tan = max(s * 2.0, 1e-06)
|
||||
atan_fov = float(torch.atan(torch.tensor(fov_tan)))
|
||||
clamp_val = atan_fov * 0.9999
|
||||
src_x = torch.tan((gx * atan_fov).clamp(-clamp_val, clamp_val)) / fov_tan
|
||||
src_y = torch.tan((gy * atan_fov).clamp(-clamp_val, clamp_val)) / fov_tan
|
||||
return src_x, src_y
|
||||
|
||||
return gx, gy
|
||||
return (src_x, src_y)
|
||||
return (gx, gy)
|
||||
|
||||
def _apply_inverse(gx: torch.Tensor, gy: torch.Tensor):
|
||||
"""Swap forward ↔ inverse for invert mode by running the complementary projection."""
|
||||
r = torch.sqrt(gx * gx + gy * gy).clamp(max=1.4142135)
|
||||
|
||||
if m == "spherize":
|
||||
# Inverse of barrel → pincushion (asin instead of sin).
|
||||
if m == 'spherize':
|
||||
t = (r * (math.pi * 0.5)).clamp(0.0, math.pi * 0.5)
|
||||
scale = torch.where(r > 1e-6, torch.asin(r.clamp(0.0, 1.0)) / r.clamp(min=1e-6) / (math.pi * 0.5), torch.ones_like(r))
|
||||
scale = torch.where(r > 1e-06, torch.asin(r.clamp(0.0, 1.0)) / r.clamp(min=1e-06) / (math.pi * 0.5), torch.ones_like(r))
|
||||
blend = scale * s + (1.0 - s)
|
||||
return gx * blend, gy * blend
|
||||
|
||||
elif m == "fisheye":
|
||||
if abs(s) <= 1e-6:
|
||||
return gx, gy
|
||||
# Inverse fisheye = defisheye
|
||||
return (gx * blend, gy * blend)
|
||||
elif m == 'fisheye':
|
||||
if abs(s) <= 1e-06:
|
||||
return (gx, gy)
|
||||
angle = r * (math.pi * 0.5) * s
|
||||
r_dst = torch.where(angle.abs() > 1e-6, torch.tan(angle.clamp(-1.5, 1.5)) / (math.pi * 0.5 * s + 1e-8), r)
|
||||
scale = torch.where(r > 1e-6, r_dst / r.clamp(min=1e-6), torch.ones_like(r))
|
||||
return gx * scale, gy * scale
|
||||
|
||||
elif m == "defisheye":
|
||||
if abs(s) <= 1e-6:
|
||||
return gx, gy
|
||||
# Inverse defisheye = fisheye
|
||||
r_dst = torch.where(angle.abs() > 1e-06, torch.tan(angle.clamp(-1.5, 1.5)) / (math.pi * 0.5 * s + 1e-08), r)
|
||||
scale = torch.where(r > 1e-06, r_dst / r.clamp(min=1e-06), torch.ones_like(r))
|
||||
return (gx * scale, gy * scale)
|
||||
elif m == 'defisheye':
|
||||
if abs(s) <= 1e-06:
|
||||
return (gx, gy)
|
||||
angle = r * (math.pi * 0.5) * s
|
||||
r_src = torch.where(r > 1e-6, torch.sin(angle) / r.clamp(min=1e-6) * r, torch.zeros_like(r))
|
||||
scale = torch.where(r > 1e-6, r_src / r.clamp(min=1e-6), torch.ones_like(r))
|
||||
return gx * scale, gy * scale
|
||||
|
||||
elif m == "latlong":
|
||||
# Inverse of latlong = unlatlong forward
|
||||
fov_tan = max(s * 2.0, 1e-6)
|
||||
r_src = torch.where(r > 1e-06, torch.sin(angle) / r.clamp(min=1e-06) * r, torch.zeros_like(r))
|
||||
scale = torch.where(r > 1e-06, r_src / r.clamp(min=1e-06), torch.ones_like(r))
|
||||
return (gx * scale, gy * scale)
|
||||
elif m == 'latlong':
|
||||
fov_tan = max(s * 2.0, 1e-06)
|
||||
atan_fov = float(torch.atan(torch.tensor(fov_tan)))
|
||||
clamp_val = atan_fov * 0.9999
|
||||
src_x = torch.tan((gx * atan_fov).clamp(-clamp_val, clamp_val)) / fov_tan
|
||||
src_y = torch.tan((gy * atan_fov).clamp(-clamp_val, clamp_val)) / fov_tan
|
||||
return src_x, src_y
|
||||
|
||||
elif m == "unlatlong":
|
||||
# Inverse of unlatlong = latlong forward
|
||||
fov_tan = max(s * 2.0, 1e-6)
|
||||
return (src_x, src_y)
|
||||
elif m == 'unlatlong':
|
||||
fov_tan = max(s * 2.0, 1e-06)
|
||||
atan_fov = float(torch.atan(torch.tensor(fov_tan)))
|
||||
lon = torch.atan(gx * fov_tan)
|
||||
lat = torch.atan(gy * fov_tan)
|
||||
return lon / atan_fov, lat / atan_fov
|
||||
|
||||
return gx, gy
|
||||
|
||||
return (lon / atan_fov, lat / atan_fov)
|
||||
return (gx, gy)
|
||||
fn = _apply_inverse if inv else _apply_forward
|
||||
src_x, src_y = fn(grid_x, grid_y)
|
||||
|
||||
# grid_sample expects [B, H, W, 2] with values in [-1, 1].
|
||||
grid = torch.stack([src_x, src_y], dim=-1).unsqueeze(0) # [1, H, W, 2]
|
||||
|
||||
grid = torch.stack([src_x, src_y], dim=-1).unsqueeze(0)
|
||||
safe_filter = str(filter_mode).strip().lower()
|
||||
if safe_filter not in ("nearest", "bilinear", "bicubic"):
|
||||
safe_filter = "bilinear"
|
||||
if safe_filter not in ('nearest', 'bilinear', 'bicubic'):
|
||||
safe_filter = 'bilinear'
|
||||
safe_edge = str(edge_mode).strip().lower()
|
||||
if safe_edge not in ("border", "reflection", "zeros"):
|
||||
safe_edge = "border"
|
||||
|
||||
img = frame.float().permute(0, 3, 1, 2) # [B, C, H, W]
|
||||
if safe_edge not in ('border', 'reflection', 'zeros'):
|
||||
safe_edge = 'border'
|
||||
img = frame.float().permute(0, 3, 1, 2)
|
||||
warped = F.grid_sample(img, grid, mode=safe_filter, padding_mode=safe_edge, align_corners=True)
|
||||
warped = warped.permute(0, 2, 3, 1).clamp(0.0, 1.0).to(device=device, dtype=dtype)
|
||||
|
||||
# Mask out pixels outside the unit circle (the sphere boundary), but only
|
||||
# for projections that actually map onto a disk. Rectangular projections
|
||||
# (latlong/unlatlong) fill the whole canvas.
|
||||
if m not in ("latlong", "unlatlong"):
|
||||
r2 = grid_x * grid_x + grid_y * grid_y # [H, W]
|
||||
circle = (r2 <= 1.0).to(dtype=warped.dtype) # 1 inside, 0 outside
|
||||
warped = warped * circle.unsqueeze(0).unsqueeze(-1) # [B, H, W, C]
|
||||
if m not in ('latlong', 'unlatlong'):
|
||||
r2 = grid_x * grid_x + grid_y * grid_y
|
||||
circle = (r2 <= 1.0).to(dtype=warped.dtype)
|
||||
warped = warped * circle.unsqueeze(0).unsqueeze(-1)
|
||||
return warped
|
||||
|
||||
|
||||
class ImageOpsSpherize:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsSpherize(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"mode": (_SPHERIZE_MODES, {"default": "spherize"}),
|
||||
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01, "round": 0.001,
|
||||
"tooltip": "Effect intensity. 1.0 = full projection. Values > 1 push beyond the normal range."}),
|
||||
"invert": ("BOOLEAN", {"default": False,
|
||||
"tooltip": "Invert the mapping direction (e.g. barrel ↔ pincushion, latlong ↔ unlatlong)."}),
|
||||
"filter": (_SPHERIZE_FILTER_MODES, {"default": "bilinear"}),
|
||||
"edge_mode": (_SPHERIZE_EDGE_MODES, {"default": "border"}),
|
||||
"size_mode": (_SPHERIZE_SIZE_MODES, {"default": "from_input",
|
||||
"tooltip": "from_input: use input image dimensions. custom: resize to width × height before applying spherize."}),
|
||||
"width": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
|
||||
"height": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsSpherize', display_name='〽️ Image Ops Spherize', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.String.Input('mode', default='spherize'), io.Float.Input('strength', default=1.0, min=0.0, max=2.0, step=0.01, round=0.001, tooltip='Effect intensity. 1.0 = full projection. Values > 1 push beyond the normal range.'), io.Boolean.Input('invert', default=False, tooltip='Invert the mapping direction (e.g. barrel ↔ pincushion, latlong ↔ unlatlong).'), io.String.Input('filter', default='bilinear'), io.String.Input('edge_mode', default='border'), io.String.Input('size_mode', default='from_input', tooltip='from_input: use input image dimensions. custom: resize to width × height before applying spherize.'), io.Int.Input('width', default=512, min=64, max=8192, step=8), io.Int.Input('height', default=512, min=64, max=8192, step=8), io.String.Input('image', tooltip='Images/Video input.', force_input=True, display_name='Images/Video', optional=True), io.Mask.Input('mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(
|
||||
self,
|
||||
bypass=False,
|
||||
mode="spherize",
|
||||
strength=1.0,
|
||||
invert=False,
|
||||
filter="bilinear",
|
||||
edge_mode="border",
|
||||
size_mode="from_input",
|
||||
width=512,
|
||||
height=512,
|
||||
image=None,
|
||||
video=None,
|
||||
mask=None,
|
||||
unique_id=None,
|
||||
):
|
||||
@classmethod
|
||||
def execute(cls, bypass=False, mode='spherize', strength=1.0, invert=False, filter='bilinear', edge_mode='border', size_mode='from_input', width=512, height=512, image=None, video=None, mask=None, unique_id=None, **kwargs):
|
||||
src = _select_media_tensor(image, video)
|
||||
progress = start_progress(total=src.shape[0] if src is not None else 1, unique_id=unique_id)
|
||||
|
||||
# Bypass BEFORE any resize or processing — bypass means pixel identity.
|
||||
if src is None or _scalar(bypass, bool):
|
||||
if src is not None:
|
||||
prepared_mask = _prepare_effect_mask(mask, src) if mask is not None else None
|
||||
@@ -233,24 +140,16 @@ class ImageOpsSpherize:
|
||||
else:
|
||||
out_mask = torch.ones(1, 64, 64, dtype=torch.float32)
|
||||
progress.finish()
|
||||
return build_node_preview_result(src, (src, out_mask), prefix="imageops_spherize")
|
||||
|
||||
# Resolve target dimensions
|
||||
use_custom = str(_scalar(size_mode, str)).strip().lower() == "custom"
|
||||
return build_node_preview_result(src, (src, out_mask), prefix='imageops_spherize')
|
||||
use_custom = str(_scalar(size_mode, str)).strip().lower() == 'custom'
|
||||
tgt_w = max(64, int(_scalar(width, int)))
|
||||
tgt_h = max(64, int(_scalar(height, int)))
|
||||
|
||||
if use_custom:
|
||||
src = _resize(src, tgt_w, tgt_h, mode="bilinear", antialias=True)
|
||||
prepared_mask = _prepare_effect_mask(mask, src) if (mask is not None and src is not None) else None
|
||||
|
||||
src = _resize(src, tgt_w, tgt_h, mode='bilinear', antialias=True)
|
||||
prepared_mask = _prepare_effect_mask(mask, src) if mask is not None and src is not None else None
|
||||
from .core.memory import check_budget
|
||||
if src is not None:
|
||||
check_budget(
|
||||
int(src.shape[0]), int(src.shape[1]), int(src.shape[2]),
|
||||
int(src.shape[3]), multiplier=3.0, label="ImageOps Spherize",
|
||||
)
|
||||
|
||||
check_budget(int(src.shape[0]), int(src.shape[1]), int(src.shape[2]), int(src.shape[3]), multiplier=3.0, label='ImageOps Spherize')
|
||||
frames = []
|
||||
warped_masks = [] if prepared_mask is not None else None
|
||||
for fi in range(src.shape[0]):
|
||||
@@ -259,51 +158,28 @@ class ImageOpsSpherize:
|
||||
frame_invert = bool(_scalar(invert, bool, index=fi))
|
||||
frame_filter = _scalar(filter, str, index=fi)
|
||||
frame_edge = _scalar(edge_mode, str, index=fi)
|
||||
frames.append(
|
||||
_spherize_frame(
|
||||
src[fi:fi + 1],
|
||||
mode=frame_mode,
|
||||
strength=frame_strength,
|
||||
invert=frame_invert,
|
||||
filter_mode=frame_filter,
|
||||
edge_mode=frame_edge,
|
||||
)
|
||||
)
|
||||
# Warp mask through the exact same grid as the image
|
||||
frames.append(_spherize_frame(src[fi:fi + 1], mode=frame_mode, strength=frame_strength, invert=frame_invert, filter_mode=frame_filter, edge_mode=frame_edge))
|
||||
if prepared_mask is not None:
|
||||
mask_frame = prepared_mask[fi:fi + 1].unsqueeze(-1) # [1, H, W, 1]
|
||||
warped_mask_frame = _spherize_frame(
|
||||
mask_frame,
|
||||
mode=frame_mode,
|
||||
strength=frame_strength,
|
||||
invert=frame_invert,
|
||||
filter_mode=frame_filter,
|
||||
edge_mode="zeros", # mask outside boundary is 0
|
||||
)
|
||||
warped_masks.append(warped_mask_frame[..., 0]) # [1, H, W]
|
||||
mask_frame = prepared_mask[fi:fi + 1].unsqueeze(-1)
|
||||
warped_mask_frame = _spherize_frame(mask_frame, mode=frame_mode, strength=frame_strength, invert=frame_invert, filter_mode=frame_filter, edge_mode='zeros')
|
||||
warped_masks.append(warped_mask_frame[..., 0])
|
||||
progress.update()
|
||||
|
||||
out = torch.cat(frames, dim=0).clamp(0.0, 1.0)
|
||||
|
||||
# Build per-frame mask: circle for disk projections, full rect for latlong/unlatlong
|
||||
H, W = out.shape[1], out.shape[2]
|
||||
H, W = (out.shape[1], out.shape[2])
|
||||
ys = torch.linspace(-1.0, 1.0, H, device=out.device, dtype=torch.float32)
|
||||
xs = torch.linspace(-1.0, 1.0, W, device=out.device, dtype=torch.float32)
|
||||
gy, gx = torch.meshgrid(ys, xs, indexing="ij")
|
||||
circle_mask = (gx * gx + gy * gy <= 1.0).float() # [H, W]
|
||||
gy, gx = torch.meshgrid(ys, xs, indexing='ij')
|
||||
circle_mask = (gx * gx + gy * gy <= 1.0).float()
|
||||
ones_mask = torch.ones_like(circle_mask)
|
||||
per_frame = []
|
||||
for fi in range(out.shape[0]):
|
||||
frame_mode = str(_scalar(mode, str, index=fi)).strip().lower()
|
||||
per_frame.append(ones_mask if frame_mode in ("latlong", "unlatlong") else circle_mask)
|
||||
circle_mask_b = torch.stack(per_frame, dim=0) # [B, H, W]
|
||||
|
||||
per_frame.append(ones_mask if frame_mode in ('latlong', 'unlatlong') else circle_mask)
|
||||
circle_mask_b = torch.stack(per_frame, dim=0)
|
||||
if warped_masks is not None:
|
||||
warped_mask_b = torch.cat(warped_masks, dim=0).clamp(0.0, 1.0)
|
||||
out_mask = circle_mask_b * warped_mask_b
|
||||
else:
|
||||
out_mask = circle_mask_b
|
||||
|
||||
progress.finish()
|
||||
return build_node_preview_result(out, (out, out_mask), prefix="imageops_spherize")
|
||||
|
||||
return build_node_preview_result(out, (out, out_mask), prefix='imageops_spherize')
|
||||
+83
-268
@@ -1,102 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_api.latest import io
|
||||
import math
|
||||
|
||||
import torch
|
||||
|
||||
from ._helpers import (
|
||||
MEDIA_INPUT_TYPE,
|
||||
EPSILON,
|
||||
_hex_to_rgb01,
|
||||
_prepare_effect_mask,
|
||||
_resolve_mask_output_source,
|
||||
_select_media_tensor,
|
||||
_unpremultiply_rgb_by_mask,
|
||||
_param_tensor,
|
||||
_scalar,
|
||||
)
|
||||
from ._helpers import MEDIA_INPUT_TYPE, EPSILON, _hex_to_rgb01, _prepare_effect_mask, _resolve_mask_output_source, _select_media_tensor, _unpremultiply_rgb_by_mask, _param_tensor, _scalar
|
||||
from ._progress import start_progress
|
||||
from ._preview import build_node_preview_result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU affine-transform helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _filter_to_grid_sample_mode(filter_mode: str | list, index: int = 0) -> str:
|
||||
def _filter_to_grid_sample_mode(filter_mode: str | list, index: int=0) -> str:
|
||||
"""Map an ImageOps filter name to a torch.nn.functional.grid_sample mode."""
|
||||
normalized = _scalar(filter_mode, str, index=index).strip().lower()
|
||||
return {"nearest": "nearest", "bilinear": "bilinear", "bicubic": "bicubic"}.get(normalized, "bilinear")
|
||||
return {'nearest': 'nearest', 'bilinear': 'bilinear', 'bicubic': 'bicubic'}.get(normalized, 'bilinear')
|
||||
_TRANSFORM_FILL_MODES = ['transparent', 'mirror', 'stretch', 'expand', 'color']
|
||||
|
||||
|
||||
_TRANSFORM_FILL_MODES = ["transparent", "mirror", "stretch", "expand", "color"]
|
||||
|
||||
|
||||
def _frame_param(value, index: int, typ: type = float):
|
||||
def _frame_param(value, index: int, typ: type=float):
|
||||
"""Return one frame's scalar value when ComfyUI passes a per-frame list."""
|
||||
return _scalar(value, typ, index=index) if isinstance(value, (list, tuple)) else value
|
||||
|
||||
def _normalize_fill_mode(value, index: int=0) -> str:
|
||||
normalized = _scalar(value, str, index=index).strip().lower().replace('-', '_').replace(' ', '_')
|
||||
if normalized in ('border', 'expand', 'edge', 'edge_extend', 'replicate', 'extend'):
|
||||
return 'expand'
|
||||
if normalized in ('reflect', 'reflection', 'mirror'):
|
||||
return 'mirror'
|
||||
if normalized in ('stretch', 'fill', 'cover'):
|
||||
return 'stretch'
|
||||
if normalized in ('color', 'colour', 'constant', 'solid'):
|
||||
return 'color'
|
||||
return 'transparent'
|
||||
|
||||
def _normalize_fill_mode(value, index: int = 0) -> str:
|
||||
normalized = _scalar(value, str, index=index).strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if normalized in ("border", "expand", "edge", "edge_extend", "replicate", "extend"):
|
||||
return "expand"
|
||||
if normalized in ("reflect", "reflection", "mirror"):
|
||||
return "mirror"
|
||||
if normalized in ("stretch", "fill", "cover"):
|
||||
return "stretch"
|
||||
if normalized in ("color", "colour", "constant", "solid"):
|
||||
return "color"
|
||||
return "transparent"
|
||||
|
||||
|
||||
def _normalize_flip_mode(value, index: int = 0) -> str:
|
||||
normalized = _scalar(value, str, index=index).strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if normalized in ("horizontal", "x", "flip_x", "left_right"):
|
||||
return "horizontal"
|
||||
if normalized in ("vertical", "y", "flip_y", "top_bottom"):
|
||||
return "vertical"
|
||||
return "none"
|
||||
|
||||
def _normalize_flip_mode(value, index: int=0) -> str:
|
||||
normalized = _scalar(value, str, index=index).strip().lower().replace('-', '_').replace(' ', '_')
|
||||
if normalized in ('horizontal', 'x', 'flip_x', 'left_right'):
|
||||
return 'horizontal'
|
||||
if normalized in ('vertical', 'y', 'flip_y', 'top_bottom'):
|
||||
return 'vertical'
|
||||
return 'none'
|
||||
|
||||
def _flip_tensor_spatial(tensor: torch.Tensor, flip_mode):
|
||||
if isinstance(flip_mode, (list, tuple)):
|
||||
if tensor.shape[0] == 0:
|
||||
return tensor
|
||||
return torch.cat([
|
||||
_flip_tensor_spatial(tensor[index:index + 1], _normalize_flip_mode(flip_mode, index=index))
|
||||
for index in range(tensor.shape[0])
|
||||
], dim=0)
|
||||
|
||||
return torch.cat([_flip_tensor_spatial(tensor[index:index + 1], _normalize_flip_mode(flip_mode, index=index)) for index in range(tensor.shape[0])], dim=0)
|
||||
mode = _normalize_flip_mode(flip_mode)
|
||||
if mode == "none":
|
||||
if mode == 'none':
|
||||
return tensor
|
||||
|
||||
if tensor.ndim < 3:
|
||||
return tensor
|
||||
|
||||
dims = [2] if mode == "horizontal" else [1]
|
||||
dims = [2] if mode == 'horizontal' else [1]
|
||||
return torch.flip(tensor, dims=dims).contiguous()
|
||||
|
||||
|
||||
def _padding_mode_from_fill(fill_mode: str) -> str:
|
||||
if fill_mode == "expand":
|
||||
return "border"
|
||||
if fill_mode == "mirror":
|
||||
return "reflection"
|
||||
return "zeros"
|
||||
|
||||
if fill_mode == 'expand':
|
||||
return 'border'
|
||||
if fill_mode == 'mirror':
|
||||
return 'reflection'
|
||||
return 'zeros'
|
||||
|
||||
def _make_fill_background(source: torch.Tensor, fill_mode, fill_color) -> torch.Tensor | None:
|
||||
normalized = _normalize_fill_mode(fill_mode)
|
||||
if normalized == "stretch":
|
||||
if normalized == 'stretch':
|
||||
background = source.float().clamp(0.0, 1.0).clone()
|
||||
if background.shape[-1] >= 4:
|
||||
background[..., 3] = 1.0
|
||||
return background.to(device=source.device, dtype=source.dtype)
|
||||
if normalized != "color":
|
||||
if normalized != 'color':
|
||||
return None
|
||||
|
||||
batch, height, width, channels = source.shape
|
||||
background = torch.zeros((batch, height, width, channels), device=source.device, dtype=torch.float32)
|
||||
r, g, b = _hex_to_rgb01(_scalar(fill_color, str))
|
||||
@@ -110,15 +78,12 @@ def _make_fill_background(source: torch.Tensor, fill_mode, fill_color) -> torch.
|
||||
background[..., 3] = 1.0
|
||||
return background.to(device=source.device, dtype=source.dtype)
|
||||
|
||||
|
||||
def _composite_fill(result: torch.Tensor, coverage_mask: torch.Tensor, background: torch.Tensor | None) -> torch.Tensor:
|
||||
if background is None:
|
||||
return result
|
||||
|
||||
fg = result.float().clamp(0.0, 1.0)
|
||||
bg = background.float().clamp(0.0, 1.0)
|
||||
blend = coverage_mask.unsqueeze(-1).float().clamp(0.0, 1.0)
|
||||
|
||||
if fg.shape[-1] >= 4:
|
||||
fg_alpha = torch.maximum(fg[..., 3:4].clamp(0.0, 1.0), blend)
|
||||
bg_alpha = bg[..., 3:4].clamp(0.0, 1.0) if bg.shape[-1] >= 4 else torch.ones_like(fg_alpha)
|
||||
@@ -127,20 +92,9 @@ def _composite_fill(result: torch.Tensor, coverage_mask: torch.Tensor, backgroun
|
||||
safe_alpha = torch.where(out_alpha > EPSILON, out_alpha, torch.ones_like(out_alpha))
|
||||
out_rgb = torch.where(out_alpha > EPSILON, premul_rgb / safe_alpha, torch.zeros_like(premul_rgb))
|
||||
return torch.cat([out_rgb.clamp(0.0, 1.0), out_alpha.clamp(0.0, 1.0)], dim=-1).to(device=result.device, dtype=result.dtype)
|
||||
|
||||
return (fg * blend + bg * (1.0 - blend)).clamp(0.0, 1.0).to(device=result.device, dtype=result.dtype)
|
||||
|
||||
|
||||
def _build_affine_theta_batch(
|
||||
B: int,
|
||||
translate_x,
|
||||
translate_y,
|
||||
rotate_deg,
|
||||
scale,
|
||||
H: int,
|
||||
W: int,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
def _build_affine_theta_batch(B: int, translate_x, translate_y, rotate_deg, scale, H: int, W: int, device: torch.device) -> torch.Tensor:
|
||||
"""Build a [B, 2, 3] batch of inverse affine matrices for affine_grid / grid_sample.
|
||||
|
||||
The matrix encodes clockwise rotation, uniform scale, and pixel translation.
|
||||
@@ -155,12 +109,10 @@ def _build_affine_theta_batch(
|
||||
angle = _param_tensor(rotate_deg, B, device=device, dtype=torch.float32).view(B) * (math.pi / 180.0)
|
||||
safe_scale = _param_tensor(scale, B, device=device, dtype=torch.float32).view(B).clamp_min(EPSILON)
|
||||
inv_scale = safe_scale.reciprocal()
|
||||
|
||||
cos_a = torch.cos(angle)
|
||||
sin_a = torch.sin(angle)
|
||||
tx_n = 2.0 * tx / max(W, 1) # pixel to normalized
|
||||
tx_n = 2.0 * tx / max(W, 1)
|
||||
ty_n = 2.0 * ty / max(H, 1)
|
||||
|
||||
theta = torch.empty((B, 2, 3), device=device, dtype=torch.float32)
|
||||
theta[:, 0, 0] = cos_a * inv_scale
|
||||
theta[:, 0, 1] = sin_a * inv_scale
|
||||
@@ -170,178 +122,67 @@ def _build_affine_theta_batch(
|
||||
theta[:, 1, 2] = (tx_n * sin_a - ty_n * cos_a) * inv_scale
|
||||
return theta
|
||||
|
||||
|
||||
def _transform_batch_affine(
|
||||
source: torch.Tensor,
|
||||
filter_mode,
|
||||
translate_x,
|
||||
translate_y,
|
||||
rotate_deg,
|
||||
scale,
|
||||
padding_mode: str = "zeros",
|
||||
) -> torch.Tensor:
|
||||
def _transform_batch_affine(source: torch.Tensor, filter_mode, translate_x, translate_y, rotate_deg, scale, padding_mode: str='zeros') -> torch.Tensor:
|
||||
"""Batched GPU affine transform for a [B, H, W, C] image tensor."""
|
||||
B, H, W, C = source.shape
|
||||
if isinstance(filter_mode, (list, tuple)):
|
||||
if B == 0:
|
||||
return source
|
||||
return torch.cat([
|
||||
_transform_batch_affine(
|
||||
source[i:i + 1],
|
||||
_scalar(filter_mode, str, index=i),
|
||||
_frame_param(translate_x, i),
|
||||
_frame_param(translate_y, i),
|
||||
_frame_param(rotate_deg, i),
|
||||
_frame_param(scale, i),
|
||||
padding_mode=padding_mode,
|
||||
)
|
||||
for i in range(B)
|
||||
], dim=0)
|
||||
|
||||
d, dt = source.device, source.dtype
|
||||
mode = _filter_to_grid_sample_mode(filter_mode)
|
||||
return torch.cat([_transform_batch_affine(source[i:i + 1], _scalar(filter_mode, str, index=i), _frame_param(translate_x, i), _frame_param(translate_y, i), _frame_param(rotate_deg, i), _frame_param(scale, i), padding_mode=padding_mode) for i in range(B)], dim=0)
|
||||
d, dt = (source.device, source.dtype)
|
||||
mode = _filter_to_grid_sample_mode(filter_mode)
|
||||
theta = _build_affine_theta_batch(B, translate_x, translate_y, rotate_deg, scale, H, W, d)
|
||||
grid = torch.nn.functional.affine_grid(theta, size=(B, C, H, W), align_corners=False)
|
||||
x = source.float().permute(0, 3, 1, 2) # [B,C,H,W]
|
||||
out = torch.nn.functional.grid_sample(x, grid, mode=mode, padding_mode=padding_mode, align_corners=False)
|
||||
grid = torch.nn.functional.affine_grid(theta, size=(B, C, H, W), align_corners=False)
|
||||
x = source.float().permute(0, 3, 1, 2)
|
||||
out = torch.nn.functional.grid_sample(x, grid, mode=mode, padding_mode=padding_mode, align_corners=False)
|
||||
return out.permute(0, 2, 3, 1).clamp(0.0, 1.0).to(device=d, dtype=dt)
|
||||
|
||||
|
||||
def _transform_mask_affine(
|
||||
mask: torch.Tensor,
|
||||
filter_mode,
|
||||
translate_x,
|
||||
translate_y,
|
||||
rotate_deg,
|
||||
scale,
|
||||
device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
padding_mode: str = "zeros",
|
||||
) -> torch.Tensor:
|
||||
def _transform_mask_affine(mask: torch.Tensor, filter_mode, translate_x, translate_y, rotate_deg, scale, device: torch.device, dtype: torch.dtype, padding_mode: str='zeros') -> torch.Tensor:
|
||||
"""Batched GPU affine transform for a [B, H, W] mask tensor."""
|
||||
B, H, W = mask.shape
|
||||
if isinstance(filter_mode, (list, tuple)):
|
||||
if B == 0:
|
||||
return mask.to(device=device, dtype=dtype)
|
||||
return torch.cat([
|
||||
_transform_mask_affine(
|
||||
mask[i:i + 1],
|
||||
_scalar(filter_mode, str, index=i),
|
||||
_frame_param(translate_x, i),
|
||||
_frame_param(translate_y, i),
|
||||
_frame_param(rotate_deg, i),
|
||||
_frame_param(scale, i),
|
||||
device,
|
||||
dtype,
|
||||
padding_mode=padding_mode,
|
||||
)
|
||||
for i in range(B)
|
||||
], dim=0)
|
||||
|
||||
mode = _filter_to_grid_sample_mode(filter_mode)
|
||||
return torch.cat([_transform_mask_affine(mask[i:i + 1], _scalar(filter_mode, str, index=i), _frame_param(translate_x, i), _frame_param(translate_y, i), _frame_param(rotate_deg, i), _frame_param(scale, i), device, dtype, padding_mode=padding_mode) for i in range(B)], dim=0)
|
||||
mode = _filter_to_grid_sample_mode(filter_mode)
|
||||
theta = _build_affine_theta_batch(B, translate_x, translate_y, rotate_deg, scale, H, W, device)
|
||||
grid = torch.nn.functional.affine_grid(theta, size=(B, 1, H, W), align_corners=False)
|
||||
m = mask.float().unsqueeze(1) # [B,1,H,W]
|
||||
out = torch.nn.functional.grid_sample(m, grid, mode=mode, padding_mode=padding_mode, align_corners=False)
|
||||
grid = torch.nn.functional.affine_grid(theta, size=(B, 1, H, W), align_corners=False)
|
||||
m = mask.float().unsqueeze(1)
|
||||
out = torch.nn.functional.grid_sample(m, grid, mode=mode, padding_mode=padding_mode, align_corners=False)
|
||||
return out.squeeze(1).clamp(0.0, 1.0).to(device=device, dtype=dtype)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Masked-source transform (premultiply -> transform -> unpremultiply)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _transform_masked_source(
|
||||
source: torch.Tensor,
|
||||
input_mask: torch.Tensor,
|
||||
filter_mode,
|
||||
translate_x,
|
||||
translate_y,
|
||||
rotate_deg,
|
||||
scale,
|
||||
progress=None,
|
||||
padding_mode: str = "zeros",
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
def _transform_masked_source(source: torch.Tensor, input_mask: torch.Tensor, filter_mode, translate_x, translate_y, rotate_deg, scale, progress=None, padding_mode: str='zeros') -> tuple[torch.Tensor, torch.Tensor]:
|
||||
if source.shape[0] != input_mask.shape[0]:
|
||||
raise ValueError(
|
||||
f"ImageOpsTransform: source batch ({source.shape[0]}) and mask batch "
|
||||
f"({input_mask.shape[0]}) must match. Broadcasting would produce incorrect "
|
||||
"per-frame transforms."
|
||||
)
|
||||
|
||||
# Premultiply RGB by mask before transforming to avoid colour bleeding at edges
|
||||
raise ValueError(f'ImageOpsTransform: source batch ({source.shape[0]}) and mask batch ({input_mask.shape[0]}) must match. Broadcasting would produce incorrect per-frame transforms.')
|
||||
premult_rgb = source[..., :3].float().clamp(0.0, 1.0) * input_mask.unsqueeze(-1)
|
||||
|
||||
# Pack RGB (+ alpha if present) into a single grid_sample call
|
||||
has_alpha = source.shape[-1] >= 4
|
||||
if has_alpha:
|
||||
combined = torch.cat([premult_rgb, source[..., 3:4].float().clamp(0.0, 1.0)], dim=-1)
|
||||
else:
|
||||
combined = premult_rgb
|
||||
|
||||
total = source.shape[0] * (3 if has_alpha else 2)
|
||||
if progress is not None:
|
||||
progress.update_absolute(0, total=max(1, total))
|
||||
|
||||
transformed = _transform_batch_affine(combined, filter_mode, translate_x, translate_y, rotate_deg, scale, padding_mode=padding_mode)
|
||||
output_mask = _transform_mask_affine(
|
||||
input_mask, filter_mode, translate_x, translate_y, rotate_deg, scale,
|
||||
source.device, source.dtype,
|
||||
)
|
||||
|
||||
transformed = _transform_batch_affine(combined, filter_mode, translate_x, translate_y, rotate_deg, scale, padding_mode=padding_mode)
|
||||
output_mask = _transform_mask_affine(input_mask, filter_mode, translate_x, translate_y, rotate_deg, scale, source.device, source.dtype)
|
||||
if progress is not None:
|
||||
progress.update_absolute(source.shape[0] * 2)
|
||||
|
||||
rgb = _unpremultiply_rgb_by_mask(transformed[..., :3], output_mask)
|
||||
rgb = _unpremultiply_rgb_by_mask(transformed[..., :3], output_mask)
|
||||
result = torch.cat([rgb, transformed[..., 3:4]], dim=-1) if has_alpha else rgb
|
||||
|
||||
if progress is not None:
|
||||
progress.update_absolute(total)
|
||||
|
||||
if not result.shape[0]:
|
||||
raise ValueError("ImageOpsTransform received an empty image batch.")
|
||||
raise ValueError('ImageOpsTransform received an empty image batch.')
|
||||
return (result.clamp(0.0, 1.0), output_mask.clamp(0.0, 1.0))
|
||||
|
||||
return result.clamp(0.0, 1.0), output_mask.clamp(0.0, 1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ImageOpsTransform:
|
||||
CATEGORY = "image/imageops"
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "apply"
|
||||
class ImageOpsTransform(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bypass": ("BOOLEAN", {"default": False}),
|
||||
"translate_x": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1}),
|
||||
"translate_y": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1}),
|
||||
"rotate_deg": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0, "step": 0.1, "round": 0.001}),
|
||||
"scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 8.0, "step": 0.01, "round": 0.001}),
|
||||
"flip": (["none", "horizontal", "vertical"], {"default": "none"}),
|
||||
"filter": (["nearest", "bilinear", "bicubic"],),
|
||||
"expand": ("BOOLEAN", {"default": False,
|
||||
"tooltip": "Reserved. Currently inactive — the GPU affine path uses a fixed-size canvas. Kept for workflow compatibility."}),
|
||||
"fill_mode": (_TRANSFORM_FILL_MODES, {"default": "transparent", "tooltip": "How to fill uncovered areas when scale, rotate, or translate leaves holes."}),
|
||||
"fill_color": ("COLOR", {"default": "#000000"}),
|
||||
"invert_mask": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"image": (MEDIA_INPUT_TYPE, {"tooltip": "Images/Video input. Accepts IMAGE batches and VIDEO frame sources.", "forceInput": True, "display_name": "Images/Video"}),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(node_id='ImageOpsTransform', display_name='〽️ Image Ops Transform', category='image/imageops', inputs=[io.Boolean.Input('bypass', default=False), io.Int.Input('translate_x', default=0, min=-4096, max=4096, step=1), io.Int.Input('translate_y', default=0, min=-4096, max=4096, step=1), io.Float.Input('rotate_deg', default=0.0, min=-180.0, max=180.0, step=0.1, round=0.001), io.Float.Input('scale', default=1.0, min=0.01, max=8.0, step=0.01, round=0.001), io.String.Input('flip', default='none'), io.String.Input('filter'), io.Boolean.Input('expand', default=False, tooltip='Reserved. Currently inactive — the GPU affine path uses a fixed-size canvas. Kept for workflow compatibility.'), io.String.Input('fill_mode', default='transparent', tooltip='How to fill uncovered areas when scale, rotate, or translate leaves holes.'), io.Color.Input('fill_color', default='#000000'), io.Boolean.Input('invert_mask', default=False), io.String.Input('image', tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', force_input=True, display_name='Images/Video', optional=True), io.Mask.Input('mask', optional=True)], outputs=[io.Image.Output('image', display_name='image'), io.Mask.Output('mask', display_name='mask')], hidden=[io.Hidden.unique_id])
|
||||
|
||||
def apply(self, image=None, bypass=False, translate_x=0, translate_y=0, rotate_deg=0.0, scale=1.0, flip="none", filter="bilinear", expand=False, fill_mode="transparent", fill_color="#000000", invert_mask=False, video=None, mask=None, unique_id=None):
|
||||
# expand is accepted for workflow compatibility; the GPU path uses one
|
||||
# fixed-size affine_grid/grid_sample pass instead of intermediate canvases.
|
||||
@classmethod
|
||||
def execute(cls, image=None, bypass=False, translate_x=0, translate_y=0, rotate_deg=0.0, scale=1.0, flip='none', filter='bilinear', expand=False, fill_mode='transparent', fill_color='#000000', invert_mask=False, video=None, mask=None, unique_id=None, **kwargs):
|
||||
del expand
|
||||
source = _select_media_tensor(image, video)
|
||||
input_mask = _prepare_effect_mask(mask, source, invert_mask=invert_mask)
|
||||
@@ -350,80 +191,54 @@ class ImageOpsTransform:
|
||||
safe_flip_mode = _normalize_flip_mode(flip)
|
||||
sample_padding = _padding_mode_from_fill(safe_fill_mode)
|
||||
progress = start_progress(unique_id=unique_id)
|
||||
|
||||
from .core.memory import check_budget
|
||||
if source is not None:
|
||||
check_budget(
|
||||
int(source.shape[0]), int(source.shape[1]), int(source.shape[2]),
|
||||
int(source.shape[3]), multiplier=2.0, label="ImageOps Transform",
|
||||
)
|
||||
|
||||
check_budget(int(source.shape[0]), int(source.shape[1]), int(source.shape[2]), int(source.shape[3]), multiplier=2.0, label='ImageOps Transform')
|
||||
if _scalar(bypass, bool):
|
||||
progress.finish()
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_transform")
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix='imageops_transform')
|
||||
|
||||
def _is_noop_param(value, kind="float"):
|
||||
def _is_noop_param(value, kind='float'):
|
||||
if isinstance(value, (list, tuple)):
|
||||
for idx in range(len(value)):
|
||||
current = _scalar(value, bool if kind == "bool" else float, index=idx) if kind == "bool" else _scalar(value, index=idx)
|
||||
if kind == "bool":
|
||||
current = _scalar(value, bool if kind == 'bool' else float, index=idx) if kind == 'bool' else _scalar(value, index=idx)
|
||||
if kind == 'bool':
|
||||
if bool(current):
|
||||
return False
|
||||
elif kind == "int":
|
||||
elif kind == 'int':
|
||||
if abs(int(round(float(current)))) > 0:
|
||||
return False
|
||||
else:
|
||||
if abs(float(current)) > EPSILON:
|
||||
return False
|
||||
elif abs(float(current)) > EPSILON:
|
||||
return False
|
||||
return True
|
||||
current = _scalar(value, bool) if kind == "bool" else _scalar(value)
|
||||
if kind == "bool":
|
||||
current = _scalar(value, bool) if kind == 'bool' else _scalar(value)
|
||||
if kind == 'bool':
|
||||
return not bool(current)
|
||||
if kind == "int":
|
||||
if kind == 'int':
|
||||
return abs(int(round(float(current)))) == 0
|
||||
return abs(float(current)) <= EPSILON
|
||||
|
||||
no_translate = _is_noop_param(translate_x, "int") and _is_noop_param(translate_y, "int")
|
||||
no_rotate = _is_noop_param(rotate_deg, "float")
|
||||
unit_scale = _is_noop_param(
|
||||
_scalar(scale) - 1.0 if not isinstance(scale, (list, tuple))
|
||||
else [(_scalar(scale, index=i) - 1.0) for i in range(len(scale))],
|
||||
"float",
|
||||
)
|
||||
no_flip = safe_flip_mode == "none" if not isinstance(flip, (list, tuple)) else all(
|
||||
_normalize_flip_mode(flip, index=i) == "none" for i in range(len(flip))
|
||||
)
|
||||
no_translate = _is_noop_param(translate_x, 'int') and _is_noop_param(translate_y, 'int')
|
||||
no_rotate = _is_noop_param(rotate_deg, 'float')
|
||||
unit_scale = _is_noop_param(_scalar(scale) - 1.0 if not isinstance(scale, (list, tuple)) else [_scalar(scale, index=i) - 1.0 for i in range(len(scale))], 'float')
|
||||
no_flip = safe_flip_mode == 'none' if not isinstance(flip, (list, tuple)) else all((_normalize_flip_mode(flip, index=i) == 'none' for i in range(len(flip))))
|
||||
if no_translate and no_rotate and unit_scale and no_flip:
|
||||
progress.finish()
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix="imageops_transform")
|
||||
|
||||
return build_node_preview_result(source, (source, output_mask_source), prefix='imageops_transform')
|
||||
if not no_flip:
|
||||
source = _flip_tensor_spatial(source, flip)
|
||||
output_mask_source = _flip_tensor_spatial(output_mask_source, flip)
|
||||
if input_mask is not None:
|
||||
input_mask = _flip_tensor_spatial(input_mask, flip)
|
||||
|
||||
if input_mask is not None:
|
||||
result, output_mask = _transform_masked_source(
|
||||
source, input_mask, filter,
|
||||
translate_x, translate_y, rotate_deg, scale,
|
||||
progress=progress,
|
||||
padding_mode=sample_padding,
|
||||
)
|
||||
result, output_mask = _transform_masked_source(source, input_mask, filter, translate_x, translate_y, rotate_deg, scale, progress=progress, padding_mode=sample_padding)
|
||||
result = _composite_fill(result, output_mask, _make_fill_background(source, safe_fill_mode, fill_color))
|
||||
progress.finish()
|
||||
return build_node_preview_result(result, (result, output_mask), prefix="imageops_transform")
|
||||
|
||||
# No external mask: transform image and alpha-derived mask on the GPU.
|
||||
return build_node_preview_result(result, (result, output_mask), prefix='imageops_transform')
|
||||
progress.update_absolute(0, total=2)
|
||||
result = _transform_batch_affine(source, filter, translate_x, translate_y, rotate_deg, scale, padding_mode=sample_padding)
|
||||
output_mask = _transform_mask_affine(
|
||||
output_mask_source, filter, translate_x, translate_y, rotate_deg, scale,
|
||||
source.device, source.dtype,
|
||||
padding_mode="zeros",
|
||||
)
|
||||
result = _transform_batch_affine(source, filter, translate_x, translate_y, rotate_deg, scale, padding_mode=sample_padding)
|
||||
output_mask = _transform_mask_affine(output_mask_source, filter, translate_x, translate_y, rotate_deg, scale, source.device, source.dtype, padding_mode='zeros')
|
||||
result = _composite_fill(result, output_mask, _make_fill_background(source, safe_fill_mode, fill_color))
|
||||
if safe_fill_mode != "transparent":
|
||||
if safe_fill_mode != 'transparent':
|
||||
output_mask = result[..., 3].clamp(0.0, 1.0) if result.shape[-1] >= 4 else torch.ones_like(output_mask)
|
||||
progress.finish()
|
||||
return build_node_preview_result(result, (result, output_mask), prefix="imageops_transform")
|
||||
return build_node_preview_result(result, (result, output_mask), prefix='imageops_transform')
|
||||
@@ -0,0 +1,61 @@
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def main():
|
||||
root = Path(__file__).resolve().parent.parent / "src" / "preview" / "ops"
|
||||
impl_path = root / "implementation.ts"
|
||||
|
||||
if not impl_path.exists():
|
||||
print("implementation.ts not found")
|
||||
return
|
||||
|
||||
# 1. Find all exported functions in implementation.ts
|
||||
impl_text = impl_path.read_text("utf-8")
|
||||
# Matches `export function foo(` or `export const foo =`
|
||||
funcs = set()
|
||||
for match in re.finditer(r'export\s+(?:function|const)\s+([a-zA-Z0-9_]+)\s*(?:=|\()', impl_text):
|
||||
funcs.add(match.group(1))
|
||||
|
||||
# 2. For each extracted TS file, replace the `import * as impl` with named imports
|
||||
targets = ["geometry.ts", "blend.ts", "masks.ts", "procedural.ts", "video.ts"]
|
||||
for target in targets:
|
||||
target_path = root / target
|
||||
if not target_path.exists():
|
||||
continue
|
||||
|
||||
text = target_path.read_text("utf-8")
|
||||
|
||||
# Find which functions are actually used in this file
|
||||
used_funcs = []
|
||||
for func in sorted(funcs):
|
||||
# Check if func is used as a word (not as a substring)
|
||||
if re.search(r'\b' + re.escape(func) + r'\b', text):
|
||||
used_funcs.append(func)
|
||||
|
||||
if not used_funcs:
|
||||
continue
|
||||
|
||||
import_stmt = "import {\n " + ",\n ".join(used_funcs) + "\n} from \"./implementation.js\";"
|
||||
|
||||
# Replace the `import * as impl from "./implementation.js";`
|
||||
text = re.sub(r'import\s+\*\s+as\s+impl\s+from\s+"./implementation\.js";', import_stmt, text)
|
||||
|
||||
# Remove duplicate `import { ops } from "./implementation.js";`
|
||||
text = re.sub(r'import\s+{\s*ops\s*}\s+from\s+"./implementation\.js";\n?', '', text)
|
||||
|
||||
# Add types
|
||||
if "RenderInputInfo" in text and "RenderInputInfo" not in text[:200]:
|
||||
text = text.replace('import type { ComfyNode } from "../../types.js";', 'import type { ComfyNode, RenderInputInfo } from "../../types.js";')
|
||||
|
||||
if "setWidgetValue" in text and "setWidgetValue" not in text[:200]:
|
||||
text = 'import { setWidgetValue } from "../shared/widgets.js";\n' + text
|
||||
|
||||
if "renderDrawPreview" in text and "renderDrawPreview" not in text[:200]:
|
||||
text = 'import { renderDrawPreview, resolveDrawOverlayCanvas } from "../draw.js";\n' + text
|
||||
|
||||
target_path.write_text(text, "utf-8")
|
||||
print(f"Fixed imports for {target}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,273 @@
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
def format_input(name, spec, is_optional):
|
||||
raw_type = spec[0] if isinstance(spec, (tuple, list)) and spec else "STRING"
|
||||
opts = spec[1] if isinstance(spec, (tuple, list)) and len(spec) > 1 and isinstance(spec[1], dict) else {}
|
||||
|
||||
type_str = "String"
|
||||
if isinstance(raw_type, (list, tuple)) and not isinstance(raw_type, str):
|
||||
options_str = repr(list(raw_type))
|
||||
kwargs_str = ", ".join(f"{k}={repr(v)}" for k, v in opts.items())
|
||||
if kwargs_str:
|
||||
kwargs_str = ", " + kwargs_str
|
||||
return f'io.String.Input("{name}", options={options_str}{kwargs_str})'
|
||||
|
||||
type_name = str(raw_type).upper()
|
||||
if type_name == "IMAGE,VIDEO":
|
||||
type_str = "MultiType"
|
||||
elif type_name == "COLOR" or ("COLOR" in name.lower() and type_name == "STRING"):
|
||||
type_str = "Color"
|
||||
elif type_name in {"BOOLEAN", "BOOL"}:
|
||||
type_str = "Boolean"
|
||||
elif type_name == "INT":
|
||||
type_str = "Int"
|
||||
elif type_name == "FLOAT":
|
||||
type_str = "Float"
|
||||
elif type_name == "MASK":
|
||||
type_str = "Mask"
|
||||
elif "IMAGE" in type_name or "VIDEO" in type_name:
|
||||
type_str = "Image"
|
||||
|
||||
kwargs_str = ""
|
||||
for k, v in opts.items():
|
||||
if k == "extra_dict" and type_str in ("Image", "Video", "Mask", "MultiType"):
|
||||
kwargs_str += f", extra_dict={repr(v)}"
|
||||
elif k == "display":
|
||||
kwargs_str += f", display_mode=io.NumberDisplay({repr(v)})"
|
||||
elif k == "forceInput":
|
||||
kwargs_str += f", force_input={repr(v)}"
|
||||
else:
|
||||
kwargs_str += f", {k}={repr(v)}"
|
||||
|
||||
if is_optional:
|
||||
kwargs_str += ", optional=True"
|
||||
|
||||
if type_str == "MultiType":
|
||||
return f'io.MultiType.Input("{name}", types=[io.Image, io.Video]{kwargs_str})'
|
||||
return f'io.{type_str}.Input("{name}"{kwargs_str})'
|
||||
|
||||
|
||||
class NodeMigrator(ast.NodeTransformer):
|
||||
def __init__(self, node_id, display_name):
|
||||
self.node_id = node_id
|
||||
self.display_name = display_name
|
||||
self.modified = False
|
||||
|
||||
# We will parse these from the legacy class attributes
|
||||
self.function_name = "apply"
|
||||
self.category = "image/imageops"
|
||||
self.return_types = []
|
||||
self.return_names = []
|
||||
self.inputs_dict = {"required": {}, "optional": {}, "hidden": {}}
|
||||
|
||||
def extract_dict_from_ast(self, dict_node):
|
||||
"""Very basic AST to dict converter for INPUT_TYPES"""
|
||||
result = {}
|
||||
if not isinstance(dict_node, ast.Dict):
|
||||
return result
|
||||
for k, v in zip(dict_node.keys, dict_node.values):
|
||||
if isinstance(k, ast.Constant) and isinstance(k.value, str):
|
||||
if isinstance(v, ast.Tuple) or isinstance(v, ast.List):
|
||||
# Tuple (TYPE, {options})
|
||||
elements = v.elts
|
||||
spec = []
|
||||
if len(elements) > 0:
|
||||
if isinstance(elements[0], ast.Constant):
|
||||
spec.append(elements[0].value)
|
||||
elif isinstance(elements[0], ast.Name):
|
||||
# like _BLUR_TYPES, just use string repr for now, we'll fix it manually later if needed
|
||||
# actually we can just output it as raw type
|
||||
spec.append(elements[0].id)
|
||||
else:
|
||||
spec.append("STRING")
|
||||
|
||||
if len(elements) > 1 and isinstance(elements[1], ast.Dict):
|
||||
opts = {}
|
||||
for dk, dv in zip(elements[1].keys, elements[1].values):
|
||||
if isinstance(dk, ast.Constant) and isinstance(dk.value, str):
|
||||
if isinstance(dv, ast.Constant):
|
||||
opts[dk.value] = dv.value
|
||||
elif isinstance(dv, ast.UnaryOp) and isinstance(dv.op, ast.USub) and isinstance(dv.operand, ast.Constant):
|
||||
opts[dk.value] = -dv.operand.value
|
||||
spec.append(opts)
|
||||
result[k.value] = spec
|
||||
elif isinstance(v, ast.Dict):
|
||||
result[k.value] = self.extract_dict_from_ast(v)
|
||||
elif isinstance(v, ast.Constant):
|
||||
result[k.value] = v.value
|
||||
return result
|
||||
|
||||
def generate_schema_code(self):
|
||||
inputs_code = []
|
||||
required = self.inputs_dict.get("required", {})
|
||||
optional = self.inputs_dict.get("optional", {})
|
||||
hidden = self.inputs_dict.get("hidden", {})
|
||||
|
||||
for name, spec in required.items():
|
||||
if not isinstance(spec, (list, tuple)): spec = [spec]
|
||||
inputs_code.append(" " + format_input(name, spec, False) + ",")
|
||||
for name, spec in optional.items():
|
||||
if not isinstance(spec, (list, tuple)): spec = [spec]
|
||||
inputs_code.append(" " + format_input(name, spec, True) + ",")
|
||||
|
||||
outputs_code = []
|
||||
for index, output_type in enumerate(self.return_types):
|
||||
name = self.return_names[index] if index < len(self.return_names) else f"output_{index + 1}"
|
||||
type_name = str(output_type).upper()
|
||||
type_str = "String"
|
||||
if type_name == "MASK": type_str = "Mask"
|
||||
elif "IMAGE" in type_name or "VIDEO" in type_name: type_str = "Image"
|
||||
elif type_name == "INT": type_str = "Int"
|
||||
elif type_name == "FLOAT": type_str = "Float"
|
||||
elif type_name in {"BOOLEAN", "BOOL"}: type_str = "Boolean"
|
||||
outputs_code.append(f' io.{type_str}.Output("{name}", display_name="{name}"),')
|
||||
|
||||
hidden_code = []
|
||||
for name in hidden:
|
||||
hidden_code.append(f"io.Hidden.{name}")
|
||||
hidden_str = f",\n hidden=[{', '.join(hidden_code)}]" if hidden_code else ""
|
||||
|
||||
return f"""@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="{self.node_id}",
|
||||
display_name="{self.display_name}",
|
||||
category="{self.category}",
|
||||
inputs=[
|
||||
{chr(10).join(inputs_code)}
|
||||
],
|
||||
outputs=[
|
||||
{chr(10).join(outputs_code)}
|
||||
]{hidden_str}
|
||||
)
|
||||
"""
|
||||
|
||||
def visit_ClassDef(self, node):
|
||||
if not node.name.startswith("ImageOps"):
|
||||
return node
|
||||
|
||||
# Already migrated?
|
||||
has_define_schema = any(isinstance(n, ast.FunctionDef) and n.name == "define_schema" for n in node.body)
|
||||
if has_define_schema:
|
||||
return node
|
||||
|
||||
self.modified = True
|
||||
|
||||
# Modify bases to inherit from io.ComfyNode
|
||||
if not any(isinstance(b, ast.Attribute) and b.value.id == "io" and b.attr == "ComfyNode" for b in node.bases):
|
||||
node.bases = [ast.parse("io.ComfyNode").body[0].value]
|
||||
|
||||
# Extract metadata
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.Assign):
|
||||
targets = [t.id for t in child.targets if isinstance(t, ast.Name)]
|
||||
if "CATEGORY" in targets and isinstance(child.value, ast.Constant):
|
||||
self.category = child.value.value
|
||||
elif "FUNCTION" in targets and isinstance(child.value, ast.Constant):
|
||||
self.function_name = child.value.value
|
||||
elif "RETURN_TYPES" in targets and isinstance(child.value, ast.Tuple):
|
||||
self.return_types = [e.value for e in child.value.elts if isinstance(e, ast.Constant)]
|
||||
elif "RETURN_NAMES" in targets and isinstance(child.value, ast.Tuple):
|
||||
self.return_names = [e.value for e in child.value.elts if isinstance(e, ast.Constant)]
|
||||
|
||||
if isinstance(child, ast.FunctionDef) and child.name == "INPUT_TYPES":
|
||||
# Find return dictionary
|
||||
for stmt in child.body:
|
||||
if isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.Dict):
|
||||
self.inputs_dict = self.extract_dict_from_ast(stmt.value)
|
||||
|
||||
# Now remove legacy attributes and inject schema
|
||||
new_body = []
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.Assign):
|
||||
targets = [t.id for t in child.targets if isinstance(t, ast.Name)]
|
||||
if any(t in {"CATEGORY", "RETURN_TYPES", "RETURN_NAMES", "FUNCTION"} for t in targets):
|
||||
continue
|
||||
if isinstance(child, ast.FunctionDef):
|
||||
if child.name == "INPUT_TYPES":
|
||||
# inject define_schema
|
||||
schema_code = self.generate_schema_code()
|
||||
schema_ast = ast.parse(schema_code).body[0]
|
||||
new_body.append(schema_ast)
|
||||
continue
|
||||
if child.name == self.function_name:
|
||||
# Rename function to execute, make it classmethod, replace self with cls
|
||||
child.name = "execute"
|
||||
if not any(isinstance(d, ast.Name) and d.id == "classmethod" for d in child.decorator_list):
|
||||
child.decorator_list.insert(0, ast.Name(id="classmethod", ctx=ast.Load()))
|
||||
if child.args.args and child.args.args[0].arg == "self":
|
||||
child.args.args[0].arg = "cls"
|
||||
|
||||
class SelfReplacer(ast.NodeTransformer):
|
||||
def visit_Name(self, n):
|
||||
if n.id == "self":
|
||||
n.id = "cls"
|
||||
return n
|
||||
child = SelfReplacer().visit(child)
|
||||
new_body.append(child)
|
||||
|
||||
node.body = new_body
|
||||
return node
|
||||
|
||||
|
||||
def process_file(filepath):
|
||||
content = filepath.read_text("utf-8")
|
||||
|
||||
if "io.Schema" in content:
|
||||
return
|
||||
|
||||
class_match = re.search(r"class (ImageOps[A-Za-z]+)(?:\(\w+\))?:", content)
|
||||
if not class_match:
|
||||
return
|
||||
class_name = class_match.group(1)
|
||||
|
||||
display_name = re.sub(r"(?<!^)(?=[A-Z])", " ", class_name).strip()
|
||||
display_name = display_name.replace("Ajust", "Color Correct").strip()
|
||||
display_name = "〽️ " + display_name
|
||||
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
except Exception as e:
|
||||
print(f"Error parsing {filepath.name}: {e}")
|
||||
return
|
||||
|
||||
migrator = NodeMigrator(class_name, display_name)
|
||||
new_tree = migrator.visit(tree)
|
||||
|
||||
if not migrator.modified:
|
||||
return
|
||||
|
||||
# Unparse code
|
||||
new_content = ast.unparse(new_tree)
|
||||
|
||||
# Add imports
|
||||
if "from comfy_api.latest import io" not in new_content and "import io" not in new_content:
|
||||
if "from __future__ import annotations" in new_content:
|
||||
new_content = new_content.replace("from __future__ import annotations", "from __future__ import annotations\nfrom comfy_api.latest import io")
|
||||
else:
|
||||
new_content = "from comfy_api.latest import io\n" + new_content
|
||||
|
||||
# Fix variables that got quoted by AST extraction
|
||||
new_content = re.sub(r'options="(_[A-Z_]+)"', r'options=\1', new_content)
|
||||
new_content = re.sub(r'types=\[\'io.Image\', \'io.Video\'\]', r'types=[io.Image, io.Video]', new_content)
|
||||
# Add **kwargs to execute if missing, but only if no ** exists
|
||||
new_content = re.sub(r'def execute\(cls, ([^)]+)\):', lambda m: f'def execute(cls, {m.group(1)}):' if '**' in m.group(1) else f'def execute(cls, {m.group(1)}, **kwargs):', new_content)
|
||||
|
||||
filepath.write_text(new_content, "utf-8")
|
||||
print(f"Migrated {filepath.name} to V3")
|
||||
|
||||
|
||||
def run():
|
||||
nodes_dir = BASE_DIR / "nodes"
|
||||
for py_file in nodes_dir.glob("*.py"):
|
||||
if py_file.name.startswith("_") or py_file.name == "comp.py":
|
||||
continue
|
||||
process_file(py_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,4 +1,22 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import {
|
||||
applyEffectToCanvas,
|
||||
blend,
|
||||
boolAny,
|
||||
compositeAt,
|
||||
compositeProcessedWithMask,
|
||||
fitCanvas,
|
||||
invertMaskCanvas,
|
||||
num,
|
||||
numAny,
|
||||
ops,
|
||||
premultLayerWithMask,
|
||||
renderCompPreview,
|
||||
resolveCompPreviewInputs,
|
||||
resolvePreviewMaskCanvas,
|
||||
strAny,
|
||||
w
|
||||
} from "./implementation.js";
|
||||
import type { ComfyNode } from "../../types.js";
|
||||
export { blendChannel01 } from "../shared/blend-modes.js";
|
||||
|
||||
export const blendOps = {
|
||||
@@ -6,3 +24,48 @@ export const blendOps = {
|
||||
composite: ops.composite,
|
||||
comp: ops.comp,
|
||||
};
|
||||
|
||||
|
||||
// Extracted with ts-morph
|
||||
|
||||
export function merge(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, topCanvasOrInputs: HTMLCanvasElement | HTMLCanvasElement[], _opts?: any, frameIndex: number = 0): HTMLCanvasElement {
|
||||
const inputs = Array.isArray(topCanvasOrInputs)
|
||||
? topCanvasOrInputs
|
||||
: [ctx.canvas, topCanvasOrInputs];
|
||||
const base = inputs[0] ?? ctx.canvas;
|
||||
const topCanvas = inputs[1] ?? null;
|
||||
if (!topCanvas) return fitCanvas(base, base.width || 1, base.height || 1);
|
||||
const mode = strAny(node, ["mode", "blend_mode"], "over", frameIndex);
|
||||
const foregroundFit = strAny(node, ["foreground_fit", "fit_mode"], "stretch", frameIndex);
|
||||
const blendSpace = strAny(node, ["blend_space", "color_space"], "linear", frameIndex);
|
||||
const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100, frameIndex) : numAny(node, ["mix", "factor", "fade_factor", "blend_factor", "start_level", "end_level"], 1, frameIndex);
|
||||
const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity;
|
||||
const merged = applyEffectToCanvas(base, (effectCtx, width, height) => {
|
||||
blend(effectCtx, width, height, topCanvas, mode, opacity, foregroundFit, blendSpace);
|
||||
});
|
||||
const effectMask = resolvePreviewMaskCanvas(node, base, inputs[2] ?? null, frameIndex);
|
||||
return effectMask ? compositeProcessedWithMask(base, merged, effectMask) : merged;
|
||||
}
|
||||
|
||||
export function composite(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): HTMLCanvasElement {
|
||||
const base = inputs[0] ?? ctx.canvas;
|
||||
const topInput = inputs[1] ?? null;
|
||||
if (!topInput) return base;
|
||||
const rawMask = inputs[2] ?? null;
|
||||
const maskInput = rawMask && boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(rawMask) : rawMask;
|
||||
const top = premultLayerWithMask(topInput, maskInput);
|
||||
const x = Math.round(numAny(node, ["x"], 0) + numAny(node, ["offset_x"], 0));
|
||||
const y = Math.round(numAny(node, ["y"], 0) + numAny(node, ["offset_y"], 0));
|
||||
const mode = strAny(node, ["mode", "blend_mode"], "over");
|
||||
const rawOpacity = w(node, "opacity") ? num(node, "opacity", 100) : numAny(node, ["mix", "factor", "blend_factor", "start_level", "end_level"], 1);
|
||||
const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity;
|
||||
return compositeAt(base, top, mode, opacity, x, y, top.width || 1, top.height || 1);
|
||||
}
|
||||
|
||||
export function comp(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): HTMLCanvasElement {
|
||||
return renderCompPreview(
|
||||
node,
|
||||
resolveCompPreviewInputs(node, inputs),
|
||||
).canvas;
|
||||
}
|
||||
|
||||
|
||||
+282
-1
@@ -1,4 +1,40 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import { setWidgetValue } from "../shared/widgets.js";
|
||||
import {
|
||||
applyCrop,
|
||||
applyCropReformat,
|
||||
applyEffectToCanvas,
|
||||
applySpherize,
|
||||
applyTransform,
|
||||
boolAny,
|
||||
composeCropStitchPreview,
|
||||
computeMaskBounds,
|
||||
cropRectCanvas,
|
||||
extractMaskDrivenCrop,
|
||||
fitCanvas,
|
||||
flipCanvas,
|
||||
getCanvasDimensions,
|
||||
makeCanvas,
|
||||
normalizeFilterName,
|
||||
num,
|
||||
numAny,
|
||||
ops,
|
||||
parseHexColor,
|
||||
renderCornerPinCanvases,
|
||||
renderCropStitchCanvases,
|
||||
renderDistortCanvas,
|
||||
renderMaskedEffectPreview,
|
||||
renderPadOutCanvases,
|
||||
resizeWithMode,
|
||||
resolveResizeDimensions,
|
||||
rotateDiscrete,
|
||||
setResampleMode,
|
||||
smoothShakeValue,
|
||||
str,
|
||||
strAny,
|
||||
w,
|
||||
wAny
|
||||
} from "./implementation.js";
|
||||
import type { ComfyNode, RenderInputInfo } from "../../types.js";
|
||||
export * from "../shared/geometry.js";
|
||||
|
||||
export const geometryOps = {
|
||||
@@ -16,3 +52,248 @@ export const geometryOps = {
|
||||
distort: ops.distort,
|
||||
spherize: ops.spherize,
|
||||
};
|
||||
|
||||
|
||||
// Extracted with ts-morph
|
||||
|
||||
export function crop(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const rawMask = inputs[1] ?? null;
|
||||
return renderMaskedEffectPreview(
|
||||
node,
|
||||
source,
|
||||
rawMask,
|
||||
(input) => applyEffectToCanvas(input, (effectCtx, width, height) => applyCrop(
|
||||
effectCtx,
|
||||
node,
|
||||
width,
|
||||
height,
|
||||
str(node, "aspect_ratio", "custom", frameIndex),
|
||||
num(node, "width", width, frameIndex),
|
||||
num(node, "height", height, frameIndex),
|
||||
)),
|
||||
{
|
||||
frameIndex,
|
||||
premultBeforeProcess: true,
|
||||
compositeWithBase: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function cropStitch(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
if (inputs.length < 2) return inputs[0] ?? ctx.canvas;
|
||||
const rendered = renderCropStitchCanvases(node, inputs, frameIndex);
|
||||
return composeCropStitchPreview(rendered.image, rendered.crop, rendered.mask, rendered.bbox);
|
||||
}
|
||||
|
||||
export function padOut(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderPadOutCanvases(node, source, frameIndex).image;
|
||||
}
|
||||
|
||||
export function cornerPin(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderCornerPinCanvases(node, source, frameIndex).image;
|
||||
}
|
||||
|
||||
export function cropGeneric(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], inputInfos: RenderInputInfo[] = []): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const sourceWidth = source.width || 1;
|
||||
const sourceHeight = source.height || 1;
|
||||
let targetWidth = Math.max(1, Math.round(numAny(node, ["width", "target_width", "base_resolution"], sourceWidth)));
|
||||
let targetHeight = Math.max(1, Math.round(numAny(node, ["height", "target_height", "base_resolution"], sourceHeight)));
|
||||
|
||||
const maskInput = inputs[1] ?? null;
|
||||
if (maskInput) {
|
||||
if (!wAny(node, ["width", "target_width", "height", "target_height"]) && !!w(node, "base_resolution")) {
|
||||
const fittedMask = fitCanvas(maskInput, sourceWidth, sourceHeight);
|
||||
const bounds = computeMaskBounds(fittedMask);
|
||||
if (bounds) {
|
||||
const aspect = bounds.width / Math.max(1, bounds.height);
|
||||
const baseResolution = Math.max(1, Math.round(numAny(node, ["base_resolution"], Math.max(bounds.width, bounds.height))));
|
||||
if (aspect >= 1) {
|
||||
targetWidth = baseResolution;
|
||||
targetHeight = Math.max(1, Math.round(baseResolution / aspect));
|
||||
} else {
|
||||
targetHeight = baseResolution;
|
||||
targetWidth = Math.max(1, Math.round(baseResolution * aspect));
|
||||
}
|
||||
}
|
||||
}
|
||||
return extractMaskDrivenCrop(source, maskInput, numAny(node, ["padding"], 0), targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
const cropRegion = w(node, "crop_region")?.value as { x?: number; y?: number; width?: number; height?: number } | null;
|
||||
const bboxNode = inputInfos[1]?.upstreamNode ?? null;
|
||||
const x = Math.max(0, Math.round(cropRegion?.x ?? numAny(bboxNode ?? node, ["x", "x_offset"], 0)));
|
||||
const y = Math.max(0, Math.round(cropRegion?.y ?? numAny(bboxNode ?? node, ["y", "y_offset"], 0)));
|
||||
const width = Math.max(1, Math.round(cropRegion?.width ?? numAny(bboxNode ?? node, ["width", "crop_w"], sourceWidth)));
|
||||
const height = Math.max(1, Math.round(cropRegion?.height ?? numAny(bboxNode ?? node, ["height", "crop_h"], sourceHeight)));
|
||||
return cropRectCanvas(source, x, y, Math.min(width, sourceWidth - x), Math.min(height, sourceHeight - y));
|
||||
}
|
||||
|
||||
export function transform(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const rawMask = inputs[1] ?? null;
|
||||
const transformImage = (input: HTMLCanvasElement): HTMLCanvasElement => {
|
||||
let working = input;
|
||||
const flip = strAny(node, ["flip", "mirror"], "none", frameIndex).toLowerCase();
|
||||
const flipMethod = strAny(node, ["flip_method"], "", frameIndex);
|
||||
const horizontal = flip === "horizontal" || flipMethod.startsWith("y");
|
||||
const vertical = flip === "vertical" || flipMethod.startsWith("x");
|
||||
working = flipCanvas(working, horizontal, vertical);
|
||||
|
||||
const rotationLabel = strAny(node, ["rotation"], "", frameIndex);
|
||||
if (rotationLabel.startsWith("90")) working = rotateDiscrete(working, 1);
|
||||
else if (rotationLabel.startsWith("180")) working = rotateDiscrete(working, 2);
|
||||
else if (rotationLabel.startsWith("270")) working = rotateDiscrete(working, 3);
|
||||
|
||||
const aspectRatio = numAny(node, ["aspect_ratio"], 1, frameIndex);
|
||||
if (Math.abs(aspectRatio - 1) > 0.0001) {
|
||||
const scaled = makeCanvas(working.width || 1, Math.max(1, Math.round((working.height || 1) * aspectRatio)));
|
||||
const sctx = scaled.getContext("2d", { willReadFrequently: true })!;
|
||||
setResampleMode(sctx, normalizeFilterName(strAny(node, ["upscale_method", "interpolation", "transform_method", "filter"], "bilinear", frameIndex)));
|
||||
sctx.drawImage(working, 0, 0, scaled.width, scaled.height);
|
||||
working = scaled;
|
||||
}
|
||||
|
||||
const tx = numAny(node, ["translate_x", "x", "shift_x"], 0, frameIndex);
|
||||
const ty = numAny(node, ["translate_y", "y", "shift_y"], 0, frameIndex);
|
||||
const rot = numAny(node, ["rotate_deg", "rotate"], 0, frameIndex);
|
||||
const scale = numAny(node, ["scale"], 1, frameIndex);
|
||||
const filter = normalizeFilterName(strAny(node, ["filter", "upscale_method", "interpolation", "transform_method"], "bilinear", frameIndex));
|
||||
const expand = boolAny(node, ["expand"], false, frameIndex);
|
||||
const fillMode = strAny(node, ["fill_mode", "edge_mode"], "transparent", frameIndex);
|
||||
const fillColor = strAny(node, ["fill_color", "background_color", "color"], "#000000", frameIndex);
|
||||
|
||||
return applyEffectToCanvas(working, (effectCtx, width, height) => {
|
||||
return applyTransform(effectCtx, width, height, tx, ty, rot, scale, filter, expand, fillMode, fillColor);
|
||||
});
|
||||
};
|
||||
|
||||
return renderMaskedEffectPreview(
|
||||
node,
|
||||
source,
|
||||
rawMask,
|
||||
transformImage,
|
||||
{
|
||||
frameIndex,
|
||||
premultBeforeProcess: true,
|
||||
compositeWithBase: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function cameraShake(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const transformImage = (input: HTMLCanvasElement): HTMLCanvasElement => {
|
||||
const translate = Math.max(0, numAny(node, ["translate_px"], 12, frameIndex));
|
||||
const rotate = Math.max(0, numAny(node, ["rotate_deg"], 1.5, frameIndex));
|
||||
const zoom = Math.max(0, numAny(node, ["zoom"], 0.03, frameIndex));
|
||||
const smoothing = numAny(node, ["smoothing"], 0.65, frameIndex);
|
||||
const frequency = Math.max(0.01, numAny(node, ["shake_frequency", "frequency"], 1, frameIndex));
|
||||
const seed = Math.max(0, Math.round(numAny(node, ["seed"], 12345, frameIndex)));
|
||||
const tx = smoothShakeValue(seed + 11, frameIndex, 1, translate, smoothing, frequency);
|
||||
const ty = smoothShakeValue(seed + 23, frameIndex, 2, translate, smoothing, frequency);
|
||||
const rot = smoothShakeValue(seed + 37, frameIndex, 3, rotate, smoothing, frequency);
|
||||
const scale = Math.max(0.01, 1 + smoothShakeValue(seed + 53, frameIndex, 4, zoom, smoothing, frequency));
|
||||
return applyEffectToCanvas(input, (effectCtx, width, height) => {
|
||||
return applyTransform(
|
||||
effectCtx,
|
||||
width,
|
||||
height,
|
||||
tx,
|
||||
ty,
|
||||
rot,
|
||||
scale,
|
||||
strAny(node, ["filter"], "bilinear", frameIndex),
|
||||
false,
|
||||
strAny(node, ["fill_mode"], "mirror", frameIndex),
|
||||
strAny(node, ["fill_color"], "#000000", frameIndex),
|
||||
);
|
||||
});
|
||||
};
|
||||
return renderMaskedEffectPreview(node, source, inputs[1] ?? null, transformImage, {
|
||||
frameIndex,
|
||||
premultBeforeProcess: true,
|
||||
compositeWithBase: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function cropReformat(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): void {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
applyCropReformat(ctx,width,height,
|
||||
numAny(node,["x"],0), numAny(node,["y"],0),
|
||||
numAny(node,["crop_w", "width"],width), numAny(node,["crop_h", "height"],height),
|
||||
numAny(node,["padding"],0),
|
||||
numAny(node,["out_w", "target_width"],0), numAny(node,["out_h", "target_height"],0),
|
||||
strAny(node,["mode", "method"],"fit")
|
||||
);
|
||||
}
|
||||
|
||||
export function resize(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): HTMLCanvasElement {
|
||||
const { width, height } = getCanvasDimensions(ctx);
|
||||
const resolved = resolveResizeDimensions(node, width, height);
|
||||
return resizeWithMode(ctx.canvas, resolved.width, resolved.height, resolved.filter, resolved.mode, resolved.fillColor, resolved.cropPosition);
|
||||
}
|
||||
|
||||
export function pad(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = []): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const top = Math.max(0, Math.round(numAny(node, ["top"], 0)));
|
||||
const bottom = Math.max(0, Math.round(numAny(node, ["bottom"], 0)));
|
||||
const left = Math.max(0, Math.round(numAny(node, ["left"], 0)));
|
||||
const right = Math.max(0, Math.round(numAny(node, ["right"], 0)));
|
||||
const output = makeCanvas((source.width || 1) + left + right, (source.height || 1) + top + bottom);
|
||||
const octx = output.getContext("2d", { willReadFrequently: true })!;
|
||||
octx.fillStyle = parseHexColor(strAny(node, ["color", "background_color", "pad_color", "padding_color"], "#808080"));
|
||||
octx.fillRect(0, 0, output.width, output.height);
|
||||
octx.drawImage(source, left, top, source.width || 1, source.height || 1);
|
||||
return output;
|
||||
}
|
||||
|
||||
export function flipRotate(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): HTMLCanvasElement {
|
||||
const flipMethod = strAny(node, ["flip_method"], "");
|
||||
const horizontal = flipMethod.startsWith("y");
|
||||
const vertical = flipMethod.startsWith("x");
|
||||
let working = flipCanvas(ctx.canvas, horizontal, vertical);
|
||||
const rotation = strAny(node, ["rotation"], "");
|
||||
if (rotation.startsWith("90")) working = rotateDiscrete(working, 1);
|
||||
else if (rotation.startsWith("180")) working = rotateDiscrete(working, 2);
|
||||
else if (rotation.startsWith("270")) working = rotateDiscrete(working, 3);
|
||||
return working;
|
||||
}
|
||||
|
||||
export function distort(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[], frameIndex: number = 0): HTMLCanvasElement {
|
||||
return renderDistortCanvas(node, inputs, frameIndex).image;
|
||||
}
|
||||
|
||||
export function spherize(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
let source = inputs[0] ?? ctx.canvas;
|
||||
|
||||
const sizeMode = strAny(node, ["size_mode"], "from_input", frameIndex).toLowerCase().trim();
|
||||
if (sizeMode === "custom") {
|
||||
const tw = Math.max(64, Math.round(numAny(node, ["width"], 512, frameIndex)));
|
||||
const th = Math.max(64, Math.round(numAny(node, ["height"], 512, frameIndex)));
|
||||
if (tw !== source.width || th !== source.height) {
|
||||
const resized = makeCanvas(tw, th);
|
||||
resized.getContext("2d", { willReadFrequently: true })!.drawImage(source, 0, 0, tw, th);
|
||||
source = resized;
|
||||
}
|
||||
} else {
|
||||
// from_input: sync width/height widgets to actual source dimensions
|
||||
const ww = w(node, "width");
|
||||
const hw = w(node, "height");
|
||||
setWidgetValue(ww, Math.max(64, source.width));
|
||||
setWidgetValue(hw, Math.max(64, source.height));
|
||||
}
|
||||
|
||||
return applyEffectToCanvas(source, (effectCtx, width, height) => {
|
||||
applySpherize(
|
||||
effectCtx, width, height,
|
||||
strAny(node, ["mode"], "spherize", frameIndex),
|
||||
numAny(node, ["strength"], 1.0, frameIndex),
|
||||
boolAny(node, ["invert"], false, frameIndex),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+171
-612
File diff suppressed because it is too large
Load Diff
+175
-1
@@ -1,6 +1,180 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import {
|
||||
alphaMaskCanvas,
|
||||
applyChannel,
|
||||
applyClamp,
|
||||
applyEffectToCanvas,
|
||||
boolAny,
|
||||
buildMaskAlphaCanvas,
|
||||
clamp01,
|
||||
fitCanvas,
|
||||
getImageData,
|
||||
imageToMaskPreviewCanvas,
|
||||
invertMaskCanvas,
|
||||
makeCanvas,
|
||||
markPreparedMaskCanvas,
|
||||
numAny,
|
||||
ops,
|
||||
putImageData,
|
||||
renderConstantCanvas,
|
||||
renderCornerPinCanvases,
|
||||
renderCropStitchCanvases,
|
||||
renderDistortCanvas,
|
||||
renderKeyerCanvases,
|
||||
renderNoiseCanvas,
|
||||
renderPadOutCanvases,
|
||||
renderRampCanvas,
|
||||
renderSpherizeMaskCanvas,
|
||||
resolvePreviewMaskCanvas,
|
||||
strAny
|
||||
} from "./implementation.js";
|
||||
import type { ComfyNode } from "../../types.js";
|
||||
|
||||
export const maskOps = {
|
||||
imageOpsMask: ops.imageOpsMask,
|
||||
channelApply: ops.channelApply,
|
||||
};
|
||||
|
||||
|
||||
// Extracted with ts-morph
|
||||
|
||||
export function channelApply(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): HTMLCanvasElement {
|
||||
const base = fitCanvas(inputs[0] ?? ctx.canvas, (inputs[0] ?? ctx.canvas).width || 1, (inputs[0] ?? ctx.canvas).height || 1);
|
||||
const mask = inputs[1] ? fitCanvas(inputs[1], base.width, base.height) : null;
|
||||
if (!mask) return base;
|
||||
|
||||
const bctx = base.getContext("2d", { willReadFrequently: true })!;
|
||||
const image = bctx.getImageData(0, 0, base.width, base.height);
|
||||
const data = image.data;
|
||||
const matte = mask.getContext("2d", { willReadFrequently: true })!.getImageData(0, 0, base.width, base.height).data;
|
||||
const channel = strAny(node, ["channel"], "A").toLowerCase();
|
||||
const channelIndex = channel === "g" || channel === "green" ? 1 : channel === "b" || channel === "blue" ? 2 : channel === "a" || channel === "alpha" ? 3 : 0;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const value = Math.round(clamp01((matte[i] / 255) * (matte[i + 3] / 255)) * 255);
|
||||
data[i + channelIndex] = value;
|
||||
}
|
||||
bctx.putImageData(image, 0, 0);
|
||||
return base;
|
||||
}
|
||||
|
||||
export function imageOpsMask(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, cls: string, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement | null {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
const rawMask = inputs[1] ?? null;
|
||||
const resolvedMask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex);
|
||||
|
||||
if (cls === "ImageOpsMaskConvert") {
|
||||
return boolAny(node, ["reverse"], false, frameIndex)
|
||||
? imageToMaskPreviewCanvas(source, node, frameIndex)
|
||||
: buildMaskAlphaCanvas(source, source.width || 1, source.height || 1);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsNoise") {
|
||||
return renderNoiseCanvas(node, true, frameIndex, W);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsConstant") {
|
||||
return renderConstantCanvas(node, true);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsRamp") {
|
||||
return renderRampCanvas(node, true);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsDistort") {
|
||||
return renderDistortCanvas(node, inputs, frameIndex).mask;
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsBlur") {
|
||||
// Backend blur.py returns the *original* prepared mask as MASK output
|
||||
// (output_mask = output_mask_source) — the blur affects the image, not the
|
||||
// mask itself. Mirror that here so the Preview MASK matches what's
|
||||
// actually sent downstream. If no upstream mask, fall back to the
|
||||
// implicit alpha matte of the source.
|
||||
return resolvedMask ?? alphaMaskCanvas(source);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsTransform") {
|
||||
return ops.transform(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsCrop") {
|
||||
return ops.crop(ctx, W, node, [resolvedMask ?? alphaMaskCanvas(source)], frameIndex);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsCropStitch") {
|
||||
return renderCropStitchCanvases(node, inputs, frameIndex).mask;
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsPadOut") {
|
||||
return renderPadOutCanvases(node, source, frameIndex).mask;
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsCornerPin") {
|
||||
return renderCornerPinCanvases(node, source, frameIndex).mask;
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsChannel") {
|
||||
const extracted = applyEffectToCanvas(source, (effectCtx, width, height) => {
|
||||
applyChannel(effectCtx, width, height, strAny(node, ["channel"], "Red", frameIndex));
|
||||
});
|
||||
return buildMaskAlphaCanvas(extracted, extracted.width || 1, extracted.height || 1);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsClamp") {
|
||||
if (!resolvedMask) return alphaMaskCanvas(source);
|
||||
// Clamp only the alpha channel of the prepared mask (which encodes mask value in A,
|
||||
// RGB=255). Using applyClamp on the full canvas would turn RGB gray and cause
|
||||
// buildMaskAlphaCanvas to double-attenuate via luma*alpha.
|
||||
const lo = numAny(node, ["min_v", "min"], 0, frameIndex);
|
||||
const hi = numAny(node, ["max_v", "max"], 1, frameIndex);
|
||||
const mn = Math.round(clamp01(Math.min(lo, hi)) * 255);
|
||||
const mx = Math.round(clamp01(Math.max(lo, hi)) * 255);
|
||||
const clampMaskOut = makeCanvas(resolvedMask.width || 1, resolvedMask.height || 1);
|
||||
const clampMaskCtx = clampMaskOut.getContext("2d", { willReadFrequently: true })!;
|
||||
clampMaskCtx.drawImage(resolvedMask, 0, 0);
|
||||
const clampImg = clampMaskCtx.getImageData(0, 0, clampMaskOut.width, clampMaskOut.height);
|
||||
const clampData = clampImg.data;
|
||||
for (let ci = 0; ci < clampData.length; ci += 4) {
|
||||
clampData[ci] = 255; clampData[ci + 1] = 255; clampData[ci + 2] = 255;
|
||||
clampData[ci + 3] = Math.max(mn, Math.min(mx, clampData[ci + 3]));
|
||||
}
|
||||
clampMaskCtx.putImageData(clampImg, 0, 0);
|
||||
return markPreparedMaskCanvas(clampMaskOut);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsKeyer") {
|
||||
return renderKeyerCanvases(node, source, rawMask, frameIndex).mask;
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsInvert") {
|
||||
const mask = resolvedMask ?? alphaMaskCanvas(source);
|
||||
return mask;
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsSpherize") {
|
||||
return renderSpherizeMaskCanvas(node, source, rawMask, frameIndex);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsMerge") {
|
||||
// inputs = [A, B, mask?] — inputs[1] is foreground B, not the mask input
|
||||
const mergeMaskInput = inputs[2] ?? null;
|
||||
if (mergeMaskInput) {
|
||||
const mergeResolvedMask = resolvePreviewMaskCanvas(node, source, mergeMaskInput, frameIndex);
|
||||
if (mergeResolvedMask) return mergeResolvedMask;
|
||||
}
|
||||
const merged = ops.merge(ctx, W, node, inputs, undefined, frameIndex);
|
||||
return alphaMaskCanvas(merged);
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsComp") {
|
||||
const mask = alphaMaskCanvas(ops.comp(ctx, W, node, inputs));
|
||||
return boolAny(node, ["invert_mask"], false, frameIndex) ? invertMaskCanvas(mask) : mask;
|
||||
}
|
||||
|
||||
if (cls === "ImageOpsColorAjust") {
|
||||
return resolvedMask ?? alphaMaskCanvas(source);
|
||||
}
|
||||
|
||||
return resolvedMask ?? alphaMaskCanvas(source);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import { renderDrawPreview, resolveDrawOverlayCanvas } from "../draw.js";
|
||||
import {
|
||||
boolAny,
|
||||
buildMaskAlphaCanvas,
|
||||
getImageData,
|
||||
invertMaskCanvas,
|
||||
makeCanvas,
|
||||
markPreparedMaskCanvas,
|
||||
numAny,
|
||||
ops,
|
||||
putImageData,
|
||||
renderConstantCanvas,
|
||||
renderGrainCanvas,
|
||||
renderKeyerCanvases,
|
||||
renderNoiseCanvas,
|
||||
renderRampCanvas,
|
||||
renderTextCanvas,
|
||||
stitchCanvases,
|
||||
strAny
|
||||
} from "./implementation.js";
|
||||
import type { ComfyNode } from "../../types.js";
|
||||
|
||||
export const proceduralOps = {
|
||||
constant: ops.constant,
|
||||
@@ -7,3 +27,80 @@ export const proceduralOps = {
|
||||
grain: ops.grain,
|
||||
text: ops.text,
|
||||
};
|
||||
|
||||
|
||||
// Extracted with ts-morph
|
||||
|
||||
export function grain(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderGrainCanvas(node, source, inputs[1] ?? null, frameIndex);
|
||||
}
|
||||
|
||||
export function text(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderTextCanvas(node, source, inputs[1] ?? null, frameIndex);
|
||||
}
|
||||
|
||||
export function keyer(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement {
|
||||
const source = inputs[0] ?? ctx.canvas;
|
||||
return renderKeyerCanvases(node, source, inputs[1] ?? null, frameIndex).image;
|
||||
}
|
||||
|
||||
export function stitch(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): HTMLCanvasElement {
|
||||
const first = inputs[0] ?? ctx.canvas;
|
||||
const second = inputs[1] ?? null;
|
||||
if (!second) return first;
|
||||
return stitchCanvases(
|
||||
first,
|
||||
second,
|
||||
strAny(node, ["direction"], "right"),
|
||||
numAny(node, ["spacing_width"], 0),
|
||||
strAny(node, ["spacing_color"], "black"),
|
||||
boolAny(node, ["match_image_size"], true),
|
||||
);
|
||||
}
|
||||
|
||||
export function constant(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): HTMLCanvasElement {
|
||||
return renderConstantCanvas(node, false);
|
||||
}
|
||||
|
||||
export function ramp(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): HTMLCanvasElement {
|
||||
return renderRampCanvas(node, false);
|
||||
}
|
||||
|
||||
export function noise(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, frameIndex: number = 0): HTMLCanvasElement {
|
||||
return renderNoiseCanvas(node, false, frameIndex, W);
|
||||
}
|
||||
|
||||
export async function draw(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): Promise<HTMLCanvasElement> {
|
||||
return await renderDrawPreview(node, inputs[0] ?? null);
|
||||
}
|
||||
|
||||
export async function drawMask(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): Promise<HTMLCanvasElement> {
|
||||
const base = inputs[0] ?? null;
|
||||
const width = base?.width || Math.max(1, Math.round(numAny(node, ["width"], 1024)));
|
||||
const height = base?.height || Math.max(1, Math.round(numAny(node, ["height"], 1024)));
|
||||
const overlay = await resolveDrawOverlayCanvas(node, width, height);
|
||||
// Backend draw.py returns overlay_alpha[..., 0] as the mask (alpha channel
|
||||
// only, brush colour is irrelevant). buildMaskAlphaCanvas applies luma*alpha
|
||||
// which would dim the preview mask whenever the user paints with a non-white
|
||||
// colour — that diverges from the backend. Build an alpha-only matte instead.
|
||||
const ow = overlay.width || 1;
|
||||
const oh = overlay.height || 1;
|
||||
const matte = makeCanvas(ow, oh);
|
||||
const mctx = matte.getContext("2d", { willReadFrequently: true })!;
|
||||
mctx.drawImage(overlay, 0, 0);
|
||||
const img = mctx.getImageData(0, 0, ow, oh);
|
||||
const data = img.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = a;
|
||||
}
|
||||
mctx.putImageData(img, 0, 0);
|
||||
const mask = markPreparedMaskCanvas(matte);
|
||||
return boolAny(node, ["invert_mask"], false) ? invertMaskCanvas(mask) : mask;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ops } from "./implementation.js";
|
||||
import {
|
||||
extractSplitChannelCanvas,
|
||||
mergeChannelInputs,
|
||||
ops,
|
||||
strAny
|
||||
} from "./implementation.js";
|
||||
import type { ComfyNode } from "../../types.js";
|
||||
|
||||
export const videoOps = {
|
||||
draw: ops.draw,
|
||||
@@ -8,3 +14,15 @@ export const videoOps = {
|
||||
channelSplit: ops.channelSplit,
|
||||
channelMerge: ops.channelMerge,
|
||||
};
|
||||
|
||||
|
||||
// Extracted with ts-morph
|
||||
|
||||
export function channelSplit(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, outputSlot: number | null): HTMLCanvasElement {
|
||||
return extractSplitChannelCanvas(ctx.canvas, outputSlot, strAny(node, ["mode"], "RGBA"));
|
||||
}
|
||||
|
||||
export function channelMerge(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): HTMLCanvasElement {
|
||||
return mergeChannelInputs(inputs, strAny(node, ["mode"], "RGBA")) ?? (inputs[0] ?? ctx.canvas);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
import os
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
COMFY_ROOT = ROOT.parent.parent
|
||||
COMFY_ROOT = Path(os.environ.get("COMFYUI_ROOT", ROOT.parent.parent))
|
||||
|
||||
|
||||
class _Routes:
|
||||
|
||||
Reference in New Issue
Block a user