feat: support mask editor in comfyui cloud

- use response from /api/upload/mask to find mask layers
- query for /api/files/mask-layers when making additional edits
This commit is contained in:
Richard Yu
2025-10-10 16:17:31 -07:00
parent 5869b04e57
commit 963741f554
3 changed files with 271 additions and 40 deletions

View File

@@ -996,7 +996,8 @@ class MaskEditorDialog extends ComfyDialog {
const image = this.uiManager.getImage()
try {
await ensureImageFullyLoaded(maskCanvas.toDataURL())
const maskDataURL = maskCanvas.toDataURL()
await ensureImageFullyLoaded(maskDataURL)
} catch (error) {
console.error('Error loading mask image:', error)
return
@@ -1036,22 +1037,38 @@ class MaskEditorDialog extends ComfyDialog {
paint: paintCanvas
})
replaceClipspaceImages(refs.paintedMaskedImage, [refs.paint])
const originalImageUrl = new URL(image.src)
this.uiManager.setBrushOpacity(0)
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."
)
// Try to get the actual hash-based filename from clipspace.images first
// This ensures we use the hash returned from upload, not the friendly name
let originalImageRef: Partial<Ref>
const originalImageRef: Partial<Ref> = {
filename: originalImageFilename,
subfolder: originalImageUrl.searchParams.get('subfolder') ?? undefined,
type: originalImageUrl.searchParams.get('type') ?? undefined
const paintedIndex = ComfyApp.clipspace?.paintedIndex
const storedImageRef = ComfyApp.clipspace?.images?.[paintedIndex ?? 0]
if (
storedImageRef &&
typeof storedImageRef === 'object' &&
'filename' in storedImageRef
) {
// Use the stored reference which has the hash-based filename
originalImageRef = storedImageRef
} else {
// Fallback to URL parsing (legacy behavior)
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."
)
originalImageRef = {
filename: originalImageFilename,
subfolder: originalImageUrl.searchParams.get('subfolder') ?? undefined,
type: originalImageUrl.searchParams.get('type') ?? undefined
}
}
const mkFormData = (
@@ -1105,7 +1122,18 @@ class MaskEditorDialog extends ComfyDialog {
'selectedIndex'
)
await this.uploadImage(refs.paint, formDatas.paint)
await this.uploadImage(refs.paintedImage, formDatas.paintedImage, false)
const uploadedPaintedRef = await this.uploadImage(
refs.paintedImage,
formDatas.paintedImage,
false
)
// Update the formData for paintedMaskedImage to use the actual hash returned from backend
// This is critical - without this, original_ref points to timestamp-based filename that doesn't exist
formDatas.paintedMaskedImage.set(
'original_ref',
JSON.stringify(uploadedPaintedRef)
)
// 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: <https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData#data_loss_due_to_browser_optimization>
@@ -1117,10 +1145,27 @@ class MaskEditorDialog extends ComfyDialog {
'combinedIndex'
)
// Now update clipspace widgets with the actual hash-based filenames
// that were stored in clipspace.images during upload
if (ComfyApp.clipspace?.images) {
const actualPaintedMasked =
ComfyApp.clipspace.images[ComfyApp.clipspace.combinedIndex ?? 2]
const actualPaint =
ComfyApp.clipspace.images[ComfyApp.clipspace.paintedIndex ?? 1]
if (actualPaintedMasked) {
// Update widgets with actual uploaded refs (with hash filenames)
replaceClipspaceImages(
actualPaintedMasked,
actualPaint ? [actualPaint] : undefined
)
}
}
ComfyApp.onClipspaceEditorSave()
this.destroy()
} catch (error) {
console.error('Error during upload:', error)
console.error('[MaskEditor] Error during upload:', error)
this.uiManager.setSaveButtonText(t('g.save'))
this.uiManager.setSaveButtonEnabled(true)
this.keyboardManager.addListeners()
@@ -1149,7 +1194,7 @@ class MaskEditorDialog extends ComfyDialog {
formData: FormData,
isPaintLayer = true
) {
const success = await requestWithRetries(() =>
const { success, data } = await requestWithRetries(() =>
api.fetchApi('/upload/image', {
method: 'POST',
body: formData
@@ -1159,27 +1204,38 @@ class MaskEditorDialog extends ComfyDialog {
throw new Error('Upload failed.')
}
// Use actual response data if available, otherwise fall back to filepath
const actualFilepath: Ref = data?.name
? {
filename: data.name,
subfolder: data.subfolder || filepath.subfolder,
type: data.type || filepath.type
}
: filepath
if (!isPaintLayer) {
ClipspaceDialog.invalidatePreview()
return success
return actualFilepath
}
try {
const paintedIndex = ComfyApp.clipspace?.paintedIndex
if (ComfyApp.clipspace?.imgs && paintedIndex !== undefined) {
// Create and set new image
const newImage = new Image()
newImage.src = mkFileUrl({ ref: filepath, preview: true })
newImage.src = mkFileUrl({ ref: actualFilepath, preview: true })
ComfyApp.clipspace.imgs[paintedIndex] = newImage
// Update images array if it exists
if (ComfyApp.clipspace.images) {
ComfyApp.clipspace.images[paintedIndex] = filepath
// Update images array - create if it doesn't exist
if (!ComfyApp.clipspace.images) {
ComfyApp.clipspace.images = []
}
ComfyApp.clipspace.images[paintedIndex] = actualFilepath
}
} catch (err) {
console.warn('Failed to update clipspace image:', err)
}
ClipspaceDialog.invalidatePreview()
return actualFilepath
}
private async uploadMask(
@@ -1187,7 +1243,7 @@ class MaskEditorDialog extends ComfyDialog {
formData: FormData,
clipspaceLocation: 'selectedIndex' | 'combinedIndex'
) {
const success = await requestWithRetries(() =>
const { success, data } = await requestWithRetries(() =>
api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
@@ -1197,6 +1253,15 @@ class MaskEditorDialog extends ComfyDialog {
throw new Error('Upload failed.')
}
// Use actual response data if available, otherwise fall back to filepath
const actualFilepath: Ref = data?.name
? {
filename: data.name,
subfolder: data.subfolder || filepath.subfolder,
type: data.type || filepath.type
}
: filepath
try {
const nameOfIndexToSaveTo = (
{
@@ -1209,13 +1274,14 @@ class MaskEditorDialog extends ComfyDialog {
if (!ComfyApp.clipspace?.imgs || indexToSaveTo === undefined) return
// Create and set new image
const newImage = new Image()
newImage.src = mkFileUrl({ ref: filepath, preview: true })
newImage.src = mkFileUrl({ ref: actualFilepath, preview: true })
ComfyApp.clipspace.imgs[indexToSaveTo] = newImage
// Update images array if it exists
if (ComfyApp.clipspace.images) {
ComfyApp.clipspace.images[indexToSaveTo] = filepath
// Update images array - create if it doesn't exist
if (!ComfyApp.clipspace.images) {
ComfyApp.clipspace.images = []
}
ComfyApp.clipspace.images[indexToSaveTo] = actualFilepath
} catch (err) {
console.warn('Failed to update clipspace image:', err)
}
@@ -4132,20 +4198,83 @@ class UIManager {
combinedImageFilename = undefined
}
const imageLayerFilenames =
let imageLayerFilenames =
mainImageFilename !== undefined
? imageLayerFilenamesIfApplicable(
combinedImageFilename ?? mainImageFilename
)
: undefined
// Try to get paint layer - API first (for cloud), then pattern matching (for OSS ComfyUI)
let paintLayerUrl: string | undefined
// Check widget value to see if it has a mask file (from previous edit)
let widgetFilename: string | undefined
if (ComfyApp.clipspace?.widgets) {
const imageWidget = ComfyApp.clipspace.widgets.find(
(w) => w.name === 'image'
)
if (
imageWidget &&
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value
) {
const widgetValue = imageWidget.value as { filename?: string }
widgetFilename = widgetValue.filename
}
}
// Prefer widget filename (most recent), then combined, then main image
const fileToQuery =
widgetFilename || combinedImageFilename || mainImageFilename
// ALWAYS try API first for cloud (works with hash-based filenames)
if (fileToQuery) {
try {
const response = await api.fetchApi(
`/files/mask-layers?filename=${fileToQuery}`
)
if (response.ok) {
const apiMaskLayers = await response.json()
// Use the painted_masked file as the base image (has all previous edits)
if (apiMaskLayers.painted_masked || apiMaskLayers.painted) {
const baseFile =
apiMaskLayers.painted_masked || apiMaskLayers.painted
// Override maskedImage to use the composite from previous edit
if (!imageLayerFilenames) {
imageLayerFilenames = {
maskedImage: baseFile,
paint: apiMaskLayers.paint || '',
paintedImage: apiMaskLayers.painted || '',
paintedMaskedImage: apiMaskLayers.painted_masked || baseFile
}
} else {
imageLayerFilenames.maskedImage = baseFile
imageLayerFilenames.paint = apiMaskLayers.paint || ''
}
if (apiMaskLayers.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(apiMaskLayers.paint) })
}
}
}
} catch (err) {
// Fallback to pattern matching (for OSS ComfyUI)
}
}
const inputUrls = {
baseImagePlusMask: imageLayerFilenames?.maskedImage
? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) })
: mainImageUrl,
paintLayer: imageLayerFilenames?.paint
? mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
: undefined
paintLayer:
paintLayerUrl ||
(imageLayerFilenames?.paint
? mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
: undefined)
}
const alpha_url = new URL(inputUrls.baseImagePlusMask)
@@ -5535,28 +5664,30 @@ const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
const requestWithRetries = async (
mkRequest: () => Promise<Response>,
maxRetries: number = 3
): Promise<{ success: boolean }> => {
): Promise<{ success: boolean; data?: any }> => {
let attempt = 0
let success = false
let responseData: any = undefined
while (attempt < maxRetries && !success) {
try {
const response = await mkRequest()
if (response.ok) {
success = true
// Try to parse JSON response
try {
responseData = await response.json()
} catch (error) {
// Response not JSON, that's okay
}
} else {
console.log('Failed to upload mask:', response)
attempt++
}
} 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 }
return { success, data: responseData }
}
const isAlphaValue = (index: number) => index % 4 === 3

View File

@@ -405,6 +405,63 @@ export class ComfyApp {
}
}
/**
* Migrate old clipspace friendly filenames to hash-based filenames
* This handles workflows saved before the hash-based filename fix
*/
migrateClipspaceFilenames(graph: LGraph) {
// Iterate through all nodes in the graph
for (const node of graph._nodes) {
if (!node.widgets) continue
// Find image widgets
for (const widget of node.widgets) {
if (widget.name !== 'image' || typeof widget.value !== 'string')
continue
// Check if widget has an old clipspace friendly filename
const oldFilenamePattern =
/clipspace\/clipspace-(painted-masked|painted|mask|paint)-\d+\.png(\s+\[input\])?/
if (oldFilenamePattern.test(widget.value)) {
const oldValue = widget.value
// Try to migrate if clipspace has images
if (
ComfyApp.clipspace?.images &&
ComfyApp.clipspace.images.length > 0
) {
const currentClipspaceImage =
ComfyApp.clipspace.images[ComfyApp.clipspace.selectedIndex || 0]
if (currentClipspaceImage?.filename) {
// Check if this is a hash-based filename (64 chars hex)
const isHashFilename = /^[0-9a-f]{64}\.png$/i.test(
currentClipspaceImage.filename
)
if (isHashFilename) {
// Construct the new hash-based value (filename only, no subfolder)
const newValue =
currentClipspaceImage.filename +
(currentClipspaceImage.type
? ` [${currentClipspaceImage.type}]`
: '')
widget.value = newValue
continue
}
}
}
// If we can't migrate, clear the widget to prevent using old friendly name
console.warn(
`Cannot migrate clipspace filename "${oldValue}" for node ${node.id} - clipspace not available. Clearing widget to prevent asset not found error. Please re-paste from clipspace.`
)
widget.value = ''
}
}
}
}
static pasteFromClipspace(node: LGraphNode) {
if (ComfyApp.clipspace) {
// image paste
@@ -483,10 +540,21 @@ export class ComfyApp {
typeof node.widgets[index].value == 'string' &&
clip_image.filename
) {
node.widgets[index].value =
(clip_image.subfolder ? clip_image.subfolder + '/' : '') +
// Widget value should be JUST the filename (no subfolder)
const widgetValue =
clip_image.filename +
(clip_image.type ? ` [${clip_image.type}]` : '')
node.widgets[index].value = widgetValue
// Also update the node's serialized properties
// This ensures the value persists when the graph is serialized/configured
if (node.properties) {
node.properties['image'] = widgetValue
}
// Update widgets_values array if it exists (used during serialization)
if (node.widgets_values) {
node.widgets_values[index] = widgetValue
}
} else {
node.widgets[index].value = clip_image
}
@@ -506,8 +574,9 @@ export class ComfyApp {
value.filename
) {
const resultItem = value as ResultItem
// For cloud storage, don't include subfolder prefix in widget value
// The subfolder is handled internally by the backend
prop.value =
(resultItem.subfolder ? resultItem.subfolder + '/' : '') +
resultItem.filename +
(resultItem.type ? ` [${resultItem.type}]` : '')
} else {
@@ -748,6 +817,9 @@ export class ComfyApp {
const r = onConfigure?.apply(this, args)
// Migrate old clipspace friendly filenames to hash-based filenames
app.migrateClipspaceFilenames(this)
// Fire after onConfigure, used by primitives to generate widget using input nodes config
triggerCallbackOnAllNodes(this, 'onAfterGraphConfigured')

View File

@@ -85,6 +85,34 @@ export const graphToPrompt = async (
const inputs: ComfyApiWorkflow[string]['inputs'] = {}
const { widgets } = node
// Sync widget values from widgets_values array ONLY for old clipspace filenames
// This ensures we use the latest hash-based filenames from clipspace
// without breaking normal image selection
const graphNode = (node as any).node || node // Access underlying LGraph node from DTO wrapper
if (widgets && graphNode.widgets_values) {
for (const [i, widget] of widgets.entries()) {
// Only apply to image widgets to avoid affecting other widget types
if (
widget.name === 'image' &&
graphNode.widgets_values[i] !== undefined
) {
const currentValue = String(widget.value)
const newValue = String(graphNode.widgets_values[i])
// Only sync if current value is an old clipspace friendly filename
const isOldClipspaceFilename =
/clipspace\/clipspace-(painted-masked|painted|mask|paint)-\d+\.png(\s+\[input\])?/.test(
currentValue
)
if (isOldClipspaceFilename && currentValue !== newValue) {
widget.value = graphNode.widgets_values[i]
}
}
}
}
// Store all widget values
if (widgets) {
for (const [i, widget] of widgets.entries()) {