diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index 619aa89f41..c862eb4c0a 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -658,8 +658,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 6e5e5c9cfd..3d9387db70 100644 --- a/src/extensions/core/load3d/Load3dUtils.ts +++ b/src/extensions/core/load3d/Load3dUtils.ts @@ -1,6 +1,6 @@ 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 { @@ -9,84 +9,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 getFilenameExtension(url: string): string | undefined { diff --git a/src/platform/assets/services/uploadService.test.ts b/src/platform/assets/services/uploadService.test.ts new file mode 100644 index 0000000000..abcb079ee1 --- /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 0000000000..b98687a65d --- /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))) +}