收紧画布素材导出元数据

只导出信息面板可见的素材元数据

过滤导出生成输入中的内置提示词

补充导出测试和图片画布文档约束
This commit is contained in:
2026-06-23 19:23:46 +08:00
parent afc2f03497
commit 2e60e43e17
8 changed files with 274 additions and 122 deletions
@@ -111,6 +111,7 @@
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;仅在无法解析尺寸时才使用上传兜底尺寸。
- 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `<video controls preload="metadata" playsInline>` 播放,外层图层仍承接选择和拖拽语义;音频图层播放前继续通过 `/api/assets/read-url` 换签。画布素材导出按 `mediaType` 保留真实媒体格式:图片进入 `images/`,音频 / 视频进入 `media/`,文件扩展名从响应 MIME、`objectKey` 或源 URL 推断,不得把音频 / 视频导出成 PNG。
- 画布素材 ZIP 的 `metadata.json` 只保存前端信息弹窗和导出文件列表可见的展示快照:项目标题、导出时间、图层标题、文件路径、类型、生成输入、模型显示名、Task 短 ID、Object 显示值、Resolution / 时长和导出错误。导出的生成输入只保留用户实际填写或选择的内容;系统默认兜底提示词、固定工作流提示词、内置图标描述、UI 提取素材固定提示词等内置提示词即使存在于历史 `generationInputs`,也不得写入导出元数据。不得把 `projectId``layerId``resourceId``sourceAssetId``sourceResourceId`、原始 `prompt` / `actualPrompt` / `provider` 或画布坐标、锁定、隐藏等布局状态写入导出元数据;Object 字段仅沿用信息弹窗当前可见值。
- `生成角色动作` 的完成结果同样按视频素材处理:图层主 `src` 使用后端返回的 `previewVideoPath``mediaType` 固定为 `video``assetKind` 固定为 `character-animation`,首个透明帧只写入 `thumbnailSrc` 供 poster 和后续动作源使用。下载和 ZIP 导出必须因此得到 mp4/webm/mov 等真实视频文件,不能回退为首帧 PNG;后端抽帧、绿幕去背和帧素材落盘流程仍完整保留。
- 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
- 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
@@ -453,44 +453,24 @@ export type CanvasAssetExportImage = {
};
export type CanvasAssetExportMetadata = {
projectId: string | null;
projectTitle: string;
exportedAt: string;
layers: Array<{
layerId: string;
title: string;
file: string | null;
mediaType?: CanvasMediaType | null;
sourceType: CanvasSourceType;
prompt?: string | null;
actualPrompt?: string | null;
model?: string | null;
provider?: string | null;
taskId?: string | null;
objectKey?: string | null;
assetObjectId?: string | null;
sourceResourceId?: string | null;
sourceAssetId?: string | null;
exportError?: string;
canvas: {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
zIndex: number;
groupId?: string | null;
hidden?: boolean;
locked?: boolean;
flipX?: boolean;
flipY?: boolean;
visible: {
type: string;
generationInputs: CanvasGenerationInputs | null;
model: string;
task: string;
object: string;
resolution?: string;
duration?: string;
};
exportError?: string;
}>;
failedImages: Array<{
key: string;
title: string;
src: string;
error: string;
}>;
};
@@ -362,9 +362,15 @@ describe('ImageCanvasEditorView', () => {
sourceType: 'generated',
sourceResourceId: 'resource-generated',
prompt: '明亮主视觉',
actualPrompt: '后端拼接提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-1',
assetObjectId: 'asset-object-visible',
generationInputs: {
fields: [{ title: '生成提示词', value: '明亮主视觉' }],
references: [],
},
},
{
layerId: 'layer-failed',
@@ -460,19 +466,33 @@ describe('ImageCanvasEditorView', () => {
const metadata = JSON.parse(
await readZipText(zip, '导出项目-画布素材/metadata.json'),
);
expect(metadata.projectId).toBe('editor-project-export');
expect(metadata.projectId).toBeUndefined();
expect(metadata.layers).toHaveLength(4);
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
expect(metadata.layers[0].canvas.hidden).toBe(true);
expect(metadata.layers[0].canvas.locked).toBe(true);
expect(metadata.layers[0].canvas.flipX).toBe(true);
expect(metadata.layers[0].canvas.groupId).toBe('group-a');
expect(metadata.layers[2].sourceType).toBe('generated');
expect(metadata.layers[2].prompt).toBe('明亮主视觉');
expect(metadata.layers[0].canvas).toBeUndefined();
expect(metadata.layers[0].visible.type).toBe('上传图片');
expect(metadata.layers[2].visible).toEqual({
type: '生成图片',
generationInputs: {
fields: [{ title: '生成提示词', value: '明亮主视觉' }],
references: [],
},
model: 'gpt-image-2',
task: '1',
object: 'asset-object-visible',
resolution: '1024 x 1024 px',
});
expect(metadata.layers[2].sourceType).toBeUndefined();
expect(metadata.layers[2].prompt).toBeUndefined();
expect(metadata.layers[2].actualPrompt).toBeUndefined();
expect(metadata.layers[2].provider).toBeUndefined();
expect(metadata.layers[2].sourceResourceId).toBeUndefined();
expect(metadata.layers[3].file).toBeNull();
expect(metadata.layers[3].exportError).toContain('404');
expect(metadata.failedImages).toHaveLength(1);
expect(metadata.failedImages).toEqual([
{ title: '失败图', error: expect.stringContaining('404') },
]);
expect(
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
).toContain('失败素材数量:1');
@@ -11,6 +11,10 @@ import {
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
DEFAULT_ICON_DESCRIPTIONS,
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
} from './ImageCanvasGenerationModel';
describe('ImageCanvasExportModel', () => {
it('normalizes export file names and dates', () => {
@@ -100,35 +104,67 @@ describe('ImageCanvasExportModel', () => {
expect(await blob.text()).toBe('Hello');
expect(buildLayerExportMetadata(buildLayer(), 'images/001-layer.png')).toEqual({
layerId: 'layer-1',
title: '导出图层',
file: 'images/001-layer.png',
mediaType: 'image',
sourceType: 'generated',
prompt: '生成提示',
actualPrompt: '实际提示',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-1',
objectKey: 'generated/layer.png',
assetObjectId: undefined,
sourceResourceId: 'source-resource',
sourceAssetId: 'asset-1',
exportError: undefined,
canvas: {
x: 10,
y: 20,
width: 512,
height: 512,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 3,
groupId: 'group-1',
hidden: true,
locked: false,
flipX: true,
flipY: false,
visible: {
type: '生成图片',
generationInputs: {
fields: [{ title: '生成提示', value: '清爽游戏按钮' }],
references: [
{
title: '参考图',
label: '角色立绘',
refType: 'project-resource',
refId: 'resource-reference',
},
],
},
model: 'gpt-image-2',
task: '1',
object: 'generated/layer.png',
resolution: '1024 x 1024 px',
},
exportError: undefined,
});
});
it('filters built-in prompts from exported visible generation inputs', () => {
const metadata = buildLayerExportMetadata(
buildLayer({
generationInputs: {
fields: [
{ title: '生成提示词', value: 'AI 生成图片' },
{ title: '修改要求', value: '修改当前图片' },
{ title: 'prompt', value: '游戏音效' },
{
title: 'gpt_description_prompt',
value: '游戏背景音乐',
},
{
title: '提取提示词',
value: UI_DESIGN_ASSET_EXTRACTION_PROMPT,
},
{
title: '素材描述',
value: DEFAULT_ICON_DESCRIPTIONS.join('\n'),
},
{ title: '用户输入', value: '用户填写的视觉要求' },
{ title: '游戏分类', value: '游戏音效' },
{ title: 'duration', value: '5秒' },
],
references: [],
},
}),
'images/001-layer.png',
);
expect(metadata.visible.generationInputs).toEqual({
fields: [
{ title: '用户输入', value: '用户填写的视觉要求' },
{ title: '游戏分类', value: '游戏音效' },
{ title: 'duration', value: '5秒' },
],
references: [],
});
});
@@ -203,6 +239,17 @@ function buildLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
objectKey: 'generated/layer.png',
sourceResourceId: 'source-resource',
sourceAssetId: 'asset-1',
generationInputs: {
fields: [{ title: '生成提示词', value: '清爽游戏按钮' }],
references: [
{
title: '参考图',
label: '角色立绘',
refType: 'project-resource',
refId: 'resource-reference',
},
],
},
groupId: 'group-1',
hidden: true,
locked: false,
@@ -1,8 +1,16 @@
import type {
CanvasAssetExportMetadata,
CanvasGenerationInputField,
CanvasMediaType,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
DEFAULT_ICON_DESCRIPTIONS,
formatLayerImageType,
getEditorImageModelDisplayName,
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
} from './ImageCanvasGenerationModel';
import { formatCanvasDurationMetric } from './ImageCanvasMediaModel';
import {
getSignedAssetReadUrl,
resolveAssetReadUrl,
@@ -232,40 +240,91 @@ export async function blobToUint8Array(blob: Blob) {
});
}
export function formatTaskIdForDisplay(taskId?: string | null) {
const numericParts = taskId?.match(/\d+/gu);
return numericParts?.length
? (numericParts[numericParts.length - 1] ?? '-')
: '-';
}
const BUILT_IN_GENERATION_INPUT_TITLES = new Set(['提取提示词']);
const BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE = new Map(
Object.entries({
生成提示词: new Set(['AI 生成图片']),
修改要求: new Set(['修改当前图片']),
快速编辑提示词: new Set(['修改当前图片']),
重绘提示词: new Set(['修改当前图片']),
prompt: new Set(['游戏音效']),
gpt_description_prompt: new Set(['游戏背景音乐']),
素材描述: new Set([DEFAULT_ICON_DESCRIPTIONS.join('\n')]),
}),
);
function isBuiltInGenerationInputField(field: CanvasGenerationInputField) {
const normalizedTitle = field.title.trim();
const normalizedValue = field.value.trim();
const builtInValues =
BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE.get(normalizedTitle);
return (
BUILT_IN_GENERATION_INPUT_TITLES.has(normalizedTitle) ||
normalizedValue === UI_DESIGN_ASSET_EXTRACTION_PROMPT ||
Boolean(builtInValues?.has(normalizedValue))
);
}
function buildVisibleGenerationInputs(layer: CanvasLayer) {
const fields =
layer.generationInputs?.fields
.filter((field) => !isBuiltInGenerationInputField(field))
.map((field) => ({
title: field.title,
value: field.value,
})) ?? [];
const references =
layer.generationInputs?.references.map((reference) => ({
title: reference.title,
label: reference.label,
refType: reference.refType,
refId: reference.refId,
})) ?? [];
return fields.length || references.length ? { fields, references } : null;
}
export function buildLayerVisibleExportMetadata(layer: CanvasLayer) {
const base = {
type: formatLayerImageType(layer),
generationInputs: buildVisibleGenerationInputs(layer),
model: layer.model ? getEditorImageModelDisplayName(layer.model) : '-',
task: formatTaskIdForDisplay(layer.taskId),
object: layer.objectKey ?? layer.assetObjectId ?? '-',
};
if (layer.mediaType === 'audio') {
return {
...base,
duration: formatCanvasDurationMetric(layer.durationSeconds).replace(
/^\s*/u,
'',
),
};
}
return {
...base,
resolution: `${layer.originalWidth} x ${layer.originalHeight} px`,
};
}
export function buildLayerExportMetadata(
layer: CanvasLayer,
file: string | null,
exportError?: string,
): CanvasAssetExportMetadata['layers'][number] {
return {
layerId: layer.id,
title: layer.title,
file,
mediaType: layer.mediaType ?? 'image',
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
visible: buildLayerVisibleExportMetadata(layer),
exportError,
canvas: {
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
originalWidth: layer.originalWidth,
originalHeight: layer.originalHeight,
zIndex: layer.zIndex,
groupId: layer.groupId,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
flipY: layer.flipY,
},
};
}
@@ -1,5 +1,6 @@
import { UnifiedModal } from '../common/UnifiedModal';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { formatTaskIdForDisplay } from './ImageCanvasExportModel';
import {
formatLayerImageType,
getEditorImageModelDisplayName,
@@ -11,11 +12,6 @@ export type ImageCanvasMetadataModalViewProps = {
onClose: () => void;
};
function formatTaskIdForDisplay(taskId?: string | null) {
const numericParts = taskId?.match(/\d+/gu);
return numericParts?.length ? numericParts[numericParts.length - 1] : '-';
}
function formatReferenceType(refType: 'project-resource' | 'asset') {
return refType === 'project-resource' ? '项目资源' : '素材';
}
@@ -128,6 +128,26 @@ describe('useImageCanvasAssetExportWorkflow', () => {
src: '/generated-ok.png',
sourceType: 'generated',
prompt: '明亮主视觉',
actualPrompt: '后端拼接提示词',
provider: 'VectorEngine',
taskId: 'task-987',
assetObjectId: 'asset-object-visible',
sourceResourceId: 'resource-generated',
sourceAssetId: 'asset-generated',
generationInputs: {
fields: [
{ title: '生成提示词', value: '明亮主视觉' },
{ title: '提取提示词', value: '系统固定提取提示词' },
],
references: [
{
title: '参考图',
label: '角色立绘',
refType: 'project-resource',
refId: 'resource-reference',
},
],
},
zIndex: 3,
}),
createLayer('video', {
@@ -209,27 +229,63 @@ describe('useImageCanvasAssetExportWorkflow', () => {
const metadata = JSON.parse(
await readZipText(zip, '导出项目-画布素材/metadata.json'),
);
expect(metadata.projectId).toBe('project-export');
expect(metadata.projectId).toBeUndefined();
expect(metadata.layers).toHaveLength(7);
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
expect(metadata.layers[2].mediaType).toBe('image');
expect(metadata.layers[0].canvas.hidden).toBe(true);
expect(metadata.layers[0].canvas.locked).toBe(true);
expect(metadata.layers[0].canvas.flipX).toBe(true);
expect(metadata.layers[2].sourceType).toBe('generated');
expect(metadata.layers[2].prompt).toBe('明亮主视觉');
expect(metadata.layers[0].canvas).toBeUndefined();
expect(metadata.layers[0].visible.type).toBe('上传图片');
expect(metadata.layers[0].visible.resolution).toBe('100 x 80 px');
expect(metadata.layers[2].visible.type).toBe('生成图片');
expect(metadata.layers[2].visible.model).toBe('-');
expect(metadata.layers[2].visible.task).toBe('987');
expect(metadata.layers[2].visible.object).toBe('asset-object-visible');
expect(metadata.layers[2].visible.generationInputs).toEqual({
fields: [{ title: '生成提示词', value: '明亮主视觉' }],
references: [
{
title: '参考图',
label: '角色立绘',
refType: 'project-resource',
refId: 'resource-reference',
},
],
});
expect(JSON.stringify(metadata)).not.toContain('系统固定提取提示词');
expect(metadata.layers[2].sourceType).toBeUndefined();
expect(metadata.layers[2].prompt).toBeUndefined();
expect(metadata.layers[2].actualPrompt).toBeUndefined();
expect(metadata.layers[2].provider).toBeUndefined();
expect(metadata.layers[2].sourceResourceId).toBeUndefined();
expect(metadata.layers[2].sourceAssetId).toBeUndefined();
expect(metadata.layers[3].file).toBe('media/003-生成视频.mp4');
expect(metadata.layers[3].mediaType).toBe('video');
expect(metadata.layers[3].visible.type).toBe('生成视频');
expect(metadata.layers[4].file).toBe('media/004-胜利音效.wav');
expect(metadata.layers[4].mediaType).toBe('audio');
expect(metadata.layers[4].visible.type).toBe('生成音频');
expect(metadata.layers[4].visible.duration).toBe('--:--');
expect(metadata.layers[5].file).toBe(
'sequences/005-角色动作/manifest.txt',
);
expect(metadata.layers[5].mediaType).toBe('image-sequence');
expect(metadata.layers[5].visible.type).toBe('角色动作序列帧');
const sequenceMetadata = JSON.parse(
await readZipText(
zip,
'导出项目-画布素材/sequences/005-角色动作/metadata.json',
),
);
expect(sequenceMetadata.visible.type).toBe('角色动作序列帧');
expect(sequenceMetadata.taskId).toBeUndefined();
expect(sequenceMetadata.previewVideoPath).toBeUndefined();
expect(sequenceMetadata.failedFrames).toEqual([]);
expect(metadata.layers[6].file).toBeNull();
expect(metadata.layers[6].exportError).toContain('404');
expect(metadata.failedImages).toHaveLength(1);
expect(metadata.failedImages[0]).toEqual({
title: '失败图',
error: expect.stringContaining('404'),
});
expect(metadata.failedImages[0].key).toBeUndefined();
expect(metadata.failedImages[0].src).toBeUndefined();
expect(
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
).toContain('失败素材数量:1');
@@ -348,6 +404,13 @@ describe('useImageCanvasAssetExportWorkflow', () => {
expect(zip.file('frames/frame-01.png')).toBeTruthy();
expect(zip.file('frames/frame-02.png')).toBeTruthy();
expect(await readZipText(zip, 'manifest.txt')).toContain('类型:序列帧');
const metadata = JSON.parse(await readZipText(zip, 'metadata.json'));
expect(metadata.title).toBe('角色动作/跑步');
expect(metadata.visible.type).toBe('角色动作序列帧');
expect(metadata.visible.resolution).toBe('100 x 80 px');
expect(metadata.taskId).toBeUndefined();
expect(metadata.previewVideoPath).toBeUndefined();
expect(metadata.failedFrames).toEqual([]);
} finally {
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
@@ -9,6 +9,7 @@ import type {
import {
blobToUint8Array,
buildLayerExportMetadata,
buildLayerVisibleExportMetadata,
formatExportDate,
getImageSequenceFrameFileName,
getLayerAssetExtensionFromTypeOrSrc,
@@ -57,7 +58,6 @@ async function buildImageSequenceZip(layer: CanvasLayer) {
const framesFolder = zip.folder('frames') ?? zip;
const failedFrames: Array<{
frameIndex: number;
imageSrc: string;
error: string;
}> = [];
let successCount = 0;
@@ -73,7 +73,6 @@ async function buildImageSequenceZip(layer: CanvasLayer) {
} catch (error) {
failedFrames.push({
frameIndex: frame.frameIndex || index + 1,
imageSrc: frame.imageSrc,
error: error instanceof Error ? error.message : '序列帧读取失败',
});
}
@@ -88,12 +87,9 @@ async function buildImageSequenceZip(layer: CanvasLayer) {
JSON.stringify(
{
title: layer.title,
taskId: layer.taskId ?? null,
mediaType: 'image-sequence',
visible: buildLayerVisibleExportMetadata(layer),
frameCount: frames.length,
exportedFrameCount: successCount,
durationSeconds: layer.durationSeconds ?? null,
previewVideoPath: layer.previewVideoPath ?? null,
failedFrames,
},
null,
@@ -118,7 +114,6 @@ async function buildImageSequenceZip(layer: CanvasLayer) {
export function useImageCanvasAssetExportWorkflow({
layers,
projectId,
projectTitle,
}: UseImageCanvasAssetExportWorkflowOptions) {
const [assetExportStatus, setAssetExportStatus] =
@@ -224,7 +219,6 @@ export function useImageCanvasAssetExportWorkflow({
sequenceFolder.folder('frames') ?? sequenceFolder;
const failedFrames: Array<{
frameIndex: number;
imageSrc: string;
error: string;
}> = [];
let successCount = 0;
@@ -247,7 +241,6 @@ export function useImageCanvasAssetExportWorkflow({
} catch (error) {
failedFrames.push({
frameIndex: frame.frameIndex || frameArrayIndex + 1,
imageSrc: frame.imageSrc,
error:
error instanceof Error ? error.message : '序列帧读取失败',
});
@@ -262,14 +255,10 @@ export function useImageCanvasAssetExportWorkflow({
'metadata.json',
JSON.stringify(
{
layerId: layer.id,
title: layer.title,
mediaType: 'image-sequence',
taskId: layer.taskId ?? null,
visible: buildLayerVisibleExportMetadata(layer),
frameCount: frames.length,
exportedFrameCount: successCount,
durationSeconds: layer.durationSeconds ?? null,
previewVideoPath: layer.previewVideoPath ?? null,
failedFrames,
},
null,
@@ -352,7 +341,6 @@ export function useImageCanvasAssetExportWorkflow({
}
const metadata: CanvasAssetExportMetadata = {
projectId,
projectTitle,
exportedAt: exportedAt.toISOString(),
layers: exportableLayers.map((layer) => {
@@ -364,9 +352,7 @@ export function useImageCanvasAssetExportWorkflow({
);
}),
failedImages: failedImages.map((image) => ({
key: image.key,
title: image.layer.title,
src: image.layer.src,
error: image.error ?? '素材读取失败',
})),
};
@@ -415,7 +401,7 @@ export function useImageCanvasAssetExportWorkflow({
} finally {
setIsExportingAssets(false);
}
}, [isExportingAssets, layers, projectId, projectTitle]);
}, [isExportingAssets, layers, projectTitle]);
return {
assetExportStatus,