回退完美像素原尺寸恢复
直接持久化逻辑分辨率 PNG 并使用最终实际尺寸 升级手动完美像素算法版本并拦截跨版本重放孤儿对象 补充非整除网格和前端权威对账回归测试 同步完美像素契约、决策记录与排障说明
This commit is contained in:
@@ -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])));
|
||||
|
||||
Reference in New Issue
Block a user