cd80c085ff
统一前后端 SFX Prompt Unicode canonicalization、字符计数和 2048 边界 固化 52 个音效预设、追加规则及跨语言测试向量 演进共享音频请求响应、duration、Loop 和 V2 metadata 契约 增加 External model 输入矩阵纯函数并保持正式接线归属 T5 补齐前端提交、读取、深拷贝与请求边界回归测试 更新共享计划并标记 T1 完成且当前不可发布
2818 lines
90 KiB
TypeScript
2818 lines
90 KiB
TypeScript
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;
|
||
}
|