|
import { app } from "../../scripts/app.js"; |
|
import { ComfyDialog, $el } from "../../scripts/ui.js"; |
|
import { ComfyApp } from "../../scripts/app.js"; |
|
import { ClipspaceDialog } from "../../extensions/core/clipspace.js"; |
|
|
|
function addMenuHandler(nodeType, cb) { |
|
const getOpts = nodeType.prototype.getExtraMenuOptions; |
|
nodeType.prototype.getExtraMenuOptions = function () { |
|
const r = getOpts.apply(this, arguments); |
|
cb.apply(this, arguments); |
|
return r; |
|
}; |
|
} |
|
|
|
|
|
function dataURLToBlob(dataURL) { |
|
const parts = dataURL.split(';base64,'); |
|
const contentType = parts[0].split(':')[1]; |
|
const byteString = atob(parts[1]); |
|
const arrayBuffer = new ArrayBuffer(byteString.length); |
|
const uint8Array = new Uint8Array(arrayBuffer); |
|
for (let i = 0; i < byteString.length; i++) { |
|
uint8Array[i] = byteString.charCodeAt(i); |
|
} |
|
return new Blob([arrayBuffer], { type: contentType }); |
|
} |
|
|
|
function loadedImageToBlob(image) { |
|
const canvas = document.createElement('canvas'); |
|
|
|
canvas.width = image.width; |
|
canvas.height = image.height; |
|
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
ctx.drawImage(image, 0, 0); |
|
|
|
const dataURL = canvas.toDataURL('image/png', 1); |
|
const blob = dataURLToBlob(dataURL); |
|
|
|
return blob; |
|
} |
|
|
|
async function uploadMask(filepath, formData) { |
|
await fetch('/upload/mask', { |
|
method: 'POST', |
|
body: formData |
|
}).then(response => {}).catch(error => { |
|
console.error('Error:', error); |
|
}); |
|
|
|
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']] = new Image(); |
|
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src = `view?filename=${filepath.filename}&type=${filepath.type}`; |
|
|
|
if(ComfyApp.clipspace.images) |
|
ComfyApp.clipspace.images[ComfyApp.clipspace['selectedIndex']] = filepath; |
|
|
|
ClipspaceDialog.invalidatePreview(); |
|
} |
|
|
|
class ImpactSamEditorDialog extends ComfyDialog { |
|
static instance = null; |
|
|
|
static getInstance() { |
|
if(!ImpactSamEditorDialog.instance) { |
|
ImpactSamEditorDialog.instance = new ImpactSamEditorDialog(); |
|
} |
|
|
|
return ImpactSamEditorDialog.instance; |
|
} |
|
|
|
constructor() { |
|
super(); |
|
this.element = $el("div.comfy-modal", { parent: document.body }, |
|
[ $el("div.comfy-modal-content", |
|
[...this.createButtons()]), |
|
]); |
|
} |
|
|
|
createButtons() { |
|
return []; |
|
} |
|
|
|
createButton(name, callback) { |
|
var button = document.createElement("button"); |
|
button.innerText = name; |
|
button.addEventListener("click", callback); |
|
return button; |
|
} |
|
|
|
createLeftButton(name, callback) { |
|
var button = this.createButton(name, callback); |
|
button.style.cssFloat = "left"; |
|
button.style.marginRight = "4px"; |
|
return button; |
|
} |
|
|
|
createRightButton(name, callback) { |
|
var button = this.createButton(name, callback); |
|
button.style.cssFloat = "right"; |
|
button.style.marginLeft = "4px"; |
|
return button; |
|
} |
|
|
|
createLeftSlider(self, name, callback) { |
|
const divElement = document.createElement('div'); |
|
divElement.id = "sam-confidence-slider"; |
|
divElement.style.cssFloat = "left"; |
|
divElement.style.fontFamily = "sans-serif"; |
|
divElement.style.marginRight = "4px"; |
|
divElement.style.color = "var(--input-text)"; |
|
divElement.style.backgroundColor = "var(--comfy-input-bg)"; |
|
divElement.style.borderRadius = "8px"; |
|
divElement.style.borderColor = "var(--border-color)"; |
|
divElement.style.borderStyle = "solid"; |
|
divElement.style.fontSize = "15px"; |
|
divElement.style.height = "21px"; |
|
divElement.style.padding = "1px 6px"; |
|
divElement.style.display = "flex"; |
|
divElement.style.position = "relative"; |
|
divElement.style.top = "2px"; |
|
self.confidence_slider_input = document.createElement('input'); |
|
self.confidence_slider_input.setAttribute('type', 'range'); |
|
self.confidence_slider_input.setAttribute('min', '0'); |
|
self.confidence_slider_input.setAttribute('max', '100'); |
|
self.confidence_slider_input.setAttribute('value', '70'); |
|
const labelElement = document.createElement("label"); |
|
labelElement.textContent = name; |
|
|
|
divElement.appendChild(labelElement); |
|
divElement.appendChild(self.confidence_slider_input); |
|
|
|
self.confidence_slider_input.addEventListener("change", callback); |
|
|
|
return divElement; |
|
} |
|
|
|
async detect_and_invalidate_mask_canvas(self) { |
|
const mask_img = await self.detect(self); |
|
|
|
const canvas = self.maskCtx.canvas; |
|
const ctx = self.maskCtx; |
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height); |
|
|
|
await new Promise((resolve, reject) => { |
|
self.mask_image = new Image(); |
|
self.mask_image.onload = function() { |
|
ctx.drawImage(self.mask_image, 0, 0, canvas.width, canvas.height); |
|
resolve(); |
|
}; |
|
self.mask_image.onerror = reject; |
|
self.mask_image.src = mask_img.src; |
|
}); |
|
} |
|
|
|
setlayout(imgCanvas, maskCanvas, pointsCanvas) { |
|
const self = this; |
|
|
|
|
|
|
|
var placeholder = document.createElement("div"); |
|
placeholder.style.position = "relative"; |
|
placeholder.style.height = "50px"; |
|
|
|
var bottom_panel = document.createElement("div"); |
|
bottom_panel.style.position = "absolute"; |
|
bottom_panel.style.bottom = "0px"; |
|
bottom_panel.style.left = "20px"; |
|
bottom_panel.style.right = "20px"; |
|
bottom_panel.style.height = "50px"; |
|
|
|
var brush = document.createElement("div"); |
|
brush.id = "sam-brush"; |
|
brush.style.backgroundColor = "blue"; |
|
brush.style.outline = "2px solid pink"; |
|
brush.style.borderRadius = "50%"; |
|
brush.style.MozBorderRadius = "50%"; |
|
brush.style.WebkitBorderRadius = "50%"; |
|
brush.style.position = "absolute"; |
|
brush.style.zIndex = 100; |
|
brush.style.pointerEvents = "none"; |
|
this.brush = brush; |
|
this.element.appendChild(imgCanvas); |
|
this.element.appendChild(maskCanvas); |
|
this.element.appendChild(pointsCanvas); |
|
this.element.appendChild(placeholder); |
|
this.element.appendChild(bottom_panel); |
|
document.body.appendChild(brush); |
|
this.brush_size = 5; |
|
|
|
var confidence_slider = this.createLeftSlider(self, "Confidence", (event) => { |
|
self.confidence = event.target.value; |
|
}); |
|
|
|
var clearButton = this.createLeftButton("Clear", () => { |
|
self.maskCtx.clearRect(0, 0, self.maskCanvas.width, self.maskCanvas.height); |
|
self.pointsCtx.clearRect(0, 0, self.pointsCanvas.width, self.pointsCanvas.height); |
|
|
|
self.prompt_points = []; |
|
|
|
self.invalidatePointsCanvas(self); |
|
}); |
|
|
|
var detectButton = this.createLeftButton("Detect", () => self.detect_and_invalidate_mask_canvas(self)); |
|
|
|
var cancelButton = this.createRightButton("Cancel", () => { |
|
document.removeEventListener("mouseup", ImpactSamEditorDialog.handleMouseUp); |
|
document.removeEventListener("keydown", ImpactSamEditorDialog.handleKeyDown); |
|
self.close(); |
|
}); |
|
|
|
self.saveButton = this.createRightButton("Save", () => { |
|
document.removeEventListener("mouseup", ImpactSamEditorDialog.handleMouseUp); |
|
document.removeEventListener("keydown", ImpactSamEditorDialog.handleKeyDown); |
|
self.save(self); |
|
}); |
|
|
|
var undoButton = this.createLeftButton("Undo", () => { |
|
if(self.prompt_points.length > 0) { |
|
self.prompt_points.pop(); |
|
self.pointsCtx.clearRect(0, 0, self.pointsCanvas.width, self.pointsCanvas.height); |
|
self.invalidatePointsCanvas(self); |
|
} |
|
}); |
|
|
|
bottom_panel.appendChild(clearButton); |
|
bottom_panel.appendChild(detectButton); |
|
bottom_panel.appendChild(self.saveButton); |
|
bottom_panel.appendChild(cancelButton); |
|
bottom_panel.appendChild(confidence_slider); |
|
bottom_panel.appendChild(undoButton); |
|
|
|
imgCanvas.style.position = "relative"; |
|
imgCanvas.style.top = "200"; |
|
imgCanvas.style.left = "0"; |
|
|
|
maskCanvas.style.position = "absolute"; |
|
maskCanvas.style.opacity = 0.5; |
|
pointsCanvas.style.position = "absolute"; |
|
} |
|
|
|
show() { |
|
this.mask_image = null; |
|
self.prompt_points = []; |
|
|
|
this.message_box = $el("p", ["Please wait a moment while the SAM model and the image are being loaded."]); |
|
this.element.appendChild(this.message_box); |
|
|
|
if(self.imgCtx) { |
|
self.imgCtx.clearRect(0, 0, self.imageCanvas.width, self.imageCanvas.height); |
|
} |
|
|
|
const target_image_path = ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src; |
|
this.load_sam(target_image_path); |
|
|
|
if(!this.is_layout_created) { |
|
|
|
const imgCanvas = document.createElement('canvas'); |
|
const maskCanvas = document.createElement('canvas'); |
|
const pointsCanvas = document.createElement('canvas'); |
|
|
|
imgCanvas.id = "imageCanvas"; |
|
maskCanvas.id = "maskCanvas"; |
|
pointsCanvas.id = "pointsCanvas"; |
|
|
|
this.setlayout(imgCanvas, maskCanvas, pointsCanvas); |
|
|
|
|
|
this.imgCanvas = imgCanvas; |
|
this.maskCanvas = maskCanvas; |
|
this.pointsCanvas = pointsCanvas; |
|
this.maskCtx = maskCanvas.getContext('2d'); |
|
this.pointsCtx = pointsCanvas.getContext('2d'); |
|
|
|
this.is_layout_created = true; |
|
|
|
|
|
const self = this; |
|
const observer = new MutationObserver(function(mutations) { |
|
mutations.forEach(function(mutation) { |
|
if (mutation.type === 'attributes' && mutation.attributeName === 'style') { |
|
if(self.last_display_style && self.last_display_style != 'none' && self.element.style.display == 'none') { |
|
ComfyApp.onClipspaceEditorClosed(); |
|
} |
|
|
|
self.last_display_style = self.element.style.display; |
|
} |
|
}); |
|
}); |
|
|
|
const config = { attributes: true }; |
|
observer.observe(this.element, config); |
|
} |
|
|
|
this.setImages(target_image_path, this.imgCanvas, this.pointsCanvas); |
|
|
|
if(ComfyApp.clipspace_return_node) { |
|
this.saveButton.innerText = "Save to node"; |
|
} |
|
else { |
|
this.saveButton.innerText = "Save"; |
|
} |
|
this.saveButton.disabled = true; |
|
|
|
this.element.style.display = "block"; |
|
this.element.style.zIndex = 8888; |
|
} |
|
|
|
updateBrushPreview(self, event) { |
|
event.preventDefault(); |
|
|
|
const centerX = event.pageX; |
|
const centerY = event.pageY; |
|
|
|
const brush = self.brush; |
|
|
|
brush.style.width = self.brush_size * 2 + "px"; |
|
brush.style.height = self.brush_size * 2 + "px"; |
|
brush.style.left = (centerX - self.brush_size) + "px"; |
|
brush.style.top = (centerY - self.brush_size) + "px"; |
|
} |
|
|
|
setImages(target_image_path, imgCanvas, pointsCanvas) { |
|
const imgCtx = imgCanvas.getContext('2d'); |
|
const maskCtx = this.maskCtx; |
|
const maskCanvas = this.maskCanvas; |
|
|
|
const self = this; |
|
|
|
|
|
const orig_image = new Image(); |
|
window.addEventListener("resize", () => { |
|
|
|
imgCanvas.width = window.innerWidth - 250; |
|
imgCanvas.height = window.innerHeight - 200; |
|
|
|
|
|
let drawWidth = orig_image.width; |
|
let drawHeight = orig_image.height; |
|
|
|
if (orig_image.width > imgCanvas.width) { |
|
drawWidth = imgCanvas.width; |
|
drawHeight = (drawWidth / orig_image.width) * orig_image.height; |
|
} |
|
|
|
if (drawHeight > imgCanvas.height) { |
|
drawHeight = imgCanvas.height; |
|
drawWidth = (drawHeight / orig_image.height) * orig_image.width; |
|
} |
|
|
|
imgCtx.drawImage(orig_image, 0, 0, drawWidth, drawHeight); |
|
|
|
|
|
pointsCanvas.width = drawWidth; |
|
pointsCanvas.height = drawHeight; |
|
pointsCanvas.style.top = imgCanvas.offsetTop + "px"; |
|
pointsCanvas.style.left = imgCanvas.offsetLeft + "px"; |
|
|
|
maskCanvas.width = drawWidth; |
|
maskCanvas.height = drawHeight; |
|
maskCanvas.style.top = imgCanvas.offsetTop + "px"; |
|
maskCanvas.style.left = imgCanvas.offsetLeft + "px"; |
|
|
|
self.invalidateMaskCanvas(self); |
|
self.invalidatePointsCanvas(self); |
|
}); |
|
|
|
|
|
orig_image.onload = () => self.onLoaded(self); |
|
const rgb_url = new URL(target_image_path); |
|
rgb_url.searchParams.delete('channel'); |
|
rgb_url.searchParams.set('channel', 'rgb'); |
|
orig_image.src = rgb_url; |
|
self.image = orig_image; |
|
} |
|
|
|
onLoaded(self) { |
|
if(self.message_box) { |
|
self.element.removeChild(self.message_box); |
|
self.message_box = null; |
|
} |
|
|
|
window.dispatchEvent(new Event('resize')); |
|
|
|
self.setEventHandler(pointsCanvas); |
|
self.saveButton.disabled = false; |
|
} |
|
|
|
setEventHandler(targetCanvas) { |
|
targetCanvas.addEventListener("contextmenu", (event) => { |
|
event.preventDefault(); |
|
}); |
|
|
|
const self = this; |
|
targetCanvas.addEventListener('pointermove', (event) => this.updateBrushPreview(self,event)); |
|
targetCanvas.addEventListener('pointerdown', (event) => this.handlePointerDown(self,event)); |
|
targetCanvas.addEventListener('pointerover', (event) => { this.brush.style.display = "block"; }); |
|
targetCanvas.addEventListener('pointerleave', (event) => { this.brush.style.display = "none"; }); |
|
document.addEventListener('keydown', ImpactSamEditorDialog.handleKeyDown); |
|
} |
|
|
|
static handleKeyDown(event) { |
|
const self = ImpactSamEditorDialog.instance; |
|
if (event.key === '=') { |
|
brush.style.backgroundColor = "blue"; |
|
brush.style.outline = "2px solid pink"; |
|
self.is_positive_mode = true; |
|
} else if (event.key === '-') { |
|
brush.style.backgroundColor = "red"; |
|
brush.style.outline = "2px solid skyblue"; |
|
self.is_positive_mode = false; |
|
} |
|
} |
|
|
|
is_positive_mode = true; |
|
prompt_points = []; |
|
confidence = 70; |
|
|
|
invalidatePointsCanvas(self) { |
|
const ctx = self.pointsCtx; |
|
|
|
for (const i in self.prompt_points) { |
|
const [is_positive, x, y] = self.prompt_points[i]; |
|
|
|
const scaledX = x * ctx.canvas.width / self.image.width; |
|
const scaledY = y * ctx.canvas.height / self.image.height; |
|
|
|
if(is_positive) |
|
ctx.fillStyle = "blue"; |
|
else |
|
ctx.fillStyle = "red"; |
|
ctx.beginPath(); |
|
ctx.arc(scaledX, scaledY, 3, 0, 3 * Math.PI); |
|
ctx.fill(); |
|
} |
|
}줘 |
|
|
|
invalidateMaskCanvas(self) { |
|
if(self.mask_image) { |
|
self.maskCtx.clearRect(0, 0, self.maskCanvas.width, self.maskCanvas.height); |
|
self.maskCtx.drawImage(self.mask_image, 0, 0, self.maskCanvas.width, self.maskCanvas.height); |
|
} |
|
} |
|
|
|
async load_sam(url) { |
|
const parsedUrl = new URL(url); |
|
const searchParams = new URLSearchParams(parsedUrl.search); |
|
|
|
const filename = searchParams.get("filename") || ""; |
|
const fileType = searchParams.get("type") || ""; |
|
const subfolder = searchParams.get("subfolder") || ""; |
|
|
|
const data = { |
|
sam_model_name: "auto", |
|
filename: filename, |
|
type: fileType, |
|
subfolder: subfolder |
|
}; |
|
|
|
fetch('/sam/prepare', { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify(data) |
|
}); |
|
} |
|
|
|
async detect(self) { |
|
const positive_points = []; |
|
const negative_points = []; |
|
|
|
for(const i in self.prompt_points) { |
|
const [is_positive, x, y] = self.prompt_points[i]; |
|
const point = [x,y]; |
|
if(is_positive) |
|
positive_points.push(point); |
|
else |
|
negative_points.push(point); |
|
} |
|
|
|
const data = { |
|
positive_points: positive_points, |
|
negative_points: negative_points, |
|
threshold: self.confidence/100 |
|
}; |
|
|
|
const response = await fetch('/sam/detect', { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'image/png' }, |
|
body: JSON.stringify(data) |
|
}); |
|
|
|
const blob = await response.blob(); |
|
const url = URL.createObjectURL(blob); |
|
|
|
return new Promise((resolve, reject) => { |
|
const image = new Image(); |
|
image.onload = () => resolve(image); |
|
image.onerror = reject; |
|
image.src = url; |
|
}); |
|
} |
|
|
|
handlePointerDown(self, event) { |
|
if ([0, 2, 5].includes(event.button)) { |
|
event.preventDefault(); |
|
const x = event.offsetX || event.targetTouches[0].clientX - maskRect.left; |
|
const y = event.offsetY || event.targetTouches[0].clientY - maskRect.top; |
|
|
|
const originalX = x * self.image.width / self.pointsCanvas.width; |
|
const originalY = y * self.image.height / self.pointsCanvas.height; |
|
|
|
var point = null; |
|
if (event.button == 0) { |
|
|
|
point = [true, originalX, originalY]; |
|
} else { |
|
|
|
point = [false, originalX, originalY]; |
|
} |
|
|
|
self.prompt_points.push(point); |
|
|
|
self.invalidatePointsCanvas(self); |
|
} |
|
} |
|
|
|
async save(self) { |
|
if(!self.mask_image) { |
|
this.close(); |
|
return; |
|
} |
|
|
|
const save_canvas = document.createElement('canvas'); |
|
|
|
const save_ctx = save_canvas.getContext('2d', {willReadFrequently:true}); |
|
save_canvas.width = self.mask_image.width; |
|
save_canvas.height = self.mask_image.height; |
|
|
|
save_ctx.drawImage(self.mask_image, 0, 0, save_canvas.width, save_canvas.height); |
|
|
|
const save_data = save_ctx.getImageData(0, 0, save_canvas.width, save_canvas.height); |
|
|
|
|
|
for (let i = 0; i < save_data.data.length; i += 4) { |
|
if(save_data.data[i]) { |
|
save_data.data[i+3] = 0; |
|
} |
|
else { |
|
save_data.data[i+3] = 255; |
|
} |
|
|
|
save_data.data[i] = 0; |
|
save_data.data[i+1] = 0; |
|
save_data.data[i+2] = 0; |
|
} |
|
|
|
save_ctx.globalCompositeOperation = 'source-over'; |
|
save_ctx.putImageData(save_data, 0, 0); |
|
|
|
const formData = new FormData(); |
|
const filename = "clipspace-mask-" + performance.now() + ".png"; |
|
|
|
const item = |
|
{ |
|
"filename": filename, |
|
"subfolder": "", |
|
"type": "temp", |
|
}; |
|
|
|
if(ComfyApp.clipspace.images) |
|
ComfyApp.clipspace.images[0] = item; |
|
|
|
if(ComfyApp.clipspace.widgets) { |
|
const index = ComfyApp.clipspace.widgets.findIndex(obj => obj.name === 'image'); |
|
|
|
if(index >= 0) |
|
ComfyApp.clipspace.widgets[index].value = `${filename} [temp]`; |
|
} |
|
|
|
const dataURL = save_canvas.toDataURL(); |
|
const blob = dataURLToBlob(dataURL); |
|
|
|
let original_url = new URL(this.image.src); |
|
|
|
const original_ref = { filename: original_url.searchParams.get('filename') }; |
|
|
|
let original_subfolder = original_url.searchParams.get("subfolder"); |
|
if(original_subfolder) |
|
original_ref.subfolder = original_subfolder; |
|
|
|
let original_type = original_url.searchParams.get("type"); |
|
if(original_type) |
|
original_ref.type = original_type; |
|
|
|
formData.append('image', blob, filename); |
|
formData.append('original_ref', JSON.stringify(original_ref)); |
|
formData.append('type', "temp"); |
|
|
|
await uploadMask(item, formData); |
|
ComfyApp.onClipspaceEditorSave(); |
|
this.close(); |
|
} |
|
} |
|
|
|
app.registerExtension({ |
|
name: "Comfy.Impact.SAMEditor", |
|
init(app) { |
|
const callback = |
|
function () { |
|
let dlg = ImpactSamEditorDialog.getInstance(); |
|
dlg.show(); |
|
}; |
|
|
|
const context_predicate = () => ComfyApp.clipspace && ComfyApp.clipspace.imgs && ComfyApp.clipspace.imgs.length > 0 |
|
ClipspaceDialog.registerButton("Impact SAM Detector", context_predicate, callback); |
|
}, |
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData, app) { |
|
if (nodeData.output.includes("MASK") && nodeData.output.includes("IMAGE")) { |
|
addMenuHandler(nodeType, function (_, options) { |
|
options.unshift({ |
|
content: "Open in SAM Detector", |
|
callback: () => { |
|
ComfyApp.copyToClipspace(this); |
|
ComfyApp.clipspace_return_node = this; |
|
|
|
let dlg = ImpactSamEditorDialog.getInstance(); |
|
dlg.show(); |
|
}, |
|
}); |
|
}); |
|
} |
|
} |
|
}); |
|
|
|
|