From 5c648ab9c78e03f8ad7c29dc91c7a68c59a53602 Mon Sep 17 00:00:00 2001 From: Linghong Date: Sun, 27 Sep 2026 12:52:10 +0000 Subject: [PATCH] =?UTF-8?q?=E4=B8=BA=E7=BE=8E=E6=9C=AF=E5=8C=85=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=9B=BE=E7=89=87=E8=87=AA=E5=8A=A8=E8=BD=AC=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 美术包在本地提交前将 JPEG 和 WebP 转为 PNG,保留有效 PNG 原字节 统一最终图片的扩展名、媒体类型与恢复摘要,保留原有透明度和大小校验 覆盖损坏下载重试、账本保留和清理、重复恢复零生成请求,并同步规范与排障记录 验证通过 106 项定向测试及格式、编码、文档索引和差异检查 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 31 +-- .../src-tauri/src/agent/generation.rs | 3 +- .../src/agent/generation/canvas_generation.rs | 237 ++++++++++++++++-- docs/project-memory/shared-memory/pitfalls.md | 6 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 + 5 files changed, 236 insertions(+), 44 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 396cd2fe9..63052f66c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -3144,27 +3144,16 @@ async fn generate_direct_taonier_art_asset_at( }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; - let generation = if retain_runtime_state { - generate_platform_art_asset_with_retained_runtime_options_at( - root, - prompt, - &[], - &options, - require_slices, - &runtime_context, - ) - .await - } else { - generate_platform_art_asset_with_runtime_options_at( - root, - prompt, - &[], - &options, - require_slices, - &runtime_context, - ) - .await - }; + let generation = generate_art_package_asset_with_runtime_options_at( + root, + prompt, + &[], + &options, + require_slices, + retain_runtime_state, + &runtime_context, + ) + .await; let generated = match generation { Ok(generated) => generated, Err(error) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index bad2f072a..fb384fe19 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -18,8 +18,7 @@ pub(crate) use canvas_generation::submit_external_generation_request; pub(in crate::agent) use canvas_generation::{ admit_platform_art_generation_at, commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, generate_admitted_platform_art_asset_at, - generate_platform_art_asset_with_retained_runtime_options_at, - generate_platform_art_asset_with_runtime_options_at, + generate_art_package_asset_with_runtime_options_at, platform_art_generation_error_result_unknown, recover_persisted_visual_generation_options, register_existing_platform_art_slices_at, request_platform_art_asset_with_runtime_options_at, restore_platform_art_asset_bytes_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 867de221d..85e63a7a1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -2267,6 +2267,35 @@ fn decode_platform_art_image_bytes_with_limits( .map_err(|error| format!("{label}无法在安全内存边界内解码:{error}")) } +fn normalize_art_package_png(download: &mut CanvasResourceDownload) -> Result<(), String> { + if download.bytes.len() > PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES { + return Err("美术包图片超过 20 MiB 本地校验上限".to_string()); + } + let format = image::guess_format(&download.bytes) + .map_err(|error| format!("美术包图片无法识别格式:{error}"))?; + if !matches!( + format, + image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP + ) { + return Err("美术包图片仅支持 PNG、JPEG、WebP".to_string()); + } + // PNG 也必须完整解码;保留原字节以兼容既有摘要和恢复记录。 + let decoded = decode_platform_art_image_with_limits(download, "美术包图片")?; + if format != image::ImageFormat::Png { + let mut output = std::io::Cursor::new(Vec::new()); + decoded + .write_to(&mut output, image::ImageFormat::Png) + .map_err(|error| format!("美术包图片转为 PNG 失败:{error}"))?; + let bytes = output.into_inner(); + if bytes.len() > PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES { + return Err("美术包图片转为 PNG 后超过 20 MiB 本地校验上限".to_string()); + } + download.bytes = bytes; + } + download.media_type = "image/png".to_string(); + Ok(()) +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(in crate::agent) struct ValidatedPlatformArtPng { pub(in crate::agent) width: u32, @@ -2555,6 +2584,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at( false, &context, Some(admission.guard), + false, ) .await } @@ -2735,20 +2765,19 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_a false, runtime_context, None, + false, ) .await } -/// Installs a recovered or newly generated asset while retaining the durable -/// request state. Package-level workflows use this to compensate local files -/// after a later stage fails without losing the accepted operation identity or -/// issuing another billable POST when the package is retried. -pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_options_at( +/// 美术包统一输出 PNG;重生成时保留账本,以便后续阶段失败后复用已付费结果。 +pub(in crate::agent) async fn generate_art_package_asset_with_runtime_options_at( root: &Path, prompt: &str, briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, require_slices: bool, + retain_runtime_state: bool, runtime_context: &PlatformArtGenerationRuntimeContext, ) -> Result { generate_platform_art_asset_with_runtime_options_and_retention_at( @@ -2757,9 +2786,10 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ briefs, options, require_slices, - true, + retain_runtime_state, runtime_context, None, + true, ) .await } @@ -2909,6 +2939,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( retain_runtime_state: bool, runtime_context: &PlatformArtGenerationRuntimeContext, admitted_guard: Option, + normalize_package_png: bool, ) -> Result { if require_slices && options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); @@ -2980,7 +3011,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( )?; recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?; } - let prepared = request_platform_art_asset_with_runtime_options_at( + let mut prepared = request_platform_art_asset_with_runtime_options_at( root, prompt, briefs, @@ -2988,6 +3019,21 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( Some(runtime_context), ) .await?; + if normalize_package_png { + normalize_art_package_png(&mut prepared.download)?; + prepared.extension = "png".to_string(); + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet + && prepared.download.bytes.len() + + prepared + .slices + .iter() + .map(|slice| slice.download.bytes.len()) + .sum::() + > PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES + { + return Err("美术包图集转码后与切片累计超过 32 MiB,已拒绝同步".to_string()); + } + } prepared.validate_frozen_session()?; let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, @@ -10322,6 +10368,81 @@ mod canvas_generation_tests { rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive) } + fn art_package_test_image(format: image::ImageFormat) -> CanvasResourceDownload { + let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 2, + 2, + image::Rgb([12, 34, 56]), + )); + let mut output = std::io::Cursor::new(Vec::new()); + image.write_to(&mut output, format).expect("encode fixture"); + CanvasResourceDownload { + bytes: output.into_inner(), + media_type: format.to_mime_type().to_string(), + } + } + + #[test] + fn art_package_png_normalization_preserves_png_bytes_and_real_alpha() { + let mut png = rgba_test_png(120); + let original = png.bytes.clone(); + png.media_type = "application/octet-stream".to_string(); + normalize_art_package_png(&mut png).expect("validate existing PNG"); + assert_eq!(png.bytes, original); + assert_eq!(png.media_type, "image/png"); + + let mut webp = std::io::Cursor::new(Vec::new()); + image::load_from_memory(&original) + .unwrap() + .write_to(&mut webp, image::ImageFormat::WebP) + .unwrap(); + let mut webp = CanvasResourceDownload { + bytes: webp.into_inner(), + media_type: "image/webp".to_string(), + }; + normalize_art_package_png(&mut webp).unwrap(); + let validated = validate_platform_art_png_bytes_with_limits(&webp.bytes, "test").unwrap(); + assert!(validated.has_transparent_pixels && validated.has_visible_pixels); + assert_eq!( + validated.pixel_sha256, + validate_platform_art_png_bytes_with_limits(&original, "test") + .unwrap() + .pixel_sha256 + ); + + let mut jpeg = art_package_test_image(image::ImageFormat::Jpeg); + normalize_art_package_png(&mut jpeg).unwrap(); + let (transparent, visible, _) = platform_art_spritesheet_alpha_contract(&jpeg).unwrap(); + assert!(!transparent, "JPEG 转码不能补造透明像素"); + assert!(visible); + } + + #[test] + fn art_package_png_normalization_rejects_invalid_or_oversized_images() { + let mut oversized = Vec::new(); + PngEncoder::new(&mut oversized) + .write_image( + &vec![0; (PLATFORM_ART_SPRITESHEET_MAX_DIMENSION as usize + 1) * 4], + PLATFORM_ART_SPRITESHEET_MAX_DIMENSION + 1, + 1, + ColorType::Rgba8.into(), + ) + .unwrap(); + for bytes in [ + b"GIF89a".to_vec(), + b"\x89PNG\r\n\x1a\n".to_vec(), + vec![0xff, 0xd8, 0xff], + oversized, + vec![0; PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES + 1], + ] { + let mut download = CanvasResourceDownload { + bytes, + media_type: "image/png".to_string(), + }; + assert!(normalize_art_package_png(&mut download).is_err()); + } + } + fn rgba_test_png_with_quality( alpha: u8, compression: CompressionType, @@ -11007,6 +11128,16 @@ mod canvas_generation_tests { #[tokio::test] async fn retained_runtime_generation_retries_a_completed_stage_without_posting_again() { + for format in [ + image::ImageFormat::Png, + image::ImageFormat::Jpeg, + image::ImageFormat::WebP, + ] { + assert_art_package_recovers_completed_stage(format).await; + } + } + + async fn assert_art_package_recovers_completed_stage(format: image::ImageFormat) { let temporary = tempfile::tempdir().expect("create retained runtime project"); let root = temporary.path(); init_local_game_project_at(root, "retained-runtime", "整包阶段恢复") @@ -11014,12 +11145,14 @@ mod canvas_generation_tests { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind retained download fixture"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); - let image_url = format!("{base_url}/retained.png"); + let extension = format.extensions_str()[0]; + let image_url = format!("{base_url}/retained.{extension}"); let signed_image_url = image_url.clone(); - let png = rgba_test_png(u8::MAX).bytes; + let source = art_package_test_image(format); + let source_bytes = source.bytes.clone(); let (request_sender, request_receiver) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..4 { + for request_index in 0..6 { let (mut stream, _) = listener.accept().expect("accept retained download"); let request = read_test_http_request(&mut stream); request_sender @@ -11038,12 +11171,19 @@ mod canvas_generation_tests { ) .expect("write retained signed URL"); } else { + // 首次下载有合法签名但内容损坏,重试必须仍使用同一已完成账本。 + let bytes = if request_index == 1 { + &source.bytes[..16] + } else { + &source.bytes + }; write!( stream, - "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - png.len() + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + source.media_type, + bytes.len() ) - .and_then(|_| stream.write_all(&png)) + .and_then(|_| stream.write_all(bytes)) .expect("write retained image"); } } @@ -11102,7 +11242,7 @@ mod canvas_generation_tests { "resource": { "resourceId": "retained-resource", "projectId": "retained-canvas-project", - "objectKey": "retained/spec.png", + "objectKey": format!("retained/spec.{extension}"), "imageSrc": image_url }, "taskId": "retained-task", @@ -11118,21 +11258,76 @@ mod canvas_generation_tests { "retained-runtime-key".to_string(), ), async { - for _ in 0..2 { - generate_platform_art_asset_with_retained_runtime_options_at( + let ledger_before = + read_platform_art_generation_runtime_state(root, &runtime_context) + .unwrap() + .unwrap(); + let failure = generate_art_package_asset_with_runtime_options_at( + root, + prompt, + &[], + &options, + false, + false, + &runtime_context, + ) + .await; + assert!(failure.is_err()); + assert!(!root.join("assets/art-spec.png").exists()); + assert!(read_manifest_for_project(root).unwrap().assets.is_empty()); + let ledger_after = + read_platform_art_generation_runtime_state(root, &runtime_context) + .unwrap() + .unwrap(); + assert_eq!( + platform_art_generation_runtime_idempotency_key(&ledger_before), + platform_art_generation_runtime_idempotency_key(&ledger_after) + ); + assert_eq!( + platform_art_generation_runtime_retained_result(&ledger_before), + platform_art_generation_runtime_retained_result(&ledger_after) + ); + let mut installed_bytes = None; + let mut installed_asset_id = None; + for retain in [true, false] { + generate_art_package_asset_with_runtime_options_at( root, prompt, &[], &options, false, + retain, &runtime_context, ) .await .expect("recover retained completed stage"); - assert!( + let bytes = fs::read(root.join("assets/art-spec.png")).unwrap(); + let validated = + validate_platform_art_png_bytes_with_limits(&bytes, "recovered").unwrap(); + assert_eq!((validated.width, validated.height), (2, 2)); + if format == image::ImageFormat::Png { + assert_eq!(bytes, source_bytes); + } + if let Some(previous) = installed_bytes.replace(bytes) { + assert_eq!(installed_bytes.as_ref().unwrap(), &previous); + } + let manifest = read_manifest_for_project(root).unwrap(); + assert_eq!(manifest.assets.len(), 1); + let asset = &manifest.assets[0]; + assert_eq!(asset.local_path, "assets/art-spec.png"); + assert_eq!(asset.media_type, "image/png"); + assert_eq!( + asset.source.resource_id.as_deref(), + Some("retained-resource") + ); + if let Some(previous) = installed_asset_id.replace(asset.id.clone()) { + assert_eq!(asset.id, previous); + } + assert_eq!( read_platform_art_generation_runtime_state(root, &runtime_context) .expect("read retained stage ledger") - .is_some() + .is_some(), + retain ); } }, @@ -11145,13 +11340,13 @@ mod canvas_generation_tests { .ok() }) .collect::>(); - assert_eq!(requests.len(), 4); + assert_eq!(requests.len(), 6); assert_eq!( requests .iter() - .filter(|request| request.starts_with("GET /retained.png ")) + .filter(|request| request.starts_with(&format!("GET /retained.{extension} "))) .count(), - 2 + 3 ); assert!(requests.iter().all(|request| request.starts_with("GET "))); assert!(requests.iter().all(|request| !request.starts_with("POST "))); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 240405e09..01ac988bf 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,11 @@ # 踩坑与排障记录 +## 美术包固定 PNG 路径与平台返回格式不一致 + +- 平台生成成功仍可能返回 JPEG/WebP;直接按 `assets/art-spec.png` 等固定路径保存会在扩展名校验时失败,图片尚未落盘但已付费结果账本仍存在,不能据 manifest 为空认定没有生成结果。 +- 美术包专用入口在本地提交前有界解码并归一化为 PNG,原 PNG 字节不变;扩展名、MIME、内容摘要和恢复比较必须使用同一份最终字节。普通图片工具和独立切片的格式合同不随之放宽,转码也不能代替真实透明度校验。 +- 失败后保留原账本并按冻结意图恢复,不能删除账本后重新生成。详情见 [AGC 美术包合同](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-08-23-direct-codex-美术包显式重生成与切片投影)。 + ## 2026-09-24 AGC 生成路由迁移必须核对账号队列契约 - AGC 普通账号会把 external editor 路由映射到站内入口;只验证 API Key 路由不足以证明客户端链路可用。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4f81fb017..84c3c3e40 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1493,6 +1493,9 @@ game-project/ ## 2026-08-23 Direct Codex 美术包显式重生成与切片投影 +- 标准美术包在客户端将规范图、背景图和主图集统一保存为 PNG:下载仍校验来源、声明类型与文件签名,随后按真实内容接受 PNG/JPEG/WebP,在已有 20 MiB、4096 像素单边和 64 MiB 解码内存限制内完整解码。有效 PNG 原样保留,JPEG/WebP 编码成 PNG,最终内容也不得超过 20 MiB;本地媒体类型固定为 `image/png`。转码不补造透明度,主图集与独立切片继续执行真实 alpha、可见像素、尺寸和唯一性合同;平台独立切片仍须为 PNG。此行为仅属于美术包,普通图片工具的指定扩展名合同不变。 +- 美术包的转码在项目提交锁和本地写入之前完成,文件摘要、已安装结果识别、替换恢复和 manifest 登记均使用最终 PNG。转码失败保留原生成账本与平台身份,同冻结意图重试重新读取已有结果,不提交新的付费生成;不改变请求快照、幂等身份、固定资源路径或旧 PNG 包的复用方式,无数据迁移。验收覆盖 JPEG/WebP 转码、PNG 字节不变、损坏/超限拒绝、真实透明度以及已有结果重复恢复零生成 POST;客户端真实 Provider 的整包验证单独记录。 +- 转码自动化验收由 `canvas_generation_tests` 的格式/边界用例和 `retained_runtime_generation_retries_a_completed_stage_without_posting_again` 本地 HTTP 夹具覆盖:PNG/JPEG/WebP 均先下载损坏内容,再从同一已完成账本恢复两次,核对最终 PNG、稳定本地 asset ID、Canvas 来源身份、账本保留/清理及零生成 POST;替换与补偿继续由现有图集事务和 Direct 重生成用例覆盖。真实 Provider 的客户端整包效果不由这些夹具替代。 - `agc_tools.taonier_prepare_game_art` 的请求模式固定为 `reuse-or-create | regenerate`。缺省使用 `reuse-or-create`,完整且可信的本地包继续零付费复用;只有用户显式要求重做、替换或切换视觉风格时使用 `regenerate`,并绕过完整包短路,按规范图、背景图、透明图集顺序生成和原位替换。`regenerate` 的旧包前置门只要求规范图和背景图已经可下载、可解码、来源一致且存在可信 manifest 登记,使两项旧字节与登记可以完整 rollback;历史主图集、私有回执、公开清单或 canonical 切片可以缺失。客户端必须把八个严格路径的实际存在性和摘要,以及其中受管顶层 asset identity,逐项冻结为 `Present/Some` 或 `Missing/None`,不能把缺失状态伪造成空文件或虚假登记。规范图或背景图任一缺失或身份无效时才失败关闭并提示先用 `reuse-or-create` 修复基础素材。 - 显式重生成不放宽 External Editor 幂等与未知态边界。固定阶段已有 `prepared / accepted` 账本时,本次生成 prompt 必须与账本冻结 prompt 一致才可恢复;不一致返回 `platform-generation-result-unknown` 并保留原 `Idempotency-Key / operationId` 对账,禁止把旧结果解释为新意图,也禁止另起付费 POST。 - 整包重生成在首个付费阶段前建立客户端私有 v4 workflow,状态固定为 `resetting / in-progress / compensating / completed`,并同时绑定意图摘要和客户端稳定 `clientTurnId`。专用 `direct-codex-art` 跨进程执行锁覆盖整个付费重生成生命周期,但不持有通用项目写锁等待网络。规范图和背景图替换后立即持久化旧字节、旧 manifest entry 与本轮双 CAS 锚点;任一后续阶段失败时进入 `compensating`,可在进程重启后继续恢复旧文件及旧登记。已成功阶段的生成账本继续保留;`completed` 持久化经脱敏和数量 / 长度限制的完整工具结果,同一 `clientTurnId` 回包丢失时必须等值重放且零新 POST。新的显式用户回合先持久化目标回合所有的 `resetting` workflow,再清理上一轮三阶段账本并转回 `in-progress`,任一崩溃点都不得出现无 workflow 窗口。只有尚无任何阶段账本且无替换锚点的孤立 `in-progress` 空壳允许被新回合原子接管;其余身份冲突、未知版本以及缺少新恢复字段的旧 v2/v3 workflow 均失败关闭,不能用 serde 缺省值把旧状态升级成可执行状态。