mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-26 17:54:14 +00:00
Consolidate image upload implementations into shared service
Creates core uploadService to eliminate ~60-70 LOC duplication across multiple implementations. Changes: - Add src/platform/assets/services/uploadService.ts with uploadMedia() and uploadMediaBatch() - Refactor Load3dUtils to use uploadService (eliminates 50+ LOC) - Refactor WidgetSelectDropdown to use uploadService (eliminates 20+ LOC) - Add comprehensive unit tests for uploadService - Maintain backward compatibility for all existing APIs Benefits: - Single source of truth for upload logic - Consistent error handling - Type-safe interfaces - Easier to test and maintain
This commit is contained in:
188
src/platform/assets/services/uploadService.test.ts
Normal file
188
src/platform/assets/services/uploadService.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { uploadMedia, uploadMediaBatch } from './uploadService'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('uploadService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('uploadMedia', () => {
|
||||
it('uploads File successfully', async () => {
|
||||
const mockFile = new File(['content'], 'test.png', { type: 'image/png' })
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
name: 'test.png',
|
||||
subfolder: 'uploads'
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(mockResponse as any)
|
||||
|
||||
const result = await uploadMedia({ source: mockFile })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.path).toBe('uploads/test.png')
|
||||
expect(result.name).toBe('test.png')
|
||||
expect(result.subfolder).toBe('uploads')
|
||||
})
|
||||
|
||||
it('uploads Blob successfully', async () => {
|
||||
const mockBlob = new Blob(['content'], { type: 'image/png' })
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
name: 'upload-123.png',
|
||||
subfolder: ''
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(mockResponse as any)
|
||||
|
||||
const result = await uploadMedia({ source: mockBlob })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.path).toBe('upload-123.png')
|
||||
})
|
||||
|
||||
it('uploads dataURL successfully', async () => {
|
||||
const dataURL = 'data:image/png;base64,iVBORw0KGgo='
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: () => Promise.resolve(new Blob(['content']))
|
||||
})
|
||||
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
name: 'upload-456.png',
|
||||
subfolder: ''
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(mockResponse as any)
|
||||
|
||||
const result = await uploadMedia({ source: dataURL })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('includes subfolder in FormData', async () => {
|
||||
const mockFile = new File(['content'], 'test.png')
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ name: 'test.png' })
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(mockResponse as any)
|
||||
|
||||
await uploadMedia(
|
||||
{ source: mockFile },
|
||||
{ subfolder: 'custom', type: 'input' }
|
||||
)
|
||||
|
||||
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
|
||||
?.body as FormData
|
||||
expect(formData.get('subfolder')).toBe('custom')
|
||||
expect(formData.get('type')).toBe('input')
|
||||
})
|
||||
|
||||
it('validates file size', async () => {
|
||||
const largeFile = new File(['x'.repeat(200 * 1024 * 1024)], 'large.png')
|
||||
|
||||
const result = await uploadMedia(
|
||||
{ source: largeFile },
|
||||
{ maxSizeMB: 100 }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('exceeds maximum')
|
||||
})
|
||||
|
||||
it('handles upload errors', async () => {
|
||||
const mockFile = new File(['content'], 'test.png')
|
||||
const mockResponse = {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(mockResponse as any)
|
||||
|
||||
const result = await uploadMedia({ source: mockFile })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('500 - Internal Server Error')
|
||||
})
|
||||
|
||||
it('handles exceptions', async () => {
|
||||
const mockFile = new File(['content'], 'test.png')
|
||||
|
||||
vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const result = await uploadMedia({ source: mockFile })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Network error')
|
||||
})
|
||||
|
||||
it('includes originalRef for mask uploads', async () => {
|
||||
const mockFile = new File(['content'], 'mask.png')
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ name: 'mask.png' })
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(mockResponse as any)
|
||||
|
||||
const originalRef = {
|
||||
filename: 'original.png',
|
||||
subfolder: 'images',
|
||||
type: 'input'
|
||||
}
|
||||
|
||||
await uploadMedia(
|
||||
{ source: mockFile },
|
||||
{ endpoint: '/upload/mask', originalRef }
|
||||
)
|
||||
|
||||
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
|
||||
?.body as FormData
|
||||
expect(formData.get('original_ref')).toBe(JSON.stringify(originalRef))
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadMediaBatch', () => {
|
||||
it('uploads multiple files', async () => {
|
||||
const mockFiles = [
|
||||
new File(['1'], 'file1.png'),
|
||||
new File(['2'], 'file2.png')
|
||||
]
|
||||
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
json: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ name: 'file1.png' })
|
||||
.mockResolvedValueOnce({ name: 'file2.png' })
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(mockResponse as any)
|
||||
|
||||
const results = await uploadMediaBatch(
|
||||
mockFiles.map((source) => ({ source }))
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].success).toBe(true)
|
||||
expect(results[1].success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
140
src/platform/assets/services/uploadService.ts
Normal file
140
src/platform/assets/services/uploadService.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
interface UploadInput {
|
||||
source: File | Blob | string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
interface UploadConfig {
|
||||
subfolder?: string
|
||||
type?: ResultItemType
|
||||
endpoint?: '/upload/image' | '/upload/mask'
|
||||
originalRef?: ImageRef
|
||||
maxSizeMB?: number
|
||||
}
|
||||
|
||||
interface ImageRef {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
success: boolean
|
||||
path: string
|
||||
name: string
|
||||
subfolder: string
|
||||
error?: string
|
||||
response: any
|
||||
}
|
||||
|
||||
async function convertToFile(
|
||||
input: UploadInput,
|
||||
mimeType: string = 'image/png'
|
||||
): Promise<File> {
|
||||
const { source, filename } = input
|
||||
|
||||
if (source instanceof File) {
|
||||
return source
|
||||
}
|
||||
|
||||
if (source instanceof Blob) {
|
||||
const name = filename || `upload-${Date.now()}.png`
|
||||
return new File([source], name, { type: mimeType })
|
||||
}
|
||||
|
||||
// dataURL string
|
||||
const blob = await fetch(source).then((r) => r.blob())
|
||||
const name = filename || `upload-${Date.now()}.png`
|
||||
return new File([blob], name, { type: mimeType })
|
||||
}
|
||||
|
||||
function validateFileSize(file: File, maxSizeMB?: number): string | null {
|
||||
if (!maxSizeMB) return null
|
||||
|
||||
const fileSizeMB = file.size / 1024 / 1024
|
||||
if (fileSizeMB > maxSizeMB) {
|
||||
return `File size ${fileSizeMB.toFixed(1)}MB exceeds maximum ${maxSizeMB}MB`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function uploadMedia(
|
||||
input: UploadInput,
|
||||
config: UploadConfig = {}
|
||||
): Promise<UploadResult> {
|
||||
const {
|
||||
subfolder,
|
||||
type,
|
||||
endpoint = '/upload/image',
|
||||
originalRef,
|
||||
maxSizeMB
|
||||
} = config
|
||||
|
||||
try {
|
||||
const file = await convertToFile(input)
|
||||
|
||||
const sizeError = validateFileSize(file, maxSizeMB)
|
||||
if (sizeError) {
|
||||
return {
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: sizeError,
|
||||
response: null
|
||||
}
|
||||
}
|
||||
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (subfolder) body.append('subfolder', subfolder)
|
||||
if (type) body.append('type', type)
|
||||
if (originalRef) body.append('original_ref', JSON.stringify(originalRef))
|
||||
|
||||
const resp = await api.fetchApi(endpoint, {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: `${resp.status} - ${resp.statusText}`,
|
||||
response: null
|
||||
}
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
const path = data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path,
|
||||
name: data.name,
|
||||
subfolder: data.subfolder || '',
|
||||
response: data
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
path: '',
|
||||
name: '',
|
||||
subfolder: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
response: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadMediaBatch(
|
||||
inputs: UploadInput[],
|
||||
config: UploadConfig = {}
|
||||
): Promise<UploadResult[]> {
|
||||
return Promise.all(inputs.map((input) => uploadMedia(input, config)))
|
||||
}
|
||||
Reference in New Issue
Block a user