d5743cd4b8
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
## 目的 资源画布支持引擎(Cocos Creator)资源的**只读预览**:能被发现、登记、进入画布,并按类型出预览。 ## 主要改动 - 发现层识别 Cocos 资源并区分 `model` / `binary` 两个发现类别;`.meta` 等导入侧车文件仍只可发现 - 登记层按扩展名写入**既有** canonical kind(模型/场景/预制体 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`),不新增 manifest 契约字段 - 引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进入发现结果(仅当目录确实是 Cocos 工程),同名目录在非引擎工程里照常列出 - 资源画布新增三个卡面分支:模型缩略图(整页共用一个 WebGL 上下文)、结构摘要(Cocos 序列化资源)、类型卡(客户端解不了的容器) - 新增模型放大预览浮层:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角 - 模型预览上限 32 MiB;缩略图按布局尺寸 2 倍超采样,缓存键改用稳定身份 - 引擎图像容器(tga/tif/tiff/hdr)在原生侧转码成 PNG 后复用既有图片预览链路 - 修复资源卡状态边框吃掉内容盒导致的卡面与角标位移 - 同步 Agent 提示词与 AGC 技能文档,并补里程碑与踩坑文档 ## 验证 - `cargo check`、`cargo fmt --check` - Rust 定向:`cargo test … cocos` 9 条、`resource_inspect::tests` 7 条、`agent_asset_import_tests` 9 条、生成目录过滤用例 - 前端:`resource` + `project` 套件 52 文件 / 546 用例;`appSurface` 450 通过 / 20 跳过;app `tsc --noEmit` - `npm run check:encoding`、`git diff --check`、`npm run check:doc-index` - 真机:在真实客户端内打开本地 Cocos 夹具工程,逐栏核对模型缩略图 / 结构摘要 / TGA 转码 / 类型卡,并量过卡片在指针移开、悬停、选中三态下几何完全一致 ## 未验证 - 模型缩略图与放大预览的真机视觉只覆盖自带夹具工程;多文件 glTF(外部 .bin/贴图)与超大模型仍是类型卡 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/413
218 lines
7.3 KiB
TypeScript
218 lines
7.3 KiB
TypeScript
import {
|
||
FileCode2,
|
||
Image as ImageIcon,
|
||
Music2,
|
||
SlidersHorizontal,
|
||
Video,
|
||
} from 'lucide-react';
|
||
import { useEffect, useRef } from 'react';
|
||
|
||
import {
|
||
projectResourceCardPreviewKind,
|
||
type ProjectResourceCardPreviewState,
|
||
projectResourceCardPreviewVariant,
|
||
projectResourceJsonPresentation,
|
||
projectResourcePathExtension,
|
||
projectResourceStructuredPreviewText,
|
||
} from './resourceCardPreviewModel';
|
||
import { ResourceModelPreview } from './ResourceModelPreview';
|
||
import { resourceModelThumbnailIdentity } from './resourceModelThumbnail';
|
||
import type { ProjectResource } from './resourceProjectionModel';
|
||
import { projectResourceTypeLabel } from './resourceProjectionModel';
|
||
|
||
type ResourcePreviewMediaProps = {
|
||
/**
|
||
* 该条目对应的资源投影。`null` 表示宿主给不出投影(候选条目理论上都来自资源投影,
|
||
* 这里只做兜底):此时只渲染类型占位,不注册预览、不请求读取。
|
||
*/
|
||
resource: ProjectResource | null;
|
||
/**
|
||
* 资源卡预览身份(`useProjectResourceCardPreviews().identityByResourceId` 的取值)。
|
||
*
|
||
* 身份缺失时同样退化为类型占位:拿不到身份就无法进入预览队列,硬挂 `<img>` 只会是破图。
|
||
*/
|
||
previewIdentity: string | null;
|
||
/** 与资源卡同源的预览状态快照。 */
|
||
preview: ProjectResourceCardPreviewState;
|
||
/**
|
||
* 把本卡元素登记进资源卡预览管线(`useProjectResourceCardPreviews().observePreview`)。
|
||
*
|
||
* 登记即复核:`observePreview` 注册后立刻按几何判一次可见性,所以弹窗里的可见候选
|
||
* 开箱即进同一条队列;滚动等后续几何变化由宿主的兜底扫描补齐。
|
||
*/
|
||
onObservePreview: (
|
||
element: HTMLElement,
|
||
resource: ProjectResource,
|
||
identity: string,
|
||
) => () => void;
|
||
onPreviewDecodeError: (identity: string, error: string) => void;
|
||
/**
|
||
* 资源投影缺失时的类型占位口径(宿主条目自带的媒体类型,取值域是宿主的 `EditorAsset`)。
|
||
* 缺省按图片处理,与资源卡对未知类型的占位一致。
|
||
*/
|
||
fallbackMediaType?: string;
|
||
};
|
||
|
||
function resourcePreviewPlaceholderIcon(mediaType: string | undefined) {
|
||
if (mediaType === 'audio') {
|
||
return <Music2 className="h-5 w-5" aria-hidden="true" />;
|
||
}
|
||
if (mediaType === 'video') {
|
||
return <Video className="h-5 w-5" aria-hidden="true" />;
|
||
}
|
||
return <ImageIcon className="h-5 w-5" aria-hidden="true" />;
|
||
}
|
||
|
||
/**
|
||
* 弹窗类宿主(portal 里)的资源缩略图。
|
||
*
|
||
* 走的是**资源卡同一条预览管线**:身份取自 `identityByResourceId`、状态取自 `previews`、
|
||
* 可见性通过 `observePreview` 登记进同一张 `observedCards` 表。没有任何第二条读取通路:
|
||
* 这里只用管线给的 `sourceUrl` 渲染,读不到就退回类型占位(与资源卡的占位语义一致)。
|
||
*
|
||
* `data-resource-preview-status` 与资源卡一样暴露"为什么没有图":`idle` / `loading` /
|
||
* `failed` 在界面上都是占位图标,排障时需要能直接分辨。
|
||
*/
|
||
export function ResourcePreviewMedia({
|
||
resource,
|
||
previewIdentity,
|
||
preview,
|
||
onObservePreview,
|
||
onPreviewDecodeError,
|
||
fallbackMediaType,
|
||
}: ResourcePreviewMediaProps) {
|
||
const mediaRef = useRef<HTMLSpanElement>(null);
|
||
|
||
useEffect(() => {
|
||
const element = mediaRef.current;
|
||
if (!element || !resource || !previewIdentity) {
|
||
return undefined;
|
||
}
|
||
return onObservePreview(element, resource, previewIdentity);
|
||
}, [onObservePreview, previewIdentity, resource]);
|
||
|
||
const kind = resource ? projectResourceCardPreviewKind(resource) : null;
|
||
/**
|
||
* 代码文件走与资源卡一致的分流:不画内容(本组件本来也只画缩略图 / 占位),
|
||
* 占位图标换成代码图标,免得弹窗里代码文件看起来像"读不出来的图片"。
|
||
*/
|
||
const previewVariant = resource
|
||
? projectResourceCardPreviewVariant(resource)
|
||
: null;
|
||
const sourceUrl =
|
||
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
|
||
const engineBadgeLabel = (() => {
|
||
if (!resource) {
|
||
return '引擎资源';
|
||
}
|
||
const extension = projectResourcePathExtension(resource.path);
|
||
const typeLabel = projectResourceTypeLabel(resource);
|
||
return extension ? `${typeLabel} · ${extension.toUpperCase()}` : typeLabel;
|
||
})();
|
||
const visual = (() => {
|
||
// 引擎资源的三个分支与资源卡同源(判据都来自 `projectResourceCardPreviewKind`),
|
||
// 差别只在尺寸:面板里的缩略图更大,因此模型缩略图按面板几何渲染。
|
||
if (kind === 'model') {
|
||
return sourceUrl ? (
|
||
<ResourceModelPreview
|
||
sourceUrl={sourceUrl}
|
||
mediaType={
|
||
preview.status === 'loaded' ? preview.preview.mediaType : ''
|
||
}
|
||
identity={
|
||
resource
|
||
? resourceModelThumbnailIdentity({
|
||
resourceKey: resource.manifestAssetId ?? resource.id,
|
||
path: resource.path,
|
||
byteLen:
|
||
preview.status === 'loaded' ? preview.preview.byteLen : 0,
|
||
})
|
||
: 'engine-model'
|
||
}
|
||
fallbackLabel={engineBadgeLabel}
|
||
/>
|
||
) : (
|
||
<span className="game-resource-card-engine-label">
|
||
{engineBadgeLabel}
|
||
</span>
|
||
);
|
||
}
|
||
if (kind === 'structured' || kind === 'binary') {
|
||
const structuredText =
|
||
kind === 'structured'
|
||
? projectResourceStructuredPreviewText(
|
||
preview.status === 'loaded' ? preview.preview.content : undefined,
|
||
)
|
||
: '';
|
||
return structuredText ? (
|
||
<span className="game-resource-card-structured-text">
|
||
{structuredText}
|
||
</span>
|
||
) : (
|
||
<span className="game-resource-card-engine-label">
|
||
{engineBadgeLabel}
|
||
</span>
|
||
);
|
||
}
|
||
const jsonPresentation = resource
|
||
? projectResourceJsonPresentation(resource, preview)
|
||
: null;
|
||
if (jsonPresentation) {
|
||
return jsonPresentation === 'ui-design' ? (
|
||
<SlidersHorizontal className="h-5 w-5" aria-label="UI 设计" />
|
||
) : (
|
||
<FileCode2 className="h-5 w-5" aria-label="JSON" />
|
||
);
|
||
}
|
||
if (sourceUrl && (kind === 'raster-image' || kind === 'media-image')) {
|
||
return (
|
||
<img
|
||
src={sourceUrl}
|
||
alt=""
|
||
className="h-full w-full object-cover"
|
||
decoding="async"
|
||
onError={() => {
|
||
if (previewIdentity) {
|
||
onPreviewDecodeError(
|
||
previewIdentity,
|
||
'美术资源无法解码,请重新生成或替换该资源',
|
||
);
|
||
}
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
if (sourceUrl && kind === 'video') {
|
||
return (
|
||
<video
|
||
src={sourceUrl}
|
||
className="h-full w-full object-cover"
|
||
preload="metadata"
|
||
muted
|
||
playsInline
|
||
/>
|
||
);
|
||
}
|
||
if (previewVariant === 'code') {
|
||
return <FileCode2 className="h-5 w-5" aria-hidden="true" />;
|
||
}
|
||
return resourcePreviewPlaceholderIcon(
|
||
kind === 'video'
|
||
? 'video'
|
||
: kind === 'audio'
|
||
? 'audio'
|
||
: fallbackMediaType,
|
||
);
|
||
})();
|
||
|
||
return (
|
||
<span
|
||
ref={mediaRef}
|
||
className="game-resource-replacement-media"
|
||
data-resource-preview-status={preview.status}
|
||
>
|
||
{visual}
|
||
</span>
|
||
);
|
||
}
|