Browse Source
* Add clipspace feature. * feat: copy content to clipspace * feat: paste content from clipspace Extend validation to allow for validating annotated_path in addition to other parameters. Add support for annotated_filepath in folder_paths function. Generalize the '/upload/image' API to allow for uploading images to the 'input', 'temp', or 'output' directories. * rename contentClipboard -> clipspace * Do deep copy for imgs on copy to clipspace. * mask painting on clipspace * add original_imgs into clipspace * Preserve the original image when 'imgs' are modified * robust patch & refactoring folder_paths about annotated_filepath * wip * Only show the Paste menu if the ComfyApp.clipspace is not empty * clipspace feature added maskeditor feature added * instant refresh on paste force triggering 'changed' on paste action * enhance mask painting smooth drawing add brush_size +/- button * robust patch use mouseup event * robust patch again... * subfolder fix on paste logic attach subfolder if subfolder isn't empty * event listener patch add ], [ key event for brush size remove listener on close * Fix button positioning issue related to window height. Change brush size from button to slider. * clean commit * clean code * various bug fixes * paste action - prevent opening upload popup - ensure rendering after widget_value update * view api update - support annotated_filepath * maskeditor layout - prevent covering button by hidden div * remove dbg message * Add cursor functionality to display brush size * refactor: Replace brush preview feature with missionfloyd implementation * missionfloyd implementation * hiding brush preview off the canvas * change brush size on wheel event * keyup -> keydown event * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * Add support for channel-specific image data retrieval in /view API to fix alpha mask loading issue When loading an image with an alpha mask in JavaScript canvas, there is an issue where the alpha and RGB channels are premultiplied. To avoid reliance on JavaScript canvas, I added support for channel-specific image data retrieval in the "/view" API. This allows us to retrieve data for each channel separately and fix the alpha mask loading issue. The changes have been committed to the repository. * Enable brush preview for key and slider events * optimize * preview fix * robust patch * fix copy (clipspace) action imgs[0] copy -> whole imgs copy * support batch images on clipspace, maskeditor * copy/paste bug fixes for batch images enhance selector preview on clipspace menu add img_paste_mode option into clipspace menu * crash fix * print message if clipspace content cannot editable * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * make default img_paste_mode to 'selected' refactor space -> tab * save clipspace files to input/clipspace instead of temp * show "clipspace/filename.png" instead of 'filename.png [clipspace]' in LoadImage/LoadImageMask * refresh fix related to FILE_COMBO * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * adjust margin based on missionfloyd impelements * mouse event -> pointer event * pen, touch, mouse drawing patched and tested * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * add comment about touch event. --------- Co-authored-by: Lt.Dr.Data <lt.dr.data@gmail.com> Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com>pull/638/head
Dr.Lt.Data
2 years ago
committed by
GitHub
8 changed files with 976 additions and 47 deletions
@ -0,0 +1,166 @@ |
|||||||
|
import { app } from "/scripts/app.js"; |
||||||
|
import { ComfyDialog, $el } from "/scripts/ui.js"; |
||||||
|
import { ComfyApp } from "/scripts/app.js"; |
||||||
|
|
||||||
|
export class ClipspaceDialog extends ComfyDialog { |
||||||
|
static items = []; |
||||||
|
static instance = null; |
||||||
|
|
||||||
|
static registerButton(name, contextPredicate, callback) { |
||||||
|
const item = |
||||||
|
$el("button", { |
||||||
|
type: "button", |
||||||
|
textContent: name, |
||||||
|
contextPredicate: contextPredicate, |
||||||
|
onclick: callback |
||||||
|
}) |
||||||
|
|
||||||
|
ClipspaceDialog.items.push(item); |
||||||
|
} |
||||||
|
|
||||||
|
static invalidatePreview() { |
||||||
|
if(ComfyApp.clipspace && ComfyApp.clipspace.imgs && ComfyApp.clipspace.imgs.length > 0) { |
||||||
|
const img_preview = document.getElementById("clipspace_preview"); |
||||||
|
if(img_preview) { |
||||||
|
img_preview.src = ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src; |
||||||
|
img_preview.style.maxHeight = "100%"; |
||||||
|
img_preview.style.maxWidth = "100%"; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static invalidate() { |
||||||
|
if(ClipspaceDialog.instance) { |
||||||
|
const self = ClipspaceDialog.instance; |
||||||
|
// allow reconstruct controls when copying from non-image to image content.
|
||||||
|
const children = $el("div.comfy-modal-content", [ self.createImgSettings(), ...self.createButtons() ]); |
||||||
|
|
||||||
|
if(self.element) { |
||||||
|
// update
|
||||||
|
self.element.removeChild(self.element.firstChild); |
||||||
|
self.element.appendChild(children); |
||||||
|
} |
||||||
|
else { |
||||||
|
// new
|
||||||
|
self.element = $el("div.comfy-modal", { parent: document.body }, [children,]); |
||||||
|
} |
||||||
|
|
||||||
|
if(self.element.children[0].children.length <= 1) { |
||||||
|
self.element.children[0].appendChild($el("p", {}, ["Unable to find the features to edit content of a format stored in the current Clipspace."])); |
||||||
|
} |
||||||
|
|
||||||
|
ClipspaceDialog.invalidatePreview(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
constructor() { |
||||||
|
super(); |
||||||
|
} |
||||||
|
|
||||||
|
createButtons(self) { |
||||||
|
const buttons = []; |
||||||
|
|
||||||
|
for(let idx in ClipspaceDialog.items) { |
||||||
|
const item = ClipspaceDialog.items[idx]; |
||||||
|
if(!item.contextPredicate || item.contextPredicate()) |
||||||
|
buttons.push(ClipspaceDialog.items[idx]); |
||||||
|
} |
||||||
|
|
||||||
|
buttons.push( |
||||||
|
$el("button", { |
||||||
|
type: "button", |
||||||
|
textContent: "Close", |
||||||
|
onclick: () => { this.close(); } |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
return buttons; |
||||||
|
} |
||||||
|
|
||||||
|
createImgSettings() { |
||||||
|
if(ComfyApp.clipspace.imgs) { |
||||||
|
const combo_items = []; |
||||||
|
const imgs = ComfyApp.clipspace.imgs; |
||||||
|
|
||||||
|
for(let i=0; i < imgs.length; i++) { |
||||||
|
combo_items.push($el("option", {value:i}, [`${i}`])); |
||||||
|
} |
||||||
|
|
||||||
|
const combo1 = $el("select", |
||||||
|
{id:"clipspace_img_selector", onchange:(event) => { |
||||||
|
ComfyApp.clipspace['selectedIndex'] = event.target.selectedIndex; |
||||||
|
ClipspaceDialog.invalidatePreview(); |
||||||
|
} }, combo_items); |
||||||
|
|
||||||
|
const row1 = |
||||||
|
$el("tr", {}, |
||||||
|
[ |
||||||
|
$el("td", {}, [$el("font", {color:"white"}, ["Select Image"])]), |
||||||
|
$el("td", {}, [combo1]) |
||||||
|
]); |
||||||
|
|
||||||
|
|
||||||
|
const combo2 = $el("select", |
||||||
|
{id:"clipspace_img_paste_mode", onchange:(event) => { |
||||||
|
ComfyApp.clipspace['img_paste_mode'] = event.target.value; |
||||||
|
} }, |
||||||
|
[ |
||||||
|
$el("option", {value:'selected'}, 'selected'), |
||||||
|
$el("option", {value:'all'}, 'all') |
||||||
|
]); |
||||||
|
combo2.value = ComfyApp.clipspace['img_paste_mode']; |
||||||
|
|
||||||
|
const row2 = |
||||||
|
$el("tr", {}, |
||||||
|
[ |
||||||
|
$el("td", {}, [$el("font", {color:"white"}, ["Paste Mode"])]), |
||||||
|
$el("td", {}, [combo2]) |
||||||
|
]); |
||||||
|
|
||||||
|
const td = $el("td", {align:'center', width:'100px', height:'100px', colSpan:'2'}, |
||||||
|
[ $el("img",{id:"clipspace_preview", ondragstart:() => false},[]) ]); |
||||||
|
|
||||||
|
const row3 = |
||||||
|
$el("tr", {}, [td]); |
||||||
|
|
||||||
|
return $el("table", {}, [row1, row2, row3]); |
||||||
|
} |
||||||
|
else { |
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
createImgPreview() { |
||||||
|
if(ComfyApp.clipspace.imgs) { |
||||||
|
return $el("img",{id:"clipspace_preview", ondragstart:() => false}); |
||||||
|
} |
||||||
|
else |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
show() { |
||||||
|
const img_preview = document.getElementById("clipspace_preview"); |
||||||
|
ClipspaceDialog.invalidate(); |
||||||
|
|
||||||
|
this.element.style.display = "block"; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
app.registerExtension({ |
||||||
|
name: "Comfy.Clipspace", |
||||||
|
init(app) { |
||||||
|
app.openClipspace = |
||||||
|
function () { |
||||||
|
if(!ClipspaceDialog.instance) { |
||||||
|
ClipspaceDialog.instance = new ClipspaceDialog(app); |
||||||
|
ComfyApp.clipspace_invalidate_handler = ClipspaceDialog.invalidate; |
||||||
|
} |
||||||
|
|
||||||
|
if(ComfyApp.clipspace) { |
||||||
|
ClipspaceDialog.instance.show(); |
||||||
|
} |
||||||
|
else |
||||||
|
app.ui.dialog.show("Clipspace is Empty!"); |
||||||
|
}; |
||||||
|
} |
||||||
|
}); |
@ -0,0 +1,589 @@ |
|||||||
|
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"; |
||||||
|
|
||||||
|
// 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(); |
||||||
|
} |
||||||
|
|
||||||
|
function prepareRGB(image, backupCanvas, backupCtx) { |
||||||
|
// paste mask data into alpha channel
|
||||||
|
backupCtx.drawImage(image, 0, 0, backupCanvas.width, backupCanvas.height); |
||||||
|
const backupData = backupCtx.getImageData(0, 0, backupCanvas.width, backupCanvas.height); |
||||||
|
|
||||||
|
// refine mask image
|
||||||
|
for (let i = 0; i < backupData.data.length; i += 4) { |
||||||
|
if(backupData.data[i+3] == 255) |
||||||
|
backupData.data[i+3] = 0; |
||||||
|
else |
||||||
|
backupData.data[i+3] = 255; |
||||||
|
|
||||||
|
backupData.data[i] = 0; |
||||||
|
backupData.data[i+1] = 0; |
||||||
|
backupData.data[i+2] = 0; |
||||||
|
} |
||||||
|
|
||||||
|
backupCtx.globalCompositeOperation = 'source-over'; |
||||||
|
backupCtx.putImageData(backupData, 0, 0); |
||||||
|
} |
||||||
|
|
||||||
|
class MaskEditorDialog extends ComfyDialog { |
||||||
|
static instance = null; |
||||||
|
constructor() { |
||||||
|
super(); |
||||||
|
this.element = $el("div.comfy-modal", { parent: document.body },
|
||||||
|
[ $el("div.comfy-modal-content",
|
||||||
|
[...this.createButtons()]), |
||||||
|
]); |
||||||
|
MaskEditorDialog.instance = this; |
||||||
|
} |
||||||
|
|
||||||
|
createButtons() { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
clearMask(self) { |
||||||
|
} |
||||||
|
|
||||||
|
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 = "maskeditor-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.brush_slider_input = document.createElement('input'); |
||||||
|
self.brush_slider_input.setAttribute('type', 'range'); |
||||||
|
self.brush_slider_input.setAttribute('min', '1'); |
||||||
|
self.brush_slider_input.setAttribute('max', '100'); |
||||||
|
self.brush_slider_input.setAttribute('value', '10'); |
||||||
|
const labelElement = document.createElement("label"); |
||||||
|
labelElement.textContent = name; |
||||||
|
|
||||||
|
divElement.appendChild(labelElement); |
||||||
|
divElement.appendChild(self.brush_slider_input); |
||||||
|
|
||||||
|
self.brush_slider_input.addEventListener("change", callback); |
||||||
|
|
||||||
|
return divElement; |
||||||
|
} |
||||||
|
|
||||||
|
setlayout(imgCanvas, maskCanvas) { |
||||||
|
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 = "brush"; |
||||||
|
brush.style.backgroundColor = "transparent"; |
||||||
|
brush.style.outline = "1px dashed black"; |
||||||
|
brush.style.boxShadow = "0 0 0 1px white"; |
||||||
|
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(placeholder); // must below z-index than bottom_panel to avoid covering button
|
||||||
|
this.element.appendChild(bottom_panel); |
||||||
|
document.body.appendChild(brush); |
||||||
|
|
||||||
|
var brush_size_slider = this.createLeftSlider(self, "Thickness", (event) => { |
||||||
|
self.brush_size = event.target.value; |
||||||
|
self.updateBrushPreview(self, null, null); |
||||||
|
}); |
||||||
|
var clearButton = this.createLeftButton("Clear", |
||||||
|
() => { |
||||||
|
self.maskCtx.clearRect(0, 0, self.maskCanvas.width, self.maskCanvas.height); |
||||||
|
self.backupCtx.clearRect(0, 0, self.backupCanvas.width, self.backupCanvas.height); |
||||||
|
}); |
||||||
|
var cancelButton = this.createRightButton("Cancel", () => { |
||||||
|
document.removeEventListener("mouseup", MaskEditorDialog.handleMouseUp); |
||||||
|
document.removeEventListener("keydown", MaskEditorDialog.handleKeyDown); |
||||||
|
self.close(); |
||||||
|
}); |
||||||
|
var saveButton = this.createRightButton("Save", () => { |
||||||
|
document.removeEventListener("mouseup", MaskEditorDialog.handleMouseUp); |
||||||
|
document.removeEventListener("keydown", MaskEditorDialog.handleKeyDown); |
||||||
|
self.save(); |
||||||
|
}); |
||||||
|
|
||||||
|
this.element.appendChild(imgCanvas); |
||||||
|
this.element.appendChild(maskCanvas); |
||||||
|
this.element.appendChild(placeholder); // must below z-index than bottom_panel to avoid covering button
|
||||||
|
this.element.appendChild(bottom_panel); |
||||||
|
|
||||||
|
bottom_panel.appendChild(clearButton); |
||||||
|
bottom_panel.appendChild(saveButton); |
||||||
|
bottom_panel.appendChild(cancelButton); |
||||||
|
bottom_panel.appendChild(brush_size_slider); |
||||||
|
|
||||||
|
this.element.style.display = "block"; |
||||||
|
imgCanvas.style.position = "relative"; |
||||||
|
imgCanvas.style.top = "200"; |
||||||
|
imgCanvas.style.left = "0"; |
||||||
|
|
||||||
|
maskCanvas.style.position = "absolute"; |
||||||
|
} |
||||||
|
|
||||||
|
show() { |
||||||
|
// layout
|
||||||
|
const imgCanvas = document.createElement('canvas'); |
||||||
|
const maskCanvas = document.createElement('canvas'); |
||||||
|
const backupCanvas = document.createElement('canvas'); |
||||||
|
|
||||||
|
imgCanvas.id = "imageCanvas"; |
||||||
|
maskCanvas.id = "maskCanvas"; |
||||||
|
backupCanvas.id = "backupCanvas"; |
||||||
|
|
||||||
|
this.setlayout(imgCanvas, maskCanvas); |
||||||
|
|
||||||
|
// prepare content
|
||||||
|
this.maskCanvas = maskCanvas; |
||||||
|
this.backupCanvas = backupCanvas; |
||||||
|
this.maskCtx = maskCanvas.getContext('2d'); |
||||||
|
this.backupCtx = backupCanvas.getContext('2d'); |
||||||
|
|
||||||
|
this.setImages(imgCanvas, backupCanvas); |
||||||
|
this.setEventHandler(maskCanvas); |
||||||
|
} |
||||||
|
|
||||||
|
setImages(imgCanvas, backupCanvas) { |
||||||
|
const imgCtx = imgCanvas.getContext('2d'); |
||||||
|
const backupCtx = backupCanvas.getContext('2d'); |
||||||
|
const maskCtx = this.maskCtx; |
||||||
|
const maskCanvas = this.maskCanvas; |
||||||
|
|
||||||
|
// 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
|
||||||
|
backupCtx.drawImage(maskCanvas, 0, 0, maskCanvas.width, maskCanvas.height, 0, 0, backupCanvas.width, backupCanvas.height); |
||||||
|
maskCanvas.width = drawWidth; |
||||||
|
maskCanvas.height = drawHeight; |
||||||
|
maskCanvas.style.top = imgCanvas.offsetTop + "px"; |
||||||
|
maskCanvas.style.left = imgCanvas.offsetLeft + "px"; |
||||||
|
maskCtx.drawImage(backupCanvas, 0, 0, backupCanvas.width, backupCanvas.height, 0, 0, maskCanvas.width, maskCanvas.height); |
||||||
|
}); |
||||||
|
|
||||||
|
const filepath = ComfyApp.clipspace.images; |
||||||
|
|
||||||
|
const touched_image = new Image(); |
||||||
|
|
||||||
|
touched_image.onload = function() { |
||||||
|
backupCanvas.width = touched_image.width; |
||||||
|
backupCanvas.height = touched_image.height; |
||||||
|
|
||||||
|
prepareRGB(touched_image, backupCanvas, backupCtx); |
||||||
|
}; |
||||||
|
|
||||||
|
const alpha_url = new URL(ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src) |
||||||
|
alpha_url.searchParams.delete('channel'); |
||||||
|
alpha_url.searchParams.set('channel', 'a'); |
||||||
|
touched_image.src = alpha_url; |
||||||
|
|
||||||
|
// original image load
|
||||||
|
orig_image.onload = function() { |
||||||
|
window.dispatchEvent(new Event('resize')); |
||||||
|
}; |
||||||
|
|
||||||
|
const rgb_url = new URL(ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src); |
||||||
|
rgb_url.searchParams.delete('channel'); |
||||||
|
rgb_url.searchParams.set('channel', 'rgb'); |
||||||
|
orig_image.src = rgb_url; |
||||||
|
this.image = orig_image; |
||||||
|
}g |
||||||
|
|
||||||
|
|
||||||
|
setEventHandler(maskCanvas) { |
||||||
|
maskCanvas.addEventListener("contextmenu", (event) => { |
||||||
|
event.preventDefault(); |
||||||
|
}); |
||||||
|
|
||||||
|
const self = this; |
||||||
|
maskCanvas.addEventListener('wheel', (event) => this.handleWheelEvent(self,event)); |
||||||
|
maskCanvas.addEventListener('pointerdown', (event) => this.handlePointerDown(self,event)); |
||||||
|
document.addEventListener('pointerup', MaskEditorDialog.handlePointerUp); |
||||||
|
maskCanvas.addEventListener('pointermove', (event) => this.draw_move(self,event)); |
||||||
|
maskCanvas.addEventListener('touchmove', (event) => this.draw_move(self,event)); |
||||||
|
maskCanvas.addEventListener('pointerover', (event) => { this.brush.style.display = "block"; }); |
||||||
|
maskCanvas.addEventListener('pointerleave', (event) => { this.brush.style.display = "none"; }); |
||||||
|
document.addEventListener('keydown', MaskEditorDialog.handleKeyDown); |
||||||
|
} |
||||||
|
|
||||||
|
brush_size = 10; |
||||||
|
drawing_mode = false; |
||||||
|
lastx = -1; |
||||||
|
lasty = -1; |
||||||
|
lasttime = 0; |
||||||
|
|
||||||
|
static handleKeyDown(event) { |
||||||
|
const self = MaskEditorDialog.instance; |
||||||
|
if (event.key === ']') { |
||||||
|
self.brush_size = Math.min(self.brush_size+2, 100); |
||||||
|
} else if (event.key === '[') { |
||||||
|
self.brush_size = Math.max(self.brush_size-2, 1); |
||||||
|
} |
||||||
|
|
||||||
|
self.updateBrushPreview(self); |
||||||
|
} |
||||||
|
|
||||||
|
static handlePointerUp(event) { |
||||||
|
event.preventDefault(); |
||||||
|
MaskEditorDialog.instance.drawing_mode = false; |
||||||
|
} |
||||||
|
|
||||||
|
updateBrushPreview(self) { |
||||||
|
const brush = self.brush; |
||||||
|
|
||||||
|
var centerX = self.cursorX; |
||||||
|
var centerY = self.cursorY; |
||||||
|
|
||||||
|
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"; |
||||||
|
} |
||||||
|
|
||||||
|
handleWheelEvent(self, event) { |
||||||
|
if(event.deltaY < 0) |
||||||
|
self.brush_size = Math.min(self.brush_size+2, 100); |
||||||
|
else |
||||||
|
self.brush_size = Math.max(self.brush_size-2, 1); |
||||||
|
|
||||||
|
self.brush_slider_input.value = self.brush_size; |
||||||
|
|
||||||
|
self.updateBrushPreview(self); |
||||||
|
} |
||||||
|
|
||||||
|
draw_move(self, event) { |
||||||
|
event.preventDefault(); |
||||||
|
|
||||||
|
this.cursorX = event.pageX; |
||||||
|
this.cursorY = event.pageY; |
||||||
|
|
||||||
|
self.updateBrushPreview(self); |
||||||
|
|
||||||
|
if (event instanceof TouchEvent || event.buttons == 1) { |
||||||
|
var diff = performance.now() - self.lasttime; |
||||||
|
|
||||||
|
const maskRect = self.maskCanvas.getBoundingClientRect(); |
||||||
|
|
||||||
|
var x = event.offsetX; |
||||||
|
var y = event.offsetY |
||||||
|
|
||||||
|
if(event.offsetX == null) { |
||||||
|
x = event.targetTouches[0].clientX - maskRect.left; |
||||||
|
} |
||||||
|
|
||||||
|
if(event.offsetY == null) { |
||||||
|
y = event.targetTouches[0].clientY - maskRect.top; |
||||||
|
} |
||||||
|
|
||||||
|
var brush_size = this.brush_size; |
||||||
|
if(event instanceof PointerEvent && event.pointerType == 'pen') { |
||||||
|
brush_size *= event.pressure; |
||||||
|
this.last_pressure = event.pressure; |
||||||
|
} |
||||||
|
else if(event instanceof TouchEvent && diff < 20){ |
||||||
|
// The firing interval of PointerEvents in Pen is unreliable, so it is supplemented by TouchEvents.
|
||||||
|
brush_size *= this.last_pressure; |
||||||
|
} |
||||||
|
else { |
||||||
|
brush_size = this.brush_size; |
||||||
|
} |
||||||
|
|
||||||
|
if(diff > 20 && !this.drawing_mode) |
||||||
|
requestAnimationFrame(() => { |
||||||
|
self.maskCtx.beginPath(); |
||||||
|
self.maskCtx.fillStyle = "rgb(0,0,0)"; |
||||||
|
self.maskCtx.globalCompositeOperation = "source-over"; |
||||||
|
self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false); |
||||||
|
self.maskCtx.fill(); |
||||||
|
self.lastx = x; |
||||||
|
self.lasty = y; |
||||||
|
}); |
||||||
|
else |
||||||
|
requestAnimationFrame(() => { |
||||||
|
self.maskCtx.beginPath(); |
||||||
|
self.maskCtx.fillStyle = "rgb(0,0,0)"; |
||||||
|
self.maskCtx.globalCompositeOperation = "source-over"; |
||||||
|
|
||||||
|
var dx = x - self.lastx; |
||||||
|
var dy = y - self.lasty; |
||||||
|
|
||||||
|
var distance = Math.sqrt(dx * dx + dy * dy); |
||||||
|
var directionX = dx / distance; |
||||||
|
var directionY = dy / distance; |
||||||
|
|
||||||
|
for (var i = 0; i < distance; i+=5) { |
||||||
|
var px = self.lastx + (directionX * i); |
||||||
|
var py = self.lasty + (directionY * i); |
||||||
|
self.maskCtx.arc(px, py, brush_size, 0, Math.PI * 2, false); |
||||||
|
self.maskCtx.fill(); |
||||||
|
} |
||||||
|
self.lastx = x; |
||||||
|
self.lasty = y; |
||||||
|
}); |
||||||
|
|
||||||
|
self.lasttime = performance.now(); |
||||||
|
} |
||||||
|
else if(event.buttons == 2 || event.buttons == 5 || event.buttons == 32) { |
||||||
|
const maskRect = self.maskCanvas.getBoundingClientRect(); |
||||||
|
const x = event.offsetX || event.targetTouches[0].clientX - maskRect.left; |
||||||
|
const y = event.offsetY || event.targetTouches[0].clientY - maskRect.top; |
||||||
|
|
||||||
|
var brush_size = this.brush_size; |
||||||
|
if(event instanceof PointerEvent && event.pointerType == 'pen') { |
||||||
|
brush_size *= event.pressure; |
||||||
|
this.last_pressure = event.pressure; |
||||||
|
} |
||||||
|
else if(event instanceof TouchEvent && diff < 20){ |
||||||
|
brush_size *= this.last_pressure; |
||||||
|
} |
||||||
|
else { |
||||||
|
brush_size = this.brush_size; |
||||||
|
} |
||||||
|
|
||||||
|
if(diff > 20 && !drawing_mode) // cannot tracking drawing_mode for touch event
|
||||||
|
requestAnimationFrame(() => { |
||||||
|
self.maskCtx.beginPath(); |
||||||
|
self.maskCtx.globalCompositeOperation = "destination-out"; |
||||||
|
self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false); |
||||||
|
self.maskCtx.fill(); |
||||||
|
self.lastx = x; |
||||||
|
self.lasty = y; |
||||||
|
}); |
||||||
|
else |
||||||
|
requestAnimationFrame(() => { |
||||||
|
self.maskCtx.beginPath(); |
||||||
|
self.maskCtx.globalCompositeOperation = "destination-out"; |
||||||
|
|
||||||
|
var dx = x - self.lastx; |
||||||
|
var dy = y - self.lasty; |
||||||
|
|
||||||
|
var distance = Math.sqrt(dx * dx + dy * dy); |
||||||
|
var directionX = dx / distance; |
||||||
|
var directionY = dy / distance; |
||||||
|
|
||||||
|
for (var i = 0; i < distance; i+=5) { |
||||||
|
var px = self.lastx + (directionX * i); |
||||||
|
var py = self.lasty + (directionY * i); |
||||||
|
self.maskCtx.arc(px, py, brush_size, 0, Math.PI * 2, false); |
||||||
|
self.maskCtx.fill(); |
||||||
|
} |
||||||
|
self.lastx = x; |
||||||
|
self.lasty = y; |
||||||
|
}); |
||||||
|
|
||||||
|
self.lasttime = performance.now(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
handlePointerDown(self, event) { |
||||||
|
var brush_size = this.brush_size; |
||||||
|
if(event instanceof PointerEvent && event.pointerType == 'pen') { |
||||||
|
brush_size *= event.pressure; |
||||||
|
this.last_pressure = event.pressure; |
||||||
|
} |
||||||
|
|
||||||
|
if ([0, 2, 5].includes(event.button)) { |
||||||
|
self.drawing_mode = true; |
||||||
|
|
||||||
|
event.preventDefault(); |
||||||
|
const maskRect = self.maskCanvas.getBoundingClientRect(); |
||||||
|
const x = event.offsetX || event.targetTouches[0].clientX - maskRect.left; |
||||||
|
const y = event.offsetY || event.targetTouches[0].clientY - maskRect.top; |
||||||
|
|
||||||
|
self.maskCtx.beginPath(); |
||||||
|
if (event.button == 0) { |
||||||
|
self.maskCtx.fillStyle = "rgb(0,0,0)"; |
||||||
|
self.maskCtx.globalCompositeOperation = "source-over"; |
||||||
|
} else { |
||||||
|
self.maskCtx.globalCompositeOperation = "destination-out"; |
||||||
|
} |
||||||
|
self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false); |
||||||
|
self.maskCtx.fill(); |
||||||
|
self.lastx = x; |
||||||
|
self.lasty = y; |
||||||
|
self.lasttime = performance.now(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
save() { |
||||||
|
const backupCtx = this.backupCanvas.getContext('2d', {willReadFrequently:true}); |
||||||
|
|
||||||
|
backupCtx.clearRect(0,0,this.backupCanvas.width,this.backupCanvas.height); |
||||||
|
backupCtx.drawImage(this.maskCanvas, |
||||||
|
0, 0, this.maskCanvas.width, this.maskCanvas.height, |
||||||
|
0, 0, this.backupCanvas.width, this.backupCanvas.height); |
||||||
|
|
||||||
|
// paste mask data into alpha channel
|
||||||
|
const backupData = backupCtx.getImageData(0, 0, this.backupCanvas.width, this.backupCanvas.height); |
||||||
|
|
||||||
|
// refine mask image
|
||||||
|
for (let i = 0; i < backupData.data.length; i += 4) { |
||||||
|
if(backupData.data[i+3] == 255) |
||||||
|
backupData.data[i+3] = 0; |
||||||
|
else |
||||||
|
backupData.data[i+3] = 255; |
||||||
|
|
||||||
|
backupData.data[i] = 0; |
||||||
|
backupData.data[i+1] = 0; |
||||||
|
backupData.data[i+2] = 0; |
||||||
|
} |
||||||
|
|
||||||
|
backupCtx.globalCompositeOperation = 'source-over'; |
||||||
|
backupCtx.putImageData(backupData, 0, 0); |
||||||
|
|
||||||
|
const formData = new FormData(); |
||||||
|
const filename = "clipspace-mask-" + performance.now() + ".png"; |
||||||
|
|
||||||
|
const item = |
||||||
|
{ |
||||||
|
"filename": filename, |
||||||
|
"subfolder": "", |
||||||
|
"type": "clipspace", |
||||||
|
}; |
||||||
|
|
||||||
|
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 = item; |
||||||
|
} |
||||||
|
|
||||||
|
const dataURL = this.backupCanvas.toDataURL(); |
||||||
|
const blob = dataURLToBlob(dataURL); |
||||||
|
|
||||||
|
const original_blob = loadedImageToBlob(this.image); |
||||||
|
|
||||||
|
formData.append('image', blob, filename); |
||||||
|
formData.append('original_image', original_blob); |
||||||
|
formData.append('type', "clipspace"); |
||||||
|
|
||||||
|
uploadMask(item, formData); |
||||||
|
this.close(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
app.registerExtension({ |
||||||
|
name: "Comfy.MaskEditor", |
||||||
|
init(app) { |
||||||
|
const callback = |
||||||
|
function () { |
||||||
|
let dlg = new MaskEditorDialog(app); |
||||||
|
dlg.show(); |
||||||
|
}; |
||||||
|
|
||||||
|
const context_predicate = () => ComfyApp.clipspace && ComfyApp.clipspace.imgs && ComfyApp.clipspace.imgs.length > 0 |
||||||
|
ClipspaceDialog.registerButton("MaskEditor", context_predicate, callback); |
||||||
|
} |
||||||
|
}); |
Loading…
Reference in new issue