回退完美像素原尺寸恢复
Project CI / Repository checks (pull_request) Successful in 1m12s
Project CI / Frontend tests (pull_request) Successful in 2m52s
Project CI / Backend tests (pull_request) Successful in 3m54s
Project CI / Native shell tests (pull_request) Successful in 13m24s

直接持久化逻辑分辨率 PNG 并使用最终实际尺寸
升级手动完美像素算法版本并拦截跨版本重放孤儿对象
补充非整除网格和前端权威对账回归测试
同步完美像素契约、决策记录与排障说明
This commit is contained in:
2026-08-10 04:45:02 +00:00
parent bcff8f1957
commit 8aea7364b6
11 changed files with 209 additions and 60 deletions
@@ -201,10 +201,11 @@ const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs(
/// HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此第一次 OSS PUT 之后仍属于
/// 未知结果边界。这个 detail 字段只在该边界之后置位,客户端据此先读权威快照对账。
pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted";
const EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL: &str = "operationResultAlreadyExists";
const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap";
const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel";
const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative";
const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v1";
const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v2";
static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock<Arc<tokio::sync::Semaphore>> = LazyLock::new(|| {
Arc::new(tokio::sync::Semaphore::new(
EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY,
@@ -3402,8 +3403,8 @@ where
None
};
// 中文注释:普通图片的逻辑像素规整在交付尺寸归一之后进行snapper 会把结果
// 还原回输入尺寸,因此这里不再需要二次恢复交付尺寸。
// 中文注释:普通图片的逻辑像素规整在交付尺寸归一之后进行snapper 直接输出
// 检测到的逻辑像素图,因此最终文件尺寸允许小于规整前的交付尺寸。
if !is_character_generation && image_style == EditorImageGenerationStyle::PixelArt {
let (pixel_art_image, pixel_art_error) =
snap_editor_pixel_art_or_original(image, request_context.external_call_deadline())
@@ -6036,6 +6037,8 @@ struct EditorPixelArtSourceResolution {
asset_kind: Option<String>,
generation_input_reference: Option<Value>,
existing_result_generation_inputs: Option<Option<Value>>,
// 外层 Some 表示稳定结果资源已存在;内层 None 保留“记录存在但缺 object_key”的损坏形状。
existing_result_object_key: Option<Option<String>>,
}
fn resolve_editor_pixel_art_persisted_generation_inputs(
@@ -6119,14 +6122,17 @@ async fn resolve_editor_pixel_art_source_for_owner(
expected_result_resource_id: &str,
expected_result_task_id: &str,
) -> Result<EditorPixelArtSourceResolution, AppError> {
let existing_result_generation_inputs = project
let existing_stable_resource = project
.resources
.iter()
.find(|resource| {
resource.resource_id.trim() == expected_result_resource_id
&& resource.task_id.as_deref().map(str::trim) == Some(expected_result_task_id)
})
.map(|resource| resource.generation_inputs.clone());
.find(|resource| resource.resource_id.trim() == expected_result_resource_id);
let existing_result = existing_stable_resource.filter(|resource| {
resource.task_id.as_deref().map(str::trim) == Some(expected_result_task_id)
});
let existing_result_generation_inputs =
existing_result.map(|resource| resource.generation_inputs.clone());
let existing_result_object_key = existing_stable_resource
.map(|resource| normalize_optional_string(resource.object_key.clone()));
let resolved_without_lookup = match source_resource {
Some(source_resource) => resolve_editor_pixel_art_source_without_lookup(
owner_user_id,
@@ -6326,9 +6332,27 @@ async fn resolve_editor_pixel_art_source_for_owner(
asset_kind,
generation_input_reference,
existing_result_generation_inputs,
existing_result_object_key,
})
}
fn ensure_editor_pixel_art_existing_result_matches_candidate_object_key(
existing_result_object_key: Option<Option<&str>>,
candidate_object_key: &str,
) -> Result<(), AppError> {
let Some(existing_result_object_key) = existing_result_object_key else {
return Ok(());
};
if existing_result_object_key == Some(candidate_object_key) {
return Ok(());
}
Err(editor_pixel_art_snap_failure(
StatusCode::CONFLICT,
"同一完美像素操作已有其它权威结果,请先读取项目状态对账。",
)
.with_detail_field(EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL, json!(true)))
}
fn resolve_editor_pixel_art_asset_folder_id(asset_folder_id: Option<String>) -> Option<String> {
normalize_optional_string(asset_folder_id)
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()))
@@ -6592,6 +6616,7 @@ pub async fn snap_editor_image_to_pixel_art(
})??;
let source_object_key = source.object_key;
let asset_kind = source.asset_kind;
let existing_result_object_key = source.existing_result_object_key;
let authoritative_generation_inputs =
rebuild_editor_generation_inputs_with_authoritative_references(
payload.generation_inputs.take(),
@@ -6690,6 +6715,16 @@ pub async fn snap_editor_image_to_pixel_art(
"genarrative",
)?;
let prepared_object_key = prepared_upload.storage_paths.object_key.clone();
// 中文注释:算法版本变化会改变 fingerprint 与 object key,但 operation/dialog 和稳定记录
// ID 保持不变。若旧版本结果已经落库,必须在任何 preflight/OSS PUT 之前交给客户端读取
// 权威项目对账;否则新对象先写入、procedure 再因稳定记录被旧结果占用而拒绝,会留下孤儿。
// 相同 object key 仍沿用既有 PUT + procedure exact compare-and-return 重放语义。
ensure_editor_pixel_art_existing_result_matches_candidate_object_key(
existing_result_object_key
.as_ref()
.map(|object_key| object_key.as_deref()),
prepared_object_key.as_str(),
)?;
let image_src = editor_media_src_from_object_key(prepared_object_key.as_str());
let mut project_resource = EditorProjectResourceCreateRecordInput {
resource_id: persistence_identity.resource_id.clone(),
@@ -14918,7 +14953,7 @@ mod tests {
assert!(legacy_error.is_none());
let legacy_output = image::load_from_memory(legacy_output.bytes.as_slice())
.expect("legacy output should remain a valid image");
assert_eq!((legacy_output.width(), legacy_output.height()), (128, 128));
assert_eq!((legacy_output.width(), legacy_output.height()), (64, 64));
}
#[test]
@@ -15245,6 +15280,7 @@ mod tests {
#[test]
fn explicit_pixel_art_snap_identity_is_stable_but_input_drift_changes_fingerprint() {
assert_eq!(EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION, "perfect-pixel-v2");
for (record_kind, expected) in [
("asset-object", "8a264e1086ee3d6878d753aec254e0a5"),
("project-resource", "9eee84b77e8828ca8f5042919198ac1c"),
@@ -15325,6 +15361,42 @@ mod tests {
assert_ne!(first.operation_fingerprint, drifted.operation_fingerprint);
}
#[test]
fn explicit_pixel_art_snap_reconciles_a_different_existing_result_before_put() {
let candidate_object_key = "pixel-art-snaps/v2.png";
for existing_object_key in [None, Some(Some(candidate_object_key))] {
assert!(
ensure_editor_pixel_art_existing_result_matches_candidate_object_key(
existing_object_key,
candidate_object_key,
)
.is_ok()
);
}
for existing_object_key in [Some(Some("pixel-art-snaps/v1.png")), Some(None)] {
let error = ensure_editor_pixel_art_existing_result_matches_candidate_object_key(
existing_object_key,
candidate_object_key,
)
.expect_err("a different or damaged stable result must fail before upload");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
assert_eq!(
error.details().and_then(|details| details
[EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL]
.as_bool()),
Some(true)
);
assert_eq!(
error
.details()
.and_then(|details| details[EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL].as_bool()),
None,
"the compatibility guard runs before this request starts persistence"
);
}
}
#[test]
fn explicit_pixel_art_snap_accepts_only_static_png_jpeg_or_webp_bytes() {
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
@@ -15566,6 +15638,9 @@ mod tests {
// PUT/HEAD/原子 persist 共用第二份 60 秒绝对 deadline。preflight 必须发生
// 在第一次外部写之前,避免已知的目录/布局拒绝留下 OSS 孤儿对象。
"prepare_editor_generated_image_object_data(",
// 中文注释:跨算法版本的稳定 operation 可能已有不同 object key;比较必须
// 位于任何 preflight/PUT 之前,不得等 procedure exact compare 才发现冲突。
"ensure_editor_pixel_art_existing_result_matches_candidate_object_key(",
"EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION",
"tokio::time::timeout_at(",
".preflight_editor_pixel_art_result(",
@@ -8,15 +8,12 @@
//! This production variant separates the flat image used for grid detection
//! from the straight-RGBA image used for sampling. Soft alpha contributes to
//! per-cell coverage and alpha-weighted RGB, while the delivered PNG uses only
//! binary alpha and is resized back to the RGBA source dimensions with nearest
//! neighbour sampling.
//! binary alpha and is emitted at the detected logical-grid dimensions, with
//! one output pixel per sampled grid cell.
use std::{error::Error, fmt, io::Cursor, time::Instant};
use image::{
DynamicImage, ImageFormat, Rgba, RgbaImage,
imageops::{self, FilterType},
};
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use crate::DownloadedImage;
@@ -153,14 +150,14 @@ impl SnapConfig {
/// `grid_source` supplies the RGB structure used to detect the grid.
/// `rgba_source` supplies straight RGBA used for coverage and color sampling.
/// Both decoded images must have exactly the same dimensions. The returned
/// image is always a PNG at the original `rgba_source` dimensions. Its alpha
/// channel contains only `0` or `255`, and fully transparent pixels are
/// canonical `[0, 0, 0, 0]`.
/// PNG uses the detected logical-grid dimensions, with one output pixel per
/// sampled grid cell. Its alpha channel contains only `0` or `255`, and fully
/// transparent pixels are canonical `[0, 0, 0, 0]`.
///
/// The deadline is checked before and after non-cooperative codec/resize
/// operations, and periodically inside the K-means, profile, and cell-sampling
/// loops. Exceeding it returns [`PixelArtSnapError::DeadlineExceeded`] without
/// producing a partial image. Pass `None` to opt out of deadline checks.
/// The deadline is checked before and after non-cooperative codec operations,
/// and periodically inside the K-means, profile, and cell-sampling loops.
/// Exceeding it returns [`PixelArtSnapError::DeadlineExceeded`] without producing
/// a partial image. Pass `None` to opt out of deadline checks.
pub fn snap_pixel_art_with_deadline(
grid_source: &DownloadedImage,
rgba_source: &DownloadedImage,
@@ -242,10 +239,8 @@ fn snap_pixel_art_with_grid_policy(
config.alpha_threshold,
deadline,
)?;
deadline.check("最近邻尺寸恢复")?;
let delivered = resize_nearest(&logical, rgba_image.width(), rgba_image.height());
deadline.check("PNG 编码")?;
let encoded = encode_png(delivered)?;
let encoded = encode_png(logical)?;
deadline.check("PNG 编码")?;
Ok(encoded)
}
@@ -1000,10 +995,6 @@ fn rounded_weighted_channel(weighted_sum: u64, alpha_sum: u64) -> u8 {
((weighted_sum + alpha_sum / 2) / alpha_sum).min(255) as u8
}
fn resize_nearest(source: &RgbaImage, width: u32, height: u32) -> RgbaImage {
imageops::resize(source, width, height, FilterType::Nearest)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1132,7 +1123,7 @@ mod tests {
let strict = snap_pixel_art_strict_with_deadline(&source, &source, None)
.expect_err("explicit strict action should reject an undetected grid");
assert_eq!(decode_output(&legacy).dimensions(), (128, 128));
assert_eq!(decode_output(&legacy).dimensions(), (64, 64));
assert!(matches!(strict, PixelArtSnapError::GridNotDetected));
}
@@ -1176,7 +1167,7 @@ mod tests {
}
#[test]
fn output_keeps_physical_size_and_uses_nearest_blocks() {
fn output_encodes_the_logical_grid_without_nearest_resize() {
let grid_image = RgbaImage::from_pixel(128, 128, Rgba([0, 0, 0, 255]));
let mut rgba_image = RgbaImage::new(128, 128);
for y in 0..128 {
@@ -1201,13 +1192,37 @@ mod tests {
)
.expect("pixel snapping should succeed");
let decoded = decode_output(&output);
let mut expected = RgbaImage::new(64, 64);
for y in 0..64 {
for x in 0..64 {
expected.put_pixel(x, y, Rgba([x as u8, y as u8, ((x + y) % 256) as u8, 255]));
}
}
assert_eq!(decoded.dimensions(), rgba_image.dimensions());
assert_eq!(decoded, rgba_image);
assert_eq!(decoded.dimensions(), (64, 64));
assert_eq!(decoded, expected);
assert_eq!(output.mime_type, "image/png");
assert_eq!(output.extension, "png");
}
#[test]
fn non_divisible_source_dimensions_still_emit_one_pixel_per_logical_cell() {
let source_dimensions = (127, 127);
let source = downloaded_png(RgbaImage::from_pixel(
source_dimensions.0,
source_dimensions.1,
Rgba([30, 80, 140, 255]),
));
let output = snap_pixel_art_with_deadline(&source, &source, None)
.expect("legacy uniform fallback should emit its logical grid");
let decoded = decode_output(&output);
assert_eq!(decoded.dimensions(), (64, 64));
assert_ne!(decoded.dimensions(), source_dimensions);
assert!(decoded.pixels().all(|pixel| pixel.0 == [30, 80, 140, 255]));
}
#[test]
fn output_is_deterministic_and_alpha_is_binary() {
let grid = downloaded_png(RgbaImage::from_pixel(128, 128, Rgba([30, 40, 50, 255])));