From c6aa8a248a09bb57b7cbdb3d5833964deb55f8ba Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 05:22:34 +0000 Subject: [PATCH 01/18] =?UTF-8?q?=E5=88=87=E6=8D=A2=E9=98=BF=E9=87=8C?= =?UTF-8?q?=E4=BA=91=E9=80=9A=E7=94=A8=E6=8A=A0=E5=9B=BE=E6=AD=A3=E5=BC=8F?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用 AuthorizeFileUpload 与 Policy POST 上传临时对象 移除 GetOssStsToken、OSS V1 和 SHA-1 依赖 补充真实链路冒烟示例、单元测试与架构运维文档 --- .../shared-memory/decision-log.md | 10 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + server-rs/Cargo.lock | 3 - .../crates/api-server/src/aliyun_matting.rs | 5 +- server-rs/crates/platform-matting/Cargo.toml | 5 +- .../examples/segment_smoke.rs | 34 +- server-rs/crates/platform-matting/src/lib.rs | 404 +++++++++++------- 8 files changed, 291 insertions(+), 173 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9ccb95532..f1c23e1bf 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 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、ObjectKey、Policy 与 Signature,使用 multipart Policy POST 上传,再把动态临时对象 URL 交给 `SegmentCommonImage`。移除 `GetOssStsToken`、固定 `viapi-customer-temp`、临时 AK/SK 下发和 OSS V1 SHA-1 签名;图片归一化、结果下载、原尺寸 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-14 后台账号采用 owner 引导账号与一级 Tab 实时授权 - 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。 @@ -2310,7 +2318,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` 生成 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`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7876582e1..77abc3a45 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -241,6 +241,7 @@ npm run check:server-rs-ddd - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 - 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 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`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=` 和 `seg_model=`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,均跳过或结束 BgFilter 调用后复用同一兜底链:先调用阿里云通用抠图,阿里云失败才使用本地 `editor_green_screen` 键色扣除;熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败时降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。 +- 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 11be34970..6eb4c8f96 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -65,6 +65,8 @@ lease 过期后不代表任务一定再次执行:claim transaction 只有在 ` 图片画布角色图、图标素材和 UI 素材提取在绿色 / 蓝色幕布去背景时优先调用 BgFilter;默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,连续失败达到 `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,再看 `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` 日志。 +阿里云通用抠图的非上海地域输入使用 `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。 + `我的` 页签或排障面板展示队列等待时,只读取 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 暴露到前端。 外部生成任务摘要投影与历史 payload 维护使用 `npm run spacetime:external-generation:maintain -- ...`,且只能由已授权 migration operator 的 SpacetimeDB CLI 登录态执行。脚本默认 dry-run、每次只处理一批,绝不自动循环全表;`--apply` 才写入。先发布包含 `external_generation_job_summary` 与 cursor 索引的 SpacetimeDB 模块,在维护模式内对事故时间以前的编辑器终态任务执行小批 dry-run,例如 `npm run spacetime:external-generation:maintain -- --database --server-url --limit 5 --completed-before-micros `;核对 `matched_count`、`before_bytes`、`after_bytes` 和 `inline_media_count` 后,保持本批输入 cursor 不变并追加 `--apply` 重跑同一批,即使最后一批 `has_more = false`,只要 dry-run 仍有 `matched_count` / `selected_count` 也必须 apply;只有 apply 成功后才使用它返回的 `next_cursor_job_id` 继续。B-tree cursor 的选择阶段最多反序列化 `limit + 1` 行,apply 会再按主键逐条读取选中行但不会同时保留整批 payload;如怀疑存在单行异常巨型历史 JSON,先用 `--limit 1`。payload 压缩硬限制 `source_module = editor-canvas`;终态压缩完成后,用 `--backfill-summaries` 先 dry-run、再 `--apply` 分批补齐仍缺失的活动任务或无内联媒体历史任务摘要,直到 `has_more = false`,最后再切换使用 summary procedure 的 api-server。Stdb 构建 artifact 和完整 release 包都必须包含 `scripts/spacetime-maintain-external-generation-jobs.mjs` 与 `scripts/spacetime-migration-common.mjs`。首次上线不得让 Full Build 从 Stdb 自动直落 API:`STDB_API_ROLLOUT_MODE` 默认 fail-closed 为 `pause-after-stdb`,必须填写受限的 `STDB_API_ROLLOUT_APPROVERS`;Stdb Publish 通过 `KEEP_MAINTENANCE_MODE` 保持维护文件并停止旧 API/controller/worker,暂停点最多等待 4 小时,完成上述维护并确认无后续批次后才由指定审批人放行 API。定时构建缺少审批人时必须在发布前失败,不能静默退回 `normal`;也可分开运行 Stdb publish、维护、API deploy 三个受控 Job。任一批次都不得处理 pending / running payload;不要用 runtime writer、bootstrap secret 或匿名 identity 代替 migration operator,也不要在未核对 dry-run 时直接 apply。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index a010f230e..8c03ba844 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -4481,18 +4481,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", diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs index d3c684d57..6094e6171 100644 --- a/server-rs/crates/api-server/src/aliyun_matting.rs +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -1,7 +1,6 @@ //! 阿里云通用抠图在 api-server 侧的适配层。 //! -//! 输入 URL 策略:抠图服务只认上海地域 OSS URL,而我们没有上海地域自有 OSS, -//! 统一由 platform-matting 上传 VIAPI 官方临时桶(1 天自动过期,无需清理)。 +//! 输入统一由 platform-matting 通过 AuthorizeFileUpload 单对象 Policy 上传动态临时 OSS。 use axum::http::StatusCode; use platform_matting::MattingError; @@ -108,7 +107,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); diff --git a/server-rs/crates/platform-matting/Cargo.toml b/server-rs/crates/platform-matting/Cargo.toml index 87a387af0..eacc33dad 100644 --- a/server-rs/crates/platform-matting/Cargo.toml +++ b/server-rs/crates/platform-matting/Cargo.toml @@ -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 } diff --git a/server-rs/crates/platform-matting/examples/segment_smoke.rs b/server-rs/crates/platform-matting/examples/segment_smoke.rs index c778b5f02..e6abd20e6 100644 --- a/server-rs/crates/platform-matting/examples/segment_smoke.rs +++ b/server-rs/crates/platform-matting/examples/segment_smoke.rs @@ -1,4 +1,4 @@ -//! 通用抠图冒烟验证:本地图片 → OSS → SegmentCommonImage → 下载结果。 +//! 通用抠图冒烟验证:本地图片 → AuthorizeFileUpload 临时对象 → SegmentCommonImage → 下载结果。 //! //! 运行(在 server-rs 目录下): //! cargo run -p platform-matting --example segment_smoke -- "C:\path\to\input.png" @@ -38,17 +38,16 @@ async fn main() { }); let input_bytes = std::fs::read(&input_path) .unwrap_or_else(|error| panic!("读取测试图片失败({input_path}):{error}")); - println!("[1/5] 已读取测试图片:{input_path}({} 字节)", input_bytes.len()); + println!( + "[1/5] 已读取测试图片:{input_path}({} 字节)", + input_bytes.len() + ); // SegmentCommonImage 要求分辨率低于 2000x2000,超限先等比缩小。 const MAX_EDGE: u32 = 1999; let decoded = image::load_from_memory(&input_bytes).expect("测试图片应可解码"); let input_bytes = if decoded.width() > MAX_EDGE || decoded.height() > MAX_EDGE { - let resized = decoded.resize( - MAX_EDGE, - MAX_EDGE, - image::imageops::FilterType::CatmullRom, - ); + let resized = decoded.resize(MAX_EDGE, MAX_EDGE, image::imageops::FilterType::CatmullRom); let mut buffer = std::io::Cursor::new(Vec::new()); resized .write_to(&mut buffer, image::ImageFormat::Png) @@ -68,11 +67,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"), - ("ALIBABA_CLOUD_ACCESS_KEY_ID", "ALIBABA_CLOUD_ACCESS_KEY_SECRET"), - ("ALIYUN_SMS_ACCESS_KEY_ID", "ALIYUN_SMS_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", + ), ] .iter() .find_map(|(id_name, secret_name)| { @@ -86,7 +90,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, @@ -94,12 +98,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 diff --git a/server-rs/crates/platform-matting/src/lib.rs b/server-rs/crates/platform-matting/src/lib.rs index 0df252889..1cf2c7312 100644 --- a/server-rs/crates/platform-matting/src/lib.rs +++ b/server-rs/crates/platform-matting/src/lib.rs @@ -18,14 +18,12 @@ pub const DEFAULT_IMAGESEG_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com"; const IMAGESEG_API_VERSION: &str = "2019-12-30"; const SEGMENT_COMMON_IMAGE_ACTION: &str = "SegmentCommonImage"; -// VIAPI 官方临时上传通道:抠图输入统一先传官方临时桶(1 天自动过期,无需清理; -// 全用户共享 QPS)。抠图服务只认上海地域 OSS URL,而我们没有上海地域自有 OSS。 -// 文档:https://help.aliyun.com/document_detail/155645.html -const VIAPI_UTILS_ENDPOINT: &str = "viapiutils.cn-shanghai.aliyuncs.com"; -const VIAPI_UTILS_VERSION: &str = "2020-04-01"; -const GET_OSS_STS_TOKEN_ACTION: &str = "GetOssStsToken"; -const VIAPI_TEMP_BUCKET: &str = "viapi-customer-temp"; -const VIAPI_TEMP_OSS_HOST: &str = "viapi-customer-temp.oss-cn-shanghai.aliyuncs.com"; +// VIAPI 新版官方 SDK 的 AdvanceRequest 上传通道:先向 Open Platform 申请单对象 +// Policy,再 multipart POST 到授权返回的上海地域临时 OSS。 +const OPEN_PLATFORM_ENDPOINT: &str = "openplatform.aliyuncs.com"; +const AUTHORIZE_FILE_UPLOAD_ACTION: &str = "AuthorizeFileUpload"; +const AUTHORIZE_FILE_UPLOAD_VERSION: &str = "2019-12-19"; +const IMAGESEG_PRODUCT: &str = "imageseg"; pub const DEFAULT_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000; @@ -382,7 +380,7 @@ impl MattingClient { /// 图片字节 → 通用抠图 → 原尺寸透明 PNG 字节。 /// - /// - 输入统一上传 VIAPI 官方临时桶(1 天自动过期,无需清理)后作为 ImageURL 送抠。 + /// - 输入统一经 AuthorizeFileUpload 单对象 Policy 上传临时 OSS 后作为 ImageURL 送抠。 /// - 分辨率守卫:任一边 >= 2000 时先等比缩小送抠,抠完只取结果 alpha 上采样回贴 /// 原图 RGB,保证输出与输入同尺寸、画质无损。 pub async fn segment_image_to_transparent_png( @@ -492,8 +490,8 @@ impl MattingClient { Ok(bytes) } - /// 把本地图片字节上传到 VIAPI 官方临时桶,返回可直接作为 ImageURL 的公网地址。 - /// 抠图输入的统一上传通道(我们没有上海地域自有 OSS,临时桶 1 天自动过期无需清理)。 + /// 按 VIAPI 新版官方 SDK 的 AdvanceRequest 协议,把本地图片字节上传到授权的 + /// 上海地域临时 OSS,返回可直接作为 ImageURL 的公网地址。 pub async fn upload_temp_image( &self, bytes: Vec, @@ -505,159 +503,93 @@ impl MattingClient { "上传内容不能为空".to_string(), )); } - let sts = self.get_oss_sts_token().await?; let file_name = file_name.trim().trim_matches('/'); if file_name.is_empty() { return Err(MattingError::InvalidRequest( "file_name 不能为空".to_string(), )); } - // 阿里云要求 ImageURL 不含中文/非 ASCII 字符;object 叶子名只保留 URL 安全的 ASCII 字符, - // 唯一性由前缀 uuid 保证。 - let safe_file_name: String = file_name - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { - c - } else { - '_' - } - }) - .collect(); - let object_key = format!( - "{}/{}/{}", - self.config.access_key_id, - uuid::Uuid::new_v4().simple(), - safe_file_name - ); - let date = httpdate::fmt_http_date(std::time::SystemTime::now()); - // OSS V1 头签名(带 STS security token)。 - let string_to_sign = format!( - "PUT\n\n{content_type}\n{date}\nx-oss-security-token:{}\n/{VIAPI_TEMP_BUCKET}/{object_key}", - sts.security_token - ); - let signature = hmac_sha1_base64(sts.access_key_secret.as_bytes(), string_to_sign.as_bytes())?; - let authorization = format!("OSS {}:{}", sts.access_key_id, signature); - let target_url = format!("https://{VIAPI_TEMP_OSS_HOST}/{object_key}"); + let authorized = self.authorize_file_upload().await?; + let upload_host = format!("{}.{}", authorized.bucket, authorized.endpoint); + let target_url = format!("https://{upload_host}"); + let file_part = reqwest::multipart::Part::bytes(bytes) + .file_name(file_name.to_string()) + .mime_str(content_type) + .map_err(|error| { + MattingError::InvalidRequest(format!("上传内容类型不合法:{error}")) + })?; + let form = reqwest::multipart::Form::new() + .text("OSSAccessKeyId", authorized.access_key_id.clone()) + .text("policy", authorized.encoded_policy.clone()) + .text("Signature", authorized.signature.clone()) + .text("key", authorized.object_key.clone()) + .text("success_action_status", "201") + .part("file", file_part); let response = self .client - .put(&target_url) - .header(reqwest::header::CONTENT_TYPE, content_type) - .header(reqwest::header::DATE, &date) - .header("x-oss-security-token", &sts.security_token) - .header(reqwest::header::AUTHORIZATION, &authorization) - .body(bytes) + .post(&target_url) + .multipart(form) .send() .await .map_err(|error| { MattingError::upstream_transport_error( - format!("上传 VIAPI 临时桶请求失败:{error}"), + format!("上传 AuthorizeFileUpload 临时对象请求失败:{error}"), error.is_timeout(), ) })?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - // OSS 签名类错误体会回显 StringToSign(含 x-oss-security-token 明文)、StringToSignBytes - // 和 SignatureProvided;脱敏后再记录,避免 STS 临时凭证进审计元数据与日志。 return Err(MattingError::upstream_http_error( format!( - "上传 VIAPI 临时桶失败(HTTP {}):{}", + "上传 AuthorizeFileUpload 临时对象失败(HTTP {}):{}", status.as_u16(), - sanitize_oss_upload_error_body(&body, &sts.security_token) + sanitize_policy_upload_error_body(&body, &authorized) ), status.as_u16(), )); } - Ok(target_url) + Ok(format!( + "https://{upload_host}/{}", + encode_oss_object_key(&authorized.object_key) + )) } - async fn get_oss_sts_token(&self) -> Result { - let mut form = BTreeMap::new(); - form.insert("Action".to_string(), GET_OSS_STS_TOKEN_ACTION.to_string()); - form.insert("Format".to_string(), "json".to_string()); - form.insert("Version".to_string(), VIAPI_UTILS_VERSION.to_string()); - let payload = build_aliyun_form_body(&form); - let headers = self.build_acs3_headers( - VIAPI_UTILS_ENDPOINT, - GET_OSS_STS_TOKEN_ACTION, - VIAPI_UTILS_VERSION, - &payload, + async fn authorize_file_upload(&self) -> Result { + let canonical_query = format!("Product={IMAGESEG_PRODUCT}"); + let headers = self.build_acs3_headers_for_request( + OPEN_PLATFORM_ENDPOINT, + AUTHORIZE_FILE_UPLOAD_ACTION, + AUTHORIZE_FILE_UPLOAD_VERSION, + "GET", + &canonical_query, + "", )?; let response = self .client - .post(format!("https://{VIAPI_UTILS_ENDPOINT}/")) + .get(format!( + "https://{OPEN_PLATFORM_ENDPOINT}/?{canonical_query}" + )) .headers(headers) - .header( - reqwest::header::CONTENT_TYPE, - "application/x-www-form-urlencoded", - ) - .body(payload) .send() .await .map_err(|error| { MattingError::upstream_transport_error( - format!("GetOssStsToken 请求失败:{error}"), + format!("AuthorizeFileUpload 请求失败:{error}"), error.is_timeout(), ) })?; let http_status = response.status(); let body_text = response.text().await.map_err(|error| { MattingError::upstream_transport_error( - format!("GetOssStsToken 响应读取失败:{error}"), + format!("AuthorizeFileUpload 响应读取失败:{error}"), error.is_timeout(), ) })?; - let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|error| { - MattingError::upstream_response_error( - format!( - "GetOssStsToken 响应不是合法 JSON:{error};原始响应:{}", - truncate_for_log(&body_text) - ), - Some(http_status.as_u16()), - ) - })?; - if http_status != StatusCode::OK { - return Err(MattingError::upstream_http_error( - format!( - "GetOssStsToken 返回失败(HTTP {},Code={}):{}", - http_status.as_u16(), - body.get("Code").and_then(|value| value.as_str()).unwrap_or("unknown"), - body.get("Message") - .and_then(|value| value.as_str()) - .unwrap_or("unknown") - ), - http_status.as_u16(), - )); - } - let data = body.get("Data").ok_or_else(|| { - MattingError::upstream_response_error( - format!( - "GetOssStsToken 响应缺少 Data;原始响应:{}", - truncate_for_log(&body_text) - ), - Some(http_status.as_u16()), - ) - })?; - let read_field = |name: &str| -> Result { - data.get(name) - .and_then(|value| value.as_str()) - .map(|value| value.to_string()) - .ok_or_else(|| { - MattingError::upstream_response_error( - format!("GetOssStsToken 响应缺少 Data.{name}"), - Some(http_status.as_u16()), - ) - }) - }; - Ok(ViapiStsToken { - access_key_id: read_field("AccessKeyId")?, - access_key_secret: read_field("AccessKeySecret")?, - security_token: read_field("SecurityToken")?, - }) + parse_authorized_file_upload_response(&body_text, http_status) } fn build_signature_headers( @@ -674,6 +606,18 @@ impl MattingClient { action: &str, version: &str, payload: &str, + ) -> Result { + self.build_acs3_headers_for_request(endpoint, action, version, "POST", "", payload) + } + + fn build_acs3_headers_for_request( + &self, + endpoint: &str, + action: &str, + version: &str, + method: &str, + canonical_query: &str, + payload: &str, ) -> Result { let date = current_aliyun_timestamp(); let nonce = uuid::Uuid::new_v4().simple().to_string(); @@ -685,8 +629,7 @@ impl MattingClient { let signed_headers = "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version"; let canonical_request = format!( - "POST\n/\n\n{}\n{}\n{}", - canonical_headers, signed_headers, payload_hash + "{method}\n/\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}" ); let string_to_sign = format!( "ACS3-HMAC-SHA256\n{}", @@ -808,18 +751,77 @@ fn decode_image_within_limits(bytes: &[u8]) -> Result Result { - use base64::Engine as _; - let mut signer = Hmac::::new_from_slice(key) - .map_err(|error| MattingError::Sign(format!("初始化 OSS V1 签名器失败:{error}")))?; - signer.update(content); - Ok(base64::engine::general_purpose::STANDARD.encode(signer.finalize().into_bytes())) +fn parse_authorized_file_upload_response( + body_text: &str, + http_status: StatusCode, +) -> Result { + // 成功响应包含 Policy 与 Signature;解析错误中不能回显原始正文。 + let body: serde_json::Value = serde_json::from_str(body_text).map_err(|error| { + MattingError::upstream_response_error( + format!("AuthorizeFileUpload 响应不是合法 JSON:{error}"), + Some(http_status.as_u16()), + ) + })?; + if http_status != StatusCode::OK { + return Err(MattingError::upstream_http_error( + format!( + "AuthorizeFileUpload 返回失败(HTTP {},Code={}):{}", + http_status.as_u16(), + body.get("Code") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + body.get("Message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ), + http_status.as_u16(), + )); + } + let read_field = |name: &str| -> Result { + body.get(name) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + MattingError::upstream_response_error( + format!("AuthorizeFileUpload 响应缺少 {name}"), + Some(http_status.as_u16()), + ) + }) + }; + let bucket = read_field("Bucket")? + .trim_matches('/') + .to_string(); + let endpoint = read_field("Endpoint")? + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_matches('/') + .to_string(); + if bucket.is_empty() || endpoint.is_empty() { + return Err(MattingError::upstream_response_error( + "AuthorizeFileUpload 响应中的 Bucket/Endpoint 不合法".to_string(), + Some(http_status.as_u16()), + )); + } + Ok(AuthorizedFileUpload { + bucket, + endpoint, + access_key_id: read_field("AccessKeyId")?, + encoded_policy: read_field("EncodedPolicy")?, + signature: read_field("Signature")?, + object_key: read_field("ObjectKey")?, + }) } fn describe_result_download_transport_error(error: &reqwest::Error) -> String { @@ -864,21 +866,38 @@ fn sanitize_result_download_error_message(message: String) -> String { message } -/// 脱敏 OSS 上传错误体:OSS 签名类错误会回显 StringToSign(含 `x-oss-security-token` 明文)、 -/// StringToSignBytes(其十六进制)和 SignatureProvided(签名串)。剥掉这三个元素,并把已知的 -/// STS 临时凭证在正文任何位置的出现替换为 `***`,保留 `` 等可诊断信息后再记录。 -fn sanitize_oss_upload_error_body(body: &str, security_token: &str) -> String { +/// 脱敏 Policy POST 上传错误体:OSS 签名类错误可能回显 StringToSign、Policy 或签名串。 +/// 剥掉已知签名元素,并替换本次授权材料,保留 `` 等可诊断信息。 +fn sanitize_policy_upload_error_body(body: &str, authorized: &AuthorizedFileUpload) -> String { let mut sanitized = body.to_string(); - for tag in ["StringToSign", "StringToSignBytes", "SignatureProvided"] { + for tag in [ + "StringToSign", + "StringToSignBytes", + "SignatureProvided", + "Policy", + ] { sanitized = redact_xml_element(&sanitized, tag); } - let security_token = security_token.trim(); - if !security_token.is_empty() { - sanitized = sanitized.replace(security_token, "***"); + for secret in [ + authorized.access_key_id.as_str(), + authorized.encoded_policy.as_str(), + authorized.signature.as_str(), + ] { + if !secret.is_empty() { + sanitized = sanitized.replace(secret, "***"); + } } truncate_for_log(&sanitized) } +fn encode_oss_object_key(object_key: &str) -> String { + object_key + .split('/') + .map(|segment| urlencoding_encode(segment).replace('+', "%20")) + .collect::>() + .join("/") +} + /// 把 `` 的内容替换成 `[redacted]`(tag 内容可跨行);只有开标签无闭标签时丢弃其后全部内容。 fn redact_xml_element(text: &str, tag: &str) -> String { let open = format!("<{tag}>"); @@ -1092,6 +1111,86 @@ mod tests { assert!(!date.contains('.'), "x-acs-date 不能带小数秒:{date}"); } + #[test] + fn authorize_file_upload_response_parses_policy_fields() { + let response = r#"{ + "RequestId":"request-id", + "Bucket":"viapi-customer-pop", + "Endpoint":"oss-cn-shanghai.aliyuncs.com", + "AccessKeyId":"temporary-key-id", + "EncodedPolicy":"encoded-policy", + "Signature":"policy-signature", + "ObjectKey":"imageseg/2026/07/input image.png" + }"#; + + let authorized = parse_authorized_file_upload_response(response, StatusCode::OK) + .expect("AuthorizeFileUpload response should parse"); + + assert_eq!(authorized.bucket, "viapi-customer-pop"); + assert_eq!(authorized.endpoint, "oss-cn-shanghai.aliyuncs.com"); + assert_eq!(authorized.access_key_id, "temporary-key-id"); + assert_eq!(authorized.encoded_policy, "encoded-policy"); + assert_eq!(authorized.signature, "policy-signature"); + assert_eq!(authorized.object_key, "imageseg/2026/07/input image.png"); + assert_eq!( + encode_oss_object_key(&authorized.object_key), + "imageseg/2026/07/input%20image.png" + ); + } + + #[test] + fn authorize_file_upload_response_does_not_echo_sensitive_body_on_parse_error() { + let sensitive_body = "not-json encoded-policy policy-signature temporary-key-id"; + + let error = parse_authorized_file_upload_response(sensitive_body, StatusCode::OK) + .expect_err("invalid response should fail"); + + assert!(error.message().contains("响应不是合法 JSON")); + assert!(!error.message().contains("encoded-policy")); + assert!(!error.message().contains("policy-signature")); + assert!(!error.message().contains("temporary-key-id")); + } + + #[test] + fn authorize_file_upload_headers_sign_get_query() { + let config = MattingConfig::new( + DEFAULT_IMAGESEG_ENDPOINT.to_string(), + "test-key-id".to_string(), + "test-key-secret".to_string(), + ) + .expect("config should build"); + let client = MattingClient::new(config).expect("client should build"); + let headers = client + .build_acs3_headers_for_request( + OPEN_PLATFORM_ENDPOINT, + AUTHORIZE_FILE_UPLOAD_ACTION, + AUTHORIZE_FILE_UPLOAD_VERSION, + "GET", + "Product=imageseg", + "", + ) + .expect("headers should build"); + + assert_eq!( + headers + .get("x-acs-action") + .and_then(|value| value.to_str().ok()), + Some(AUTHORIZE_FILE_UPLOAD_ACTION) + ); + assert_eq!( + headers + .get("x-acs-version") + .and_then(|value| value.to_str().ok()), + Some(AUTHORIZE_FILE_UPLOAD_VERSION) + ); + assert_eq!( + headers + .get("x-acs-content-sha256") + .and_then(|value| value.to_str().ok()), + Some(sha256_hex(b"").as_str()) + ); + } + #[test] fn result_download_error_message_redacts_signed_oss_url() { let message = sanitize_result_download_error_message( @@ -1117,24 +1216,35 @@ mod tests { } #[test] - fn oss_upload_error_body_redacts_signature_material_and_token() { - let token = "CAISabcSECRETtoken123"; + fn policy_upload_error_body_redacts_authorization_material() { + let authorized = AuthorizedFileUpload { + bucket: "viapi-customer-pop".to_string(), + endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(), + access_key_id: "temporary-key-id".to_string(), + encoded_policy: "encoded-policy".to_string(), + signature: "policy-signature".to_string(), + object_key: "imageseg/input.png".to_string(), + }; let body = format!( "SignatureDoesNotMatch\ - mismatch for {token}\ - sigSECRET==\ + mismatch for {} and {}\ + {}\ + {}\ 50 55 54 0a\ - PUT\nx-oss-security-token:{token}\n/bucket/key" + POST\npolicy={}", + authorized.access_key_id, + authorized.signature, + authorized.encoded_policy, + authorized.signature, + authorized.encoded_policy ); - let sanitized = sanitize_oss_upload_error_body(&body, token); + let sanitized = sanitize_policy_upload_error_body(&body, &authorized); - // 敏感串全部消失:StringToSign 明文、其中的 token、十六进制、签名串、以及 Message 里的 token 明文。 - assert!(!sanitized.contains(token), "STS token 不能残留(含元素外的明文)"); - assert!(!sanitized.contains("x-oss-security-token:CAIS"), "StringToSign 明文不能残留"); - assert!(!sanitized.contains("sigSECRET"), "SignatureProvided 不能残留"); + assert!(!sanitized.contains(&authorized.access_key_id)); + assert!(!sanitized.contains(&authorized.encoded_policy)); + assert!(!sanitized.contains(&authorized.signature)); assert!(!sanitized.contains("50 55 54 0a"), "StringToSignBytes 不能残留"); - // 可诊断信息保留。 assert!(sanitized.contains("SignatureDoesNotMatch"), "OSS Code 应保留供诊断"); assert!(sanitized.contains("[redacted]"), "签名材料元素应被脱敏为 [redacted]"); } -- 2.52.0 From 70fef2e75399a741708977b4ab34069b010da433 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 06:25:25 +0000 Subject: [PATCH 02/18] =?UTF-8?q?=E6=94=B9=E7=94=A8OSS=E7=AD=BE=E5=90=8DUR?= =?UTF-8?q?L=E8=B0=83=E7=94=A8BgFilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用生成原图object key并签发十分钟读取地址 移除BgFilter请求中的图片文件重传并保留原有fallback顺序 补充签名URL脱敏测试及架构运维文档 --- .../shared-memory/decision-log.md | 7 ++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + .../crates/api-server/src/editor_project.rs | 109 ++++++++++++++---- 4 files changed, 94 insertions(+), 25 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f1c23e1bf..3595be133 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,13 @@ --- +## 2026-07-15 BgFilter 输入改用私有 OSS 短期签名 URL + +- 背景:角色形象、图标图集和 UI 素材图集在调用 BgFilter 前已经把带背景原图持久化到私有 OSS;继续由 api-server 把同一图片作为 multipart `file` 再上传一次,会重复传输图片字节并占用 API 进程网络带宽。 +- 决策:上述生成后抠图链路统一复用已持久化原图的 object key,签发 600 秒 OSS GET URL,并通过 BgFilter multipart 的 `image_url` 字段提交;请求中不再携带 `file`。签名 URL 只交给 BgFilter,不写日志或持久化。BgFilter 失败或熔断打开后才使用已保留的图片字节进入原有“阿里云通用抠图 → 本地键色”兜底链。 +- 影响范围:仅 `api-server/editor_project` 的 BgFilter 请求输入与相关文档;不改变 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 次。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 77abc3a45..a15052ec9 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -241,6 +241,7 @@ npm run check:server-rs-ddd - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 - 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 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`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=` 和 `seg_model=`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,均跳过或结束 BgFilter 调用后复用同一兜底链:先调用阿里云通用抠图,阿里云失败才使用本地 `editor_green_screen` 键色扣除;熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败时降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。 +- BgFilter 处理已经持久化到私有 OSS 的带背景原图时,`api-server` 必须签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不再下载对象或用 `file` 重传图片字节;签名 URL 不得写入日志、审计或持久化。只有 BgFilter 失败或熔断打开进入 fallback 后,才允许使用图片字节继续调用阿里云通用抠图或本地键色。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 6eb4c8f96..315174a3e 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -67,6 +67,8 @@ lease 过期后不代表任务一定再次执行:claim transaction 只有在 ` 阿里云通用抠图的非上海地域输入使用 `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`、`screen_color` 和 `seg_model`,不再提交 `file`,也不会在 BgFilter 调用前重新下载 OSS 对象。排障日志只应出现 object key 与签名有效期,不得记录带 `x-oss-*` 查询参数的完整 URL;BgFilter 失败或熔断打开后仍按既有顺序进入阿里云通用抠图和本地键色 fallback。 + `我的` 页签或排障面板展示队列等待时,只读取 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 暴露到前端。 外部生成任务摘要投影与历史 payload 维护使用 `npm run spacetime:external-generation:maintain -- ...`,且只能由已授权 migration operator 的 SpacetimeDB CLI 登录态执行。脚本默认 dry-run、每次只处理一批,绝不自动循环全表;`--apply` 才写入。先发布包含 `external_generation_job_summary` 与 cursor 索引的 SpacetimeDB 模块,在维护模式内对事故时间以前的编辑器终态任务执行小批 dry-run,例如 `npm run spacetime:external-generation:maintain -- --database --server-url --limit 5 --completed-before-micros `;核对 `matched_count`、`before_bytes`、`after_bytes` 和 `inline_media_count` 后,保持本批输入 cursor 不变并追加 `--apply` 重跑同一批,即使最后一批 `has_more = false`,只要 dry-run 仍有 `matched_count` / `selected_count` 也必须 apply;只有 apply 成功后才使用它返回的 `next_cursor_job_id` 继续。B-tree cursor 的选择阶段最多反序列化 `limit + 1` 行,apply 会再按主键逐条读取选中行但不会同时保留整批 payload;如怀疑存在单行异常巨型历史 JSON,先用 `--limit 1`。payload 压缩硬限制 `source_module = editor-canvas`;终态压缩完成后,用 `--backfill-summaries` 先 dry-run、再 `--apply` 分批补齐仍缺失的活动任务或无内联媒体历史任务摘要,直到 `has_more = false`,最后再切换使用 summary procedure 的 api-server。Stdb 构建 artifact 和完整 release 包都必须包含 `scripts/spacetime-maintain-external-generation-jobs.mjs` 与 `scripts/spacetime-migration-common.mjs`。首次上线不得让 Full Build 从 Stdb 自动直落 API:`STDB_API_ROLLOUT_MODE` 默认 fail-closed 为 `pause-after-stdb`,必须填写受限的 `STDB_API_ROLLOUT_APPROVERS`;Stdb Publish 通过 `KEEP_MAINTENANCE_MODE` 保持维护文件并停止旧 API/controller/worker,暂停点最多等待 4 小时,完成上述维护并确认无后续批次后才由指定审批人放行 API。定时构建缺少审批人时必须在发布前失败,不能静默退回 `normal`;也可分开运行 Stdb publish、维护、API deploy 三个受控 Job。任一批次都不得处理 pending / running payload;不要用 runtime writer、bootstrap secret 或匿名 identity 代替 migration operator,也不要在未核对 dry-run 时直接 apply。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ac2b2d93f..344193301 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -126,6 +126,7 @@ const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_ const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source"; const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; +const EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS: u64 = 600; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; @@ -1630,6 +1631,7 @@ pub(crate) async fn generate_editor_image_for_owner( "character-image", ) .await?; + let source_object_key = source_persisted.object_key.clone(); let source_record = persist_editor_provider_source_resource( state, source_persisted, @@ -1667,6 +1669,7 @@ pub(crate) async fn generate_editor_image_for_owner( }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, + source_object_key.as_str(), &image, screen_color.expect("character generation should have screen color"), seg_model.expect("character generation should have BgFilter seg model"), @@ -2755,7 +2758,8 @@ struct EditorScreenBackgroundRemovalOutput { async fn remove_editor_generated_screen_background_with_bgfilter( state: &AppState, - image: &DownloadedOpenAiImage, + source_object_key: &str, + fallback_image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, seg_model: &str, audit: &crate::external_api_audit::ExternalApiAuditContext, @@ -2770,12 +2774,18 @@ async fn remove_editor_generated_screen_background_with_bgfilter( cooldown_ms = remaining.as_millis() as u64, "editor_bgfilter_circuit_open_fallback_to_aliyun_matting" ); - return fallback_editor_screen_background_removal(state, image, screen_color, audit).await; + return fallback_editor_screen_background_removal( + state, + fallback_image, + screen_color, + audit, + ) + .await; } match request_editor_generated_screen_background_with_bgfilter( state, - image, + source_object_key, screen_color, seg_model, ) @@ -2813,7 +2823,13 @@ async fn remove_editor_generated_screen_background_with_bgfilter( crate::external_api_audit::matting_failure_audit_raw_excerpt(&error), ) .await; - fallback_editor_screen_background_removal(state, image, screen_color, audit).await + fallback_editor_screen_background_removal( + state, + fallback_image, + screen_color, + audit, + ) + .await } } } @@ -2926,28 +2942,34 @@ fn reset_editor_bgfilter_circuit_for_tests() { async fn request_editor_generated_screen_background_with_bgfilter( state: &AppState, - image: &DownloadedOpenAiImage, + source_object_key: &str, screen_color: EditorScreenBackgroundColor, seg_model: &str, ) -> Result { let url = editor_bgfilter_endpoint(state)?; + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let signed = oss_client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: source_object_key.to_string(), + expire_seconds: Some(EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS), + }) + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let signed_image_url = signed.signed_url; let call_id = format!("bgfilter-call-{}", current_utc_micros()); let request_started_at = Instant::now(); let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(1); - let input_bytes = image.bytes.len(); - let source_mime_type = image.mime_type.clone(); - let source_file_name = format!( - "editor-generated-source.{}", - image.extension.trim_start_matches('.') - ); tracing::info!( %call_id, upstream_url = %url, - file_name = %source_file_name, - mime_type = %source_mime_type, + source_object_key, + image_url_expire_seconds = EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS, screen_color = screen_color.hex, seg_model, - input_bytes, timeout_ms, "editor_bgfilter_request_start" ); @@ -2960,17 +2982,8 @@ async fn request_editor_generated_screen_background_with_bgfilter( "message": format!("创建 BgFilter HTTP 客户端失败:{error}"), })) })?; - let file_part = reqwest::multipart::Part::bytes(image.bytes.clone()) - .file_name(source_file_name) - .mime_str(source_mime_type.as_str()) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "bgfilter", - "message": format!("图片 MIME 类型无效:{error}"), - })) - })?; let form = reqwest::multipart::Form::new() - .part("file", file_part) + .text("image_url", signed_image_url.clone()) .text("screen_color", screen_color.hex.to_string()) .text("seg_model", seg_model.to_string()); let mut request = http_client.post(url.as_str()).multipart(form); @@ -3000,6 +3013,7 @@ async fn request_editor_generated_screen_background_with_bgfilter( .text() .await .unwrap_or_else(|error| format!("读取上游错误失败:{error}")); + let message = sanitize_editor_bgfilter_upstream_message(&message, &signed_image_url); tracing::warn!( %call_id, upstream_status = status.as_u16(), @@ -3071,7 +3085,6 @@ async fn request_editor_generated_screen_background_with_bgfilter( seg_model, upstream_screen_color = upstream_screen_color.as_deref().unwrap_or(""), upstream_seg_model = upstream_seg_model.as_deref().unwrap_or(""), - input_bytes, output_bytes = image.bytes.len(), elapsed_ms = request_started_at.elapsed().as_millis() as u64, upstream_elapsed_ms, @@ -3244,6 +3257,17 @@ fn editor_bgfilter_endpoint(state: &AppState) -> Result { )) } +fn sanitize_editor_bgfilter_upstream_message(message: &str, signed_image_url: &str) -> String { + let sanitized = message.replace(signed_image_url, "[signed OSS URL redacted]"); + if sanitized.to_ascii_lowercase().contains("x-oss-") { + return "BgFilter 服务返回错误(OSS 签名 URL 已脱敏)".to_string(); + } + sanitized + .chars() + .take(500) + .collect() +} + fn parse_editor_bgfilter_seg_model(value: Option<&str>) -> Result<&'static str, AppError> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(EDITOR_BGFILTER_DEFAULT_SEG_MODEL); @@ -3698,6 +3722,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheet", ) .await?; + let source_object_key = source_persisted.object_key.clone(); let source_record = persist_editor_provider_source_resource( state, source_persisted, @@ -3729,6 +3754,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, + source_object_key.as_str(), &image, screen_color, seg_model, @@ -4307,6 +4333,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "spritesheet", ) .await?; + let source_object_key = source_persisted.object_key.clone(); let source_record = persist_editor_provider_source_resource( state, source_persisted, @@ -4338,6 +4365,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, + source_object_key.as_str(), &image, screen_color, seg_model, @@ -7022,6 +7050,28 @@ mod tests { ); } + #[test] + fn bgfilter_upstream_message_redacts_signed_oss_url() { + let signed_url = "https://dev-bucket.oss-cn-beijing.aliyuncs.com/generated/input.png?x-oss-signature=secret&x-oss-expires=600"; + let message = format!("invalid image_url: {signed_url}; code=fetch_failed"); + + let sanitized = sanitize_editor_bgfilter_upstream_message(&message, signed_url); + + assert!(!sanitized.contains("x-oss-signature")); + assert!(!sanitized.contains("secret")); + assert!(sanitized.contains("[signed OSS URL redacted]")); + assert!(sanitized.contains("fetch_failed")); + + let escaped_message = format!( + "invalid image_url: {}", + signed_url.replace('&', "\\u0026") + ); + let escaped_sanitized = + sanitize_editor_bgfilter_upstream_message(&escaped_message, signed_url); + assert!(!escaped_sanitized.contains("x-oss-")); + assert!(!escaped_sanitized.contains("secret")); + } + #[test] fn bgfilter_body_read_timeout_maps_to_gateway_timeout_and_transport() { let error = editor_image_removal_body_read_error( @@ -9405,12 +9455,21 @@ mod tests { "async fn request_editor_background_removal_image", &[ "editor_bgfilter_endpoint", + "sign_get_object_url", + "EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS", + "\"image_url\"", "editor_bgfilter_request_timeout_ms.max(1)", "\"screen_color\"", "\"seg_model\"", "seg_model.to_string()", ], ); + assert_function_not_contains( + source, + "async fn request_editor_generated_screen_background_with_bgfilter", + "async fn request_editor_background_removal_image", + &[".part(\"file\"", "reqwest::multipart::Part::bytes"], + ); assert_function_contains( source, "async fn request_editor_background_removal_image", -- 2.52.0 From 9e0a20aac43ef403d7b740a96801292f3806762d Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 17 Jul 2026 05:31:15 +0000 Subject: [PATCH 03/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E5=8E=9F=E5=9B=BE=E5=86=85=E5=AD=98=E5=8D=A0?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 带背景生成图改为上传 OSS 后释放,BgFilter 直接使用短期签名 URL。 阿里云兜底改为按需下载并上传临时桶,本地兜底再次按需读取后及时释放。 透明图集继续沿用内存上传与切分链路,不改变透明结果处理。 补充平台适配器测试与抠图链路文档。 --- .../shared-memory/decision-log.md | 9 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../crates/api-server/src/aliyun_matting.rs | 49 +++ .../crates/api-server/src/editor_project.rs | 300 ++++++++++++++---- server-rs/crates/platform-matting/src/lib.rs | 232 ++++++++++++-- 6 files changed, 515 insertions(+), 79 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3595be133..14ccc89bb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,10 +16,17 @@ --- +## 2026-07-17 生成后抠图原图以 OSS 作为内存生命周期边界 + +- 背景:角色形象、图标图集和 UI 素材图集的生成原图虽然已先落私有 OSS,但 api-server 仍把带背景原图字节保留到 BgFilter / 阿里云 / 本地 fallback 结束,造成并发任务下的内存峰值叠加。 +- 决策:目标链路的带背景原图上传 OSS 时消费 `DownloadedImage` 所有权,不为上传克隆整张字节缓冲;上传完成后不再跨 BgFilter 调用常驻。BgFilter 只读取 600 秒签名 URL;进入阿里云 fallback 时由 `platform-matting` 新 URL 接口下载原图、上传 `AuthorizeFileUpload` 临时对象,并在开始阿里云推理前结束下载缓冲作用域;阿里云继续失败时,api-server 再从私有 OSS 独立下载原图供本地键色,产出后释放本次原图下载缓冲。 +- 边界:不改变接口 DTO、资源记录、画布原图展示、图集切分行为和降级顺序;“释放”指 Rust 所有权和 `Vec` 析构,RSS 不保证同步下降。 +- 验证方式:`platform-matting` 测试覆盖 URL 下载缓冲在临时上传后结束、降尺寸 Alpha 回贴;`api-server` 结构测试覆盖带背景原图 owned 上传、URL 阿里云 fallback、本地重新下载与原图释放;随后运行两个 crate 的测试与编译检查。 + ## 2026-07-15 BgFilter 输入改用私有 OSS 短期签名 URL - 背景:角色形象、图标图集和 UI 素材图集在调用 BgFilter 前已经把带背景原图持久化到私有 OSS;继续由 api-server 把同一图片作为 multipart `file` 再上传一次,会重复传输图片字节并占用 API 进程网络带宽。 -- 决策:上述生成后抠图链路统一复用已持久化原图的 object key,签发 600 秒 OSS GET URL,并通过 BgFilter multipart 的 `image_url` 字段提交;请求中不再携带 `file`。签名 URL 只交给 BgFilter,不写日志或持久化。BgFilter 失败或熔断打开后才使用已保留的图片字节进入原有“阿里云通用抠图 → 本地键色”兜底链。 +- 决策:上述生成后抠图链路统一复用已持久化原图的 object key,签发 600 秒 OSS GET URL,并通过 BgFilter multipart 的 `image_url` 字段提交;请求中不再携带 `file`。签名 URL 只交给 BgFilter,不写日志或持久化。2026-07-17 起,原图上传后不再保留图片字节;进入“阿里云通用抠图 → 本地键色”兜底链时按阶段从私有 OSS 重新下载。 - 影响范围:仅 `api-server/editor_project` 的 BgFilter 请求输入与相关文档;不改变 BgFilter endpoint、鉴权、`screen_color`、`seg_model`、输出校验、熔断规则、阿里云上传协议或降级顺序。 - 验证方式:定向测试必须断言 BgFilter 请求函数包含 `image_url` 与 600 秒 OSS 换签,不包含 multipart `file` 或源图字节读取;随后运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a15052ec9..43fd27693 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -241,7 +241,7 @@ npm run check:server-rs-ddd - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 - 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 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`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=` 和 `seg_model=`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,均跳过或结束 BgFilter 调用后复用同一兜底链:先调用阿里云通用抠图,阿里云失败才使用本地 `editor_green_screen` 键色扣除;熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败时降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。 -- BgFilter 处理已经持久化到私有 OSS 的带背景原图时,`api-server` 必须签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不再下载对象或用 `file` 重传图片字节;签名 URL 不得写入日志、审计或持久化。只有 BgFilter 失败或熔断打开进入 fallback 后,才允许使用图片字节继续调用阿里云通用抠图或本地键色。 +- 生成后抠图以内存中的 `DownloadedImage` → 私有 OSS owned 上传作为带背景原图生命周期边界:上传消费字节所有权,完成后不保留原图缓冲。BgFilter 必须签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不下载对象或用 `file` 重传;进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲;继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 315174a3e..c4e646474 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -67,7 +67,7 @@ lease 过期后不代表任务一定再次执行:claim transaction 只有在 ` 阿里云通用抠图的非上海地域输入使用 `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`、`screen_color` 和 `seg_model`,不再提交 `file`,也不会在 BgFilter 调用前重新下载 OSS 对象。排障日志只应出现 object key 与签名有效期,不得记录带 `x-oss-*` 查询参数的完整 URL;BgFilter 失败或熔断打开后仍按既有顺序进入阿里云通用抠图和本地键色 fallback。 +BgFilter 对已经落入私有 OSS 的生成原图直接使用 600 秒签名 URL:`api-server` 的 multipart 只提交 `image_url`、`screen_color` 和 `seg_model`,不再提交 `file`,也不会在 BgFilter 调用前重新下载 OSS 对象。带背景原图上传完成后应已消费并释放字节所有权;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 暴露到前端。 diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs index 6094e6171..d60004f75 100644 --- a/server-rs/crates/api-server/src/aliyun_matting.rs +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -43,6 +43,39 @@ 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_url: &str, + log_label: &str, +) -> Result { + let matting_client = state + .matting_client() + .ok_or_else(aliyun_matting_unconfigured_error)?; + + let file_name = format!("{log_label}.png"); + let started_at = std::time::Instant::now(); + let output_bytes = matting_client + .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) + })?; + tracing::info!( + provider = "aliyun-matting", + log_label, + elapsed_ms = started_at.elapsed().as_millis() as u64, + "阿里云通用抠图 URL 输入完成" + ); + + Ok(DownloadedOpenAiImage { + bytes: output_bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + /// 把 platform-matting 的错误映射成审计友好的 AppError。 /// /// 分类(是否外部调用、超时、传输层故障、上游 HTTP 状态)由 platform-matting 在错误发生处 @@ -99,6 +132,22 @@ 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")); + assert!(!body.contains("segment_image_to_transparent_png(image.bytes")); + } + #[test] fn local_preflight_failures_do_not_count_as_external_call() { for error in [ diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 344193301..8cbedbf9e 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -126,7 +126,7 @@ const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_ const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source"; const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; -const EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS: u64 = 600; +const EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS: u64 = 600; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; @@ -1623,7 +1623,7 @@ pub(crate) async fn generate_editor_image_for_owner( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), - &image, + image, submitted_prompt.as_str(), generated.actual_prompt.as_deref(), storage_profile.asset_kind, @@ -1670,7 +1670,6 @@ pub(crate) async fn generate_editor_image_for_owner( let removal = remove_editor_generated_screen_background_with_bgfilter( state, source_object_key.as_str(), - &image, screen_color.expect("character generation should have screen color"), seg_model.expect("character generation should have BgFilter seg model"), &matting_audit, @@ -2759,7 +2758,6 @@ struct EditorScreenBackgroundRemovalOutput { async fn remove_editor_generated_screen_background_with_bgfilter( state: &AppState, source_object_key: &str, - fallback_image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, seg_model: &str, audit: &crate::external_api_audit::ExternalApiAuditContext, @@ -2776,7 +2774,7 @@ async fn remove_editor_generated_screen_background_with_bgfilter( ); return fallback_editor_screen_background_removal( state, - fallback_image, + source_object_key, screen_color, audit, ) @@ -2825,7 +2823,7 @@ async fn remove_editor_generated_screen_background_with_bgfilter( .await; fallback_editor_screen_background_removal( state, - fallback_image, + source_object_key, screen_color, audit, ) @@ -2838,17 +2836,26 @@ async fn remove_editor_generated_screen_background_with_bgfilter( /// 熔断打开路径与 BgFilter 调用失败路径共用此链,确保 cooldown 内仍是「阿里云 → 本地」而非直接本地。 async fn fallback_editor_screen_background_removal( state: &AppState, - image: &DownloadedOpenAiImage, + source_object_key: &str, screen_color: EditorScreenBackgroundColor, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { - match crate::aliyun_matting::segment_image_with_aliyun_matting( + let aliyun_result = match sign_editor_private_object_read_url( state, - image, - "editor-screen-background", - ) - .await - { + source_object_key, + EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS, + ) { + Ok(source_url) => { + crate::aliyun_matting::segment_image_url_with_aliyun_matting( + state, + source_url.as_str(), + "editor-screen-background", + ) + .await + } + Err(error) => Err(error), + }; + match aliyun_result { Ok(image) => Ok(EditorScreenBackgroundRemovalOutput { image, provider: "Aliyun Matting", @@ -2878,12 +2885,13 @@ async fn fallback_editor_screen_background_removal( ) .await; } - remove_editor_generated_green_screen_background(image, screen_color).map(|image| { - EditorScreenBackgroundRemovalOutput { - image, - provider: "Genarrative Local", - model: "screen-color-keying".to_string(), - } + let source_image = download_editor_persisted_image_object(state, source_object_key).await?; + let output = remove_editor_generated_green_screen_background(&source_image, screen_color); + drop(source_image); + output.map(|image| EditorScreenBackgroundRemovalOutput { + image, + provider: "Genarrative Local", + model: "screen-color-keying".to_string(), }) } } @@ -2947,19 +2955,11 @@ async fn request_editor_generated_screen_background_with_bgfilter( seg_model: &str, ) -> Result { let url = editor_bgfilter_endpoint(state)?; - let oss_client = state.oss_client().ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "aliyun-oss", - "reason": "OSS 未完成环境变量配置", - })) - })?; - let signed = oss_client - .sign_get_object_url(OssSignedGetObjectUrlRequest { - object_key: source_object_key.to_string(), - expire_seconds: Some(EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS), - }) - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let signed_image_url = signed.signed_url; + let signed_image_url = sign_editor_private_object_read_url( + state, + source_object_key, + EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS, + )?; let call_id = format!("bgfilter-call-{}", current_utc_micros()); let request_started_at = Instant::now(); let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(1); @@ -2967,7 +2967,7 @@ async fn request_editor_generated_screen_background_with_bgfilter( %call_id, upstream_url = %url, source_object_key, - image_url_expire_seconds = EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS, + image_url_expire_seconds = EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS, screen_color = screen_color.hex, seg_model, timeout_ms, @@ -3257,6 +3257,26 @@ fn editor_bgfilter_endpoint(state: &AppState) -> Result { )) } +fn sign_editor_private_object_read_url( + state: &AppState, + object_key: &str, + expire_seconds: u64, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + oss_client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(expire_seconds), + }) + .map(|signed| signed.signed_url) + .map_err(|error| map_oss_error(error, "aliyun-oss")) +} + fn sanitize_editor_bgfilter_upstream_message(message: &str, signed_image_url: &str) -> String { let sanitized = message.replace(signed_image_url, "[signed OSS URL redacted]"); if sanitized.to_ascii_lowercase().contains("x-oss-") { @@ -3714,7 +3734,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), - &image, + image, prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_ICON_SPRITESHEET_ASSET_KIND, @@ -3755,7 +3775,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( let removal = remove_editor_generated_screen_background_with_bgfilter( state, source_object_key.as_str(), - &image, screen_color, seg_model, &matting_audit, @@ -3891,7 +3910,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( }), ) } - }; + }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( @@ -4325,7 +4344,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), - &image, + image, prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND, @@ -4366,7 +4385,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( let removal = remove_editor_generated_screen_background_with_bgfilter( state, source_object_key.as_str(), - &image, screen_color, seg_model, &matting_audit, @@ -4496,7 +4514,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( }), ) } - }; + }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( @@ -6359,6 +6377,71 @@ pub(crate) async fn persist_editor_generated_image( file_stem: &str, slot: &str, provider: &str, +) -> Result { + persist_editor_generated_image_data( + state, + owner_user_id, + task_id, + GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(image.mime_type.as_str()), + bytes: image.bytes.clone(), + }, + prompt, + actual_prompt, + asset_kind, + path_kind, + file_stem, + slot, + provider, + ) + .await +} + +async fn persist_editor_generated_image_owned( + state: &AppState, + owner_user_id: &str, + task_id: &str, + image: DownloadedOpenAiImage, + prompt: &str, + actual_prompt: Option<&str>, + asset_kind: &str, + path_kind: &str, + file_stem: &str, + slot: &str, + provider: &str, +) -> Result { + let image_data = GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(image.mime_type.as_str()), + bytes: image.bytes, + }; + persist_editor_generated_image_data( + state, + owner_user_id, + task_id, + image_data, + prompt, + actual_prompt, + asset_kind, + path_kind, + file_stem, + slot, + provider, + ) + .await +} + +async fn persist_editor_generated_image_data( + state: &AppState, + owner_user_id: &str, + task_id: &str, + image: GeneratedImageAssetDataUrl, + prompt: &str, + actual_prompt: Option<&str>, + asset_kind: &str, + path_kind: &str, + file_stem: &str, + slot: &str, + provider: &str, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ @@ -6375,10 +6458,7 @@ pub(crate) async fn persist_editor_generated_image( sanitize_editor_storage_segment(task_id, "task"), ], file_stem: sanitize_editor_storage_segment(file_stem, "image"), - image: GeneratedImageAssetDataUrl { - format: normalize_generated_image_asset_mime(image.mime_type.as_str()), - bytes: image.bytes.clone(), - }, + image, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { asset_kind: Some(asset_kind.to_string()), @@ -6454,14 +6534,14 @@ async fn persist_editor_provider_source_image( state: &AppState, owner_user_id: &str, task_id: &str, - image: &DownloadedOpenAiImage, + image: DownloadedOpenAiImage, prompt: &str, actual_prompt: Option<&str>, asset_kind: &str, path_kind: &str, file_stem: &str, ) -> Result { - persist_editor_generated_image( + persist_editor_generated_image_owned( state, owner_user_id, task_id, @@ -6808,7 +6888,7 @@ async fn read_editor_reference_image_object( expire_seconds: Some(EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS), }) .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let response = reqwest::Client::new() + let mut response = reqwest::Client::new() .get(signed.signed_url.as_str()) .send() .await @@ -6840,12 +6920,20 @@ async fn read_editor_reference_image_object( .and_then(|value| value.to_str().ok()) .and_then(normalize_editor_reference_image_mime_type) .map(ToOwned::to_owned); - let bytes = response.bytes().await.map_err(|error| { + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取图片参考图内容失败:{error}"), })) - })?; + })? { + if bytes.len().saturating_add(chunk.len()) as u64 + > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES + { + return Err(editor_reference_image_too_large()); + } + bytes.extend_from_slice(chunk.as_ref()); + } if bytes.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ @@ -6855,11 +6943,8 @@ async fn read_editor_reference_image_object( })), ); } - if bytes.len() as u64 > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES { - return Err(editor_reference_image_too_large()); - } let mime_type = content_type_mime - .or_else(|| infer_editor_reference_image_mime_type(bytes.as_ref()).map(ToOwned::to_owned)) + .or_else(|| infer_editor_reference_image_mime_type(bytes.as_slice()).map(ToOwned::to_owned)) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-reference-image", @@ -6871,12 +6956,24 @@ async fn read_editor_reference_image_object( .to_string(); let extension = editor_reference_image_extension(mime_type.as_str()); Ok(OpenAiReferenceImage { - bytes: bytes.to_vec(), + bytes, mime_type, file_name: format!("editor-reference.{extension}"), }) } +async fn download_editor_persisted_image_object( + state: &AppState, + object_key: &str, +) -> Result { + let image = read_editor_reference_image_object(state, object_key).await?; + Ok(DownloadedOpenAiImage { + extension: editor_reference_image_extension(image.mime_type.as_str()).to_string(), + mime_type: image.mime_type, + bytes: image.bytes, + }) +} + fn normalize_editor_reference_image_mime_type(content_type: &str) -> Option<&str> { let mime_type = content_type.split(';').next()?.trim(); mime_type.starts_with("image/").then_some(mime_type) @@ -9445,8 +9542,10 @@ mod tests { "async fn fallback_editor_screen_background_removal", "async fn request_editor_generated_screen_background_with_bgfilter", &[ - "segment_image_with_aliyun_matting", + "segment_image_url_with_aliyun_matting", + "download_editor_persisted_image_object", "remove_editor_generated_green_screen_background", + "drop(source_image)", ], ); assert_function_contains( @@ -9455,8 +9554,8 @@ mod tests { "async fn request_editor_background_removal_image", &[ "editor_bgfilter_endpoint", - "sign_get_object_url", - "EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS", + "sign_editor_private_object_read_url", + "EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS", "\"image_url\"", "editor_bgfilter_request_timeout_ms.max(1)", "\"screen_color\"", @@ -9541,6 +9640,95 @@ mod tests { ); } + #[test] + fn editor_matting_releases_source_buffers_at_oss_boundaries() { + let source = include_str!("editor_project.rs"); + for (start, end) in [ + ( + "pub(crate) async fn generate_editor_image_for_owner", + "fn normalize_editor_image_generation_size", + ), + ( + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn extract_editor_ui_design_assets", + ), + ( + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "pub(crate) fn editor_project_payload_from_record", + ), + ] { + assert_function_contains_in_order( + source, + start, + end, + &[ + "persist_editor_provider_source_image", + "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_generated_image", + ], + ); + } + assert_function_contains( + source, + "async fn persist_editor_provider_source_image", + "async fn persist_editor_provider_source_resource", + &["persist_editor_generated_image_owned"], + ); + for (start, end) in [ + ( + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn extract_editor_ui_design_assets", + ), + ( + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "pub(crate) fn editor_project_payload_from_record", + ), + ] { + assert_function_contains( + source, + start, + end, + &["bytes: image.bytes.clone()", "slice_source"], + ); + assert_function_not_contains( + source, + start, + end, + &["download_editor_persisted_image_object", "drop(slice_source)"], + ); + } + assert_function_not_contains( + source, + "async fn remove_editor_generated_screen_background_with_bgfilter", + "async fn fallback_editor_screen_background_removal", + &["fallback_image", "DownloadedOpenAiImage"], + ); + assert_function_contains( + source, + "async fn persist_editor_generated_image_owned", + "async fn persist_editor_generated_image_data", + &["bytes: image.bytes"], + ); + assert_function_not_contains( + source, + "async fn persist_editor_generated_image_owned", + "async fn persist_editor_generated_image_data", + &["image.bytes.clone()"], + ); + assert_function_contains( + source, + "async fn read_editor_reference_image_object", + "async fn download_editor_persisted_image_object", + &["response.chunk().await", "bytes.extend_from_slice"], + ); + assert_function_not_contains( + source, + "async fn read_editor_reference_image_object", + "async fn download_editor_persisted_image_object", + &["response.bytes().await", "bytes.to_vec()"], + ); + } + #[test] fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() { let source = include_str!("editor_project.rs"); diff --git a/server-rs/crates/platform-matting/src/lib.rs b/server-rs/crates/platform-matting/src/lib.rs index 1cf2c7312..70f8f9728 100644 --- a/server-rs/crates/platform-matting/src/lib.rs +++ b/server-rs/crates/platform-matting/src/lib.rs @@ -407,16 +407,9 @@ impl MattingClient { let image_url = self .upload_temp_image(upload_bytes, file_name, "image/png") .await?; - - let result = self - .segment_common_image(SegmentCommonImageRequest { - image_url, - // 默认 ReturnForm:原尺寸 + 透明背景,无需本地合成。 - return_form: None, - }) + let result_image = self + .segment_uploaded_input_to_rgba(image_url, (source_width, source_height), upload_dims) .await?; - let result_bytes = self.download_result_image(&result.image_url).await?; - let result_image = decode_result_image(&result_bytes)?.to_rgba8(); if !downscaled { if result_image.dimensions() != (source_width, source_height) { @@ -434,18 +427,140 @@ impl MattingClient { return encode_rgba_png(&result_image); } - // 缩图送抠的场景:只取结果 alpha,上采样回原尺寸后贴回原图 RGB。 - let alpha_mask = image::DynamicImage::ImageRgba8(result_image).resize_exact( - source_width, - source_height, - image::imageops::FilterType::Triangle, - ); - let alpha_mask = alpha_mask.to_rgba8(); - let mut output = source_rgba; - for (target, mask) in output.pixels_mut().zip(alpha_mask.pixels()) { - target.0[3] = target.0[3].min(mask.0[3]); + compose_source_with_result_alpha(source_rgba, result_image) + } + + /// 私有 OSS 签名 URL → 下载 → AuthorizeFileUpload 临时对象 → 通用抠图 → 透明 PNG。 + /// + /// 源图下载缓冲和解码图只活到临时对象上传完成;若输入发生过降尺寸,结果返回后再单独下载 + /// 一次源图完成 Alpha 回贴,避免在整个阿里云推理期间常驻原图内存。 + pub async fn segment_image_url_to_transparent_png( + &self, + source_url: &str, + file_name: &str, + ) -> Result, MattingError> { + let source_url = source_url.trim(); + if source_url.is_empty() { + return Err(MattingError::InvalidRequest( + "待抠图源图 URL 不能为空".to_string(), + )); } - encode_rgba_png(&output) + + let (source_dims, upload_dims, image_url) = { + let source_bytes = self.download_source_image(source_url).await?; + let source = decode_source_image(&source_bytes)?; + let source_dims = (source.width(), source.height()); + validate_source_dimensions(source_dims)?; + let (upload_bytes, upload_dims) = normalize_matting_input_png(&source)?; + let image_url = self + .upload_temp_image(upload_bytes, file_name, "image/png") + .await?; + (source_dims, upload_dims, image_url) + }; + + let result_image = self + .segment_uploaded_input_to_rgba(image_url, source_dims, upload_dims) + .await?; + if upload_dims == source_dims { + return encode_rgba_png(&result_image); + } + + // 只有降尺寸送抠时才重新下载源图恢复原始 RGB;第一次下载缓冲已在上传临时对象后释放。 + let source_rgba = { + let source_bytes = self.download_source_image(source_url).await?; + let source = decode_source_image(&source_bytes)?; + if (source.width(), source.height()) != source_dims { + return Err(MattingError::upstream_response_error( + "待抠图源图在处理期间尺寸发生变化".to_string(), + None, + )); + } + source.to_rgba8() + }; + compose_source_with_result_alpha(source_rgba, result_image) + } + + async fn segment_uploaded_input_to_rgba( + &self, + image_url: String, + source_dims: (u32, u32), + upload_dims: (u32, u32), + ) -> Result { + let result = self + .segment_common_image(SegmentCommonImageRequest { + image_url, + // 默认 ReturnForm:原尺寸 + 透明背景,无需本地合成。 + return_form: None, + }) + .await?; + let result_image = { + let result_bytes = self.download_result_image(&result.image_url).await?; + decode_result_image(&result_bytes)?.to_rgba8() + }; + if upload_dims == source_dims && result_image.dimensions() != source_dims { + return Err(MattingError::upstream_response_error( + format!( + "抠图结果尺寸 {}x{} 与输入 {}x{} 不一致", + result_image.width(), + result_image.height(), + source_dims.0, + source_dims.1 + ), + None, + )); + } + Ok(result_image) + } + + async fn download_source_image(&self, url: &str) -> Result, MattingError> { + let mut response = self.client.get(url).send().await.map_err(|error| { + MattingError::upstream_transport_error( + format!( + "下载待抠图源图失败(transport={}, timeout={}, connect={})", + classify_reqwest_error(&error), + error.is_timeout(), + error.is_connect() + ), + error.is_timeout(), + ) + })?; + let status = response.status(); + if !status.is_success() { + return Err(MattingError::upstream_http_error( + format!("下载待抠图源图失败(HTTP {})", status.as_u16()), + status.as_u16(), + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_RESULT_RESPONSE_BYTES as u64) + { + return Err(source_response_too_large_error()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|error| { + MattingError::upstream_transport_error( + format!( + "读取待抠图源图失败(transport={}, timeout={}, connect={})", + classify_reqwest_error(&error), + error.is_timeout(), + error.is_connect() + ), + error.is_timeout(), + ) + })? { + if bytes.len().saturating_add(chunk.len()) > MAX_RESULT_RESPONSE_BYTES { + return Err(source_response_too_large_error()); + } + bytes.extend_from_slice(chunk.as_ref()); + } + if bytes.is_empty() { + return Err(MattingError::upstream_response_error( + "待抠图源图为空".to_string(), + Some(status.as_u16()), + )); + } + Ok(bytes) } async fn download_result_image(&self, url: &str) -> Result, MattingError> { @@ -663,6 +778,33 @@ fn normalize_matting_input_png( normalize_matting_input_png_within(source, MAX_INPUT_BYTES) } +fn validate_source_dimensions(source_dims: (u32, u32)) -> Result<(), MattingError> { + if source_dims.0 <= MIN_INPUT_EDGE || source_dims.1 <= MIN_INPUT_EDGE { + return Err(MattingError::InvalidRequest(format!( + "待抠图图片尺寸 {}x{} 过小,阿里云通用抠图要求每条边大于 {MIN_INPUT_EDGE} 像素", + source_dims.0, source_dims.1 + ))); + } + Ok(()) +} + +fn compose_source_with_result_alpha( + mut source_rgba: image::RgbaImage, + result_image: image::RgbaImage, +) -> Result, MattingError> { + let (source_width, source_height) = source_rgba.dimensions(); + let alpha_mask = image::DynamicImage::ImageRgba8(result_image).resize_exact( + source_width, + source_height, + image::imageops::FilterType::Triangle, + ); + let alpha_mask = alpha_mask.to_rgba8(); + for (target, mask) in source_rgba.pixels_mut().zip(alpha_mask.pixels()) { + target.0[3] = target.0[3].min(mask.0[3]); + } + encode_rgba_png(&source_rgba) +} + /// `normalize_matting_input_png` 的可注入体积上限版本,便于单测用小阈值触发降尺寸循环。 fn normalize_matting_input_png_within( source: &image::DynamicImage, @@ -720,6 +862,13 @@ fn result_response_too_large_error() -> MattingError { ) } +fn source_response_too_large_error() -> MattingError { + MattingError::upstream_response_error( + format!("待抠图源图响应过大,超过 {MAX_RESULT_RESPONSE_BYTES} 字节上限"), + None, + ) +} + /// 解码待抠图源图时套上尺寸 / 分配上限。源图直接来自请求体 / 生成产物,缺省 /// `image::Limits` 无尺寸上限、仅 512MiB alloc 兜底,12MB 请求体即可构造巨幅压缩图 /// (解压炸弹)在缩图前撑爆内存;逐帧动画并发调用时风险叠加。 @@ -1311,4 +1460,47 @@ mod tests { "降尺寸后仍是 PNG" ); } + + #[test] + fn url_input_upload_scope_ends_before_aliyun_inference() { + let source = include_str!("lib.rs"); + let start = source + .find("pub async fn segment_image_url_to_transparent_png") + .expect("URL input method should exist"); + let tail = &source[start..]; + let end = tail + .find("async fn segment_uploaded_input_to_rgba") + .expect("uploaded input helper should follow URL method"); + let body = &tail[..end]; + + let download = body + .find("let source_bytes = self.download_source_image(source_url).await?") + .expect("source should download lazily"); + let upload = body + .find(".upload_temp_image(upload_bytes, file_name, \"image/png\")") + .expect("source should upload to temporary OSS"); + let scope_end = body + .find("let result_image = self") + .expect("Aliyun inference should start after upload scope"); + assert!(download < upload && upload < scope_end); + assert!(body.contains("let (source_dims, upload_dims, image_url) = {")); + assert_eq!(body.matches("download_source_image(source_url)").count(), 2); + } + + #[test] + fn compose_source_with_result_alpha_preserves_rgb() { + let source = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 180])); + let mask = image::RgbaImage::from_pixel(1, 1, image::Rgba([200, 210, 220, 90])); + + let encoded = compose_source_with_result_alpha(source, mask) + .expect("alpha composition should encode"); + let output = image::load_from_memory(&encoded) + .expect("output should decode") + .to_rgba8(); + + assert_eq!(output.dimensions(), (2, 2)); + assert!(output + .pixels() + .all(|pixel| pixel.0 == [10, 20, 30, 90])); + } } -- 2.52.0 From 471f874587c295c99583d47c4659ad75658dfe05 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 17 Jul 2026 05:53:25 +0000 Subject: [PATCH 04/18] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=90=8E=E7=9A=84BgFilter=E8=B6=85=E6=97=B6=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 请求超时测试改用保留的动作帧字节调用入口。 生产生成原图继续使用 OSS object key 与短期签名 URL。 --- server-rs/crates/api-server/src/editor_project.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 95520f13c..d0cfd83a7 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -10549,7 +10549,7 @@ mod tests { extension: "png".to_string(), }; - let removed = request_editor_generated_screen_background_with_bgfilter( + let removed = request_editor_generated_screen_background_bytes_with_bgfilter( &state, &source_image, parse_editor_screen_background_color(Some("#CFEFFF")) -- 2.52.0 From d1b6b5e695477ffb381ef02df3d946da9a55a78a Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 17 Jul 2026 08:02:33 +0000 Subject: [PATCH 05/18] =?UTF-8?q?=E5=8F=96=E6=B6=88=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=8E=BB=E8=83=8C=E6=99=AFData=20URL=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手动去背景在HTTP入队前拒绝Data URL。 worker执行前重复校验,防止历史任务和内部调用绕过。 保留objectKey、resourceId和assetId输入并补充定向测试与架构文档。 --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../crates/api-server/src/editor_project.rs | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 217edd854..856c69cf8 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -242,7 +242,7 @@ npm run check:server-rs-ddd - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 - 生成后抠图以内存中的 `DownloadedImage` → 私有 OSS owned 上传作为带背景原图生命周期边界:上传消费字节所有权,完成后不保留原图缓冲。BgFilter 必须签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不下载对象或用 `file` 重传;进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲;继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 -- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一走 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`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `screen_color`;标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路的 BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。 +- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一走 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`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `screen_color`;该 API 只接受已登记的 `objectKey`、`resourceId` 或 `assetId`,BFF 入队前和 worker 执行前都拒绝 Data URL。标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路的 BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。 - BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是所有路径的基准请求超时;角色动作逐帧 BgFilter 的每一次 HTTP attempt 使用“基准超时 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。api-server 只在共享 Client 的单次 RequestBuilder 上覆盖该值;它覆盖从请求发起到响应体读取完成,是单次 attempt 的总 deadline,不是整批帧或 worker job 超时,重试会重新获得同样的 deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d0cfd83a7..cf0ea925d 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2908,6 +2908,7 @@ pub async fn remove_editor_image_background( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { + ensure_editor_background_removal_source_is_not_data_url(payload.source_image_src.as_str())?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), @@ -2932,6 +2933,20 @@ pub async fn remove_editor_image_background( )) } +fn ensure_editor_background_removal_source_is_not_data_url(source: &str) -> Result<(), AppError> { + let prefix = source.trim_start().as_bytes().get(..5); + if prefix.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"data:")) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "sourceImageSrc", + "message": "手动去背景图片必须先上传 OSS,并提交已登记的 objectKey、resourceId 或 assetId,不能提交 Data URL。", + })), + ); + } + Ok(()) +} + pub(crate) async fn remove_editor_image_background_for_owner( state: &AppState, request_context: &RequestContext, @@ -2939,6 +2954,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { let started_at = Instant::now(); + ensure_editor_background_removal_source_is_not_data_url(payload.source_image_src.as_str())?; caller.report_processing_phase(state).await?; let source_image = parse_editor_reference_image( state, @@ -9914,6 +9930,48 @@ mod tests { assert!(normalize_icon_descriptions(too_many).is_err()); } + #[test] + fn editor_background_removal_rejects_data_url_before_queue_and_worker_execution() { + for source in [ + "data:image/png;base64,AAAA", + " \nDaTa:image/webp;base64,BBBB", + ] { + let error = ensure_editor_background_removal_source_is_not_data_url(source) + .expect_err("手动去背景不应继续兼容 Data URL"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("不能提交 Data URL")); + } + for source in [ + "generated-character-drafts/editor/source.png", + "resource-1", + "asset-1", + ] { + ensure_editor_background_removal_source_is_not_data_url(source) + .expect("已登记的 OSS 对象引用应继续进入后续归属校验"); + } + + let source = include_str!("editor_project.rs"); + assert_function_contains_in_order( + source, + "pub async fn remove_editor_image_background(", + "pub(crate) async fn remove_editor_image_background_for_owner", + &[ + "ensure_editor_background_removal_source_is_not_data_url", + "enqueue_editor_generation_job", + ], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn remove_editor_image_background_for_owner", + "struct EditorBackgroundRemovalImage", + &[ + "ensure_editor_background_removal_source_is_not_data_url", + "caller.report_processing_phase(state).await?", + "parse_editor_reference_image", + ], + ); + } + #[test] fn editor_reference_image_data_url_stays_compatible() { let parsed = parse_editor_reference_image_data_url("data:image/png;base64,AAAA") -- 2.52.0 From 68283c9469641708ff2d1d6e183ea73fbe6bebba Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 17 Jul 2026 12:24:55 +0000 Subject: [PATCH 06/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E7=9A=84OSS=E4=B8=B4=E6=97=B6URL=E8=B0=83?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手动去背景直接校验对象归属并向BgFilter提交OSS临时URL 角色动作帧上传OSS时转移字节所有权并按object key执行去背和降级 删除旧的BgFilter文件上传与阿里云字节输入适配链路 更新抠图内存边界、接口契约与运维文档 --- .../shared-memory/decision-log.md | 8 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 8 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 6 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- .../crates/api-server/src/aliyun_matting.rs | 33 -- .../src/character_animation_assets.rs | 20 +- .../crates/api-server/src/editor_project.rs | 448 ++++-------------- 7 files changed, 106 insertions(+), 421 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index af11422e1..ecc587fc8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,15 +18,15 @@ ## 2026-07-17 生成后抠图原图以 OSS 作为内存生命周期边界 -- 背景:角色形象、图标图集和 UI 素材图集的生成原图虽然已先落私有 OSS,但 api-server 仍把带背景原图字节保留到 BgFilter / 阿里云 / 本地 fallback 结束,造成并发任务下的内存峰值叠加。 -- 决策:目标链路的带背景原图上传 OSS 时消费 `DownloadedImage` 所有权,不为上传克隆整张字节缓冲;上传完成后不再跨 BgFilter 调用常驻。BgFilter 只读取 600 秒签名 URL;进入阿里云 fallback 时由 `platform-matting` 新 URL 接口下载原图、上传 `AuthorizeFileUpload` 临时对象,并在开始阿里云推理前结束下载缓冲作用域;阿里云继续失败时,api-server 再从私有 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` 析构,RSS 不保证同步下降。 - 验证方式:`platform-matting` 测试覆盖 URL 下载缓冲在临时上传后结束、降尺寸 Alpha 回贴;`api-server` 结构测试覆盖带背景原图 owned 上传、URL 阿里云 fallback、本地重新下载与原图释放;随后运行两个 crate 的测试与编译检查。 ## 2026-07-15 BgFilter 输入改用私有 OSS 短期签名 URL -- 背景:角色形象、图标图集和 UI 素材图集在调用 BgFilter 前已经把带背景原图持久化到私有 OSS;继续由 api-server 把同一图片作为 multipart `file` 再上传一次,会重复传输图片字节并占用 API 进程网络带宽。 -- 决策:上述生成后抠图链路统一复用已持久化原图的 object key,签发 600 秒 OSS GET URL,并通过 BgFilter multipart 的 `image_url` 字段提交;请求中不再携带 `file`。签名 URL 只交给 BgFilter,不写日志或持久化。2026-07-17 起,原图上传后不再保留图片字节;进入“阿里云通用抠图 → 本地键色”兜底链时按阶段从私有 OSS 重新下载。 +- 背景:角色形象、图标图集、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 重新下载。 - 影响范围:仅 `api-server/editor_project` 的 BgFilter 请求输入与相关文档;不改变 BgFilter endpoint、鉴权、`screen_color`、`seg_model`、输出校验、熔断规则、阿里云上传协议或降级顺序。 - 验证方式:定向测试必须断言 BgFilter 请求函数包含 `image_url` 与 600 秒 OSS 换签,不包含 multipart `file` 或源图字节读取;随后运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index dd0e28295..34e73a063 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,9 +21,9 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 校验已有 OSS object key 归属后直接签发 600 秒 URL,固定提交 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不下载原图,也不提交 `file` 或 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 -- 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 +- 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新签发 OSS URL、重新计时,第二次仍失败才按 object key 进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图上传 OSS 并释放原帧字节 → 以签名 URL 调 BgFilter/按 object key 降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 - 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置;透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 @@ -88,7 +88,7 @@ - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=`,透明处理成功时生成透明 PNG,最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。宣发素材携带 `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 / asset 快照。 -- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后无条件创建外部生成任务,响应只返回 `queueState`。worker 由 api-server 解析图片文件,并通过共享 BgFilter HTTP client 调用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`;multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。 +- `POST /api/editor/images/background-removals`:接收当前图片的已登记 `objectKey`、`resourceId` 或 `assetId`,校验登录态后无条件创建外部生成任务,响应只返回 `queueState`。worker 解析 object key 并校验归属,不下载原图;调用共享 BgFilter HTTP client 前签发 600 秒 OSS URL,multipart 固定为 `image_url + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `file` 或 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 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`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 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`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸;nanobanana2 路径保留 provider 按比例和清晰度返回的几何尺寸。成功时只上传最终结果,尺寸恢复失败时只上传 provider 原图;无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 @@ -138,7 +138,7 @@ - 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。 - 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `