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.
This commit is contained in:
@@ -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
|
||||
|
||||

|
||||
@@ -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
|
||||
|
||||
+10
@@ -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:
|
||||
|
||||
+6
-1
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+68
-5
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
+119
-19
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+53
-13
@@ -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
|
||||
|
||||
+35
-24
@@ -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:
|
||||
|
||||
+100
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
+12
-26
@@ -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))
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import torch
|
||||
|
||||
from ._helpers import (
|
||||
,
|
||||
_apply_mask_to_image,
|
||||
_prepare_effect_mask,
|
||||
_resolve_mask_output_source,
|
||||
|
||||
@@ -3,7 +3,6 @@ import torch
|
||||
from ._helpers import (
|
||||
EPSILON,
|
||||
LUMA_WEIGHTS,
|
||||
,
|
||||
_blur_mask,
|
||||
_param_tensor,
|
||||
_prepare_effect_mask,
|
||||
|
||||
@@ -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")
|
||||
@@ -3,7 +3,6 @@ import torch
|
||||
from ._helpers import (
|
||||
EPSILON,
|
||||
LUMA_WEIGHTS,
|
||||
,
|
||||
_apply_blur,
|
||||
_coerce_mask_tensor,
|
||||
_has_list_param,
|
||||
|
||||
+29
-3
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<RenderResult> {
|
||||
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<HTMLCanvasElement | null> {
|
||||
|
||||
+78
-6
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLButtonElement>("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<HTMLDivElement>("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);
|
||||
}
|
||||
|
||||
@@ -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<string, JoinTrim> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLSelectElement>("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<HTMLButtonElement>("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<HTMLDivElement>("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}%`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
+176
-19
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> | 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<number>;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user