回退完美像素原尺寸恢复
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(",