diff --git a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index c7c4313e1..edbdeb1ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -26,6 +26,12 @@ pub(crate) struct LocalProjectImagePreview { pub(crate) byte_len: u64, pub(crate) pixel_width: u32, pub(crate) pixel_height: u32, + /// 这张图是否**真的**带 alpha 通道,判据见 [`detect_raster_image_has_alpha`]。 + /// + /// 资源卡只按它决定要不要铺棋盘格底:`data-preview-kind` 只说明「走图片预览分支」, + /// 与这张图有没有透明像素无关 —— 无条件铺底会让「AI 把棋盘格画进像素里」的不透明图 + /// 与卡面棋盘格叠成两套,验收时无法区分「真透明底」与「假棋盘格」。 + pub(crate) has_alpha: bool, pub(crate) data_url: String, } @@ -102,12 +108,17 @@ pub(crate) fn load_local_project_image_preview_with_cancellation( false, )?; cancellation.check()?; + // 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`), + // 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」 + // 的不透明图都不会因此变慢。 + let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type); Ok(LocalProjectImagePreview { path: image.relative_path.clone(), media_type: image.media_type.to_string(), byte_len: image.byte_len, pixel_width: image.pixel_width, pixel_height: image.pixel_height, + has_alpha, data_url: image.data_url_with_cancellation(cancellation)?, }) } @@ -420,6 +431,84 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32 } } +/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志 +/// (PNG 还会按 chunk 头跳过数据体找 `tRNS`)。 +/// +/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取, +/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB), +/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。 +/// +/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格): +/// - PNG:颜色类型 4(灰度 + alpha)/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道, +/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`; +/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位; +/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`); +/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。 +fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool { + match media_type { + "image/png" => detect_png_has_alpha(bytes), + "image/webp" => detect_webp_has_alpha(bytes), + _ => false, + } +} + +fn detect_png_has_alpha(bytes: &[u8]) -> bool { + // 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。 + if bytes.len() < 26 || &bytes[12..16] != b"IHDR" { + return false; + } + if matches!(bytes[25], 4 | 6) { + return true; + } + png_has_transparency_chunk(bytes) +} + +/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。 +fn png_has_transparency_chunk(bytes: &[u8]) -> bool { + let mut offset = 8usize; + loop { + let Some(header_end) = offset.checked_add(8) else { + return false; + }; + if header_end > bytes.len() { + return false; + } + let chunk_type = &bytes[offset + 4..header_end]; + // `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。 + if chunk_type == b"tRNS" { + return true; + } + if chunk_type == b"IDAT" || chunk_type == b"IEND" { + return false; + } + let chunk_len = + u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize; + let Some(next) = header_end + .checked_add(chunk_len) + .and_then(|value| value.checked_add(4)) + else { + return false; + }; + if next <= offset || next > bytes.len() { + return false; + } + offset = next; + } +} + +fn detect_webp_has_alpha(bytes: &[u8]) -> bool { + if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { + return false; + } + match &bytes[12..16] { + // `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。 + b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0), + // `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。 + b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0, + _ => false, + } +} + #[derive(Clone, Copy)] enum TiffByteOrder { LittleEndian, @@ -745,6 +834,74 @@ mod tests { .expect("valid 1x1 png") } + /// PNG 的「签名 + IHDR」头。判据只读这一段的位深 / 颜色类型,因此后续 chunk 由用例自行拼。 + fn png_header(color_type: u8) -> Vec { + png_header_with_size(color_type, 1, 1) + } + + fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); + ihdr.push(color_type); + ihdr.extend_from_slice(&[0, 0, 0]); + push_png_chunk(&mut bytes, b"IHDR", &ihdr); + bytes + } + + /// 追加一个结构合法(长度、类型、CRC 位置正确)但数据体可以是任意字节的 PNG chunk。 + /// alpha 判据不消费 CRC,因此这里填零;正因数据体不必是合法 deflate 流,它同时能证明 + /// 判据没有解码像素。 + fn push_png_chunk(bytes: &mut Vec, kind: &[u8; 4], data: &[u8]) { + bytes.extend_from_slice( + &u32::try_from(data.len()) + .expect("chunk length") + .to_be_bytes(), + ); + bytes.extend_from_slice(kind); + bytes.extend_from_slice(data); + bytes.extend_from_slice(&[0, 0, 0, 0]); + } + + /// 扩展格式 WebP(`VP8X`):`flags` 第 4 位(0x10)是 alpha 标志。 + fn webp_vp8x(flags: u8) -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8X"); + bytes.extend_from_slice(&10_u32.to_le_bytes()); + bytes.push(flags); + bytes.extend_from_slice(&[0, 0, 0]); + bytes.extend_from_slice(&[0, 0, 0]); + bytes.extend_from_slice(&[0, 0, 0]); + bytes + } + + /// 无损 WebP(`VP8L`):位流头第 28 位是 `alpha_is_used`,落在下标 24 的 0x10 位。 + fn webp_vp8l(has_alpha: bool) -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8L"); + bytes.extend_from_slice(&5_u32.to_le_bytes()); + bytes.push(0x2f); + bytes.extend_from_slice(&[0, 0, 0, if has_alpha { 0x10 } else { 0 }]); + bytes + } + + /// 简单有损 WebP(`VP8 `):容器上没有 alpha 通道;带 alpha 的有损 WebP 一定走 + /// `VP8X` 扩展格式(+ `ALPH` chunk)。 + fn webp_vp8_simple() -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8 "); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes + } + fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec { let mut bytes = vec![0xff, 0xd8]; if let Some(payload) = app1_payload { @@ -840,6 +997,138 @@ mod tests { assert_eq!(preview.media_type, "image/png"); assert_eq!(preview.byte_len, png_bytes().len() as u64); assert!(preview.data_url.starts_with("data:image/png;base64,")); + // 这份 fixture 是 PNG colorType 4(灰度 + alpha),因此预览必须报「有 alpha」—— + // 资源卡据此才铺棋盘格底。 + assert!(preview.has_alpha); + } + + #[test] + fn png_alpha_follows_color_type_and_transparency_chunk() { + let color_type_alpha = |color_type: u8| { + let mut bytes = png_header(color_type); + push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut bytes, b"IEND", &[]); + detect_raster_image_has_alpha(&bytes, "image/png") + }; + + // 颜色类型 4(灰度 + alpha)与 6(真彩 + alpha)才带 alpha 通道。 + assert!(color_type_alpha(4), "colorType 4 应判为有 alpha"); + assert!(color_type_alpha(6), "PNG-32(colorType 6)应判为有 alpha"); + // 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。 + assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha"); + assert!( + !color_type_alpha(2), + "PNG-24(colorType 2)不应判为有 alpha" + ); + assert!( + !color_type_alpha(3), + "colorType 3 无 tRNS 时不应判为有 alpha" + ); + // 未定义的颜色类型失败关闭为「不透明」,不能把坏文件当成透明。 + assert!(!color_type_alpha(7), "未定义 colorType 不应判为有 alpha"); + + // 灰度 / 真彩 / 调色板可以靠 tRNS 声明透明色,那也是真透明 PNG,必须铺棋盘格。 + for color_type in [0_u8, 2, 3] { + let mut bytes = png_header(color_type); + push_png_chunk(&mut bytes, b"tRNS", &[0]); + push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut bytes, b"IEND", &[]); + assert!( + detect_raster_image_has_alpha(&bytes, "image/png"), + "colorType {color_type} + tRNS 也是真透明 PNG" + ); + } + + // tRNS 规范上必须在 IDAT 之前:出现在之后不再继续扫 chunk(成本有界)。 + let mut late_trns = png_header(3); + push_png_chunk(&mut late_trns, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut late_trns, b"tRNS", &[0]); + push_png_chunk(&mut late_trns, b"IEND", &[]); + assert!(!detect_raster_image_has_alpha(&late_trns, "image/png")); + } + + #[test] + fn jpeg_and_webp_alpha_follow_container_flags() { + // JPEG 没有 alpha 通道:恒不透明(也绝不为了判 alpha 去解码扫描段)。 + assert!(!detect_raster_image_has_alpha( + &jpeg_bytes(40, 20, None), + "image/jpeg" + )); + // 扩展格式 VP8X 的 flags 第 4 位就是 alpha 标志。 + assert!(detect_raster_image_has_alpha( + &webp_vp8x(0x10), + "image/webp" + )); + assert!(!detect_raster_image_has_alpha( + &webp_vp8x(0x00), + "image/webp" + )); + // 只有 ICC(0x20)/ EXIF(0x08)等其它标志时不是 alpha。 + assert!(!detect_raster_image_has_alpha( + &webp_vp8x(0x28), + "image/webp" + )); + // 无损 VP8L 的 alpha_is_used 位。 + assert!(detect_raster_image_has_alpha( + &webp_vp8l(true), + "image/webp" + )); + assert!(!detect_raster_image_has_alpha( + &webp_vp8l(false), + "image/webp" + )); + // 简单有损格式不带 alpha 通道。 + assert!(!detect_raster_image_has_alpha( + &webp_vp8_simple(), + "image/webp" + )); + + // 头部被截断时失败关闭为「不透明」,且不得 panic。 + let truncated_webp = webp_vp8x(0x10); + assert!(!detect_raster_image_has_alpha( + &truncated_webp[..18], + "image/webp" + )); + let truncated_png = png_header(6); + assert!(!detect_raster_image_has_alpha( + &truncated_png[..20], + "image/png" + )); + } + + #[test] + fn alpha_judgement_never_decodes_pixels() { + // 4096×4096 的 PNG-32:真按像素解码要 64 MiB 缓冲,而下面的 IDAT 数据体不是合法 + // deflate 流(全零),任何真正的解码器都会失败。判据只看头部,所以这里必须成功, + // 并且仍然判 has_alpha=true —— 这就是「不做全量解码」的可执行证据。 + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir"); + let mut bytes = png_header_with_size(6, 4_096, 4_096); + push_png_chunk(&mut bytes, b"IDAT", &[0x00, 0x00, 0x00, 0x00]); + push_png_chunk(&mut bytes, b"IEND", &[]); + fs::write(root.path().join("assets/ui/large.png"), &bytes).expect("large image"); + + let preview = load_local_project_image_preview(root.path(), "assets/ui/large.png") + .expect("header-only preview"); + + assert_eq!(preview.pixel_width, 4_096); + assert_eq!(preview.byte_len, bytes.len() as u64); + assert!(preview.has_alpha); + } + + #[test] + fn image_preview_serializes_alpha_flag_for_the_shell() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir"); + fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image"); + + let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png") + .expect("load project preview"); + + // 前端按 camelCase 读 `hasAlpha`(`ProjectResourceCardPreviewTransportPayload`); + // 字段名或大小写改了会让资源卡永远退回纯色底,所以这里钉住 IPC 契约。 + let serialized = serde_json::to_value(&preview).expect("serialize preview"); + assert_eq!(serialized["hasAlpha"], serde_json::json!(true)); } #[test] diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index a0fec719e..af05c40b7 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7023,10 +7023,24 @@ iframe.preview-frame { place-items: center; } -.game-resource-card[data-preview-kind='raster-image'] +/* + * 棋盘格底只铺给**这张图真的有 alpha 通道**的卡(`data-preview-has-alpha='true'`, + * 判据来自原生侧头部解析,见 `src-tauri/src/image_inspect.rs`),不再按「预览分支是图片」 + * 无条件铺。 + * + * 原因:AI 生成的「透明底」PNG 常常把棋盘格**画进像素里**。无条件铺底时,卡面棋盘格与图内 + * 棋盘格叠在一起,验收无法区分「真透明底」与「假棋盘格」;改为按真实 alpha 判定后两者可分。 + * + * 没有该属性时(JPEG 恒不透明;media-image / video 目前没有头部 alpha 判据)退回 + * `.game-resource-card-visual` 的既有纯色底(见上一条规则),不引入第二套底色, + * 因此不会出现「半透明叠色」之类的问题。 + */ +.game-resource-card[data-preview-kind='raster-image'][data-preview-has-alpha='true'] .game-resource-card-visual, -.game-resource-card[data-preview-kind='media-image'] .game-resource-card-visual, -.game-resource-card[data-preview-kind='video'] .game-resource-card-visual { +.game-resource-card[data-preview-kind='media-image'][data-preview-has-alpha='true'] + .game-resource-card-visual, +.game-resource-card[data-preview-kind='video'][data-preview-has-alpha='true'] + .game-resource-card-visual { background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%), linear-gradient(-45deg, #f1ebe7 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f1ebe7 75%), diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 60842a4f6..3979baebd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -974,6 +974,18 @@ const ResourceCard = memo(function ResourceCard({ data-preview-error={ preview.status === 'failed' ? preview.error : undefined } + // 这张图是否**真的**带 alpha 通道(原生头部判据:PNG colorType 4/6 或 tRNS、 + // WebP alpha 标志;JPEG 恒 false)。棋盘格底只允许铺在真透明图上 —— + // 否则「AI 把棋盘格画进像素里」的不透明图会与卡面棋盘格叠成两套,验收时反而 + // 分不出哪张真透明。 + // + // 只写 `'true'`,不写 `'false'`:缺属性表示「还没读出来 / 判据说不透明 / 这条读取链路 + // 没有 alpha 判据」,三者必须同档(CSS 里只认 `'true'`),免得出现第三种中间态。 + data-preview-has-alpha={ + preview.status === 'loaded' && preview.preview.hasAlpha === true + ? 'true' + : undefined + } data-used-by-current-version={usedByCurrentVersion ? 'true' : undefined} // 替换血缘的稳定 DOM 判据(值都是 manifest 资产 id,不是显示名): // 「被替换掉的源素材」卡上给出替换它的那张卡的 id,「替换素材」卡上给出源素材的 id。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts index 006048f62..8791b8d25 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts @@ -43,6 +43,19 @@ export type ProjectResourceCardPreviewPayload = { byteLen: number; pixelWidth?: number; pixelHeight?: number; + /** + * 这张图是否**真的**带 alpha 通道(原生侧头部级判据,见 `image_inspect.rs` 的 + * `detect_raster_image_has_alpha`):PNG 颜色类型 4/6 或 `tRNS`、WebP 的 alpha 标志为 true; + * JPEG 恒 false。 + * + * 资源卡的棋盘格底只按它铺(`data-preview-has-alpha='true'`),不再按「预览分支是图片」 + * 无条件铺 —— 否则 AI 把棋盘格画进像素里的不透明图会与卡面棋盘格叠在一起, + * 验收时无法区分「真透明底」与「假棋盘格」。 + * + * 只有 `read_local_project_image_preview` 这条图像读取链路会给出该字段;文本 / 媒体预览的 + * payload 没有它(`undefined`),必须与 `false` 同档处理:不知道就不铺棋盘格。 + */ + hasAlpha?: boolean; sourceUrl?: string; content?: string; }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 94d17a065..06309ad28 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -164,6 +164,7 @@ function materializeProjectResourceCardPreview( byteLen: transport.byteLen, pixelWidth: transport.pixelWidth, pixelHeight: transport.pixelHeight, + hasAlpha: transport.hasAlpha, content: transport.content, }, retainedBytes: @@ -200,6 +201,8 @@ function materializeProjectResourceCardPreview( byteLen: transport.byteLen, pixelWidth: imageDimensions?.pixelWidth, pixelHeight: imageDimensions?.pixelHeight, + // 头部级 alpha 判据随图像预览 payload 一起透传:卡面棋盘格底只认它。 + hasAlpha: transport.hasAlpha, sourceUrl: objectUrl, }, retainedBytes: blob.size, diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx new file mode 100644 index 000000000..b611522f5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx @@ -0,0 +1,434 @@ +// @vitest-environment jsdom +/** + * 资源卡的棋盘格底必须由**这张图真实的 alpha** 决定,而不是「预览分支是图片」。 + * + * 现场缺陷(验收截图):所有 PNG/JPEG 卡一律铺 CSS 棋盘格,于是「真透明底」与 + * 「AI 把棋盘格画进像素里」在卡面上完全同形,验收时无法区分两者。 + * + * 本文件把整条链路钉住: + * 1. 真的渲染 `ProjectDevelopmentView`,用假的 `read_local_project_image_preview` + * 返回原生头部判据 `hasAlpha`,断言卡片根节点的 `data-preview-has-alpha`; + * 2. 把真实 DOM 上的 `data-preview-kind` / `data-preview-has-alpha` 喂给 + * `styles.css` 的声明级层叠求值,断言只有真透明卡片的 `.game-resource-card-visual` + * 最终生效声明里才有棋盘格(判据取 `background-size: 16px 16px` 与渐变层)。 + * + * 为什么不用 `getComputedStyle(visual).backgroundImage`:jsdom 不加载样式表,且本仓库 + * 测试环境的 cssstyle 解析不了渐变 —— 实测把 `background: linear-gradient(...)` 与 + * `background-image: linear-gradient(...)` 写进 `