统一素材任务层级与成本归因

后台素材查询按任务分页并将中间产物逐行收纳为子项
生成原始产物沿用真实素材类型并承载任务生成成本
抠图、拆分和逐帧后处理记录实际生成器且阶段成本归零
补齐历史类型映射、角色动作链路、测试与文档
This commit is contained in:
2026-07-15 03:04:41 +08:00
parent 3f3a6c4ab2
commit 2f092c3566
18 changed files with 1161 additions and 255 deletions
+4
View File
@@ -467,6 +467,10 @@ export interface AdminEditorAssetPayload {
generationCostMudPoints: number;
createdAt: string;
updatedAt: string;
generator: string;
taskGenerator: string;
taskCostMudPoints: number;
children: AdminEditorAssetPayload[];
}
export interface AdminEditorAssetListResponse {
@@ -148,6 +148,10 @@ const generatedAsset: AdminEditorAssetPayload = {
generationCostMudPoints: 12,
createdAt: '2026-07-04T10:00:00Z',
updatedAt: '2026-07-04T10:00:00Z',
generator: '角色抠图',
taskGenerator: '角色生成',
taskCostMudPoints: 12,
children: [],
};
beforeEach(() => {
@@ -245,17 +249,45 @@ test('后台素材查询不展示分类筛选和分类列', async () => {
expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull();
});
test('后台素材查询将中间产物标记为不单独计费', async () => {
test('后台素材查询将中间产物按真实类型逐行收纳在最终产物下', async () => {
const providerSource: AdminEditorAssetPayload = {
...generatedAsset,
assetId: 'asset-source',
label: '角色形象生成原图',
objectKey:
'generated-character-drafts/editor/character-images/provider-source-task-1.png',
imageSrc:
'/generated-character-drafts/editor/character-images/provider-source-task-1.png',
assetKind: 'character',
generator: '角色生图',
taskGenerator: '角色生成',
generationCostMudPoints: 12,
taskCostMudPoints: 12,
children: [],
};
const postprocessOutput: AdminEditorAssetPayload = {
...generatedAsset,
assetId: 'asset-postprocess',
label: '角色抠图阶段产物',
objectKey:
'generated-character-drafts/editor/character-images/background-removal-task-1.png',
imageSrc:
'/generated-character-drafts/editor/character-images/background-removal-task-1.png',
assetKind: 'character',
generator: '角色抠图',
taskGenerator: '角色生成',
generationCostMudPoints: 0,
taskCostMudPoints: 0,
children: [],
};
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
assetId: 'asset-source',
label: '角色形象原图',
assetKind: 'editor_green_screen_source',
generationCostMudPoints: 0,
taskCostMudPoints: 12,
children: [providerSource, postprocessOutput],
},
generatedAsset,
],
nextCursor: null,
});
@@ -264,19 +296,31 @@ test('后台素材查询将中间产物标记为不单独计费', async () => {
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const sourceRow = (await screen.findByText('角色形象原图')).closest('tr');
const finalRow = screen.getByText('角色形象 1').closest('tr');
expect(sourceRow).not.toBeNull();
const finalRow = (await screen.findByText('角色形象 1')).closest('tr');
expect(finalRow).not.toBeNull();
expect(within(sourceRow!).getByText('不单独计费')).toBeTruthy();
expect(within(finalRow!).getByText('12 泥点')).toBeTruthy();
expect(screen.queryByText('角色形象生成原图')).toBeNull();
expect(screen.queryByText('角色抠图阶段产物')).toBeNull();
fireEvent.click(
within(finalRow!).getByRole('button', { name: '展开中间产物' }),
);
const sourceRow = (await screen.findByText('角色形象生成原图')).closest('tr');
const postprocessRow = screen.getByText('角色抠图阶段产物').closest('tr');
expect(sourceRow).not.toBeNull();
expect(postprocessRow).not.toBeNull();
expect(within(sourceRow!).getByText('角色生图')).toBeTruthy();
expect(within(sourceRow!).getByText('12 泥点')).toBeTruthy();
expect(within(postprocessRow!).getByText('角色抠图')).toBeTruthy();
expect(
within(postprocessRow!).getByText('本阶段不额外扣费'),
).toBeTruthy();
fireEvent.click(within(sourceRow!).getByRole('button', { name: '详情' }));
expect(
within(await screen.findByRole('dialog', { name: '素材详情' })).getByText(
'不单独计费',
),
).toBeTruthy();
const detail = await screen.findByRole('dialog', { name: '素材详情' });
expect(within(detail).getByText('角色生图')).toBeTruthy();
expect(within(detail).getByText('12 泥点')).toBeTruthy();
});
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
@@ -1,5 +1,5 @@
import { Eye, FileText, RefreshCcw, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide-react';
import { Fragment, type ReactNode } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
@@ -24,10 +24,6 @@ const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
const NON_BILLED_INTERMEDIATE_ASSET_KINDS = new Set([
'editor_green_screen_source',
'editor_image_edit_source',
]);
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
let adminAssetReadDispatchTail = Promise.resolve();
@@ -48,6 +44,9 @@ export function AdminEditorAssetQueryPage({
useState<AdminEditorAssetPayload | null>(null);
const [previewEntry, setPreviewEntry] =
useState<AdminEditorAssetPayload | null>(null);
const [expandedTaskIds, setExpandedTaskIds] = useState<Set<string>>(
() => new Set(),
);
const [promptPreview, setPromptPreview] = useState<{
title: string;
prompt: string;
@@ -153,7 +152,7 @@ export function AdminEditorAssetQueryPage({
<label className="admin-field">
<span></span>
<input
placeholder="素材名 / 用户 / 模型 / 提示词"
placeholder="素材名 / 用户 / 生成器 / 模型 / 提示词"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
@@ -168,6 +167,7 @@ export function AdminEditorAssetQueryPage({
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@@ -175,64 +175,143 @@ export function AdminEditorAssetQueryPage({
<tbody>
{entries.map((entry) => {
const promptText = entry.prompt || entry.actualPrompt || '-';
const expanded = expandedTaskIds.has(entry.assetId);
const children = entry.children ?? [];
return (
<tr key={entry.assetId}>
<td>
<button
className="admin-asset-query-thumb-button"
title="预览素材"
type="button"
onClick={() => setPreviewEntry(entry)}
>
<AdminAssetThumbnail entry={entry} token={token} />
</button>
<small>{entry.label || '-'}</small>
</td>
<td>{formatDateTime(entry.createdAt)}</td>
<td>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
<Fragment key={entry.assetId}>
<tr>
<td>
<div className="admin-asset-query-resource-cell">
{children.length ? (
<button
aria-label={expanded ? '收起中间产物' : '展开中间产物'}
className="admin-asset-query-expand-button"
type="button"
onClick={() =>
setExpandedTaskIds((current) => {
const next = new Set(current);
if (next.has(entry.assetId)) {
next.delete(entry.assetId);
} else {
next.add(entry.assetId);
}
return next;
})
}
>
{expanded ? (
<ChevronDown size={17} aria-hidden="true" />
) : (
<ChevronRight size={17} aria-hidden="true" />
)}
</button>
) : (
<span className="admin-asset-query-expand-spacer" />
)}
<div>
<button
className="admin-asset-query-thumb-button"
title="预览素材"
type="button"
onClick={() => setPreviewEntry(entry)}
>
<AdminAssetThumbnail entry={entry} token={token} />
</button>
<small>{entry.label || '-'}</small>
</div>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
</td>
<td>{formatDateTime(entry.createdAt)}</td>
<td>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
<AdminAssetPromptButton
entry={entry}
promptText={promptText}
onOpen={setPromptPreview}
/>
</div>
</td>
<td>
<button
className="admin-text-button admin-asset-query-prompt-text"
disabled={promptText === '-'}
title={promptText}
type="button"
onClick={() =>
setPromptPreview({
title: entry.label || entry.assetId,
prompt: promptText,
})
}
>
{promptText}
</button>
</td>
<td>{generationCostLabel(entry)}</td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(entry)}
>
<Eye size={16} aria-hidden="true" />
<span></span>
</button>
</td>
</tr>
</td>
<td>{entry.taskGenerator || entry.generator || '-'}</td>
<td>{taskGenerationCostLabel(entry)}</td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(entry)}
>
<Eye size={16} aria-hidden="true" />
<span></span>
</button>
</td>
</tr>
{expanded
? children.map((child) => {
const childPrompt =
child.prompt || child.actualPrompt || '-';
return (
<tr
className="admin-asset-query-child-row"
key={child.assetId}
>
<td>
<div className="admin-asset-query-resource-cell admin-asset-query-resource-cell-child">
<span className="admin-asset-query-child-branch" />
<div>
<button
className="admin-asset-query-thumb-button"
title="预览中间产物"
type="button"
onClick={() => setPreviewEntry(child)}
>
<AdminAssetThumbnail
entry={child}
token={token}
/>
</button>
<small>{child.label || '-'}</small>
</div>
</div>
</td>
<td>{formatDateTime(child.createdAt)}</td>
<td>{authorDisplayName(child)}</td>
<td>
<AdminAssetPromptButton
entry={child}
promptText={childPrompt}
onOpen={setPromptPreview}
/>
</td>
<td>{child.generator || '-'}</td>
<td>{stageGenerationCostLabel(child)}</td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(child)}
>
<Eye size={16} aria-hidden="true" />
<span></span>
</button>
</td>
</tr>
);
})
: null}
</Fragment>
);
})}
</tbody>
@@ -310,6 +389,33 @@ export function AdminEditorAssetQueryPage({
);
}
function AdminAssetPromptButton({
entry,
promptText,
onOpen,
}: {
entry: AdminEditorAssetPayload;
promptText: string;
onOpen: (value: { title: string; prompt: string }) => void;
}) {
return (
<button
className="admin-text-button admin-asset-query-prompt-text"
disabled={promptText === '-'}
title={promptText}
type="button"
onClick={() =>
onOpen({
title: entry.label || entry.assetId,
prompt: promptText,
})
}
>
{promptText}
</button>
);
}
function AdminAssetThumbnail({
entry,
token,
@@ -520,8 +626,20 @@ function AdminAssetDetailDialog({
{entry.width} x {entry.height}
</AdminInfoItem>
<AdminInfoItem label="成本">
{generationCostLabel(entry)}
{entry.children?.length
? taskGenerationCostLabel(entry)
: stageGenerationCostLabel(entry)}
</AdminInfoItem>
<AdminInfoItem label="生成器">
{entry.children?.length
? entry.taskGenerator || entry.generator || '-'
: entry.generator || '-'}
</AdminInfoItem>
{entry.children?.length ? (
<AdminInfoItem label="当前产物阶段">
{entry.generator || '-'}
</AdminInfoItem>
) : null}
<AdminInfoItem label="模型">{entry.model || '-'}</AdminInfoItem>
<AdminInfoItem label="Provider">
{entry.provider || '-'}
@@ -562,11 +680,14 @@ function AdminAssetDetailDialog({
);
}
function generationCostLabel(entry: AdminEditorAssetPayload) {
return entry.assetKind &&
NON_BILLED_INTERMEDIATE_ASSET_KINDS.has(entry.assetKind)
? '不单独计费'
: `${entry.generationCostMudPoints} 泥点`;
function taskGenerationCostLabel(entry: AdminEditorAssetPayload) {
return `${entry.taskCostMudPoints ?? entry.generationCostMudPoints} 泥点`;
}
function stageGenerationCostLabel(entry: AdminEditorAssetPayload) {
return entry.generationCostMudPoints > 0
? `${entry.generationCostMudPoints} 泥点`
: '本阶段不额外扣费';
}
function AdminAssetPreviewDialog({
+59 -4
View File
@@ -820,6 +820,55 @@ button:disabled {
display: block;
}
.admin-asset-query-resource-cell {
display: flex;
align-items: flex-start;
gap: 6px;
}
.admin-asset-query-resource-cell > div:last-child small {
display: block;
max-width: 92px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-asset-query-expand-button {
display: inline-flex;
flex: 0 0 28px;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: #78523e;
cursor: pointer;
}
.admin-asset-query-expand-spacer,
.admin-asset-query-child-branch {
display: block;
flex: 0 0 28px;
width: 28px;
}
.admin-asset-query-child-row {
background: #fffaf5;
}
.admin-asset-query-resource-cell-child {
padding-left: 12px;
}
.admin-asset-query-child-branch {
height: 34px;
border-bottom: 1px solid #d8c3b3;
border-left: 1px solid #d8c3b3;
}
.admin-asset-query-prompt-text {
display: block;
max-width: 100%;
@@ -1472,29 +1521,30 @@ button:disabled {
}
.admin-asset-query-table {
min-width: 1080px;
table-layout: fixed;
}
.admin-asset-query-table th:nth-child(1),
.admin-asset-query-table td:nth-child(1) {
width: 10%;
width: 13%;
}
.admin-asset-query-table th:nth-child(2),
.admin-asset-query-table td:nth-child(2),
.admin-asset-query-table th:nth-child(3),
.admin-asset-query-table td:nth-child(3) {
width: 14%;
width: 13%;
}
.admin-asset-query-table th:nth-child(4),
.admin-asset-query-table td:nth-child(4) {
width: 34%;
width: 27%;
}
.admin-asset-query-table th:nth-child(5),
.admin-asset-query-table td:nth-child(5) {
width: 8%;
width: 10%;
}
.admin-asset-query-table th:nth-child(6),
@@ -1502,6 +1552,11 @@ button:disabled {
width: 10%;
}
.admin-asset-query-table th:nth-child(7),
.admin-asset-query-table td:nth-child(7) {
width: 10%;
}
.admin-showcase-review-table {
table-layout: fixed;
}
@@ -36,7 +36,7 @@
- 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。
- 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。
- 画布与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。中间产物的 `generation_cost_mud_points = 0` 只表示不重复计费,后台必须显示“不单独计费”;任务成本仍只记在最终素材。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。
- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs``character_animation_assets.rs`、外部生成任务摘要、图片画布完成快照、账号素材库和前端生成提示。
- 验证方式:覆盖中间产物登记先于后处理、默认素材文件夹、图集拆分降级、inline / queue warning 和主结果锚定的定向测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`、前端定向测试、`npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -388,8 +388,8 @@
- 现象:`nanobanana2` 已返回并成功解码图片,任务随后报“尺寸无效”;普通生图若把尺寸恢复作为硬失败,provider 已成功回图后仍可能没有素材进入素材库和画布。
- 原因:通用尺寸恢复把 `nanobanana2` 的标量清晰度档位 `512 / 1024 / 2K` 当成 `WIDTHxHEIGHT` 解析;普通图片与快速编辑还把 OSS 持久化放在尺寸恢复之后。同步 `generateContent` 返回的是内联 base64,本地兜底 task id 不能用于向 provider 回查原图。
- 处理:`nanobanana2` 保留 provider 输出几何尺寸;其它模型仍按显式像素目标尝试恢复。普通生图和快速编辑先把 provider 回图留在内存,尺寸恢复成功后只上传变换结果,恢复失败则降级为只上传 provider 原图;每个主结果只执行一次 OSS 持久化并只创建一个素材。角色、图标图集UI 提取的带背景原图仍按多产物语义单独保留。扣费确认仍以 provider 成功为界,不延长到 OSS、后处理或画布回填;后台把历史中间产物的零成本显示为“不单独计费”
- 验证:`cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml` 覆盖 nanobanana 标量尺寸、变换失败回落 provider 原图、变换先于单次持久化;`npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx` 覆盖中间产物成本文案。
- 处理:`nanobanana2` 保留 provider 输出几何尺寸;其它模型仍按显式像素目标尝试恢复。普通生图和快速编辑先把 provider 回图留在内存,尺寸恢复成功后只上传变换结果,恢复失败则降级为只上传 provider 原图;每个主结果只执行一次 OSS 持久化并只创建一个素材。角色、图标图集UI 提取和角色动作的 provider 原始输出按多产物语义单独保留并承载任务模型成本,后续抠图、逐帧处理和切片阶段成本为 0;中间产物沿用 `character``icon-spritesheet``character-animation` 等真实类型,不新增“原图类型”。扣费确认仍以 provider 成功为界,不延长到 OSS、后处理或画布回填;后台按任务显示最终产物父行,并把每个中间产物作为独立子行展开,分别展示阶段生成器和阶段成本
- 验证:`cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml` 覆盖 nanobanana 标量尺寸、变换失败回落 provider 原图、变换先于单次持久化;`cargo test -p api-server character_animation_assets --manifest-path server-rs/Cargo.toml` 覆盖角色动作原始预览的真实类型和成本归因;`npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx` 覆盖中间产物逐行展开和成本文案。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`
## 图片画布快速编辑元数据必须记录原图引用
@@ -55,7 +55,7 @@
- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*``data:video/*``data:audio/*``blob:`。旧数据读取时如果已有 `objectKey``imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId``assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色``抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId``publicationGameInfo``publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution``originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把 provider 原始输出及后处理结果分别入库:所有条目沿用 `character``icon-spritesheet``character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel``canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。
- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。
- Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。
@@ -62,7 +62,7 @@ npm run check:server-rs-ddd
- 外部 OpenAPI`/api/external/v1/openapi.json``/api/external/v1/assets/direct-upload-tickets``/api/external/v1/assets/objects/confirm``/api/external/v1/assets/read-url``/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。
- 创作 / 游玩支撑能力:`/api/creation-entry/config``/api/ai/tasks*``/api/runtime/frontend-config``/api/runtime/chat/*``/api/runtime/settings``/api/runtime/save/snapshot``/api/profile/browse-history``/api/profile/save-archives*``/api/profile/play-stats``/api/assets/history``/api/assets/character-visual/*``/api/assets/character-animation/*``/api/assets/character-workflow-cache*``/api/assets/hyper3d/*``/api/runtime/custom-world/asset-studio/*``/api/editor/projects*``/api/editor/projects/{projectId}/agent-conversations``/api/editor/agent-conversations/{conversationId}*``/api/runtime/frontend-config``api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。
- 后台入口配置:`/admin/api/creation-entry/config``/admin/api/creation-entry/config/banners``/admin/api/creation-entry/config/interactions`
- 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset``source_type = 'generated'` 的素材,支持 `ownerUserId``keyword``createdAfter``createdBefore``cursor``limit`返回缩略图 / Object Key、作者展示名、陶泥号、提示词、生成输入和生成成本,只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。`editor_green_screen_source`历史 `editor_image_edit_source` 是同一付费任务的可恢复中间产物,`generation_cost_mud_points = 0` 表示不重复计费,列表和详情统一显示“不单独计费”,不能显示成误导性的“0 泥点”。图片放大、视频和音频预览统一调用仅后台管理员会话可访问的 `GET /admin/api/assets/read-url`;该入口仅为预览允许后台跨 owner 签名,不改变主站 `/api/assets/read-*` 或 External API 的 owner 边界。成功换签后必须写入 `event_key = admin_asset_read_url``tracking_event`,记录管理员 subject、Object Key / legacy path 和有效期,不记录 signed URL。
- 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset``source_type = 'generated'` 的素材,支持 `ownerUserId``keyword``createdAfter``createdBefore``cursor``limit`查询先按 `task_id` 分组并按最终产物父项分页,响应中每个父项通过 `children` 携带可展开的中间产物,子项各占一行而不单独占分页名额。父项返回任务生成器和任务总成本,子项返回阶段生成器和阶段成本。provider 原图 / 角色动作预览承载模型生成成本,抠图、逐帧处理、透明图集和切片成本为 0;中间产物使用所属任务真实 `asset_kind`,不再新写 `editor_green_screen_source`历史旧值只在 read model 中按 Object Key 映射为 `character``icon-spritesheet``character-animation` 并把旧任务成本只读归回 provider 原始产物。接口只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。图片放大、视频和音频预览统一调用仅后台管理员会话可访问的 `GET /admin/api/assets/read-url`;该入口仅为预览允许后台跨 owner 签名,不改变主站 `/api/assets/read-*` 或 External API 的 owner 边界。成功换签后必须写入 `event_key = admin_asset_read_url``tracking_event`,记录管理员 subject、Object Key / legacy path 和有效期,不记录 signed URL。
- 自定义世界 / RPG`/api/runtime/custom-world*``/api/story/*``/api/runtime/chat/*`
- 拼图:`/api/runtime/puzzle/*`
- 抓大鹅 Match3D`/api/creation/match3d/*``/api/runtime/match3d/*`
+252 -15
View File
@@ -626,7 +626,7 @@ pub async fn admin_list_editor_assets(
.clamp(1, ADMIN_EDITOR_ASSET_MAX_LIMIT);
let date_range = resolve_admin_editor_asset_date_range(&query)
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
let mut records = state
let records = state
.spacetime_client()
.admin_list_editor_assets(AdminEditorAssetListRecordInput {
limit: page_size + 1,
@@ -639,20 +639,22 @@ pub async fn admin_list_editor_assets(
.await
.map_err(map_admin_spacetime_error)?;
let next_cursor = if records.len() > page_size as usize {
records
let mut groups = group_admin_editor_asset_records(records);
let next_cursor = if groups.len() > page_size as usize {
groups
.get(page_size.saturating_sub(1) as usize)
.and_then(|group| group.first())
.and_then(admin_editor_asset_cursor)
} else {
None
};
records.truncate(page_size as usize);
groups.truncate(page_size as usize);
Ok(json_success_body(
Some(&request_context),
AdminEditorAssetListResponse {
entries: records
entries: groups
.into_iter()
.map(|record| admin_editor_asset_payload_from_record(&state, record))
.filter_map(|records| admin_editor_asset_group_payload(&state, records))
.collect(),
next_cursor,
},
@@ -1122,6 +1124,7 @@ fn admin_editor_asset_payload_from_record(
record: AdminEditorAssetRecord,
) -> AdminEditorAssetPayload {
let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None);
let generator = resolve_admin_editor_asset_generator(&record);
AdminEditorAssetPayload {
asset_id: record.asset_id,
owner_user_id: record.owner_user_id,
@@ -1147,9 +1150,160 @@ fn admin_editor_asset_payload_from_record(
generation_cost_mud_points: record.generation_cost_mud_points,
created_at: record.created_at,
updated_at: record.updated_at,
task_generator: generator.clone(),
task_cost_mud_points: record.generation_cost_mud_points,
generator,
children: Vec::new(),
}
}
fn group_admin_editor_asset_records(
records: Vec<AdminEditorAssetRecord>,
) -> Vec<Vec<AdminEditorAssetRecord>> {
let mut groups = Vec::<Vec<AdminEditorAssetRecord>>::new();
let mut indexes = BTreeMap::<String, usize>::new();
for record in records {
let key = record
.task_id
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(|value| format!("owner:{}:task:{value}", record.owner_user_id))
.unwrap_or_else(|| format!("asset:{}", record.asset_id));
let index = if let Some(index) = indexes.get(&key).copied() {
index
} else {
let index = groups.len();
groups.push(Vec::new());
indexes.insert(key, index);
index
};
groups[index].push(record);
}
groups
}
fn admin_editor_asset_group_payload(
state: &AppState,
mut records: Vec<AdminEditorAssetRecord>,
) -> Option<AdminEditorAssetPayload> {
if records.is_empty() {
return None;
}
let task_generator = resolve_admin_editor_asset_task_generator(records.as_slice());
let task_cost_mud_points = records
.iter()
.map(|record| record.generation_cost_mud_points)
.max()
.unwrap_or_default();
let primary = records.remove(0);
let mut payload = admin_editor_asset_payload_from_record(state, primary);
payload.task_generator = task_generator;
payload.task_cost_mud_points = task_cost_mud_points;
payload.children = records
.into_iter()
.map(|record| admin_editor_asset_payload_from_record(state, record))
.collect();
Some(payload)
}
fn resolve_admin_editor_asset_task_generator(records: &[AdminEditorAssetRecord]) -> String {
let paths = records
.iter()
.filter_map(|record| record.object_key.as_deref())
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>();
if paths.iter().any(|path| path.contains("/character-images/")) {
return "角色生成".to_string();
}
if paths.iter().any(|path| {
path.contains("/ui-design-spritesheets/") || path.contains("/ui-design-assets/")
}) {
return "UI素材提取".to_string();
}
if paths.iter().any(|path| {
path.contains("/icon-spritesheets/") || path.contains("/icon-spritesheet-assets/")
}) {
return "图标图集生成".to_string();
}
records
.first()
.map(resolve_admin_editor_asset_generator)
.unwrap_or_else(|| "未知生成器".to_string())
}
fn resolve_admin_editor_asset_generator(record: &AdminEditorAssetRecord) -> String {
let object_key = record
.object_key
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
let provider_source = object_key.contains("green-screen-source")
|| object_key.contains("provider-source")
|| object_key.contains("/animation/editor-character-animation/");
if object_key.contains("/animation/editor-character-animation/") {
return "角色动作视频生成".to_string();
}
if object_key.contains("/character-images/") {
return if provider_source {
"角色生图"
} else {
"角色抠图"
}
.to_string();
}
if object_key.contains("/ui-design-assets/") || object_key.contains("/icon-spritesheet-assets/")
{
return "图集拆分".to_string();
}
if object_key.contains("/ui-design-spritesheets/") {
return if provider_source {
"UI素材生成"
} else {
"UI图集抠图"
}
.to_string();
}
if object_key.contains("/icon-spritesheets/") {
return if provider_source {
"图标图集生成"
} else {
"图标图集抠图"
}
.to_string();
}
if object_key.contains("/edit-images/") {
return "快速编辑".to_string();
}
if object_key.contains("/spec-images/") {
return "规范生成".to_string();
}
if object_key.contains("/publication-materials/") {
return "宣发素材生成".to_string();
}
if object_key.contains("/generated-images/") {
return "图片生成".to_string();
}
match record.asset_kind.as_deref() {
Some("character-animation") => {
if provider_source {
"角色动作视频生成"
} else {
"角色动作逐帧处理"
}
}
Some("sound-effect") => "音效生成",
Some("background-music") => "背景音乐生成",
Some("video") => "视频生成",
Some("icon") => "图集拆分",
Some("icon-spritesheet") => "图标图集生成",
Some("character") => "角色生成",
Some("ui-design") => "UI设计生成",
_ => "未知生成器",
}
.to_string()
}
fn admin_editor_asset_cursor(record: &AdminEditorAssetRecord) -> Option<String> {
let created_at_micros = parse_timestamp_text_to_micros(record.created_at.as_str())?;
Some(format!("{created_at_micros}:{}", record.asset_id))
@@ -3725,14 +3879,15 @@ mod tests {
use super::{
AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord,
admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result,
admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk,
apply_admin_database_table_filters, build_admin_asset_read_url_audit,
build_admin_dashboard_chart, build_admin_database_table_row,
build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview,
build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit,
admin_editor_asset_group_payload, admin_editor_showcase_asset_payload_from_record,
append_spacetime_sql_response_chunk, apply_admin_database_table_filters,
build_admin_asset_read_url_audit, build_admin_dashboard_chart,
build_admin_database_table_row, build_admin_tracking_event_keys_sql,
build_admin_tracking_events_sql, build_body_preview, build_debug_base_url,
build_spacetime_schema_url, clamp_admin_database_table_limit,
clamp_admin_tracking_event_limit, enforce_admin_request_permission,
finalize_admin_database_table_rows_response, hash_admin_password,
is_admin_account_not_found, is_safe_spacetime_table_name,
finalize_admin_database_table_rows_response, group_admin_editor_asset_records,
hash_admin_password, is_admin_account_not_found, is_safe_spacetime_table_name,
matches_admin_editor_asset_keyword, normalize_debug_path, normalize_table_count_error,
parse_admin_database_table_rows_sql_response, parse_admin_tracking_event_keys_sql_response,
parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response,
@@ -3752,8 +3907,8 @@ mod tests {
use shared_contracts::assets::GetReadUrlQuery;
use spacetime_client::{
AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord,
AdminDashboardRetentionRecord, AdminDashboardStatsRecord, SpacetimeClientError,
SpacetimeClientStage,
AdminDashboardRetentionRecord, AdminDashboardStatsRecord, AdminEditorAssetRecord,
SpacetimeClientError, SpacetimeClientStage,
};
use crate::{config::AppConfig, state::AppState};
@@ -3798,6 +3953,39 @@ mod tests {
}
}
fn test_admin_editor_asset_record(
asset_id: &str,
task_id: &str,
label: &str,
object_key: &str,
generation_cost_mud_points: u64,
) -> AdminEditorAssetRecord {
AdminEditorAssetRecord {
asset_id: asset_id.to_string(),
owner_user_id: "user-1".to_string(),
folder_id: "project".to_string(),
label: label.to_string(),
asset_object_id: Some(format!("object-{asset_id}")),
image_src: format!("/{object_key}"),
object_key: Some(object_key.to_string()),
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: Some(label.to_string()),
actual_prompt: None,
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some(task_id.to_string()),
asset_kind: Some("character".to_string()),
generation_inputs: None,
source_resource_id: None,
thumbnail_src: None,
generation_cost_mud_points,
created_at: "2026-07-04T10:00:00Z".to_string(),
updated_at: "2026-07-04T10:00:00Z".to_string(),
}
}
#[test]
fn admin_asset_read_url_audit_keeps_actor_and_target_without_signed_url() {
let draft = build_admin_asset_read_url_audit(
@@ -3842,6 +4030,51 @@ mod tests {
);
}
#[test]
fn admin_editor_asset_group_nests_each_intermediate_output_under_final_asset() {
let final_output = test_admin_editor_asset_record(
"asset-final",
"task-1",
"角色抠图结果",
"generated-character-drafts/editor/character-images/character-task-1.png",
0,
);
let provider_source = test_admin_editor_asset_record(
"asset-source",
"task-1",
"角色生成原图",
"generated-character-drafts/editor/character-images/character-provider-source-task-1.png",
9,
);
let mut other_task = test_admin_editor_asset_record(
"asset-other",
"task-1",
"其他素材",
"generated-images/editor/image-task-1.png",
3,
);
other_task.owner_user_id = "user-2".to_string();
let mut groups =
group_admin_editor_asset_records(vec![final_output, provider_source, other_task]);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].len(), 2);
let state = AppState::new(AppConfig::default()).expect("state should build");
let payload = admin_editor_asset_group_payload(&state, groups.remove(0))
.expect("group payload should exist");
assert_eq!(payload.asset_id, "asset-final");
assert_eq!(payload.task_generator, "角色生成");
assert_eq!(payload.task_cost_mud_points, 9);
assert_eq!(payload.generation_cost_mud_points, 0);
assert_eq!(payload.children.len(), 1);
assert_eq!(payload.children[0].asset_id, "asset-source");
assert_eq!(payload.children[0].asset_kind.as_deref(), Some("character"));
assert_eq!(payload.children[0].generator, "角色生图");
assert_eq!(payload.children[0].generation_cost_mud_points, 9);
}
#[test]
fn admin_editor_asset_keyword_matches_public_author_prompt_and_generation_inputs() {
let payload = AdminEditorAssetPayload {
@@ -3869,6 +4102,10 @@ mod tests {
generation_cost_mud_points: 12,
created_at: "2026-07-04T10:00:00Z".to_string(),
updated_at: "2026-07-04T10:00:00Z".to_string(),
generator: "角色生图".to_string(),
task_generator: "角色生成".to_string(),
task_cost_mud_points: 12,
children: Vec::new(),
};
assert!(matches_admin_editor_asset_keyword(
@@ -112,8 +112,8 @@ const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
const SOURCE_IMAGE_RESOLVE_TIMEOUT_MS: u64 = 30_000;
const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0-fast";
const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation";
const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source";
const EDITOR_GREEN_SCREEN_SOURCE_SLOT: &str = "green_screen_source";
const EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND: &str = "character-animation";
const EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT: &str = "provider_source";
const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video";
const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas";
const EDITOR_VIDEO_SLOT: &str = "video_preview";
@@ -736,7 +736,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
source_data_url.as_str(),
)
.await?;
persist_editor_generated_media_asset(
let source_record = persist_editor_generated_media_asset(
&state,
PersistEditorGeneratedAssetRequest {
project_id: project_id.clone(),
@@ -756,13 +756,19 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
provider: "Ark".to_string(),
task_id: task_id.clone(),
source_resource_id: source_resource_id.clone(),
asset_kind: Some(EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()),
asset_kind: Some(
EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(),
),
generation_inputs: generation_inputs.clone(),
thumbnail_src: None,
generation_cost_mud_points: 0,
generation_cost_mud_points: u64::from(normalized.price_mud_points),
},
)
.await?;
let provider_source_resource_id = source_record
.0
.as_ref()
.map(|resource| resource.resource_id.clone());
let persisted_frames = extract_and_persist_editor_character_animation_frames(
&state,
owner_user_id.as_str(),
@@ -807,11 +813,13 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
provider: "Ark".to_string(),
task_id: task_id.clone(),
source_resource_id: None,
asset_kind: Some("character-animation".to_string()),
source_resource_id: provider_source_resource_id,
asset_kind: Some(
EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(),
),
generation_inputs: generation_inputs.clone(),
thumbnail_src: Some(first_frame.image_src.clone()),
generation_cost_mud_points: u64::from(normalized.price_mud_points),
generation_cost_mud_points: 0,
},
)
.await?;
@@ -1917,11 +1925,11 @@ async fn put_generated_editor_character_animation_preview(
preview_payload.mime_type,
preview_payload.bytes,
build_asset_metadata(
EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND,
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
"editor_layer",
source_layer_id,
EDITOR_GREEN_SCREEN_SOURCE_SLOT,
EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT,
"editor-character-animation",
),
)
@@ -2272,7 +2280,7 @@ async fn extract_and_persist_editor_character_animation_frames(
Some(request.duration_seconds as f64),
)
.await?;
persist_editor_character_animation_green_screen_source_frames(
persist_editor_character_animation_provider_source_frames(
state,
owner_user_id,
source_layer_id,
@@ -2352,7 +2360,7 @@ async fn extract_and_persist_editor_character_animation_frames(
})
}
async fn persist_editor_character_animation_green_screen_source_frames(
async fn persist_editor_character_animation_provider_source_frames(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
@@ -2372,11 +2380,11 @@ async fn persist_editor_character_animation_green_screen_source_frames(
frame.mime_type.clone(),
frame.bytes.clone(),
build_asset_metadata(
EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND,
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
"editor_layer",
source_layer_id,
EDITOR_GREEN_SCREEN_SOURCE_SLOT,
EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT,
"editor-character-animation",
),
)
@@ -2819,7 +2827,7 @@ async fn confirm_editor_character_animation_source_asset_object(
task_id,
object_key,
content_type,
EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND,
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
)
.await
}
@@ -6044,7 +6052,7 @@ mod tests {
"async fn publish_animation_set",
&[
"apply_chroma_key: false",
"persist_editor_character_animation_green_screen_source_frames",
"persist_editor_character_animation_provider_source_frames",
"remove_editor_character_animation_frame_backgrounds",
],
);
@@ -6053,17 +6061,17 @@ mod tests {
"async fn extract_and_persist_editor_character_animation_frames",
"async fn publish_animation_set",
&[
"persist_editor_character_animation_green_screen_source_frames",
"persist_editor_character_animation_provider_source_frames",
"remove_editor_character_animation_frame_backgrounds",
],
);
assert_function_contains(
source,
"async fn persist_editor_character_animation_green_screen_source_frames",
"async fn persist_editor_character_animation_provider_source_frames",
"fn remove_editor_character_animation_frame_backgrounds",
&[
"EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND",
"EDITOR_GREEN_SCREEN_SOURCE_SLOT",
"EDITOR_CHARACTER_ANIMATION_ASSET_KIND",
"EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT",
"green-screen-frame",
"put_character_animation_object",
],
@@ -6134,14 +6142,14 @@ mod tests {
"attach_editor_character_animation_result_metadata",
"project_id: None",
"label: asset_label.clone()",
"asset_kind: Some(\"character-animation\".to_string())",
"generation_cost_mud_points: u64::from(normalized.price_mud_points)",
"EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND",
"generation_cost_mud_points: 0",
],
);
assert_function_contains(
source,
"async fn extract_and_persist_editor_character_animation_frames",
"async fn persist_editor_character_animation_green_screen_source_frames",
"async fn persist_editor_character_animation_provider_source_frames",
&[
"confirm_editor_character_animation_frame_asset_object",
"first_frame_object_key",
@@ -6175,7 +6183,8 @@ mod tests {
"pub async fn generate_editor_video",
&[
"or_else(|| Some(\"project\".to_string()))",
"EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND",
"EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND",
"generation_cost_mud_points: u64::from(normalized.price_mud_points)",
],
);
assert_function_contains_in_order(
File diff suppressed because it is too large Load Diff
@@ -226,6 +226,10 @@ pub struct AdminEditorAssetPayload {
pub generation_cost_mud_points: u64,
pub created_at: String,
pub updated_at: String,
pub generator: String,
pub task_generator: String,
pub task_cost_mud_points: u64,
pub children: Vec<AdminEditorAssetPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -17,6 +17,7 @@ const EDITOR_GENERATION_PRICING_CONFIG_ID: &str = "global";
const EDITOR_SHOWCASE_CATEGORIES: [&str; 4] = ["characters", "ui", "music", "marketing"];
const EDITOR_GENERATION_PRICING_UNIT_PER_GENERATION: &str = "perGeneration";
const EDITOR_GENERATION_PRICING_UNIT_PER_SECOND: &str = "perSecond";
const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source";
const EDITOR_GENERATION_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_GENERATION_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
const EDITOR_GENERATION_SOUND_EFFECT_MODEL: &str = "audio1.0";
@@ -1662,16 +1663,28 @@ fn admin_list_editor_assets(
let cursor = parse_public_showcase_cursor(input.cursor.as_deref());
let owner_user_id = normalize_optional(input.owner_user_id);
let keyword = normalize_optional(input.keyword).map(|value| value.to_lowercase());
let mut assets = ctx
let mut grouped = BTreeMap::<String, Vec<EditorAsset>>::new();
for asset in ctx
.db
.editor_asset()
.iter()
.filter(|asset| {
let created_at_micros = asset.created_at.to_micros_since_unix_epoch();
asset.source_type == "generated"
&& owner_user_id
.as_ref()
.is_none_or(|value| asset.owner_user_id == *value)
.filter(|asset| asset.source_type == "generated")
{
grouped
.entry(editor_asset_task_group_key(&asset))
.or_default()
.push(asset);
}
let mut groups = grouped
.into_values()
.filter_map(|mut assets| {
let primary_index = editor_asset_primary_index(&assets)?;
let primary = assets.get(primary_index)?;
let created_at_micros = primary.created_at.to_micros_since_unix_epoch();
let matches = owner_user_id
.as_ref()
.is_none_or(|value| primary.owner_user_id == *value)
&& input
.created_after_micros
.is_none_or(|value| created_at_micros >= value)
@@ -1680,22 +1693,42 @@ fn admin_list_editor_assets(
.is_none_or(|value| created_at_micros <= value)
&& is_after_public_showcase_cursor(
created_at_micros,
asset.asset_id.as_str(),
primary.asset_id.as_str(),
cursor.as_ref(),
)
&& matches_admin_editor_asset_keyword(asset, keyword.as_deref())
&& keyword.as_deref().is_none_or(|keyword| {
assets
.iter()
.any(|asset| matches_admin_editor_asset_keyword(asset, Some(keyword)))
});
if !matches {
return None;
}
normalize_legacy_editor_source_costs(&mut assets);
let primary = assets.swap_remove(primary_index.min(assets.len().saturating_sub(1)));
assets.sort_by(|left, right| {
editor_asset_child_rank(left)
.cmp(&editor_asset_child_rank(right))
.then_with(|| left.created_at.cmp(&right.created_at))
.then_with(|| left.asset_id.cmp(&right.asset_id))
});
let sort_key = (
primary.created_at.to_micros_since_unix_epoch(),
primary.asset_id.clone(),
);
let mut ordered = Vec::with_capacity(assets.len() + 1);
ordered.push(primary);
ordered.extend(assets);
Some((sort_key, ordered))
})
.map(admin_asset_snapshot_from_row)
.collect::<Vec<_>>();
assets.sort_by(|left, right| {
right
.created_at_micros
.cmp(&left.created_at_micros)
.then_with(|| right.asset_id.cmp(&left.asset_id))
});
assets.truncate(limit);
Ok(assets)
groups.sort_by(|left, right| right.0.cmp(&left.0));
groups.truncate(limit);
Ok(groups
.into_iter()
.flat_map(|(_, assets)| assets.into_iter().map(admin_asset_snapshot_from_row))
.collect())
}
fn create_editor_asset_folder(
@@ -2891,11 +2924,15 @@ fn build_asset_library_snapshot(
.then_with(|| left.created_at_micros.cmp(&right.created_at_micros))
.then_with(|| left.folder_id.cmp(&right.folder_id))
});
let mut assets = ctx
let mut asset_rows = ctx
.db
.editor_asset()
.by_editor_asset_owner_user_id()
.filter(&owner_key)
.collect::<Vec<_>>();
normalize_legacy_editor_source_costs_across_groups(&mut asset_rows);
let mut assets = asset_rows
.into_iter()
.map(|asset| asset_snapshot_from_row(ctx, asset))
.collect::<Vec<_>>();
assets.sort_by(|left, right| {
@@ -2906,6 +2943,154 @@ fn build_asset_library_snapshot(
Ok(EditorAssetLibrarySnapshot { folders, assets })
}
fn editor_asset_task_group_key(asset: &EditorAsset) -> String {
asset
.task_id
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(|value| format!("owner:{}:task:{value}", asset.owner_user_id))
.unwrap_or_else(|| format!("asset:{}", asset.asset_id))
}
fn editor_asset_is_provider_source(asset: &EditorAsset) -> bool {
asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND)
|| asset
.object_key
.as_deref()
.is_some_and(editor_object_key_is_provider_source)
}
fn editor_object_key_is_provider_source(object_key: &str) -> bool {
let object_key = object_key.trim().to_ascii_lowercase();
object_key.contains("green-screen-source")
|| object_key.contains("provider-source")
|| object_key.contains("/animation/editor-character-animation/")
}
fn editor_asset_primary_rank(asset: &EditorAsset) -> u8 {
if editor_asset_is_provider_source(asset) {
return 0;
}
let object_key = asset
.object_key
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
if object_key.contains("/icon-spritesheet-assets/") || object_key.contains("/ui-design-assets/")
{
return 1;
}
if asset.asset_kind.as_deref() == Some("icon-spritesheet") {
return 4;
}
3
}
fn editor_asset_primary_index(assets: &[EditorAsset]) -> Option<usize> {
assets
.iter()
.enumerate()
.max_by(|(_, left), (_, right)| {
editor_asset_primary_rank(left)
.cmp(&editor_asset_primary_rank(right))
.then_with(|| left.created_at.cmp(&right.created_at))
.then_with(|| left.asset_id.cmp(&right.asset_id))
})
.map(|(index, _)| index)
}
fn editor_asset_child_rank(asset: &EditorAsset) -> u8 {
if editor_asset_is_provider_source(asset) {
0
} else {
1
}
}
fn normalize_legacy_editor_source_costs(assets: &mut [EditorAsset]) {
let has_legacy_source = assets.iter().any(|asset| {
asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND)
});
let task_cost = assets
.iter()
.map(|asset| asset.generation_cost_mud_points)
.max()
.unwrap_or_default();
for asset in assets {
let is_legacy_source =
asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND);
if is_legacy_source {
asset.asset_kind = normalize_legacy_editor_source_asset_kind(
asset.asset_kind.take(),
asset.object_key.as_deref(),
);
asset.generation_cost_mud_points = task_cost;
} else if has_legacy_source && asset.generation_cost_mud_points > 0 {
asset.generation_cost_mud_points = 0;
}
}
}
fn normalize_legacy_editor_source_costs_across_groups(assets: &mut [EditorAsset]) {
let mut task_indexes = BTreeMap::<String, Vec<usize>>::new();
for (index, asset) in assets.iter().enumerate() {
task_indexes
.entry(editor_asset_task_group_key(asset))
.or_default()
.push(index);
}
for indexes in task_indexes.into_values() {
let has_legacy_source = indexes.iter().any(|index| {
assets.get(*index).is_some_and(|asset| {
asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND)
})
});
if !has_legacy_source {
continue;
}
let task_cost = indexes
.iter()
.filter_map(|index| assets.get(*index))
.map(|asset| asset.generation_cost_mud_points)
.max()
.unwrap_or_default();
for index in indexes {
let asset = &mut assets[index];
if asset.asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) {
asset.asset_kind = normalize_legacy_editor_source_asset_kind(
asset.asset_kind.take(),
asset.object_key.as_deref(),
);
asset.generation_cost_mud_points = task_cost;
} else if asset.generation_cost_mud_points > 0 {
asset.generation_cost_mud_points = 0;
}
}
}
}
fn normalize_legacy_editor_source_asset_kind(
asset_kind: Option<String>,
object_key: Option<&str>,
) -> Option<String> {
if asset_kind.as_deref() != Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) {
return asset_kind;
}
let object_key = object_key.unwrap_or_default().to_ascii_lowercase();
if object_key.contains("/character-images/") {
return Some("character".to_string());
}
if object_key.contains("/icon-spritesheets/") || object_key.contains("/ui-design-spritesheets/")
{
return Some("icon-spritesheet".to_string());
}
if object_key.contains("/animation/editor-character-animation/") {
return Some("character-animation".to_string());
}
None
}
fn find_showcase_asset_for_asset(
ctx: &ReducerContext,
asset_id: &str,
@@ -3581,6 +3766,8 @@ fn normalize_media_ref(value: &str) -> String {
}
fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResourceSnapshot {
let asset_kind =
normalize_legacy_editor_source_asset_kind(row.asset_kind, row.object_key.as_deref());
EditorProjectResourceSnapshot {
resource_id: row.resource_id,
project_id: row.project_id,
@@ -3597,7 +3784,7 @@ fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResour
provider: row.provider,
task_id: row.task_id,
source_resource_id: row.source_resource_id,
asset_kind: row.asset_kind,
asset_kind,
generation_inputs_json: row.generation_inputs_json,
public_showcase_enabled: row.public_showcase_enabled,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
@@ -4139,6 +4326,40 @@ mod tests {
models
}
fn generated_editor_asset(
asset_id: &str,
task_id: &str,
asset_kind: &str,
object_key: &str,
generation_cost_mud_points: u64,
created_at_micros: i64,
) -> EditorAsset {
EditorAsset {
asset_id: asset_id.to_string(),
owner_user_id: "user-1".to_string(),
folder_id: "project".to_string(),
label: asset_id.to_string(),
asset_object_id: Some(format!("object-{asset_id}")),
image_src: format!("/{object_key}"),
object_key: Some(object_key.to_string()),
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: Some(task_id.to_string()),
created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(created_at_micros),
asset_kind: Some(asset_kind.to_string()),
generation_inputs_json: None,
source_resource_id: None,
thumbnail_src: None,
generation_cost_mud_points,
}
}
#[test]
fn normalize_editor_generation_pricing_models_accepts_complete_matrix() {
let normalized =
@@ -4430,4 +4651,82 @@ mod tests {
cursor.as_ref()
));
}
#[test]
fn editor_asset_task_group_uses_final_output_as_primary() {
let assets = vec![
generated_editor_asset(
"source",
"task-1",
"icon-spritesheet",
"generated-icon-spritesheets/editor/provider-source-task-1.png",
9,
1,
),
generated_editor_asset(
"slice",
"task-1",
"icon",
"generated-icon-spritesheet-assets/editor/slice-1.png",
0,
3,
),
generated_editor_asset(
"final",
"task-1",
"icon-spritesheet",
"generated-icon-spritesheets/editor/spritesheet-task-1.png",
0,
2,
),
];
let primary_index = editor_asset_primary_index(&assets).expect("primary asset");
assert_eq!(assets[primary_index].asset_id, "final");
assert_eq!(
editor_asset_task_group_key(&assets[0]),
"owner:user-1:task:task-1"
);
assert!(editor_asset_is_provider_source(&assets[0]));
assert!(!editor_asset_is_provider_source(&assets[1]));
}
#[test]
fn legacy_provider_source_uses_real_type_and_carries_task_cost() {
let mut assets = vec![
generated_editor_asset(
"source",
"task-1",
EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND,
"generated-character-drafts/editor/character-images/character-green-screen-source.png",
0,
1,
),
generated_editor_asset(
"final",
"task-1",
"character",
"generated-character-drafts/editor/character-images/character.png",
9,
2,
),
];
normalize_legacy_editor_source_costs_across_groups(&mut assets);
assert_eq!(assets[0].asset_kind.as_deref(), Some("character"));
assert_eq!(assets[0].generation_cost_mud_points, 9);
assert_eq!(assets[1].generation_cost_mud_points, 0);
assert_eq!(
normalize_legacy_editor_source_asset_kind(
Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()),
Some(
"generated-character-drafts/layer/animation/editor-character-animation/task/preview.mp4",
),
)
.as_deref(),
Some("character-animation")
);
}
}
@@ -1146,7 +1146,6 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
value === 'icon' ||
value === 'icon-spritesheet' ||
value === 'icon-spec' ||
value === 'editor_green_screen_source' ||
value === 'publication-material' ||
value === 'ui-design' ||
value === 'video' ||
@@ -20,7 +20,6 @@ export type CanvasAssetKind =
| 'icon'
| 'icon-spritesheet'
| 'icon-spec'
| 'editor_green_screen_source'
| 'publication-material'
| 'ui-design'
| 'video'
@@ -285,7 +285,7 @@ describe('ImageCanvasWorldView', () => {
id: 'layer-source',
resourceId: 'resource-source',
title: '角色原图',
assetKind: 'editor_green_screen_source',
assetKind: 'character',
}),
],
});
@@ -298,7 +298,7 @@ describe('ImageCanvasWorldView', () => {
expect(within(layerButton).getByText('未知')).toBeTruthy();
expect(within(characterButton).getByText('角色')).toBeTruthy();
expect(within(sourceButton).getByText('原图')).toBeTruthy();
expect(within(sourceButton).getByText('角色')).toBeTruthy();
});
it('renders snap guides, marquee and floating generation status', () => {
@@ -205,11 +205,6 @@ const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{
{ value: 'icon', label: '图标', className: 'icon' },
{ value: 'icon-spritesheet', label: '图集', className: 'icon-spritesheet' },
{ value: 'icon-spec', label: '图标规范', className: 'icon-spec' },
{
value: 'editor_green_screen_source',
label: '原图',
className: 'source-image',
},
{
value: 'publication-material',
label: '宣发素材',
@@ -1428,7 +1428,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'editor_green_screen_source',
assetKind: 'character',
},
],
updatedAt: '2026-06-21T12:16:22.000Z',
@@ -1503,7 +1503,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
}),
expect.objectContaining({
resourceId: 'editor-resource-character-source',
assetKind: 'editor_green_screen_source',
assetKind: 'character',
}),
]),
}),