mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
merge: FE-750 (PR #12318) into m1-fe-integration
This commit is contained in:
@@ -218,21 +218,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-mask-${maskUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadCount++
|
||||
return route.fulfill({
|
||||
@@ -252,20 +239,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
|
||||
// through the unified /upload/image endpoint.
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
imageUploadCount,
|
||||
'save should upload all four layers via /upload/image'
|
||||
).toBe(4)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -6,7 +8,6 @@ import type {
|
||||
EditorOutputLayer,
|
||||
ImageRef
|
||||
} from '@/stores/maskEditorDataStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
@@ -209,18 +210,11 @@ export function useMaskEditorSaver() {
|
||||
}
|
||||
|
||||
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
|
||||
const sourceRef = dataStore.inputData!.sourceRef
|
||||
|
||||
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
|
||||
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
|
||||
const actualPaintedRef = await uploadImage(
|
||||
outputData.paintedImage,
|
||||
sourceRef
|
||||
)
|
||||
|
||||
const actualPaintedMaskedRef = await uploadMask(
|
||||
outputData.paintedMaskedImage,
|
||||
actualPaintedRef
|
||||
const actualMaskedRef = await uploadLayer(outputData.maskedImage)
|
||||
const actualPaintRef = await uploadLayer(outputData.paintLayer)
|
||||
const actualPaintedRef = await uploadLayer(outputData.paintedImage)
|
||||
const actualPaintedMaskedRef = await uploadLayer(
|
||||
outputData.paintedMaskedImage
|
||||
)
|
||||
|
||||
outputData.maskedImage.ref = actualMaskedRef
|
||||
@@ -229,50 +223,10 @@ export function useMaskEditorSaver() {
|
||||
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
|
||||
}
|
||||
|
||||
async function uploadMask(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
async function uploadLayer(layer: EditorOutputLayer): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/mask', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -280,23 +234,31 @@ export function useMaskEditorSaver() {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
|
||||
throw new Error(`Failed to upload: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
let data: UploadImageResponse
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
data = await response.json()
|
||||
} catch (error) {
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
throw new Error(
|
||||
`Invalid upload response for ${layer.ref.filename}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
if (!data?.name) {
|
||||
throw new Error(
|
||||
`Upload response missing 'name' for ${layer.ref.filename}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || '',
|
||||
type: data.type || 'input'
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNodePreview(
|
||||
@@ -322,19 +284,8 @@ export function useMaskEditorSaver() {
|
||||
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
// Widget value format differs between Cloud and OSS:
|
||||
// - Cloud: JUST the filename (subfolder handled by backend)
|
||||
// - OSS: subfolder/filename (traditional format)
|
||||
let widgetValue: string
|
||||
if (isCloud) {
|
||||
widgetValue =
|
||||
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
} else {
|
||||
widgetValue =
|
||||
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
|
||||
mainRef.filename +
|
||||
(mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
}
|
||||
const widgetValue =
|
||||
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
|
||||
imageWidget.value = widgetValue
|
||||
|
||||
|
||||
@@ -384,7 +384,63 @@ describe('usePainter', () => {
|
||||
'/upload/image',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
expect(result).toBe('painter/uploaded.png [temp]')
|
||||
expect(result).toBe('uploaded.png [input]')
|
||||
|
||||
const [, init] = fetchApiMock.mock.calls[0]
|
||||
const body = init?.body as FormData
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect(body.get('type')).toBe('input')
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
})
|
||||
|
||||
it('throws when the upload response is missing a name', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => ({})
|
||||
} as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter('test-node', '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/painter\.uploadError/)
|
||||
})
|
||||
|
||||
it('throws when the upload response body is not valid JSON', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token')
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter('test-node', '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/painter\.uploadError/)
|
||||
})
|
||||
|
||||
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
@@ -12,7 +13,6 @@ import { hexToRgb } from '@/utils/colorUtil'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -631,8 +631,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
const name = `painter-${nodeId}-${Date.now()}.png`
|
||||
const body = new FormData()
|
||||
body.append('image', blob, name)
|
||||
if (!isCloud) body.append('subfolder', 'painter')
|
||||
body.append('type', isCloud ? 'input' : 'temp')
|
||||
body.append('type', 'input')
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
@@ -658,7 +657,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
let data: { name: string }
|
||||
let data: UploadImageResponse
|
||||
try {
|
||||
data = await resp.json()
|
||||
} catch (e) {
|
||||
@@ -670,9 +669,16 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
const result = isCloud
|
||||
? `${data.name} [input]`
|
||||
: `painter/${data.name} [temp]`
|
||||
if (!data?.name) {
|
||||
const err = t('painter.uploadError', {
|
||||
status: resp.status,
|
||||
statusText: "missing 'name' in response"
|
||||
})
|
||||
toastStore.addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
const result = `${data.name} [input]`
|
||||
modelValue.value = result
|
||||
isDirty.value = false
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user