Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts
T
suzmii 181e364d80 AGC 资源卡棋盘格底改按图像真实 alpha 判定
- 原生预览新增头部级 alpha 判据:`LocalProjectImagePreview.hasAlpha`(PNG colorType 4/6 或 tRNS、WebP VP8X/VP8L alpha 标志;JPEG 恒 false),只读签名与 chunk 头、不做像素解码。
- 前端预览 payload 增加可选 `hasAlpha`,并在 `materializeProjectResourceCardPreview` 的两条分支里透传。
- 资源卡根节点新增排障属性 `data-preview-has-alpha`:只有预览已加载且判据为真才写 `'true'`,其余一律不写(缺属性与不透明同档)。
- 棋盘格底选择器加 `[data-preview-has-alpha='true']` 条件;无 alpha 时退回卡片既有纯色底,不引入第二套底色。
- 新增 TS 用例(真渲染资源卡 + styles.css 声明级层叠求值)与 Rust 用例(PNG/WebP/JPEG 头部判据、tRNS、坏文件失败关闭、非解码证明、IPC 字段序列化契约)。
- 验收文档补记 `data-preview-has-alpha` 判据与「真假棋盘格」取证脚本,便于现场区分真透明底与 AI 画出的假棋盘格。
2026-09-14 17:13:43 +08:00

