Spaces:
Paused
Paused
| 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; | |
| }; | |
| } | |
| // Helper function to convert a data URL to a Blob object | |
| 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; | |
| // If it is specified as relative, using it only as a hidden placeholder for padding is recommended | |
| // to prevent anomalies where it exceeds a certain size and goes outside of the window. | |
| 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); // must below z-index than bottom_panel to avoid covering button | |
| 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) { | |
| // layout | |
| 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); | |
| // prepare content | |
| this.imgCanvas = imgCanvas; | |
| this.maskCanvas = maskCanvas; | |
| this.pointsCanvas = pointsCanvas; | |
| this.maskCtx = maskCanvas.getContext('2d'); | |
| this.pointsCtx = pointsCanvas.getContext('2d'); | |
| this.is_layout_created = true; | |
| // replacement of onClose hook since close is not real close | |
| 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; // NOTE: alert dialog must be high priority. | |
| } | |
| 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; | |
| // image load | |
| const orig_image = new Image(); | |
| window.addEventListener("resize", () => { | |
| // repositioning | |
| imgCanvas.width = window.innerWidth - 250; | |
| imgCanvas.height = window.innerHeight - 200; | |
| // redraw image | |
| 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); | |
| // update mask | |
| 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); | |
| }); | |
| // original image load | |
| 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 === '=') { // positive | |
| brush.style.backgroundColor = "blue"; | |
| brush.style.outline = "2px solid pink"; | |
| self.is_positive_mode = true; | |
| } else if (event.key === '-') { // negative | |
| 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) { | |
| // positive | |
| point = [true, originalX, originalY]; | |
| } else { | |
| // negative | |
| 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); | |
| // refine mask image | |
| 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 (Array.isArray(nodeData.output) && (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(); | |
| }, | |
| }); | |
| }); | |
| } | |
| } | |
| }); | |