diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index b07daba2e89..9751f73cc40 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -386,8 +386,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { : '3d' const uploadedPath = await Load3dUtils.uploadFile(file, subfolder) - sceneConfig.value.backgroundImage = uploadedPath - await load3d?.setBackgroundImage(uploadedPath) + if (uploadedPath) { + sceneConfig.value.backgroundImage = uploadedPath + await load3d?.setBackgroundImage(uploadedPath) + } } const handleExportModel = async (format: string) => { diff --git a/src/extensions/core/load3d/Load3dUtils.ts b/src/extensions/core/load3d/Load3dUtils.ts index ba7c36e5570..dd5c15fba89 100644 --- a/src/extensions/core/load3d/Load3dUtils.ts +++ b/src/extensions/core/load3d/Load3dUtils.ts @@ -1,5 +1,6 @@ import type Load3d from '@/extensions/core/load3d/Load3d' import { t } from '@/i18n' +import { uploadMedia } from '@/platform/assets/services/uploadService' import { useToastStore } from '@/platform/updates/common/toastStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' @@ -34,84 +35,54 @@ class Load3dUtils { prefix: string, fileType: string = 'png' ) { - const blob = await fetch(imageData).then((r) => r.blob()) - const name = `${prefix}_${Date.now()}.${fileType}` - const file = new File([blob], name, { - type: fileType === 'mp4' ? 'video/mp4' : 'image/png' - }) - - const body = new FormData() - body.append('image', file) - body.append('subfolder', 'threed') - body.append('type', 'temp') - - const resp = await api.fetchApi('/upload/image', { - method: 'POST', - body - }) + const filename = `${prefix}_${Date.now()}.${fileType}` + const result = await uploadMedia( + { source: imageData, filename }, + { subfolder: 'threed', type: 'temp' } + ) - if (resp.status !== 200) { - const err = `Error uploading temp file: ${resp.status} - ${resp.statusText}` + if (!result.success || !result.response) { + const err = t('toastMessages.tempUploadFailed', { + error: result.error || '' + }) useToastStore().addAlert(err) throw new Error(err) } - return await resp.json() + return result.response } static readonly MAX_UPLOAD_SIZE_MB = 100 static async uploadFile(file: File, subfolder: string) { - let uploadPath - - const fileSizeMB = file.size / 1024 / 1024 - if (fileSizeMB > this.MAX_UPLOAD_SIZE_MB) { - const message = t('toastMessages.fileTooLarge', { - size: fileSizeMB.toFixed(1), - maxSize: this.MAX_UPLOAD_SIZE_MB - }) - console.warn( - '[Load3D] uploadFile: file too large', - fileSizeMB.toFixed(2), - 'MB' - ) - useToastStore().addAlert(message) - return undefined - } - - try { - const body = new FormData() - body.append('image', file) - - body.append('subfolder', subfolder) - - const resp = await api.fetchApi('/upload/image', { - method: 'POST', - body - }) - - if (resp.status === 200) { - const data = await resp.json() - let path = data.name - - if (data.subfolder) { - path = data.subfolder + '/' + path - } + const result = await uploadMedia( + { source: file }, + { subfolder, maxSizeMB: this.MAX_UPLOAD_SIZE_MB } + ) - uploadPath = path + if (!result.success) { + if (result.error?.includes('exceeds maximum')) { + const fileSizeMB = file.size / 1024 / 1024 + const message = t('toastMessages.fileTooLarge', { + size: fileSizeMB.toFixed(1), + maxSize: this.MAX_UPLOAD_SIZE_MB + }) + console.warn( + '[Load3D] uploadFile: file too large', + fileSizeMB.toFixed(2), + 'MB' + ) + useToastStore().addAlert(message) } else { - useToastStore().addAlert(resp.status + ' - ' + resp.statusText) + console.error('[Load3D] uploadFile: exception', result.error) + useToastStore().addAlert( + result.error || t('toastMessages.fileUploadFailed') + ) } - } catch (error) { - console.error('[Load3D] uploadFile: exception', error) - useToastStore().addAlert( - error instanceof Error - ? error.message - : t('toastMessages.fileUploadFailed') - ) + return undefined } - return uploadPath + return result.path } static splitFilePath(path: string): [string, string] { @@ -140,12 +111,16 @@ class Load3dUtils { return `/view?${params}` } - static async uploadMultipleFiles(files: FileList, subfolder: string = '3d') { + static async uploadMultipleFiles( + files: FileList, + subfolder: string = '3d' + ): Promise { const uploadPromises = Array.from(files).map((file) => this.uploadFile(file, subfolder) ) - await Promise.all(uploadPromises) + const results = await Promise.all(uploadPromises) + return results.filter((path): path is string => path !== undefined) } static getThumbnailFilename(modelFilename: string): string { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 17f4b8268e6..938f7d6d74f 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1806,6 +1806,8 @@ "pleaseSelectNodesToGroup": "Please select the nodes (or other groups) to create a group for", "emptyCanvas": "Empty canvas", "fileUploadFailed": "File upload failed", + "uploadFailed": "Upload failed", + "tempUploadFailed": "Error uploading temp file: {error}", "fileTooLarge": "File too large ({size} MB). Maximum supported size is {maxSize} MB", "unableToGetModelFilePath": "Unable to get model file path", "couldNotDetermineFileType": "Could not determine file type", diff --git a/src/platform/assets/services/uploadService.test.ts b/src/platform/assets/services/uploadService.test.ts new file mode 100644 index 00000000000..b3e523d2520 --- /dev/null +++ b/src/platform/assets/services/uploadService.test.ts @@ -0,0 +1,221 @@ +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 Partial as Response + ) + + 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 Partial as Response + ) + + 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=' + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ + blob: () => Promise.resolve(new Blob(['content'])) + } as Response) + + const mockResponse = { + status: 200, + json: vi.fn().mockResolvedValue({ + name: 'upload-456.png', + subfolder: '' + }) + } + + vi.mocked(api.fetchApi).mockResolvedValue( + mockResponse as Partial as Response + ) + + try { + const result = await uploadMedia({ source: dataURL }) + expect(result.success).toBe(true) + } finally { + fetchSpy.mockRestore() + } + }) + + it('rejects invalid dataURL', async () => { + const invalidURL = 'not-a-data-url' + + const result = await uploadMedia({ source: invalidURL }) + + expect(result.success).toBe(false) + expect(result.error).toContain('Invalid data URL') + }) + + 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 Partial as Response + ) + + 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 () => { + // Create a file that reports as 200MB without actually allocating that much memory + const largeFile = new File(['content'], 'large.png') + Object.defineProperty(largeFile, 'size', { + value: 200 * 1024 * 1024, + writable: false + }) + + 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 Partial as Response + ) + + 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 Partial as Response + ) + + 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 mockResponse1 = { + status: 200, + json: vi.fn().mockResolvedValue({ name: 'file1.png', subfolder: '' }) + } + + const mockResponse2 = { + status: 200, + json: vi.fn().mockResolvedValue({ name: 'file2.png', subfolder: '' }) + } + + vi.mocked(api.fetchApi) + .mockResolvedValueOnce(mockResponse1 as Partial as Response) + .mockResolvedValueOnce(mockResponse2 as Partial as Response) + + const results = await uploadMediaBatch( + mockFiles.map((source) => ({ source })) + ) + + expect(results).toHaveLength(2) + expect(results[0].success).toBe(true) + expect(results[1].success).toBe(true) + }) + }) +}) diff --git a/src/platform/assets/services/uploadService.ts b/src/platform/assets/services/uploadService.ts new file mode 100644 index 00000000000..7b020c7dbd1 --- /dev/null +++ b/src/platform/assets/services/uploadService.ts @@ -0,0 +1,155 @@ +import type { ResultItemType } from '@/schemas/apiSchema' +import { api } from '@/scripts/api' +import type { ImageRef } from '@/stores/maskEditorDataStore' + +interface UploadInput { + source: File | Blob | string + filename?: string +} + +interface UploadConfig { + subfolder?: string + type?: ResultItemType + endpoint?: '/upload/image' | '/upload/mask' + originalRef?: ImageRef + maxSizeMB?: number +} + +interface UploadApiResponse { + name: string + subfolder?: string + type?: string +} + +interface UploadResult { + success: boolean + path: string + name: string + subfolder: string + error?: string + response: UploadApiResponse | null +} + +function isDataURL(str: string): boolean { + return typeof str === 'string' && str.startsWith('data:') +} + +async function convertToFile( + input: UploadInput, + mimeType: string = 'image/png' +): Promise { + 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 + if (!isDataURL(source)) { + throw new Error('Invalid data URL') + } + + try { + const blob = await fetch(source).then((r) => r.blob()) + const name = filename || `upload-${Date.now()}.png` + return new File([blob], name, { type: mimeType }) + } catch (error) { + throw new Error( + `Failed to convert data URL to file: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +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 { + 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: UploadApiResponse = 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 { + return Promise.all(inputs.map((input) => uploadMedia(input, config))) +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index 2b0304ed7fc..3b20990f2be 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -4,6 +4,7 @@ import { computed, provide, ref, toRef, watch } from 'vue' import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps' import { t } from '@/i18n' +import { uploadMediaBatch } from '@/platform/assets/services/uploadService' import { useToastStore } from '@/platform/updates/common/toastStore' import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue' import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types' @@ -16,7 +17,6 @@ import type { import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue' import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData' import type { ResultItemType } from '@/schemas/apiSchema' -import { api } from '@/scripts/api' import { useAssetsStore } from '@/stores/assetsStore' import { useQueueStore } from '@/stores/queueStore' import type { SimplifiedWidget } from '@/types/simplifiedWidget' @@ -307,46 +307,30 @@ function updateSelectedItems(selectedItems: Set) { modelValue.value = name } -// Upload file function (copied from useNodeImageUpload.ts) -const uploadFile = async ( - file: File, - isPasted: boolean = false, - formFields: Partial<{ type: ResultItemType }> = {} -) => { - const body = new FormData() - body.append('image', file) - if (isPasted) body.append('subfolder', 'pasted') - if (formFields.type) body.append('type', formFields.type) - - const resp = await api.fetchApi('/upload/image', { - method: 'POST', - body - }) +// Handle multiple file uploads using shared uploadMediaBatch service +const uploadFiles = async (files: File[]): Promise => { + const folder = props.uploadFolder ?? 'input' + const assetsStore = useAssetsStore() + + const results = await uploadMediaBatch( + files.map((file) => ({ source: file })), + { type: folder } + ) - if (resp.status !== 200) { - toastStore.addAlert(resp.status + ' - ' + resp.statusText) - return null + // Report failed uploads + const failedUploads = results.filter((r) => !r.success) + for (const failed of failedUploads) { + toastStore.addAlert(failed.error || t('toastMessages.uploadFailed')) } - const data = await resp.json() + // Update AssetsStore once after all uploads complete (not per-file) + const successfulPaths = results.filter((r) => r.success).map((r) => r.path) - // Update AssetsStore when uploading to input folder - if (formFields.type === 'input' || (!formFields.type && !isPasted)) { - const assetsStore = useAssetsStore() + if (folder === 'input' && successfulPaths.length > 0) { await assetsStore.updateInputs() } - return data.subfolder ? `${data.subfolder}/${data.name}` : data.name -} - -// Handle multiple file uploads -const uploadFiles = async (files: File[]): Promise => { - const folder = props.uploadFolder ?? 'input' - const uploadPromises = files.map((file) => - uploadFile(file, false, { type: folder }) - ) - const results = await Promise.all(uploadPromises) - return results.filter((path): path is string => path !== null) + return successfulPaths } async function handleFilesUpdate(files: File[]) { @@ -357,7 +341,7 @@ async function handleFilesUpdate(files: File[]) { const uploadedPaths = await uploadFiles(files) if (uploadedPaths.length === 0) { - toastStore.addAlert('File upload failed') + toastStore.addAlert(t('toastMessages.uploadFailed')) return }