完善创作主页精选素材预览
新增精选素材分组模型,按素材包、角色、UI、音乐、美宣等分类组合展示素材 补齐素材卡片和预览弹窗中的提示词、作者与生成总成本展示 新增接近全屏的素材预览弹窗,支持底部外置缩略图导航切换 让私有图片、视频和音频预览携带 objectKey 换签读取 补充创作主页素材展示文档和聚焦测试
This commit is contained in:
@@ -8,12 +8,14 @@ type ResolvedAssetImageProps = Omit<
|
||||
'src'
|
||||
> & {
|
||||
src?: string | null;
|
||||
objectKey?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetImage({
|
||||
src,
|
||||
objectKey,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
refreshKey,
|
||||
@@ -21,6 +23,7 @@ export function ResolvedAssetImage({
|
||||
...rest
|
||||
}: ResolvedAssetImageProps) {
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(src, {
|
||||
objectKey,
|
||||
refreshKey,
|
||||
});
|
||||
const normalizedSource = src?.trim() ?? '';
|
||||
|
||||
@@ -7,17 +7,20 @@ type ResolvedAssetVideoProps = Omit<
|
||||
'src'
|
||||
> & {
|
||||
src?: string | null;
|
||||
objectKey?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetVideo({
|
||||
src,
|
||||
objectKey,
|
||||
fallbackSrc,
|
||||
refreshKey,
|
||||
...rest
|
||||
}: ResolvedAssetVideoProps) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
|
||||
objectKey,
|
||||
refreshKey,
|
||||
});
|
||||
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
|
||||
|
||||
@@ -258,6 +258,80 @@ describe('CreationLandingView', () => {
|
||||
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens a fullscreen asset preview modal with thumbnail navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-character',
|
||||
folderId: 'folder-1',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-animation',
|
||||
folderId: 'folder-1',
|
||||
label: '待机动画',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'idle animation prompt',
|
||||
actualPrompt: null,
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'ark',
|
||||
taskId: 'task-2',
|
||||
assetKind: 'character-animation',
|
||||
priceMudPoints: 40,
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '角色英雄',
|
||||
refType: 'asset',
|
||||
refId: 'asset-character',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '角色' }));
|
||||
await user.click(await screen.findByText('角色英雄'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '角色英雄' });
|
||||
expect(within(dialog).getByText('生成总成本')).toBeTruthy();
|
||||
expect(within(dialog).getByText('60泥点')).toBeTruthy();
|
||||
expect(within(dialog).getByText('创作者A')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByLabelText('素材缩略图导航'),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '查看待机动画' }));
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '查看待机动画' }).getAttribute(
|
||||
'aria-current',
|
||||
),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('uses Taonier featured as the account asset waterfall instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
EditorAssetLibrarySnapshot,
|
||||
EditorAssetSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { buildCreationShowcaseItems } from './creationShowcaseModel';
|
||||
|
||||
function createAsset(
|
||||
overrides: Partial<EditorAssetSnapshot> & Record<string, unknown>,
|
||||
): EditorAssetSnapshot {
|
||||
return {
|
||||
assetId: 'asset-1',
|
||||
folderId: 'folder-1',
|
||||
label: '素材',
|
||||
imageSrc: 'data:image/png;base64,asset',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: null,
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createLibrary(assets: EditorAssetSnapshot[]): EditorAssetLibrarySnapshot {
|
||||
return {
|
||||
folders: [],
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
describe('creationShowcaseModel', () => {
|
||||
it('groups pack items by the same referenced spec image and sums known cost', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'spec-1',
|
||||
label: '角色规范图',
|
||||
assetKind: 'spec',
|
||||
prompt: '规范提示词',
|
||||
priceMudPoints: 5,
|
||||
authorName: '创作者A',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
label: '角色主图',
|
||||
assetKind: 'character',
|
||||
priceMudPoints: 12,
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '角色提示词' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
label: '角色规范图',
|
||||
refType: 'asset',
|
||||
refId: 'spec-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('角色规范图');
|
||||
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
|
||||
'角色规范图',
|
||||
'角色主图',
|
||||
]);
|
||||
expect(items[0]?.prompt).toBe('规范提示词');
|
||||
expect(items[0]?.author).toBe('创作者A');
|
||||
expect(items[0]?.cost).toBe('17泥点');
|
||||
});
|
||||
|
||||
it('groups assets when references point to the source project resource', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'spec-1',
|
||||
label: '项目规范图',
|
||||
assetKind: 'spec',
|
||||
sourceResourceId: 'resource-spec-1',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
label: '项目角色图',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '项目角色提示词' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
label: '项目规范图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-spec-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
|
||||
'项目规范图',
|
||||
'项目角色图',
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups character animations under the referenced character image', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
assetKind: 'character',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'asset',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('游戏角色图');
|
||||
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
|
||||
'游戏角色图',
|
||||
'待机动画',
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups UI design extraction assets under the referenced UI image', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'ui',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'ui-design-1',
|
||||
label: '游戏UI图',
|
||||
assetKind: 'ui-design',
|
||||
prompt: 'UI提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-sheet-1',
|
||||
label: 'UI子素材图集',
|
||||
assetKind: 'icon-spritesheet',
|
||||
generationInputs: {
|
||||
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
|
||||
references: [
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-1',
|
||||
label: '开始按钮',
|
||||
assetKind: 'icon',
|
||||
generationInputs: {
|
||||
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
|
||||
references: [
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('游戏UI图');
|
||||
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
|
||||
'游戏UI图',
|
||||
'UI子素材图集',
|
||||
'开始按钮',
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts a spritesheet task only once when UI children share the same task', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'ui',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'ui-design-1',
|
||||
label: '游戏UI图',
|
||||
assetKind: 'ui-design',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
taskId: 'task-ui-design',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-sheet-1',
|
||||
label: 'UI子素材图集',
|
||||
assetKind: 'icon-spritesheet',
|
||||
model: 'gpt-image-2',
|
||||
taskId: 'task-ui-extract',
|
||||
generationInputs: {
|
||||
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
|
||||
references: [
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-1',
|
||||
label: '开始按钮',
|
||||
assetKind: 'icon',
|
||||
model: 'gpt-image-2',
|
||||
taskId: 'task-ui-extract',
|
||||
generationInputs: {
|
||||
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
|
||||
references: [
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe('32泥点');
|
||||
});
|
||||
|
||||
it('keeps music assets as audio preview items', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'music',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'music-1',
|
||||
label: '背景音乐',
|
||||
imageSrc: '/generated-editor-audios/music.mp3',
|
||||
assetKind: 'background-music',
|
||||
prompt: '舒缓背景音乐',
|
||||
priceMudPoints: '5',
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
fallbackAuthorName: '当前用户',
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.previews[0]?.mediaType).toBe('audio');
|
||||
expect(items[0]?.author).toBe('当前用户');
|
||||
expect(items[0]?.cost).toBe('5泥点');
|
||||
});
|
||||
|
||||
it('keeps character animation videos out of the marketing tab', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'marketing',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
label: '角色动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'poster-1',
|
||||
label: '上线美宣视频',
|
||||
assetKind: 'publication-material',
|
||||
imageSrc: '/generated-editor-videos/poster.mp4',
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('上线美宣视频');
|
||||
expect(items[0]?.previews[0]?.mediaType).toBe('video');
|
||||
});
|
||||
|
||||
it('infers costs from generated asset kinds when stored price is absent', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
imageSrc: '/generated-editor-assets/character.png',
|
||||
objectKey: 'generated-editor-assets/character.png',
|
||||
assetKind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
objectKey: 'generated-editor-videos/animation.mp4',
|
||||
model: 'seedance2.0-fast',
|
||||
durationSeconds: 6,
|
||||
generationInputs: {
|
||||
fields: [{ title: '清晰度', value: '720p' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'asset',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe('140泥点');
|
||||
expect(items[0]?.previews[0]?.objectKey).toBe(
|
||||
'generated-editor-assets/character.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user