From 23dcd1c2d984bd2bbf08eadb60063ce4cfeb0b0c Mon Sep 17 00:00:00 2001 From: Ewald <129623145+MajoorWaldi@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:19:49 +0200 Subject: [PATCH] Add lens/compositing effect nodes and depth-based Defocus, A/B compare on Preview - Add ImageOpsVignette, ImageOpsChromaticAberration, ImageOpsBloom, and ImageOpsLensArtifacts for common lens/compositing effects. - Add ImageOpsDefocus: depth-driven defocus/bokeh with shaped aperture kernels (circle/hexagon/octagon/custom) and highlight bloom. - Add input_space (srgb/linear) to ImageOpsColorAjust so scene-linear/HDR sources aren't gamma-encoded twice. - Add image_b + compare_mode/wipe_position to ImageOpsPreview for a real A/B comparison, plus a separate live on-demand A/B toggle while editing. - Redesign the live-preview HUD: unified pill-style badges, frame counter now shows the real total and hides for single-frame sources, and the Output overlay only appears for Pad Out with the real pixel size. - Align README with the current node set and fix stale blend-mode docs. --- README.md | 162 ++++++++++- __init__.py | 10 + imageops_nodes.json | 7 +- js/preview/adapters/imageops.js | 80 +++++- js/preview/core/media.js | 8 + js/preview/core/renderer.js | 4 +- js/preview/host.js | 73 ++++- js/preview/interactions/append.js | 11 +- js/preview/interactions/preview.js | 37 +++ js/preview/nodes/append.js | 3 +- js/preview/nodes/preview.js | 67 +++++ js/preview/ops.js | 4 + js/preview/ops/lens.js | 295 +++++++++++++++++++ js/preview/shared/bounds.js | 138 +++++++-- js/preview/shared/geometry.js | 11 +- js/preview/shared/imageops-metadata.js | 20 ++ js/preview/shared/state.js | 5 + nodes/__init__.py | 10 + nodes/_helpers.py | 66 ++++- nodes/append.py | 59 ++-- nodes/bloom.py | 100 +++++++ nodes/chromatic_aberration.py | 119 ++++++++ nodes/color_ajust.py | 6 +- nodes/defocus.py | 199 +++++++++++++ nodes/frame_range.py | 38 +-- nodes/grain.py | 1 - nodes/keyer.py | 1 - nodes/lens_artifacts.py | 150 ++++++++++ nodes/mask_convert.py | 1 - nodes/preview.py | 32 ++- nodes/text.py | 1 - nodes/vignette.py | 131 +++++++++ src/preview/adapters/imageops.ts | 66 ++++- src/preview/core/media.ts | 14 + src/preview/core/renderer.ts | 7 +- src/preview/host.ts | 84 +++++- src/preview/interactions/append.ts | 17 +- src/preview/interactions/preview.ts | 41 ++- src/preview/nodes/append.ts | 5 +- src/preview/nodes/frame-range.ts | 5 +- src/preview/nodes/preview.ts | 80 ++++++ src/preview/ops.ts | 3 + src/preview/ops/lens.ts | 336 ++++++++++++++++++++++ src/preview/shared/bounds.ts | 195 +++++++++++-- src/preview/shared/geometry.ts | 15 +- src/preview/shared/imageops-metadata.ts | 20 ++ src/preview/shared/state.ts | 5 + src/types.ts | 33 +++ tests/frontend/imageops-registry.test.cjs | 2 +- tests/integration/test_media_nodes.py | 53 +++- tests/unit/test_hdr_support.py | 21 ++ 51 files changed, 2659 insertions(+), 192 deletions(-) create mode 100644 js/preview/ops/lens.js create mode 100644 nodes/bloom.py create mode 100644 nodes/chromatic_aberration.py create mode 100644 nodes/defocus.py create mode 100644 nodes/lens_artifacts.py create mode 100644 nodes/vignette.py create mode 100644 src/preview/ops/lens.ts diff --git a/README.md b/README.md index 46fc8c8..55de2ef 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,26 @@ | **ImageOps Noise** | `ImageOpsNoise` | GPU-backed procedural noise source with Perlin, value, seamless tiling, 3D Z animation, seed stepping, frame length/FPS controls, and color ramp output | | **ImageOps Paint** | `ImageOpsDraw` | Digital painting with brush/eraser tools, cropped overlay payloads, layer JSON support, and pen dynamics | | **ImageOps Comp** | `ImageOpsComp` | Multi-layer compositor with blend modes, positioning, and opacity per layer | +| **ImageOps Grain** | `ImageOpsGrain` | Synthetic film grain with add/overlay/soft_light blend, size, luma response, and per-frame animation | +| **ImageOps Camera Shake** | `ImageOpsCameraShake` | Procedural handheld camera jitter (translate/rotate/zoom) with smoothing and frequency control | +| **ImageOps Keyer** | `ImageOpsKeyer` | Chroma/luma keying with multi-color keys and despill | +| **ImageOps Text** | `ImageOpsText` | Text overlay with font size, alignment, stroke, and opacity | +| **ImageOps Frame Range** | `ImageOpsFrameRange` | Trim, hold, and repeat (loop/bounce/reverse) a frame range | +| **ImageOps Append** | `ImageOpsAppend` | Concatenate two IMAGE/VIDEO clips with FPS policy and padding options | +| **ImageOps Constant** | `ImageOpsConstant` | Solid color, checkerboard, or grid generator | +| **ImageOps Ramp** | `ImageOpsRamp` | Linear or radial gradient generator | +| **ImageOps Crop Stitch** | `ImageOpsCropStitch` | Paste an edited crop back into its original-image position with feathered edges | +| **ImageOps Vignette** | `ImageOpsVignette` | Radial darken-to-color falloff from a configurable center, size, and softness | +| **ImageOps Chromatic Aberration** | `ImageOpsChromaticAberration` | Radial red/blue channel shift simulating lens color fringing | +| **ImageOps Bloom** | `ImageOpsBloom` | Threshold + blur + additive glow on highlights, HDR-safe | +| **ImageOps Lens Artifacts** | `ImageOpsLensArtifacts` | Dirt/smudge texture compositing plus procedural static dust specks | +| **ImageOps Defocus** | `ImageOpsDefocus` | Depth-driven defocus/bokeh with shaped aperture kernels (circle/hexagon/octagon/custom) and highlight bloom | ### ๐Ÿ“ค Output Nodes -| Node | Description | -|------|-------------| -| **ImageOps Preview** | `ImageOpsPreview` | Preview bridge node with multiple display modes (images, strip, animated WebP/GIF) | +| Node | Internal ID | Description | +|------|-------------|-------------| +| **ImageOps Preview** | `ImageOpsPreview` | Preview bridge node with multiple display modes (images, strip, animated WebP/GIF) and optional A/B compare against a second input | --- @@ -83,9 +97,12 @@ Professional color grading with reference-based correction. - `contrast` (-100 to 100): Contrast adjustment - `saturation` (-100 to 100): Saturation adjustment - `gamma` (0.2 to 2.2): Gamma correction +- `input_space`: `srgb` (default) or `linear` โ€” set to `linear` for scene-linear/HDR sources so the node doesn't gamma-encode them twice - `invert_mask`: Invert mask effect - `bypass`: Skip processing +Also exposes per-zone (shadows/midtones/highlights) temperature, tint, hue, contrast, saturation, vibrance, gamma, and brightness wheels for three-way color grading. + **Outputs:** `IMAGE`, `MASK` --- @@ -355,7 +372,7 @@ Blend two images with linear-light or sRGB blend modes. - `mask` (MASK, optional): Effect mask **Parameters:** -- `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 +- `mode`: over, add, multiply, screen, overlay, soft_light, difference, lighten, darken, color_dodge, color_burn, exclusion - `mix` (0.0 to 1.0): Blend opacity - `foreground_fit`: stretch, contain, cover, or none for adapting foreground B to background A - `blend_space`: linear or srgb; linear performs RGB blend math in gamma 1.0 then converts back to sRGB @@ -424,6 +441,117 @@ The node still accepts older full-frame Base64 PNG `overlay_data` values. New pr --- +### ๐ŸŒ‘ ImageOps Vignette + +Radial darken-to-color falloff from a configurable center. + +**Inputs:** +- `image` (IMAGE/VIDEO): Source media +- `mask` (MASK, optional): Effect mask + +**Parameters:** +- `amount` (0.0 to 1.0): Strength of the darkening +- `size` (0.0 to 2.0): Radius (normalized frame units) where the vignette reaches full amount +- `softness` (0.01 to 2.0): Falloff width feathering the edge inward from `size` +- `center_x`, `center_y` (-1.0 to 2.0): Vignette center, normalized +- `color`: Color the vignette darkens toward (default black) +- `invert_mask`: Invert mask effect +- `bypass`: Skip processing + +**Outputs:** `IMAGE`, `MASK` + +--- + +### ๐ŸŒˆ ImageOps Chromatic Aberration + +Radial red/blue channel shift simulating lens color fringing. + +**Inputs:** +- `image` (IMAGE/VIDEO): Source media +- `mask` (MASK, optional): Effect mask + +**Parameters:** +- `amount` (-30 to 30): Radial channel shift in pixels at the frame corner; positive shifts red outward and blue inward +- `center_x`, `center_y` (-1.0 to 2.0): Aberration center, normalized +- `invert_mask`: Invert mask effect +- `bypass`: Skip processing + +**Outputs:** `IMAGE`, `MASK` + +--- + +### โœจ ImageOps Bloom + +Threshold + blur + additive glow on highlights. + +**Inputs:** +- `image` (IMAGE/VIDEO): Source media +- `mask` (MASK, optional): Effect mask + +**Parameters:** +- `threshold` (0.0 to 4.0): Brightness level above which pixels start to glow +- `intensity` (0.0 to 4.0): Strength of the added glow +- `radius` (0 to 128): Gaussian blur radius of the glow, in pixels +- `invert_mask`: Invert mask effect +- `bypass`: Skip processing + +The glow is added, not screened, so highlights above 1.0 on HDR sources stay correct instead of clamping. + +**Outputs:** `IMAGE`, `MASK` + +--- + +### ๐Ÿงด ImageOps Lens Artifacts + +Dirt/smudge texture compositing plus procedural static dust specks, in one node. + +**Inputs:** +- `image` (IMAGE/VIDEO): Source media +- `dirt` (IMAGE/VIDEO, optional): Dirt/smudge texture plate (e.g. a stock lens-dirt asset); its luma attenuates the image multiplicatively +- `mask` (MASK, optional): Effect mask + +**Parameters:** +- `dirt_amount` (0.0 to 1.0): Opacity of the connected dirt texture +- `dust_amount` (0.0 to 1.0): Procedural dust speck density and brightness +- `dust_size` (0.5 to 20.0): Speck size in pixels +- `seed`: Dust is a static lens artifact โ€” the same seed always produces the same specks (doesn't drift frame to frame) +- `invert_mask`: Invert mask effect +- `bypass`: Skip processing + +Dirt darkens multiplicatively (HDR-safe); dust specks add light additively, like real dust catching a highlight. + +**Outputs:** `IMAGE`, `MASK` + +--- + +### ๐Ÿ”ต ImageOps Defocus + +Depth-driven defocus/bokeh with shaped aperture kernels. + +**Inputs:** +- `image` (IMAGE/VIDEO): Source media +- `depth` (IMAGE/VIDEO, optional): Depth map โ€” its luma drives the per-pixel blur amount. Without it connected, the node is a no-op +- `shape_texture` (IMAGE/VIDEO, optional): Grayscale bokeh aperture shape, used when `bokeh_shape` is `custom` +- `mask` (MASK, optional): Effect mask + +**Parameters:** +- `focus_distance` (0.0 to 1.0): Depth value that stays in focus +- `focus_range` (0.0 to 0.99): Depth thickness around `focus_distance` that stays sharp before blur ramps up +- `max_blur_radius` (0 to 128): Bokeh disc radius in pixels at the depth extremes +- `num_layers` (2 to 32): Depth slices blurred and interpolated between โ€” higher is smoother but slower +- `bokeh_shape`: `circle`, `hexagon`, `octagon`, or `custom` (uses `shape_texture`) +- `highlight_threshold` (0.0 to 2.0): Brightness above which pixels bloom into visible bokeh discs when blurred +- `highlight_boost` (0.0 to 8.0): Strength of the bokeh highlight bloom +- `invert_depth`: Flip the depth map convention (near/far) +- `invert_mask`: Invert mask effect +- `bypass`: Skip processing + +In-focus pixels stay pixel-perfect sharp; out-of-focus depth slices are convolved with the chosen aperture shape and interpolated per-pixel to avoid banding. The live canvas preview only approximates this (a few gaussian-blur bands by depth, no shape or highlight bloom) โ€” the real shaped/glowing bokeh appears once the backend runs. + +**Outputs:** `IMAGE`, `MASK` + +--- + ### ๐ŸŽฌ ImageOps Comp ![ImageOps Comp preview](docs/OpsComp.gif) @@ -461,15 +589,20 @@ Preview output node with advanced visualization modes. **Inputs:** - `image` (IMAGE/VIDEO): Source media +- `image_b` (IMAGE/VIDEO, optional): Second source to compare against - `mask` (MASK, optional): Mask to preview **Parameters:** - `preview_target`: auto, image, or mask -- `mode`: +- `mode`: - `images`: Individual frames - `strip`: Horizontal strip for batch inspection - `animated_webp`: Animated WebP preview - `animated_gif`: Animated GIF preview +- `compare_mode`: `off`, `side_by_side`, `wipe`, or `diff` โ€” when `image_b` is connected, builds the saved/queued preview thumbnail as an A/B comparison instead of Image alone +- `wipe_position` (0.0 to 1.0): Split position used by `compare_mode=wipe` + +While editing (before queuing), the live canvas also offers its own on-demand A/B toggle (Live/B/Wipe/Diff buttons) โ€” this compares the two live-rendered inputs directly and is independent of the `compare_mode`/`wipe_position` widgets above, which control what actually gets saved once the prompt runs. Without `image_b` connected, those same buttons fall back to comparing the live canvas proxy against the last real queued result of `image`, for checking preview accuracy. **Outputs:** `IMAGE`, `MASK` @@ -544,18 +677,22 @@ Some packs expose video via custom types. Best results when upstream provides fr - **VIDEO**: Frame sources from video nodes (VHS, etc.) - **MASK**: Single-channel masks `[B, H, W]` -### ๐ŸŽจ Blend Modes Reference +### ๐ŸŽจ Blend Modes Reference (ImageOps Merge) | Mode | Description | |------|-------------| | `over` | Standard alpha compositing | | `add` | Additive blending (brightens) | -| `subtract` | Subtractive blending (darkens) | | `multiply` | Multiply colors (darkens) | | `screen` | Screen blend (brightens) | +| `overlay` | Contrast-boosting multiply/screen mix | +| `soft_light` | Softer overlay variant | | `difference` | Absolute difference | -| `max` | Maximum of each channel | -| `min` | Minimum of each channel | +| `lighten` | Maximum of each channel | +| `darken` | Minimum of each channel | +| `color_dodge` | Brightens base to reflect the blend color | +| `color_burn` | Darkens base to reflect the blend color | +| `exclusion` | Lower-contrast difference | ### ๐ŸŽ›๏ธ Compositor Blend Modes @@ -611,13 +748,18 @@ All nodes process batches natively: **Author**: Majoor **Category**: `image/imageops` -**Version**: 0.1.5 +**Version**: 0.1.7 --- ## ๐Ÿ“‹ Changelog ### Recent changes +- **ImageOps Vignette, Chromatic Aberration, Bloom, Lens Artifacts** โ€” new nodes for common lens/compositing effects: radial darken falloff, red/blue channel fringing, HDR-safe additive glow, and dirt/dust compositing +- **ImageOps Defocus** โ€” new depth-driven defocus/bokeh node with shaped aperture kernels (circle, hexagon, octagon, or a custom shape texture) and highlight bloom +- **ImageOps Color Correct** โ€” added `input_space` (`srgb`/`linear`) so scene-linear/HDR sources aren't gamma-encoded twice +- **ImageOps Preview** โ€” added an `image_b` input and `compare_mode`/`wipe_position` for a real A/B comparison baked into the saved/queued preview thumbnail, plus a separate live on-demand A/B toggle while editing +- **Live preview HUD** โ€” the frame-count badge now shows the real total (`3 / 24`) and hides itself for single-frame sources instead of always showing `f 0000`; the "Output" overlay only appears for Pad Out (where it's meaningful) and now shows the real pixel size instead of a static label - **ImageOps Spherize** โ€” new node with five projection modes (spherize, fisheye, defisheye, latlong, unlatlong), bicubic/bilinear/nearest filter, edge modes, custom output size, and circle-mask output - **ImageOps Corner Pin** โ€” bicubic interpolation option added - **ImageOps Color Correct** โ€” enhanced color correction capabilities diff --git a/__init__.py b/__init__.py index ef1cb13..b4e9b45 100644 --- a/__init__.py +++ b/__init__.py @@ -70,6 +70,11 @@ ImageOpsKeyer = _load_module(f"{_PKG}.nodes.keyer", _nodes_dir / "keyer.py").Ima ImageOpsText = _load_module(f"{_PKG}.nodes.text", _nodes_dir / "text.py").ImageOpsText 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 +ImageOpsVignette = _load_module(f"{_PKG}.nodes.vignette", _nodes_dir / "vignette.py").ImageOpsVignette +ImageOpsChromaticAberration = _load_module(f"{_PKG}.nodes.chromatic_aberration", _nodes_dir / "chromatic_aberration.py").ImageOpsChromaticAberration +ImageOpsBloom = _load_module(f"{_PKG}.nodes.bloom", _nodes_dir / "bloom.py").ImageOpsBloom +ImageOpsLensArtifacts = _load_module(f"{_PKG}.nodes.lens_artifacts", _nodes_dir / "lens_artifacts.py").ImageOpsLensArtifacts +ImageOpsDefocus = _load_module(f"{_PKG}.nodes.defocus", _nodes_dir / "defocus.py").ImageOpsDefocus NODES = [ ImageOpsBlur, @@ -98,6 +103,11 @@ NODES = [ ImageOpsText, ImageOpsFrameRange, ImageOpsAppend, + ImageOpsVignette, + ImageOpsChromaticAberration, + ImageOpsBloom, + ImageOpsLensArtifacts, + ImageOpsDefocus, ] if ComfyExtension is not object: diff --git a/imageops_nodes.json b/imageops_nodes.json index 5cd4e87..60dbb75 100644 --- a/imageops_nodes.json +++ b/imageops_nodes.json @@ -25,7 +25,12 @@ {"className":"ImageOpsPreview","module":"preview","classExport":"ImageOpsPreview","displayName":"ImageOps Preview","ui":"custom","minPreviewHeight":360,"aliases":["preview","viewer","view","monitor","scope","histogram","waveform"]}, {"className":"ImageOpsRamp","module":"ramp","classExport":"ImageOpsRamp","displayName":"ImageOps Ramp","ui":"custom","minPreviewHeight":430,"aliases":["ramp","gradient","linear gradient","radial gradient"]}, {"className":"ImageOpsSpherize","module":"spherize","classExport":"ImageOpsSpherize","displayName":"ImageOps Spherize","ui":"native","aliases":["spherize","sphere","fisheye","defisheye","latlong","lens"]}, - {"className":"ImageOpsText","module":"text","classExport":"ImageOpsText","displayName":"ImageOps Text","ui":"custom","minPreviewHeight":470,"aliases":["text","title","caption","label","type","font"]} + {"className":"ImageOpsText","module":"text","classExport":"ImageOpsText","displayName":"ImageOps Text","ui":"custom","minPreviewHeight":470,"aliases":["text","title","caption","label","type","font"]}, + {"className":"ImageOpsVignette","module":"vignette","classExport":"ImageOpsVignette","displayName":"ImageOps Vignette","ui":"native","aliases":["vignette","lens","darken edges","falloff"]}, + {"className":"ImageOpsChromaticAberration","module":"chromatic_aberration","classExport":"ImageOpsChromaticAberration","displayName":"ImageOps Chromatic Aberration","ui":"native","aliases":["chromatic aberration","fringe","fringing","lens","color fringe","ca"]}, + {"className":"ImageOpsBloom","module":"bloom","classExport":"ImageOpsBloom","displayName":"ImageOps Bloom","ui":"native","aliases":["bloom","glow","highlight glow","lens"]}, + {"className":"ImageOpsLensArtifacts","module":"lens_artifacts","classExport":"ImageOpsLensArtifacts","displayName":"ImageOps Lens Artifacts","ui":"native","aliases":["lens dirt","dust","smudge","grime","lens artifacts"]}, + {"className":"ImageOpsDefocus","module":"defocus","classExport":"ImageOpsDefocus","displayName":"ImageOps Defocus","ui":"native","aliases":["defocus","bokeh","depth of field","dof","depth blur","lens blur"]} ], "aliases": { "checker": "ImageOpsConstant", diff --git a/js/preview/adapters/imageops.js b/js/preview/adapters/imageops.js index f3a9443..5b37384 100644 --- a/js/preview/adapters/imageops.js +++ b/js/preview/adapters/imageops.js @@ -95,11 +95,47 @@ async function seekFrozenVideoFrame(videoEl, targetTime, tolerance) { } }); } +const HANDLED_CLASSES = /* @__PURE__ */ new Set([ + "ImageOpsMaskConvert", + "ImageOpsDraw", + "ImageOpsColorAjust", + "ImageOpsCameraShake", + "ImageOpsChannel", + "ImageOpsCrop", + "ImageOpsCropStitch", + "ImageOpsPadOut", + "ImageOpsCornerPin", + "ImageOpsBlur", + "ImageOpsTransform", + "ImageOpsInvert", + "ImageOpsClamp", + "ImageOpsGrain", + "ImageOpsText", + "ImageOpsKeyer", + "ImageOpsMerge", + "ImageOpsAppend", + "ImageOpsComp", + "ImageOpsDistort", + "ImageOpsSpherize", + "ImageOpsNoise", + "ImageOpsConstant", + "ImageOpsRamp", + "ImageOpsFrameRange", + "ImageOpsPreview", + "ImageOpsVignette", + "ImageOpsChromaticAberration", + "ImageOpsBloom", + "ImageOpsLensArtifacts", + "ImageOpsDefocus" +]); function imageOpsAdapter() { return { match(node) { return isImageOpsClass(node?.comfyClass); }, + previewSupport(node) { + return HANDLED_CLASSES.has(resolveImageOpsClassName(node?.comfyClass)) ? "approximate" : "unsupported"; + }, inputs: (node) => { const cls = resolveImageOpsClassName(node?.comfyClass); const bypass = !!(node?.widgets ?? []).find((w) => w?.name === "bypass")?.value; @@ -118,6 +154,15 @@ function imageOpsAdapter() { return 1 + Number(displacementConnected) + Number(effectMaskConnected); } if (cls === "ImageOpsMerge") return bypass ? 1 : maskConnected ? 3 : 2; + if (cls === "ImageOpsLensArtifacts") { + const dirtConnected = connectedInput(node, "dirt"); + return 1 + Number(dirtConnected) + Number(maskConnected); + } + if (cls === "ImageOpsDefocus") { + const depthConnected = connectedInput(node, "depth"); + const shapeConnected = connectedInput(node, "shape_texture"); + return 1 + Number(depthConnected) + Number(shapeConnected) + Number(maskConnected); + } if (cls === "ImageOpsCropStitch") { const cropMaskConnected = (node.inputs ?? []).some((input) => String(input?.name ?? "").toLowerCase() === "crop_mask" && (input?.link ?? null) != null); return 2 + Number(cropMaskConnected); @@ -127,8 +172,9 @@ function imageOpsAdapter() { if (cls === "ImageOpsDraw") return (node.inputs?.[0]?.link ?? null) != null ? 1 : 0; if (cls === "ImageOpsPreview") { const imageConnected = connectedInput(node, "image"); + const imageBConnected = connectedInput(node, "image_b"); const maskConnected2 = connectedInput(node, "mask"); - return Number(imageConnected) + Number(maskConnected2); + return Number(imageConnected) + Number(imageBConnected) + Number(maskConnected2); } return maskConnected ? 2 : 1; }, @@ -144,8 +190,14 @@ function imageOpsAdapter() { if (cls === "ImageOpsDistort") { return namedInputIndexes(node, ["image", "displacement", "mask"]); } + if (cls === "ImageOpsLensArtifacts") { + return namedInputIndexes(node, ["image", "dirt", "mask"]); + } + if (cls === "ImageOpsDefocus") { + return namedInputIndexes(node, ["image", "depth", "shape_texture", "mask"]); + } if (cls === "ImageOpsPreview") { - return namedInputIndexes(node, ["image", "mask"]); + return namedInputIndexes(node, ["image", "image_b", "mask"]); } if (cls === "ImageOpsCropStitch") { const indexes = namedInputIndexes(node, ["original", "crop"]); @@ -211,6 +263,16 @@ function imageOpsAdapter() { return ops.keyer(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsMerge") { return ops.merge(ctx, canvasSize, node, inputs, void 0, tick ?? 0); + } else if (cls === "ImageOpsVignette") { + return ops.vignette(ctx, canvasSize, node, inputs, tick ?? 0); + } else if (cls === "ImageOpsChromaticAberration") { + return ops.chromaticAberration(ctx, canvasSize, node, inputs, tick ?? 0); + } else if (cls === "ImageOpsBloom") { + return ops.bloom(ctx, canvasSize, node, inputs, tick ?? 0); + } else if (cls === "ImageOpsLensArtifacts") { + return ops.lensArtifacts(ctx, canvasSize, node, inputs, tick ?? 0); + } else if (cls === "ImageOpsDefocus") { + return ops.defocus(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsAppend") { if (inputs.length === 0) return void 0; const infos = inputInfos ?? inputs.map((canvas, index) => ({ canvas, inputIndex: index, originSlot: null, upstreamNode: null })); @@ -317,11 +379,15 @@ function imageOpsAdapter() { return inputs[0]; } else if (cls === "ImageOpsPreview") { const previewTarget = String((node?.widgets ?? []).find((widget) => widget?.name === "preview_target")?.value ?? "auto").toLowerCase(); - if (outputSlot === 1) return inputs[1] ?? inputs[0]; - if (outputSlot === 0) return inputs[0] ?? inputs[1]; - if (previewTarget === "mask") return inputs[1] ?? inputs[0]; - if (previewTarget === "image") return inputs[0] ?? inputs[1]; - return inputs[0] ?? inputs[1]; + const imageBConnected = (node.inputs?.[1]?.link ?? null) != null; + const maskConnected = (node.inputs?.[2]?.link ?? null) != null; + const maskCursor = 1 + Number(imageBConnected); + const mask = maskConnected ? inputs[maskCursor] ?? null : null; + if (outputSlot === 1) return mask ?? inputs[0]; + if (outputSlot === 0) return inputs[0] ?? mask; + if (previewTarget === "mask") return mask ?? inputs[0]; + if (previewTarget === "image") return inputs[0] ?? mask; + return inputs[0] ?? mask; } else { } } diff --git a/js/preview/core/media.js b/js/preview/core/media.js index 7a66898..b971e97 100644 --- a/js/preview/core/media.js +++ b/js/preview/core/media.js @@ -9,6 +9,13 @@ function getNativePreviewImage(node) { const candidate = imgs[Math.max(0, Math.min(imgs.length - 1, index))] ?? imgs[imgs.length - 1] ?? null; return candidate instanceof HTMLImageElement ? candidate : null; } +function getNativePreviewImageForTick(node, tick) { + const imgs = node.imgs; + if (!Array.isArray(imgs) || imgs.length === 0) return null; + const index = (Math.max(0, Math.round(tick)) % imgs.length + imgs.length) % imgs.length; + const candidate = imgs[index] ?? imgs[imgs.length - 1] ?? null; + return candidate instanceof HTMLImageElement ? candidate : null; +} function parsePreviewSourceUrl(src) { try { return new URL(src, window.location.href); @@ -96,6 +103,7 @@ export { buildMediaPreviewUrl, getInputIndexByName, getNativePreviewImage, + getNativePreviewImageForTick, hideNativeMediaPreview, nativePreviewFilename, parsePreviewSourceUrl, diff --git a/js/preview/core/renderer.js b/js/preview/core/renderer.js index 490592a..540d005 100644 --- a/js/preview/core/renderer.js +++ b/js/preview/core/renderer.js @@ -71,7 +71,9 @@ function buildRenderer({ api, registry, canvasSize }) { async function render(node, tick = 0, outputSlot = null, canvasSizeOverride) { const ctx = { api, canvasSize: canvasSizeOverride ?? canvasSize, tick, cache: /* @__PURE__ */ new Map(), visited: /* @__PURE__ */ new Set() }; const canvas = await renderNode(node, ctx, outputSlot); - return { canvas }; + const adapter = registry.pick(node); + const unsupported = isImageOpsClass(node?.comfyClass) && (!adapter || adapter.previewSupport?.(node) === "unsupported"); + return { canvas, unsupported }; } async function renderNode(node, ctx, outputSlot) { if (!node) return null; diff --git a/js/preview/host.js b/js/preview/host.js index 6eb0d2f..3bce92e 100644 --- a/js/preview/host.js +++ b/js/preview/host.js @@ -4,7 +4,7 @@ import { getCompSlots } from "./comp.js"; import { getPreviewConfig } from "./config.js"; import { initOpsConstants } from "./constants.js"; import { detectSourceUpstream, findDependents, getInputOriginSlot, getUpstreamNode, getUpstreamNodes, isGraphTooLarge } from "./core/graph.js"; -import { getInputIndexByName, getNativePreviewImage, getUpstreamVideoFps } from "./core/media.js"; +import { getInputIndexByName, getNativePreviewImage, getNativePreviewImageForTick, getUpstreamVideoFps } from "./core/media.js"; import { buildRenderer } from "./core/renderer.js"; import { schedule, stopRAF } from "./core/scheduler.js"; import { attachInteractions as attachJoinInteractionsExt, syncJoinControls } from "./interactions/append.js"; @@ -68,7 +68,7 @@ import { renderCompPreview } from "./ops.js"; import { attachProgressBus } from "./progress.js"; import { buildAdapterRegistry } from "./registry.js"; import { getProceduralFrameCount, getProceduralPlaybackFps, hasProceduralAnimation } from "./shared/animation.js"; -import { blit, tryRenderNativePreview } from "./shared/bounds.js"; +import { blit, blitCompare, tryRenderNativePreview } from "./shared/bounds.js"; import { markCanvasDirty } from "./shared/canvas.js"; import { isImageOpsClass } from "./shared/classes.js"; import { noteFrame } from "./shared/fps-monitor.js"; @@ -606,12 +606,17 @@ function registerImageOpsLivePreview() { function getPreviewFrameIndex(node, tick) { const frameIndex = Math.max(0, Math.round(tick || 0)); const overlayFrameCount = isJoinNode(node) ? getJoinPreviewFrameCount(node) : getPreviewNodeFrameCount(getPrimaryOverlaySourceNode(node) ?? node); - return overlayFrameCount > 0 ? frameIndex % overlayFrameCount : frameIndex; + return { + frameIndex: overlayFrameCount > 0 ? frameIndex % overlayFrameCount : frameIndex, + frameCount: Math.max(1, overlayFrameCount) + }; } function renderNode(node, tick = 0) { const st = ensurePreviewWidget(node, progress, canvasSize); if (!st) return; - st.previewFrameIndex = getPreviewFrameIndex(node, tick); + const frame = getPreviewFrameIndex(node, tick); + st.previewFrameIndex = frame.frameIndex; + st.previewFrameCount = frame.frameCount; const renderCanvasSize = getRenderCanvasSize(st); const renderKey = buildPreviewRenderKey(node, tick, st, renderCanvasSize); if (!st.nativeDirty && st.lastKey === renderKey && st.lastRenderTick === tick) { @@ -752,6 +757,62 @@ function registerImageOpsLivePreview() { }); return; } + if (isPreviewNode(node) && st.previewCompareMode !== "live") { + const compareMode = st.previewCompareMode; + const imageBIndex = getInputIndexByName(node, "image_b"); + const imageBUpstream = imageBIndex >= 0 ? getUpstreamNode(node, imageBIndex) : null; + if (imageBUpstream) { + Promise.all([ + renderer.render(node, tick, null, renderCanvasSize), + renderer.render(imageBUpstream, tick, getInputOriginSlot(node, imageBIndex), renderCanvasSize) + ]).then(([resultA, resultB]) => { + if (!resultA?.canvas || !resultB?.canvas) { + setInfo(st, "Live preview: connect a supported loader/chain"); + finishRender(); + return; + } + const sourceWidth = resultB.canvas.width || resultA.canvas.width || 1; + const sourceHeight = resultB.canvas.height || resultA.canvas.height || 1; + if (compareMode === "backend") { + blit(node, st, resultB.canvas, renderCanvasSize, sourceWidth, sourceHeight); + setInfo(st, "Showing B input"); + } else { + blitCompare(node, st, resultA.canvas, resultB.canvas, renderCanvasSize, compareMode, st.previewWipeFraction, sourceWidth, sourceHeight, { + solo: "B", + diff: "DIFF (A vs B)", + wipe: "A \u2190 wipe \u2192 B" + }); + setInfo(st, `Comparing A vs B (${compareMode})`); + } + commitRender(); + finishRender(); + }).catch((err) => { + failRender("Compare preview error (check console)", err); + }); + return; + } + const backendImg = widgetString(node, "mode", "images").toLowerCase() === "images" ? getNativePreviewImageForTick(node, tick) : null; + if (backendImg) { + renderer.render(node, tick, null, renderCanvasSize).then((result) => { + if (!result?.canvas) { + setInfo(st, "Live preview: connect a supported loader/chain"); + finishRender(); + return; + } + const sourceWidth = backendImg.naturalWidth || result.canvas.width || 1; + const sourceHeight = backendImg.naturalHeight || result.canvas.height || 1; + blitCompare(node, st, result.canvas, backendImg, renderCanvasSize, compareMode, st.previewWipeFraction, sourceWidth, sourceHeight); + const frameCount = Array.isArray(node.imgs) ? node.imgs.length : 1; + const frameIndex = (Math.max(0, Math.round(tick)) % frameCount + frameCount) % frameCount; + setInfo(st, `Comparing frame ${frameIndex} (${compareMode}) \u2014 both sides are sRGB 8-bit display pixels, not raw HDR`); + commitRender(); + finishRender(); + }).catch((err) => { + failRender("Compare preview error (check console)", err); + }); + return; + } + } if (tryRenderNativePreview(node, st, renderCanvasSize)) { commitRender(); finishRender(); @@ -787,7 +848,9 @@ function registerImageOpsLivePreview() { } } blit(node, st, result.canvas, renderCanvasSize, sourceWidth, sourceHeight); - if (isCornerPinNode(node)) { + if (result.unsupported) { + setInfo(st, "No live preview for this node - showing its input; run the queue to see the real result"); + } else if (isCornerPinNode(node)) { setInfo(st, getCornerPinInfoText(node, sourceWidth, sourceHeight)); } else if (isTextNode(node)) { setInfo(st, getTextInfoText(node)); diff --git a/js/preview/interactions/append.js b/js/preview/interactions/append.js index bffc15e..7b8f1f5 100644 --- a/js/preview/interactions/append.js +++ b/js/preview/interactions/append.js @@ -19,7 +19,7 @@ function selectionCount(start, end, frameCount) { const max = Math.max(0, frameCount - 1); const s = Math.max(0, Math.min(max, Math.round(start))); const e = end < 0 ? max : Math.max(0, Math.min(max, Math.round(end))); - return Math.max(1, Math.abs(e - s) + 1); + return e < s ? 0 : e - s + 1; } function syncFill(fill, start, end, max) { const lo = Math.max(0, Math.min(max, Math.min(start, end))); @@ -239,13 +239,8 @@ function buildJoinTrimRow(node, ctx, st, slot, index) { existing = { slot: rowState.slot, start: 0, end: -1 }; next.push(existing); } - let startVal = Math.max(0, Math.min(rowState.maxFrame, Math.round(Number(rowState.startNumber.value || 0)))); - let endVal = Math.max(0, Math.min(rowState.maxFrame, Math.round(Number(rowState.endNumber.value || rowState.maxFrame)))); - if (endVal < startVal) { - const tmp = startVal; - startVal = endVal; - endVal = tmp; - } + const startVal = Math.max(0, Math.min(rowState.maxFrame, Math.round(Number(rowState.startNumber.value || 0)))); + const endVal = Math.max(startVal, Math.min(rowState.maxFrame, Math.round(Number(rowState.endNumber.value || rowState.maxFrame)))); existing.start = startVal; existing.end = rowState.endIsAuto && endVal >= rowState.maxFrame ? -1 : endVal; rowState.startNumber.value = String(startVal); diff --git a/js/preview/interactions/preview.js b/js/preview/interactions/preview.js index 23ab498..8af2e6f 100644 --- a/js/preview/interactions/preview.js +++ b/js/preview/interactions/preview.js @@ -24,6 +24,43 @@ function attachInteractions(node, ctx) { st.nativeDirty = true; ctx.schedule(node, () => ctx.startLoopIfVideo(node), 0); }); + for (const button of Array.from(root.querySelectorAll("button[data-compare-mode]"))) { + button.addEventListener("click", (event) => { + event.preventDefault(); + st.previewCompareMode = button.dataset.compareMode ?? "live"; + syncPreviewWidgets(node); + ctx.refreshPreviewOnly(node, 0); + }); + } + const wipeTrack = root.querySelector("div[data-wipe-track]"); + if (wipeTrack) { + const fractionFromPointer = (event) => { + const rect = wipeTrack.getBoundingClientRect(); + return Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))); + }; + wipeTrack.addEventListener("pointerdown", (event) => { + st.previewWipeDrag = { pointerId: event.pointerId }; + st.previewWipeFraction = fractionFromPointer(event); + syncPreviewWidgets(node); + ctx.refreshPreviewOnly(node, 0); + wipeTrack.setPointerCapture?.(event.pointerId); + event.preventDefault(); + }); + wipeTrack.addEventListener("pointermove", (event) => { + if (!st.previewWipeDrag || st.previewWipeDrag.pointerId !== event.pointerId) return; + st.previewWipeFraction = fractionFromPointer(event); + syncPreviewWidgets(node); + ctx.refreshPreviewOnly(node, 0); + event.preventDefault(); + }); + const release = (event) => { + if (!st.previewWipeDrag || st.previewWipeDrag.pointerId !== event.pointerId) return; + st.previewWipeDrag = null; + wipeTrack.releasePointerCapture?.(event.pointerId); + }; + wipeTrack.addEventListener("pointerup", release); + wipeTrack.addEventListener("pointercancel", release); + } syncPreviewWidgets(node); } export { diff --git a/js/preview/nodes/append.js b/js/preview/nodes/append.js index 208ce5c..ac18c19 100644 --- a/js/preview/nodes/append.js +++ b/js/preview/nodes/append.js @@ -151,8 +151,7 @@ function trimSelectionCount(frameCount, start, end) { const max = Math.max(0, Math.round(frameCount) - 1); const actualStart = Math.max(0, Math.min(max, Math.round(start))); const actualEnd = end < 0 ? max : Math.max(0, Math.min(max, Math.round(end))); - const [lo, hi] = actualStart <= actualEnd ? [actualStart, actualEnd] : [actualEnd, actualStart]; - return Math.max(1, hi - lo + 1); + return actualEnd < actualStart ? 0 : actualEnd - actualStart + 1; } function trimsBySlot(node) { return new Map(readJoinTrims(node).map((trim) => [trim.slot, trim])); diff --git a/js/preview/nodes/preview.js b/js/preview/nodes/preview.js index 9328eff..cb54867 100644 --- a/js/preview/nodes/preview.js +++ b/js/preview/nodes/preview.js @@ -4,6 +4,12 @@ const NODE_CLASS = "ImageOpsPreview"; function isNode(node) { return String(node?.comfyClass ?? "") === NODE_CLASS; } +const COMPARE_MODES = [ + ["live", "Live"], + ["backend", "Backend"], + ["wipe", "Wipe"], + ["diff", "Diff"] +]; function createPreviewControlsUi() { const controls = document.createElement("div"); controls.style.marginTop = "8px"; @@ -35,12 +41,51 @@ function createPreviewControlsUi() { } targetRow.appendChild(createContextMenuSelect(modeSelect)); controls.appendChild(targetRow); + const compareRow = document.createElement("div"); + compareRow.style.display = "grid"; + compareRow.style.gridTemplateColumns = `repeat(${COMPARE_MODES.length}, 1fr)`; + compareRow.style.gap = "6px"; + for (const [value, label] of COMPARE_MODES) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = value === "live" ? "Interactive canvas proxy (default)" : value === "backend" ? "Show the last real queued result for this frame" : value === "wipe" ? "Drag the divider to wipe between live proxy and backend result" : "Highlight per-pixel differences between live proxy and backend result"; + button.dataset.compareMode = value; + styleSoftButton(button, value === "live"); + compareRow.appendChild(button); + } + controls.appendChild(compareRow); + const wipeTrack = document.createElement("div"); + wipeTrack.dataset.wipeTrack = "1"; + wipeTrack.style.position = "relative"; + wipeTrack.style.height = "14px"; + wipeTrack.style.background = "rgba(255,255,255,0.09)"; + wipeTrack.style.borderRadius = "4px"; + wipeTrack.style.cursor = "ew-resize"; + wipeTrack.style.userSelect = "none"; + wipeTrack.style.display = "none"; + wipeTrack.title = "Live \u2194 Backend wipe position"; + const wipeHandle = document.createElement("div"); + wipeHandle.style.position = "absolute"; + wipeHandle.style.top = "0"; + wipeHandle.style.width = "4px"; + wipeHandle.style.height = "100%"; + wipeHandle.style.background = "#3b82f6"; + wipeHandle.style.transform = "translateX(-50%)"; + wipeHandle.style.left = "50%"; + wipeHandle.style.borderRadius = "2px"; + wipeHandle.style.pointerEvents = "none"; + wipeTrack.appendChild(wipeHandle); + controls.appendChild(wipeTrack); return { controls }; } function hidePreviewWidgets(node) { hideWidgetForGood(node, findWidget(node, "preview_target")); hideWidgetForGood(node, findWidget(node, "mode")); } +function isImageBConnected(node) { + return (node.inputs ?? []).some((input) => String(input?.name ?? "").toLowerCase() === "image_b" && (input?.link ?? null) != null); +} function syncPreviewWidgets(node) { if (!isNode(node)) return; const st = node.__imageops_state ?? null; @@ -53,6 +98,28 @@ function syncPreviewWidgets(node) { } const modeSelect = root.querySelector("select[data-preview-mode]"); if (modeSelect) modeSelect.value = mode; + const compareMode = st?.previewCompareMode ?? "live"; + const hasImageB = isImageBConnected(node); + for (const button of Array.from(root.querySelectorAll("button[data-compare-mode]"))) { + styleSoftButton(button, button.dataset.compareMode === compareMode); + if (button.dataset.compareMode === "backend") { + button.textContent = hasImageB ? "B" : "Backend"; + button.title = hasImageB ? "Show the B input alone" : "Show the last real queued result for this frame"; + } else if (button.dataset.compareMode === "wipe") { + button.title = hasImageB ? "Drag the divider to wipe between A and B" : "Drag the divider to wipe between live proxy and backend result"; + } else if (button.dataset.compareMode === "diff") { + button.title = hasImageB ? "Highlight per-pixel differences between A and B" : "Highlight per-pixel differences between live proxy and backend result"; + } else if (button.dataset.compareMode === "live") { + button.title = hasImageB ? "Show the A input alone (interactive canvas proxy)" : "Interactive canvas proxy (default)"; + } + } + const wipeTrack = root.querySelector("div[data-wipe-track]"); + if (wipeTrack) { + wipeTrack.style.display = compareMode === "wipe" ? "block" : "none"; + wipeTrack.title = hasImageB ? "A \u2194 B wipe position" : "Live \u2194 Backend wipe position"; + const handle = wipeTrack.firstElementChild; + if (handle) handle.style.left = `${Math.max(0, Math.min(1, st?.previewWipeFraction ?? 0.5)) * 100}%`; + } } export { NODE_CLASS, diff --git a/js/preview/ops.js b/js/preview/ops.js index 64ddb31..c616c5c 100644 --- a/js/preview/ops.js +++ b/js/preview/ops.js @@ -4,12 +4,14 @@ import { geometryOps } from "./ops/geometry.js"; import { renderCompPreview } from "./ops/blend.js"; import { renderDrawNodePreview } from "./ops/procedural.js"; import { ops as runtimeOps } from "./ops/implementation.js"; +import { lensOps } from "./ops/lens.js"; import { maskOps } from "./ops/masks.js"; import { proceduralOps } from "./ops/procedural.js"; import { videoOps } from "./ops/video.js"; import { blendOps as blendOps2 } from "./ops/blend.js"; import { colorOps as colorOps2 } from "./ops/color.js"; import { geometryOps as geometryOps2 } from "./ops/geometry.js"; +import { lensOps as lensOps2 } from "./ops/lens.js"; import { maskOps as maskOps2 } from "./ops/masks.js"; import { proceduralOps as proceduralOps2 } from "./ops/procedural.js"; import { videoOps as videoOps2 } from "./ops/video.js"; @@ -20,12 +22,14 @@ const ops = { ...blendOps, ...maskOps, ...proceduralOps, + ...lensOps, ...videoOps }; export { blendOps2 as blendOps, colorOps2 as colorOps, geometryOps2 as geometryOps, + lensOps2 as lensOps, maskOps2 as maskOps, ops, proceduralOps2 as proceduralOps, diff --git a/js/preview/ops/lens.js b/js/preview/ops/lens.js new file mode 100644 index 0000000..9bb9296 --- /dev/null +++ b/js/preview/ops/lens.js @@ -0,0 +1,295 @@ +import { makeCanvas } from "../draw.js"; +import { boolAny, numAny, strAny } from "../graph.js"; +import { clamp01, hexToRgb01 } from "./color.js"; +import { grainRandom01 } from "./procedural.js"; +import { resolvePreviewMaskCanvas } from "./masks.js"; +function smoothstep01(t) { + const c = Math.max(0, Math.min(1, t)); + return c * c * (3 - 2 * c); +} +function renderVignetteCanvas(node, source, rawMask, frameIndex) { + const width = source.width || 1; + const height = source.height || 1; + const output = makeCanvas(width, height); + const octx = output.getContext("2d", { willReadFrequently: true }); + octx.drawImage(source, 0, 0, width, height); + const img = octx.getImageData(0, 0, width, height); + const data = img.data; + const amount = Math.max(0, Math.min(1, numAny(node, ["amount"], 0.5, frameIndex))); + if (amount <= 0) return output; + const size = numAny(node, ["size"], 1, frameIndex); + const softness = Math.max(1e-4, numAny(node, ["softness"], 0.6, frameIndex)); + const centerX = numAny(node, ["center_x"], 0.5, frameIndex); + const centerY = numAny(node, ["center_y"], 0.5, frameIndex); + const [tr, tg, tb] = hexToRgb01(strAny(node, ["color"], "#000000", frameIndex)); + const aspect = width / Math.max(1, height); + const inner = Math.max(0, size - softness); + const outer = Math.max(size, inner + 1e-4); + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + for (let y = 0; y < height; y++) { + const ny = height > 1 ? y / (height - 1) : 0; + for (let x = 0; x < width; x++) { + const nx = width > 1 ? x / (width - 1) : 0; + const i = (y * width + x) * 4; + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) continue; + const dx = aspect >= 1 ? (nx - centerX) * aspect : nx - centerX; + const dy = aspect >= 1 ? ny - centerY : (ny - centerY) / Math.max(1e-6, aspect); + const dist = Math.hypot(dx, dy); + const t = smoothstep01((dist - inner) / (outer - inner)); + const falloff = t * amount * weight; + if (falloff <= 0) continue; + data[i] = Math.round((data[i] / 255 * (1 - falloff) + tr * falloff) * 255); + data[i + 1] = Math.round((data[i + 1] / 255 * (1 - falloff) + tg * falloff) * 255); + data[i + 2] = Math.round((data[i + 2] / 255 * (1 - falloff) + tb * falloff) * 255); + } + } + octx.putImageData(img, 0, 0); + return output; +} +function renderChromaticAberrationCanvas(node, source, rawMask, frameIndex) { + const width = source.width || 1; + const height = source.height || 1; + const output = makeCanvas(width, height); + const octx = output.getContext("2d", { willReadFrequently: true }); + octx.drawImage(source, 0, 0, width, height); + const amount = numAny(node, ["amount"], 2, frameIndex); + if (amount === 0) return output; + const centerX = numAny(node, ["center_x"], 0.5, frameIndex); + const centerY = numAny(node, ["center_y"], 0.5, frameIndex); + const cxPx = centerX * Math.max(0, width - 1); + const cyPx = centerY * Math.max(0, height - 1); + const halfDiag = Math.hypot(width / 2, height / 2) || 1; + const k = amount / halfDiag; + const src = octx.getImageData(0, 0, width, height); + const srcData = src.data; + const out = octx.createImageData(width, height); + const outData = out.data; + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + const sampleChannel = (sx, sy, channel) => { + const cx = Math.max(0, Math.min(width - 1, sx)); + const cy = Math.max(0, Math.min(height - 1, sy)); + const x0 = Math.floor(cx); + const y0 = Math.floor(cy); + const x1 = Math.min(width - 1, x0 + 1); + const y1 = Math.min(height - 1, y0 + 1); + const tx = cx - x0; + const ty = cy - y0; + const p00 = srcData[(y0 * width + x0) * 4 + channel]; + const p10 = srcData[(y0 * width + x1) * 4 + channel]; + const p01 = srcData[(y1 * width + x0) * 4 + channel]; + const p11 = srcData[(y1 * width + x1) * 4 + channel]; + return (p00 * (1 - tx) + p10 * tx) * (1 - ty) + (p01 * (1 - tx) + p11 * tx) * ty; + }; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) { + outData[i] = srcData[i]; + outData[i + 1] = srcData[i + 1]; + outData[i + 2] = srcData[i + 2]; + outData[i + 3] = srcData[i + 3]; + continue; + } + const rx = (x - cxPx) * (1 + k) + cxPx; + const ry = (y - cyPx) * (1 + k) + cyPx; + const bx = (x - cxPx) * (1 - k) + cxPx; + const by = (y - cyPx) * (1 - k) + cyPx; + const r = sampleChannel(rx, ry, 0); + const bch = sampleChannel(bx, by, 2); + outData[i] = Math.round(srcData[i] * (1 - weight) + r * weight); + outData[i + 1] = srcData[i + 1]; + outData[i + 2] = Math.round(srcData[i + 2] * (1 - weight) + bch * weight); + outData[i + 3] = srcData[i + 3]; + } + } + octx.putImageData(out, 0, 0); + return output; +} +function renderBloomCanvas(node, source, rawMask, frameIndex) { + const width = source.width || 1; + const height = source.height || 1; + const output = makeCanvas(width, height); + const octx = output.getContext("2d", { willReadFrequently: true }); + octx.drawImage(source, 0, 0, width, height); + const threshold = numAny(node, ["threshold"], 0.8, frameIndex); + const intensity = Math.max(0, numAny(node, ["intensity"], 0.5, frameIndex)); + const radius = Math.max(0, Math.round(numAny(node, ["radius"], 12, frameIndex))); + if (intensity <= 0) return output; + const img = octx.getImageData(0, 0, width, height); + const data = img.data; + const bright = makeCanvas(width, height); + const bctx = bright.getContext("2d", { willReadFrequently: true }); + const brightImg = bctx.createImageData(width, height); + const brightData = brightImg.data; + for (let i = 0; i < data.length; i += 4) { + brightData[i] = Math.max(0, data[i] - threshold * 255); + brightData[i + 1] = Math.max(0, data[i + 1] - threshold * 255); + brightData[i + 2] = Math.max(0, data[i + 2] - threshold * 255); + brightData[i + 3] = 255; + } + bctx.putImageData(brightImg, 0, 0); + const glow = makeCanvas(width, height); + const gctx = glow.getContext("2d", { willReadFrequently: true }); + gctx.filter = radius > 0 ? `blur(${radius}px)` : "none"; + gctx.drawImage(bright, 0, 0, width, height); + gctx.filter = "none"; + const glowData = gctx.getImageData(0, 0, width, height).data; + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + for (let i = 0; i < data.length; i += 4) { + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) continue; + data[i] = Math.min(255, Math.round(data[i] + glowData[i] * intensity * weight)); + data[i + 1] = Math.min(255, Math.round(data[i + 1] + glowData[i + 1] * intensity * weight)); + data[i + 2] = Math.min(255, Math.round(data[i + 2] + glowData[i + 2] * intensity * weight)); + } + octx.putImageData(img, 0, 0); + return output; +} +function renderLensArtifactsCanvas(node, source, dirt, rawMask, frameIndex) { + const width = source.width || 1; + const height = source.height || 1; + const output = makeCanvas(width, height); + const octx = output.getContext("2d", { willReadFrequently: true }); + octx.drawImage(source, 0, 0, width, height); + const dirtAmount = Math.max(0, Math.min(1, numAny(node, ["dirt_amount"], 0, frameIndex))); + const dustAmount = Math.max(0, Math.min(1, numAny(node, ["dust_amount"], 0, frameIndex))); + if (dirtAmount <= 0 && dustAmount <= 0) return output; + const dustSize = Math.max(0.5, numAny(node, ["dust_size"], 2, frameIndex)); + const seed = Math.max(0, Math.round(numAny(node, ["seed"], 12345, frameIndex))); + const img = octx.getImageData(0, 0, width, height); + const data = img.data; + let dirtData = null; + if (dirt && dirtAmount > 0) { + const dctx = makeCanvas(width, height).getContext("2d", { willReadFrequently: true }); + dctx.drawImage(dirt, 0, 0, width, height); + dirtData = dctx.getImageData(0, 0, width, height).data; + } + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) continue; + for (let c = 0; c < 3; c++) { + let value = data[i + c] / 255; + if (dirtData) { + const luma = (dirtData[i] + dirtData[i + 1] + dirtData[i + 2]) / (3 * 255); + const attenuation = 1 - (1 - luma) * dirtAmount * weight; + value = value * attenuation; + } + if (dustAmount > 0) { + const gx = Math.floor(x / dustSize); + const gy = Math.floor(y / dustSize); + const threshold = 1 - dustAmount * 0.12; + const speck = grainRandom01(seed, gx, gy, 0, 0) > threshold ? 1 : 0; + value = value + speck * dustAmount * weight; + } + data[i + c] = Math.round(clamp01(value) * 255); + } + } + } + octx.putImageData(img, 0, 0); + return output; +} +function vignette(ctx, W, node, inputs = [], frameIndex = 0) { + const source = inputs[0] ?? ctx.canvas; + return renderVignetteCanvas(node, source, inputs[1] ?? null, frameIndex); +} +function chromaticAberration(ctx, W, node, inputs = [], frameIndex = 0) { + const source = inputs[0] ?? ctx.canvas; + return renderChromaticAberrationCanvas(node, source, inputs[1] ?? null, frameIndex); +} +function bloom(ctx, W, node, inputs = [], frameIndex = 0) { + const source = inputs[0] ?? ctx.canvas; + return renderBloomCanvas(node, source, inputs[1] ?? null, frameIndex); +} +function renderDefocusCanvas(node, source, depth, rawMask, frameIndex) { + const width = source.width || 1; + const height = source.height || 1; + const output = makeCanvas(width, height); + const octx = output.getContext("2d", { willReadFrequently: true }); + octx.drawImage(source, 0, 0, width, height); + if (!depth) return output; + const maxRadius = Math.max(0, Math.round(numAny(node, ["max_blur_radius"], 24, frameIndex))); + if (maxRadius <= 0) return output; + const focusDistance = numAny(node, ["focus_distance"], 0.5, frameIndex); + const focusRange = Math.min(0.99, Math.max(0, numAny(node, ["focus_range"], 0.05, frameIndex))); + const invertDepth = boolAny(node, ["invert_depth"], false, frameIndex); + const dctx = makeCanvas(width, height).getContext("2d", { willReadFrequently: true }); + dctx.drawImage(depth, 0, 0, width, height); + const depthData = dctx.getImageData(0, 0, width, height).data; + const BANDS = 4; + const bandRadii = Array.from({ length: BANDS }, (_, i) => Math.round(i / (BANDS - 1) * maxRadius)); + const bandData = bandRadii.map((r) => { + if (r <= 0) return octx.getImageData(0, 0, width, height).data; + const band = makeCanvas(width, height); + const bctx = band.getContext("2d", { willReadFrequently: true }); + bctx.filter = `blur(${r}px)`; + bctx.drawImage(source, 0, 0, width, height); + bctx.filter = "none"; + return bctx.getImageData(0, 0, width, height).data; + }); + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + const img = octx.getImageData(0, 0, width, height); + const data = img.data; + const step = maxRadius / (BANDS - 1) || 1; + for (let i = 0; i < data.length; i += 4) { + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) continue; + let depthValue = depthData[i] / 255; + if (invertDepth) depthValue = 1 - depthValue; + const coc = Math.max(0, Math.abs(depthValue - focusDistance) - focusRange) / Math.max(1e-3, 1 - focusRange); + const radius = Math.min(maxRadius, coc * maxRadius); + const pos = Math.min(BANDS - 1, radius / step); + const lo = Math.floor(pos); + const hi = Math.min(BANDS - 1, lo + 1); + const frac = pos - lo; + for (let c = 0; c < 3; c++) { + const blended = bandData[lo][i + c] * (1 - frac) + bandData[hi][i + c] * frac; + data[i + c] = Math.round(data[i + c] * (1 - weight) + blended * weight); + } + } + octx.putImageData(img, 0, 0); + return output; +} +function defocus(ctx, W, node, inputs = [], frameIndex = 0) { + const source = inputs[0] ?? ctx.canvas; + const depthConnected = (node.inputs?.[1]?.link ?? null) != null; + const shapeConnected = (node.inputs?.[2]?.link ?? null) != null; + const maskConnected = (node.inputs?.[3]?.link ?? null) != null; + let cursor = 1; + const depth = depthConnected ? inputs[cursor++] ?? null : null; + if (shapeConnected) cursor++; + const mask = maskConnected ? inputs[cursor] ?? null : null; + return renderDefocusCanvas(node, source, depth, mask, frameIndex); +} +function lensArtifacts(ctx, W, node, inputs = [], frameIndex = 0) { + const source = inputs[0] ?? ctx.canvas; + const dirtConnected = (node.inputs?.[1]?.link ?? null) != null; + const maskConnected = (node.inputs?.[2]?.link ?? null) != null; + let cursor = 1; + const dirt = dirtConnected ? inputs[cursor++] ?? null : null; + const mask = maskConnected ? inputs[cursor] ?? null : null; + return renderLensArtifactsCanvas(node, source, dirt, mask, frameIndex); +} +const lensOps = { vignette, chromaticAberration, bloom, lensArtifacts, defocus }; +export { + bloom, + chromaticAberration, + defocus, + lensArtifacts, + lensOps, + renderBloomCanvas, + renderChromaticAberrationCanvas, + renderDefocusCanvas, + renderLensArtifactsCanvas, + renderVignetteCanvas, + vignette +}; diff --git a/js/preview/shared/bounds.js b/js/preview/shared/bounds.js index a63909d..e05c401 100644 --- a/js/preview/shared/bounds.js +++ b/js/preview/shared/bounds.js @@ -5,7 +5,7 @@ import { getCropCanvasMetrics, getCropControlState, isNode as isCropNode } from import { isNode as isDrawNode } from "../nodes/draw.js"; import { isNode as isPadOutNode } from "../nodes/pad-out.js"; import { isNode as isRampNode, rampControlPoints } from "../nodes/ramp.js"; -import { drawOutputFormatBox, getFitPlacement } from "./geometry.js"; +import { drawOutputBounds, getFitPlacement } from "./geometry.js"; import { getNativePreviewImage, hideNativeMediaPreview, showNativeMediaPreview } from "./media.js"; import { ensureState, setInfo } from "./state.js"; import { widgetNumber } from "./widgets.js"; @@ -362,27 +362,66 @@ function drawPadOutBounds(node, ctx, width, height, outputWidth, outputHeight, f fitDrawHeight: fit.drawHeight }; } -function drawFrameNumberOverlay(ctx, canvasSize, frameIndex) { - if (frameIndex == null || !Number.isFinite(frameIndex)) return; - const label = `f ${Math.max(0, Math.round(frameIndex)).toString().padStart(4, "0")}`; +function drawHudPill(ctx, canvasSize, anchor, text, accent = "rgba(255,255,255,0.85)") { ctx.save(); ctx.font = "11px ui-monospace, SFMono-Regular, Consolas, monospace"; - ctx.textBaseline = "top"; - const metrics = ctx.measureText(label); - const padX = 7; - const padY = 4; - const x = 8; - const w = Math.ceil(metrics.width + padX * 2); + ctx.textBaseline = "middle"; + const metrics = ctx.measureText(text); + const padX = 10; + const dotRadius = 3; + const dotGap = 6; const h = 20; - const y = Math.max(8, canvasSize - h - 8); - ctx.fillStyle = "rgba(0,0,0,0.58)"; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = "rgba(255,255,255,0.16)"; - ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); - ctx.fillStyle = "rgba(255,255,255,0.9)"; - ctx.fillText(label, x + padX, y + padY); + const w = Math.ceil(metrics.width + padX * 2 + dotRadius * 2 + dotGap); + let x; + let y; + if (anchor === "bottom-center") { + x = Math.round((canvasSize - w) / 2); + y = canvasSize - h - 10; + } else if (anchor === "top-right") { + x = canvasSize - w - 8; + y = 8; + } else { + x = 8; + y = 8; + } + const r = h / 2; + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + ctx.fillStyle = "rgba(14,16,20,0.72)"; + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = "rgba(255,255,255,0.14)"; + ctx.stroke(); + const dotCx = x + padX; + const dotCy = y + h / 2; + ctx.beginPath(); + ctx.arc(dotCx, dotCy, dotRadius, 0, Math.PI * 2); + ctx.fillStyle = accent; + ctx.fill(); + ctx.fillStyle = "rgba(255,255,255,0.92)"; + ctx.fillText(text, dotCx + dotRadius + dotGap, dotCy + 0.5); ctx.restore(); } +const FRAME_HUD_ACCENT = "rgba(96,165,250,0.95)"; +function drawFrameNumberOverlay(ctx, canvasSize, frameIndex, frameCount) { + if (frameIndex == null || !Number.isFinite(frameIndex)) return; + if (!Number.isFinite(frameCount) || frameCount <= 1) return; + const digits = String(frameCount).length; + const current = (Math.max(0, Math.round(frameIndex)) + 1).toString().padStart(digits, "0"); + const total = frameCount.toString().padStart(digits, "0"); + drawHudPill(ctx, canvasSize, "bottom-center", `${current} / ${total}`, FRAME_HUD_ACCENT); +} +const OUTPUT_HUD_ACCENT = "rgba(251,191,36,0.95)"; +function drawOutputSizePill(ctx, canvasSize, outputWidth, outputHeight) { + const w = Math.max(1, Math.round(outputWidth)); + const h = Math.max(1, Math.round(outputHeight)); + drawHudPill(ctx, canvasSize, "top-right", `${w} \xD7 ${h}`, OUTPUT_HUD_ACCENT); +} function blit(node, st, source, canvasSize, sourceWidth, sourceHeight) { if (!st.canvas) return; hideNativeMediaPreview(st); @@ -501,9 +540,69 @@ function blit(node, st, source, canvasSize, sourceWidth, sourceHeight) { st.padOutGeometry = drawPadOutBounds(node, ctx, canvasSize, canvasSize, effOW, effOH, padOutSw, padOutSh); drawTransformBounds(node, ctx, canvasSize, canvasSize, resolvedWidth, resolvedHeight); drawCompBounds(node, ctx, canvasSize, canvasSize, st.compOutputWidth || resolvedWidth, st.compOutputHeight || resolvedHeight, st.compLayers); - drawOutputFormatBox(ctx, padOutFit ?? fit); + if (padOutFit) { + drawOutputBounds(ctx, padOutFit); + drawOutputSizePill(ctx, canvasSize, effOW, effOH); + } if (hasTransform) ctx.restore(); - drawFrameNumberOverlay(ctx, canvasSize, st.previewFrameIndex); + drawFrameNumberOverlay(ctx, canvasSize, st.previewFrameIndex, st.previewFrameCount); +} +const COMPARE_HUD_ACCENT = "rgba(244,114,182,0.95)"; +function drawTopLeftBadge(ctx, canvasSize, label) { + drawHudPill(ctx, canvasSize, "top-left", label, COMPARE_HUD_ACCENT); +} +const BACKEND_COMPARE_LABELS = { + solo: "BACKEND", + diff: "DIFF (live vs backend)", + wipe: "LIVE \u2190 wipe \u2192 BACKEND" +}; +function blitCompare(node, st, liveSource, otherSource, canvasSize, mode, wipeFraction, sourceWidth, sourceHeight, labels = BACKEND_COMPARE_LABELS) { + if (!st.canvas) return; + hideNativeMediaPreview(st); + const ctx = st.canvas.getContext("2d"); + if (!ctx) return; + if (st.canvas.width !== canvasSize) st.canvas.width = canvasSize; + if (st.canvas.height !== canvasSize) st.canvas.height = canvasSize; + st.previewSourceWidth = sourceWidth; + st.previewSourceHeight = sourceHeight; + st.previewLastSource = liveSource; + const fit = getFitPlacement(canvasSize, canvasSize, sourceWidth, sourceHeight); + st.fitGeometry = fit; + ctx.clearRect(0, 0, canvasSize, canvasSize); + ctx.imageSmoothingEnabled = true; + if (mode === "backend") { + ctx.drawImage(otherSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + drawTopLeftBadge(ctx, canvasSize, labels.solo); + } else if (mode === "diff") { + ctx.drawImage(otherSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + ctx.save(); + ctx.globalCompositeOperation = "difference"; + ctx.drawImage(liveSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + ctx.restore(); + drawTopLeftBadge(ctx, canvasSize, labels.diff); + } else { + const fraction = Math.max(0, Math.min(1, wipeFraction)); + const splitX = fit.dx + fit.drawWidth * fraction; + ctx.drawImage(liveSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + ctx.save(); + ctx.beginPath(); + ctx.rect(splitX, fit.dy, fit.dx + fit.drawWidth - splitX, fit.drawHeight); + ctx.clip(); + ctx.drawImage(otherSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + ctx.restore(); + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(splitX, fit.dy); + ctx.lineTo(splitX, fit.dy + fit.drawHeight); + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.85)"; + ctx.beginPath(); + ctx.arc(splitX, fit.dy + fit.drawHeight / 2, 5, 0, Math.PI * 2); + ctx.fill(); + drawTopLeftBadge(ctx, canvasSize, labels.wipe); + } + drawFrameNumberOverlay(ctx, canvasSize, st.previewFrameIndex, st.previewFrameCount); } function tryRenderNativePreview(node, st, canvasSize) { if (isCropNode(node) || isCompNode(node) || isDrawNode(node) || isPadOutNode(node) || isCornerPinNode(node) || isRampNode(node)) return false; @@ -527,6 +626,7 @@ function tryRenderNativePreview(node, st, canvasSize) { } export { blit, + blitCompare, drawCompBounds, drawCornerPinBounds, drawCropBounds, diff --git a/js/preview/shared/geometry.js b/js/preview/shared/geometry.js index 98e1950..7eddb7c 100644 --- a/js/preview/shared/geometry.js +++ b/js/preview/shared/geometry.js @@ -24,18 +24,13 @@ function drawFitSource(ctx, width, height, source, sourceWidth, sourceHeight, fi ctx.imageSmoothingEnabled = true; ctx.drawImage(source, placement.dx, placement.dy, placement.drawWidth, placement.drawHeight); } -function drawOutputFormatBox(ctx, fit, label = "Output") { +function drawOutputBounds(ctx, fit) { ctx.save(); - ctx.strokeStyle = "rgba(255,255,255,0.24)"; + ctx.strokeStyle = "rgba(251,191,36,0.55)"; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); ctx.strokeRect(fit.dx + 0.5, fit.dy + 0.5, fit.drawWidth, fit.drawHeight); ctx.setLineDash([]); - ctx.fillStyle = "rgba(10,12,16,0.72)"; - ctx.fillRect(fit.dx + 6, fit.dy + 6, 52, 16); - ctx.fillStyle = "rgba(255,255,255,0.82)"; - ctx.font = "10px sans-serif"; - ctx.fillText(label, fit.dx + 10, fit.dy + 18); ctx.restore(); } function getCanvasPointer(canvas, event) { @@ -51,7 +46,7 @@ function getCanvasPointer(canvas, event) { export { clampPreviewZoom, drawFitSource, - drawOutputFormatBox, + drawOutputBounds, getCanvasPointer, getFitPlacement, screenToWorld diff --git a/js/preview/shared/imageops-metadata.js b/js/preview/shared/imageops-metadata.js index 2ef096e..47aa071 100644 --- a/js/preview/shared/imageops-metadata.js +++ b/js/preview/shared/imageops-metadata.js @@ -114,6 +114,26 @@ const IMAGEOPS_NODE_METADATA = [ "className": "ImageOpsText", "ui": "custom", "minPreviewHeight": 470 + }, + { + "className": "ImageOpsVignette", + "ui": "native" + }, + { + "className": "ImageOpsChromaticAberration", + "ui": "native" + }, + { + "className": "ImageOpsBloom", + "ui": "native" + }, + { + "className": "ImageOpsLensArtifacts", + "ui": "native" + }, + { + "className": "ImageOpsDefocus", + "ui": "native" } ]; const IMAGEOPS_DEFAULT_PREVIEW_MIN_HEIGHT = 320; diff --git a/js/preview/shared/state.js b/js/preview/shared/state.js index 65aa6ba..015c0f2 100644 --- a/js/preview/shared/state.js +++ b/js/preview/shared/state.js @@ -36,6 +36,11 @@ function createInitialState(node) { previewSourceWidth: 0, previewSourceHeight: 0, previewFrameIndex: null, + previewFrameCount: 1, + previewCompareMode: "live", + previewWipeFraction: 0.5, + previewCompareHooked: false, + previewWipeDrag: null, cropAspectRatio: null, cropGeometry: null, cropDrag: null, diff --git a/nodes/__init__.py b/nodes/__init__.py index 1445276..eb4b43b 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -25,6 +25,11 @@ from .keyer import ImageOpsKeyer from .text import ImageOpsText from .frame_range import ImageOpsFrameRange from .append import ImageOpsAppend +from .vignette import ImageOpsVignette +from .chromatic_aberration import ImageOpsChromaticAberration +from .bloom import ImageOpsBloom +from .lens_artifacts import ImageOpsLensArtifacts +from .defocus import ImageOpsDefocus __all__ = [ "ImageOpsBlur", @@ -53,4 +58,9 @@ __all__ = [ "ImageOpsText", "ImageOpsFrameRange", "ImageOpsAppend", + "ImageOpsVignette", + "ImageOpsChromaticAberration", + "ImageOpsBloom", + "ImageOpsLensArtifacts", + "ImageOpsDefocus", ] diff --git a/nodes/_helpers.py b/nodes/_helpers.py index f66b3e5..fd17d7e 100644 --- a/nodes/_helpers.py +++ b/nodes/_helpers.py @@ -277,6 +277,7 @@ def _apply_color_adjust( highlights_vibrance: ScalarOrList = 0.0, highlights_gamma: ScalarOrList = 1.0, highlights_brightness: ScalarOrList = 0.0, + input_space: str = "srgb", ) -> torch.Tensor: """Pure PyTorch color adjustment in linear RGB; alpha/extra channels pass through.""" if image is None: @@ -311,7 +312,8 @@ def _apply_color_adjust( # is non-zero the corresponding param tensor becomes per-pixel. When all # zone sliders are zero, eff(P) collapses back to a [B,1,1,1] scalar and # the code path is identical to the previous global-only version. - rgb_src_linear = _srgb_to_linear(x[..., :3]) + is_linear_input = str(input_space or "srgb").strip().lower() in ("linear", "lin") + rgb_src_linear = x[..., :3] if is_linear_input else _srgb_to_linear(x[..., :3]) luma_src = _linear_luma(rgb_src_linear) shadow_mask = ((0.5 - luma_src) / 0.5).clamp(0.0, 1.0) shadow_mask = shadow_mask * shadow_mask @@ -396,7 +398,7 @@ def _apply_color_adjust( if has_gamma: # Prevent pow() crash on negative values rgb = rgb.clamp(min=0.0).pow(1.0 / gamma_t) - rgb = _linear_to_srgb(rgb) + rgb = rgb if is_linear_input else _linear_to_srgb(rgb) if extra is not None: extra = extra.clamp(0.0, 1.0) @@ -603,22 +605,60 @@ def _disk_kernel2d(radius: int) -> torch.Tensor: return k / k.sum().clamp(min=EPSILON) +def _apply_kernel_blur(image: torch.Tensor, kernel: torch.Tensor) -> torch.Tensor: + """Depthwise conv2d with an arbitrary normalized 2D kernel (disk/polygon/custom bokeh shapes).""" + if image.shape[1] == 0 or image.shape[2] == 0: + return image + x = image.permute(0, 3, 1, 2).contiguous() + _, C, H, W = x.shape + kh, kw = kernel.shape + pad_h, pad_w = kh // 2, kw // 2 + pad_mode = "reflect" if min(H, W) > max(pad_h, pad_w) else "replicate" + x_padded = torch.nn.functional.pad(x, (pad_w, pad_w, pad_h, pad_h), mode=pad_mode) + k2d = kernel.to(device=x.device, dtype=x.dtype).unsqueeze(0).unsqueeze(0).repeat(C, 1, 1, 1) + result = torch.nn.functional.conv2d(x_padded, k2d, groups=C) + return result.permute(0, 2, 3, 1).contiguous() + + def _apply_defocus_blur(image: torch.Tensor, radius: int) -> torch.Tensor: """Disk / bokeh blur simulating an out-of-focus lens aperture.""" r = int(max(0, radius)) if r <= 0: return image - if image.shape[1] == 0 or image.shape[2] == 0: - return image - x = image.permute(0, 3, 1, 2).contiguous() - _, C, H, W = x.shape - k = _disk_kernel2d(r).to(device=x.device, dtype=x.dtype) - pad = r - pad_mode = "reflect" if min(H, W) > pad else "replicate" - x_padded = torch.nn.functional.pad(x, (pad, pad, pad, pad), mode=pad_mode) - k2d = k.unsqueeze(0).unsqueeze(0).repeat(C, 1, 1, 1) - result = torch.nn.functional.conv2d(x_padded, k2d, groups=C) - return result.permute(0, 2, 3, 1).contiguous() + return _apply_kernel_blur(image, _disk_kernel2d(r)) + + +@functools.lru_cache(maxsize=128) +def _polygon_kernel2d(radius: int, sides: int) -> torch.Tensor: + """Normalized regular-polygon aperture kernel (sides < 3 falls back to a disk), for shaped bokeh.""" + r = max(1, int(radius)) + ys, xs = torch.meshgrid( + torch.arange(-r, r + 1, dtype=torch.float32), + torch.arange(-r, r + 1, dtype=torch.float32), + indexing="ij", + ) + dist = torch.sqrt(xs * xs + ys * ys) + if sides < 3: + boundary = torch.full_like(dist, float(r)) + else: + # Inscribed-radius formula for a regular n-gon: boundary(theta) traces the + # polygon edge so the shape's flats touch radius r at each side midpoint. + n = float(sides) + sector = (2.0 * math.pi) / n + theta = torch.remainder(torch.atan2(ys, xs), sector) - sector / 2.0 + boundary = r * math.cos(math.pi / n) / torch.cos(theta) + k = (1.0 - (dist - boundary).clamp(0.0, 1.0)).clamp(min=0.0) + return k / k.sum().clamp(min=EPSILON) + + +def _custom_shape_kernel2d(radius: int, shape_luma: torch.Tensor) -> torch.Tensor: + """Normalized kernel resampled from a user-supplied grayscale bokeh shape texture.""" + r = max(1, int(radius)) + size = 2 * r + 1 + k = shape_luma.view(1, 1, *shape_luma.shape[-2:]) + k = torch.nn.functional.interpolate(k, size=(size, size), mode="bilinear", align_corners=False)[0, 0] + k = k.clamp(min=0.0) + return k / k.sum().clamp(min=EPSILON) # Maximum spatial radius for the surface (bilateral) filter before memory becomes diff --git a/nodes/append.py b/nodes/append.py index b26580a..b3c66d7 100644 --- a/nodes/append.py +++ b/nodes/append.py @@ -7,8 +7,10 @@ import torch from ._helpers import _resize, _select_media_tensor from ._preview import build_node_preview_result from ._progress import start_progress +from .core.timeline import trim_indices from .core.video_io import extract_video_media, media_to_video _JOIN_FIT_MODES = ['strict', 'resize_to_first', 'pad_to_max'] +_FPS_RELATIVE_TOLERANCE = 0.001 def _sorted_clip_inputs(inputs: dict[str, Any]) -> list[tuple[int, Any]]: clips: list[tuple[int, Any]] = [] @@ -57,15 +59,8 @@ def _parse_trims(trims_json: str | dict | list | None) -> dict[int, tuple[int, i 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: - return source - 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) - return source[actual_start:actual_end + 1] +def _fps_matches(a: float, b: float) -> bool: + return abs(a - b) <= max(a, b) * _FPS_RELATIVE_TOLERANCE def _coerce_channels(tensor: torch.Tensor, target_channels: int) -> torch.Tensor: batch, h, w, channels = tensor.shape @@ -91,21 +86,21 @@ 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: +def _pad_to_size(source: torch.Tensor, target_w: int, target_h: int, pad_alpha: str = 'opaque') -> torch.Tensor: batch, source_h, source_w, channels = source.shape if source_w == target_w and source_h == target_h: return source from .core.memory import check_budget check_budget(batch, target_h, target_w, channels, label='ImageOps Append (Pad to Size)') out = torch.zeros((batch, target_h, target_w, channels), device=source.device, dtype=source.dtype) - if channels >= 4: + if channels >= 4 and str(pad_alpha or 'opaque').strip().lower() != 'transparent': out[..., 3] = 1.0 left = max(0, (target_w - source_w) // 2) top = max(0, (target_h - source_h) // 2) 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]: +def _align_pair(image_a: torch.Tensor, image_b: torch.Tensor, fit_mode: str, pad_alpha: str = 'opaque') -> 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])) if a_w == b_w and a_h == b_h: @@ -116,17 +111,17 @@ def _align_pair(image_a: torch.Tensor, image_b: torch.Tensor, fit_mode: str) -> 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_alpha), _pad_to_size(image_b, target_w, target_h, pad_alpha)) 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.') class ImageOpsAppend(io.ComfyNode): @classmethod def define_schema(cls) -> io.Schema: - return io.Schema(node_id='ImageOpsAppend', display_name='ใ€ฝ๏ธ Image Ops Append', category='image/imageops', search_aliases=['append', 'join', 'concat', 'concatenate', 'clips', 'sequence'], inputs=[io.Boolean.Input('bypass', default=False), io.Combo.Input('fit_mode', options=['strict', 'resize_to_first', 'pad_to_max'], 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.MultiType.Input('image_1', types=[io.Image, io.Video], display_name='Images/Video 1', optional=True, extra_dict={'forceInput': True}), io.MultiType.Input('image_2', types=[io.Image, io.Video], display_name='Images/Video 2', optional=True, extra_dict={'forceInput': 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'), io.Video.Output('video', display_name='video', tooltip='Native VIDEO output. Carries the real audio/fps from the first connected VIDEO clip (not a plain IMAGE batch), if any.')], hidden=[io.Hidden.unique_id]) + return io.Schema(node_id='ImageOpsAppend', display_name='ใ€ฝ๏ธ Image Ops Append', category='image/imageops', search_aliases=['append', 'join', 'concat', 'concatenate', 'clips', 'sequence'], inputs=[io.Boolean.Input('bypass', default=False), io.Combo.Input('fit_mode', options=['strict', 'resize_to_first', 'pad_to_max'], 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.MultiType.Input('image_1', types=[io.Image, io.Video], display_name='Images/Video 1', optional=True, extra_dict={'forceInput': True}), io.MultiType.Input('image_2', types=[io.Image, io.Video], display_name='Images/Video 2', optional=True, extra_dict={'forceInput': True}), io.Float.Input('image_fps', default=24.0, min=1.0, max=1000.0, step=0.001, optional=True, tooltip='Frame rate assumed for plain IMAGE clips (no VIDEO fps of their own) when checking that all clips share a frame rate.'), io.Combo.Input('pad_alpha', options=['opaque', 'transparent'], default='opaque', optional=True, tooltip='Alpha of the padding added by fit_mode=pad_to_max. opaque matches this node\'s historical behavior; transparent leaves the added margins see-through for compositing.')], 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'), io.Video.Output('video', display_name='video', tooltip='Native VIDEO output. Carries the real audio/fps from the first connected VIDEO clip (not a plain IMAGE batch), if any.')], hidden=[io.Hidden.unique_id]) @classmethod - def execute(cls, bypass=False, fit_mode='strict', trims_json='{"version":1,"clips":[]}', unique_id=None, **inputs): + def execute(cls, bypass=False, fit_mode='strict', trims_json='{"version":1,"clips":[]}', image_fps=24.0, pad_alpha='opaque', unique_id=None, **inputs): from .core.media import ImageOpsMedia progress = start_progress(unique_id=unique_id) clips = _sorted_clip_inputs(inputs) @@ -141,9 +136,10 @@ class ImageOpsAppend(io.ComfyNode): fps = 24.0 sample_rate = 44100 audio_list = [] - - from .frame_range import _slice_audio_for_indices, _timeline_indices - + effective_fps: list[tuple[int, float]] = [] + + from .frame_range import _slice_audio_for_indices + for clip_index, value in clips: video_media = extract_video_media(value) if isinstance(value, ImageOpsMedia): @@ -154,13 +150,15 @@ class ImageOpsAppend(io.ComfyNode): _, clip_fps, clip_audio, clip_sample_rate = video_media else: is_media = False - clip_audio, clip_fps, clip_sample_rate = None, 24.0, 44100 + clip_audio, clip_fps, clip_sample_rate = None, float(image_fps), 44100 tensor = _select_media_tensor(value, None).float() start, end = trims.get(clip_index, (0, -1)) source_count = int(tensor.shape[0]) - trimmed = _trim_clip(tensor, start, end) + indices = trim_indices(source_count, start, end, label=f'ImageOps Append (clip slot {clip_index})') + trimmed = tensor[indices] tensors.append(trimmed) + effective_fps.append((clip_index, clip_fps if is_media else float(image_fps))) if is_media: if not has_media: @@ -169,15 +167,28 @@ class ImageOpsAppend(io.ComfyNode): has_media = True if clip_audio is not None and clip_fps > 0: - indices = _timeline_indices(source_count, start, end) trimmed_audio = _slice_audio_for_indices(clip_audio, indices, clip_fps, clip_sample_rate) audio_list.append(trimmed_audio) else: audio_list.append(None) else: audio_list.append(None) - + clip_metadata.append({'slot': int(clip_index), 'source_count': source_count, 'trimmed_count': int(trimmed.shape[0]), 'start': int(start), 'end': int(end), 'sample_rate': int(clip_sample_rate)}) + if len(effective_fps) > 1: + ref_slot, ref_fps = effective_fps[0] + mismatched_fps = [ + (slot, clip_fps, next(m['trimmed_count'] for m in clip_metadata if m['slot'] == slot) / clip_fps) + for slot, clip_fps in effective_fps[1:] + if not _fps_matches(clip_fps, ref_fps) + ] + if mismatched_fps: + details = ', '.join(f'slot {slot}: {clip_fps:.3f} fps ({duration:.2f}s)' for slot, clip_fps, duration in mismatched_fps) + raise ValueError( + f'ImageOps Append requires matching frame rates. Output fps is {ref_fps:.3f} ' + f'(from clip slot {ref_slot}); {details} do not match. Use clips with a matching ' + f'frame rate, or set image_fps to declare the rate of plain IMAGE clips.' + ) max_channels = max((int(t.shape[3]) for t in tensors)) tensors = [_coerce_channels(t, max_channels) for t in tensors] if len(tensors) == 1: @@ -185,9 +196,9 @@ class ImageOpsAppend(io.ComfyNode): else: aligned = [tensors[0]] for tensor in tensors[1:]: - first, current = _align_pair(aligned[0], tensor, fit_mode) + first, current = _align_pair(aligned[0], tensor, fit_mode, pad_alpha) if first is not aligned[0]: - aligned = [first] + [_align_pair(first, item, fit_mode)[1] for item in aligned[1:]] + aligned = [first] + [_align_pair(first, item, fit_mode, pad_alpha)[1] for item in aligned[1:]] aligned.append(current) out_tensor = torch.cat(aligned, dim=0) if has_media: diff --git a/nodes/bloom.py b/nodes/bloom.py new file mode 100644 index 0000000..d2a1f93 --- /dev/null +++ b/nodes/bloom.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import torch + +from ._helpers import ( + _apply_blur, + _apply_mask_to_image, + _param_tensor, + _prepare_effect_mask, + _resolve_mask_output_source, + _scalar, + _select_media_tensor, +) +from comfy_api.latest import io +from ._preview import build_node_preview_result +from ._progress import start_progress + + +def _apply_bloom(source: torch.Tensor, threshold, intensity, radius: int) -> torch.Tensor: + x = source.float() + b = x.shape[0] + device, dtype = x.device, x.dtype + rgb = x[..., :3] + + threshold_t = _param_tensor(threshold, b, device, dtype) + intensity_t = _param_tensor(intensity, b, device, dtype) + + bright = (rgb - threshold_t).clamp(min=0.0) + if radius > 0: + bright = _apply_blur(bright, radius, 0.0) + # Additive, not screen: highlights can already exceed 1.0 on HDR sources and + # a screen blend's (1 - x) term breaks down past that range. + out_rgb = rgb + bright * intensity_t + if x.shape[-1] > 3: + return torch.cat([out_rgb, x[..., 3:].clamp(0.0, 1.0)], dim=-1) + return out_rgb + + +class ImageOpsBloom(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="ImageOpsBloom", + display_name="ใ€ฝ๏ธ Image Ops Bloom", + category="image/imageops", + search_aliases=["bloom", "glow", "highlight glow", "lens"], + inputs=[ + io.Boolean.Input("bypass", default=False), + io.Float.Input("threshold", default=0.8, min=0.0, max=4.0, step=0.01, display_mode=io.NumberDisplay("slider"), round=0.001, tooltip="Brightness level above which pixels start to glow."), + io.Float.Input("intensity", default=0.5, min=0.0, max=4.0, step=0.01, display_mode=io.NumberDisplay("slider"), round=0.001, tooltip="Strength of the added glow."), + io.Int.Input("radius", default=12, min=0, max=128, step=1, tooltip="Gaussian blur radius of the glow, in pixels."), + io.Boolean.Input("invert_mask", default=False), + io.MultiType.Input("image", types=[io.Image, io.Video], tooltip="Images/Video input.", display_name="Images/Video", optional=True, extra_dict={"forceInput": 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], + ) + + @classmethod + def execute( + cls, + image=None, + bypass=False, + threshold=0.8, + intensity=0.5, + radius=12, + 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 isinstance(bypass, bool) and bypass: + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_bloom") + if isinstance(bypass, (list, tuple)) and all(bypass): + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_bloom") + if float(max(0.0, _scalar(intensity))) <= 0.0: + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_bloom") + + 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 Bloom") + + result = _apply_bloom(source, threshold, intensity, _scalar(radius, int)) + result = _apply_mask_to_image(source, result, effect_mask) if effect_mask is not None else result + from ._helpers import apply_per_frame_bypass + result = apply_per_frame_bypass(source, result, bypass) + progress.finish() + return build_node_preview_result(result, (result, output_mask), prefix="imageops_bloom") diff --git a/nodes/chromatic_aberration.py b/nodes/chromatic_aberration.py new file mode 100644 index 0000000..9b56f51 --- /dev/null +++ b/nodes/chromatic_aberration.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import math + +import torch +import torch.nn.functional as F + +from ._helpers import ( + EPSILON, + _apply_mask_to_image, + _param_tensor, + _prepare_effect_mask, + _resolve_mask_output_source, + _scalar, + _select_media_tensor, +) +from comfy_api.latest import io +from ._preview import build_node_preview_result +from ._progress import start_progress + + +def _radial_sample(channel: torch.Tensor, grid_x_px: torch.Tensor, grid_y_px: torch.Tensor, cx_px: torch.Tensor, cy_px: torch.Tensor, k: torch.Tensor, w: int, h: int) -> torch.Tensor: + factor = 1.0 + k + sx = (grid_x_px - cx_px) * factor + cx_px + sy = (grid_y_px - cy_px) * factor + cy_px + gx = sx / max(w - 1, 1) * 2.0 - 1.0 + gy = sy / max(h - 1, 1) * 2.0 - 1.0 + grid = torch.stack([gx, gy], dim=-1) + return F.grid_sample(channel.unsqueeze(1), grid, mode="bilinear", padding_mode="border", align_corners=True).squeeze(1) + + +def _apply_chromatic_aberration(source: torch.Tensor, amount, center_x, center_y) -> torch.Tensor: + x = source.float() + b, h, w, c = x.shape + device, dtype = x.device, x.dtype + + amount_t = _param_tensor(amount, b, device, dtype).view(b, 1, 1) + cx_t = _param_tensor(center_x, b, device, dtype).view(b, 1, 1) * float(max(0, w - 1)) + cy_t = _param_tensor(center_y, b, device, dtype).view(b, 1, 1) * float(max(0, h - 1)) + + half_diag = math.sqrt((w / 2.0) ** 2 + (h / 2.0) ** 2) + k = amount_t / max(half_diag, EPSILON) + + ys_px, xs_px = torch.meshgrid( + torch.arange(h, device=device, dtype=dtype), + torch.arange(w, device=device, dtype=dtype), + indexing="ij", + ) + xs_px = xs_px.unsqueeze(0).expand(b, -1, -1) + ys_px = ys_px.unsqueeze(0).expand(b, -1, -1) + + rgb = x[..., :3] + r = _radial_sample(rgb[..., 0], xs_px, ys_px, cx_t, cy_t, k, w, h) + g = rgb[..., 1] + bl = _radial_sample(rgb[..., 2], xs_px, ys_px, cx_t, cy_t, -k, w, h) + out_rgb = torch.stack([r, g, bl], dim=-1) + if c > 3: + return torch.cat([out_rgb, x[..., 3:].clamp(0.0, 1.0)], dim=-1) + return out_rgb + + +class ImageOpsChromaticAberration(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="ImageOpsChromaticAberration", + display_name="ใ€ฝ๏ธ Image Ops Chromatic Aberration", + category="image/imageops", + search_aliases=["chromatic aberration", "fringe", "fringing", "lens", "color fringe", "ca"], + inputs=[ + io.Boolean.Input("bypass", default=False), + io.Float.Input("amount", default=2.0, min=-30.0, max=30.0, step=0.1, display_mode=io.NumberDisplay("slider"), round=0.01, tooltip="Radial channel shift in pixels at the frame corner. Positive shifts red outward and blue inward."), + io.Float.Input("center_x", default=0.5, min=-1.0, max=2.0, step=0.01, round=0.001), + io.Float.Input("center_y", default=0.5, min=-1.0, max=2.0, step=0.01, round=0.001), + io.Boolean.Input("invert_mask", default=False), + io.MultiType.Input("image", types=[io.Image, io.Video], tooltip="Images/Video input.", display_name="Images/Video", optional=True, extra_dict={"forceInput": 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], + ) + + @classmethod + def execute( + cls, + image=None, + bypass=False, + amount=2.0, + center_x=0.5, + center_y=0.5, + 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 isinstance(bypass, bool) and bypass: + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_chromatic_aberration") + if isinstance(bypass, (list, tuple)) and all(bypass): + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_chromatic_aberration") + if _scalar(amount) == 0.0 and not isinstance(amount, (list, tuple)): + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_chromatic_aberration") + + result = _apply_chromatic_aberration(source, amount, center_x, center_y) + result = _apply_mask_to_image(source, result, effect_mask) if effect_mask is not None else result + from ._helpers import apply_per_frame_bypass + result = apply_per_frame_bypass(source, result, bypass) + progress.finish() + return build_node_preview_result(result, (result, output_mask), prefix="imageops_chromatic_aberration") diff --git a/nodes/color_ajust.py b/nodes/color_ajust.py index b9d3268..59cd27d 100644 --- a/nodes/color_ajust.py +++ b/nodes/color_ajust.py @@ -7,10 +7,10 @@ class ImageOpsColorAjust(io.ComfyNode): @classmethod def define_schema(cls) -> io.Schema: - return io.Schema(node_id='ImageOpsColorAjust', display_name='ใ€ฝ๏ธ Image Ops Color Correct', category='image/imageops', search_aliases=['color correct', 'color', 'grade', 'grading', 'hue', 'saturation', 'brightness', 'contrast', 'gamma', 'temperature'], 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.MultiType.Input('image', types=[io.Image, io.Video], tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', display_name='Images/Video', optional=True, extra_dict={'forceInput': 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]) + return io.Schema(node_id='ImageOpsColorAjust', display_name='ใ€ฝ๏ธ Image Ops Color Correct', category='image/imageops', search_aliases=['color correct', 'color', 'grade', 'grading', 'hue', 'saturation', 'brightness', 'contrast', 'gamma', 'temperature'], 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.Combo.Input('input_space', options=['srgb', 'linear'], default='srgb', tooltip='Color space of the incoming image. Use linear for scene-linear/HDR sources so this node does not gamma-encode them twice.'), 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.MultiType.Input('image', types=[io.Image, io.Video], tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', display_name='Images/Video', optional=True, extra_dict={'forceInput': 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]) @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): + 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, input_space='srgb', 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) @@ -21,7 +21,7 @@ class ImageOpsColorAjust(io.ComfyNode): if isinstance(bypass, (list, tuple)) and all(bypass): 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) + 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, input_space=input_space, 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) from ._helpers import apply_per_frame_bypass result = apply_per_frame_bypass(source, result, bypass) diff --git a/nodes/defocus.py b/nodes/defocus.py new file mode 100644 index 0000000..ac8c9bf --- /dev/null +++ b/nodes/defocus.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import torch + +from ._helpers import ( + _apply_kernel_blur, + _apply_mask_to_image, + _coerce_mask_tensor, + _coerce_media_to_tensor, + _custom_shape_kernel2d, + _param_tensor, + _polygon_kernel2d, + _prepare_effect_mask, + _resize, + _resolve_mask_output_source, + _scalar, + _select_media_tensor, +) +from comfy_api.latest import io +from ._preview import build_node_preview_result +from ._progress import start_progress + +_BOKEH_SHAPES = ["circle", "hexagon", "octagon", "custom"] +_SHAPE_SIDES = {"circle": 0, "hexagon": 6, "octagon": 8} + + +def _shape_kernel(radius: int, shape: str, custom_luma: torch.Tensor | None) -> torch.Tensor: + if shape == "custom" and custom_luma is not None: + return _custom_shape_kernel2d(radius, custom_luma) + return _polygon_kernel2d(radius, _SHAPE_SIDES.get(shape, 0)) + + +def _fit_depth(depth: torch.Tensor, batch: int, height: int, width: int, device, dtype) -> torch.Tensor: + # depth is already reduced to [B,H,W] by _coerce_mask_tensor. + d = depth.unsqueeze(-1) + if d.shape[1] != height or d.shape[2] != width: + d = _resize(d, width, height) + if d.shape[0] != batch: + d = d[:1].expand(batch, -1, -1, -1) if d.shape[0] == 1 else d[:batch] + return d.clamp(0.0, 1.0).to(device=device, dtype=dtype) + + +def _apply_defocus( + source: torch.Tensor, + depth: torch.Tensor, + focus_distance, + focus_range, + max_blur_radius: int, + num_layers: int, + bokeh_shape: str, + highlight_threshold, + highlight_boost, + custom_luma: torch.Tensor | None, +) -> torch.Tensor: + x = source.float() + b, h, w, c = x.shape + device, dtype = x.device, x.dtype + + depth_t = _fit_depth(depth, b, h, w, device, dtype) + focus_t = _param_tensor(focus_distance, b, device, dtype) + range_t = _param_tensor(focus_range, b, device, dtype).clamp(0.0, 0.99) + threshold_t = _param_tensor(highlight_threshold, b, device, dtype) + boost_t = _param_tensor(highlight_boost, b, device, dtype).clamp(min=0.0) + + coc = (depth_t - focus_t).abs() + coc = (coc - range_t).clamp(min=0.0) / (1.0 - range_t).clamp(min=1e-3) + radius_field = (coc * max_blur_radius).clamp(0.0, float(max_blur_radius)) + + layers = max(2, int(num_layers)) + layer_radii = [round(i * max_blur_radius / (layers - 1)) for i in range(layers)] + highlight = (x[..., :3] - threshold_t).clamp(min=0.0) * boost_t + + blurred_layers = [] + for r in layer_radii: + if r <= 0: + blurred_layers.append(x) + continue + kernel = _shape_kernel(r, bokeh_shape, custom_luma) + base = _apply_kernel_blur(x, kernel) + glow = _apply_kernel_blur(highlight, kernel) + base_rgb = base[..., :3] + glow * (r / max_blur_radius) + layer = torch.cat([base_rgb, base[..., 3:]], dim=-1) if c > 3 else base_rgb + blurred_layers.append(layer) + + step = max(1e-6, max_blur_radius / (layers - 1)) + pos = (radius_field / step).clamp(0.0, float(layers - 1)) + idx_lo = pos.floor().clamp(max=layers - 2).long() + idx_hi = idx_lo + 1 + frac = (pos - idx_lo.float()).clamp(0.0, 1.0) + + stack = torch.stack(blurred_layers, dim=3) # [B,H,W,L,C] + idx_lo_exp = idx_lo.unsqueeze(3).expand(-1, -1, -1, 1, c) + idx_hi_exp = idx_hi.unsqueeze(3).expand(-1, -1, -1, 1, c) + lo_vals = torch.gather(stack, 3, idx_lo_exp).squeeze(3) + hi_vals = torch.gather(stack, 3, idx_hi_exp).squeeze(3) + out = lo_vals * (1.0 - frac) + hi_vals * frac + + if c > 3: + out = torch.cat([out[..., :3], out[..., 3:].clamp(0.0, 1.0)], dim=-1) + return out.to(device=device, dtype=dtype) + + +class ImageOpsDefocus(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="ImageOpsDefocus", + display_name="ใ€ฝ๏ธ Image Ops Defocus", + category="image/imageops", + search_aliases=["defocus", "bokeh", "depth of field", "dof", "depth blur", "lens blur"], + inputs=[ + io.Boolean.Input("bypass", default=False), + io.Float.Input("focus_distance", default=0.5, min=0.0, max=1.0, step=0.01, display_mode=io.NumberDisplay("slider"), round=0.001, tooltip="Depth value (0=near, 1=far by default) that stays in focus."), + io.Float.Input("focus_range", default=0.05, min=0.0, max=0.99, step=0.01, round=0.001, tooltip="Depth thickness around focus_distance that stays sharp before blur ramps up."), + io.Int.Input("max_blur_radius", default=24, min=0, max=128, step=1, tooltip="Bokeh disc radius in pixels at the depth extremes."), + io.Int.Input("num_layers", default=8, min=2, max=32, step=1, tooltip="Depth slices blurred and interpolated between. Higher = smoother falloff, slower."), + io.Combo.Input("bokeh_shape", options=_BOKEH_SHAPES, default="circle"), + io.Float.Input("highlight_threshold", default=0.9, min=0.0, max=2.0, step=0.01, round=0.001, tooltip="Brightness above which pixels bloom into visible bokeh discs when blurred."), + io.Float.Input("highlight_boost", default=1.5, min=0.0, max=8.0, step=0.1, round=0.001, tooltip="Strength of the bokeh highlight bloom."), + io.Boolean.Input("invert_depth", default=False), + io.Boolean.Input("invert_mask", default=False), + io.MultiType.Input("image", types=[io.Image, io.Video], tooltip="Images/Video input.", display_name="Images/Video", optional=True, extra_dict={"forceInput": True}), + io.MultiType.Input("depth", types=[io.Image, io.Video], tooltip="Depth map (grayscale). Its luma drives the per-pixel blur amount.", optional=True, extra_dict={"forceInput": True}), + io.MultiType.Input("shape_texture", types=[io.Image, io.Video], tooltip="Optional grayscale bokeh aperture shape, used when bokeh_shape is custom.", display_name="Shape Texture", optional=True, extra_dict={"forceInput": 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], + ) + + @classmethod + def execute( + cls, + image=None, + bypass=False, + focus_distance=0.5, + focus_range=0.05, + max_blur_radius=24, + num_layers=8, + bokeh_shape="circle", + highlight_threshold=0.9, + highlight_boost=1.5, + invert_depth=False, + invert_mask=False, + video=None, + depth=None, + shape_texture=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 isinstance(bypass, bool) and bypass: + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_defocus") + if isinstance(bypass, (list, tuple)) and all(bypass): + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_defocus") + + depth_tensor = _coerce_media_to_tensor(depth, "depth") if depth is not None else None + depth_mask = _coerce_mask_tensor(depth_tensor, device=source.device, dtype=source.dtype) if depth_tensor is not None else None + if depth_mask is None or _scalar(max_blur_radius, int) <= 0: + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_defocus") + if _scalar(invert_depth, bool): + depth_mask = 1.0 - depth_mask + + custom_luma = None + shape_tensor = _coerce_media_to_tensor(shape_texture, "shape_texture") if shape_texture is not None else None + if shape_tensor is not None: + shape_mask = _coerce_mask_tensor(shape_tensor, device=source.device, dtype=source.dtype) + custom_luma = shape_mask[0] if shape_mask is not None else None + + from .core.memory import check_budget + check_budget(int(source.shape[0]), int(source.shape[1]), int(source.shape[2]), int(source.shape[3]), multiplier=float(_scalar(num_layers, int)), label="ImageOps Defocus") + + result = _apply_defocus( + source, + depth_mask, + focus_distance, + focus_range, + _scalar(max_blur_radius, int), + _scalar(num_layers, int), + _scalar(bokeh_shape, str), + highlight_threshold, + highlight_boost, + custom_luma, + ) + result = _apply_mask_to_image(source, result, effect_mask) if effect_mask is not None else result + from ._helpers import apply_per_frame_bypass + result = apply_per_frame_bypass(source, result, bypass) + progress.finish() + return build_node_preview_result(result, (result, output_mask), prefix="imageops_defocus") diff --git a/nodes/frame_range.py b/nodes/frame_range.py index 4ecae26..00617f0 100644 --- a/nodes/frame_range.py +++ b/nodes/frame_range.py @@ -4,22 +4,12 @@ import torch from ._helpers import _coerce_media_to_tensor, _scalar from ._preview import build_node_preview_result from ._progress import start_progress +from .core.timeline import trim_indices as _timeline_indices from .core.video_io import extract_video_media, media_to_video 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]: - """Frame indices for [trim_start, trim_end]. An inverted range (trim_end < trim_start) - selects zero frames rather than silently swapping the bounds.""" - 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: - return [] - 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 @@ -108,32 +98,28 @@ class ImageOpsFrameRange(io.ComfyNode): progress.finish() video_out = media_to_video(tensor, media_obj.fps if is_media else 24.0, media_obj.audio if is_media else None, getattr(media_obj, 'sample_rate', 44100) if is_media else 44100) return build_node_preview_result(image, (image, source_count, video_out), metadata={'imageops_frame_range_source_count': [source_count]}) - indices = _timeline_indices(source_count, _scalar(trim_start, int), _scalar(trim_end, int)) + indices = _timeline_indices(source_count, _scalar(trim_start, int), _scalar(trim_end, int), label='ImageOps Frame Range') 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: + if apply_hold: 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: + if repeat_enabled: 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 + from .core.memory import check_budget + check_budget(len(indices), int(tensor.shape[1]), int(tensor.shape[2]), int(tensor.shape[3]), multiplier=2.0, label='ImageOps Frame Range') + 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 and media_obj.fps > 0: + sample_rate = getattr(media_obj, 'sample_rate', 44100) + out_audio = _slice_audio_for_indices(media_obj.audio, indices, media_obj.fps, sample_rate) else: - from .core.memory import check_budget - check_budget(len(indices), int(tensor.shape[1]), int(tensor.shape[2]), int(tensor.shape[3]), multiplier=2.0, label='ImageOps Frame Range') - 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 and media_obj.fps > 0: - sample_rate = getattr(media_obj, 'sample_rate', 44100) - out_audio = _slice_audio_for_indices(media_obj.audio, indices, media_obj.fps, sample_rate) - else: - out_audio = None + out_audio = None if is_media: out = ImageOpsMedia(frames=out_tensor, fps=media_obj.fps, audio=out_audio, metadata=dict(media_obj.metadata)) video_out = media_to_video(out_tensor, media_obj.fps, out_audio, getattr(media_obj, 'sample_rate', 44100)) diff --git a/nodes/grain.py b/nodes/grain.py index a654476..55a8ae6 100644 --- a/nodes/grain.py +++ b/nodes/grain.py @@ -3,7 +3,6 @@ from __future__ import annotations import torch from ._helpers import ( - , _apply_mask_to_image, _prepare_effect_mask, _resolve_mask_output_source, diff --git a/nodes/keyer.py b/nodes/keyer.py index 90036c9..c982b67 100644 --- a/nodes/keyer.py +++ b/nodes/keyer.py @@ -3,7 +3,6 @@ import torch from ._helpers import ( EPSILON, LUMA_WEIGHTS, - , _blur_mask, _param_tensor, _prepare_effect_mask, diff --git a/nodes/lens_artifacts.py b/nodes/lens_artifacts.py new file mode 100644 index 0000000..cb23808 --- /dev/null +++ b/nodes/lens_artifacts.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import torch +import torch.nn.functional as F + +from ._helpers import ( + _apply_mask_to_image, + _coerce_media_to_tensor, + _prepare_effect_mask, + _resolve_mask_output_source, + _scalar, + _select_media_tensor, +) +from comfy_api.latest import io +from ._preview import build_node_preview_result +from ._progress import start_progress + + +def _fit_dirt_texture(dirt: torch.Tensor, height: int, width: int) -> torch.Tensor: + if dirt.shape[1] == height and dirt.shape[2] == width: + return dirt + return F.interpolate( + dirt.float().permute(0, 3, 1, 2), size=(height, width), mode="bilinear", align_corners=False + ).permute(0, 2, 3, 1) + + +def _dust_speck_field(batch: int, height: int, width: int, amount: float, size: float, seed: int, device, dtype) -> torch.Tensor: + # Lens dust is a physical artifact of the lens surface: it doesn't move + # frame to frame, so the field is generated once and shared across the batch. + speck_size = max(0.5, float(size)) + gen_h = max(1, int(round(height / speck_size))) + gen_w = max(1, int(round(width / speck_size))) + generator = torch.Generator(device="cpu").manual_seed(int(seed)) + field = torch.rand((1, gen_h, gen_w, 1), generator=generator, dtype=torch.float32) + density = max(0.0, min(1.0, amount)) + threshold = 1.0 - density * 0.12 + specks = (field > threshold).float() + if gen_h != height or gen_w != width: + specks = F.interpolate( + specks.permute(0, 3, 1, 2), size=(height, width), mode="bilinear", align_corners=False + ).permute(0, 2, 3, 1) + return specks.clamp(0.0, 1.0).expand(batch, height, width, 1).to(device=device, dtype=dtype) + + +def _apply_lens_artifacts( + source: torch.Tensor, + dirt: torch.Tensor | None, + dirt_amount: float, + dust_amount: float, + dust_size: float, + seed: int, +) -> torch.Tensor: + x = source.float() + b, h, w, c = x.shape + device, dtype = x.device, x.dtype + rgb = x[..., :3] + + if dirt is not None and dirt_amount > 0.0: + dirt_rgb = _fit_dirt_texture(dirt, h, w)[..., :3].clamp(0.0, 1.0).to(device=device, dtype=dtype) + if dirt_rgb.shape[0] != b: + dirt_rgb = dirt_rgb[:1].expand(b, -1, -1, -1) + dirt_luma = dirt_rgb.mean(dim=-1, keepdim=True) + # Grime/dust deposits on a lens block light: attenuate multiplicatively + # so it stays correct on HDR (unclamped) source values. + attenuation = 1.0 - (1.0 - dirt_luma) * dirt_amount + rgb = rgb * attenuation + + if dust_amount > 0.0: + specks = _dust_speck_field(b, h, w, dust_amount, dust_size, seed, device, dtype) + # Dust catching light is a specular highlight: additive, HDR-safe. + rgb = rgb + specks * dust_amount + + if c > 3: + return torch.cat([rgb, x[..., 3:].clamp(0.0, 1.0)], dim=-1) + return rgb + + +class ImageOpsLensArtifacts(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="ImageOpsLensArtifacts", + display_name="ใ€ฝ๏ธ Image Ops Lens Artifacts", + category="image/imageops", + search_aliases=["lens dirt", "dust", "smudge", "grime", "lens artifacts"], + inputs=[ + io.Boolean.Input("bypass", default=False), + io.Float.Input("dirt_amount", default=0.0, min=0.0, max=1.0, step=0.01, display_mode=io.NumberDisplay("slider"), round=0.001, tooltip="Opacity of the connected dirt texture."), + io.Float.Input("dust_amount", default=0.0, min=0.0, max=1.0, step=0.01, display_mode=io.NumberDisplay("slider"), round=0.001, tooltip="Procedural dust speck density and brightness."), + io.Float.Input("dust_size", default=2.0, min=0.5, max=20.0, step=0.1, round=0.001, tooltip="Speck size in pixels."), + io.Int.Input("seed", default=12345, min=0, max=0xffffffffffffffff, tooltip="Dust is a static lens artifact: the same seed always produces the same specks."), + io.Boolean.Input("invert_mask", default=False), + io.MultiType.Input("image", types=[io.Image, io.Video], tooltip="Images/Video input.", display_name="Images/Video", optional=True, extra_dict={"forceInput": True}), + io.MultiType.Input("dirt", types=[io.Image, io.Video], tooltip="Optional dirt/smudge texture to composite (a stock lens-dirt plate). Its luma is used as an attenuation mask.", display_name="Dirt Texture", optional=True, extra_dict={"forceInput": 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], + ) + + @classmethod + def execute( + cls, + image=None, + bypass=False, + dirt_amount=0.0, + dust_amount=0.0, + dust_size=2.0, + seed=12345, + invert_mask=False, + video=None, + dirt=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 isinstance(bypass, bool) and bypass: + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_lens_artifacts") + if isinstance(bypass, (list, tuple)) and all(bypass): + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_lens_artifacts") + + dirt_amount_v = float(max(0.0, _scalar(dirt_amount))) + dust_amount_v = float(max(0.0, _scalar(dust_amount))) + dirt_tensor = _coerce_media_to_tensor(dirt, "dirt") if dirt is not None else None + if dirt_amount_v <= 0.0 and dust_amount_v <= 0.0: + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_lens_artifacts") + + result = _apply_lens_artifacts( + source, + dirt_tensor if dirt_amount_v > 0.0 else None, + dirt_amount_v, + dust_amount_v, + _scalar(dust_size, float), + _scalar(seed, int), + ) + result = _apply_mask_to_image(source, result, effect_mask) if effect_mask is not None else result + from ._helpers import apply_per_frame_bypass + result = apply_per_frame_bypass(source, result, bypass) + progress.finish() + return build_node_preview_result(result, (result, output_mask), prefix="imageops_lens_artifacts") diff --git a/nodes/mask_convert.py b/nodes/mask_convert.py index 8a3000f..0351887 100644 --- a/nodes/mask_convert.py +++ b/nodes/mask_convert.py @@ -3,7 +3,6 @@ import torch from ._helpers import ( EPSILON, LUMA_WEIGHTS, - , _apply_blur, _coerce_mask_tensor, _has_list_param, diff --git a/nodes/preview.py b/nodes/preview.py index 73a573a..3635bdf 100644 --- a/nodes/preview.py +++ b/nodes/preview.py @@ -4,7 +4,7 @@ import uuid import torch from PIL import Image import folder_paths -from ._helpers import _alpha_mask_from_image, _coerce_mask_tensor, _mask_to_preview_image, _scalar, _select_media_tensor, _tensor_batch_to_pil_list, logger +from ._helpers import _alpha_mask_from_image, _coerce_mask_tensor, _mask_to_preview_image, _resize, _scalar, _select_media_tensor, _tensor_batch_to_pil_list, logger from ._progress import start_progress def _ensure_dir(p: str): @@ -99,6 +99,28 @@ def save_temp_strip(images, prefix='imageops_strip', ext='png', max_frames=16, t return None return {'filename': name, 'subfolder': '', 'type': 'temp'} +_COMPARE_MODES = ['off', 'side_by_side', 'wipe', 'diff'] + + +def _compose_compare(a: torch.Tensor, b: torch.Tensor, mode: str, wipe_position: float) -> torch.Tensor: + """Build the saved preview thumbnail for an A/B compare, not the passthrough output.""" + if b.shape[1] != a.shape[1] or b.shape[2] != a.shape[2]: + b = _resize(b, a.shape[2], a.shape[1]) + if b.shape[0] != a.shape[0]: + b = b[:1].expand(a.shape[0], -1, -1, -1) if b.shape[0] == 1 else b[:a.shape[0]] + a3 = a[..., :3].clamp(0.0, 1.0) + b3 = b[..., :3].clamp(0.0, 1.0) + if mode == 'diff': + return (a3 - b3).abs().clamp(0.0, 1.0) + if mode == 'side_by_side': + return torch.cat([a3, b3], dim=2) + width = a3.shape[2] + split = int(round(max(0.0, min(1.0, _scalar(wipe_position))) * width)) + out = a3.clone() + out[:, :, split:, :] = b3[:, :, split:, :] + return out + + class ImageOpsPreview(io.ComfyNode): """ Preview bridge node: @@ -109,10 +131,10 @@ class ImageOpsPreview(io.ComfyNode): @classmethod def define_schema(cls) -> io.Schema: - return io.Schema(node_id='ImageOpsPreview', display_name='ใ€ฝ๏ธ Image Ops Preview', category='image/imageops', is_output_node=True, search_aliases=['preview', 'viewer', 'view', 'monitor', 'scope', 'histogram', 'waveform'], inputs=[io.Combo.Input('preview_target', options=['auto', 'image', 'mask'], default='auto'), io.Combo.Input('mode', options=['images', 'strip', 'animated_webp', 'animated_gif'], default='images'), io.MultiType.Input('image', types=[io.Image, io.Video], tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', display_name='Images/Video', optional=True, extra_dict={'forceInput': 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]) + return io.Schema(node_id='ImageOpsPreview', display_name='ใ€ฝ๏ธ Image Ops Preview', category='image/imageops', is_output_node=True, search_aliases=['preview', 'viewer', 'view', 'monitor', 'scope', 'histogram', 'waveform', 'compare', 'a/b'], inputs=[io.Combo.Input('preview_target', options=['auto', 'image', 'mask'], default='auto'), io.Combo.Input('mode', options=['images', 'strip', 'animated_webp', 'animated_gif'], default='images'), io.Combo.Input('compare_mode', options=_COMPARE_MODES, default='off', tooltip='Compare against Image B in the saved preview thumbnail. off previews Image only.'), io.Float.Input('wipe_position', default=0.5, min=0.0, max=1.0, step=0.01, tooltip='Split position for compare_mode=wipe.'), io.MultiType.Input('image', types=[io.Image, io.Video], tooltip='Images/Video input. Accepts IMAGE batches and VIDEO frame sources.', display_name='Images/Video', optional=True, extra_dict={'forceInput': True}), io.MultiType.Input('image_b', types=[io.Image, io.Video], tooltip='Optional second Images/Video input to compare against, via compare_mode.', display_name='Images/Video B', optional=True, extra_dict={'forceInput': 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]) @classmethod - def execute(cls, image=None, preview_target='auto', mode='images', mask=None, prompt=None, extra_pnginfo=None, unique_id=None, **kwargs): + def execute(cls, image=None, preview_target='auto', mode='images', compare_mode='off', wipe_position=0.5, image_b=None, 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 @@ -133,6 +155,10 @@ class ImageOpsPreview(io.ComfyNode): 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) + compare = str(compare_mode or 'off').strip().lower() + if compare != 'off' and image_b is not None: + image_b_tensor = _select_media_tensor(image_b, None) + preview_image = _compose_compare(preview_image, image_b_tensor, compare, _scalar(wipe_position, float)) 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')} diff --git a/nodes/text.py b/nodes/text.py index 05ef122..9a206e7 100644 --- a/nodes/text.py +++ b/nodes/text.py @@ -9,7 +9,6 @@ import torch from PIL import Image, ImageDraw, ImageFont from ._helpers import ( - , _apply_mask_to_image, _hex_to_rgb01, _prepare_effect_mask, diff --git a/nodes/vignette.py b/nodes/vignette.py new file mode 100644 index 0000000..8c5d9fb --- /dev/null +++ b/nodes/vignette.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import torch + +from ._helpers import ( + _apply_mask_to_image, + _hex_to_rgb01, + _param_tensor, + _prepare_effect_mask, + _resolve_mask_output_source, + _scalar, + _select_media_tensor, +) +from comfy_api.latest import io +from ._preview import build_node_preview_result +from ._progress import start_progress + + +def _apply_vignette( + source: torch.Tensor, + amount, + size, + softness, + center_x, + center_y, + color: str, +) -> torch.Tensor: + x = source.float() + b, h, w, c = x.shape + device, dtype = x.device, x.dtype + + amount_t = _param_tensor(amount, b, device, dtype).clamp(0.0, 1.0) + size_t = _param_tensor(size, b, device, dtype) + softness_t = _param_tensor(softness, b, device, dtype).clamp(min=1e-4) + cx_t = _param_tensor(center_x, b, device, dtype) + cy_t = _param_tensor(center_y, b, device, dtype) + + aspect = float(w) / float(max(1, h)) + ys, xs = torch.meshgrid( + torch.linspace(0.0, 1.0, h, device=device, dtype=dtype), + torch.linspace(0.0, 1.0, w, device=device, dtype=dtype), + indexing="ij", + ) + ys = ys.unsqueeze(0) + xs = xs.unsqueeze(0) + if aspect >= 1.0: + dx = (xs - cx_t.view(b, 1, 1)) * aspect + dy = ys - cy_t.view(b, 1, 1) + else: + dx = xs - cx_t.view(b, 1, 1) + dy = (ys - cy_t.view(b, 1, 1)) / max(aspect, 1e-6) + dist = torch.sqrt(dx * dx + dy * dy).unsqueeze(-1) + + inner = (size_t - softness_t).clamp(min=0.0) + outer = size_t.clamp(min=inner + 1e-4) + t = ((dist - inner) / (outer - inner).view(b, 1, 1, 1)).clamp(0.0, 1.0) + t = t * t * (3.0 - 2.0 * t) + falloff = t * amount_t.view(b, 1, 1, 1) + + rgb = x[..., :3] + tint = torch.tensor(_hex_to_rgb01(color), device=device, dtype=dtype).view(1, 1, 1, 3) + out_rgb = rgb * (1.0 - falloff) + tint * falloff + if c > 3: + return torch.cat([out_rgb, x[..., 3:].clamp(0.0, 1.0)], dim=-1) + return out_rgb + + +class ImageOpsVignette(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="ImageOpsVignette", + display_name="ใ€ฝ๏ธ Image Ops Vignette", + category="image/imageops", + search_aliases=["vignette", "lens", "darken edges", "falloff"], + inputs=[ + io.Boolean.Input("bypass", default=False), + io.Float.Input("amount", default=0.5, min=0.0, max=1.0, step=0.01, display_mode=io.NumberDisplay("slider"), round=0.001), + io.Float.Input("size", default=1.0, min=0.0, max=2.0, step=0.01, round=0.001, tooltip="Radius (in normalized frame units) where the vignette reaches full amount."), + io.Float.Input("softness", default=0.6, min=0.01, max=2.0, step=0.01, round=0.001, tooltip="Falloff width. Larger values feather the edge further inward from size."), + io.Float.Input("center_x", default=0.5, min=-1.0, max=2.0, step=0.01, round=0.001), + io.Float.Input("center_y", default=0.5, min=-1.0, max=2.0, step=0.01, round=0.001), + io.Color.Input("color", default="#000000", tooltip="Color the vignette darkens toward."), + io.Boolean.Input("invert_mask", default=False), + io.MultiType.Input("image", types=[io.Image, io.Video], tooltip="Images/Video input.", display_name="Images/Video", optional=True, extra_dict={"forceInput": 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], + ) + + @classmethod + def execute( + cls, + image=None, + bypass=False, + amount=0.5, + size=1.0, + softness=0.6, + center_x=0.5, + center_y=0.5, + color="#000000", + 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 isinstance(bypass, bool) and bypass: + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_vignette") + if isinstance(bypass, (list, tuple)) and all(bypass): + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_vignette") + if float(max(0.0, _scalar(amount))) <= 0.0: + progress.finish() + return build_node_preview_result(source, (source, output_mask), prefix="imageops_vignette") + + result = _apply_vignette(source, amount, size, softness, center_x, center_y, _scalar(color, str)) + result = _apply_mask_to_image(source, result, effect_mask) if effect_mask is not None else result + from ._helpers import apply_per_frame_bypass + result = apply_per_frame_bypass(source, result, bypass) + progress.finish() + return build_node_preview_result(result, (result, output_mask), prefix="imageops_vignette") diff --git a/src/preview/adapters/imageops.ts b/src/preview/adapters/imageops.ts index 179873c..95f4fe2 100644 --- a/src/preview/adapters/imageops.ts +++ b/src/preview/adapters/imageops.ts @@ -112,11 +112,29 @@ async function seekFrozenVideoFrame(videoEl: HTMLVideoElement, targetTime: numbe }); } +// Classes with a real branch in apply() below. A class that falls through to +// the trailing `else` (plain passthrough) is not previewing its own +// transform, so previewSupport() must say "unsupported" for it rather than +// let the passthrough canvas look like a real result. +const HANDLED_CLASSES = new Set([ + "ImageOpsMaskConvert", "ImageOpsDraw", "ImageOpsColorAjust", "ImageOpsCameraShake", + "ImageOpsChannel", "ImageOpsCrop", "ImageOpsCropStitch", "ImageOpsPadOut", + "ImageOpsCornerPin", "ImageOpsBlur", "ImageOpsTransform", "ImageOpsInvert", + "ImageOpsClamp", "ImageOpsGrain", "ImageOpsText", "ImageOpsKeyer", "ImageOpsMerge", + "ImageOpsAppend", "ImageOpsComp", "ImageOpsDistort", "ImageOpsSpherize", + "ImageOpsNoise", "ImageOpsConstant", "ImageOpsRamp", "ImageOpsFrameRange", + "ImageOpsPreview", "ImageOpsVignette", "ImageOpsChromaticAberration", + "ImageOpsBloom", "ImageOpsLensArtifacts", "ImageOpsDefocus", +]); + export function imageOpsAdapter(): Adapter { return { match(node: ComfyNode): boolean { return isImageOpsClass(node?.comfyClass); }, + previewSupport(node: ComfyNode): "approximate" | "unsupported" { + return HANDLED_CLASSES.has(resolveImageOpsClassName(node?.comfyClass)) ? "approximate" : "unsupported"; + }, inputs: (node: ComfyNode): number => { const cls = resolveImageOpsClassName(node?.comfyClass); const bypass = !!(node?.widgets ?? []).find(w => w?.name === "bypass")?.value; @@ -135,6 +153,15 @@ export function imageOpsAdapter(): Adapter { return 1 + Number(displacementConnected) + Number(effectMaskConnected); } if (cls === "ImageOpsMerge") return bypass ? 1 : (maskConnected ? 3 : 2); + if (cls === "ImageOpsLensArtifacts") { + const dirtConnected = connectedInput(node, "dirt"); + return 1 + Number(dirtConnected) + Number(maskConnected); + } + if (cls === "ImageOpsDefocus") { + const depthConnected = connectedInput(node, "depth"); + const shapeConnected = connectedInput(node, "shape_texture"); + return 1 + Number(depthConnected) + Number(shapeConnected) + Number(maskConnected); + } if (cls === "ImageOpsCropStitch") { const cropMaskConnected = (node.inputs ?? []).some((input) => String(input?.name ?? "").toLowerCase() === "crop_mask" && (input?.link ?? null) != null); return 2 + Number(cropMaskConnected); @@ -144,8 +171,9 @@ export function imageOpsAdapter(): Adapter { if (cls === "ImageOpsDraw") return (node.inputs?.[0]?.link ?? null) != null ? 1 : 0; if (cls === "ImageOpsPreview") { const imageConnected = connectedInput(node, "image"); + const imageBConnected = connectedInput(node, "image_b"); const maskConnected = connectedInput(node, "mask"); - return Number(imageConnected) + Number(maskConnected); + return Number(imageConnected) + Number(imageBConnected) + Number(maskConnected); } return maskConnected ? 2 : 1; }, @@ -161,8 +189,14 @@ export function imageOpsAdapter(): Adapter { if (cls === "ImageOpsDistort") { return namedInputIndexes(node, ["image", "displacement", "mask"]); } + if (cls === "ImageOpsLensArtifacts") { + return namedInputIndexes(node, ["image", "dirt", "mask"]); + } + if (cls === "ImageOpsDefocus") { + return namedInputIndexes(node, ["image", "depth", "shape_texture", "mask"]); + } if (cls === "ImageOpsPreview") { - return namedInputIndexes(node, ["image", "mask"]); + return namedInputIndexes(node, ["image", "image_b", "mask"]); } if (cls === "ImageOpsCropStitch") { const indexes = namedInputIndexes(node, ["original", "crop"]); @@ -233,6 +267,16 @@ export function imageOpsAdapter(): Adapter { return ops.keyer(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsMerge") { return ops.merge(ctx, canvasSize, node, inputs, undefined, tick ?? 0); + } else if (cls === "ImageOpsVignette") { + return ops.vignette(ctx, canvasSize, node, inputs, tick ?? 0); + } else if (cls === "ImageOpsChromaticAberration") { + return ops.chromaticAberration(ctx, canvasSize, node, inputs, tick ?? 0); + } else if (cls === "ImageOpsBloom") { + return ops.bloom(ctx, canvasSize, node, inputs, tick ?? 0); + } else if (cls === "ImageOpsLensArtifacts") { + return ops.lensArtifacts(ctx, canvasSize, node, inputs, tick ?? 0); + } else if (cls === "ImageOpsDefocus") { + return ops.defocus(ctx, canvasSize, node, inputs, tick ?? 0); } else if (cls === "ImageOpsAppend") { if (inputs.length === 0) return undefined; @@ -357,12 +401,20 @@ export function imageOpsAdapter(): Adapter { return inputs[0]; } else if (cls === "ImageOpsPreview") { + // Input schema order is image, image_b, mask; inputs[] only contains + // the currently-connected slots, so walk it with a cursor (image_b + // itself is only consumed by the A/B compare overlay in host.ts, not + // by this node's own image/mask output selection). const previewTarget = String((node?.widgets ?? []).find((widget) => widget?.name === "preview_target")?.value ?? "auto").toLowerCase(); - if (outputSlot === 1) return inputs[1] ?? inputs[0]; - if (outputSlot === 0) return inputs[0] ?? inputs[1]; - if (previewTarget === "mask") return inputs[1] ?? inputs[0]; - if (previewTarget === "image") return inputs[0] ?? inputs[1]; - return inputs[0] ?? inputs[1]; + const imageBConnected = (node.inputs?.[1]?.link ?? null) != null; + const maskConnected = (node.inputs?.[2]?.link ?? null) != null; + const maskCursor = 1 + Number(imageBConnected); + const mask = maskConnected ? (inputs[maskCursor] ?? null) : null; + if (outputSlot === 1) return mask ?? inputs[0]; + if (outputSlot === 0) return inputs[0] ?? mask; + if (previewTarget === "mask") return mask ?? inputs[0]; + if (previewTarget === "image") return inputs[0] ?? mask; + return inputs[0] ?? mask; } else { // Preview / Load pass-through } diff --git a/src/preview/core/media.ts b/src/preview/core/media.ts index 0ec1d38..fcd0985 100644 --- a/src/preview/core/media.ts +++ b/src/preview/core/media.ts @@ -14,6 +14,20 @@ export function getNativePreviewImage(node: ComfyNode): HTMLImageElement | null return candidate instanceof HTMLImageElement ? candidate : null; } +/** + * Like getNativePreviewImage, but indexes by our own playback tick instead of + * ComfyUI's node.imageIndex (the dot-navigation index), so the Live/Backend + * compare feature in ImageOpsPreview shows the backend frame that matches + * what the live proxy is currently playing. + */ +export function getNativePreviewImageForTick(node: ComfyNode, tick: number): HTMLImageElement | null { + const imgs = node.imgs; + if (!Array.isArray(imgs) || imgs.length === 0) return null; + const index = ((Math.max(0, Math.round(tick)) % imgs.length) + imgs.length) % imgs.length; + const candidate = imgs[index] ?? imgs[imgs.length - 1] ?? null; + return candidate instanceof HTMLImageElement ? candidate : null; +} + export function parsePreviewSourceUrl(src: string): URL | null { try { return new URL(src, window.location.href); diff --git a/src/preview/core/renderer.ts b/src/preview/core/renderer.ts index 049d25b..1979e9b 100644 --- a/src/preview/core/renderer.ts +++ b/src/preview/core/renderer.ts @@ -90,7 +90,12 @@ export function buildRenderer({ api, registry, canvasSize }: RendererConfig): Re async function render(node: ComfyNode, tick: number = 0, outputSlot: number | null = null, canvasSizeOverride?: number): Promise { const ctx: RenderContext = { api, canvasSize: canvasSizeOverride ?? canvasSize, tick, cache: new Map(), visited: new Set() }; const canvas = await renderNode(node, ctx, outputSlot); - return { canvas }; + // An adapter that declares itself unsupported for this node (or has no + // registered adapter at all) is only ever a passthrough of the first + // input, not a preview of this node's own transform. + const adapter = registry.pick(node); + const unsupported = isImageOpsClass(node?.comfyClass) && (!adapter || adapter.previewSupport?.(node) === "unsupported"); + return { canvas, unsupported }; } async function renderNode(node: ComfyNode | null, ctx: RenderContext, outputSlot: number | null): Promise { diff --git a/src/preview/host.ts b/src/preview/host.ts index b7c7636..bd5e41c 100644 --- a/src/preview/host.ts +++ b/src/preview/host.ts @@ -15,7 +15,7 @@ import { getCompSlots } from "./comp.js"; import { getPreviewConfig } from "./config.js"; import { initOpsConstants } from "./constants.js"; import { detectSourceUpstream, findDependents, getInputOriginSlot, getUpstreamNode, getUpstreamNodes, isGraphTooLarge } from "./core/graph.js"; -import { getInputIndexByName, getNativePreviewImage, getUpstreamVideoFps } from "./core/media.js"; +import { getInputIndexByName, getNativePreviewImage, getNativePreviewImageForTick, getUpstreamVideoFps } from "./core/media.js"; import { buildRenderer } from "./core/renderer.js"; import { schedule, stopRAF } from "./core/scheduler.js"; import { attachInteractions as attachJoinInteractionsExt, syncJoinControls } from "./interactions/append.js"; @@ -79,7 +79,7 @@ import { renderCompPreview } from "./ops.js"; import { attachProgressBus } from "./progress.js"; import { buildAdapterRegistry } from "./registry.js"; import { getProceduralFrameCount, getProceduralPlaybackFps, hasProceduralAnimation } from "./shared/animation.js"; -import { blit, tryRenderNativePreview } from "./shared/bounds.js"; +import { blit, blitCompare, tryRenderNativePreview } from "./shared/bounds.js"; import { markCanvasDirty } from "./shared/canvas.js"; import { isImageOpsClass } from "./shared/classes.js"; import { noteFrame } from "./shared/fps-monitor.js"; @@ -677,18 +677,23 @@ export function registerImageOpsLivePreview(): void { return null; } - function getPreviewFrameIndex(node: ComfyNode, tick: number): number { + function getPreviewFrameIndex(node: ComfyNode, tick: number): { frameIndex: number; frameCount: number } { const frameIndex = Math.max(0, Math.round(tick || 0)); const overlayFrameCount = isJoinNode(node) ? getJoinPreviewFrameCount(node) : getPreviewNodeFrameCount(getPrimaryOverlaySourceNode(node) ?? node); - return overlayFrameCount > 0 ? frameIndex % overlayFrameCount : frameIndex; + return { + frameIndex: overlayFrameCount > 0 ? frameIndex % overlayFrameCount : frameIndex, + frameCount: Math.max(1, overlayFrameCount), + }; } function renderNode(node: ComfyNode, tick: number = 0): void { const st = ensurePreviewWidget(node, progress, canvasSize); if (!st) return; - st.previewFrameIndex = getPreviewFrameIndex(node, tick); + const frame = getPreviewFrameIndex(node, tick); + st.previewFrameIndex = frame.frameIndex; + st.previewFrameCount = frame.frameCount; const renderCanvasSize = getRenderCanvasSize(st); const renderKey = buildPreviewRenderKey(node, tick, st, renderCanvasSize); @@ -846,6 +851,71 @@ export function registerImageOpsLivePreview(): void { return; } + if (isPreviewNode(node) && st.previewCompareMode !== "live") { + const compareMode = st.previewCompareMode; + const imageBIndex = getInputIndexByName(node, "image_b"); + const imageBUpstream = imageBIndex >= 0 ? getUpstreamNode(node, imageBIndex) : null; + + // With a B input connected, compare is a real A/B comparison between + // the two live-rendered sources instead of live-vs-last-queued-result. + if (imageBUpstream) { + Promise.all([ + renderer.render(node, tick, null, renderCanvasSize), + renderer.render(imageBUpstream, tick, getInputOriginSlot(node, imageBIndex), renderCanvasSize), + ]).then(([resultA, resultB]) => { + if (!resultA?.canvas || !resultB?.canvas) { + setInfo(st, "Live preview: connect a supported loader/chain"); + finishRender(); + return; + } + const sourceWidth = resultB.canvas.width || resultA.canvas.width || 1; + const sourceHeight = resultB.canvas.height || resultA.canvas.height || 1; + if (compareMode === "backend") { + blit(node, st, resultB.canvas, renderCanvasSize, sourceWidth, sourceHeight); + setInfo(st, "Showing B input"); + } else { + blitCompare(node, st, resultA.canvas, resultB.canvas, renderCanvasSize, compareMode, st.previewWipeFraction, sourceWidth, sourceHeight, { + solo: "B", + diff: "DIFF (A vs B)", + wipe: "A โ† wipe โ†’ B", + }); + setInfo(st, `Comparing A vs B (${compareMode})`); + } + commitRender(); + finishRender(); + }).catch(err => { + failRender("Compare preview error (check console)", err); + }); + return; + } + + // Compare only makes sense against per-frame stills (mode=images); a strip + // or animated export isn't indexable by our own playback tick. + const backendImg = widgetString(node, "mode", "images").toLowerCase() === "images" + ? getNativePreviewImageForTick(node, tick) + : null; + if (backendImg) { + renderer.render(node, tick, null, renderCanvasSize).then(result => { + if (!result?.canvas) { + setInfo(st, "Live preview: connect a supported loader/chain"); + finishRender(); + return; + } + const sourceWidth = backendImg.naturalWidth || result.canvas.width || 1; + const sourceHeight = backendImg.naturalHeight || result.canvas.height || 1; + blitCompare(node, st, result.canvas, backendImg, renderCanvasSize, compareMode, st.previewWipeFraction, sourceWidth, sourceHeight); + const frameCount = Array.isArray(node.imgs) ? node.imgs.length : 1; + const frameIndex = ((Math.max(0, Math.round(tick)) % frameCount) + frameCount) % frameCount; + setInfo(st, `Comparing frame ${frameIndex} (${compareMode}) โ€” both sides are sRGB 8-bit display pixels, not raw HDR`); + commitRender(); + finishRender(); + }).catch(err => { + failRender("Compare preview error (check console)", err); + }); + return; + } + } + if (tryRenderNativePreview(node, st, renderCanvasSize)) { commitRender(); finishRender(); @@ -883,7 +953,9 @@ export function registerImageOpsLivePreview(): void { } } blit(node, st, result.canvas, renderCanvasSize, sourceWidth, sourceHeight); - if (isCornerPinNode(node)) { + if (result.unsupported) { + setInfo(st, "No live preview for this node - showing its input; run the queue to see the real result"); + } else if (isCornerPinNode(node)) { setInfo(st, getCornerPinInfoText(node, sourceWidth, sourceHeight)); } else if (isTextNode(node)) { setInfo(st, getTextInfoText(node)); diff --git a/src/preview/interactions/append.ts b/src/preview/interactions/append.ts index 424c5f4..d62980e 100644 --- a/src/preview/interactions/append.ts +++ b/src/preview/interactions/append.ts @@ -52,7 +52,9 @@ function selectionCount(start: number, end: number, frameCount: number): number const max = Math.max(0, frameCount - 1); const s = Math.max(0, Math.min(max, Math.round(start))); const e = end < 0 ? max : Math.max(0, Math.min(max, Math.round(end))); - return Math.max(1, Math.abs(e - s) + 1); + // Match nodes/core/timeline.py trim_indices: an inverted range selects zero + // frames rather than a plausible-looking swapped count. + return e < s ? 0 : e - s + 1; } function syncFill(fill: HTMLDivElement, start: number, end: number, max: number): void { @@ -301,14 +303,11 @@ function buildJoinTrimRow(node: ComfyNode, ctx: NodeInteractionContext, st: any, existing = { slot: rowState.slot, start: 0, end: -1 }; next.push(existing); } - let startVal = Math.max(0, Math.min(rowState.maxFrame, Math.round(Number(rowState.startNumber.value || 0)))); - let endVal = Math.max(0, Math.min(rowState.maxFrame, Math.round(Number(rowState.endNumber.value || rowState.maxFrame)))); - // Match Python _trim_clip: swap if reversed so the rendered range is [min, max]. - if (endVal < startVal) { - const tmp = startVal; - startVal = endVal; - endVal = tmp; - } + const startVal = Math.max(0, Math.min(rowState.maxFrame, Math.round(Number(rowState.startNumber.value || 0)))); + // The backend (nodes/core/timeline.py trim_indices) rejects an inverted range + // instead of swapping it, so clamp here rather than construct a state execute() + // would reject. + const endVal = Math.max(startVal, Math.min(rowState.maxFrame, Math.round(Number(rowState.endNumber.value || rowState.maxFrame)))); existing.start = startVal; // Preserve the "to end" sentinel (-1) so the trim auto-extends if upstream grows. existing.end = rowState.endIsAuto && endVal >= rowState.maxFrame ? -1 : endVal; diff --git a/src/preview/interactions/preview.ts b/src/preview/interactions/preview.ts index 5630550..0e2f290 100644 --- a/src/preview/interactions/preview.ts +++ b/src/preview/interactions/preview.ts @@ -1,4 +1,4 @@ -import type { ComfyNode, NodeInteractionContext } from "../../types.js"; +import type { ComfyNode, NodeInteractionContext, PreviewCompareMode } from "../../types.js"; import { isNode, syncPreviewWidgets } from "../nodes/preview.js"; import { findWidget, setWidgetStringValue } from "../shared/widgets.js"; @@ -29,5 +29,44 @@ export function attachInteractions(node: ComfyNode, ctx: NodeInteractionContext) ctx.schedule(node, () => ctx.startLoopIfVideo(node), 0); }); + for (const button of Array.from(root.querySelectorAll("button[data-compare-mode]"))) { + button.addEventListener("click", (event) => { + event.preventDefault(); + st.previewCompareMode = (button.dataset.compareMode as PreviewCompareMode) ?? "live"; + syncPreviewWidgets(node); + ctx.refreshPreviewOnly(node, 0); + }); + } + + const wipeTrack = root.querySelector("div[data-wipe-track]"); + if (wipeTrack) { + const fractionFromPointer = (event: PointerEvent): number => { + const rect = wipeTrack.getBoundingClientRect(); + return Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))); + }; + wipeTrack.addEventListener("pointerdown", (event: PointerEvent) => { + st.previewWipeDrag = { pointerId: event.pointerId }; + st.previewWipeFraction = fractionFromPointer(event); + syncPreviewWidgets(node); + ctx.refreshPreviewOnly(node, 0); + wipeTrack.setPointerCapture?.(event.pointerId); + event.preventDefault(); + }); + wipeTrack.addEventListener("pointermove", (event: PointerEvent) => { + if (!st.previewWipeDrag || st.previewWipeDrag.pointerId !== event.pointerId) return; + st.previewWipeFraction = fractionFromPointer(event); + syncPreviewWidgets(node); + ctx.refreshPreviewOnly(node, 0); + event.preventDefault(); + }); + const release = (event: PointerEvent): void => { + if (!st.previewWipeDrag || st.previewWipeDrag.pointerId !== event.pointerId) return; + st.previewWipeDrag = null; + wipeTrack.releasePointerCapture?.(event.pointerId); + }; + wipeTrack.addEventListener("pointerup", release); + wipeTrack.addEventListener("pointercancel", release); + } + syncPreviewWidgets(node); } diff --git a/src/preview/nodes/append.ts b/src/preview/nodes/append.ts index 599ba51..9072dde 100644 --- a/src/preview/nodes/append.ts +++ b/src/preview/nodes/append.ts @@ -191,8 +191,9 @@ function trimSelectionCount(frameCount: number, start: number, end: number): num const max = Math.max(0, Math.round(frameCount) - 1); const actualStart = Math.max(0, Math.min(max, Math.round(start))); const actualEnd = end < 0 ? max : Math.max(0, Math.min(max, Math.round(end))); - const [lo, hi] = actualStart <= actualEnd ? [actualStart, actualEnd] : [actualEnd, actualStart]; - return Math.max(1, hi - lo + 1); + // Match nodes/core/timeline.py trim_indices: an inverted range selects zero + // frames rather than a plausible-looking swapped count. + return actualEnd < actualStart ? 0 : actualEnd - actualStart + 1; } function trimsBySlot(node: ComfyNode): Map { diff --git a/src/preview/nodes/frame-range.ts b/src/preview/nodes/frame-range.ts index 4d30821..0b38bea 100644 --- a/src/preview/nodes/frame-range.ts +++ b/src/preview/nodes/frame-range.ts @@ -221,8 +221,9 @@ export function getFrameSelectorOutputCount(node: ComfyNode, sourceCountOverride if (repeat && repeatMode === "input_duration") return bounds.sourceCount; if (repeat) return customFrameCount; if (frameHold) return 1; - // An inverted trim range selects zero frames here, but execute() then falls - // back to a single frame (tensor[:1]) rather than an empty batch. + // An inverted trim range selects zero frames here; execute() now raises instead + // of silently falling back to a single frame. The floor of 1 is only so the + // preview estimate never renders a zero-length timeline while dragging. return Math.max(1, bounds.selectionCount); } diff --git a/src/preview/nodes/preview.ts b/src/preview/nodes/preview.ts index 664f8da..65483fb 100644 --- a/src/preview/nodes/preview.ts +++ b/src/preview/nodes/preview.ts @@ -12,6 +12,13 @@ export type PreviewControlsUi = { controls: HTMLDivElement; }; +const COMPARE_MODES = [ + ["live", "Live"], + ["backend", "Backend"], + ["wipe", "Wipe"], + ["diff", "Diff"], +] as const; + export function createPreviewControlsUi(): PreviewControlsUi { const controls = document.createElement("div"); controls.style.marginTop = "8px"; @@ -47,6 +54,52 @@ export function createPreviewControlsUi(): PreviewControlsUi { targetRow.appendChild(createContextMenuSelect(modeSelect)); controls.appendChild(targetRow); + // Compare the interactive canvas proxy against the last real queued result, + // at the same frame. Only meaningful once the queue has actually run. + const compareRow = document.createElement("div"); + compareRow.style.display = "grid"; + compareRow.style.gridTemplateColumns = `repeat(${COMPARE_MODES.length}, 1fr)`; + compareRow.style.gap = "6px"; + for (const [value, label] of COMPARE_MODES) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = value === "live" + ? "Interactive canvas proxy (default)" + : value === "backend" + ? "Show the last real queued result for this frame" + : value === "wipe" + ? "Drag the divider to wipe between live proxy and backend result" + : "Highlight per-pixel differences between live proxy and backend result"; + button.dataset.compareMode = value; + styleSoftButton(button, value === "live"); + compareRow.appendChild(button); + } + controls.appendChild(compareRow); + + const wipeTrack = document.createElement("div"); + wipeTrack.dataset.wipeTrack = "1"; + wipeTrack.style.position = "relative"; + wipeTrack.style.height = "14px"; + wipeTrack.style.background = "rgba(255,255,255,0.09)"; + wipeTrack.style.borderRadius = "4px"; + wipeTrack.style.cursor = "ew-resize"; + wipeTrack.style.userSelect = "none"; + wipeTrack.style.display = "none"; + wipeTrack.title = "Live โ†” Backend wipe position"; + const wipeHandle = document.createElement("div"); + wipeHandle.style.position = "absolute"; + wipeHandle.style.top = "0"; + wipeHandle.style.width = "4px"; + wipeHandle.style.height = "100%"; + wipeHandle.style.background = "#3b82f6"; + wipeHandle.style.transform = "translateX(-50%)"; + wipeHandle.style.left = "50%"; + wipeHandle.style.borderRadius = "2px"; + wipeHandle.style.pointerEvents = "none"; + wipeTrack.appendChild(wipeHandle); + controls.appendChild(wipeTrack); + return { controls }; } @@ -55,6 +108,10 @@ export function hidePreviewWidgets(node: ComfyNode): void { hideWidgetForGood(node, findWidget(node, "mode")); } +function isImageBConnected(node: ComfyNode): boolean { + return (node.inputs ?? []).some((input) => String(input?.name ?? "").toLowerCase() === "image_b" && (input?.link ?? null) != null); +} + export function syncPreviewWidgets(node: ComfyNode): void { if (!isNode(node)) return; const st = node.__imageops_state ?? null; @@ -67,4 +124,27 @@ export function syncPreviewWidgets(node: ComfyNode): void { } const modeSelect = root.querySelector("select[data-preview-mode]"); if (modeSelect) modeSelect.value = mode; + + const compareMode = st?.previewCompareMode ?? "live"; + const hasImageB = isImageBConnected(node); + for (const button of Array.from(root.querySelectorAll("button[data-compare-mode]"))) { + styleSoftButton(button, button.dataset.compareMode === compareMode); + if (button.dataset.compareMode === "backend") { + button.textContent = hasImageB ? "B" : "Backend"; + button.title = hasImageB ? "Show the B input alone" : "Show the last real queued result for this frame"; + } else if (button.dataset.compareMode === "wipe") { + button.title = hasImageB ? "Drag the divider to wipe between A and B" : "Drag the divider to wipe between live proxy and backend result"; + } else if (button.dataset.compareMode === "diff") { + button.title = hasImageB ? "Highlight per-pixel differences between A and B" : "Highlight per-pixel differences between live proxy and backend result"; + } else if (button.dataset.compareMode === "live") { + button.title = hasImageB ? "Show the A input alone (interactive canvas proxy)" : "Interactive canvas proxy (default)"; + } + } + const wipeTrack = root.querySelector("div[data-wipe-track]"); + if (wipeTrack) { + wipeTrack.style.display = compareMode === "wipe" ? "block" : "none"; + wipeTrack.title = hasImageB ? "A โ†” B wipe position" : "Live โ†” Backend wipe position"; + const handle = wipeTrack.firstElementChild as HTMLDivElement | null; + if (handle) handle.style.left = `${Math.max(0, Math.min(1, st?.previewWipeFraction ?? 0.5)) * 100}%`; + } } diff --git a/src/preview/ops.ts b/src/preview/ops.ts index 43c7ecc..d2f7406 100644 --- a/src/preview/ops.ts +++ b/src/preview/ops.ts @@ -5,6 +5,7 @@ import { geometryOps } from "./ops/geometry.js"; import { renderCompPreview } from "./ops/blend.js"; import { renderDrawNodePreview } from "./ops/procedural.js"; import { ops as runtimeOps } from "./ops/implementation.js"; +import { lensOps } from "./ops/lens.js"; import { maskOps } from "./ops/masks.js"; import { proceduralOps } from "./ops/procedural.js"; import { videoOps } from "./ops/video.js"; @@ -12,6 +13,7 @@ import { videoOps } from "./ops/video.js"; export { blendOps } from "./ops/blend.js"; export { colorOps } from "./ops/color.js"; export { geometryOps } from "./ops/geometry.js"; +export { lensOps } from "./ops/lens.js"; export { maskOps } from "./ops/masks.js"; export { proceduralOps } from "./ops/procedural.js"; export { videoOps } from "./ops/video.js"; @@ -25,5 +27,6 @@ export const ops = { ...blendOps, ...maskOps, ...proceduralOps, + ...lensOps, ...videoOps, }; diff --git a/src/preview/ops/lens.ts b/src/preview/ops/lens.ts new file mode 100644 index 0000000..576da0c --- /dev/null +++ b/src/preview/ops/lens.ts @@ -0,0 +1,336 @@ +import type { ComfyNode } from "../../types.js"; +import { makeCanvas } from "../draw.js"; +import { boolAny, numAny, strAny } from "../graph.js"; +import { clamp01, hexToRgb01 } from "./color.js"; +import { grainRandom01 } from "./procedural.js"; +import { resolvePreviewMaskCanvas } from "./masks.js"; + +function smoothstep01(t: number): number { + const c = Math.max(0, Math.min(1, t)); + return c * c * (3 - 2 * c); +} + +export function renderVignetteCanvas(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); + const octx = output.getContext("2d", { willReadFrequently: true })!; + octx.drawImage(source, 0, 0, width, height); + const img = octx.getImageData(0, 0, width, height); + const data = img.data; + + const amount = Math.max(0, Math.min(1, numAny(node, ["amount"], 0.5, frameIndex))); + if (amount <= 0) return output; + const size = numAny(node, ["size"], 1.0, frameIndex); + const softness = Math.max(1e-4, numAny(node, ["softness"], 0.6, frameIndex)); + const centerX = numAny(node, ["center_x"], 0.5, frameIndex); + const centerY = numAny(node, ["center_y"], 0.5, frameIndex); + const [tr, tg, tb] = hexToRgb01(strAny(node, ["color"], "#000000", frameIndex)); + const aspect = width / Math.max(1, height); + const inner = Math.max(0, size - softness); + const outer = Math.max(size, inner + 1e-4); + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + + for (let y = 0; y < height; y++) { + const ny = height > 1 ? y / (height - 1) : 0; + for (let x = 0; x < width; x++) { + const nx = width > 1 ? x / (width - 1) : 0; + const i = (y * width + x) * 4; + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) continue; + const dx = aspect >= 1 ? (nx - centerX) * aspect : nx - centerX; + const dy = aspect >= 1 ? ny - centerY : (ny - centerY) / Math.max(1e-6, aspect); + const dist = Math.hypot(dx, dy); + const t = smoothstep01((dist - inner) / (outer - inner)); + const falloff = t * amount * weight; + if (falloff <= 0) continue; + data[i] = Math.round((data[i] / 255 * (1 - falloff) + tr * falloff) * 255); + data[i + 1] = Math.round((data[i + 1] / 255 * (1 - falloff) + tg * falloff) * 255); + data[i + 2] = Math.round((data[i + 2] / 255 * (1 - falloff) + tb * falloff) * 255); + } + } + + octx.putImageData(img, 0, 0); + return output; +} + +export function renderChromaticAberrationCanvas(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); + const octx = output.getContext("2d", { willReadFrequently: true })!; + octx.drawImage(source, 0, 0, width, height); + + const amount = numAny(node, ["amount"], 2.0, frameIndex); + if (amount === 0) return output; + const centerX = numAny(node, ["center_x"], 0.5, frameIndex); + const centerY = numAny(node, ["center_y"], 0.5, frameIndex); + const cxPx = centerX * Math.max(0, width - 1); + const cyPx = centerY * Math.max(0, height - 1); + const halfDiag = Math.hypot(width / 2, height / 2) || 1; + const k = amount / halfDiag; + + const src = octx.getImageData(0, 0, width, height); + const srcData = src.data; + const out = octx.createImageData(width, height); + const outData = out.data; + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + + const sampleChannel = (sx: number, sy: number, channel: number): number => { + const cx = Math.max(0, Math.min(width - 1, sx)); + const cy = Math.max(0, Math.min(height - 1, sy)); + const x0 = Math.floor(cx); + const y0 = Math.floor(cy); + const x1 = Math.min(width - 1, x0 + 1); + const y1 = Math.min(height - 1, y0 + 1); + const tx = cx - x0; + const ty = cy - y0; + const p00 = srcData[(y0 * width + x0) * 4 + channel]; + const p10 = srcData[(y0 * width + x1) * 4 + channel]; + const p01 = srcData[(y1 * width + x0) * 4 + channel]; + const p11 = srcData[(y1 * width + x1) * 4 + channel]; + return (p00 * (1 - tx) + p10 * tx) * (1 - ty) + (p01 * (1 - tx) + p11 * tx) * ty; + }; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) { + outData[i] = srcData[i]; + outData[i + 1] = srcData[i + 1]; + outData[i + 2] = srcData[i + 2]; + outData[i + 3] = srcData[i + 3]; + continue; + } + const rx = (x - cxPx) * (1 + k) + cxPx; + const ry = (y - cyPx) * (1 + k) + cyPx; + const bx = (x - cxPx) * (1 - k) + cxPx; + const by = (y - cyPx) * (1 - k) + cyPx; + const r = sampleChannel(rx, ry, 0); + const bch = sampleChannel(bx, by, 2); + outData[i] = Math.round(srcData[i] * (1 - weight) + r * weight); + outData[i + 1] = srcData[i + 1]; + outData[i + 2] = Math.round(srcData[i + 2] * (1 - weight) + bch * weight); + outData[i + 3] = srcData[i + 3]; + } + } + + octx.putImageData(out, 0, 0); + return output; +} + +export function renderBloomCanvas(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); + const octx = output.getContext("2d", { willReadFrequently: true })!; + octx.drawImage(source, 0, 0, width, height); + + const threshold = numAny(node, ["threshold"], 0.8, frameIndex); + const intensity = Math.max(0, numAny(node, ["intensity"], 0.5, frameIndex)); + const radius = Math.max(0, Math.round(numAny(node, ["radius"], 12, frameIndex))); + if (intensity <= 0) return output; + + const img = octx.getImageData(0, 0, width, height); + const data = img.data; + + const bright = makeCanvas(width, height); + const bctx = bright.getContext("2d", { willReadFrequently: true })!; + const brightImg = bctx.createImageData(width, height); + const brightData = brightImg.data; + for (let i = 0; i < data.length; i += 4) { + brightData[i] = Math.max(0, data[i] - threshold * 255); + brightData[i + 1] = Math.max(0, data[i + 1] - threshold * 255); + brightData[i + 2] = Math.max(0, data[i + 2] - threshold * 255); + brightData[i + 3] = 255; + } + bctx.putImageData(brightImg, 0, 0); + + // Canvas's native CSS blur filter approximates the backend's gaussian glow + // cheaply enough for a live preview. + const glow = makeCanvas(width, height); + const gctx = glow.getContext("2d", { willReadFrequently: true })!; + gctx.filter = radius > 0 ? `blur(${radius}px)` : "none"; + gctx.drawImage(bright, 0, 0, width, height); + gctx.filter = "none"; + const glowData = gctx.getImageData(0, 0, width, height).data; + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + + for (let i = 0; i < data.length; i += 4) { + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) continue; + data[i] = Math.min(255, Math.round(data[i] + glowData[i] * intensity * weight)); + data[i + 1] = Math.min(255, Math.round(data[i + 1] + glowData[i + 1] * intensity * weight)); + data[i + 2] = Math.min(255, Math.round(data[i + 2] + glowData[i + 2] * intensity * weight)); + } + + octx.putImageData(img, 0, 0); + return output; +} + +export function renderLensArtifactsCanvas(node: ComfyNode, source: HTMLCanvasElement, dirt: HTMLCanvasElement | null, rawMask: HTMLCanvasElement | null, frameIndex: number): HTMLCanvasElement { + const width = source.width || 1; + const height = source.height || 1; + const output = makeCanvas(width, height); + const octx = output.getContext("2d", { willReadFrequently: true })!; + octx.drawImage(source, 0, 0, width, height); + + const dirtAmount = Math.max(0, Math.min(1, numAny(node, ["dirt_amount"], 0, frameIndex))); + const dustAmount = Math.max(0, Math.min(1, numAny(node, ["dust_amount"], 0, frameIndex))); + if (dirtAmount <= 0 && dustAmount <= 0) return output; + const dustSize = Math.max(0.5, numAny(node, ["dust_size"], 2.0, frameIndex)); + const seed = Math.max(0, Math.round(numAny(node, ["seed"], 12345, frameIndex))); + + const img = octx.getImageData(0, 0, width, height); + const data = img.data; + + let dirtData: Uint8ClampedArray | null = null; + if (dirt && dirtAmount > 0) { + const dctx = makeCanvas(width, height).getContext("2d", { willReadFrequently: true })!; + dctx.drawImage(dirt, 0, 0, width, height); + dirtData = dctx.getImageData(0, 0, width, height).data; + } + + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) continue; + for (let c = 0; c < 3; c++) { + let value = data[i + c] / 255; + if (dirtData) { + const luma = (dirtData[i] + dirtData[i + 1] + dirtData[i + 2]) / (3 * 255); + const attenuation = 1 - (1 - luma) * dirtAmount * weight; + value = value * attenuation; + } + if (dustAmount > 0) { + const gx = Math.floor(x / dustSize); + const gy = Math.floor(y / dustSize); + const threshold = 1 - dustAmount * 0.12; + const speck = grainRandom01(seed, gx, gy, 0, 0) > threshold ? 1 : 0; + value = value + speck * dustAmount * weight; + } + data[i + c] = Math.round(clamp01(value) * 255); + } + } + } + + octx.putImageData(img, 0, 0); + return output; +} + +export function vignette(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { + const source = inputs[0] ?? ctx.canvas; + return renderVignetteCanvas(node, source, inputs[1] ?? null, frameIndex); +} + +export function chromaticAberration(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { + const source = inputs[0] ?? ctx.canvas; + return renderChromaticAberrationCanvas(node, source, inputs[1] ?? null, frameIndex); +} + +export function bloom(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { + const source = inputs[0] ?? ctx.canvas; + return renderBloomCanvas(node, source, inputs[1] ?? null, frameIndex); +} + +/** + * Cheap live-preview approximation of the backend's depth-layered bokeh: + * a handful of CSS gaussian-blur bands interpolated by per-pixel depth. It + * ignores bokeh_shape and the highlight bloom (canvas has no native shaped + * blur) โ€” the real shaped/glowing bokeh only exists once the backend runs. + */ +export function renderDefocusCanvas(node: ComfyNode, source: HTMLCanvasElement, depth: HTMLCanvasElement | null, rawMask: HTMLCanvasElement | null, frameIndex: number): HTMLCanvasElement { + const width = source.width || 1; + const height = source.height || 1; + const output = makeCanvas(width, height); + const octx = output.getContext("2d", { willReadFrequently: true })!; + octx.drawImage(source, 0, 0, width, height); + if (!depth) return output; + + const maxRadius = Math.max(0, Math.round(numAny(node, ["max_blur_radius"], 24, frameIndex))); + if (maxRadius <= 0) return output; + const focusDistance = numAny(node, ["focus_distance"], 0.5, frameIndex); + const focusRange = Math.min(0.99, Math.max(0, numAny(node, ["focus_range"], 0.05, frameIndex))); + const invertDepth = boolAny(node, ["invert_depth"], false, frameIndex); + + const dctx = makeCanvas(width, height).getContext("2d", { willReadFrequently: true })!; + dctx.drawImage(depth, 0, 0, width, height); + const depthData = dctx.getImageData(0, 0, width, height).data; + + const BANDS = 4; + const bandRadii = Array.from({ length: BANDS }, (_, i) => Math.round((i / (BANDS - 1)) * maxRadius)); + const bandData = bandRadii.map((r) => { + if (r <= 0) return octx.getImageData(0, 0, width, height).data; + const band = makeCanvas(width, height); + const bctx = band.getContext("2d", { willReadFrequently: true })!; + bctx.filter = `blur(${r}px)`; + bctx.drawImage(source, 0, 0, width, height); + bctx.filter = "none"; + return bctx.getImageData(0, 0, width, height).data; + }); + + const mask = resolvePreviewMaskCanvas(node, source, rawMask, frameIndex); + const maskData = mask?.getContext("2d", { willReadFrequently: true })?.getImageData(0, 0, width, height).data ?? null; + + const img = octx.getImageData(0, 0, width, height); + const data = img.data; + const step = maxRadius / (BANDS - 1) || 1; + + for (let i = 0; i < data.length; i += 4) { + const weight = maskData ? maskData[i + 3] / 255 : 1; + if (weight <= 0) continue; + let depthValue = depthData[i] / 255; + if (invertDepth) depthValue = 1 - depthValue; + const coc = Math.max(0, Math.abs(depthValue - focusDistance) - focusRange) / Math.max(1e-3, 1 - focusRange); + const radius = Math.min(maxRadius, coc * maxRadius); + const pos = Math.min(BANDS - 1, radius / step); + const lo = Math.floor(pos); + const hi = Math.min(BANDS - 1, lo + 1); + const frac = pos - lo; + for (let c = 0; c < 3; c++) { + const blended = bandData[lo][i + c] * (1 - frac) + bandData[hi][i + c] * frac; + data[i + c] = Math.round(data[i + c] * (1 - weight) + blended * weight); + } + } + + octx.putImageData(img, 0, 0); + return output; +} + +export function defocus(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { + // Input schema order is image, depth, shape_texture, mask; shape_texture is + // only consumed by the real backend blur (see renderDefocusCanvas's doc + // comment), so it's skipped here but still needs to advance the cursor. + const source = inputs[0] ?? ctx.canvas; + const depthConnected = (node.inputs?.[1]?.link ?? null) != null; + const shapeConnected = (node.inputs?.[2]?.link ?? null) != null; + const maskConnected = (node.inputs?.[3]?.link ?? null) != null; + let cursor = 1; + const depth = depthConnected ? (inputs[cursor++] ?? null) : null; + if (shapeConnected) cursor++; + const mask = maskConnected ? (inputs[cursor] ?? null) : null; + return renderDefocusCanvas(node, source, depth, mask, frameIndex); +} + +export function lensArtifacts(ctx: CanvasRenderingContext2D, W: number, node: ComfyNode, inputs: HTMLCanvasElement[] = [], frameIndex: number = 0): HTMLCanvasElement { + // Input schema order is image, dirt, mask; inputs[] only contains the + // currently-connected slots, so walk it with a cursor the same way the + // Distort adapter disambiguates its optional displacement/mask inputs. + const source = inputs[0] ?? ctx.canvas; + const dirtConnected = (node.inputs?.[1]?.link ?? null) != null; + const maskConnected = (node.inputs?.[2]?.link ?? null) != null; + let cursor = 1; + const dirt = dirtConnected ? (inputs[cursor++] ?? null) : null; + const mask = maskConnected ? (inputs[cursor] ?? null) : null; + return renderLensArtifactsCanvas(node, source, dirt, mask, frameIndex); +} + +export const lensOps = { vignette, chromaticAberration, bloom, lensArtifacts, defocus }; diff --git a/src/preview/shared/bounds.ts b/src/preview/shared/bounds.ts index 1c47789..59c027c 100644 --- a/src/preview/shared/bounds.ts +++ b/src/preview/shared/bounds.ts @@ -6,7 +6,7 @@ import { getCropCanvasMetrics, getCropControlState, isNode as isCropNode } from import { isNode as isDrawNode } from "../nodes/draw.js"; import { isNode as isPadOutNode } from "../nodes/pad-out.js"; import { isNode as isRampNode, rampControlPoints } from "../nodes/ramp.js"; -import { drawOutputFormatBox, getFitPlacement } from "./geometry.js"; +import { drawOutputBounds, getFitPlacement } from "./geometry.js"; import { getNativePreviewImage, hideNativeMediaPreview, showNativeMediaPreview } from "./media.js"; import { ensureState, setInfo } from "./state.js"; import { widgetNumber } from "./widgets.js"; @@ -454,28 +454,87 @@ export function drawPadOutBounds( }; } -function drawFrameNumberOverlay(ctx: CanvasRenderingContext2D, canvasSize: number, frameIndex: number | null): void { - if (frameIndex == null || !Number.isFinite(frameIndex)) return; - const label = `f ${Math.max(0, Math.round(frameIndex)).toString().padStart(4, "0")}`; +export type HudAnchor = "bottom-center" | "top-left" | "top-right"; + +/** + * Shared pill-shaped HUD chip: rounded background, a small accent dot (colour + * carries meaning โ€” e.g. blue for "frame position", amber for "output size"), + * and auto-sized monospace text. Every canvas-drawn overlay badge in this file + * renders through this one function so they share a single visual language. + */ +function drawHudPill(ctx: CanvasRenderingContext2D, canvasSize: number, anchor: HudAnchor, text: string, accent: string = "rgba(255,255,255,0.85)"): void { ctx.save(); ctx.font = "11px ui-monospace, SFMono-Regular, Consolas, monospace"; - ctx.textBaseline = "top"; - const metrics = ctx.measureText(label); - const padX = 7; - const padY = 4; - const x = 8; - const w = Math.ceil(metrics.width + padX * 2); + ctx.textBaseline = "middle"; + const metrics = ctx.measureText(text); + const padX = 10; + const dotRadius = 3; + const dotGap = 6; const h = 20; - const y = Math.max(8, canvasSize - h - 8); - ctx.fillStyle = "rgba(0,0,0,0.58)"; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = "rgba(255,255,255,0.16)"; - ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); - ctx.fillStyle = "rgba(255,255,255,0.9)"; - ctx.fillText(label, x + padX, y + padY); + const w = Math.ceil(metrics.width + padX * 2 + dotRadius * 2 + dotGap); + + let x: number; + let y: number; + if (anchor === "bottom-center") { + x = Math.round((canvasSize - w) / 2); + y = canvasSize - h - 10; + } else if (anchor === "top-right") { + x = canvasSize - w - 8; + y = 8; + } else { + x = 8; + y = 8; + } + + const r = h / 2; + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + ctx.fillStyle = "rgba(14,16,20,0.72)"; + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = "rgba(255,255,255,0.14)"; + ctx.stroke(); + + const dotCx = x + padX; + const dotCy = y + h / 2; + ctx.beginPath(); + ctx.arc(dotCx, dotCy, dotRadius, 0, Math.PI * 2); + ctx.fillStyle = accent; + ctx.fill(); + + ctx.fillStyle = "rgba(255,255,255,0.92)"; + ctx.fillText(text, dotCx + dotRadius + dotGap, dotCy + 0.5); ctx.restore(); } +const FRAME_HUD_ACCENT = "rgba(96,165,250,0.95)"; // blue: "where am I in the sequence" + +function drawFrameNumberOverlay(ctx: CanvasRenderingContext2D, canvasSize: number, frameIndex: number | null, frameCount: number): void { + if (frameIndex == null || !Number.isFinite(frameIndex)) return; + // A single still image has nothing to count โ€” the chip would just be noise. + if (!Number.isFinite(frameCount) || frameCount <= 1) return; + const digits = String(frameCount).length; + // Display is 1-indexed ("3 / 24") to read like a normal frame counter; the + // internal frameIndex driving playback stays 0-indexed. + const current = (Math.max(0, Math.round(frameIndex)) + 1).toString().padStart(digits, "0"); + const total = frameCount.toString().padStart(digits, "0"); + drawHudPill(ctx, canvasSize, "bottom-center", `${current} / ${total}`, FRAME_HUD_ACCENT); +} + +const OUTPUT_HUD_ACCENT = "rgba(251,191,36,0.95)"; // amber, matches the dashed output-bounds outline + +/** Real output pixel size, e.g. for PadOut where the output canvas extends past the source content. */ +function drawOutputSizePill(ctx: CanvasRenderingContext2D, canvasSize: number, outputWidth: number, outputHeight: number): void { + const w = Math.max(1, Math.round(outputWidth)); + const h = Math.max(1, Math.round(outputHeight)); + drawHudPill(ctx, canvasSize, "top-right", `${w} ร— ${h}`, OUTPUT_HUD_ACCENT); +} + export function blit( node: ComfyNode, st: NodeState, @@ -618,10 +677,108 @@ export function blit( st.padOutGeometry = drawPadOutBounds(node, ctx, canvasSize, canvasSize, effOW, effOH, padOutSw, padOutSh); drawTransformBounds(node, ctx, canvasSize, canvasSize, resolvedWidth, resolvedHeight); drawCompBounds(node, ctx, canvasSize, canvasSize, st.compOutputWidth || resolvedWidth, st.compOutputHeight || resolvedHeight, st.compLayers); - drawOutputFormatBox(ctx, padOutFit ?? fit); + // Only PadOut's output canvas differs from the plain source fit (it extends + // past the source with padding) โ€” everywhere else this would just retrace + // the image's own edge, so it stays hidden for every other node. + if (padOutFit) { + drawOutputBounds(ctx, padOutFit); + drawOutputSizePill(ctx, canvasSize, effOW, effOH); + } if (hasTransform) ctx.restore(); - drawFrameNumberOverlay(ctx, canvasSize, st.previewFrameIndex); + drawFrameNumberOverlay(ctx, canvasSize, st.previewFrameIndex, st.previewFrameCount); +} + +const COMPARE_HUD_ACCENT = "rgba(244,114,182,0.95)"; // pink: "this is a compare overlay, not the plain result" + +function drawTopLeftBadge(ctx: CanvasRenderingContext2D, canvasSize: number, label: string): void { + drawHudPill(ctx, canvasSize, "top-left", label, COMPARE_HUD_ACCENT); +} + +export type CompareLabels = { + solo: string; + diff: string; + wipe: string; +}; + +const BACKEND_COMPARE_LABELS: CompareLabels = { + solo: "BACKEND", + diff: "DIFF (live vs backend)", + wipe: "LIVE โ† wipe โ†’ BACKEND", +}; + +/** + * ImageOpsPreview compare modes: blit the interactive Canvas2D proxy against + * a second source at the same frame โ€” either the last real queued (backend) + * result of the same input, or a live-rendered "B" input for a true A/B + * comparison. "backend" shows the second source alone; "wipe" splits the two + * at wipeFraction with a draggable divider; "diff" uses canvas difference + * compositing (abs(a-b) per channel) to highlight where they disagree. Both + * sources are already sRGB 8-bit by the time they reach the canvas, so this + * compares final display pixels, not the scene-linear/HDR values upstream of + * that. + */ +export function blitCompare( + node: ComfyNode, + st: NodeState, + liveSource: CanvasImageSource, + otherSource: CanvasImageSource, + canvasSize: number, + mode: "backend" | "wipe" | "diff", + wipeFraction: number, + sourceWidth: number, + sourceHeight: number, + labels: CompareLabels = BACKEND_COMPARE_LABELS, +): void { + if (!st.canvas) return; + hideNativeMediaPreview(st); + const ctx = st.canvas.getContext("2d"); + if (!ctx) return; + if (st.canvas.width !== canvasSize) st.canvas.width = canvasSize; + if (st.canvas.height !== canvasSize) st.canvas.height = canvasSize; + st.previewSourceWidth = sourceWidth; + st.previewSourceHeight = sourceHeight; + st.previewLastSource = liveSource; + + const fit = getFitPlacement(canvasSize, canvasSize, sourceWidth, sourceHeight); + st.fitGeometry = fit; + ctx.clearRect(0, 0, canvasSize, canvasSize); + ctx.imageSmoothingEnabled = true; + + if (mode === "backend") { + ctx.drawImage(otherSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + drawTopLeftBadge(ctx, canvasSize, labels.solo); + } else if (mode === "diff") { + ctx.drawImage(otherSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + ctx.save(); + ctx.globalCompositeOperation = "difference"; + ctx.drawImage(liveSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + ctx.restore(); + drawTopLeftBadge(ctx, canvasSize, labels.diff); + } else { + const fraction = Math.max(0, Math.min(1, wipeFraction)); + const splitX = fit.dx + fit.drawWidth * fraction; + ctx.drawImage(liveSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + ctx.save(); + ctx.beginPath(); + ctx.rect(splitX, fit.dy, fit.dx + fit.drawWidth - splitX, fit.drawHeight); + ctx.clip(); + ctx.drawImage(otherSource, fit.dx, fit.dy, fit.drawWidth, fit.drawHeight); + ctx.restore(); + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(splitX, fit.dy); + ctx.lineTo(splitX, fit.dy + fit.drawHeight); + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.85)"; + ctx.beginPath(); + ctx.arc(splitX, fit.dy + fit.drawHeight / 2, 5, 0, Math.PI * 2); + ctx.fill(); + drawTopLeftBadge(ctx, canvasSize, labels.wipe); + } + + drawFrameNumberOverlay(ctx, canvasSize, st.previewFrameIndex, st.previewFrameCount); } export function tryRenderNativePreview(node: ComfyNode, st: NodeState, canvasSize: number): boolean { diff --git a/src/preview/shared/geometry.ts b/src/preview/shared/geometry.ts index d05d503..5dc9d09 100644 --- a/src/preview/shared/geometry.ts +++ b/src/preview/shared/geometry.ts @@ -58,18 +58,19 @@ export function drawFitSource( ctx.drawImage(source, placement.dx, placement.dy, placement.drawWidth, placement.drawHeight); } -export function drawOutputFormatBox(ctx: CanvasRenderingContext2D, fit: FitPlacement, label: string = "Output"): void { +/** + * Traces the output canvas bounds. Only meaningful when those bounds differ + * from the plain source fit (e.g. PadOut's padding) โ€” the caller is + * responsible for only drawing this when that's actually the case, since for + * most nodes it would just retrace the image's own edge. + */ +export function drawOutputBounds(ctx: CanvasRenderingContext2D, fit: FitPlacement): void { ctx.save(); - ctx.strokeStyle = "rgba(255,255,255,0.24)"; + ctx.strokeStyle = "rgba(251,191,36,0.55)"; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); ctx.strokeRect(fit.dx + 0.5, fit.dy + 0.5, fit.drawWidth, fit.drawHeight); ctx.setLineDash([]); - ctx.fillStyle = "rgba(10,12,16,0.72)"; - ctx.fillRect(fit.dx + 6, fit.dy + 6, 52, 16); - ctx.fillStyle = "rgba(255,255,255,0.82)"; - ctx.font = "10px sans-serif"; - ctx.fillText(label, fit.dx + 10, fit.dy + 18); ctx.restore(); } diff --git a/src/preview/shared/imageops-metadata.ts b/src/preview/shared/imageops-metadata.ts index 2267d75..40a7ea6 100644 --- a/src/preview/shared/imageops-metadata.ts +++ b/src/preview/shared/imageops-metadata.ts @@ -122,6 +122,26 @@ export const IMAGEOPS_NODE_METADATA: readonly ImageOpsNodeMeta[] = [ "className": "ImageOpsText", "ui": "custom", "minPreviewHeight": 470 + }, + { + "className": "ImageOpsVignette", + "ui": "native" + }, + { + "className": "ImageOpsChromaticAberration", + "ui": "native" + }, + { + "className": "ImageOpsBloom", + "ui": "native" + }, + { + "className": "ImageOpsLensArtifacts", + "ui": "native" + }, + { + "className": "ImageOpsDefocus", + "ui": "native" } ] as const; diff --git a/src/preview/shared/state.ts b/src/preview/shared/state.ts index d18ee80..4b7c39c 100644 --- a/src/preview/shared/state.ts +++ b/src/preview/shared/state.ts @@ -38,6 +38,11 @@ function createInitialState(node: ComfyNode): NodeState { previewSourceWidth: 0, previewSourceHeight: 0, previewFrameIndex: null, + previewFrameCount: 1, + previewCompareMode: "live", + previewWipeFraction: 0.5, + previewCompareHooked: false, + previewWipeDrag: null, cropAspectRatio: null, cropGeometry: null, cropDrag: null, diff --git a/src/types.ts b/src/types.ts index 48dfea9..7e8a16a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,12 +97,22 @@ export interface ComfyNode { // โ”€โ”€ Adapter โ”€โ”€ +export type PreviewSupportLevel = "exact" | "approximate" | "unsupported"; + export interface Adapter { name?: string; match(node: ComfyNode): boolean; inputs: number | ((node: ComfyNode) => number); inputIndexes?: number[] | ((node: ComfyNode) => number[]); apply(ctx: AdapterApplyContext): Promise | HTMLCanvasElement | void; + /** + * How faithfully this adapter's canvas preview represents the node's real + * (tensor, full-resolution) backend output. Defaults to "approximate" when + * absent โ€” none of the reduced-resolution Canvas2D adapters have been + * validated bit-for-bit against the backend, so "exact" must be earned per + * operation, not assumed. + */ + previewSupport?(node: ComfyNode): PreviewSupportLevel; } export interface AdapterApplyContext { @@ -130,8 +140,22 @@ export interface RenderContext { visited: Set; } +// ImageOpsPreview: how the node's canvas compares the interactive Canvas2D +// proxy against the last real (tensor, full-resolution) queued result. +// "live" is the existing always-on interactive proxy; the other three only +// apply once a backend result exists for the current frame. +export type PreviewCompareMode = "live" | "backend" | "wipe" | "diff"; + export interface RenderResult { canvas: HTMLCanvasElement | null; + /** + * True when `node` is an ImageOps node with no registered preview adapter, + * so `canvas` is just a passthrough of its first input rather than a real + * preview of this node's own transform (e.g. Append, which has no + * canvas-based compositing adapter). Callers should say so instead of + * showing the passthrough as if it were the node's actual output. + */ + unsupported?: boolean; } export interface RenderInputInfo { @@ -177,6 +201,15 @@ export interface NodeState { previewSourceWidth: number; previewSourceHeight: number; previewFrameIndex: number | null; + // Total frames in the current tick loop (batch/video length, join timeline, โ€ฆ). + // <= 1 means "nothing to count" โ€” the frame HUD hides itself in that case. + previewFrameCount: number; + // ImageOpsPreview only: on-demand comparison between the interactive canvas + // proxy and the last real queued (backend) result, at the same frame. + previewCompareMode: PreviewCompareMode; + previewWipeFraction: number; + previewCompareHooked: boolean; + previewWipeDrag: { pointerId: number } | null; cropAspectRatio: number | null; fitGeometry?: { dx: number; dy: number; drawWidth: number; drawHeight: number } | null; cropGeometry: CropPreviewGeometry | null; diff --git a/tests/frontend/imageops-registry.test.cjs b/tests/frontend/imageops-registry.test.cjs index 85b7636..e4d883e 100644 --- a/tests/frontend/imageops-registry.test.cjs +++ b/tests/frontend/imageops-registry.test.cjs @@ -9,7 +9,7 @@ test("all manifest nodes are enabled for frontend previews", async () => { const classes = await import("../../js/preview/shared/classes.js"); const expected = manifest.nodes.map((node) => node.className); - assert.equal(expected.length, 26, "the canonical manifest must contain all 26 ImageOps nodes"); + assert.equal(expected.length, 31, "the canonical manifest must contain all 31 ImageOps nodes"); assert.deepEqual([...classes.IMAGEOPS_CLASSES], expected); const custom = manifest.nodes.filter((node) => node.ui === "custom").map((node) => node.className); diff --git a/tests/integration/test_media_nodes.py b/tests/integration/test_media_nodes.py index e769b90..3fa3022 100644 --- a/tests/integration/test_media_nodes.py +++ b/tests/integration/test_media_nodes.py @@ -37,10 +37,10 @@ def test_append_preserves_media(): node = ImageOpsAppend() frames1 = torch.ones((2, 16, 16, 3)) media1 = ImageOpsMedia(frames=frames1, fps=24.0, audio=torch.zeros((1, 100))) - + frames2 = torch.zeros((3, 16, 16, 3)) - media2 = ImageOpsMedia(frames=frames2, fps=30.0, audio=torch.ones((1, 100))) - + media2 = ImageOpsMedia(frames=frames2, fps=24.0, audio=torch.ones((1, 100))) + result = node.execute(image_1=media1, image_2=media2) out_dict = result[0] assert isinstance(out_dict, dict) @@ -51,3 +51,50 @@ def test_append_preserves_media(): assert out_audio.shape[-1] == 200 assert torch.all(out_audio[..., :100] == 0) assert torch.all(out_audio[..., 100:] == 1) + + +def test_append_rejects_mismatched_fps(): + node = ImageOpsAppend() + media1 = ImageOpsMedia(frames=torch.ones((2, 16, 16, 3)), fps=24.0) + media2 = ImageOpsMedia(frames=torch.zeros((3, 16, 16, 3)), fps=30.0) + + with pytest.raises(ValueError, match='matching frame rates'): + node.execute(image_1=media1, image_2=media2) + + +def test_append_rejects_inverted_trim(): + node = ImageOpsAppend() + media1 = ImageOpsMedia(frames=torch.ones((5, 16, 16, 3)), fps=24.0) + + with pytest.raises(ValueError, match='trim_end'): + node.execute(image_1=media1, trims_json='{"version":1,"clips":[{"slot":"image_1","start":3,"end":1}]}') + + +def test_frame_range_rejects_inverted_trim(): + node = ImageOpsFrameRange() + frames = torch.ones((5, 16, 16, 3)) + + with pytest.raises(ValueError, match='trim_end'): + node.execute(image=frames, trim_start=3, trim_end=1) + + +def test_append_pad_to_max_alpha_opaque_by_default(): + node = ImageOpsAppend() + small = torch.ones((1, 4, 4, 4)) + big = torch.ones((1, 8, 8, 4)) + + result = node.execute(image_1=small, image_2=big, fit_mode='pad_to_max') + out = result[0] + assert out.shape == (2, 8, 8, 4) + # Corner of the padded (smaller) clip must be fully opaque by default. + assert out[0, 0, 0, 3] == 1.0 + + +def test_append_pad_to_max_alpha_transparent_opt_in(): + node = ImageOpsAppend() + small = torch.ones((1, 4, 4, 4)) + big = torch.ones((1, 8, 8, 4)) + + result = node.execute(image_1=small, image_2=big, fit_mode='pad_to_max', pad_alpha='transparent') + out = result[0] + assert out[0, 0, 0, 3] == 0.0 diff --git a/tests/unit/test_hdr_support.py b/tests/unit/test_hdr_support.py index d0133b0..a5c5571 100644 --- a/tests/unit/test_hdr_support.py +++ b/tests/unit/test_hdr_support.py @@ -25,4 +25,25 @@ class TestHDRSupport: assert adjusted.max().item() > 1.0 assert torch.allclose(adjusted, hdr_image, atol=1e-4) + def test_apply_color_adjust_input_space_linear_skips_srgb_curve(self): + # A scene-linear/HDR source should not be run through the sRGB OETF/EOTF + # when input_space="linear", or brightness would double-gamma the result. + linear_image = torch.tensor([[[[0.25, 0.5, 2.0]]]], dtype=torch.float32) + + adjusted = _apply_color_adjust( + linear_image, + temperature=0.0, + tint=0.0, + hue=0.0, + brightness=20.0, + contrast=0.0, + saturation=0.0, + vibrance=0.0, + gamma=1.0, + input_space="linear", + ) + + expected = linear_image * 1.2 + assert torch.allclose(adjusted, expected, atol=1e-4) +