refactor: extract platform media crates

This commit is contained in:
kdletters
2026-05-26 13:18:13 +08:00
parent 50f44489cd
commit 44c65df5c9
92 changed files with 7381 additions and 5848 deletions

View File

@@ -6,7 +6,9 @@ license.workspace = true
[dependencies]
base64 = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["time"] }
tracing = { workspace = true }
platform-oss = { workspace = true }

View File

@@ -0,0 +1,429 @@
use super::color::{
GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE,
GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit,
compute_generated_asset_sheet_green_screen_score,
compute_generated_asset_sheet_white_screen_score,
is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel,
touches_generated_asset_sheet_background_mask,
};
pub fn apply_generated_asset_sheet_green_screen_alpha(
source: image::DynamicImage,
) -> image::DynamicImage {
let mut image = source.to_rgba8();
let (width, height) = image.dimensions();
remove_generated_asset_sheet_green_screen_background(
image.as_mut(),
width as usize,
height as usize,
);
image::DynamicImage::ImageRgba8(image)
}
fn remove_generated_asset_sheet_green_screen_background(
pixels: &mut [u8],
width: usize,
height: usize,
) -> bool {
let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
return false;
}
let mut green_scores = vec![0.0f32; pixel_count];
let mut white_scores = vec![0.0f32; pixel_count];
let mut background_hints = vec![0.0f32; pixel_count];
let mut background_mask = vec![0u8; pixel_count];
let mut queue = Vec::<usize>::new();
let mut queue_index = 0usize;
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
let red = pixels[offset];
let green = pixels[offset + 1];
let blue = pixels[offset + 2];
let alpha = pixels[offset + 3];
let green_score =
compute_generated_asset_sheet_green_screen_score([red, green, blue, alpha]);
let white_score =
compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]);
let transparency_hint =
clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75;
green_scores[pixel_index] = green_score;
white_scores[pixel_index] = white_score;
background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint);
}
let seed_background_pixel =
|pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec<usize>| {
if background_mask[pixel_index] != 0 {
return;
}
let alpha = pixels[pixel_index * 4 + 3];
let strong_candidate = alpha < 40
|| green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224
&& green_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
|| white_scores[pixel_index] > 0.32;
if !strong_candidate {
return;
}
background_mask[pixel_index] = 1;
queue.push(pixel_index);
};
for x in 0..width {
seed_background_pixel(x, &mut background_mask, &mut queue);
seed_background_pixel((height - 1) * width + x, &mut background_mask, &mut queue);
}
for y in 1..height.saturating_sub(1) {
seed_background_pixel(y * width, &mut background_mask, &mut queue);
seed_background_pixel(y * width + width - 1, &mut background_mask, &mut queue);
}
while queue_index < queue.len() {
let pixel_index = queue[queue_index];
queue_index += 1;
let x = pixel_index % width;
let y = pixel_index / width;
let neighbor_indexes = [
if x > 0 { Some(pixel_index - 1) } else { None },
if x + 1 < width {
Some(pixel_index + 1)
} else {
None
},
if y > 0 {
Some(pixel_index - width)
} else {
None
},
if y + 1 < height {
Some(pixel_index + width)
} else {
None
},
];
for next_pixel_index in neighbor_indexes.into_iter().flatten() {
if background_mask[next_pixel_index] != 0 {
continue;
}
let next_offset = next_pixel_index * 4;
let alpha = pixels[next_offset + 3];
let green_score = green_scores[next_pixel_index];
let white_score = white_scores[next_pixel_index];
let hint = background_hints[next_pixel_index];
let reachable_soft_edge = hint > 0.08
&& alpha < 224
&& (green_score > 0.04 || white_score > 0.08 || alpha < 180);
let green_background = green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224 && green_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge {
background_mask[next_pixel_index] = 1;
queue.push(next_pixel_index);
}
}
}
for pixel_index in 0..pixel_count {
if background_mask[pixel_index] == 0
&& green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
{
background_mask[pixel_index] = 1;
}
}
let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14);
for _ in 0..soft_green_cleanup_rounds {
let mut expanded_mask = background_mask.clone();
let mut changed_this_round = false;
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if background_mask[pixel_index] != 0 {
continue;
}
let offset = pixel_index * 4;
let pixel = [
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
];
let green_score = green_scores[pixel_index];
let white_score = white_scores[pixel_index];
if !is_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score)
{
continue;
}
if !touches_generated_asset_sheet_background_mask(
x,
y,
width,
height,
&background_mask,
) {
continue;
}
expanded_mask[pixel_index] = 1;
changed_this_round = true;
}
}
background_mask = expanded_mask;
if !changed_this_round {
break;
}
}
for _ in 0..2 {
let mut expanded_mask = background_mask.clone();
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if background_mask[pixel_index] != 0 {
continue;
}
let alpha = pixels[pixel_index * 4 + 3];
let green_score = green_scores[pixel_index];
let white_score = white_scores[pixel_index];
let hint = background_hints[pixel_index];
let soft_matte_candidate = alpha < 224
|| white_score > 0.10
|| green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE;
if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate {
continue;
}
let mut adjacent_background_count = 0usize;
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0
|| next_x >= width as i32
|| next_y < 0
|| next_y >= height as i32
{
adjacent_background_count += 1;
continue;
}
if background_mask[next_y as usize * width + next_x as usize] != 0 {
adjacent_background_count += 1;
}
}
}
if adjacent_background_count >= 2
|| (adjacent_background_count >= 1
&& hint >= GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
{
expanded_mask[pixel_index] = 1;
}
}
}
background_mask = expanded_mask;
}
let mut changed = false;
for pixel_index in 0..pixel_count {
if background_mask[pixel_index] == 0 {
continue;
}
let alpha_offset = pixel_index * 4 + 3;
if pixels[alpha_offset] != 0 {
pixels[alpha_offset] = 0;
changed = true;
}
}
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
let offset = pixel_index * 4;
let alpha = pixels[offset + 3];
if alpha == 0 {
continue;
}
let mut touches_transparent_edge = false;
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
{
touches_transparent_edge = true;
continue;
}
let next_pixel_index = next_y as usize * width + next_x as usize;
if background_mask[next_pixel_index] != 0
|| pixels[next_pixel_index * 4 + 3] < 16
{
touches_transparent_edge = true;
}
}
}
if !touches_transparent_edge {
continue;
}
let green_score = green_scores[pixel_index];
let white_score = white_scores[pixel_index];
let contamination = green_score.max(white_score).max(if alpha < 220 {
((220 - alpha) as f32 / 220.0) * 0.25
} else {
0.0
});
if contamination < 0.06 {
continue;
}
let sample = collect_generated_asset_sheet_foreground_neighbor_color(
pixels,
width,
height,
x,
y,
&background_mask,
&background_hints,
);
let mut red = pixels[offset] as f32;
let mut green = pixels[offset + 1] as f32;
let mut blue = pixels[offset + 2] as f32;
let blend = clamp_generated_asset_sheet_unit(contamination.max(0.22));
if let Some((sample_red, sample_green, sample_blue)) = sample {
red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend);
green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend);
blue = lerp_generated_asset_sheet_channel(blue, sample_blue as f32, blend);
if green_score > 0.04 {
green = green.min(sample_green as f32 + 18.0);
}
if white_score > 0.1 {
red = red.min(sample_red as f32 + 26.0);
green = green.min(sample_green as f32 + 26.0);
blue = blue.min(sample_blue as f32 + 26.0);
}
} else {
if green_score > 0.04 {
let toned_green = (green - (green - red.max(blue)) * 0.78)
.round()
.max(red.max(blue));
green = green.min(toned_green).min(red.max(blue) + 18.0);
}
if white_score > 0.12 {
let spread = red.max(green).max(blue) - red.min(green).min(blue);
if spread < 20.0 {
let toned_value = ((red + green + blue) / 3.0 * 0.88).round();
red = red.min(toned_value);
green = green.min(toned_value);
blue = blue.min(toned_value);
}
}
}
let mut next_alpha = alpha;
let edge_fade = (green_score * 0.35).max(white_score * 0.28);
if edge_fade > 0.08 {
next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8;
if next_alpha < 10 {
next_alpha = 0;
}
}
let next_red = red.round().clamp(0.0, 255.0) as u8;
let next_green = green.round().clamp(0.0, 255.0) as u8;
let next_blue = blue.round().clamp(0.0, 255.0) as u8;
if next_red != pixels[offset]
|| next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2]
|| next_alpha != alpha
{
pixels[offset] = next_red;
pixels[offset + 1] = next_green;
pixels[offset + 2] = next_blue;
pixels[offset + 3] = next_alpha;
changed = true;
}
}
}
changed
}
fn collect_generated_asset_sheet_foreground_neighbor_color(
pixels: &[u8],
width: usize,
height: usize,
x: usize,
y: usize,
background_mask: &[u8],
background_hints: &[f32],
) -> Option<(u8, u8, u8)> {
let mut total_weight = 0.0f32;
let mut total_red = 0.0f32;
let mut total_green = 0.0f32;
let mut total_blue = 0.0f32;
for offset_y in -2i32..=2 {
for offset_x in -2i32..=2 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 {
continue;
}
let next_pixel_index = next_y as usize * width + next_x as usize;
if background_mask[next_pixel_index] != 0 || background_hints[next_pixel_index] >= 0.18
{
continue;
}
let next_offset = next_pixel_index * 4;
let next_alpha = pixels[next_offset + 3];
if next_alpha < 96 {
continue;
}
let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs();
let weight = (next_alpha as f32 / 255.0)
* if distance <= 1 {
1.8
} else if distance == 2 {
1.2
} else {
0.7
};
total_weight += weight;
total_red += pixels[next_offset] as f32 * weight;
total_green += pixels[next_offset + 1] as f32 * weight;
total_blue += pixels[next_offset + 2] as f32 * weight;
}
}
if total_weight <= 0.0 {
return None;
}
Some((
(total_red / total_weight).round() as u8,
(total_green / total_weight).round() as u8,
(total_blue / total_weight).round() as u8,
))
}

View File

