diff --git a/__init__.py b/__init__.py index 9e040f3..c063f28 100644 --- a/__init__.py +++ b/__init__.py @@ -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"(? 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", diff --git a/js/preview/ops/blend.js b/js/preview/ops/blend.js index e1b2c2c..44a6ca8 100644 --- a/js/preview/ops/blend.js +++ b/js/preview/ops/blend.js @@ -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 }; diff --git a/js/preview/ops/geometry.js b/js/preview/ops/geometry.js index ff2cca8..9105b76 100644 --- a/js/preview/ops/geometry.js +++ b/js/preview/ops/geometry.js @@ -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 }; diff --git a/js/preview/ops/implementation.js b/js/preview/ops/implementation.js index 4b4d8cb..705b2d5 100644 --- a/js/preview/ops/implementation.js +++ b/js/preview/ops/implementation.js @@ -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 }; diff --git a/js/preview/ops/masks.js b/js/preview/ops/masks.js index 79ddedd..f32a357 100644 --- a/js/preview/ops/masks.js +++ b/js/preview/ops/masks.js @@ -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 }; diff --git a/js/preview/ops/procedural.js b/js/preview/ops/procedural.js index eb5c16e..9cdf87e 100644 --- a/js/preview/ops/procedural.js +++ b/js/preview/ops/procedural.js @@ -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 }; diff --git a/js/preview/ops/video.js b/js/preview/ops/video.js index a3c8919..563aa21 100644 --- a/js/preview/ops/video.js +++ b/js/preview/ops/video.js @@ -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 }; diff --git a/nodes/append.py b/nodes/append.py index cc954d7..16d85c4 100644 --- a/nodes/append.py +++ b/nodes/append.py @@ -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]}) \ No newline at end of file diff --git a/nodes/blur.py b/nodes/blur.py index ce01525..0ae6fa6 100644 --- a/nodes/blur.py +++ b/nodes/blur.py @@ -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') \ No newline at end of file diff --git a/nodes/camera_shake.py b/nodes/camera_shake.py index e9a1594..19fca94 100644 --- a/nodes/camera_shake.py +++ b/nodes/camera_shake.py @@ -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) \ No newline at end of file diff --git a/nodes/channel.py b/nodes/channel.py index aa41aa5..f6fa1e2 100644 --- a/nodes/channel.py +++ b/nodes/channel.py @@ -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') \ No newline at end of file diff --git a/nodes/clamp.py b/nodes/clamp.py index 4424f19..2fa4ac4 100644 --- a/nodes/clamp.py +++ b/nodes/clamp.py @@ -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') \ No newline at end of file diff --git a/nodes/color_ajust.py b/nodes/color_ajust.py index 08248c9..85839b0 100644 --- a/nodes/color_ajust.py +++ b/nodes/color_ajust.py @@ -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') \ No newline at end of file diff --git a/nodes/constant.py b/nodes/constant.py index 65735d6..b44ec4a 100644 --- a/nodes/constant.py +++ b/nodes/constant.py @@ -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') \ No newline at end of file diff --git a/nodes/corner_pin.py b/nodes/corner_pin.py index ec3a91a..2370f02 100644 --- a/nodes/corner_pin.py +++ b/nodes/corner_pin.py @@ -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') \ No newline at end of file diff --git a/nodes/crop.py b/nodes/crop.py index 43154d8..ce46314 100644 --- a/nodes/crop.py +++ b/nodes/crop.py @@ -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) \ No newline at end of file diff --git a/nodes/crop_stitch.py b/nodes/crop_stitch.py index deec259..6436cef 100644 --- a/nodes/crop_stitch.py +++ b/nodes/crop_stitch.py @@ -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') \ No newline at end of file diff --git a/nodes/distort.py b/nodes/distort.py index 9eeaa1f..e611208 100644 --- a/nodes/distort.py +++ b/nodes/distort.py @@ -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') \ No newline at end of file diff --git a/nodes/frame_range.py b/nodes/frame_range.py index fd5ec5d..d40794e 100644 --- a/nodes/frame_range.py +++ b/nodes/frame_range.py @@ -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]}) \ No newline at end of file diff --git a/nodes/invert.py b/nodes/invert.py index 5e2dd61..27825a5 100644 --- a/nodes/invert.py +++ b/nodes/invert.py @@ -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') \ No newline at end of file diff --git a/nodes/merge.py b/nodes/merge.py index 9581cd9..7a1a122 100644 --- a/nodes/merge.py +++ b/nodes/merge.py @@ -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') \ No newline at end of file diff --git a/nodes/noise.py b/nodes/noise.py index 6eaa001..efac01f 100644 --- a/nodes/noise.py +++ b/nodes/noise.py @@ -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) \ No newline at end of file diff --git a/nodes/padout.py b/nodes/padout.py index 49c50cb..d6870fc 100644 --- a/nodes/padout.py +++ b/nodes/padout.py @@ -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) \ No newline at end of file diff --git a/nodes/preview.py b/nodes/preview.py index fe4cde9..812ec18 100644 --- a/nodes/preview.py +++ b/nodes/preview.py @@ -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)} \ No newline at end of file diff --git a/nodes/ramp.py b/nodes/ramp.py index 0fa1331..9d8629f 100644 --- a/nodes/ramp.py +++ b/nodes/ramp.py @@ -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') \ No newline at end of file diff --git a/nodes/spherize.py b/nodes/spherize.py index 75fa0c3..dc63c89 100644 --- a/nodes/spherize.py +++ b/nodes/spherize.py @@ -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') \ No newline at end of file diff --git a/nodes/transform.py b/nodes/transform.py index 5344359..f3f2e4c 100644 --- a/nodes/transform.py +++ b/nodes/transform.py @@ -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') \ No newline at end of file diff --git a/scripts/fix_imports.py b/scripts/fix_imports.py new file mode 100644 index 0000000..dd0b48a --- /dev/null +++ b/scripts/fix_imports.py @@ -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() diff --git a/scripts/migrate_v3.py b/scripts/migrate_v3.py new file mode 100644 index 0000000..16a217a --- /dev/null +++ b/scripts/migrate_v3.py @@ -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"(? 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; + } + diff --git a/src/preview/ops/geometry.ts b/src/preview/ops/geometry.ts index 7d27bae..db03907 100644 --- a/src/preview/ops/geometry.ts +++ b/src/preview/ops/geometry.ts @@ -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), + ); + }); + } + diff --git a/src/preview/ops/implementation.ts b/src/preview/ops/implementation.ts index d6cc44d..af1cad7 100644 --- a/src/preview/ops/implementation.ts +++ b/src/preview/ops/implementation.ts @@ -9,13 +9,18 @@ import { acquireCanvas, releaseCanvas } from "../shared/canvas-pool.js"; import { applyColorCorrectGL, type ColorCorrectParams } 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: ComfyNode, name: string): ComfyWidget | null { +export function w(node: ComfyNode, name: string): ComfyWidget | null { return node?.widgets?.find((x: ComfyWidget) => x?.name === name) ?? null; } -function widgetScalarValue(value: unknown, index: number = 0): unknown { +export function widgetScalarValue(value: unknown, index: number = 0): unknown { let current = value; while (Array.isArray(current) && current.length > 0) { const resolvedIndex = Math.max(0, Math.min(current.length - 1, index)); @@ -23,41 +28,41 @@ function widgetScalarValue(value: unknown, index: number = 0): unknown { } return current; } -function num(node: ComfyNode, name: string, fallback: number = 0, index: number = 0): number { +export function num(node: ComfyNode, name: string, fallback: number = 0, index: number = 0): number { const v = widgetScalarValue(w(node, name)?.value, index); const n = parseFloat(v as string); return Number.isFinite(n) ? n : fallback; } -function str(node: ComfyNode, name: string, fallback: string = "", index: number = 0): string { +export function str(node: ComfyNode, name: string, fallback: string = "", index: number = 0): string { const v = widgetScalarValue(w(node, name)?.value, index); return typeof v === "string" ? v : fallback; } -function bool(node: ComfyNode, name: string, fallback: boolean = false, index: number = 0): boolean { +export function bool(node: ComfyNode, name: string, fallback: boolean = false, index: number = 0): boolean { const v = widgetScalarValue(w(node, name)?.value, index); if (typeof v === "boolean") return v; if (typeof v === "number") return !!v; if (typeof v === "string") return v.toLowerCase() === "true"; return fallback; } -function wAny(node: ComfyNode, names: string[]): ComfyWidget | null { +export function wAny(node: ComfyNode, names: string[]): ComfyWidget | null { for (const name of names) { const found = w(node, name); if (found) return found; } return null; } -function numAny(node: ComfyNode, names: string[], fallback: number = 0, index: number = 0): number { +export function numAny(node: ComfyNode, names: string[], fallback: number = 0, index: number = 0): number { const v = widgetScalarValue(wAny(node, names)?.value, index); const n = parseFloat(v as string); return Number.isFinite(n) ? n : fallback; } -function strAny(node: ComfyNode, names: string[], fallback: string = "", index: number = 0): string { +export function strAny(node: ComfyNode, names: string[], fallback: string = "", index: number = 0): string { const v = widgetScalarValue(wAny(node, names)?.value, index); return typeof v === "string" ? v : fallback; } // Follow a STRING input connection to read the upstream widget's current value. // Returns null if the input is not connected or no string widget found upstream. -function resolveConnectedString(node: ComfyNode, inputName: string): string | null { +export function resolveConnectedString(node: ComfyNode, inputName: string): string | null { const inputs: any[] = (node as any)?.inputs ?? []; const slotIndex = inputs.findIndex((inp: any) => inp?.name === inputName); if (slotIndex < 0) return null; @@ -70,25 +75,25 @@ function resolveConnectedString(node: ComfyNode, inputName: string): string | nu const upWidget = ((upNode as any)?.widgets ?? []).find((w: any) => typeof w?.value === "string"); return upWidget ? String(upWidget.value) : null; } -function boolAny(node: ComfyNode, names: string[], fallback: boolean = false, index: number = 0): boolean { +export function boolAny(node: ComfyNode, names: string[], fallback: boolean = false, index: number = 0): boolean { const v = widgetScalarValue(wAny(node, names)?.value, index); if (typeof v === "boolean") return v; if (typeof v === "number") return !!v; if (typeof v === "string") return v.toLowerCase() === "true"; return fallback; } -function clamp01(x: number): number { return Math.max(0, Math.min(1, x)); } -function luma01(r: number, g: number, b: number, lw: number[]): number { return lw[0]*r + lw[1]*g + lw[2]*b; } +export function clamp01(x: number): number { return Math.max(0, Math.min(1, x)); } +export function luma01(r: number, g: number, b: number, lw: number[]): number { return lw[0]*r + lw[1]*g + lw[2]*b; } -function getImageData(ctx: CanvasRenderingContext2D, W: number, H: number): ImageData { return ctx.getImageData(0,0,W,H); } -function putImageData(ctx: CanvasRenderingContext2D, img: ImageData): void { ctx.putImageData(img,0,0); } -function getCanvasDimensions(ctx: CanvasRenderingContext2D): { width: number; height: number } { +export function getImageData(ctx: CanvasRenderingContext2D, W: number, H: number): ImageData { return ctx.getImageData(0,0,W,H); } +export function putImageData(ctx: CanvasRenderingContext2D, img: ImageData): void { ctx.putImageData(img,0,0); } +export function getCanvasDimensions(ctx: CanvasRenderingContext2D): { width: number; height: number } { return { width: Math.max(1, ctx.canvas.width || 1), height: Math.max(1, ctx.canvas.height || 1), }; } -function makeCanvas(width: number, height: number): HTMLCanvasElement { +export function makeCanvas(width: number, height: number): HTMLCanvasElement { const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width)); canvas.height = Math.max(1, Math.round(height)); @@ -99,7 +104,7 @@ type MaskCanvas = HTMLCanvasElement & { __imageopsPreparedMask?: boolean }; const preparedMaskCache = new WeakMap>(); const canvasFieldCache = new WeakMap>(); -function prepareMaskCanvasInPlace(canvas: HTMLCanvasElement): HTMLCanvasElement { +export function prepareMaskCanvasInPlace(canvas: HTMLCanvasElement): HTMLCanvasElement { const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return canvas; const width = canvas.width || 1; @@ -123,17 +128,17 @@ function prepareMaskCanvasInPlace(canvas: HTMLCanvasElement): HTMLCanvasElement return canvas; } -function markPreparedMaskCanvas(canvas: HTMLCanvasElement): HTMLCanvasElement { +export function markPreparedMaskCanvas(canvas: HTMLCanvasElement): HTMLCanvasElement { prepareMaskCanvasInPlace(canvas); (canvas as MaskCanvas).__imageopsPreparedMask = true; return canvas; } -function isPreparedMaskCanvas(canvas: HTMLCanvasElement | null | undefined): canvas is MaskCanvas { +export function isPreparedMaskCanvas(canvas: HTMLCanvasElement | null | undefined): canvas is MaskCanvas { return !!canvas && (canvas as MaskCanvas).__imageopsPreparedMask === true; } -function normalizeFilterName(filter: string): string { +export function normalizeFilterName(filter: string): string { const value = String(filter || "bilinear").toLowerCase(); if (value === "nearest") return "nearest-exact"; if (value === "linear") return "bilinear"; @@ -141,7 +146,7 @@ function normalizeFilterName(filter: string): string { return value; } -function parseHexColor(value: string): string { +export function parseHexColor(value: string): string { const raw = String(value || "").trim(); if (/^#[0-9a-f]{6}$/i.test(raw)) return raw; if (/^#[0-9a-f]{3}$/i.test(raw)) { @@ -163,11 +168,11 @@ function parseHexColor(value: string): string { return "#000000"; } -function imageLikeInputName(name: string): boolean { +export function imageLikeInputName(name: string): boolean { return /image|images|source|destination|background|foreground|layer|red|green|blue|channel|input/i.test(name); } -function getPreferredInputIndexes(node: ComfyNode): number[] { +export function getPreferredInputIndexes(node: ComfyNode): number[] { const indexes: number[] = []; for (let index = 0; index < (node.inputs?.length ?? 0); index++) { const slot = node.inputs?.[index]; @@ -181,7 +186,7 @@ function getPreferredInputIndexes(node: ComfyNode): number[] { return [0]; } -function resizeWithMode( +export function resizeWithMode( source: HTMLCanvasElement, width: number, height: number, @@ -234,7 +239,7 @@ function resizeWithMode( return output; } -function fitCanvas(source: HTMLCanvasElement, width: number, height: number): HTMLCanvasElement { +export function fitCanvas(source: HTMLCanvasElement, width: number, height: number): HTMLCanvasElement { if ((source.width || 1) === Math.max(1, Math.round(width)) && (source.height || 1) === Math.max(1, Math.round(height))) { return source; } @@ -249,7 +254,7 @@ function fitCanvas(source: HTMLCanvasElement, width: number, height: number): HT return output; } -function flipCanvas(source: HTMLCanvasElement, horizontal: boolean, vertical: boolean): HTMLCanvasElement { +export function flipCanvas(source: HTMLCanvasElement, horizontal: boolean, vertical: boolean): HTMLCanvasElement { if (!horizontal && !vertical) return source; const output = makeCanvas(source.width || 1, source.height || 1); const octx = output.getContext("2d", { willReadFrequently: true })!; @@ -261,7 +266,7 @@ function flipCanvas(source: HTMLCanvasElement, horizontal: boolean, vertical: bo return output; } -function rotateDiscrete(source: HTMLCanvasElement, quarterTurns: number): HTMLCanvasElement { +export function rotateDiscrete(source: HTMLCanvasElement, quarterTurns: number): HTMLCanvasElement { const turns = ((quarterTurns % 4) + 4) % 4; if (turns === 0) return source; const swap = turns % 2 === 1; @@ -273,7 +278,7 @@ function rotateDiscrete(source: HTMLCanvasElement, quarterTurns: number): HTMLCa return output; } -function computeMaskBounds(maskCanvas: HTMLCanvasElement): { x: number; y: number; width: number; height: number } | null { +export function computeMaskBounds(maskCanvas: HTMLCanvasElement): { x: number; y: number; width: number; height: number } | null { const ctx = maskCanvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return null; const image = ctx.getImageData(0, 0, maskCanvas.width || 1, maskCanvas.height || 1); @@ -298,7 +303,7 @@ function computeMaskBounds(maskCanvas: HTMLCanvasElement): { x: number; y: numbe return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 }; } -function compositeAt( +export function compositeAt( base: HTMLCanvasElement, top: HTMLCanvasElement, mode: string, @@ -322,7 +327,7 @@ function compositeAt( return output; } -function compModeToCanvasOp(mode: string): GlobalCompositeOperation { +export function compModeToCanvasOp(mode: string): GlobalCompositeOperation { const normalized = String(mode || "over").toLowerCase().replace(/[-\s]+/g, "_"); if (normalized === "add") return "lighter"; if (normalized === "multiply") return "multiply"; @@ -338,7 +343,7 @@ function compModeToCanvasOp(mode: string): GlobalCompositeOperation { return "source-over"; } -function hexToRgb01(value: string): [number, number, number] { +export function hexToRgb01(value: string): [number, number, number] { const hex = parseHexColor(value); return [ parseInt(hex.slice(1, 3), 16) / 255, @@ -347,7 +352,7 @@ function hexToRgb01(value: string): [number, number, number] { ]; } -function renderConstantCanvas(node: ComfyNode, maskOnly: boolean = false): HTMLCanvasElement { +export function renderConstantCanvas(node: ComfyNode, maskOnly: boolean = false): HTMLCanvasElement { const width = Math.max(1, Math.round(numAny(node, ["width"], 1024))); const height = Math.max(1, Math.round(numAny(node, ["height"], 1024))); const alpha = Math.max(0, Math.min(1, numAny(node, ["alpha"], 1))); @@ -387,7 +392,7 @@ function renderConstantCanvas(node: ComfyNode, maskOnly: boolean = false): HTMLC return canvas; } -function applyRampCurve(value: number, mode: string): number { +export function applyRampCurve(value: number, mode: string): number { const normalized = String(mode || "linear").toLowerCase().replace(/[-\s]+/g, "_"); if (normalized === "ease_in") return value * value; if (normalized === "ease_out") return 1 - (1 - value) * (1 - value); @@ -395,7 +400,7 @@ function applyRampCurve(value: number, mode: string): number { return value; } -function renderRampCanvas(node: ComfyNode, maskOnly: boolean = false): HTMLCanvasElement { +export function renderRampCanvas(node: ComfyNode, maskOnly: boolean = false): HTMLCanvasElement { const width = Math.max(1, Math.round(numAny(node, ["width"], 1024))); const height = Math.max(1, Math.round(numAny(node, ["height"], 1024))); const alpha = Math.max(0, Math.min(1, numAny(node, ["alpha"], 1))); @@ -445,7 +450,7 @@ function renderRampCanvas(node: ComfyNode, maskOnly: boolean = false): HTMLCanva return canvas; } -function grainRandom01(seed: number, x: number, y: number, channel: number, frame: number): number { +export function grainRandom01(seed: number, x: number, y: number, channel: number, frame: number): number { let v = (seed >>> 0) ^ Math.imul(x + 374761393, 668265263) ^ Math.imul(y + 2246822519, 3266489917); v ^= Math.imul(channel + 1, 1274126177); v ^= Math.imul(frame + 1, 1597334677); @@ -457,7 +462,7 @@ function grainRandom01(seed: number, x: number, y: number, channel: number, fram return (v >>> 0) / 4294967295; } -function blendGrainValue(base: number, noise: number, amount: number, mode: string): number { +export function blendGrainValue(base: number, noise: number, amount: number, mode: string): number { const normalized = String(mode || "add").toLowerCase().replace(/[-\s]+/g, "_"); const top = Math.max(0, Math.min(1, 0.5 + noise * amount)); if (normalized === "overlay") { @@ -474,7 +479,7 @@ function blendGrainValue(base: number, noise: number, amount: number, mode: stri return base + noise * amount; } -function renderGrainCanvas(node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, frameIndex: number): HTMLCanvasElement { +export function renderGrainCanvas(node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, frameIndex: number): HTMLCanvasElement { const width = source.width || 1; const height = source.height || 1; const output = makeCanvas(width, height); @@ -509,7 +514,7 @@ function renderGrainCanvas(node: ComfyNode, source: HTMLCanvasElement, rawMask: return output; } -function renderTextCanvas(node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, frameIndex: number): HTMLCanvasElement { +export function renderTextCanvas(node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, frameIndex: number): HTMLCanvasElement { const width = source.width || 1; const height = source.height || 1; const output = makeCanvas(width, height); @@ -552,7 +557,7 @@ function renderTextCanvas(node: ComfyNode, source: HTMLCanvasElement, rawMask: H return output; } -function shakeRandom(seed: number, frame: number, salt: number): number { +export function shakeRandom(seed: number, frame: number, salt: number): number { let v = (seed >>> 0) ^ Math.imul(frame + 1, 1597334677) ^ Math.imul(salt + 1, 3812015801); v ^= v >>> 15; v = Math.imul(v, 2246822519); @@ -562,7 +567,7 @@ function shakeRandom(seed: number, frame: number, salt: number): number { return ((v >>> 0) / 4294967295) * 2 - 1; } -function smoothShakeValue(seed: number, frame: number, salt: number, amount: number, smoothing: number, frequency: number = 1): number { +export function smoothShakeValue(seed: number, frame: number, salt: number, amount: number, smoothing: number, frequency: number = 1): number { const smooth = Math.max(0, Math.min(0.98, smoothing)); const sampleFrame = Math.max(0, frame * Math.max(0.01, frequency)); const baseFrame = Math.floor(sampleFrame); @@ -579,11 +584,11 @@ function smoothShakeValue(seed: number, frame: number, salt: number, amount: num return valueAt(baseFrame) * (1 - t) + valueAt(baseFrame + 1) * t; } -function noiseFade(t: number): number { +export function noiseFade(t: number): number { return t * t * t * (t * (t * 6 - 15) + 10); } -function noiseLerp(a: number, b: number, t: number): number { +export function noiseLerp(a: number, b: number, t: number): number { return a + (b - a) * t; } @@ -595,7 +600,7 @@ const NOISE_GRAD3_X = new Float32Array([ 1,-1, 1,-1, 1,-1, 1,-1, 0, 0, 0, 0, const NOISE_GRAD3_Y = new Float32Array([ 1, 1,-1,-1, 0, 0, 0, 0, 1,-1, 1,-1, 1, 1,-1, 1]); const NOISE_GRAD3_Z = new Float32Array([ 0, 0, 0, 0, 1, 1,-1,-1, 1, 1,-1,-1, 0, 0, 1,-1]); -function noiseHash3D(x: number, y: number, z: number, seed: number): number { +export function noiseHash3D(x: number, y: number, z: number, seed: number): number { let h = ( Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) @@ -608,12 +613,12 @@ function noiseHash3D(x: number, y: number, z: number, seed: number): number { return (h >>> 0) / 4294967295; } -function wrapNoiseIndex(value: number, period: number): number { +export function wrapNoiseIndex(value: number, period: number): number { if (period <= 0) return value; return ((value % period) + period) % period; } -function sampleWhiteNoise(x: number, y: number, z: number, seed: number, periodX: number = 0, periodY: number = 0): number { +export function sampleWhiteNoise(x: number, y: number, z: number, seed: number, periodX: number = 0, periodY: number = 0): number { const x0 = Math.floor(x); const y0 = Math.floor(y); const z0 = Math.floor(z); @@ -626,7 +631,7 @@ function sampleWhiteNoise(x: number, y: number, z: number, seed: number, periodX return noiseLerp(n0, n1, noiseFade(tz)); } -function sampleValueNoise(x: number, y: number, z: number, seed: number, periodX: number = 0, periodY: number = 0): number { +export function sampleValueNoise(x: number, y: number, z: number, seed: number, periodX: number = 0, periodY: number = 0): number { const x0 = Math.floor(x); const y0 = Math.floor(y); const z0 = Math.floor(z); @@ -661,7 +666,7 @@ function sampleValueNoise(x: number, y: number, z: number, seed: number, periodX return noiseLerp(noiseLerp(x00, x10, v), noiseLerp(x01, x11, v), w); } -function gradientDot3(ix: number, iy: number, iz: number, x: number, y: number, z: number, seed: number, periodX: number = 0, periodY: number = 0): number { +export function gradientDot3(ix: number, iy: number, iz: number, x: number, y: number, z: number, seed: number, periodX: number = 0, periodY: number = 0): number { const hashX = wrapNoiseIndex(ix, periodX); const hashY = wrapNoiseIndex(iy, periodY); const h = noiseHash3D(hashX, hashY, iz, seed); @@ -669,7 +674,7 @@ function gradientDot3(ix: number, iy: number, iz: number, x: number, y: number, return NOISE_GRAD3_X[gi] * (x - ix) + NOISE_GRAD3_Y[gi] * (y - iy) + NOISE_GRAD3_Z[gi] * (z - iz); } -function samplePerlinNoise(x: number, y: number, z: number, seed: number, periodX: number = 0, periodY: number = 0): number { +export function samplePerlinNoise(x: number, y: number, z: number, seed: number, periodX: number = 0, periodY: number = 0): number { const x0 = Math.floor(x); const y0 = Math.floor(y); const z0 = Math.floor(z); @@ -699,7 +704,7 @@ function samplePerlinNoise(x: number, y: number, z: number, seed: number, period return Math.max(-1, Math.min(1, noiseLerp(noiseLerp(x00, x10, v), noiseLerp(x01, x11, v), w) * 1.15470053838)); } -function sampleNoiseBasis( +export function sampleNoiseBasis( basis: string, sampleX: number, sampleY: number, @@ -717,7 +722,7 @@ function sampleNoiseBasis( return samplePerlinNoise(sampleX, sampleY, sampleZ, seed, periodX, periodY); } -function buildNoiseField( +export function buildNoiseField( width: number, height: number, options: { @@ -818,7 +823,7 @@ function buildNoiseField( return grayValues; } -function renderNoiseFieldCanvas( +export function renderNoiseFieldCanvas( width: number, height: number, grayValues: Float32Array, @@ -856,7 +861,7 @@ function renderNoiseFieldCanvas( // CPU pixel loop runs synchronously on the main thread, so cap lower than general canvasSize. const NOISE_CANVAS_MAX = 256; -function renderNoiseCanvas(node: ComfyNode, maskOnly: boolean = false, frameIndex: number = 0, canvasSize: number = 512): HTMLCanvasElement { +export function renderNoiseCanvas(node: ComfyNode, maskOnly: boolean = false, frameIndex: number = 0, canvasSize: number = 512): HTMLCanvasElement { const fullWidth = Math.max(1, Math.round(numAny(node, ["width"], 1024))); const fullHeight = Math.max(1, Math.round(numAny(node, ["height"], 1024))); // Cap render resolution to the preview canvas size — the full node dimensions @@ -898,7 +903,7 @@ function renderNoiseCanvas(node: ComfyNode, maskOnly: boolean = false, frameInde return renderNoiseFieldCanvas(width, height, grayValues, low, high, maskOnly); } -function distortConnectedInputs( +export function distortConnectedInputs( node: ComfyNode, inputs: HTMLCanvasElement[], ): { source: HTMLCanvasElement; displacement: HTMLCanvasElement | null; mask: HTMLCanvasElement | null } { @@ -911,7 +916,7 @@ function distortConnectedInputs( return { source, displacement, mask }; } -function extractCanvasField(canvas: HTMLCanvasElement, width: number, height: number, channel: string): Float32Array { +export function extractCanvasField(canvas: HTMLCanvasElement, width: number, height: number, channel: string): Float32Array { const normalized = String(channel || "red").toLowerCase(); const cacheKey = `${Math.max(1, width)}x${Math.max(1, height)}:${normalized}`; const cachedField = canvasFieldCache.get(canvas)?.get(cacheKey); @@ -945,14 +950,14 @@ function extractCanvasField(canvas: HTMLCanvasElement, width: number, height: nu return field; } -function neutralField(width: number, height: number, centered: boolean): Float32Array { +export function neutralField(width: number, height: number, centered: boolean): Float32Array { const field = new Float32Array(width * height); field.fill(centered ? 0.5 : 0); return field; } // Approximate gaussian blur with 3 separable box passes (mirror padding). -function blurField(field: Float32Array, width: number, height: number, radiusPx: number): Float32Array { +export function blurField(field: Float32Array, width: number, height: number, radiusPx: number): Float32Array { const r = Math.max(0, Math.round(radiusPx)); if (r <= 0 || width < 2 || height < 2) return field; const passes = 3; @@ -1003,7 +1008,7 @@ function blurField(field: Float32Array, width: number, height: number, radiusPx: return src; } -function reflectCoordinate(value: number, size: number): number { +export function reflectCoordinate(value: number, size: number): number { if (size <= 1) return 0; let coord = value; const max = size - 1; @@ -1014,7 +1019,7 @@ function reflectCoordinate(value: number, size: number): number { return coord; } -function sampleChannel(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, edgeMode: string): [number, number, number, number] { +export function sampleChannel(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, edgeMode: string): [number, number, number, number] { let px = x; let py = y; if (edgeMode === "zeros") { @@ -1030,7 +1035,7 @@ function sampleChannel(data: Uint8ClampedArray, width: number, height: number, x return [data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]; } -function bilinearSample(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, edgeMode: string): [number, number, number, number] { +export function bilinearSample(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, edgeMode: string): [number, number, number, number] { const x0 = Math.floor(x); const y0 = Math.floor(y); const x1 = x0 + 1; @@ -1050,7 +1055,7 @@ function bilinearSample(data: Uint8ClampedArray, width: number, height: number, return out; } -function renderDistortCanvas( +export function renderDistortCanvas( node: ComfyNode, inputs: HTMLCanvasElement[], frameIndex: number = 0, @@ -1137,7 +1142,7 @@ function renderDistortCanvas( return { image: finalImage, mask: alphaMaskCanvas(finalImage) }; } -function setResampleMode(ctx: CanvasRenderingContext2D, filter: string): void { +export function setResampleMode(ctx: CanvasRenderingContext2D, filter: string): void { const mode = String(filter || "bilinear").toLowerCase(); ctx.imageSmoothingEnabled = mode !== "nearest"; if (ctx.imageSmoothingEnabled) { @@ -1145,7 +1150,7 @@ function setResampleMode(ctx: CanvasRenderingContext2D, filter: string): void { } } -function applyLevels(ctx: CanvasRenderingContext2D, W: number, H: number, inMin: number, inMax: number, gamma: number, outMin: number, outMax: number): void { +export function applyLevels(ctx: CanvasRenderingContext2D, W: number, H: number, inMin: number, inMax: number, gamma: number, outMin: number, outMax: number): void { const { epsilon: EPS, preview_gamma_epsilon: GE } = getOpsConstants(); const img = getImageData(ctx,W,H); const d = img.data; @@ -1163,7 +1168,7 @@ function applyLevels(ctx: CanvasRenderingContext2D, W: number, H: number, inMin: putImageData(ctx,img); } -function applyHueSat(ctx: CanvasRenderingContext2D, W: number, H: number, hueDeg: number, sat: number, val: number): void { +export function applyHueSat(ctx: CanvasRenderingContext2D, W: number, H: number, hueDeg: number, sat: number, val: number): void { const { epsilon: EPS } = getOpsConstants(); const img = getImageData(ctx,W,H); const d = img.data; @@ -1207,7 +1212,7 @@ function applyHueSat(ctx: CanvasRenderingContext2D, W: number, H: number, hueDeg putImageData(ctx,img); } -function applyInvert(ctx: CanvasRenderingContext2D, W: number, H: number, invertAlpha: boolean = false): void { +export function applyInvert(ctx: CanvasRenderingContext2D, W: number, H: number, invertAlpha: boolean = false): void { const img=getImageData(ctx,W,H); const d=img.data; for (let i=0;i row.slice()); const b = vector.slice(); @@ -1800,7 +1805,7 @@ function solveLinear8x8(matrix: number[][], vector: number[]): number[] | null { return b; } -function invert3x3(m: number[]): number[] | null { +export function invert3x3(m: number[]): number[] | null { const a = m[0], b = m[1], c = m[2]; const d = m[3], e = m[4], f = m[5]; const g = m[6], h = m[7], i = m[8]; @@ -1819,7 +1824,7 @@ function invert3x3(m: number[]): number[] | null { return [A * invDet, D * invDet, G * invDet, B * invDet, E * invDet, H * invDet, C * invDet, F * invDet, I * invDet]; } -function solveCornerPinInverseHomography(node: ComfyNode, width: number, height: number, frameIndex: number = 0): number[] | null { +export function solveCornerPinInverseHomography(node: ComfyNode, width: number, height: number, frameIndex: number = 0): number[] | null { const src = [ [0, 0], [Math.max(0, width - 1), 0], @@ -1851,7 +1856,7 @@ function solveCornerPinInverseHomography(node: ComfyNode, width: number, height: return invert3x3(H); } -function reflectCoord(value: number, maxInclusive: number): number { +export function reflectCoord(value: number, maxInclusive: number): number { if (maxInclusive <= 0) return 0; const period = maxInclusive * 2; let x = value % period; @@ -1860,13 +1865,13 @@ function reflectCoord(value: number, maxInclusive: number): number { return x; } -function sampleChannelNearest(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, channel: number): number { +export function sampleChannelNearest(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, channel: number): number { const ix = Math.max(0, Math.min(width - 1, Math.round(x))); const iy = Math.max(0, Math.min(height - 1, Math.round(y))); return data[(iy * width + ix) * 4 + channel]; } -function sampleChannelBilinear(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, channel: number): number { +export function sampleChannelBilinear(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, channel: number): number { const x0 = Math.floor(x); const y0 = Math.floor(y); const x1 = Math.min(width - 1, x0 + 1); @@ -1880,14 +1885,14 @@ function sampleChannelBilinear(data: Uint8ClampedArray, width: number, height: n return (c00 * (1 - fx) + c10 * fx) * (1 - fy) + (c01 * (1 - fx) + c11 * fx) * fy; } -function cubicHermite(a: number, b: number, c: number, d: number, t: number): number { +export function cubicHermite(a: number, b: number, c: number, d: number, t: number): number { const a1 = -0.5 * a + 1.5 * b - 1.5 * c + 0.5 * d; const a2 = a - 2.5 * b + 2 * c - 0.5 * d; const a3 = -0.5 * a + 0.5 * c; return ((a1 * t + a2) * t + a3) * t + b; } -function sampleChannelBicubic(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, channel: number): number { +export function sampleChannelBicubic(data: Uint8ClampedArray, width: number, height: number, x: number, y: number, channel: number): number { const x1 = Math.floor(x); const y1 = Math.floor(y); const tx = x - x1; @@ -1904,7 +1909,7 @@ function sampleChannelBicubic(data: Uint8ClampedArray, width: number, height: nu return cubicHermite(rows[0], rows[1], rows[2], rows[3], ty); } -function solveInverseHomographyFromCorners( +export function solveInverseHomographyFromCorners( sourceWidth: number, sourceHeight: number, corners: Record, @@ -1938,7 +1943,7 @@ function solveInverseHomographyFromCorners( return invert3x3([solved[0], solved[1], solved[2], solved[3], solved[4], solved[5], solved[6], solved[7], 1]); } -function warpCanvasToQuad( +export function warpCanvasToQuad( source: HTMLCanvasElement, outputWidth: number, outputHeight: number, @@ -2015,7 +2020,7 @@ function warpCanvasToQuad( return { image, mask }; } -function renderCornerPinCanvases( +export function renderCornerPinCanvases( node: ComfyNode, source: HTMLCanvasElement, frameIndex: number = 0, @@ -2179,7 +2184,7 @@ function renderCornerPinCanvases( return { image, mask }; } -function buildMaskAlphaCanvas(maskCanvas: HTMLCanvasElement, width: number, height: number): HTMLCanvasElement { +export function buildMaskAlphaCanvas(maskCanvas: HTMLCanvasElement, width: number, height: number): HTMLCanvasElement { if (isPreparedMaskCanvas(maskCanvas) && (maskCanvas.width || 1) === width && (maskCanvas.height || 1) === height) { return maskCanvas; } @@ -2212,7 +2217,7 @@ function buildMaskAlphaCanvas(maskCanvas: HTMLCanvasElement, width: number, heig return prepared; } -function maskCanvasToPreviewCanvas(maskCanvas: HTMLCanvasElement, includeAlpha: boolean = false): HTMLCanvasElement { +export function maskCanvasToPreviewCanvas(maskCanvas: HTMLCanvasElement, includeAlpha: boolean = false): HTMLCanvasElement { const prepared = buildMaskAlphaCanvas(maskCanvas, maskCanvas.width || 1, maskCanvas.height || 1); const output = makeCanvas(prepared.width || 1, prepared.height || 1); const octx = output.getContext("2d", { willReadFrequently: true })!; @@ -2230,7 +2235,7 @@ function maskCanvasToPreviewCanvas(maskCanvas: HTMLCanvasElement, includeAlpha: return output; } -function alphaMaskCanvas(source: HTMLCanvasElement): HTMLCanvasElement { +export function alphaMaskCanvas(source: HTMLCanvasElement): HTMLCanvasElement { const output = makeCanvas(source.width || 1, source.height || 1); const octx = output.getContext("2d", { willReadFrequently: true })!; octx.clearRect(0, 0, output.width, output.height); @@ -2249,7 +2254,7 @@ function alphaMaskCanvas(source: HTMLCanvasElement): HTMLCanvasElement { return markPreparedMaskCanvas(output); } -function canvasHasVisibleTransparency(source: HTMLCanvasElement): boolean { +export function canvasHasVisibleTransparency(source: HTMLCanvasElement): boolean { const ctx = source.getContext("2d", { willReadFrequently: true }); if (!ctx) return false; const data = ctx.getImageData(0, 0, source.width || 1, source.height || 1).data; @@ -2259,7 +2264,7 @@ function canvasHasVisibleTransparency(source: HTMLCanvasElement): boolean { return false; } -function maskConvertSourceValue( +export function maskConvertSourceValue( data: Uint8ClampedArray, index: number, sourceMode: string, @@ -2285,13 +2290,13 @@ function maskConvertSourceValue( return luma01(r, g, b, lumaWeights); } -function applyMaskConvertLevels(value: number, blackPoint: number, whitePoint: number): number { +export function applyMaskConvertLevels(value: number, blackPoint: number, whitePoint: number): number { const black = clamp01(blackPoint); const white = Math.max(black + 1e-6, clamp01(whitePoint)); return clamp01((value - black) / (white - black)); } -function imageToMaskPreviewCanvas(source: HTMLCanvasElement, node?: ComfyNode, frameIndex: number = 0): HTMLCanvasElement { +export function imageToMaskPreviewCanvas(source: HTMLCanvasElement, node?: ComfyNode, frameIndex: number = 0): HTMLCanvasElement { const output = makeCanvas(source.width || 1, source.height || 1); const octx = output.getContext("2d", { willReadFrequently: true })!; octx.drawImage(source, 0, 0, output.width, output.height); @@ -2342,7 +2347,7 @@ function imageToMaskPreviewCanvas(source: HTMLCanvasElement, node?: ComfyNode, f return markPreparedMaskCanvas(levelsSource); } -function applyEffectToCanvas( +export function applyEffectToCanvas( source: HTMLCanvasElement, effect: (ctx: CanvasRenderingContext2D, width: number, height: number) => HTMLCanvasElement | void, ): HTMLCanvasElement { @@ -2354,7 +2359,7 @@ function applyEffectToCanvas( return result instanceof HTMLCanvasElement ? result : output; } -function resolvePreviewMaskCanvas( +export function resolvePreviewMaskCanvas( node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, @@ -2365,7 +2370,7 @@ function resolvePreviewMaskCanvas( return boolAny(node, ["invert_mask"], false, frameIndex) ? invertMaskCanvas(matte) : matte; } -function compositeProcessedWithMask( +export function compositeProcessedWithMask( baseCanvas: HTMLCanvasElement, processedCanvas: HTMLCanvasElement, maskCanvas: HTMLCanvasElement | null, @@ -2387,7 +2392,7 @@ function compositeProcessedWithMask( return output; } -function renderMaskedEffectPreview( +export function renderMaskedEffectPreview( node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, @@ -2410,7 +2415,7 @@ function renderMaskedEffectPreview( return compositeProcessedWithMask(options.baseCanvas ?? source, processed, processedMask); } -function premultLayerWithMask(imageCanvas: HTMLCanvasElement, maskCanvas: HTMLCanvasElement | null): HTMLCanvasElement { +export function premultLayerWithMask(imageCanvas: HTMLCanvasElement, maskCanvas: HTMLCanvasElement | null): HTMLCanvasElement { if (!maskCanvas) return imageCanvas; const output = makeCanvas(imageCanvas.width || 1, imageCanvas.height || 1); const octx = output.getContext("2d", { willReadFrequently: true })!; @@ -2427,7 +2432,7 @@ function premultLayerWithMask(imageCanvas: HTMLCanvasElement, maskCanvas: HTMLCa return output; } -function invertMaskCanvas(maskCanvas: HTMLCanvasElement): HTMLCanvasElement { +export function invertMaskCanvas(maskCanvas: HTMLCanvasElement): HTMLCanvasElement { const prepared = buildMaskAlphaCanvas(maskCanvas, maskCanvas.width || 1, maskCanvas.height || 1); const output = makeCanvas(prepared.width || 1, prepared.height || 1); const octx = output.getContext("2d", { willReadFrequently: true })!; @@ -2444,11 +2449,11 @@ function invertMaskCanvas(maskCanvas: HTMLCanvasElement): HTMLCanvasElement { return markPreparedMaskCanvas(output); } -function invertMaskAlphaCanvas(maskCanvas: HTMLCanvasElement): HTMLCanvasElement { +export function invertMaskAlphaCanvas(maskCanvas: HTMLCanvasElement): HTMLCanvasElement { return invertMaskCanvas(maskCanvas); } -function normalizePreparedMaskCanvas(maskCanvas: HTMLCanvasElement): HTMLCanvasElement { +export function normalizePreparedMaskCanvas(maskCanvas: HTMLCanvasElement): HTMLCanvasElement { const prepared = isPreparedMaskCanvas(maskCanvas) ? maskCanvas : buildMaskAlphaCanvas(maskCanvas, maskCanvas.width || 1, maskCanvas.height || 1); @@ -2472,7 +2477,7 @@ function normalizePreparedMaskCanvas(maskCanvas: HTMLCanvasElement): HTMLCanvasE return markPreparedMaskCanvas(output); } -function blurMaskAlphaCanvas(maskCanvas: HTMLCanvasElement, radius: number): HTMLCanvasElement { +export function blurMaskAlphaCanvas(maskCanvas: HTMLCanvasElement, radius: number): HTMLCanvasElement { const prepared = buildMaskAlphaCanvas(maskCanvas, maskCanvas.width || 1, maskCanvas.height || 1); const safeRadius = Math.max(0, radius); if (safeRadius <= 0) return prepared; @@ -2484,11 +2489,11 @@ function blurMaskAlphaCanvas(maskCanvas: HTMLCanvasElement, radius: number): HTM return normalizePreparedMaskCanvas(output); } -function emptyMaskCanvas(width: number, height: number): HTMLCanvasElement { +export function emptyMaskCanvas(width: number, height: number): HTMLCanvasElement { return markPreparedMaskCanvas(makeCanvas(width, height)); } -function renderSpherizeMaskCanvas( +export function renderSpherizeMaskCanvas( node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, @@ -2700,7 +2705,7 @@ export function renderCompPreview( return { canvas: output, layers: geometries }; } -function resolveCompPreviewInputs( +export function resolveCompPreviewInputs( node: ComfyNode, inputs: HTMLCanvasElement[], ): Array<{ image: HTMLCanvasElement; mask?: HTMLCanvasElement | null; slot: string; layerNumber: number; inputIndex: number }> { @@ -2729,7 +2734,7 @@ export async function renderDrawNodePreview(node: ComfyNode, baseCanvas: HTMLCan return await renderDrawPreview(node, baseCanvas); } -function applyLumaKey(ctx: CanvasRenderingContext2D, W: number, H: number, low: number, high: number, softness: number): void { +export function applyLumaKey(ctx: CanvasRenderingContext2D, W: number, H: number, low: number, high: number, softness: number): void { const { epsilon: EPS, luma_weights: LW } = getOpsConstants(); const img=getImageData(ctx,W,H); const d=img.data; @@ -2749,7 +2754,7 @@ function applyLumaKey(ctx: CanvasRenderingContext2D, W: number, H: number, low: } // W3C composite spec D() helper for soft-light — matches Python's _soft_light_curve. -function rgbToHsv01(r: number, g: number, b: number): [number, number, number] { +export function rgbToHsv01(r: number, g: number, b: number): [number, number, number] { const max = Math.max(r, g, b); const min = Math.min(r, g, b); const delta = max - min; @@ -2765,7 +2770,7 @@ function rgbToHsv01(r: number, g: number, b: number): [number, number, number] { return [hue, sat, max]; } -function smoothRange01(value: number, low: number, high: number, softness: number): number { +export function smoothRange01(value: number, low: number, high: number, softness: number): number { const lo = Math.min(low, high); const hi = Math.max(low, high); const soft = Math.max(0, softness); @@ -2777,7 +2782,7 @@ function smoothRange01(value: number, low: number, high: number, softness: numbe return clamp01(Math.min(smoothLower, smoothUpper)); } -function softKeyDistance(distance: number, tolerance: number, softness: number): number { +export function softKeyDistance(distance: number, tolerance: number, softness: number): number { const tol = clamp01(tolerance); const soft = clamp01(softness); if (soft <= 0.000001) return distance <= tol ? 1 : 0; @@ -2785,7 +2790,7 @@ function softKeyDistance(distance: number, tolerance: number, softness: number): return t * t * (3 - 2 * t); } -function blurMaskCanvas(source: HTMLCanvasElement, radius: number): HTMLCanvasElement { +export function blurMaskCanvas(source: HTMLCanvasElement, radius: number): HTMLCanvasElement { const normalizedRadius = Math.max(0, radius); if (normalizedRadius <= 0.001) return source; const output = makeCanvas(source.width || 1, source.height || 1); @@ -2797,7 +2802,7 @@ function blurMaskCanvas(source: HTMLCanvasElement, radius: number): HTMLCanvasEl return output; } -function parseKeyColors(value: string | null | undefined): Array<[number, number, number]> { +export function parseKeyColors(value: string | null | undefined): Array<[number, number, number]> { const raw = String(value ?? "").trim(); if (!raw) return []; try { @@ -2813,7 +2818,7 @@ function parseKeyColors(value: string | null | undefined): Array<[number, number } } -function renderKeyerCanvases(node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, frameIndex: number): { image: HTMLCanvasElement; mask: HTMLCanvasElement } { +export function renderKeyerCanvases(node: ComfyNode, source: HTMLCanvasElement, rawMask: HTMLCanvasElement | null, frameIndex: number): { image: HTMLCanvasElement; mask: HTMLCanvasElement } { const width = source.width || 1; const height = source.height || 1; const image = makeCanvas(width, height); @@ -2873,22 +2878,22 @@ function renderKeyerCanvases(node: ComfyNode, source: HTMLCanvasElement, rawMask return { image, mask: finalMask }; } -function srgbToLinear01(value: number): number { +export function srgbToLinear01(value: number): number { const v = clamp01(value); return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); } -function linearToSrgb01(value: number): number { +export function linearToSrgb01(value: number): number { const v = clamp01(value); return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; } -function normalizeBlendModeName(mode: string): string { +export function normalizeBlendModeName(mode: string): string { const normalized = String(mode || "over").toLowerCase().replace(/[-\s]+/g, "_"); return normalized === "normal" ? "over" : normalized; } -function fitMergeForeground(topCanvas: HTMLCanvasElement, width: number, height: number, fitMode: string): HTMLCanvasElement { +export function fitMergeForeground(topCanvas: HTMLCanvasElement, width: number, height: number, fitMode: string): HTMLCanvasElement { const mode = String(fitMode || "stretch").toLowerCase().replace(/[-\s]+/g, "_"); const out = makeCanvas(width, height); const octx = out.getContext("2d", { willReadFrequently: true })!; @@ -2919,7 +2924,7 @@ function fitMergeForeground(topCanvas: HTMLCanvasElement, width: number, height: return out; } -function blend(ctx: CanvasRenderingContext2D, W: number, H: number, topCanvas: HTMLCanvasElement, mode: string, mix: number, foregroundFit: string = "stretch", blendSpace: string = "linear"): void { +export function blend(ctx: CanvasRenderingContext2D, W: number, H: number, topCanvas: HTMLCanvasElement, mode: string, mix: number, foregroundFit: string = "stretch", blendSpace: string = "linear"): void { const m=Math.max(0,Math.min(1,mix)); if (m<=0) return; const scaledTop = fitMergeForeground(topCanvas, W, H, foregroundFit); @@ -2981,7 +2986,7 @@ function blend(ctx: CanvasRenderingContext2D, W: number, H: number, topCanvas: H putImageData(ctx, base); } -function resolveResizeDimensions(node: ComfyNode, sourceWidth: number, sourceHeight: number): { width: number; height: number; mode: string; filter: string; fillColor: string; cropPosition: string } { +export function resolveResizeDimensions(node: ComfyNode, sourceWidth: number, sourceHeight: number): { width: number; height: number; mode: string; filter: string; fillColor: string; cropPosition: string } { let width = Math.round(numAny(node, ["target_width", "width", "largest_size"], 0)); let height = Math.round(numAny(node, ["target_height", "height", "largest_size"], 0)); const scaleBy = numAny(node, ["scale_by", "multiplier"], 0); @@ -3040,7 +3045,7 @@ function resolveResizeDimensions(node: ComfyNode, sourceWidth: number, sourceHei }; } -function cropRectCanvas(source: HTMLCanvasElement, x: number, y: number, width: number, height: number): HTMLCanvasElement { +export function cropRectCanvas(source: HTMLCanvasElement, x: number, y: number, width: number, height: number): HTMLCanvasElement { const out = makeCanvas(Math.max(1, width), Math.max(1, height)); const octx = out.getContext("2d", { willReadFrequently: true })!; octx.clearRect(0, 0, out.width, out.height); @@ -3048,7 +3053,7 @@ function cropRectCanvas(source: HTMLCanvasElement, x: number, y: number, width: return out; } -function extractMaskDrivenCrop(source: HTMLCanvasElement, maskCanvas: HTMLCanvasElement | null, padding: number, targetWidth: number, targetHeight: number): HTMLCanvasElement { +export function extractMaskDrivenCrop(source: HTMLCanvasElement, maskCanvas: HTMLCanvasElement | null, padding: number, targetWidth: number, targetHeight: number): HTMLCanvasElement { if (!maskCanvas) return resizeWithMode(source, targetWidth, targetHeight, "bicubic", "crop"); const fittedMask = fitCanvas(maskCanvas, source.width || 1, source.height || 1); const bounds = computeMaskBounds(fittedMask); @@ -3063,7 +3068,7 @@ function extractMaskDrivenCrop(source: HTMLCanvasElement, maskCanvas: HTMLCanvas return resizeWithMode(cropped, targetWidth, targetHeight, "bicubic", "crop"); } -function parseCropStitchBBox(node: ComfyNode, sourceWidth: number, sourceHeight: number, frameIndex: number = 0): { x: number; y: number; width: number; height: number } | null { +export function parseCropStitchBBox(node: ComfyNode, sourceWidth: number, sourceHeight: number, frameIndex: number = 0): { x: number; y: number; width: number; height: number } | null { const raw = resolveConnectedString(node, "crop_bbox") ?? strAny(node, ["crop_bbox", "bbox"], ""); if (!raw) return null; @@ -3090,14 +3095,14 @@ function parseCropStitchBBox(node: ComfyNode, sourceWidth: number, sourceHeight: return { x, y, width, height }; } -function cropStitchBBoxFromMask(maskCanvas: HTMLCanvasElement | null, width: number, height: number): { x: number; y: number; width: number; height: number } { +export function cropStitchBBoxFromMask(maskCanvas: HTMLCanvasElement | null, width: number, height: number): { x: number; y: number; width: number; height: number } { if (!maskCanvas) return { x: 0, y: 0, width, height }; const fittedMask = buildMaskAlphaCanvas(maskCanvas, width, height); const bounds = computeMaskBounds(fittedMask); return bounds ?? { x: 0, y: 0, width, height }; } -function makeCropStitchRectMask(width: number, height: number, bbox: { x: number; y: number; width: number; height: number }): HTMLCanvasElement { +export function makeCropStitchRectMask(width: number, height: number, bbox: { x: number; y: number; width: number; height: number }): HTMLCanvasElement { const mask = makeCanvas(width, height); const mctx = mask.getContext("2d", { willReadFrequently: true })!; mctx.clearRect(0, 0, width, height); @@ -3106,7 +3111,7 @@ function makeCropStitchRectMask(width: number, height: number, bbox: { x: number return markPreparedMaskCanvas(mask); } -function renderCropStitchCanvases(node: ComfyNode, inputs: HTMLCanvasElement[], frameIndex: number = 0): { image: HTMLCanvasElement; mask: HTMLCanvasElement; crop: HTMLCanvasElement; bbox: { x: number; y: number; width: number; height: number } } { +export function renderCropStitchCanvases(node: ComfyNode, inputs: HTMLCanvasElement[], frameIndex: number = 0): { image: HTMLCanvasElement; mask: HTMLCanvasElement; crop: HTMLCanvasElement; bbox: { x: number; y: number; width: number; height: number } } { const original = inputs[0] ?? makeCanvas(1, 1); const crop = inputs[1] ?? original; const width = Math.max(1, original.width || 1); @@ -3132,7 +3137,7 @@ function renderCropStitchCanvases(node: ComfyNode, inputs: HTMLCanvasElement[], return { image, mask, crop: fittedCrop, bbox }; } -function drawCropStitchPanel( +export function drawCropStitchPanel( ctx: CanvasRenderingContext2D, source: HTMLCanvasElement, x: number, @@ -3176,7 +3181,7 @@ function drawCropStitchPanel( ctx.restore(); } -function composeCropStitchPreview(image: HTMLCanvasElement, crop: HTMLCanvasElement, mask: HTMLCanvasElement, bbox: { x: number; y: number; width: number; height: number }): HTMLCanvasElement { +export function composeCropStitchPreview(image: HTMLCanvasElement, crop: HTMLCanvasElement, mask: HTMLCanvasElement, bbox: { x: number; y: number; width: number; height: number }): HTMLCanvasElement { const maskPreview = maskCanvasToPreviewCanvas(mask); const mainW = Math.max(1, image.width || 1); const mainH = Math.max(1, image.height || 1); @@ -3193,7 +3198,7 @@ function composeCropStitchPreview(image: HTMLCanvasElement, crop: HTMLCanvasElem return output; } -function stitchCanvases(a: HTMLCanvasElement, b: HTMLCanvasElement, direction: string, spacingWidth: number, spacingColor: string, matchSize: boolean): HTMLCanvasElement { +export function stitchCanvases(a: HTMLCanvasElement, b: HTMLCanvasElement, direction: string, spacingWidth: number, spacingColor: string, matchSize: boolean): HTMLCanvasElement { const normalizedDirection = String(direction || "right").toLowerCase(); const spacing = Math.max(0, Math.round(spacingWidth)); const second = matchSize @@ -3224,7 +3229,7 @@ function stitchCanvases(a: HTMLCanvasElement, b: HTMLCanvasElement, direction: s return output; } -function extractSplitChannelCanvas(source: HTMLCanvasElement, outputSlot: number | null, mode: string): HTMLCanvasElement { +export function extractSplitChannelCanvas(source: HTMLCanvasElement, outputSlot: number | null, mode: string): HTMLCanvasElement { const normalizedMode = String(mode || "rgba").toLowerCase(); const channelIndex = Math.max(0, Math.min(3, outputSlot ?? 0)); const output = makeCanvas(source.width || 1, source.height || 1); @@ -3279,7 +3284,7 @@ function extractSplitChannelCanvas(source: HTMLCanvasElement, outputSlot: number return output; } -function mergeChannelInputs(inputs: HTMLCanvasElement[], mode: string): HTMLCanvasElement | null { +export function mergeChannelInputs(inputs: HTMLCanvasElement[], mode: string): HTMLCanvasElement | null { if (inputs.length < 3) return null; const width = inputs[0].width || 1; const height = inputs[0].height || 1; @@ -3312,7 +3317,7 @@ function mergeChannelInputs(inputs: HTMLCanvasElement[], mode: string): HTMLCanv return output; } -function applySpherize( +export function applySpherize( ctx: CanvasRenderingContext2D, width: number, height: number, @@ -3388,7 +3393,7 @@ function applySpherize( ctx.putImageData(dst, 0, 0); } -function _spherizeMapFwd(nx: number, ny: number, mode: string, s: number): [number, number] { +export function _spherizeMapFwd(nx: number, ny: number, mode: string, s: number): [number, number] { const r = Math.sqrt(nx * nx + ny * ny); if (r < 1e-7) return [0, 0]; @@ -3430,7 +3435,7 @@ function _spherizeMapFwd(nx: number, ny: number, mode: string, s: number): [numb return [nx, ny]; } -function _spherizeMapInv(nx: number, ny: number, mode: string, s: number): [number, number] { +export function _spherizeMapInv(nx: number, ny: number, mode: string, s: number): [number, number] { const r = Math.sqrt(nx * nx + ny * ny); if (r < 1e-7) return [0, 0]; @@ -3598,163 +3603,13 @@ export const ops = { ectx.putImageData(img, 0, 0); return extracted; }, - 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, - }, - ); - }, - 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); - }, - 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; - }, - 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; - }, - 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)); - }, - 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, - }, - ); - }, - 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, - }); - }, + crop, + cropStitch, + padOut, + cornerPin, + cropGeneric, + transform, + cameraShake, levels(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, _opts?: any): void { const { width, height } = getCanvasDimensions(ctx); applyLevels(ctx,width,height, @@ -3785,18 +3640,9 @@ export const ops = { applyClamp(effectCtx, width, height, numAny(node, ["min_v", "min"], 0, frameIndex), numAny(node, ["max_v", "max"], 1, frameIndex)); }); }, - 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); - }, - 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); - }, - 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; - }, + grain, + text, + keyer, sharpen(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): void { const { width, height } = getCanvasDimensions(ctx); applyUnsharp(ctx,width,height, numAny(node,["amount", "strength", "factor"],1)); @@ -3809,319 +3655,32 @@ export 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: 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") - ); - }, + cropReformat, lumaKey(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): void { 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: 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; - }, - 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); - }, - 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; - }, - 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; - }, + merge, + resize, + pad, + flipRotate, desaturate(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): void { const { width, height } = getCanvasDimensions(ctx); const factor = numAny(node, ["factor", "amount"], 1); applyDesaturate(ctx, width, height, factor); }, - 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); - }, - 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), - ); - }, - channelSplit(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, outputSlot: number | null): HTMLCanvasElement { - return extractSplitChannelCanvas(ctx.canvas, outputSlot, strAny(node, ["mode"], "RGBA")); - }, - channelMerge(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): HTMLCanvasElement { - return mergeChannelInputs(inputs, strAny(node, ["mode"], "RGBA")) ?? (inputs[0] ?? ctx.canvas); - }, - 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; - }, - comp(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): HTMLCanvasElement { - return renderCompPreview( - node, - resolveCompPreviewInputs(node, inputs), - ).canvas; - }, - distort(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[], frameIndex: number = 0): HTMLCanvasElement { - return renderDistortCanvas(node, inputs, frameIndex).image; - }, - 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), - ); - }); - }, - constant(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): HTMLCanvasElement { - return renderConstantCanvas(node, false); - }, - ramp(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode): HTMLCanvasElement { - return renderRampCanvas(node, false); - }, - noise(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, frameIndex: number = 0): HTMLCanvasElement { - return renderNoiseCanvas(node, false, frameIndex, W); - }, - async draw(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): Promise { - return await renderDrawPreview(node, inputs[0] ?? null); - }, - async drawMask(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): Promise { - 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; - }, - 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); - }, + composite, + stitch, + channelSplit, + channelMerge, + channelApply, + comp, + distort, + spherize, + constant, + ramp, + noise, + draw, + drawMask, + imageOpsMask, }; diff --git a/src/preview/ops/masks.ts b/src/preview/ops/masks.ts index 4607d70..1f7e69f 100644 --- a/src/preview/ops/masks.ts +++ b/src/preview/ops/masks.ts @@ -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); + } + diff --git a/src/preview/ops/procedural.ts b/src/preview/ops/procedural.ts index 44df42e..ad960a8 100644 --- a/src/preview/ops/procedural.ts +++ b/src/preview/ops/procedural.ts @@ -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 { + return await renderDrawPreview(node, inputs[0] ?? null); + } + +export async function drawMask(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[]): Promise { + 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; + } + diff --git a/src/preview/ops/video.ts b/src/preview/ops/video.ts index da1c02f..c115135 100644 --- a/src/preview/ops/video.ts +++ b/src/preview/ops/video.ts @@ -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); + } + diff --git a/tests/unit/test_comfy_registry.py b/tests/unit/test_comfy_registry.py index abbd370..b41236c 100644 --- a/tests/unit/test_comfy_registry.py +++ b/tests/unit/test_comfy_registry.py @@ -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: