diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 181cd0ac8..ae19afaf0 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -241,6 +241,7 @@ dependencies = [ "platform-hyper3d", "platform-image", "platform-llm", + "platform-matting", "platform-oss", "platform-speech", "platform-wechat", @@ -4476,6 +4477,29 @@ dependencies = [ "tokio", ] +[[package]] +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", + "tracing", + "uuid", +] + [[package]] name = "platform-oss" version = "0.1.0" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index c7681f2fb..64ce7ba85 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -38,6 +38,7 @@ members = [ "crates/platform-hyper3d", "crates/platform-image", "crates/platform-llm", + "crates/platform-matting", "crates/platform-wechat", "crates/platform-speech", "crates/platform-agent", @@ -88,6 +89,7 @@ platform-audio = { path = "crates/platform-audio", default-features = false } platform-hyper3d = { path = "crates/platform-hyper3d", default-features = false } platform-image = { path = "crates/platform-image", default-features = false } platform-llm = { path = "crates/platform-llm", default-features = false } +platform-matting = { path = "crates/platform-matting", default-features = false } platform-oss = { path = "crates/platform-oss", default-features = false } platform-speech = { path = "crates/platform-speech", default-features = false } platform-wechat = { path = "crates/platform-wechat", default-features = false } diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index a38b50da4..8949fdca5 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -43,6 +43,7 @@ platform-auth = { workspace = true } platform-hyper3d = { workspace = true } platform-image = { workspace = true } platform-llm = { workspace = true } +platform-matting = { workspace = true } platform-oss = { workspace = true } platform-speech = { workspace = true } platform-wechat = { workspace = true } diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs new file mode 100644 index 000000000..79fc2e986 --- /dev/null +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -0,0 +1,124 @@ +//! 阿里云通用抠图在 api-server 侧的适配层。 +//! +//! 输入 URL 策略:自有 OSS 在上海地域时直接上传草稿区并签名读 URL(抠图服务只认 +//! 上海地域 OSS);其他地域交给 platform-matting 走 VIAPI 官方临时桶。 + +use std::collections::BTreeMap; + +use axum::http::StatusCode; +use platform_oss::{ + LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, +}; +use serde_json::json; + +use crate::{ + http_error::AppError, openai_image_generation::DownloadedOpenAiImage, state::AppState, +}; + +const MATTING_INPUT_PATH_SEGMENT: &str = "matting-input"; +const MATTING_INPUT_READ_EXPIRE_SECONDS: u64 = 10 * 60; +const ALIYUN_MATTING_OSS_REGION_MARKER: &str = "oss-cn-shanghai"; + +/// 图片字节 → 阿里云通用抠图 → 原尺寸透明 PNG。 +/// 未配置抠图客户端时返回错误,由调用方决定是否降级本地算法。 +pub(crate) async fn segment_image_with_aliyun_matting( + state: &AppState, + image: &DownloadedOpenAiImage, + log_label: &str, +) -> Result { + let matting_client = state.matting_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-matting", + "message": "阿里云抠图客户端未配置或未启用。", + })) + })?; + + let accessible_url = prepare_shanghai_oss_input_url(state, image, log_label).await; + 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, accessible_url) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-matting", + "message": error.message(), + })) + })?; + 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; +/// 其他情况返回 None(platform-matting 会转走官方临时桶)。 +/// 该步骤失败不阻断抠图,只是退化为临时桶通道。 +async fn prepare_shanghai_oss_input_url( + state: &AppState, + image: &DownloadedOpenAiImage, + log_label: &str, +) -> Option { + let oss_client = state.oss_client()?; + let endpoint = state.config.oss_endpoint.as_deref().unwrap_or(""); + if !endpoint.contains(ALIYUN_MATTING_OSS_REGION_MARKER) { + return None; + } + + let http_client = reqwest::Client::new(); + let file_name = format!( + "{log_label}-{}.{}", + shared_kernel::new_uuid_simple_string(), + image.extension + ); + let put_result = oss_client + .put_object( + &http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec![MATTING_INPUT_PATH_SEGMENT.to_string()], + file_name, + content_type: Some(image.mime_type.clone()), + access: OssObjectAccess::Private, + metadata: BTreeMap::new(), + body: image.bytes.clone(), + }, + ) + .await; + let put_result = match put_result { + Ok(result) => result, + Err(error) => { + tracing::warn!( + provider = "aliyun-matting", + log_label, + error = %error, + "抠图输入图上传自有 OSS 失败,回退官方临时桶" + ); + return None; + } + }; + + match oss_client.sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: put_result.object_key, + expire_seconds: Some(MATTING_INPUT_READ_EXPIRE_SECONDS), + }) { + Ok(signed) => Some(signed.signed_url), + Err(error) => { + tracing::warn!( + provider = "aliyun-matting", + log_label, + error = %error, + "抠图输入图签名读 URL 失败,回退官方临时桶" + ); + None + } + } +} diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 8a93c51d7..e3881e7a7 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -18,6 +18,8 @@ const DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL: &str = "http://58.87.105.82"; const DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS: u64 = 120_000; const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter"; const DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: u64 = 120_000; +const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com"; +const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000; // 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。 #[derive(Clone, Debug)] @@ -61,6 +63,11 @@ pub struct AppConfig { pub editor_bgfilter_base_url: String, pub editor_bgfilter_token: Option, pub editor_bgfilter_request_timeout_ms: u64, + pub aliyun_matting_enabled: bool, + pub aliyun_matting_endpoint: String, + pub aliyun_matting_access_key_id: Option, + pub aliyun_matting_access_key_secret: Option, + pub aliyun_matting_request_timeout_ms: u64, pub image_editor_agent_sidebar_enabled: bool, pub log_filter: String, pub otel_enabled: bool, @@ -292,6 +299,11 @@ impl Default for AppConfig { editor_bgfilter_base_url: DEFAULT_EDITOR_BGFILTER_BASE_URL.to_string(), editor_bgfilter_token: None, editor_bgfilter_request_timeout_ms: DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS, + aliyun_matting_enabled: true, + aliyun_matting_endpoint: DEFAULT_ALIYUN_MATTING_ENDPOINT.to_string(), + aliyun_matting_access_key_id: None, + aliyun_matting_access_key_secret: None, + aliyun_matting_request_timeout_ms: DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS, image_editor_agent_sidebar_enabled: false, log_filter: "info,tower_http=info".to_string(), otel_enabled: false, @@ -496,6 +508,25 @@ impl AppConfig { { config.editor_bgfilter_request_timeout_ms = timeout_ms; } + if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ALIYUN_MATTING_ENABLED"]) { + config.aliyun_matting_enabled = enabled; + } + if let Some(endpoint) = read_first_non_empty_env(&["GENARRATIVE_ALIYUN_MATTING_ENDPOINT"]) { + config.aliyun_matting_endpoint = endpoint; + } + config.aliyun_matting_access_key_id = read_first_non_empty_env(&[ + "GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID", + "ALIBABA_CLOUD_ACCESS_KEY_ID", + ]); + config.aliyun_matting_access_key_secret = read_first_non_empty_env(&[ + "GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET", + ]); + if let Some(timeout_ms) = + read_first_positive_u64_env(&["GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS"]) + { + config.aliyun_matting_request_timeout_ms = timeout_ms; + } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"]) { @@ -2263,6 +2294,63 @@ mod tests { } } + #[test] + fn from_env_reads_aliyun_matting_settings_and_reuses_alibaba_cloud_keys() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENABLED"); + std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENDPOINT"); + std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID"); + std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET"); + std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS"); + std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_ID"); + std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET"); + std::env::set_var("ALIBABA_CLOUD_ACCESS_KEY_ID", "shared-ak-id"); + std::env::set_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "shared-ak-secret"); + std::env::set_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS", "45000"); + } + + let config = AppConfig::from_env(); + assert!(config.aliyun_matting_enabled); + assert_eq!( + config.aliyun_matting_endpoint, + "imageseg.cn-shanghai.aliyuncs.com" + ); + assert_eq!( + config.aliyun_matting_access_key_id.as_deref(), + Some("shared-ak-id") + ); + assert_eq!( + config.aliyun_matting_access_key_secret.as_deref(), + Some("shared-ak-secret") + ); + assert_eq!(config.aliyun_matting_request_timeout_ms, 45_000); + + unsafe { + std::env::set_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID", "matting-ak-id"); + std::env::set_var("GENARRATIVE_ALIYUN_MATTING_ENABLED", "false"); + } + + let config = AppConfig::from_env(); + assert!(!config.aliyun_matting_enabled); + assert_eq!( + config.aliyun_matting_access_key_id.as_deref(), + Some("matting-ak-id") + ); + + unsafe { + std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENABLED"); + std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID"); + std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS"); + std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_ID"); + std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET"); + } + } + #[test] fn from_env_reads_image_editor_agent_sidebar_runtime_flag() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 7f080430d..1e7d1dd66 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2221,9 +2221,27 @@ async fn remove_editor_generated_screen_background_with_bgfilter( seg_model, error = %error, error_details = ?error.details(), - "editor_bgfilter_fallback_to_local_screen_background_removal" + "editor_bgfilter_fallback_to_aliyun_matting" ); - remove_editor_generated_green_screen_background(image, screen_color) + match crate::aliyun_matting::segment_image_with_aliyun_matting( + state, + image, + "editor-screen-background", + ) + .await + { + Ok(image) => Ok(image), + Err(error) => { + tracing::warn!( + provider = "aliyun-matting", + screen_color = screen_color.hex, + error = %error, + error_details = ?error.details(), + "editor_aliyun_matting_fallback_to_local_screen_background_removal" + ); + remove_editor_generated_green_screen_background(image, screen_color) + } + } } } } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index b64b8bf79..06b145b50 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -2,6 +2,7 @@ mod admin; mod ai_generation_drafts; +mod aliyun_matting; mod ai_tasks; mod api_response; mod app; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 923e7906b..f62b7a705 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -24,6 +24,7 @@ use platform_auth::{ SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token, }; use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider}; +use platform_matting::{MattingClient, MattingConfig}; use platform_oss::{OssClient, OssConfig, OssError}; use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient}; use serde_json::Value; @@ -269,6 +270,7 @@ pub struct AppStateInner { editor_generation_pricing_store: EditorGenerationPricingStore, llm_client: Option, creative_agent_gpt5_client: Option, + matting_client: Option, creative_agent_executor: Arc, // Phase 1 任务 E 的 creative session facade 暂存在 api-server。 // creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。 @@ -418,6 +420,7 @@ impl AppState { .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; let llm_client = build_llm_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; + let matting_client = build_matting_client(&config)?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); let (profile_recharge_order_updates, _) = broadcast::channel(128); @@ -455,6 +458,7 @@ impl AppState { editor_generation_pricing_store, llm_client, creative_agent_gpt5_client, + matting_client, creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())), profile_recharge_order_updates, @@ -917,6 +921,10 @@ impl AppState { self.creative_agent_gpt5_client.as_ref() } + pub fn matting_client(&self) -> Option<&MattingClient> { + self.matting_client.as_ref() + } + pub fn creative_agent_executor(&self) -> Arc { self.creative_agent_executor.clone() } @@ -1446,6 +1454,38 @@ fn build_oss_client(config: &AppConfig) -> Result, AppStateIni Ok(Some(OssClient::new(oss_config))) } +fn build_matting_client(config: &AppConfig) -> Result, AppStateInitError> { + if !config.aliyun_matting_enabled { + return Ok(None); + } + let (Some(access_key_id), Some(access_key_secret)) = ( + config + .aliyun_matting_access_key_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + config + .aliyun_matting_access_key_secret + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + ) else { + warn!("阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化"); + return Ok(None); + }; + + let matting_config = MattingConfig::with_timeout( + config.aliyun_matting_endpoint.clone(), + access_key_id.to_string(), + access_key_secret.to_string(), + config.aliyun_matting_request_timeout_ms, + ) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + MattingClient::new(matting_config) + .map(Some) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string())) +} + fn build_wechat_client(config: &AppConfig) -> WechatClient { WechatClient::new(WechatConfig { app_id: config.wechat_mini_program_app_id.clone(), diff --git a/server-rs/crates/platform-matting/Cargo.toml b/server-rs/crates/platform-matting/Cargo.toml new file mode 100644 index 000000000..87a387af0 --- /dev/null +++ b/server-rs/crates/platform-matting/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "platform-matting" +edition.workspace = true +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"] } +serde = { workspace = true } +serde_json = { workspace = true } +serde_urlencoded = { workspace = true } +sha2 = { workspace = true } +time = { workspace = true, features = ["std"] } +tracing = { workspace = true } +uuid = { workspace = true, features = ["v4"] } + +[dev-dependencies] +dotenvy = { workspace = true } +platform-oss = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/server-rs/crates/platform-matting/examples/crop_size_probe.rs b/server-rs/crates/platform-matting/examples/crop_size_probe.rs new file mode 100644 index 000000000..21f89a891 --- /dev/null +++ b/server-rs/crates/platform-matting/examples/crop_size_probe.rs @@ -0,0 +1,104 @@ +//! 探测 SegmentCommonImage 在小尺寸(480p 级)输入下 crop/mask 的输出尺寸行为。 +//! +//! cargo run -p platform-matting --example crop_size_probe + +use std::io::Cursor; +use std::path::Path; + +use image::{DynamicImage, GenericImage, ImageFormat, Rgba, RgbaImage}; +use platform_matting::{ + DEFAULT_IMAGESEG_ENDPOINT, MattingClient, MattingConfig, SegmentCommonImageRequest, + SegmentReturnForm, +}; + +fn load_env_files() { + for candidate in [ + ".env", + ".env.local", + ".env.secrets.local", + "../.env", + "../.env.local", + "../.env.secrets.local", + ] { + if Path::new(candidate).exists() { + let _ = dotenvy::from_path_override(candidate); + } + } +} + +fn encode_png(image: &DynamicImage) -> Vec { + let mut buffer = Cursor::new(Vec::new()); + image + .write_to(&mut buffer, ImageFormat::Png) + .expect("PNG 编码应成功"); + buffer.into_inner() +} + +#[tokio::main] +async fn main() { + load_env_files(); + + let input_path = std::env::args().nth(1).unwrap_or_else(|| { + r"C:\Users\lingh\Downloads\gpt-image-2-elf-archer-2048x2048.png".to_string() + }); + let source = image::open(&input_path).expect("测试图片应可解码"); + + // 变体1:等比缩到 480(模拟 1:1 480p 视频帧,主体居中) + let centered = source.resize(480, 480, image::imageops::FilterType::CatmullRom); + + // 变体2:把 360 尺寸的主体贴到 640x480 画布左上角(模拟横版帧、主体偏移) + let small = source.resize(360, 360, image::imageops::FilterType::CatmullRom); + let corner_pixel = centered.to_rgba8().get_pixel(0, 0).0; + let mut offset_canvas = RgbaImage::from_pixel(640, 480, Rgba(corner_pixel)); + offset_canvas + .copy_from(&small.to_rgba8(), 20, 40) + .expect("贴图应成功"); + let offset = DynamicImage::ImageRgba8(offset_canvas); + + let matting_config = MattingConfig::new( + std::env::var("ALIYUN_IMAGESEG_ENDPOINT") + .unwrap_or_else(|_| DEFAULT_IMAGESEG_ENDPOINT.to_string()), + std::env::var("ALIBABA_CLOUD_ACCESS_KEY_ID").expect("需要 ALIBABA_CLOUD_ACCESS_KEY_ID"), + std::env::var("ALIBABA_CLOUD_ACCESS_KEY_SECRET") + .expect("需要 ALIBABA_CLOUD_ACCESS_KEY_SECRET"), + ) + .expect("抠图配置应有效"); + let client = MattingClient::new(matting_config).expect("客户端应可构建"); + let http_client = reqwest::Client::new(); + + for (label, variant) in [("居中480x480", ¢ered), ("偏移640x480", &offset)] { + let bytes = encode_png(variant); + let temp_url = client + .upload_temp_image(bytes, &format!("probe-{label}.png"), "image/png") + .await + .expect("上传临时桶应成功"); + + for return_form in [None, Some(SegmentReturnForm::Crop), Some(SegmentReturnForm::Mask)] { + let result = client + .segment_common_image(SegmentCommonImageRequest { + image_url: temp_url.clone(), + return_form, + }) + .await + .expect("抠图调用应成功"); + let output_bytes = http_client + .get(&result.image_url) + .send() + .await + .expect("下载结果应成功") + .bytes() + .await + .expect("读取结果字节应成功"); + let output = image::load_from_memory(&output_bytes).expect("结果应可解码"); + println!( + "{label} 输入 {}x{} -> {} 输出 {}x{} (alpha通道: {})", + variant.width(), + variant.height(), + return_form.map_or("默认(不传)".to_string(), |f| format!("{f:?}")), + output.width(), + output.height(), + output.color().has_alpha() + ); + } + } +} diff --git a/server-rs/crates/platform-matting/examples/segment_smoke.rs b/server-rs/crates/platform-matting/examples/segment_smoke.rs new file mode 100644 index 000000000..82d08639d --- /dev/null +++ b/server-rs/crates/platform-matting/examples/segment_smoke.rs @@ -0,0 +1,158 @@ +//! 通用抠图冒烟验证:本地图片 → OSS → 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)。 + +use std::path::{Path, PathBuf}; + +use platform_matting::{ + DEFAULT_IMAGESEG_ENDPOINT, MattingClient, MattingConfig, SegmentCommonImageRequest, + SegmentReturnForm, +}; + +fn load_env_files() { + // example 的工作目录通常是 server-rs,.env 都在仓库根目录。 + for candidate in [ + ".env", + ".env.local", + ".env.secrets.local", + "../.env", + "../.env.local", + "../.env.secrets.local", + ] { + if Path::new(candidate).exists() { + let _ = dotenvy::from_path_override(candidate); + } + } +} + +#[tokio::main] +async fn main() { + load_env_files(); + + let input_path = std::env::args().nth(1).unwrap_or_else(|| { + r"C:\Users\lingh\Downloads\gpt-image-2-elf-archer-2048x2048.png".to_string() + }); + let input_bytes = std::fs::read(&input_path) + .unwrap_or_else(|error| panic!("读取测试图片失败({input_path}):{error}")); + 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 mut buffer = std::io::Cursor::new(Vec::new()); + resized + .write_to(&mut buffer, image::ImageFormat::Png) + .expect("缩放后的图片应可编码为 PNG"); + println!( + " 分辨率 {}x{} 超限,已缩放到 {}x{}", + decoded.width(), + decoded.height(), + resized.width(), + resized.height() + ); + buffer.into_inner() + } else { + input_bytes + }; + + let http_client = reqwest::Client::new(); + + // --- 调用通用抠图 --- + // key 优先级:VIAPI 专用 → 官方 SDK 标准命名(#IMAGE_CALL)→ 短信 key 兜底。 + 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"), + ] + .iter() + .find_map(|(id_name, secret_name)| { + let id = std::env::var(id_name).ok()?; + let secret = std::env::var(secret_name).ok()?; + if id.trim().is_empty() || secret.trim().is_empty() { + return None; + } + println!(" 使用 {id_name} 调用抠图服务"); + Some((id, secret)) + }) + .expect("未找到可用的抠图 AccessKey 环境变量"); + let matting_config = MattingConfig::new( + std::env::var("ALIYUN_IMAGESEG_ENDPOINT") + .unwrap_or_else(|_| DEFAULT_IMAGESEG_ENDPOINT.to_string()), + matting_key_id, + matting_key_secret, + ) + .expect("抠图配置应有效"); + let matting_client = MattingClient::new(matting_config).expect("抠图客户端应可构建"); + + // 本地 OSS 在北京地域,抠图服务要求上海地域,走 VIAPI 官方临时桶上传。 + let temp_url = matting_client + .upload_temp_image(input_bytes, "segment-input.png", "image/png") + .await + .expect("上传 VIAPI 临时桶应成功"); + println!("[2/5] 已上传 VIAPI 临时桶"); + println!("[3/5] 输入图 URL host:{}", host_of(&temp_url)); + + let result = matting_client + .segment_common_image(SegmentCommonImageRequest { + image_url: temp_url, + return_form: Some(SegmentReturnForm::Crop), + }) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + eprintln!("[4/5] 通用抠图调用失败:{error}"); + std::process::exit(1); + } + }; + println!( + "[4/5] 通用抠图成功,RequestId={}", + result.request_id.as_deref().unwrap_or("unknown") + ); + + // --- 下载结果 --- + let output_bytes = http_client + .get(&result.image_url) + .send() + .await + .expect("下载抠图结果应成功") + .error_for_status() + .expect("抠图结果 URL 应返回 200") + .bytes() + .await + .expect("读取抠图结果字节应成功"); + + let output_path = build_output_path(&input_path); + std::fs::write(&output_path, &output_bytes).expect("写出抠图结果应成功"); + println!( + "[5/5] 抠图结果已保存:{}({} 字节)", + output_path.display(), + output_bytes.len() + ); +} + +fn build_output_path(input_path: &str) -> PathBuf { + let input = Path::new(input_path); + let stem = input + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("segment-output"); + input.with_file_name(format!("{stem}-matting.png")) +} + +fn host_of(url: &str) -> String { + url.split("//") + .nth(1) + .and_then(|rest| rest.split('/').next()) + .unwrap_or("unknown") + .to_string() +} diff --git a/server-rs/crates/platform-matting/src/lib.rs b/server-rs/crates/platform-matting/src/lib.rs new file mode 100644 index 000000000..0a61e314d --- /dev/null +++ b/server-rs/crates/platform-matting/src/lib.rs @@ -0,0 +1,724 @@ +//! 阿里云 VIAPI 通用抠图(SegmentCommonImage)客户端。 +//! +//! 官方没有 Rust SDK,这里按 ACS3-HMAC-SHA256 签名协议手搓 HTTP 调用, +//! 签名实现与 platform-auth 的阿里云短信调用保持一致。 +//! 文档:https://help.aliyun.com/zh/viapi/use-cases/general-image-segmentation + +use std::collections::BTreeMap; + +use hmac::{Hmac, Mac}; +use reqwest::{Client, StatusCode}; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; +use tracing::{info, warn}; + +type HmacSha256 = Hmac; + +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 官方临时上传通道:非上海地域 OSS / 本地文件先传到官方临时桶(1 天过期, +// 全用户共享 QPS,生产建议直接用上海地域自有 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"; + +pub const DEFAULT_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000; + +/// SegmentCommonImage 要求每条边小于 2000。 +const MAX_INPUT_EDGE: u32 = 1_999; + +#[derive(Clone, Debug)] +pub struct MattingConfig { + pub endpoint: String, + pub access_key_id: String, + pub access_key_secret: String, + pub request_timeout_ms: u64, +} + +impl MattingConfig { + pub fn new( + endpoint: String, + access_key_id: String, + access_key_secret: String, + ) -> Result { + Self::with_timeout( + endpoint, + access_key_id, + access_key_secret, + DEFAULT_MATTING_REQUEST_TIMEOUT_MS, + ) + } + + pub fn with_timeout( + endpoint: String, + access_key_id: String, + access_key_secret: String, + request_timeout_ms: u64, + ) -> Result { + let endpoint = endpoint + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_matches('/') + .to_string(); + if endpoint.is_empty() { + return Err(MattingError::InvalidConfig( + "抠图服务 endpoint 不能为空".to_string(), + )); + } + let access_key_id = access_key_id.trim().to_string(); + let access_key_secret = access_key_secret.trim().to_string(); + if access_key_id.is_empty() || access_key_secret.is_empty() { + return Err(MattingError::InvalidConfig( + "抠图服务 AccessKeyId/AccessKeySecret 不能为空".to_string(), + )); + } + Ok(Self { + endpoint, + access_key_id, + access_key_secret, + request_timeout_ms: request_timeout_ms.max(1), + }) + } +} + +/// SegmentCommonImage 的 ReturnForm 取值。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SegmentReturnForm { + /// 透明背景 PNG(默认,抠像主用途)。 + Crop, + /// 黑白 mask 图。 + Mask, + /// 白底图。 + WhiteBackground, +} + +impl SegmentReturnForm { + fn as_str(&self) -> &'static str { + match self { + Self::Crop => "crop", + Self::Mask => "mask", + Self::WhiteBackground => "whiteBK", + } + } +} + +#[derive(Clone, Debug)] +pub struct SegmentCommonImageRequest { + /// 待抠图图片的公网可访问 URL(推荐上海地域 OSS 签名 URL)。 + pub image_url: String, + /// None 表示不传 ReturnForm,使用服务端默认行为。 + pub return_form: Option, +} + +#[derive(Clone, Debug)] +pub struct SegmentCommonImageResult { + /// 抠图结果图片 URL(阿里云临时地址,30 分钟内有效,需及时转存)。 + pub image_url: String, + pub request_id: Option, +} + +#[derive(Debug)] +pub enum MattingError { + InvalidConfig(String), + InvalidRequest(String), + Sign(String), + Upstream(String), +} + +impl MattingError { + pub fn message(&self) -> &str { + match self { + Self::InvalidConfig(message) + | Self::InvalidRequest(message) + | Self::Sign(message) + | Self::Upstream(message) => message, + } + } +} + +impl std::fmt::Display for MattingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +impl std::error::Error for MattingError {} + +#[derive(Clone)] +pub struct MattingClient { + config: MattingConfig, + client: Client, +} + +impl std::fmt::Debug for MattingClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MattingClient") + .field("endpoint", &self.config.endpoint) + .finish_non_exhaustive() + } +} + +impl MattingClient { + pub fn new(config: MattingConfig) -> Result { + let client = Client::builder() + .timeout(std::time::Duration::from_millis(config.request_timeout_ms)) + .build() + .map_err(|error| { + MattingError::InvalidConfig(format!("构建 reqwest client 失败:{error}")) + })?; + Ok(Self { config, client }) + } + + pub async fn segment_common_image( + &self, + request: SegmentCommonImageRequest, + ) -> Result { + let image_url = request.image_url.trim().to_string(); + if image_url.is_empty() { + return Err(MattingError::InvalidRequest( + "imageUrl 不能为空".to_string(), + )); + } + + let mut form = BTreeMap::new(); + form.insert("Action".to_string(), SEGMENT_COMMON_IMAGE_ACTION.to_string()); + form.insert("Format".to_string(), "json".to_string()); + form.insert("Version".to_string(), IMAGESEG_API_VERSION.to_string()); + form.insert("ImageURL".to_string(), image_url); + if let Some(return_form) = request.return_form { + form.insert("ReturnForm".to_string(), return_form.as_str().to_string()); + } + + let payload = build_aliyun_form_body(&form); + let headers = self.build_signature_headers(SEGMENT_COMMON_IMAGE_ACTION, &payload)?; + + info!( + provider = "aliyun-imageseg", + endpoint = self.config.endpoint.as_str(), + action = SEGMENT_COMMON_IMAGE_ACTION, + return_form = request + .return_form + .map_or("default", |return_form| return_form.as_str()), + "准备调用阿里云通用抠图接口" + ); + + let response = self + .client + .post(format!("https://{}/", self.config.endpoint)) + .headers(headers) + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(payload) + .send() + .await + .map_err(|error| MattingError::Upstream(format!("通用抠图请求失败:{error}")))?; + + let http_status = response.status(); + let body_text = response.text().await.map_err(|error| { + MattingError::Upstream(format!("通用抠图响应读取失败:{error}")) + })?; + let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|error| { + MattingError::Upstream(format!( + "通用抠图响应不是合法 JSON:{error};原始响应:{}", + truncate_for_log(&body_text) + )) + })?; + + let request_id = body + .get("RequestId") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + + if http_status != StatusCode::OK { + let code = body.get("Code").and_then(|value| value.as_str()); + let message = body.get("Message").and_then(|value| value.as_str()); + warn!( + provider = "aliyun-imageseg", + http_status = http_status.as_u16(), + provider_code = code.unwrap_or("unknown"), + provider_message = message.unwrap_or("unknown"), + provider_request_id = request_id.as_deref().unwrap_or("unknown"), + "阿里云通用抠图接口返回失败" + ); + return Err(MattingError::Upstream(format!( + "通用抠图接口返回失败(HTTP {},Code={}):{}", + http_status.as_u16(), + code.unwrap_or("unknown"), + message.unwrap_or("unknown") + ))); + } + + let result_image_url = body + .get("Data") + .and_then(|data| data.get("ImageURL")) + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + .ok_or_else(|| { + MattingError::Upstream(format!( + "通用抠图响应缺少 Data.ImageURL;原始响应:{}", + truncate_for_log(&body_text) + )) + })?; + + info!( + provider = "aliyun-imageseg", + provider_request_id = request_id.as_deref().unwrap_or("unknown"), + "阿里云通用抠图接口调用成功" + ); + + Ok(SegmentCommonImageResult { + image_url: result_image_url, + request_id, + }) + } + + /// 图片字节 → 通用抠图 → 原尺寸透明 PNG 字节。 + /// + /// - `accessible_url`:该字节对应的、抠图服务可访问的 URL(上海地域 OSS 签名 URL)。 + /// 为 None 或图片超分辨率上限需要缩图时,自动改走官方临时桶上传。 + /// - 分辨率守卫:任一边 >= 2000 时先等比缩小送抠,抠完只取结果 alpha 上采样回贴 + /// 原图 RGB,保证输出与输入同尺寸、画质无损。 + pub async fn segment_image_to_transparent_png( + &self, + bytes: &[u8], + file_name: &str, + accessible_url: Option, + ) -> Result, MattingError> { + let source = image::load_from_memory(bytes).map_err(|error| { + MattingError::InvalidRequest(format!("解析待抠图图片失败:{error}")) + })?; + let source_rgba = source.to_rgba8(); + let (source_width, source_height) = source_rgba.dimensions(); + + let needs_resize = source_width > MAX_INPUT_EDGE || source_height > MAX_INPUT_EDGE; + let (submit_bytes, submit_url) = if needs_resize { + let resized = source.resize( + MAX_INPUT_EDGE, + MAX_INPUT_EDGE, + image::imageops::FilterType::CatmullRom, + ); + let resized_bytes = encode_rgba_png(&resized.to_rgba8())?; + (Some(resized_bytes), None) + } else { + (None, accessible_url) + }; + + let image_url = match submit_url { + Some(url) => url, + None => { + let upload_bytes = submit_bytes + .clone() + .unwrap_or_else(|| bytes.to_vec()); + self.upload_temp_image(upload_bytes, file_name, "image/png") + .await? + } + }; + + let result = self + .segment_common_image(SegmentCommonImageRequest { + image_url, + // 默认 ReturnForm:原尺寸 + 透明背景,无需本地合成。 + return_form: None, + }) + .await?; + let result_bytes = self.download_result_image(&result.image_url).await?; + let result_image = image::load_from_memory(&result_bytes) + .map_err(|error| { + MattingError::Upstream(format!("解析抠图结果图片失败:{error}")) + })? + .to_rgba8(); + + if !needs_resize { + if result_image.dimensions() != (source_width, source_height) { + return Err(MattingError::Upstream(format!( + "抠图结果尺寸 {}x{} 与输入 {}x{} 不一致", + result_image.width(), + result_image.height(), + source_width, + source_height + ))); + } + 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]); + } + encode_rgba_png(&output) + } + + async fn download_result_image(&self, url: &str) -> Result, MattingError> { + let response = self + .client + .get(url) + .send() + .await + .map_err(|error| MattingError::Upstream(format!("下载抠图结果失败:{error}")))?; + let status = response.status(); + if !status.is_success() { + return Err(MattingError::Upstream(format!( + "下载抠图结果失败(HTTP {})", + status.as_u16() + ))); + } + response + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(|error| MattingError::Upstream(format!("读取抠图结果字节失败:{error}"))) + } + + /// 把本地图片字节上传到 VIAPI 官方临时桶,返回可直接作为 ImageURL 的公网地址。 + /// 适用于本地文件或非上海地域 OSS 的开发/调试场景;生产建议用上海地域自有 OSS。 + pub async fn upload_temp_image( + &self, + bytes: Vec, + file_name: &str, + content_type: &str, + ) -> Result { + if bytes.is_empty() { + return Err(MattingError::InvalidRequest( + "上传内容不能为空".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(), + )); + } + let object_key = format!( + "{}/{}/{}", + self.config.access_key_id, + uuid::Uuid::new_v4().simple(), + 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 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) + .send() + .await + .map_err(|error| { + MattingError::Upstream(format!("上传 VIAPI 临时桶请求失败:{error}")) + })?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(MattingError::Upstream(format!( + "上传 VIAPI 临时桶失败(HTTP {}):{}", + status.as_u16(), + truncate_for_log(&body) + ))); + } + + Ok(target_url) + } + + 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, + )?; + + let response = self + .client + .post(format!("https://{VIAPI_UTILS_ENDPOINT}/")) + .headers(headers) + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(payload) + .send() + .await + .map_err(|error| MattingError::Upstream(format!("GetOssStsToken 请求失败:{error}")))?; + let http_status = response.status(); + let body_text = response.text().await.map_err(|error| { + MattingError::Upstream(format!("GetOssStsToken 响应读取失败:{error}")) + })?; + let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|error| { + MattingError::Upstream(format!( + "GetOssStsToken 响应不是合法 JSON:{error};原始响应:{}", + truncate_for_log(&body_text) + )) + })?; + if http_status != StatusCode::OK { + return Err(MattingError::Upstream(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") + ))); + } + let data = body.get("Data").ok_or_else(|| { + MattingError::Upstream(format!( + "GetOssStsToken 响应缺少 Data;原始响应:{}", + truncate_for_log(&body_text) + )) + })?; + 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(format!("GetOssStsToken 响应缺少 Data.{name}")) + }) + }; + Ok(ViapiStsToken { + access_key_id: read_field("AccessKeyId")?, + access_key_secret: read_field("AccessKeySecret")?, + security_token: read_field("SecurityToken")?, + }) + } + + fn build_signature_headers( + &self, + action: &str, + payload: &str, + ) -> Result { + self.build_acs3_headers(&self.config.endpoint, action, IMAGESEG_API_VERSION, payload) + } + + fn build_acs3_headers( + &self, + endpoint: &str, + action: &str, + version: &str, + payload: &str, + ) -> Result { + let date = current_aliyun_timestamp(); + let nonce = uuid::Uuid::new_v4().simple().to_string(); + let payload_hash = sha256_hex(payload.as_bytes()); + let canonical_headers = format!( + "host:{}\nx-acs-action:{}\nx-acs-content-sha256:{}\nx-acs-date:{}\nx-acs-signature-nonce:{}\nx-acs-version:{}\n", + endpoint, action, payload_hash, date, nonce, version + ); + 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 + ); + let string_to_sign = format!( + "ACS3-HMAC-SHA256\n{}", + sha256_hex(canonical_request.as_bytes()) + ); + let signature = hmac_sha256_hex( + self.config.access_key_secret.as_bytes(), + string_to_sign.as_bytes(), + )?; + let authorization = format!( + "ACS3-HMAC-SHA256 Credential={},SignedHeaders={signed_headers},Signature={signature}", + self.config.access_key_id + ); + let mut headers = reqwest::header::HeaderMap::new(); + insert_header(&mut headers, "x-acs-action", action)?; + insert_header(&mut headers, "x-acs-version", version)?; + insert_header(&mut headers, "x-acs-date", &date)?; + insert_header(&mut headers, "x-acs-signature-nonce", &nonce)?; + insert_header(&mut headers, "x-acs-content-sha256", &payload_hash)?; + insert_header(&mut headers, "authorization", &authorization)?; + + Ok(headers) + } +} + +fn encode_rgba_png(image: &image::RgbaImage) -> Result, MattingError> { + use image::ImageEncoder as _; + let mut encoded = Vec::new(); + image::codecs::png::PngEncoder::new(&mut encoded) + .write_image( + image.as_raw(), + image.width(), + image.height(), + image::ExtendedColorType::Rgba8, + ) + .map_err(|error| MattingError::Upstream(format!("编码抠图结果 PNG 失败:{error}")))?; + Ok(encoded) +} + +struct ViapiStsToken { + access_key_id: String, + access_key_secret: String, + security_token: String, +} + +fn hmac_sha1_base64(key: &[u8], content: &[u8]) -> 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 insert_header( + headers: &mut reqwest::header::HeaderMap, + name: &'static str, + value: &str, +) -> Result<(), MattingError> { + let value = reqwest::header::HeaderValue::from_str(value) + .map_err(|error| MattingError::Sign(format!("构造请求头 {name} 失败:{error}")))?; + headers.insert(name, value); + Ok(()) +} + +fn current_aliyun_timestamp() -> String { + // 阿里云 OpenAPI ACS3 签名头 x-acs-date 要求不带小数秒的 UTC ISO 8601 格式 + // (yyyy-MM-dd'T'HH:mm:ss'Z'),Rfc3339 默认会保留纳秒,网关会判定时间格式非法。 + let now = OffsetDateTime::now_utc(); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + now.year(), + u8::from(now.month()), + now.day(), + now.hour(), + now.minute(), + now.second() + ) +} + +fn canonicalize_aliyun_form_params(params: &BTreeMap) -> String { + params + .iter() + .map(|(key, value)| { + format!( + "{}={}", + urlencoding_encode(key), + urlencoding_encode(value) + ) + }) + .collect::>() + .join("&") +} + +fn urlencoding_encode(value: &str) -> String { + serde_urlencoded::to_string([("k", value)]) + .map(|encoded| encoded.trim_start_matches("k=").to_string()) + .unwrap_or_else(|_| value.to_string()) +} + +fn build_aliyun_form_body(params: &BTreeMap) -> String { + serde_urlencoded::to_string(params).unwrap_or_else(|_| canonicalize_aliyun_form_params(params)) +} + +fn hmac_sha256_hex(key: &[u8], content: &[u8]) -> Result { + let mut signer = HmacSha256::new_from_slice(key) + .map_err(|error| MattingError::Sign(format!("初始化抠图签名器失败:{error}")))?; + signer.update(content); + Ok(hex::encode(signer.finalize().into_bytes())) +} + +fn sha256_hex(content: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +fn truncate_for_log(text: &str) -> String { + const LIMIT: usize = 512; + if text.len() <= LIMIT { + text.to_string() + } else { + let mut end = LIMIT; + while !text.is_char_boundary(end) { + end -= 1; + } + format!("{}...(截断)", &text[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn form_body_is_sorted_and_url_encoded() { + let mut params = BTreeMap::new(); + params.insert("ReturnForm".to_string(), "crop".to_string()); + params.insert( + "ImageURL".to_string(), + "https://example.com/a.png?x=1&y=2".to_string(), + ); + params.insert("Action".to_string(), "SegmentCommonImage".to_string()); + + let body = build_aliyun_form_body(¶ms); + assert_eq!( + body, + "Action=SegmentCommonImage&ImageURL=https%3A%2F%2Fexample.com%2Fa.png%3Fx%3D1%26y%3D2&ReturnForm=crop" + ); + } + + #[test] + fn signature_headers_use_acs3_sha256() { + 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_signature_headers("SegmentCommonImage", "ImageURL=x") + .expect("headers should build"); + + let authorization = headers + .get("authorization") + .expect("authorization header should exist") + .to_str() + .expect("authorization header should be ascii"); + assert!(authorization.starts_with("ACS3-HMAC-SHA256 Credential=test-key-id,")); + assert!(authorization.contains("SignedHeaders=host;x-acs-action;x-acs-content-sha256;")); + assert_eq!( + headers + .get("x-acs-version") + .and_then(|value| value.to_str().ok()), + Some("2019-12-30") + ); + let date = headers + .get("x-acs-date") + .and_then(|value| value.to_str().ok()) + .expect("x-acs-date should exist"); + assert!(!date.contains('.'), "x-acs-date 不能带小数秒:{date}"); + } +}