298 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type ProjectResource,
projectResourceDisplayKind,
} from './resourceProjectionModel';
export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3;
/**
* 终态缓存条目上限。
*
* 从 `48` 提到 `72` 的依据(真机 `What do u wanna do kitten` 实测,不是估算):
* - 该项目「UI 交互」栏目有 **51 张**可预览卡,而原上限 48 **小于一栏的规模** ⇒ 滚满该栏目
* 必然触发驱逐(每多加载 1 张淘汰 1 张),且被淘汰的正是"停在屏幕上不动"的卡;
* - 取 **72**:覆盖 51 张并留约 40% 余量,同时按真机单张均值 591 KB 外推 ≈ **43 MiB**,
* **仍在既有的 64 MiB 字节预算之内**(真机 52 张 blob 合计仅 29.32 MiB,用掉 45.8%)。
*
* 因此这次调整**不动字节预算**、也**不是"把上限放大到任意大"** —— 字节侧的绑定约束没有放松,
* 只是把"条目数"这一侧从"小于一栏"提到"能装下一栏"。
*/
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT = 72;
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT = 64 * 1024 * 1024;
export const PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT = 96;
export const PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE =
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY;
export type ProjectResourceCardPreviewKind =
| 'raster-image'
| 'media-image'
| 'video'
| 'audio'
| 'code'
| 'document'
| 'version'
| 'placeholder';
export type ProjectResourceImageDimensions = {
pixelWidth: number;
pixelHeight: number;
};
export type ProjectResourceCardPreviewPayload = {
path: string;
mediaType: string;
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;
};
export type ProjectResourceCardPreviewTransportPayload = Omit<
ProjectResourceCardPreviewPayload,
'sourceUrl'
> & {
dataUrl?: string;
};
export function projectResourceCardPreviewImageDimensions(
preview: Pick<
ProjectResourceCardPreviewPayload,
'pixelWidth' | 'pixelHeight'
>,
): ProjectResourceImageDimensions | null {
const { pixelWidth, pixelHeight } = preview;
if (pixelWidth === undefined && pixelHeight === undefined) {
return null;
}
if (
typeof pixelWidth !== 'number' ||
typeof pixelHeight !== 'number' ||
!Number.isSafeInteger(pixelWidth) ||
!Number.isSafeInteger(pixelHeight) ||
pixelWidth <= 0 ||
pixelHeight <= 0
) {
throw new Error('图片预览像素尺寸无效');
}
return { pixelWidth, pixelHeight };
}
export type ProjectResourceCardPreviewCacheEntry = {
identity: string;
retainedBytes: number;
};
export type ProjectResourceCardPreviewState =
| { status: 'idle' }
| { status: 'loading' }
| {
status: 'loaded';
preview: ProjectResourceCardPreviewPayload;
}
| { status: 'failed'; error: string; retryable: boolean };
export const IDLE_PROJECT_RESOURCE_CARD_PREVIEW = {
status: 'idle',
} as const satisfies ProjectResourceCardPreviewState;
function normalizedProjectResourceCardPreviewRetainedBytes(
retainedBytes: number,
) {
return Number.isSafeInteger(retainedBytes) && retainedBytes >= 0
? retainedBytes
: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT + 1;
}
/**
* "没有任何可见性信息"的空集:调用方给不出可见集合时用它,语义等价于**全部条目都不在视口内**
* (于是退化为纯 LRU)。线上只有 `useProjectResourceCardPreviews` 一处调用,且总会算出真实的
* 可见集合;这个默认值只服务类型与测试的可读性,不是线上路径。
*/
const NO_VISIBLE_PREVIEW_IDENTITIES: ReadonlySet<string> = new Set<string>();
export function projectResourceCardPreviewEvictionIdentities(
entries: readonly ProjectResourceCardPreviewCacheEntry[],
protectedIdentity: string | null,
/**
* 当前**实际停留在视口内**的 identity 集合(调用方按几何判定后传入)。
*
* 淘汰分两轮,顺序是这条函数的核心契约:
* 1. **先只淘汰不在视口内的条目**(按传入顺序,即 LRU 顺序);
* 2. 只有当"**剩余条目全部仍在视口内**且依旧超预算"时,才回退到对全表按 LRU 淘汰。
*
* 为什么必须这样:条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生
* 新的请求 —— 于是**恰恰是用户正看着的那几张排在队首被首选淘汰**,表现出来就是
* "图片自己消失又回来"。第一轮把可见卡排除在外,正是消除这个闪烁。
*
* ⚠️ 第 2 轮回退**不可省略**:否则"可见即永不淘汰"会让缓存无界增长。
* 该回退由用例钉住(全可见且超预算时必须仍能淘汰)。
*/
visibleIdentities: ReadonlySet<string> = NO_VISIBLE_PREVIEW_IDENTITIES,
): string[] {
let retainedCount = entries.length;
let retainedBytes = entries.reduce(
(total, entry) =>
total +
normalizedProjectResourceCardPreviewRetainedBytes(entry.retainedBytes),
0,
);
const evicted = new Set<string>();
const overBudget = () =>
retainedCount > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT ||
retainedBytes > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT;
const evict = (entry: ProjectResourceCardPreviewCacheEntry) => {
evicted.add(entry.identity);
retainedCount -= 1;
retainedBytes -= normalizedProjectResourceCardPreviewRetainedBytes(
entry.retainedBytes,
);
};
// 第一轮:只动视口外的条目,可见卡一律跳过。
for (const entry of entries) {
if (!overBudget()) {
break;
}
if (entry.identity === protectedIdentity) {
continue;
}
if (visibleIdentities.has(entry.identity)) {
continue;
}
evict(entry);
}
// 第二轮(回退):仍超预算时可见卡不再豁免 —— 防止"可见即永不淘汰"造成无界内存。
if (overBudget()) {
for (const entry of entries) {
if (!overBudget()) {
break;
}
if (entry.identity === protectedIdentity) {
continue;
}
if (evicted.has(entry.identity)) {
continue;
}
evict(entry);
}
}
if (overBudget() && protectedIdentity) {
const protectedEntry = entries.find(
(entry) => entry.identity === protectedIdentity,
);
if (protectedEntry && !evicted.has(protectedEntry.identity)) {
evicted.add(protectedEntry.identity);
}
}
return Array.from(evicted);
}
/** `read_local_project_media_preview` 只认这两个线上取值,与画布栏目轴无关。 */
export type ProjectResourceMediaPreviewCategory = 'art' | 'audio';
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
const videoExtension = /\.(mp4|webm|mov)$/iu;
export function projectResourceCardPreviewKind(
resource: ProjectResource,
): ProjectResourceCardPreviewKind {
if (resource.subtype === 'project-version') {
return 'version';
}
if (resource.subtype === 'agent-result') {
return 'document';
}
const kind = projectResourceDisplayKind(resource);
if (kind === 'code') {
return 'code';
}
if (kind === 'document') {
return 'document';
}
if (kind === 'audio') {
return 'audio';
}
const mediaType = resource.mediaType.trim().toLowerCase();
if (mediaType.startsWith('video/') || videoExtension.test(resource.path)) {
return 'video';
}
if (
['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(
mediaType,
) ||
rasterImageExtension.test(resource.path)
) {
return 'raster-image';
}
if (
mediaType.startsWith('image/') ||
extendedImageExtension.test(resource.path)
) {
return 'media-image';
}
return 'placeholder';
}
/**
* `read_local_project_media_preview` 的线上 `category` 入参:只按文件类型分美术 / 音频两支。
*
* 该参数在原生侧只接受 `art` / `audio`(见 `commands.rs` 的
* `read_local_project_media_preview_at`),它是**文件读取分支**,不是画布分区栏目。
* `ProjectResource.category` 自 6 类资产分类轴落地后已经是画布栏目(`unclassified` /
* `ui-interaction` / …),把它当线上分类传给原生侧会被直接拒绝
* (`媒体预览类别只支持 art 或 audio`),扩展名图片、视频与音频卡片因此全部读不出预览。
* 这里从卡片预览类型重新派生,保持与分区栏目解耦。
*/
export function projectResourceMediaPreviewCategory(
resource: ProjectResource,
): ProjectResourceMediaPreviewCategory {
return projectResourceCardPreviewKind(resource) === 'audio' ? 'audio' : 'art';
}
export function projectResourceCardPreviewIdentity(input: {
projectPath: string;
projectId: string;
previewVersion?: string;
resource: ProjectResource;
}) {
return JSON.stringify([
input.projectPath,
input.projectId,
input.resource.id,
input.resource.category,
input.resource.path,
input.resource.mediaType,
input.previewVersion ?? '',
]);
}
export function summarizeProjectResourceDocument(content: string) {
return content
.replace(/!\[[^\]]*\]\([^)]*\)/gu, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
.replace(/<[^>]*>/gu, ' ')
.replace(/[`*_~>#|{}[\]]/gu, ' ')
.replace(/^\s*[-+]\s+/gmu, '')
.replace(/\s+/gu, ' ')
.trim()
.slice(0, 180);
}