限制创作主页精选素材来源
只从项目内画布生成资源构建陶泥儿精选 移除公开作品和账号素材库精选回退 补充精选数据边界测试和项目记忆文档
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-22 创作主页精选只展示项目内画布生成资源
|
||||
|
||||
- 背景:`/creation` 的 `陶泥儿精选` 曾从账号级素材库读取,并在素材为空时用公开作品图片补充,导致新创作页出现不属于任何当前图片画布项目的素材。
|
||||
- 决策:`陶泥儿精选` 只从用户图片画布项目中的 `editor_project_resource` 构建素材包和素材,且只保留 `sourceType="generated"` 的画布生成资源;账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。
|
||||
- 影响范围:`/creation` 创作主页、`creationShowcaseModel`、最近项目列表资源读取、创作主页改版计划和精选素材相关测试。
|
||||
- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts` 与 `CreationLandingView.test.tsx`,确认项目内生成资源展示、上传素材过滤、公开作品不再 fallback。
|
||||
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-22 编辑器生成模型默认定价调整
|
||||
|
||||
- 背景:图片画布生成按钮、后端模型定价扣费和后台定价页需要统一使用新的模型默认泥点。
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。
|
||||
- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。
|
||||
- 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。
|
||||
- `陶泥儿精选` 展示真实账号素材瀑布流,不使用 mock 素材。
|
||||
- `陶泥儿精选` 只展示已经存在于用户图片画布项目中的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。
|
||||
- 现有 `/creation/<play>` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。
|
||||
|
||||
## 页面结构
|
||||
@@ -61,9 +61,9 @@
|
||||
|
||||
### 陶泥儿精选
|
||||
|
||||
`陶泥儿精选` 是页面底部的用户素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
|
||||
`陶泥儿精选` 是页面底部的项目内画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
|
||||
|
||||
精选内容优先使用真实账号级素材和生成结果数据;未登录或账号素材为空时,可使用已公开作品中真实用户生成的图片作为瀑布流素材来源。两种来源都不使用 mock。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。
|
||||
精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 的项目资源,代表用户通过图片画布生成并已经落入某个项目的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。
|
||||
|
||||
Tab:
|
||||
|
||||
@@ -100,9 +100,10 @@ Tab:
|
||||
|
||||
数据来源:
|
||||
|
||||
- 图片类素材优先读取账号级素材库和已落库生成结果。
|
||||
- 未登录或账号素材为空时,读取已公开作品中的真实用户生成图片作为精选素材补充,但卡片仍按素材瀑布流呈现,不展示玩法入口卡。
|
||||
- 音乐、音效、视频类素材只展示已存在的真实记录。
|
||||
- 读取用户项目列表中的 `editor_project_resource` 快照,只保留 `sourceType="generated"` 的画布生成资源。
|
||||
- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。
|
||||
- 未登录、项目为空或项目内没有画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
|
||||
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
|
||||
- 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。
|
||||
- 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。
|
||||
|
||||
@@ -113,7 +114,7 @@ Tab:
|
||||
- 最近项目继续使用编辑器项目接口:`listEditorProjects`、`createEditorProject` 和 `/editor/canvas?projectid=xxx`。
|
||||
- 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由。
|
||||
- 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。
|
||||
- `陶泥儿精选` 读取账号级素材库;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相。
|
||||
- `陶泥儿精选` 读取项目内画布生成资源;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相。
|
||||
- `/creation/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
|
||||
|
||||
## 路由与导航
|
||||
@@ -196,9 +197,9 @@ git diff --check
|
||||
- 移动视口底部不显示“创作”和“项目”。
|
||||
- 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`。
|
||||
- “我的”页没有项目快捷入口。
|
||||
- 陶泥儿精选素材瀑布流不出现 mock 素材、假作者或假泥点成本。
|
||||
- 陶泥儿精选素材瀑布流不出现账号级非项目素材、公开作品补充、上传素材、mock 素材、假作者或假泥点成本。
|
||||
- 陶泥儿精选素材卡按真实素材宽高呈现瀑布流,不退化为固定等高网格。
|
||||
- 陶泥儿精选不出现玩法入口卡;即使使用公开作品图片补充展示,也必须呈现为用户素材瀑布流。
|
||||
- 陶泥儿精选不出现玩法入口卡或公开作品图片补充展示。
|
||||
|
||||
## 非目标
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@ import type { ContextType } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { CreationLandingView } from './CreationLandingView';
|
||||
|
||||
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||
|
||||
@@ -48,7 +46,6 @@ function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
||||
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||
listEditorProjects: listEditorProjectsMock,
|
||||
createEditorProject: createEditorProjectMock,
|
||||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||||
}));
|
||||
|
||||
const projectItems = [
|
||||
@@ -72,12 +69,10 @@ const projectItems = [
|
||||
|
||||
type RenderCreationLandingOptions = {
|
||||
authValue?: AuthValue | null;
|
||||
publicGalleryEntries?: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
function renderCreationLanding({
|
||||
authValue = createAuthValue(),
|
||||
publicGalleryEntries = [],
|
||||
}: RenderCreationLandingOptions = {}) {
|
||||
const result = render(
|
||||
<AuthUiContext.Provider value={authValue}>
|
||||
@@ -85,7 +80,6 @@ function renderCreationLanding({
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
onOpenCommunity={vi.fn()}
|
||||
publicGalleryEntries={publicGalleryEntries}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
@@ -97,12 +91,10 @@ describe('CreationLandingView', () => {
|
||||
afterEach(() => {
|
||||
listEditorProjectsMock.mockReset();
|
||||
createEditorProjectMock.mockReset();
|
||||
loadEditorAssetLibraryMock.mockReset();
|
||||
});
|
||||
|
||||
it('renders the Taonier creation landing modules without external branding', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
@@ -129,7 +121,6 @@ describe('CreationLandingView', () => {
|
||||
const onOpenProject = vi.fn();
|
||||
const onOpenProjects = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
@@ -173,7 +164,6 @@ describe('CreationLandingView', () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenProject = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
createEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'created-project',
|
||||
title: '未命名画布',
|
||||
@@ -203,7 +193,6 @@ describe('CreationLandingView', () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenCommunity = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
@@ -221,30 +210,32 @@ describe('CreationLandingView', () => {
|
||||
expect(createEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders real account assets in showcase tabs without mock rows', async () => {
|
||||
it('renders generated project resources in showcase tabs without mock rows', 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',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
{
|
||||
...projectItems[0]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'project-newest',
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
@@ -260,56 +251,57 @@ describe('CreationLandingView', () => {
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
{
|
||||
...projectItems[0]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'project-newest',
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
{
|
||||
resourceId: 'resource-animation',
|
||||
projectId: 'project-newest',
|
||||
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: 'project-resource',
|
||||
refId: 'resource-character',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
@@ -332,46 +324,67 @@ describe('CreationLandingView', () => {
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('uses Taonier featured as the account asset waterfall instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-pack-a',
|
||||
folderId: 'folder-1',
|
||||
label: '素材包 A',
|
||||
imageSrc: 'data:image/png;base64,a',
|
||||
width: 512,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
prompt: '素材包提示词',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-pack-b',
|
||||
folderId: 'folder-1',
|
||||
label: '素材包 B',
|
||||
imageSrc: 'data:image/png;base64,b',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '第二组素材',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
{
|
||||
...projectItems[0]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-pack-a',
|
||||
projectId: 'project-newest',
|
||||
label: '素材包 A',
|
||||
imageSrc: 'data:image/png;base64,a',
|
||||
width: 512,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
prompt: '素材包提示词',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-uploaded',
|
||||
projectId: 'project-newest',
|
||||
label: '上传素材',
|
||||
imageSrc: 'data:image/png;base64,uploaded',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'uploaded',
|
||||
prompt: null,
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...projectItems[1]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-pack-b',
|
||||
projectId: 'project-old',
|
||||
label: '素材包 B',
|
||||
imageSrc: 'data:image/png;base64,b',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '第二组素材',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const { container } = renderCreationLanding();
|
||||
|
||||
expect(await screen.findByText('素材包 A')).toBeTruthy();
|
||||
expect(screen.getByText('素材包 B')).toBeTruthy();
|
||||
expect(screen.queryByText('上传素材')).toBeNull();
|
||||
expect(
|
||||
screen.getByLabelText('用户素材瀑布流').className,
|
||||
).toContain('creation-landing__asset-waterfall');
|
||||
@@ -383,52 +396,16 @@ describe('CreationLandingView', () => {
|
||||
expect(screen.queryByText('拼图关卡创作')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to real public user assets for the featured waterfall', async () => {
|
||||
const publicAsset = {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'work-public-asset',
|
||||
profileId: 'profile-public-asset',
|
||||
publicWorkCode: 'PZ-PUBLIC-ASSET',
|
||||
ownerUserId: 'owner-public-asset',
|
||||
authorDisplayName: '公开作者',
|
||||
worldName: '公开素材组合',
|
||||
subtitle: '公开素材副标题',
|
||||
summaryText: '公开用户生成素材提示词',
|
||||
coverImageSrc: 'https://assets.example.test/public-asset.png',
|
||||
coverSlides: [
|
||||
{
|
||||
id: 'slide-1',
|
||||
imageSrc: 'https://assets.example.test/public-asset-slide.png',
|
||||
label: '公开素材图',
|
||||
},
|
||||
],
|
||||
themeTags: ['素材包'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-18T09:00:00.000Z',
|
||||
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const { container } = renderCreationLanding({
|
||||
it('keeps the featured waterfall empty when project resources are empty', async () => {
|
||||
renderCreationLanding({
|
||||
authValue: createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
}),
|
||||
publicGalleryEntries: [publicAsset],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('公开素材组合')).toBeTruthy();
|
||||
const card = screen
|
||||
.getByText('公开素材组合')
|
||||
.closest('.creation-landing__asset-card');
|
||||
expect(card).toBeTruthy();
|
||||
expect(
|
||||
within(card as HTMLElement).getByText('公开用户生成素材提示词'),
|
||||
).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('公开作者')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.creation-landing__asset-waterfall'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('暂无素材')).toBeNull();
|
||||
expect(await screen.findByText('暂无素材')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('用户素材瀑布流')).toBeNull();
|
||||
expect(screen.queryByText('精选入口')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createEditorProject,
|
||||
listEditorProjects,
|
||||
loadEditorAssetLibrary,
|
||||
type EditorAssetLibrarySnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
@@ -20,7 +19,6 @@ import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { ProjectCanvasCover } from '../project/ProjectCanvasCover';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { ResolvedAssetVideo } from '../ResolvedAssetVideo';
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
buildCreationShowcaseItems,
|
||||
SHOWCASE_TABS,
|
||||
@@ -33,7 +31,6 @@ type CreationLandingViewProps = {
|
||||
onOpenProject: (projectId: string) => void;
|
||||
onOpenProjects: () => void;
|
||||
onOpenCommunity?: () => void;
|
||||
publicGalleryEntries?: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
type CreationFeatureItem = {
|
||||
@@ -360,7 +357,6 @@ export function CreationLandingView({
|
||||
onOpenProject,
|
||||
onOpenProjects,
|
||||
onOpenCommunity,
|
||||
publicGalleryEntries = [],
|
||||
}: CreationLandingViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
@@ -370,9 +366,9 @@ export function CreationLandingView({
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [projectError, setProjectError] = useState<string | null>(null);
|
||||
const [assetLibrary, setAssetLibrary] =
|
||||
useState<EditorAssetLibrarySnapshot | null>(null);
|
||||
const [isLoadingAssets, setIsLoadingAssets] = useState(false);
|
||||
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
|
||||
EditorProjectResourceSnapshot[]
|
||||
>([]);
|
||||
const [assetError, setAssetError] = useState<string | null>(null);
|
||||
const [activeShowcaseTab, setActiveShowcaseTab] =
|
||||
useState<ShowcaseTabId>('packs');
|
||||
@@ -383,21 +379,24 @@ export function CreationLandingView({
|
||||
const refreshRecentProjects = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
setRecentProjects([]);
|
||||
setProjectShowcaseResources([]);
|
||||
setProjectError(null);
|
||||
setAssetError(null);
|
||||
return;
|
||||
}
|
||||
setIsLoadingProjects(true);
|
||||
setProjectError(null);
|
||||
setAssetError(null);
|
||||
listEditorProjects()
|
||||
.then((projects) => {
|
||||
setRecentProjects(
|
||||
[...projects]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() -
|
||||
new Date(left.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 4),
|
||||
const sortedProjects = [...projects].sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() -
|
||||
new Date(left.updatedAt).getTime(),
|
||||
);
|
||||
setRecentProjects(sortedProjects.slice(0, 4));
|
||||
setProjectShowcaseResources(
|
||||
sortedProjects.flatMap((project) => project.resources ?? []),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -405,42 +404,20 @@ export function CreationLandingView({
|
||||
authUi?.openLoginModal(refreshRecentProjects);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '读取最近项目失败';
|
||||
setProjectError(
|
||||
error instanceof Error ? error.message : '读取最近项目失败',
|
||||
message,
|
||||
);
|
||||
setAssetError(message);
|
||||
})
|
||||
.finally(() => setIsLoadingProjects(false));
|
||||
}, [authUi, isAuthenticated]);
|
||||
|
||||
const refreshAssetLibrary = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
setAssetLibrary(null);
|
||||
setAssetError(null);
|
||||
return;
|
||||
}
|
||||
setIsLoadingAssets(true);
|
||||
setAssetError(null);
|
||||
loadEditorAssetLibrary()
|
||||
.then(setAssetLibrary)
|
||||
.catch((error: unknown) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
return;
|
||||
}
|
||||
setAssetError(
|
||||
error instanceof Error ? error.message : '读取素材展示失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingAssets(false));
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRecentProjects();
|
||||
}, [refreshRecentProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAssetLibrary();
|
||||
}, [refreshAssetLibrary]);
|
||||
|
||||
const createProject = useCallback(() => {
|
||||
if (authUi && !authUi.user) {
|
||||
authUi.openLoginModal(createProject);
|
||||
@@ -466,16 +443,14 @@ export function CreationLandingView({
|
||||
() =>
|
||||
buildCreationShowcaseItems({
|
||||
activeTab: activeShowcaseTab,
|
||||
assetLibrary,
|
||||
publicGalleryEntries,
|
||||
projectResources: projectShowcaseResources,
|
||||
fallbackAuthorName: authUi?.user?.displayName,
|
||||
}),
|
||||
[activeShowcaseTab, assetLibrary, authUi?.user?.displayName, publicGalleryEntries],
|
||||
[activeShowcaseTab, projectShowcaseResources, authUi?.user?.displayName],
|
||||
);
|
||||
|
||||
const isShowcaseLoading = isAuthenticated && isLoadingAssets;
|
||||
const showcaseEmptyText =
|
||||
publicGalleryEntries.length > 0 ? '暂无匹配素材' : '暂无素材';
|
||||
const isShowcaseLoading = isAuthenticated && isLoadingProjects;
|
||||
const showcaseEmptyText = '暂无素材';
|
||||
|
||||
const renderAssetShowcase = () => {
|
||||
if (assetError) {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
EditorAssetLibrarySnapshot,
|
||||
EditorAssetSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import { buildCreationShowcaseItems } from './creationShowcaseModel';
|
||||
|
||||
function createAsset(
|
||||
overrides: Partial<EditorAssetSnapshot> & Record<string, unknown>,
|
||||
): EditorAssetSnapshot {
|
||||
function createResource(
|
||||
overrides: Partial<EditorProjectResourceSnapshot> & Record<string, unknown>,
|
||||
): EditorProjectResourceSnapshot {
|
||||
return {
|
||||
assetId: 'asset-1',
|
||||
folderId: 'folder-1',
|
||||
resourceId: 'resource-1',
|
||||
projectId: 'project-1',
|
||||
label: '素材',
|
||||
imageSrc: 'data:image/png;base64,asset',
|
||||
width: 512,
|
||||
@@ -23,31 +20,53 @@ function createAsset(
|
||||
provider: null,
|
||||
taskId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createLibrary(assets: EditorAssetSnapshot[]): EditorAssetLibrarySnapshot {
|
||||
return {
|
||||
folders: [],
|
||||
assets,
|
||||
};
|
||||
} as EditorProjectResourceSnapshot;
|
||||
}
|
||||
|
||||
describe('creationShowcaseModel', () => {
|
||||
it('only builds showcase items from generated project resources', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'generated-1',
|
||||
label: '画布生成素材',
|
||||
sourceType: 'generated',
|
||||
prompt: '真实画布生成',
|
||||
}),
|
||||
createResource({
|
||||
resourceId: 'uploaded-1',
|
||||
label: '上传素材',
|
||||
sourceType: 'uploaded',
|
||||
prompt: '上传不应展示',
|
||||
}),
|
||||
createResource({
|
||||
resourceId: 'mock-1',
|
||||
label: 'Mock 素材',
|
||||
sourceType: 'mock_generated',
|
||||
prompt: 'Mock 不应展示',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('画布生成素材');
|
||||
});
|
||||
|
||||
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',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'spec-1',
|
||||
label: '角色规范图',
|
||||
assetKind: 'spec',
|
||||
prompt: '规范提示词',
|
||||
priceMudPoints: 5,
|
||||
authorName: '创作者A',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '角色主图',
|
||||
assetKind: 'character',
|
||||
priceMudPoints: 12,
|
||||
@@ -57,14 +76,13 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: '角色规范',
|
||||
label: '角色规范图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'spec-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -81,15 +99,15 @@ describe('creationShowcaseModel', () => {
|
||||
it('groups assets when references point to the source project resource', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'spec-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'spec-1',
|
||||
label: '项目规范图',
|
||||
assetKind: 'spec',
|
||||
sourceResourceId: 'resource-spec-1',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '项目角色图',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
@@ -104,8 +122,7 @@ describe('creationShowcaseModel', () => {
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -118,15 +135,15 @@ describe('creationShowcaseModel', () => {
|
||||
it('groups character animations under the referenced character image', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
assetKind: 'character',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
createResource({
|
||||
resourceId: 'animation-1',
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
@@ -136,14 +153,13 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -157,15 +173,15 @@ describe('creationShowcaseModel', () => {
|
||||
it('groups UI design extraction assets under the referenced UI image', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'ui',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'ui-design-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'ui-design-1',
|
||||
label: '游戏UI图',
|
||||
assetKind: 'ui-design',
|
||||
prompt: 'UI提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-sheet-1',
|
||||
createResource({
|
||||
resourceId: 'icon-sheet-1',
|
||||
label: 'UI子素材图集',
|
||||
assetKind: 'icon-spritesheet',
|
||||
generationInputs: {
|
||||
@@ -174,14 +190,14 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-1',
|
||||
createResource({
|
||||
resourceId: 'icon-1',
|
||||
label: '开始按钮',
|
||||
assetKind: 'icon',
|
||||
generationInputs: {
|
||||
@@ -190,14 +206,13 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -212,16 +227,16 @@ describe('creationShowcaseModel', () => {
|
||||
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',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'ui-design-1',
|
||||
label: '游戏UI图',
|
||||
assetKind: 'ui-design',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
taskId: 'task-ui-design',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-sheet-1',
|
||||
createResource({
|
||||
resourceId: 'icon-sheet-1',
|
||||
label: 'UI子素材图集',
|
||||
assetKind: 'icon-spritesheet',
|
||||
model: 'gpt-image-2',
|
||||
@@ -232,14 +247,14 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-1',
|
||||
createResource({
|
||||
resourceId: 'icon-1',
|
||||
label: '开始按钮',
|
||||
assetKind: 'icon',
|
||||
model: 'gpt-image-2',
|
||||
@@ -250,34 +265,32 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe('32泥点');
|
||||
expect(items[0]?.cost).toBe('15泥点');
|
||||
});
|
||||
|
||||
it('keeps music assets as audio preview items', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'music',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'music-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'music-1',
|
||||
label: '背景音乐',
|
||||
imageSrc: '/generated-editor-audios/music.mp3',
|
||||
assetKind: 'background-music',
|
||||
prompt: '舒缓背景音乐',
|
||||
priceMudPoints: '5',
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
fallbackAuthorName: '当前用户',
|
||||
});
|
||||
|
||||
@@ -290,21 +303,20 @@ describe('creationShowcaseModel', () => {
|
||||
it('keeps character animation videos out of the marketing tab', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'marketing',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'animation-1',
|
||||
label: '角色动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'poster-1',
|
||||
createResource({
|
||||
resourceId: 'poster-1',
|
||||
label: '上线美宣视频',
|
||||
assetKind: 'publication-material',
|
||||
imageSrc: '/generated-editor-videos/poster.mp4',
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -315,9 +327,9 @@ describe('creationShowcaseModel', () => {
|
||||
it('infers costs from generated asset kinds when stored price is absent', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
imageSrc: '/generated-editor-assets/character.png',
|
||||
objectKey: 'generated-editor-assets/character.png',
|
||||
@@ -325,8 +337,8 @@ describe('creationShowcaseModel', () => {
|
||||
model: 'gpt-image-2',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
createResource({
|
||||
resourceId: 'animation-1',
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
@@ -339,18 +351,17 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe('140泥点');
|
||||
expect(items[0]?.cost).toBe('123泥点');
|
||||
expect(items[0]?.previews[0]?.objectKey).toBe(
|
||||
'generated-editor-assets/character.png',
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
EditorAssetGenerationInputReference,
|
||||
EditorAssetLibrarySnapshot,
|
||||
EditorAssetSnapshot,
|
||||
EditorProjectResourceSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
calculateCharacterAnimationPrice,
|
||||
@@ -15,12 +15,6 @@ import {
|
||||
DEFAULT_VIDEO_DURATION_SECONDS,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
} from '../image-editor/ImageCanvasGenerationModel';
|
||||
import {
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export type ShowcaseTabId =
|
||||
| 'packs'
|
||||
@@ -91,6 +85,10 @@ function assetRecord(asset: EditorAssetSnapshot) {
|
||||
return asset as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function projectResourceRecord(resource: EditorProjectResourceSnapshot) {
|
||||
return resource as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
@@ -162,6 +160,9 @@ function getSelfAssetKey(asset: EditorAssetSnapshot) {
|
||||
|
||||
function getAssetReferenceKeys(asset: EditorAssetSnapshot) {
|
||||
const keys = [getSelfAssetKey(asset)];
|
||||
if (asset.assetId?.trim()) {
|
||||
keys.push(`project-resource:${asset.assetId.trim()}`);
|
||||
}
|
||||
if (asset.sourceResourceId?.trim()) {
|
||||
keys.push(`project-resource:${asset.sourceResourceId.trim()}`);
|
||||
}
|
||||
@@ -174,6 +175,61 @@ function getAssetReferenceKeys(asset: EditorAssetSnapshot) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function projectResourceToShowcaseAsset(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
): EditorAssetSnapshot {
|
||||
const record = projectResourceRecord(resource);
|
||||
const fallbackLabel =
|
||||
normalizeText(record.label) ||
|
||||
normalizeText(record.title) ||
|
||||
resource.assetKind?.trim() ||
|
||||
'项目素材';
|
||||
|
||||
return {
|
||||
...record,
|
||||
assetId: resource.resourceId,
|
||||
folderId: `project:${resource.projectId}`,
|
||||
label: fallbackLabel,
|
||||
imageSrc: resource.imageSrc,
|
||||
objectKey: resource.objectKey,
|
||||
assetObjectId: resource.assetObjectId,
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
sourceType: resource.sourceType,
|
||||
prompt: resource.prompt ?? null,
|
||||
actualPrompt: resource.actualPrompt ?? null,
|
||||
model: resource.model ?? null,
|
||||
provider: resource.provider ?? null,
|
||||
taskId: resource.taskId ?? null,
|
||||
durationSeconds: resource.durationSeconds,
|
||||
sourceResourceId: resource.sourceResourceId,
|
||||
assetKind: resource.assetKind,
|
||||
generationInputs: resource.generationInputs,
|
||||
createdAt: resource.createdAt,
|
||||
updatedAt: resource.updatedAt,
|
||||
} as EditorAssetSnapshot;
|
||||
}
|
||||
|
||||
function buildGeneratedProjectResourceAssets(
|
||||
resources: EditorProjectResourceSnapshot[],
|
||||
) {
|
||||
const seenResourceIds = new Set<string>();
|
||||
|
||||
return resources
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.sourceType === 'generated' && resource.imageSrc.trim(),
|
||||
)
|
||||
.filter((resource) => {
|
||||
if (seenResourceIds.has(resource.resourceId)) {
|
||||
return false;
|
||||
}
|
||||
seenResourceIds.add(resource.resourceId);
|
||||
return true;
|
||||
})
|
||||
.map(projectResourceToShowcaseAsset);
|
||||
}
|
||||
|
||||
function findReference(
|
||||
asset: EditorAssetSnapshot,
|
||||
matcher: (reference: EditorAssetGenerationInputReference) => boolean,
|
||||
@@ -641,114 +697,21 @@ function toLibraryShowcaseItem(
|
||||
};
|
||||
}
|
||||
|
||||
function matchesPublicGalleryTab(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
tab: ShowcaseTabId,
|
||||
) {
|
||||
if (tab === 'packs') {
|
||||
return true;
|
||||
}
|
||||
if (tab === 'games') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const themeTags = 'themeTags' in entry ? entry.themeTags : [];
|
||||
const haystack = [
|
||||
entry.worldName,
|
||||
entry.subtitle,
|
||||
entry.summaryText,
|
||||
entry.sourceType,
|
||||
...themeTags,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
if (tab === 'characters') {
|
||||
return /角色|character|rpg|visual/u.test(haystack);
|
||||
}
|
||||
if (tab === 'ui') {
|
||||
return /\bui\b|界面|图标|icon|puzzle/u.test(haystack);
|
||||
}
|
||||
if (tab === 'music') {
|
||||
return /音乐|音效|audio|music|sound/u.test(haystack);
|
||||
}
|
||||
if (tab === 'marketing') {
|
||||
return /美宣|宣传|poster|video|视觉|visual/u.test(haystack);
|
||||
}
|
||||
if (tab === 'effects') {
|
||||
return /特效|effect|vfx/u.test(haystack);
|
||||
}
|
||||
if (tab === 'scenes') {
|
||||
return /场景|scene|background|world|rpg/u.test(haystack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toPublicGalleryShowcaseItems(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
tab: ShowcaseTabId,
|
||||
): ShowcaseAssetItem[] {
|
||||
const usedImageSrcs = new Set<string>();
|
||||
const items: ShowcaseAssetItem[] = [];
|
||||
|
||||
entries
|
||||
.filter((entry) => matchesPublicGalleryTab(entry, tab))
|
||||
.forEach((entry) => {
|
||||
const coverImageSrc = resolvePlatformWorldCoverImage(entry).trim();
|
||||
const imageCandidates = [
|
||||
...resolvePlatformWorldCoverSlides(entry).map((slide) => ({
|
||||
src: slide.imageSrc.trim(),
|
||||
label: slide.label?.trim() || entry.worldName,
|
||||
})),
|
||||
{ src: coverImageSrc, label: entry.worldName },
|
||||
].filter((candidate) => candidate.src);
|
||||
|
||||
imageCandidates.forEach((candidate, index) => {
|
||||
if (usedImageSrcs.has(candidate.src)) {
|
||||
return;
|
||||
}
|
||||
usedImageSrcs.add(candidate.src);
|
||||
items.push({
|
||||
id: `public:${entry.sourceType}:${entry.profileId}:${index}`,
|
||||
label: index === 0 ? entry.worldName : `${entry.worldName} ${index + 1}`,
|
||||
previews: [
|
||||
{
|
||||
id: `public:${entry.profileId}:${index}`,
|
||||
label: candidate.label,
|
||||
src: candidate.src,
|
||||
mediaType: 'image',
|
||||
width: index === 0 ? 720 : 640,
|
||||
height: index === 0 ? 480 : 720,
|
||||
},
|
||||
],
|
||||
prompt: entry.summaryText || entry.subtitle || UNKNOWN_META_VALUE,
|
||||
author: resolvePlatformWorkAuthorDisplayName(entry),
|
||||
cost: UNKNOWN_META_VALUE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return items.slice(0, 36);
|
||||
}
|
||||
|
||||
export function buildCreationShowcaseItems({
|
||||
activeTab,
|
||||
assetLibrary,
|
||||
publicGalleryEntries,
|
||||
projectResources,
|
||||
fallbackAuthorName,
|
||||
}: {
|
||||
activeTab: ShowcaseTabId;
|
||||
assetLibrary: EditorAssetLibrarySnapshot | null;
|
||||
publicGalleryEntries: PlatformPublicGalleryCard[];
|
||||
projectResources: EditorProjectResourceSnapshot[];
|
||||
fallbackAuthorName?: string | null;
|
||||
}) {
|
||||
const libraryItems = getGroupsForTab(assetLibrary?.assets ?? [], activeTab)
|
||||
const projectItems = getGroupsForTab(
|
||||
buildGeneratedProjectResourceAssets(projectResources),
|
||||
activeTab,
|
||||
)
|
||||
.map((group) => toLibraryShowcaseItem(group, fallbackAuthorName))
|
||||
.filter((item): item is ShowcaseAssetItem => item !== null);
|
||||
|
||||
if (libraryItems.length > 0) {
|
||||
return libraryItems.slice(0, 36);
|
||||
}
|
||||
|
||||
return toPublicGalleryShowcaseItems(publicGalleryEntries, activeTab);
|
||||
return projectItems.slice(0, 36);
|
||||
}
|
||||
|
||||
@@ -15283,10 +15283,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenProject={openEditorProject}
|
||||
onOpenProjects={openProjectGallery}
|
||||
onOpenCommunity={openCreationCommunity}
|
||||
publicGalleryEntries={[
|
||||
...featuredGalleryEntries,
|
||||
...latestGalleryEntries,
|
||||
]}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user