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:
MajoorWaldi
2026-08-22 13:40:04 +02:00
parent 287b98b5dd
commit ac06dabd89
36 changed files with 2563 additions and 3846 deletions
+44 -363
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
};
+87 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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')
+61
View File
@@ -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()
+273
View File
@@ -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()
+64 -1
View File
@@ -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
View File
@@ -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),
);
});
}
File diff suppressed because it is too large Load Diff
+175 -1
View File
@@ -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);
}
+98 -1
View File
@@ -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;
}
+19 -1
View File
@@ -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);
}
+3 -1
View File
@@ -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: