refactor: extract platform media crates

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

View File

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

View File

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