@@ -0,0 +1,162 @@
pub(super) const GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 36;
pub(super) const GENERATED_ASSET_SHEET_FOREGROUND_ALPHA_THRESHOLD: i32 = 36;
pub(super) const GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE: f32 = 0.34;
pub(super) const GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18;
pub(super) const GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE: f32 = 0.82;
pub(super) fn clamp_generated_asset_sheet_unit(value: f32) -> f32 {
value.clamp(0.0, 1.0)
}
pub(super) fn lerp_generated_asset_sheet_channel(from: f32, to: f32, t: f32) -> f32 {
from + (to - from) * clamp_generated_asset_sheet_unit(t)
}
pub(super) fn is_generated_asset_sheet_foreground_pixel(
pixel: [u8; 4],
background: [u8; 4],
) -> bool {
let alpha_diff = pixel[3] as i32 - background[3] as i32;
if alpha_diff.abs() >= GENERATED_ASSET_SHEET_FOREGROUND_ALPHA_THRESHOLD && pixel[3] > 24 {
return true;
}
if pixel[3] <= 24 {
return false;
}
let color_diff = (pixel[0] as i32 - background[0] as i32).abs()
+ (pixel[1] as i32 - background[1] as i32).abs()
+ (pixel[2] as i32 - background[2] as i32).abs();
color_diff >= GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD
}
pub(super) fn is_generated_asset_sheet_view_background_pixel(pixel: [u8; 4]) -> bool {
pixel[3] < 16
|| is_generated_asset_sheet_soft_edge_pixel(pixel)
|| compute_generated_asset_sheet_white_screen_score(pixel) > 0.18
}
pub(super) fn is_generated_asset_sheet_visible_pixel(pixel: [u8; 4]) -> bool {
pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8)
}
pub(super) fn is_generated_asset_sheet_soft_edge_pixel(pixel: [u8; 4]) -> bool {
if pixel[3] == 0 {
return false;
}
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
green >= 188
&& green.saturating_sub(red.max(blue)) >= 42
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
}
pub(super) fn is_generated_asset_sheet_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool {
if pixel[3] == 0 {
return false;
}
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
green >= 72 && green.saturating_sub(red.max(blue)) >= 18
}
pub(super) fn is_generated_asset_sheet_strong_green_contamination(pixel: [u8; 4]) -> bool {
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
green >= 148 && green.saturating_sub(red.max(blue)) >= 34
}
pub(super) fn touches_generated_asset_sheet_background_mask(
x: usize,
y: usize,
width: usize,
height: usize,
background_mask: &[u8],
) -> bool {
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 {
return true;
}
if background_mask[next_y as usize * width + next_x as usize] != 0 {
return true;
}
}
}
false
}
pub(super) fn is_generated_asset_sheet_soft_green_matte_pixel(
pixel: [u8; 4],
green_score: f32,
white_score: f32,
) -> bool {
if pixel[3] == 0 || green_score < GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE {
return false;
}
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
let foreground_mix = red.max(blue);
green >= 188
&& white_score < 0.34
&& green.saturating_sub(foreground_mix) >= 42
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
}
pub(super) fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) -> f32 {
if pixel[3] == 0 {
return 1.0;
}
let red = pixel[0] as f32;
let green = pixel[1] as f32;
let blue = pixel[2] as f32;
let green_lead = green - red.max(blue);
if green < 96.0 || green_lead <= 18.0 {
return 0.0;
}
let green_ratio = green / (red + blue).max(1.0);
if green_ratio <= 0.9 {
return 0.0;
}
(((green - 96.0) / 128.0).clamp(0.0, 1.0) * 0.34
+ ((green_lead - 18.0) / 120.0).clamp(0.0, 1.0) * 0.46
+ ((green_ratio - 0.9) / 2.4).clamp(0.0, 1.0) * 0.20)
.clamp(0.0, 1.0)
}
pub(super) fn compute_generated_asset_sheet_white_screen_score(pixel: [u8; 4]) -> f32 {
if pixel[3] == 0 {
return 1.0;
}
let red = pixel[0] as f32;
let green = pixel[1] as f32;
let blue = pixel[2] as f32;
let max_channel = red.max(green).max(blue);
let min_channel = red.min(green).min(blue);
let average = (red + green + blue) / 3.0;
if average < 188.0 || min_channel < 168.0 {
return 0.0;
}
let spread = max_channel - min_channel;
let neutrality = 1.0 - clamp_generated_asset_sheet_unit((spread - 6.0) / 34.0);
let brightness = clamp_generated_asset_sheet_unit((average - 188.0) / 55.0);
let floor = clamp_generated_asset_sheet_unit((min_channel - 168.0) / 60.0);
clamp_generated_asset_sheet_unit(neutrality * (brightness * 0.85 + floor * 0.15))
}

View File

@@ -0,0 +1,62 @@
use std::{error::Error, fmt};
use platform_oss::OssError;
pub const GENERATED_ASSET_SHEET_PROVIDER: &str = "generated-asset-sheets";
#[derive(Debug)]
pub enum GeneratedAssetSheetError {
InvalidRequest { message: String },
DecodeImage { message: String },
EncodeImage { message: String },
BuildHttpClient { message: String },
Oss(OssError),
}
impl GeneratedAssetSheetError {
pub fn provider(&self) -> &'static str {
GENERATED_ASSET_SHEET_PROVIDER
}
pub fn message(&self) -> String {
match self {
Self::InvalidRequest { message }
| Self::DecodeImage { message }
| Self::EncodeImage { message }
| Self::BuildHttpClient { message } => message.clone(),
Self::Oss(error) => error.to_string(),
}
}
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::InvalidRequest {
message: message.into(),
}
}
pub fn decode_image(message: impl Into<String>) -> Self {
Self::DecodeImage {
message: message.into(),
}
}
pub fn encode_image(message: impl Into<String>) -> Self {
Self::EncodeImage {
message: message.into(),
}
}
pub fn build_http_client(message: impl Into<String>) -> Self {
Self::BuildHttpClient {
message: message.into(),
}
}
}
impl fmt::Display for GeneratedAssetSheetError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.message().as_str())
}
}
impl Error for GeneratedAssetSheetError {}

View File

@@ -0,0 +1,18 @@
pub mod alpha;
mod color;
pub mod error;
pub mod persist;
pub mod prompt;
pub mod sheet;
pub use alpha::apply_generated_asset_sheet_green_screen_alpha;
pub use error::GeneratedAssetSheetError;
pub use persist::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload,
persist_generated_asset_sheet_bytes, prepare_generated_asset_sheet_put_request,
};
pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt};
pub use sheet::{
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row,
};

View File

@@ -0,0 +1,203 @@
use std::{collections::BTreeMap, time::Duration};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use platform_oss::{LegacyAssetPrefix, OssClient, OssObjectAccess, OssPutObjectRequest};
use super::error::GeneratedAssetSheetError;
const GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetUpload {
pub src: String,
pub object_key: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GeneratedAssetSheetPersistPrompt {
pub sheet_prompt: Option<String>,
pub item_name_prompt: Option<String>,
pub special_prompt: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetPersistInput {
pub prefix: LegacyAssetPrefix,
pub owner_user_id: String,
pub session_id: String,
pub profile_id: String,
pub path_segments: Vec<String>,
pub file_name: String,
pub content_type: String,
pub bytes: Vec<u8>,
pub asset_kind: String,
pub source_job_id: Option<String>,
pub generated_at_micros: i64,
pub grid_size: usize,
pub row_index: usize,
pub view_index: usize,
pub prompt: GeneratedAssetSheetPersistPrompt,
}
pub fn prepare_generated_asset_sheet_put_request(
input: GeneratedAssetSheetPersistInput,
) -> Result<OssPutObjectRequest, GeneratedAssetSheetError> {
if input.grid_size == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集的 n 必须大于 0。",
));
}
if input.row_index == 0
|| input.view_index == 0
|| input.row_index > input.grid_size
|| input.view_index > input.grid_size
{
return Err(GeneratedAssetSheetError::invalid_request(format!(
"系列素材图集持久化的行列索引必须落在 n*n 范围内。gridSize={}, rowIndex={}, viewIndex={}",
input.grid_size, input.row_index, input.view_index
)));
}
let mut metadata = BTreeMap::new();
metadata.insert(
"x-oss-meta-asset-kind".to_string(),
input.asset_kind.clone(),
);
metadata.insert(
"x-oss-meta-owner-user-id".to_string(),
input.owner_user_id.clone(),
);
metadata.insert(
"x-oss-meta-profile-id".to_string(),
input.profile_id.clone(),
);
metadata.insert(
"x-oss-meta-generated-asset-sheet-grid-size".to_string(),
input.grid_size.to_string(),
);
metadata.insert(
"x-oss-meta-generated-asset-sheet-row-index".to_string(),
input.row_index.to_string(),
);
metadata.insert(
"x-oss-meta-generated-asset-sheet-view-index".to_string(),
input.view_index.to_string(),
);
metadata.insert(
"x-oss-meta-generated-at-micros".to_string(),
input.generated_at_micros.to_string(),
);
if let Some(source_job_id) = input
.source_job_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
metadata.insert(
"x-oss-meta-source-job-id".to_string(),
source_job_id.to_string(),
);
}
insert_generated_asset_sheet_prompt_metadata(
&mut metadata,
"generated-asset-sheet-prompt-b64",
input.prompt.sheet_prompt.as_deref(),
);
insert_generated_asset_sheet_prompt_metadata(
&mut metadata,
"generated-asset-sheet-item-name-prompt-b64",
input.prompt.item_name_prompt.as_deref(),
);
insert_generated_asset_sheet_prompt_metadata(
&mut metadata,
"generated-asset-sheet-special-prompt-b64",
input.prompt.special_prompt.as_deref(),
);
if input.prompt.sheet_prompt.is_some()
|| input.prompt.item_name_prompt.is_some()
|| input.prompt.special_prompt.is_some()
{
metadata.insert(
"x-oss-meta-generated-asset-sheet-prompt-encoding".to_string(),
"utf8-base64".to_string(),
);
}
Ok(OssPutObjectRequest {
prefix: input.prefix,
path_segments: std::iter::once(input.session_id.as_str())
.chain(std::iter::once(input.profile_id.as_str()))
.chain(input.path_segments.iter().map(String::as_str))
.map(|segment| sanitize_generated_asset_sheet_path_segment(segment, "asset"))
.collect(),
file_name: input.file_name,
content_type: Some(input.content_type),
access: OssObjectAccess::Private,
metadata,
body: input.bytes,
})
}
pub async fn persist_generated_asset_sheet_bytes(
oss_client: &OssClient,
input: GeneratedAssetSheetPersistInput,
) -> Result<GeneratedAssetSheetUpload, GeneratedAssetSheetError> {
let put_request = prepare_generated_asset_sheet_put_request(input)?;
let oss_http_client = reqwest::Client::builder()
.timeout(Duration::from_millis(
GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS,
))
.build()
.map_err(|error| {
GeneratedAssetSheetError::build_http_client(format!(
"构造系列素材图集 OSS 上传客户端失败:{error}"
))
})?;
let put_result = oss_client
.put_object(&oss_http_client, put_request)
.await
.map_err(GeneratedAssetSheetError::Oss)?;
Ok(GeneratedAssetSheetUpload {
src: put_result.legacy_public_path,
object_key: put_result.object_key,
})
}
fn insert_generated_asset_sheet_prompt_metadata(
metadata: &mut BTreeMap<String, String>,
key: &str,
value: Option<&str>,
) {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return;
};
metadata.insert(
format!("x-oss-meta-{key}"),
BASE64_STANDARD.encode(value.as_bytes()),
);
}
fn sanitize_generated_asset_sheet_path_segment(raw: &str, fallback: &str) -> String {
let normalized = raw
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let collapsed = normalized
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if collapsed.is_empty() {
fallback.to_string()
} else {
collapsed.chars().take(64).collect()
}
}

View File

@@ -0,0 +1,65 @@
use super::error::GeneratedAssetSheetError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetPromptInput<'a> {
pub subject_text: &'a str,
pub item_names: &'a [String],
pub grid_size: usize,
pub item_name_prompt_template: Option<&'a str>,
pub special_prompt: Option<&'a str>,
}
pub fn build_generated_asset_sheet_prompt(
input: &GeneratedAssetSheetPromptInput<'_>,
) -> Result<String, GeneratedAssetSheetError> {
let grid_size = input.grid_size;
if grid_size == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集的 n 必须大于 0。",
));
}
if input.item_names.len() > grid_size {
return Err(GeneratedAssetSheetError::invalid_request(format!(
"系列素材图集的物品行数不能超过 n。gridSize={grid_size}, itemCount={}",
input.item_names.len()
)));
}
let subject_text = input.subject_text.trim();
let subject_text = if subject_text.is_empty() {
"系列素材"
} else {
subject_text
};
let item_rows = input
.item_names
.iter()
.enumerate()
.map(|(index, item_name)| {
let row_index = index + 1;
let item_name = item_name.trim();
if let Some(template) = input
.item_name_prompt_template
.map(str::trim)
.filter(|value| !value.is_empty())
{
return template
.replace("{row_index}", row_index.to_string().as_str())
.replace("{item_name}", item_name)
.replace("{view_count}", grid_size.to_string().as_str());
}
format!("{row_index}行:{item_name}{grid_size} 个不同视图")
})
.collect::<Vec<_>>()
.join("");
let special_prompt = input
.special_prompt
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("每个物品生成 {grid_size} 个不同视图。"));
Ok(format!(
"生成一张1:1图片。固定生成{grid_size}行*{grid_size}列网格素材图,画面是{subject_text}。严格{grid_size}*{grid_size}均匀排布,严格按行组织:{item_rows}{special_prompt}每个格子一个独立居中的完整素材,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00背景平整无纹理、无渐变、无阴影、无道具方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色若素材天然含绿色必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照清晰轮廓适合直接切割成游戏2D素材。请让每个素材完整落在自己的格子中央四周保留留白相邻素材主体之间必须至少保留单个素材格宽度的1/4空白间距约25%单格宽度包含左右相邻格和上下相邻行素材主体不得占满格子。禁止主体跨格、贴边或越界禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。"
))
}

