内存优化 (#91)

BGfilter服务调用改为传url,减少内存占用

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/91
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #91.
This commit is contained in:
2026-07-18 21:17:35 +08:00
committed by 段舒康
parent d465e9b66c
commit 0c04bbbea3
18 changed files with 1475 additions and 440 deletions
-3
View File
@@ -4495,18 +4495,15 @@ dependencies = [
name = "platform-matting"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"dotenvy",
"hex",
"hmac",
"httpdate",
"image",
"platform-oss",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_urlencoded",
"sha1",
"sha2",
"time",
"tokio",
+122 -13
View File
@@ -1,21 +1,20 @@
//! 阿里云通用抠图在 api-server 侧的适配层。
//!
//! 输入 URL 策略:抠图服务只认上海地域 OSS URL,而我们没有上海地域自有 OSS,
//! 统一由 platform-matting 上传 VIAPI 官方临时桶(1 天自动过期,无需清理)。
//! 输入统一由 platform-matting 通过 AuthorizeFileUpload 单对象 Policy 上传动态临时 OSS。
use axum::http::StatusCode;
use platform_matting::MattingError;
use serde_json::json;
use serde_json::{Value, json};
use crate::{
http_error::AppError, openai_image_generation::DownloadedOpenAiImage, state::AppState,
};
/// 图片字节 → 阿里云通用抠图 → 原尺寸透明 PNG。
/// 未配置抠图客户端时返回错误,由调用方决定是否降级本地算法。
pub(crate) async fn segment_image_with_aliyun_matting(
/// 私有 OSS 签名 URL → 延迟下载 → 阿里云临时桶 → 原尺寸透明 PNG。
/// 下载和临时上传由 platform-matting 收口,源图缓冲不会跨越整次阿里云推理常驻。
pub(crate) async fn segment_image_url_with_aliyun_matting(
state: &AppState,
image: &DownloadedOpenAiImage,
image_url: &str,
log_label: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let matting_client = state
@@ -25,7 +24,7 @@ pub(crate) async fn segment_image_with_aliyun_matting(
let file_name = format!("{log_label}.png");
let started_at = std::time::Instant::now();
let output_bytes = matting_client
.segment_image_to_transparent_png(image.bytes.as_slice(), &file_name)
.segment_image_url_to_transparent_png(image_url, &file_name)
.await
.map_err(|error| {
aliyun_matting_failure_to_app_error(&error, started_at.elapsed().as_millis() as u64)
@@ -34,7 +33,7 @@ pub(crate) async fn segment_image_with_aliyun_matting(
provider = "aliyun-matting",
log_label,
elapsed_ms = started_at.elapsed().as_millis() as u64,
"阿里云通用抠图完成"
"阿里云通用抠图 URL 输入完成"
);
Ok(DownloadedOpenAiImage {
@@ -48,18 +47,36 @@ pub(crate) async fn segment_image_with_aliyun_matting(
///
/// 分类(是否外部调用、超时、传输层故障、上游 HTTP 状态)由 platform-matting 在错误发生处
/// 结构化捕获,这里只做协议中立的读取,不再从中文 message 反推——外部供应商协议归属留在
/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是发请求前的本地预检失败,
/// `external_call_attempted()` 为 false,据此跳过外部失败审计。
/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是尚未开始外部调用的本地预检失败;
/// URL 链路在 OSS GET 成功后发生的解码、尺寸或其它本地处理失败由 `LocalProcessing` 表示,
/// 仍然需要进入外部失败审计,但不得包装成可重试的上游 5xx。
fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> AppError {
let message = error.message();
if !error.external_call_attempted() {
// 本地预检失败(未配置 / 图片解码失败 / 尺寸过小 / 签名构造失败),未触达阿里云。
// 本地预检失败(未配置 / 尚未下载源图前的参数或签名错误),未触达外部调用链路。
return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": false,
"transport": false,
"localProcessing": false,
"externalCallAttempted": false,
"failureStage": error.failure_stage(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}));
}
// 外部调用已开始,但失败在本地解码 / 校验 / 归一等阶段:要审计,不能标成上游 5xx / 可重试。
if matches!(error, MattingError::LocalProcessing(_)) {
return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": false,
"transport": false,
"localProcessing": true,
"upstreamStatus": Value::Null,
"externalCallAttempted": true,
"failureStage": error.failure_stage(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}));
@@ -75,7 +92,10 @@ fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) ->
"message": message,
"timeout": timeout,
"transport": error.is_transport(),
"localProcessing": false,
"upstreamStatus": error.upstream_status(),
"externalCallAttempted": true,
"failureStage": error.failure_stage(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}))
@@ -86,6 +106,7 @@ fn aliyun_matting_unconfigured_error() -> AppError {
"provider": "aliyun-matting",
"message": "阿里云抠图客户端未配置或未启用。",
"externalCallAttempted": false,
"failureStage": "preflight",
}))
}
@@ -100,6 +121,21 @@ mod tests {
assert!(!crate::external_api_audit::matting_failure_external_call_attempted(&error));
}
#[test]
fn url_input_path_delegates_download_and_temp_upload_to_platform_matting() {
let source = include_str!("aliyun_matting.rs");
let start = source
.find("pub(crate) async fn segment_image_url_with_aliyun_matting")
.expect("URL input adapter should exist");
let tail = &source[start..];
let end = tail
.find("/// 把 platform-matting 的错误映射")
.expect("URL input adapter should end before error mapper");
let body = &tail[..end];
assert!(body.contains("segment_image_url_to_transparent_png"));
}
#[test]
fn local_preflight_failures_do_not_count_as_external_call() {
for error in [
@@ -108,7 +144,7 @@ mod tests {
),
MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string()),
MattingError::InvalidConfig("endpoint 为空".to_string()),
MattingError::Sign("初始化 OSS V1 签名器失败".to_string()),
MattingError::Sign("构造 AuthorizeFileUpload 签名失败".to_string()),
] {
let mapped = aliyun_matting_failure_to_app_error(&error, 3);
@@ -129,6 +165,79 @@ mod tests {
}
}
#[test]
fn local_processing_after_source_download_is_audited_with_failure_stage() {
for (error, expected_stage) in [
(
MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string())
.with_failure_stage("source_decode"),
"source_decode",
),
(
MattingError::InvalidRequest("待抠图图片尺寸 16x16 过小".to_string())
.with_failure_stage("source_validate"),
"source_validate",
),
] {
let mapped = aliyun_matting_failure_to_app_error(&error, 3);
assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped));
// 本地处理失败要审计,但 HTTP 包装不得落成可重试 5xx。
assert_eq!(mapped.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
let details = mapped.details().expect("details present");
assert_eq!(
details
.get("externalCallAttempted")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
details.get("localProcessing").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
details.get("transport").and_then(|v| v.as_bool()),
Some(false)
);
assert!(
details
.get("upstreamStatus")
.is_none_or(|value| value.is_null())
);
assert_eq!(
details.get("failureStage").and_then(|v| v.as_str()),
Some(expected_stage)
);
assert_eq!(
crate::external_api_audit::matting_failure_audit_failure_stage(
&mapped,
"aliyun_segment",
),
expected_stage
);
assert_eq!(
crate::external_api_audit::matting_failure_audit_status_code(&mapped),
None,
"本地处理失败没有上游 HTTP 状态,不能回退包装码"
);
let draft = crate::external_api_audit::build_matting_external_api_failure_draft(
"aliyun-matting",
"imageseg.example".to_string(),
"editor-screen-background-removal",
expected_stage,
crate::external_api_audit::matting_failure_audit_status_code(&mapped),
crate::external_api_audit::matting_failure_audit_timeout(&mapped),
crate::external_api_audit::matting_failure_audit_is_transport(&mapped),
crate::external_api_audit::matting_failure_audit_latency_ms(&mapped),
mapped.message().to_string(),
crate::external_api_audit::matting_failure_audit_raw_excerpt(&mapped),
&crate::external_api_audit::ExternalApiAuditContext::default(),
);
assert_eq!(draft.status_class, Some("local"));
assert!(!draft.retryable);
}
}
#[test]
fn upstream_transport_failure_maps_to_retryable_transport() {
let error = MattingError::upstream_transport_error(
@@ -79,7 +79,6 @@ use crate::{
resolve_editor_screen_background_color,
},
http_error::AppError,
openai_image_generation::DownloadedOpenAiImage,
platform_errors::map_oss_error,
prompt::role_asset_studio::{
build_role_asset_workflow, normalize_animation_prompt_text_by_key,
@@ -2418,7 +2417,7 @@ async fn process_and_persist_editor_character_animation_frame(
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<ProcessedEditorCharacterAnimationFrame, AppError> {
// 中文注释:每一帧只要求自己的绿幕源图先落 OSS,不再等待整批源图全部上传完成。
put_character_animation_object(
let source_put = put_character_animation_object(
state,
LegacyAssetPrefix::Animations,
vec![
@@ -2432,7 +2431,7 @@ async fn process_and_persist_editor_character_animation_frame(
frame.extension
),
frame.mime_type.clone(),
frame.bytes.clone(),
frame.bytes,
build_asset_metadata(
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
@@ -2444,14 +2443,9 @@ async fn process_and_persist_editor_character_animation_frame(
)
.await?;
let image = DownloadedOpenAiImage {
bytes: frame.bytes,
mime_type: frame.mime_type,
extension: frame.extension,
};
let removed = remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
state,
&image,
source_put.object_key.as_str(),
screen_color,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
@@ -6223,6 +6217,7 @@ mod tests {
"put_character_animation_object",
"green-screen-frame",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"source_put.object_key.as_str()",
"bgfilter_request_timeout_ms",
"finalize_animation_frame_payload",
"put_character_animation_object",
@@ -6244,6 +6239,13 @@ mod tests {
"finalize_animation_frame_payload",
],
);
let frame_pipeline = source
.split_once("async fn process_and_persist_editor_character_animation_frame")
.and_then(|(_, tail)| tail.split_once("async fn publish_animation_set"))
.map(|(body, _)| body)
.expect("frame pipeline function should exist");
assert!(!frame_pipeline.contains("frame.bytes.clone()"));
assert!(!frame_pipeline.contains("DownloadedOpenAiImage"));
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -159,6 +159,7 @@ pub(crate) async fn record_matting_external_api_failure(
failure_stage: &'static str,
status_code: Option<u16>,
timeout: bool,
transport: bool,
latency_ms: Option<u64>,
error_message: String,
raw_excerpt: Option<String>,
@@ -170,6 +171,7 @@ pub(crate) async fn record_matting_external_api_failure(
failure_stage,
status_code,
timeout,
transport,
latency_ms,
error_message,
raw_excerpt,
@@ -178,31 +180,41 @@ pub(crate) async fn record_matting_external_api_failure(
record_external_api_failure(state, draft).await;
}
/// 构建抠图失败审计 draft。`transport` 必须由调用方从错误结构化字段读取,
/// 不能用 `status_code.is_none()` 反推——本地处理失败同样没有上游 HTTP 状态。
#[allow(clippy::too_many_arguments)]
fn build_matting_external_api_failure_draft(
pub(crate) fn build_matting_external_api_failure_draft(
provider: &'static str,
endpoint: String,
operation: &'static str,
failure_stage: &'static str,
status_code: Option<u16>,
timeout: bool,
transport: bool,
latency_ms: Option<u64>,
error_message: String,
raw_excerpt: Option<String>,
context: &ExternalApiAuditContext,
) -> ExternalApiFailureDraft {
// status_code=None ⟺ statusClass=transport(见 status_class):DNS / 连接重置 / 读体中断 / 超时
// 这类传输层失败没有上游 HTTP 状态。它们必须与 "transport failures actionable" 语义一致,
// 记为 retryable=true,否则 statusClass=transport 却 retryable=false 会误导告警 / 重试分析。
let has_transport_error = status_code.is_none();
// 传输层:无上游 HTTP 状态 + timeout/transport 标记 → statusClass=transport、retryable=true。
// 本地处理:无上游 HTTP 状态且非 transport → statusClass=local、retryable=false。
// 不得把「status_code=None」一律当成 transport,否则 LocalProcessing 会污染可重试 5xx 分析。
let is_transport_failure = timeout || transport;
let resolved_status_class = if is_transport_failure && status_code.is_none() {
"transport"
} else if status_code.is_none() {
"local"
} else {
status_class(status_code)
};
ExternalApiFailureDraft::new(provider, endpoint, operation, failure_stage, error_message)
.with_status_code(status_code)
.with_optional_status_class(Some(status_class(status_code)))
.with_optional_status_class(Some(resolved_status_class))
.with_timeout(timeout)
.with_retryable(is_retryable_external_api_failure(
status_code,
timeout,
has_transport_error,
is_transport_failure,
))
.with_latency_ms(latency_ms)
.with_raw_excerpt(raw_excerpt)
@@ -210,21 +222,31 @@ fn build_matting_external_api_failure_draft(
}
pub(crate) fn matting_failure_audit_status_code(error: &AppError) -> Option<u16> {
error
// 真实上游 HTTP 状态优先;本地处理 / 传输层都没有可写的上游 status。
if let Some(status) = error
.details()
.and_then(|details| details.get("upstreamStatus"))
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
.or_else(|| {
if matting_failure_audit_is_transport(error) {
None
} else {
Some(error.status_code().as_u16())
}
})
{
return Some(status);
}
if matting_failure_audit_is_local_processing(error) || matting_failure_audit_is_transport(error)
{
return None;
}
Some(error.status_code().as_u16())
}
fn matting_failure_audit_is_transport(error: &AppError) -> bool {
pub(crate) fn matting_failure_audit_is_local_processing(error: &AppError) -> bool {
error
.details()
.and_then(|details| details.get("localProcessing"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
pub(crate) fn matting_failure_audit_is_transport(error: &AppError) -> bool {
matting_failure_audit_timeout(error)
|| error
.details()
@@ -256,6 +278,30 @@ pub(crate) fn matting_failure_external_call_attempted(error: &AppError) -> bool
.unwrap_or(true)
}
pub(crate) fn matting_failure_audit_failure_stage(
error: &AppError,
fallback: &'static str,
) -> &'static str {
let stage = error
.details()
.and_then(|details| details.get("failureStage"))
.and_then(Value::as_str);
match stage {
Some("preflight") => "preflight",
Some("source_download") => "source_download",
Some("source_decode") => "source_decode",
Some("source_validate") => "source_validate",
Some("source_normalize") => "source_normalize",
Some("temp_upload") => "temp_upload",
Some("aliyun_segment") => "aliyun_segment",
Some("result_download") => "result_download",
Some("result_decode") => "result_decode",
Some("result_validate") => "result_validate",
Some("result_encode") => "result_encode",
_ => fallback,
}
}
pub(crate) fn matting_failure_audit_raw_excerpt(error: &AppError) -> Option<String> {
error
.details()
@@ -575,7 +621,7 @@ mod tests {
#[test]
fn matting_failure_draft_marks_non_timeout_transport_retryable() {
// status_code=None、timeout=false 的非超时 transport 失败(DNS / 连接重置 / 读体中断):
// status_code=None、timeout=false、transport=true 的非超时传输故障:
// statusClass 必须是 transport 且 retryable=true,否则与 "transport failures actionable" 冲突。
let draft = build_matting_external_api_failure_draft(
"bgfilter",
@@ -584,6 +630,7 @@ mod tests {
"bgfilter_segment",
None,
false,
true,
Some(67),
"请求 BgFilter 服务失败:dns error".to_string(),
Some("dns error".to_string()),
@@ -599,6 +646,29 @@ mod tests {
);
}
#[test]
fn matting_failure_draft_marks_local_processing_non_retryable() {
// 本地处理失败没有上游 HTTP 状态,也不是 transport;不得记成可重试 5xx/transport。
let draft = build_matting_external_api_failure_draft(
"aliyun-matting",
"imageseg.example".to_string(),
"editor-screen-background-removal",
"source_decode",
None,
false,
false,
Some(3),
"解析待抠图图片失败:invalid png".to_string(),
Some("invalid png".to_string()),
&ExternalApiAuditContext::default(),
);
assert_eq!(draft.status_code, None);
assert_eq!(draft.status_class, Some("local"));
assert!(!draft.timeout);
assert!(!draft.retryable);
}
#[test]
fn matting_failure_draft_keeps_client_error_non_retryable() {
// 真实上游 4xx(非 429 / 408)不是传输故障,仍应 retryable=false,不能被误判为可重试。
@@ -609,6 +679,7 @@ mod tests {
"aliyun_segment",
Some(400),
false,
false,
Some(12),
"通用抠图接口返回失败(HTTP 400,Code=InvalidImage):bad image".to_string(),
None,
+1 -4
View File
@@ -5,13 +5,10 @@ version.workspace = true
license.workspace = true
[dependencies]
base64 = { workspace = true }
hmac = { workspace = true }
hex = { workspace = true }
httpdate = { workspace = true }
image = { workspace = true, features = ["png", "jpeg", "webp"] }
sha1 = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = { workspace = true }
@@ -1,9 +1,12 @@
//! 通用抠图冒烟验证:本地图片 → OSS → SegmentCommonImage → 下载结果。
//! 通用抠图冒烟验证:本地图片 → AuthorizeFileUpload 临时对象 → SegmentCommonImage → 下载结果。
//!
//! 运行(在 server-rs 目录下):
//! cargo run -p platform-matting --example segment_smoke -- "C:\path\to\input.png"
//!
//! 依赖仓库根目录 .env.local(OSS bucket/endpoint)与 .env.secrets.local(AK/SK)。
//! 依赖仓库根目录 .env / .env.local / .env.secrets.local 中的 AK/SK
//!(`GENARRATIVE_ALIYUN_MATTING_*` 或 `ALIBABA_CLOUD_ACCESS_KEY_*`);
//! 可选 `GENARRATIVE_ALIYUN_MATTING_ENDPOINT`。临时 OSS bucket/endpoint 由
//! AuthorizeFileUpload 动态下发,不依赖自有 OSS 配置。
use std::path::{Path, PathBuf};
@@ -67,17 +70,16 @@ async fn main() {
let http_client = reqwest::Client::new();
// --- 调用通用抠图 ---
// key 优先级:VIAPI 专用 → 官方 SDK 标准命名(#IMAGE_CALL)→ 短信 key 兜底。
// key 优先级与 api-server 配置保持一致:抠图专用 → 官方 SDK 标准命名。
let (matting_key_id, matting_key_secret) = [
(
"ALIYUN_IMAGESEG_ACCESS_KEY_ID",
"ALIYUN_IMAGESEG_ACCESS_KEY_SECRET",
"GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID",
"GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET",
),
(
"ALIBABA_CLOUD_ACCESS_KEY_ID",
"ALIBABA_CLOUD_ACCESS_KEY_SECRET",
),
("ALIYUN_SMS_ACCESS_KEY_ID", "ALIYUN_SMS_ACCESS_KEY_SECRET"),
]
.iter()
.find_map(|(id_name, secret_name)| {
@@ -91,7 +93,7 @@ async fn main() {
})
.expect("未找到可用的抠图 AccessKey 环境变量");
let matting_config = MattingConfig::new(
std::env::var("ALIYUN_IMAGESEG_ENDPOINT")
std::env::var("GENARRATIVE_ALIYUN_MATTING_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_IMAGESEG_ENDPOINT.to_string()),
matting_key_id,
matting_key_secret,
@@ -99,12 +101,12 @@ async fn main() {
.expect("抠图配置应有效");
let matting_client = MattingClient::new(matting_config).expect("抠图客户端应可构建");
// 本地 OSS 在北京地域,抠图服务要求上海地域,走 VIAPI 官方临时桶上传。
// 非上海地域输入按新版官方 SDK 的 AdvanceRequest 口径申请单对象 Policy 后上传。
let temp_url = matting_client
.upload_temp_image(input_bytes, "segment-input.png", "image/png")
.await
.expect("上传 VIAPI 临时桶应成功");
println!("[2/5] 已上传 VIAPI 临时桶");
.expect("上传 AuthorizeFileUpload 临时对象应成功");
println!("[2/5] 已上传 AuthorizeFileUpload 临时对象");
println!("[3/5] 输入图 URL host:{}", host_of(&temp_url));
let result = matting_client
File diff suppressed because it is too large Load Diff