Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx
T
kdletters 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
资源画布支持引擎资源预览与模型交互预览 (#413)
## 目的

资源画布支持引擎(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
2026-09-17 20:28:39 +08:00

218 lines
7.3 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 {
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>
);
}