Files
Genarrative/src/components/image-editor/ImageCanvasUploadModel.ts
T
kdletters ad72e4691c 修复编辑器素材与交互验收问题
生成视频结果写入编辑器资源和素材库,并返回视频素材快照

上传视频使用真实视频尺寸创建素材和画布图层

素材库视频卡片使用签名地址渲染首帧预览并显示视频标签

剪贴板图片粘贴重新接入系统粘贴和右键粘贴路径

多选图层拖拽保留已有选区并批量移动

图标规范引用支持对象存储资源和素材引用,不再要求 Data URL

音效和背景音乐生成失败展示上游详细原因
2026-06-29 11:29:48 +08:00

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 !== '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,
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 !== '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:
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,
};
}