From 963741f5548195f1a98ad76feb04f598ac3a35f5 Mon Sep 17 00:00:00 2001 From: Richard Yu Date: Fri, 10 Oct 2025 16:17:31 -0700 Subject: [PATCH] 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 --- src/extensions/core/maskeditor.ts | 205 ++++++++++++++++++++++++------ src/scripts/app.ts | 78 +++++++++++- src/utils/executionUtil.ts | 28 ++++ 3 files changed, 271 insertions(+), 40 deletions(-) diff --git a/src/extensions/core/maskeditor.ts b/src/extensions/core/maskeditor.ts index 2497fd287..af97c1340 100644 --- a/src/extensions/core/maskeditor.ts +++ b/src/extensions/core/maskeditor.ts @@ -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 - const originalImageRef: Partial = { - 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: @@ -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, 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 diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 57b6f521b..1badfda17 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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') diff --git a/src/utils/executionUtil.ts b/src/utils/executionUtil.ts index 3f7f982c4..a3732bc4b 100644 --- a/src/utils/executionUtil.ts +++ b/src/utils/executionUtil.ts @@ -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()) {