0a85c4f87e
合入PR410的常用操作收纳与卡片信息类型入口 保留本分支资源名称、文档图标、占位生成与批量移动能力 整合引擎结构摘要与当前图集测试契约
618 lines
23 KiB
TypeScript
618 lines
23 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'
|
||
/** 引擎序列化文本资源(Cocos 的 `.prefab` / `.scene` / `.anim` / `.effect` …):只读结构预览。 */
|
||
| 'structured'
|
||
/** 引擎三维模型(`.glb` / `.gltf` / `.fbx`):整份字节交给模型渲染器出缩略图。 */
|
||
| 'model'
|
||
/** 客户端解不了的引擎容器(压缩纹理、Spine 二进制、PSD/EXR、裸 PCM…):只画类型卡,不读取。 */
|
||
| 'binary'
|
||
| '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;
|
||
/** 原生侧完整校验过的 UI State 资产身份,不由前端解析 JSON 推断。 */
|
||
uiDesignAssetId?: 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` 只认这两个线上取值,与画布栏目轴无关。 */
|
||
/**
|
||
* `read_local_project_media_preview` 的线上 `category` 入参:只按**文件读取分支**分三支,
|
||
* 与画布栏目轴无关 —— `model` 是引擎三维模型的读取分支(整份字节送进模型渲染器)。
|
||
*/
|
||
export type ProjectResourceMediaPreviewCategory = 'art' | 'audio' | 'model';
|
||
|
||
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
|
||
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
|
||
const videoExtension = /\.(mp4|webm|mov)$/iu;
|
||
|
||
/**
|
||
* 引擎三维模型:卡面走模型渲染器(`.glb` / `.gltf` / `.fbx` 是三种能被通用三维库直接
|
||
* 加载的格式)。`.mesh` / `.skeleton` 是引擎自己的实例化数据,归入结构预览而不是这里。
|
||
*/
|
||
const modelExtension = /\.(glb|gltf|fbx)$/iu;
|
||
|
||
/**
|
||
* 引擎序列化文本资源:Cocos 把场景、预制体、动画、材质、特效、图集配置都存成
|
||
* JSON / XML / 自定义 DSL 文本,卡面按「结构摘要」预览。
|
||
*
|
||
* `.plist` 同时用于图集与粒子,`.pac` 是自动图集配置,`.atlas` 是 Spine 图集文本描述;
|
||
* 它们可能是二进制变体,此时原生读取返回 `content: null`,卡面退化到类型卡。
|
||
*/
|
||
const structuredExtension =
|
||
/\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|mtl|material|pmtl|effect|chunk|tmx|terrain|plist|labelatlas|atlas|fnt|pac|mesh|skeleton)$/iu;
|
||
|
||
/**
|
||
* 客户端无法解码、也拿不到有意义结构的引擎容器:**不发起任何读取**,卡面直接画类型卡。
|
||
*
|
||
* - 压缩纹理 / 渲染纹理(`.texture` / `.cubemap` / `.rt`)只有引擎自己能解;
|
||
* - `.skel` / `.dbbin` 是 Spine、DragonBones 的二进制骨架;
|
||
* - `.psd` / `.exr` 当前没有可用解码器(`.exr` 的解码依赖在本仓库依赖源里取不到);
|
||
* - `.pcm` 是无头裸音频,浏览器拿到也没法解码播放;
|
||
* - `.bin` 是引擎的 BufferAsset,语义取决于使用方。
|
||
*/
|
||
const opaqueContainerExtension =
|
||
/\.(dbbin|bin|skel|texture|cubemap|rt|psd|znt|exr|pcm)$/iu;
|
||
|
||
/** Markdown 文档:卡面显示前几行,并做轻量标记清理。 */
|
||
const markdownExtension = /\.(md|markdown|mdx)$/iu;
|
||
/**
|
||
* 代码文件扩展名(**只看路径,不读文件内容**)。
|
||
*
|
||
* 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用:
|
||
* 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。
|
||
* 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
|
||
* 一并算代码。JSON 留在文本读取通道,由原生内容识别区分普通 JSON 和 UI 设计,
|
||
* 不能像源码卡一样跳过预取;卡面不显示 JSON 正文。
|
||
*/
|
||
const cardCodeExtension =
|
||
/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu;
|
||
/** 纯文本:按纯文本处理,显示前几行但不做标记清理。 */
|
||
const plainTextExtension =
|
||
/\.(txt|text|csv|tsv|log|ini|conf|cfg|properties|env)$/iu;
|
||
|
||
/**
|
||
* 取路径末段的扩展名(小写、不含点)。取不到(无扩展名)时返回 `null`。
|
||
*/
|
||
export function projectResourcePathExtension(path: string): string | null {
|
||
const fileName = path.split(/[\\/]/u).filter(Boolean).pop() ?? '';
|
||
const matched = /\.([a-z0-9]+)$/iu.exec(fileName.trim());
|
||
return matched ? matched[1]!.toLowerCase() : null;
|
||
}
|
||
|
||
export function isProjectResourceJson(
|
||
resource: Pick<ProjectResource, 'path' | 'mediaType'>,
|
||
): boolean {
|
||
return (
|
||
projectResourcePathExtension(resource.path) === 'json' ||
|
||
(projectResourcePathExtension(resource.path) === null &&
|
||
resource.mediaType.toLowerCase().includes('json'))
|
||
);
|
||
}
|
||
|
||
/** 读取结果必须仍属于当前卡片;元数据中的 UI 标签本身不能授予编辑入口。 */
|
||
export function projectResourceJsonPresentation(
|
||
resource: ProjectResource,
|
||
preview: ProjectResourceCardPreviewState | null | undefined,
|
||
): 'json' | 'ui-design' | null {
|
||
if (!isProjectResourceJson(resource)) return null;
|
||
return resource.manifestAssetId &&
|
||
preview?.status === 'loaded' &&
|
||
preview.preview.path === resource.path &&
|
||
preview.preview.uiDesignAssetId === resource.manifestAssetId
|
||
? 'ui-design'
|
||
: 'json';
|
||
}
|
||
|
||
/** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */
|
||
export function projectResourceCodeTypeLabel(path: string): string | null {
|
||
const trimmed = path.trim();
|
||
if (!cardCodeExtension.test(trimmed)) {
|
||
return null;
|
||
}
|
||
const extension = projectResourcePathExtension(trimmed);
|
||
return extension ? extension.toUpperCase() : null;
|
||
}
|
||
|
||
export function projectResourceCardPreviewKind(
|
||
resource: ProjectResource,
|
||
): ProjectResourceCardPreviewKind {
|
||
if (resource.subtype === 'project-version') {
|
||
return 'version';
|
||
}
|
||
if (resource.subtype === 'agent-result') {
|
||
return 'document';
|
||
}
|
||
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
|
||
// JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。
|
||
if (markdownExtension.test(resource.path)) {
|
||
return 'document';
|
||
}
|
||
if (cardCodeExtension.test(resource.path)) {
|
||
return 'code';
|
||
}
|
||
/*
|
||
* 引擎资源的三个新分支必须排在 mediaType 兜底之前:
|
||
* `.pcm` 的 mediaType 是 `audio/*`、`.tga` 是 `image/*`、`.glb` 是 `model/*`,
|
||
* 一旦让通用分支先跑,就会出现「音频卡点播放却解不出来」「模型卡按图片解码失败」
|
||
* 这类看起来像坏掉的预览。
|
||
*/
|
||
if (modelExtension.test(resource.path)) {
|
||
return 'model';
|
||
}
|
||
if (structuredExtension.test(resource.path)) {
|
||
return 'structured';
|
||
}
|
||
if (opaqueContainerExtension.test(resource.path)) {
|
||
return 'binary';
|
||
}
|
||
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';
|
||
}
|
||
// 纯文本兜底:`.txt` / `.csv` / `.log` 这类文件在上游没有类型结论(判成 `null`),
|
||
// 卡面按纯文本预览比占位图标更有信息量。放在最后,不改动任何已有分支的结论。
|
||
if (kind === null && plainTextExtension.test(resource.path)) {
|
||
return 'document';
|
||
}
|
||
return 'placeholder';
|
||
}
|
||
|
||
/**
|
||
* 文档类卡片(`document` / `code`)的卡面分流结论。
|
||
*
|
||
* 三种落点:
|
||
* - `markdown`:卡面只画居中文档图标,正文只在独立详情浮层按 Markdown 渲染;
|
||
* - `code`:**完全不读内容**,卡面改画代码图标 + 类型标签;
|
||
* - `plain-text`:卡面只画居中文档图标,正文只在独立详情浮层按原文展示。
|
||
*
|
||
* 三者都判**路径**,不依赖是否读到内容:卡面本来就不铺正文,等读完再决定画什么
|
||
* 只会把"半截正文"和多余的重排一起留在链路上。
|
||
*/
|
||
export type ProjectResourceCardPreviewVariant =
|
||
| 'markdown'
|
||
| 'code'
|
||
| 'plain-text'
|
||
/** 引擎序列化文本资源:卡面显示结构摘要,不显示原始正文。 */
|
||
| 'structured'
|
||
| null;
|
||
|
||
export function projectResourceCardPreviewVariant(
|
||
resource: ProjectResource,
|
||
): ProjectResourceCardPreviewVariant {
|
||
if (resource.subtype === 'agent-result') {
|
||
return 'markdown';
|
||
}
|
||
if (markdownExtension.test(resource.path)) {
|
||
return 'markdown';
|
||
}
|
||
if (cardCodeExtension.test(resource.path)) {
|
||
return 'code';
|
||
}
|
||
if (projectResourceCardPreviewKind(resource) === 'structured') {
|
||
return 'structured';
|
||
}
|
||
return projectResourceCardPreviewKind(resource) === 'document'
|
||
? 'plain-text'
|
||
: null;
|
||
}
|
||
|
||
/**
|
||
* 卡面是否预取正文:代码卡只用路径画图标,不为卡面占读取槽;显式详情请求单独放行。
|
||
*
|
||
* 与 `projectResourceCardPreviewVariant` 同源,避免"卡面不画内容、却仍在后台读内容"的分叉。
|
||
*/
|
||
export function projectResourceCardPreviewReadsContent(
|
||
resource: ProjectResource,
|
||
): boolean {
|
||
return (
|
||
projectResourceCardPreviewVariant(resource) !== 'code' &&
|
||
projectResourceCardPreviewKind(resource) !== 'binary'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* `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 {
|
||
const kind = projectResourceCardPreviewKind(resource);
|
||
if (kind === 'audio') {
|
||
return 'audio';
|
||
}
|
||
return kind === 'model' ? 'model' : '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 ?? '',
|
||
]);
|
||
}
|
||
|
||
/** 卡面文档预览的最大行数(超出由 CSS 省略号截断)。 */
|
||
export const PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT = 3;
|
||
/** 单行预览的最大字符数,超出截断成省略号。 */
|
||
export const PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH = 72;
|
||
|
||
/**
|
||
* Markdown 的**轻量**清理:只去掉标记符号,不是渲染器。
|
||
*
|
||
* - 去掉围栏标记行(``` / ~~~),保留代码内容本身;
|
||
* - 去掉行首标题 `#`、引用 `>` 与列表符号(保留缩进层级);
|
||
* - 去掉强调 / 行内代码 / 图片 / 链接的标记符号,保留可见文字;
|
||
* - **不折叠换行** —— 卡面要的是"前几行",压成一行就看不出结构了。
|
||
*/
|
||
function stripMarkdownMarkers(content: string): string {
|
||
return content
|
||
.replace(/^\s*(?:```|~~~).*$/gmu, '')
|
||
.replace(/^\s{0,3}#{1,6}\s*/gmu, '')
|
||
.replace(/^\s{0,3}>\s?/gmu, '')
|
||
.replace(/^\s*[-+*]\s+/gmu, '')
|
||
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1')
|
||
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
|
||
.replace(/`{1,3}([^`]*)`{1,3}/gu, '$1')
|
||
.replace(/\*\*([^*]+)\*\*/gu, '$1')
|
||
.replace(/__([^_]+)__/gu, '$1')
|
||
.replace(/(^|[^*])\*([^*\n]+)\*/gu, '$1$2')
|
||
.replace(/(^|[^_])_([^_\n]+)_/gu, '$1$2');
|
||
}
|
||
|
||
/**
|
||
* 文档卡面的前几行预览文本。
|
||
*
|
||
* `isMarkdown` 为 `true` 时先做轻量标记清理;纯文本原样输出。
|
||
* 逐行去掉多余空白(缩进保留最多 2 个空格)并按 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH`
|
||
* 单行截断,最多取 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT` 行。
|
||
*/
|
||
export function projectResourceDocumentPreviewText(
|
||
content: string,
|
||
isMarkdown: boolean,
|
||
): string {
|
||
const normalized = isMarkdown ? stripMarkdownMarkers(content) : content;
|
||
const lines: string[] = [];
|
||
for (const rawLine of normalized.split(/\r?\n/u)) {
|
||
const line = rawLine.replace(/\t/gu, ' ').replace(/\s+$/u, '');
|
||
const trimmed = line.trimStart();
|
||
if (!trimmed) {
|
||
continue;
|
||
}
|
||
const indent = line.slice(0, line.length - trimmed.length).slice(0, 2);
|
||
const text = `${indent}${trimmed}`;
|
||
lines.push(
|
||
text.length > PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH
|
||
? `${text.slice(0, PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH)}…`
|
||
: text,
|
||
);
|
||
if (lines.length >= PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT) {
|
||
break;
|
||
}
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* 引擎(Cocos)序列化资源的**结构摘要**。
|
||
*
|
||
* Cocos 把场景、预制体、动画剪辑、材质都序列化成「`{ __type__: 'cc.Xxx', ... }` 对象的
|
||
* 数组」,直接显示原始 JSON 前几行对用户没有信息量(第一行永远是 `[` 和第一个组件的
|
||
* 大段属性)。这里只抽取三类稳定事实:根类型、节点数与类型数、以及动画时长/名称。
|
||
*
|
||
* 解析失败或不是这种形状时返回 `null`,调用方回退到「显示前几行文本」,不猜内容。
|
||
*/
|
||
export function projectResourceCocosStructureSummary(
|
||
content: string,
|
||
): string | null {
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(content);
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!Array.isArray(parsed)) {
|
||
return null;
|
||
}
|
||
const entries = parsed.filter(
|
||
(entry): entry is Record<string, unknown> =>
|
||
typeof entry === 'object' && entry !== null && !Array.isArray(entry),
|
||
);
|
||
const typeCounts = new Map<string, number>();
|
||
for (const entry of entries) {
|
||
const type = entry['__type__'];
|
||
if (typeof type === 'string' && type) {
|
||
typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1);
|
||
}
|
||
}
|
||
const rootType = entries
|
||
.map((entry) => entry['__type__'])
|
||
.find(
|
||
(type): type is string => typeof type === 'string' && type.length > 0,
|
||
);
|
||
if (!rootType) {
|
||
return null;
|
||
}
|
||
const parts: string[] = [rootType];
|
||
const nodeCount = typeCounts.get('cc.Node') ?? 0;
|
||
if (nodeCount > 0) {
|
||
parts.push(`${nodeCount} 个节点`);
|
||
}
|
||
const duration = entries
|
||
.map((entry) => entry['_duration'])
|
||
.find((value): value is number => typeof value === 'number' && value > 0);
|
||
if (duration !== undefined) {
|
||
parts.push(`${duration.toFixed(2)} 秒`);
|
||
}
|
||
const name = entries
|
||
.map((entry) => entry['_name'])
|
||
.find(
|
||
(value): value is string => typeof value === 'string' && value.length > 0,
|
||
);
|
||
if (name) {
|
||
parts.push(name);
|
||
}
|
||
const assetReferences = entries.filter(
|
||
(entry) => entry['__uuid__'] !== undefined,
|
||
).length;
|
||
if (assetReferences > 0) {
|
||
parts.push(`${assetReferences} 处资源引用`);
|
||
}
|
||
parts.push(`${typeCounts.size} 种类型`);
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
/**
|
||
* 引擎序列化资源的卡面预览文本:优先结构摘要,其次前几行正文。
|
||
*
|
||
* `content` 为 `undefined`(原生读取判定的二进制变体)时返回空串,卡面画类型卡。
|
||
*/
|
||
export function projectResourceStructuredPreviewText(
|
||
content: string | undefined,
|
||
): string {
|
||
if (content === undefined) {
|
||
return '';
|
||
}
|
||
return (
|
||
projectResourceCocosStructureSummary(content) ??
|
||
projectResourceDocumentPreviewText(content, false)
|
||
);
|
||
}
|