Merge branch 'fix/agc-preview-alpha-background' into fix/agc-canvas-acceptance-batch

This commit is contained in:
2026-09-14 17:16:30 +08:00
7 changed files with 776 additions and 3 deletions
@@ -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<u8> {
png_header_with_size(color_type, 1, 1)
}
fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec<u8> {
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<u8>, 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<u8> {
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<u8> {
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<u8> {
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<u8> {
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-32colorType 6)应判为有 alpha");
// 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。
assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha");
assert!(
!color_type_alpha(2),
"PNG-24colorType 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"
));
// 只有 ICC0x20/ EXIF0x08)等其它标志时不是 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]
+17 -3
View File
@@ -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%),
@@ -976,6 +976,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。
@@ -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;
};
@@ -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,
@@ -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(...)` 写进 `<style>` 后,`getComputedStyle` 的
* `backgroundImage` 恒为 `''`(连 `background` 都退化成 `rgba(0, 0, 0, 0)`)。
* 因此沿用本仓库既有口径(见 `resourceCardCurrentVersionStyle.test.ts`、`styleCascade.ts`):
* 在源文件声明上按真实层叠求值,肉眼效果留给真机确认。
*/
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
act,
createGameCreationAppManifest,
describe,
expect,
findResourceSelectButton,
fireEvent,
it,
ProjectDevelopmentView,
React,
readFileSync,
render,
resolve,
screen,
vi,
waitFor,
} from './appSurface/harness';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const PROJECT_PATH = '/tmp/workbench-preview-alpha';
const PROJECT_ID = 'workbench-preview-alpha';
/** 棋盘格底的两条判据:`background-size` 是这套底独有的档位,渐变层是像素级图案本身。 */
const CHECKERBOARD_BACKGROUND_SIZE = '16px 16px';
const CHECKERBOARD_LAYER =
'linear-gradient(45deg, #f1ebe7 25%, transparent 25%)';
/** 无 alpha 时必须退回的卡片既有纯色底(`.game-resource-card-visual` 本体那条)。 */
const PLAIN_CARD_BACKGROUND = 'linear-gradient(145deg, #fffaf6, #f6ece6)';
function installResourceCardIntersectionObserver() {
const instances: Array<{
callback: IntersectionObserverCallback;
observed: Set<Element>;
observer: IntersectionObserver;
}> = [];
class ResourceCardIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
readonly observed = new Set<Element>();
constructor(readonly callback: IntersectionObserverCallback) {
instances.push({
callback,
observed: this.observed,
observer: this as unknown as IntersectionObserver,
});
}
observe(element: Element) {
this.observed.add(element);
}
unobserve(element: Element) {
this.observed.delete(element);
}
disconnect() {
this.observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: ResourceCardIntersectionObserver,
});
return {
triggerVisible(elements?: Element[]) {
const instance = instances.at(-1);
if (!instance) {
throw new Error('resource card IntersectionObserver was not created');
}
const targets = elements ?? Array.from(instance.observed);
instance.callback(
targets.map(
(target) =>
({
target,
isIntersecting: true,
intersectionRatio: 1,
}) as IntersectionObserverEntry,
),
instance.observer,
);
},
observedCount() {
return instances.at(-1)?.observed.size ?? 0;
},
};
}
/**
* 四张卡都是 `raster-image` 预览分支,只有**头部 alpha 判据**不同:
* - `alpha.png`:真有 alpha 通道(PNG colorType 6)⇒ `hasAlpha: true`
* - `painted.png`AI 把棋盘格画进像素里的不透明 PNGcolorType 2)⇒ `hasAlpha: false`
* - `photo.jpg`JPEG 恒不透明 ⇒ `hasAlpha: false`
* - `unknown.png`:预览 payload 里**没有** `hasAlpha` 字段(旧原生构建 / 判据未透出)⇒ 必须与
* `false` 同样处理:不铺棋盘格。这条把「缺属性」与「显式 false」钉成同一档,避免出现第三种中间态。
*/
const ALPHA_PATHS = new Set(['assets/alpha.png']);
const KNOWN_ALPHA_PATHS = new Set([
'assets/alpha.png',
'assets/painted.png',
'assets/photo.jpg',
]);
const LABELS = [
'alpha.png',
'painted.png',
'photo.jpg',
'unknown.png',
] as const;
function createFixtureManifest(): GameCreationAppManifest {
const manifest = createGameCreationAppManifest(
PROJECT_ID,
'资源卡预览 alpha 底',
);
const characterAsset = (
id: string,
localPath: string,
mediaType: string,
) => ({
id,
kind: 'character',
category: 'character' as const,
mediaType,
localPath,
source: { kind: 'generated' as const },
});
manifest.assets = [
characterAsset('asset-alpha', 'assets/alpha.png', 'image/png'),
characterAsset('asset-painted', 'assets/painted.png', 'image/png'),
characterAsset('asset-photo', 'assets/photo.jpg', 'image/jpeg'),
characterAsset('asset-unknown', 'assets/unknown.png', 'image/png'),
];
return manifest;
}
function graphFor(manifest: GameCreationAppManifest) {
const resourceIds = manifest.assets.map((asset) => `asset:${asset.id}`);
return {
resourceIds,
referenceEdges: [],
taskFlows: [],
connectionIndex: resourceIds.map((resourceId) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: [],
dependencyDepths: resourceIds.map((resourceId) => ({
resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
function installPreviewTauri(manifest: GameCreationAppManifest) {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return graphFor(manifest);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: PROJECT_ID,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: PROJECT_ID,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'read_local_project_image_preview') {
const relativePath = String(args?.relativePath ?? '');
const isJpeg = relativePath.endsWith('.jpg');
// 原生侧 `LocalProjectImagePreview.hasAlpha`:头部级判据,不做像素解码。
// `unknown.png` 刻意不带该字段:缺属性必须与显式 false 同档。
const hasAlpha = KNOWN_ALPHA_PATHS.has(relativePath)
? ALPHA_PATHS.has(relativePath)
: undefined;
return {
path: relativePath,
mediaType: isJpeg ? 'image/jpeg' : 'image/png',
byteLen: 1,
pixelWidth: 1,
pixelHeight: 1,
...(hasAlpha === undefined ? {} : { hasAlpha }),
dataUrl: isJpeg
? 'data:image/jpeg;base64,AA=='
: 'data:image/png;base64,AA==',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke: invoke as never } };
return invoke;
}
async function renderedCards(manifest: GameCreationAppManifest) {
const observer = installResourceCardIntersectionObserver();
installPreviewTauri(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: PROJECT_PATH,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(
await screen.findByRole('button', { name: '打开角色与对象' }),
);
// 四张卡都渲染出来之后再等注册:观察数就是「卡片真的挂上了预览管线」的证据。
for (const label of LABELS) {
await findResourceSelectButton(label);
}
await waitFor(() => expect(observer.observedCount()).toBe(4));
return observer;
}
function cardFor(label: string) {
const button = document.querySelector<HTMLElement>(
`.game-resource-card-select[aria-label*="${label}"]`,
);
const card = button?.closest<HTMLElement>('.game-resource-card');
expect(card, `卡片未渲染:${label}`).not.toBeNull();
return card!;
}
/**
* 用**真实 DOM 上的属性**拼出这个卡面元素会命中的选择器集合,再按 styles.css 的
* 真实层叠算最终生效声明。这样「卡片写了什么属性」与「CSS 认什么属性」之间没有中间假设。
*/
function visualDeclarationsForCard(card: HTMLElement) {
const rules = parseStyleSheet(
readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
),
);
const kind = card.getAttribute('data-preview-kind');
const hasAlpha = card.getAttribute('data-preview-has-alpha');
const selectors = ['.game-resource-card-visual'];
if (kind) {
selectors.push(
`.game-resource-card[data-preview-kind='${kind}'] .game-resource-card-visual`,
);
}
if (kind && hasAlpha) {
selectors.push(
`.game-resource-card[data-preview-kind='${kind}'][data-preview-has-alpha='${hasAlpha}'] .game-resource-card-visual`,
);
}
return resolveDeclarations(rules, selectors, 1280);
}
describe('资源卡棋盘格底按真实 alpha 决定', () => {
it('真透明 PNG 卡带 data-preview-has-alpha=true,不透明 PNG/JPEG 卡不带该属性', async () => {
const manifest = createFixtureManifest();
const observer = await renderedCards(manifest);
act(() => observer.triggerVisible());
await waitFor(() => {
for (const label of LABELS) {
expect(cardFor(label).getAttribute('data-preview-status')).toBe(
'loaded',
);
}
});
// 四张卡走的是同一个预览分支:只按 kind 判定的旧口径无法区分它们,
// 这正是本次缺陷的成因(所有 PNG/JPEG 卡一律铺棋盘格)。
expect(
LABELS.map((label) => cardFor(label).getAttribute('data-preview-kind')),
).toEqual(['raster-image', 'raster-image', 'raster-image', 'raster-image']);
expect(cardFor('alpha.png').getAttribute('data-preview-has-alpha')).toBe(
'true',
);
// 不透明卡不写属性(而不是写 'false'):缺属性与 'false' 在样式上必须等价。
for (const label of ['painted.png', 'photo.jpg', 'unknown.png']) {
expect(
cardFor(label).getAttribute('data-preview-has-alpha'),
`${label} 不得写 data-preview-has-alpha`,
).toBeNull();
}
});
it('真透明 PNG 卡面最终生效棋盘格底,假棋盘格的不透明 PNG/JPEG 卡退回纯色底', async () => {
const manifest = createFixtureManifest();
const observer = await renderedCards(manifest);
act(() => observer.triggerVisible());
await waitFor(() => {
expect(cardFor('alpha.png').getAttribute('data-preview-status')).toBe(
'loaded',
);
});
const alphaDeclarations = visualDeclarationsForCard(cardFor('alpha.png'));
expect(declaration(alphaDeclarations, 'background')).toContain(
CHECKERBOARD_LAYER,
);
expect(declaration(alphaDeclarations, 'background-size')).toBe(
CHECKERBOARD_BACKGROUND_SIZE,
);
for (const label of ['painted.png', 'photo.jpg', 'unknown.png']) {
const declarations = visualDeclarationsForCard(cardFor(label));
// 变异判据:把 CSS 的 alpha 条件去掉(退回只按 data-preview-kind),
// 这里会重新出现 `background-size: 16px 16px` ⇒ 变红。
expect(
declarations.has('background-size'),
`${label} 不该有棋盘格档位`,
).toBe(false);
expect(declaration(declarations, 'background')).toBe(
PLAIN_CARD_BACKGROUND,
);
}
});
it('透明图自身不被填底色:没有任何规则给 .game-resource-card-visual > img 声明背景', () => {
const rules = parseStyleSheet(
readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
),
);
const declarations = resolveDeclarations(
rules,
['.game-resource-card-visual > img'],
1280,
);
for (const property of [
'background',
'background-image',
'background-color',
'background-size',
]) {
expect(
declarations.has(property),
`.game-resource-card-visual > img 不得声明 ${property}`,
).toBe(false);
}
});
it('media-image / video 目前没有头部 alpha 判据,因此退回纯色底而不是棋盘格', () => {
// 这是本次刻意收窄的行为:拿不到真实 alpha 时宁可不铺棋盘格,
// 也不要用装饰性棋盘格继续冒充「透明底」。将来给这些格式补上头部判据时,
// 这条用例必须同步改成「有判据才铺」。
const rules = parseStyleSheet(
readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
),
);
for (const kind of ['media-image', 'video']) {
const declarations = resolveDeclarations(
rules,
[
'.game-resource-card-visual',
`.game-resource-card[data-preview-kind='${kind}'] .game-resource-card-visual`,
],
1280,
);
expect(
declarations.has('background-size'),
`${kind} 无 alpha 判据时不得铺棋盘格`,
).toBe(false);
expect(declaration(declarations, 'background')).toBe(
PLAIN_CARD_BACKGROUND,
);
}
});
});
@@ -101,6 +101,8 @@
先记住状态枚举:`data-preview-kind``raster-image | media-image | video | audio | code | document | version | placeholder``data-preview-status` **只有 `idle | loading | loaded | failed` 四个值,不存在 `unsupported`**。"不适用"不是状态,表现为**永远 idle**。
棋盘格底另有一条独立判据:`data-preview-has-alpha` **只在预览已加载且这张图真的带 alpha 通道时才写 `'true'`**(原生头部判据:PNG colorType 4/6 或 `tRNS`、WebP alpha 标志;JPEG 恒不透明)。缺属性与不透明同档,卡面退回纯色底。因此「真透明底」与「AI 把棋盘格画进像素里」不再同形:**看 `data-preview-has-alpha` 而不是看卡面有没有棋盘格**。
```js
// ① 全量分布(首选)
[...document.querySelectorAll('.game-resource-card[data-preview-kind]')]
@@ -120,6 +122,11 @@
[...document.querySelectorAll('.game-resource-card[data-preview-status="loaded"]')]
.filter(e => !e.querySelector('.game-resource-card-visual img, .game-resource-card-visual video'))
.map(e => [e.dataset.resourceCardId, e.dataset.previewKind])
// ⑤ 真假透明底:已加载的图片卡按 alpha 分组
// 真透明(true)才应有棋盘格底;AI 画了棋盘格的不透明图这里必须是 undefined
[...document.querySelectorAll('.game-resource-card[data-preview-status="loaded"][data-preview-kind="raster-image"], .game-resource-card[data-preview-status="loaded"][data-preview-kind="media-image"]')]
.reduce((m, e) => { const k = e.dataset.previewHasAlpha ?? '(无 alpha 判据)'; m[k] = (m[k] || 0) + 1; return m; }, {})
```
| 现象 | 说明 | 第一眼做什么 |
@@ -128,6 +135,7 @@
| `failed` + `data-preview-error` | 读取或解码真失败,读 `data-preview-error` 原文 | `retryable=false` 表示永久失败(超尺寸、损坏、类型不支持、不安全 SVG);可见性预取遇 failed 会直接放弃,不会自愈 |
| `loaded` 但无主体节点 | 原生返回的 dataUrl 为空、或解码不出画面 | 这类**不报错**,只能靠脚本 ④ 抓 |
| `placeholder` + `idle` | **不适用**(游戏代码、无法识别的二进制产物等),正常 | 不要当 bug 报 |
| 卡面有棋盘格但 `data-preview-has-alpha` 缺失 | **这张图其实不透明**:棋盘格是图里画进去的(旧构建会额外再铺一层卡面棋盘格,两者叠在一起) | 脚本 ⑤ 分组确认;真透明卡才允许有 `'true'` |
**IPC 侧**:预览只走 4 条命令 `read_local_project_image_preview``read_local_project_media_preview``read_local_project_text_preview``cancel_local_project_resource_preview_scope`。媒体那条的 `category` **只接受 `art` / `audio`**,传栏目值会被原生拒绝,表现是卡片全空、没有缩略图。