View File

@@ -0,0 +1,672 @@
use super::alpha::apply_generated_asset_sheet_green_screen_alpha;
use super::color::{
is_generated_asset_sheet_foreground_pixel,
is_generated_asset_sheet_green_contaminated_edge_pixel,
is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination,
is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel,
touches_generated_asset_sheet_background_mask,
};
use super::error::GeneratedAssetSheetError;
use image::{GenericImageView, ImageFormat};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetSliceImage {
pub bytes: Vec<u8>,
}
pub fn slice_generated_asset_sheet(
image: &crate::DownloadedImage,
item_names: &[String],
grid_size: usize,
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, GeneratedAssetSheetError> {
if grid_size == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集的 n 必须大于 0。",
));
}
let grid_size_u32 = u32::try_from(grid_size).map_err(|_| {
GeneratedAssetSheetError::invalid_request("系列素材图集的 n 超出可支持范围。")
})?;
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
GeneratedAssetSheetError::decode_image(format!("系列素材图集解码失败:{error}"))
})?;
let source = apply_generated_asset_sheet_green_screen_alpha(source);
let (width, height) = source.dimensions();
let cell_width = width / grid_size_u32;
let cell_height = height / grid_size_u32;
if cell_width == 0 || cell_height == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集尺寸过小,无法切割。",
));
}
let mut slices = Vec::with_capacity(item_names.len().min(grid_size));
for item_index in 0..item_names.len().min(grid_size) {
let row = item_index as u32;
let mut views = Vec::with_capacity(grid_size);
for view_index in 0..grid_size {
let col = view_index as u32;
let (crop_x, crop_y, crop_width, crop_height) =
resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col);
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
let mut cursor = std::io::Cursor::new(Vec::new());
cleaned
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|error| {
GeneratedAssetSheetError::encode_image(format!("系列素材图集切割失败:{error}"))
})?;
views.push(GeneratedAssetSheetSliceImage {
bytes: cursor.into_inner(),
});
}
slices.push(views);
}
Ok(slices)
}
pub fn slice_generated_asset_sheet_two_items_per_row(
image: &crate::DownloadedImage,
item_names: &[String],
grid_size: usize,
views_per_item: usize,
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, GeneratedAssetSheetError> {
if grid_size == 0 || views_per_item == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集的 n 和每物品视图数必须大于 0。",
));
}
if !grid_size.is_multiple_of(views_per_item) {
return Err(GeneratedAssetSheetError::invalid_request(format!(
"系列素材图集每行必须能均分为若干物品。gridSize={}, viewsPerItem={}",
grid_size, views_per_item
)));
}
let grid_size_u32 = u32::try_from(grid_size).map_err(|_| {
GeneratedAssetSheetError::invalid_request("系列素材图集的 n 超出可支持范围。")
})?;
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
GeneratedAssetSheetError::decode_image(format!("系列素材图集解码失败:{error}"))
})?;
let source = apply_generated_asset_sheet_green_screen_alpha(source);
let (width, height) = source.dimensions();
if width / grid_size_u32 == 0 || height / grid_size_u32 == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集尺寸过小,无法切割。",
));
}
let items_per_row = grid_size / views_per_item;
let max_item_count = grid_size.saturating_mul(items_per_row);
let mut slices = Vec::with_capacity(item_names.len().min(max_item_count));
for item_index in 0..item_names.len().min(max_item_count) {
let row = (item_index / items_per_row) as u32;
let start_col = ((item_index % items_per_row) * views_per_item) as u32;
let mut views = Vec::with_capacity(views_per_item);
for view_offset in 0..views_per_item {
let col = start_col + view_offset as u32;
let (crop_x, crop_y, crop_width, crop_height) =
resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col);
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
let mut cursor = std::io::Cursor::new(Vec::new());
cleaned
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|error| {
GeneratedAssetSheetError::encode_image(format!("系列素材图集切割失败:{error}"))
})?;
views.push(GeneratedAssetSheetSliceImage {
bytes: cursor.into_inner(),
});
}
slices.push(views);
}
Ok(slices)
}
pub fn crop_generated_asset_sheet_view_edge_matte(
image: image::DynamicImage,
) -> image::DynamicImage {
let mut image = image.to_rgba8();
let (width, height) = image.dimensions();
remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize);
let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| {
GeneratedAssetSheetCellBounds {
x0: 0,
y0: 0,
x1: width,
y1: height,
}
});
if bounds.x0 == 0 && bounds.y0 == 0 && bounds.x1 == width && bounds.y1 == height {
return image::DynamicImage::ImageRgba8(image);
}
image::DynamicImage::ImageRgba8(
image::imageops::crop_imm(
&image,
bounds.x0,
bounds.y0,
bounds.width(),
bounds.height(),
)
.to_image(),
)
}
#[derive(Clone, Copy, Debug)]
struct GeneratedAssetSheetCellBounds {
x0: u32,
y0: u32,
x1: u32,
y1: u32,
}
impl GeneratedAssetSheetCellBounds {
fn width(self) -> u32 {
self.x1.saturating_sub(self.x0).max(1)
}
fn height(self) -> u32 {
self.y1.saturating_sub(self.y0).max(1)
}
fn area(self) -> u32 {
self.width().saturating_mul(self.height())
}
fn to_crop_tuple(self) -> (u32, u32, u32, u32) {
(self.x0, self.y0, self.width(), self.height())
}
}
fn resolve_generated_asset_sheet_cell_crop(
source: &image::DynamicImage,
grid_size: u32,
row: u32,
col: u32,
) -> (u32, u32, u32, u32) {
let (image_width, image_height) = source.dimensions();
let cell =
resolve_generated_asset_sheet_cell_bounds(image_width, image_height, grid_size, row, col);
let Some(foreground) = detect_generated_asset_sheet_foreground_bounds(source, cell) else {
return cell.to_crop_tuple();
};
let cell_width = cell.width();
let cell_height = cell.height();
let pad_x = (cell_width / 16).clamp(4, 16);
let pad_y = (cell_height / 16).clamp(4, 16);
let crop = GeneratedAssetSheetCellBounds {
x0: foreground.x0.saturating_sub(pad_x).max(cell.x0),
y0: foreground.y0.saturating_sub(pad_y).max(cell.y0),
x1: foreground.x1.saturating_add(pad_x).min(cell.x1),
y1: foreground.y1.saturating_add(pad_y).min(cell.y1),
};
crop.to_crop_tuple()
}
fn resolve_generated_asset_sheet_cell_bounds(
image_width: u32,
image_height: u32,
grid_size: u32,
row: u32,
col: u32,
) -> GeneratedAssetSheetCellBounds {
let normalized_grid_size = grid_size.max(1);
let cell_x0 = col.saturating_mul(image_width) / normalized_grid_size;
let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / normalized_grid_size;
let cell_y0 = row.saturating_mul(image_height) / normalized_grid_size;
let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_grid_size;
GeneratedAssetSheetCellBounds {
x0: cell_x0.min(image_width.saturating_sub(1)),
y0: cell_y0.min(image_height.saturating_sub(1)),
x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width),
y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height),
}
}
fn detect_generated_asset_sheet_foreground_bounds(
source: &image::DynamicImage,
cell: GeneratedAssetSheetCellBounds,
) -> Option<GeneratedAssetSheetCellBounds> {
let background = sample_generated_asset_sheet_cell_background(source, cell);
let mut foreground: Option<GeneratedAssetSheetCellBounds> = None;
let mut foreground_pixels = 0u32;
for y in cell.y0..cell.y1 {
for x in cell.x0..cell.x1 {
if !is_generated_asset_sheet_foreground_pixel(source.get_pixel(x, y).0, background) {
continue;
}
foreground_pixels = foreground_pixels.saturating_add(1);
foreground = Some(match foreground {
Some(bounds) => GeneratedAssetSheetCellBounds {
x0: bounds.x0.min(x),
y0: bounds.y0.min(y),
x1: bounds.x1.max(x.saturating_add(1)),
y1: bounds.y1.max(y.saturating_add(1)),
},
None => GeneratedAssetSheetCellBounds {
x0: x,
y0: y,
x1: x.saturating_add(1),
y1: y.saturating_add(1),
},
});
}
}
let min_foreground_pixels = (cell.area() / 320).clamp(12, 220);
foreground.filter(|bounds| {
foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2
})
}
fn detect_generated_asset_sheet_visible_bounds(
image: &image::RgbaImage,
) -> Option<GeneratedAssetSheetCellBounds> {
let (width, height) = image.dimensions();
let mut bounds: Option<GeneratedAssetSheetCellBounds> = None;
let mut visible_pixels = 0u32;
for y in 0..height {
for x in 0..width {
let pixel = image.get_pixel(x, y).0;
if !is_generated_asset_sheet_visible_pixel(pixel) {
continue;
}
visible_pixels = visible_pixels.saturating_add(1);
bounds = Some(match bounds {
Some(current) => GeneratedAssetSheetCellBounds {
x0: current.x0.min(x),
y0: current.y0.min(y),
x1: current.x1.max(x.saturating_add(1)),
y1: current.y1.max(y.saturating_add(1)),
},
None => GeneratedAssetSheetCellBounds {
x0: x,
y0: y,
x1: x.saturating_add(1),
y1: y.saturating_add(1),
},
});
}
}
let min_visible_pixels = ((width.saturating_mul(height)) / 540).clamp(10, 120);
bounds.filter(|visible_bounds| {
visible_pixels >= min_visible_pixels
&& visible_bounds.width() > 2
&& visible_bounds.height() > 2
})
}
fn sample_generated_asset_sheet_cell_background(
source: &image::DynamicImage,
cell: GeneratedAssetSheetCellBounds,
) -> [u8; 4] {
let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8);
let sample_points = [
(cell.x0, cell.y0),
(cell.x1.saturating_sub(sample_size), cell.y0),
(cell.x0, cell.y1.saturating_sub(sample_size)),
(
cell.x1.saturating_sub(sample_size),
cell.y1.saturating_sub(sample_size),
),
];
let mut samples = Vec::new();
for (start_x, start_y) in sample_points {
let mut totals = [0u32; 4];
let mut count = 0u32;
for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) {
for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) {
let pixel = source.get_pixel(x, y).0;
totals[0] = totals[0].saturating_add(pixel[0] as u32);
totals[1] = totals[1].saturating_add(pixel[1] as u32);
totals[2] = totals[2].saturating_add(pixel[2] as u32);
totals[3] = totals[3].saturating_add(pixel[3] as u32);
count = count.saturating_add(1);
}
}
if count > 0 {
samples.push([
(totals[0] / count) as u8,
(totals[1] / count) as u8,
(totals[2] / count) as u8,
(totals[3] / count) as u8,
]);
}
}
samples
.into_iter()
.min_by_key(|sample| {
let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16;
(sample[3] as u16, u16::MAX.saturating_sub(luminance))
})
.unwrap_or([255, 255, 255, 255])
}
fn remove_generated_asset_sheet_view_edge_matte(
pixels: &mut [u8],
width: usize,
height: usize,
) -> bool {
let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
return false;
}
let mut changed = false;
let mut background_mask = vec![0u8; pixel_count];
let mut queue = Vec::<usize>::new();
let mut queue_index = 0usize;
let mut transparent_pixel_count = 0usize;
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
if pixels[offset + 3] == 0 {
background_mask[pixel_index] = 1;
queue.push(pixel_index);
transparent_pixel_count = transparent_pixel_count.saturating_add(1);
}
}
let has_transparent_background = transparent_pixel_count > pixel_count / 200;
// 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘;
// 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte避免误伤贴边主体。
let edge_width = resolve_generated_asset_sheet_view_edge_cleanup_width(width, height);
for y in 0..height {
for x in 0..width {
if x >= edge_width
&& y >= edge_width
&& x.saturating_add(edge_width) < width
&& y.saturating_add(edge_width) < height
{
continue;
}
let pixel_index = y * width + x;
if background_mask[pixel_index] != 0 {
continue;
}
let offset = pixel_index * 4;
let pixel = [
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
];
if !is_generated_asset_sheet_view_background_pixel(pixel) {
continue;
}
background_mask[pixel_index] = 1;
queue.push(pixel_index);
}
}
while queue_index < queue.len() {
let pixel_index = queue[queue_index];
queue_index += 1;
let x = pixel_index % width;
let y = pixel_index / width;
let neighbors = [
(x > 0).then(|| pixel_index - 1),
(x + 1 < width).then_some(pixel_index + 1),
(y > 0).then(|| pixel_index - width),
(y + 1 < height).then_some(pixel_index + width),
];
for next_pixel_index in neighbors.into_iter().flatten() {
if background_mask[next_pixel_index] != 0 {
continue;
}
let offset = next_pixel_index * 4;
let pixel = [
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
];
if !is_generated_asset_sheet_view_background_pixel(pixel) {
continue;
}
background_mask[next_pixel_index] = 1;
queue.push(next_pixel_index);
}
}
for _ in 0..edge_width {
let mut expanded_mask = background_mask.clone();
let mut changed_this_round = false;
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if background_mask[pixel_index] != 0 {
continue;
}
let offset = pixel_index * 4;
if !is_generated_asset_sheet_view_background_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
continue;
}
if touches_generated_asset_sheet_background_mask(
x,
y,
width,
height,
&background_mask,
) {
expanded_mask[pixel_index] = 1;
changed_this_round = true;
}
}
}
background_mask = expanded_mask;
if !changed_this_round {
break;
}
}
for pixel_index in 0..pixel_count {
if background_mask[pixel_index] == 0 {
continue;
}
let offset = pixel_index * 4;
if pixels[offset + 3] != 0
|| pixels[offset] != 0
|| pixels[offset + 1] != 0
|| pixels[offset + 2] != 0
{
pixels[offset] = 0;
pixels[offset + 1] = 0;
pixels[offset + 2] = 0;
pixels[offset + 3] = 0;
changed = true;
}
}
if has_transparent_background {
let mut visible_mask = vec![0u8; pixel_count];
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
if is_generated_asset_sheet_visible_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
visible_mask[pixel_index] = 1;
}
}
for _ in 0..2 {
let mut changed_this_round = false;
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if visible_mask[pixel_index] == 0 {
continue;
}
let offset = pixel_index * 4;
let pixel = [
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
];
if !is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) {
continue;
}
if !touches_generated_asset_sheet_background_mask(
x,
y,
width,
height,
&background_mask,
) {
continue;
}
if is_generated_asset_sheet_strong_green_contamination(pixel) {
pixels[offset] = 0;
pixels[offset + 1] = 0;
pixels[offset + 2] = 0;
pixels[offset + 3] = 0;
visible_mask[pixel_index] = 0;
background_mask[pixel_index] = 1;
changed = true;
changed_this_round = true;
continue;
}
let replacement = collect_generated_asset_sheet_visible_neighbor_color(
pixels,
width,
height,
x,
y,
&background_mask,
&visible_mask,
)
.unwrap_or((
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
));
let next_red = replacement.0.max(pixels[offset]);
let next_blue = replacement.2.max(pixels[offset + 2]);
let next_green = replacement
.1
.min(next_red.max(next_blue).saturating_add(12));
if next_red != pixels[offset]
|| next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2]
{
pixels[offset] = next_red;
pixels[offset + 1] = next_green;
pixels[offset + 2] = next_blue;
changed = true;
changed_this_round = true;
}
background_mask[pixel_index] = 1;
}
}
if !changed_this_round {
break;
}
}
}
changed
}
fn resolve_generated_asset_sheet_view_edge_cleanup_width(width: usize, height: usize) -> usize {
let min_side = width.min(height).max(1);
(min_side / 24).clamp(4, 12).min(min_side)
}
fn collect_generated_asset_sheet_visible_neighbor_color(
pixels: &[u8],
width: usize,
height: usize,
x: usize,
y: usize,
background_mask: &[u8],
visible_mask: &[u8],
) -> Option<(u8, u8, u8)> {
let mut total_weight = 0.0f32;
let mut total_red = 0.0f32;
let mut total_green = 0.0f32;
let mut total_blue = 0.0f32;
for offset_y in -3i32..=3 {
for offset_x in -3i32..=3 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 {
continue;
}
let next_pixel_index = next_y as usize * width + next_x as usize;
if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 {
continue;
}
let next_offset = next_pixel_index * 4;
let next_alpha = pixels[next_offset + 3];
if next_alpha < 96 {
continue;
}
let pixel = [
pixels[next_offset],
pixels[next_offset + 1],
pixels[next_offset + 2],
next_alpha,
];
if is_generated_asset_sheet_green_contaminated_edge_pixel(pixel)
|| is_generated_asset_sheet_soft_edge_pixel(pixel)
{
continue;
}
let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs();
let weight = (next_alpha as f32 / 255.0)
* if distance <= 1 {
2.0
} else if distance <= 3 {
1.2
} else {
0.7
};
total_weight += weight;
total_red += pixels[next_offset] as f32 * weight;
total_green += pixels[next_offset + 1] as f32 * weight;
total_blue += pixels[next_offset + 2] as f32 * weight;
}
}
if total_weight <= 0.0 {
return None;
}
Some((
(total_red / total_weight).round() as u8,
(total_green / total_weight).round() as u8,
(total_blue / total_weight).round() as u8,
))
}

