Files
Genarrative/src/components/image-editor/useImageCanvasUploadWorkflow.ts
T
menghao e4e3cab559
Project CI / Repository checks (push) Successful in 3m3s
Project CI / Frontend tests (push) Successful in 3m32s
Project CI / Backend tests (push) Successful in 5m45s
Project CI / Native shell tests (push) Successful in 16m17s
修复画布参考图上传删除链路 (#164)
主站规范生成支持多张参考图

修复普通生成参考图删除与提交状态不同步

优化触屏设备删除按钮可达性并补充回归测试

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/164
Co-authored-by: 孟豪 <mh18530625731@163.com>
Co-committed-by: 孟豪 <mh18530625731@163.com>
2026-08-21 11:55:32 +08:00

1641 lines
49 KiB
TypeScript

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<SetStateAction<EditorAssetFolder[]>>;
setAssets: Dispatch<SetStateAction<EditorAsset[]>>;
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
resolveImageReferenceUploadCapacity: (
target: UploadTarget,
) => ImageReferenceUploadCapacity | null;
appendUiAssetExtractionReferences: (
references: CharacterReferenceImage[],
) => void;
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
selectSingleLayer: (layerId: string | null) => void;
refreshAssetLibrary?: () => Promise<unknown> | 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<HTMLInputElement | null>(null);
const canAccessProtectedDataRef = useRef(canAccessProtectedData);
const uploadTargetRef = useRef<UploadTarget>('asset');
const uploadRequestOptionsRef = useRef<UploadRequestOptions>({});
const pendingImageReferenceUploadsRef = useRef<Record<string, number>>({});
const uploadAssetFolderCreatePromiseRef = useRef<Promise<string> | null>(
null,
);
const [uploadTarget, setUploadTargetState] = useState<UploadTarget>('asset');
const [
pendingImageReferenceUploadCount,
setPendingImageReferenceUploadCount,
] = useState(0);
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 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<CharacterReferenceImage> => {
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<typeof probeMediaFileMetadata>
> | 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<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 === '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,
};
}