| import { app } from "../../scripts/app.js";
|
| import { api } from "../../scripts/api.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 api.fetchApi('/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
|
| };
|
|
|
| api.fetchApi('/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 api.fetchApi('/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 (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();
|
| },
|
| });
|
| });
|
| }
|
| }
|
| });
|
|
|
|
|