diff --git a/README.md b/README.md index daa8e14..5d2d0e4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ State-of-the-art image and video enhancement using NVIDIA RTX Video SDK. It exec The **DaSiWa Scale Calculator** provides mathematically precise resolution management for high-performance video models. It uses a **Constant-Area Square-Root method** to ensure that your GPU VRAM usage remains stable regardless of the aspect ratio. +- **Unified Resolution Presets:** Pick standard `p` targets from 144p to 2160p/4K or optimized megapixel tiers from one dropdown. +- **Clear Aspect Modes:** `IMAGE ASPECT` uses the connected image shape; `USE ASPECT BELOW` uses the always-visible aspect controls. +- **Video-Safe Snapping:** Standard, Div32, Div64, and custom divisor modes keep dimensions aligned for different model families. + ![ResolutionScaleCalculator.png](assets/ResolutionScaleCalculator.png) [Full documentation →](docs/ResolutionScaleCalculator.md) @@ -80,6 +84,21 @@ The **DaSiWa Metadata Image Saver** ensures your images are fully compatible wit --- +### 🎬 Watermark Overlay + +A professional-grade watermark tool optimized for image and video batches. It uses a stable CPU compositor with high-quality resampling and precise rotation. + +- **Dynamic Random Positioning:** Toggle seeded corner cycling while keeping the selected position as the start position. +- **Splash Mode:** Configure dynamic fade-in and fade-out at the start and end of clips for professional branding. +- **Optical Padding:** Automatically adjusts placement by the watermark's visual center of mass for perfect alignment. +- **Stable Compositing:** Output frames are initialized from the source batch before the watermark region is blended, avoiding flicker and black-frame artifacts. + +![DaSiWa-Watermark.png](assets/DaSiWa-Watermark.png) + +[Full documentation →](docs/watermark.md) + +--- + ## 🛠️ Installation ### Manual install @@ -104,5 +123,6 @@ Search for **DaSiWa-Nodes** and install. ## Credits -The RTX implementation in this collection is based on the excellent work by Deno2026/comfyui-deno-custom-nodes. -Lora-Loader is based on Brojakhoeman/Loradaddyloaderltx. \ No newline at end of file +- The RTX implementation in this collection is based on the excellent work by [Deno2026/comfyui-deno-custom-nodes](https://github.com/Deno2026/comfyui-deno-custom-nodes). +- Lora-Loader is based on [Brojakhoeman/Loradaddyloaderltx](https://github.com/Brojakhoeman/Loradaddyloaderltx/tree/main). +- Ideas for Watermark Overlay are inspired by [Artificial-Sweetener/comfyui-WhiteRabbit](https://github.com/Artificial-Sweetener/comfyui-WhiteRabbit) \ No newline at end of file diff --git a/__init__.py b/__init__.py index e10dcfe..f258116 100644 --- a/__init__.py +++ b/__init__.py @@ -3,6 +3,7 @@ from .nodes.node_status_switch import DaSiWa_NodeStatusSwitch from .nodes.rtx_upscaler_refiner import DaSiWa_RTX_UpscalerRefiner from .nodes.metadata_nodes import DaSiWa_MetadataImageSaver, DaSiWa_MetadataImageSaverFull, DaSiWa_MetadataConfig, DaSiWa_CreateExtraMetadata from .nodes.ltx2_loader import DaSiWa_LTX2LoraLoader +from .nodes.watermark_nodes import DaSiWa_Watermark NODE_CLASS_MAPPINGS = { "DaSiWa_ResolutionScaleCalculator": DaSiWa_ResolutionScaleCalculator, @@ -13,6 +14,7 @@ NODE_CLASS_MAPPINGS = { "DaSiWa_MetadataConfig": DaSiWa_MetadataConfig, "DaSiWa_CreateExtraMetadata": DaSiWa_CreateExtraMetadata, "DaSiWa_LTX2LoraLoader": DaSiWa_LTX2LoraLoader, + "DaSiWa_Watermark": DaSiWa_Watermark, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -24,6 +26,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DaSiWa_MetadataConfig": "DaSiWa Metadata Config", "DaSiWa_CreateExtraMetadata": "DaSiWa Create Extra Metadata", "DaSiWa_LTX2LoraLoader": "DaSiWa LTX-2 LoRA Loader", + "DaSiWa_Watermark": "DaSiWa Watermark Overlay", } WEB_DIRECTORY = "./js" diff --git a/docs/ResolutionScaleCalculator.md b/docs/ResolutionScaleCalculator.md index dc05270..9403a87 100644 --- a/docs/ResolutionScaleCalculator.md +++ b/docs/ResolutionScaleCalculator.md @@ -9,21 +9,21 @@ Whether you are generating in **1:1 Square**, **9:16 Vertical**, or **21:9 Ultra ## 🚀 Key Features - **WAN/LTX (Div32) Mode:** Snaps calculations to the nearest **32-pixel boundary**. Mandatory for WAN 2.1 and LTX-Video. -- **Precision Presets:** Multiple tiers from 0.26 MP (Preview) to 8.30 MP (4K Production). +- **Unified Resolution Preset Selector:** Standard labels from 144p to 2160p/4K and optimized MP tiers from 0.26 MP to 8.30 MP live in one dropdown. +- **Clear Aspect Modes:** **IMAGE ASPECT** uses the connected image shape. **USE ASPECT BELOW** uses the always-visible aspect controls. - **No Scale Toggle:** A dedicated "Bypass" switch to use source image dimensions exactly. - **Batch-Safe Scaling:** Intelligent first-frame parsing for video and image batches. # 🛠 Quick Start Guide -### 1. Define your "Size" (Method) +### 1. Define your "Size" (Resolution Preset) The node uses a **Constant-Area** formula. Instead of setting raw width/height, you set a **Pixel Budget**. -* **Precision Presets:** Optimized Megapixel tiers. **0.52 MP (SD)** is the baseline for most video models. -* **Resolution Presets:** Standard targets (e.g., **1080p**). The node adjusts the actual dimensions to fit your custom aspect ratio while keeping the "1080p density." +* **resolution_preset:** Choose a standard resolution label such as **540p**, **720p**, or **1080p**, or an optimized megapixel tier such as **0.52 MP - SD**. The node converts the choice into a pixel budget and adapts it to your aspect ratio. ### 2. Define your "Shape" (Aspect Ratio) -* **Scale From Image (YES):** The node "peeks" at the first frame of your input. It calculates the aspect ratio and applies your chosen MP budget to that specific shape. -* **Scale From Image (NO):** Uses the **Aspect Preset** (e.g., 9:16) or **Manual Aspect** sliders. - * *Note: Manual sliders define the ratio (e.g., 21 x 9), not the final pixels.* +* **IMAGE ASPECT:** The node "peeks" at the first frame of your connected image. It calculates the aspect ratio and applies your chosen MP budget to that shape. The aspect controls remain visible, but they are ignored. +* **USE ASPECT BELOW:** The node uses the visible **aspect_preset_when_not_image** selector (e.g., 9:16) instead of the image shape. +* **Manual Aspect Note:** **custom_aspect_width** and **custom_aspect_height** are only used when **USE ASPECT BELOW** is active and the aspect preset is **CUSTOM**. They define a ratio such as 21 x 9, not final pixel dimensions. ### 3. Choose your "Engine" (Mode) * **WAN/LTX (Div32):** **Mandatory for Video.** Forces the math to snap to the nearest 32-pixel block. This prevents VAE artifacts and edge flickering. @@ -36,9 +36,9 @@ The node uses a **Constant-Area** formula. Instead of setting raw width/height, ## 💡 Quick Reference Table -| Goal | Method | Mode | Scale From Image? | +| Goal | Resolution Preset | Mode | Aspect Mode | | :--- | :--- | :--- | :--- | -| **WAN/LTX Video** | Precision Presets | WAN/LTX (Div32) | YES | -| **Flux Image** | Resolution Presets | Standard | NO (Manual) | -| **Social Media Clip** | Precision Presets | WAN/LTX (Div32) | NO (Use 9:16) | -| **Original Dims** | *Any* | *Any* | **No Scale: ON** | \ No newline at end of file +| **WAN/LTX Video** | 0.52 MP - SD | WAN/LTX (Div32) | IMAGE ASPECT | +| **Flux Image** | 1080p | Standard | USE ASPECT BELOW | +| **Social Media Clip** | 0.52 MP - SD | WAN/LTX (Div32) | USE ASPECT BELOW with 9:16 | +| **Original Dims** | *Any* | *Any* | **No Scale: ON** | diff --git a/docs/watermark.md b/docs/watermark.md new file mode 100644 index 0000000..853cfe3 --- /dev/null +++ b/docs/watermark.md @@ -0,0 +1,65 @@ +# DaSiWa Watermark Overlay + +**Category:** `DaSiWa/Video` +**Class name:** `DaSiWa_Watermark` +**File:** `nodes/watermark_nodes.py` + +--- + +## Overview + +The **DaSiWa Watermark Overlay** is a professional-grade node designed for adding watermarks to images and video batches. It features high-quality bicubic resampling, precise rotation, and a stable CPU compositor for consistent frame-to-frame output. + +## Key Features + +- **Robust Batch Processing:** Stores output batches in system RAM and processes watermark regions frame by frame. +- **Stable CPU Compositing:** Initializes every output frame from the source image before blending the watermark region, avoiding uninitialized frames, CUDA fallback differences, and black-frame artifacts. +- **Dynamic Random Positioning:** Toggleable corner cycling that starts from the selected position, with a seed to keep chained nodes from matching unintentionally. +- **Dynamic Fade (Splash Mode):** Toggleable fade that brings the watermark in at the start and end of the sequence while keeping the middle clear. +- **Optical Padding:** Automatically adjusts the watermark's position based on its visual center of mass. This ensures that asymmetric or rotated watermarks look perfectly aligned relative to the edges. +- **Premultiplied Alpha Blending:** Premultiplies before scaling/rotation and clamps the result to avoid dark halos or black transparent backgrounds. +- **Bicubic Scaling:** Maintains sharpness and detail when resizing the watermark image. + +## Inputs + +| Input | Type | Description | +|---|---|---| +| `images` | IMAGE | The image or video batch to apply the watermark to. | +| `watermark_path` | Combo | Select or upload a watermark file (PNG with transparency is highly recommended). | +| `position` | Combo | Static placement, or the starting placement when `randomize_position` is enabled. | +| `scale` | FLOAT | Size relative to the input image width (e.g., 0.2 = 20% width). | +| `resampling` | Combo | The algorithm used to resize the watermark. **Bicubic** is the recommended default for quality. | +| `transparency` | FLOAT | Opacity of the watermark (1.0 = Opaque, 0.0 = Invisible). | +| `rotation` | INT | Rotation in degrees (0–359). Canvas expands automatically to prevent clipping. | +| `padding_x / y` | INT | Pixel offset from the chosen edge. | +| `optical_padding`| BOOLEAN | Enable centroid-based alignment for visual balance. | +| `random_switches` | INT | How many times the position should jump when `randomize_position` is enabled. | +| `fade` | BOOLEAN | Enable the splash branding effect. | +| `fade_margin` | FLOAT | Duration for the start and end fades as a percentage of total frames (0.1 = 10%). | +| `randomize_position` | BOOLEAN | Enable seeded corner cycling. | +| `random_seed` | INT | Seed for corner order. `0` derives a stable seed from the watermark file name. | + +## Tips for Best Results + +### Visual Centering +If your watermark has a significant shadow on one side or is a long string of text, standard corner alignment often looks "off." Enable **Optical Padding** to let the node calculate the visual center of the logo and nudge it into the perfect position. + +### Anti-Cropping with Random Mode +Enable **randomize_position** to alternate between the four corners. The first interval uses the selected **position**, then the node jumps exactly the number of times specified in `random_switches` over the length of the batch. Use different start positions or different `random_seed` values when chaining multiple watermark nodes. + +### Splash Branding with Dynamic Fade +Enable **fade** and set a **fade_margin** (e.g., 0.1 for 10%) to create a professional "splash" effect. The watermark will fade in over the first 10% of frames and reappear during the last 10%, remaining hidden in between. + +### Stability and Memory +When working with 2K/4K video batches or complex LTX workflows, the node keeps the output batch in system RAM and uses one consistent CPU blend path. This is intentionally more conservative than opportunistic GPU blending, but it avoids frame-to-frame differences, fallback flicker, and black-border artifacts from device-path changes. + +### High Quality Blending +The node automatically premultiplies the alpha channel before resizing and clamps the rotated result so transparent pixels stay transparent. This means you don't need to pre-process your logos in Photoshop; just provide a high-quality transparent PNG, and the node handles the math for a seamless overlay. + +## Technical Details + +- **Rotation:** Uses bicubic interpolation with an axis-aligned bounding box expansion. +- **Memory Strategy:** + 1. Watermark is loaded, rescaled, and rotated on CPU. + 2. Output batches are initialized from the source frames in CPU/RAM. + 3. Frames are processed one-by-one using the same premultiplied-alpha blend path for every frame. diff --git a/nodes/scaling_nodes.py b/nodes/scaling_nodes.py index 338194b..f7fee52 100644 --- a/nodes/scaling_nodes.py +++ b/nodes/scaling_nodes.py @@ -2,6 +2,23 @@ import math class DaSiWa_ResolutionScaleCalculator: # --- DATA ARRAYS --- + RESOLUTION_PRESETS = { + "144p": 0.04, + "240p": 0.10, + "360p": 0.23, + "480p": 0.38, + "540p": 0.52, + "576p": 0.59, + "720p": 0.92, + "900p": 1.44, + "1080p": 2.07, + "1152p": 2.36, + "1440p": 3.68, + "2160p": 8.29, + "2K": 4.19, + "4K": 8.29, + } + PRECISION_PRESETS = { "0.26 MP - Preview": 0.26, "0.36 MP - Small": 0.36, @@ -21,15 +38,7 @@ class DaSiWa_ResolutionScaleCalculator: "8.30 MP - UHD": 8.30, } - RESOLUTION_PRESETS = { - "360p": 0.23, - "480p": 0.38, - "720p": 0.92, - "1080p": 2.07, - "1440p": 3.68, - "2K": 4.19, - "4K": 8.29, - } + PRESETS = {**RESOLUTION_PRESETS, **PRECISION_PRESETS} ASPECT_PRESETS = { "1:1 - Square": (1, 1), @@ -58,17 +67,15 @@ class DaSiWa_ResolutionScaleCalculator: def INPUT_TYPES(cls): return { "required": { - "method": (["Use Precision Presets", "Use Resolution Presets"], {"default": "Use Precision Presets", "description": "Switch between using Megapixel presets or standard Resolution targets."}), - "precision_presets": (list(cls.PRECISION_PRESETS.keys()), {"default": "0.52 MP - SD", "description": "Optimized Megapixel budgets for various quality tiers."}), - "resolution_presets": (list(cls.RESOLUTION_PRESETS.keys()), {"default": "1080p", "description": "Standard video resolution targets."}), + "resolution_preset": (list(cls.PRESETS.keys()), {"default": "0.52 MP - SD", "description": "Target visual resolution / pixel budget. Pick either a standard resolution label or an optimized megapixel tier."}), "no_scale": ("BOOLEAN", {"default": False, "label_on": "ON (Source Dims)", "label_off": "OFF (Calculated)", "description": "Bypass all calculations and output the source dimensions exactly."}), - "scale_from_image": ("BOOLEAN", {"default": True, "label_on": "yes", "label_off": "no", "description": "If YES, uses the input image's aspect ratio. If NO, uses presets or manual sliders."}), - "aspect_preset": (list(cls.ASPECT_PRESETS.keys()), {"default": "9:16 - Social", "description": "Choose a common aspect ratio."}), - "swap_aspect": ("BOOLEAN", {"default": False, "label_on": "yes", "label_off": "no", "description": "Flip width and height."}), - "manual_aspect_width": ("INT", {"default": 16, "min": 1, "max": 8192, "description": "Custom ratio width (e.g., 21)."}), - "manual_aspect_height": ("INT", {"default": 9, "min": 1, "max": 8192, "description": "Custom ratio height (e.g., 9)."}), + "scale_from_image": ("BOOLEAN", {"default": True, "label_on": "IMAGE ASPECT", "label_off": "USE ASPECT BELOW", "description": "IMAGE ASPECT ignores the aspect controls below and uses the connected image shape. USE ASPECT BELOW uses the visible aspect controls."}), + "aspect_preset_when_not_image": (list(cls.ASPECT_PRESETS.keys()), {"default": "9:16 - Social", "description": "Used only when scale_from_image is USE ASPECT BELOW. Ignored while IMAGE ASPECT is selected."}), + "swap_aspect_when_not_image": ("BOOLEAN", {"default": False, "label_on": "yes", "label_off": "no", "description": "Used only when scale_from_image is USE ASPECT BELOW. Flip width and height."}), + "custom_aspect_width": ("INT", {"default": 16, "min": 1, "max": 8192, "description": "Used only when scale_from_image is USE ASPECT BELOW and aspect preset is CUSTOM. Ratio width, not final pixels."}), + "custom_aspect_height": ("INT", {"default": 9, "min": 1, "max": 8192, "description": "Used only when scale_from_image is USE ASPECT BELOW and aspect preset is CUSTOM. Ratio height, not final pixels."}), "mode": (["Standard", "WAN/LTX (Div32)", "LTX 2-Stage (Div64)", "CUSTOM"], {"default": "WAN/LTX (Div32)", "description": "Snapping engine. Use WAN/LTX (Div32) for modern video models."}), "custom_divisor": ("INT", {"default": 8, "min": 1, "max": 256, "step": 1, "description": "Custom pixel boundary snapping."}), }, @@ -82,7 +89,34 @@ class DaSiWa_ResolutionScaleCalculator: FUNCTION = "calculate" CATEGORY = "DaSiWa/Scaling" - def calculate(self, method, precision_presets, resolution_presets, no_scale, scale_from_image, aspect_preset, swap_aspect, manual_aspect_width, manual_aspect_height, mode, custom_divisor, image=None): + def calculate( + self, + resolution_preset=None, + no_scale=False, + scale_from_image=True, + aspect_preset_when_not_image="9:16 - Social", + swap_aspect_when_not_image=False, + custom_aspect_width=16, + custom_aspect_height=9, + mode="WAN/LTX (Div32)", + custom_divisor=8, + image=None, + method=None, + preset=None, + precision_presets=None, + resolution_presets=None, + aspect_preset=None, + swap_aspect=None, + manual_aspect_width=None, + manual_aspect_height=None, + ): + if resolution_preset is None: + resolution_preset = preset + aspect_preset = aspect_preset if aspect_preset is not None else aspect_preset_when_not_image + swap_aspect = swap_aspect if swap_aspect is not None else swap_aspect_when_not_image + manual_aspect_width = manual_aspect_width if manual_aspect_width is not None else custom_aspect_width + manual_aspect_height = manual_aspect_height if manual_aspect_height is not None else custom_aspect_height + # 1. GET SOURCE DIMENSIONS (From Image or Manual) if scale_from_image: if image is None: @@ -109,10 +143,12 @@ class DaSiWa_ResolutionScaleCalculator: # 3. GET MP TARGET aspect_ratio = source_w / source_h - if method == "Use Resolution Presets": - a = self.RESOLUTION_PRESETS.get(resolution_presets, 2.07) - else: - a = self.PRECISION_PRESETS.get(precision_presets, 0.52) + if resolution_preset is None: + if method == "Use Resolution Presets": + resolution_preset = resolution_presets + else: + resolution_preset = precision_presets + a = self.PRESETS.get(resolution_preset, 0.52) # 4. CALCULATE target_total_pixels = a * 1000000 @@ -138,4 +174,4 @@ class DaSiWa_ResolutionScaleCalculator: final_h = int(round(calc_h)) floor = 1 - return (max(final_w, floor), max(final_h, floor), float(final_w), float(final_h)) \ No newline at end of file + return (max(final_w, floor), max(final_h, floor), float(final_w), float(final_h)) diff --git a/nodes/watermark_nodes.py b/nodes/watermark_nodes.py new file mode 100644 index 0000000..c49a5cd --- /dev/null +++ b/nodes/watermark_nodes.py @@ -0,0 +1,297 @@ +import os +import torch +import torch.nn.functional as F +import numpy as np +from PIL import Image +from typing import Tuple +import folder_paths +import comfy.utils + +# --- Helpers --- + +def _bhwc_to_nchw(x: torch.Tensor) -> torch.Tensor: + return x.movedim(-1, -3) + +def _nchw_to_bhwc(x: torch.Tensor) -> torch.Tensor: + return x.movedim(-3, -1) + +def _rotate_bicubic_expand(x: torch.Tensor, degrees: float) -> torch.Tensor: + """Rotate around center with bicubic sampling and expand canvas.""" + deg = float(degrees) % 360.0 + if deg == 0.0: + return x + + N, C, H, W = x.shape + rad = deg * 3.141592653589793 / 180.0 + cosr = float(torch.cos(torch.tensor(rad))) + sinr = float(torch.sin(torch.tensor(rad))) + + new_w = max(1, int((abs(W * cosr) + abs(H * sinr)) + 0.9999)) + new_h = max(1, int((abs(H * cosr) + abs(W * sinr)) + 0.9999)) + + cx_in, cy_in = (W - 1) * 0.5, (H - 1) * 0.5 + cx_out, cy_out = (new_w - 1) * 0.5, (new_h - 1) * 0.5 + + ys = torch.linspace(0, new_h - 1, new_h, device=x.device, dtype=x.dtype) + xs = torch.linspace(0, new_w - 1, new_w, device=x.device, dtype=x.dtype) + gy, gx = torch.meshgrid(ys, xs, indexing="ij") + + rx, ry = gx - cx_out, gy - cy_out + x_in = cosr * rx + sinr * ry + cx_in + y_in = -sinr * rx + cosr * ry + cy_in + + x_norm = (x_in + 0.5) / W * 2.0 - 1.0 + y_norm = (y_in + 0.5) / H * 2.0 - 1.0 + grid = torch.stack((x_norm, y_norm), dim=-1).unsqueeze(0).repeat(N, 1, 1, 1) + + return F.grid_sample(x, grid, mode="bicubic", padding_mode="zeros", align_corners=False) + +def _position_xy(position: str, base_w: int, base_h: int, wm_w: int, wm_h: int, pad_x: int, pad_y: int) -> Tuple[int, int]: + pos = (position or "bottom-right").strip().lower() + if pos == "center": + return (base_w - wm_w) // 2, (base_h - wm_h) // 2 + + x = 0 if "left" in pos else (base_w - wm_w if "right" in pos else (base_w - wm_w) // 2) + y = 0 if "top" in pos else (base_h - wm_h if "bottom" in pos else (base_h - wm_h) // 2) + + if "left" in pos: x += pad_x + if "right" in pos: x -= pad_x + if "top" in pos: y += pad_y + if "bottom" in pos: y -= pad_y + return x, y + +def _corner_sequence(start_position: str, switches: int, seed: int) -> Tuple[str, ...]: + corners = ["top-left", "top-right", "bottom-left", "bottom-right"] + start = start_position if start_position in corners or start_position == "center" else "bottom-right" + remaining = [pos for pos in corners if pos != start] + + rng = np.random.default_rng(seed) + rng.shuffle(remaining) + + sequence = [start] + while len(sequence) < switches + 1: + if len(remaining) > 1 and sequence[-1] == remaining[0]: + remaining[0], remaining[-1] = remaining[-1], remaining[0] + sequence.extend(remaining) + rng.shuffle(remaining) + return tuple(sequence[:switches + 1]) + +def _interpolate_watermark(x: torch.Tensor, size: Tuple[int, int], mode: str) -> torch.Tensor: + kwargs = {"size": size, "mode": mode} + if mode in {"bicubic", "bilinear"}: + kwargs["align_corners"] = False + return F.interpolate(x, **kwargs) + +def _as_bool(value) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + +def _sanitize_premultiplied_rgba(x: torch.Tensor) -> torch.Tensor: + alpha = x[:, 3:4, :, :].clamp(0.0, 1.0) + rgb = x[:, :3, :, :].clamp(0.0, 1.0).minimum(alpha) + return torch.cat((rgb, alpha), dim=1) + +def _tensor_nbytes(shape: Tuple[int, ...], dtype: torch.dtype) -> int: + return int(np.prod(shape)) * torch.empty((), dtype=dtype).element_size() + +def _safe_output_device(images: torch.Tensor, out_bytes: int) -> torch.device: + # Watermark compositing is intentionally deterministic. Keeping the output + # buffer in RAM avoids frame-to-frame differences from opportunistic CUDA + # blending and avoids duplicating long video batches in VRAM. + return torch.device("cpu") + +class DaSiWa_Watermark: + DESCRIPTION = ( + "DaSiWa Watermark: A professional-grade watermark overlay node with stable CPU compositing.\n" + "Stores output batches in RAM and blends from initialized source frames to avoid flicker,\n" + "Bicubic/Lanczos scaling, rotation, and 'Optical Padding' for perfect visual alignment." + ) + + @classmethod + def INPUT_TYPES(cls): + input_dir = folder_paths.get_input_directory() + files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] + files = folder_paths.filter_files_content_types(files, ["image"]) + + return { + "required": { + "images": ("IMAGE", {"description": "Input image or video batch."}), + "watermark_path": (sorted(files), {"image_upload": True, "description": "Select the watermark file (PNG recommended for transparency)."}), + "position": (["bottom-right", "bottom-left", "top-right", "top-left", "center"], {"default": "bottom-right", "description": "Static placement, or starting placement when randomize_position is enabled."}), + "scale": ("FLOAT", {"default": 0.2, "min": 0.01, "max": 1.0, "step": 0.01, "description": "Size relative to image width (0.2 = 20% width)."}), + "resampling": (["bicubic", "bilinear", "nearest", "area", "nearest-exact"], {"default": "bicubic"}), + "transparency": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "1.0 = Opaque, 0.0 = Invisible."}), + "rotation": ("INT", {"default": 0, "min": 0, "max": 359, "step": 1}), + "padding_x": ("INT", {"default": 20, "min": 0, "max": 4096}), + "padding_y": ("INT", {"default": 20, "min": 0, "max": 4096}), + "optical_padding": ("BOOLEAN", {"default": False, "description": "Adjust placement by the watermark's visual center (useful for wide/rotated marks)."}), + "optical_strength": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.05}), + "random_switches": ("INT", {"default": 3, "min": 1, "max": 64, "description": "Number of times to switch position when randomize_position is enabled."}), + "fade": ("BOOLEAN", {"default": False, "description": "Enable fade in at start and end."}), + "fade_margin": ("FLOAT", {"default": 0.1, "min": 0.01, "max": 0.5, "step": 0.01, "description": "Fade duration as a percentage of total frames (0.1 = 10%)."}), + "randomize_position": ("BOOLEAN", {"default": False, "description": "Cycle through corner positions, starting at the selected position."}), + "random_seed": ("INT", {"default": 0, "min": 0, "max": 2147483647, "description": "0 derives a stable seed from the watermark file name."}), + }, + "optional": { + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_watermark" + CATEGORY = "DaSiWa/Video" + + def apply_watermark( + self, + images, + watermark_path, + position, + scale, + resampling, + transparency, + rotation, + padding_x, + padding_y, + optical_padding, + optical_strength, + random_switches=3, + fade=False, + fade_margin=0.1, + randomize_position=False, + random_seed=0, + compute_device=None, + ): + fade = _as_bool(fade) + randomize_position = _as_bool(randomize_position) + + B, H, W, C = images.shape + output_dtype = images.dtype if images.is_floating_point() else torch.float32 + out_bytes = _tensor_nbytes((B, H, W, 3), output_dtype) + output_device = _safe_output_device(images, out_bytes) + full_path = folder_paths.get_annotated_filepath(watermark_path) + + with torch.no_grad(): + # 1. Load and prepare the watermark on CPU. This avoids large rotation grids in VRAM. + with Image.open(full_path) as im: + im = im.convert("RGBA") + wm_np = np.asarray(im, dtype=np.float32) / 255.0 + wm_rgba = torch.from_numpy(wm_np).permute(2, 0, 1).contiguous() + + wm_rgba[:3, :, :] *= wm_rgba[3:4, :, :] + wm_h0, wm_w0 = wm_rgba.shape[1], wm_rgba.shape[2] + + # 2. Rescale Watermark (Maintain Aspect Ratio) + target_w = max(1, int(round(W * scale))) + target_h = max(1, int(round(wm_h0 * (target_w / wm_w0)))) + + wm_resampled = _interpolate_watermark( + wm_rgba.unsqueeze(0), + size=(target_h, target_w), + mode=resampling, + ) + + # Apply transparency to Alpha channel + if transparency < 1.0: + wm_resampled *= transparency + + wm_resampled = _sanitize_premultiplied_rgba(wm_resampled) + + # 3. Apply Rotation (Expand Canvas) + wm_final = _sanitize_premultiplied_rgba(_rotate_bicubic_expand(wm_resampled, rotation)).squeeze(0) + pm_final, a_final = wm_final[:3, :, :], wm_final[3:4, :, :] + wm_h, wm_w = pm_final.shape[1], pm_final.shape[2] + + # 4. Precompute Optical Shift (Center of Mass) + opt_dx, opt_dy = 0.0, 0.0 + if optical_padding: + alpha = a_final[0] + denom = alpha.sum() + if denom > 1e-8: + ys = torch.linspace(0, wm_h - 1, wm_h) + xs = torch.linspace(0, wm_w - 1, wm_w) + cy = (alpha.sum(dim=1) * ys).sum() / denom + cx = (alpha.sum(dim=0) * xs).sum() / denom + opt_dx = float(((wm_w - 1) * 0.5 - cx) * optical_strength) + opt_dy = float(((wm_h - 1) * 0.5 - cy) * optical_strength) + + # 5. Batch Process with a stable compositor. + # Start from a full copy so every frame is initialized before overlay. + out = images[:, :, :, :3].to(device=output_device, dtype=output_dtype).clone() + + pbar = comfy.utils.ProgressBar(B) + + if position == "random": + position = "bottom-right" + + if random_seed == 0: + random_seed = sum((idx + 1) * ord(ch) for idx, ch in enumerate(str(watermark_path))) + random_positions = _corner_sequence(position, random_switches, int(random_seed)) + + last_pos = None + x0, y0, x1, y1 = 0, 0, 0, 0 + pm_crop, a_crop = None, None + + num_fade_frames = max(1, int(B * fade_margin)) if fade else 1 + print( + f"[DaSiWa Watermark] stable compositor: output={output_device.type}, " + f"dtype={output_dtype}, frames={B}" + ) + + for i in range(B): + # Determine current position. Random mode starts at the selected position. + if randomize_position: + num_chunks = random_switches + 1 + chunk_size = B / num_chunks + interval_idx = min(int(i // chunk_size), num_chunks - 1) + current_pos = random_positions[interval_idx] + else: + current_pos = position + + if current_pos != last_pos: + x, y = _position_xy(current_pos, W, H, wm_w, wm_h, padding_x, padding_y) + if optical_padding and current_pos != "center": + if "right" in current_pos: x += int(round(opt_dx)) + if "left" in current_pos: x -= int(round(opt_dx)) + if "bottom" in current_pos: y += int(round(opt_dy)) + if "top" in current_pos: y -= int(round(opt_dy)) + x0, y0 = max(0, x), max(0, y) + x1, y1 = min(W, x + wm_w), min(H, y + wm_h) + if x1 > x0 and y1 > y0: + wx0, wy0 = x0 - x, y0 - y + w_w, w_h = x1 - x0, y1 - y0 + pm_crop = pm_final[:, wy0:wy0 + w_h, wx0:wx0 + w_w].to(dtype=torch.float32).contiguous() + a_crop = a_final[:, wy0:wy0 + w_h, wx0:wx0 + w_w].to(dtype=torch.float32).contiguous() + else: + pm_crop, a_crop = None, None + last_pos = current_pos + + if fade: + if i < num_fade_frames: + fade_mult = i / float(num_fade_frames) + elif i >= (B - num_fade_frames): + fade_mult = (i - (B - num_fade_frames)) / float(num_fade_frames) + else: + fade_mult = 0.0 + else: + fade_mult = 1.0 + + # --- Stable CPU Processing --- + out_i = out[i] + if pm_crop is not None and fade_mult > 0: + roi = out_i[y0:y1, x0:x1, :3].to(device="cpu", dtype=torch.float32).permute(2, 0, 1) + alpha = (a_crop * fade_mult).clamp(0.0, 1.0) + roi = roi * (1.0 - alpha) + (pm_crop * fade_mult) + roi = roi.clamp(0, 1) + out_i[y0:y1, x0:x1] = roi.permute(1, 2, 0).to(device=output_device, dtype=output_dtype) + + pbar.update(1) + + return (out,) + +NODE_CLASS_MAPPINGS = { + "DaSiWa_Watermark": DaSiWa_Watermark, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "DaSiWa_Watermark": "DaSiWa Watermark Overlay", +} diff --git a/pyproject.toml b/pyproject.toml index a82b37e..9dbcc8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI-DaSiWa-Nodes" description = "DaSiWa Custom Nodes Collection" -version = "0.1.8" +version = "0.1.9" license = {file = "LICENSE"} dependencies = []