Files
Genarrative/src/components/image-editor/ImageCanvasUploadModel.ts
T
k88936 4c0fdf1806
Project CI / Repository checks (push) Successful in 1m4s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Backend tests (push) Successful in 3m52s
Project CI / Native shell tests (push) Successful in 14m46s
修正画布改造工具恢复链路 (#158)
在文档中明确改造特殊情况的处理

修复fallback到快速编辑的行为:
旧版图片按 assetKind 与 mediaType 选择工具,未知图片回落默认生成工具并保留旧版告警,而不是all in fallback到快速编辑

清理冗余代码:
移除 QuickEditPanelState 的 redraw 模式、旧提交分支及无调用结果层构造器

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/158
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-11 12:36:30 +08:00

689 lines
17 KiB
TypeScript

import { resolvePreferredAssetUploadFolder } from './ImageCanvasAssetLibraryModel';
import { resolveLayerResolutionSize } from './ImageCanvasEditorModel';
import type {
CanvasGenerationInputs,
CanvasLayer,
CanvasMediaType,
CanvasViewport,
CharacterReferenceImage,
EditorAsset,
EditorAssetFolder,
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import {
appendLimitedImageReferences,
QUICK_EDIT_REFERENCE_LIMIT,
resolveDialogExtraImageReferenceLimit,
resolveExtraImageReferenceLimit,
} from './ImageCanvasGenerationModel';
import { resolveUploadedAssetKind } from './ImageCanvasUploadUtils';
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;
generationInputs?: CanvasGenerationInputs | 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,
characterReferences: dialog.characterReferences,
}
: dialog;
}
if (target === 'icon-spec') {
return dialog?.mode === 'icon'
? {
...setFailedGenerationIdle(dialog),
iconSpecReference: firstReference,
generationReferences: appendLimitedImageReferences(
[],
dialog.generationReferences ?? [],
resolveDialogExtraImageReferenceLimit(dialog),
),
}
: dialog;
}
if (target === 'ui-design-icon-spec') {
return dialog?.mode === 'ui-design'
? {
...setFailedGenerationIdle(dialog),
uiDesignSpecReference: firstReference,
generationReferences: dialog.generationReferences,
}
: dialog;
}
if (target === 'publication-reference') {
return dialog?.mode === 'publication'
? {
...setFailedGenerationIdle(dialog),
publicationReferences: appendLimitedImageReferences(
dialog.publicationReferences,
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
}
: dialog;
}
if (target === 'generation-reference') {
return dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'video' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design'
? {
...setFailedGenerationIdle(dialog),
generationReferences:
dialog.mode === 'video'
? appendLimitedVideoReferences(
dialog.generationReferences ?? [],
references,
'image',
)
: appendLimitedImageReferences(
dialog.generationReferences,
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
}
: 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: appendLimitedImageReferences(
dialog.characterReferences,
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
}
: dialog;
}
export function applyQuickEditReferenceUpload({
panel,
references,
}: {
panel: QuickEditPanelState | null;
references: CharacterReferenceImage[];
}): QuickEditPanelState | null {
if (!panel) {
return panel;
}
return {
...panel,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
quickEditReferences: appendLimitedImageReferences(
panel.quickEditReferences,
references,
resolveExtraImageReferenceLimit(
panel.model,
QUICK_EDIT_REFERENCE_LIMIT,
1,
),
),
};
}
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,
}: {
uploadIndex: number;
fileName?: string;
folderId: string;
mediaType?: CanvasMediaType;
imageDimensions?: UploadImageDimensions;
}): EditorAsset {
const fallbackSize = resolveUploadFallbackSize(mediaType);
const size =
mediaType !== 'audio' && 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,
assetKind: resolveUploadedAssetKind(mediaType),
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,
generationInputs:
persistedAsset.generationInputs ?? asset.generationInputs,
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,
}
: 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 buildUploadedMediaGenerationInputs(
mediaType: CanvasMediaType,
durationSeconds: number | undefined,
): CanvasGenerationInputs | undefined {
if (
mediaType === 'image' ||
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return undefined;
}
const roundedDurationSeconds = Math.round(durationSeconds * 1_000) / 1_000;
return {
fields: [{ title: '时长', value: `${roundedDurationSeconds}秒` }],
references: [],
};
}
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 !== 'audio' && 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: resolveUploadedAssetKind(mediaType),
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,
generationInputs: buildUploadedMediaGenerationInputs(
mediaType,
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,
};
}