修复画布生图交付与素材成本展示

修复 nanobanana 标量尺寸被误判为宽高的问题。

普通生图和快速编辑在内存完成尺寸变换后仅上传一次,变换失败保留 provider 原图。

移除快速编辑重复的原始输出素材,保留角色、图集和 UI 的独立中间产物。

后台将不重复计费的中间产物显示为不单独计费。

补充单次上传、降级交付和成本展示回归测试及相关文档。
This commit is contained in:
2026-07-15 01:36:09 +08:00
parent 5c1434511b
commit 3f3a6c4ab2
7 changed files with 272 additions and 98 deletions
@@ -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/*`
+211 -90
View File
@@ -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"
);
}
}