Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
T
k88936 48caa23997 收紧AGC资源kind枚举边界
canvas生成与运行时统一使用共享GameCreationAppAssetKind

删除平台kind兼容映射与退役kind拒绝测试

更新资源kind审计里程碑与绑定文档
2026-09-19 01:28:27 +08:00

546 lines
18 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 {
GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
type GameCreationAppAssetKind,
gameCreationAppAssetTags,
type GameCreationAppManifest,
type GameIterationVersion,
isGameCreationAppAssetKind,
type ProjectResourceCanvasCategory,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ProjectResourceCategory = ProjectResourceCanvasCategory;
export type ProjectAttachmentResult = {
fileName: string;
mediaType: string;
localPath?: string;
status: 'imported' | 'failed';
error?: string;
};
export type ProjectAgentResultSummary = {
agentId: string;
runId: string;
label: string;
title: string;
content: string;
updatedAt: number;
};
export type ProjectVersionResourceSummary = GameIterationVersion & {
label: string;
childVersionIds: string[];
};
export type ProjectResource = {
id: string;
category: ProjectResourceCategory;
subtype: string;
label: string;
path: string;
mediaType: string;
sourceLabel: string;
taskTitle: string | null;
manifestAssetId: string | null;
producerTaskId: string | null;
externalResourceId: string | null;
referenceResourceIds: string[];
dependencies: string[];
dependencyDepth: number;
content?: string;
version?: ProjectVersionResourceSummary;
/**
* manifest 资产条目的功能分类与自定义标签投影。只有登记在 manifest 的资产才有该口径;
* 任务产物、附件、Agent 回执与项目版本不是 manifest 资产,按筛选口径归入 `待归类`。
*/
assetCategory?: GameCreationAppAssetCategory;
assetTags?: string[];
imageSequenceFrames?: Array<{
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
}> | null;
imageSequenceDurationMs?: number | null;
};
export type ProjectResourceTypeLabel =
| '图片'
| 'SVG'
| '视频'
| '音频'
| '文档'
| '任务产物'
| 'Agent 回执'
| '项目版本'
| '游戏代码'
| '模型'
| '动画'
| '骨骼动画'
| '场景'
| '预制体'
| '瓦片地图'
| '材质'
| '特效'
| '图集'
| '位图字体'
| '纹理'
| '自动图集'
| '音频片段'
| '二进制'
| '未知';
const documentExtension =
/\.(md|markdown|mdx|txt|json|ya?ml|toml|csv|ini|conf|xml)$/iu;
const gameCodeExtension =
/\.(html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|vue|svelte)$/iu;
const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu;
const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu;
/*
* 引擎(Cocos 3.8.8)资源扩展名。四组扩展名与原生侧的三份表
* (`bridge_project_file_class` / `agent_local_project_file_type` / `prompt_context_media_type`)
* 必须同步:少一个扩展名在这里,资源就会「登记得了、画布不显示」,而且没有任何报错。
*/
/** 三维模型与网格数据。 */
const engineModelExtension = /\.(glb|gltf|fbx|mesh|skeleton)$/iu;
/** 图像容器(浏览器解不了,原生侧转码成 PNG 后按图片显示)。 */
const engineImageContainerExtension = /\.(tga|tif|tiff|hdr|exr|psd|znt)$/iu;
/** 材质与特效:登记为 `code`,卡面另行按结构预览。 */
const engineMaterialExtension = /\.(mtl|material|pmtl|effect|chunk)$/iu;
/** 场景、动画、图集与容器:登记为 `document`,卡面按结构预览或类型卡。 */
const engineStructuredExtension =
/\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|tmx|terrain|plist|labelatlas|atlas|fnt|pac|dbbin|bin|skel|texture|cubemap|rt)$/iu;
const gameCodeKind =
/(?:^|[-_])(game-(?:entry|style|script)|code|source)(?:$|[-_])/iu;
const artKind =
/(?:^|[-_])(art|animation|character|icon|image|scene|sprite|spritesheet|ui|video)(?:$|[-_])/iu;
const audioKind = /(?:^|[-_])(audio|bgm|music|sfx|sound|voice)(?:$|[-_])/iu;
function fileName(path: string) {
return path.split(/[\\/]/u).filter(Boolean).pop() || path;
}
export type ProjectedResourceKind = 'audio' | 'art' | 'code' | 'document';
/**
* 资源类型判定:按 mediaType、扩展名与 manifest `kind` 判定资源类型。
*
* 只有能判定类型的资源才进入资源画布:`.zip` / `.exe` 等无法识别的二进制产物与附件返回
* `null`,卡片显示类型复用同一口径。画布分区口径见 `projectResourceCanvasCategory`。
*
* 判定分两趟,**顺序是这条函数的核心合同**:
*
* 1. 第一趟只看 `mediaType` 与路径扩展名——它们是文件内容的可验证证据;
* 2. 第二趟才看 `kind`——它是登记时的标签,可能过时、可能宽泛。
*
* 为什么不能把 `kind` 混进第一趟:登记标签不能覆盖可验证的内容类型。一旦 `kind` 与
* mediaType 同权,界面规格就会被判成 `art`,
* 于是卡面角标按 `art` 派生显示「图片」,而预览调度按 `art` 走图像分支、
* 又因 mediaType 不是图像而兜底成 `placeholder` 且**永不发起读取** ——
* 出现「标着图片、却是占位、没有任何报错」的分叉。分趟后 JSON/HTML 这类
* 有明确文档 / 代码 mediaType 的资产不会再被 `kind` 顶掉。
*/
export function projectedResourceKind(input: {
path: string;
mediaType: string;
kind?: GameCreationAppAssetKind;
}): ProjectedResourceKind | null {
const normalizedPath = input.path.trim().toLowerCase();
const normalizedMediaType = input.mediaType.trim().toLowerCase();
const normalizedKind = input.kind?.trim().toLowerCase() ?? '';
// 第一趟:mediaType 与扩展名(内容证据,权威)。
if (
normalizedMediaType.startsWith('audio/') ||
audioExtension.test(normalizedPath)
) {
return 'audio';
}
if (
normalizedMediaType.startsWith('image/') ||
normalizedMediaType.startsWith('video/') ||
artExtension.test(normalizedPath) ||
engineModelExtension.test(normalizedPath) ||
engineImageContainerExtension.test(normalizedPath)
) {
return 'art';
}
// 引擎材质 / 特效排在文档之前:它们的 mediaType 多为 `application/json`,
// 一旦先落到文档分支,画布分类就和登记时的 kind 对不上。
if (engineMaterialExtension.test(normalizedPath)) {
return 'code';
}
if (
normalizedMediaType === 'text/html' ||
normalizedMediaType === 'text/css' ||
normalizedMediaType.includes('javascript') ||
normalizedMediaType.includes('typescript') ||
gameCodeExtension.test(normalizedPath)
) {
return 'code';
}
if (
normalizedMediaType.includes('json') ||
normalizedMediaType.includes('yaml') ||
normalizedMediaType.startsWith('text/') ||
documentExtension.test(normalizedPath) ||
engineStructuredExtension.test(normalizedPath)
) {
return 'document';
}
// 第二趟:登记标签兜底。只在 mediaType 与扩展名都给不出结论时生效,
// 保证「有明确 mediaType 的资产」永远不被宽泛的 `kind` 改写类型。
if (audioKind.test(normalizedKind)) {
return 'audio';
}
if (gameCodeKind.test(normalizedKind)) {
return 'code';
}
if (artKind.test(normalizedKind)) {
return 'art';
}
return null;
}
/**
* 资源显示类型判定:与准入使用同一套扩展名 / mediaType / manifest `kind` 口径。
*
* manifest 资产的 `subtype` 就是 `asset.kind`,任务产物、附件与回执使用各自的稳定
* 人造 subtype,不会命中 kind 规则,因此这里可以直接透传。
*/
export function projectResourceDisplayKind(
resource: Pick<ProjectResource, 'path' | 'mediaType' | 'subtype'>,
): ProjectedResourceKind | null {
return projectedResourceKind({
path: resource.path,
mediaType: resource.mediaType,
kind: isGameCreationAppAssetKind(resource.subtype)
? resource.subtype
: undefined,
});
}
/**
* Converts an already projected resource into short user-facing type text.
* This is deliberately display-only: it derives from existing projection
* fields and does not add a backend/read-model attribute.
*/
export function projectResourceTypeLabel(
resource: Pick<ProjectResource, 'subtype' | 'path' | 'mediaType'>,
): ProjectResourceTypeLabel {
const subtype = resource.subtype.trim().toLowerCase();
const path = resource.path.trim().toLowerCase();
const mediaType = resource.mediaType.trim().toLowerCase();
const kind = projectResourceDisplayKind(resource);
if (subtype === 'project-version') {
return '项目版本';
}
if (subtype === 'agent-result') {
return 'Agent 回执';
}
/*
* 引擎资源的类型角标先按扩展名判定。
*
* 它们在 manifest 里复用的是既有 canonical kind(模型/场景 → `scene`,动画 →
* `character-animation`,材质/特效 → `code`,容器 → `document`),只用 kind 反推
* 会把这些资源一律说成「图片」「文档」或「游戏代码」,用户看不出这是什么。
*/
if (/\.(glb|gltf|fbx|mesh)$/iu.test(path)) {
return '模型';
}
if (/\.(anim|animation|animgraph|animgraphvari|animask)$/iu.test(path)) {
return '动画';
}
if (/\.(skeleton|skel|dbbin)$/iu.test(path)) {
return '骨骼动画';
}
if (/\.(scene|fire|terrain)$/iu.test(path)) {
return '场景';
}
if (/\.prefab$/iu.test(path)) {
return '预制体';
}
if (/\.tmx$/iu.test(path)) {
return '瓦片地图';
}
if (/\.(mtl|material|pmtl)$/iu.test(path)) {
return '材质';
}
if (/\.(effect|chunk)$/iu.test(path)) {
return '特效';
}
if (/\.(plist|atlas|labelatlas)$/iu.test(path)) {
return '图集';
}
if (/\.fnt$/iu.test(path)) {
return '位图字体';
}
if (/\.(texture|cubemap|rt)$/iu.test(path)) {
return '纹理';
}
if (/\.pac$/iu.test(path)) {
return '自动图集';
}
if (/\.pcm$/iu.test(path)) {
return '音频片段';
}
if (/\.bin$/iu.test(path)) {
return '二进制';
}
if (kind === 'code') {
return '游戏代码';
}
if (mediaType === 'image/svg+xml' || /\.svg$/iu.test(path)) {
return 'SVG';
}
if (kind === 'audio') {
return '音频';
}
if (mediaType.startsWith('video/') || /\.(mp4|webm|mov)$/iu.test(path)) {
return '视频';
}
if (kind === 'document') {
return '文档';
}
if (kind === 'art') {
return '图片';
}
if (subtype === 'task-artifact') {
return '任务产物';
}
return '未知';
}
/**
* 资源画布筛选口径:只有 manifest 资产携带功能分类,其余投影资源(任务产物、附件、
* Agent 回执与项目版本)没有 `category` 事实源,统一归 `待归类`,不按扩展名另造分类。
*/
export function projectResourceAssetCategory(
resource: Pick<ProjectResource, 'assetCategory'>,
): GameCreationAppAssetCategory {
return resource.assetCategory ?? 'unclassified';
}
/**
* 资源画布分区口径:manifest 资产的 6 类功能分类,加上末尾独立的「项目版本」栏目。
*
* 项目版本不是资源资产,6 类资产分类轴对它不适用,固定单独成栏。Agent 文本回执是文本
* 成果,保持在「文档」栏目。其余非 manifest 资源(任务产物、附件)没有 `category` 事实源,
* 统一归 `待归类`,不按扩展名另造分区。
*/
export function projectResourceCanvasCategory(
resource: Pick<ProjectResource, 'subtype' | 'assetCategory'>,
): ProjectResourceCanvasCategory {
if (resource.subtype === 'project-version') {
return 'version';
}
if (resource.subtype === 'agent-result') {
return 'document';
}
return projectResourceAssetCategory(resource);
}
function resourcePriority(resource: ProjectResource) {
if (resource.manifestAssetId) {
return 4;
}
if (resource.id.startsWith('attachment:')) {
return 3;
}
if (resource.id.startsWith('task:')) {
return 2;
}
return 1;
}
export function projectResourcesFromReadModels(
manifest: GameCreationAppManifest,
attachments: ProjectAttachmentResult[],
agentResults: ProjectAgentResultSummary[],
) {
const taskById = new Map(manifest.tasks.map((task) => [task.id, task]));
const resources: ProjectResource[] = [];
for (const task of manifest.tasks) {
if (task.status !== 'completed') {
continue;
}
for (const path of task.artifacts) {
const kind = projectedResourceKind({ path, mediaType: '' });
if (!kind) {
continue;
}
resources.push({
id: `task:${task.id}:${path}`,
category: projectResourceCanvasCategory({ subtype: 'task-artifact' }),
subtype: 'task-artifact',
label: fileName(path),
path,
mediaType:
kind === 'document'
? '项目文档'
: kind === 'code'
? '游戏代码'
: kind === 'art'
? '美术产物'
: '音乐音效产物',
sourceLabel: '任务产物',
taskTitle: task.title,
manifestAssetId: null,
producerTaskId: task.id,
externalResourceId: null,
referenceResourceIds: [],
dependencies: task.dependencies,
dependencyDepth: 0,
});
}
}
for (const asset of manifest.assets) {
const kind = projectedResourceKind({
path: asset.localPath,
mediaType: asset.mediaType,
kind: asset.kind,
});
if (!kind) {
continue;
}
const assetCategory = gameCreationAppAssetCategory(asset);
const isPendingUiPrototype =
asset.kind === GAME_CREATION_APP_UI_DESIGN_ASSET_KIND &&
taskById.get('design-foundation')?.status !== 'completed';
resources.push({
id: `asset:${asset.id}`,
category: projectResourceCanvasCategory({
subtype: asset.kind,
assetCategory,
}),
subtype: asset.kind,
label: `${fileName(asset.localPath)}${
isPendingUiPrototype ? '(待视觉验收)' : ''
}`,
path: asset.localPath,
mediaType: asset.mediaType,
sourceLabel:
isPendingUiPrototype && asset.source.kind === 'canvas'
? '画板 · 候选界面图'
: asset.source.kind === 'canvas'
? '画板'
: asset.source.kind === 'generated'
? 'Agent 生成'
: '用户上传',
taskTitle: null,
manifestAssetId: asset.id,
producerTaskId: null,
externalResourceId: asset.source.resourceId ?? null,
referenceResourceIds: asset.source.referenceResourceIds ?? [],
dependencies: [],
dependencyDepth: 0,
assetCategory,
assetTags: gameCreationAppAssetTags(asset),
imageSequenceFrames: asset.imageSequenceFrames ?? null,
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? null,
});
}
for (const attachment of attachments) {
if (attachment.status !== 'imported' || !attachment.localPath) {
continue;
}
const kind = projectedResourceKind({
path: attachment.localPath,
mediaType: attachment.mediaType,
});
if (!kind) {
continue;
}
resources.push({
id: `attachment:${attachment.localPath}`,
category: projectResourceCanvasCategory({ subtype: 'attachment' }),
subtype: 'attachment',
label: attachment.fileName,
path: attachment.localPath,
mediaType: attachment.mediaType || '未知媒体类型',
sourceLabel: '用户上传',
taskTitle: null,
manifestAssetId: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
});
}
for (const result of agentResults) {
resources.push({
id: `agent-result:${result.agentId}:${result.runId}`,
category: projectResourceCanvasCategory({ subtype: 'agent-result' }),
subtype: 'agent-result',
label: result.title,
path: `专业 Agent 文本回执 · ${result.label}`,
mediaType: 'Agent 历史文本回执',
sourceLabel: `历史成果 · ${result.label}`,
taskTitle: null,
manifestAssetId: null,
producerTaskId: taskById.has(result.agentId) ? result.agentId : null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
content: result.content,
});
}
const childVersionIdsByParent = new Map<string, string[]>();
for (const version of manifest.versions ?? []) {
if (!version.parentVersionId) {
continue;
}
const children = childVersionIdsByParent.get(version.parentVersionId) ?? [];
children.push(version.versionId);
childVersionIdsByParent.set(version.parentVersionId, children);
}
for (const [index, manifestVersion] of (manifest.versions ?? []).entries()) {
const version: ProjectVersionResourceSummary = {
...manifestVersion,
label: `版本 ${index + 1}`,
childVersionIds:
childVersionIdsByParent.get(manifestVersion.versionId) ?? [],
};
resources.push({
id: `version:${version.versionId}`,
category: projectResourceCanvasCategory({ subtype: 'project-version' }),
subtype: 'project-version',
label: version.label,
path: `项目版本 · ${version.versionId}`,
mediaType: '正式项目版本',
sourceLabel: version.parentVersionId
? `项目修订 ${version.projectRevision} · 父版本 ${version.parentVersionId}`
: `项目修订 ${version.projectRevision} · 初始版本`,
taskTitle: null,
manifestAssetId: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
version,
});
}
const uniqueByPath = new Map<string, ProjectResource>();
for (const resource of resources) {
const existing = uniqueByPath.get(resource.path);
if (!existing || resourcePriority(resource) > resourcePriority(existing)) {
uniqueByPath.set(resource.path, resource);
}
}
return Array.from(uniqueByPath.values());
}