6f09bf3a00
- 契约层新增 PROJECT_RESOURCE_CANVAS_SECTIONS:6 类资产分类加末尾独立的项目版本栏目,并补齐分区类型 - 契约层新增旧四 / 五栏目取值白名单、Persisted 联合类型与两个类型守卫 - Rust 契约 ProjectResourceCanvasSection 增补 7 个现行 variant 并保留 Code / Art 旧值,新增同名分区常量 - Rust 契约不升 game-creator-resource-layout.v1 版本、不给未知 section 加兜底,损坏 payload 继续失败关闭 - 新增 resourceCanvasSectionMapping.ts:旧栏目到现行分区的映射表、回落栏目与读时归一化 - 读时归一化只改写 section,x / y / manuallyPlaced 原样保留,无法归并时沿用既有丢弃行为 - resourceProjectionModel 把扩展名分类器更名为 projectedResourceKind 并收敛为准入与显示类型 - resourceProjectionModel 新增 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类 - Rust resource_layout 测试夹具改用现行分区值,并新增既有旧 section sidecar 仍可读取的用例 - 新增 resourceCanvasSectionMapping 测试:旧值乘目标栏目矩阵覆盖坐标保留、section 改写、changed 判定与回落列不变量 - projectResourceProjectionModel 测试新增 7 栏分区投影、项目版本独立成栏与只登记游戏代码项目落在待归类 - 同步 PRD、GameAgent 资源自由画板技术方案、AGC 实施计划与共享记忆决策记录的分区口径
421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
import {
|
|
type GameCreationAppAssetCategory,
|
|
gameCreationAppAssetCategory,
|
|
gameCreationAppAssetTags,
|
|
type GameCreationAppManifest,
|
|
type GameIterationVersion,
|
|
type ProjectResourceCanvasCategory,
|
|
type ProjectResourceCanvasSection,
|
|
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
|
|
|
export type ProjectResourceCategory = ProjectResourceCanvasSection;
|
|
|
|
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;
|
|
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`。
|
|
*/
|
|
export function projectedResourceKind(input: {
|
|
path: string;
|
|
mediaType: string;
|
|
kind?: string;
|
|
}): ProjectedResourceKind | null {
|
|
const normalizedPath = input.path.trim().toLowerCase();
|
|
const normalizedMediaType = input.mediaType.trim().toLowerCase();
|
|
const normalizedKind = input.kind?.trim().toLowerCase() ?? '';
|
|
|
|
if (
|
|
normalizedMediaType.startsWith('audio/') ||
|
|
audioExtension.test(normalizedPath) ||
|
|
audioKind.test(normalizedKind)
|
|
) {
|
|
return 'audio';
|
|
}
|
|
if (
|
|
normalizedMediaType.startsWith('image/') ||
|
|
normalizedMediaType.startsWith('video/') ||
|
|
artExtension.test(normalizedPath) ||
|
|
artKind.test(normalizedKind)
|
|
) {
|
|
return 'art';
|
|
}
|
|
if (
|
|
normalizedMediaType === 'text/html' ||
|
|
normalizedMediaType === 'text/css' ||
|
|
normalizedMediaType.includes('javascript') ||
|
|
normalizedMediaType.includes('typescript') ||
|
|
gameCodeExtension.test(normalizedPath) ||
|
|
gameCodeKind.test(normalizedKind)
|
|
) {
|
|
return 'code';
|
|
}
|
|
if (
|
|
normalizedMediaType.includes('json') ||
|
|
normalizedMediaType.includes('yaml') ||
|
|
normalizedMediaType.startsWith('text/') ||
|
|
documentExtension.test(normalizedPath)
|
|
) {
|
|
return 'document';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
'category' | 'subtype' | 'path' | 'mediaType'
|
|
>,
|
|
): ProjectResourceTypeLabel {
|
|
const subtype = resource.subtype.trim().toLowerCase();
|
|
const path = resource.path.trim().toLowerCase();
|
|
const mediaType = resource.mediaType.trim().toLowerCase();
|
|
|
|
if (resource.category === 'version' || subtype === 'project-version') {
|
|
return '项目版本';
|
|
}
|
|
if (subtype === 'agent-result') {
|
|
return 'Agent 回执';
|
|
}
|
|
if (resource.category === 'code') {
|
|
return '游戏代码';
|
|
}
|
|
if (mediaType === 'image/svg+xml' || /\.svg$/iu.test(path)) {
|
|
return 'SVG';
|
|
}
|
|
if (
|
|
resource.category === 'audio' ||
|
|
mediaType.startsWith('audio/') ||
|
|
audioExtension.test(path)
|
|
) {
|
|
return '音频';
|
|
}
|
|
if (mediaType.startsWith('video/') || /\.(mp4|webm|mov)$/iu.test(path)) {
|
|
return '视频';
|
|
}
|
|
if (resource.category === 'document' || mediaType.startsWith('text/')) {
|
|
return '文档';
|
|
}
|
|
if (resource.category === 'art' || mediaType.startsWith('image/')) {
|
|
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 category = projectedResourceKind({ path, mediaType: '' });
|
|
if (!category) {
|
|
continue;
|
|
}
|
|
resources.push({
|
|
id: `task:${task.id}:${path}`,
|
|
category,
|
|
subtype: 'task-artifact',
|
|
label: fileName(path),
|
|
path,
|
|
mediaType:
|
|
category === 'document'
|
|
? '项目文档'
|
|
: category === 'code'
|
|
? '游戏代码'
|
|
: category === '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 category = projectedResourceKind({
|
|
path: asset.localPath,
|
|
mediaType: asset.mediaType,
|
|
kind: asset.kind,
|
|
});
|
|
if (!category) {
|
|
continue;
|
|
}
|
|
const isPendingUiPrototype =
|
|
asset.kind === 'ui-prototype' &&
|
|
taskById.get('design-foundation')?.status !== 'completed';
|
|
resources.push({
|
|
id: `asset:${asset.id}`,
|
|
category,
|
|
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: gameCreationAppAssetCategory(asset),
|
|
assetTags: gameCreationAppAssetTags(asset),
|
|
imageSequenceFrames: asset.imageSequenceFrames ?? null,
|
|
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? null,
|
|
});
|
|
}
|
|
|
|
for (const attachment of attachments) {
|
|
if (attachment.status !== 'imported' || !attachment.localPath) {
|
|
continue;
|
|
}
|
|
const category = projectedResourceKind({
|
|
path: attachment.localPath,
|
|
mediaType: attachment.mediaType,
|
|
});
|
|
if (!category) {
|
|
continue;
|
|
}
|
|
resources.push({
|
|
id: `attachment:${attachment.localPath}`,
|
|
category,
|
|
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: 'document',
|
|
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: '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());
|
|
}
|