View File

@@ -0,0 +1,2 @@
pub mod adapter;
pub mod helpers;

View File

@@ -0,0 +1,160 @@
use std::collections::BTreeMap;
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
use super::helpers::{
GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat,
GeneratedImageAssetStoragePaths, build_generated_image_asset_metadata,
build_generated_image_asset_storage_paths, merge_generated_image_asset_metadata,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetAdapterBoundary;
impl GeneratedImageAssetAdapterBoundary {
pub const BILLING_BOUNDARY_COMMENT: &'static str = "generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence.";
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetAdapter;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetPersistInput {
pub prefix: LegacyAssetPrefix,
pub path_segments: Vec<String>,
pub file_stem: String,
pub image: GeneratedImageAssetDataUrl,
pub access: OssObjectAccess,
pub metadata: GeneratedImageAssetAdapterMetadata,
pub extra_metadata: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GeneratedImageAssetAdapterMetadata {
pub asset_kind: Option<String>,
pub owner_user_id: Option<String>,
pub entity_kind: Option<String>,
pub entity_id: Option<String>,
pub slot: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetPreparedPut {
pub request: OssPutObjectRequest,
pub storage_paths: GeneratedImageAssetStoragePaths,
pub format: GeneratedImageAssetImageFormat,
}
impl GeneratedImageAssetAdapter {
pub fn prepare_put_object(
input: GeneratedImageAssetPersistInput,
) -> Result<GeneratedImageAssetPreparedPut, GeneratedImageAssetHelperError> {
let file_name = format!(
"{}.{}",
input.file_stem.trim(),
input.image.format.extension
);
let storage_paths = build_generated_image_asset_storage_paths(
input.prefix,
&input.path_segments,
file_name.as_str(),
)?;
let metadata = merge_generated_image_asset_metadata(
build_generated_image_asset_metadata(input.metadata.into()),
input.extra_metadata,
);
let format = input.image.format.clone();
Ok(GeneratedImageAssetPreparedPut {
request: OssPutObjectRequest {
prefix: input.prefix,
path_segments: input.path_segments,
file_name,
content_type: Some(format.mime_type.clone()),
access: input.access,
metadata,
body: input.image.bytes,
},
storage_paths,
format,
})
}
}
impl From<GeneratedImageAssetAdapterMetadata> for super::helpers::GeneratedImageAssetMetadataInput {
fn from(value: GeneratedImageAssetAdapterMetadata) -> Self {
Self {
asset_kind: value.asset_kind,
owner_user_id: value.owner_user_id,
entity_kind: value.entity_kind,
entity_id: value.entity_id,
slot: value.slot,
provider: value.provider,
task_id: value.task_id,
}
}
}
#[cfg(test)]
mod generated_image_assets_adapter_tests {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use super::*;
use crate::generated_assets::helpers::decode_generated_image_asset_data_url;
#[test]
fn generated_image_assets_adapter_prepares_put_without_billing_side_effects() {
let image = decode_generated_image_asset_data_url(&format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(b"png bytes")
))
.expect("image should decode");
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::SquareHoleAssets,
path_segments: vec!["work/1".to_string(), "cover".to_string()],
file_stem: "image".to_string(),
image,
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some("square-hole-cover".to_string()),
owner_user_id: Some("user-1".to_string()),
entity_kind: Some("work".to_string()),
entity_id: Some("work-1".to_string()),
slot: Some("cover".to_string()),
provider: Some("dashscope".to_string()),
task_id: Some("task-1".to_string()),
},
extra_metadata: BTreeMap::from([("caller".to_string(), "unit-test".to_string())]),
})
.expect("put object should be prepared");
assert_eq!(
GeneratedImageAssetAdapterBoundary::BILLING_BOUNDARY_COMMENT,
"generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence."
);
assert_eq!(prepared.request.prefix, LegacyAssetPrefix::SquareHoleAssets);
assert_eq!(prepared.request.file_name, "image.png");
assert_eq!(prepared.request.content_type, Some("image/png".to_string()));
assert_eq!(prepared.request.body, b"png bytes");
assert_eq!(
prepared.storage_paths.object_key,
"generated-square-hole-assets/work-1/cover/image.png"
);
assert_eq!(
prepared.storage_paths.legacy_public_path,
"/generated-square-hole-assets/work-1/cover/image.png"
);
assert_eq!(
prepared.request.metadata.get("asset_kind"),
Some(&"square-hole-cover".to_string())
);
assert_eq!(
prepared.request.metadata.get("caller"),
Some(&"unit-test".to_string())
);
}
}

View File

