修复画布生图交付与素材成本展示
修复 nanobanana 标量尺寸被误判为宽高的问题。 普通生图和快速编辑在内存完成尺寸变换后仅上传一次,变换失败保留 provider 原图。 移除快速编辑重复的原始输出素材,保留角色、图集和 UI 的独立中间产物。 后台将不重复计费的中间产物显示为不单独计费。 补充单次上传、降级交付和成本展示回归测试及相关文档。
This commit is contained in:
@@ -245,6 +245,40 @@ test('后台素材查询不展示分类筛选和分类列', async () => {
|
||||
expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull();
|
||||
});
|
||||
|
||||
test('后台素材查询将中间产物标记为不单独计费', async () => {
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...generatedAsset,
|
||||
assetId: 'asset-source',
|
||||
label: '角色形象原图',
|
||||
assetKind: 'editor_green_screen_source',
|
||||
generationCostMudPoints: 0,
|
||||
},
|
||||
generatedAsset,
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<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();
|
||||
expect(finalRow).not.toBeNull();
|
||||
expect(within(sourceRow!).getByText('不单独计费')).toBeTruthy();
|
||||
expect(within(finalRow!).getByText('12 泥点')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(sourceRow!).getByRole('button', { name: '详情' }));
|
||||
expect(
|
||||
within(await screen.findByRole('dialog', { name: '素材详情' })).getByText(
|
||||
'不单独计费',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
|
||||
@@ -24,6 +24,10 @@ 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();
|
||||
|
||||
@@ -217,7 +221,7 @@ export function AdminEditorAssetQueryPage({
|
||||
{promptText}
|
||||
</button>
|
||||
</td>
|
||||
<td>{entry.generationCostMudPoints} 泥点</td>
|
||||
<td>{generationCostLabel(entry)}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
@@ -516,7 +520,7 @@ function AdminAssetDetailDialog({
|
||||
{entry.width} x {entry.height}
|
||||
</AdminInfoItem>
|
||||
<AdminInfoItem label="成本">
|
||||
{entry.generationCostMudPoints} 泥点
|
||||
{generationCostLabel(entry)}
|
||||
</AdminInfoItem>
|
||||
<AdminInfoItem label="模型">{entry.model || '-'}</AdminInfoItem>
|
||||
<AdminInfoItem label="Provider">
|
||||
@@ -558,6 +562,13 @@ function AdminAssetDetailDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function generationCostLabel(entry: AdminEditorAssetPayload) {
|
||||
return entry.assetKind &&
|
||||
NON_BILLED_INTERMEDIATE_ASSET_KINDS.has(entry.assetKind)
|
||||
? '不单独计费'
|
||||
: `${entry.generationCostMudPoints} 泥点`;
|
||||
}
|
||||
|
||||
function AdminAssetPreviewDialog({
|
||||
entry,
|
||||
token,
|
||||
|
||||
@@ -28,15 +28,15 @@
|
||||
|
||||
- 背景:图片画布的普通图片、规范、角色、图标图集、UI 设计、宣发素材、视频和音频默认使用“类型 + 数字”命名,用户只能在生成后单独重命名素材,画布图层、项目资源和素材库名称容易不一致。
|
||||
- 决策:主生成状态继续使用可选 `assetLabel`,名称最多 80 个字符并在提交时去除首尾空格;当前生成面板不展示“资源名称”标签和输入框,默认沿用现有自动编号名称,历史状态或内部调用若携带非空名称,仍必须让同一个名称贯穿 `assetLabel`、`canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。移除名称输入后,角色、图标图集、UI 设计和角色动作等提示词输入恢复统一可见边框。
|
||||
- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、图片修改 provider 原始输出等中间产物基于主名称追加“(原图)”或“(原始输出)”;图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。
|
||||
- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、角色动作绿幕预览等具有独立复用价值的中间产物基于主名称追加“(原图)”等后缀;普通图片和图片修改的纯尺寸变换在内存完成后只上传一次,不生成“原始输出”副本。图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。
|
||||
- 影响范围:图片画布生成状态与面板、提交模型、图标和角色动作请求契约、项目资源 / 素材库持久化和相关编辑器文档。
|
||||
- 验证方式:覆盖生成面板不渲染资源名称输入、提示词边框、空白回退、内部自定义名与长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、Rust 契约与 API 定向测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-13 图片画布多产物生成任务必须保存全部可恢复产物
|
||||
|
||||
- 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。
|
||||
- 决策:凡一次资产生成任务产生多个可恢复产物,后端必须先把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、尺寸恢复、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果;图片修改保留模型原始输出与尺寸恢复结果;角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。普通图片、去背景、音频等没有独立上游中间产物的任务不制造重复副本。
|
||||
- 画布与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。
|
||||
- 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 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、尺寸恢复和画布回填不延长退款保护。
|
||||
- 影响范围:`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`。
|
||||
|
||||
@@ -384,6 +384,14 @@
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖来源参数继承、模型参数切换、目标尺寸提交和图层回填;`cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml` 覆盖图标类拒绝、provider 尺寸对齐和回图恢复。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
## 图片画布纯尺寸变换必须先在内存决策再单次上传
|
||||
|
||||
- 现象:`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` 覆盖中间产物成本文案。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`。
|
||||
|
||||
## 图片画布快速编辑元数据必须记录原图引用
|
||||
|
||||
- 现象:快速编辑生成的新图可以替换画布,但打开图片信息时“生成输入”里看不到被修改的原图。
|
||||
|
||||
@@ -85,11 +85,11 @@
|
||||
- `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/<objectKey>` 轻量路径,不允许把 Data URL / signed URL 写入素材库。
|
||||
- `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。
|
||||
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=<screenColor>`、`seg_model=<segModel>` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=<screenColor>`、`seg_model=<segModel>` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
|
||||
- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider` 和可选 `project` 快照。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后恢复到业务目标精确尺寸,再落 OSS、project resource、账号素材和画布快照。16 对齐尺寸不得泄漏到响应、持久化资源或图层 Resolution。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸,成功时只上传恢复结果,失败时只上传 provider 原图。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
|
||||
- `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`。
|
||||
|
||||
|
||||
@@ -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 直查私有表。图片放大、视频和音频预览统一调用仅后台管理员会话可访问的 `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`;返回缩略图 / 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。
|
||||
- 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。
|
||||
- 拼图:`/api/runtime/puzzle/*`。
|
||||
- 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。
|
||||
|
||||
@@ -116,7 +116,6 @@ const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image";
|
||||
const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image";
|
||||
const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image";
|
||||
const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit";
|
||||
const EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND: &str = "editor_image_edit_source";
|
||||
const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet";
|
||||
const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice";
|
||||
const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components";
|
||||
@@ -1604,10 +1603,16 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
"message": "VectorEngine 未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let (provider_width, provider_height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": format!("素材生成结果不是有效图片:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let storage_profile = editor_generated_image_storage_profile(normalized_kind);
|
||||
let source_record = if is_character_generation {
|
||||
let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((1024, 1024));
|
||||
let source_persisted = persist_editor_green_screen_source_image(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
@@ -1630,8 +1635,8 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
.clone()
|
||||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
|
||||
label: editor_generated_asset_variant_label(asset_label.as_str(), "原图"),
|
||||
width: source_width,
|
||||
height: source_height,
|
||||
width: provider_width,
|
||||
height: provider_height,
|
||||
prompt: role_setting.clone(),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: generation_options.model.to_string(),
|
||||
@@ -1664,11 +1669,23 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
|
||||
// TODO the image size passed to api is already normalized, should remove those normalize
|
||||
// and let resize here to get the proper size
|
||||
image = restore_editor_generated_image_output_dimensions(image, image_size.as_ref())?;
|
||||
let (restored_image, dimension_restore_error) =
|
||||
restore_editor_generated_image_output_dimensions_or_original(
|
||||
image,
|
||||
generation_options.model,
|
||||
image_size.as_ref(),
|
||||
);
|
||||
if let Some(error) = dimension_restore_error {
|
||||
tracing::warn!(
|
||||
task_id = %generated.task_id,
|
||||
error = ?error,
|
||||
"画板生成图片尺寸恢复失败,保留并上传 provider 原图"
|
||||
);
|
||||
}
|
||||
image = restored_image;
|
||||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((1024, 1024));
|
||||
let storage_profile = editor_generated_image_storage_profile(normalized_kind);
|
||||
let persisted = persist_editor_generated_image(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
@@ -1681,8 +1698,8 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
"image",
|
||||
storage_profile.slot,
|
||||
"vector-engine",
|
||||
);
|
||||
let persisted = persisted.await?;
|
||||
)
|
||||
.await?;
|
||||
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
|
||||
let generated_asset = persist_editor_generated_asset(
|
||||
state,
|
||||
@@ -2188,9 +2205,9 @@ fn prepare_editor_image_edit_references(
|
||||
}
|
||||
|
||||
fn restore_editor_image_edit_output_dimensions(
|
||||
output: DownloadedOpenAiImage,
|
||||
output: &DownloadedOpenAiImage,
|
||||
dimensions: &EditorImageEditDimensions,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
) -> Result<Option<DownloadedOpenAiImage>, AppError> {
|
||||
let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
@@ -2198,7 +2215,7 @@ fn restore_editor_image_edit_output_dimensions(
|
||||
}))
|
||||
})?;
|
||||
if decoded.width() == dimensions.target_width && decoded.height() == dimensions.target_height {
|
||||
return Ok(output);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let restored = if decoded.width() == dimensions.provider_width
|
||||
@@ -2212,7 +2229,7 @@ fn restore_editor_image_edit_output_dimensions(
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
};
|
||||
Ok(DownloadedOpenAiImage {
|
||||
Ok(Some(DownloadedOpenAiImage {
|
||||
bytes: encode_editor_image_edit_png(
|
||||
restored,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
@@ -2220,13 +2237,42 @@ fn restore_editor_image_edit_output_dimensions(
|
||||
)?,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
fn restore_editor_generated_image_output_dimensions(
|
||||
|
||||
fn restore_editor_image_edit_output_dimensions_or_original(
|
||||
output: DownloadedOpenAiImage,
|
||||
// TODO primitive obsession
|
||||
dimensions: &EditorImageEditDimensions,
|
||||
) -> (DownloadedOpenAiImage, Option<AppError>) {
|
||||
match restore_editor_image_edit_output_dimensions(&output, dimensions) {
|
||||
Ok(Some(restored)) => (restored, None),
|
||||
Ok(None) => (output, None),
|
||||
Err(error) => (output, Some(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_editor_generated_image_output_dimensions_or_original(
|
||||
output: DownloadedOpenAiImage,
|
||||
model: &str,
|
||||
target_size: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
) -> (DownloadedOpenAiImage, Option<AppError>) {
|
||||
match restore_editor_generated_image_output_dimensions(&output, model, target_size) {
|
||||
Ok(Some(restored)) => (restored, None),
|
||||
Ok(None) => (output, None),
|
||||
Err(error) => (output, Some(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_editor_generated_image_output_dimensions(
|
||||
output: &DownloadedOpenAiImage,
|
||||
model: &str,
|
||||
target_size: &str,
|
||||
) -> Result<Option<DownloadedOpenAiImage>, AppError> {
|
||||
// nanobanana 使用标量清晰度档位和独立比例;provider 回图已经是目标几何尺寸,
|
||||
// 不能再把 512 / 1024 / 2K 当作 WIDTHxHEIGHT 解析。
|
||||
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
@@ -2252,7 +2298,7 @@ fn restore_editor_generated_image_output_dimensions(
|
||||
}))
|
||||
})?;
|
||||
if decoded.width() == target_width && decoded.height() == target_height {
|
||||
return Ok(output);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let restored = decoded.resize_to_fill(
|
||||
@@ -2260,7 +2306,7 @@ fn restore_editor_generated_image_output_dimensions(
|
||||
target_height,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
);
|
||||
Ok(DownloadedOpenAiImage {
|
||||
Ok(Some(DownloadedOpenAiImage {
|
||||
bytes: encode_editor_image_edit_png(
|
||||
restored,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
@@ -2268,7 +2314,7 @@ fn restore_editor_generated_image_output_dimensions(
|
||||
)?,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn edit_editor_image(
|
||||
@@ -2393,57 +2439,27 @@ pub(crate) async fn edit_editor_image_for_owner(
|
||||
let (provider_width, provider_height) =
|
||||
image::load_from_memory(provider_image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((
|
||||
edit_dimensions.provider_width,
|
||||
edit_dimensions.provider_height,
|
||||
));
|
||||
let source_persisted = persist_editor_generated_image(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
generated.task_id.as_str(),
|
||||
&provider_image,
|
||||
prompt.as_str(),
|
||||
generated.actual_prompt.as_deref(),
|
||||
EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND,
|
||||
"edit-images",
|
||||
"image-provider-source",
|
||||
"provider_source",
|
||||
"vector-engine",
|
||||
)
|
||||
.await?;
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": format!("图片改造结果不是有效图片:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let asset_label =
|
||||
resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果");
|
||||
let source_label =
|
||||
editor_generated_asset_variant_label(asset_label.as_str(), "原始输出");
|
||||
let _source_record = persist_editor_generated_asset(
|
||||
state,
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id.clone(),
|
||||
owner_user_id: caller.owner_user_id.clone(),
|
||||
folder_id: payload
|
||||
.asset_folder_id
|
||||
.clone()
|
||||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
|
||||
label: source_label,
|
||||
image_src: editor_media_src_from_object_key(source_persisted.object_key.as_str()),
|
||||
object_key: Some(source_persisted.object_key),
|
||||
asset_object_id: Some(source_persisted.asset_object_id),
|
||||
width: provider_width,
|
||||
height: provider_height,
|
||||
prompt: prompt.clone(),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: generation_options.model.to_string(),
|
||||
provider: "VectorEngine".to_string(),
|
||||
task_id: generated.task_id.clone(),
|
||||
source_resource_id: payload.source_resource_id.clone(),
|
||||
asset_kind: Some(EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND.to_string()),
|
||||
generation_inputs: payload.generation_inputs.clone(),
|
||||
thumbnail_src: None,
|
||||
generation_cost_mud_points: 0,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let image = restore_editor_image_edit_output_dimensions(provider_image, &edit_dimensions)?;
|
||||
let (image, dimension_restore_error) =
|
||||
restore_editor_image_edit_output_dimensions_or_original(provider_image, &edit_dimensions);
|
||||
if let Some(error) = dimension_restore_error {
|
||||
tracing::warn!(
|
||||
task_id = %generated.task_id,
|
||||
provider_width,
|
||||
provider_height,
|
||||
target_width = edit_dimensions.target_width,
|
||||
target_height = edit_dimensions.target_height,
|
||||
error = ?error,
|
||||
"画板快速编辑尺寸恢复失败,保留并上传 provider 原图"
|
||||
);
|
||||
}
|
||||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((1024, 1024));
|
||||
@@ -7530,15 +7546,14 @@ mod tests {
|
||||
let extra = image::load_from_memory(references[1].bytes.as_slice()).unwrap();
|
||||
assert_eq!((extra.width(), extra.height()), (512, 336));
|
||||
|
||||
let restored = restore_editor_image_edit_output_dimensions(
|
||||
DownloadedOpenAiImage {
|
||||
bytes: encode_png(1552, 1040),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
},
|
||||
&dimensions,
|
||||
)
|
||||
.expect("provider output should restore source dimensions");
|
||||
let provider_output = DownloadedOpenAiImage {
|
||||
bytes: encode_png(1552, 1040),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
let restored = restore_editor_image_edit_output_dimensions(&provider_output, &dimensions)
|
||||
.expect("provider output should restore source dimensions")
|
||||
.expect("aligned provider output should require a transformation");
|
||||
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
|
||||
assert_eq!(
|
||||
(restored_image.width(), restored_image.height()),
|
||||
@@ -7556,22 +7571,83 @@ mod tests {
|
||||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
|
||||
let provider_output = DownloadedOpenAiImage {
|
||||
bytes: bytes.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
let restored = restore_editor_generated_image_output_dimensions(
|
||||
DownloadedOpenAiImage {
|
||||
bytes: bytes.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
},
|
||||
&provider_output,
|
||||
GPT_IMAGE_2_MODEL,
|
||||
"720x540",
|
||||
)
|
||||
.expect("provider output should restore delivery dimensions");
|
||||
.expect("provider output should restore delivery dimensions")
|
||||
.expect("mismatched provider output should require a transformation");
|
||||
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
|
||||
|
||||
assert_eq!((restored_image.width(), restored_image.height()), (720, 540));
|
||||
assert_eq!(
|
||||
(restored_image.width(), restored_image.height()),
|
||||
(720, 540)
|
||||
);
|
||||
assert_eq!(restored.mime_type, "image/png");
|
||||
assert_eq!(restored.extension, "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nanobanana_generation_keeps_provider_dimensions_for_scalar_size_tiers() {
|
||||
let image = image::DynamicImage::new_rgba8(896, 896);
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
|
||||
let provider_output = DownloadedOpenAiImage {
|
||||
bytes: bytes.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
let (restored, restore_error) =
|
||||
restore_editor_generated_image_output_dimensions_or_original(
|
||||
provider_output,
|
||||
EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||||
"1024",
|
||||
);
|
||||
assert!(restore_error.is_none());
|
||||
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
(restored_image.width(), restored_image.height()),
|
||||
(896, 896)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_dimension_restore_keeps_provider_output_for_single_upload() {
|
||||
let image = image::DynamicImage::new_rgba8(896, 896);
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
let provider_output = DownloadedOpenAiImage {
|
||||
bytes: bytes.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
let expected_bytes = provider_output.bytes.clone();
|
||||
|
||||
let (delivery_output, restore_error) =
|
||||
restore_editor_generated_image_output_dimensions_or_original(
|
||||
provider_output,
|
||||
GPT_IMAGE_2_MODEL,
|
||||
"invalid-size",
|
||||
);
|
||||
|
||||
assert!(restore_error.is_some());
|
||||
assert_eq!(delivery_output.bytes, expected_bytes);
|
||||
assert_eq!(delivery_output.mime_type, "image/png");
|
||||
assert_eq!(delivery_output.extension, "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_dimensions_follow_model_options() {
|
||||
let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K"));
|
||||
@@ -9278,14 +9354,37 @@ mod tests {
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"pub(crate) async fn edit_editor_image_for_owner",
|
||||
"pub async fn generate_editor_icon_spritesheet",
|
||||
"pub async fn remove_editor_image_background",
|
||||
&[
|
||||
"let provider_image",
|
||||
"EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND",
|
||||
"persist_editor_generated_asset",
|
||||
"restore_editor_image_edit_output_dimensions",
|
||||
"restore_editor_image_edit_output_dimensions_or_original",
|
||||
"persist_editor_generated_image",
|
||||
],
|
||||
);
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"pub(crate) async fn generate_editor_image_for_owner",
|
||||
"fn normalize_editor_image_generation_size",
|
||||
&[
|
||||
"let mut image",
|
||||
"restore_editor_generated_image_output_dimensions_or_original",
|
||||
"persist_editor_generated_image",
|
||||
],
|
||||
);
|
||||
assert_function_occurrence_count(
|
||||
source,
|
||||
"pub(crate) async fn edit_editor_image_for_owner",
|
||||
"pub async fn remove_editor_image_background",
|
||||
"persist_editor_generated_image(",
|
||||
1,
|
||||
);
|
||||
assert_function_occurrence_count(
|
||||
source,
|
||||
"pub(crate) async fn generate_editor_image_for_owner",
|
||||
"fn normalize_editor_image_generation_size",
|
||||
"persist_editor_generated_image(",
|
||||
1,
|
||||
);
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||||
@@ -9388,4 +9487,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_function_occurrence_count(
|
||||
source: &str,
|
||||
start: &str,
|
||||
end: &str,
|
||||
snippet: &str,
|
||||
expected_count: usize,
|
||||
) {
|
||||
let start_index = source
|
||||
.find(start)
|
||||
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
|
||||
let tail = &source[start_index..];
|
||||
let end_index = tail
|
||||
.find(end)
|
||||
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
|
||||
let body = &tail[..end_index];
|
||||
assert_eq!(
|
||||
body.matches(snippet).count(),
|
||||
expected_count,
|
||||
"function body starting with {start} should contain {snippet} exactly {expected_count} times"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user