5976c2b337
图标素材生成支持上传普通参考图并提交到后端参考图数组。 UI设计图生成和UI素材提取支持上传参考图并写入生成输入记录。 同步前后端契约、OpenAPI、文档和相关单测。
646 lines
16 KiB
TypeScript
646 lines
16 KiB
TypeScript
import { resolveLayerResolutionSize } from './ImageCanvasEditorModel';
|
|
import type {
|
|
CharacterReferenceImage,
|
|
CanvasMediaType,
|
|
CanvasLayer,
|
|
CanvasViewport,
|
|
EditorAsset,
|
|
EditorAssetFolder,
|
|
GenerateDialogState,
|
|
QuickEditPanelState,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { resolvePreferredAssetUploadFolder } from './ImageCanvasAssetLibraryModel';
|
|
import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel';
|
|
|
|
type CanvasSize = { width: number; height: number };
|
|
type CanvasPoint = { x: number; y: number };
|
|
type UploadImageDimensions = { width: number; height: number } | null;
|
|
type PersistedUploadAsset = {
|
|
assetId: string;
|
|
folderId: string;
|
|
label: string;
|
|
imageSrc: string;
|
|
mediaType?: CanvasMediaType;
|
|
width: number;
|
|
height: number;
|
|
objectKey?: string | null;
|
|
assetObjectId?: string | null;
|
|
durationSeconds?: number | null;
|
|
};
|
|
type GenerationReferenceUploadTarget =
|
|
| 'character-reference'
|
|
| 'character-spec'
|
|
| 'generation-reference'
|
|
| 'quick-edit-reference'
|
|
| 'video-reference-image'
|
|
| 'video-reference-video'
|
|
| 'video-reference-audio'
|
|
| 'icon-spec'
|
|
| 'spec-reference'
|
|
| 'ui-design-icon-spec'
|
|
| 'publication-reference';
|
|
|
|
const VIDEO_REFERENCE_LIMITS = {
|
|
image: 9,
|
|
video: 3,
|
|
audio: 3,
|
|
} as const;
|
|
type VideoReferenceMediaType = keyof typeof VIDEO_REFERENCE_LIMITS;
|
|
|
|
export const UPLOAD_LAYER_FALLBACK_SIZE = {
|
|
width: 420,
|
|
height: 315,
|
|
} as const;
|
|
export const UPLOAD_VIDEO_FALLBACK_SIZE = {
|
|
width: 560,
|
|
height: 315,
|
|
} as const;
|
|
export const UPLOAD_AUDIO_FALLBACK_SIZE = {
|
|
width: 420,
|
|
height: 120,
|
|
} as const;
|
|
|
|
export function resolveUploadFallbackSize(mediaType: CanvasMediaType) {
|
|
if (mediaType === 'video') {
|
|
return UPLOAD_VIDEO_FALLBACK_SIZE;
|
|
}
|
|
if (mediaType === 'audio') {
|
|
return UPLOAD_AUDIO_FALLBACK_SIZE;
|
|
}
|
|
return UPLOAD_LAYER_FALLBACK_SIZE;
|
|
}
|
|
|
|
export function resolveUploadFolderId({
|
|
assetFolders,
|
|
requestedFolderId,
|
|
activeUploadFolderId,
|
|
preferUploadFolder = false,
|
|
}: {
|
|
assetFolders: EditorAssetFolder[];
|
|
requestedFolderId?: string;
|
|
activeUploadFolderId: string;
|
|
preferUploadFolder?: boolean;
|
|
}) {
|
|
const targetFolderId =
|
|
requestedFolderId ??
|
|
(preferUploadFolder
|
|
? resolvePreferredAssetUploadFolder(assetFolders)?.id
|
|
: activeUploadFolderId) ??
|
|
activeUploadFolderId;
|
|
return assetFolders.some((folder) => folder.id === targetFolderId)
|
|
? targetFolderId
|
|
: 'project';
|
|
}
|
|
|
|
export function createUploadedGenerationReference({
|
|
idPrefix,
|
|
index,
|
|
fileName,
|
|
fallbackLabel,
|
|
imageSrc,
|
|
mediaType = 'image',
|
|
mimeType,
|
|
sizeBytes,
|
|
durationSeconds,
|
|
objectKey,
|
|
assetObjectId,
|
|
resourceId,
|
|
sourceAssetId,
|
|
}: {
|
|
idPrefix: string;
|
|
index?: number;
|
|
fileName?: string;
|
|
fallbackLabel: string;
|
|
imageSrc: string;
|
|
mediaType?: CanvasMediaType;
|
|
mimeType?: string;
|
|
sizeBytes?: number;
|
|
durationSeconds?: number;
|
|
objectKey?: string | null;
|
|
assetObjectId?: string | null;
|
|
resourceId?: string | null;
|
|
sourceAssetId?: string | null;
|
|
}): CharacterReferenceImage {
|
|
return {
|
|
id:
|
|
typeof index === 'number'
|
|
? `${idPrefix}-${Date.now()}-${index}`
|
|
: `${idPrefix}-${Date.now()}`,
|
|
label: fileName || fallbackLabel,
|
|
src: imageSrc,
|
|
mediaType,
|
|
mimeType,
|
|
sizeBytes,
|
|
durationSeconds,
|
|
objectKey,
|
|
assetObjectId,
|
|
resourceId,
|
|
sourceAssetId,
|
|
};
|
|
}
|
|
|
|
export function setFailedGenerationIdle(dialog: GenerateDialogState) {
|
|
return {
|
|
...dialog,
|
|
status: dialog.status === 'failed' ? 'idle' : dialog.status,
|
|
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
|
|
};
|
|
}
|
|
|
|
export function applyGenerationReferenceUpload({
|
|
dialog,
|
|
target,
|
|
references,
|
|
}: {
|
|
dialog: GenerateDialogState | null;
|
|
target: GenerationReferenceUploadTarget;
|
|
references: CharacterReferenceImage[];
|
|
}): GenerateDialogState | null {
|
|
const firstReference = references[0];
|
|
if (!firstReference) {
|
|
return dialog;
|
|
}
|
|
if (target === 'spec-reference') {
|
|
return dialog?.mode === 'spec'
|
|
? {
|
|
...setFailedGenerationIdle(dialog),
|
|
specReference: firstReference,
|
|
}
|
|
: dialog;
|
|
}
|
|
if (target === 'character-spec') {
|
|
return dialog?.mode === 'character'
|
|
? {
|
|
...setFailedGenerationIdle(dialog),
|
|
characterSpecReference: firstReference,
|
|
}
|
|
: dialog;
|
|
}
|
|
if (target === 'icon-spec') {
|
|
return dialog?.mode === 'icon'
|
|
? {
|
|
...setFailedGenerationIdle(dialog),
|
|
iconSpecReference: firstReference,
|
|
}
|
|
: dialog;
|
|
}
|
|
if (target === 'ui-design-icon-spec') {
|
|
return dialog?.mode === 'ui-design'
|
|
? {
|
|
...setFailedGenerationIdle(dialog),
|
|
uiDesignSpecReference: firstReference,
|
|
}
|
|
: dialog;
|
|
}
|
|
if (target === 'publication-reference') {
|
|
return dialog?.mode === 'publication'
|
|
? {
|
|
...setFailedGenerationIdle(dialog),
|
|
publicationReferences: [
|
|
...(dialog.publicationReferences ?? []),
|
|
...references,
|
|
],
|
|
composerOpen: true,
|
|
}
|
|
: dialog;
|
|
}
|
|
if (target === 'generation-reference') {
|
|
return dialog?.mode === 'generate' ||
|
|
dialog?.mode === 'video' ||
|
|
dialog?.mode === 'spec' ||
|
|
dialog?.mode === 'icon' ||
|
|
dialog?.mode === 'ui-design'
|
|
? {
|
|
...setFailedGenerationIdle(dialog),
|
|
generationReferences: [
|
|
...(dialog.generationReferences ?? []),
|
|
...references,
|
|
],
|
|
}
|
|
: dialog;
|
|
}
|
|
if (
|
|
target === 'video-reference-image' ||
|
|
target === 'video-reference-video' ||
|
|
target === 'video-reference-audio'
|
|
) {
|
|
const mediaType: VideoReferenceMediaType =
|
|
target === 'video-reference-video'
|
|
? 'video'
|
|
: target === 'video-reference-audio'
|
|
? 'audio'
|
|
: 'image';
|
|
return dialog?.mode === 'video'
|
|
? {
|
|
...setFailedGenerationIdle(dialog),
|
|
generationReferences: appendLimitedVideoReferences(
|
|
dialog.generationReferences ?? [],
|
|
references,
|
|
mediaType,
|
|
),
|
|
composerOpen: true,
|
|
}
|
|
: dialog;
|
|
}
|
|
return dialog?.mode === 'character'
|
|
? {
|
|
...setFailedGenerationIdle(dialog),
|
|
characterReferences: [
|
|
...(dialog.characterReferences ?? []),
|
|
...references,
|
|
],
|
|
}
|
|
: dialog;
|
|
}
|
|
|
|
export function applyQuickEditReferenceUpload({
|
|
panel,
|
|
references,
|
|
}: {
|
|
panel: QuickEditPanelState | null;
|
|
references: CharacterReferenceImage[];
|
|
}): QuickEditPanelState | null {
|
|
if (!panel || panel.mode === 'redraw') {
|
|
return panel;
|
|
}
|
|
return {
|
|
...panel,
|
|
status: panel.status === 'failed' ? 'idle' : panel.status,
|
|
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
|
|
quickEditReferences: appendLimitedQuickEditReferences(
|
|
panel.quickEditReferences,
|
|
references,
|
|
),
|
|
};
|
|
}
|
|
|
|
function appendLimitedVideoReferences(
|
|
currentReferences: CharacterReferenceImage[],
|
|
nextReferences: CharacterReferenceImage[],
|
|
mediaType: VideoReferenceMediaType,
|
|
) {
|
|
const limit = VIDEO_REFERENCE_LIMITS[mediaType];
|
|
const currentCount = currentReferences.filter(
|
|
(reference) => (reference.mediaType ?? 'image') === mediaType,
|
|
).length;
|
|
const remaining = Math.max(0, limit - currentCount);
|
|
return [
|
|
...currentReferences,
|
|
...nextReferences.slice(0, remaining).map((reference) => ({
|
|
...reference,
|
|
mediaType,
|
|
})),
|
|
];
|
|
}
|
|
|
|
export function createUploadingAssetPlaceholder({
|
|
uploadIndex,
|
|
fileName,
|
|
folderId,
|
|
mediaType = 'image',
|
|
imageDimensions,
|
|
durationSeconds,
|
|
}: {
|
|
uploadIndex: number;
|
|
fileName?: string;
|
|
folderId: string;
|
|
mediaType?: CanvasMediaType;
|
|
imageDimensions?: UploadImageDimensions;
|
|
durationSeconds?: number;
|
|
}): EditorAsset {
|
|
const fallbackSize = resolveUploadFallbackSize(mediaType);
|
|
const size =
|
|
mediaType === 'image' && imageDimensions
|
|
? resolveLayerResolutionSize(
|
|
imageDimensions.width,
|
|
imageDimensions.height,
|
|
fallbackSize,
|
|
)
|
|
: fallbackSize;
|
|
return {
|
|
id: `upload-${uploadIndex}`,
|
|
label:
|
|
fileName ||
|
|
(mediaType === 'audio'
|
|
? '上传音频'
|
|
: mediaType === 'video'
|
|
? '上传视频'
|
|
: '上传图片'),
|
|
src: '',
|
|
mediaType,
|
|
width: size.width,
|
|
height: size.height,
|
|
folderId,
|
|
sourceKind: 'uploaded',
|
|
sourceType: 'uploaded',
|
|
persisted: false,
|
|
durationSeconds,
|
|
uploadStatus: 'uploading',
|
|
uploadProgress: 8,
|
|
uploadMessage: '准备上传',
|
|
};
|
|
}
|
|
|
|
export function expandUploadFolder({
|
|
folders,
|
|
folderId,
|
|
}: {
|
|
folders: EditorAssetFolder[];
|
|
folderId: string;
|
|
}) {
|
|
return folders.map((folder) =>
|
|
folder.id === folderId
|
|
? {
|
|
...folder,
|
|
collapsed: false,
|
|
}
|
|
: folder,
|
|
);
|
|
}
|
|
|
|
export function applyUploadAssetReadSuccess({
|
|
assets,
|
|
uploadAssetId,
|
|
imageSrc,
|
|
}: {
|
|
assets: EditorAsset[];
|
|
uploadAssetId: string;
|
|
imageSrc: string;
|
|
}) {
|
|
return assets.map((asset) =>
|
|
asset.id === uploadAssetId
|
|
? {
|
|
...asset,
|
|
src: imageSrc,
|
|
uploadProgress: 42,
|
|
uploadMessage: '读取素材',
|
|
}
|
|
: asset,
|
|
);
|
|
}
|
|
|
|
export function applyUploadAssetReadFailure({
|
|
assets,
|
|
uploadAssetId,
|
|
}: {
|
|
assets: EditorAsset[];
|
|
uploadAssetId: string;
|
|
}) {
|
|
return assets.map((asset) =>
|
|
asset.id === uploadAssetId
|
|
? {
|
|
...asset,
|
|
uploadStatus: 'failed' as const,
|
|
uploadProgress: 100,
|
|
uploadMessage: '读取失败',
|
|
}
|
|
: asset,
|
|
);
|
|
}
|
|
|
|
export function applyUploadAssetCreatePending({
|
|
assets,
|
|
uploadAssetId,
|
|
}: {
|
|
assets: EditorAsset[];
|
|
uploadAssetId: string;
|
|
}) {
|
|
return assets.map((asset) =>
|
|
asset.id === uploadAssetId
|
|
? {
|
|
...asset,
|
|
uploadProgress: 68,
|
|
uploadMessage: '上传中',
|
|
}
|
|
: asset,
|
|
);
|
|
}
|
|
|
|
export function applyPersistedUploadAsset({
|
|
assets,
|
|
uploadAssetId,
|
|
persistedAsset,
|
|
}: {
|
|
assets: EditorAsset[];
|
|
uploadAssetId: string;
|
|
persistedAsset: PersistedUploadAsset;
|
|
}) {
|
|
return assets.map((asset) =>
|
|
asset.id === uploadAssetId
|
|
? {
|
|
...asset,
|
|
id: persistedAsset.assetId,
|
|
folderId: persistedAsset.folderId,
|
|
label: persistedAsset.label,
|
|
src: persistedAsset.imageSrc,
|
|
mediaType: persistedAsset.mediaType ?? asset.mediaType,
|
|
width: persistedAsset.width,
|
|
height: persistedAsset.height,
|
|
objectKey: persistedAsset.objectKey ?? undefined,
|
|
assetObjectId: persistedAsset.assetObjectId ?? undefined,
|
|
durationSeconds:
|
|
persistedAsset.durationSeconds ?? asset.durationSeconds,
|
|
persisted: true,
|
|
uploadStatus: undefined,
|
|
uploadProgress: undefined,
|
|
uploadMessage: undefined,
|
|
}
|
|
: asset,
|
|
);
|
|
}
|
|
|
|
export function applyUploadAssetCreateFailure({
|
|
assets,
|
|
uploadAssetId,
|
|
message,
|
|
}: {
|
|
assets: EditorAsset[];
|
|
uploadAssetId: string;
|
|
message: string;
|
|
}) {
|
|
return assets.map((asset) =>
|
|
asset.id === uploadAssetId
|
|
? {
|
|
...asset,
|
|
uploadStatus: 'failed' as const,
|
|
uploadProgress: 100,
|
|
uploadMessage: message,
|
|
}
|
|
: asset,
|
|
);
|
|
}
|
|
|
|
export function applyMeasuredUploadAssetSize({
|
|
assets,
|
|
uploadAssetId,
|
|
originalWidth,
|
|
originalHeight,
|
|
}: {
|
|
assets: EditorAsset[];
|
|
uploadAssetId: string;
|
|
originalWidth: number;
|
|
originalHeight: number;
|
|
}) {
|
|
return assets.map((asset) =>
|
|
asset.id === uploadAssetId
|
|
? {
|
|
...asset,
|
|
width: originalWidth,
|
|
height: originalHeight,
|
|
}
|
|
: asset,
|
|
);
|
|
}
|
|
|
|
export function bindUploadLayerToPersistedAsset({
|
|
layers,
|
|
layerId,
|
|
persistedAsset,
|
|
}: {
|
|
layers: CanvasLayer[];
|
|
layerId: string;
|
|
persistedAsset: PersistedUploadAsset;
|
|
}) {
|
|
return layers.map((layer) =>
|
|
layer.id === layerId
|
|
? {
|
|
...layer,
|
|
src: persistedAsset.imageSrc || layer.src,
|
|
sourceAssetId: persistedAsset.assetId,
|
|
mediaType: persistedAsset.mediaType ?? layer.mediaType,
|
|
objectKey: persistedAsset.objectKey ?? layer.objectKey,
|
|
assetObjectId: persistedAsset.assetObjectId ?? layer.assetObjectId,
|
|
durationSeconds:
|
|
persistedAsset.durationSeconds ?? layer.durationSeconds,
|
|
}
|
|
: layer,
|
|
);
|
|
}
|
|
|
|
export function normalizeUploadScreenPoint({
|
|
canvasPoint,
|
|
canvasSize,
|
|
}: {
|
|
canvasPoint?: CanvasPoint;
|
|
canvasSize: CanvasSize;
|
|
}): CanvasPoint {
|
|
const fallbackScreenPoint = {
|
|
x: canvasSize.width > 0 ? canvasSize.width / 2 : 640,
|
|
y: canvasSize.height > 0 ? canvasSize.height / 2 : 360,
|
|
};
|
|
const screenPoint = canvasPoint ?? fallbackScreenPoint;
|
|
return {
|
|
x: Number.isFinite(screenPoint.x) ? screenPoint.x : fallbackScreenPoint.x,
|
|
y: Number.isFinite(screenPoint.y) ? screenPoint.y : fallbackScreenPoint.y,
|
|
};
|
|
}
|
|
|
|
export function createUploadCanvasLayer({
|
|
uploadIndex,
|
|
fileName,
|
|
imageSrc,
|
|
objectKey,
|
|
assetObjectId,
|
|
mediaType = 'image',
|
|
imageDimensions,
|
|
durationSeconds,
|
|
canvasPoint,
|
|
canvasSize,
|
|
viewport,
|
|
}: {
|
|
uploadIndex: number;
|
|
fileName?: string;
|
|
imageSrc: string;
|
|
objectKey?: string | null;
|
|
assetObjectId?: string | null;
|
|
mediaType?: CanvasMediaType;
|
|
imageDimensions?: UploadImageDimensions;
|
|
durationSeconds?: number;
|
|
canvasPoint?: CanvasPoint;
|
|
canvasSize: CanvasSize;
|
|
viewport: CanvasViewport;
|
|
}): CanvasLayer {
|
|
const fallbackSize = resolveUploadFallbackSize(mediaType);
|
|
const layerSize =
|
|
mediaType === 'image' && imageDimensions
|
|
? resolveLayerResolutionSize(
|
|
imageDimensions.width,
|
|
imageDimensions.height,
|
|
fallbackSize,
|
|
)
|
|
: fallbackSize;
|
|
const screenPoint = normalizeUploadScreenPoint({ canvasPoint, canvasSize });
|
|
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
|
|
const worldCenterX = (screenPoint.x - viewport.x) / safeScale;
|
|
const worldCenterY = (screenPoint.y - viewport.y) / safeScale;
|
|
return {
|
|
id: `layer-upload-${uploadIndex}`,
|
|
resourceId: `local-resource-upload-${uploadIndex}`,
|
|
title:
|
|
fileName ||
|
|
(mediaType === 'audio'
|
|
? '上传音频'
|
|
: mediaType === 'video'
|
|
? '上传视频'
|
|
: '上传图片'),
|
|
src: imageSrc,
|
|
mediaType,
|
|
assetKind:
|
|
mediaType === 'video'
|
|
? 'video'
|
|
: mediaType === 'audio'
|
|
? 'sound-effect'
|
|
: undefined,
|
|
x: worldCenterX - layerSize.width / 2,
|
|
y: worldCenterY - layerSize.height / 2,
|
|
width: layerSize.width,
|
|
height: layerSize.height,
|
|
originalWidth: layerSize.width,
|
|
originalHeight: layerSize.height,
|
|
zIndex: uploadIndex + 10,
|
|
sourceType: 'uploaded',
|
|
sourceAssetId: `upload-${uploadIndex}`,
|
|
objectKey: objectKey ?? undefined,
|
|
assetObjectId: assetObjectId ?? undefined,
|
|
durationSeconds,
|
|
};
|
|
}
|
|
|
|
export function resizeUploadCanvasLayerToImage({
|
|
layer,
|
|
originalWidth,
|
|
originalHeight,
|
|
canvasPoint,
|
|
canvasSize,
|
|
viewport,
|
|
}: {
|
|
layer: CanvasLayer;
|
|
originalWidth: number;
|
|
originalHeight: number;
|
|
canvasPoint?: CanvasPoint;
|
|
canvasSize: CanvasSize;
|
|
viewport: CanvasViewport;
|
|
}): CanvasLayer {
|
|
const safeOriginalWidth = originalWidth || UPLOAD_LAYER_FALLBACK_SIZE.width;
|
|
const safeOriginalHeight =
|
|
originalHeight || UPLOAD_LAYER_FALLBACK_SIZE.height;
|
|
const { width, height } = resolveLayerResolutionSize(
|
|
safeOriginalWidth,
|
|
safeOriginalHeight,
|
|
UPLOAD_LAYER_FALLBACK_SIZE,
|
|
);
|
|
const screenPoint = normalizeUploadScreenPoint({ canvasPoint, canvasSize });
|
|
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
|
|
const worldCenterX = (screenPoint.x - viewport.x) / safeScale;
|
|
const worldCenterY = (screenPoint.y - viewport.y) / safeScale;
|
|
return {
|
|
...layer,
|
|
width,
|
|
height,
|
|
originalWidth: safeOriginalWidth,
|
|
originalHeight: safeOriginalHeight,
|
|
x: worldCenterX - width / 2,
|
|
y: worldCenterY - height / 2,
|
|
};
|
|
}
|