Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/composables/useLoad3d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
: '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) => {
Expand Down
105 changes: 40 additions & 65 deletions src/extensions/core/load3d/Load3dUtils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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<string[]> {
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 {
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
221 changes: 221 additions & 0 deletions src/platform/assets/services/uploadService.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response> 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<Response> 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<Response> 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<Response> 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<Response> 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<Response> 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<Response> as Response)
.mockResolvedValueOnce(mockResponse2 as Partial<Response> 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)
})
})
})
Loading