mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-10 22:40:00 +00:00
Compare commits
7 Commits
dev/remote
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2d9f1fcc | ||
|
|
08c2b1ff37 | ||
|
|
11b84be06b | ||
|
|
226fc7e840 | ||
|
|
2a8cc540f7 | ||
|
|
ba8f501fb3 | ||
|
|
a957afdd1a |
@@ -388,8 +388,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) => {
|
||||
|
||||
@@ -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 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 || !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 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] {
|
||||
@@ -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 {
|
||||
|
||||
@@ -1974,6 +1974,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",
|
||||
|
||||
195
src/platform/assets/services/uploadService.test.ts
Normal file
195
src/platform/assets/services/uploadService.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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()
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockResponse(
|
||||
status: number,
|
||||
data?: { name: string; subfolder?: string }
|
||||
) {
|
||||
return {
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: vi.fn().mockResolvedValue(data ?? {})
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('uploadService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('uploadMedia', () => {
|
||||
it('uploads File successfully', async () => {
|
||||
const mockFile = new File(['content'], 'test.png', { type: 'image/png' })
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'test.png', subfolder: 'uploads' })
|
||||
)
|
||||
|
||||
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' })
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'upload-123.png', subfolder: '' })
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'upload-456.png', subfolder: '' })
|
||||
)
|
||||
|
||||
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')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'test.png' })
|
||||
)
|
||||
|
||||
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')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error'
|
||||
} as unknown 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')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
createMockResponse(200, { name: 'mask.png' })
|
||||
)
|
||||
|
||||
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('returns empty array for empty input', async () => {
|
||||
const results = await uploadMediaBatch([])
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads multiple files', async () => {
|
||||
const mockFiles = [
|
||||
new File(['1'], 'file1.png'),
|
||||
new File(['2'], 'file2.png')
|
||||
]
|
||||
|
||||
vi.mocked(api.fetchApi)
|
||||
.mockResolvedValueOnce(
|
||||
createMockResponse(200, { name: 'file1.png', subfolder: '' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createMockResponse(200, { name: 'file2.png', subfolder: '' })
|
||||
)
|
||||
|
||||
const results = await uploadMediaBatch(
|
||||
mockFiles.map((source) => ({ source }))
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].success).toBe(true)
|
||||
expect(results[1].success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
155
src/platform/assets/services/uploadService.ts
Normal file
155
src/platform/assets/services/uploadService.ts
Normal file
@@ -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<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: source.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<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: 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<UploadResult[]> {
|
||||
return Promise.all(inputs.map((input) => uploadMedia(input, config)))
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import { computed, provide, ref, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { uploadMediaBatch } from '@/platform/assets/services/uploadService'
|
||||
import {
|
||||
filterItemByBaseModels,
|
||||
filterItemByOwnership
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
||||
@@ -32,7 +33,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 type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
@@ -53,11 +53,20 @@ interface Props {
|
||||
defaultLayoutMode?: LayoutMode
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const {
|
||||
widget,
|
||||
nodeType,
|
||||
assetKind,
|
||||
allowUpload = false,
|
||||
uploadFolder,
|
||||
uploadSubfolder,
|
||||
isAssetMode = false,
|
||||
defaultLayoutMode = 'grid'
|
||||
} = defineProps<Props>()
|
||||
|
||||
provide(
|
||||
AssetKindKey,
|
||||
computed(() => props.assetKind)
|
||||
computed(() => assetKind)
|
||||
)
|
||||
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
@@ -75,15 +84,14 @@ const outputMediaAssets = useMediaAssets('output')
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...filterWidgetProps(widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
|
||||
const getAssetData = () => {
|
||||
const nodeType: string | undefined =
|
||||
props.widget.options?.nodeType ?? props.nodeType
|
||||
if (props.isAssetMode && nodeType) {
|
||||
return useAssetWidgetData(toRef(nodeType))
|
||||
const resolvedNodeType = widget.options?.nodeType ?? nodeType
|
||||
if (isAssetMode && resolvedNodeType) {
|
||||
return useAssetWidgetData(toRef(resolvedNodeType))
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -91,7 +99,7 @@ const assetData = getAssetData()
|
||||
|
||||
const filterSelected = ref('all')
|
||||
const filterOptions = computed<FilterOption[]>(() => {
|
||||
if (props.isAssetMode) {
|
||||
if (isAssetMode) {
|
||||
const categoryName = assetData?.category.value ?? 'All'
|
||||
return [{ name: capitalize(categoryName), value: 'all' }]
|
||||
}
|
||||
@@ -103,28 +111,23 @@ const filterOptions = computed<FilterOption[]>(() => {
|
||||
})
|
||||
|
||||
const ownershipSelected = ref<OwnershipOption>('all')
|
||||
const showOwnershipFilter = computed(() => props.isAssetMode)
|
||||
const showOwnershipFilter = computed(() => isAssetMode)
|
||||
|
||||
const { ownershipOptions, availableBaseModels } = useAssetFilterOptions(
|
||||
() => assetData?.assets.value ?? []
|
||||
)
|
||||
|
||||
const baseModelSelected = ref<Set<string>>(new Set())
|
||||
const showBaseModelFilter = computed(() => props.isAssetMode)
|
||||
const showBaseModelFilter = computed(() => isAssetMode)
|
||||
const baseModelOptions = computed<FilterOption[]>(() => {
|
||||
if (!props.isAssetMode || !assetData) return []
|
||||
if (!isAssetMode || !assetData) return []
|
||||
return availableBaseModels.value
|
||||
})
|
||||
|
||||
const selectedSet = ref<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* Transforms a value using getOptionLabel if available.
|
||||
* Falls back to the original value if getOptionLabel is not provided,
|
||||
* returns undefined/null, or throws an error.
|
||||
*/
|
||||
function getDisplayLabel(value: string): string {
|
||||
const getOptionLabel = props.widget.options?.getOptionLabel
|
||||
const getOptionLabel = widget.options?.getOptionLabel
|
||||
if (!getOptionLabel) return value
|
||||
|
||||
try {
|
||||
@@ -136,7 +139,7 @@ function getDisplayLabel(value: string): string {
|
||||
}
|
||||
|
||||
const inputItems = computed<FormDropdownItem[]>(() => {
|
||||
const values = props.widget.options?.values || []
|
||||
const values = widget.options?.values || []
|
||||
|
||||
if (!Array.isArray(values)) {
|
||||
return []
|
||||
@@ -154,10 +157,9 @@ function assetKindToMediaType(kind: AssetKind): string {
|
||||
}
|
||||
|
||||
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
|
||||
return []
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(assetKind ?? '')) return []
|
||||
|
||||
const targetMediaType = assetKindToMediaType(props.assetKind!)
|
||||
const targetMediaType = assetKindToMediaType(assetKind!)
|
||||
const outputFiles = outputMediaAssets.media.value.filter(
|
||||
(asset) => getMediaTypeFromFilename(asset.name) === targetMediaType
|
||||
)
|
||||
@@ -173,18 +175,11 @@ const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a fallback item for the current modelValue when it doesn't exist
|
||||
* in the available items list. This handles cases like template-loaded nodes
|
||||
* where the saved value may not exist in the current server environment.
|
||||
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
|
||||
*/
|
||||
const missingValueItem = computed<FormDropdownItem | undefined>(() => {
|
||||
const currentValue = modelValue.value
|
||||
if (!currentValue) return undefined
|
||||
|
||||
// Check in cloud mode assets
|
||||
if (props.isAssetMode && assetData) {
|
||||
if (isAssetMode && assetData) {
|
||||
const existsInAssets = assetData.assets.value.some(
|
||||
(asset) => getAssetFilename(asset) === currentValue
|
||||
)
|
||||
@@ -198,7 +193,6 @@ const missingValueItem = computed<FormDropdownItem | undefined>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check in local mode inputs/outputs
|
||||
const existsInInputs = inputItems.value.some(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
@@ -221,12 +215,8 @@ const missingValueItem = computed<FormDropdownItem | undefined>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Transforms AssetItem[] to FormDropdownItem[] for cloud mode.
|
||||
* Uses getAssetFilename for display name, asset.name for label.
|
||||
*/
|
||||
const assetItems = computed<FormDropdownItem[]>(() => {
|
||||
if (!props.isAssetMode || !assetData) return []
|
||||
if (!isAssetMode || !assetData) return []
|
||||
return assetData.assets.value.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: getAssetFilename(asset),
|
||||
@@ -249,9 +239,7 @@ const baseModelFilteredAssetItems = computed<FormDropdownItem[]>(() =>
|
||||
)
|
||||
|
||||
const allItems = computed<FormDropdownItem[]>(() => {
|
||||
if (props.isAssetMode && assetData) {
|
||||
// Cloud assets not in user's library shouldn't appear as search results (COM-14333).
|
||||
// Unlike local mode, cloud users can't access files they don't own.
|
||||
if (isAssetMode && assetData) {
|
||||
return baseModelFilteredAssetItems.value
|
||||
}
|
||||
return [
|
||||
@@ -262,7 +250,7 @@ const allItems = computed<FormDropdownItem[]>(() => {
|
||||
})
|
||||
|
||||
const dropdownItems = computed<FormDropdownItem[]>(() => {
|
||||
if (props.isAssetMode) {
|
||||
if (isAssetMode) {
|
||||
return allItems.value
|
||||
}
|
||||
|
||||
@@ -282,20 +270,20 @@ const dropdownItems = computed<FormDropdownItem[]>(() => {
|
||||
* missing items so users can see their selected value even if not in library.
|
||||
*/
|
||||
const displayItems = computed<FormDropdownItem[]>(() => {
|
||||
if (props.isAssetMode && assetData && missingValueItem.value) {
|
||||
if (isAssetMode && assetData && missingValueItem.value) {
|
||||
return [missingValueItem.value, ...baseModelFilteredAssetItems.value]
|
||||
}
|
||||
return dropdownItems.value
|
||||
})
|
||||
|
||||
const mediaPlaceholder = computed(() => {
|
||||
const options = props.widget.options
|
||||
const options = widget.options
|
||||
|
||||
if (options?.placeholder) {
|
||||
return options.placeholder
|
||||
}
|
||||
|
||||
switch (props.assetKind) {
|
||||
switch (assetKind) {
|
||||
case 'image':
|
||||
return t('widgets.uploadSelect.placeholderImage')
|
||||
case 'video':
|
||||
@@ -314,14 +302,12 @@ const mediaPlaceholder = computed(() => {
|
||||
})
|
||||
|
||||
const uploadable = computed(() => {
|
||||
if (props.isAssetMode) return false
|
||||
return props.allowUpload === true
|
||||
if (isAssetMode) return false
|
||||
return allowUpload
|
||||
})
|
||||
|
||||
const acceptTypes = computed(() => {
|
||||
// Be permissive with accept types because backend uses libraries
|
||||
// that can handle a wide range of formats
|
||||
switch (props.assetKind) {
|
||||
switch (assetKind) {
|
||||
case 'image':
|
||||
return 'image/*'
|
||||
case 'video':
|
||||
@@ -335,7 +321,7 @@ const acceptTypes = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
|
||||
const layoutMode = ref<LayoutMode>(defaultLayoutMode)
|
||||
|
||||
watch(
|
||||
[modelValue, displayItems],
|
||||
@@ -374,63 +360,43 @@ function updateSelectedItems(selectedItems: Set<string>) {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
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')
|
||||
else if (props.uploadSubfolder)
|
||||
body.append('subfolder', props.uploadSubfolder)
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
// Handle multiple file uploads using shared uploadMediaBatch service
|
||||
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
||||
const folder = uploadFolder ?? 'input'
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
const results = await uploadMediaBatch(
|
||||
files.map((file) => ({ source: file })),
|
||||
{ type: folder, subfolder: uploadSubfolder }
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
||||
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[]) {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
try {
|
||||
// 1. Upload files to server
|
||||
const uploadedPaths = await uploadFiles(files)
|
||||
|
||||
if (uploadedPaths.length === 0) {
|
||||
toastStore.addAlert('File upload failed')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Update widget options to include new files
|
||||
// This simulates what addToComboValues does but for SimplifiedWidget
|
||||
const values = props.widget.options?.values
|
||||
const values = widget.options?.values
|
||||
if (Array.isArray(values)) {
|
||||
uploadedPaths.forEach((path) => {
|
||||
if (!values.includes(path)) {
|
||||
@@ -439,12 +405,10 @@ async function handleFilesUpdate(files: File[]) {
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Update widget value to the first uploaded file
|
||||
modelValue.value = uploadedPaths[0]
|
||||
|
||||
// 4. Trigger callback to notify underlying LiteGraph widget
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback(uploadedPaths[0])
|
||||
if (widget.callback) {
|
||||
widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
// 5. Snapshot undo state so the image change gets its own undo entry
|
||||
@@ -459,8 +423,7 @@ function getMediaUrl(
|
||||
filename: string,
|
||||
type: 'input' | 'output' = 'input'
|
||||
): string {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
|
||||
return ''
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(assetKind ?? '')) return ''
|
||||
const params = new URLSearchParams({ filename, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return `/api/view?${params}`
|
||||
|
||||
Reference in New Issue
Block a user