改为行引用保存画布参考图元数据
生成输入参考图只保存 refType/refId 行引用 生成器布局快照只保存参考图行指针并在加载时恢复临时源 上传参考图优先创建项目资源行并回写 resourceId 元数据弹窗改为展示参考图行引用信息 更新图片画布技术文档和项目决策记录
This commit is contained in:
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user