This commit is contained in:
Richard Yu
2025-10-10 15:55:43 -07:00
parent 529a4de583
commit fdd6b3bcfc
4 changed files with 395 additions and 41 deletions

View File

@@ -0,0 +1,95 @@
/**
* Store for mask metadata from cloud backend
* This helps identify masks when using hash-based filenames
*/
interface MaskMetadata {
is_mask: boolean
original_hash?: string
mask_type?: string
related_files?: {
mask?: string
paint?: string
painted?: string
}
}
class MaskMetadataStore {
private metadata: Map<string, MaskMetadata> = new Map()
/**
* Store metadata for a file
* @param filename The filename (hash) of the file
* @param metadata The metadata from the backend
*/
setMetadata(filename: string, metadata: MaskMetadata | undefined) {
if (!metadata || !filename) return
console.log('[MaskMetadataStore] Storing metadata for', filename, metadata)
this.metadata.set(filename, metadata)
}
/**
* Get metadata for a file
* @param filename The filename (hash) to look up
*/
getMetadata(filename: string): MaskMetadata | undefined {
return this.metadata.get(filename)
}
/**
* Check if a file is a mask based on metadata
* @param filename The filename to check
*/
isMask(filename: string): boolean {
const metadata = this.getMetadata(filename)
return metadata?.is_mask === true
}
/**
* Get the original image hash for a mask
* @param filename The mask filename
*/
getOriginalHash(filename: string): string | undefined {
const metadata = this.getMetadata(filename)
return metadata?.original_hash
}
/**
* Store related files for a mask (from upload response)
* @param filename The mask filename
* @param relatedFiles The related files object
*/
setRelatedFiles(filename: string, relatedFiles: any) {
const metadata = this.metadata.get(filename)
if (metadata) {
metadata.related_files = relatedFiles
this.metadata.set(filename, metadata)
} else {
// Create new metadata entry with just related files
this.metadata.set(filename, {
is_mask: true,
related_files: relatedFiles
})
}
}
/**
* Get related files for a mask
* @param filename The mask filename
*/
getRelatedFiles(filename: string): MaskMetadata['related_files'] | undefined {
const metadata = this.getMetadata(filename)
return metadata?.related_files
}
/**
* Clear all stored metadata
*/
clear() {
this.metadata.clear()
}
}
// Export singleton instance
export const maskMetadataStore = new MaskMetadataStore()

View File

@@ -996,9 +996,13 @@ 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)
console.error('[MaskEditor] Error loading mask image:', error)
if (error instanceof Error) {
console.error('[MaskEditor] Error details:', error.message, error.stack)
}
return
}
@@ -1036,22 +1040,41 @@ class MaskEditorDialog extends ComfyDialog {
paint: paintCanvas
})
replaceClipspaceImages(refs.paintedMaskedImage, [refs.paint])
// Don't set clipspace widgets here - wait until after uploads complete
// replaceClipspaceImages will be called after we have the actual hash-based filenames
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 +1128,22 @@ 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
console.log(
'[MaskEditor] Updating painted_masked original_ref to:',
uploadedPaintedRef
)
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 +1155,42 @@ 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]
console.log('[MaskEditor] Clipspace state after uploads:', {
combinedIndex: ComfyApp.clipspace.combinedIndex,
paintedIndex: ComfyApp.clipspace.paintedIndex,
actualPaintedMasked,
actualPaint,
allImages: ComfyApp.clipspace.images
})
if (actualPaintedMasked) {
// Update widgets with actual uploaded refs (with hash filenames)
replaceClipspaceImages(
actualPaintedMasked,
actualPaint ? [actualPaint] : undefined
)
console.log(
'[MaskEditor] Updated clipspace widgets with hash-based filenames'
)
} else {
console.error(
'[MaskEditor] actualPaintedMasked is undefined - widget not updated!'
)
}
}
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 +1219,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 +1229,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 +1268,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 +1278,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 +1299,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 +4223,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(
`/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 +5689,33 @@ 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)
console.error(
`[requestWithRetries] 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

@@ -404,6 +404,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
@@ -482,10 +539,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
}
@@ -505,8 +573,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 {
@@ -794,6 +863,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()) {