diff --git a/src/extensions/core/maskEditorLayerFilenames.ts b/src/extensions/core/maskEditorLayerFilenames.ts new file mode 100644 index 000000000..2e2f1bf98 --- /dev/null +++ b/src/extensions/core/maskEditorLayerFilenames.ts @@ -0,0 +1,29 @@ +export interface ImageLayerFilenames { + maskedImage: string + paint: string + paintedImage: string + paintedMaskedImage: string +} + +const paintedMaskedImagePrefix = 'clipspace-painted-masked-' + +export const imageLayerFilenamesByTimestamp = ( + timestamp: number +): ImageLayerFilenames => ({ + maskedImage: `clipspace-mask-${timestamp}.png`, + paint: `clipspace-paint-${timestamp}.png`, + paintedImage: `clipspace-painted-${timestamp}.png`, + paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png` +}) + +export const imageLayerFilenamesIfApplicable = ( + inputImageFilename: string +): ImageLayerFilenames | undefined => { + const isPaintedMaskedImageFilename = inputImageFilename.startsWith( + paintedMaskedImagePrefix + ) + if (!isPaintedMaskedImageFilename) return undefined + const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length) + const timestamp = parseInt(suffix.split('.')[0], 10) + return imageLayerFilenamesByTimestamp(timestamp) +} diff --git a/src/extensions/core/maskeditor.ts b/src/extensions/core/maskeditor.ts index fa7f29f47..c07a3d84d 100644 --- a/src/extensions/core/maskeditor.ts +++ b/src/extensions/core/maskeditor.ts @@ -1,4 +1,5 @@ import { debounce } from 'lodash' +import _ from 'lodash' import { t } from '@/i18n' @@ -7,7 +8,12 @@ import { app } from '../../scripts/app' import { ComfyApp } from '../../scripts/app' import { $el, ComfyDialog } from '../../scripts/ui' import { getStorageValue, setStorageValue } from '../../scripts/utils' +import { hexToRgb } from '../../utils/colorUtil' import { ClipspaceDialog } from './clipspace' +import { + imageLayerFilenamesByTimestamp, + imageLayerFilenamesIfApplicable +} from './maskEditorLayerFilenames' import { MaskEditorDialogOld } from './maskEditorOld' var styles = ` @@ -42,6 +48,7 @@ var styles = ` backdrop-filter: blur(10px); overflow: hidden; user-select: none; + --mask-editor-top-bar-height: 44px; } #maskEditor_sidePanelContainer { height: 100%; @@ -55,13 +62,17 @@ var styles = ` height: 100%; display: flex; align-items: center; - overflow-y: hidden; + overflow-y: auto; width: 220px; + padding: 0 10px; + } + #maskEditor_sidePanelContent { + width: 100%; } #maskEditor_sidePanelShortcuts { display: flex; flex-direction: row; - width: 200px; + width: 100%; margin-top: 10px; gap: 10px; justify-content: center; @@ -82,7 +93,7 @@ var styles = ` display: flex; flex-direction: column; gap: 10px; - width: 200px; + width: 100%; padding: 10px; } .maskEditor_sidePanelTitle { @@ -171,12 +182,12 @@ var styles = ` display: flex; flex-direction: column; gap: 10px; - width: 200px; + width: 100%; align-items: center; } .maskEditor_sidePanelLayer { display: flex; - width: 200px; + width: 100%; height: 50px; } .maskEditor_sidePanelLayerVisibilityContainer { @@ -314,7 +325,7 @@ var styles = ` display: flex; flex-direction: column; gap: 10px; - width: 200px; + width: 100%; padding: 10px; } #canvasBackground { @@ -329,10 +340,10 @@ var styles = ` margin-top: 10px; } .maskEditor_sidePanelSeparator { - width: 200px; + width: 100%; height: 2px; background: var(--border-color); - margin-top: 5px; + margin-top: 1.5em; margin-bottom: 5px; } #maskEditor_pointerZone { @@ -364,14 +375,15 @@ var styles = ` } #maskEditor_uiHorizontalContainer { width: 100%; - height: 100%; + height: calc(100% - var(--mask-editor-top-bar-height)); display: flex; } #maskEditor_topBar { display: flex; - height: 44px; + height: var(--mask-editor-top-bar-height); align-items: center; background: var(--comfy-menu-bg); + flex-shrink: 0; } #maskEditor_topBarTitle { margin: 0; @@ -385,7 +397,7 @@ var styles = ` margin-right: 0.5rem; position: absolute; right: 0; - width: 200px; + width: 100%; } #maskEditor_topBarShortcutsContainer { display: flex; @@ -523,6 +535,7 @@ var styles = ` display: flex; flex-direction: column; gap: 12px; + padding-bottom: 12px; } .maskEditor_sidePanelContainerRow { @@ -669,7 +682,7 @@ var styles = ` .maskEditor_layerRow { height: 50px; - width: 200px; + width: 100%; border-radius: 10px; } @@ -747,10 +760,28 @@ enum BrushShape { } enum Tools { - Pen = 'pen', + MaskPen = 'pen', + PaintPen = 'rgbPaint', Eraser = 'eraser', - PaintBucket = 'paintBucket', - ColorSelect = 'colorSelect' + MaskBucket = 'paintBucket', + MaskColorFill = 'colorSelect' +} + +const allTools = [ + Tools.MaskPen, + Tools.PaintPen, + Tools.Eraser, + Tools.MaskBucket, + Tools.MaskColorFill +] + +const allImageLayers = ['mask', 'rgb'] as const +type ImageLayer = (typeof allImageLayers)[number] + +interface ToolInternalSettings { + container: HTMLElement + cursor?: string + newActiveLayerOnSet?: ImageLayer } enum CompositionOperation { @@ -956,181 +987,138 @@ class MaskEditorDialog extends ComfyDialog { } async save() { - const backupCanvas = document.createElement('canvas') const imageCanvas = this.uiManager.getImgCanvas() const maskCanvas = this.uiManager.getMaskCanvas() + const maskCanvasCtx = getCanvas2dContext(maskCanvas) + const paintCanvas = this.uiManager.getRgbCanvas() const image = this.uiManager.getImage() - const backupCtx = backupCanvas.getContext('2d', { - willReadFrequently: true - }) - - backupCanvas.width = imageCanvas.width - backupCanvas.height = imageCanvas.height - - if (!backupCtx) { - return - } - - // Ensure the mask image is fully loaded - const maskImageLoaded = new Promise((resolve, reject) => { - const maskImage = new Image() - maskImage.src = maskCanvas.toDataURL() - maskImage.onload = () => { - resolve() - } - maskImage.onerror = (error) => { - reject(error) - } - }) try { - await maskImageLoaded + await ensureImageFullyLoaded(maskCanvas.toDataURL()) } catch (error) { console.error('Error loading mask image:', error) return } - backupCtx.clearRect(0, 0, backupCanvas.width, backupCanvas.height) - backupCtx.drawImage( - maskCanvas, + const unrefinedMaskImageData = maskCanvasCtx.getImageData( 0, 0, maskCanvas.width, - maskCanvas.height, - 0, - 0, - backupCanvas.width, - backupCanvas.height + maskCanvas.height ) - let maskHasContent = false - const maskData = backupCtx.getImageData( - 0, - 0, - backupCanvas.width, - backupCanvas.height + const refinedMaskOnlyData = new ImageData( + removeImageRgbValuesAndInvertAlpha(unrefinedMaskImageData.data), + unrefinedMaskImageData.width, + unrefinedMaskImageData.height ) - for (let i = 0; i < maskData.data.length; i += 4) { - if (maskData.data[i + 3] !== 0) { - maskHasContent = true - break - } + // We create an undisplayed copy so as not to alter the original--displayed--canvas + const [refinedMaskCanvas, refinedMaskCanvasCtx] = + createCanvasCopy(maskCanvas) + refinedMaskCanvasCtx.globalCompositeOperation = + CompositionOperation.SourceOver + refinedMaskCanvasCtx.putImageData(refinedMaskOnlyData, 0, 0) + + const timestamp = Math.round(performance.now()) + const filenames = imageLayerFilenamesByTimestamp(timestamp) + const refs = { + maskedImage: toRef(filenames.maskedImage), + paint: toRef(filenames.paint), + paintedImage: toRef(filenames.paintedImage), + paintedMaskedImage: toRef(filenames.paintedMaskedImage) } - // paste mask data into alpha channel - const backupData = backupCtx.getImageData( - 0, - 0, - backupCanvas.width, - backupCanvas.height - ) + const [paintedImageCanvas] = combineOriginalImageAndPaint({ + originalImage: imageCanvas, + paint: paintCanvas + }) - let backupHasContent = false - for (let i = 0; i < backupData.data.length; i += 4) { - if (backupData.data[i + 3] !== 0) { - backupHasContent = true - break - } - } + replaceClipspaceImages(refs.paintedMaskedImage, [refs.paint]) - if (maskHasContent && !backupHasContent) { - console.error('Mask appears to be empty') - alert('Cannot save empty mask') - return - } - - // refine mask image - for (let i = 0; i < backupData.data.length; i += 4) { - const alpha = backupData.data[i + 3] - backupData.data[i] = 0 - backupData.data[i + 1] = 0 - backupData.data[i + 2] = 0 - backupData.data[i + 3] = 255 - alpha - } - - backupCtx.globalCompositeOperation = CompositionOperation.SourceOver - backupCtx.putImageData(backupData, 0, 0) - - const formData = new FormData() - const filename = 'clipspace-mask-' + performance.now() + '.png' - - const item = { - filename: filename, - subfolder: 'clipspace', - type: 'input' - } - - if (ComfyApp?.clipspace?.widgets?.length) { - const index = ComfyApp.clipspace.widgets.findIndex( - (obj) => obj?.name === 'image' - ) - - if (index >= 0 && item !== undefined) { - try { - ComfyApp.clipspace.widgets[index].value = item - } catch (err) { - console.warn('Failed to set widget value:', err) - } - } - } - - const dataURL = backupCanvas.toDataURL() - const blob = this.dataURLToBlob(dataURL) - - let original_url = new URL(image.src) - - type Ref = { filename: string; subfolder?: string; type?: string } + const originalImageUrl = new URL(image.src) this.uiManager.setBrushOpacity(0) - const filenameRef = original_url.searchParams.get('filename') - if (!filenameRef) { - throw new Error('filename parameter is required') - } - const original_ref: Ref = { - filename: filenameRef + const originalImageFilename = originalImageUrl.searchParams.get('filename') + if (!originalImageFilename) + throw new Error( + "Expected original image URL to have a `filename` query parameter, but couldn't find it." + ) + + const originalImageRef: Partial = { + filename: originalImageFilename, + subfolder: originalImageUrl.searchParams.get('subfolder') ?? undefined, + type: originalImageUrl.searchParams.get('type') ?? undefined } - let original_subfolder = original_url.searchParams.get('subfolder') - if (original_subfolder) original_ref.subfolder = original_subfolder + const mkFormData = ( + blob: Blob, + filename: string, + originalImageRefOverride?: Partial + ) => { + const formData = new FormData() + formData.append('image', blob, filename) + formData.append( + 'original_ref', + JSON.stringify(originalImageRefOverride ?? originalImageRef) + ) + formData.append('type', 'input') + formData.append('subfolder', 'clipspace') + return formData + } - let original_type = original_url.searchParams.get('type') - if (original_type) original_ref.type = original_type + const canvasToFormData = ( + canvas: HTMLCanvasElement, + filename: string, + originalImageRefOverride?: Partial + ) => { + const blob = this.dataURLToBlob(canvas.toDataURL()) + return mkFormData(blob, filename, originalImageRefOverride) + } - formData.append('image', blob, filename) - formData.append('original_ref', JSON.stringify(original_ref)) - formData.append('type', 'input') - formData.append('subfolder', 'clipspace') + const formDatas = { + // Note: this canvas only contains mask data (no image), but during the upload process, the backend combines the mask with the original_image. Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")` + maskedImage: canvasToFormData(refinedMaskCanvas, filenames.maskedImage), + paint: canvasToFormData(paintCanvas, filenames.paint), + paintedImage: canvasToFormData( + paintedImageCanvas, + filenames.paintedImage + ), + paintedMaskedImage: canvasToFormData( + refinedMaskCanvas, + filenames.paintedMaskedImage, + refs.paintedImage + ) + } this.uiManager.setSaveButtonText(t('g.saving')) this.uiManager.setSaveButtonEnabled(false) this.keyboardManager.removeListeners() - // Retry mechanism - const maxRetries = 3 - let attempt = 0 - let success = false + try { + await this.uploadMask( + refs.maskedImage, + formDatas.maskedImage, + 'selectedIndex' + ) + await this.uploadImage(refs.paint, formDatas.paint) + await this.uploadImage(refs.paintedImage, formDatas.paintedImage, false) - while (attempt < maxRetries && !success) { - try { - await this.uploadMask(item, formData) - success = true - } catch (error) { - console.error(`Upload attempt ${attempt + 1} failed:`, error) - attempt++ - if (attempt < maxRetries) { - console.log('Retrying upload...') - } else { - console.log('Max retries reached. Upload failed.') - } - } - } + // IMPORTANT: We using `uploadMask` here, because the backend combines the mask with the painted image during the upload process. We do NOT want to combine the mask with the original image on the frontend, because the spec for CanvasRenderingContext2D does not allow for setting pixels to transparent while preserving their RGB values. + // See: + // It is possible that WebGL contexts can achieve this, but WebGL is extremely complex, and the backend functionality is here for this purpose! + // Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")` + await this.uploadMask( + refs.paintedMaskedImage, + formDatas.paintedMaskedImage, + 'combinedIndex' + ) - if (success) { ComfyApp.onClipspaceEditorSave() this.destroy() - } else { + } catch (error) { + console.error('Error during upload:', error) this.uiManager.setSaveButtonText(t('g.save')) this.uiManager.setSaveButtonEnabled(true) this.keyboardManager.addListeners() @@ -1154,46 +1142,36 @@ class MaskEditorDialog extends ComfyDialog { return new Blob([arrayBuffer], { type: contentType }) } - private async uploadMask( - filepath: { filename: string; subfolder: string; type: string }, + private async uploadImage( + filepath: Ref, formData: FormData, - retries = 3 + isPaintLayer = true ) { - if (retries <= 0) { - throw new Error('Max retries reached') - return - } - await api - .fetchApi('/upload/mask', { + const success = await requestWithRetries(() => + api.fetchApi('/upload/image', { method: 'POST', body: formData }) - .then((response) => { - if (!response.ok) { - console.log('Failed to upload mask:', response) - this.uploadMask(filepath, formData, retries - 1) - } - }) - .catch((error) => { - console.error('Error:', error) - }) + ) + if (!success) { + throw new Error('Upload failed.') + } + if (!isPaintLayer) { + ClipspaceDialog.invalidatePreview() + return success + } try { - const selectedIndex = ComfyApp.clipspace?.selectedIndex - if (ComfyApp.clipspace?.imgs && selectedIndex !== undefined) { + const paintedIndex = ComfyApp.clipspace?.paintedIndex + if (ComfyApp.clipspace?.imgs && paintedIndex !== undefined) { // Create and set new image const newImage = new Image() - newImage.src = api.apiURL( - '/view?' + - new URLSearchParams(filepath).toString() + - app.getPreviewFormatParam() + - app.getRandParam() - ) - ComfyApp.clipspace.imgs[selectedIndex] = newImage + newImage.src = mkFileUrl({ ref: filepath, preview: true }) + ComfyApp.clipspace.imgs[paintedIndex] = newImage // Update images array if it exists if (ComfyApp.clipspace.images) { - ComfyApp.clipspace.images[selectedIndex] = filepath + ComfyApp.clipspace.images[paintedIndex] = filepath } } } catch (err) { @@ -1201,6 +1179,46 @@ class MaskEditorDialog extends ComfyDialog { } ClipspaceDialog.invalidatePreview() } + + private async uploadMask( + filepath: Ref, + formData: FormData, + clipspaceLocation: 'selectedIndex' | 'combinedIndex' + ) { + const success = await requestWithRetries(() => + api.fetchApi('/upload/mask', { + method: 'POST', + body: formData + }) + ) + if (!success) { + throw new Error('Upload failed.') + } + + try { + const nameOfIndexToSaveTo = ( + { + selectedIndex: 'selectedIndex', + combinedIndex: 'combinedIndex' + } as const + )[clipspaceLocation] + if (!nameOfIndexToSaveTo) return + const indexToSaveTo = ComfyApp.clipspace?.[nameOfIndexToSaveTo] + if (!ComfyApp.clipspace?.imgs || indexToSaveTo === undefined) return + // Create and set new image + const newImage = new Image() + newImage.src = mkFileUrl({ ref: filepath, preview: true }) + ComfyApp.clipspace.imgs[indexToSaveTo] = newImage + + // Update images array if it exists + if (ComfyApp.clipspace.images) { + ComfyApp.clipspace.images[indexToSaveTo] = filepath + } + } catch (err) { + console.warn('Failed to update clipspace image:', err) + } + ClipspaceDialog.invalidatePreview() + } } class CanvasHistory { @@ -1210,7 +1228,9 @@ class CanvasHistory { private canvas!: HTMLCanvasElement private ctx!: CanvasRenderingContext2D - private states: ImageData[] = [] + private rgbCanvas!: HTMLCanvasElement + private rgbCtx!: CanvasRenderingContext2D + private states: { mask: ImageData; rgb: ImageData }[] = [] private currentStateIndex: number = -1 private maxStates: number = 20 private initialized: boolean = false @@ -1225,6 +1245,8 @@ class CanvasHistory { private async pullCanvas() { this.canvas = await this.messageBroker.pull('maskCanvas') this.ctx = await this.messageBroker.pull('maskCtx') + this.rgbCanvas = await this.messageBroker.pull('rgbCanvas') + this.rgbCtx = await this.messageBroker.pull('rgbCtx') } private createListeners() { @@ -1241,21 +1263,31 @@ class CanvasHistory { async saveInitialState() { await this.pullCanvas() - if (!this.canvas.width || !this.canvas.height) { + if ( + !this.canvas.width || + !this.canvas.height || + !this.rgbCanvas.width || + !this.rgbCanvas.height + ) { // Canvas not ready yet, defer initialization requestAnimationFrame(() => this.saveInitialState()) return } this.clearStates() - const state = this.ctx.getImageData( + const maskState = this.ctx.getImageData( 0, 0, this.canvas.width, this.canvas.height ) - - this.states.push(state) + const rgbState = this.rgbCtx.getImageData( + 0, + 0, + this.rgbCanvas.width, + this.rgbCanvas.height + ) + this.states.push({ mask: maskState, rgb: rgbState }) this.currentStateIndex = 0 this.initialized = true } @@ -1268,13 +1300,19 @@ class CanvasHistory { } this.states = this.states.slice(0, this.currentStateIndex + 1) - const state = this.ctx.getImageData( + const maskState = this.ctx.getImageData( 0, 0, this.canvas.width, this.canvas.height ) - this.states.push(state) + const rgbState = this.rgbCtx.getImageData( + 0, + 0, + this.rgbCanvas.width, + this.rgbCanvas.height + ) + this.states.push({ mask: maskState, rgb: rgbState }) this.currentStateIndex++ if (this.states.length > this.maxStates) { @@ -1304,9 +1342,10 @@ class CanvasHistory { } } - restoreState(state: ImageData) { + restoreState(state: { mask: ImageData; rgb: ImageData }) { if (state && this.initialized) { - this.ctx.putImageData(state, 0, 0) + this.ctx.putImageData(state.mask, 0, 0) + this.rgbCtx.putImageData(state.rgb, 0, 0) } } } @@ -2007,6 +2046,7 @@ class BrushTool { smoothingCordsArray: Point[] = [] smoothingLastDrawTime!: Date maskCtx: CanvasRenderingContext2D | null = null + rgbCtx: CanvasRenderingContext2D | null = null initialDraw: boolean = true brushStrokeCanvas: HTMLCanvasElement | null = null @@ -2022,6 +2062,9 @@ class BrushTool { maskEditor: MaskEditorDialog messageBroker: MessageBroker + private rgbColor: string = '#FF0000' // Default color + private activeLayer: ImageLayer = 'mask' + constructor(maskEditor: MaskEditorDialog) { this.maskEditor = maskEditor this.messageBroker = maskEditor.getMessageBroker() @@ -2065,10 +2108,17 @@ class BrushTool { this.messageBroker.subscribe('setBrushShape', (type: BrushShape) => this.setBrushType(type) ) + this.messageBroker.subscribe( + 'setActiveLayer', + (layer: ImageLayer) => (this.activeLayer = layer) + ) this.messageBroker.subscribe( 'setBrushSmoothingPrecision', (precision: number) => this.setBrushSmoothingPrecision(precision) ) + this.messageBroker.subscribe('setRGBColor', (color: string) => { + this.rgbColor = color + }) //brush adjustment this.messageBroker.subscribe( 'brushAdjustmentStart', @@ -2149,7 +2199,6 @@ class BrushTool { compositionOp = CompositionOperation.SourceOver //pen } - //check if user wants to draw line or free draw if (event.shiftKey && this.lineStartPoint) { this.isDrawingLine = true this.drawLine(this.lineStartPoint, coords_canvas, compositionOp) @@ -2374,24 +2423,59 @@ class BrushTool { private async draw_shape(point: Point, overrideOpacity?: number) { const brushSettings: Brush = this.brushSettings const maskCtx = this.maskCtx || (await this.messageBroker.pull('maskCtx')) + const rgbCtx = this.rgbCtx || (await this.messageBroker.pull('rgbCtx')) const brushType = await this.messageBroker.pull('brushType') const maskColor = await this.messageBroker.pull('getMaskColor') const size = brushSettings.size - const sliderOpacity = brushSettings.opacity + const brushSettingsSliderOpacity = brushSettings.opacity const opacity = - overrideOpacity == undefined ? sliderOpacity : overrideOpacity + overrideOpacity == undefined + ? brushSettingsSliderOpacity + : overrideOpacity const hardness = brushSettings.hardness - const x = point.x const y = point.y - // Extend the gradient radius beyond the brush size const extendedSize = size * (2 - hardness) - let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize) - const isErasing = maskCtx.globalCompositeOperation === 'destination-out' + const currentTool = await this.messageBroker.pull('currentTool') + // handle paint pen + if ( + this.activeLayer === 'rgb' && + (currentTool === Tools.Eraser || currentTool === Tools.PaintPen) + ) { + const rgbaColor = this.formatRgba(this.rgbColor, opacity) + let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, extendedSize) + if (hardness === 1) { + gradient.addColorStop(0, rgbaColor) + gradient.addColorStop( + 1, + this.formatRgba(this.rgbColor, brushSettingsSliderOpacity) + ) + } else { + gradient.addColorStop(0, rgbaColor) + gradient.addColorStop(hardness, rgbaColor) + gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0)) + } + rgbCtx.fillStyle = gradient + rgbCtx.beginPath() + if (brushType === BrushShape.Rect) { + rgbCtx.rect( + x - extendedSize, + y - extendedSize, + extendedSize * 2, + extendedSize * 2 + ) + } else { + rgbCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false) + } + rgbCtx.fill() + return + } + + let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize) if (hardness === 1) { gradient.addColorStop( 0, @@ -2450,15 +2534,28 @@ class BrushTool { maskCtx.fill() } + private formatRgba(hex: string, alpha: number): string { + const { r, g, b } = hexToRgb(hex) + return `rgba(${r}, ${g}, ${b}, ${alpha})` + } + private async init_shape(compositionOperation: CompositionOperation) { const maskBlendMode = await this.messageBroker.pull('maskBlendMode') const maskCtx = this.maskCtx || (await this.messageBroker.pull('maskCtx')) + const rgbCtx = this.rgbCtx || (await this.messageBroker.pull('rgbCtx')) + maskCtx.beginPath() + rgbCtx.beginPath() + + // For both contexts, set the composite operation based on the passed parameter + // This ensures right-click always works for erasing if (compositionOperation == CompositionOperation.SourceOver) { maskCtx.fillStyle = maskBlendMode maskCtx.globalCompositeOperation = CompositionOperation.SourceOver + rgbCtx.globalCompositeOperation = CompositionOperation.SourceOver } else if (compositionOperation == CompositionOperation.DestinationOut) { maskCtx.globalCompositeOperation = CompositionOperation.DestinationOut + rgbCtx.globalCompositeOperation = CompositionOperation.DestinationOut } } @@ -2541,8 +2638,10 @@ class UIManager { private brush!: HTMLDivElement private brushPreviewGradient!: HTMLDivElement private maskCtx!: CanvasRenderingContext2D + private rgbCtx!: CanvasRenderingContext2D private imageCtx!: CanvasRenderingContext2D private maskCanvas!: HTMLCanvasElement + private rgbCanvas!: HTMLCanvasElement private imgCanvas!: HTMLCanvasElement private brushSettingsHTML!: HTMLDivElement private paintBucketSettingsHTML!: HTMLDivElement @@ -2562,13 +2661,28 @@ class UIManager { private canvasBackground!: HTMLDivElement private canvasContainer!: HTMLDivElement private image!: HTMLImageElement + private paint_image!: HTMLImageElement private imageURL!: URL private darkMode: boolean = true + private maskLayerContainer: HTMLElement | null = null + private paintLayerContainer: HTMLElement | null = null + + private createColorPicker(): HTMLInputElement { + const colorPicker = document.createElement('input') + colorPicker.type = 'color' + colorPicker.id = 'maskEditor_colorPicker' + colorPicker.value = '#FF0000' // Default color + colorPicker.addEventListener('input', (event) => { + const color = (event.target as HTMLInputElement).value + this.messageBroker.publish('setRGBColor', color) + }) + return colorPicker + } private maskEditor: MaskEditorDialog private messageBroker: MessageBroker - private mask_opacity: number = 1.0 + private mask_opacity: number = 0.8 private maskBlendMode: MaskBlendMode = MaskBlendMode.Black private zoomTextHTML!: HTMLSpanElement @@ -2620,6 +2734,8 @@ class UIManager { this.messageBroker.createPullTopic('maskCtx', async () => this.maskCtx) this.messageBroker.createPullTopic('imageCtx', async () => this.imageCtx) this.messageBroker.createPullTopic('imgCanvas', async () => this.imgCanvas) + this.messageBroker.createPullTopic('rgbCtx', async () => this.rgbCtx) + this.messageBroker.createPullTopic('rgbCanvas', async () => this.rgbCanvas) this.messageBroker.createPullTopic( 'screenToCanvas', async (coords: Point) => this.screenToCanvas(coords) @@ -2681,22 +2797,32 @@ class UIManager { const maskCanvas = document.createElement('canvas') maskCanvas.id = 'maskCanvas' + const rgbCanvas = document.createElement('canvas') + rgbCanvas.id = 'rgbCanvas' + const canvas_background = document.createElement('div') canvas_background.id = 'canvasBackground' canvasContainer.appendChild(imgCanvas) + canvasContainer.appendChild(rgbCanvas) canvasContainer.appendChild(maskCanvas) canvasContainer.appendChild(canvas_background) // prepare content this.imgCanvas = imgCanvas! + this.rgbCanvas = rgbCanvas! this.maskCanvas = maskCanvas! this.canvasContainer = canvasContainer! this.canvasBackground = canvas_background! + let maskCtx = maskCanvas!.getContext('2d', { willReadFrequently: true }) if (maskCtx) { this.maskCtx = maskCtx } + let rgbCtx = rgbCanvas.getContext('2d', { willReadFrequently: true }) + if (rgbCtx) { + this.rgbCtx = rgbCtx + } let imgCtx = imgCanvas!.getContext('2d', { willReadFrequently: true }) if (imgCtx) { this.imageCtx = imgCtx @@ -2706,11 +2832,15 @@ class UIManager { //remove styling and move to css file this.imgCanvas.style.position = 'absolute' + this.rgbCanvas.style.position = 'absolute' this.maskCanvas.style.position = 'absolute' this.imgCanvas.style.top = '200' this.imgCanvas.style.left = '0' + this.rgbCanvas.style.top = this.imgCanvas.style.top + this.rgbCanvas.style.left = this.imgCanvas.style.left + this.maskCanvas.style.top = this.imgCanvas.style.top this.maskCanvas.style.left = this.imgCanvas.style.left @@ -2747,8 +2877,10 @@ class UIManager { } private async createSidePanel() { - const side_panel = this.createContainer(true) - side_panel.id = 'maskEditor_sidePanel' + const sidePanelWrapper = this.createContainer(true) + const side_panel = document.createElement('div') + sidePanelWrapper.id = 'maskEditor_sidePanel' + side_panel.id = 'maskEditor_sidePanelContent' const brush_settings = await this.createBrushSettings() brush_settings.id = 'maskEditor_brushSettings' @@ -2771,8 +2903,9 @@ class UIManager { side_panel.appendChild(color_select_settings) side_panel.appendChild(separator) side_panel.appendChild(image_layer_settings) + sidePanelWrapper.appendChild(side_panel) - return side_panel + return sidePanelWrapper } private async createBrushSettings() { @@ -2895,18 +3028,18 @@ class UIManager { resetBrushSettingsButton.addEventListener('click', () => { this.messageBroker.publish('setBrushShape', BrushShape.Arc) - this.messageBroker.publish('setBrushSize', 10) - this.messageBroker.publish('setBrushOpacity', 0.7) + this.messageBroker.publish('setBrushSize', 20) + this.messageBroker.publish('setBrushOpacity', 1) this.messageBroker.publish('setBrushHardness', 1) - this.messageBroker.publish('setBrushSmoothingPrecision', 10) + this.messageBroker.publish('setBrushSmoothingPrecision', 60) circle_shape.style.background = 'var(--p-button-text-primary-color)' square_shape.style.background = '' - thicknesSliderObj.slider.value = '10' - opacitySliderObj.slider.value = '0.7' + thicknesSliderObj.slider.value = '20' + opacitySliderObj.slider.value = '1' hardnessSliderObj.slider.value = '1' - brushSmoothingPrecisionSliderObj.slider.value = '10' + brushSmoothingPrecisionSliderObj.slider.value = '60' this.setBrushBorderRadius() this.updateBrushPreview() @@ -2915,6 +3048,23 @@ class UIManager { brush_settings_container.appendChild(brush_settings_title) brush_settings_container.appendChild(resetBrushSettingsButton) brush_settings_container.appendChild(brush_shape_outer_container) + + // Create a new container for the color picker and its title + const color_picker_container = this.createContainer(true) + + // Add the color picker title + const colorPickerTitle = document.createElement('span') + colorPickerTitle.innerText = 'Color Selector' + colorPickerTitle.classList.add('maskEditor_sidePanelSubTitle') // Mimic brush shape title style + color_picker_container.appendChild(colorPickerTitle) + + // Add the color picker + const colorPicker = this.createColorPicker() + color_picker_container.appendChild(colorPicker) + + // Add the color picker container to the main settings container + brush_settings_container.appendChild(color_picker_container) + brush_settings_container.appendChild(thicknesSliderObj.container) brush_settings_container.appendChild(opacitySliderObj.container) brush_settings_container.appendChild(hardnessSliderObj.container) @@ -3058,6 +3208,78 @@ class UIManager { return color_select_settings_container } + activeLayer: 'mask' | 'rgb' = 'mask' + layerButtons: Record = { + mask: (() => { + const btn = document.createElement('button') + btn.style.fontSize = '12px' + return btn + })(), + rgb: (() => { + const btn = document.createElement('button') + btn.style.fontSize = '12px' + return btn + })() + } + updateButtonsVisibility() { + allImageLayers.forEach((layer) => { + const button = this.layerButtons[layer] + if (layer === this.activeLayer) { + button.style.opacity = '0.5' + button.disabled = true + } else { + button.style.opacity = '1' + button.disabled = false + } + }) + } + + async updateLayerButtonsForTool() { + const currentTool = await this.messageBroker.pull('currentTool') + const isEraserTool = currentTool === Tools.Eraser + + // Show/hide buttons based on whether eraser tool is active + Object.values(this.layerButtons).forEach((button) => { + if (isEraserTool) { + button.style.display = 'block' + } else { + button.style.display = 'none' + } + }) + } + + async setActiveLayer(layer: 'mask' | 'rgb') { + this.messageBroker.publish('setActiveLayer', layer) + this.activeLayer = layer + this.updateButtonsVisibility() + const currentTool = await this.messageBroker.pull('currentTool') + const maskOnlyTools = [Tools.MaskPen, Tools.MaskBucket, Tools.MaskColorFill] + if (maskOnlyTools.includes(currentTool) && layer === 'rgb') { + this.setToolTo(Tools.PaintPen) + } + if (currentTool === Tools.PaintPen && layer === 'mask') { + this.setToolTo(Tools.MaskPen) + } + this.updateActiveLayerHighlight() + } + + updateActiveLayerHighlight() { + // Remove blue border from all containers + if (this.maskLayerContainer) { + this.maskLayerContainer.style.border = 'none' + } + if (this.paintLayerContainer) { + this.paintLayerContainer.style.border = 'none' + } + + // Add blue border to active layer container + if (this.activeLayer === 'mask' && this.maskLayerContainer) { + this.maskLayerContainer.style.border = '2px solid #007acc' + } else if (this.activeLayer === 'rgb' && this.paintLayerContainer) { + this.paintLayerContainer.style.border = '2px solid #007acc' + } + } + private async createImageLayerSettings() { const accentColor = this.darkMode ? 'maskEditor_accent_bg_dark' @@ -3069,10 +3291,29 @@ class UIManager { t('maskEditor.Layers') ) - const mask_layer_title = this.createContainerTitle( - t('maskEditor.Mask Layer') - ) + // Add a new container for layer selection + const layer_selection_container = this.createContainer(false) + layer_selection_container.classList.add(accentColor) + layer_selection_container.classList.add('maskEditor_layerRow') + this.layerButtons.mask.innerText = 'Activate Layer' + this.layerButtons.mask.addEventListener('click', async () => { + this.setActiveLayer('mask') + }) + + this.layerButtons.rgb.innerText = 'Activate Layer' + this.layerButtons.rgb.addEventListener('click', async () => { + this.setActiveLayer('rgb') + }) + + // Initially hide the buttons (they'll be shown when eraser tool is selected) + this.layerButtons.mask.style.display = 'none' + this.layerButtons.rgb.style.display = 'none' + + this.setActiveLayer('mask') + + // 1. MASK LAYER CONTAINER + const mask_layer_title = this.createContainerTitle('Mask Layer') const mask_layer_container = this.createContainer(false) mask_layer_container.classList.add(accentColor) mask_layer_container.classList.add('maskEditor_layerRow') @@ -3087,7 +3328,7 @@ class UIManager { if (!(event.target as HTMLInputElement)!.checked) { this.maskCanvas.style.opacity = '0' } else { - this.maskCanvas.style.opacity = String(this.mask_opacity) //change name + this.maskCanvas.style.opacity = String(this.mask_opacity) } }) @@ -3096,17 +3337,32 @@ class UIManager { 'maskEditor_sidePanelLayerPreviewContainer' ) mask_layer_image_container.innerHTML = - ' ' + ' ' + // Add checkbox, image container, and activate button to mask layer container + mask_layer_container.appendChild(mask_layer_visibility_checkbox) + mask_layer_container.appendChild(mask_layer_image_container) + mask_layer_container.appendChild(this.layerButtons.mask) + + // Store reference to container for highlighting + this.maskLayerContainer = mask_layer_container + + // 2. MASK BLENDING OPTIONS CONTAINER + const mask_blending_options_title = this.createContainerTitle( + 'Mask Blending Options' + ) + const mask_blending_options_container = this.createContainer(false) + // mask_blending_options_container.classList.add(accentColor) + mask_blending_options_container.classList.add('maskEditor_layerRow') + mask_blending_options_container.style.marginTop = '-9px' + mask_blending_options_container.style.marginBottom = '-6px' var blending_options = ['black', 'white', 'negative'] - const sidePanelDropdownAccent = this.darkMode ? 'maskEditor_sidePanelDropdown_dark' : 'maskEditor_sidePanelDropdown_light' var mask_layer_dropdown = document.createElement('select') mask_layer_dropdown.classList.add(sidePanelDropdownAccent) - mask_layer_dropdown.classList.add(sidePanelDropdownAccent) blending_options.forEach((option) => { var option_element = document.createElement('option') option_element.value = option @@ -3125,10 +3381,12 @@ class UIManager { this.updateMaskColor() }) - mask_layer_container.appendChild(mask_layer_visibility_checkbox) - mask_layer_container.appendChild(mask_layer_image_container) - mask_layer_container.appendChild(mask_layer_dropdown) + // Center the dropdown in its container + // mask_blending_options_container.style.display = 'flex' + // mask_blending_options_container.style.justifyContent = 'center' + mask_blending_options_container.appendChild(mask_layer_dropdown) + // 3. MASK OPACITY SLIDER const mask_layer_opacity_sliderObj = this.createSlider( t('maskEditor.Mask Opacity'), 0.0, @@ -3148,21 +3406,55 @@ class UIManager { ) this.maskOpacitySlider = mask_layer_opacity_sliderObj.slider - const image_layer_title = this.createContainerTitle( - t('maskEditor.Image Layer') + // 4. PAINT LAYER CONTAINER + const paint_layer_title = this.createContainerTitle('Paint Layer') + const paint_layer_container = this.createContainer(false) + paint_layer_container.classList.add(accentColor) + paint_layer_container.classList.add('maskEditor_layerRow') + + const paint_layer_checkbox = document.createElement('input') + paint_layer_checkbox.setAttribute('type', 'checkbox') + paint_layer_checkbox.classList.add('maskEditor_sidePanelLayerCheckbox') + paint_layer_checkbox.checked = true + paint_layer_checkbox.addEventListener('change', (event) => { + if (!(event.target as HTMLInputElement)!.checked) { + this.rgbCanvas.style.opacity = '0' + } else { + this.rgbCanvas.style.opacity = '1' + } + }) + + const paint_layer_image_container = document.createElement('div') + paint_layer_image_container.classList.add( + 'maskEditor_sidePanelLayerPreviewContainer' ) + paint_layer_image_container.innerHTML = ` + + + + + ` - const image_layer_container = this.createContainer(false) - image_layer_container.classList.add(accentColor) - image_layer_container.classList.add('maskEditor_layerRow') + paint_layer_container.appendChild(paint_layer_checkbox) + paint_layer_container.appendChild(paint_layer_image_container) + paint_layer_container.appendChild(this.layerButtons.rgb) - const image_layer_visibility_checkbox = document.createElement('input') - image_layer_visibility_checkbox.setAttribute('type', 'checkbox') - image_layer_visibility_checkbox.classList.add( + // Store reference to container for highlighting + this.paintLayerContainer = paint_layer_container + + // 5. BASE IMAGE LAYER CONTAINER + const base_image_layer_title = this.createContainerTitle('Base Image Layer') + const base_image_layer_container = this.createContainer(false) + base_image_layer_container.classList.add(accentColor) + base_image_layer_container.classList.add('maskEditor_layerRow') + + const base_image_layer_visibility_checkbox = document.createElement('input') + base_image_layer_visibility_checkbox.setAttribute('type', 'checkbox') + base_image_layer_visibility_checkbox.classList.add( 'maskEditor_sidePanelLayerCheckbox' ) - image_layer_visibility_checkbox.checked = true - image_layer_visibility_checkbox.addEventListener('change', (event) => { + base_image_layer_visibility_checkbox.checked = true + base_image_layer_visibility_checkbox.addEventListener('change', (event) => { if (!(event.target as HTMLInputElement)!.checked) { this.imgCanvas.style.opacity = '0' } else { @@ -3170,35 +3462,51 @@ class UIManager { } }) - const image_layer_image_container = document.createElement('div') - image_layer_image_container.classList.add( + const base_image_layer_image_container = document.createElement('div') + base_image_layer_image_container.classList.add( 'maskEditor_sidePanelLayerPreviewContainer' ) - const image_layer_image = document.createElement('img') - image_layer_image.id = 'maskEditor_sidePanelImageLayerImage' - image_layer_image.src = + const base_image_layer_image = document.createElement('img') + base_image_layer_image.id = 'maskEditor_sidePanelImageLayerImage' + base_image_layer_image.src = ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ?? '' - this.sidebarImage = image_layer_image + this.sidebarImage = base_image_layer_image - image_layer_image_container.appendChild(image_layer_image) + base_image_layer_image_container.appendChild(base_image_layer_image) - image_layer_container.appendChild(image_layer_visibility_checkbox) - image_layer_container.appendChild(image_layer_image_container) + base_image_layer_container.appendChild(base_image_layer_visibility_checkbox) + base_image_layer_container.appendChild(base_image_layer_image_container) + // APPEND ALL CONTAINERS IN ORDER image_layer_settings_container.appendChild(image_layer_settings_title) - image_layer_settings_container.appendChild(mask_layer_title) - image_layer_settings_container.appendChild(mask_layer_container) image_layer_settings_container.appendChild( mask_layer_opacity_sliderObj.container ) - image_layer_settings_container.appendChild(image_layer_title) - image_layer_settings_container.appendChild(image_layer_container) + image_layer_settings_container.appendChild(mask_blending_options_title) + image_layer_settings_container.appendChild(mask_blending_options_container) + image_layer_settings_container.appendChild(mask_layer_title) + image_layer_settings_container.appendChild(mask_layer_container) + image_layer_settings_container.appendChild(paint_layer_title) + image_layer_settings_container.appendChild(paint_layer_container) + image_layer_settings_container.appendChild(base_image_layer_title) + image_layer_settings_container.appendChild(base_image_layer_container) + + // Initialize the active layer highlighting + this.updateActiveLayerHighlight() + + // Initialize button visibility based on current tool + this.updateLayerButtonsForTool() return image_layer_settings_container } + // Method to be called when tool changes + async onToolChange() { + await this.updateLayerButtonsForTool() + } + private createHeadline(title: string) { var headline = document.createElement('h3') headline.classList.add('maskEditor_sidePanelTitle') @@ -3236,7 +3544,6 @@ class UIManager { ) { var slider_container = this.createContainer(true) var slider_title = this.createContainerTitle(title) - var slider = document.createElement('input') slider.classList.add('maskEditor_sidePanelBrushRange') slider.setAttribute('type', 'range') @@ -3392,6 +3699,7 @@ class UIManager { this.maskCanvas.width, this.maskCanvas.height ) + this.rgbCtx.clearRect(0, 0, this.rgbCanvas.width, this.rgbCanvas.height) this.messageBroker.publish('saveState') }) @@ -3427,6 +3735,56 @@ class UIManager { return top_bar } + toolElements: HTMLElement[] = [] + toolSettings: Record = { + [Tools.MaskPen]: { + container: document.createElement('div'), + newActiveLayerOnSet: 'mask' + }, + [Tools.Eraser]: { + container: document.createElement('div') + }, + [Tools.PaintPen]: { + container: document.createElement('div'), + newActiveLayerOnSet: 'rgb' + }, + [Tools.MaskBucket]: { + container: document.createElement('div'), + cursor: "url('/cursor/paintBucket.png') 30 25, auto", + newActiveLayerOnSet: 'mask' + }, + [Tools.MaskColorFill]: { + container: document.createElement('div'), + cursor: "url('/cursor/colorSelect.png') 15 25, auto", + newActiveLayerOnSet: 'mask' + } + } + + setToolTo(tool: Tools) { + this.messageBroker.publish('setTool', tool) + for (let toolElement of this.toolElements) { + if (toolElement != this.toolSettings[tool].container) { + toolElement.classList.remove('maskEditor_toolPanelContainerSelected') + } else { + toolElement.classList.add('maskEditor_toolPanelContainerSelected') + this.brushSettingsHTML.style.display = 'flex' + this.colorSelectSettingsHTML.style.display = 'none' + this.paintBucketSettingsHTML.style.display = 'none' + } + } + this.messageBroker.publish('setTool', tool) + this.onToolChange() + const newActiveLayer = this.toolSettings[tool].newActiveLayerOnSet + if (newActiveLayer) { + this.setActiveLayer(newActiveLayer) + } + const cursor = this.toolSettings[tool].cursor + this.pointerZone.style.cursor = cursor ?? 'none' + if (cursor) { + this.brush.style.opacity = '0' + } + } + private createToolPanel() { var tool_panel = document.createElement('div') tool_panel.id = 'maskEditor_toolPanel' @@ -3435,194 +3793,54 @@ class UIManager { ? 'maskEditor_toolPanelContainerDark' : 'maskEditor_toolPanelContainerLight' - var toolElements: HTMLElement[] = [] + this.toolElements = [] + // mask pen tool + const setupToolContainer = (tool: Tools) => { + this.toolSettings[tool].container = document.createElement('div') + this.toolSettings[tool].container.classList.add( + 'maskEditor_toolPanelContainer' + ) + if (tool == Tools.MaskPen) + this.toolSettings[tool].container.classList.add( + 'maskEditor_toolPanelContainerSelected' + ) + this.toolSettings[tool].container.classList.add(toolPanelHoverAccent) + this.toolSettings[tool].container.innerHTML = iconsHtml[tool] + this.toolElements.push(this.toolSettings[tool].container) + this.toolSettings[tool].container.addEventListener('click', () => { + this.setToolTo(tool) + }) + const activeIndicator = document.createElement('div') + activeIndicator.classList.add('maskEditor_toolPanelIndicator') + this.toolSettings[tool].container.appendChild(activeIndicator) + tool_panel.appendChild(this.toolSettings[tool].container) + } + allTools.forEach(setupToolContainer) - //brush tool + const setupZoomIndicatorContainer = () => { + var toolPanel_zoomIndicator = document.createElement('div') + toolPanel_zoomIndicator.classList.add('maskEditor_toolPanelZoomIndicator') + toolPanel_zoomIndicator.classList.add(toolPanelHoverAccent) - var toolPanel_brushToolContainer = document.createElement('div') - toolPanel_brushToolContainer.classList.add('maskEditor_toolPanelContainer') - toolPanel_brushToolContainer.classList.add( - 'maskEditor_toolPanelContainerSelected' - ) - toolPanel_brushToolContainer.classList.add(toolPanelHoverAccent) - toolPanel_brushToolContainer.innerHTML = ` - - - - - ` - toolElements.push(toolPanel_brushToolContainer) + var toolPanel_zoomText = document.createElement('span') + toolPanel_zoomText.id = 'maskEditor_toolPanelZoomText' + toolPanel_zoomText.innerText = '100%' + this.zoomTextHTML = toolPanel_zoomText - toolPanel_brushToolContainer.addEventListener('click', () => { - //move logic to tool manager - this.messageBroker.publish('setTool', Tools.Pen) - for (let toolElement of toolElements) { - if (toolElement != toolPanel_brushToolContainer) { - toolElement.classList.remove('maskEditor_toolPanelContainerSelected') - } else { - toolElement.classList.add('maskEditor_toolPanelContainerSelected') - this.brushSettingsHTML.style.display = 'flex' - this.colorSelectSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'none' - } - } - this.messageBroker.publish('setTool', Tools.Pen) - this.pointerZone.style.cursor = 'none' - }) + var toolPanel_DimensionsText = document.createElement('span') + toolPanel_DimensionsText.id = 'maskEditor_toolPanelDimensionsText' + toolPanel_DimensionsText.innerText = ' ' + this.dimensionsTextHTML = toolPanel_DimensionsText - var toolPanel_brushToolIndicator = document.createElement('div') - toolPanel_brushToolIndicator.classList.add('maskEditor_toolPanelIndicator') + toolPanel_zoomIndicator.appendChild(toolPanel_zoomText) + toolPanel_zoomIndicator.appendChild(toolPanel_DimensionsText) - toolPanel_brushToolContainer.appendChild(toolPanel_brushToolIndicator) - - //eraser tool - - var toolPanel_eraserToolContainer = document.createElement('div') - toolPanel_eraserToolContainer.classList.add('maskEditor_toolPanelContainer') - toolPanel_eraserToolContainer.classList.add(toolPanelHoverAccent) - toolPanel_eraserToolContainer.innerHTML = ` - - - - - - - - ` - toolElements.push(toolPanel_eraserToolContainer) - - toolPanel_eraserToolContainer.addEventListener('click', () => { - //move logic to tool manager - this.messageBroker.publish('setTool', Tools.Eraser) - for (let toolElement of toolElements) { - if (toolElement != toolPanel_eraserToolContainer) { - toolElement.classList.remove('maskEditor_toolPanelContainerSelected') - } else { - toolElement.classList.add('maskEditor_toolPanelContainerSelected') - this.brushSettingsHTML.style.display = 'flex' - this.colorSelectSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'none' - } - } - this.messageBroker.publish('setTool', Tools.Eraser) - this.pointerZone.style.cursor = 'none' - }) - - var toolPanel_eraserToolIndicator = document.createElement('div') - toolPanel_eraserToolIndicator.classList.add('maskEditor_toolPanelIndicator') - - toolPanel_eraserToolContainer.appendChild(toolPanel_eraserToolIndicator) - - //paint bucket tool - - var toolPanel_paintBucketToolContainer = document.createElement('div') - toolPanel_paintBucketToolContainer.classList.add( - 'maskEditor_toolPanelContainer' - ) - toolPanel_paintBucketToolContainer.classList.add(toolPanelHoverAccent) - toolPanel_paintBucketToolContainer.innerHTML = ` - - - - - - ` - toolElements.push(toolPanel_paintBucketToolContainer) - - toolPanel_paintBucketToolContainer.addEventListener('click', () => { - //move logic to tool manager - this.messageBroker.publish('setTool', Tools.PaintBucket) - for (let toolElement of toolElements) { - if (toolElement != toolPanel_paintBucketToolContainer) { - toolElement.classList.remove('maskEditor_toolPanelContainerSelected') - } else { - toolElement.classList.add('maskEditor_toolPanelContainerSelected') - this.brushSettingsHTML.style.display = 'none' - this.colorSelectSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'flex' - } - } - this.messageBroker.publish('setTool', Tools.PaintBucket) - this.pointerZone.style.cursor = - "url('/cursor/paintBucket.png') 30 25, auto" - this.brush.style.opacity = '0' - }) - - var toolPanel_paintBucketToolIndicator = document.createElement('div') - toolPanel_paintBucketToolIndicator.classList.add( - 'maskEditor_toolPanelIndicator' - ) - - toolPanel_paintBucketToolContainer.appendChild( - toolPanel_paintBucketToolIndicator - ) - - //color select tool - - var toolPanel_colorSelectToolContainer = document.createElement('div') - toolPanel_colorSelectToolContainer.classList.add( - 'maskEditor_toolPanelContainer' - ) - toolPanel_colorSelectToolContainer.classList.add(toolPanelHoverAccent) - toolPanel_colorSelectToolContainer.innerHTML = ` - - - - ` - toolElements.push(toolPanel_colorSelectToolContainer) - toolPanel_colorSelectToolContainer.addEventListener('click', () => { - this.messageBroker.publish('setTool', 'colorSelect') - for (let toolElement of toolElements) { - if (toolElement != toolPanel_colorSelectToolContainer) { - toolElement.classList.remove('maskEditor_toolPanelContainerSelected') - } else { - toolElement.classList.add('maskEditor_toolPanelContainerSelected') - this.brushSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'none' - this.colorSelectSettingsHTML.style.display = 'flex' - } - } - this.messageBroker.publish('setTool', Tools.ColorSelect) - this.pointerZone.style.cursor = - "url('/cursor/colorSelect.png') 15 25, auto" - this.brush.style.opacity = '0' - }) - - var toolPanel_colorSelectToolIndicator = document.createElement('div') - toolPanel_colorSelectToolIndicator.classList.add( - 'maskEditor_toolPanelIndicator' - ) - toolPanel_colorSelectToolContainer.appendChild( - toolPanel_colorSelectToolIndicator - ) - - //zoom indicator - var toolPanel_zoomIndicator = document.createElement('div') - toolPanel_zoomIndicator.classList.add('maskEditor_toolPanelZoomIndicator') - toolPanel_zoomIndicator.classList.add(toolPanelHoverAccent) - - var toolPanel_zoomText = document.createElement('span') - toolPanel_zoomText.id = 'maskEditor_toolPanelZoomText' - toolPanel_zoomText.innerText = '100%' - this.zoomTextHTML = toolPanel_zoomText - - var toolPanel_DimensionsText = document.createElement('span') - toolPanel_DimensionsText.id = 'maskEditor_toolPanelDimensionsText' - toolPanel_DimensionsText.innerText = ' ' - this.dimensionsTextHTML = toolPanel_DimensionsText - - toolPanel_zoomIndicator.appendChild(toolPanel_zoomText) - toolPanel_zoomIndicator.appendChild(toolPanel_DimensionsText) - - toolPanel_zoomIndicator.addEventListener('click', () => { - this.messageBroker.publish('resetZoom') - }) - - tool_panel.appendChild(toolPanel_brushToolContainer) - tool_panel.appendChild(toolPanel_eraserToolContainer) - tool_panel.appendChild(toolPanel_paintBucketToolContainer) - tool_panel.appendChild(toolPanel_colorSelectToolContainer) - tool_panel.appendChild(toolPanel_zoomIndicator) + toolPanel_zoomIndicator.addEventListener('click', () => { + this.messageBroker.publish('resetZoom') + }) + tool_panel.appendChild(toolPanel_zoomIndicator) + } + setupZoomIndicatorContainer() return tool_panel } @@ -3674,14 +3892,25 @@ class UIManager { } async screenToCanvas(clientPoint: Point): Promise { - // Get the bounding rectangles for both elements + // Get the zoom ratio const zoomRatio = await this.messageBroker.pull('zoomRatio') - const canvasRect = this.maskCanvas.getBoundingClientRect() + + // Get the bounding rectangles for both canvases + const maskCanvasRect = this.maskCanvas.getBoundingClientRect() + const rgbCanvasRect = this.rgbCanvas.getBoundingClientRect() + + // Check which canvas is currently being used for drawing + const currentTool = await this.messageBroker.pull('currentTool') + const isUsingRGBCanvas = currentTool === Tools.PaintPen + + // Use the appropriate canvas rect based on the current tool + const canvasRect = isUsingRGBCanvas ? rgbCanvasRect : maskCanvasRect // Calculate the offset between pointer zone and canvas const offsetX = clientPoint.x - canvasRect.left + this.toolPanel.clientWidth const offsetY = clientPoint.y - canvasRect.top + 44 // 44 is the height of the top menu + // Adjust for zoom ratio const x = offsetX / zoomRatio const y = offsetY / zoomRatio @@ -3693,6 +3922,10 @@ class UIManager { event.preventDefault() }) + this.rgbCanvas.addEventListener('contextmenu', (event: Event) => { + event.preventDefault() + }) + this.rootElement.addEventListener('contextmenu', (event: Event) => { event.preventDefault() }) @@ -3725,32 +3958,56 @@ class UIManager { const maskCtx = this.maskCtx const maskCanvas = this.maskCanvas + const rgbCanvas = this.rgbCanvas + imgCtx!.clearRect(0, 0, this.imgCanvas.width, this.imgCanvas.height) maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height) - const alpha_url = new URL( - ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ?? - '' - ) - alpha_url.searchParams.delete('channel') - alpha_url.searchParams.delete('preview') - alpha_url.searchParams.set('channel', 'a') - let mask_image: HTMLImageElement = await this.loadImage(alpha_url) + const mainImageUrl = + ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src // original image load - if ( - !ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src - ) { + if (!mainImageUrl) { throw new Error( 'Unable to access image source - clipspace or image is null' ) } - const rgb_url = new URL( - ComfyApp.clipspace.imgs[ComfyApp.clipspace.selectedIndex].src - ) + const mainImageFilename = + new URL(mainImageUrl).searchParams.get('filename') ?? undefined + + const combinedImageFilename = + ComfyApp.clipspace?.combinedIndex !== undefined && + ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace.combinedIndex]?.src + ? new URL( + ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src + ).searchParams.get('filename') + : undefined + + const imageLayerFilenames = + mainImageFilename !== undefined + ? imageLayerFilenamesIfApplicable( + combinedImageFilename ?? mainImageFilename + ) + : undefined + + const inputUrls = { + baseImagePlusMask: imageLayerFilenames?.maskedImage + ? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) }) + : mainImageUrl, + paintLayer: imageLayerFilenames?.paint + ? mkFileUrl({ ref: toRef(imageLayerFilenames.paint) }) + : undefined + } + + const alpha_url = new URL(inputUrls.baseImagePlusMask) + alpha_url.searchParams.delete('channel') + alpha_url.searchParams.delete('preview') + alpha_url.searchParams.set('channel', 'a') + let mask_image: HTMLImageElement = await this.loadImage(alpha_url) + + const rgb_url = new URL(inputUrls.baseImagePlusMask) this.imageURL = rgb_url - console.log(rgb_url) rgb_url.searchParams.delete('channel') rgb_url.searchParams.set('channel', 'rgb') this.image = new Image() @@ -3762,18 +4019,35 @@ class UIManager { img.src = rgb_url.toString() }) + if (inputUrls.paintLayer) { + const paintURL = new URL(inputUrls.paintLayer) + this.paint_image = new Image() + this.paint_image = await new Promise( + (resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = reject + img.src = paintURL.toString() + } + ) + } + maskCanvas.width = this.image.width maskCanvas.height = this.image.height + rgbCanvas.width = this.image.width + rgbCanvas.height = this.image.height + this.dimensionsTextHTML.innerText = `${this.image.width}x${this.image.height}` - await this.invalidateCanvas(this.image, mask_image) + await this.invalidateCanvas(this.image, mask_image, this.paint_image) this.messageBroker.publish('initZoomPan', [this.image, this.rootElement]) } async invalidateCanvas( orig_image: HTMLImageElement, - mask_image: HTMLImageElement + mask_image: HTMLImageElement, + paint_image: HTMLImageElement ) { this.imgCanvas.width = orig_image.width this.imgCanvas.height = orig_image.height @@ -3781,12 +4055,27 @@ class UIManager { this.maskCanvas.width = orig_image.width this.maskCanvas.height = orig_image.height + this.rgbCanvas.width = orig_image.width + this.rgbCanvas.height = orig_image.height + let imgCtx = this.imgCanvas.getContext('2d', { willReadFrequently: true }) let maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }) + let rgbCtx = this.rgbCanvas.getContext('2d', { + willReadFrequently: true + }) imgCtx!.drawImage(orig_image, 0, 0, orig_image.width, orig_image.height) + if (paint_image) { + rgbCtx!.drawImage( + paint_image, + 0, + 0, + paint_image.width, + paint_image.height + ) + } await this.prepare_mask( mask_image, this.maskCanvas, @@ -3962,6 +4251,10 @@ class UIManager { return this.imgCanvas } + getRgbCanvas() { + return this.rgbCanvas + } + getImage() { return this.image } @@ -4005,11 +4298,11 @@ class UIManager { async updateCursor() { const currentTool = await this.messageBroker.pull('currentTool') - if (currentTool === Tools.PaintBucket) { + if (currentTool === Tools.MaskBucket) { this.pointerZone.style.cursor = "url('/cursor/paintBucket.png') 30 25, auto" this.setBrushOpacity(0) - } else if (currentTool === Tools.ColorSelect) { + } else if (currentTool === Tools.MaskColorFill) { this.pointerZone.style.cursor = "url('/cursor/colorSelect.png') 15 25, auto" this.setBrushOpacity(0) @@ -4036,7 +4329,7 @@ class ToolManager { messageBroker: MessageBroker mouseDownPoint: Point | null = null - currentTool: Tools = Tools.Pen + currentTool: Tools = Tools.MaskPen isAdjustingBrush: boolean = false // is user adjusting brush size or hardness with alt + right mouse button constructor(maskEditor: MaskEditorDialog) { @@ -4079,7 +4372,7 @@ class ToolManager { setTool(tool: Tools) { this.currentTool = tool - if (tool != Tools.ColorSelect) { + if (tool != Tools.MaskColorFill) { this.messageBroker.publish('clearLastPoint') } } @@ -4101,8 +4394,21 @@ class ToolManager { return } + // RGB painting + if (this.currentTool === Tools.PaintPen && event.button === 0) { + this.messageBroker.publish('drawStart', event) + this.messageBroker.publish('saveState') + return + } + + // RGB painting + if (this.currentTool === Tools.PaintPen && event.buttons === 1) { + this.messageBroker.publish('draw', event) + return + } + //paint bucket - if (this.currentTool === Tools.PaintBucket && event.button === 0) { + if (this.currentTool === Tools.MaskBucket && event.button === 0) { const offset = { x: event.offsetX, y: event.offsetY } const coords_canvas = await this.messageBroker.pull( 'screenToCanvas', @@ -4113,7 +4419,7 @@ class ToolManager { return } - if (this.currentTool === Tools.ColorSelect && event.button === 0) { + if (this.currentTool === Tools.MaskColorFill && event.button === 0) { const offset = { x: event.offsetX, y: event.offsetY } const coords_canvas = await this.messageBroker.pull( 'screenToCanvas', @@ -4130,7 +4436,9 @@ class ToolManager { return } - var isDrawingTool = [Tools.Pen, Tools.Eraser].includes(this.currentTool) + var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes( + this.currentTool + ) //drawing if ([0, 2].includes(event.button) && isDrawingTool) { this.messageBroker.publish('drawStart', event) @@ -4155,13 +4463,16 @@ class ToolManager { //prevent drawing with other tools - var isDrawingTool = [Tools.Pen, Tools.Eraser].includes(this.currentTool) + var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes( + this.currentTool + ) if (!isDrawingTool) return // alt + right mouse button hold brush adjustment if ( this.isAdjustingBrush && - (this.currentTool === Tools.Pen || this.currentTool === Tools.Eraser) && + (this.currentTool === Tools.MaskPen || + this.currentTool === Tools.Eraser) && event.altKey && event.buttons === 2 ) { @@ -4213,6 +4524,7 @@ class PanAndZoomManager { canvasContainer: HTMLElement | null = null maskCanvas: HTMLCanvasElement | null = null + rgbCanvas: HTMLCanvasElement | null = null rootElement: HTMLElement | null = null image: HTMLImageElement | null = null @@ -4641,6 +4953,22 @@ class PanAndZoomManager { left: `${this.pan_offset.x}px`, top: `${this.pan_offset.y}px` }) + + this.rgbCanvas = await this.messageBroker.pull('rgbCanvas') + if (this.rgbCanvas) { + // Ensure the canvas has the proper dimensions + if ( + this.rgbCanvas.width !== this.image.width || + this.rgbCanvas.height !== this.image.height + ) { + this.rgbCanvas.width = this.image.width + this.rgbCanvas.height = this.image.height + } + + // Make sure the style dimensions match the container + this.rgbCanvas.style.width = `${raw_width}px` + this.rgbCanvas.style.height = `${raw_height}px` + } } private handlePanStart(event: PointerEvent) { @@ -4711,6 +5039,7 @@ class MessageBroker { this.createPushTopic('setBrushShape') this.createPushTopic('initZoomPan') this.createPushTopic('setTool') + this.createPushTopic('setActiveLayer') this.createPushTopic('pointerDown') this.createPushTopic('pointerMove') this.createPushTopic('pointerUp') @@ -4734,6 +5063,8 @@ class MessageBroker { this.createPushTopic('setZoomText') this.createPushTopic('resetZoom') this.createPushTopic('invert') + this.createPushTopic('setRGBColor') + this.createPushTopic('paintedurl') this.createPushTopic('setSelectionOpacity') this.createPushTopic('setFillOpacity') } @@ -4850,6 +5181,7 @@ class MessageBroker { class KeyboardManager { private keysDown: string[] = [] + // @ts-expect-error unused variable private maskEditor: MaskEditorDialog private messageBroker: MessageBroker @@ -5005,12 +5337,23 @@ app.registerExtension({ selectedNode.previewMediaType !== 'image' ) return - ComfyApp.copyToClipspace(selectedNode) // @ts-expect-error clipspace_return_node is an extension property added at runtime ComfyApp.clipspace_return_node = selectedNode openMaskEditor() } + }, + { + id: 'Comfy.MaskEditor.BrushSize.Increase', + icon: 'pi pi-plus-circle', + label: 'Increase Brush Size in MaskEditor', + function: () => changeBrushSize((old) => _.clamp(old + 4, 1, 100)) + }, + { + id: 'Comfy.MaskEditor.BrushSize.Decrease', + icon: 'pi pi-minus-circle', + label: 'Decrease Brush Size in MaskEditor', + function: () => changeBrushSize((old) => _.clamp(old - 4, 1, 100)) } ], init() { @@ -5024,3 +5367,180 @@ app.registerExtension({ ) } }) + +const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => { + if (!isOpened()) return + const maskEditor = MaskEditorDialog.getInstance() + if (!maskEditor) return + const messageBroker = maskEditor.getMessageBroker() + const oldBrushSize = (await messageBroker.pull('brushSettings')).size + const newBrushSize = sizeChanger(oldBrushSize) + messageBroker.publish('setBrushSize', newBrushSize) + messageBroker.publish('updateBrushPreview') +} + +const requestWithRetries = async ( + mkRequest: () => Promise, + maxRetries: number = 3 +): Promise<{ success: boolean }> => { + let attempt = 0 + let success = false + while (attempt < maxRetries && !success) { + try { + const response = await mkRequest() + if (response.ok) { + success = true + } else { + console.log('Failed to upload mask:', response) + } + } catch (error) { + console.error(`Upload attempt ${attempt + 1} failed:`, error) + attempt++ + if (attempt < maxRetries) { + console.log('Retrying upload...') + } else { + console.log('Max retries reached. Upload failed.') + } + } + } + return { success } +} + +const isAlphaValue = (index: number) => index % 4 === 3 + +const removeImageRgbValuesAndInvertAlpha = (imageData: Uint8ClampedArray) => + imageData.map((val, i) => (isAlphaValue(i) ? 255 - val : 0)) + +type Ref = { filename: string; subfolder?: string; type?: string } + +/** + * Note: the images' positions are important here. What the positions mean is hardcoded in `src/scripts/app.ts` in the `copyToClipspace` method. + * - `newMainOutput` should be the fully composited image: base image + mask (in the alpha channel) + paint. + * - The first array element of `extraImagesShownButNotOutputted` should be JUST the paint layer, with a transparent background. + * - It is possible to add more images in the clipspace array, but is not useful currently. + * With this configuration, the MaskEditor will properly load the paint layer separately from the base image, ensuring it is editable. + * */ +const replaceClipspaceImages = ( + newMainOutput: Ref, + otherImagesInClipspace?: Ref[] +) => { + try { + if (!ComfyApp?.clipspace?.widgets?.length) return + const firstImageWidgetIndex = ComfyApp.clipspace.widgets.findIndex( + (obj) => obj?.name === 'image' + ) + const firstImageWidget = ComfyApp.clipspace.widgets[firstImageWidgetIndex] + if (!firstImageWidget) return + + ComfyApp!.clipspace!.widgets![firstImageWidgetIndex].value = newMainOutput + + otherImagesInClipspace?.forEach((extraImage, extraImageIndex) => { + const extraImageWidgetIndex = firstImageWidgetIndex + extraImageIndex + 1 + ComfyApp!.clipspace!.widgets![extraImageWidgetIndex].value = extraImage + }) + } catch (err) { + console.warn('Failed to set widget value:', err) + } +} + +const ensureImageFullyLoaded = (src: string) => + new Promise((resolve, reject) => { + const maskImage = new Image() + maskImage.src = src + maskImage.onload = () => resolve() + maskImage.onerror = reject + }) + +const createCanvasCopy = ( + canvas: HTMLCanvasElement +): [HTMLCanvasElement, CanvasRenderingContext2D] => { + const newCanvas = document.createElement('canvas') + const newCanvasCtx = getCanvas2dContext(newCanvas) + newCanvas.width = canvas.width + newCanvas.height = canvas.height + newCanvasCtx.clearRect(0, 0, canvas.width, canvas.height) + newCanvasCtx.drawImage( + canvas, + 0, + 0, + canvas.width, + canvas.height, + 0, + 0, + canvas.width, + canvas.height + ) + return [newCanvas, newCanvasCtx] +} + +const getCanvas2dContext = ( + canvas: HTMLCanvasElement +): CanvasRenderingContext2D => { + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + // Safe with the way we use canvases + if (!ctx) throw new Error('Failed to get 2D context from canvas') + return ctx +} + +const combineOriginalImageAndPaint = ( + canvases: Record<'originalImage' | 'paint', HTMLCanvasElement> +): [HTMLCanvasElement, CanvasRenderingContext2D] => { + const { originalImage, paint } = canvases + const [resultCanvas, resultCanvasCtx] = createCanvasCopy(originalImage) + resultCanvasCtx.drawImage(paint, 0, 0) + return [resultCanvas, resultCanvasCtx] +} + +const iconsHtml: Record = { + [Tools.MaskPen]: ` + + + + `, + [Tools.Eraser]: ` + + + + + + + + `, + [Tools.MaskBucket]: ` + + + + + + `, + [Tools.MaskColorFill]: ` + + + + `, + [Tools.PaintPen]: ` + + + + + ` +} + +const toRef = (filename: string): Ref => ({ + filename, + subfolder: 'clipspace', + type: 'input' +}) + +const mkFileUrl = (props: { ref: Ref; preview?: boolean }) => { + const pathPlusQueryParams = api.apiURL( + '/view?' + + new URLSearchParams(props.ref).toString() + + app.getPreviewFormatParam() + + app.getRandParam() + ) + const imageElement = new Image() + imageElement.src = pathPlusQueryParams + const fullyResolvedUrl = imageElement.src + return fullyResolvedUrl +} diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 257a30cf6..6fb3c61e7 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -116,6 +116,8 @@ type Clipspace = { images?: any[] | null selectedIndex: number img_paste_mode: string + paintedIndex: number + combinedIndex: number } export class ComfyApp { @@ -357,13 +359,18 @@ export class ComfyApp { selectedIndex = node.imageIndex } + const paintedIndex = selectedIndex + 1 + const combinedIndex = selectedIndex + 2 + ComfyApp.clipspace = { widgets: widgets, imgs: imgs, original_imgs: orig_imgs, images: node.images, selectedIndex: selectedIndex, - img_paste_mode: 'selected' // reset to default im_paste_mode state on copy action + img_paste_mode: 'selected', // reset to default im_paste_mode state on copy action + paintedIndex: paintedIndex, + combinedIndex: combinedIndex } ComfyApp.clipspace_return_node = null @@ -376,6 +383,8 @@ export class ComfyApp { static pasteFromClipspace(node: LGraphNode) { if (ComfyApp.clipspace) { // image paste + const combinedImgSrc = + ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex].src if (ComfyApp.clipspace.imgs && node.imgs) { if (node.images && ComfyApp.clipspace.images) { if (ComfyApp.clipspace['img_paste_mode'] == 'selected') { @@ -409,6 +418,28 @@ export class ComfyApp { } } + // Paste the RGB canvas if paintedindex exists + if ( + ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.paintedIndex] && + node.imgs + ) { + const paintedImg = new Image() + paintedImg.src = + ComfyApp.clipspace.imgs[ComfyApp.clipspace.paintedIndex].src + node.imgs.push(paintedImg) // Add the RGB canvas to the node's images + } + + // Store only combined image inside the node if it exists + if ( + ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex] && + node.imgs && + combinedImgSrc + ) { + const combinedImg = new Image() + combinedImg.src = combinedImgSrc + node.imgs = [combinedImg] + } + if (node.widgets) { if (ComfyApp.clipspace.images) { const clip_image = diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index fc3726fcc..29fd535da 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -787,7 +787,7 @@ export const useLitegraphService = () => { if (isImageNode(this)) { options.push({ - content: 'Open in MaskEditor', + content: 'Open in MaskEditor | Image Canvas', callback: () => { ComfyApp.copyToClipspace(this) // @ts-expect-error fixme ts strict error diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts index 75e8622a4..c57bc14ad 100644 --- a/src/utils/colorUtil.ts +++ b/src/utils/colorUtil.ts @@ -40,7 +40,7 @@ function rgbToHsl({ r, g, b }: RGB): HSL { return { h, s, l } } -function hexToRgb(hex: string): RGB { +export function hexToRgb(hex: string): RGB { let r = 0, g = 0, b = 0 diff --git a/tests-ui/tests/maskeditor.test.ts b/tests-ui/tests/maskeditor.test.ts new file mode 100644 index 000000000..d3fad6e33 --- /dev/null +++ b/tests-ui/tests/maskeditor.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' + +import { imageLayerFilenamesIfApplicable } from '@/extensions/core/maskEditorLayerFilenames' + +describe('imageLayerFilenamesIfApplicable', () => { + // In case the naming scheme changes, this test will ensure CI fails if developers forget to support the old naming scheme. (Causing MaskEditor to lose layer data for previously-saved images.) + it('should support all past layer naming schemes to preserve backward compatibility', async () => { + const dummyTimestamp = 1234567890 + const inputToSupport = `clipspace-painted-masked-${dummyTimestamp}.png` + const expectedOutput = { + maskedImage: `clipspace-mask-${dummyTimestamp}.png`, + paint: `clipspace-paint-${dummyTimestamp}.png`, + paintedImage: `clipspace-painted-${dummyTimestamp}.png`, + paintedMaskedImage: inputToSupport + } + const actualOutput = imageLayerFilenamesIfApplicable(inputToSupport) + expect(actualOutput).toEqual(expectedOutput) + }) +})