@@ -0,0 +1,306 @@
use std::collections::BTreeMap;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use platform_oss::LegacyAssetPrefix;
const DEFAULT_IMAGE_MIME: &str = "image/jpeg";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetImageFormat {
pub mime_type: String,
pub extension: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetDataUrl {
pub format: GeneratedImageAssetImageFormat,
pub bytes: Vec<u8>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GeneratedImageAssetMetadataInput {
pub asset_kind: Option<String>,
pub owner_user_id: Option<String>,
pub entity_kind: Option<String>,
pub entity_id: Option<String>,
pub slot: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetStoragePaths {
pub object_key: String,
pub legacy_public_path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GeneratedImageAssetHelperError {
InvalidDataUrl,
UnsupportedEncoding,
DecodeBase64(String),
InvalidFileName,
}
pub fn normalize_generated_image_asset_mime(
raw_content_type: impl AsRef<str>,
) -> GeneratedImageAssetImageFormat {
let mime_type = raw_content_type
.as_ref()
.split(';')
.next()
.map(str::trim)
.unwrap_or(DEFAULT_IMAGE_MIME)
.to_ascii_lowercase();
match mime_type.as_str() {
"image/png" => image_format("image/png", "png"),
"image/webp" => image_format("image/webp", "webp"),
"image/gif" => image_format("image/gif", "gif"),
"image/jpeg" | "image/jpg" | "application/octet-stream" | "" => {
image_format(DEFAULT_IMAGE_MIME, "jpg")
}
_ => image_format(DEFAULT_IMAGE_MIME, "jpg"),
}
}
pub fn decode_generated_image_asset_data_url(
raw_data_url: &str,
) -> Result<GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError> {
let (metadata, encoded) = raw_data_url
.trim()
.split_once(',')
.ok_or(GeneratedImageAssetHelperError::InvalidDataUrl)?;
let metadata = metadata.trim();
if !metadata.to_ascii_lowercase().starts_with("data:") {
return Err(GeneratedImageAssetHelperError::InvalidDataUrl);
}
let header = &metadata["data:".len()..];
let mut parts = header
.split(';')
.map(str::trim)
.filter(|part| !part.is_empty());
let mime_type = parts.next().unwrap_or(DEFAULT_IMAGE_MIME);
let is_base64 = parts.any(|part| part.eq_ignore_ascii_case("base64"));
if !is_base64 {
return Err(GeneratedImageAssetHelperError::UnsupportedEncoding);
}
let bytes = BASE64_STANDARD
.decode(encoded.trim())
.map_err(|error| GeneratedImageAssetHelperError::DecodeBase64(error.to_string()))?;
Ok(GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(mime_type),
bytes,
})
}
pub fn build_generated_image_asset_storage_paths(
prefix: LegacyAssetPrefix,
path_segments: &[String],
file_name: &str,
) -> Result<GeneratedImageAssetStoragePaths, GeneratedImageAssetHelperError> {
let file_name = sanitize_generated_image_asset_file_name(file_name)?;
let mut parts = vec![prefix.as_str().to_string()];
parts.extend(
path_segments
.iter()
.map(|segment| sanitize_generated_image_asset_path_segment(segment))
.filter(|segment| !segment.is_empty()),
);
parts.push(file_name);
let object_key = parts.join("/");
Ok(GeneratedImageAssetStoragePaths {
legacy_public_path: format!("/{object_key}"),
object_key,
})
}
pub fn build_generated_image_asset_metadata(
input: GeneratedImageAssetMetadataInput,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::new();
insert_optional_metadata(&mut metadata, "asset_kind", input.asset_kind);
insert_optional_metadata(&mut metadata, "owner_user_id", input.owner_user_id);
insert_optional_metadata(&mut metadata, "entity_kind", input.entity_kind);
insert_optional_metadata(&mut metadata, "entity_id", input.entity_id);
insert_optional_metadata(&mut metadata, "slot", input.slot);
insert_optional_metadata(&mut metadata, "provider", input.provider);
insert_optional_metadata(&mut metadata, "task_id", input.task_id);
metadata
}
pub fn merge_generated_image_asset_metadata(
base: BTreeMap<String, String>,
overlay: BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut merged = BTreeMap::new();
for (key, value) in base.into_iter().chain(overlay) {
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
continue;
}
merged.insert(key.to_string(), value.to_string());
}
merged
}
fn image_format(mime_type: &str, extension: &str) -> GeneratedImageAssetImageFormat {
GeneratedImageAssetImageFormat {
mime_type: mime_type.to_string(),
extension: extension.to_string(),
}
}
fn insert_optional_metadata(
metadata: &mut BTreeMap<String, String>,
key: &str,
value: Option<String>,
) {
if let Some(value) = value {
let value = value.trim();
if !value.is_empty() {
metadata.insert(key.to_string(), value.to_string());
}
}
}
fn sanitize_generated_image_asset_path_segment(raw: &str) -> String {
raw.trim()
.trim_matches('/')
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
ch if ch.is_control() => '-',
ch => ch,
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
fn sanitize_generated_image_asset_file_name(
raw: &str,
) -> Result<String, GeneratedImageAssetHelperError> {
let sanitized = sanitize_generated_image_asset_path_segment(raw);
if sanitized.is_empty() || sanitized == "." || sanitized == ".." || sanitized.contains('/') {
return Err(GeneratedImageAssetHelperError::InvalidFileName);
}
Ok(sanitized)
}
#[cfg(test)]
mod generated_image_assets_tests {
use std::collections::BTreeMap;
use super::*;
#[test]
fn generated_image_assets_normalize_mime_and_extension() {
assert_eq!(
normalize_generated_image_asset_mime(" image/PNG; charset=utf-8 "),
GeneratedImageAssetImageFormat {
mime_type: "image/png".to_string(),
extension: "png".to_string(),
}
);
assert_eq!(
normalize_generated_image_asset_mime("image/jpg"),
GeneratedImageAssetImageFormat {
mime_type: "image/jpeg".to_string(),
extension: "jpg".to_string(),
}
);
assert_eq!(
normalize_generated_image_asset_mime("text/plain"),
GeneratedImageAssetImageFormat {
mime_type: "image/jpeg".to_string(),
extension: "jpg".to_string(),
}
);
}
#[test]
fn generated_image_assets_decode_data_url_base64() {
let decoded = decode_generated_image_asset_data_url("data:image/webp;base64,aGVsbG8=")
.expect("data url should decode");
assert_eq!(
decoded.format,
GeneratedImageAssetImageFormat {
mime_type: "image/webp".to_string(),
extension: "webp".to_string(),
}
);
assert_eq!(decoded.bytes, b"hello");
}
#[test]
fn generated_image_assets_reject_non_base64_data_url() {
assert_eq!(
decode_generated_image_asset_data_url("data:image/png,hello").unwrap_err(),
GeneratedImageAssetHelperError::UnsupportedEncoding
);
}
#[test]
fn generated_image_assets_build_object_key_and_legacy_path() {
let paths = build_generated_image_asset_storage_paths(
LegacyAssetPrefix::BigFishAssets,
&[" world/001 ".to_string(), "slot:cover".to_string()],
" image.png ",
)
.expect("paths should build");
assert_eq!(
paths.object_key,
"generated-big-fish-assets/world-001/slot-cover/image.png"
);
assert_eq!(
paths.legacy_public_path,
"/generated-big-fish-assets/world-001/slot-cover/image.png"
);
}
#[test]
fn generated_image_assets_merge_metadata_trims_and_overlay_wins() {
let base = BTreeMap::from([
("asset_kind".to_string(), " old ".to_string()),
("empty".to_string(), " ".to_string()),
]);
let overlay = BTreeMap::from([
("asset_kind".to_string(), "cover".to_string()),
(" task_id ".to_string(), " task-1 ".to_string()),
]);
assert_eq!(
merge_generated_image_asset_metadata(base, overlay),
BTreeMap::from([
("asset_kind".to_string(), "cover".to_string()),
("task_id".to_string(), "task-1".to_string()),
])
);
}
#[test]
fn generated_image_assets_build_metadata_omits_blank_values() {
let metadata = build_generated_image_asset_metadata(GeneratedImageAssetMetadataInput {
asset_kind: Some(" scene ".to_string()),
owner_user_id: Some("".to_string()),
entity_kind: Some("world".to_string()),
entity_id: None,
slot: Some(" cover ".to_string()),
provider: Some("dashscope".to_string()),
task_id: Some(" task-1 ".to_string()),
});
assert_eq!(metadata.get("asset_kind"), Some(&"scene".to_string()));
assert_eq!(metadata.get("owner_user_id"), None);
assert_eq!(metadata.get("slot"), Some(&"cover".to_string()));
assert_eq!(metadata.get("task_id"), Some(&"task-1".to_string()));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
use super::constants::{VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER};
#[derive(Clone, Debug)]
pub struct PlatformImageFailureAudit {
pub provider: &'static str,
pub endpoint: String,
pub operation: String,
pub failure_stage: &'static str,
pub status_code: Option<u16>,
pub status_class: Option<&'static str>,
pub timeout: bool,
pub retryable: bool,
pub error_message: String,
pub error_source: Option<String>,
pub raw_excerpt: Option<String>,
pub latency_ms: Option<u64>,
pub prompt_chars: Option<usize>,
pub reference_image_count: Option<usize>,
pub image_model: Option<&'static str>,
}
pub(crate) fn build_failure_audit(
request_url: &str,
operation: &str,
failure_stage: &'static str,
status_code: Option<u16>,
status_class: Option<&'static str>,
timeout: bool,
connect: bool,
error_message: &str,
error_source: Option<String>,
raw_excerpt: Option<String>,
latency_ms: Option<u64>,
prompt_chars: Option<usize>,
reference_image_count: Option<usize>,
) -> PlatformImageFailureAudit {
PlatformImageFailureAudit {
provider: VECTOR_ENGINE_PROVIDER,
endpoint: request_url.to_string(),
operation: operation.to_string(),
failure_stage,
status_code,
status_class,
timeout,
retryable: is_retryable_external_api_failure(status_code, timeout, connect),
error_message: error_message.to_string(),
error_source,
raw_excerpt,
latency_ms,
prompt_chars,
reference_image_count,
image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL),
}
}
pub(crate) fn is_retryable_external_api_failure(
status_code: Option<u16>,
timeout: bool,
connect: bool,
) -> bool {
timeout
|| connect
|| status_code.is_some_and(|status| status == 429 || status == 408 || status >= 500)
}

View File

@@ -0,0 +1,245 @@
use reqwest::header;
use super::{
constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
error::PlatformImageError,
image_source::resolve_reference_images,
request::{
build_prompt_with_negative, build_vector_engine_image_request_body, normalize_image_size,
vector_engine_images_edit_url, vector_engine_images_generation_url,
},
response::handle_vector_engine_response,
transport::map_reqwest_error,
types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings},
};
pub async fn create_vector_engine_image_generation(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[String],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
if !reference_images.is_empty() {
let resolved_references =
resolve_reference_images(http_client, reference_images, failure_context).await?;
return create_vector_engine_image_edit_with_references(
http_client,
settings,
prompt,
negative_prompt,
size,
candidate_count,
resolved_references.as_slice(),
failure_context,
)
.await;
}
let request_url = vector_engine_images_generation_url(settings);
let normalized_size = normalize_image_size(size);
let request_body = build_vector_engine_image_request_body(
prompt,
negative_prompt,
normalized_size.as_str(),
candidate_count,
reference_images,
);
let started_at = std::time::Instant::now();
let response = match http_client
.post(request_url.as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
.await
{
Ok(response) => response,
Err(error) => {
return Err(map_reqwest_error(
format!("{failure_context}:创建图片生成任务失败").as_str(),
request_url.as_str(),
"request_send",
error,
started_at.elapsed().as_millis() as u64,
Some(prompt.chars().count()),
Some(reference_images.len()),
));
}
};
let response_status = response.status();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
status = response_status.as_u16(),
prompt_chars = prompt.chars().count(),
size = %normalized_size,
reference_image_count = reference_images.len(),
elapsed_ms = started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片生成 HTTP 返回"
);
let response_text = match response.text().await {
Ok(response_text) => response_text,
Err(error) => {
return Err(map_reqwest_error(
format!("{failure_context}:读取图片生成响应失败").as_str(),
request_url.as_str(),
"response_body",
error,
started_at.elapsed().as_millis() as u64,
Some(prompt.chars().count()),
Some(reference_images.len()),
));
}
};
handle_vector_engine_response(
http_client,
request_url.as_str(),
response_status.as_u16(),
response_text.as_str(),
failure_context,
started_at.elapsed().as_millis() as u64,
Some(prompt.chars().count()),
Some(reference_images.len()),
candidate_count,
"vector-engine",
)
.await
}
pub async fn create_vector_engine_image_edit(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
reference_image: &ReferenceImage,
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
create_vector_engine_image_edit_with_references(
http_client,
settings,
prompt,
negative_prompt,
size,
1,
std::slice::from_ref(reference_image),
failure_context,
)
.await
}
pub async fn create_vector_engine_image_edit_with_references(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[ReferenceImage],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
if reference_images.is_empty() {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"),
});
}
let request_url = vector_engine_images_edit_url(settings);
let normalized_size = normalize_image_size(size);
let mut form = reqwest::multipart::Form::new()
.text("model", GPT_IMAGE_2_MODEL.to_string())
.text(
"prompt",
build_prompt_with_negative(prompt, negative_prompt),
)
.text("n", candidate_count.clamp(1, 4).to_string())
.text("size", normalized_size.clone());
for reference_image in reference_images.iter().take(5) {
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
.file_name(reference_image.file_name.clone())
.mime_str(reference_image.mime_type.as_str())
.map_err(|error| PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{failure_context}:构造参考图失败:{error}"),
})?;
form = form.part("image", image_part);
}
let reference_image_count = reference_images.iter().take(5).count();
let started_at = std::time::Instant::now();
let response = match http_client
.post(request_url.as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.multipart(form)
.send()
.await
{
Ok(response) => response,
Err(error) => {
return Err(map_reqwest_error(
format!("{failure_context}:创建图片编辑任务失败").as_str(),
request_url.as_str(),
"request_send",
error,
started_at.elapsed().as_millis() as u64,
Some(prompt.chars().count()),
Some(reference_image_count),
));
}
};
let response_status = response.status();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
status = response_status.as_u16(),
prompt_chars = prompt.chars().count(),
size = %normalized_size,
reference_image_count,
elapsed_ms = started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片编辑 HTTP 返回"
);
let response_text = match response.text().await {
Ok(response_text) => response_text,
Err(error) => {
return Err(map_reqwest_error(
format!("{failure_context}:读取图片编辑响应失败").as_str(),
request_url.as_str(),
"response_body",
error,
started_at.elapsed().as_millis() as u64,
Some(prompt.chars().count()),
Some(reference_image_count),
));
}
};
handle_vector_engine_response(
http_client,
request_url.as_str(),
response_status.as_u16(),
response_text.as_str(),
failure_context,
started_at.elapsed().as_millis() as u64,
Some(prompt.chars().count()),
Some(reference_image_count),
candidate_count,
"vector-engine-edit",
)
.await
}

