import { type ChangeEvent, type Dispatch, type SetStateAction, useCallback, useRef, useState, } from 'react'; import { ApiClientError } from '../../services/apiClient'; import { createEditorAsset } from '../../services/image-editor/editorProjectClient'; import { uploadEditorSeedanceReferenceFile } from '../../services/image-editor/editorReferenceUploadClient'; import type { CanvasLayer, CanvasTool, CanvasViewport, EditorAsset, EditorAssetFolder, GenerateDialogState, QuickEditPanelState, UploadTarget, } from './ImageCanvasEditorTypes'; import { SEEDANCE_REFERENCE_FILE_LIMITS, isImageFile, isSeedanceReferenceAudioFile, isSeedanceReferenceImageFile, isSeedanceReferenceVideoFile, probeMediaDurationSeconds, readFileAsDataUrl, readImageFileAsDataUrl, } from './ImageCanvasFileModel'; import { applyGenerationReferenceUpload, applyQuickEditReferenceUpload, applyMeasuredUploadAssetSize, applyPersistedUploadAsset, applyUploadAssetCreateFailure, applyUploadAssetCreatePending, applyUploadAssetReadFailure, applyUploadAssetReadSuccess, bindUploadLayerToPersistedAsset, createUploadCanvasLayer, createUploadedGenerationReference, createUploadingAssetPlaceholder, expandUploadFolder, resizeUploadCanvasLayerToImage, resolveUploadFolderId, } from './ImageCanvasUploadModel'; type UploadFilesOptions = { folderId?: string; canvasPoint?: { x: number; y: number }; addToCanvas?: boolean; }; type UploadAssetFileOptions = UploadFilesOptions & { uploadIndex: number; }; type UseImageCanvasUploadWorkflowOptions = { canAccessProtectedData: boolean; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; 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>; appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; selectSingleLayer: (layerId: string | null) => void; }; function isEditorAuthError(error: unknown) { return ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) ); } export function useImageCanvasUploadWorkflow({ canAccessProtectedData, openEditorLoginModal, assetFolders, activeUploadFolderId, canvasSize, viewport, activeTool, allocateUploadIndex, setAssetFolders, setAssets, setLayers, setGenerateDialog, setQuickEditPanel, appendCanvasLayersWithResources, selectSingleLayer, }: UseImageCanvasUploadWorkflowOptions) { const uploadInputRef = useRef(null); const canAccessProtectedDataRef = useRef(canAccessProtectedData); const uploadTargetRef = useRef('asset'); const [uploadTarget, setUploadTargetState] = useState('asset'); canAccessProtectedDataRef.current = canAccessProtectedData; const setUploadTarget: Dispatch> = useCallback( (nextTarget) => { const resolvedTarget = typeof nextTarget === 'function' ? nextTarget(uploadTargetRef.current) : nextTarget; uploadTargetRef.current = resolvedTarget; setUploadTargetState(resolvedTarget); }, [], ); const addCharacterSpecReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const imageSrc = await readImageFileAsDataUrl(imageFile); setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'character-spec', references: [ createUploadedGenerationReference({ idPrefix: 'upload-character-spec', fileName: imageFile.name, fallbackLabel: '角色规范', imageSrc, }), ], }), ); }, [setGenerateDialog], ); const addSpecReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const imageSrc = await readImageFileAsDataUrl(imageFile); setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'spec-reference', references: [ createUploadedGenerationReference({ idPrefix: 'upload-spec-reference', fileName: imageFile.name, fallbackLabel: '参考图', imageSrc, }), ], }), ); }, [setGenerateDialog], ); const addCharacterReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isImageFile); if (!imageFiles.length) { window.alert('请选择图片文件'); return; } const references = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, imageSrc: await readImageFileAsDataUrl(file), })), ); setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'character-reference', references: references.map(({ file, imageSrc, index }) => createUploadedGenerationReference({ idPrefix: 'upload-character-reference', index, fileName: file.name, fallbackLabel: `参考图${index + 1}`, imageSrc, }), ), }), ); }, [setGenerateDialog], ); const addGenerationReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isImageFile); if (!imageFiles.length) { window.alert('请选择图片文件'); return; } const references = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, imageSrc: await readImageFileAsDataUrl(file), })), ); setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'generation-reference', references: references.map(({ file, imageSrc, index }) => createUploadedGenerationReference({ idPrefix: 'upload-generation-reference', index, fileName: file.name, fallbackLabel: `参考图${index + 1}`, imageSrc, }), ), }), ); }, [setGenerateDialog], ); const addQuickEditReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isImageFile); if (!imageFiles.length) { window.alert('请选择图片文件'); return; } const references = await Promise.all( imageFiles.map(async (file, index) => ({ file, index, imageSrc: await readImageFileAsDataUrl(file), })), ); setQuickEditPanel((currentPanel) => applyQuickEditReferenceUpload({ panel: currentPanel, references: references.map(({ file, imageSrc, index }) => createUploadedGenerationReference({ idPrefix: 'upload-quick-edit-reference', index, fileName: file.name, fallbackLabel: `参考图${index + 1}`, imageSrc, }), ), }), ); }, [setQuickEditPanel], ); 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), })), ); setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'video-reference-image', references: references.map(({ file, src, index }) => createUploadedGenerationReference({ idPrefix: 'upload-video-reference-image', index, fileName: file.name, fallbackLabel: `参考图${index + 1}`, imageSrc: src, mediaType: 'image', mimeType: file.type || undefined, sizeBytes: file.size, }), ), }), ); }, [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; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'video-reference-video', references: uploadedReferences.map(({ file, src, index, durationSeconds, objectKey, assetObjectId }) => createUploadedGenerationReference({ idPrefix: 'upload-video-reference-video', index, fileName: file.name, fallbackLabel: `参考视频${index + 1}`, imageSrc: src, mediaType: 'video', mimeType: file.type || undefined, sizeBytes: file.size, durationSeconds, objectKey, assetObjectId, }), ), }), ); }, [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; } setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'video-reference-audio', references: uploadedReferences.map(({ file, src, index, durationSeconds, objectKey, assetObjectId }) => createUploadedGenerationReference({ idPrefix: 'upload-video-reference-audio', index, fileName: file.name, fallbackLabel: `参考音频${index + 1}`, imageSrc: src, mediaType: 'audio', mimeType: file.type || undefined, sizeBytes: file.size, durationSeconds, objectKey, assetObjectId, }), ), }), ); }, [setGenerateDialog], ); const addIconSpecReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const imageSrc = await readImageFileAsDataUrl(imageFile); setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'icon-spec', references: [ createUploadedGenerationReference({ idPrefix: 'upload-icon-spec', fileName: imageFile.name, fallbackLabel: '图标规范', imageSrc, }), ], }), ); }, [setGenerateDialog], ); const addUiDesignSpecReferenceFiles = useCallback( async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const imageSrc = await readImageFileAsDataUrl(imageFile); setGenerateDialog((currentDialog) => applyGenerationReferenceUpload({ dialog: currentDialog, target: 'ui-design-icon-spec', references: [ createUploadedGenerationReference({ idPrefix: 'upload-ui-design-icon-spec', fileName: imageFile.name, fallbackLabel: 'UI设计图标规范', imageSrc, }), ], }), ); }, [setGenerateDialog], ); const uploadAssetFile = useCallback( async (file: File, options: UploadAssetFileOptions) => { if (!isImageFile(file)) { window.alert('请选择图片文件'); return; } const uploadFolderId = resolveUploadFolderId({ assetFolders, requestedFolderId: options.folderId, activeUploadFolderId, }); const uploadIndex = options.uploadIndex; const uploadedAsset = createUploadingAssetPlaceholder({ uploadIndex, fileName: file.name, folderId: uploadFolderId, }); setAssets((currentAssets) => [...currentAssets, uploadedAsset]); setAssetFolders((currentFolders) => expandUploadFolder({ folders: currentFolders, folderId: uploadFolderId, }), ); let imageSrc = ''; try { imageSrc = await readImageFileAsDataUrl(file); setAssets((currentAssets) => applyUploadAssetReadSuccess({ assets: currentAssets, uploadAssetId: uploadedAsset.id, imageSrc, }), ); } catch { setAssets((currentAssets) => applyUploadAssetReadFailure({ assets: currentAssets, uploadAssetId: uploadedAsset.id, }), ); return; } const nextLayer = createUploadCanvasLayer({ uploadIndex, fileName: file.name, imageSrc, canvasPoint: options.canvasPoint, canvasSize, viewport, }); if (options.addToCanvas) { appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); } setAssets((currentAssets) => applyUploadAssetCreatePending({ assets: currentAssets, uploadAssetId: uploadedAsset.id, }), ); createEditorAsset({ folderId: uploadFolderId, label: uploadedAsset.label, imageSrc, width: uploadedAsset.width, height: uploadedAsset.height, sourceType: 'uploaded', }) .then((asset) => { setAssets((currentAssets) => applyPersistedUploadAsset({ assets: currentAssets, uploadAssetId: uploadedAsset.id, persistedAsset: asset, }), ); if (options.addToCanvas) { setLayers((currentLayers) => bindUploadLayerToPersistedAsset({ layers: currentLayers, layerId: nextLayer.id, persistedAsset: asset, }), ); } }) .catch((error: unknown) => { const isAuthError = isEditorAuthError(error); if (isAuthError) { openEditorLoginModal(); } setAssets((currentAssets) => applyUploadAssetCreateFailure({ assets: currentAssets, uploadAssetId: uploadedAsset.id, message: isAuthError ? '请先登录' : '上传失败', }), ); }); if (imageSrc) { 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 = imageSrc; } }, [ activeUploadFolderId, appendCanvasLayersWithResources, assetFolders, canvasSize.height, canvasSize.width, openEditorLoginModal, selectSingleLayer, setAssetFolders, setAssets, setLayers, viewport.scale, viewport.x, viewport.y, ], ); 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) => { setUploadTarget(target); uploadInputRef.current?.click(); }, [setUploadTarget], ); const requestUpload = useCallback( (target: UploadTarget = 'asset') => { if (target === 'asset' && !canAccessProtectedDataRef.current) { openEditorLoginModal(); return; } openUploadPicker(target); }, [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 === 'quick-edit-reference') { void addQuickEditReferenceFiles(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 { addUploadedFiles(files, { addToCanvas: activeTool === 'upload' }); } } setUploadTarget('asset'); event.currentTarget.value = ''; }, [ activeTool, addCharacterReferenceFiles, addCharacterSpecReferenceFiles, addGenerationReferenceFiles, addQuickEditReferenceFiles, addVideoReferenceImageFiles, addVideoReferenceVideoFiles, addVideoReferenceAudioFiles, addSpecReferenceFiles, addIconSpecReferenceFiles, addUiDesignSpecReferenceFiles, addUploadedFiles, setUploadTarget, ], ); return { uploadInputRef, uploadTarget, setUploadTarget, requestUpload, handleUploadInputChange, addUploadedFiles, addSpecReferenceFiles, addCharacterSpecReferenceFiles, addCharacterReferenceFiles, addGenerationReferenceFiles, addQuickEditReferenceFiles, addVideoReferenceImageFiles, addVideoReferenceVideoFiles, addVideoReferenceAudioFiles, addIconSpecReferenceFiles, addUiDesignSpecReferenceFiles, }; }