收紧图集切片内存并批量持久化
按需编码并限制图集上传并发、超时与累计裁剪像素 批量确认切片对象、项目资源、账号素材和完成批次 稳定派生切片记录标识并收紧重放与来源资源校验 复用已鉴权项目资源避免手动拆分全账号扫描 补充图集资源边界文档与回归测试
This commit is contained in:
@@ -26,7 +26,7 @@
|
||||
|
||||
- 现象:透明图集含大量独立碎块或噪点时,接口长时间占用 async worker;最终即使报“超过 64 个切片”,此前仍已完成全量两两合并、裁剪和 PNG 编码。
|
||||
- 原因:原始连通域无上限,辅助部件合并全量扫描所有 pair,输出限制只在 platform slicer 返回后由 api-server 检查;UI 提取还绕过了该 wrapper。
|
||||
- 处理:platform slicer 对全部 flood-fill 连通域设置 `4096` 硬上限,用空间网格只查 `48px` 邻域候选;单网格最多 `256` 个组件、单 source 最多 `512` 个候选,避免拥挤网格重新退化为全量 pair。`maxOutputSlices` 在排序、裁剪和编码前拒绝。图标自动、手动和 UI 三入口统一在 2 路 semaphore 与 30 秒 / 请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 留在 blocking 闭包直到 CPU 工作退出。
|
||||
- 处理:platform slicer 对全部 flood-fill 连通域设置 `4096` 硬上限,用空间网格只查 `48px` 邻域候选;单网格最多 `256` 个组件、单 source 最多 `512` 个候选,避免拥挤网格重新退化为全量 pair。`maxOutputSlices` 与 padding crop 总像素预算在首片 PNG 编码前拒绝。图标自动、手动和 UI 三入口统一在 2 路 CPU semaphore 与 30 秒 / 请求 deadline 保护下 prepare 出共享 RGBA + bounds 计划,不再一次返回最多 64 份 PNG。api-server 只按需编码并用容量 2 的有界管线上传,OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 32 MiB 来源对象前取得独立内存 admission,同一 admission 覆盖下载、计划与上传生命周期,并在最后一次 HEAD 完成后、数据库调用前释放,排队请求、慢 OSS 或慢数据库都不能绕过内存边界。全部 `PUT + HEAD` 成功后,单个 SpacetimeDB procedure 在一个事务中批量确认对象、创建项目资源 / 账号素材并完成 cohort;resource / asset ID 按 owner + task + 序号稳定派生,重放只复用内容一致的素材,来源资源必须存在且同 owner / project;不在上传失败后留下部分数据库批次,也不在不确定结果重放后复制整批素材。
|
||||
- 验证:覆盖大量独立 `4×4` 块、超过上限的单像素噪点、65 个有效输出和既有高光 / 阴影合并样本;手动超限必须发生在首次持久化前,自动超限不得产生切片 PUT、资源或画布切片。
|
||||
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -340,7 +340,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。
|
||||
- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。
|
||||
|
||||
- 2026-07-29 收口补充:上条 source-only 的“透明背景处理最终失败”同时包含 Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败和透明图完整解码失败;三条链路共用 helper,只写已保存 provider 原图画布层,图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不得写透明图、派生资源或切片。provider 原图本身解码失败时在首次持久化前失败,不允许 `512×512` 元数据兜底。图标自动拆分、手动拆分与 UI 提取共用受 2 路 semaphore、30 秒 / 请求 deadline 保护的 blocking helper;platform 对全部原始连通域设置 `4096` 硬上限、用空间网格查询邻近辅助候选,并在裁剪 / PNG 编码前执行 `maxOutputSlices=64`。自动超限只保留整张可信透明图并返回稳定 `sliceWarning`,不写切片;手动超限在首次持久化前返回 `422`。
|
||||
- 2026-07-29 收口补充:上条 source-only 的“透明背景处理最终失败”同时包含 Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败和透明图完整解码失败;三条链路共用 helper,只写已保存 provider 原图画布层,图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不得写透明图、派生资源或切片。provider 原图本身解码失败时在首次持久化前失败,不允许 `512×512` 元数据兜底。图标自动拆分、手动拆分与 UI 提取先在受 2 路 CPU semaphore、30 秒 / 请求 deadline 保护的 blocking prepare 中完成解码、透明化、连通域和 bounds 排序;platform 对全部原始连通域设置 `4096` 硬上限、用空间网格查询邻近辅助候选,并在首片 PNG 编码前同时执行 `maxOutputSlices=64` 与全部 padding crop 总像素预算。prepare 返回共享 RGBA + bounds 计划,api-server 再以容量 `2` 的有界管线按需编码、共享 HTTP client 并发 OSS `PUT + HEAD`,OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 `32 MiB` 来源对象前取得独立内存 admission,同一 admission 覆盖下载、prepare 到最后一片上传结束并在数据库调用前释放,CPU permit 只覆盖实际 CPU 阶段。全部对象上传验证成功后,切片的 `asset_object + editor_project_resource + editor_asset + editor_asset_group_cohort` 由单个受 editor generation runtime service identity 保护的 SpacetimeDB procedure 在一次 `try_with_tx` 中原子写入;resource / asset ID 由 owner + task + 序号稳定派生,已有同 ID 素材仅在内容完全一致时幂等复用,来源资源必须存在且与派生资源同 owner / project;上传中途失败不得写部分资源、素材或 cohort,不确定结果重放不得复制整批素材。自动超限只保留整张可信透明图并返回稳定 `sliceWarning`,不写切片;手动超限在首次持久化前返回 `422`。
|
||||
|
||||
### `external_generation_job_summary`
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ fn map_editor_green_screen_error(error: GeneratedAssetSheetError) -> AppError {
|
||||
GeneratedAssetSheetError::InvalidRequest { .. }
|
||||
| GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { .. }
|
||||
| GeneratedAssetSheetError::OutputSliceLimitExceeded { .. }
|
||||
| GeneratedAssetSheetError::TotalCropPixelLimitExceeded { .. }
|
||||
| GeneratedAssetSheetError::MergeCandidateLimitExceeded { .. } => StatusCode::BAD_REQUEST,
|
||||
GeneratedAssetSheetError::EncodeImage { .. }
|
||||
| GeneratedAssetSheetError::BuildHttpClient { .. }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -93,7 +93,7 @@ pub fn apply_generated_asset_sheet_alpha_with_options(
|
||||
source: image::DynamicImage,
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> image::DynamicImage {
|
||||
let mut image = source.to_rgba8();
|
||||
let mut image = source.into_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
remove_generated_asset_sheet_green_screen_background(
|
||||
image.as_mut(),
|
||||
|
||||
@@ -17,6 +17,10 @@ pub enum GeneratedAssetSheetError {
|
||||
slice_count: usize,
|
||||
max_slice_count: usize,
|
||||
},
|
||||
TotalCropPixelLimitExceeded {
|
||||
total_crop_pixels: u64,
|
||||
max_total_crop_pixels: u64,
|
||||
},
|
||||
MergeCandidateLimitExceeded {
|
||||
candidate_count: usize,
|
||||
max_candidate_count: usize,
|
||||
@@ -56,6 +60,12 @@ impl GeneratedAssetSheetError {
|
||||
} => format!(
|
||||
"图标 spritesheet 识别出的素材数量超过输出上限:{slice_count},最多允许 {max_slice_count} 个。"
|
||||
),
|
||||
Self::TotalCropPixelLimitExceeded {
|
||||
total_crop_pixels,
|
||||
max_total_crop_pixels,
|
||||
} => format!(
|
||||
"图标 spritesheet 带留白裁剪的累计像素超过处理上限:{total_crop_pixels},最多允许 {max_total_crop_pixels} 像素。"
|
||||
),
|
||||
Self::MergeCandidateLimitExceeded {
|
||||
candidate_count,
|
||||
max_candidate_count,
|
||||
|
||||
@@ -17,9 +17,10 @@ pub use persist::{
|
||||
};
|
||||
pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt};
|
||||
pub use sheet::{
|
||||
GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetSliceImage,
|
||||
crop_generated_asset_sheet_view_edge_matte,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet,
|
||||
GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan,
|
||||
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options,
|
||||
prepare_generated_icon_spritesheet_all_by_connected_components, slice_generated_asset_sheet,
|
||||
slice_generated_asset_sheet_two_items_per_row,
|
||||
slice_generated_icon_spritesheet_all_by_connected_components,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ use super::color::{
|
||||
use super::error::GeneratedAssetSheetError;
|
||||
use image::{GenericImageView, ImageFormat};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GeneratedAssetSheetSliceImage {
|
||||
@@ -141,6 +142,72 @@ pub struct GeneratedAssetSheetConnectedIcon {
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GeneratedAssetSheetConnectedIconPlan {
|
||||
source: Arc<image::RgbaImage>,
|
||||
icons: Arc<[GeneratedAssetSheetConnectedIconPlanItem]>,
|
||||
#[cfg(test)]
|
||||
encode_count: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct GeneratedAssetSheetConnectedIconPlanItem {
|
||||
name: String,
|
||||
crop: GeneratedAssetSheetCellBounds,
|
||||
}
|
||||
|
||||
impl GeneratedAssetSheetConnectedIconPlan {
|
||||
pub fn len(&self) -> usize {
|
||||
self.icons.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.icons.is_empty()
|
||||
}
|
||||
|
||||
pub fn encode(
|
||||
&self,
|
||||
index: usize,
|
||||
) -> Result<GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetError> {
|
||||
let item = self.icons.get(index).ok_or_else(|| {
|
||||
GeneratedAssetSheetError::invalid_request(format!(
|
||||
"图标 spritesheet 切片索引超出范围:index={index}, sliceCount={}。",
|
||||
self.icons.len()
|
||||
))
|
||||
})?;
|
||||
#[cfg(test)]
|
||||
self.encode_count
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let cropped = image::imageops::crop_imm(
|
||||
self.source.as_ref(),
|
||||
item.crop.x0,
|
||||
item.crop.y0,
|
||||
item.crop.width(),
|
||||
item.crop.height(),
|
||||
)
|
||||
.to_image();
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(cropped)
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
GeneratedAssetSheetError::encode_image(format!(
|
||||
"图标 spritesheet 切割失败:{error}"
|
||||
))
|
||||
})?;
|
||||
Ok(GeneratedAssetSheetConnectedIcon {
|
||||
name: item.name.clone(),
|
||||
bytes: cursor.into_inner(),
|
||||
width: item.crop.width(),
|
||||
height: item.crop.height(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_encode_count(&self) -> usize {
|
||||
self.encode_count.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
const GENERATED_ICON_MIN_VISIBLE_PIXELS: u32 = 16;
|
||||
const GENERATED_ICON_MAX_MERGE_ITERATIONS: usize = 16;
|
||||
const GENERATED_ICON_MAX_RAW_COMPONENTS: usize = 4096;
|
||||
@@ -153,16 +220,38 @@ pub fn slice_generated_icon_spritesheet_all_by_connected_components(
|
||||
image: &crate::DownloadedImage,
|
||||
max_output_slices: usize,
|
||||
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, GeneratedAssetSheetError> {
|
||||
let plan = prepare_generated_icon_spritesheet_all_by_connected_components(
|
||||
image,
|
||||
max_output_slices,
|
||||
u64::MAX,
|
||||
)?;
|
||||
(0..plan.len()).map(|index| plan.encode(index)).collect()
|
||||
}
|
||||
|
||||
pub fn prepare_generated_icon_spritesheet_all_by_connected_components(
|
||||
image: &crate::DownloadedImage,
|
||||
max_output_slices: usize,
|
||||
max_total_crop_pixels: u64,
|
||||
) -> Result<GeneratedAssetSheetConnectedIconPlan, GeneratedAssetSheetError> {
|
||||
if max_output_slices == 0 {
|
||||
return Err(GeneratedAssetSheetError::invalid_request(
|
||||
"图标 spritesheet 输出切片上限必须大于 0。",
|
||||
));
|
||||
}
|
||||
if max_total_crop_pixels == 0 {
|
||||
return Err(GeneratedAssetSheetError::invalid_request(
|
||||
"图标 spritesheet 累计裁剪像素上限必须大于 0。",
|
||||
));
|
||||
}
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}"))
|
||||
})?;
|
||||
let source = apply_generated_asset_sheet_green_screen_alpha(source);
|
||||
slice_generated_icon_spritesheet_rgba_by_connected_components(source, max_output_slices)
|
||||
prepare_generated_icon_spritesheet_rgba_by_connected_components(
|
||||
source,
|
||||
max_output_slices,
|
||||
max_total_crop_pixels,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn crop_generated_asset_sheet_view_edge_matte(
|
||||
@@ -174,11 +263,12 @@ pub fn crop_generated_asset_sheet_view_edge_matte(
|
||||
)
|
||||
}
|
||||
|
||||
fn slice_generated_icon_spritesheet_rgba_by_connected_components(
|
||||
fn prepare_generated_icon_spritesheet_rgba_by_connected_components(
|
||||
source: image::DynamicImage,
|
||||
max_output_slices: usize,
|
||||
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, GeneratedAssetSheetError> {
|
||||
let mut image = source.to_rgba8();
|
||||
max_total_crop_pixels: u64,
|
||||
) -> Result<GeneratedAssetSheetConnectedIconPlan, GeneratedAssetSheetError> {
|
||||
let mut image = source.into_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
let pixel_count = (width as usize).saturating_mul(height as usize);
|
||||
if pixel_count == 0 {
|
||||
@@ -207,12 +297,9 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components(
|
||||
});
|
||||
}
|
||||
sort_generated_icon_components_in_visual_rows(&mut components);
|
||||
let icon_names = (1..=components.len())
|
||||
.map(|index| format!("素材 {index}"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut icons = Vec::with_capacity(icon_names.len());
|
||||
for (name, bounds) in icon_names.iter().zip(components.into_iter()) {
|
||||
let mut total_crop_pixels = 0u64;
|
||||
let mut icons = Vec::with_capacity(components.len());
|
||||
for (index, bounds) in components.into_iter().enumerate() {
|
||||
let pad_x = resolve_generated_icon_crop_padding(bounds.width(), width);
|
||||
let pad_y = resolve_generated_icon_crop_padding(bounds.height(), height);
|
||||
let crop = GeneratedAssetSheetCellBounds {
|
||||
@@ -221,26 +308,26 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components(
|
||||
x1: bounds.x1.saturating_add(pad_x).min(width),
|
||||
y1: bounds.y1.saturating_add(pad_y).min(height),
|
||||
};
|
||||
let cropped =
|
||||
image::imageops::crop_imm(&image, crop.x0, crop.y0, crop.width(), crop.height())
|
||||
.to_image();
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(cropped)
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
GeneratedAssetSheetError::encode_image(format!(
|
||||
"图标 spritesheet 切割失败:{error}"
|
||||
))
|
||||
})?;
|
||||
icons.push(GeneratedAssetSheetConnectedIcon {
|
||||
name: name.clone(),
|
||||
bytes: cursor.into_inner(),
|
||||
width: crop.width(),
|
||||
height: crop.height(),
|
||||
total_crop_pixels = total_crop_pixels
|
||||
.saturating_add(u64::from(crop.width()).saturating_mul(u64::from(crop.height())));
|
||||
if total_crop_pixels > max_total_crop_pixels {
|
||||
return Err(GeneratedAssetSheetError::TotalCropPixelLimitExceeded {
|
||||
total_crop_pixels,
|
||||
max_total_crop_pixels,
|
||||
});
|
||||
}
|
||||
icons.push(GeneratedAssetSheetConnectedIconPlanItem {
|
||||
name: format!("素材 {}", index + 1),
|
||||
crop,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(icons)
|
||||
Ok(GeneratedAssetSheetConnectedIconPlan {
|
||||
source: Arc::new(image),
|
||||
icons: icons.into(),
|
||||
#[cfg(test)]
|
||||
encode_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
})
|
||||
}
|
||||
|
||||
fn generated_icon_alpha_fill_ratio(image: &image::RgbaImage) -> f32 {
|
||||
@@ -788,6 +875,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn build_connected_icon_test_source() -> crate::DownloadedImage {
|
||||
let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255]));
|
||||
for y in 10..24 {
|
||||
for x in 12..28 {
|
||||
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
|
||||
}
|
||||
}
|
||||
for y in 32..46 {
|
||||
for x in 52..70 {
|
||||
sheet.put_pixel(x, y, Rgba([80, 120, 240, 255]));
|
||||
}
|
||||
}
|
||||
crate::DownloadedImage {
|
||||
bytes: encode_png(sheet),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_icon_plan_prepares_without_encoding_and_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<GeneratedAssetSheetConnectedIconPlan>();
|
||||
|
||||
let source = build_connected_icon_test_source();
|
||||
let plan =
|
||||
prepare_generated_icon_spritesheet_all_by_connected_components(&source, 64, u64::MAX)
|
||||
.expect("connected icon plan should prepare");
|
||||
|
||||
assert_eq!(plan.len(), 2);
|
||||
assert!(!plan.is_empty());
|
||||
assert_eq!(plan.test_encode_count(), 0);
|
||||
let icon = plan.encode(0).expect("first icon should encode");
|
||||
assert_eq!(icon.name, "素材 1");
|
||||
assert_eq!(plan.test_encode_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_icon_plan_encodes_multiple_indices_concurrently_without_changing_results() {
|
||||
let source = build_connected_icon_test_source();
|
||||
let plan =
|
||||
prepare_generated_icon_spritesheet_all_by_connected_components(&source, 64, u64::MAX)
|
||||
.expect("connected icon plan should prepare");
|
||||
let concurrent_icons = std::thread::scope(|scope| {
|
||||
let plan = &plan;
|
||||
let handles = (0..plan.len())
|
||||
.map(|index| {
|
||||
scope.spawn(move || plan.encode(index).expect("icon should encode in worker"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().expect("worker should not panic"))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let legacy_icons =
|
||||
slice_generated_icon_spritesheet_all_by_connected_components(&source, 64)
|
||||
.expect("legacy slicing API should succeed");
|
||||
|
||||
assert_eq!(concurrent_icons, legacy_icons);
|
||||
assert_eq!(plan.test_encode_count(), plan.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_icon_plan_rejects_total_padded_crop_pixels_before_encoding() {
|
||||
let source = build_connected_icon_test_source();
|
||||
let error = prepare_generated_icon_spritesheet_all_by_connected_components(&source, 64, 1)
|
||||
.err()
|
||||
.expect("total padded crop pixel budget must fail during prepare");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
GeneratedAssetSheetError::TotalCropPixelLimitExceeded {
|
||||
total_crop_pixels,
|
||||
max_total_crop_pixels: 1,
|
||||
} if total_crop_pixels > 1
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slices_icon_spritesheet_by_connected_components_in_reading_order() {
|
||||
let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255]));
|
||||
|
||||
@@ -1,6 +1,87 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EditorSpritesheetSlicePersistItemRecordInput {
|
||||
pub asset_object: module_assets::AssetObjectUpsertInput,
|
||||
pub project_resource: Option<EditorProjectResourceCreateRecordInput>,
|
||||
pub asset: Option<EditorAssetCreateRecordInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EditorSpritesheetSliceBatchPersistRecordInput {
|
||||
pub owner_user_id: String,
|
||||
pub task_id: String,
|
||||
pub group_task_id: Option<String>,
|
||||
pub expected_asset_count: u32,
|
||||
pub items: Vec<EditorSpritesheetSlicePersistItemRecordInput>,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EditorSpritesheetSlicePersistedItemRecord {
|
||||
pub asset_object: module_assets::AssetObjectUpsertSnapshot,
|
||||
pub project_resource: Option<EditorProjectResourceRecord>,
|
||||
pub asset: Option<EditorAssetRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EditorSpritesheetSliceBatchPersistRecord {
|
||||
pub items: Vec<EditorSpritesheetSlicePersistedItemRecord>,
|
||||
}
|
||||
|
||||
impl From<EditorSpritesheetSlicePersistItemRecordInput>
|
||||
for crate::module_bindings::EditorSpritesheetSlicePersistItemInput
|
||||
{
|
||||
fn from(input: EditorSpritesheetSlicePersistItemRecordInput) -> Self {
|
||||
Self {
|
||||
asset_object: input.asset_object.into(),
|
||||
project_resource: input.project_resource.map(Into::into),
|
||||
asset: input.asset.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EditorSpritesheetSliceBatchPersistRecordInput>
|
||||
for crate::module_bindings::EditorSpritesheetSliceBatchPersistInput
|
||||
{
|
||||
fn from(input: EditorSpritesheetSliceBatchPersistRecordInput) -> Self {
|
||||
Self {
|
||||
owner_user_id: input.owner_user_id,
|
||||
task_id: input.task_id,
|
||||
group_task_id: input.group_task_id,
|
||||
expected_asset_count: input.expected_asset_count,
|
||||
items: input.items.into_iter().map(Into::into).collect(),
|
||||
completed_at_micros: input.completed_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn persist_editor_spritesheet_slice_batch(
|
||||
&self,
|
||||
input: EditorSpritesheetSliceBatchPersistRecordInput,
|
||||
) -> Result<EditorSpritesheetSliceBatchPersistRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"persist_editor_spritesheet_slice_batch_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.persist_editor_spritesheet_slice_batch_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_spritesheet_slice_batch_persist_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_editor_project(
|
||||
&self,
|
||||
input: EditorProjectCreateRecordInput,
|
||||
@@ -899,3 +980,136 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn map_editor_spritesheet_slice_batch_persist_result(
|
||||
result: crate::module_bindings::EditorSpritesheetSliceBatchPersistResult,
|
||||
) -> Result<EditorSpritesheetSliceBatchPersistRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
let items = result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
Ok(EditorSpritesheetSlicePersistedItemRecord {
|
||||
asset_object: map_editor_spritesheet_asset_object_snapshot(item.asset_object),
|
||||
project_resource: item
|
||||
.project_resource
|
||||
.map(map_editor_spritesheet_project_resource_snapshot)
|
||||
.transpose()?,
|
||||
asset: item
|
||||
.asset
|
||||
.map(map_editor_spritesheet_asset_snapshot)
|
||||
.transpose()?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, SpacetimeClientError>>()?;
|
||||
Ok(EditorSpritesheetSliceBatchPersistRecord { items })
|
||||
}
|
||||
|
||||
fn map_editor_spritesheet_asset_object_snapshot(
|
||||
snapshot: crate::module_bindings::AssetObjectUpsertSnapshot,
|
||||
) -> module_assets::AssetObjectUpsertSnapshot {
|
||||
module_assets::AssetObjectUpsertSnapshot {
|
||||
asset_object_id: snapshot.asset_object_id,
|
||||
bucket: snapshot.bucket,
|
||||
object_key: snapshot.object_key,
|
||||
access_policy: match snapshot.access_policy {
|
||||
crate::module_bindings::AssetObjectAccessPolicy::Private => {
|
||||
module_assets::AssetObjectAccessPolicy::Private
|
||||
}
|
||||
crate::module_bindings::AssetObjectAccessPolicy::PublicRead => {
|
||||
module_assets::AssetObjectAccessPolicy::PublicRead
|
||||
}
|
||||
},
|
||||
content_type: snapshot.content_type,
|
||||
content_length: snapshot.content_length,
|
||||
content_hash: snapshot.content_hash,
|
||||
version: snapshot.version,
|
||||
source_job_id: snapshot.source_job_id,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
entity_id: snapshot.entity_id,
|
||||
asset_kind: snapshot.asset_kind,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_editor_spritesheet_project_resource_snapshot(
|
||||
snapshot: crate::module_bindings::EditorProjectResourceSnapshot,
|
||||
) -> Result<EditorProjectResourceRecord, SpacetimeClientError> {
|
||||
Ok(EditorProjectResourceRecord {
|
||||
resource_id: snapshot.resource_id,
|
||||
project_id: snapshot.project_id,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
asset_object_id: snapshot.asset_object_id,
|
||||
image_src: snapshot.image_src,
|
||||
object_key: snapshot.object_key,
|
||||
width: snapshot.width,
|
||||
height: snapshot.height,
|
||||
source_type: snapshot.source_type,
|
||||
prompt: snapshot.prompt,
|
||||
actual_prompt: snapshot.actual_prompt,
|
||||
model: snapshot.model,
|
||||
provider: snapshot.provider,
|
||||
task_id: snapshot.task_id,
|
||||
source_resource_id: snapshot.source_resource_id,
|
||||
asset_kind: snapshot.asset_kind,
|
||||
generation_inputs: parse_editor_spritesheet_generation_inputs(
|
||||
snapshot.generation_inputs_json,
|
||||
"图片画布图集切片资源生成输入 JSON",
|
||||
)?,
|
||||
public_showcase_enabled: snapshot.public_showcase_enabled,
|
||||
created_at: format_timestamp_micros(snapshot.created_at_micros),
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_editor_spritesheet_asset_snapshot(
|
||||
snapshot: crate::module_bindings::EditorAssetSnapshot,
|
||||
) -> Result<EditorAssetRecord, SpacetimeClientError> {
|
||||
Ok(EditorAssetRecord {
|
||||
asset_id: snapshot.asset_id,
|
||||
folder_id: snapshot.folder_id,
|
||||
label: snapshot.label,
|
||||
asset_object_id: snapshot.asset_object_id,
|
||||
image_src: snapshot.image_src,
|
||||
object_key: snapshot.object_key,
|
||||
width: snapshot.width,
|
||||
height: snapshot.height,
|
||||
source_type: snapshot.source_type,
|
||||
prompt: snapshot.prompt,
|
||||
actual_prompt: snapshot.actual_prompt,
|
||||
model: snapshot.model,
|
||||
provider: snapshot.provider,
|
||||
task_id: snapshot.task_id,
|
||||
asset_kind: snapshot.asset_kind,
|
||||
generation_inputs: parse_editor_spritesheet_generation_inputs(
|
||||
snapshot.generation_inputs_json,
|
||||
"图片画布图集切片素材生成输入 JSON",
|
||||
)?,
|
||||
source_resource_id: snapshot.source_resource_id,
|
||||
public_showcase_enabled: snapshot.public_showcase_enabled,
|
||||
thumbnail_src: snapshot.thumbnail_src,
|
||||
generation_cost_mud_points: snapshot.generation_cost_mud_points,
|
||||
showcase_id: snapshot.showcase_id,
|
||||
showcase_review_status: snapshot.showcase_review_status,
|
||||
showcase_display_enabled: snapshot.showcase_display_enabled,
|
||||
showcase_like_count: snapshot.showcase_like_count,
|
||||
created_at: format_timestamp_micros(snapshot.created_at_micros),
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_editor_spritesheet_generation_inputs(
|
||||
raw: Option<String>,
|
||||
label: &str,
|
||||
) -> Result<Option<serde_json::Value>, SpacetimeClientError> {
|
||||
raw.map(|value| {
|
||||
serde_json::from_str(&value).map_err(|error| {
|
||||
SpacetimeClientError::validation_failed(format!("{label} 无法解析:{error}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
@@ -345,6 +345,10 @@ pub mod editor_showcase_campaign_config_snapshot_type;
|
||||
pub mod editor_showcase_campaign_config_table;
|
||||
pub mod editor_showcase_campaign_config_type;
|
||||
pub mod editor_showcase_campaign_config_upsert_input_type;
|
||||
pub mod editor_spritesheet_slice_batch_persist_input_type;
|
||||
pub mod editor_spritesheet_slice_batch_persist_result_type;
|
||||
pub mod editor_spritesheet_slice_persist_item_input_type;
|
||||
pub mod editor_spritesheet_slice_persisted_item_type;
|
||||
pub mod enqueue_external_generation_job_and_return_procedure;
|
||||
pub mod ensure_analytics_date_dimension_for_date_reducer;
|
||||
pub mod expire_profile_recharge_order_timer_reducer;
|
||||
@@ -471,6 +475,7 @@ pub mod npc_relation_state_type;
|
||||
pub mod npc_stance_profile_type;
|
||||
pub mod npc_state_table;
|
||||
pub mod npc_state_type;
|
||||
pub mod persist_editor_spritesheet_slice_batch_and_return_procedure;
|
||||
pub mod player_progression_grant_source_type;
|
||||
pub mod player_progression_table;
|
||||
pub mod player_progression_type;
|
||||
@@ -1163,6 +1168,10 @@ pub use editor_showcase_campaign_config_snapshot_type::EditorShowcaseCampaignCon
|
||||
pub use editor_showcase_campaign_config_table::*;
|
||||
pub use editor_showcase_campaign_config_type::EditorShowcaseCampaignConfig;
|
||||
pub use editor_showcase_campaign_config_upsert_input_type::EditorShowcaseCampaignConfigUpsertInput;
|
||||
pub use editor_spritesheet_slice_batch_persist_input_type::EditorSpritesheetSliceBatchPersistInput;
|
||||
pub use editor_spritesheet_slice_batch_persist_result_type::EditorSpritesheetSliceBatchPersistResult;
|
||||
pub use editor_spritesheet_slice_persist_item_input_type::EditorSpritesheetSlicePersistItemInput;
|
||||
pub use editor_spritesheet_slice_persisted_item_type::EditorSpritesheetSlicePersistedItem;
|
||||
pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_generation_job_and_return;
|
||||
pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date;
|
||||
pub use expire_profile_recharge_order_timer_reducer::expire_profile_recharge_order_timer;
|
||||
@@ -1289,6 +1298,7 @@ pub use npc_relation_state_type::NpcRelationState;
|
||||
pub use npc_stance_profile_type::NpcStanceProfile;
|
||||
pub use npc_state_table::*;
|
||||
pub use npc_state_type::NpcState;
|
||||
pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_editor_spritesheet_slice_batch_and_return;
|
||||
pub use player_progression_grant_source_type::PlayerProgressionGrantSource;
|
||||
pub use player_progression_table::*;
|
||||
pub use player_progression_type::PlayerProgression;
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::editor_spritesheet_slice_persist_item_input_type::EditorSpritesheetSlicePersistItemInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorSpritesheetSliceBatchPersistInput {
|
||||
pub owner_user_id: String,
|
||||
pub task_id: String,
|
||||
pub group_task_id: Option<String>,
|
||||
pub expected_asset_count: u32,
|
||||
pub items: Vec<EditorSpritesheetSlicePersistItemInput>,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorSpritesheetSliceBatchPersistInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::editor_spritesheet_slice_persisted_item_type::EditorSpritesheetSlicePersistedItem;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorSpritesheetSliceBatchPersistResult {
|
||||
pub ok: bool,
|
||||
pub items: Vec<EditorSpritesheetSlicePersistedItem>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorSpritesheetSliceBatchPersistResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::asset_object_upsert_input_type::AssetObjectUpsertInput;
|
||||
use super::editor_asset_create_input_type::EditorAssetCreateInput;
|
||||
use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorSpritesheetSlicePersistItemInput {
|
||||
pub asset_object: AssetObjectUpsertInput,
|
||||
pub project_resource: Option<EditorProjectResourceCreateInput>,
|
||||
pub asset: Option<EditorAssetCreateInput>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorSpritesheetSlicePersistItemInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot;
|
||||
use super::editor_asset_snapshot_type::EditorAssetSnapshot;
|
||||
use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorSpritesheetSlicePersistedItem {
|
||||
pub asset_object: AssetObjectUpsertSnapshot,
|
||||
pub project_resource: Option<EditorProjectResourceSnapshot>,
|
||||
pub asset: Option<EditorAssetSnapshot>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorSpritesheetSlicePersistedItem {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::editor_spritesheet_slice_batch_persist_input_type::EditorSpritesheetSliceBatchPersistInput;
|
||||
use super::editor_spritesheet_slice_batch_persist_result_type::EditorSpritesheetSliceBatchPersistResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct PersistEditorSpritesheetSliceBatchAndReturnArgs {
|
||||
pub input: EditorSpritesheetSliceBatchPersistInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PersistEditorSpritesheetSliceBatchAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `persist_editor_spritesheet_slice_batch_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait persist_editor_spritesheet_slice_batch_and_return {
|
||||
fn persist_editor_spritesheet_slice_batch_and_return(
|
||||
&self,
|
||||
input: EditorSpritesheetSliceBatchPersistInput,
|
||||
) {
|
||||
self.persist_editor_spritesheet_slice_batch_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn persist_editor_spritesheet_slice_batch_and_return_then(
|
||||
&self,
|
||||
input: EditorSpritesheetSliceBatchPersistInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<EditorSpritesheetSliceBatchPersistResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl persist_editor_spritesheet_slice_batch_and_return for super::RemoteProcedures {
|
||||
fn persist_editor_spritesheet_slice_batch_and_return_then(
|
||||
&self,
|
||||
input: EditorSpritesheetSliceBatchPersistInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<EditorSpritesheetSliceBatchPersistResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, EditorSpritesheetSliceBatchPersistResult>(
|
||||
"persist_editor_spritesheet_slice_batch_and_return",
|
||||
PersistEditorSpritesheetSliceBatchAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -292,7 +292,7 @@ pub(crate) fn upsert_asset_object(
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn find_asset_object_by_location(
|
||||
pub(crate) fn find_asset_object_by_location(
|
||||
ctx: &ReducerContext,
|
||||
input: &AssetObjectLocationInput,
|
||||
) -> Result<Option<AssetObjectUpsertSnapshot>, String> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user