Files
Genarrative/src/components/image-editor/ImageCanvasEditorModel.ts
T
lhk229 cd80c085ff 实现 SFX V2 T1 契约与提示词基础
统一前后端 SFX Prompt Unicode canonicalization、字符计数和 2048 边界
固化 52 个音效预设、追加规则及跨语言测试向量
演进共享音频请求响应、duration、Loop 和 V2 metadata 契约
增加 External model 输入矩阵纯函数并保持正式接线归属 T5
补齐前端提交、读取、深拷贝与请求边界回归测试
更新共享计划并标记 T1 完成且当前不可发布
2026-08-06 13:06:10 +00:00

2818 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
EDITOR_SOUND_EFFECT_MODEL,
SOUND_EFFECT_DURATION_MAX_SECONDS,
SOUND_EFFECT_DURATION_MIN_SECONDS,
} from '../../../packages/shared/src/contracts/editorAudio';
import type {
EditorAssetGenerationInputs,
EditorAssetLibrarySnapshot,
EditorCharacterAnimationGenerationResult,
EditorPixelArtSnapInput,
EditorProjectLayerSnapshot,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasLayer,
CanvasMediaType,
CanvasSnapItem,
CanvasViewport,
CharacterReferenceImage,
EditorAsset,
EditorAssetFolder,
PerfectPixelOperationSnapshot,
SnapCandidate,
} from './ImageCanvasEditorTypes';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
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';
const INTERNAL_EDITOR_PROCESSING_MODELS = new Set([
'anime-seg',
'bgfilter complex',
'birefnet',
'connected-components',
'screen-color-keying',
'segment-common-image',
]);
export function isEditorInternalProcessingModel(
model: string | null | undefined,
) {
const normalizedModel = model?.trim().toLowerCase();
return Boolean(
normalizedModel && INTERNAL_EDITOR_PROCESSING_MODELS.has(normalizedModel),
);
}
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 },
'generation-dialog': { width: 188, height: 64 },
} 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 },
options: { applyCascadeOffset?: boolean } = {},
): CanvasLayer {
assertCompleteImageSequenceAsset(asset);
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 = options.applyCascadeOffset === false ? 0 : index * 34;
const assetKind = inferEditorAssetKind(
asset.label,
asset.src,
asset.objectKey,
asset.assetKind,
asset.mediaType,
);
const mediaType = resolveCanvasMediaTypeFromAssetKind(assetKind);
return {
id: `layer-${asset.id}-${index}`,
resourceId: `local-resource-${asset.id}-${index}`,
resourcePersistenceState: 'pending',
title: asset.label,
src: asset.src,
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,
sourceAssetId: asset.id,
resourceAssetKind: asset.assetKind ?? assetKind ?? null,
assetKindOverride: null,
assetKind: asset.assetKind ?? assetKind,
generationInputs: asset.generationInputs,
imageSequenceFrames: asset.imageSequenceFrames,
imageSequenceDurationMs: asset.imageSequenceDurationMs,
} satisfies CanvasLayer;
}
export function assertCompleteImageSequenceAsset(asset: EditorAsset) {
if (
asset.assetKind === 'character-animation' &&
(asset.imageSequenceFrames?.length ?? 0) < 2
) {
throw new Error(`角色动作素材“${asset.label}”不完整:缺少可用序列帧。`);
}
if (
asset.assetKind === 'character-animation' &&
!(asset.imageSequenceDurationMs && asset.imageSequenceDurationMs > 0)
) {
throw new Error(`角色动作素材“${asset.label}”不完整:缺少序列播放时长。`);
}
}
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;
}
/**
* 中文注释:`model` / `provider` 只有在图层挂着项目资源行时才交给资源行权威持有。
*
* 有资源行:服务端读边界会脱敏内部处理模型(`model`)并无条件省略 `provider`,客户端拿到的
* `layer.model` 是脱敏后按来源链推导出的展示值,回写必然与资源行原值冲突,被结构化保存判为
* 「与项目资源不一致」而整次 400。保存时服务端本就会剥离这两个字段,也不参与画布布局哈希,
* 因此直接不发。
*
* 没有资源行(自包含的 legacy 本地图片序列,例如角色动画逐帧层):服务端
* `normalize_structured_canvas_layer_against_resource` 走的是 `resource == None` 早退分支,
* 只摘掉 `assetKind` 就把 item 原样写回,item_json 是这些元数据的**唯一**存储。此时停发会让
* 模型信息在下一次保存后永久丢失,图片信息、ZIP 导出与快速编辑默认模型一起静默退化。
*/
function serializeResourceOwnedModelFields(layer: CanvasLayer) {
if (layer.resourcePersistenceState === 'registered') {
return {};
}
return { model: layer.model, provider: layer.provider };
}
export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
const isFormalCharacterAnimation =
layer.assetKind === 'character-animation' &&
layer.resourcePersistenceState !== 'self-contained-local';
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,
...(isFormalCharacterAnimation
? {}
: {
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,
...serializeResourceOwnedModelFields(layer),
taskId: layer.taskId,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
}),
groupId: layer.groupId,
assetKindOverride: layer.assetKindOverride ?? null,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
flipY: layer.flipY,
};
}
type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & {
itemType: 'generation-dialog';
dialog: CanvasGenerationDialogState;
};
type CanvasSettingsLayoutSnapshot = EditorProjectLayerSnapshot & {
itemType: 'canvas-settings';
canvasBackgroundColor?: string;
};
export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default';
const PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX = 'pixel-art-snap-';
// 中文注释:从稳定请求快照写入开始,提交与项目事实对账共用 75 秒绝对窗口。截止时间随
// durable operation 持久化并在 hydrate 后继续沿用;读取侧还会把跨设备时钟偏差限制在
// “从当前最多再观察一个窗口”。POST 回包与素材刷新都不能替同一次 operation 续期。
export const PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 75_000;
// 中文注释:第一批曾把 v1 快照写成 240 秒。滚动部署与旧标签页仍可能持久化该形状,
// 所以读取侧保留兼容上限;它只决定快照是否可信,不会延长当前 75 秒对账窗口。
const LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 240_000;
const INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE =
'完美像素操作快照无效,禁止自动重试。';
const PERFECT_PIXEL_OPERATION_KEYS = new Set([
'version',
'kind',
'operationId',
'taskId',
'request',
'submittedAt',
'reconcileUntil',
]);
const PERFECT_PIXEL_REQUEST_KEYS = new Set([
'sourceImageSrc',
'projectId',
'sourceResourceId',
'assetKind',
'generationInputs',
'assetFolderId',
'assetLabel',
'canvasCompletion',
]);
const PERFECT_PIXEL_COMPLETION_KEYS = new Set([
'dialogId',
'title',
'placeholder',
]);
const PERFECT_PIXEL_PLACEHOLDER_KEYS = new Set([
'x',
'y',
'width',
'height',
'originalWidth',
'originalHeight',
]);
const PERFECT_PIXEL_GENERATION_INPUTS_KEYS = new Set(['fields', 'references']);
const PERFECT_PIXEL_GENERATION_FIELD_KEYS = new Set(['title', 'value']);
const PERFECT_PIXEL_GENERATION_REFERENCE_KEYS = new Set([
'title',
'label',
'refType',
'refId',
]);
function isSnapshotRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function hasOnlySnapshotKeys(
value: Record<string, unknown>,
allowedKeys: ReadonlySet<string>,
) {
return Object.keys(value).every((key) => allowedKeys.has(key));
}
function isOptionalNullableString(value: unknown) {
return value === undefined || value === null || typeof value === 'string';
}
function isStableEditorMediaReference(value: unknown): value is string {
if (typeof value !== 'string' || !value.trim()) {
return false;
}
const normalized = value.trimStart().toLowerCase();
if (normalized.startsWith('data:') || normalized.startsWith('blob:')) {
return false;
}
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return true;
}
const signedQueryKeys = new Set([
'expires',
'signature',
'x-amz-signature',
'x-amz-security-token',
'x-oss-signature',
'x-oss-security-token',
]);
return [...url.searchParams.keys()].every(
(key) => !signedQueryKeys.has(key.toLowerCase()),
);
} catch {
return true;
}
}
function hydratePerfectPixelGenerationInputs(
value: unknown,
): EditorAssetGenerationInputs | null {
if (
!isSnapshotRecord(value) ||
!hasOnlySnapshotKeys(value, PERFECT_PIXEL_GENERATION_INPUTS_KEYS) ||
!Array.isArray(value.fields) ||
!Array.isArray(value.references)
) {
return null;
}
const fields = value.fields.flatMap((field) => {
if (
!isSnapshotRecord(field) ||
!hasOnlySnapshotKeys(field, PERFECT_PIXEL_GENERATION_FIELD_KEYS) ||
typeof field.title !== 'string' ||
typeof field.value !== 'string'
) {
return [];
}
return [{ title: field.title, value: field.value }];
});
const references: EditorAssetGenerationInputs['references'] =
value.references.flatMap((reference) => {
if (
!isSnapshotRecord(reference) ||
!hasOnlySnapshotKeys(
reference,
PERFECT_PIXEL_GENERATION_REFERENCE_KEYS,
) ||
typeof reference.title !== 'string' ||
typeof reference.label !== 'string' ||
(reference.refType !== 'project-resource' &&
reference.refType !== 'asset') ||
typeof reference.refId !== 'string'
) {
return [];
}
return [
{
title: reference.title,
label: reference.label,
refType: reference.refType as 'project-resource' | 'asset',
refId: reference.refId,
},
];
});
if (
fields.length !== value.fields.length ||
references.length !== value.references.length
) {
return null;
}
return { fields, references };
}
/**
* 中文注释:完美像素没有 durable job,恢复与人工重试只能依赖这份精确请求快照。
* 因此这里按 v1 白名单重建,并交叉校验 dialog / operation / task / completion 身份;
* 任何未知版本、字段漂移或不稳定媒体引用都失败关闭,绝不能据当前画布状态猜测并重放 POST。
*/
export function hydratePerfectPixelOperation(
value: unknown,
dialogId: string,
): PerfectPixelOperationSnapshot | null {
const now = Date.now();
if (
!isSnapshotRecord(value) ||
!hasOnlySnapshotKeys(value, PERFECT_PIXEL_OPERATION_KEYS) ||
value.version !== 1 ||
value.kind !== 'perfect-pixel' ||
value.operationId !== dialogId ||
value.taskId !== `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}` ||
typeof value.submittedAt !== 'number' ||
!Number.isFinite(value.submittedAt) ||
value.submittedAt <= 0 ||
typeof value.reconcileUntil !== 'number' ||
!Number.isFinite(value.reconcileUntil) ||
value.reconcileUntil < value.submittedAt ||
value.reconcileUntil - value.submittedAt >
LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS ||
value.submittedAt >
now + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS
) {
return null;
}
const normalizedSubmittedAt = Math.min(value.submittedAt, now);
const request = value.request;
if (
!isSnapshotRecord(request) ||
!hasOnlySnapshotKeys(request, PERFECT_PIXEL_REQUEST_KEYS) ||
!isStableEditorMediaReference(request.sourceImageSrc) ||
typeof request.projectId !== 'string' ||
!request.projectId.trim() ||
!isOptionalNullableString(request.sourceResourceId) ||
!isOptionalNullableString(request.assetKind) ||
!isOptionalNullableString(request.assetFolderId) ||
!isOptionalNullableString(request.assetLabel)
) {
return null;
}
const completion = request.canvasCompletion;
if (
!isSnapshotRecord(completion) ||
!hasOnlySnapshotKeys(completion, PERFECT_PIXEL_COMPLETION_KEYS) ||
completion.dialogId !== dialogId ||
typeof completion.title !== 'string'
) {
return null;
}
const placeholder = completion.placeholder;
if (
!isSnapshotRecord(placeholder) ||
!hasOnlySnapshotKeys(placeholder, PERFECT_PIXEL_PLACEHOLDER_KEYS) ||
![...PERFECT_PIXEL_PLACEHOLDER_KEYS].every(
(key) =>
typeof placeholder[key] === 'number' &&
Number.isFinite(placeholder[key]),
)
) {
return null;
}
let generationInputs: EditorAssetGenerationInputs | null | undefined;
if (request.generationInputs === undefined) {
generationInputs = undefined;
} else if (request.generationInputs === null) {
generationInputs = null;
} else {
generationInputs = hydratePerfectPixelGenerationInputs(
request.generationInputs,
);
}
if (
request.generationInputs !== undefined &&
request.generationInputs !== null &&
!generationInputs
) {
return null;
}
const hydratedRequest: EditorPixelArtSnapInput = {
sourceImageSrc: request.sourceImageSrc,
projectId: request.projectId,
...(request.sourceResourceId !== undefined
? {
sourceResourceId: request.sourceResourceId as string | null,
}
: {}),
...(request.assetKind !== undefined
? { assetKind: request.assetKind as string | null }
: {}),
...(generationInputs !== undefined ? { generationInputs } : {}),
...(request.assetFolderId !== undefined
? { assetFolderId: request.assetFolderId as string | null }
: {}),
...(request.assetLabel !== undefined
? { assetLabel: request.assetLabel as string | null }
: {}),
canvasCompletion: {
dialogId,
title: completion.title,
placeholder: {
x: placeholder.x as number,
y: placeholder.y as number,
width: placeholder.width as number,
height: placeholder.height as number,
originalWidth: placeholder.originalWidth as number,
originalHeight: placeholder.originalHeight as number,
},
},
};
return {
version: 1,
kind: 'perfect-pixel',
operationId: dialogId,
taskId: `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}`,
request: hydratedRequest,
// 中文注释:把未来时间规范到当前时刻,确保收紧后的快照再次序列化、hydrate 时仍合法;
// 过去时间保持不变,不能借刷新给 operation 续期。兼容读入的旧 240 秒快照同样只保留
// 当前 75 秒绝对窗口。
submittedAt: normalizedSubmittedAt,
reconcileUntil: Math.min(
value.reconcileUntil,
normalizedSubmittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
),
};
}
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 normalizedCurrentUserId(userId: string | null | undefined) {
return userId?.trim() || null;
}
function isReferencePointerSrc(src: string | null | undefined) {
const normalizedSrc = src?.trim() ?? '';
return (
normalizedSrc.startsWith('ref:project-resource:') ||
normalizedSrc.startsWith('ref:asset:')
);
}
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] : [];
});
}
/**
* 中文注释:布局里只写 `perfectPixelOperationId` 标记,不写请求账本本身。
*
* 账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容,改由
* `perfectPixelOperationStore` 存在本机(见那里的长注释)。布局仍需要一个标记,否则
* 换设备打开时无法把这类占位与队列型占位区分开,只能当成普通占位一直转下去。
*
* 标记的寿命必须与账本对齐:收口后账本会被清掉,标记也就不该再留在布局里。否则每一次成功
* 的完美像素都会在布局里留下一个「有标记、没账本」的占位,被 hydrate 判成无效。
*/
function serializeDialogReferences(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogState {
const { perfectPixelOperation, ...persistedDialog } = dialog;
const perfectPixelOperationId = isSettledPerfectPixelDialogRecord(
dialog as unknown as Record<string, unknown>,
)
? undefined
: (dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId);
return {
...persistedDialog,
...(perfectPixelOperationId ? { perfectPixelOperationId } : {}),
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),
};
}
function serializeCanvasSettings({
canvasBackgroundColor,
}: {
canvasBackgroundColor?: string | null;
}): CanvasSettingsLayoutSnapshot | null {
const normalizedBackgroundColor = canvasBackgroundColor
? normalizeCanvasBackgroundHex(canvasBackgroundColor)
: null;
if (!normalizedBackgroundColor) {
return null;
}
return {
itemType: 'canvas-settings',
layerId: CANVAS_SETTINGS_LAYOUT_ITEM_ID,
resourceId: CANVAS_SETTINGS_LAYOUT_ITEM_ID,
canvasBackgroundColor: normalizedBackgroundColor,
};
}
export function serializeCanvasLayout({
layers,
canvasGenerationDialogs,
canvasBackgroundColor,
}: {
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
canvasBackgroundColor?: string | null;
}): CanvasLayoutItems {
const canvasSettings = serializeCanvasSettings({ canvasBackgroundColor });
return [
...(canvasSettings ? [canvasSettings] : []),
...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),
)
);
}
function isCanvasSettingsLayoutItem(
item: EditorProjectLayerSnapshot,
): item is CanvasSettingsLayoutSnapshot {
return item.itemType === 'canvas-settings';
}
/**
* 中文注释:从快照里取出指定 id 的全部生成占位原始记录,取不到返回空数组。
*
* 用原始 record 而不是 hydrate:调用方要判的是服务端写了什么,hydrate 会给缺失字段补默认值
* (例如 status 缺失时补 `idle`),把「服务端没写」和「服务端写了 idle」混成一种。
*/
export function findCanvasGenerationDialogRecords(
project: EditorProjectSnapshot,
dialogId: string | null | undefined,
): Record<string, unknown>[] {
const normalizedDialogId = dialogId?.trim();
if (!normalizedDialogId) {
return [];
}
const matchingDialogs: Record<string, unknown>[] = [];
for (const item of project.layers) {
if (item.itemType !== 'generation-dialog') {
continue;
}
const dialog =
(item as { dialog?: unknown }).dialog &&
typeof (item as { dialog?: unknown }).dialog === 'object'
? ((item as { dialog?: unknown }).dialog as Record<string, unknown>)
: null;
if (dialog?.id === normalizedDialogId) {
matchingDialogs.push(dialog);
}
}
return matchingDialogs;
}
/**
* 中文注释:占位是否仍未收口。这是本仓库对「这个生成完成了没有」的既有定义,原先只存在于
* `useImageCanvasGenerationSubmissionWorkflow` 的队列轮询里,现在提取共用。
*
* 判据必须是 status / generatedLayerId**不能**是「占位还在不在」。服务端成功回填时会保留
* 该 dialog 并就地改写(`editor_project.rs` 的 `apply_editor_canvas_generation_items`:置
* `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`),
* 该行为另有服务端测试钉住。按「在不在」判会把真成功判成失败。
*/
export function isUnresolvedCanvasGenerationDialogRecord(
dialog: Record<string, unknown> | null,
): boolean {
if (!dialog) {
return false;
}
return (
dialog.status === 'generating' ||
dialog.status === 'pending-confirmation' ||
typeof dialog.generatedLayerId !== 'string' ||
dialog.generatedLayerId.trim() === ''
);
}
/**
* 中文注释:完美像素占位是否已经收口,即结果图层已被服务端回填进画布。
*
* **本机账本只在占位未收口期间存在**:发 POST 前写入,拿到终态就清除。而布局里的
* `perfectPixelOperationId` 标记寿命无限——服务端完成时只做字段级改写(置 `status: "idle"`、
* 写入 `generatedLayerId`),从不摘掉这个标记。两者寿命不对称,所以任何「有标记、没账本
* ⇒ 无效」的判据都必须先排除收口态,否则每一次**成功**的完美像素都会在下一次 hydrate 时
* 被判成 `failed + perfectPixelOperationInvalid`,并把这个错误状态写回服务端。
*/
function isSettledPerfectPixelDialogRecord(
dialog: Record<string, unknown> | null,
): boolean {
return Boolean(dialog) && !isUnresolvedCanvasGenerationDialogRecord(dialog);
}
/**
* 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。
*
* 上界由两侧共同封死:服务端最坏合法时长是处理预算 30 秒加持久化预算 60 秒(都由
* `timeout_at` 强制),客户端整个 POST 又被 `snapEditorImageToPixelArt` 的 120 秒超时封顶。
* 客户端整条链的上界是提交前置预算 90 秒加 POST 120 秒 = 210 秒,之后必已 abort 并把占位改成
* `failed` 或直接移除,服务端也早已越过自己的 90 秒。取 240 秒 = 210 秒客户端上界 + 30 秒余量
* (网络往返、标签页被挂起后的时钟漂移)。
*
* 本会话自己的占位另有归属登记豁免,不依赖这个窗口;窗口只用于跨标签页——B 标签不得清掉
* A 标签仍在合法执行的占位,所以它必须大于 A 的客户端上界。
*/
export const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 240_000;
function inlineGenerationPlaceholderExpiryAt(
dialog: CanvasGenerationDialogState,
): number | null {
if (
dialog.perfectPixelOperation ||
dialog.requiresLiveSession !== true ||
dialog.status !== 'generating'
) {
return null;
}
const startedAt = dialog.generationStartedAt;
// 中文注释:缺时间戳按「立即到期」处理,与 dropDeadInlineGenerationPlaceholders 的兜底
// 方向一致——按保留会让这类占位永久转下去。
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
return Number.NEGATIVE_INFINITY;
}
return startedAt + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS;
}
/**
* 中文注释:内存态里已经越过存活窗口的 inline 占位 id。
*
* 与 `dropDeadInlineGenerationPlaceholders` 是同一条规则的两个作用面:那个跑在**快照**上、
* 只在项目加载时执行一次;这个跑在**内存 dialog** 上,供页面打开期间的到期清理使用。
* 少了后者,加载时因未到期而被保留的孤儿占位就再没有任何东西会重新判定,只能转到用户
* 下一次加载——那正是引入存活窗口带来的回归。
*/
export function collectExpiredInlineGenerationDialogIds(
dialogs: readonly CanvasGenerationDialogState[],
now: number = Date.now(),
): string[] {
return dialogs
.filter((dialog) => {
const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog);
return expiryAt !== null && now > expiryAt;
})
.map((dialog) => dialog.id);
}
/**
* 中文注释:下一个 inline 占位到期的绝对时刻,没有则返回 null。调用方据此挂一次性定时器,
* 而不是轮询——到期时刻是可以精确算出来的。
*/
export function resolveNextInlineGenerationDialogExpiryAt(
dialogs: readonly CanvasGenerationDialogState[],
): number | null {
let earliest: number | null = null;
for (const dialog of dialogs) {
const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog);
if (expiryAt === null || !Number.isFinite(expiryAt)) {
continue;
}
if (earliest === null || expiryAt < earliest) {
earliest = expiryAt;
}
}
return earliest;
}
/**
* 中文注释:剥离「只能由已死会话收口」的 generating 占位。
*
* 原先的判据是一条结构性不变量——活着的那份始终在内存里、永远不经过 hydrate,所以从服务端
* 读回来的必然属于已死会话。**这条在多标签页下是假的**:B 标签打开同一项目时,会 hydrate 到
* A 标签正在用的活占位,据此剥离并在自己下一次布局保存里把它写没。CAS 挡不住——B 是以当前
* revision 写入一份合法布局。(反方向倒是被 CAS 挡住的:A 完成后 B 再写,B 的 revision 已陈旧。)
*
* 旧 inline 占位改为有界时间窗:只有超过 `INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS`
* 才判定为孤儿。带 `perfectPixelOperation` 请求账本的新占位不再走这条 legacy 清理,而由
* GET-only 对账收口;即便账本损坏也要留给 hydrate 失败关闭,不能在这里静默删卡。
*
* 时钟取自读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移
* 风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。
*
* 只能用在项目首次加载。会话内 applyQueuedEditorGenerationProject 会重新 GET 项目并套用,
* 那时候占位对应的操作正在进行,套用本函数会把自己的活占位清掉。
*/
function canvasGenerationDialogRecord(
item: EditorProjectLayerSnapshot,
): Record<string, unknown> | null {
if (item.itemType !== 'generation-dialog') {
return null;
}
const dialog = (item as { dialog?: unknown }).dialog;
return isSnapshotRecord(dialog) ? dialog : null;
}
function canvasGenerationDialogMirrorKey(item: EditorProjectLayerSnapshot) {
const dialogId = stringOrNull(canvasGenerationDialogRecord(item)?.id);
return dialogId ? `dialog:${dialogId}` : `layer:${item.layerId}`;
}
function isDeadLegacyInlineGenerationPlaceholder(
item: EditorProjectLayerSnapshot,
now: number,
) {
const dialog = canvasGenerationDialogRecord(item);
if (dialog?.requiresLiveSession !== true || dialog.status !== 'generating') {
return false;
}
const startedAt = dialog.generationStartedAt;
// 中文注释:缺时间戳按「可剥离」处理。实践中不会出现——`requiresLiveSession` 与
// `generationStartedAt` 在同一次创建里一起写——但 legacy 兜底仍需避免永久孤儿。
return (
typeof startedAt !== 'number' ||
!Number.isFinite(startedAt) ||
now - startedAt > INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS
);
}
export function dropDeadInlineGenerationPlaceholders(
project: EditorProjectSnapshot,
now: number = Date.now(),
): { project: EditorProjectSnapshot; droppedCount: number } {
const mirroredLayers = [...project.layers, ...(project.canvas?.layers ?? [])];
const operationBackedDialogKeys = new Set(
mirroredLayers.flatMap((item) => {
const dialog = canvasGenerationDialogRecord(item);
// 中文注释:两种形状都算 operation-backed——`perfectPixelOperationId` 是账本移出
// 布局后的新标记,`perfectPixelOperation` 是内联账本的 legacy 形状。
return dialog &&
(Object.prototype.hasOwnProperty.call(
dialog,
'perfectPixelOperationId',
) ||
Object.prototype.hasOwnProperty.call(dialog, 'perfectPixelOperation'))
? [canvasGenerationDialogMirrorKey(item)]
: [];
}),
);
const droppedDialogKeys = new Set(
mirroredLayers.flatMap((item) => {
const key = canvasGenerationDialogMirrorKey(item);
return !operationBackedDialogKeys.has(key) &&
isDeadLegacyInlineGenerationPlaceholder(item, now)
? [key]
: [];
}),
);
const droppedCount = droppedDialogKeys.size;
if (droppedCount === 0) {
return { project, droppedCount: 0 };
}
const dropFromMirror = (layers: EditorProjectLayerSnapshot[]) =>
layers.filter(
(item) =>
item.itemType !== 'generation-dialog' ||
!droppedDialogKeys.has(canvasGenerationDialogMirrorKey(item)),
);
return {
project: {
...project,
layers: dropFromMirror(project.layers),
...(project.canvas
? {
canvas: {
...project.canvas,
layers: dropFromMirror(project.canvas.layers),
},
}
: {}),
},
droppedCount,
};
}
export function splitCanvasLayoutItems(
items: EditorProjectLayerSnapshot[],
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
currentUserId?: string | null,
localPerfectPixelOperations?: ReadonlyMap<
string,
PerfectPixelOperationSnapshot
>,
): {
layerItems: EditorProjectLayerSnapshot[];
generationDialogs: CanvasGenerationDialogState[];
canvasBackgroundColor?: string;
} {
const layerItems: EditorProjectLayerSnapshot[] = [];
const generationDialogs: CanvasGenerationDialogState[] = [];
let canvasBackgroundColor: string | undefined;
items.forEach((item) => {
if (isCanvasSettingsLayoutItem(item)) {
const normalizedBackgroundColor =
typeof item.canvasBackgroundColor === 'string'
? normalizeCanvasBackgroundHex(item.canvasBackgroundColor)
: null;
if (normalizedBackgroundColor) {
canvasBackgroundColor = normalizedBackgroundColor;
}
return;
}
if (isCanvasGenerationDialogLayoutItem(item)) {
const dialog = hydrateCanvasGenerationDialog(
item.dialog,
resourcesById,
currentUserId,
localPerfectPixelOperations,
);
if (dialog) {
generationDialogs.push(dialog);
}
return;
}
layerItems.push(item);
});
return { layerItems, generationDialogs, canvasBackgroundColor };
}
export function hydrateCanvasGenerationDialog(
value: unknown,
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
currentUserId?: string | null,
localPerfectPixelOperations?: ReadonlyMap<
string,
PerfectPixelOperationSnapshot
>,
): 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;
}
// 中文注释:布局内联快照是 legacy 形状——账本改存本机之前写下的占位仍在库里,
// 必须继续认,否则滚动部署会把所有在途操作一次性判死。新写入只有 id 标记。
const hasLegacyInlinePerfectPixelOperation =
Object.prototype.hasOwnProperty.call(snapshot, 'perfectPixelOperation');
const legacyInlinePerfectPixelOperation = hasLegacyInlinePerfectPixelOperation
? hydratePerfectPixelOperation(snapshot.perfectPixelOperation, id)
: null;
const declaredPerfectPixelOperationId = Object.prototype.hasOwnProperty.call(
snapshot,
'perfectPixelOperationId',
);
const isPerfectPixelPlaceholder =
hasLegacyInlinePerfectPixelOperation || declaredPerfectPixelOperationId;
const perfectPixelOperation =
legacyInlinePerfectPixelOperation ??
// 中文注释:只认 id 与占位一致的账本。id 漂移一律当账本不可用失败关闭,绝不按
// 当前画布状态猜一条请求出来重放。
(declaredPerfectPixelOperationId &&
stringOrNull(snapshot.perfectPixelOperationId) === id
? (localPerfectPixelOperations?.get(id) ?? null)
: null);
const hasPersistedInvalidPerfectPixelOperationMarker =
Object.prototype.hasOwnProperty.call(
snapshot,
'perfectPixelOperationInvalid',
);
// 中文注释:收口态占位不需要账本——服务端已经把结果图层回填进画布,`generatedLayerId`
// 就是证据。账本在收口那一刻已被主动清除,标记却永远留在布局里(服务端不摘),所以下面
// 这些判据必须先排除收口态,否则每一次成功都会被判成失败,并把错误状态写回服务端。
// 已经被写脏的历史行也在这里一并纠正:收口态无条件忽略已落库的无效标记。
const isSettledPerfectPixelPlaceholder =
isPerfectPixelPlaceholder &&
isSettledPerfectPixelDialogRecord(snapshot as Record<string, unknown>);
// 中文注释:标记在、账本不在且尚未收口,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的
// 路径。收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。
const hasInvalidPerfectPixelOperation =
!isSettledPerfectPixelPlaceholder &&
((isPerfectPixelPlaceholder && !perfectPixelOperation) ||
hasPersistedInvalidPerfectPixelOperationMarker ||
(snapshot.status === 'pending-confirmation' && !perfectPixelOperation));
const trustedPerfectPixelOperation = hasInvalidPerfectPixelOperation
? undefined
: perfectPixelOperation;
const style =
snapshot.mode === 'generate' ||
snapshot.mode === 'character' ||
snapshot.mode === 'icon'
? snapshot.style === 'pixelArt'
? 'pixelArt'
: 'none'
: undefined;
return {
id,
mode: snapshot.mode,
prompt,
status: hasInvalidPerfectPixelOperation
? 'failed'
: // 中文注释:带 generatedLayerId 的完美像素占位按定义已被服务端回填,状态只能是
// `idle`。这里强制归位,顺带修复被上一版判据写脏成 `failed` 的历史行。
isSettledPerfectPixelPlaceholder
? 'idle'
: isGenerationStatus(snapshot.status)
? snapshot.status
: 'idle',
// 中文注释:只认布尔 true。缺字段的历史占位一律视为未置位,按队列型处理原样恢复,
// 不会被 dropDeadInlineGenerationPlaceholders 误清。
requiresLiveSession:
snapshot.requiresLiveSession === true ? true : undefined,
...(isPerfectPixelPlaceholder && !isSettledPerfectPixelPlaceholder
? { perfectPixelOperationId: id }
: {}),
...(trustedPerfectPixelOperation
? { perfectPixelOperation: trustedPerfectPixelOperation }
: {}),
...(hasInvalidPerfectPixelOperation
? { perfectPixelOperationInvalid: true }
: {}),
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,
currentUserId,
),
generationReferences: hydrateCharacterReferences(
snapshot.generationReferences,
resourcesById,
currentUserId,
),
characterSpecReference: hydrateCharacterReference(
snapshot.characterSpecReference,
resourcesById,
currentUserId,
),
characterReferences: hydrateCharacterReferences(
snapshot.characterReferences,
resourcesById,
currentUserId,
),
iconSpecReference: hydrateCharacterReference(
snapshot.iconSpecReference,
resourcesById,
currentUserId,
),
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,
currentUserId,
),
uiDesignSpecReference: hydrateCharacterReference(
snapshot.uiDesignSpecReference,
resourcesById,
currentUserId,
),
imageModel: stringOrUndefined(snapshot.imageModel),
style,
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: hasInvalidPerfectPixelOperation
? INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE
: // 中文注释:服务端回填成功时会清掉 errorMessage,所以收口态占位身上的错误文案一定
// 是残留——上一版判据写进去的那句「快照无效」正是这样落库的。一并清掉,否则状态
// 已经纠正回 idle,面板上却还挂着一句失败提示。
isSettledPerfectPixelPlaceholder
? undefined
: stringOrUndefined(snapshot.errorMessage),
generationStartedAt: numberOrUndefined(snapshot.generationStartedAt),
generationFinishedAt: numberOrUndefined(snapshot.generationFinishedAt),
placeholder: hydrateGenerationPlaceholder(snapshot.placeholder),
};
}
export function hydrateLayer(
snapshot: EditorProjectLayerSnapshot,
resourcesById: Map<string, CanvasLayerResourceMetadata>,
options: {
onAssetKindOverrideFallback?: (
issue: CanvasAssetKindOverrideFallbackIssue,
) => void;
} = {},
): 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 snapshotImageSequenceFrames = hydrateImageSequenceFrames(
snapshot.imageSequenceFrames,
);
const resourceImageSequenceFrames = hydrateImageSequenceFrames(
resource?.imageSequenceFrames,
);
const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind);
const hasPersistedAssetKindOverride = Object.prototype.hasOwnProperty.call(
snapshot,
'assetKindOverride',
);
const persistedAssetKindOverride = hasPersistedAssetKindOverride
? canvasAssetKindOrNull(snapshot.assetKindOverride)
: canvasAssetKindOrNull(snapshot.assetKind);
const assetKindOverrideFellBack = Boolean(
resource &&
hasPersistedAssetKindOverride &&
persistedAssetKindOverride &&
!isCanvasAssetKindOverrideCompatible(
resourceAssetKind,
persistedAssetKindOverride,
),
);
const assetKindOverride = assetKindOverrideFellBack
? null
: persistedAssetKindOverride;
const assetKind = assetKindOverride ?? resourceAssetKind;
const isSelfContainedLocalResource =
!resource && isSelfContainedLegacyLocalImageSequence(snapshot);
const isFormalCharacterAnimation =
assetKind === 'character-animation' && !isSelfContainedLocalResource;
if (
isFormalCharacterAnimation &&
resourceAssetKind !== 'character-animation'
) {
return null;
}
let imageSequenceFrames: NonNullable<CanvasLayer['imageSequenceFrames']> = [];
if (isFormalCharacterAnimation) {
imageSequenceFrames = resourceImageSequenceFrames;
} else if (
isSelfContainedLocalResource ||
snapshot.mediaType === 'image-sequence'
) {
imageSequenceFrames = snapshotImageSequenceFrames;
}
const firstSequenceFrame = imageSequenceFrames[0]?.imageSrc ?? '';
const src = isFormalCharacterAnimation
? firstSequenceFrame || resource?.imageSrc || ''
: snapshotSrc || firstSequenceFrame || resource?.imageSrc || '';
const title =
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
if (!resourceId || !layerId || !src) {
return null;
}
if (
isFormalCharacterAnimation &&
(imageSequenceFrames.length < 2 ||
!(
resource?.imageSequenceDurationMs &&
resource.imageSequenceDurationMs > 0
))
) {
return null;
}
if (assetKindOverrideFellBack && persistedAssetKindOverride) {
options.onAssetKindOverrideFallback?.({
layerId,
resourceId,
resourceAssetKind,
rejectedAssetKindOverride: persistedAssetKindOverride,
});
}
let resourcePersistenceState: CanvasLayer['resourcePersistenceState'];
if (resource) {
resourcePersistenceState = 'registered';
} else if (isSelfContainedLocalResource) {
resourcePersistenceState = 'self-contained-local';
} else if (resourceId.startsWith('local-')) {
resourcePersistenceState = 'unresolved-local';
}
let sourceType: CanvasLayer['sourceType'] = 'uploaded';
if (isCanvasSourceType(resource?.sourceType)) {
sourceType = resource.sourceType;
} else if (isCanvasSourceType(snapshot.sourceType)) {
sourceType = snapshot.sourceType;
}
const mediaType = resolveHydratedLayerMediaType(
snapshot,
assetKind,
isSelfContainedLocalResource,
);
let imageSequenceDurationMs: number | undefined;
if (isFormalCharacterAnimation) {
imageSequenceDurationMs =
audioDurationOrNull(resource?.imageSequenceDurationMs) ?? undefined;
} else if (isSelfContainedLocalResource) {
imageSequenceDurationMs =
audioDurationOrNull(snapshot.imageSequenceDurationMs) ?? undefined;
}
return {
id: layerId,
resourceId,
resourcePersistenceState,
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,
mediaType,
thumbnailSrc: isFormalCharacterAnimation
? firstSequenceFrame || null
: stringOrNull(snapshot.thumbnailSrc),
imageSequenceFrames: imageSequenceFrames.length
? imageSequenceFrames
: undefined,
previewVideoPath: isFormalCharacterAnimation
? null
: stringOrNull(snapshot.previewVideoPath),
prompt: isFormalCharacterAnimation
? stringOrNull(resource?.prompt)
: (stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt)),
actualPrompt: isFormalCharacterAnimation
? stringOrNull(resource?.actualPrompt)
: (stringOrNull(snapshot.actualPrompt) ??
stringOrNull(resource?.actualPrompt)),
model: resolveHydratedLayerModel(
snapshot,
resource,
resourcesById,
isFormalCharacterAnimation,
),
provider: isFormalCharacterAnimation
? stringOrNull(resource?.provider)
: (stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider)),
taskId: isFormalCharacterAnimation
? stringOrNull(resource?.taskId)
: (stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId)),
objectKey: isFormalCharacterAnimation
? stringOrNull(resource?.objectKey)
: (stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey)),
assetObjectId: isFormalCharacterAnimation
? stringOrNull(resource?.assetObjectId)
: (stringOrNull(snapshot.assetObjectId) ??
stringOrNull(resource?.assetObjectId)),
imageSequenceDurationMs,
sourceResourceId: isFormalCharacterAnimation
? stringOrNull(resource?.sourceResourceId)
: (stringOrNull(snapshot.sourceResourceId) ??
stringOrNull(resource?.sourceResourceId)),
sourceAssetId: isFormalCharacterAnimation
? null
: stringOrNull(snapshot.sourceAssetId),
groupId: stringOrNull(snapshot.groupId),
resourceAssetKind,
assetKindOverride,
assetKind,
generationInputs: isFormalCharacterAnimation
? generationInputsOrNull(resource?.generationInputs)
: (generationInputsOrNull(resource?.generationInputs) ??
generationInputsOrNull(snapshot.generationInputs)),
hidden: booleanFromSnapshot(snapshot.hidden),
locked: booleanFromSnapshot(snapshot.locked),
flipX: booleanFromSnapshot(snapshot.flipX),
flipY: booleanFromSnapshot(snapshot.flipY),
};
}
function resolveHydratedLayerMediaType(
snapshot: EditorProjectLayerSnapshot,
assetKind: CanvasAssetKind | null,
isSelfContainedLocalResource: boolean,
): CanvasMediaType {
if (isSelfContainedLocalResource) {
return 'image-sequence';
}
if (assetKind) {
return resolveCanvasMediaTypeFromAssetKind(assetKind);
}
if (snapshot.mediaType === 'video' || snapshot.mediaType === 'audio') {
return snapshot.mediaType;
}
return 'image';
}
function isSelfContainedLegacyLocalImageSequence(
snapshot: EditorProjectLayerSnapshot,
) {
if (
!snapshot.resourceId.startsWith('local-') ||
snapshot.sourceType !== 'generated' ||
snapshot.mediaType !== 'image-sequence'
) {
return false;
}
if (
!isEmptyLegacyLocalSequenceField(snapshot.imageSrc) ||
!isEmptyLegacyLocalSequenceField(snapshot.objectKey) ||
!isEmptyLegacyLocalSequenceField(snapshot.assetObjectId)
) {
return false;
}
if (
!Array.isArray(snapshot.imageSequenceFrames) ||
snapshot.imageSequenceFrames.length === 0
) {
return false;
}
let firstFrameSrc = '';
for (const [index, value] of snapshot.imageSequenceFrames.entries()) {
if (!value || typeof value !== 'object') {
return false;
}
const frame = value as Record<string, unknown>;
const imageSrc =
typeof frame.imageSrc === 'string' ? frame.imageSrc.trim() : '';
if (
frame.frameIndex !== index + 1 ||
typeof frame.width !== 'number' ||
!Number.isFinite(frame.width) ||
frame.width <= 0 ||
typeof frame.height !== 'number' ||
!Number.isFinite(frame.height) ||
frame.height <= 0 ||
!isStableLegacyCanvasObjectPath(imageSrc)
) {
return false;
}
if (index === 0) {
firstFrameSrc = imageSrc;
}
if (frame.objectKey !== undefined) {
if (
typeof frame.objectKey !== 'string' ||
!isStableLegacyCanvasObjectKey(frame.objectKey) ||
frame.objectKey.trim() !== imageSrc.replace(/^\/+/, '')
) {
return false;
}
}
}
const source = typeof snapshot.src === 'string' ? snapshot.src.trim() : '';
if (
source &&
(!isStableLegacyCanvasObjectPath(source) || source !== firstFrameSrc)
) {
return false;
}
return [snapshot.thumbnailSrc, snapshot.previewVideoPath].every(
(value) =>
value === undefined ||
value === null ||
(typeof value === 'string' && isStableLegacyCanvasObjectPath(value)),
);
}
function isEmptyLegacyLocalSequenceField(value: unknown) {
return (
value === undefined ||
value === null ||
(typeof value === 'string' && !value.trim())
);
}
function isStableLegacyCanvasObjectPath(value: string) {
const normalized = value.trim();
return (
normalized.startsWith('/') &&
!normalized.startsWith('//') &&
!normalized.includes('?') &&
!normalized.includes('#') &&
!normalized.includes('\\') &&
!normalized.split('/').includes('..') &&
!hasControlCharacter(normalized)
);
}
function isStableLegacyCanvasObjectKey(value: string) {
const normalized = value.trim();
return (
Boolean(normalized) &&
!normalized.startsWith('/') &&
!normalized.includes('?') &&
!normalized.includes('#') &&
!normalized.includes('\\') &&
!normalized.split('/').includes('..') &&
!hasControlCharacter(normalized)
);
}
function hasControlCharacter(value: string) {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
});
}
function resolveHydratedLayerModel(
snapshot: EditorProjectLayerSnapshot,
resource: CanvasLayerResourceMetadata | undefined,
resourcesById: Map<string, CanvasLayerResourceMetadata>,
resourceAuthoritative = false,
) {
const persistedModel = resourceAuthoritative
? stringOrNull(resource?.model)
: (stringOrNull(snapshot.model) ?? stringOrNull(resource?.model));
if (persistedModel && !isEditorInternalProcessingModel(persistedModel)) {
return persistedModel;
}
let sourceResourceId = resourceAuthoritative
? stringOrNull(resource?.sourceResourceId)
: (stringOrNull(snapshot.sourceResourceId) ??
stringOrNull(resource?.sourceResourceId));
const visitedResourceIds = new Set<string>();
while (sourceResourceId && !visitedResourceIds.has(sourceResourceId)) {
visitedResourceIds.add(sourceResourceId);
const sourceResource = resourcesById.get(sourceResourceId);
const sourceModel = stringOrNull(sourceResource?.model);
if (sourceModel && !isEditorInternalProcessingModel(sourceModel)) {
return sourceModel;
}
sourceResourceId = stringOrNull(sourceResource?.sourceResourceId);
}
return persistedModel;
}
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 assetKind = inferEditorAssetKind(
asset.label,
asset.imageSrc,
asset.objectKey,
asset.assetKind,
);
const mediaType = resolveCanvasMediaTypeFromAssetKind(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,
generationCostMudPoints: asset.generationCostMudPoints ?? 0,
showcaseId: asset.showcaseId ?? null,
showcaseReviewStatus: asset.showcaseReviewStatus ?? null,
showcaseDisplayEnabled: asset.showcaseDisplayEnabled ?? null,
showcaseLikeCount: asset.showcaseLikeCount ?? null,
assetKind,
generationInputs: generationInputsOrNull(asset.generationInputs),
imageSequenceFrames: asset.imageSequenceFrames ?? undefined,
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined,
};
}),
};
}
export function resolveCanvasMediaTypeFromAssetKind(
assetKind?: string | null,
): CanvasMediaType {
if (assetKind === 'character-animation') {
return 'image-sequence';
}
if (assetKind === 'video') {
return 'video';
}
if (
assetKind === 'audio' ||
assetKind === 'sound-effect' ||
assetKind === 'background-music'
) {
return 'audio';
}
return 'image';
}
export function inferEditorAssetMediaType(
imageSrc: string,
objectKey?: string | null,
assetKind?: string | null,
): CanvasMediaType {
const normalizedAssetKind = canvasAssetKindOrNull(assetKind);
if (normalizedAssetKind) {
return resolveCanvasMediaTypeFromAssetKind(normalizedAssetKind);
}
const sources = [objectKey, imageSrc];
if (sources.some((source) => hasMediaFileExtension(source, '.mp4'))) {
return 'video';
}
if (sources.some((source) => hasMediaFileExtension(source, '.mp3'))) {
return 'audio';
}
return 'image';
}
function inferEditorAssetKind(
label: string,
imageSrc: string,
objectKey?: string | null,
assetKind?: string | null,
mediaType?: CanvasMediaType,
): CanvasAssetKind {
const normalizedAssetKind = canvasAssetKindOrNull(assetKind);
if (normalizedAssetKind) {
return normalizedAssetKind;
}
const inferredMediaType =
mediaType === 'video' || mediaType === 'audio'
? mediaType
: inferEditorAssetMediaType(imageSrc, objectKey);
if (inferredMediaType === 'video') {
return 'video';
}
if (inferredMediaType === 'audio') {
return inferAudioAssetKindFromLabel(label);
}
return 'image';
}
function hasMediaFileExtension(
source: string | null | undefined,
extension: string,
) {
const normalizedSource = source?.trim().toLowerCase() ?? '';
const sourceWithoutQuery = normalizedSource.split(/[?#]/u)[0] ?? '';
return sourceWithoutQuery.endsWith(extension);
}
function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
if (label.includes('背景音乐') || label.toLowerCase().includes('music')) {
return 'background-music';
}
return 'sound-effect';
}
export type CanvasLayerResourceMetadata = {
resourceId?: string | null;
ownerUserId?: string | null;
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width?: number | null;
height?: number | null;
sourceType?: string | null;
imageSequenceDurationMs?: 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;
imageSequenceFrames?: unknown;
createdAt?: string | null;
updatedAt?: string | null;
};
export type CanvasAssetKindOverrideFallbackIssue = {
layerId: string;
resourceId: string;
resourceAssetKind: CanvasAssetKind | null;
rejectedAssetKindOverride: CanvasAssetKind;
};
type CanvasAssetMediaFamily =
'image' | 'character-animation' | 'video' | 'audio';
function resolveCanvasAssetMediaFamily(
assetKind: CanvasAssetKind | null | undefined,
): CanvasAssetMediaFamily {
if (assetKind === 'character-animation') {
return 'character-animation';
}
if (assetKind === 'video') {
return 'video';
}
if (
assetKind === 'audio' ||
assetKind === 'sound-effect' ||
assetKind === 'background-music'
) {
return 'audio';
}
return 'image';
}
export function isCanvasAssetKindOverrideCompatible(
resourceAssetKind: CanvasAssetKind | null | undefined,
assetKindOverride: CanvasAssetKind | null,
) {
return (
assetKindOverride === null ||
resolveCanvasAssetMediaFamily(resourceAssetKind) ===
resolveCanvasAssetMediaFamily(assetKindOverride)
);
}
export function resolveLayerResourceAssetKind(
layer: CanvasLayer,
): CanvasAssetKind | null {
if (layer.resourceAssetKind !== undefined) {
return layer.resourceAssetKind;
}
return layer.assetKindOverride === undefined
? (layer.assetKind ?? null)
: 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 numberOrUndefined(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
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) => {
if (!frame || typeof frame !== 'object') {
return [];
}
const snapshot = frame as Record<string, unknown>;
const imageSrc = stringOrNull(snapshot.imageSrc);
if (!imageSrc) {
return [];
}
return [
{
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;
soundEffect?: 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 }]
: [];
})
: [];
const hasSoundEffect = Object.prototype.hasOwnProperty.call(
snapshot,
'soundEffect',
);
const soundEffect = hasSoundEffect
? soundEffectGenerationMetadataOrNull(snapshot.soundEffect)
: null;
if (hasSoundEffect && !soundEffect) {
return null;
}
return fields.length || references.length || soundEffect
? {
fields,
references,
...(soundEffect ? { soundEffect } : {}),
}
: null;
}
function soundEffectGenerationMetadataOrNull(
value: unknown,
): NonNullable<CanvasGenerationInputs['soundEffect']> | null {
if (!isSnapshotRecord(value)) {
return null;
}
const userPrompt =
typeof value.userPrompt === 'string' ? value.userPrompt : '';
const actualPrompt =
typeof value.actualPrompt === 'string' ? value.actualPrompt : '';
const userPromptValidation = validateSoundEffectPrompt(userPrompt);
const actualPromptValidation = validateSoundEffectPrompt(actualPrompt);
if (
value.schemaVersion !== 2 ||
value.model !== EDITOR_SOUND_EFFECT_MODEL ||
(value.durationMode !== 'auto' && value.durationMode !== 'manual') ||
typeof value.loop !== 'boolean' ||
!userPromptValidation.ok ||
userPromptValidation.prompt !== userPrompt ||
!actualPromptValidation.ok ||
actualPromptValidation.prompt !== actualPrompt ||
typeof value.actualDurationSeconds !== 'number' ||
!Number.isFinite(value.actualDurationSeconds) ||
value.actualDurationSeconds <= 0 ||
value.actualDurationSeconds > 600
) {
return null;
}
let requestedDurationSeconds: number | null;
if (value.durationMode === 'auto') {
if (value.requestedDurationSeconds !== null) {
return null;
}
requestedDurationSeconds = null;
} else {
if (
typeof value.requestedDurationSeconds !== 'number' ||
!Number.isFinite(value.requestedDurationSeconds) ||
value.requestedDurationSeconds < SOUND_EFFECT_DURATION_MIN_SECONDS ||
value.requestedDurationSeconds > SOUND_EFFECT_DURATION_MAX_SECONDS
) {
return null;
}
requestedDurationSeconds = value.requestedDurationSeconds;
}
return {
schemaVersion: 2,
userPrompt,
actualPrompt,
model: EDITOR_SOUND_EFFECT_MODEL,
durationMode: value.durationMode,
requestedDurationSeconds,
actualDurationSeconds: value.actualDurationSeconds,
loop: value.loop,
};
}
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
return value === 'image' ||
value === 'audio' ||
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 === 'pending-confirmation' ||
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>,
currentUserId?: string | null,
) {
if (resourceId) {
const resource = resourcesById.get(resourceId);
const currentUser = normalizedCurrentUserId(currentUserId);
const ownerUserId = resource?.ownerUserId?.trim() || null;
if (currentUser && ownerUserId && ownerUserId !== currentUser) {
return null;
}
return resource;
}
return undefined;
}
function hydrateCharacterReference(
value: unknown,
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
currentUserId?: string | null,
): 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,
currentUserId,
);
if (
resource === null ||
(currentUserId && resourceId && !resource) ||
(!resource && isReferencePointerSrc(src))
) {
return null;
}
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(),
currentUserId?: string | null,
): CharacterReferenceImage[] | undefined {
return Array.isArray(value)
? value.flatMap((reference) => {
const hydrated = hydrateCharacterReference(
reference,
resourcesById,
currentUserId,
);
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;
}