Merge branch 'master' into feat/canvas-undo-protection
This commit is contained in:
@@ -2569,7 +2569,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "图片 Data URL。quick-edit 最多 9 张,其它模式最多使用前 5 张。"
|
||||
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多使用前 5 张,数组上限为 9。"
|
||||
},
|
||||
"maxItems": 9
|
||||
},
|
||||
@@ -2691,7 +2691,7 @@
|
||||
},
|
||||
"sourceImageSrc": {
|
||||
"type": "string",
|
||||
"description": "待重绘/调整的图片 Data URL。"
|
||||
"description": "待重绘/调整图片的稳定引用:当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。"
|
||||
},
|
||||
"projectId": {
|
||||
"type": [
|
||||
@@ -2763,7 +2763,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "图片 Data URL、asset://、OSS object key 或公网 URL。"
|
||||
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。"
|
||||
},
|
||||
"maxItems": 8
|
||||
},
|
||||
@@ -2892,13 +2892,13 @@
|
||||
"properties": {
|
||||
"referenceImageSrc": {
|
||||
"type": "string",
|
||||
"description": "规范图/参考图 Data URL 或 objectKey;上传参考图优先提交 objectKey。"
|
||||
"description": "图标规范的稳定引用:当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。"
|
||||
},
|
||||
"referenceImageSrcs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "额外图标素材参考图 Data URL 或 objectKey;上传参考图优先提交 objectKey。"
|
||||
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 8 张。"
|
||||
},
|
||||
"maxItems": 8
|
||||
},
|
||||
@@ -2988,7 +2988,7 @@
|
||||
"properties": {
|
||||
"sourceImageSrc": {
|
||||
"type": "string",
|
||||
"description": "UI 设计图 Data URL 或 objectKey。"
|
||||
"description": "带红框标注的 UI 设计图稳定引用:当前账号的 objectKey、项目资源 ID 或素材 ID;浏览器内合成后必须先上传 OSS 再提交。禁止 Data URL / Blob URL。"
|
||||
},
|
||||
"screenColor": {
|
||||
"type": [
|
||||
@@ -3006,7 +3006,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "额外 UI 素材参考图 Data URL 或 objectKey;上传参考图优先提交 objectKey。"
|
||||
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 5 张。"
|
||||
},
|
||||
"maxItems": 5
|
||||
},
|
||||
@@ -3273,7 +3273,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"sourceImageSrc": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "角色源图的稳定引用:当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。"
|
||||
},
|
||||
"sourceWidth": {
|
||||
"type": "integer",
|
||||
@@ -3557,7 +3558,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "公网 URL、asset://、OSS object key 或图片 Data URL。仅 Seedance 2.0 系列支持。"
|
||||
"description": "公网 URL、asset://、当前账号的 objectKey、项目资源 ID 或素材 ID。本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。仅 Seedance 2.0 系列支持。"
|
||||
},
|
||||
"maxItems": 9
|
||||
},
|
||||
@@ -3565,7 +3566,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "公网 URL、asset:// 或 OSS object key。仅 Seedance 2.0 系列支持。"
|
||||
"description": "公网 URL、asset://、当前账号的 objectKey、项目资源 ID 或素材 ID。本地临时视频必须先上传 OSS。禁止 Data URL / Blob URL。仅 Seedance 2.0 系列支持。"
|
||||
},
|
||||
"maxItems": 3
|
||||
},
|
||||
@@ -3573,7 +3574,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "公网 URL、asset://、OSS object key 或音频 Data URL。仅 Seedance 2.0 系列支持。"
|
||||
"description": "公网 URL、asset://、当前账号的 objectKey、项目资源 ID 或素材 ID。本地临时音频必须先上传 OSS。禁止 Data URL / Blob URL。仅 Seedance 2.0 系列支持。"
|
||||
},
|
||||
"maxItems": 3
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-18 图片生成 K 档由 provider 直接生成
|
||||
|
||||
- 背景:旧 gpt-image-2 尺寸表会把 2K 竖版回落到 `1024x1536`,图标入口又使用固定 `360x360 / 512x512` 占位;角色去背景结果变小时还会直接放大整张透明成品,导致 UI 显示的 2K 与模型实际生成清晰度不一致。
|
||||
@@ -23,6 +24,21 @@
|
||||
- 验证方式:前端尺寸矩阵和入口占位测试、api-server 生成参数与 alpha 合成测试、platform-image 最终 request body 测试、类型检查、Rust check、编码和 diff 门禁。
|
||||
- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-07-18 阿里云 URL 抠图链路按外部调用阶段审计
|
||||
|
||||
- 背景:阿里云 URL 抠图先从源 OSS GET,再解码、校验尺寸、归一化并上传临时 OSS;此前解码和尺寸失败仍使用普通 `InvalidRequest`,被错误标记为 `externalCallAttempted=false`,无法满足阿里云失败统一审计约定。
|
||||
- 决策:真正开始外部调用前的本地预检不写 `external_api_call_failure`;源 OSS GET 成功后发生的解码、尺寸、归一化、临时上传、阿里云请求和结果处理失败均进入审计。`platform-matting` 使用结构化 `LocalProcessing` 分类和 `failureStage`,由 api-server 映射为 `source_decode`、`source_validate`、`source_normalize`、`temp_upload`、`result_decode` 等阶段,不再把这些错误统称为“发请求前本地预检”。
|
||||
- 影响范围:`server-rs/crates/platform-matting/src/lib.rs`、`server-rs/crates/api-server/src/aliyun_matting.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/editor_project.rs`、后端架构文档。
|
||||
- 验证方式:运行 `cargo test -p platform-matting --manifest-path server-rs/Cargo.toml`、阿里云抠图与外部审计定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
|
||||
## 2026-07-18 手动去背景稳定媒体引用校验收口
|
||||
|
||||
- 背景:当前分支与 `master` 分别增加手动去背景专用 Data URL 校验和编辑器通用稳定媒体引用校验,直接叠加会让 API 与 worker 重复执行语义相同的 helper,并造成 `data:` / `blob:` 覆盖范围和错误文案漂移。
|
||||
- 决策:删除手动去背景专用校验。HTTP API 在入队前统一调用 `ensure_editor_reference_image_source_is_stable`,立即拒绝 `data:` / `blob:`;worker 不重复调用该入口校验,只通过 `resolve_editor_reference_object_key_for_owner` 完成稳定引用解析和 owner 归属校验。底层 `resolve_editor_reference_object_key` 在尝试 object key、项目资源 ID 或素材 ID 解析前统一拒绝内联媒体,作为历史任务和内部直接调用的最终边界。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、图片画布手动去背景测试和图片画布技术方案;不改变队列 DTO、BgFilter `image_url` 协议或 SpacetimeDB 的编辑器任务 payload 门禁。
|
||||
- 验证方式:覆盖 API 入队前拒绝 `data:` / `blob:`、解析器拒绝内联媒体、worker 只调用稳定引用解析与归属校验;运行 api-server 编辑器定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-07-17 画布 Agent 普通消息不提供客户端停止
|
||||
|
||||
- 背景:普通消息进入 LLM 前,后端已经把用户消息写入 OSS;前端中断 fetch 只能停止本地等待,不能保证后端停止规划,且会保留无法与后端消息对齐的 optimistic message。
|
||||
@@ -39,14 +55,21 @@
|
||||
- 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、`npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`。
|
||||
|
||||
## 2026-07-18 图片多产物任务的中间原图不进入正常完成画布
|
||||
## 2026-07-18 图片多产物任务的原图进入正常完成画布
|
||||
|
||||
- 背景:角色形象、图标 spritesheet 和 UI 素材提取会同时持久化带纯色背景的 provider 原图与透明后处理结果。正常成功时若把两者都写入 `canvasCompletion`,用户执行一次任务会在画布看到两张近似图片,误以为重复生成;任务与扣费实际仍只有一次。
|
||||
- 决策:provider 原图继续写入 OSS、`asset_object`、项目资源和账号素材库,保留追溯与后处理失败恢复能力,但正常成功的画布只放透明主结果;图标和 UI 在主图集后仍可放业务上明确需要的拆分素材。只有透明背景处理最终失败时,才把 provider 原图作为唯一主图完成占位并返回 warning。
|
||||
- 背景:角色形象、图标 spritesheet 和 UI 素材提取会同时持久化带纯色背景的 provider 原图与透明后处理结果;这些原图都需要在画布中可直接查看和复用。任务与扣费实际仍只有一次。
|
||||
- 决策:provider 原图继续写入 OSS、`asset_object`、项目资源和账号素材库。三类任务透明处理正常成功时,透明结果作为主图并保持生成器 `generatedLayerId` 锚点,provider 原图作为第二个图层放在透明主结果右侧;图标和 UI 的业务拆分素材从原图右侧继续排列。只有透明背景处理最终失败时,才把 provider 原图作为唯一主图完成占位并返回 warning。
|
||||
- 影响范围:角色形象、图标 spritesheet、UI 素材提取的画布完成快照,以及多产物持久化与画布展示边界。
|
||||
- 验证方式:后端定向测试断言角色正常成功只落一个透明角色图层、图标 / UI 不把 provider 原图加入画布、无切片时只落透明主图集,同时保留 source-only 失败降级测试;并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 验证方式:后端定向测试断言三类任务正常成功都落透明主图与右侧 provider 原图、`generatedLayerId` 仍指向透明主图,图标 / UI 拆分素材继续排列在原图右侧,同时保留 source-only 失败降级测试;并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-07-17 生成后抠图原图以 OSS 作为内存生命周期边界
|
||||
|
||||
- 背景:角色形象、图标图集、UI 素材图集和角色动作抽取帧的带背景原图虽然已先落私有 OSS,但 api-server 仍可能把原图字节保留到 BgFilter / 阿里云 / 本地 fallback 结束,造成并发任务下的内存峰值叠加。
|
||||
- 决策:目标链路的带背景原图上传 OSS 时消费 `DownloadedImage` 或动作帧字节所有权,不为上传克隆整张字节缓冲;上传完成后不再跨 BgFilter 调用常驻。手动去背景直接复用已有 OSS object key,不下载原图。BgFilter 只读取 600 秒签名 URL;进入阿里云 fallback 时由 `platform-matting` 新 URL 接口下载原图、上传 `AuthorizeFileUpload` 临时对象,并在开始阿里云推理前结束下载缓冲作用域;阿里云继续失败时,api-server 再从私有 OSS 独立下载原图供本地键色,产出后释放本次原图下载缓冲。
|
||||
- 边界:不改变接口 DTO、资源记录、画布原图展示、图集切分行为和降级顺序;“释放”指 Rust 所有权和 `Vec<u8>` 析构,RSS 不保证同步下降。
|
||||
- 验证方式:`platform-matting` 测试覆盖 URL 下载缓冲在临时上传后结束、降尺寸 Alpha 回贴;`api-server` 结构测试覆盖带背景原图 owned 上传、URL 阿里云 fallback、本地重新下载与原图释放;随后运行两个 crate 的测试与编译检查。
|
||||
|
||||
## 2026-07-17 图片改造保持源图与所选清晰度
|
||||
|
||||
- 背景:图片画布从已生成的 2K 角色图重新打开生成器时,面板恢复逻辑会优先采用新建面板的 1K 默认值;即使用户重新选择 2K,角色透明化链路也可能接受 BgFilter / 阿里云返回的 1K 后处理图,并因 `nanobanana2` 使用标量清晰度档位而跳过几何尺寸恢复,最终把 2K provider 原图降为 1K 透明图。
|
||||
@@ -55,6 +78,21 @@
|
||||
- 验证方式:覆盖“持久化 2K 角色图重开仍为 2K”“普通图片 / 角色 / 图标 / UI 改造的 2K 占位与目标一致”“普通生图、规范图和角色图生成中占位不回退 1K”“UI 提取和旧修改入口的完成占位使用业务目标尺寸”以及“较小去背结果只提供 alpha、最终 RGB 仍来自 2K provider 原图”的前后端定向测试,并运行前端类型检查、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 2026-07-15 BgFilter 输入改用私有 OSS 短期签名 URL
|
||||
|
||||
- 背景:角色形象、图标图集、UI 素材图集、角色动作抽取帧和手动去背景在调用 BgFilter 前都已有私有 OSS object key;继续由 api-server 下载或保留图片并作为 multipart `file` 再上传,会重复传输图片字节并占用 API 进程网络与内存。
|
||||
- 决策:上述抠图链路统一复用 object key,签发 600 秒 OSS GET URL,并通过 BgFilter multipart 的 `image_url` 字段提交;请求中不再携带 `file`。签名 URL 只交给 BgFilter,不写日志或持久化。2026-07-17 起,生成原图和动作帧上传后不再保留图片字节;进入“阿里云通用抠图 → 本地键色”兜底链时按阶段从私有 OSS 重新下载。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、相关测试与文档;不改变 BgFilter endpoint、鉴权、`screen_color`、`seg_model`、输出校验、熔断规则、阿里云上传协议或降级顺序。
|
||||
- 验证方式:定向测试必须断言 BgFilter 请求函数包含 `image_url` 与 600 秒 OSS 换签,不包含 multipart `file` 或源图字节读取;随后运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||
|
||||
## 2026-07-15 阿里云通用抠图上传切换到 AuthorizeFileUpload 正式链路
|
||||
|
||||
- 背景:`platform-matting` 原先通过 `viapiutils/GetOssStsToken` 获取临时 AK/SK,再向固定 `viapi-customer-temp` 共享桶执行 OSS V1 PUT。阿里云官方文档将该显式生成 URL 的共享临时桶通道标记为不保证 SLA、仅便于调试且不推荐生产使用;动作视频逐帧抠图会把这条风险放大到每任务 32 至 48 次。
|
||||
- 决策:非上海地域图片字节统一按新版官方 SDK `AdvanceRequest` 的实际协议处理:调用 `openplatform.aliyuncs.com` 的 `AuthorizeFileUpload` 获取单对象 `Bucket`、`Endpoint`、`AccessKeyId`、`EncodedPolicy`、`Signature` 与 `ObjectKey`;再以 multipart Policy POST 上传到动态返回的上海临时 OSS,表单字段为 `OSSAccessKeyId`(= AccessKeyId)、`policy`(= EncodedPolicy)、`Signature`、`key`(= ObjectKey)、`success_action_status=201` 与 `file`,最后把临时对象 URL 交给 `SegmentCommonImage`。移除 `GetOssStsToken`、固定 `viapi-customer-temp`、AccessKeySecret/SecurityToken 临时凭证组合和 OSS V1 SHA-1 签名;Policy POST 仍需要授权响应中的 `AccessKeyId`,不再下发可独立签名的完整临时密钥。图片归一化、结果下载、原尺寸 Alpha 回贴和上层降级顺序保持不变。该链路仍会让图片字节经过执行任务的 api-server / worker 并上传临时 OSS,不把它描述成阿里云服务端直接抓取任意公网 URL。
|
||||
- 影响范围:`server-rs/crates/platform-matting`、阿里云抠图冒烟示例、后端架构与开发运维文档;不改变 api-server DTO、动作拆帧、BgFilter 或业务降级契约。
|
||||
- 验证方式:`cargo test -p platform-matting --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,并用真实图片运行 `segment_smoke`,确认授权上传 host 来自动态上海 OSS 且 `SegmentCommonImage` 成功返回。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、阿里云“通用图像分割”与“文件 URL 处理”官方文档。
|
||||
|
||||
## 2026-07-15 角色动作 BgFilter 请求超时按帧数扩展
|
||||
|
||||
- 背景:角色动作全部序列帧会并发进入 BgFilter,而服务端可能在自身进程内排队;固定 `180000ms` 会把排队时间和单帧推理共用同一预算,靠后的请求可能在服务仍正常处理时被 api-server 提前取消。
|
||||
@@ -73,6 +111,8 @@
|
||||
|
||||
## 2026-07-14 手动去背景迁移到 BgFilter complex 模式
|
||||
|
||||
> 后续更正:本条关于 multipart 图片文件输入的描述已由 2026-07-15「BgFilter 输入改用私有 OSS 短期签名 URL」和 2026-07-17「生成后抠图原图以 OSS 作为内存生命周期边界」取代;当前手动路径不下载原图,只提交 `image_url`。下文保留作历史记录。
|
||||
|
||||
- 背景:图片画布手动“去除背景”此前单独代理 BiRefNet 服务;BgFilter 已增加 `background_mode=complex`,可直接处理非纯色背景,继续保留独立服务会形成重复的上游、配置和错误处理链路。
|
||||
- 决策:`POST /api/editor/images/background-removals` 保持前端与 BFF 契约不变,worker 改用现有 BgFilter 地址、token、超时和共享 HTTP client。multipart 提交图片文件、`background_mode=complex`、`seg_model=birefnet` 与 `cross_check=off`,不提交 `screen_color`。标准纯色背景的角色形象、图标 spritesheet、UI 素材提取和角色动作逐帧抠图继续使用 `background_mode=flat`。删除独立 BiRefNet base URL / timeout 配置;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 仅作为 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 的兼容回退别名。
|
||||
- 影响范围:图片画布手动去背景 worker、BgFilter HTTP 协议、api-server 配置、资源元数据、前端 provider 展示和相关文档。
|
||||
@@ -135,7 +175,7 @@
|
||||
|
||||
- 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。
|
||||
- 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时保留纯色背景原图与透明后处理结果;透明背景处理最终失败时只保留已经持久化的 provider 原图,并按下一条降级规则收口。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。
|
||||
- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果,provider 原图只保留在项目资源和账号素材库,不作为第二张近似图片进入画布;图标和 UI 的业务拆分素材仍可随透明主图集进入画布。角色形象、图标 spritesheet 或 UI 素材提取已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。
|
||||
- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果,角色形象、图标 spritesheet 和 UI 素材提取都把 provider 原图作为第二个图层放在透明主结果右侧;图标和 UI 的业务拆分素材从原图右侧继续排列。三类任务已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。
|
||||
- 2026-07-16 告警契约补充:inline / external v1 继续返回结构化原始诊断;queue 有意把通用 `warning` 或 `sliceWarning` 归一为展示就绪字符串,通用 `warning.reason` 原样保留,`sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀,摘要与 BFF 原样投影,Web 直接展示。历史值保留写入时快照,不按新格式回填或推断;该内部字符串契约通过 API/worker 与 Web 同一维护窗口、同版本发布收口,不增加混部兼容层。
|
||||
- 影响范围:`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`。
|
||||
@@ -144,7 +184,7 @@
|
||||
## 2026-07-12 泥点充值收敛为四档并统一资产入口
|
||||
|
||||
- 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。
|
||||
- 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。
|
||||
- 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。钱包明细每次展开都重新读取充值中心 BFF,打开期间实时总额变化时继续补读;图片画板的生成扣费或退款完成后同时刷新总额与充值中心拆分。充值中心读请求必须使用 revision 门禁,支付创建、到账确认等权威响应写入时使旧读失效,避免旧响应覆盖新的每日免费 / 不限时明细。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。
|
||||
- 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。
|
||||
- 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
@@ -264,6 +304,8 @@
|
||||
|
||||
## 2026-07-05 BgFilter 失败时本地纯色去背兜底
|
||||
|
||||
> 后续更正:本条关于手动去背景仍使用独立 BiRefNet BFF 的描述已由 2026-07-14「手动去背景迁移到 BgFilter complex 模式」、2026-07-15 OSS 签名 URL 决策和 2026-07-17 内存生命周期决策取代。下文保留作历史记录。
|
||||
|
||||
- 背景:曾用一次性 BgFilter live probe 稳定复现 BgFilter 对 2K 输入返回 `HTTP 500 {"detail":"inference failed"}`,浏览器生成链路会因此收到“BgFilter 服务返回非成功状态”。
|
||||
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取仍优先调用独立 BgFilter;若 BgFilter 请求失败、返回非成功状态、返回空图片或非法图片,api-server 记录 warning 后使用本地 `editor_green_screen` 按解析后的纯色背景执行确定性去背兜底,不中断生成。手动任意图片去背景仍只走独立 BiRefNet BFF,不使用该兜底。
|
||||
- 更正(截至 2026-07-10 实现):BgFilter 失败/熔断后不再直接本地兜底,而是先走阿里云通用抠图,仅阿里云也失败才本地 `editor_green_screen` 键色兜底;口径统一见 2026-07-09「角色动作视频…阿里云抠帧」决策,并已扩展到本条的角色形象/图标/UI 三条静态生图链路。
|
||||
@@ -273,6 +315,8 @@
|
||||
|
||||
## 2026-07-03 图片画布生成纯色背景资产接入 BgFilter
|
||||
|
||||
> 后续更正:本条关于 multipart `file`、手动去背景独立 BiRefNet 配置以及 BgFilter 失败后直接本地兜底的描述,已分别由 2026-07-14、2026-07-15 OSS 签名 URL 决策和 2026-07-17 内存生命周期决策取代。下文保留作历史记录。
|
||||
|
||||
- 背景:独立 BgFilter 服务已部署在 image host,并提供 `POST /bgfilter/remove-background`,支持显式 `screen_color` 和 `seg_model`。手动去背景已有独立 BiRefNet BFF,不能把两个服务的配置或语义混在一起。
|
||||
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file`、`screen_color=<screenColor>` 和内部固定的 `seg_model=birefnet`。`segModel` 虽是后端可识别的内部兼容字段(另保留 `anime-seg`),但不向用户或外部 OpenAPI 暴露:当前 BgFilter 的内存与并发容量不适合由调用方自由切换模型。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时 `180000ms`,token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底;连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接本地兜底。角色动作背景色和抠帧口径已由 2026-07-09 决策取代:角色动作同样使用多色自动决策,抽帧后优先阿里云通用抠图,失败再按选定背景色本地兜底。更正(截至 2026-07-10 实现):BgFilter 失败与熔断期本条描述的「直接本地兜底」已过时——角色形象/图标/UI 三条静态生图链路同样先走阿里云通用抠图,仅阿里云也失败才本地 `editor_green_screen` 兜底。
|
||||
- 影响范围:`server-rs/crates/api-server/src/config.rs`、`server-rs/crates/api-server/src/editor_project.rs`、图片画布 MVP 文档和角色形象生成设计文档。
|
||||
@@ -330,6 +374,8 @@
|
||||
|
||||
## 2026-06-29 图片画布手动抠图走远端 BiRefNet BFF
|
||||
|
||||
> 后续更正:本条独立 BiRefNet 服务、专用 base URL 以及 api-server 下载并解析原图的实现,已由 2026-07-14「手动去背景迁移到 BgFilter complex 模式」、2026-07-15 OSS 签名 URL 决策和 2026-07-17 内存生命周期决策取代。下文保留作历史记录。
|
||||
|
||||
- 背景:用户手动“去除背景”面对任意图片,前端 `chromaKey` 和标准绿幕后处理不适合复杂人物、自然背景或非纯色背景;远端 image host 已部署 BiRefNet 服务,需要让手动抠图走高质量模型,同时避免把服务令牌暴露到浏览器。
|
||||
- 决策:画布手动“去除背景”默认调用登录态同源 BFF `POST /api/editor/images/background-removals`。api-server 解析当前图片后代理到 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认指向 `http://58.87.105.82/remove-background`,可选 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只在服务端注入。api-server 对上游结果做字节和尺寸上限保护,并先落 OSS / asset object 再返回给前端。编辑器自己生成的标准绿幕资产不属于该决策,见 2026-06-30 绿幕契约收口。
|
||||
- 影响范围:api-server 编辑器图片接口、图片画布手动去背景、画布右上角任务侧栏、图片画布 MVP 技术文档。
|
||||
@@ -460,13 +506,15 @@
|
||||
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
|
||||
|
||||
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
|
||||
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。
|
||||
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放 provider 原图和拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库和画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分素材从 provider 原图右侧继续排列。拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。
|
||||
- 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。
|
||||
- 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 2026-06-30 图片画布标准绿幕契约收口
|
||||
|
||||
> 后续更正:本条关于生成资产只使用本地透明化、手动路径继续使用独立 BiRefNet 的描述,已由 2026-07-14、2026-07-15 OSS 签名 URL 决策和 2026-07-17 内存生命周期决策取代;当前 flat 链路为 `BgFilter → 阿里云 → 本地键色`。下文保留作历史记录。
|
||||
|
||||
- 背景:角色形象、图标 spritesheet、UI 提取 spritesheet 和角色动作帧都要求模型生成标准绿幕,但提示词片段和后处理入口分散在多个模块中,容易把标准绿幕资产误接到远端 BiRefNet。
|
||||
- 决策:编辑器标准绿幕提示词和本地确定性绿幕透明化统一收口到 `server-rs/crates/api-server/src/editor_green_screen.rs`。手动 `POST /api/editor/images/background-removals` 继续面向用户任意图片并走 BiRefNet;编辑器自己生成的标准绿幕资产统一复用 `platform-image::generated_asset_sheets` 的本地透明化能力,不再依赖 BiRefNet。角色图、图标 spritesheet、UI 提取 spritesheet 和角色动作抽帧源图必须在绿幕透明化前先保存一份带绿幕原图到 OSS,便于追溯和重处理。
|
||||
- 影响范围:`editor_project.rs` 的角色图 / 图标图集 / UI 提取图集、`character_animation_assets.rs` 的编辑器角色动作帧、编辑器绿幕相关文档。
|
||||
@@ -2427,7 +2475,7 @@
|
||||
## 2026-05-07 server-rs Cargo 依赖集中到 workspace
|
||||
|
||||
- 背景:`server-rs` 多 crate 已稳定成 DDD workspace,成员 `Cargo.toml` 中重复散写第三方版本和本地 path 依赖,升级 SpacetimeDB SDK、`serde`、`reqwest`、`tokio` 等依赖时容易漂移。
|
||||
- 决策:`server-rs/Cargo.toml` 的 `[workspace.dependencies]` 统一维护第三方依赖版本和 workspace 内部 crate path;成员 crate 默认使用 `{ workspace = true }`,只保留自身 feature、optional 或 target-specific 差异;OSS 与阿里云 OpenAPI 签名统一走 `sha2::Sha256` 对应的 V4/V3 口径。例外:`platform-matting` 向 VIAPI 官方临时 OSS 桶上传抠图输入时,因该临时桶接口要求 OSS V1 头签名,允许在该 crate 内部受限使用 `sha1` / `Hmac<sha1::Sha1>` 生成 V1 签名;该例外不得扩展到自有 OSS、通用阿里云 OpenAPI 或其它新链路。
|
||||
- 决策:`server-rs/Cargo.toml` 的 `[workspace.dependencies]` 统一维护第三方依赖版本和 workspace 内部 crate path;成员 crate 默认使用 `{ workspace = true }`,只保留自身 feature、optional 或 target-specific 差异;OSS 与阿里云 OpenAPI 签名统一走 `sha2::Sha256` 对应的 V4/V3 口径。2026-07-15 起,`platform-matting` 已移除 VIAPI 共享临时桶的 OSS V1 SHA-1 例外,改用 `AuthorizeFileUpload` 返回的 Policy POST 授权;后续不得恢复 crate 内 SHA-1 签名。
|
||||
- 影响范围:`server-rs/Cargo.toml`、所有 `server-rs/crates/*/Cargo.toml`、`platform-oss`、`platform-auth`、后续新增 Rust crate 或新增 Rust 依赖的开发流程。
|
||||
- 验证方式:修改 Cargo 配置后先执行 `cargo metadata --manifest-path server-rs\Cargo.toml --format-version 1 --no-deps`,再按影响范围执行 `cargo check`、DDD 边界检查和编码检查。
|
||||
- 关联文档:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`。
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
- 验证:覆盖 nanobanana2 / gpt-image-2 的比例与 K 档尺寸矩阵、VectorEngine 最终请求体、普通图片 / 角色 / 图标 / UI 占位,以及低分辨率去背景结果只贡献 alpha、不贡献被放大的 RGB。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/vector_engine/request.rs`。
|
||||
|
||||
## 多产物任务保留中间原图不等于把中间原图放进画布
|
||||
## 多产物任务的中间原图不能只落资源库
|
||||
|
||||
- 现象:角色形象只提交一次、任务列表和泥点也只记录一次,但画布同时出现透明角色与纯色背景角色;图标或 UI 生成也可能多出一张纯色背景图集。
|
||||
- 原因:后端为了可追溯和后处理失败恢复而持久化 provider 原图时,又把它与透明主结果一起传入同一次 `canvasCompletion`,把“资源必须保留”错误等同为“画布必须展示”。
|
||||
- 处理:provider 原图继续写 OSS、项目资源和账号素材库;正常成功的画布只放透明主结果及明确需要的图标 / UI 拆分素材。仅当透明处理最终失败时,才用 provider 原图作为唯一主图完成占位。
|
||||
- 验证:覆盖角色正常成功只落一个图层、图标 / UI 正常成功不落 provider 原图、无切片时只有透明主图集,以及透明处理失败仍由原图单独完成占位。
|
||||
- 现象:角色形象、图标 spritesheet 或 UI 素材提取正常成功后,画布只有透明结果,用户无法直接对照和复用 provider 原图。
|
||||
- 原因:后端虽然为追溯和后处理失败恢复持久化了 provider 原图,但完成画布只写入透明主结果和拆分素材,把“资源已保存”错误等同为“用户已拿到原图”。
|
||||
- 处理:provider 原图始终写入 OSS、项目资源和账号素材库;三类任务正常成功时都同时落透明主结果与右侧 provider 原图,生成器 `generatedLayerId` 仍指向透明主结果,图标 / UI 拆分素材从原图右侧继续排列。仅当透明处理最终失败时,才用 provider 原图作为唯一主图完成占位。
|
||||
- 验证:覆盖三类任务正常成功都落透明结果与原图、原图位于透明图右侧、`generatedLayerId` 指向透明图、图标 / UI 拆分素材位于原图右侧,以及透明处理失败仍由原图单独完成占位。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
## phase 上报的业务拒绝与传输失败不能共用字符串错误
|
||||
@@ -130,8 +130,8 @@
|
||||
## 画板参考图 objectKey 必须先做归属校验
|
||||
|
||||
- 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。
|
||||
- 原因:Data URL 参考图可以直接解析,但 objectKey 是服务端私有对象引用;只校验 generated 前缀、mime 和大小不能证明它属于当前账号。
|
||||
- 处理:所有编辑器参考图入口统一走 `parse_editor_reference_image(state, owner_user_id, source)`;objectKey 分支必须先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key,再读取 OSS。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。
|
||||
- 原因:Data URL/Blob URL 只允许停留在浏览器临时态,正式编辑器引用必须先上传并经统一 resolver 校验归属。
|
||||
- 处理:所有私有对象引用在读取字节或签发 URL 前统一走 `resolve_editor_reference_object_key_for_owner(state, owner_user_id, source)`,先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key。只有确实需要图片字节的入口(生成 / 重绘 / 图标 / UI 提取等交给 provider 的路径)再走 `parse_editor_reference_image`(内部仍先 resolve,再下载 OSS 字节);手动去背景等只签发短期 URL 的入口不要 `parse` 整图。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。
|
||||
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入图标生成请求;若快速编辑重开额外参考图,再补对应请求覆盖。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。
|
||||
|
||||
@@ -3189,3 +3189,11 @@
|
||||
- 处理:在调用 `try_with_tx` 前通过 `let caller = ctx.sender()` 捕获真实调用者,再把 `caller` 显式传入事务函数;迁移、后台账号、runtime profile、外部生成等现有 procedure 已采用这一模式。`npm run check:spacetime-runtime-access` 禁止编辑器 runtime writer 鉴权重新直接读取事务 `ctx.sender()`。运行环境升级到 2.6.1 后继续保留显式 caller,避免鉴权依赖 SDK 版本细节。
|
||||
- 验证:运行 `npm run check:spacetime-runtime-access`、`cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`;发布使用 SpacetimeDB 2.6.1 构建的新模块后,以 runtime writer identity 重试 `POST /api/editor/icon-spritesheets/slices`,确认来源查询、分片素材写入与 cohort 完成不再返回 identity 403。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`scripts/check-spacetime-runtime-access.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## Portal 弹窗必须在遮罩根节点携带平台主题
|
||||
|
||||
- 现象:项目库点击“重命名”后,标题、输入框和按钮仍显示,但弹窗面板及遮罩背景变透明,看起来像“改名界面的背景没了”。
|
||||
- 原因:`UnifiedModal` 默认 portal 到 `document.body`;若业务入口只在页面内层继承 `platform-theme`,portal 根节点不会继承该容器的 CSS 变量。此时 `.platform-modal-shell` 的 `background: var(--platform-modal-fill)` 和 `.platform-overlay` 的背景声明都会失效。
|
||||
- 处理:平台白底工具弹窗优先复用 `PlatformToolModalShell`,由共享壳读取当前 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--<light|dark>` 挂到 portal overlay;不要用硬编码白底掩盖主题变量缺失。必须直接使用 `UnifiedModal` 的特殊场景,也要在 `overlayClassName` 显式传递当前平台主题。
|
||||
- 验证:在 light / dark 主题下打开 portal 弹窗,断言 dialog 的 overlay 携带对应主题类,并在真实浏览器核对 panel 与遮罩的 computed background 均非透明。
|
||||
- 关联:`src/components/project/ProjectGalleryView.tsx`、`src/components/common/PlatformToolModalShell.tsx`、`src/components/common/UnifiedModal.tsx`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -197,7 +197,7 @@ controller 配置:
|
||||
|
||||
角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。
|
||||
|
||||
透明背景处理正常成功时,provider 原图仍保留在项目资源与账号素材库中,但不作为第二张近似图片进入画布;角色画布只放透明主结果,图标和 UI 画布放透明主图集以及实际拆分出的业务素材。这样一次任务仍能保留全部可恢复产物,同时不会让用户误以为请求执行或扣费了两次。
|
||||
透明背景处理正常成功时,角色形象、图标 spritesheet 和 UI 素材提取的画布都同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧;图标和 UI 实际拆分出的业务素材从 provider 原图右侧继续排列。
|
||||
|
||||
inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:通用 `warning` 优先并原样保留完整 `reason`;只有不存在通用 `warning` 时,才给 `sliceWarning.reason` 添加“图集已生成,但自动拆分未完成:”前缀。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -68,7 +68,11 @@ HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer
|
||||
lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。
|
||||
|
||||
图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter;当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,该值是所有路径的基准请求超时;角色动作逐帧请求的每一次 HTTP attempt 额外增加 `2000ms × 本次实际帧数`,默认 `32 / 40 / 48` 帧对应 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景继续使用基准值。该 request timeout 不是整批帧或整项任务超时,首次失败后的重试会重新计时;角色动作整项任务仍受默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800` 预算约束,排查时以 `editor_bgfilter_request_start.timeout_ms` 确认实际值。连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒。BgFilter 调用失败和熔断期均先走阿里云通用抠图,只有阿里云失败才走本地幕布色去背景兜底。阿里云这层默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,抠图直接塌成 BgFilter→本地两级,`npm run check:api-server-env` 也会给出对应告警。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看带 `background_mode=flat` 的 `editor_bgfilter_request_start`、`editor_bgfilter_fallback_to_aliyun_matting`、`editor_bgfilter_circuit_open_fallback_to_aliyun_matting`,以及阿里云失败后的 `editor_aliyun_matting_fallback_to_local_screen_background_removal` 日志。
|
||||
手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传背景色;首次失败后立即重试 `1` 次,两次都失败则返回最终错误。它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链,也不改变 flat 路径的熔断状态。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。
|
||||
手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但 worker 只解析并校验已有 OSS object key,直接签发 600 秒 URL,不下载原图;multipart 固定传 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或背景色。首次失败后立即重试 `1` 次,两次都失败则返回最终错误。它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链,也不改变 flat 路径的熔断状态。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。
|
||||
|
||||
阿里云通用抠图的非上海地域输入使用 `AuthorizeFileUpload → Policy POST → SegmentCommonImage` 正式链路,上传 Bucket / Endpoint / ObjectKey 由阿里云动态返回;不得恢复 `GetOssStsToken`、固定 `viapi-customer-temp`、临时 AK/SK 或 OSS V1 PUT。该切换不新增环境变量;真实链路冒烟可运行 `cargo run -p platform-matting --example segment_smoke --manifest-path server-rs/Cargo.toml -- <图片路径>`,预期日志中的输入 host 为授权响应返回的上海 OSS host,并完成结果下载。图片字节仍经过执行任务的 api-server / worker,排障时不要把 Advance 路径误判为阿里云直接抓取任意公网 URL。
|
||||
|
||||
BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去背景源图直接使用 600 秒签名 URL:`api-server` 的 multipart 提交 `image_url` 及对应模式参数,不再提交 `file`,也不会在 BgFilter 调用前重新下载 OSS 对象。生成原图和动作帧上传完成后应已消费并释放字节所有权;手动路径从始至终不读取原图字节。flat 路径的 BgFilter 失败或熔断打开后,阿里云 fallback 才单独下载源对象并上传动态临时桶,临时上传完成即释放本次下载缓冲;阿里云继续失败时本地 fallback 再独立下载,并在本地处理产出后释放本次原图缓冲。排障日志只应出现 object key 与签名有效期,不得记录带 `x-oss-*` 查询参数的完整 URL。这里的释放是 Rust 缓冲析构,不以操作系统 RSS 立即下降作为判据。
|
||||
|
||||
`我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U
|
||||
- 宣发素材请求携带 `kind: publication-material`,前端提交和后端 handler 都固定归一为 `gpt-image-2`;即使旧前端或外部请求传入 `nanobanana2`,后端也按 `gpt-image-2` 生成和计费,但价格仍来自运行时模型定价配置,不写死数值。
|
||||
- `publication-detail-gallery` 不再携带 `candidateCount: 5`;后端仍兼容多候选请求,但当前宣发素材入口不主动批量生成。
|
||||
- 尺寸请求和成品交付规格必须使用明确像素值:游戏首图 `720x540`、详情图 `720x1280`、运营海报 `1280x720`。这些值是画布图层与成品的业务规格;由于三种规格并非都满足 `gpt-image-2` 的上游尺寸约束,VectorEngine 适配层会在发送前等比归一到合法请求尺寸(最大边 `3840`、两边为 `16` 的倍数、长短边比不超过 `3:1`、总像素 `655360..8294400`),不能将上游归一后的尺寸当作宣发成品规格。只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名才走 provider 预设映射。
|
||||
- 参考图在提交 `/api/editor/images/generations` 前由前端压缩成适合生成理解的图片 Data URL,避免原图 Data URL 撑爆 JSON 请求体;后端该路由保留 `12MB` body limit 作为兼容兜底。
|
||||
- 参考图在提交 `/api/editor/images/generations` 前必须先落稳定引用:本地图先上传 OSS 并确认 asset object,提交时使用 `objectKey`、项目资源 ID 或素材 ID;禁止把 Data URL / Blob URL 写入生成请求或外部生成队列载荷。浏览器内可对临时图做压缩等处理,但压缩结果也必须上传后再提交。
|
||||
- 扣费通过现有钱包资产操作封装执行;上游生成失败或未返回图片时按现有补偿逻辑退款。
|
||||
- 生成成功后,成品作为图片画布生成图层加入画布,并保留游戏输入和参考图摘要供图层信息使用。
|
||||
|
||||
|
||||
@@ -54,14 +54,14 @@
|
||||
- 框选状态参考微信截图工具栏布局,默认矩形框选,并支持矩形框选、椭圆框选和画笔自由框选三种工具。
|
||||
- 用户可在同一张 UI 设计图上多次框选;未至少框选一个区域时,工具栏 `提取` 按钮不可点击。
|
||||
- 可上传普通参考图辅助 UI 素材提取,用于约束被框选素材的风格、配色或材质;普通参考图会以 `objectKey` 随合成后的红框 UI 设计图一起提交。
|
||||
- 点击 `提取` 后,前端把所有框选的红色轮廓绘入原 UI 设计图,生成合成图片 Data URL,再提交到 `POST /api/editor/ui-designs/assets/extractions`。
|
||||
- 点击 `提取` 后,前端把所有框选的红色轮廓绘入原 UI 设计图生成合成图;Data URL / Blob URL 只允许停留在浏览器临时态,必须先上传 OSS 并确认 asset object,再以返回的 `objectKey`(或项目资源 / 素材 ID)作为 `sourceImageSrc` 提交到 `POST /api/editor/ui-designs/assets/extractions`。
|
||||
- 后端固定使用 `gpt-image-2` 图片编辑链路,并固定提示词:
|
||||
|
||||
```text
|
||||
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
|
||||
```
|
||||
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS、项目资源和账号素材库,再调用 BgFilter,固定传 `background_mode=flat`、`cross_check=off` 并按默认 `segModel=birefnet` 透明化。透明背景处理正常成功时,透明 spritesheet 同样先进入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力;调用方未指定素材文件夹时落默认“项目”文件夹。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲,不克隆保留),写入项目资源和账号素材库;随后只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,默认 `segModel=birefnet`。BgFilter 主路径不重新下载原图;首次失败后立即重试 `1` 次(重试同样重新换签),第二次仍失败进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。透明背景处理正常成功时,透明 spritesheet 同样先进入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力;调用方未指定素材文件夹时落默认“项目”文件夹。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。
|
||||
- UI 素材自动拆分只在透明图集成功后执行,与图标图集一致,属于 best-effort 附加动作。未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`;识别或切片持久化失败仍返回整张透明图集和 `sliceWarning`,前端显示非阻断 warning toast,用户可手动重试。`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。
|
||||
- 正常透明化成功时,前端先把透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分成功的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布;透明背景处理最终失败时只消费后端快照中的 provider 原图。透明图集图层提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。
|
||||
- 图标素材占位图必须按当前模型、比例和 K 档对应的真实 provider 请求像素初始化;切换参数后继续保持占位尺寸与请求尺寸一致,不得用固定 `360x360 / 512x512` 框代替生成目标。
|
||||
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
|
||||
- 透明背景处理正常成功后删除占位态,把后端返回的透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,并把按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材铺到图集右侧;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。
|
||||
- 透明背景处理正常成功后删除占位态:透明 spritesheet 作为主图(`assetKind: "icon-spritesheet"`,`generatedLayerId` 锚点)放入画布,provider 带背景原图作为第二个同类型图层放在透明主图右侧,按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材从原图右侧继续铺放;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。
|
||||
- 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。
|
||||
- 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`。
|
||||
- 请求字段:
|
||||
- `referenceImageSrc`:图标规范 Data URL。
|
||||
- `referenceImageSrc`:图标规范的稳定引用(当前账号的 `objectKey`、项目资源 ID 或素材 ID);本地临时图必须先上传 OSS,禁止 Data URL / Blob URL。
|
||||
- `iconDescriptions`:过滤空文本后的图标描述数组,`1..100`。
|
||||
- `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。
|
||||
- `aspectRatio`:按 `x:y` 展示,选项跟随模型。
|
||||
@@ -59,17 +59,17 @@
|
||||
|
||||
## 去背与保存
|
||||
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;BgFilter multipart 固定传 `background_mode=flat`、`cross_check=off`,请求字段同时包含 `screenColor` 和 `segModel`。前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。
|
||||
- 透明背景处理正常成功时,带背景原图和去背后的透明 spritesheet 都先写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。
|
||||
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲,再签发 600 秒 GET URL 调用 BgFilter 透明化;BgFilter multipart 固定传 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。
|
||||
- 透明背景处理正常成功时,带背景原图和去背后的透明 spritesheet 都先写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。
|
||||
- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。
|
||||
- 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。
|
||||
- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。
|
||||
|
||||
## 前端铺放规则
|
||||
|
||||
- spritesheet 图集放在原占位图位置附近。
|
||||
- 自动拆分素材从图集右侧开始换行铺放;手动拆分使用相同布局,但保留原图集不变。
|
||||
- 生成成功后关闭图标素材面板,选中 spritesheet 图集,并打开图层面板。
|
||||
- 透明 spritesheet 主图放在原占位图位置附近;provider 带背景原图放在透明主图右侧。
|
||||
- 自动拆分素材从 provider 原图右侧开始换行铺放;手动拆分使用相同布局,但保留原图集不变。
|
||||
- 生成成功后关闭图标素材面板,选中透明 spritesheet 主图,并打开图层面板。
|
||||
|
||||
## 验收
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
- 默认 6 个素材描述会进入 prompt;用户在单个文本框中继续输入时最多解析 100 个素材描述。
|
||||
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
|
||||
- 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。
|
||||
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,上传参考图优先提交 `objectKey`,并写入 `generationInputs.references`。
|
||||
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层;透明图集成功但拆分失败时只出现透明图集,透明背景处理最终失败时只出现 provider 原图。
|
||||
- 选中图集图层时显示 `拆分图集`,点击后不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。
|
||||
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。
|
||||
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的按描述命名的独立图标图层;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
|
||||
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
|
||||
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
角色设定:<用户输入的角色设定>
|
||||
```
|
||||
|
||||
- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用共享 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=on`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。首次请求失败后立即重试 `1` 次,第二次仍失败进入“阿里云通用抠图 → 本地键色”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回透明 PNG Data URL 及 `objectKey` / `assetObjectId`。三段透明背景处理最终仍失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。
|
||||
- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲),再对 object key 签发 600 秒 GET URL 调用共享 BgFilter 服务透明化。BgFilter 主路径不重新下载原图;每次 HTTP attempt 重新换签,multipart 字段包含 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=on`,不包含 `file`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。首次请求失败后立即重试 `1` 次,第二次仍失败进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回 `imageSrc: "/<objectKey>"`、`objectKey`、`assetObjectId` 及资源快照,画布同时写入透明主结果和 provider 原图,生成器 `generatedLayerId` 锚定透明主结果,provider 原图作为第二个图层放在其右侧。三段透明背景处理最终仍失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。
|
||||
- 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;透明背景正常成功与最终失败保留 provider 原图的收口规则和角色新生成一致。普通图片图层重绘仍保持 `kind: "quick-edit"`。
|
||||
|
||||
## 生成规范参考图
|
||||
@@ -114,7 +114,7 @@
|
||||
- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。
|
||||
- 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。
|
||||
- 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。
|
||||
- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,最终失败时则保留并返回已经持久化的 provider 原图和通用 warning。若 BgFilter 返回较小图片,只允许把其 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不得放大低分辨率透明成品。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
|
||||
- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,并把透明主结果与其右侧 provider 原图一起写入画布,`generatedLayerId` 仍锚定透明主结果;最终失败时则只保留并返回已经持久化的 provider 原图和通用 warning。若 BgFilter 返回较小图片,只允许把其 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不得放大低分辨率透明成品。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
|
||||
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
|
||||
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
|
||||
- 本次验证命令:
|
||||
@@ -162,7 +162,7 @@
|
||||
|
||||
- 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。
|
||||
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
|
||||
- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再走 `BgFilter(background_mode=flat、seg_model=birefnet、cross_check=on)→ 阿里云通用抠图 → 本地 editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。
|
||||
- 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。BgFilter 主路径不重新下载原帧;失败后走 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。
|
||||
- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。
|
||||
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
|
||||
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
|
||||
|
||||
@@ -57,7 +57,7 @@ test('shows only permanent and daily free points in the shared wallet panel', as
|
||||
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
|
||||
expect(within(details).getByText('27')).toBeTruthy();
|
||||
expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy();
|
||||
expect(onRequestDetails).not.toHaveBeenCalled();
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(within(details).getByRole('button', { name: '使用详情' }));
|
||||
expect(onOpenLedger).toHaveBeenCalledTimes(1);
|
||||
@@ -85,6 +85,61 @@ test('keeps the chip on the live balance when cached details are stale', () => {
|
||||
expect(screen.queryByRole('button', { name: '泥点 207' })).toBeNull();
|
||||
});
|
||||
|
||||
test('refreshes cached balance details while open and after reopening', () => {
|
||||
const onRequestDetails = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={207}
|
||||
breakdown={breakdown}
|
||||
onRequestDetails={onRequestDetails}
|
||||
onRecharge={vi.fn()}
|
||||
onOpenLedger={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
|
||||
fireEvent.mouseEnter(balanceButton);
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={195}
|
||||
breakdown={breakdown}
|
||||
onRequestDetails={onRequestDetails}
|
||||
onRecharge={vi.fn()}
|
||||
onOpenLedger={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(2);
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: '泥点 195' }));
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('requests missing details after the opening balance load completes', () => {
|
||||
const onRequestDetails = vi.fn();
|
||||
const props = {
|
||||
balance: 20,
|
||||
breakdown: null,
|
||||
variant: 'mobile' as const,
|
||||
onRequestDetails,
|
||||
onRecharge: vi.fn(),
|
||||
onOpenLedger: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<PlatformMudPointWalletEntry {...props} isLoading />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '泥点 20' }));
|
||||
expect(onRequestDetails).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<PlatformMudPointWalletEntry {...props} isLoading={false} />);
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps the desktop panel open while moving across the gap without click pinning', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export function PlatformMudPointWalletEntry({
|
||||
}: PlatformMudPointWalletEntryProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const isOpenRef = useRef(false);
|
||||
const requestedDetailsKeyForOpenRef = useRef<string | null>(null);
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isCompact = variant === 'mobile';
|
||||
@@ -74,6 +75,7 @@ export function PlatformMudPointWalletEntry({
|
||||
: formatMudPointCount(displayedBalance, true);
|
||||
const exactBalanceLabel =
|
||||
displayedBalance === null ? '--' : formatMudPointCount(displayedBalance);
|
||||
const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`;
|
||||
|
||||
const cancelPendingClose = useCallback(() => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
@@ -85,9 +87,21 @@ export function PlatformMudPointWalletEntry({
|
||||
const closeDetails = useCallback(() => {
|
||||
cancelPendingClose();
|
||||
isOpenRef.current = false;
|
||||
requestedDetailsKeyForOpenRef.current = null;
|
||||
setIsOpen(false);
|
||||
}, [cancelPendingClose]);
|
||||
|
||||
const requestDetailsIfNeeded = useCallback(() => {
|
||||
if (
|
||||
isLoading ||
|
||||
requestedDetailsKeyForOpenRef.current === detailsRequestKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
requestedDetailsKeyForOpenRef.current = detailsRequestKey;
|
||||
onRequestDetails();
|
||||
}, [detailsRequestKey, isLoading, onRequestDetails]);
|
||||
|
||||
const requestAndOpen = useCallback(() => {
|
||||
cancelPendingClose();
|
||||
if (isOpenRef.current) {
|
||||
@@ -95,13 +109,17 @@ export function PlatformMudPointWalletEntry({
|
||||
}
|
||||
isOpenRef.current = true;
|
||||
setIsOpen(true);
|
||||
if (!breakdown && !isLoading) {
|
||||
onRequestDetails();
|
||||
}
|
||||
}, [breakdown, cancelPendingClose, isLoading, onRequestDetails]);
|
||||
requestDetailsIfNeeded();
|
||||
}, [cancelPendingClose, requestDetailsIfNeeded]);
|
||||
|
||||
useEffect(() => cancelPendingClose, [cancelPendingClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
requestDetailsIfNeeded();
|
||||
}
|
||||
}, [isOpen, requestDetailsIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
@@ -151,9 +169,7 @@ export function PlatformMudPointWalletEntry({
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`platform-mud-point-wallet-entry relative shrink-0 ${className ?? ''}`}
|
||||
onMouseEnter={isCompact ? undefined : requestAndOpen}
|
||||
onMouseLeave={isCompact ? undefined : closeAfterPointerLeaves}
|
||||
onFocusCapture={isCompact ? undefined : requestAndOpen}
|
||||
onBlurCapture={closeAfterFocusLeaves}
|
||||
>
|
||||
<div
|
||||
@@ -168,6 +184,8 @@ export function PlatformMudPointWalletEntry({
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-busy={isLoading}
|
||||
onMouseEnter={isCompact ? undefined : requestAndOpen}
|
||||
onFocus={isCompact ? undefined : requestAndOpen}
|
||||
onClick={
|
||||
isCompact
|
||||
? () => {
|
||||
|
||||
Generated
-3
@@ -4495,18 +4495,15 @@ dependencies = [
|
||||
name = "platform-matting"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dotenvy",
|
||||
"hex",
|
||||
"hmac",
|
||||
"httpdate",
|
||||
"image",
|
||||
"platform-oss",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"time",
|
||||
"tokio",
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
//! 阿里云通用抠图在 api-server 侧的适配层。
|
||||
//!
|
||||
//! 输入 URL 策略:抠图服务只认上海地域 OSS URL,而我们没有上海地域自有 OSS,
|
||||
//! 统一由 platform-matting 上传 VIAPI 官方临时桶(1 天自动过期,无需清理)。
|
||||
//! 输入统一由 platform-matting 通过 AuthorizeFileUpload 单对象 Policy 上传动态临时 OSS。
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use platform_matting::MattingError;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
http_error::AppError, openai_image_generation::DownloadedOpenAiImage, state::AppState,
|
||||
};
|
||||
|
||||
/// 图片字节 → 阿里云通用抠图 → 原尺寸透明 PNG。
|
||||
/// 未配置抠图客户端时返回错误,由调用方决定是否降级本地算法。
|
||||
pub(crate) async fn segment_image_with_aliyun_matting(
|
||||
/// 私有 OSS 签名 URL → 延迟下载 → 阿里云临时桶 → 原尺寸透明 PNG。
|
||||
/// 下载和临时上传由 platform-matting 收口,源图缓冲不会跨越整次阿里云推理常驻。
|
||||
pub(crate) async fn segment_image_url_with_aliyun_matting(
|
||||
state: &AppState,
|
||||
image: &DownloadedOpenAiImage,
|
||||
image_url: &str,
|
||||
log_label: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let matting_client = state
|
||||
@@ -25,7 +24,7 @@ pub(crate) async fn segment_image_with_aliyun_matting(
|
||||
let file_name = format!("{log_label}.png");
|
||||
let started_at = std::time::Instant::now();
|
||||
let output_bytes = matting_client
|
||||
.segment_image_to_transparent_png(image.bytes.as_slice(), &file_name)
|
||||
.segment_image_url_to_transparent_png(image_url, &file_name)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
aliyun_matting_failure_to_app_error(&error, started_at.elapsed().as_millis() as u64)
|
||||
@@ -34,7 +33,7 @@ pub(crate) async fn segment_image_with_aliyun_matting(
|
||||
provider = "aliyun-matting",
|
||||
log_label,
|
||||
elapsed_ms = started_at.elapsed().as_millis() as u64,
|
||||
"阿里云通用抠图完成"
|
||||
"阿里云通用抠图 URL 输入完成"
|
||||
);
|
||||
|
||||
Ok(DownloadedOpenAiImage {
|
||||
@@ -48,18 +47,36 @@ pub(crate) async fn segment_image_with_aliyun_matting(
|
||||
///
|
||||
/// 分类(是否外部调用、超时、传输层故障、上游 HTTP 状态)由 platform-matting 在错误发生处
|
||||
/// 结构化捕获,这里只做协议中立的读取,不再从中文 message 反推——外部供应商协议归属留在
|
||||
/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是发请求前的本地预检失败,
|
||||
/// `external_call_attempted()` 为 false,据此跳过外部失败审计。
|
||||
/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是尚未开始外部调用的本地预检失败;
|
||||
/// URL 链路在 OSS GET 成功后发生的解码、尺寸或其它本地处理失败由 `LocalProcessing` 表示,
|
||||
/// 仍然需要进入外部失败审计,但不得包装成可重试的上游 5xx。
|
||||
fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> AppError {
|
||||
let message = error.message();
|
||||
if !error.external_call_attempted() {
|
||||
// 本地预检失败(未配置 / 图片解码失败 / 尺寸过小 / 签名构造失败),未触达阿里云。
|
||||
// 本地预检失败(未配置 / 尚未下载源图前的参数或签名错误),未触达外部调用链路。
|
||||
return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||||
"provider": "aliyun-matting",
|
||||
"message": message,
|
||||
"timeout": false,
|
||||
"transport": false,
|
||||
"localProcessing": false,
|
||||
"externalCallAttempted": false,
|
||||
"failureStage": error.failure_stage(),
|
||||
"latencyMs": latency_ms,
|
||||
"rawExcerpt": message.chars().take(500).collect::<String>(),
|
||||
}));
|
||||
}
|
||||
// 外部调用已开始,但失败在本地解码 / 校验 / 归一等阶段:要审计,不能标成上游 5xx / 可重试。
|
||||
if matches!(error, MattingError::LocalProcessing(_)) {
|
||||
return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||||
"provider": "aliyun-matting",
|
||||
"message": message,
|
||||
"timeout": false,
|
||||
"transport": false,
|
||||
"localProcessing": true,
|
||||
"upstreamStatus": Value::Null,
|
||||
"externalCallAttempted": true,
|
||||
"failureStage": error.failure_stage(),
|
||||
"latencyMs": latency_ms,
|
||||
"rawExcerpt": message.chars().take(500).collect::<String>(),
|
||||
}));
|
||||
@@ -75,7 +92,10 @@ fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) ->
|
||||
"message": message,
|
||||
"timeout": timeout,
|
||||
"transport": error.is_transport(),
|
||||
"localProcessing": false,
|
||||
"upstreamStatus": error.upstream_status(),
|
||||
"externalCallAttempted": true,
|
||||
"failureStage": error.failure_stage(),
|
||||
"latencyMs": latency_ms,
|
||||
"rawExcerpt": message.chars().take(500).collect::<String>(),
|
||||
}))
|
||||
@@ -86,6 +106,7 @@ fn aliyun_matting_unconfigured_error() -> AppError {
|
||||
"provider": "aliyun-matting",
|
||||
"message": "阿里云抠图客户端未配置或未启用。",
|
||||
"externalCallAttempted": false,
|
||||
"failureStage": "preflight",
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -100,6 +121,21 @@ mod tests {
|
||||
assert!(!crate::external_api_audit::matting_failure_external_call_attempted(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_input_path_delegates_download_and_temp_upload_to_platform_matting() {
|
||||
let source = include_str!("aliyun_matting.rs");
|
||||
let start = source
|
||||
.find("pub(crate) async fn segment_image_url_with_aliyun_matting")
|
||||
.expect("URL input adapter should exist");
|
||||
let tail = &source[start..];
|
||||
let end = tail
|
||||
.find("/// 把 platform-matting 的错误映射")
|
||||
.expect("URL input adapter should end before error mapper");
|
||||
let body = &tail[..end];
|
||||
|
||||
assert!(body.contains("segment_image_url_to_transparent_png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_preflight_failures_do_not_count_as_external_call() {
|
||||
for error in [
|
||||
@@ -108,7 +144,7 @@ mod tests {
|
||||
),
|
||||
MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string()),
|
||||
MattingError::InvalidConfig("endpoint 为空".to_string()),
|
||||
MattingError::Sign("初始化 OSS V1 签名器失败".to_string()),
|
||||
MattingError::Sign("构造 AuthorizeFileUpload 签名失败".to_string()),
|
||||
] {
|
||||
let mapped = aliyun_matting_failure_to_app_error(&error, 3);
|
||||
|
||||
@@ -129,6 +165,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_processing_after_source_download_is_audited_with_failure_stage() {
|
||||
for (error, expected_stage) in [
|
||||
(
|
||||
MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string())
|
||||
.with_failure_stage("source_decode"),
|
||||
"source_decode",
|
||||
),
|
||||
(
|
||||
MattingError::InvalidRequest("待抠图图片尺寸 16x16 过小".to_string())
|
||||
.with_failure_stage("source_validate"),
|
||||
"source_validate",
|
||||
),
|
||||
] {
|
||||
let mapped = aliyun_matting_failure_to_app_error(&error, 3);
|
||||
|
||||
assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped));
|
||||
// 本地处理失败要审计,但 HTTP 包装不得落成可重试 5xx。
|
||||
assert_eq!(mapped.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let details = mapped.details().expect("details present");
|
||||
assert_eq!(
|
||||
details
|
||||
.get("externalCallAttempted")
|
||||
.and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
details.get("localProcessing").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
details.get("transport").and_then(|v| v.as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
assert!(
|
||||
details
|
||||
.get("upstreamStatus")
|
||||
.is_none_or(|value| value.is_null())
|
||||
);
|
||||
assert_eq!(
|
||||
details.get("failureStage").and_then(|v| v.as_str()),
|
||||
Some(expected_stage)
|
||||
);
|
||||
assert_eq!(
|
||||
crate::external_api_audit::matting_failure_audit_failure_stage(
|
||||
&mapped,
|
||||
"aliyun_segment",
|
||||
),
|
||||
expected_stage
|
||||
);
|
||||
assert_eq!(
|
||||
crate::external_api_audit::matting_failure_audit_status_code(&mapped),
|
||||
None,
|
||||
"本地处理失败没有上游 HTTP 状态,不能回退包装码"
|
||||
);
|
||||
let draft = crate::external_api_audit::build_matting_external_api_failure_draft(
|
||||
"aliyun-matting",
|
||||
"imageseg.example".to_string(),
|
||||
"editor-screen-background-removal",
|
||||
expected_stage,
|
||||
crate::external_api_audit::matting_failure_audit_status_code(&mapped),
|
||||
crate::external_api_audit::matting_failure_audit_timeout(&mapped),
|
||||
crate::external_api_audit::matting_failure_audit_is_transport(&mapped),
|
||||
crate::external_api_audit::matting_failure_audit_latency_ms(&mapped),
|
||||
mapped.message().to_string(),
|
||||
crate::external_api_audit::matting_failure_audit_raw_excerpt(&mapped),
|
||||
&crate::external_api_audit::ExternalApiAuditContext::default(),
|
||||
);
|
||||
assert_eq!(draft.status_class, Some("local"));
|
||||
assert!(!draft.retryable);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_transport_failure_maps_to_retryable_transport() {
|
||||
let error = MattingError::upstream_transport_error(
|
||||
|
||||
@@ -79,7 +79,6 @@ use crate::{
|
||||
resolve_editor_screen_background_color,
|
||||
},
|
||||
http_error::AppError,
|
||||
openai_image_generation::DownloadedOpenAiImage,
|
||||
platform_errors::map_oss_error,
|
||||
prompt::role_asset_studio::{
|
||||
build_role_asset_workflow, normalize_animation_prompt_text_by_key,
|
||||
@@ -2418,7 +2417,7 @@ async fn process_and_persist_editor_character_animation_frame(
|
||||
audit: &crate::external_api_audit::ExternalApiAuditContext,
|
||||
) -> Result<ProcessedEditorCharacterAnimationFrame, AppError> {
|
||||
// 中文注释:每一帧只要求自己的绿幕源图先落 OSS,不再等待整批源图全部上传完成。
|
||||
put_character_animation_object(
|
||||
let source_put = put_character_animation_object(
|
||||
state,
|
||||
LegacyAssetPrefix::Animations,
|
||||
vec![
|
||||
@@ -2432,7 +2431,7 @@ async fn process_and_persist_editor_character_animation_frame(
|
||||
frame.extension
|
||||
),
|
||||
frame.mime_type.clone(),
|
||||
frame.bytes.clone(),
|
||||
frame.bytes,
|
||||
build_asset_metadata(
|
||||
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
|
||||
owner_user_id,
|
||||
@@ -2444,14 +2443,9 @@ async fn process_and_persist_editor_character_animation_frame(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let image = DownloadedOpenAiImage {
|
||||
bytes: frame.bytes,
|
||||
mime_type: frame.mime_type,
|
||||
extension: frame.extension,
|
||||
};
|
||||
let removed = remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
|
||||
state,
|
||||
&image,
|
||||
source_put.object_key.as_str(),
|
||||
screen_color,
|
||||
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
|
||||
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
|
||||
@@ -6223,6 +6217,7 @@ mod tests {
|
||||
"put_character_animation_object",
|
||||
"green-screen-frame",
|
||||
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
|
||||
"source_put.object_key.as_str()",
|
||||
"bgfilter_request_timeout_ms",
|
||||
"finalize_animation_frame_payload",
|
||||
"put_character_animation_object",
|
||||
@@ -6244,6 +6239,13 @@ mod tests {
|
||||
"finalize_animation_frame_payload",
|
||||
],
|
||||
);
|
||||
let frame_pipeline = source
|
||||
.split_once("async fn process_and_persist_editor_character_animation_frame")
|
||||
.and_then(|(_, tail)| tail.split_once("async fn publish_animation_set"))
|
||||
.map(|(body, _)| body)
|
||||
.expect("frame pipeline function should exist");
|
||||
assert!(!frame_pipeline.contains("frame.bytes.clone()"));
|
||||
assert!(!frame_pipeline.contains("DownloadedOpenAiImage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -159,6 +159,7 @@ pub(crate) async fn record_matting_external_api_failure(
|
||||
failure_stage: &'static str,
|
||||
status_code: Option<u16>,
|
||||
timeout: bool,
|
||||
transport: bool,
|
||||
latency_ms: Option<u64>,
|
||||
error_message: String,
|
||||
raw_excerpt: Option<String>,
|
||||
@@ -170,6 +171,7 @@ pub(crate) async fn record_matting_external_api_failure(
|
||||
failure_stage,
|
||||
status_code,
|
||||
timeout,
|
||||
transport,
|
||||
latency_ms,
|
||||
error_message,
|
||||
raw_excerpt,
|
||||
@@ -178,31 +180,41 @@ pub(crate) async fn record_matting_external_api_failure(
|
||||
record_external_api_failure(state, draft).await;
|
||||
}
|
||||
|
||||
/// 构建抠图失败审计 draft。`transport` 必须由调用方从错误结构化字段读取,
|
||||
/// 不能用 `status_code.is_none()` 反推——本地处理失败同样没有上游 HTTP 状态。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_matting_external_api_failure_draft(
|
||||
pub(crate) fn build_matting_external_api_failure_draft(
|
||||
provider: &'static str,
|
||||
endpoint: String,
|
||||
operation: &'static str,
|
||||
failure_stage: &'static str,
|
||||
status_code: Option<u16>,
|
||||
timeout: bool,
|
||||
transport: bool,
|
||||
latency_ms: Option<u64>,
|
||||
error_message: String,
|
||||
raw_excerpt: Option<String>,
|
||||
context: &ExternalApiAuditContext,
|
||||
) -> ExternalApiFailureDraft {
|
||||
// status_code=None ⟺ statusClass=transport(见 status_class):DNS / 连接重置 / 读体中断 / 超时
|
||||
// 这类传输层失败没有上游 HTTP 状态。它们必须与 "transport failures actionable" 语义一致,
|
||||
// 记为 retryable=true,否则 statusClass=transport 却 retryable=false 会误导告警 / 重试分析。
|
||||
let has_transport_error = status_code.is_none();
|
||||
// 传输层:无上游 HTTP 状态 + timeout/transport 标记 → statusClass=transport、retryable=true。
|
||||
// 本地处理:无上游 HTTP 状态且非 transport → statusClass=local、retryable=false。
|
||||
// 不得把「status_code=None」一律当成 transport,否则 LocalProcessing 会污染可重试 5xx 分析。
|
||||
let is_transport_failure = timeout || transport;
|
||||
let resolved_status_class = if is_transport_failure && status_code.is_none() {
|
||||
"transport"
|
||||
} else if status_code.is_none() {
|
||||
"local"
|
||||
} else {
|
||||
status_class(status_code)
|
||||
};
|
||||
ExternalApiFailureDraft::new(provider, endpoint, operation, failure_stage, error_message)
|
||||
.with_status_code(status_code)
|
||||
.with_optional_status_class(Some(status_class(status_code)))
|
||||
.with_optional_status_class(Some(resolved_status_class))
|
||||
.with_timeout(timeout)
|
||||
.with_retryable(is_retryable_external_api_failure(
|
||||
status_code,
|
||||
timeout,
|
||||
has_transport_error,
|
||||
is_transport_failure,
|
||||
))
|
||||
.with_latency_ms(latency_ms)
|
||||
.with_raw_excerpt(raw_excerpt)
|
||||
@@ -210,21 +222,31 @@ fn build_matting_external_api_failure_draft(
|
||||
}
|
||||
|
||||
pub(crate) fn matting_failure_audit_status_code(error: &AppError) -> Option<u16> {
|
||||
error
|
||||
// 真实上游 HTTP 状态优先;本地处理 / 传输层都没有可写的上游 status。
|
||||
if let Some(status) = error
|
||||
.details()
|
||||
.and_then(|details| details.get("upstreamStatus"))
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.or_else(|| {
|
||||
if matting_failure_audit_is_transport(error) {
|
||||
None
|
||||
} else {
|
||||
Some(error.status_code().as_u16())
|
||||
}
|
||||
})
|
||||
{
|
||||
return Some(status);
|
||||
}
|
||||
if matting_failure_audit_is_local_processing(error) || matting_failure_audit_is_transport(error)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(error.status_code().as_u16())
|
||||
}
|
||||
|
||||
fn matting_failure_audit_is_transport(error: &AppError) -> bool {
|
||||
pub(crate) fn matting_failure_audit_is_local_processing(error: &AppError) -> bool {
|
||||
error
|
||||
.details()
|
||||
.and_then(|details| details.get("localProcessing"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn matting_failure_audit_is_transport(error: &AppError) -> bool {
|
||||
matting_failure_audit_timeout(error)
|
||||
|| error
|
||||
.details()
|
||||
@@ -256,6 +278,30 @@ pub(crate) fn matting_failure_external_call_attempted(error: &AppError) -> bool
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub(crate) fn matting_failure_audit_failure_stage(
|
||||
error: &AppError,
|
||||
fallback: &'static str,
|
||||
) -> &'static str {
|
||||
let stage = error
|
||||
.details()
|
||||
.and_then(|details| details.get("failureStage"))
|
||||
.and_then(Value::as_str);
|
||||
match stage {
|
||||
Some("preflight") => "preflight",
|
||||
Some("source_download") => "source_download",
|
||||
Some("source_decode") => "source_decode",
|
||||
Some("source_validate") => "source_validate",
|
||||
Some("source_normalize") => "source_normalize",
|
||||
Some("temp_upload") => "temp_upload",
|
||||
Some("aliyun_segment") => "aliyun_segment",
|
||||
Some("result_download") => "result_download",
|
||||
Some("result_decode") => "result_decode",
|
||||
Some("result_validate") => "result_validate",
|
||||
Some("result_encode") => "result_encode",
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matting_failure_audit_raw_excerpt(error: &AppError) -> Option<String> {
|
||||
error
|
||||
.details()
|
||||
@@ -575,7 +621,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn matting_failure_draft_marks_non_timeout_transport_retryable() {
|
||||
// status_code=None、timeout=false 的非超时 transport 失败(DNS / 连接重置 / 读体中断):
|
||||
// status_code=None、timeout=false、transport=true 的非超时传输故障:
|
||||
// statusClass 必须是 transport 且 retryable=true,否则与 "transport failures actionable" 冲突。
|
||||
let draft = build_matting_external_api_failure_draft(
|
||||
"bgfilter",
|
||||
@@ -584,6 +630,7 @@ mod tests {
|
||||
"bgfilter_segment",
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
Some(67),
|
||||
"请求 BgFilter 服务失败:dns error".to_string(),
|
||||
Some("dns error".to_string()),
|
||||
@@ -599,6 +646,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matting_failure_draft_marks_local_processing_non_retryable() {
|
||||
// 本地处理失败没有上游 HTTP 状态,也不是 transport;不得记成可重试 5xx/transport。
|
||||
let draft = build_matting_external_api_failure_draft(
|
||||
"aliyun-matting",
|
||||
"imageseg.example".to_string(),
|
||||
"editor-screen-background-removal",
|
||||
"source_decode",
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
Some(3),
|
||||
"解析待抠图图片失败:invalid png".to_string(),
|
||||
Some("invalid png".to_string()),
|
||||
&ExternalApiAuditContext::default(),
|
||||
);
|
||||
|
||||
assert_eq!(draft.status_code, None);
|
||||
assert_eq!(draft.status_class, Some("local"));
|
||||
assert!(!draft.timeout);
|
||||
assert!(!draft.retryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matting_failure_draft_keeps_client_error_non_retryable() {
|
||||
// 真实上游 4xx(非 429 / 408)不是传输故障,仍应 retryable=false,不能被误判为可重试。
|
||||
@@ -609,6 +679,7 @@ mod tests {
|
||||
"aliyun_segment",
|
||||
Some(400),
|
||||
false,
|
||||
false,
|
||||
Some(12),
|
||||
"通用抠图接口返回失败(HTTP 400,Code=InvalidImage):bad image".to_string(),
|
||||
None,
|
||||
|
||||
@@ -5,13 +5,10 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
httpdate = { workspace = true }
|
||||
image = { workspace = true, features = ["png", "jpeg", "webp"] }
|
||||
sha1 = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! 通用抠图冒烟验证:本地图片 → OSS → SegmentCommonImage → 下载结果。
|
||||
//! 通用抠图冒烟验证:本地图片 → AuthorizeFileUpload 临时对象 → SegmentCommonImage → 下载结果。
|
||||
//!
|
||||
//! 运行(在 server-rs 目录下):
|
||||
//! cargo run -p platform-matting --example segment_smoke -- "C:\path\to\input.png"
|
||||
//!
|
||||
//! 依赖仓库根目录 .env.local(OSS bucket/endpoint)与 .env.secrets.local(AK/SK)。
|
||||
//! 依赖仓库根目录 .env / .env.local / .env.secrets.local 中的 AK/SK
|
||||
//!(`GENARRATIVE_ALIYUN_MATTING_*` 或 `ALIBABA_CLOUD_ACCESS_KEY_*`);
|
||||
//! 可选 `GENARRATIVE_ALIYUN_MATTING_ENDPOINT`。临时 OSS bucket/endpoint 由
|
||||
//! AuthorizeFileUpload 动态下发,不依赖自有 OSS 配置。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -67,17 +70,16 @@ async fn main() {
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
// --- 调用通用抠图 ---
|
||||
// key 优先级:VIAPI 专用 → 官方 SDK 标准命名(#IMAGE_CALL)→ 短信 key 兜底。
|
||||
// key 优先级与 api-server 配置保持一致:抠图专用 → 官方 SDK 标准命名。
|
||||
let (matting_key_id, matting_key_secret) = [
|
||||
(
|
||||
"ALIYUN_IMAGESEG_ACCESS_KEY_ID",
|
||||
"ALIYUN_IMAGESEG_ACCESS_KEY_SECRET",
|
||||
"GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID",
|
||||
"GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET",
|
||||
),
|
||||
(
|
||||
"ALIBABA_CLOUD_ACCESS_KEY_ID",
|
||||
"ALIBABA_CLOUD_ACCESS_KEY_SECRET",
|
||||
),
|
||||
("ALIYUN_SMS_ACCESS_KEY_ID", "ALIYUN_SMS_ACCESS_KEY_SECRET"),
|
||||
]
|
||||
.iter()
|
||||
.find_map(|(id_name, secret_name)| {
|
||||
@@ -91,7 +93,7 @@ async fn main() {
|
||||
})
|
||||
.expect("未找到可用的抠图 AccessKey 环境变量");
|
||||
let matting_config = MattingConfig::new(
|
||||
std::env::var("ALIYUN_IMAGESEG_ENDPOINT")
|
||||
std::env::var("GENARRATIVE_ALIYUN_MATTING_ENDPOINT")
|
||||
.unwrap_or_else(|_| DEFAULT_IMAGESEG_ENDPOINT.to_string()),
|
||||
matting_key_id,
|
||||
matting_key_secret,
|
||||
@@ -99,12 +101,12 @@ async fn main() {
|
||||
.expect("抠图配置应有效");
|
||||
let matting_client = MattingClient::new(matting_config).expect("抠图客户端应可构建");
|
||||
|
||||
// 本地 OSS 在北京地域,抠图服务要求上海地域,走 VIAPI 官方临时桶上传。
|
||||
// 非上海地域输入按新版官方 SDK 的 AdvanceRequest 口径申请单对象 Policy 后上传。
|
||||
let temp_url = matting_client
|
||||
.upload_temp_image(input_bytes, "segment-input.png", "image/png")
|
||||
.await
|
||||
.expect("上传 VIAPI 临时桶应成功");
|
||||
println!("[2/5] 已上传 VIAPI 临时桶");
|
||||
.expect("上传 AuthorizeFileUpload 临时对象应成功");
|
||||
println!("[2/5] 已上传 AuthorizeFileUpload 临时对象");
|
||||
println!("[3/5] 输入图 URL host:{}", host_of(&temp_url));
|
||||
|
||||
let result = matting_client
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -527,6 +527,10 @@ export function ImageCanvasEditorView({
|
||||
requestLogin: () => authUiRef.current?.openLoginModal(),
|
||||
currentUser: authUi?.user ?? null,
|
||||
});
|
||||
const refreshEditorWalletState = useCallback(() => {
|
||||
refreshEditorWalletBalance();
|
||||
loadRechargeCenter();
|
||||
}, [loadRechargeCenter, refreshEditorWalletBalance]);
|
||||
const isAccountPaymentModalOpen =
|
||||
isRewardCodeOpen ||
|
||||
isRechargeOpen ||
|
||||
@@ -1289,7 +1293,7 @@ export function ImageCanvasEditorView({
|
||||
assetFolderId: activeUploadFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot: applyGeneratedProjectSnapshot,
|
||||
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
|
||||
onWalletBalanceMayHaveChanged: refreshEditorWalletState,
|
||||
});
|
||||
const handleEditorAgentConfirmSent = useCallback(() => {
|
||||
generationSurface.refreshTaskList();
|
||||
@@ -1308,7 +1312,7 @@ export function ImageCanvasEditorView({
|
||||
if (warning) {
|
||||
showGenerationWarning(warning);
|
||||
}
|
||||
refreshEditorWalletBalance();
|
||||
refreshEditorWalletState();
|
||||
void loadEditorProject(projectId)
|
||||
.then(applyGeneratedProjectSnapshot)
|
||||
.catch(() => undefined);
|
||||
@@ -1316,7 +1320,7 @@ export function ImageCanvasEditorView({
|
||||
[
|
||||
applyGeneratedProjectSnapshot,
|
||||
projectId,
|
||||
refreshEditorWalletBalance,
|
||||
refreshEditorWalletState,
|
||||
showGenerationWarning,
|
||||
],
|
||||
);
|
||||
@@ -1405,6 +1409,7 @@ export function ImageCanvasEditorView({
|
||||
openCropExpandPanel,
|
||||
removeSelectedLayerBackground,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
extractUiDesignAssets,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickGenerationReferenceFromLayer,
|
||||
@@ -2176,6 +2181,7 @@ export function ImageCanvasEditorView({
|
||||
quickEditSelectionSourceLayer,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
|
||||
@@ -42,6 +42,7 @@ function renderSelectedToolbar(
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
isSplittingIconSpritesheet: false,
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
@@ -155,6 +156,33 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(props.onSplitIconSpritesheet).toHaveBeenCalledWith(layer);
|
||||
});
|
||||
|
||||
it('renders a disabled loading state while splitting an icon spritesheet', () => {
|
||||
const layer = createLayer({
|
||||
sourceType: 'generated',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
const props = renderSelectedToolbar({
|
||||
selectedLayer: layer,
|
||||
isSplittingIconSpritesheet: true,
|
||||
});
|
||||
const splitButton = screen.getByRole('button', { name: '拆图中' });
|
||||
|
||||
expect(splitButton.getAttribute('title')).toBe('拆图中');
|
||||
expect(splitButton.getAttribute('aria-busy')).toBe('true');
|
||||
expect((splitButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(splitButton.textContent).toContain('拆图中');
|
||||
expect(splitButton.querySelector('.lucide-loader-circle')).toBeTruthy();
|
||||
expect(
|
||||
splitButton.querySelector('.lucide-loader-circle.animate-spin'),
|
||||
).toBeTruthy();
|
||||
expect(splitButton.querySelector('.lucide-scissors')).toBeNull();
|
||||
|
||||
fireEvent.click(splitButton);
|
||||
fireEvent.click(splitButton);
|
||||
|
||||
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps only remodel and download for audio layers', () => {
|
||||
const layer = createLayer({
|
||||
title: '游戏音效',
|
||||
@@ -293,6 +321,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
isSplittingIconSpritesheet={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
onExtractUiDesignAssets={vi.fn()}
|
||||
onOpenCharacterAnimationPanel={vi.fn()}
|
||||
@@ -310,6 +339,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
isSplittingIconSpritesheet={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
onExtractUiDesignAssets={vi.fn()}
|
||||
onOpenCharacterAnimationPanel={vi.fn()}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Crop,
|
||||
Download,
|
||||
ImageOff,
|
||||
Loader2,
|
||||
PersonStanding,
|
||||
Scissors,
|
||||
Sparkles,
|
||||
@@ -21,6 +22,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
isSplittingIconSpritesheet: boolean;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
||||
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
||||
@@ -34,6 +36,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onOpenRedrawPanel,
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
isSplittingIconSpritesheet,
|
||||
onSplitIconSpritesheet,
|
||||
onExtractUiDesignAssets,
|
||||
onOpenCharacterAnimationPanel,
|
||||
@@ -120,12 +123,20 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="拆分图集"
|
||||
title="拆分图集"
|
||||
icon={<Scissors className="h-4 w-4" />}
|
||||
label={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
|
||||
title={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
|
||||
icon={
|
||||
isSplittingIconSpritesheet ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Scissors className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
disabled={isSplittingIconSpritesheet}
|
||||
aria-busy={isSplittingIconSpritesheet}
|
||||
onClick={() => onSplitIconSpritesheet(selectedLayer)}
|
||||
>
|
||||
<span>拆分图集</span>
|
||||
<span>{isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}</span>
|
||||
</PlatformIconButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'ui-design' ? (
|
||||
|
||||
@@ -345,9 +345,9 @@ describe('ImageCanvasStageInteractionModel', () => {
|
||||
minimapScale: 0.4,
|
||||
moved: false,
|
||||
});
|
||||
expect(
|
||||
updateMinimapDragMovement(minimapDrag, { x: 121, y: 90 }),
|
||||
).toBe(minimapDrag);
|
||||
expect(updateMinimapDragMovement(minimapDrag, { x: 121, y: 90 })).toBe(
|
||||
minimapDrag,
|
||||
);
|
||||
expect(updateMinimapDragMovement(minimapDrag, { x: 123, y: 90 })).toEqual({
|
||||
...minimapDrag,
|
||||
moved: true,
|
||||
@@ -385,7 +385,10 @@ describe('ImageCanvasStageInteractionModel', () => {
|
||||
dialog: anotherDialog,
|
||||
layers,
|
||||
generationDialogs: [dialog, anotherDialog],
|
||||
selectedLayerIds: ['layer-a', getCanvasGenerationSelectionId('dialog-1')],
|
||||
selectedLayerIds: [
|
||||
'layer-a',
|
||||
getCanvasGenerationSelectionId('dialog-1'),
|
||||
],
|
||||
isMultiSelectGesture: true,
|
||||
pointerId: 12,
|
||||
pointer: { x: 360, y: 260 },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user