优化抠图链路的OSS临时URL调用

手动去背景直接校验对象归属并向BgFilter提交OSS临时URL

角色动作帧上传OSS时转移字节所有权并按object key执行去背和降级

删除旧的BgFilter文件上传与阿里云字节输入适配链路

更新抠图内存边界、接口契约与运维文档
This commit is contained in:
2026-07-17 12:24:55 +00:00
parent d1b6b5e695
commit 68283c9469
7 changed files with 106 additions and 421 deletions
@@ -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<u8>` 析构,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`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -68,11 +68,11 @@ HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer
lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。
图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter;当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,该值是所有路径的基准请求超时;角色动作逐帧请求的每一次 HTTP attempt 额外增加 `2000ms × 本次实际帧数`,默认 `32 / 40 / 48` 帧对应 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景继续使用基准值。该 request timeout 不是整批帧或整项任务超时,首次失败后的重试会重新计时;角色动作整项任务仍受默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800` 预算约束,排查时以 `editor_bgfilter_request_start.timeout_ms` 确认实际值。连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒。BgFilter 调用失败和熔断期均先走阿里云通用抠图,只有阿里云失败才走本地幕布色去背景兜底。阿里云这层默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,抠图直接塌成 BgFilter→本地两级,`npm run check:api-server-env` 也会给出对应告警。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看带 `background_mode=flat``editor_bgfilter_request_start``editor_bgfilter_fallback_to_aliyun_matting``editor_bgfilter_circuit_open_fallback_to_aliyun_matting`,以及阿里云失败后的 `editor_aliyun_matting_fallback_to_local_screen_background_removal` 日志。
手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但固定传 `background_mode=complex``seg_model=birefnet``cross_check=off`,不传背景色首次失败后立即重试 `1` 次,两次都失败则返回最终错误。它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链,也不改变 flat 路径的熔断状态。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。
手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但 worker 只解析并校验已有 OSS object key,直接签发 600 秒 URL,不下载原图;multipart 固定传 `image_url``background_mode=complex``seg_model=birefnet``cross_check=off`,不传 `file`背景色首次失败后立即重试 `1` 次,两次都失败则返回最终错误。它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链,也不改变 flat 路径的熔断状态。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。
阿里云通用抠图的非上海地域输入使用 `AuthorizeFileUpload → Policy POST → SegmentCommonImage` 正式链路,上传 Bucket / Endpoint / ObjectKey 由阿里云动态返回;不得恢复 `GetOssStsToken`、固定 `viapi-customer-temp`、临时 AK/SK 或 OSS V1 PUT。该切换不新增环境变量;真实链路冒烟可运行 `cargo run -p platform-matting --example segment_smoke --manifest-path server-rs/Cargo.toml -- <图片路径>`,预期日志中的输入 host 为授权响应返回的上海 OSS host,并完成结果下载。图片字节仍经过执行任务的 api-server / worker,排障时不要把 Advance 路径误判为阿里云直接抓取任意公网 URL。
BgFilter 对已经落入私有 OSS 的生成原图直接使用 600 秒签名 URL:`api-server` 的 multipart 提交 `image_url``screen_color``seg_model`,不再提交 `file`,也不会在 BgFilter 调用前重新下载 OSS 对象。带背景原图上传完成后应已消费并释放字节所有权;BgFilter 失败或熔断打开后,阿里云 fallback 才单独下载源对象并上传动态临时桶,临时上传完成即释放本次下载缓冲;阿里云继续失败时本地 fallback 再独立下载,并在本地处理产出后释放本次原图缓冲。排障日志只应出现 object key 与签名有效期,不得记录带 `x-oss-*` 查询参数的完整 URL。这里的释放是 Rust 缓冲析构,不以操作系统 RSS 立即下降作为判据。
BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去背景源图直接使用 600 秒签名 URL`api-server` 的 multipart 提交 `image_url` 及对应模式参数,不再提交 `file`,也不会在 BgFilter 调用前重新下载 OSS 对象。生成原图和动作帧上传完成后应已消费并释放字节所有权;手动路径从始至终不读取原图字节。flat 路径的 BgFilter 失败或熔断打开后,阿里云 fallback 才单独下载源对象并上传动态临时桶,临时上传完成即释放本次下载缓冲;阿里云继续失败时本地 fallback 再独立下载,并在本地处理产出后释放本次原图缓冲。排障日志只应出现 object key 与签名有效期,不得记录带 `x-oss-*` 查询参数的完整 URL。这里的释放是 Rust 缓冲析构,不以操作系统 RSS 立即下降作为判据。
`我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。
@@ -10,39 +10,6 @@ use crate::{
http_error::AppError, openai_image_generation::DownloadedOpenAiImage, state::AppState,
};
/// 图片字节 → 阿里云通用抠图 → 原尺寸透明 PNG。
/// 未配置抠图客户端时返回错误,由调用方决定是否降级本地算法。
pub(crate) async fn segment_image_with_aliyun_matting(
state: &AppState,
image: &DownloadedOpenAiImage,
log_label: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
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_to_transparent_png(image.bytes.as_slice(), &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,
"阿里云通用抠图完成"
);
Ok(DownloadedOpenAiImage {
bytes: output_bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
/// 私有 OSS 签名 URL → 延迟下载 → 阿里云临时桶 → 原尺寸透明 PNG。
/// 下载和临时上传由 platform-matting 收口,源图缓冲不会跨越整次阿里云推理常驻。
pub(crate) async fn segment_image_url_with_aliyun_matting(
@@ -77,7 +77,6 @@ use crate::{
resolve_editor_screen_background_color,
},
http_error::AppError,
openai_image_generation::DownloadedOpenAiImage,
platform_errors::map_oss_error,
prompt::role_asset_studio::{
build_role_asset_workflow, normalize_animation_prompt_text_by_key,
@@ -2391,7 +2390,7 @@ async fn process_and_persist_editor_character_animation_frame(
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<ProcessedEditorCharacterAnimationFrame, AppError> {
// 中文注释:每一帧只要求自己的绿幕源图先落 OSS,不再等待整批源图全部上传完成。
put_character_animation_object(
let source_put = put_character_animation_object(
state,
LegacyAssetPrefix::Animations,
vec![
@@ -2405,7 +2404,7 @@ async fn process_and_persist_editor_character_animation_frame(
frame.extension
),
frame.mime_type.clone(),
frame.bytes.clone(),
frame.bytes,
build_asset_metadata(
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
@@ -2417,14 +2416,9 @@ async fn process_and_persist_editor_character_animation_frame(
)
.await?;
let image = DownloadedOpenAiImage {
bytes: frame.bytes,
mime_type: frame.mime_type,
extension: frame.extension,
};
let removed = remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
state,
&image,
source_put.object_key.as_str(),
screen_color,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
@@ -6147,6 +6141,7 @@ mod tests {
"put_character_animation_object",
"green-screen-frame",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"source_put.object_key.as_str()",
"bgfilter_request_timeout_ms",
"finalize_animation_frame_payload",
"put_character_animation_object",
@@ -6168,6 +6163,13 @@ mod tests {
"finalize_animation_frame_payload",
],
);
let frame_pipeline = source
.split_once("async fn process_and_persist_editor_character_animation_frame")
.and_then(|(_, tail)| tail.split_once("async fn publish_animation_set"))
.map(|(body, _)| body)
.expect("frame pipeline function should exist");
assert!(!frame_pipeline.contains("frame.bytes.clone()"));
assert!(!frame_pipeline.contains("DownloadedOpenAiImage"));
}
#[test]
File diff suppressed because it is too large Load Diff