Merge branch 'fix/agc-preview-alpha-background' into fix/agc-canvas-acceptance-batch
This commit is contained in:
@@ -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-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]
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+3
@@ -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 把棋盘格画进像素里的不透明 PNG(colorType 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`**,传栏目值会被原生拒绝,表现是卡片全空、没有缩略图。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user