f40a45e01b
合并 codex/240e-editor-asset-fixes 到 codex/editor-asset-library 保留视频生成资源与素材返回 保留视频缩略图与生成输入元数据
1548 lines
45 KiB
TypeScript
1548 lines
45 KiB
TypeScript
import type {
|
|
EditorAssetLibrarySnapshot,
|
|
EditorCharacterAnimationGenerationResult,
|
|
EditorProjectLayerSnapshot,
|
|
} from '../../services/image-editor/editorProjectClient';
|
|
import type {
|
|
CanvasAssetKind,
|
|
CanvasContextMenuState,
|
|
CanvasGenerationDialogState,
|
|
CanvasGenerationInputs,
|
|
CanvasLayer,
|
|
CanvasSnapItem,
|
|
CanvasMediaType,
|
|
CanvasViewport,
|
|
CharacterReferenceImage,
|
|
EditorAsset,
|
|
EditorAssetFolder,
|
|
SnapCandidate,
|
|
} from './ImageCanvasEditorTypes';
|
|
|
|
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
|
{
|
|
id: 'project',
|
|
label: '项目素材',
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
persisted: false,
|
|
},
|
|
];
|
|
|
|
export const CANVAS_WORLD_SIZE = 12000;
|
|
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
|
|
export const MIN_SCALE = 0.025;
|
|
export const MAX_SCALE = 3.2;
|
|
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
|
|
export const TOOLBAR_HALF_WIDTH = 132;
|
|
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
|
|
export const SNAP_THRESHOLD_SCREEN_PX = 18;
|
|
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
|
|
const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3;
|
|
export const FIT_VIEW_PADDING = 10;
|
|
export const MINIMAP_SIZE = { width: 132, height: 84 };
|
|
export const MINIMAP_PADDING = 8;
|
|
export const MINIMAP_DRAG_SENSITIVITY = 0.3;
|
|
export const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset';
|
|
export const MAX_HISTORY_STEPS = 60;
|
|
export const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
|
|
export const CONTEXT_MENU_SIZE = {
|
|
blank: { width: 188, height: 176 },
|
|
layer: { width: 188, height: 492 },
|
|
} as const;
|
|
export const CANVAS_BACKGROUND_OPTIONS = [
|
|
{ label: '白色', value: '#ffffff' },
|
|
{ label: '浅灰', value: '#f8fafc' },
|
|
{ label: '暖灰', value: '#f3f0ea' },
|
|
{ label: '冷蓝', value: '#eef6ff' },
|
|
];
|
|
export const DEFAULT_CANVAS_BACKGROUND_COLOR = '#f8fafc';
|
|
|
|
export function normalizeCanvasBackgroundHex(value: string) {
|
|
const trimmedValue = value.trim().toLowerCase();
|
|
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/u.exec(trimmedValue);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const hexValue = match[1] ?? '';
|
|
if (hexValue.length === 3) {
|
|
return `#${hexValue
|
|
.split('')
|
|
.map((part) => `${part}${part}`)
|
|
.join('')}`;
|
|
}
|
|
return `#${hexValue}`;
|
|
}
|
|
|
|
export function clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
export function formatPercent(value: number) {
|
|
return `${Math.round(value * 100)}%`;
|
|
}
|
|
|
|
export function formatCanvasDisplayScalePercent(scale: number) {
|
|
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
|
return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE);
|
|
}
|
|
|
|
export function canvasDisplayScaleToViewportScale(displayScale: number) {
|
|
const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1;
|
|
return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE;
|
|
}
|
|
|
|
export function viewportScaleToCanvasDisplayScale(scale: number) {
|
|
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
|
return safeScale / CANVAS_DISPLAY_SCALE_BASE;
|
|
}
|
|
|
|
export function viewportToCanvasDisplayViewport(
|
|
viewport: CanvasViewport,
|
|
): CanvasViewport {
|
|
return {
|
|
...viewport,
|
|
scale: viewportScaleToCanvasDisplayScale(viewport.scale),
|
|
};
|
|
}
|
|
|
|
export function canvasDisplayViewportToViewport(
|
|
viewport: CanvasViewport,
|
|
): CanvasViewport {
|
|
return {
|
|
...viewport,
|
|
scale: canvasDisplayScaleToViewportScale(viewport.scale),
|
|
};
|
|
}
|
|
|
|
export function formatImageSizeValue(width: number, height: number) {
|
|
const safeWidth = Math.max(1, Math.round(width || 1024));
|
|
const safeHeight = Math.max(1, Math.round(height || 1024));
|
|
return `${safeWidth}x${safeHeight}`;
|
|
}
|
|
|
|
export function resolveLayerResolutionSize(
|
|
originalWidth: number,
|
|
originalHeight: number,
|
|
fallback: { width: number; height: number },
|
|
) {
|
|
// 中文注释:画布不再维护独立展示 Size,图片显示尺寸统一跟随图片原始 Resolution。
|
|
return {
|
|
width: Math.max(1, Math.round(originalWidth || fallback.width || 1)),
|
|
height: Math.max(1, Math.round(originalHeight || fallback.height || 1)),
|
|
};
|
|
}
|
|
|
|
export function createLayerFromAsset(
|
|
asset: EditorAsset,
|
|
index: number,
|
|
viewport: CanvasViewport,
|
|
screenCenter: { x: number; y: number },
|
|
): CanvasLayer {
|
|
const { width, height } = resolveLayerResolutionSize(
|
|
asset.width,
|
|
asset.height,
|
|
{ width: 360, height: 360 },
|
|
);
|
|
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
|
|
const safeScreenCenter = {
|
|
x: Number.isFinite(screenCenter.x) ? screenCenter.x : 0,
|
|
y: Number.isFinite(screenCenter.y) ? screenCenter.y : 0,
|
|
};
|
|
const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale;
|
|
const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale;
|
|
const offset = index * 34;
|
|
const assetKind: CanvasAssetKind | undefined =
|
|
asset.mediaType === 'video'
|
|
? 'video'
|
|
: asset.mediaType === 'audio'
|
|
? inferAudioAssetKindFromLabel(asset.label)
|
|
: undefined;
|
|
|
|
return {
|
|
id: `layer-${asset.id}-${index}`,
|
|
resourceId: `local-resource-${asset.id}-${index}`,
|
|
title: asset.label,
|
|
src: asset.src,
|
|
mediaType: asset.mediaType,
|
|
x: worldCenterX - width / 2 + offset,
|
|
y: worldCenterY - height / 2 + offset,
|
|
width,
|
|
height,
|
|
originalWidth: asset.width,
|
|
originalHeight: asset.height,
|
|
zIndex: index + 10,
|
|
sourceType: asset.sourceType,
|
|
thumbnailSrc: asset.thumbnailSrc,
|
|
prompt: asset.prompt,
|
|
actualPrompt: asset.actualPrompt,
|
|
model: asset.model,
|
|
provider: asset.provider,
|
|
taskId: asset.taskId,
|
|
objectKey: asset.objectKey,
|
|
assetObjectId: asset.assetObjectId,
|
|
sourceResourceId: asset.sourceResourceId,
|
|
durationSeconds: asset.durationSeconds,
|
|
sourceAssetId: asset.id,
|
|
assetKind: asset.assetKind ?? assetKind,
|
|
generationInputs: asset.generationInputs,
|
|
} satisfies CanvasLayer;
|
|
}
|
|
|
|
export function isInlineEditorMediaSource(value: string | null | undefined) {
|
|
const normalizedValue = value?.trim().toLowerCase() ?? '';
|
|
return (
|
|
normalizedValue.startsWith('data:image/') ||
|
|
normalizedValue.startsWith('data:video/') ||
|
|
normalizedValue.startsWith('data:audio/') ||
|
|
normalizedValue.startsWith('blob:')
|
|
);
|
|
}
|
|
|
|
function isSignedEditorMediaSource(value: string | null | undefined) {
|
|
const normalizedValue = value?.trim().toLowerCase() ?? '';
|
|
return (
|
|
/^(?:https?:)?\/\//u.test(normalizedValue) &&
|
|
(normalizedValue.includes('x-oss-signature') ||
|
|
normalizedValue.includes('x-oss-credential') ||
|
|
normalizedValue.includes('expires=') ||
|
|
normalizedValue.includes('signature='))
|
|
);
|
|
}
|
|
|
|
function normalizeObjectKeyPath(objectKey: string | null | undefined) {
|
|
const normalizedObjectKey = objectKey?.trim().replace(/^\/+/u, '') ?? '';
|
|
return normalizedObjectKey ? `/${normalizedObjectKey}` : undefined;
|
|
}
|
|
|
|
function serializeOptionalMediaSource(
|
|
source: string | null | undefined,
|
|
objectKey?: string | null,
|
|
) {
|
|
const normalizedSource = source?.trim() ?? '';
|
|
if (!normalizedSource) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
isInlineEditorMediaSource(normalizedSource) ||
|
|
isSignedEditorMediaSource(normalizedSource)
|
|
) {
|
|
return normalizeObjectKeyPath(objectKey);
|
|
}
|
|
return normalizedSource;
|
|
}
|
|
|
|
function serializeImageSequenceFrames(
|
|
frames: CanvasLayer['imageSequenceFrames'],
|
|
) {
|
|
if (!frames?.length) {
|
|
return undefined;
|
|
}
|
|
const serializedFrames = frames.flatMap((frame) => {
|
|
const imageSrc = serializeOptionalMediaSource(frame.imageSrc, frame.objectKey);
|
|
return imageSrc
|
|
? [
|
|
{
|
|
...frame,
|
|
imageSrc,
|
|
},
|
|
]
|
|
: [];
|
|
});
|
|
return serializedFrames.length ? serializedFrames : undefined;
|
|
}
|
|
|
|
export function serializeLayer(
|
|
layer: CanvasLayer,
|
|
): EditorProjectLayerSnapshot {
|
|
return {
|
|
layerId: layer.id,
|
|
resourceId: layer.resourceId,
|
|
title: layer.title,
|
|
x: layer.x,
|
|
y: layer.y,
|
|
width: layer.width,
|
|
height: layer.height,
|
|
originalWidth: layer.originalWidth,
|
|
originalHeight: layer.originalHeight,
|
|
zIndex: layer.zIndex,
|
|
sourceType: layer.sourceType,
|
|
mediaType: layer.mediaType,
|
|
thumbnailSrc: serializeOptionalMediaSource(
|
|
layer.thumbnailSrc,
|
|
layer.objectKey,
|
|
),
|
|
imageSequenceFrames: serializeImageSequenceFrames(layer.imageSequenceFrames),
|
|
previewVideoPath: serializeOptionalMediaSource(
|
|
layer.previewVideoPath,
|
|
layer.objectKey,
|
|
),
|
|
prompt: layer.prompt,
|
|
actualPrompt: layer.actualPrompt,
|
|
model: layer.model,
|
|
provider: layer.provider,
|
|
taskId: layer.taskId,
|
|
objectKey: layer.objectKey,
|
|
assetObjectId: layer.assetObjectId,
|
|
durationSeconds: layer.durationSeconds,
|
|
sourceResourceId: layer.sourceResourceId,
|
|
sourceAssetId: layer.sourceAssetId,
|
|
groupId: layer.groupId,
|
|
hidden: layer.hidden,
|
|
locked: layer.locked,
|
|
flipX: layer.flipX,
|
|
flipY: layer.flipY,
|
|
};
|
|
}
|
|
|
|
type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & {
|
|
itemType: 'generation-dialog';
|
|
dialog: CanvasGenerationDialogState;
|
|
};
|
|
|
|
export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
|
|
|
|
function isPersistedReferenceResourceId(resourceId: string | null | undefined) {
|
|
const normalizedResourceId = resourceId?.trim();
|
|
return Boolean(
|
|
normalizedResourceId &&
|
|
!normalizedResourceId.startsWith('local-') &&
|
|
!normalizedResourceId.startsWith('generation-dialog:'),
|
|
);
|
|
}
|
|
|
|
function isPersistedReferenceAssetId(assetId: string | null | undefined) {
|
|
const normalizedAssetId = assetId?.trim();
|
|
return Boolean(normalizedAssetId && !normalizedAssetId.startsWith('upload-'));
|
|
}
|
|
|
|
function resolveReferencePointer(reference: CharacterReferenceImage) {
|
|
const resourceId = reference.resourceId?.trim();
|
|
if (isPersistedReferenceResourceId(resourceId)) {
|
|
return {
|
|
src: `ref:project-resource:${resourceId}`,
|
|
resourceId,
|
|
sourceAssetId: reference.sourceAssetId,
|
|
};
|
|
}
|
|
const sourceAssetId = reference.sourceAssetId?.trim();
|
|
if (isPersistedReferenceAssetId(sourceAssetId)) {
|
|
return {
|
|
src: `ref:asset:${sourceAssetId}`,
|
|
resourceId: reference.resourceId,
|
|
sourceAssetId,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function serializeGenerationReference(
|
|
reference: CharacterReferenceImage | null | undefined,
|
|
): CharacterReferenceImage | null {
|
|
if (!reference) {
|
|
return null;
|
|
}
|
|
const pointer = resolveReferencePointer(reference);
|
|
if (!pointer) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: reference.id,
|
|
label: reference.label,
|
|
src: pointer.src,
|
|
mediaType: reference.mediaType,
|
|
mimeType: reference.mimeType,
|
|
sizeBytes: reference.sizeBytes,
|
|
durationSeconds: reference.durationSeconds,
|
|
resourceId: pointer.resourceId,
|
|
sourceAssetId: pointer.sourceAssetId,
|
|
};
|
|
}
|
|
|
|
function serializeGenerationReferences(
|
|
references: CharacterReferenceImage[] | undefined,
|
|
) {
|
|
if (!references) {
|
|
return undefined;
|
|
}
|
|
return references.flatMap((reference) => {
|
|
const serialized = serializeGenerationReference(reference);
|
|
return serialized ? [serialized] : [];
|
|
});
|
|
}
|
|
|
|
function serializeDialogReferences(
|
|
dialog: CanvasGenerationDialogState,
|
|
): CanvasGenerationDialogState {
|
|
return {
|
|
...dialog,
|
|
specReference: serializeGenerationReference(dialog.specReference),
|
|
generationReferences: serializeGenerationReferences(
|
|
dialog.generationReferences,
|
|
),
|
|
characterSpecReference: serializeGenerationReference(
|
|
dialog.characterSpecReference,
|
|
),
|
|
characterReferences: serializeGenerationReferences(
|
|
dialog.characterReferences,
|
|
),
|
|
iconSpecReference: serializeGenerationReference(dialog.iconSpecReference),
|
|
publicationReferences: serializeGenerationReferences(
|
|
dialog.publicationReferences,
|
|
),
|
|
uiDesignSpecReference: serializeGenerationReference(
|
|
dialog.uiDesignSpecReference,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function serializeCanvasGenerationDialog(
|
|
dialog: CanvasGenerationDialogState,
|
|
): CanvasGenerationDialogSnapshot {
|
|
return {
|
|
itemType: 'generation-dialog',
|
|
layerId: `generation-dialog:${dialog.id}`,
|
|
resourceId: `generation-dialog:${dialog.id}`,
|
|
dialog: serializeDialogReferences(dialog),
|
|
};
|
|
}
|
|
|
|
export function serializeCanvasLayout({
|
|
layers,
|
|
canvasGenerationDialogs,
|
|
}: {
|
|
layers: CanvasLayer[];
|
|
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
|
}): CanvasLayoutItems {
|
|
return [
|
|
...layers.map(serializeLayer),
|
|
...canvasGenerationDialogs.map(serializeCanvasGenerationDialog),
|
|
];
|
|
}
|
|
|
|
export function isCanvasGenerationDialogLayoutItem(
|
|
item: EditorProjectLayerSnapshot,
|
|
): item is CanvasGenerationDialogSnapshot {
|
|
return (
|
|
item.itemType === 'generation-dialog' &&
|
|
Boolean(
|
|
hydrateCanvasGenerationDialog(
|
|
(item as { dialog?: unknown }).dialog,
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
export function splitCanvasLayoutItems(
|
|
items: EditorProjectLayerSnapshot[],
|
|
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
|
): {
|
|
layerItems: EditorProjectLayerSnapshot[];
|
|
generationDialogs: CanvasGenerationDialogState[];
|
|
} {
|
|
const layerItems: EditorProjectLayerSnapshot[] = [];
|
|
const generationDialogs: CanvasGenerationDialogState[] = [];
|
|
|
|
items.forEach((item) => {
|
|
if (isCanvasGenerationDialogLayoutItem(item)) {
|
|
const dialog = hydrateCanvasGenerationDialog(item.dialog, resourcesById);
|
|
if (dialog) {
|
|
generationDialogs.push(dialog);
|
|
}
|
|
return;
|
|
}
|
|
layerItems.push(item);
|
|
});
|
|
|
|
return { layerItems, generationDialogs };
|
|
}
|
|
|
|
export function hydrateCanvasGenerationDialog(
|
|
value: unknown,
|
|
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
|
): CanvasGenerationDialogState | null {
|
|
if (!value || typeof value !== 'object') {
|
|
return null;
|
|
}
|
|
const snapshot = value as Partial<CanvasGenerationDialogState>;
|
|
const id = stringOrNull(snapshot.id);
|
|
const prompt = typeof snapshot.prompt === 'string' ? snapshot.prompt : '';
|
|
if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id,
|
|
mode: snapshot.mode,
|
|
prompt,
|
|
status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle',
|
|
composerOpen:
|
|
typeof snapshot.composerOpen === 'boolean'
|
|
? snapshot.composerOpen
|
|
: true,
|
|
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
|
|
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
|
|
specType: isSpecGenerationType(snapshot.specType)
|
|
? snapshot.specType
|
|
: undefined,
|
|
specValues: hydrateSpecFormValues(snapshot.specValues),
|
|
specReference: hydrateCharacterReference(
|
|
snapshot.specReference,
|
|
resourcesById,
|
|
),
|
|
generationReferences: hydrateCharacterReferences(
|
|
snapshot.generationReferences,
|
|
resourcesById,
|
|
),
|
|
characterSpecReference: hydrateCharacterReference(
|
|
snapshot.characterSpecReference,
|
|
resourcesById,
|
|
),
|
|
characterReferences: hydrateCharacterReferences(
|
|
snapshot.characterReferences,
|
|
resourcesById,
|
|
),
|
|
iconSpecReference: hydrateCharacterReference(
|
|
snapshot.iconSpecReference,
|
|
resourcesById,
|
|
),
|
|
iconDescriptions: Array.isArray(snapshot.iconDescriptions)
|
|
? snapshot.iconDescriptions.filter(
|
|
(description): description is string =>
|
|
typeof description === 'string',
|
|
)
|
|
: undefined,
|
|
publicationWorkflowId: hydratePublicationWorkflowId(
|
|
snapshot.publicationWorkflowId,
|
|
),
|
|
publicationGameInfo: hydratePublicationGameInfo(
|
|
snapshot.publicationGameInfo,
|
|
),
|
|
publicationReferences: hydrateCharacterReferences(
|
|
snapshot.publicationReferences,
|
|
resourcesById,
|
|
),
|
|
uiDesignSpecReference: hydrateCharacterReference(
|
|
snapshot.uiDesignSpecReference,
|
|
resourcesById,
|
|
),
|
|
imageModel: stringOrUndefined(snapshot.imageModel),
|
|
videoModel:
|
|
typeof snapshot.videoModel === 'string'
|
|
? snapshot.videoModel
|
|
: undefined,
|
|
videoAspectRatio: videoAspectRatioOrUndefined(snapshot.videoAspectRatio),
|
|
videoResolution:
|
|
snapshot.videoResolution === '480p' ||
|
|
snapshot.videoResolution === '720p' ||
|
|
snapshot.videoResolution === '1080p'
|
|
? snapshot.videoResolution
|
|
: undefined,
|
|
videoDurationSeconds:
|
|
typeof snapshot.videoDurationSeconds === 'number'
|
|
? Math.min(15, Math.max(4, Math.round(snapshot.videoDurationSeconds)))
|
|
: undefined,
|
|
videoMode: snapshot.videoMode === 'std' ? 'std' : undefined,
|
|
videoSound:
|
|
snapshot.videoSound === 'on' || snapshot.videoSound === 'off'
|
|
? snapshot.videoSound
|
|
: undefined,
|
|
videoWebSearchEnabled:
|
|
typeof snapshot.videoWebSearchEnabled === 'boolean'
|
|
? snapshot.videoWebSearchEnabled
|
|
: undefined,
|
|
characterAnimationResolution:
|
|
snapshot.characterAnimationResolution === '480p' ||
|
|
snapshot.characterAnimationResolution === '720p'
|
|
? snapshot.characterAnimationResolution
|
|
: undefined,
|
|
characterAnimationRatio:
|
|
snapshot.characterAnimationRatio === 'same' ||
|
|
snapshot.characterAnimationRatio === '1:1' ||
|
|
snapshot.characterAnimationRatio === '4:3' ||
|
|
snapshot.characterAnimationRatio === '16:9' ||
|
|
snapshot.characterAnimationRatio === '9:16' ||
|
|
snapshot.characterAnimationRatio === '3:4'
|
|
? snapshot.characterAnimationRatio
|
|
: undefined,
|
|
characterAnimationFrameCount:
|
|
snapshot.characterAnimationFrameCount === 32 ||
|
|
snapshot.characterAnimationFrameCount === 40 ||
|
|
snapshot.characterAnimationFrameCount === 48
|
|
? snapshot.characterAnimationFrameCount
|
|
: undefined,
|
|
characterAnimationDurationSeconds:
|
|
snapshot.characterAnimationDurationSeconds === 4 ||
|
|
snapshot.characterAnimationDurationSeconds === 5 ||
|
|
snapshot.characterAnimationDurationSeconds === 6
|
|
? snapshot.characterAnimationDurationSeconds
|
|
: undefined,
|
|
characterAnimationResult: hydrateCharacterAnimationResult(
|
|
snapshot.characterAnimationResult,
|
|
),
|
|
soundModel: snapshot.soundModel === 'audio1.0' ? 'audio1.0' : undefined,
|
|
soundDurationSeconds: soundDurationOrDefault(
|
|
snapshot.soundDurationSeconds,
|
|
snapshot.audioDurationSeconds,
|
|
),
|
|
makeInstrumental:
|
|
typeof snapshot.makeInstrumental === 'boolean'
|
|
? snapshot.makeInstrumental
|
|
: undefined,
|
|
audioDurationSeconds: audioDurationOrNull(snapshot.audioDurationSeconds),
|
|
aspectRatio: stringOrUndefined(snapshot.aspectRatio),
|
|
imageSize: stringOrUndefined(snapshot.imageSize),
|
|
errorMessage: stringOrUndefined(snapshot.errorMessage),
|
|
placeholder: hydrateGenerationPlaceholder(snapshot.placeholder),
|
|
};
|
|
}
|
|
|
|
export function hydrateLayer(
|
|
snapshot: EditorProjectLayerSnapshot,
|
|
resourcesById: Map<string, CanvasLayerResourceMetadata>,
|
|
): CanvasLayer | null {
|
|
const resourceId =
|
|
typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
|
|
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
|
|
const resource = resourcesById.get(resourceId);
|
|
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
|
|
const imageSequenceFrames = hydrateImageSequenceFrames(
|
|
snapshot.imageSequenceFrames,
|
|
);
|
|
const firstSequenceFrame = imageSequenceFrames[0]?.imageSrc ?? '';
|
|
const src = snapshotSrc || firstSequenceFrame || resource?.imageSrc || '';
|
|
const title =
|
|
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
|
|
if (!resourceId || !layerId || !src) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: layerId,
|
|
resourceId,
|
|
title,
|
|
src,
|
|
x: numberFromSnapshot(snapshot.x, 0),
|
|
y: numberFromSnapshot(snapshot.y, 0),
|
|
...(() => {
|
|
const originalWidth = numberFromSnapshot(snapshot.originalWidth, 320);
|
|
const originalHeight = numberFromSnapshot(snapshot.originalHeight, 320);
|
|
return {
|
|
...resolveLayerResolutionSize(originalWidth, originalHeight, {
|
|
width: numberFromSnapshot(snapshot.width, 320),
|
|
height: numberFromSnapshot(snapshot.height, 320),
|
|
}),
|
|
originalWidth,
|
|
originalHeight,
|
|
};
|
|
})(),
|
|
zIndex: numberFromSnapshot(snapshot.zIndex, 1),
|
|
sourceType: isCanvasSourceType(snapshot.sourceType)
|
|
? snapshot.sourceType
|
|
: 'uploaded',
|
|
mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames),
|
|
thumbnailSrc: stringOrNull(snapshot.thumbnailSrc),
|
|
imageSequenceFrames: imageSequenceFrames.length
|
|
? imageSequenceFrames
|
|
: undefined,
|
|
previewVideoPath: stringOrNull(snapshot.previewVideoPath),
|
|
prompt: stringOrNull(snapshot.prompt),
|
|
actualPrompt: stringOrNull(snapshot.actualPrompt),
|
|
model: stringOrNull(snapshot.model),
|
|
provider: stringOrNull(snapshot.provider),
|
|
taskId: stringOrNull(snapshot.taskId),
|
|
objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
|
|
assetObjectId:
|
|
stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId),
|
|
durationSeconds:
|
|
audioDurationOrNull(snapshot.durationSeconds) ??
|
|
audioDurationOrNull(resource?.durationSeconds),
|
|
sourceResourceId:
|
|
stringOrNull(snapshot.sourceResourceId) ??
|
|
stringOrNull(resource?.sourceResourceId),
|
|
sourceAssetId: stringOrNull(snapshot.sourceAssetId),
|
|
groupId: stringOrNull(snapshot.groupId),
|
|
assetKind:
|
|
canvasAssetKindOrNull(resource?.assetKind) ??
|
|
canvasAssetKindOrNull(snapshot.assetKind),
|
|
generationInputs:
|
|
generationInputsOrNull(resource?.generationInputs) ??
|
|
generationInputsOrNull(snapshot.generationInputs),
|
|
hidden: booleanFromSnapshot(snapshot.hidden),
|
|
locked: booleanFromSnapshot(snapshot.locked),
|
|
flipX: booleanFromSnapshot(snapshot.flipX),
|
|
flipY: booleanFromSnapshot(snapshot.flipY),
|
|
};
|
|
}
|
|
|
|
export function mapAssetLibrarySnapshot(
|
|
library: EditorAssetLibrarySnapshot,
|
|
): {
|
|
folders: EditorAssetFolder[];
|
|
assets: EditorAsset[];
|
|
} {
|
|
return {
|
|
folders: library.folders.map((folder) => ({
|
|
id: folder.folderId,
|
|
label: folder.label,
|
|
collapsed: folder.collapsed,
|
|
systemDefault: folder.systemDefault,
|
|
persisted: true,
|
|
})),
|
|
assets: library.assets.map((asset) => {
|
|
const mediaType = inferEditorAssetMediaType(
|
|
asset.imageSrc,
|
|
asset.objectKey ?? undefined,
|
|
asset.assetKind,
|
|
);
|
|
return {
|
|
id: asset.assetId,
|
|
label: asset.label,
|
|
src: asset.imageSrc,
|
|
mediaType,
|
|
width: asset.width,
|
|
height: asset.height,
|
|
folderId: asset.folderId,
|
|
sourceKind: 'uploaded',
|
|
sourceType: asset.sourceType,
|
|
persisted: true,
|
|
prompt: asset.prompt ?? undefined,
|
|
actualPrompt: asset.actualPrompt ?? undefined,
|
|
model: asset.model ?? undefined,
|
|
provider: asset.provider ?? undefined,
|
|
taskId: asset.taskId ?? undefined,
|
|
objectKey: asset.objectKey ?? undefined,
|
|
assetObjectId: asset.assetObjectId ?? undefined,
|
|
thumbnailSrc: asset.thumbnailSrc ?? undefined,
|
|
sourceResourceId: asset.sourceResourceId ?? null,
|
|
publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null,
|
|
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
|
generationInputs: generationInputsOrNull(asset.generationInputs),
|
|
durationSeconds: asset.durationSeconds ?? undefined,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function inferEditorAssetMediaType(
|
|
imageSrc: string,
|
|
objectKey?: string | null,
|
|
assetKind?: string | null,
|
|
): CanvasMediaType {
|
|
if (assetKind === 'video') {
|
|
return 'video';
|
|
}
|
|
if (assetKind === 'sound-effect' || assetKind === 'background-music') {
|
|
return 'audio';
|
|
}
|
|
const source = `${objectKey ?? ''} ${imageSrc}`.toLowerCase();
|
|
const sourceWithoutQuery = source.split(/[?#]/u)[0] ?? source;
|
|
if (sourceWithoutQuery.includes('.mp4')) {
|
|
return 'video';
|
|
}
|
|
if (sourceWithoutQuery.includes('.mp3')) {
|
|
return 'audio';
|
|
}
|
|
return 'image';
|
|
}
|
|
|
|
function resolveHydratedLayerMediaType(
|
|
snapshot: EditorProjectLayerSnapshot,
|
|
imageSequenceFrames: CanvasLayer['imageSequenceFrames'],
|
|
): CanvasMediaType {
|
|
if (snapshot.mediaType === 'image-sequence') {
|
|
return 'image-sequence';
|
|
}
|
|
if (
|
|
snapshot.assetKind === 'character-animation' &&
|
|
(imageSequenceFrames?.length || stringOrNull(snapshot.thumbnailSrc))
|
|
) {
|
|
return 'image-sequence';
|
|
}
|
|
if (snapshot.mediaType === 'video' || snapshot.mediaType === 'audio') {
|
|
return snapshot.mediaType;
|
|
}
|
|
return 'image';
|
|
}
|
|
|
|
function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
|
|
return label.includes('背景音乐') || label.toLowerCase().includes('music')
|
|
? 'background-music'
|
|
: 'sound-effect';
|
|
}
|
|
|
|
export type CanvasLayerResourceMetadata = {
|
|
resourceId?: string | null;
|
|
imageSrc: string;
|
|
objectKey?: string | null;
|
|
assetObjectId?: string | null;
|
|
width?: number | null;
|
|
height?: number | null;
|
|
sourceType?: string | null;
|
|
durationSeconds?: number | null;
|
|
sourceResourceId?: string | null;
|
|
assetKind?: string | null;
|
|
prompt?: string | null;
|
|
actualPrompt?: string | null;
|
|
model?: string | null;
|
|
provider?: string | null;
|
|
taskId?: string | null;
|
|
generationInputs?: unknown;
|
|
createdAt?: string | null;
|
|
updatedAt?: string | null;
|
|
};
|
|
|
|
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
|
|
const mapped = mapAssetLibrarySnapshot(library);
|
|
let hasDefaultFolder = false;
|
|
const normalizedFolders = mapped.folders.filter((folder) => {
|
|
if (!folder.systemDefault) {
|
|
return true;
|
|
}
|
|
if (hasDefaultFolder) {
|
|
return false;
|
|
}
|
|
hasDefaultFolder = true;
|
|
return true;
|
|
});
|
|
const persistedFolderIds = new Set(
|
|
normalizedFolders.map((folder) => folder.id),
|
|
);
|
|
const fallbackFolders = hasDefaultFolder
|
|
? []
|
|
: EDITOR_ASSET_FOLDERS.filter(
|
|
(folder) => !persistedFolderIds.has(folder.id),
|
|
);
|
|
return {
|
|
folders: [...normalizedFolders, ...fallbackFolders],
|
|
assets: mapped.assets,
|
|
};
|
|
}
|
|
|
|
export function numberFromSnapshot(value: unknown, fallback: number) {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function audioDurationOrNull(value: unknown) {
|
|
if (value && typeof value === 'object' && 'durationSeconds' in value) {
|
|
return audioDurationOrNull(
|
|
(value as { durationSeconds?: unknown }).durationSeconds,
|
|
);
|
|
}
|
|
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
export function bpmOrNull(value: unknown) {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
return undefined;
|
|
}
|
|
const roundedValue = Math.round(value);
|
|
return roundedValue >= 1 && roundedValue <= 300 ? roundedValue : undefined;
|
|
}
|
|
|
|
export function soundDurationOrDefault(
|
|
value: unknown,
|
|
fallbackValue?: unknown,
|
|
) {
|
|
const resolvedValue = audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue);
|
|
if (!resolvedValue) {
|
|
return undefined;
|
|
}
|
|
return Math.min(10, Math.max(2, Math.round(resolvedValue)));
|
|
}
|
|
|
|
export function stringOrNull(value: unknown) {
|
|
return typeof value === 'string' && value.trim() ? value : null;
|
|
}
|
|
|
|
export function stringOrUndefined(value: unknown) {
|
|
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
}
|
|
|
|
function videoAspectRatioOrUndefined(value: unknown) {
|
|
return value === '16:9' ||
|
|
value === '9:16' ||
|
|
value === '1:1' ||
|
|
value === '4:3' ||
|
|
value === '3:4' ||
|
|
value === '21:9'
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function hydrateCharacterAnimationResult(
|
|
value: unknown,
|
|
): EditorCharacterAnimationGenerationResult | undefined {
|
|
if (!value || typeof value !== 'object') {
|
|
return undefined;
|
|
}
|
|
const result = value as Partial<EditorCharacterAnimationGenerationResult>;
|
|
if (
|
|
typeof result.taskId !== 'string' ||
|
|
result.model !== 'seedance2.0-fast' ||
|
|
typeof result.prompt !== 'string' ||
|
|
typeof result.previewVideoPath !== 'string' ||
|
|
!Array.isArray(result.frames) ||
|
|
typeof result.frameCount !== 'number' ||
|
|
typeof result.durationSeconds !== 'number' ||
|
|
typeof result.fps !== 'number' ||
|
|
typeof result.priceMudPoints !== 'number'
|
|
) {
|
|
return undefined;
|
|
}
|
|
return result as EditorCharacterAnimationGenerationResult;
|
|
}
|
|
|
|
function hydrateImageSequenceFrames(
|
|
value: unknown,
|
|
): NonNullable<CanvasLayer['imageSequenceFrames']> {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
const frames = value.flatMap((frame, index) => {
|
|
if (!frame || typeof frame !== 'object') {
|
|
return [];
|
|
}
|
|
const snapshot = frame as Record<string, unknown>;
|
|
const imageSrc = stringOrNull(snapshot.imageSrc);
|
|
if (!imageSrc) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
frameIndex: numberFromSnapshot(snapshot.frameIndex, index + 1),
|
|
imageSrc,
|
|
objectKey: stringOrUndefined(snapshot.objectKey),
|
|
assetObjectId: stringOrUndefined(snapshot.assetObjectId),
|
|
width: numberFromSnapshot(snapshot.width, 0),
|
|
height: numberFromSnapshot(snapshot.height, 0),
|
|
},
|
|
];
|
|
});
|
|
return frames;
|
|
}
|
|
|
|
export function booleanFromSnapshot(value: unknown) {
|
|
return value === true;
|
|
}
|
|
|
|
export function resolveContextMenuPosition(
|
|
clientX: number,
|
|
clientY: number,
|
|
kind: CanvasContextMenuState['kind'],
|
|
) {
|
|
if (typeof window === 'undefined') {
|
|
return { x: clientX, y: clientY };
|
|
}
|
|
const menuSize = CONTEXT_MENU_SIZE[kind];
|
|
return {
|
|
x: clamp(
|
|
clientX,
|
|
CONTEXT_MENU_VIEWPORT_MARGIN,
|
|
Math.max(
|
|
CONTEXT_MENU_VIEWPORT_MARGIN,
|
|
window.innerWidth - menuSize.width - CONTEXT_MENU_VIEWPORT_MARGIN,
|
|
),
|
|
),
|
|
y: clamp(
|
|
clientY,
|
|
CONTEXT_MENU_VIEWPORT_MARGIN,
|
|
Math.max(
|
|
CONTEXT_MENU_VIEWPORT_MARGIN,
|
|
window.innerHeight - menuSize.height - CONTEXT_MENU_VIEWPORT_MARGIN,
|
|
),
|
|
),
|
|
};
|
|
}
|
|
|
|
export function hasDataTransferType(
|
|
dataTransfer: DataTransfer,
|
|
type: string,
|
|
) {
|
|
return Array.from(dataTransfer.types).includes(type);
|
|
}
|
|
|
|
export function getDraggedAssetId(dataTransfer: DataTransfer) {
|
|
if (typeof dataTransfer.getData !== 'function') {
|
|
return '';
|
|
}
|
|
if (!hasDataTransferType(dataTransfer, ASSET_DRAG_MIME_TYPE)) {
|
|
return '';
|
|
}
|
|
return dataTransfer.getData(ASSET_DRAG_MIME_TYPE);
|
|
}
|
|
|
|
export function escapeCssIdentifier(value: string) {
|
|
return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
|
? CSS.escape(value)
|
|
: value.replace(/["\\]/gu, '\\$&');
|
|
}
|
|
|
|
export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
|
|
const assetSourceResourceId = asset.sourceResourceId?.trim();
|
|
const assetObjectKey = asset.objectKey?.trim().replace(/^\/+/u, '');
|
|
const layerObjectKey = layer.objectKey?.trim().replace(/^\/+/u, '');
|
|
const assetSrc = asset.src.trim().replace(/^\/+/u, '');
|
|
const layerSrc = layer.src.trim().replace(/^\/+/u, '');
|
|
return (
|
|
layer.sourceAssetId === asset.id ||
|
|
Boolean(
|
|
assetSourceResourceId &&
|
|
(layer.resourceId === assetSourceResourceId ||
|
|
layer.sourceResourceId === assetSourceResourceId),
|
|
) ||
|
|
Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) ||
|
|
Boolean(assetObjectKey && layerObjectKey === assetObjectKey) ||
|
|
Boolean(assetSrc && layerSrc === assetSrc)
|
|
);
|
|
}
|
|
|
|
export function generationInputsOrNull(
|
|
value: unknown,
|
|
): CanvasGenerationInputs | null {
|
|
if (!value || typeof value !== 'object') {
|
|
return null;
|
|
}
|
|
const snapshot = value as {
|
|
fields?: unknown;
|
|
references?: unknown;
|
|
};
|
|
const fields = Array.isArray(snapshot.fields)
|
|
? snapshot.fields.flatMap((field) => {
|
|
if (!field || typeof field !== 'object') {
|
|
return [];
|
|
}
|
|
const item = field as { title?: unknown; value?: unknown };
|
|
const title = stringOrNull(item.title);
|
|
const fieldValue = stringOrNull(item.value);
|
|
return title && fieldValue ? [{ title, value: fieldValue }] : [];
|
|
})
|
|
: [];
|
|
const references = Array.isArray(snapshot.references)
|
|
? snapshot.references.flatMap((reference) => {
|
|
if (!reference || typeof reference !== 'object') {
|
|
return [];
|
|
}
|
|
const item = reference as {
|
|
title?: unknown;
|
|
label?: unknown;
|
|
refType?: unknown;
|
|
refId?: unknown;
|
|
};
|
|
const title = stringOrNull(item.title);
|
|
const label = stringOrNull(item.label);
|
|
const refType: 'project-resource' | 'asset' | null =
|
|
item.refType === 'project-resource' || item.refType === 'asset'
|
|
? item.refType
|
|
: null;
|
|
const refId = stringOrNull(item.refId);
|
|
return title && label && refType && refId
|
|
? [{ title, label, refType, refId }]
|
|
: [];
|
|
})
|
|
: [];
|
|
|
|
return fields.length || references.length ? { fields, references } : null;
|
|
}
|
|
|
|
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
|
|
return value === 'spec' ||
|
|
value === 'character' ||
|
|
value === 'character-animation' ||
|
|
value === 'icon' ||
|
|
value === 'icon-spritesheet' ||
|
|
value === 'icon-spec' ||
|
|
value === 'publication-material' ||
|
|
value === 'ui-design' ||
|
|
value === 'video' ||
|
|
value === 'sound-effect' ||
|
|
value === 'background-music'
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
export function isCanvasSourceType(
|
|
value: unknown,
|
|
): value is CanvasLayer['sourceType'] {
|
|
return (
|
|
value === 'uploaded' || value === 'generated' || value === 'mock_generated'
|
|
);
|
|
}
|
|
|
|
export function isGeneratedLayer(layer: CanvasLayer) {
|
|
return (
|
|
layer.sourceType === 'generated' || layer.sourceType === 'mock_generated'
|
|
);
|
|
}
|
|
|
|
function isCanvasGenerationDialogMode(
|
|
value: unknown,
|
|
): value is CanvasGenerationDialogState['mode'] {
|
|
return (
|
|
value === 'generate' ||
|
|
value === 'spec' ||
|
|
value === 'character' ||
|
|
value === 'icon' ||
|
|
value === 'publication' ||
|
|
value === 'ui-design' ||
|
|
value === 'quick-edit' ||
|
|
value === 'character-animation' ||
|
|
value === 'video' ||
|
|
value === 'audio-sound-effect' ||
|
|
value === 'audio-background-music'
|
|
);
|
|
}
|
|
|
|
function isGenerationStatus(
|
|
value: unknown,
|
|
): value is CanvasGenerationDialogState['status'] {
|
|
return value === 'idle' || value === 'generating' || value === 'failed';
|
|
}
|
|
|
|
function isSpecGenerationType(
|
|
value: unknown,
|
|
): value is NonNullable<CanvasGenerationDialogState['specType']> {
|
|
return (
|
|
value === 'character' ||
|
|
value === 'ui' ||
|
|
value === 'icon' ||
|
|
value === 'custom'
|
|
);
|
|
}
|
|
|
|
function hydratePublicationWorkflowId(
|
|
value: unknown,
|
|
): CanvasGenerationDialogState['publicationWorkflowId'] {
|
|
return value === 'publication-cover-image' ||
|
|
value === 'publication-detail-gallery' ||
|
|
value === 'publication-promo-poster'
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function hydratePublicationGameInfo(
|
|
value: unknown,
|
|
): CanvasGenerationDialogState['publicationGameInfo'] {
|
|
if (!value || typeof value !== 'object') {
|
|
return undefined;
|
|
}
|
|
const snapshot = value as Record<string, unknown>;
|
|
return {
|
|
gameName: stringOrUndefined(snapshot.gameName) ?? '',
|
|
gameCategories: stringOrUndefined(snapshot.gameCategories) ?? '',
|
|
gameDescription: stringOrUndefined(snapshot.gameDescription) ?? '',
|
|
};
|
|
}
|
|
|
|
function hydrateSpecFormValues(
|
|
value: unknown,
|
|
): CanvasGenerationDialogState['specValues'] {
|
|
if (!value || typeof value !== 'object') {
|
|
return undefined;
|
|
}
|
|
const snapshot = value as Record<string, unknown>;
|
|
return {
|
|
playSetting:
|
|
typeof snapshot.playSetting === 'string' ? snapshot.playSetting : '',
|
|
artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '',
|
|
bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '',
|
|
characterView:
|
|
typeof snapshot.characterView === 'string'
|
|
? snapshot.characterView
|
|
: '',
|
|
customPrompt:
|
|
typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '',
|
|
};
|
|
}
|
|
|
|
function resolveHydratedReferenceResource(
|
|
resourceId: string | undefined,
|
|
resourcesById: Map<string, CanvasLayerResourceMetadata>,
|
|
) {
|
|
if (resourceId) {
|
|
return resourcesById.get(resourceId);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function hydrateCharacterReference(
|
|
value: unknown,
|
|
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
|
): CharacterReferenceImage | null {
|
|
if (!value || typeof value !== 'object') {
|
|
return null;
|
|
}
|
|
const snapshot = value as Record<string, unknown>;
|
|
const id = stringOrNull(snapshot.id);
|
|
const label = stringOrNull(snapshot.label);
|
|
const src = stringOrNull(snapshot.src);
|
|
const resourceId = stringOrUndefined(snapshot.resourceId);
|
|
const sourceAssetId = stringOrUndefined(snapshot.sourceAssetId);
|
|
const mediaType: CanvasMediaType =
|
|
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
|
|
? snapshot.mediaType
|
|
: 'image';
|
|
const mimeType = stringOrUndefined(snapshot.mimeType);
|
|
const sizeBytes = numberFromSnapshot(snapshot.sizeBytes, 0) || undefined;
|
|
const durationSeconds =
|
|
numberFromSnapshot(snapshot.durationSeconds, 0) || undefined;
|
|
const resource = resolveHydratedReferenceResource(resourceId, resourcesById);
|
|
const objectKey =
|
|
stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey);
|
|
const assetObjectId =
|
|
stringOrUndefined(snapshot.assetObjectId) ??
|
|
stringOrUndefined(resource?.assetObjectId);
|
|
const resolvedSrc = resource?.imageSrc || objectKey || src;
|
|
return id && label && resolvedSrc
|
|
? {
|
|
id,
|
|
label,
|
|
src: resolvedSrc,
|
|
mediaType,
|
|
mimeType,
|
|
sizeBytes,
|
|
durationSeconds,
|
|
objectKey,
|
|
assetObjectId,
|
|
resourceId,
|
|
sourceAssetId,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
function hydrateCharacterReferences(
|
|
value: unknown,
|
|
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
|
): CharacterReferenceImage[] | undefined {
|
|
return Array.isArray(value)
|
|
? value.flatMap((reference) => {
|
|
const hydrated = hydrateCharacterReference(reference, resourcesById);
|
|
return hydrated ? [hydrated] : [];
|
|
})
|
|
: undefined;
|
|
}
|
|
|
|
function hydrateGenerationPlaceholder(
|
|
value: unknown,
|
|
): CanvasGenerationDialogState['placeholder'] {
|
|
if (!value || typeof value !== 'object') {
|
|
return undefined;
|
|
}
|
|
const snapshot = value as Record<string, unknown>;
|
|
return {
|
|
x: numberFromSnapshot(snapshot.x, 0),
|
|
y: numberFromSnapshot(snapshot.y, 0),
|
|
width: numberFromSnapshot(snapshot.width, 320),
|
|
height: numberFromSnapshot(snapshot.height, 320),
|
|
originalWidth: numberFromSnapshot(snapshot.originalWidth, 320),
|
|
originalHeight: numberFromSnapshot(snapshot.originalHeight, 320),
|
|
};
|
|
}
|
|
|
|
export function getLayerBounds(targetLayers: CanvasLayer[]) {
|
|
if (targetLayers.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return targetLayers.reduce(
|
|
(current, layer) => ({
|
|
minX: Math.min(current.minX, layer.x),
|
|
minY: Math.min(current.minY, layer.y),
|
|
maxX: Math.max(current.maxX, layer.x + layer.width),
|
|
maxY: Math.max(current.maxY, layer.y + layer.height),
|
|
}),
|
|
{
|
|
minX: Number.POSITIVE_INFINITY,
|
|
minY: Number.POSITIVE_INFINITY,
|
|
maxX: Number.NEGATIVE_INFINITY,
|
|
maxY: Number.NEGATIVE_INFINITY,
|
|
},
|
|
);
|
|
}
|
|
|
|
export function resolveSnappedLayerPosition(
|
|
movingLayer: CanvasLayer,
|
|
proposedX: number,
|
|
proposedY: number,
|
|
layers: CanvasLayer[],
|
|
scale: number,
|
|
) {
|
|
return resolveSnappedItemPosition(
|
|
movingLayer,
|
|
proposedX,
|
|
proposedY,
|
|
layers,
|
|
scale,
|
|
);
|
|
}
|
|
|
|
export function resolveSnappedItemPosition(
|
|
movingItem: CanvasSnapItem,
|
|
proposedX: number,
|
|
proposedY: number,
|
|
items: CanvasSnapItem[],
|
|
scale: number,
|
|
) {
|
|
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
|
|
const visibleItems = items.filter(
|
|
(item) => item.id !== movingItem.id && !item.hidden,
|
|
);
|
|
const verticalTargets = [
|
|
0,
|
|
CANVAS_WORLD_ORIGIN,
|
|
...visibleItems.flatMap((item) => [
|
|
item.x,
|
|
item.x + item.width / 2,
|
|
item.x + item.width,
|
|
]),
|
|
];
|
|
const horizontalTargets = [
|
|
0,
|
|
CANVAS_WORLD_ORIGIN,
|
|
...visibleItems.flatMap((item) => [
|
|
item.y,
|
|
item.y + item.height / 2,
|
|
item.y + item.height,
|
|
]),
|
|
];
|
|
|
|
const xAlignmentSnap = findNearestSnap(
|
|
proposedX,
|
|
[0, movingItem.width / 2, movingItem.width],
|
|
verticalTargets,
|
|
threshold,
|
|
);
|
|
const yAlignmentSnap = findNearestSnap(
|
|
proposedY,
|
|
[0, movingItem.height / 2, movingItem.height],
|
|
horizontalTargets,
|
|
threshold,
|
|
);
|
|
const xDistributionSnap = findNearestEqualSpacingSnap({
|
|
axis: 'x',
|
|
proposedStart: proposedX,
|
|
proposedCrossStart: proposedY,
|
|
movingSize: movingItem.width,
|
|
movingCrossSize: movingItem.height,
|
|
items: visibleItems,
|
|
threshold,
|
|
});
|
|
const yDistributionSnap = findNearestEqualSpacingSnap({
|
|
axis: 'y',
|
|
proposedStart: proposedY,
|
|
proposedCrossStart: proposedX,
|
|
movingSize: movingItem.height,
|
|
movingCrossSize: movingItem.width,
|
|
items: visibleItems,
|
|
threshold,
|
|
});
|
|
const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap);
|
|
const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap);
|
|
|
|
return {
|
|
x: xSnap ? xSnap.position : proposedX,
|
|
y: ySnap ? ySnap.position : proposedY,
|
|
guide:
|
|
xSnap || ySnap
|
|
? {
|
|
vertical: xSnap?.guide,
|
|
horizontal: ySnap?.guide,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function chooseNearestSnap(
|
|
first: SnapCandidate | null,
|
|
second: SnapCandidate | null,
|
|
) {
|
|
if (!first) {
|
|
return second;
|
|
}
|
|
if (!second) {
|
|
return first;
|
|
}
|
|
return second.distance < first.distance ? second : first;
|
|
}
|
|
|
|
function findNearestEqualSpacingSnap({
|
|
axis,
|
|
proposedStart,
|
|
proposedCrossStart,
|
|
movingSize,
|
|
movingCrossSize,
|
|
items,
|
|
threshold,
|
|
}: {
|
|
axis: 'x' | 'y';
|
|
proposedStart: number;
|
|
proposedCrossStart: number;
|
|
movingSize: number;
|
|
movingCrossSize: number;
|
|
items: CanvasSnapItem[];
|
|
threshold: number;
|
|
}): SnapCandidate | null {
|
|
const orderedItems = items
|
|
.filter((item) =>
|
|
snapItemsOverlapOnCrossAxis(
|
|
proposedCrossStart,
|
|
movingCrossSize,
|
|
item,
|
|
axis,
|
|
),
|
|
)
|
|
.sort(
|
|
(firstItem, secondItem) =>
|
|
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
|
|
);
|
|
let nearest: SnapCandidate | null = null;
|
|
|
|
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
|
|
const firstItem = orderedItems[firstIndex];
|
|
if (!firstItem) {
|
|
continue;
|
|
}
|
|
for (
|
|
let secondIndex = firstIndex + 1;
|
|
secondIndex < orderedItems.length &&
|
|
secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD;
|
|
secondIndex += 1
|
|
) {
|
|
const secondItem = orderedItems[secondIndex];
|
|
if (!secondItem) {
|
|
continue;
|
|
}
|
|
|
|
const firstStart = getSnapItemStart(firstItem, axis);
|
|
const firstEnd = getSnapItemEnd(firstItem, axis);
|
|
const secondStart = getSnapItemStart(secondItem, axis);
|
|
const secondEnd = getSnapItemEnd(secondItem, axis);
|
|
const pairGap = secondStart - firstEnd;
|
|
if (pairGap < 0) {
|
|
continue;
|
|
}
|
|
|
|
nearest = chooseNearestSnap(
|
|
nearest,
|
|
createDistributionSnapCandidate({
|
|
position: firstStart - pairGap - movingSize,
|
|
proposedStart,
|
|
movingSize,
|
|
threshold,
|
|
}),
|
|
);
|
|
nearest = chooseNearestSnap(
|
|
nearest,
|
|
createDistributionSnapCandidate({
|
|
position: secondEnd + pairGap,
|
|
proposedStart,
|
|
movingSize,
|
|
threshold,
|
|
}),
|
|
);
|
|
|
|
const betweenGap = (pairGap - movingSize) / 2;
|
|
if (betweenGap >= 0) {
|
|
nearest = chooseNearestSnap(
|
|
nearest,
|
|
createDistributionSnapCandidate({
|
|
position: firstEnd + betweenGap,
|
|
proposedStart,
|
|
movingSize,
|
|
threshold,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return nearest;
|
|
}
|
|
|
|
function createDistributionSnapCandidate({
|
|
position,
|
|
proposedStart,
|
|
movingSize,
|
|
threshold,
|
|
}: {
|
|
position: number;
|
|
proposedStart: number;
|
|
movingSize: number;
|
|
threshold: number;
|
|
}): SnapCandidate | null {
|
|
const distance = Math.abs(position - proposedStart);
|
|
if (distance > threshold) {
|
|
return null;
|
|
}
|
|
return {
|
|
position,
|
|
guide: position + movingSize / 2,
|
|
distance,
|
|
};
|
|
}
|
|
|
|
function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') {
|
|
return axis === 'x' ? item.x : item.y;
|
|
}
|
|
|
|
function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') {
|
|
return axis === 'x' ? item.width : item.height;
|
|
}
|
|
|
|
function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') {
|
|
return getSnapItemStart(item, axis) + getSnapItemSize(item, axis);
|
|
}
|
|
|
|
function getSnapItemCrossStart(item: CanvasSnapItem, axis: 'x' | 'y') {
|
|
return axis === 'x' ? item.y : item.x;
|
|
}
|
|
|
|
function getSnapItemCrossSize(item: CanvasSnapItem, axis: 'x' | 'y') {
|
|
return axis === 'x' ? item.height : item.width;
|
|
}
|
|
|
|
function snapItemsOverlapOnCrossAxis(
|
|
proposedCrossStart: number,
|
|
movingCrossSize: number,
|
|
item: CanvasSnapItem,
|
|
axis: 'x' | 'y',
|
|
) {
|
|
const movingCrossEnd = proposedCrossStart + movingCrossSize;
|
|
const itemCrossStart = getSnapItemCrossStart(item, axis);
|
|
const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis);
|
|
return (
|
|
proposedCrossStart <=
|
|
itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
|
|
movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
|
|
);
|
|
}
|
|
|
|
export function findNearestSnap(
|
|
origin: number,
|
|
offsets: number[],
|
|
targets: number[],
|
|
threshold: number,
|
|
): SnapCandidate | null {
|
|
let nearest: SnapCandidate | null = null;
|
|
for (const offset of offsets) {
|
|
for (const target of targets) {
|
|
const distance = Math.abs(target - (origin + offset));
|
|
if (distance > threshold) {
|
|
continue;
|
|
}
|
|
if (!nearest || distance < nearest.distance) {
|
|
nearest = {
|
|
position: target - offset,
|
|
guide: target,
|
|
distance,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return nearest;
|
|
}
|