View File

@@ -0,0 +1,3 @@
pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";

View File

@@ -0,0 +1,114 @@
use std::{error::Error, fmt};
use super::{audit::PlatformImageFailureAudit, util::is_timeout_message};
#[derive(Clone, Debug)]
pub enum PlatformImageError {
InvalidConfig {
provider: &'static str,
message: String,
},
InvalidRequest {
provider: &'static str,
message: String,
},
Request {
provider: &'static str,
message: String,
endpoint: Option<String>,
timeout: bool,
connect: bool,
request: bool,
body: bool,
status_code: Option<u16>,
source: Option<String>,
audit: Option<PlatformImageFailureAudit>,
},
Upstream {
provider: &'static str,
message: String,
upstream_status: u16,
raw_excerpt: String,
audit: Option<PlatformImageFailureAudit>,
},
ResponseParse {
provider: &'static str,
message: String,
raw_excerpt: String,
audit: Option<PlatformImageFailureAudit>,
},
MissingImage {
provider: &'static str,
message: String,
audit: Option<PlatformImageFailureAudit>,
},
}
impl PlatformImageError {
pub fn provider(&self) -> &'static str {
match self {
Self::InvalidConfig { provider, .. }
| Self::InvalidRequest { provider, .. }
| Self::Request { provider, .. }
| Self::Upstream { provider, .. }
| Self::ResponseParse { provider, .. }
| Self::MissingImage { provider, .. } => provider,
}
}
pub fn message(&self) -> &str {
match self {
Self::InvalidConfig { message, .. }
| Self::InvalidRequest { message, .. }
| Self::Request { message, .. }
| Self::Upstream { message, .. }
| Self::ResponseParse { message, .. }
| Self::MissingImage { message, .. } => message,
}
}
pub fn audit(&self) -> Option<&PlatformImageFailureAudit> {
match self {
Self::Request { audit, .. }
| Self::Upstream { audit, .. }
| Self::ResponseParse { audit, .. }
| Self::MissingImage { audit, .. } => audit.as_ref(),
Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => None,
}
}
pub fn status_hint(&self) -> PlatformImageStatusHint {
match self {
Self::InvalidConfig { .. } => PlatformImageStatusHint::ServiceUnavailable,
Self::InvalidRequest { .. } => PlatformImageStatusHint::BadRequest,
Self::Request { timeout, .. } if *timeout => PlatformImageStatusHint::GatewayTimeout,
Self::Upstream {
message,
raw_excerpt,
..
} if is_timeout_message(message) || is_timeout_message(raw_excerpt) => {
PlatformImageStatusHint::GatewayTimeout
}
Self::Request { .. }
| Self::Upstream { .. }
| Self::ResponseParse { .. }
| Self::MissingImage { .. } => PlatformImageStatusHint::BadGateway,
}
}
}
impl fmt::Display for PlatformImageError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.message())
}
}
impl Error for PlatformImageError {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlatformImageStatusHint {
BadRequest,
ServiceUnavailable,
BadGateway,
GatewayTimeout,
}

View File

@@ -0,0 +1,248 @@
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use reqwest::header;
use super::{
constants::VECTOR_ENGINE_PROVIDER,
error::PlatformImageError,
types::{DownloadedImage, GeneratedImages, ReferenceImage},
};
pub async fn download_remote_image(
http_client: &reqwest::Client,
image_url: &str,
) -> Result<DownloadedImage, PlatformImageError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
map_simple_request_error(
format!("下载生成图片失败:{error}"),
Some(image_url.to_string()),
)
})?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let body = response.bytes().await.map_err(|error| {
map_simple_request_error(
format!("读取生成图片内容失败:{error}"),
Some(image_url.to_string()),
)
})?;
if !status.is_success() {
return Err(PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
message: "下载生成图片失败".to_string(),
endpoint: Some(image_url.to_string()),
timeout: false,
connect: false,
request: false,
body: false,
status_code: Some(status.as_u16()),
source: None,
audit: None,
});
}
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
Ok(DownloadedImage {
extension: mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: body.to_vec(),
})
}
pub(crate) async fn download_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
image_urls: Vec<String>,
candidate_count: u32,
) -> Result<GeneratedImages, PlatformImageError> {
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
{
images.push(download_remote_image(http_client, image_url.as_str()).await?);
}
Ok(GeneratedImages {
task_id,
actual_prompt: None,
images,
})
}
pub(crate) async fn resolve_reference_images(
http_client: &reqwest::Client,
reference_images: &[String],
failure_context: &str,
) -> Result<Vec<ReferenceImage>, PlatformImageError> {
let mut resolved = Vec::new();
for (index, source) in reference_images.iter().take(5).enumerate() {
let source = source.trim();
if source.is_empty() {
continue;
}
if let Some(reference_image) = parse_reference_image_data_url(source, index)? {
resolved.push(reference_image);
continue;
}
if source.starts_with("http://") || source.starts_with("https://") {
let downloaded = download_remote_image(http_client, source)
.await
.map_err(|error| PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{failure_context}:下载参考图失败:{error}"),
endpoint: Some(source.to_string()),
timeout: false,
connect: false,
request: false,
body: false,
status_code: None,
source: None,
audit: None,
})?;
resolved.push(ReferenceImage {
bytes: downloaded.bytes,
mime_type: downloaded.mime_type.clone(),
file_name: format!(
"reference-{index}.{}",
mime_to_extension(downloaded.mime_type.as_str())
),
});
continue;
}
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"),
});
}
if resolved.is_empty() {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{failure_context}:图片编辑需要至少一张参考图。"),
});
}
Ok(resolved)
}
pub(crate) fn parse_reference_image_data_url(
source: &str,
index: usize,
) -> Result<Option<ReferenceImage>, PlatformImageError> {
let Some(body) = source.strip_prefix("data:") else {
return Ok(None);
};
let Some((mime_type, data)) = body.split_once(";base64,") else {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: "参考图 Data URL 必须是 base64 图片。".to_string(),
});
};
if !mime_type.starts_with("image/") {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: "参考图 Data URL 必须是图片类型。".to_string(),
});
}
let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| {
PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("参考图 Data URL 解码失败:{error}"),
}
})?;
let mime_type = normalize_downloaded_image_mime_type(mime_type);
Ok(Some(ReferenceImage {
bytes,
file_name: format!(
"reference-{index}.{}",
mime_to_extension(mime_type.as_str())
),
mime_type,
}))
}
pub(crate) fn images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> GeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
.filter_map(|raw| decode_generated_image_base64(raw.as_str()))
.collect();
GeneratedImages {
task_id,
actual_prompt: None,
images,
}
}
pub(crate) fn decode_generated_image_base64(raw: &str) -> Option<DownloadedImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_image_mime_type(bytes.as_slice());
Some(DownloadedImage {
extension: mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
pub(crate) fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/jpeg");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/jpeg".to_string(),
}
}
pub(crate) fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
pub(crate) fn infer_image_mime_type(bytes: &[u8]) -> String {
if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
return "image/png".to_string();
}
if bytes.starts_with(b"\xFF\xD8\xFF") {
return "image/jpeg".to_string();
}
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
return "image/webp".to_string();
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return "image/gif".to_string();
}
"image/png".to_string()
}
fn map_simple_request_error(message: String, endpoint: Option<String>) -> PlatformImageError {
PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
message,
endpoint,
timeout: false,
connect: false,
request: true,
body: false,
status_code: None,
source: None,
audit: None,
}
}

View File

@@ -0,0 +1,26 @@
mod audit;
mod client;
mod constants;
mod error;
mod image_source;
mod payload;
mod request;
mod response;
mod transport;
mod types;
mod util;
pub use audit::PlatformImageFailureAudit;
pub use client::{
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
create_vector_engine_image_generation,
};
pub use constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER};
pub use error::{PlatformImageError, PlatformImageStatusHint};
pub use image_source::download_remote_image;
pub use request::{
build_vector_engine_image_request_body, normalize_image_size, vector_engine_images_edit_url,
vector_engine_images_generation_url,
};
pub use transport::build_vector_engine_image_http_client;
pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings};

View File

