Files
Genarrative/src/components/image-editor/useImageCanvasUploadWorkflow.ts
T
JenkenB ac180ecad9 完善画板生成音乐与生成面板交互
新增画板底部生成音乐入口,支持游戏音效和游戏背景音乐生成面板。

接入编辑器音频生成前后端契约、VectorEngine 请求与 BFF 路由。

补齐音频结果图层、元数据、画布恢复和客户端提交流程。

统一生成类面板 Lovart 式参数浮层、参考素材入口与相关测试。

更新编辑器音乐生成设计文档和项目长期记忆。
2026-06-18 15:42:17 +08:00

814 lines
24 KiB
TypeScript

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,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
SEEDANCE_REFERENCE_FILE_LIMITS,
isImageFile,
isSeedanceReferenceAudioFile,
isSeedanceReferenceImageFile,
isSeedanceReferenceVideoFile,
probeMediaDurationSeconds,
readFileAsDataUrl,
readImageFileAsDataUrl,
} from './ImageCanvasFileModel';
import {
applyGenerationReferenceUpload,
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<SetStateAction<EditorAssetFolder[]>>;
setAssets: Dispatch<SetStateAction<EditorAsset[]>>;
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
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,
appendCanvasLayersWithResources,
selectSingleLayer,
}: UseImageCanvasUploadWorkflowOptions) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const canAccessProtectedDataRef = useRef(canAccessProtectedData);
const uploadTargetRef = useRef<UploadTarget>('asset');
const [uploadTarget, setUploadTargetState] = useState<UploadTarget>('asset');
canAccessProtectedDataRef.current = canAccessProtectedData;
const setUploadTarget: Dispatch<SetStateAction<UploadTarget>> = 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 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<HTMLInputElement>) => {
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 === '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,
addVideoReferenceImageFiles,
addVideoReferenceVideoFiles,
addVideoReferenceAudioFiles,
addSpecReferenceFiles,
addIconSpecReferenceFiles,
addUiDesignSpecReferenceFiles,
addUploadedFiles,
setUploadTarget,
],
);
return {
uploadInputRef,
uploadTarget,
setUploadTarget,
requestUpload,
handleUploadInputChange,
addUploadedFiles,
addSpecReferenceFiles,
addCharacterSpecReferenceFiles,
addCharacterReferenceFiles,
addGenerationReferenceFiles,
addVideoReferenceImageFiles,
addVideoReferenceVideoFiles,
addVideoReferenceAudioFiles,
addIconSpecReferenceFiles,
addUiDesignSpecReferenceFiles,
};
}