改为行引用保存画布参考图元数据

生成输入参考图只保存 refType/refId 行引用
生成器布局快照只保存参考图行指针并在加载时恢复临时源
上传参考图优先创建项目资源行并回写 resourceId
元数据弹窗改为展示参考图行引用信息
更新图片画布技术文档和项目决策记录
This commit is contained in:
2026-06-21 17:27:23 +08:00
parent 29d9c9ff5b
commit de9eaef28f
19 changed files with 822 additions and 264 deletions
@@ -16,6 +16,14 @@
---
## 2026-06-21 图片画布参考图元数据只保存项目内行引用
- 背景:参考图如果把 Data URL、signed URL 或 `objectKey` 写入 `generationInputs` 或生成器布局快照,会撑大资源 / 素材 / 画布 JSON,也无法稳定索引到项目内用户可见行数据。
- 决策:`generationInputs.references` 只保存 `{ title, label, refType, refId }`,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId``refType="asset"` 指向 `editor_asset.assetId`。生成器 `itemType="generation-dialog"` 布局快照中的参考图也只保存 `resourceId/sourceAssetId` 和展示 label,不保存图片 Data URL、signed URL 或 `objectKey`;提交生成请求前的内存态可以临时持有 `src/objectKey`,刷新恢复时从 `editor_project_resource` / `editor_asset` 行补回请求所需图片源。不兼容旧 `src` 型参考图元数据。
- 影响范围:图片画布生成输入快照、生成器布局保存 / 恢复、参考图上传工作流、元数据弹窗和图片画布技术文档。
- 验证方式:运行图片画布生成模型、生成提交、上传工作流、项目持久化、元数据弹窗相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-06-19 外部 OpenAPI 与 API Key 管理走 server-rs 正式链路
- 背景:外部调用方需要稳定调用图片画布项目创建、画布布局保存和编辑器美术生图能力,同时需要可撤销的开发者凭据,不能依赖前端临时状态或人工分发密钥。
File diff suppressed because one or more lines are too long
@@ -348,9 +348,8 @@ describe('ImageCanvasEditorModel', () => {
{
id: 'publication-reference-1',
label: '首图参考',
src: '/generated-character-drafts/editor/reference.png',
objectKey: 'generated-character-drafts/editor/reference.png',
assetObjectId: 'asset-object-publication-reference',
src: 'data:image/png;base64,publication-ref',
resourceId: 'resource-publication-reference',
},
],
imageModel: 'gpt-image-2',
@@ -367,11 +366,27 @@ describe('ImageCanvasEditorModel', () => {
},
};
const layout = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [dialog],
});
expect(JSON.stringify(layout)).not.toContain('data:image');
expect(JSON.stringify(layout)).toContain(
'ref:project-resource:resource-publication-reference',
);
const { generationDialogs } = splitCanvasLayoutItems(
serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [dialog],
}),
layout,
new Map([
[
'resource-publication-reference',
{
imageSrc: '/read/publication-reference.png',
objectKey: 'generated-character-drafts/editor/reference.png',
assetObjectId: 'asset-object-publication-reference',
},
],
]),
);
expect(generationDialogs).toHaveLength(1);
@@ -390,9 +405,10 @@ describe('ImageCanvasEditorModel', () => {
expect(generationDialogs[0]?.publicationReferences?.[0]).toMatchObject({
id: 'publication-reference-1',
label: '首图参考',
src: '/generated-character-drafts/editor/reference.png',
src: '/read/publication-reference.png',
objectKey: 'generated-character-drafts/editor/reference.png',
assetObjectId: 'asset-object-publication-reference',
resourceId: 'resource-publication-reference',
});
});
@@ -432,6 +448,7 @@ describe('ImageCanvasEditorModel', () => {
id: 'reference-1',
label: '参考图',
src: 'data:image/png;base64,ref',
resourceId: 'resource-reference-1',
},
],
};
@@ -440,7 +457,29 @@ describe('ImageCanvasEditorModel', () => {
layers: [layer],
canvasGenerationDialogs: [dialog],
});
const { layerItems, generationDialogs } = splitCanvasLayoutItems(layout);
expect(
JSON.stringify(
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
),
).not.toContain('data:image');
expect(
JSON.stringify(
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
),
).toContain('ref:project-resource:resource-reference-1');
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
layout,
new Map([
[
'resource-reference-1',
{
imageSrc: '/read/reference-1.png',
objectKey: 'generated/reference-1.png',
},
],
]),
);
expect(layerItems).toHaveLength(1);
expect(generationDialogs).toHaveLength(1);
@@ -459,6 +498,9 @@ describe('ImageCanvasEditorModel', () => {
});
expect(generationDialogs[0]?.generationReferences?.[0]).toMatchObject({
label: '参考图',
src: '/read/reference-1.png',
resourceId: 'resource-reference-1',
objectKey: 'generated/reference-1.png',
});
});
@@ -190,6 +190,100 @@ type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & {
export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
function isPersistedReferenceResourceId(resourceId: string | null | undefined) {
const normalizedResourceId = resourceId?.trim();
return Boolean(
normalizedResourceId &&
!normalizedResourceId.startsWith('local-') &&
!normalizedResourceId.startsWith('generation-dialog:'),
);
}
function isPersistedReferenceAssetId(assetId: string | null | undefined) {
const normalizedAssetId = assetId?.trim();
return Boolean(normalizedAssetId && !normalizedAssetId.startsWith('upload-'));
}
function resolveReferencePointer(reference: CharacterReferenceImage) {
const resourceId = reference.resourceId?.trim();
if (isPersistedReferenceResourceId(resourceId)) {
return {
src: `ref:project-resource:${resourceId}`,
resourceId,
sourceAssetId: reference.sourceAssetId,
};
}
const sourceAssetId = reference.sourceAssetId?.trim();
if (isPersistedReferenceAssetId(sourceAssetId)) {
return {
src: `ref:asset:${sourceAssetId}`,
resourceId: reference.resourceId,
sourceAssetId,
};
}
return null;
}
function serializeGenerationReference(
reference: CharacterReferenceImage | null | undefined,
): CharacterReferenceImage | null {
if (!reference) {
return null;
}
const pointer = resolveReferencePointer(reference);
if (!pointer) {
return null;
}
return {
id: reference.id,
label: reference.label,
src: pointer.src,
mediaType: reference.mediaType,
mimeType: reference.mimeType,
sizeBytes: reference.sizeBytes,
durationSeconds: reference.durationSeconds,
resourceId: pointer.resourceId,
sourceAssetId: pointer.sourceAssetId,
};
}
function serializeGenerationReferences(
references: CharacterReferenceImage[] | undefined,
) {
if (!references) {
return undefined;
}
return references.flatMap((reference) => {
const serialized = serializeGenerationReference(reference);
return serialized ? [serialized] : [];
});
}
function serializeDialogReferences(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogState {
return {
...dialog,
specReference: serializeGenerationReference(dialog.specReference),
generationReferences: serializeGenerationReferences(
dialog.generationReferences,
),
characterSpecReference: serializeGenerationReference(
dialog.characterSpecReference,
),
characterReferences: serializeGenerationReferences(
dialog.characterReferences,
),
iconSpecReference: serializeGenerationReference(dialog.iconSpecReference),
publicationReferences: serializeGenerationReferences(
dialog.publicationReferences,
),
uiDesignSpecReference: serializeGenerationReference(
dialog.uiDesignSpecReference,
),
};
}
export function serializeCanvasGenerationDialog(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogSnapshot {
@@ -197,7 +291,7 @@ export function serializeCanvasGenerationDialog(
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialog.id}`,
resourceId: `generation-dialog:${dialog.id}`,
dialog,
dialog: serializeDialogReferences(dialog),
};
}
@@ -229,6 +323,7 @@ export function isCanvasGenerationDialogLayoutItem(
export function splitCanvasLayoutItems(
items: EditorProjectLayerSnapshot[],
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
): {
layerItems: EditorProjectLayerSnapshot[];
generationDialogs: CanvasGenerationDialogState[];
@@ -238,7 +333,7 @@ export function splitCanvasLayoutItems(
items.forEach((item) => {
if (isCanvasGenerationDialogLayoutItem(item)) {
const dialog = hydrateCanvasGenerationDialog(item.dialog);
const dialog = hydrateCanvasGenerationDialog(item.dialog, resourcesById);
if (dialog) {
generationDialogs.push(dialog);
}
@@ -252,6 +347,7 @@ export function splitCanvasLayoutItems(
export function hydrateCanvasGenerationDialog(
value: unknown,
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
): CanvasGenerationDialogState | null {
if (!value || typeof value !== 'object') {
return null;
@@ -278,17 +374,26 @@ export function hydrateCanvasGenerationDialog(
? snapshot.specType
: undefined,
specValues: hydrateSpecFormValues(snapshot.specValues),
specReference: hydrateCharacterReference(snapshot.specReference),
specReference: hydrateCharacterReference(
snapshot.specReference,
resourcesById,
),
generationReferences: hydrateCharacterReferences(
snapshot.generationReferences,
resourcesById,
),
characterSpecReference: hydrateCharacterReference(
snapshot.characterSpecReference,
resourcesById,
),
characterReferences: hydrateCharacterReferences(
snapshot.characterReferences,
resourcesById,
),
iconSpecReference: hydrateCharacterReference(
snapshot.iconSpecReference,
resourcesById,
),
iconSpecReference: hydrateCharacterReference(snapshot.iconSpecReference),
iconDescriptions: Array.isArray(snapshot.iconDescriptions)
? snapshot.iconDescriptions.filter(
(description): description is string =>
@@ -303,9 +408,11 @@ export function hydrateCanvasGenerationDialog(
),
publicationReferences: hydrateCharacterReferences(
snapshot.publicationReferences,
resourcesById,
),
uiDesignSpecReference: hydrateCharacterReference(
snapshot.uiDesignSpecReference,
resourcesById,
),
imageModel: stringOrUndefined(snapshot.imageModel),
videoModel:
@@ -707,12 +814,19 @@ export function generationInputsOrNull(
const item = reference as {
title?: unknown;
label?: unknown;
src?: unknown;
refType?: unknown;
refId?: unknown;
};
const title = stringOrNull(item.title);
const label = stringOrNull(item.label);
const src = stringOrNull(item.src);
return title && label && src ? [{ title, label, src }] : [];
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 }]
: [];
})
: [];
@@ -829,7 +943,20 @@ function hydrateSpecFormValues(
};
}
function hydrateCharacterReference(value: unknown): CharacterReferenceImage | null {
function resolveHydratedReferenceResource(
resourceId: string | undefined,
resourcesById: Map<string, CanvasLayerResourceMetadata>,
) {
if (resourceId) {
return resourcesById.get(resourceId);
}
return undefined;
}
function hydrateCharacterReference(
value: unknown,
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
): CharacterReferenceImage | null {
if (!value || typeof value !== 'object') {
return null;
}
@@ -837,6 +964,8 @@ function hydrateCharacterReference(value: unknown): CharacterReferenceImage | nu
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
@@ -845,29 +974,37 @@ function hydrateCharacterReference(value: unknown): CharacterReferenceImage | nu
const sizeBytes = numberFromSnapshot(snapshot.sizeBytes, 0) || undefined;
const durationSeconds =
numberFromSnapshot(snapshot.durationSeconds, 0) || undefined;
const objectKey = stringOrUndefined(snapshot.objectKey);
const assetObjectId = stringOrUndefined(snapshot.assetObjectId);
return id && label && src
const resource = resolveHydratedReferenceResource(resourceId, resourcesById);
const objectKey =
stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey);
const assetObjectId =
stringOrUndefined(snapshot.assetObjectId) ??
stringOrUndefined(resource?.assetObjectId);
const resolvedSrc = resource?.imageSrc || objectKey || src;
return id && label && resolvedSrc
? {
id,
label,
src,
src: resolvedSrc,
mediaType,
mimeType,
sizeBytes,
durationSeconds,
objectKey,
assetObjectId,
resourceId,
sourceAssetId,
}
: null;
}
function hydrateCharacterReferences(
value: unknown,
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
): CharacterReferenceImage[] | undefined {
return Array.isArray(value)
? value.flatMap((reference) => {
const hydrated = hydrateCharacterReference(reference);
const hydrated = hydrateCharacterReference(reference, resourcesById);
return hydrated ? [hydrated] : [];
})
: undefined;
@@ -59,7 +59,8 @@ export type CanvasGenerationInputField = {
export type CanvasGenerationInputReference = {
title: string;
label: string;
src: string;
refType: 'project-resource' | 'asset';
refId: string;
};
export type CanvasGenerationInputs = {
@@ -151,6 +152,8 @@ export type CharacterReferenceImage = {
durationSeconds?: number;
objectKey?: string | null;
assetObjectId?: string | null;
resourceId?: string | null;
sourceAssetId?: string | null;
};
export type PublicationMaterialsGameInfo = {
@@ -558,6 +558,7 @@ export function ImageCanvasEditorView() {
} = useImageCanvasUploadWorkflow({
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
projectId,
assetFolders,
activeUploadFolderId,
canvasSize,
@@ -454,11 +454,12 @@ describe('ImageCanvasGenerationDialogModel', () => {
status: 'idle',
errorMessage: undefined,
composerOpen: true,
characterSpecReference: {
characterSpecReference: expect.objectContaining({
id: 'canvas-layer-source',
label: '参考图',
src: 'data:image/png;base64,source',
},
resourceId: 'resource-source',
}),
}),
);
expect(
@@ -163,14 +163,36 @@ describe('ImageCanvasGenerationModel', () => {
expect(
buildCharacterGenerationInputs(
'主角骑士',
{ id: 'spec', label: '角色规范', src: '/spec.png' },
[{ id: 'ref-1', label: '盔甲参考', src: '/armor.png' }],
{
id: 'spec',
label: '角色规范',
src: '/spec.png',
resourceId: 'resource-spec',
},
[
{
id: 'ref-1',
label: '盔甲参考',
src: '/armor.png',
resourceId: 'resource-armor',
},
],
),
).toEqual({
fields: [{ title: '角色设定', value: '主角骑士' }],
references: [
{ title: '角色规范', label: '角色规范', src: '/spec.png' },
{ title: '常规参考图 1', label: '盔甲参考', src: '/armor.png' },
{
title: '角色规范',
label: '角色规范',
refType: 'project-resource',
refId: 'resource-spec',
},
{
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
});
@@ -179,13 +201,19 @@ describe('ImageCanvasGenerationModel', () => {
id: 'icon-spec',
label: '图标规范',
src: '/icon-spec.png',
resourceId: 'resource-icon-spec',
}),
).toEqual({
fields: [
{ title: '素材描述', value: '返回按钮\n设置按钮' },
],
references: [
{ title: '图标规范', label: '图标规范', src: '/icon-spec.png' },
{
title: '图标规范',
label: '图标规范',
refType: 'project-resource',
refId: 'resource-icon-spec',
},
],
});
@@ -194,18 +222,38 @@ describe('ImageCanvasGenerationModel', () => {
).toEqual({
fields: [{ title: '修改要求', value: '换成夜晚' }],
references: [
{ title: '参考图', label: '原图', src: '/source.png' },
{
title: '参考图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
});
expect(
buildQuickEditGenerationInputs('快速编辑提示词', '让图2下雨', sourceLayer, [
{ id: 'ref-1', label: '天空', src: '/sky.png' },
{
id: 'ref-1',
label: '天空',
src: '/sky.png',
resourceId: 'resource-sky',
},
]),
).toEqual({
fields: [{ title: '快速编辑提示词', value: '让图2下雨' }],
references: [
{ title: '参考图 1', label: '天空', src: '/sky.png' },
{ title: '参考图 2', label: '原图', src: '/source.png' },
{
title: '参考图 1',
label: '天空',
refType: 'project-resource',
refId: 'resource-sky',
},
{
title: '参考图 2',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
});
});
@@ -278,11 +326,23 @@ describe('ImageCanvasGenerationModel', () => {
buildSpecGenerationInputs(
'custom',
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
{ id: 'spec-ref', label: '参考.png', src: '/ref.png' },
{
id: 'spec-ref',
label: '参考.png',
src: '/ref.png',
resourceId: 'resource-ref',
},
),
).toEqual({
fields: [{ title: '自定义规范提示词', value: '生成一张怪兽规范图' }],
references: [{ title: '参考图', label: '参考.png', src: '/ref.png' }],
references: [
{
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-ref',
},
],
});
});
@@ -7,6 +7,7 @@ import { isGeneratedLayer } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputField,
CanvasGenerationInputReference,
CanvasGenerationInputs,
CanvasLayer,
CharacterReferenceImage,
@@ -543,13 +544,7 @@ export function buildCharacterAnimationGenerationInputs(
): CanvasGenerationInputs {
return {
fields: createGenerationInputField('动作描述', promptText),
references: [
{
title: '角色图片',
label: sourceLayer.title,
src: sourceLayer.objectKey?.trim() || sourceLayer.src,
},
],
references: createLayerGenerationInputReference('角色图片', sourceLayer),
};
}
@@ -710,9 +705,57 @@ export function createCanvasLayerReference(
...(mediaType ? { mediaType } : {}),
objectKey: layer.objectKey ?? undefined,
assetObjectId: layer.assetObjectId ?? undefined,
resourceId: layer.resourceId,
sourceAssetId: layer.sourceAssetId ?? undefined,
};
}
export function createGenerationInputReference(
title: string,
reference: Pick<
CharacterReferenceImage,
'label' | 'resourceId' | 'sourceAssetId'
>,
): CanvasGenerationInputReference[] {
const resourceId = reference.resourceId?.trim();
if (
resourceId &&
!resourceId.startsWith('local-') &&
!resourceId.startsWith('generation-dialog:')
) {
return [
{
title,
label: reference.label,
refType: 'project-resource',
refId: resourceId,
},
];
}
const sourceAssetId = reference.sourceAssetId?.trim();
return sourceAssetId && !sourceAssetId.startsWith('upload-')
? [
{
title,
label: reference.label,
refType: 'asset',
refId: sourceAssetId,
},
]
: [];
}
export function createLayerGenerationInputReference(
title: string,
layer: CanvasLayer,
): CanvasGenerationInputReference[] {
return createGenerationInputReference(title, {
label: layer.title,
resourceId: layer.resourceId,
sourceAssetId: layer.sourceAssetId,
});
}
export function appendLimitedQuickEditReferences(
references: CharacterReferenceImage[] | undefined,
nextReferences: CharacterReferenceImage[],
@@ -751,11 +794,9 @@ export function buildImageGenerationInputs(
): CanvasGenerationInputs {
return {
fields: createGenerationInputField('生成提示词', prompt),
references: (references ?? []).map((reference, index) => ({
title: `参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
references: (references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`参考图 ${index + 1}`, reference),
),
};
}
@@ -770,7 +811,7 @@ export function buildVideoGenerationInputs(
};
return {
fields: createGenerationInputField('视频描述', prompt),
references: (references ?? []).map((reference) => {
references: (references ?? []).flatMap((reference) => {
const mediaType = reference.mediaType ?? 'image';
mediaIndexes[mediaType] += 1;
const title =
@@ -779,11 +820,7 @@ export function buildVideoGenerationInputs(
: mediaType === 'audio'
? `参考音频 ${mediaIndexes.audio}`
: `参考图 ${mediaIndexes.image}`;
return {
title,
label: reference.label,
src: reference.src,
};
return createGenerationInputReference(title, reference);
}),
};
}
@@ -821,7 +858,7 @@ export function buildSpecGenerationInputs(
reference?: CharacterReferenceImage | null,
): CanvasGenerationInputs {
const references = reference
? [{ title: '参考图', label: reference.label, src: reference.src }]
? createGenerationInputReference('参考图', reference)
: [];
if (specType === 'custom') {
return {
@@ -855,19 +892,11 @@ export function buildCharacterGenerationInputs(
fields: createGenerationInputField('角色设定', prompt),
references: [
...(specReference
? [
{
title: '角色规范',
label: specReference.label,
src: specReference.src,
},
]
? createGenerationInputReference('角色规范', specReference)
: []),
...(references ?? []).map((reference, index) => ({
title: `常规参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
...(references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`常规参考图 ${index + 1}`, reference),
),
],
};
}
@@ -879,13 +908,7 @@ export function buildUiDesignGenerationInputs(
return {
fields: createGenerationInputField('用户输入', prompt),
references: specReference
? [
{
title: '图标规范',
label: specReference.label,
src: specReference.src,
},
]
? createGenerationInputReference('图标规范', specReference)
: [],
};
}
@@ -947,11 +970,9 @@ export function buildPublicationMaterialsGenerationInputs(
normalizedGameInfo.gameDescription,
),
],
references: (references ?? []).map((reference, index) => ({
title: `宣发参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
references: (references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`宣发参考图 ${index + 1}`, reference),
),
};
}
@@ -966,13 +987,7 @@ export function buildIconGenerationInputs(
value: iconDescriptions.join('\n'),
},
],
references: [
{
title: '图标规范',
label: specReference.label,
src: specReference.src,
},
],
references: createGenerationInputReference('图标规范', specReference),
};
}
@@ -984,13 +999,7 @@ export function buildUiDesignAssetExtractionGenerationInputs(
'提取提示词',
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
),
references: [
{
title: 'UI设计图',
label: sourceLayer.title,
src: sourceLayer.objectKey?.trim() || sourceLayer.src,
},
],
references: createLayerGenerationInputReference('UI设计图', sourceLayer),
};
}
@@ -1001,13 +1010,7 @@ export function buildEditGenerationInputs(
): CanvasGenerationInputs {
return {
fields: createGenerationInputField(title, prompt),
references: [
{
title: '参考图',
label: sourceLayer.title,
src: sourceLayer.src,
},
],
references: createLayerGenerationInputReference('参考图', sourceLayer),
};
}
@@ -1024,16 +1027,13 @@ export function buildQuickEditGenerationInputs(
return {
fields: createGenerationInputField(title, prompt),
references: [
...limitedReferences.map((reference, index) => ({
title: `参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
{
title: `参考图 ${limitedReferences.length + 1}`,
label: sourceLayer.title,
src: sourceLayer.objectKey?.trim() || sourceLayer.src,
},
...limitedReferences.flatMap((reference, index) =>
createGenerationInputReference(`参考图 ${index + 1}`, reference),
),
...createLayerGenerationInputReference(
`参考图 ${limitedReferences.length + 1}`,
sourceLayer,
),
],
};
}
@@ -117,7 +117,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
{
title: '参考图',
label: '源图',
src: 'data:image/png;base64,source',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
@@ -142,6 +143,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
id: 'spec-ref',
label: '参考.png',
src: 'data:image/png;base64,ref',
resourceId: 'resource-spec-ref',
},
},
layers: [],
@@ -174,7 +176,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
{
title: '参考图',
label: '参考.png',
src: 'data:image/png;base64,ref',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
});
@@ -221,12 +224,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
id: 'spec',
label: '角色规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-character-spec',
},
characterReferences: [
{
id: 'ref-1',
label: '盔甲参考',
src: 'data:image/png;base64,armor',
resourceId: 'resource-armor',
},
],
},
@@ -260,12 +265,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
{
title: '角色规范',
label: '角色规范',
src: 'data:image/png;base64,spec',
refType: 'project-resource',
refId: 'resource-character-spec',
},
{
title: '常规参考图 1',
label: '盔甲参考',
src: 'data:image/png;base64,armor',
refType: 'project-resource',
refId: 'resource-armor',
},
],
});
@@ -287,6 +294,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
id: 'ref-1',
label: '参考图',
src: 'data:image/png;base64,ref',
resourceId: 'resource-publication-ref',
},
],
},
@@ -325,7 +333,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
{
title: '宣发参考图 1',
label: '参考图',
src: 'data:image/png;base64,ref',
refType: 'project-resource',
refId: 'resource-publication-ref',
},
],
},
@@ -490,6 +499,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
id: 'icon-spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-icon-spec',
},
iconDescriptions: [' ', '\n'],
});
@@ -512,6 +522,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
id: 'icon-spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-icon-spec',
},
iconDescriptions: ['旧返回', '旧设置'],
});
@@ -533,7 +544,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
{
title: '图标规范',
label: '图标规范',
src: 'data:image/png;base64,spec',
refType: 'project-resource',
refId: 'resource-icon-spec',
},
],
},
@@ -604,6 +616,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
mediaType: 'image',
mimeType: 'image/png',
sizeBytes: 1024,
resourceId: 'resource-video-image-ref',
},
{
id: 'video-ref',
@@ -615,6 +628,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
durationSeconds: 4,
objectKey:
'generated-character-drafts/editor/seedance-references/video/video.mp4',
resourceId: 'resource-video-ref',
},
{
id: 'audio-ref',
@@ -626,6 +640,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
durationSeconds: 3,
objectKey:
'generated-character-drafts/editor/seedance-references/audio/audio.mp3',
resourceId: 'resource-audio-ref',
},
],
},
@@ -648,9 +663,24 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
result: {
generationInputs: {
references: [
{ title: '参考图 1', label: '参考图.png' },
{ title: '参考视频 1', label: '参考视频.mp4' },
{ title: '参考音频 1', label: '参考音频.mp3' },
{
title: '参考 1',
label: '参考图.png',
refType: 'project-resource',
refId: 'resource-video-image-ref',
},
{
title: '参考视频 1',
label: '参考视频.mp4',
refType: 'project-resource',
refId: 'resource-video-ref',
},
{
title: '参考音频 1',
label: '参考音频.mp3',
refType: 'project-resource',
refId: 'resource-audio-ref',
},
],
},
},
@@ -39,7 +39,8 @@ describe('ImageCanvasMetadataModalView', () => {
{
title: '参考图',
label: '角色立绘',
src: 'data:image/png;base64,reference',
refType: 'project-resource',
refId: 'resource-reference',
},
],
},
@@ -56,6 +57,7 @@ describe('ImageCanvasMetadataModalView', () => {
expect(within(dialog).getByText('清爽游戏按钮')).toBeTruthy();
expect(within(dialog).getByText('参考图')).toBeTruthy();
expect(within(dialog).getByText('角色立绘')).toBeTruthy();
expect(within(dialog).getByText('项目资源 · resource-reference')).toBeTruthy();
expect(within(dialog).getByText('gpt-image-2')).toBeTruthy();
expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy();
expect(within(dialog).queryByText('Provider')).toBeNull();
@@ -13,6 +13,10 @@ function formatTaskIdForDisplay(taskId?: string | null) {
return numericParts?.length ? numericParts[numericParts.length - 1] : '-';
}
function formatReferenceType(refType: 'project-resource' | 'asset') {
return refType === 'project-resource' ? '项目资源' : '素材';
}
export function ImageCanvasMetadataModalView({
layer,
onClose,
@@ -57,15 +61,18 @@ export function ImageCanvasMetadataModalView({
<div className="image-canvas-editor__metadata-reference-list">
{layer.generationInputs.references.map((reference) => (
<div
key={`${reference.title}-${reference.label}-${reference.src}`}
key={`${reference.title}-${reference.label}-${reference.refType}-${reference.refId}`}
className="image-canvas-editor__metadata-reference-card"
>
<img src={reference.src} alt="" aria-hidden="true" />
<span className="image-canvas-editor__metadata-reference-copy">
<span className="image-canvas-editor__metadata-input-title">
{reference.title}
</span>
<span>{reference.label}</span>
<span className="image-canvas-editor__metadata-reference-id">
{formatReferenceType(reference.refType)} ·{' '}
{reference.refId}
</span>
</span>
</div>
))}
@@ -95,6 +95,8 @@ export function createUploadedGenerationReference({
durationSeconds,
objectKey,
assetObjectId,
resourceId,
sourceAssetId,
}: {
idPrefix: string;
index?: number;
@@ -107,6 +109,8 @@ export function createUploadedGenerationReference({
durationSeconds?: number;
objectKey?: string | null;
assetObjectId?: string | null;
resourceId?: string | null;
sourceAssetId?: string | null;
}): CharacterReferenceImage {
return {
id:
@@ -121,6 +125,8 @@ export function createUploadedGenerationReference({
durationSeconds,
objectKey,
assetObjectId,
resourceId,
sourceAssetId,
};
}
@@ -663,7 +663,8 @@ describe('useImageCanvasGenerationWorkflow', () => {
{
title: '参考图 1',
label: '画布参考图',
src: 'data:image/png;base64,source',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
@@ -739,7 +740,8 @@ describe('useImageCanvasGenerationWorkflow', () => {
{
title: '参考图 1',
label: '源图',
src: 'data:image/png;base64,source',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
@@ -230,6 +230,7 @@ export function useImageCanvasProjectPersistence({
);
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
project.layers,
resourcesById,
);
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById))
@@ -23,6 +23,7 @@ import type {
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
const createEditorAssetMock = vi.hoisted(() => vi.fn());
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const uploadEditorSeedanceReferenceFileMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
let createdMediaElements: HTMLMediaElement[] = [];
@@ -34,6 +35,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
return {
...actual,
createEditorAsset: createEditorAssetMock,
createEditorProjectResource: createEditorProjectResourceMock,
};
});
@@ -112,10 +114,12 @@ function UploadWorkflowHarness({
canAccessProtectedData = true,
openEditorLoginModal = vi.fn(),
activeTool = 'select',
projectId = 'project-1',
}: {
canAccessProtectedData?: boolean;
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
activeTool?: CanvasTool;
projectId?: string | null;
}) {
const [assetFolders, setAssetFolders] = useState<EditorAssetFolder[]>([
createDefaultFolder(),
@@ -132,6 +136,7 @@ function UploadWorkflowHarness({
const workflow = useImageCanvasUploadWorkflow({
canAccessProtectedData,
openEditorLoginModal,
projectId,
assetFolders,
activeUploadFolderId: 'project',
canvasSize: { width: 900, height: 640 },
@@ -189,7 +194,7 @@ function UploadWorkflowHarness({
<span data-testid="selected-layer">{selectedLayerId ?? '-'}</span>
<span data-testid="dialog">
{generateDialog
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}:${generateDialog.generationReferences?.length ?? 0}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationReferences?.map((reference) => `${reference.mediaType ?? 'image'}:${reference.src}:${reference.objectKey ?? '-'}`).join(',') || '-'}`
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}:${generateDialog.generationReferences?.length ?? 0}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationReferences?.map((reference) => `${reference.mediaType ?? 'image'}:${reference.src}:${reference.objectKey ?? '-'}:${reference.resourceId ?? '-'}:${reference.sourceAssetId ?? '-'}`).join(',') || '-'}`
: '-'}
</span>
<button
@@ -333,6 +338,19 @@ describe('useImageCanvasUploadWorkflow', () => {
objectKey: 'object-key-uploaded',
assetObjectId: 'asset-object-uploaded',
}));
createEditorProjectResourceMock.mockImplementation(
async (projectId, input) => ({
resourceId: `resource-${input.objectKey ?? input.imageSrc.slice(0, 8)}`,
projectId,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: input.sourceType,
objectKey: input.objectKey,
assetObjectId: input.assetObjectId,
durationSeconds: input.durationSeconds,
}),
);
uploadEditorSeedanceReferenceFileMock.mockImplementation(
async (file: File, mediaType: 'video' | 'audio') => ({
src: `https://signed.example.com/${mediaType}/${file.name}`,
@@ -427,7 +445,7 @@ describe('useImageCanvasUploadWorkflow', () => {
expect(clickUploadInput).not.toHaveBeenCalled();
});
it('keeps generation reference uploads local and opens the file picker without login', () => {
it('opens login instead of the reference file picker when protected data is unavailable', () => {
const openEditorLoginModal = vi.fn();
render(
<UploadWorkflowHarness
@@ -442,8 +460,8 @@ describe('useImageCanvasUploadWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '请求角色规范上传' }));
expect(openEditorLoginModal).not.toHaveBeenCalled();
expect(clickUploadInput).toHaveBeenCalledTimes(1);
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
expect(clickUploadInput).not.toHaveBeenCalled();
});
it('creates an uploading asset card, adds a canvas layer, keeps the sidebar, and patches the layer with the persisted asset id', async () => {
@@ -567,6 +585,15 @@ describe('useImageCanvasUploadWorkflow', () => {
'character:idle:角色规范.png:0:-:-',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc: 'data:image/png;base64,aW1hZ2U=',
width: 420,
height: 315,
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
@@ -586,6 +613,15 @@ describe('useImageCanvasUploadWorkflow', () => {
'generate:idle:-:0:-:-:1:普通参考.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc: 'data:image/png;base64,aW1hZ2U=',
width: 420,
height: 315,
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
@@ -605,6 +641,15 @@ describe('useImageCanvasUploadWorkflow', () => {
'spec:idle:-:0:-:UI参考.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc: 'data:image/png;base64,aW1hZ2U=',
width: 420,
height: 315,
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
@@ -632,6 +677,19 @@ describe('useImageCanvasUploadWorkflow', () => {
'video:idle:-:0:-:-:1:动作参考.mp4:video:https://signed.example.com/video/动作参考.mp4:generated-character-drafts/editor/seedance-references/video/动作参考.mp4',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc: 'https://signed.example.com/video/动作参考.mp4',
objectKey:
'generated-character-drafts/editor/seedance-references/video/动作参考.mp4',
assetObjectId: 'assetobj-video',
width: 560,
height: 315,
sourceType: 'uploaded',
durationSeconds: 4,
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).not.toContain('data:video');
});
@@ -659,6 +717,19 @@ describe('useImageCanvasUploadWorkflow', () => {
'video:idle:-:0:-:-:1:旁白.mp3:audio:https://signed.example.com/audio/旁白.mp3:generated-character-drafts/editor/seedance-references/audio/旁白.mp3',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc: 'https://signed.example.com/audio/旁白.mp3',
objectKey:
'generated-character-drafts/editor/seedance-references/audio/旁白.mp3',
assetObjectId: 'assetobj-audio',
width: 420,
height: 120,
sourceType: 'uploaded',
durationSeconds: 4,
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).not.toContain('data:audio');
});
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -7405,7 +7405,6 @@ button.image-canvas-editor__reference-chip:disabled {
.image-canvas-editor__metadata-reference-card {
display: grid;
grid-template-columns: 2.4rem minmax(0, 1fr);
align-items: center;
gap: 0.5rem;
width: 100%;
@@ -7415,19 +7414,18 @@ button.image-canvas-editor__reference-chip:disabled {
padding: 0.38rem;
}
.image-canvas-editor__metadata-reference-card img {
width: 2.4rem;
height: 2.4rem;
border-radius: 0.48rem;
object-fit: cover;
}
.image-canvas-editor__metadata-reference-copy {
display: grid;
min-width: 0;
gap: 0.12rem;
}
.image-canvas-editor__metadata-reference-id {
color: #64748b;
font-size: 0.72rem;
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.image-canvas-editor {
flex-direction: column;
@@ -37,7 +37,8 @@ export type EditorAssetGenerationInputField = {
export type EditorAssetGenerationInputReference = {
title: string;
label: string;
src: string;
refType: 'project-resource' | 'asset';
refId: string;
};
export type EditorAssetGenerationInputs = {