refactor: extract platform media crates
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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、边框、网格线、标签、底座、场景或其他物体。"
|
||||
))
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
2
server-rs/crates/platform-image/src/generated_assets.rs
Normal file
2
server-rs/crates/platform-image/src/generated_assets.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod adapter;
|
||||
pub mod helpers;
|
||||
160
server-rs/crates/platform-image/src/generated_assets/adapter.rs
Normal file
160
server-rs/crates/platform-image/src/generated_assets/adapter.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
306
server-rs/crates/platform-image/src/generated_assets/helpers.rs
Normal file
306
server-rs/crates/platform-image/src/generated_assets/helpers.rs
Normal 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
64
server-rs/crates/platform-image/src/vector_engine/audit.rs
Normal file
64
server-rs/crates/platform-image/src/vector_engine/audit.rs
Normal 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)
|
||||
}
|
||||
245
server-rs/crates/platform-image/src/vector_engine/client.rs
Normal file
245
server-rs/crates/platform-image/src/vector_engine/client.rs
Normal 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
|
||||
}
|
||||
@@ -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";
|
||||
114
server-rs/crates/platform-image/src/vector_engine/error.rs
Normal file
114
server-rs/crates/platform-image/src/vector_engine/error.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
26
server-rs/crates/platform-image/src/vector_engine/mod.rs
Normal file
26
server-rs/crates/platform-image/src/vector_engine/mod.rs
Normal 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};
|
||||
128
server-rs/crates/platform-image/src/vector_engine/payload.rs
Normal file
128
server-rs/crates/platform-image/src/vector_engine/payload.rs
Normal 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()
|
||||
}
|
||||
69
server-rs/crates/platform-image/src/vector_engine/request.rs
Normal file
69
server-rs/crates/platform-image/src/vector_engine/request.rs
Normal 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}")
|
||||
}
|
||||
180
server-rs/crates/platform-image/src/vector_engine/response.rs
Normal file
180
server-rs/crates/platform-image/src/vector_engine/response.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
177
server-rs/crates/platform-image/src/vector_engine/tests.rs
Normal file
177
server-rs/crates/platform-image/src/vector_engine/tests.rs
Normal 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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
27
server-rs/crates/platform-image/src/vector_engine/types.rs
Normal file
27
server-rs/crates/platform-image/src/vector_engine/types.rs
Normal 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,
|
||||
}
|
||||
89
server-rs/crates/platform-image/src/vector_engine/util.rs
Normal file
89
server-rs/crates/platform-image/src/vector_engine/util.rs
Normal 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,
|
||||
}
|
||||
229
server-rs/crates/platform-image/tests/generated_asset_sheets.rs
Normal file
229
server-rs/crates/platform-image/tests/generated_asset_sheets.rs
Normal 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"))
|
||||
);
|
||||
}
|
||||
32
server-rs/crates/platform-image/tests/vector_engine.rs
Normal file
32
server-rs/crates/platform-image/tests/vector_engine.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user