20 Commits
Author SHA1 Message Date
kuschanow 68bc65783d chore: bump version to 3.0.1 in pyproject.toml 2026-08-10 23:32:17 +03:00
kuschanow 2e7a3bf3d0 fix: update GitHub Actions condition to reflect correct repository owner 2026-08-10 23:29:53 +03:00
kuschanow 551835195e feat: update transforms to operate on model instead of latent; bug fixes; version bump to 3.0.0 2026-08-10 23:15:09 +03:00
Roman Kushanov 9e685f9f2d Merge pull request #7 from ComfyNodePRs/update-publish-yaml
Update Github Action for Publishing to Comfy Registry
2025-03-27 19:57:43 +02:00
snomiao df4d4210f1 chore(publish): update workflow for node publishing
- Added permissions to allow issue writing.
- Updated condition to run job only for specific repository owner.
- Changed action version from `main` to `v1` for stability.
2025-01-21 08:45:27 +00:00
RomanKuschanow a92091a8f2 add publisher id 2024-06-21 10:29:08 +03:00
Kuschanow Roman e8ea84b4cf Merge pull request #4 from ComfyNodePRs/pyproject
Add pyproject.toml for Custom Node Registry
2024-06-21 10:26:33 +03:00
Kuschanow Roman 589c3626e1 Merge pull request #3 from ComfyNodePRs/publish
Add Github Action for Publishing to Comfy Registry
2024-06-20 20:52:31 +03:00
snomiao 36a76e06dc chore(pyproject): Add pyproject.toml for Custom Node Registry 2024-06-14 08:10:54 +00:00
snomiao 20496551ad chore(publish): Add Github Action for Publishing to Comfy Registry 2024-06-14 08:10:54 +00:00
RomanKuschanow b88bb480fc add preview for mirror and shift 2024-05-05 12:17:04 +03:00
RomanKuschanow 7bd9377e94 fix square size 2024-04-04 18:03:25 +03:00
RomanKuschanow a89d51dd05 readme 2024-04-02 00:04:58 +03:00
RomanKuschanow 0b3641c0e6 transform hijack 2024-04-01 23:51:10 +03:00
RomanKuschanow 26c36e9c91 adding more than 2 offsets to offset combine node 2024-03-31 17:45:19 +03:00
RomanKuschanow d74687b48c refactoring 2024-03-31 15:06:15 +03:00
RomanKuschanow 5f46f8f623 remove test node 2024-03-31 15:05:28 +03:00
RomanKuschanow 872f5b371c offset widget 2024-03-31 15:04:33 +03:00
RomanKuschanow 6b3770acea test widget 2024-03-18 22:14:01 +02:00
RomanKuschanow 17c23c1203 invert factor parameter in latent interpolate node 2024-03-18 18:12:37 +02:00
15 changed files with 560 additions and 228 deletions
+26
View File
@@ -0,0 +1,26 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'kuschanow' }}
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+26 -33
View File
@@ -38,37 +38,8 @@ This node can shift latent along x and y-axis.
**Usage:**
![sample](https://i.imgur.com/1Dp5dSw.png)
## TSampler with transforms (Latent Control)
This node can multiply, mirror and shift latent during generation.
**Input:**
exactly matches the base KSampler
**Fields:**
- base KSampler fields
- `start_mirror_at` – a number between 0 and 1 that indicates at what point the sampler will start mirroring
- `stop_mirror_at` – a number between 0 and 1 that indicates at what point the sampler will stop mirroring
- `mirror_mode` – can be `replace` or `combine`. `replace` will replace the latent with the transformed one, `combine` will add the original and the transformed latent and divide by 2
- `mirror_direction` – can be `none`, `vertically`, `horizontally`, `both`, `90 degree rotation` or `180 degree rotation`
- `start_shift_at` – a number between 0 and 1 that indicates at what point the sampler will start shifting
- `stop_shift_at` – a number between 0 and 1 that indicates at what point the sampler will stop shifting
- `shift_mode` – can be `replace` or `combine`. `replace` will replace the latent with the transformed one, `combine` will add the original and the transformed latent and divide by 2
- `x_shift` – a number between -1 and 1 that indicates how much the latent should be shifted
- `y_shift` – a number between -1 and 1 that indicates how much the latent should be shifted
- `start_multiplier_at` – a number between 0 and 1 that indicates at what point the sampler will start multiplying
- `stop_multiplier_at` – a number between 0 and 1 that indicates at what point the sampler will stop multiplying
- `multiplier_mode` – can be `replace` or `combine`. `replace` will replace the latent with the transformed one, `combine` will add the original and the transformed latent
- `multiplier` – multiply latent by specified number
**Output:**
exactly matches the base KSampler
**Usage:**
**You also can use those params together**
![sample](https://i.imgur.com/RMJTnWF.png)
![sample](https://i.imgur.com/fQ7UWuS.png)
![sample](https://i.imgur.com/pxWupAx.png)
![sample](https://i.imgur.com/1YkERDu.png)
## ~~TSampler with transforms (Latent Control)~~
Removed from version 2.0.0
## TSampler (Latent Control)
This node allows to combine a lot of transforms with different parameters.
@@ -125,10 +96,10 @@ Each transform node has own one-time version. They allow to make one transform a
Fixes some issues when sampling modified latent space.
**Input**
**Input:**
exactly matches the `VAE Decode` node
**Output**
**Output:**
- latent
When you multiply latent by negative or big positive (bigger than 2) number and paste this latent in sampler, you can see that the
@@ -150,4 +121,26 @@ And it very slightly changes results from latent, which have not been modified.
![sample](https://i.imgur.com/xTU08xm.png)
![sample](https://i.imgur.com/yzgW7QT.png)
## Transform hijack
Allow you to use transforms with any samplers that you like.
Instead of patching the latent, this node patches the **model**: it attaches the transforms
to a cloned model via a sampler post-cfg hook. Connect the returned model to any sampler
(`KSampler`, `KSamplerAdvanced`, custom sampler nodes, etc.) and the transforms will be applied
during sampling.
**Inputs:**
- model
- transforms
**Outputs:**
- model
**Usage:**
![sample](https://i.imgur.com/YwVhHYF.png)
> **Breaking change in 3.0.0:** `Transform hijack` now takes and returns a `MODEL` instead of a
> `LATENT`. This replaces the old global `common_ksampler` monkey-patch, which conflicted with the
> stock `KSampler` and other custom nodes. Rewire this node to your model input/output after updating.
+10 -5
View File
@@ -1,11 +1,15 @@
from .nodes import *
WEB_DIRECTORY = "js"
NODE_CLASS_MAPPINGS = {
"LatentMirror": LatentMirror,
"LatentShift": LatentShift,
"LatentNormalize": LatentNormalize,
"TSamplerWithTransform": TSamplerWithTransform,
"TransformSampler": TransformSampler,
"TransformSampler": TSampler,
"TransformSamplerAdvanced": TSamplerAdvanced,
"TransformHijack": TransformHijack,
"MirrorTransform": MirrorTransform,
"ShiftTransform": ShiftTransform,
"MultiplyTransform": MultiplyTransform,
@@ -25,16 +29,17 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"LatentMirror": "Latent mirror",
"LatentShift": "Latent shift",
"LatentNormalize": "Latent normalize",
"TSamplerWithTransform": "TSampler with transforms (Latent Control)",
"TransformSampler": "TSampler (Latent Control)",
"TransformSamplerAdvanced": "TSampler Advanced (Latent Control)",
"TransformHijack": "Transform Hijack",
"MirrorTransform": "Mirror transform",
"ShiftTransform": "Shift transform",
"MultiplyTransform": "Multiply transform",
"LatentInterpolateTransform": "Latent interpolate transform",
"LatentAddTransform": "Latent add transform",
"OneTimeMirrorTransform": "Mirror transform (one time)",
"OneTimeMultiplyTransform": "Shift transform (one time)",
"OneTimeShiftTransform": "Multiply transform (one time)",
"OneTimeMultiplyTransform": "Multiply transform (one time)",
"OneTimeShiftTransform": "Shift transform (one time)",
"OneTimeLatentInterpolateTransform": "Latent interpolate transform (one time)",
"OneTimeLatentAddTransform": "Latent add transform (one time)",
"TransformsCombine": "Combine transforms",
+191
View File
@@ -0,0 +1,191 @@
import { app } from "/scripts/app.js";
import {addCanvas, computeCanvasSize, generatePattern, recursiveLinkUpstream, renameNodeInputs, removeNodeInputs} from "./utils.js";
function drawSquares(ctx, widgetX, widgetY, squareSize, pattern) {
const actualSquareSize = squareSize - Math.floor(squareSize / 16);
widgetY += Math.floor(squareSize / 16) / 2;
widgetX += Math.floor(squareSize / 16) / 2;
pattern.forEach((value, index) => {
const x = widgetX + index * squareSize;
ctx.fillStyle = value === 1 ? "#222223" : "#00000000";
ctx.strokeStyle = "#222223";
ctx.lineWidth = Math.floor(squareSize / 16);
if (value === 1) {
ctx.fillRect(x, widgetY, actualSquareSize, actualSquareSize);
}
ctx.strokeRect(x, widgetY, actualSquareSize, actualSquareSize);
if (squareSize >= 24) {
ctx.font = `bold ${squareSize/3}px Arial`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.text
ctx.fillStyle = value === 1 ? "#dbdbdc" : "#222223";
ctx.fillText(value.toString(), x + actualSquareSize/2, widgetY + actualSquareSize/2);
}
});
}
const offsetWidget = {
type: "customCanvas",
name: "Offset-Canvas",
get value() {
return this.canvas.value;
},
set value(x) {
this.canvas.value = x;
},
draw: function (ctx, node, widgetWidth, widgetY) {
if (!node.canvasHeight) {
computeCanvasSize(node, node.size)
}
let patterns = []
if (node.type === "OffsetCombine") {
const connectedNodes = recursiveLinkUpstream(node, node.inputs[0].type, node.type, 0)
if (connectedNodes.length !== 0) {
for (let [node_ID, depth] of connectedNodes) {
const connectedNode = node.graph._nodes_by_id[node_ID]
if (connectedNode.type !== "OffsetCombine") {
const pattern = {
process_every: connectedNode.widgets[0].value,
offset: connectedNode.widgets[1].value + node.widgets[0].value,
mode: connectedNode.widgets[2].value
}
patterns.push(pattern)
}
}
}
} else {
const pattern = {
process_every: node.widgets[0].value,
offset: node.widgets[1].value,
mode: node.widgets[2].value}
patterns.push(pattern)
}
const pattern = generatePattern(patterns)
const visible = true
const t = ctx.getTransform();
const margin = 10
const widgetHeight = node.canvasHeight
const width = pattern.length * 32
const height = 32
const scale = Math.min((widgetWidth-margin*2)/width, (widgetHeight-margin*2)/height)
Object.assign(this.canvas.style, {
left: `${t.e}px`,
top: `${t.f + (widgetY*t.d)}px`,
width: `${widgetWidth * t.a}px`,
height: `${widgetHeight * t.d}px`,
position: "absolute",
zIndex: 1,
fontSize: `${t.d * 10.0}px`,
pointerEvents: "none",
});
this.canvas.hidden = !visible;
let backgroundWidth = width * scale
let backgroundHeight = height * scale
let xOffset = margin
if (backgroundWidth < widgetWidth) {
xOffset += (widgetWidth-backgroundWidth)/2 - margin
}
let yOffset = margin
if (backgroundHeight < widgetHeight) {
yOffset += (widgetHeight-backgroundHeight)/2 - margin
}
let widgetX = xOffset
widgetY = widgetY + yOffset
const squareSize = backgroundWidth / pattern.length;
drawSquares(ctx, widgetX, widgetY, squareSize, pattern)
ctx.fillStyle = "#ffffff88"
ctx.fillRect(widgetX, widgetY, backgroundWidth, backgroundHeight);
},
};
app.registerExtension({
name: "Comfy.LatentControl.TransformOffset",
async beforeRegisterNodeDef (nodeType, nodeData, app){
if (nodeData.name === "TransformOffset") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
addCanvas(this, app, offsetWidget)
return r;
}
}
},
});
app.registerExtension({
name: "Comfy.LatentControl.OffsetCombine",
async beforeRegisterNodeDef (nodeType, nodeData, app){
if (nodeData.name === "OffsetCombine") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
addCanvas(this, app, offsetWidget)
this.getExtraMenuOptions = function(_, options) {
options.unshift(
{
content: `add offset`,
callback: () => {
this.addInput("offset", "OFFSET")
renameNodeInputs(this, "offset")
this.setDirtyCanvas(true);
},
},
{
content: `remove offset`,
callback: () => {
removeNodeInputs(this, [this.inputs.length-1])
renameNodeInputs(this, "offset")
},
},
{
content: "remove all unconnected offsets",
callback: () => {
let indexesToRemove = []
for (let i = 0; i < this.inputs.length; i++) {
if (!this.inputs[i].link) {
indexesToRemove.push(i)
}
}
if (indexesToRemove.length) {
removeNodeInputs(this, indexesToRemove)
}
renameNodeInputs(this, "offset")
},
},
);
}
return r;
}
}
},
});
+156
View File
@@ -0,0 +1,156 @@
export function computeCanvasSize(node, size) {
if (node.widgets[0].last_y == null) return;
const MIN_SIZE = 64;
const inputs = node.inputs === undefined ? 0 : node.inputs.length
const outputs = node.outputs === undefined ? 0 : node.outputs.length
let y = LiteGraph.NODE_WIDGET_HEIGHT * Math.max(inputs, outputs) + 5;
let freeSpace = size[1] - y;
// Compute the height of all non customtext widgets
let widgetHeight = 0;
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
if (w.type !== "customCanvas") {
if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
} else {
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 5;
}
}
}
// See how large the canvas can be
freeSpace -= widgetHeight;
// There isnt enough space for all the widgets, increase the size of the node
if (freeSpace < MIN_SIZE) {
freeSpace = MIN_SIZE;
node.size[1] = y + widgetHeight + freeSpace;
node.graph.setDirtyCanvas(true);
}
// Position each of the widgets
for (const w of node.widgets) {
w.y = y;
if (w.type === "customCanvas") {
y += freeSpace;
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
} else {
y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
}
}
node.canvasHeight = freeSpace;
}
function gcd(a, b) {
// Функция для вычисления наибольшего общего делителя (НОД)
while (b !== 0) {
let t = b;
b = a % b;
a = t;
}
return a;
}
function lcm(a, b) {
// Функция для вычисления наименьшего общего кратного (НОК)
return (a * b) / gcd(a, b);
}
function findPatternLength(rules) {
// Вычисление длины цикла как НОК всех process_every
return rules.map(rule => rule.process_every).reduce((acc, val) => lcm(acc, val), 1);
}
export function generatePattern(rules) {
let length = findPatternLength(rules); // Определение длины паттерна
let pattern = new Array(length).fill(0);
rules.forEach(rule => {
let offset = rule.offset % rule.process_every;
for (let i = 0; i < length; i++) {
let value = ((i + offset) % rule.process_every === 0) === (rule.mode === "process_every") ? 1 : 0;
pattern[i] = pattern[i] || value;
}
});
return pattern;
}
export function recursiveLinkUpstream(node, slot_type, node_type, depth) {
depth += 1
let connections = []
const inputList = [...Array(node.inputs.length).keys()]
for (let i of inputList) {
const link = node.inputs[i].link
if (link) {
const nodeID = node.graph.links[link].origin_id
const slotID = node.graph.links[link].origin_slot
const connectedNode = node.graph._nodes_by_id[nodeID]
if (connectedNode.outputs[slotID].type === slot_type) {
connections.push([connectedNode.id, depth])
if (connectedNode.inputs) {
const index = (connectedNode.type === node_type) ? 0 : null
connections = connections.concat(recursiveLinkUpstream(connectedNode, slot_type, node_type, depth))
}
}
}
}
return connections
}
export function renameNodeInputs(node, name) {
for (let i=0; i < node.inputs.length; i++) {
node.inputs[i].name = `${name}${i + 1}`
}
}
export function removeNodeInputs(node, indexesToRemove) {
indexesToRemove.sort((a, b) => b - a);
for (let i of indexesToRemove) {
if (node.inputs.length <= 2) { console.log("too short"); continue } // if only 2 left
node.removeInput(i)
}
node.onResize(node.size)
}
export function addCanvas(node, app, widget) {
widget.canvas = document.createElement("canvas");
widget.canvas.className = "latent-control-custom-canvas";
widget.parent = node;
document.body.appendChild(widget.canvas);
node.addCustomWidget(widget);
app.canvas.onDrawBackground = function () {
for (let n in app.graph._nodes) {
n = graph._nodes[n];
for (let w in n.widgets) {
let wid = n.widgets[w];
if (Object.hasOwn(wid, "canvas")) {
wid.canvas.style.left = -8000 + "px";
wid.canvas.style.position = "absolute";
}
}
}
};
node.onResize = function (size) {
computeCanvasSize(node, size);
}
}
@@ -1,88 +0,0 @@
import comfy.samplers
from .TransformSampler import TransformSampler
from .Transforms import MirrorTransform, ShiftTransform, MultiplyTransform
MIRROR_DIRECTIONS = ["none", "vertically", "horizontally", "both", "90 degree rotation", "180 degree rotation"]
MODE = ["replace", "combine"]
class TSamplerWithTransform:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
"cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step":0.1, "round": 0.01}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, ),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, ),
"positive": ("CONDITIONING", ),
"negative": ("CONDITIONING", ),
"latent_image": ("LATENT", ),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"start_mirror_at": ("FLOAT", {"default": 0, "min": 0.0, "max": 1.0, "step": 0.01}),
"stop_mirror_at": ("FLOAT", {"default": 0, "min": 0.0, "max": 1.0, "step": 0.01}),
"mirror_mode": (MODE,),
"mirror_direction": (MIRROR_DIRECTIONS, {"default": "none"}),
"start_shift_at": ("FLOAT", {"default": 0, "min": 0.0, "max": 1.0, "step": 0.01}),
"stop_shift_at": ("FLOAT", {"default": 0, "min": 0.0, "max": 1.0, "step": 0.01}),
"shift_mode": (MODE, {"default": "replace"}),
"x_shift": ("FLOAT", {"default": 0, "min": -1, "max": 1, "step": 0.01}),
"y_shift": ("FLOAT", {"default": 0, "min": -1, "max": 1, "step": 0.01}),
"start_multiplier_at": ("FLOAT", {"default": 0, "min": 0.0, "max": 1.0, "step": 0.01}),
"stop_multiplier_at": ("FLOAT", {"default": 0, "min": 0.0, "max": 1.0, "step": 0.01}),
"multiplier_mode": (MODE, {"default": "combine"}),
"multiplier": ("FLOAT", {"default": 1, "min": -10, "max": 10, "step": 0.01}),
}
}
RETURN_TYPES = ("LATENT",)
FUNCTION = "sample"
CATEGORY = "sampling"
def sample(self,
model,
seed,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
latent_image,
denoise=1.0,
start_mirror_at=0,
stop_mirror_at=0,
mirror_mode="replace",
mirror_direction="none",
start_shift_at=0,
stop_shift_at=0,
shift_mode="replace",
x_shift=0,
y_shift=0,
start_multiplier_at=0,
stop_multiplier_at=0,
multiplier_mode="combine",
multiplier=1):
transforms = (
MirrorTransform().process(start_mirror_at, stop_mirror_at, mirror_mode, mirror_direction) +
ShiftTransform().process(start_shift_at, stop_shift_at, shift_mode, x_shift, y_shift) +
MultiplyTransform().process(start_multiplier_at, stop_multiplier_at, multiplier_mode, multiplier))[0]
return TransformSampler().sample(
model,
seed,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
latent_image,
transform_optional=transforms,
denoise=denoise)
+20
View File
@@ -0,0 +1,20 @@
from .transform_apply import attach_transforms
class TransformHijack:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"transforms": ("TRANSFORM",),
},
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "func"
CATEGORY = "sampling/transforms"
def func(self, model, transforms):
return (attach_transforms(model, transforms),)
+20 -75
View File
@@ -1,86 +1,31 @@
import torch
import comfy.samplers
from latent_preview import prepare_callback as preview_callback
from .transform_apply import attach_transforms
from nodes import KSampler, KSamplerAdvanced
def prepare_callback(model, steps, transforms, x0_output_dict=None):
def transform_callback(step, x0, x, total_steps):
for transform in transforms:
for i in range(x0.size()[0]):
x0[i] = transform["function"](step, x0[i].unsqueeze(0), total_steps, transform["params"])
preview = preview_callback(model, steps, x0_output_dict)
def callback(step, x0, x, total_steps):
transform_callback(step, x0, x, total_steps)
preview(step, x0, x, total_steps)
def insert_transform_input(input_types):
input_types["optional"] = {"transform_optional": ("TRANSFORM",)}
return input_types
return callback
class Transforms:
clazz = None
def sample_common(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent, transform, denoise=1.0, disable_noise=False, start_step=None, last_step=None, force_full_denoise=False):
latent_image = latent["samples"]
if disable_noise:
noise = torch.zeros(latent_image.size(), dtype=latent_image.dtype, layout=latent_image.layout, device="cpu")
else:
batch_inds = latent["batch_index"] if "batch_index" in latent else None
noise = comfy.sample.prepare_noise(latent_image, seed, batch_inds)
noise_mask = None
if "noise_mask" in latent:
noise_mask = latent["noise_mask"]
callback = prepare_callback(model, steps, transform)
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
samples = comfy.sample.sample(model, noise, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise, disable_noise=disable_noise, start_step=start_step, last_step=last_step,
force_full_denoise=force_full_denoise, noise_mask=noise_mask, callback=callback,
disable_pbar=disable_pbar, seed=seed)
out = latent.copy()
out["samples"] = samples
return (out,)
class TransformSampler:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
"cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step":0.1, "round": 0.01}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, ),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, ),
"positive": ("CONDITIONING", ),
"negative": ("CONDITIONING", ),
"latent_image": ("LATENT", ),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
},
"optional":{
"transform_optional": ("TRANSFORM",),
}
}
def INPUT_TYPES(cls):
return insert_transform_input(cls.clazz.INPUT_TYPES())
RETURN_TYPES = ("LATENT",)
FUNCTION = "sample"
FUNCTION = "func"
CATEGORY = "sampling"
def func(self, **kwargs):
transforms = kwargs.pop("transform_optional", None)
if transforms:
kwargs["model"] = attach_transforms(kwargs["model"], transforms)
return getattr(self, self.clazz.FUNCTION)(**kwargs)
def sample(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=1.0, transform_optional=None):
if transform_optional is None:
transform_optional = []
def variations_factory(original_class: type, name=None) -> type:
name = name or original_class.__name__ + "Transform"
return type(name, (Transforms, original_class), {'clazz': original_class})
return sample_common(
model,
seed,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
latent_image,
transform=transform_optional,
denoise=denoise)
TSampler = variations_factory(KSampler)
TSamplerAdvanced = variations_factory(KSamplerAdvanced)
@@ -1,4 +1,4 @@
from itertools import chain
class OffsetCombine:
@@ -8,6 +8,7 @@ class OffsetCombine:
"required": {
"offset1": ("OFFSET", ),
"offset2": ("OFFSET", ),
"offset": ("INT", {"default": 0, "min": -10000, "max": 10000}),
}
}
@@ -16,5 +17,10 @@ class OffsetCombine:
CATEGORY = "sampling/transforms"
def combine(self, offset1, offset2):
return (offset1 + offset2,)
def combine(self, offset, **kwargs):
offsets = sum(chain([v for k, v in kwargs.items()]), [])
for o in offsets:
o["offset"] += offset
return (offsets,)
+16 -20
View File
@@ -17,14 +17,14 @@ def shift_transform(x0, params):
if params["mode"] == "replace":
if params["x_shift"] != 0:
x = torch.roll(x, shifts=int(x.size()[2] * params["x_shift"]), dims=[2])
x = torch.roll(x, shifts=int(x.size()[2] * params["x_shift"]), dims=[3])
if params["y_shift"] != 0:
x = torch.roll(x, shifts=int(x.size()[1] * params["y_shift"]), dims=[1])
x = torch.roll(x, shifts=int(x.size()[1] * params["y_shift"]), dims=[2])
elif params["mode"] == "combine":
if params["x_shift"] != 0:
x = (torch.roll(x, shifts=int(x.size()[2] * params["x_shift"]), dims=[2]) + x) / 2
x = (torch.roll(x, shifts=int(x.size()[2] * params["x_shift"]), dims=[3]) + x) / 2
if params["y_shift"] != 0:
x = (torch.roll(x, shifts=int(x.size()[1] * params["y_shift"]), dims=[1]) + x) / 2
x = (torch.roll(x, shifts=int(x.size()[1] * params["y_shift"]), dims=[2]) + x) / 2
return x
@@ -34,26 +34,26 @@ def mirror_transform(x0, params):
if params["mode"] == "replace":
if params["direction"] == "vertically":
x = torch.flip(x, [1])
elif params["direction"] == "horizontally":
x = torch.flip(x, [2])
elif params["direction"] == "horizontally":
x = torch.flip(x, [3])
elif params["direction"] == "both":
x = torch.flip(x, [1, 2])
x = torch.flip(x, [2, 3])
elif params["direction"] == "90 degree rotation":
x = torch.rot90(x, dims=[1, 2])
x = torch.rot90(x, dims=[2, 3])
elif params["direction"] == "180 degree rotation":
x = torch.rot90(torch.rot90(x, dims=[1, 2]), dims=[1, 2])
x = torch.rot90(torch.rot90(x, dims=[2, 3]), dims=[2, 3])
elif params["mode"] == "combine":
if params["direction"] == "vertically":
x = (torch.flip(x, [1]) + x) / 2
elif params["direction"] == "horizontally":
x = (torch.flip(x, [2]) + x) / 2
elif params["direction"] == "horizontally":
x = (torch.flip(x, [3]) + x) / 2
elif params["direction"] == "both":
x = (torch.flip(x, [1, 2]) + x) / 2
x = (torch.flip(x, [2, 3]) + x) / 2
elif params["direction"] == "90 degree rotation":
x = (torch.rot90(x, dims=[1, 2]) + x) / 2
x = (torch.rot90(x, dims=[2, 3]) + x) / 2
elif params["direction"] == "180 degree rotation":
x = (torch.rot90(torch.rot90(x, dims=[1, 2]), dims=[1, 2]) + x) / 2
x = (torch.rot90(torch.rot90(x, dims=[2, 3]), dims=[2, 3]) + x) / 2
return x
@@ -62,11 +62,9 @@ def latent_interpolate_transform(x0, params):
latent = params["latent"].to(x0.device)
if x0.shape != latent.shape:
latent.permute(0, 3, 1, 2)
latent = comfy.utils.common_upscale(latent, x0.shape[3], x0.shape[2], 'bicubic', crop='center')
latent.permute(0, 2, 3, 1)
x = x0 * params["factor"] + latent * (1 - params["factor"])
x = latent * params["factor"] + x0 * (1 - params["factor"])
x *= params["multiplier"]
return x
@@ -76,9 +74,7 @@ def latent_add_transform(x0, params):
latent = params["latent"].to(x0.device)
if x0.shape != latent.shape:
latent.permute(0, 3, 1, 2)
latent = comfy.utils.common_upscale(latent, x0.shape[3], x0.shape[2], 'bicubic', crop='center')
latent.permute(0, 2, 3, 1)
latent = comfy.utils.common_upscale(latent, x0.shape[3], x0.shape[2], 'bicubic', crop='center')
x = x0 + latent
x *= params["multiplier"]
+51
View File
@@ -0,0 +1,51 @@
import torch
def apply_transforms_to_x0(x0, step, total_steps, transforms):
x = x0.clone()
for transform in transforms:
for i in range(x.size()[0]):
x[i] = transform["function"](step, x[i].unsqueeze(0), total_steps, transform["params"])
return x
def _find_step(sigma, sigmas):
# sigma is a scalar tensor for the current model evaluation, sigmas is the full
# schedule. High order samplers evaluate the model at intermediate sigmas that are
# not part of the schedule; for those we return None so the transform is applied
# exactly once per step (parity with the old per-step callback).
diff = torch.abs(sigmas - sigma.to(sigmas.device))
idx = int(torch.argmin(diff).item())
if diff[idx] <= 1e-4 * max(1.0, float(sigmas[idx].abs())):
return idx
return None
def make_post_cfg_function(transforms):
def post_cfg_function(args):
denoised = args["denoised"]
if not transforms:
return denoised
sigmas = args["model_options"].get("transformer_options", {}).get("sample_sigmas", None)
if sigmas is None:
return denoised
step = _find_step(args["sigma"], sigmas)
if step is None:
return denoised
total_steps = len(sigmas) - 1
return apply_transforms_to_x0(denoised, step, total_steps, transforms)
return post_cfg_function
def attach_transforms(model, transforms):
m = model.clone()
m.set_model_sampler_post_cfg_function(make_post_cfg_function(transforms))
return m
+9 -1
View File
@@ -1,4 +1,5 @@
import torch
from nodes import PreviewImage
MIRROR_DIRECTIONS = ["vertically", "horizontally", "both"]
@@ -15,6 +16,9 @@ class LatentMirror:
"max": 10.0,
"step": 0.01
})
},
"optional": {
"vae_optional": ("VAE",)
}
}
@@ -23,7 +27,7 @@ class LatentMirror:
CATEGORY = "latent/advanced"
def mirror(self, latent, direction, multiplier):
def mirror(self, latent, direction, multiplier, vae_optional = None):
l = latent.copy()
if direction == "vertically" or direction == "both":
l["samples"] = torch.flip(l["samples"], dims=[2]) + l["samples"]
@@ -31,4 +35,8 @@ class LatentMirror:
l["samples"] = torch.flip(l["samples"], dims=[3]) + l["samples"]
l["samples"] *= multiplier
if vae_optional:
return {"result": (l,), "ui": PreviewImage().save_images(vae_optional.decode(l["samples"]))["ui"]}
return (l,)
+9 -1
View File
@@ -1,4 +1,5 @@
import torch
from nodes import PreviewImage
class LatentShift:
@@ -19,6 +20,9 @@ class LatentShift:
"max": 1,
"step": 0.01
}),
},
"optional": {
"vae_optional": ("VAE",)
}
}
@@ -27,7 +31,7 @@ class LatentShift:
CATEGORY = "latent/advanced"
def shift(self, latent, x_shift, y_shift):
def shift(self, latent, x_shift, y_shift, vae_optional = None):
l = latent.copy()
if x_shift != 0:
@@ -35,4 +39,8 @@ class LatentShift:
if y_shift != 0:
l["samples"] = torch.roll(l["samples"], shifts=int(l["samples"].size()[2] * y_shift), dims=[2])
if vae_optional:
return {"result": (l,), "ui": PreviewImage().save_images(vae_optional.decode(l["samples"]))["ui"]}
return (l,)
+3 -2
View File
@@ -1,8 +1,9 @@
from .LatentMirror import LatentMirror
from .LatentShift import LatentShift
from .LatentNormalize import LatentNormalize
from .KSamplerNodes.TSamplerWithTransform import TSamplerWithTransform
from .KSamplerNodes.TransformSampler import TransformSampler
from .KSamplerNodes.TransformSampler import TSampler
from .KSamplerNodes.TransformSampler import TSamplerAdvanced
from .KSamplerNodes.TransformHijack import TransformHijack
from .KSamplerNodes.Transforms import MirrorTransform
from .KSamplerNodes.Transforms import MultiplyTransform
from .KSamplerNodes.Transforms import ShiftTransform
+14
View File
@@ -0,0 +1,14 @@
[project]
name = "comfyui-advanced-latent-control"
description = "This custom node helps to transform latent in different ways."
version = "3.0.1"
license = "LICENSE"
[project.urls]
Repository = "https://github.com/RomanKuschanow/ComfyUI-Advanced-Latent-Control"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
PublisherId = "kuschanow"
DisplayName = "ComfyUI-Advanced-Latent-Control"
Icon = ""