Files
Genarrative/src/components/image-editor/ImageCanvasEditorModel.ts
T
kdletters aa67e72ec5 修复宣发素材生成器刷新恢复
恢复画布布局中的 publication 生成器快照

补齐宣发素材卡片类型、游戏信息和参考图 hydrate

增加模型、面板和编辑器加载链路回归测试

更新图片画布持久化文档和共享踩坑记录
2026-06-19 17:23:34 +08:00

989 lines
29 KiB
TypeScript

import type {
EditorCharacterAnimationGenerationResult,
EditorAssetLibrarySnapshot,
EditorProjectLayerSnapshot,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasLayer,
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.24;
export const MAX_SCALE = 3.2;
export const TOOLBAR_HALF_WIDTH = 132;
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
export const SNAP_THRESHOLD_SCREEN_PX = 18;
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 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,
prompt: asset.prompt,
actualPrompt: asset.actualPrompt,
model: asset.model,
provider: asset.provider,
taskId: asset.taskId,
objectKey: asset.objectKey,
assetObjectId: asset.assetObjectId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
assetKind: asset.assetKind ?? assetKind,
generationInputs: asset.generationInputs,
} satisfies CanvasLayer;
}
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,
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[];
export function serializeCanvasGenerationDialog(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogSnapshot {
return {
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialog.id}`,
resourceId: `generation-dialog:${dialog.id}`,
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[],
): {
layerItems: EditorProjectLayerSnapshot[];
generationDialogs: CanvasGenerationDialogState[];
} {
const layerItems: EditorProjectLayerSnapshot[] = [];
const generationDialogs: CanvasGenerationDialogState[] = [];
items.forEach((item) => {
if (isCanvasGenerationDialogLayoutItem(item)) {
const dialog = hydrateCanvasGenerationDialog(item.dialog);
if (dialog) {
generationDialogs.push(dialog);
}
return;
}
layerItems.push(item);
});
return { layerItems, generationDialogs };
}
export function hydrateCanvasGenerationDialog(
value: unknown,
): 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),
generationReferences: hydrateCharacterReferences(
snapshot.generationReferences,
),
characterSpecReference: hydrateCharacterReference(
snapshot.characterSpecReference,
),
characterReferences: hydrateCharacterReferences(
snapshot.characterReferences,
),
iconSpecReference: hydrateCharacterReference(snapshot.iconSpecReference),
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,
),
uiDesignSpecReference: hydrateCharacterReference(
snapshot.uiDesignSpecReference,
),
imageModel: stringOrUndefined(snapshot.imageModel),
videoModel:
typeof snapshot.videoModel === 'string'
? snapshot.videoModel
: undefined,
videoAspectRatio: stringOrUndefined(snapshot.videoAspectRatio),
videoResolution:
snapshot.videoResolution === '480p' || snapshot.videoResolution === '720p'
? snapshot.videoResolution
: undefined,
videoDurationSeconds:
snapshot.videoDurationSeconds === 4 ||
snapshot.videoDurationSeconds === 5
? snapshot.videoDurationSeconds
: undefined,
videoMode: snapshot.videoMode === 'std' ? 'std' : undefined,
videoSound: snapshot.videoSound === 'off' ? 'off' : 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 src = snapshotSrc || 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:
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
? snapshot.mediaType
: 'image',
prompt: stringOrNull(snapshot.prompt),
actualPrompt: stringOrNull(snapshot.actualPrompt),
model: stringOrNull(snapshot.model),
provider: stringOrNull(snapshot.provider),
taskId: stringOrNull(snapshot.taskId),
objectKey: stringOrNull(snapshot.objectKey),
assetObjectId: stringOrNull(snapshot.assetObjectId),
durationSeconds:
audioDurationOrNull(snapshot.durationSeconds) ??
audioDurationOrNull(resource?.durationSeconds),
sourceResourceId: stringOrNull(snapshot.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,
);
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,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
durationSeconds: asset.durationSeconds ?? undefined,
};
}),
};
}
export function inferEditorAssetMediaType(
imageSrc: string,
objectKey?: string | null,
): CanvasMediaType {
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 inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
return label.includes('背景音乐') || label.toLowerCase().includes('music')
? 'background-music'
: 'sound-effect';
}
export type CanvasLayerResourceMetadata = {
imageSrc: string;
durationSeconds?: number | null;
assetKind?: string | null;
generationInputs?: unknown;
};
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 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;
}
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) {
return (
layer.sourceAssetId === asset.id ||
Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) ||
Boolean(asset.objectKey && layer.objectKey === asset.objectKey) ||
layer.src === asset.src
);
}
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;
src?: unknown;
};
const title = stringOrNull(item.title);
const label = stringOrNull(item.label);
const src = stringOrNull(item.src);
return title && label && src ? [{ title, label, src }] : [];
})
: [];
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 hydrateCharacterReference(value: unknown): 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 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 objectKey = stringOrUndefined(snapshot.objectKey);
const assetObjectId = stringOrUndefined(snapshot.assetObjectId);
return id && label && src
? {
id,
label,
src,
mediaType,
mimeType,
sizeBytes,
durationSeconds,
objectKey,
assetObjectId,
}
: null;
}
function hydrateCharacterReferences(
value: unknown,
): CharacterReferenceImage[] | undefined {
return Array.isArray(value)
? value.flatMap((reference) => {
const hydrated = hydrateCharacterReference(reference);
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,
) {
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
const verticalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...layers
.filter((layer) => layer.id !== movingLayer.id)
.flatMap((layer) => [
layer.x,
layer.x + layer.width / 2,
layer.x + layer.width,
]),
];
const horizontalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...layers
.filter((layer) => layer.id !== movingLayer.id)
.flatMap((layer) => [
layer.y,
layer.y + layer.height / 2,
layer.y + layer.height,
]),
];
const xSnap = findNearestSnap(
proposedX,
[0, movingLayer.width / 2, movingLayer.width],
verticalTargets,
threshold,
);
const ySnap = findNearestSnap(
proposedY,
[0, movingLayer.height / 2, movingLayer.height],
horizontalTargets,
threshold,
);
return {
x: xSnap ? xSnap.position : proposedX,
y: ySnap ? ySnap.position : proposedY,
guide:
xSnap || ySnap
? {
vertical: xSnap?.guide,
horizontal: ySnap?.guide,
}
: null,
};
}
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;
}