diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index b07daba2e..9751f73cc 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 ba7c36e55..6ed3c71e9 100644 --- a/src/extensions/core/load3d/Load3dUtils.ts +++ b/src/extensions/core/load3d/Load3dUtils.ts @@ -1,7 +1,7 @@ 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' class Load3dUtils { @@ -34,84 +34,52 @@ 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 filename = `${prefix}_${Date.now()}.${fileType}` + const result = await uploadMedia( + { source: imageData, filename }, + { subfolder: 'threed', type: 'temp' } + ) - 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 - }) - - if (resp.status !== 200) { - const err = `Error uploading temp file: ${resp.status} - ${resp.statusText}` + if (!result.success) { + const err = `Error uploading temp file: ${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 result = await uploadMedia( + { source: file }, + { subfolder, maxSizeMB: this.MAX_UPLOAD_SIZE_MB } + ) - 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) + 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 { + console.error('[Load3D] uploadFile: exception', result.error) + useToastStore().addAlert( + result.error || t('toastMessages.fileUploadFailed') + ) + } 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 - } - - uploadPath = path - } else { - useToastStore().addAlert(resp.status + ' - ' + resp.statusText) - } - } catch (error) { - console.error('[Load3D] uploadFile: exception', error) - useToastStore().addAlert( - error instanceof Error - ? error.message - : t('toastMessages.fileUploadFailed') - ) - } - - return uploadPath + return result.path } static splitFilePath(path: string): [string, string] { diff --git a/src/platform/assets/services/uploadService.test.ts b/src/platform/assets/services/uploadService.test.ts new file mode 100644 index 000000000..abcb079ee --- /dev/null +++ b/src/platform/assets/services/uploadService.test.ts @@ -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) + }) + }) +}) diff --git a/src/platform/assets/services/uploadService.ts b/src/platform/assets/services/uploadService.ts new file mode 100644 index 000000000..b98687a65 --- /dev/null +++ b/src/platform/assets/services/uploadService.ts @@ -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 { + 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 { + 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 { + 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 84cb49218..34f3b52bd 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 { uploadMedia } 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' @@ -248,44 +248,27 @@ 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 - }) - - if (resp.status !== 200) { - toastStore.addAlert(resp.status + ' - ' + resp.statusText) - return null - } - - const data = await resp.json() - - // Update AssetsStore when uploading to input folder - if (formFields.type === 'input' || (!formFields.type && !isPasted)) { - const assetsStore = useAssetsStore() - await assetsStore.updateInputs() - } - - return data.subfolder ? `${data.subfolder}/${data.name}` : data.name -} - -// Handle multiple file uploads +// Handle multiple file uploads using shared uploadMedia service const uploadFiles = async (files: File[]): Promise => { const folder = props.uploadFolder ?? 'input' - const uploadPromises = files.map((file) => - uploadFile(file, false, { type: folder }) - ) + const assetsStore = useAssetsStore() + + const uploadPromises = files.map(async (file) => { + const result = await uploadMedia({ source: file }, { type: folder }) + + if (!result.success) { + toastStore.addAlert(result.error || 'Upload failed') + return null + } + + // Update AssetsStore when uploading to input folder + if (folder === 'input') { + await assetsStore.updateInputs() + } + + return result.path + }) + const results = await Promise.all(uploadPromises) return results.filter((path): path is string => path !== null) }