/** * 特殊类型的批量上传处理函数 * 包括:音乐、淘系-逛逛视频、3D场景、品牌VI、买家秀、小红书 */ import { chunkUpload } from '@/lib/chunk-upload' import { shouldPassRenderer } from '@/lib/material-validation' import { TAOXI_GUANGGUANG_VIDEO_TYPE } from '@/lib/video-material-types' import { compressPreviewImage, compressVideoPreviewImage } from './image-processor' /** * 将File转换为base64字符串格式(data:image/xxx;base64,...) * 用于批量上传中传递预览图给 completeBatchUpload */ export async function fileToBase64String(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result as string) reader.onerror = reject reader.readAsDataURL(file) }) } /** * 批量上传完成后调用complete接口,然后创建素材记录 */ /** * 带超时的fetch辅助函数 */ async function fetchWithTimeout(url: string, options: RequestInit & { timeout?: number }) { const { timeout = 60000, ...fetchOptions } = options // 默认60秒超时 const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) try { const response = await fetch(url, { ...fetchOptions, signal: controller.signal, }) clearTimeout(timeoutId) return response } catch (error) { clearTimeout(timeoutId) if (error instanceof Error && error.name === 'AbortError') { throw new Error(`请求超时(${timeout / 1000}秒)`) } throw error } } /** * 异步转换base64为Blob(避免阻塞主线程) */ async function base64ToBlob(base64Data: string): Promise { if (!base64Data.startsWith('data:')) { return null } const matches = base64Data.match(/^data:([^;]+);base64,(.+)$/) if (!matches) { return null } const mimeType = matches[1] const base64String = matches[2] // 使用Promise包裹,避免阻塞主线程 return new Promise((resolve) => { setTimeout(() => { try { const byteCharacters = atob(base64String) const byteNumbers = new Array(byteCharacters.length) for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i) } const byteArray = new Uint8Array(byteNumbers) const blob = new Blob([byteArray], { type: mimeType }) resolve(blob) } catch (error) { console.warn('[Upload] base64转换失败:', error) resolve(null) } }, 0) // 使用setTimeout(0)将操作推迟到下一个事件循环 }) } // complete 接口服务端只允许 1 个处理中请求并排队少量后续请求。 // 文件级并发保持很小,单个文件内部的 chunkUpload 并发仍由其自身策略控制。 const SPECIAL_BATCH_FILE_CONCURRENCY = 3 const BRAND_VI_FILE_CONCURRENCY = 1 const COMPLETE_MAX_ATTEMPTS = 3 const MATERIAL_CREATE_TIMEOUT_MS = 15 * 60 * 1000 // PDF 工具自身最多运行 10 分钟;单文件串行后仍需覆盖一个在途请求和排队开销。 const BRAND_VI_MATERIAL_CREATE_TIMEOUT_MS = 30 * 60 * 1000 const MATERIAL_STATUS_REQUEST_TIMEOUT_MS = 15 * 1000 const MATERIAL_STATUS_POLL_INTERVAL_MS = 2 * 1000 async function mapWithConcurrency( items: T[], concurrency: number, mapper: (item: T, index: number) => Promise, ): Promise { if (items.length === 0) { return [] } const results = new Array(items.length) let nextIndex = 0 const worker = async () => { while (true) { const index = nextIndex nextIndex += 1 if (index >= items.length) { return } results[index] = await mapper(items[index], index) } } const workerCount = Math.min(Math.max(1, concurrency), items.length) await Promise.all(Array.from({ length: workerCount }, () => worker())) return results } function isRetryableCompleteStatus(status: number): boolean { // 507 是上传盘不足,501 是服务不支持;立即重试只会放大压力。 return status === 429 || status === 500 || status === 502 || status === 503 || status === 504 } function getRetryAfterDelayMs(value: string | null | undefined): number | null { if (!value) { return null } const seconds = Number(value) if (Number.isFinite(seconds) && seconds >= 0) { return Math.ceil(seconds * 1000) } const retryAt = Date.parse(value) return Number.isNaN(retryAt) ? null : Math.max(0, retryAt - Date.now()) } function waitForRetry(delayMs: number): Promise { if (delayMs <= 0) { return Promise.resolve() } return new Promise((resolve) => setTimeout(resolve, delayMs)) } function getDefaultRetryDelayMs(attempt: number): number { return Math.min(2_000, 500 * (2 ** Math.max(0, attempt - 1))) } type MaterialUploadStatusResult = { status?: 'completed' | 'processing' | 'not_found' material?: Record error?: string } async function recoverAmbiguousMaterialCreation( uploadId: string, waitTimeoutMs: number, originalError: unknown, ): Promise> { const deadline = Date.now() + waitTimeoutMs while (true) { let response: Response try { response = await fetchWithTimeout( `/api/materials/upload?uploadId=${encodeURIComponent(uploadId)}`, { method: 'GET', timeout: MATERIAL_STATUS_REQUEST_TIMEOUT_MS, }, ) } catch (statusError) { throw new Error( `创建素材请求中断,且状态查询失败;已保留上传断点,请稍后重试确认(${ statusError instanceof Error ? statusError.message : '网络错误' })`, ) } if (response.status === 404) { throw new Error('创建素材请求中断,服务器尚未确认建档;已保留上传断点,请稍后重试') } const statusResult = await response.json().catch(() => ({})) as MaterialUploadStatusResult if (!response.ok) { throw new Error( statusResult.error || `创建素材请求中断,状态查询失败: ${response.status}`, ) } if (statusResult.status === 'completed' && statusResult.material) { console.log(`[CompleteBatchUpload] 状态查询确认建档完成: ${uploadId}`) return { message: '上传成功', material: statusResult.material, replayed: true, } } if (statusResult.status !== 'processing' || Date.now() >= deadline) { const reason = originalError instanceof Error ? originalError.message : '网络连接中断' throw new Error(`创建素材结果尚未确认(${reason});已保留上传断点,请稍后重试`) } await waitForRetry(Math.min(MATERIAL_STATUS_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now()))) } } export async function completeBatchUpload(uploadId: string, metadata: Record) { const fileName = metadata.fileName || metadata.originalFileName || 'unknown' console.log(`[CompleteBatchUpload] 开始处理: ${fileName}, uploadId: ${uploadId}`) try { // 第一步:合并分片(设置5分钟超时) let completeResponse: Response | null = null for (let attempt = 1; attempt <= COMPLETE_MAX_ATTEMPTS; attempt += 1) { console.log(`[CompleteBatchUpload] 调用complete端点: ${uploadId}(第${attempt}次)`) try { completeResponse = await fetchWithTimeout('/api/materials/upload/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId, originalFileName: fileName, }), timeout: 300000, // 5分钟超时 }) } catch (error) { if (attempt >= COMPLETE_MAX_ATTEMPTS) { throw error } const delayMs = getDefaultRetryDelayMs(attempt) console.warn( `[CompleteBatchUpload] complete请求中断,将用同一uploadId重试: ${uploadId}, ` + `等待${delayMs}ms`, error, ) await waitForRetry(delayMs) continue } if (completeResponse.ok) { break } const errorText = await completeResponse.text().catch(() => 'Unknown error') const canRetry = isRetryableCompleteStatus(completeResponse.status) && attempt < COMPLETE_MAX_ATTEMPTS if (!canRetry) { if (completeResponse.status === 404) { // 建档成功后会删除临时目录;若浏览器在删除 IndexedDB 前退出, // 下一次恢复会先看到 complete=404。此时只查 owner-scoped 结果。 return await recoverAmbiguousMaterialCreation( uploadId, 0, new Error('上传临时目录已清理'), ) } console.error(`[CompleteBatchUpload] complete端点返回错误: ${completeResponse.status}, ${errorText}`) throw new Error(`分片合并失败: ${completeResponse.status}`) } const retryAfter = getRetryAfterDelayMs(completeResponse.headers?.get?.('Retry-After')) ?? getDefaultRetryDelayMs(attempt) console.warn( `[CompleteBatchUpload] complete端点暂时不可用,将重试: ${completeResponse.status}, ` + `等待${retryAfter}ms`, ) await waitForRetry(retryAfter) } if (!completeResponse || !completeResponse.ok) { throw new Error('分片合并失败') } const completeResult = await completeResponse.json() const existingFilePath = completeResult.existingFilePath const originalFileName = completeResult.fileName || metadata.fileName if (!existingFilePath) { console.error(`[CompleteBatchUpload] complete端点未返回文件路径: ${uploadId}`) throw new Error('分片合并后未返回文件路径') } console.log(`[CompleteBatchUpload] complete成功,开始调用upload端点: ${uploadId}, 路径: ${existingFilePath}`) // 第二步:创建素材记录(使用合并后的文件路径) const formData = new FormData() formData.append('existingFilePath', existingFilePath) if (originalFileName) { formData.append('originalFileName', originalFileName) } else if (metadata.fileName) { formData.append('originalFileName', metadata.fileName) } formData.append('productModel', metadata.productModel) formData.append('categoryName', metadata.categoryName || '通用') formData.append('sceneName', metadata.sceneName || '通用') formData.append('platformName', metadata.platformName || '') formData.append('productCategoryName', metadata.productCategoryName || '') if (metadata.liveDate) formData.append('liveDate', String(metadata.liveDate)) if (metadata.liveHost) formData.append('liveHost', String(metadata.liveHost)) if (metadata.liveHostUserId) formData.append('liveHostUserId', String(metadata.liveHostUserId)) if (metadata.livePlatform) formData.append('livePlatform', String(metadata.livePlatform)) const materialType = metadata.materialType || '3D场景' formData.append('materialType', materialType) // 统一处理renderer字段:根据素材类型判断是否需要传递 const rendererInfo = shouldPassRenderer(materialType, metadata.renderer) if (rendererInfo.shouldPass) { formData.append('renderer', rendererInfo.value) } // 如果有预览图(base64字符串格式),需要转换为File对象(异步处理) if (metadata.previewImage) { console.log(`[CompleteBatchUpload] 开始处理预览图: ${uploadId}`) try { const blob = await base64ToBlob(metadata.previewImage) if (blob) { const file = new File([blob], 'preview.jpg', { type: blob.type }) formData.append('previewImage', file) console.log(`[CompleteBatchUpload] 预览图处理完成: ${uploadId}, 大小: ${blob.size} bytes`) } else { throw new Error('预览图数据无法解析,请重新选择图片后重试') } } catch (previewError) { console.error(`[CompleteBatchUpload] 预览图处理失败: ${uploadId}`, previewError) throw previewError instanceof Error ? previewError : new Error('预览图处理失败,请重新选择图片后重试') } } // 调用 upload 端点。品牌 VI 的 PDF 处理上限为 10 分钟,30 分钟窗口 // 同时覆盖一个在途任务、服务端排队和收尾;请求结果不确定时只查状态。 console.log(`[CompleteBatchUpload] 调用upload端点: ${uploadId}`) formData.append('uploadId', uploadId) const materialCreateTimeout = materialType === '品牌VI灵感' ? BRAND_VI_MATERIAL_CREATE_TIMEOUT_MS : MATERIAL_CREATE_TIMEOUT_MS let uploadResponse: Response try { uploadResponse = await fetchWithTimeout('/api/materials/upload', { method: 'POST', body: formData, timeout: materialCreateTimeout, }) } catch (error) { // POST 可能已经在服务器完成,不能盲目再次建档;owner-scoped GET // 是唯一恢复路径,失败时保留 IndexedDB 供用户稍后确认。 return await recoverAmbiguousMaterialCreation(uploadId, materialCreateTimeout, error) } if (!uploadResponse.ok) { const errorData = await uploadResponse.json().catch(() => ({ error: `HTTP ${uploadResponse.status}` })) console.error(`[CompleteBatchUpload] upload端点返回错误: ${uploadId}, ${errorData.error}`) throw new Error(errorData.error || `创建素材记录失败: ${uploadResponse.status}`) } const uploadResult = await uploadResponse.json() console.log(`[CompleteBatchUpload] 全部完成: ${uploadId}, materialId: ${uploadResult.material?.id || 'N/A'}`) return uploadResult } catch (error) { console.error(`[CompleteBatchUpload] 处理失败: ${uploadId}`, error) throw error } } /** * 批量上传音乐文件 */ export async function uploadBatchMusic( files: Array<{ file: File; productModel: string }>, platformName: string, renderer: string, onProgress: (progress: number) => void ) { const baseMetadata = { materialType: '音乐', categoryName: '通用', sceneName: '通用', platformName: platformName || '', renderer: renderer, } const results = await mapWithConcurrency(files, SPECIAL_BATCH_FILE_CONCURRENCY, async (item, index) => { try { const metadata = { ...baseMetadata, productModel: item.productModel, fileName: item.file.name, } const result = await chunkUpload(item.file, metadata, { // chunkSize使用默认配置:内网20MB,公网10MB onProgress: (progress) => { const totalProgress = Math.round((100 * index + progress) / files.length) onProgress(totalProgress) }, skipComplete: true, retainStateOnSkipComplete: true, maxRetries: 5, }) // 等待completeBatchUpload完成,确保素材记录被创建 if (result?.uploadId) { try { await completeBatchUpload(result.uploadId, metadata) await result.confirmMaterialCreated?.() } catch (err) { console.error(`[Upload] 音乐文件 ${item.file.name} 后台处理失败:`, err) throw err // 重新抛出错误,让上层知道失败 } } return { tempId: `pending-${Date.now()}-${index}`, uploadId: result?.uploadId || null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'processing' as const, } } catch (error) { console.error(`[Upload] 音乐文件 ${item.file.name} 上传失败:`, error) return { tempId: `pending-${Date.now()}-${index}`, uploadId: null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'failed' as const, } } }) return results } /** * 批量上传淘系-逛逛视频 */ export async function uploadBatchVideo( files: Array<{ file: File productModel: string productCategoryName?: string previewImage?: File }>, platformName: string, renderer: string, productCategoryName: string, onProgress: (progress: number) => void ) { const baseMetadata = { materialType: TAOXI_GUANGGUANG_VIDEO_TYPE, categoryName: '通用', sceneName: '通用', platformName: platformName || '', renderer: renderer, productCategoryName: productCategoryName || '', } // 预览图可能达到 50MB。逐条处理可以把图片解码、压缩和 base64 // 转换的峰值限制在一个文件内,避免最多 8 条视频同时占满浏览器内存。 const results: Array<{ tempId: string uploadId: string | null productModel: string fileName: string fileSize: number status: 'processing' | 'failed' }> = [] for (let index = 0; index < files.length; index += 1) { const item = files[index] try { const metadata: any = { ...baseMetadata, productModel: item.productModel, productCategoryName: item.productCategoryName || productCategoryName || '', fileName: item.file.name, } if (item.previewImage) { const compressedPreview = await compressVideoPreviewImage(item.previewImage) metadata.previewImage = await fileToBase64String(compressedPreview) } const result = await chunkUpload(item.file, metadata, { // chunkSize使用默认配置:内网20MB,公网10MB onProgress: (progress) => { const totalProgress = Math.round((100 * index + progress) / files.length) onProgress(totalProgress) }, skipComplete: true, retainStateOnSkipComplete: true, maxRetries: 5, }) // 等待completeBatchUpload完成,确保素材记录被创建 if (result?.uploadId) { try { await completeBatchUpload(result.uploadId, metadata) await result.confirmMaterialCreated?.() } catch (err) { console.error(`[Upload] 视频文件 ${item.file.name} 后台处理失败:`, err) throw err // 重新抛出错误,让上层知道失败 } } results.push({ tempId: `pending-${Date.now()}-${index}`, uploadId: result?.uploadId || null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'processing', }) } catch (error) { console.error(`[Upload] 视频文件 ${item.file.name} 上传失败:`, error) results.push({ tempId: `pending-${Date.now()}-${index}`, uploadId: null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'failed', }) } } return results } /** * 批量上传3D场景文件 */ export async function uploadBatch3D( files: Array<{ file: File; productModel: string; previewImage?: File }>, categoryName: string, sceneName: string, platformName: string, renderer: string, onProgress: (progress: number) => void ) { const baseMetadata = { materialType: '3D场景', categoryName: categoryName || '通用', sceneName: sceneName || '通用', platformName: platformName || '', renderer: renderer, } const results = await mapWithConcurrency( files, SPECIAL_BATCH_FILE_CONCURRENCY, async (item, index) => { try { const metadata: any = { ...baseMetadata, productModel: item.productModel, fileName: item.file.name, } // 如果有预览图,先压缩再转换为base64字符串格式 if (item.previewImage) { const compressed = await compressPreviewImage(item.previewImage) metadata.previewImage = await fileToBase64String(compressed) } const result = await chunkUpload(item.file, metadata, { // chunkSize使用默认配置:内网20MB,公网10MB onProgress: (progress) => { const totalProgress = Math.round((100 * index + progress) / files.length) onProgress(totalProgress) }, skipComplete: true, retainStateOnSkipComplete: true, maxRetries: 5, }) if (result?.uploadId) { // 等待completeBatchUpload完成,确保素材记录被创建 try { await completeBatchUpload(result.uploadId, metadata) await result.confirmMaterialCreated?.() } catch (err) { console.error(`[Upload] 3D场景文件 ${item.file.name} 后台处理失败:`, err) throw err // 重新抛出错误,让上层知道失败 } } return { tempId: `pending-${Date.now()}-${index}`, uploadId: result?.uploadId || null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'success', } } catch (error) { console.error(`[Upload] 3D场景文件 ${item.file.name} 上传失败:`, error) return { tempId: `pending-${Date.now()}-${index}`, uploadId: null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'failed', } } }, ) return results } /** * 批量上传买家秀 */ export async function uploadBatchBuyerShow( batches: Array<{ productModel: string; images: File[] }>, categoryName: string, sceneName: string, platformName: string, renderer: string, onProgress: (progress: number) => void ) { const baseMetadata = { materialType: '买家秀图片', categoryName: categoryName || '通用', sceneName: sceneName || '通用', platformName: platformName || '', renderer: renderer, } const results: Array<{ tempId: string uploadId: string | null productModel: string status: 'processing' | 'failed' }> = [] // 一个买家秀批次可能包含多张原图。逐批提交可避免多个大 multipart // 同时进入 Node.js 并占满内存;单批内仍保留原图质量和原有字段语义。 for (let index = 0; index < batches.length; index += 1) { const item = batches[index] try { const metadata: any = { ...baseMetadata, productModel: item.productModel, } // 买家秀类型直接调用upload接口创建素材记录(不需要分片上传) const formData = new FormData() formData.append('productModel', item.productModel) formData.append('categoryName', metadata.categoryName) formData.append('sceneName', metadata.sceneName) formData.append('platformName', metadata.platformName || '') formData.append('productCategoryName', metadata.productCategoryName || '') const materialType = metadata.materialType formData.append('materialType', materialType) // 统一处理renderer字段:根据素材类型判断是否需要传递 const rendererInfo = shouldPassRenderer(materialType, metadata.renderer) if (rendererInfo.shouldPass) { formData.append('renderer', rendererInfo.value) } // 添加预览图和模特图(预览图需要压缩) if (item.images.length > 0) { const compressedPreview = await compressPreviewImage(item.images[0]) formData.append('previewImage', compressedPreview) // 其他图片作为模特图 for (let i = 1; i < item.images.length; i++) { formData.append('modelImages', item.images[i]) } } const response = await fetch('/api/materials/upload', { method: 'POST', body: formData, }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || `上传失败: ${response.status}`) } const result = await response.json() const totalProgress = Math.round(((index + 1) * 100) / batches.length) onProgress(totalProgress) results.push({ tempId: `pending-${Date.now()}-${index}`, uploadId: result.material?.id || null, productModel: item.productModel, status: 'processing', }) } catch (error) { console.error(`[Upload] 买家秀批次 ${item.productModel} 上传失败:`, error) results.push({ tempId: `pending-${Date.now()}-${index}`, uploadId: null, productModel: item.productModel, status: 'failed', }) } } return results } /** * 批量上传小红书 */ export async function uploadBatchXiaohongshu( batches: Array<{ productModel: string; images: File[] }>, categoryName: string, sceneName: string, platformName: string, renderer: string, onProgress: (progress: number) => void ) { const baseMetadata = { materialType: '小红书种草', categoryName: categoryName || '通用', sceneName: sceneName || '通用', platformName: platformName || '', renderer: renderer, } const results: Array<{ tempId: string uploadId: string | null productModel: string status: 'processing' | 'failed' }> = [] // 与买家秀保持同一安全边界:逐批提交,避免多个图片 multipart // 同时解析;某一批失败不会阻止后续批次继续。 for (let index = 0; index < batches.length; index += 1) { const item = batches[index] try { const metadata: any = { ...baseMetadata, productModel: item.productModel, } // 小红书类型直接调用upload接口创建素材记录(不需要分片上传) const formData = new FormData() formData.append('productModel', item.productModel) formData.append('categoryName', metadata.categoryName) formData.append('sceneName', metadata.sceneName) formData.append('platformName', metadata.platformName || '') formData.append('productCategoryName', metadata.productCategoryName || '') const materialType = metadata.materialType formData.append('materialType', materialType) // 统一处理renderer字段:根据素材类型判断是否需要传递 const rendererInfo = shouldPassRenderer(materialType, metadata.renderer) if (rendererInfo.shouldPass) { formData.append('renderer', rendererInfo.value) } // 添加预览图和模特图(预览图需要压缩) if (item.images.length > 0) { const compressedPreview = await compressPreviewImage(item.images[0]) formData.append('previewImage', compressedPreview) // 其他图片作为模特图 for (let i = 1; i < item.images.length; i++) { formData.append('modelImages', item.images[i]) } } const response = await fetch('/api/materials/upload', { method: 'POST', body: formData, }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || `上传失败: ${response.status}`) } const result = await response.json() const totalProgress = Math.round(((index + 1) * 100) / batches.length) onProgress(totalProgress) results.push({ tempId: `pending-${Date.now()}-${index}`, uploadId: result.material?.id || null, productModel: item.productModel, status: 'processing', }) } catch (error) { console.error(`[Upload] 小红书批次 ${item.productModel} 上传失败:`, error) results.push({ tempId: `pending-${Date.now()}-${index}`, uploadId: null, productModel: item.productModel, status: 'failed', }) } } return results } /** * 批量上传品牌VI */ export async function uploadBatchBrandVI( files: Array<{ file: File; productModel: string }>, productCategoryName: string, onProgress: (progress: number) => void ) { const baseMetadata = { materialType: '品牌VI灵感', categoryName: '通用', sceneName: '通用', platformName: '', renderer: '', productCategoryName: productCategoryName || '', } const results = await mapWithConcurrency(files, BRAND_VI_FILE_CONCURRENCY, async (item, index) => { try { const metadata = { ...baseMetadata, productModel: item.productModel, fileName: item.file.name, } const result = await chunkUpload(item.file, metadata, { // chunkSize使用默认配置:内网20MB,公网10MB onProgress: (progress) => { const totalProgress = Math.round((100 * index + progress) / files.length) onProgress(totalProgress) }, skipComplete: true, retainStateOnSkipComplete: true, maxRetries: 5, }) // 等待completeBatchUpload完成,确保素材记录被创建 if (result?.uploadId) { try { await completeBatchUpload(result.uploadId, metadata) await result.confirmMaterialCreated?.() } catch (err) { console.error(`[Upload] PDF文件 ${item.file.name} 后台处理失败:`, err) throw err // 重新抛出错误,让上层知道失败 } } return { tempId: `pending-${Date.now()}-${index}`, uploadId: result?.uploadId || null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'processing' as const, } } catch (error) { console.error(`[Upload] PDF文件 ${item.file.name} 上传失败:`, error) return { tempId: `pending-${Date.now()}-${index}`, uploadId: null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'failed' as const, } } }) return results }