统一素材任务层级与成本归因
后台素材查询按任务分页并将中间产物逐行收纳为子项 生成原始产物沿用真实素材类型并承载任务生成成本 抠图、拆分和逐帧后处理记录实际生成器且阶段成本归零 补齐历史类型映射、角色动作链路、测试与文档
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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/*`。
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user