import { type ChangeEvent, type Dispatch, type SetStateAction, useCallback, useRef, useState, } from 'react'; import { ApiClientError } from '../../services/apiClient'; import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient'; import { createEditorAsset, createEditorAssetFolder, createEditorProjectResource, } from '../../services/image-editor/editorProjectClient'; import { uploadEditorSeedanceReferenceFile } from '../../services/image-editor/editorReferenceUploadClient'; import { EDITOR_UPLOAD_ASSET_FOLDER_LABEL, resolvePreferredAssetUploadFolder, resolveUploadAssetFolder, } from './ImageCanvasAssetLibraryModel'; import type { CanvasAssetKind, CanvasHistoryAction, CanvasLayer, CanvasMediaType, CanvasTool, CanvasViewport, CharacterReferenceImage, EditorAsset, EditorAssetFolder, GenerateDialogState, QuickEditPanelState, UploadTarget, } from './ImageCanvasEditorTypes'; import { isImageFile, isSeedanceReferenceAudioFile, isSeedanceReferenceImageFile, isSeedanceReferenceVideoFile, probeImageFileDimensions, probeMediaDurationSeconds, probeMediaFileMetadata, readFileAsDataUrl, readImageFileAsDataUrl, resolveEditorAssetUploadMediaType, SEEDANCE_REFERENCE_FILE_LIMITS, } from './ImageCanvasFileModel'; import { applyGenerationReferenceUpload, applyMeasuredUploadAssetSize, applyPersistedUploadAsset, applyQuickEditReferenceUpload, applyUploadAssetCreateFailure, applyUploadAssetCreatePending, applyUploadAssetReadFailure, applyUploadAssetReadSuccess, bindUploadLayerToPersistedAsset, buildUploadedMediaGenerationInputs, createUploadCanvasLayer, createUploadedGenerationReference, createUploadingAssetPlaceholder, expandUploadFolder, resizeUploadCanvasLayerToImage, resolveUploadFolderId, } from './ImageCanvasUploadModel'; import { resolveUploadedAssetKind } from './ImageCanvasUploadUtils'; type UploadFilesOptions = { folderId?: string; canvasPoint?: { x: number; y: number }; addToCanvas?: boolean; }; type UploadRequestOptions = { addToCanvas?: boolean; folderId?: string; }; type UploadAssetFileOptions = UploadFilesOptions & { uploadIndex: number; }; class ImageReferenceCapacityChangedError extends Error {} export type ImageReferenceUploadCapacity = { label: string; currentCount: number; limit: number; contextId?: string; }; type ImageReferenceUploadReservation = { target: UploadTarget; count: number; contextId?: string; key: string; release: () => void; contextWarningShown: boolean; }; type UseImageCanvasUploadWorkflowOptions = { canAccessProtectedData: boolean; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; projectId?: string | null; assetFolders: EditorAssetFolder[]; activeUploadFolderId: string; canvasSize: { width: number; height: number }; viewport: CanvasViewport; activeTool: CanvasTool; allocateUploadIndex: () => number; setAssetFolders: Dispatch>; setAssets: Dispatch>; setLayers: Dispatch>; setGenerateDialog: Dispatch>; setQuickEditPanel: Dispatch>; resolveImageReferenceUploadCapacity: ( target: UploadTarget, ) => ImageReferenceUploadCapacity | null; appendUiAssetExtractionReferences: ( references: CharacterReferenceImage[], ) => void; appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; captureCanvasHistory: (action: CanvasHistoryAction) => void; selectSingleLayer: (layerId: string | null) => void; refreshAssetLibrary?: () => Promise | void; }; function isEditorAuthError(error: unknown) { return ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) ); } function resolveReferenceFallbackSize(mediaType: CanvasMediaType) { if (mediaType === 'video') { return { width: 560, height: 315 }; } if (mediaType === 'audio') { return { width: 420, height: 120 }; } return { width: 420, height: 315 }; } export function useImageCanvasUploadWorkflow({ canAccessProtectedData, openEditorLoginModal, projectId, assetFolders, activeUploadFolderId, canvasSize, viewport, activeTool, allocateUploadIndex, setAssetFolders, setAssets, setLayers, setGenerateDialog, setQuickEditPanel, resolveImageReferenceUploadCapacity, appendUiAssetExtractionReferences, appendCanvasLayersWithResources, captureCanvasHistory, selectSingleLayer, refreshAssetLibrary, }: UseImageCanvasUploadWorkflowOptions) { const uploadInputRef = useRef(null); const canAccessProtectedDataRef = useRef(canAccessProtectedData); const uploadTargetRef = useRef('asset'); const uploadRequestOptionsRef = useRef({}); const pendingImageReferenceUploadsRef = useRef>({}); const uploadAssetFolderCreatePromiseRef = useRef | null>( null, ); const [uploadTarget, setUploadTargetState] = useState('asset'); const [ pendingImageReferenceUploadCount, setPendingImageReferenceUploadCount, ] = useState(0); canAccessProtectedDataRef.current = canAccessProtectedData; const setUploadTarget: Dispatch> = useCallback( (nextTarget) => { const resolvedTarget = typeof nextTarget === 'function' ? nextTarget(uploadTargetRef.current) : nextTarget; uploadTargetRef.current = resolvedTarget; setUploadTargetState(resolvedTarget); }, [], ); const showImageReferenceCapacityWarning = useCallback( (capacity: ImageReferenceUploadCapacity, remaining: number) => { window.alert( `${capacity.label}最多允许 ${capacity.limit} 张,当前已有 ${capacity.currentCount} 张,本次最多还能选择 ${Math.max(0, remaining)} 张`, ); }, [], ); const reserveImageReferenceUpload = useCallback( (target: UploadTarget, count: number) => { const capacity = resolveImageReferenceUploadCapacity(target); const key = `${target}\u0000${capacity?.contextId ?? ''}`; const pending = pendingImageReferenceUploadsRef.current[key] ?? 0; if (capacity) { const remaining = capacity.limit - capacity.currentCount - pending; if (count > remaining) { showImageReferenceCapacityWarning(capacity, remaining); return null; } } pendingImageReferenceUploadsRef.current[key] = pending + count; setPendingImageReferenceUploadCount((current) => current + count); let released = false; const reservation: ImageReferenceUploadReservation = { target, count, contextId: capacity?.contextId, key, contextWarningShown: false, release: () => { if (released) { return; } released = true; const current = pendingImageReferenceUploadsRef.current[key] ?? 0; pendingImageReferenceUploadsRef.current[key] = Math.max( 0, current - count, ); setPendingImageReferenceUploadCount((currentCount) => Math.max(0, currentCount - count), ); }, }; return reservation; }, [resolveImageReferenceUploadCapacity, showImageReferenceCapacityWarning], ); const validateImageReferenceUploadReservation = useCallback( (reservation: ImageReferenceUploadReservation) => { const capacity = resolveImageReferenceUploadCapacity(reservation.target); if (!capacity) { if (reservation.contextId === undefined) { return true; } if (!reservation.contextWarningShown) { reservation.contextWarningShown = true; window.alert( '参考图上传目标已关闭或切换,本次结果不会添加到其他面板', ); } return false; } if (capacity.contextId !== reservation.contextId) { if (!reservation.contextWarningShown) { reservation.contextWarningShown = true; window.alert( '参考图上传目标已关闭或切换,本次结果不会添加到其他面板', ); } return false; } const pending = pendingImageReferenceUploadsRef.current[reservation.key] ?? 0; const remaining = capacity.limit - capacity.currentCount; if (pending <= remaining) { return true; } showImageReferenceCapacityWarning(capacity, remaining); return false; }, [resolveImageReferenceUploadCapacity, showImageReferenceCapacityWarning], ); const createPersistedReference = useCallback( async ({ idPrefix, index, file, fallbackLabel, imageSrc, mediaType = 'image', mimeType, sizeBytes, durationSeconds, objectKey, assetObjectId, reservation, authoritativeAssetKind, }: { idPrefix: string; index?: number; file: File; fallbackLabel: string; imageSrc: string; mediaType?: CanvasMediaType; mimeType?: string; sizeBytes?: number; durationSeconds?: number; objectKey?: string | null; assetObjectId?: string | null; reservation?: ImageReferenceUploadReservation; authoritativeAssetKind?: CanvasAssetKind; }): Promise => { const resolvedMediaType = mediaType ?? 'image'; let persistedImageSrc = imageSrc; let persistedObjectKey = objectKey; let persistedAssetObjectId = assetObjectId; if (resolvedMediaType === 'image' && !persistedObjectKey) { const uploadedImage = await uploadEditorMediaAssetFile(file, 'image'); persistedImageSrc = uploadedImage.legacyPublicPath; persistedObjectKey = uploadedImage.objectKey; persistedAssetObjectId = uploadedImage.assetObjectId; } const fallbackSize = resolveReferenceFallbackSize(resolvedMediaType); const imageDimensions = resolvedMediaType === 'image' ? await probeImageFileDimensions(file).catch(() => null) : null; const width = imageDimensions?.width ?? fallbackSize.width; const height = imageDimensions?.height ?? fallbackSize.height; if ( reservation && !validateImageReferenceUploadReservation(reservation) ) { throw new ImageReferenceCapacityChangedError(); } const assetKind = authoritativeAssetKind ?? resolveUploadedAssetKind(resolvedMediaType); const generationInputs = buildUploadedMediaGenerationInputs( resolvedMediaType, durationSeconds, ); if (projectId) { const resource = await createEditorProjectResource(projectId, { imageSrc: persistedImageSrc, ...(persistedObjectKey ? { objectKey: persistedObjectKey } : {}), ...(persistedAssetObjectId ? { assetObjectId: persistedAssetObjectId } : {}), width, height, sourceType: 'uploaded', assetKind, generationInputs, }); return createUploadedGenerationReference({ idPrefix, index, fileName: file.name, fallbackLabel, imageSrc: persistedImageSrc, mediaType: resolvedMediaType, mimeType, sizeBytes, durationSeconds, objectKey: persistedObjectKey, assetObjectId: persistedAssetObjectId, resourceId: resource.resourceId, }); } const uploadFolderId = resolveUploadFolderId({ assetFolders, activeUploadFolderId, }); const asset = await createEditorAsset({ folderId: uploadFolderId, label: file.name || fallbackLabel, imageSrc: persistedImageSrc, ...(persistedObjectKey ? { objectKey: persistedObjectKey } : {}), ...(persistedAssetObjectId ? { assetObjectId: persistedAssetObjectId } : {}), width, height, sourceType: 'uploaded', assetKind, generationInputs, }); return createUploadedGenerationReference({ idPrefix, index, fileName: file.name, fallbackLabel, imageSrc: persistedImageSrc, mediaType: resolvedMediaType, mimeType, sizeBytes, durationSeconds, objectKey: persistedObjectKey, assetObjectId: persistedAssetObjectId, sourceAssetId: asset.assetId, }); }, [ activeUploadFolderId, assetFolders, projectId, validateImageReferenceUploadReservation, ], ); const persistReferenceUploads = useCallback( async ( inputs: Array<{ idPrefix: string; index?: number; file: File; fallbackLabel: string; imageSrc: string; mediaType?: CanvasMediaType; mimeType?: string; sizeBytes?: number; durationSeconds?: number; objectKey?: string | null; assetObjectId?: string | null; authoritativeAssetKind?: CanvasAssetKind; }>, reservation?: ImageReferenceUploadReservation, ) => { const results = await Promise.allSettled( inputs.map((input) => createPersistedReference({ ...input, reservation }), ), ); const references = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : [], ); const errors = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : [], ); if (references.length > 0) { void Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined); } if (errors.length === 0) { return references; } const authError = errors.find(isEditorAuthError); if (authError) { openEditorLoginModal(); window.alert( references.length > 0 ? `部分参考图保存失败,已保留成功的 ${references.length} 张;请登录后重试其余图片` : '请先登录后再上传参考图', ); } else if ( !errors.every( (error) => error instanceof ImageReferenceCapacityChangedError, ) ) { window.alert( references.length > 0 ? `部分参考图保存失败,已保留成功的 ${references.length} 张` : '参考图保存失败,请稍后重试', ); } return references; }, [createPersistedReference, openEditorLoginModal, refreshAssetLibrary], ); const addCharacterSpecReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const reservation = reserveImageReferenceUpload('character-spec', 1); if (!reservation) { return; } try { const imageSrc = await readImageFileAsDataUrl(imageFile); if (!validateImageReferenceUploadReservation(reservation)) { return; } const references = await persistReferenceUploads( [ { idPrefix: 'upload-character-spec', file: imageFile, fallbackLabel: '角色规范', imageSrc, }, ], reservation, ); if ( references.length === 0 || !validateImageReferenceUploadReservation(reservation) ) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'character-spec', references, }), ); } finally { reservation.release(); } }, [ persistReferenceUploads, reserveImageReferenceUpload, setGenerateDialog, validateImageReferenceUploadReservation, ], ); const addSpecReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const reservation = reserveImageReferenceUpload( 'spec-reference', Array.from(files).filter(isImageFile).length, ); if (!reservation) { return; } try { const imageFiles = Array.from(files).filter(isImageFile); const fileReferences = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, imageSrc: await readImageFileAsDataUrl(file), })), ); if (!validateImageReferenceUploadReservation(reservation)) { return; } const references = await persistReferenceUploads( fileReferences.map(({ file, imageSrc, index }) => ({ idPrefix: 'upload-spec-reference', index, file, fallbackLabel: `参考图${index + 1}`, imageSrc, })), reservation, ); if ( references.length === 0 || !validateImageReferenceUploadReservation(reservation) ) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'spec-reference', references, }), ); } finally { reservation.release(); } }, [ persistReferenceUploads, reserveImageReferenceUpload, setGenerateDialog, validateImageReferenceUploadReservation, ], ); const addCharacterReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isImageFile); if (!imageFiles.length) { window.alert('请选择图片文件'); return; } const reservation = reserveImageReferenceUpload( 'character-reference', imageFiles.length, ); if (!reservation) { return; } try { const references = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, imageSrc: await readImageFileAsDataUrl(file), })), ); if (!validateImageReferenceUploadReservation(reservation)) { return; } const persistedReferences = await persistReferenceUploads( references.map(({ file, imageSrc, index }) => ({ idPrefix: 'upload-character-reference', index, file, fallbackLabel: `参考图${index + 1}`, imageSrc, })), reservation, ); if ( persistedReferences.length === 0 || !validateImageReferenceUploadReservation(reservation) ) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'character-reference', references: persistedReferences, }), ); } finally { reservation.release(); } }, [ persistReferenceUploads, reserveImageReferenceUpload, setGenerateDialog, validateImageReferenceUploadReservation, ], ); const addGenerationReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isImageFile); if (!imageFiles.length) { window.alert('请选择图片文件'); return; } const reservation = reserveImageReferenceUpload( 'generation-reference', imageFiles.length, ); if (!reservation) { return; } try { const references = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, imageSrc: await readImageFileAsDataUrl(file), })), ); if (!validateImageReferenceUploadReservation(reservation)) { return; } const persistedReferences = await persistReferenceUploads( references.map(({ file, imageSrc, index }) => ({ idPrefix: 'upload-generation-reference', index, file, fallbackLabel: `参考图${index + 1}`, imageSrc, })), reservation, ); if ( persistedReferences.length === 0 || !validateImageReferenceUploadReservation(reservation) ) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'generation-reference', references: persistedReferences, }), ); } finally { reservation.release(); } }, [ persistReferenceUploads, reserveImageReferenceUpload, setGenerateDialog, validateImageReferenceUploadReservation, ], ); const addQuickEditReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isImageFile); if (!imageFiles.length) { window.alert('请选择图片文件'); return; } const reservation = reserveImageReferenceUpload( 'quick-edit-reference', imageFiles.length, ); if (!reservation) { return; } try { const references = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, imageSrc: await readImageFileAsDataUrl(file), })), ); if (!validateImageReferenceUploadReservation(reservation)) { return; } const persistedReferences = await persistReferenceUploads( references.map(({ file, imageSrc, index }) => ({ idPrefix: 'upload-quick-edit-reference', index, file, fallbackLabel: `参考图${index + 1}`, imageSrc, })), reservation, ); if ( persistedReferences.length === 0 || !validateImageReferenceUploadReservation(reservation) ) { return; } setQuickEditPanel((currentPanel) => applyQuickEditReferenceUpload({ panel: currentPanel, references: persistedReferences, }), ); } finally { reservation.release(); } }, [ persistReferenceUploads, reserveImageReferenceUpload, setQuickEditPanel, validateImageReferenceUploadReservation, ], ); const addUiAssetExtractionReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isImageFile); if (!imageFiles.length) { window.alert('请选择图片文件'); return; } const reservation = reserveImageReferenceUpload( 'ui-asset-extraction-reference', imageFiles.length, ); if (!reservation) { return; } try { const references = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, imageSrc: await readImageFileAsDataUrl(file), })), ); if (!validateImageReferenceUploadReservation(reservation)) { return; } const persistedReferences = await persistReferenceUploads( references.map(({ file, imageSrc, index }) => ({ idPrefix: 'upload-ui-asset-extraction-reference', index, file, fallbackLabel: `UI素材参考图${index + 1}`, imageSrc, })), reservation, ); if ( persistedReferences.length === 0 || !validateImageReferenceUploadReservation(reservation) ) { return; } appendUiAssetExtractionReferences(persistedReferences); } finally { reservation.release(); } }, [ appendUiAssetExtractionReferences, persistReferenceUploads, reserveImageReferenceUpload, validateImageReferenceUploadReservation, ], ); const addPublicationReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isImageFile); if (!imageFiles.length) { window.alert('请选择图片文件'); return; } const reservation = reserveImageReferenceUpload( 'publication-reference', imageFiles.length, ); if (!reservation) { return; } try { const references = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, imageSrc: await readImageFileAsDataUrl(file), })), ); if (!validateImageReferenceUploadReservation(reservation)) { return; } const persistedReferences = await persistReferenceUploads( references.map(({ file, imageSrc, index }) => ({ idPrefix: 'upload-publication-reference', index, file, fallbackLabel: `宣发参考图${index + 1}`, imageSrc, })), reservation, ); if ( persistedReferences.length === 0 || !validateImageReferenceUploadReservation(reservation) ) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'publication-reference', references: persistedReferences, }), ); } finally { reservation.release(); } }, [ persistReferenceUploads, reserveImageReferenceUpload, setGenerateDialog, validateImageReferenceUploadReservation, ], ); const addVideoReferenceImageFiles = useCallback( async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isSeedanceReferenceImageFile); if (!imageFiles.length) { window.alert('请选择 Seedance 支持的图片文件'); return; } if ( imageFiles.some( (file) => file.size >= SEEDANCE_REFERENCE_FILE_LIMITS.imageBytes, ) ) { window.alert('参考图片单张必须小于 30MB'); return; } const references = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, src: await readFileAsDataUrl(file), })), ); const persistedReferences = await persistReferenceUploads( references.map(({ file, src, index }) => ({ idPrefix: 'upload-video-reference-image', index, file, fallbackLabel: `参考图${index + 1}`, imageSrc: src, mediaType: 'image', mimeType: file.type || undefined, sizeBytes: file.size, })), ); if (persistedReferences.length === 0) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'video-reference-image', references: persistedReferences, }), ); }, [persistReferenceUploads, setGenerateDialog], ); const addVideoReferenceVideoFiles = useCallback( async (files: FileList | File[]) => { const videoFiles = Array.from(files).filter(isSeedanceReferenceVideoFile); if (!videoFiles.length) { window.alert('请选择 mp4 或 mov 视频文件'); return; } if ( videoFiles.some( (file) => file.size > SEEDANCE_REFERENCE_FILE_LIMITS.videoBytes, ) ) { window.alert('参考视频单个不能超过 50MB'); return; } const references = await Promise.all( videoFiles.map(async (file, index) => { const durationSeconds = await probeMediaDurationSeconds( file, 'video', ); return { file, index, durationSeconds, }; }), ); if ( references.some( ({ durationSeconds }) => Number.isFinite(durationSeconds) && (durationSeconds < SEEDANCE_REFERENCE_FILE_LIMITS.mediaMinDurationSeconds || durationSeconds > SEEDANCE_REFERENCE_FILE_LIMITS.mediaMaxDurationSeconds), ) ) { window.alert('参考视频时长必须为 2-15 秒'); return; } const totalDuration = references.reduce( (sum, reference) => Number.isFinite(reference.durationSeconds) ? sum + reference.durationSeconds : sum, 0, ); if ( totalDuration > SEEDANCE_REFERENCE_FILE_LIMITS.mediaTotalDurationSeconds ) { window.alert('参考视频总时长不能超过 15 秒'); return; } let uploadedReferences: Array<{ file: File; index: number; durationSeconds: number; src: string; objectKey: string; assetObjectId: string; }>; try { uploadedReferences = await Promise.all( references.map(async ({ file, index, durationSeconds }) => { const uploaded = await uploadEditorSeedanceReferenceFile( file, 'video', ); return { file, index, durationSeconds, src: uploaded.src, objectKey: uploaded.objectKey, assetObjectId: uploaded.assetObjectId, }; }), ); } catch { window.alert('参考视频上传失败,请稍后重试'); return; } const persistedReferences = await persistReferenceUploads( uploadedReferences.map( ({ file, src, index, durationSeconds, objectKey, assetObjectId, }) => ({ idPrefix: 'upload-video-reference-video', index, file, fallbackLabel: `参考视频${index + 1}`, imageSrc: src, mediaType: 'video', mimeType: file.type || undefined, sizeBytes: file.size, durationSeconds, objectKey, assetObjectId, }), ), ); if (persistedReferences.length === 0) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'video-reference-video', references: persistedReferences, }), ); }, [persistReferenceUploads, setGenerateDialog], ); const addVideoReferenceAudioFiles = useCallback( async (files: FileList | File[]) => { const audioFiles = Array.from(files).filter(isSeedanceReferenceAudioFile); if (!audioFiles.length) { window.alert('请选择 wav 或 mp3 音频文件'); return; } if ( audioFiles.some( (file) => file.size > SEEDANCE_REFERENCE_FILE_LIMITS.audioBytes, ) ) { window.alert('参考音频单个不能超过 15MB'); return; } const references = await Promise.all( audioFiles.map(async (file, index) => { const durationSeconds = await probeMediaDurationSeconds( file, 'audio', ); return { file, index, durationSeconds, }; }), ); if ( references.some( ({ durationSeconds }) => Number.isFinite(durationSeconds) && (durationSeconds < SEEDANCE_REFERENCE_FILE_LIMITS.mediaMinDurationSeconds || durationSeconds > SEEDANCE_REFERENCE_FILE_LIMITS.mediaMaxDurationSeconds), ) ) { window.alert('参考音频时长必须为 2-15 秒'); return; } const totalDuration = references.reduce( (sum, reference) => Number.isFinite(reference.durationSeconds) ? sum + reference.durationSeconds : sum, 0, ); if ( totalDuration > SEEDANCE_REFERENCE_FILE_LIMITS.mediaTotalDurationSeconds ) { window.alert('参考音频总时长不能超过 15 秒'); return; } let uploadedReferences: Array<{ file: File; index: number; durationSeconds: number; src: string; objectKey: string; assetObjectId: string; }>; try { uploadedReferences = await Promise.all( references.map(async ({ file, index, durationSeconds }) => { const uploaded = await uploadEditorSeedanceReferenceFile( file, 'audio', ); return { file, index, durationSeconds, src: uploaded.src, objectKey: uploaded.objectKey, assetObjectId: uploaded.assetObjectId, }; }), ); } catch { window.alert('参考音频上传失败,请稍后重试'); return; } const persistedReferences = await persistReferenceUploads( uploadedReferences.map( ({ file, src, index, durationSeconds, objectKey, assetObjectId, }) => ({ idPrefix: 'upload-video-reference-audio', index, file, fallbackLabel: `参考音频${index + 1}`, imageSrc: src, mediaType: 'audio', mimeType: file.type || undefined, sizeBytes: file.size, durationSeconds, objectKey, assetObjectId, }), ), ); if (persistedReferences.length === 0) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'video-reference-audio', references: persistedReferences, }), ); }, [persistReferenceUploads, setGenerateDialog], ); const addIconSpecReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const reservation = reserveImageReferenceUpload('icon-spec', 1); if (!reservation) { return; } try { const imageSrc = await readImageFileAsDataUrl(imageFile); if (!validateImageReferenceUploadReservation(reservation)) { return; } const references = await persistReferenceUploads( [ { idPrefix: 'upload-icon-spec', file: imageFile, fallbackLabel: '图标规范', imageSrc, authoritativeAssetKind: 'icon-spec', }, ], reservation, ); if ( references.length === 0 || !validateImageReferenceUploadReservation(reservation) ) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'icon-spec', references, }), ); } finally { reservation.release(); } }, [ persistReferenceUploads, reserveImageReferenceUpload, setGenerateDialog, validateImageReferenceUploadReservation, ], ); const addUiDesignSpecReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const reservation = reserveImageReferenceUpload('ui-design-icon-spec', 1); if (!reservation) { return; } try { const imageSrc = await readImageFileAsDataUrl(imageFile); if (!validateImageReferenceUploadReservation(reservation)) { return; } const references = await persistReferenceUploads( [ { idPrefix: 'upload-ui-design-icon-spec', file: imageFile, fallbackLabel: 'UI设计图标规范', imageSrc, }, ], reservation, ); if ( references.length === 0 || !validateImageReferenceUploadReservation(reservation) ) { return; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'ui-design-icon-spec', references, }), ); } finally { reservation.release(); } }, [ persistReferenceUploads, reserveImageReferenceUpload, setGenerateDialog, validateImageReferenceUploadReservation, ], ); const uploadAssetFile = useCallback( async (file: File, options: UploadAssetFileOptions) => { const mediaType = resolveEditorAssetUploadMediaType(file); if (!mediaType) { window.alert('请选择图片、MP3 或 MP4 文件'); return; } let uploadFolderId = resolveUploadFolderId({ assetFolders, requestedFolderId: options.folderId, activeUploadFolderId, preferUploadFolder: !options.folderId, }); if (!options.folderId && !resolveUploadAssetFolder(assetFolders)) { if (!uploadAssetFolderCreatePromiseRef.current) { uploadAssetFolderCreatePromiseRef.current = createEditorAssetFolder( EDITOR_UPLOAD_ASSET_FOLDER_LABEL, assetFolders.length + 100, ) .then((folder) => { setAssetFolders((currentFolders) => currentFolders.some( (currentFolder) => currentFolder.id === folder.folderId, ) ? currentFolders : [ ...currentFolders, { id: folder.folderId, label: folder.label, collapsed: folder.collapsed, systemDefault: folder.systemDefault, persisted: true, }, ], ); return folder.folderId; }) .catch(() => { const fallbackFolder = resolvePreferredAssetUploadFolder(assetFolders); return fallbackFolder?.id ?? 'project'; }) .finally(() => { uploadAssetFolderCreatePromiseRef.current = null; }); } uploadFolderId = await uploadAssetFolderCreatePromiseRef.current; } const uploadIndex = options.uploadIndex; let imageDimensions: { width: number; height: number } | null = null; let mediaMetadata: Awaited< ReturnType > | null = null; if (mediaType === 'image') { imageDimensions = await probeImageFileDimensions(file).catch( () => null, ); } else if (mediaType === 'video') { mediaMetadata = await probeMediaFileMetadata(file, 'video').catch( () => null, ); imageDimensions = mediaMetadata?.dimensions ?? null; } const uploadedAsset = createUploadingAssetPlaceholder({ uploadIndex, fileName: file.name, folderId: uploadFolderId, mediaType, imageDimensions, }); setAssets((currentAssets) => [...currentAssets, uploadedAsset]); setAssetFolders((currentFolders) => expandUploadFolder({ folders: currentFolders, folderId: uploadFolderId, }), ); let previewSrc = ''; let persistedImageSrc = ''; let objectKey: string | null = null; let assetObjectId: string | null = null; let durationSeconds: number | undefined; try { if (mediaType !== 'image') { durationSeconds = mediaMetadata?.durationSeconds ?? (await probeMediaDurationSeconds(file, mediaType).catch( () => undefined, )); } const uploadedMedia = await uploadEditorMediaAssetFile(file, mediaType); previewSrc = uploadedMedia.src; persistedImageSrc = uploadedMedia.legacyPublicPath; objectKey = uploadedMedia.objectKey; assetObjectId = uploadedMedia.assetObjectId; setAssets((currentAssets) => applyUploadAssetReadSuccess({ assets: currentAssets, uploadAssetId: uploadedAsset.id, imageSrc: previewSrc, }), ); } catch { setAssets((currentAssets) => applyUploadAssetReadFailure({ assets: currentAssets, uploadAssetId: uploadedAsset.id, }), ); return; } const nextLayer = createUploadCanvasLayer({ uploadIndex, fileName: file.name, imageSrc: previewSrc, objectKey, assetObjectId, mediaType, imageDimensions, durationSeconds, canvasPoint: options.canvasPoint, canvasSize, viewport, }); if (options.addToCanvas) { captureCanvasHistory({ type: 'upload-image', count: 1 }); appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); } setAssets((currentAssets) => applyUploadAssetCreatePending({ assets: currentAssets, uploadAssetId: uploadedAsset.id, }), ); createEditorAsset({ folderId: uploadFolderId, label: uploadedAsset.label, imageSrc: persistedImageSrc, ...(objectKey ? { objectKey } : {}), ...(assetObjectId ? { assetObjectId } : {}), width: uploadedAsset.width, height: uploadedAsset.height, sourceType: 'uploaded', assetKind: nextLayer.assetKind, generationInputs: nextLayer.generationInputs, }) .then((asset) => { const persistedAssetWithPreview = { ...asset, imageSrc: previewSrc, }; setAssets((currentAssets) => applyPersistedUploadAsset({ assets: currentAssets, uploadAssetId: uploadedAsset.id, persistedAsset: persistedAssetWithPreview, }), ); if (options.addToCanvas) { setLayers((currentLayers) => bindUploadLayerToPersistedAsset({ layers: currentLayers, layerId: nextLayer.id, persistedAsset: persistedAssetWithPreview, }), ); } }) .catch((error: unknown) => { const isAuthError = isEditorAuthError(error); if (isAuthError) { openEditorLoginModal(); } setAssets((currentAssets) => applyUploadAssetCreateFailure({ assets: currentAssets, uploadAssetId: uploadedAsset.id, message: isAuthError ? '请先登录' : '上传失败', }), ); }); if (mediaType === 'image' && previewSrc) { const uploadedImage = new Image(); uploadedImage.onload = () => { const originalWidth = uploadedImage.naturalWidth || uploadedAsset.width; const originalHeight = uploadedImage.naturalHeight || uploadedAsset.height; if (options.addToCanvas) { setLayers((currentLayers) => currentLayers.map((layer) => layer.id === nextLayer.id ? resizeUploadCanvasLayerToImage({ layer, originalWidth, originalHeight, canvasPoint: options.canvasPoint, canvasSize, viewport, }) : layer, ), ); } setAssets((currentAssets) => applyMeasuredUploadAssetSize({ assets: currentAssets, uploadAssetId: uploadedAsset.id, originalWidth, originalHeight, }), ); }; uploadedImage.src = previewSrc; } }, [ activeUploadFolderId, appendCanvasLayersWithResources, captureCanvasHistory, assetFolders, canvasSize, openEditorLoginModal, selectSingleLayer, setAssetFolders, setAssets, setLayers, viewport, ], ); const addUploadedFiles = useCallback( (files: FileList | File[], options: UploadFilesOptions = {}) => { const imageFiles = Array.from(files); if (!canAccessProtectedDataRef.current) { openEditorLoginModal(() => { addUploadedFiles(imageFiles, options); }); return; } imageFiles.forEach((file, index) => { const uploadIndex = allocateUploadIndex(); void uploadAssetFile(file, { ...options, addToCanvas: options.addToCanvas ?? false, uploadIndex, canvasPoint: options.canvasPoint ? { x: options.canvasPoint.x + index * 28, y: options.canvasPoint.y + index * 28, } : undefined, }); }); }, [allocateUploadIndex, openEditorLoginModal, uploadAssetFile], ); const openUploadPicker = useCallback( (target: UploadTarget, options: UploadRequestOptions = {}) => { setUploadTarget(target); uploadRequestOptionsRef.current = options; uploadInputRef.current?.click(); }, [setUploadTarget], ); const requestUpload = useCallback( (target: UploadTarget = 'asset', options: UploadRequestOptions = {}) => { if (!canAccessProtectedDataRef.current) { openEditorLoginModal(); return; } openUploadPicker(target, options); }, [openEditorLoginModal, openUploadPicker], ); const handleUploadInputChange = useCallback( (event: ChangeEvent) => { const files = event.currentTarget.files; const currentUploadTarget = uploadTargetRef.current; if (files?.length) { if (currentUploadTarget === 'spec-reference') { void addSpecReferenceFiles(files); } else if (currentUploadTarget === 'generation-reference') { void addGenerationReferenceFiles(files); } else if (currentUploadTarget === 'ui-asset-extraction-reference') { void addUiAssetExtractionReferenceFiles(files); } else if (currentUploadTarget === 'quick-edit-reference') { void addQuickEditReferenceFiles(files); } else if (currentUploadTarget === 'publication-reference') { void addPublicationReferenceFiles(files); } else if (currentUploadTarget === 'video-reference-image') { void addVideoReferenceImageFiles(files); } else if (currentUploadTarget === 'video-reference-video') { void addVideoReferenceVideoFiles(files); } else if (currentUploadTarget === 'video-reference-audio') { void addVideoReferenceAudioFiles(files); } else if (currentUploadTarget === 'character-spec') { void addCharacterSpecReferenceFiles(files); } else if (currentUploadTarget === 'character-reference') { void addCharacterReferenceFiles(files); } else if (currentUploadTarget === 'icon-spec') { void addIconSpecReferenceFiles(files); } else if (currentUploadTarget === 'ui-design-icon-spec') { void addUiDesignSpecReferenceFiles(files); } else { const requestOptions = uploadRequestOptionsRef.current; addUploadedFiles(files, { folderId: requestOptions.folderId, addToCanvas: requestOptions.addToCanvas ?? activeTool === 'upload', }); } } uploadRequestOptionsRef.current = {}; setUploadTarget('asset'); event.currentTarget.value = ''; }, [ activeTool, addCharacterReferenceFiles, addCharacterSpecReferenceFiles, addGenerationReferenceFiles, addUiAssetExtractionReferenceFiles, addQuickEditReferenceFiles, addPublicationReferenceFiles, addVideoReferenceImageFiles, addVideoReferenceVideoFiles, addVideoReferenceAudioFiles, addSpecReferenceFiles, addIconSpecReferenceFiles, addUiDesignSpecReferenceFiles, addUploadedFiles, setUploadTarget, ], ); return { uploadInputRef, uploadTarget, pendingImageReferenceUploadCount, setUploadTarget, requestUpload, handleUploadInputChange, addUploadedFiles, addSpecReferenceFiles, addCharacterSpecReferenceFiles, addCharacterReferenceFiles, addGenerationReferenceFiles, addUiAssetExtractionReferenceFiles, addQuickEditReferenceFiles, addPublicationReferenceFiles, addVideoReferenceImageFiles, addVideoReferenceVideoFiles, addVideoReferenceAudioFiles, addIconSpecReferenceFiles, addUiDesignSpecReferenceFiles, }; }