@@ -0,0 +1,128 @@
use serde_json::Value;
use super::{
constants::VECTOR_ENGINE_PROVIDER,
error::PlatformImageError,
util::{ParsedJsonPayload, truncate_raw},
};
pub(super) fn parse_json_payload(
raw_text: &str,
failure_context: &str,
) -> Result<ParsedJsonPayload, PlatformImageError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| PlatformImageError::ResponseParse {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{failure_context}:解析响应失败:{error}"),
raw_excerpt: truncate_raw(raw_text),
audit: None,
})
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
for entry in entries {
collect_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key {
match nested_value {
Value::String(text) => {
let text = text.trim();
if !text.is_empty() {
results.push(text.to_string());
continue;
}
}
Value::Array(entries) => {
for entry in entries {
if let Some(text) = entry
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
}
}
}
_ => {}
}
}
collect_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
pub(super) fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_strings_by_key(value, target_key, &mut results);
results.into_iter().next()
}
pub(super) fn extract_generation_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "id")
.or_else(|| find_first_string_by_key(payload, "created"))
.or_else(|| find_first_string_by_key(payload, "request_id"))
}
pub(super) fn extract_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_strings_by_key(payload, "url", &mut urls);
collect_strings_by_key(payload, "image", &mut urls);
collect_strings_by_key(payload, "image_url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
pub(super) fn extract_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_strings_by_key(payload, "b64_json", &mut values);
values
}
pub(super) fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
}
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
for pointer in [
"/error/message",
"/message",
"/output/message",
"/data/message",
] {
if let Some(message) = parsed
.pointer(pointer)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
}
for pointer in ["/error/code", "/code", "/output/code", "/data/code"] {
if let Some(code) = parsed
.pointer(pointer)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
}
}
raw_text.trim().to_string()
}

View File

@@ -0,0 +1,69 @@
use serde_json::{Map, Value, json};
use super::{constants::GPT_IMAGE_2_MODEL, types::VectorEngineImageSettings};
pub fn build_vector_engine_image_request_body(
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
_reference_images: &[String],
) -> Value {
let body = Map::from_iter([
(
"model".to_string(),
Value::String(GPT_IMAGE_2_MODEL.to_string()),
),
(
"prompt".to_string(),
Value::String(build_prompt_with_negative(prompt, negative_prompt)),
),
("n".to_string(), json!(candidate_count.clamp(1, 4))),
(
"size".to_string(),
Value::String(normalize_image_size(size)),
),
]);
Value::Object(body)
}
pub fn normalize_image_size(size: &str) -> String {
match size.trim() {
"1024*1024" | "1024x1024" | "1:1" => "1024x1024",
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152"
| "2k" => "1536x1024",
"1024*1536" | "1024x1536" | "9:16" => "1024x1536",
value if !value.is_empty() => value,
_ => "1024x1024",
}
.to_string()
}
pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/generations", settings.base_url)
} else {
format!("{}/v1/images/generations", settings.base_url)
}
}
pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/edits", settings.base_url)
} else {
format!("{}/v1/images/edits", settings.base_url)
}
}
pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String {
let prompt = prompt.trim();
let Some(negative_prompt) = negative_prompt
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return prompt.to_string();
};
format!("{prompt}\n避免:{negative_prompt}")
}

View File

@@ -0,0 +1,180 @@
use super::{
audit::build_failure_audit,
constants::VECTOR_ENGINE_PROVIDER,
error::PlatformImageError,
image_source::{download_images_from_urls, images_from_base64},
payload::{
extract_b64_images, extract_generation_id, extract_image_urls, find_first_string_by_key,
parse_api_error_message, parse_json_payload,
},
types::GeneratedImages,
util::{current_utc_micros, is_timeout_message, truncate_raw},
};
pub(crate) async fn handle_vector_engine_response(
http_client: &reqwest::Client,
request_url: &str,
response_status: u16,
response_text: &str,
failure_context: &str,
latency_ms: u64,
prompt_chars: Option<usize>,
reference_image_count: Option<usize>,
candidate_count: u32,
task_prefix: &str,
) -> Result<GeneratedImages, PlatformImageError> {
if !(200..=299).contains(&response_status) {
let message = parse_api_error_message(response_text, failure_context);
let raw_excerpt = truncate_raw(response_text);
let audit = build_failure_audit(
request_url,
failure_context,
"upstream_status",
Some(response_status),
None,
false,
false,
message.as_str(),
None,
Some(raw_excerpt.clone()),
Some(latency_ms),
prompt_chars,
reference_image_count,
);
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
upstream_status = response_status,
timeout = is_timeout_message(message.as_str()) || is_timeout_message(raw_excerpt.as_str()),
retryable = audit.retryable,
message = %message,
raw_excerpt = %raw_excerpt,
"VectorEngine 图片生成上游错误"
);
return Err(PlatformImageError::Upstream {
provider: VECTOR_ENGINE_PROVIDER,
message,
upstream_status: response_status,
raw_excerpt,
audit: Some(audit),
});
}
let response_json = match parse_json_payload(response_text, failure_context) {
Ok(response_json) => response_json,
Err(error) => {
let audit = build_failure_audit(
request_url,
failure_context,
"response_parse",
Some(response_status),
None,
false,
false,
error.message(),
None,
Some(truncate_raw(response_text)),
Some(latency_ms),
prompt_chars,
reference_image_count,
);
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
status = response_status,
raw_excerpt = %truncate_raw(response_text),
message = %error.message(),
"VectorEngine 图片响应解析失败"
);
return Err(error.with_audit(audit));
}
};
let task_id = extract_generation_id(&response_json.payload)
.unwrap_or_else(|| format!("{task_prefix}-{}", current_utc_micros()));
let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt")
.or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt"));
let image_urls = extract_image_urls(&response_json.payload);
if !image_urls.is_empty() {
let download_started_at = std::time::Instant::now();
let mut generated = match download_images_from_urls(
http_client,
task_id,
image_urls,
candidate_count,
)
.await
{
Ok(generated) => generated,
Err(error) => {
let audit = build_failure_audit(
request_url,
failure_context,
"image_download",
Some(response_status),
Some("5xx"),
false,
false,
error.message(),
None,
None,
Some(download_started_at.elapsed().as_millis() as u64),
prompt_chars,
reference_image_count,
);
return Err(error.with_audit(audit));
}
};
generated.actual_prompt = actual_prompt;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
image_count = generated.images.len(),
elapsed_ms = download_started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片下载完成"
);
return Ok(generated);
}
let b64_images = extract_b64_images(&response_json.payload);
if !b64_images.is_empty() {
let mut generated = images_from_base64(task_id, b64_images, candidate_count);
generated.actual_prompt = actual_prompt;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
image_count = generated.images.len(),
failure_context,
"VectorEngine 图片 base64 解码完成"
);
return Ok(generated);
}
let message = format!("{failure_context}VectorEngine 未返回图片地址");
let audit = build_failure_audit(
request_url,
failure_context,
"missing_image",
Some(response_status),
None,
false,
false,
message.as_str(),
None,
Some(truncate_raw(response_text)),
Some(latency_ms),
prompt_chars,
reference_image_count,
);
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
status = response_status,
raw_excerpt = %truncate_raw(response_text),
"VectorEngine 图片响应未返回图片"
);
Err(PlatformImageError::MissingImage {
provider: VECTOR_ENGINE_PROVIDER,
message,
audit: Some(audit),
})
}

View File

@@ -0,0 +1,177 @@
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use serde_json::json;
#[test]
fn request_body_normalizes_size_prompt_and_candidate_count() {
let body = build_vector_engine_image_request_body(
" 风雨夜里的街道 ",
Some(" 低清,水印 "),
" 1:1 ",
10,
&["data:image/png;base64,AAAA".to_string()],
);
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], "1024x1024");
assert_eq!(body["n"], 4);
assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印");
assert!(body.get("image").is_none());
}
#[test]
fn provider_urls_normalize_root_and_v1_base_urls() {
let root_settings = VectorEngineImageSettings {
base_url: "https://vector.example".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
};
let v1_settings = VectorEngineImageSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
};
assert_eq!(
vector_engine_images_generation_url(&root_settings),
"https://vector.example/v1/images/generations"
);
assert_eq!(
vector_engine_images_generation_url(&v1_settings),
"https://vector.example/v1/images/generations"
);
assert_eq!(
vector_engine_images_edit_url(&root_settings),
"https://vector.example/v1/images/edits"
);
assert_eq!(
vector_engine_images_edit_url(&v1_settings),
"https://vector.example/v1/images/edits"
);
}
#[test]
fn data_url_and_base64_image_decoding_preserves_image_metadata() {
let data_url = format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")
);
let reference = parse_reference_image_data_url(&data_url, 2)
.expect("data url should parse")
.expect("image data url should be accepted");
assert_eq!(reference.file_name, "reference-2.png");
assert_eq!(reference.mime_type, "image/png");
assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest");
let image = decode_generated_image_base64(BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str())
.expect("base64 image should decode");
assert_eq!(image.extension, "png");
assert_eq!(image.mime_type, "image/png");
assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest");
}
#[test]
fn error_status_hints_and_audit_fields_are_structured() {
let audit = PlatformImageFailureAudit {
provider: VECTOR_ENGINE_PROVIDER,
endpoint: "https://vector.example/v1/images/generations".to_string(),
operation: "图片生成失败".to_string(),
failure_stage: "upstream_status",
status_code: Some(504),
status_class: Some("5xx"),
timeout: true,
retryable: true,
error_message: "上游超时".to_string(),
error_source: Some("read timeout".to_string()),
raw_excerpt: Some("{\"error\":\"timeout\"}".to_string()),
latency_ms: Some(987),
prompt_chars: Some(64),
reference_image_count: Some(2),
image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL),
};
let request_error = PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
message: "请求发送失败".to_string(),
endpoint: Some("https://vector.example/v1/images/generations".to_string()),
timeout: true,
connect: false,
request: true,
body: false,
status_code: None,
source: None,
audit: None,
};
let invalid_config = PlatformImageError::InvalidConfig {
provider: VECTOR_ENGINE_PROVIDER,
message: "缺少配置".to_string(),
};
let invalid_request = PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: "请求不合法".to_string(),
};
let upstream_timeout = PlatformImageError::Upstream {
provider: VECTOR_ENGINE_PROVIDER,
message: "upstream timeout".to_string(),
upstream_status: 502,
raw_excerpt: "deadline has elapsed".to_string(),
audit: Some(audit.clone()),
};
assert_eq!(invalid_config.status_hint(), PlatformImageStatusHint::ServiceUnavailable);
assert_eq!(invalid_request.status_hint(), PlatformImageStatusHint::BadRequest);
assert_eq!(request_error.status_hint(), PlatformImageStatusHint::GatewayTimeout);
assert_eq!(upstream_timeout.status_hint(), PlatformImageStatusHint::GatewayTimeout);
assert_eq!(
PlatformImageError::MissingImage {
provider: VECTOR_ENGINE_PROVIDER,
message: "缺图".to_string(),
audit: Some(audit.clone()),
}
.status_hint(),
PlatformImageStatusHint::BadGateway
);
let audit_ref = upstream_timeout.audit().expect("audit should be preserved");
assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER);
assert_eq!(audit_ref.endpoint, "https://vector.example/v1/images/generations");
assert_eq!(audit_ref.status_code, Some(504));
assert_eq!(audit_ref.status_class, Some("5xx"));
assert!(audit_ref.timeout);
assert!(audit_ref.retryable);
assert_eq!(audit_ref.reference_image_count, Some(2));
assert_eq!(audit_ref.image_model, Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL));
assert!(invalid_config.audit().is_none());
assert!(invalid_request.audit().is_none());
}
#[test]
fn extract_image_urls_and_b64_values_are_deduped() {
let payload = json!({
"data": [
{"image": "https://example.com/a.png"},
{"url": "https://example.com/a.png"},
{"image_url": "ftp://example.com/b.png"},
{"url": "https://example.com/b.png"}
],
"nested": {
"b64_json": ["YWJj", "ZGVm"]
}
});
assert_eq!(
extract_image_urls(&payload),
vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string()
]
);
assert_eq!(
extract_b64_images(&payload),
vec!["YWJj".to_string(), "ZGVm".to_string()]
);
}
}

View File

