181e364d80
- 原生预览新增头部级 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 画出的假棋盘格。
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
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);
|
||
}
|