48caa23997
canvas生成与运行时统一使用共享GameCreationAppAssetKind 删除平台kind兼容映射与退役kind拒绝测试 更新资源kind审计里程碑与绑定文档
546 lines
18 KiB
TypeScript
546 lines
18 KiB
TypeScript
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());
|
||
}
|