@@ -0,0 +1,78 @@
use std::{error::Error, time::Duration};
use super::{
audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError,
types::VectorEngineImageSettings,
};
pub fn build_vector_engine_image_http_client(
settings: &VectorEngineImageSettings,
) -> Result<reqwest::Client, PlatformImageError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms.max(1)))
.http1_only()
.build()
.map_err(|error| PlatformImageError::InvalidConfig {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"),
})
}
pub(super) fn map_reqwest_error(
context: &str,
request_url: &str,
failure_stage: &'static str,
error: reqwest::Error,
latency_ms: u64,
prompt_chars: Option<usize>,
reference_image_count: Option<usize>,
) -> PlatformImageError {
let is_timeout = error.is_timeout();
let is_connect = error.is_connect();
let source = error.source().map(ToString::to_string);
let message = format!("{context}{error}");
let audit = build_failure_audit(
request_url,
context,
failure_stage,
error.status().map(|status| status.as_u16()),
None,
is_timeout,
is_connect,
message.as_str(),
source.clone(),
None,
Some(latency_ms),
prompt_chars,
reference_image_count,
);
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
failure_stage,
timeout = is_timeout,
connect = is_connect,
request = error.is_request(),
body = error.is_body(),
status = error.status().map(|status| status.as_u16()).unwrap_or_default(),
source = %source.clone().unwrap_or_default(),
message = %message,
elapsed_ms = latency_ms,
prompt_chars,
reference_image_count,
"VectorEngine 图片请求发送失败"
);
PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
message,
endpoint: Some(request_url.to_string()),
timeout: is_timeout,
connect: is_connect,
request: error.is_request(),
body: error.is_body(),
status_code: error.status().map(|status| status.as_u16()),
source,
audit: Some(audit),
}
}

View File

@@ -0,0 +1,27 @@
#[derive(Clone, Debug)]
pub struct VectorEngineImageSettings {
pub base_url: String,
pub api_key: String,
pub request_timeout_ms: u64,
}
#[derive(Clone, Debug)]
pub struct GeneratedImages {
pub task_id: String,
pub actual_prompt: Option<String>,
pub images: Vec<DownloadedImage>,
}
#[derive(Clone, Debug)]
pub struct DownloadedImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub extension: String,
}
#[derive(Clone, Debug)]
pub struct ReferenceImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub file_name: String,
}

View File

@@ -0,0 +1,89 @@
use serde_json::Value;
use super::{audit::PlatformImageFailureAudit, error::PlatformImageError};
pub(crate) fn is_timeout_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("timed out")
|| lower.contains("timeout")
|| lower.contains("operation timed out")
|| lower.contains("deadline has elapsed")
}
pub(crate) fn truncate_raw(raw_text: &str) -> String {
raw_text.chars().take(800).collect()
}
pub(crate) fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
impl PlatformImageError {
pub(crate) fn with_audit(self, audit: PlatformImageFailureAudit) -> Self {
match self {
Self::Request {
provider,
message,
endpoint,
timeout,
connect,
request,
body,
status_code,
source,
..
} => Self::Request {
provider,
message,
endpoint,
timeout,
connect,
request,
body,
status_code,
source,
audit: Some(audit),
},
Self::Upstream {
provider,
message,
upstream_status,
raw_excerpt,
..
} => Self::Upstream {
provider,
message,
upstream_status,
raw_excerpt,
audit: Some(audit),
},
Self::ResponseParse {
provider,
message,
raw_excerpt,
..
} => Self::ResponseParse {
provider,
message,
raw_excerpt,
audit: Some(audit),
},
Self::MissingImage {
provider, message, ..
} => Self::MissingImage {
provider,
message,
audit: Some(audit),
},
Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => self,
}
}
}
pub(crate) struct ParsedJsonPayload {
pub(crate) payload: Value,
}

View File

@@ -0,0 +1,229 @@
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use platform_image::DownloadedImage;
use platform_image::generated_asset_sheets::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetPromptInput, apply_generated_asset_sheet_green_screen_alpha,
build_generated_asset_sheet_prompt, crop_generated_asset_sheet_view_edge_matte,
prepare_generated_asset_sheet_put_request, slice_generated_asset_sheet,
slice_generated_asset_sheet_two_items_per_row,
};
use platform_oss::LegacyAssetPrefix;
fn encode_image(image: RgbaImage) -> Vec<u8> {
let mut encoded = std::io::Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, ImageFormat::Png)
.expect("image should encode");
encoded.into_inner()
}
fn build_test_sheet(width: u32, height: u32) -> DownloadedImage {
let mut sheet = RgbaImage::new(width, height);
for row in 0..height / 100 {
for col in 0..width / 100 {
let row_u8 = row as u8;
let col_u8 = col as u8;
let color = Rgba([
32u8.saturating_add(row_u8.saturating_mul(40)),
24u8.saturating_add(col_u8.saturating_mul(36)),
210u8.saturating_sub(row_u8.saturating_mul(30)),
255,
]);
for y in row * 100..(row + 1) * 100 {
for x in col * 100..(col + 1) * 100 {
sheet.put_pixel(x, y, color);
}
}
}
}
DownloadedImage {
bytes: encode_image(sheet),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
}
}
#[test]
fn generated_asset_sheet_prompt_uses_default_rows_and_special_instruction() {
let item_names = vec!["草莓".to_string(), "苹果".to_string()];
let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
subject_text: "水果题材的抓大鹅 2D 物品素材",
item_names: &item_names,
grid_size: 5,
item_name_prompt_template: None,
special_prompt: None,
})
.expect("prompt should build");
assert!(prompt.contains("5行*5列"));
assert!(prompt.contains("第1行草莓 的 5 个不同视图"));
assert!(prompt.contains("第2行苹果 的 5 个不同视图"));
assert!(prompt.contains("每个物品生成 5 个不同视图"));
}
#[test]
fn generated_asset_sheet_prompt_allows_custom_row_template_and_special_prompt() {
let item_names = vec!["草莓".to_string()];
let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
subject_text: "水果题材的抓大鹅 2D 物品素材",
item_names: &item_names,
grid_size: 5,
item_name_prompt_template: Some("第{row_index}行是 {item_name},共 {view_count} 个视图"),
special_prompt: Some("每个物品要生成五个不同视图:正面、左前、右前、俯视、背面。"),
})
.expect("prompt should build");
assert!(prompt.contains("第1行是 草莓,共 5 个视图"));
assert!(prompt.contains("每个物品要生成五个不同视图"));
}
#[test]
fn generated_asset_sheet_prompt_rejects_zero_grid_size() {
let item_names = vec!["草莓".to_string()];
let error = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
subject_text: "水果题材的抓大鹅 2D 物品素材",
item_names: &item_names,
grid_size: 0,
item_name_prompt_template: None,
special_prompt: None,
})
.expect_err("grid size 0 should be rejected");
assert_eq!(error.provider(), "generated-asset-sheets");
}
#[test]
fn generated_asset_sheet_slices_by_requested_grid_size() {
let item_names = vec!["樱桃".to_string(), "苹果".to_string()];
let image = build_test_sheet(500, 500);
let slices = slice_generated_asset_sheet(&image, &item_names, 5).expect("sheet should slice");
assert_eq!(slices.len(), 2);
assert_eq!(slices[0].len(), 5);
assert_eq!(slices[1].len(), 5);
}
#[test]
fn generated_asset_sheet_two_items_per_row_slices_match3d_layout() {
let item_names = vec![
"苹果".to_string(),
"香蕉".to_string(),
"葡萄".to_string(),
"草莓".to_string(),
];
let image = build_test_sheet(1000, 1000);
let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5)
.expect("sheet should slice");
assert_eq!(slices.len(), 4);
assert!(slices.iter().all(|views| views.len() == 5));
}
#[test]
fn generated_asset_sheet_green_screen_alpha_removes_green_background() {
let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 255, 0, 255]));
for y in 6..14 {
for x in 6..14 {
sheet.put_pixel(x, y, Rgba([220, 40, 40, 255]));
}
}
let cleaned =
apply_generated_asset_sheet_green_screen_alpha(DynamicImage::ImageRgba8(sheet)).to_rgba8();
assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
assert_eq!(cleaned.get_pixel(10, 10).0[3], 255);
}
#[test]
fn generated_asset_sheet_view_edge_matte_trims_transparent_border() {
let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0]));
for y in 4..16 {
for x in 4..16 {
sheet.put_pixel(x, y, Rgba([220, 40, 40, 255]));
}
}
let cropped =
crop_generated_asset_sheet_view_edge_matte(DynamicImage::ImageRgba8(sheet)).to_rgba8();
assert_eq!(cropped.width(), 12);
assert_eq!(cropped.height(), 12);
assert_eq!(cropped.get_pixel(0, 0).0[3], 255);
}
#[test]
fn generated_asset_sheet_prepare_put_request_packs_prompt_metadata() {
let request = prepare_generated_asset_sheet_put_request(GeneratedAssetSheetPersistInput {
prefix: LegacyAssetPrefix::Match3DAssets,
owner_user_id: "user-1".to_string(),
session_id: "session-1".to_string(),
profile_id: "profile-1".to_string(),
path_segments: vec!["items".to_string(), "view".to_string()],
file_name: "view-01.png".to_string(),
content_type: "image/png".to_string(),
bytes: b"sheet-bytes".to_vec(),
asset_kind: "match3d_item_image_view".to_string(),
source_job_id: Some("task-1".to_string()),
generated_at_micros: 123,
grid_size: 5,
row_index: 1,
view_index: 2,
prompt: GeneratedAssetSheetPersistPrompt {
sheet_prompt: Some("sheet prompt".to_string()),
item_name_prompt: Some("item prompt".to_string()),
special_prompt: Some("special prompt".to_string()),
},
})
.expect("request should prepare");
assert_eq!(
request
.metadata
.get("x-oss-meta-generated-asset-sheet-prompt-encoding"),
Some(&"utf8-base64".to_string())
);
assert_eq!(
request
.metadata
.get("x-oss-meta-generated-asset-sheet-grid-size"),
Some(&"5".to_string())
);
assert_eq!(
request
.metadata
.get("x-oss-meta-generated-asset-sheet-row-index"),
Some(&"1".to_string())
);
assert_eq!(
request
.metadata
.get("x-oss-meta-generated-asset-sheet-view-index"),
Some(&"2".to_string())
);
assert_eq!(
request
.metadata
.get("x-oss-meta-generated-asset-sheet-prompt-b64"),
Some(&BASE64_STANDARD.encode("sheet prompt"))
);
assert_eq!(
request
.metadata
.get("x-oss-meta-generated-asset-sheet-item-name-prompt-b64"),
Some(&BASE64_STANDARD.encode("item prompt"))
);
assert_eq!(
request
.metadata
.get("x-oss-meta-generated-asset-sheet-special-prompt-b64"),
Some(&BASE64_STANDARD.encode("special prompt"))
);
}

View File

@@ -0,0 +1,32 @@
use platform_image::vector_engine::{
GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
build_vector_engine_image_request_body, vector_engine_images_edit_url,
vector_engine_images_generation_url,
};
#[test]
fn vector_engine_module_exposes_provider_protocol_helpers() {
let settings = VectorEngineImageSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
};
let body =
build_vector_engine_image_request_body("雾海神殿", Some("文字,水印"), "16:9", 9, &[]);
assert_eq!(GPT_IMAGE_2_MODEL, "gpt-image-2");
assert_eq!(VECTOR_ENGINE_PROVIDER, "vector-engine");
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], "1536x1024");
assert_eq!(body["n"], 4);
assert_eq!(body["prompt"], "雾海神殿\n避免:文字,水印");
assert_eq!(
vector_engine_images_generation_url(&settings),
"https://vector.example/v1/images/generations"
);
assert_eq!(
vector_engine_images_edit_url(&settings),
"https://vector.example/v1/images/edits"
);
}