@vgai/editor 0.4.0 → 0.4.1-canary.20260715.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{axe-BJjvBeOZ.js → axe-DaRRWiOi.js} +1 -1
- package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-DLd8cEzW.js} +1 -1
- package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-BwRLsjpc.js} +1 -1
- package/dist/assets/{game-DeTGdgYt.js → game-Bi4xWV6Y.js} +1 -1
- package/dist/assets/{game-BCNN63uV.js → game-BlEDdPMm.js} +1 -1
- package/dist/assets/{game-BR0GBNgF.js → game-CKXTrtx6.js} +1 -1
- package/dist/assets/{game-B346oj_f.js → game-CjodSgcy.js} +1 -1
- package/dist/assets/{game-u6c2Patu.js → game-CpSujmHR.js} +1 -1
- package/dist/assets/{game-umrHIqFB.js → game-D5NFr6FT.js} +1 -1
- package/dist/assets/{game-CLNm2g0y.js → game-PKdSMKkV.js} +1 -1
- package/dist/assets/{game-DQxhCYsZ.js → game-dsUNL9SB.js} +1 -1
- package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-X827vWLi.js} +1 -1
- package/dist/assets/{game-source-DzE9taML.js → game-source-BjjzZVF9.js} +1 -1
- package/dist/assets/{index-CgcH1RpF.js → index-BLcaXrPd.js} +1 -1
- package/dist/assets/index-BtZ1fX9i.css +1 -0
- package/dist/assets/{index-DbQzGLxv.js → index-BvKfaQa-.js} +815 -815
- package/dist/assets/{index-Fc8Ezr20.js → index-kEMc_dJ8.js} +1 -1
- package/dist/assets/{index-D3tcNB2T.js → index-ou19ogsR.js} +1 -1
- package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-C2PS3-dY.js} +1 -1
- package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-BSS7Se0W.js} +1 -1
- package/dist/assets/{react-18-C4tVCii_.js → react-18-DqPett-m.js} +1 -1
- package/dist/assets/webworkerAll-njZ4iAXd.js +1 -0
- package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DjmEFkN0.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/packaged.mjs +26752 -883
- package/package.json +2 -2
- package/server/asset-history-snapshots.ts +189 -0
- package/server/asset-library-routes.ts +404 -53
- package/server/catalog-import-recipe.ts +97 -0
- package/server/catalog-reimport.ts +141 -0
- package/server/cloud-asset-catalog.ts +71 -5
- package/server/editor-server.ts +67 -5
- package/server/model-import-conversion.ts +345 -0
- package/server/project-output-writer.ts +412 -36
- package/src/asset-editor-context.tsx +9 -0
- package/src/asset-workflow/asset-capabilities.ts +21 -0
- package/src/asset-workflow/asset-import-jobs.ts +108 -0
- package/src/asset-workflow/import-contract.ts +2 -0
- package/src/asset-workflow/model-inspection.ts +92 -1
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +1 -0
- package/src/asset-workflow/project-asset-operations.ts +119 -4
- package/src/command-listener.ts +20 -3
- package/src/components/AssetBrowser.tsx +301 -26
- package/src/components/AssetImportDetails.tsx +207 -92
- package/src/components/AssetImportJobStatus.tsx +55 -0
- package/src/components/DefaultEditorLayout.tsx +4 -0
- package/src/components/OnlineAssetBrowser.tsx +197 -11
- package/src/components/WorkspaceDock.tsx +103 -17
- package/src/components/asset-documents.tsx +8 -0
- package/src/components/asset-editor-persistence.ts +300 -5
- package/src/components/asset-selection-section.tsx +1 -0
- package/src/components/asset-viewers/AudioViewer.tsx +4 -7
- package/src/components/asset-viewers/ModelViewer.tsx +168 -5
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +45 -9
- package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
- package/src/components/asset-workflow.css +26 -0
- package/src/components/workspace-dock.css +16 -0
- package/src/editor-api.ts +159 -22
- package/src/play-mode.ts +12 -3
- package/src/storage/handle-storage.ts +5 -1
- package/src/storage/http-storage.ts +5 -1
- package/src/storage/mem-storage.ts +1 -0
- package/src/storage/types.ts +2 -0
- package/src/workspace-dock-controller.ts +8 -0
- package/template/vgai.game.json +1 -1
- package/dist/assets/index-bAGhEnnO.css +0 -1
- package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
- package/dist/project-scripts.bundle.js +0 -3917
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
type AssetImportPlan,
|
|
6
|
+
assetImportPlanSchema,
|
|
7
|
+
importRecipePath,
|
|
8
|
+
} from '../src/asset-workflow/import-contract';
|
|
9
|
+
|
|
10
|
+
export interface CatalogRecipeSource {
|
|
11
|
+
stagedPath: string;
|
|
12
|
+
path: string;
|
|
13
|
+
url: string;
|
|
14
|
+
role: 'primary' | 'dependency' | 'material' | 'texture';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function writeCatalogImportRecipe(input: {
|
|
18
|
+
stagingDirectory: string;
|
|
19
|
+
source: string;
|
|
20
|
+
familyId: string;
|
|
21
|
+
variantId: string;
|
|
22
|
+
deliveryId: string;
|
|
23
|
+
displayName: string;
|
|
24
|
+
destination: string;
|
|
25
|
+
recipeRelativePath?: string;
|
|
26
|
+
sources: readonly CatalogRecipeSource[];
|
|
27
|
+
generatedFiles?: readonly string[];
|
|
28
|
+
contentKind?: AssetImportPlan['contentKind'];
|
|
29
|
+
targetSkeleton?: string;
|
|
30
|
+
runtimeReady: boolean;
|
|
31
|
+
backend: AssetImportPlan['backend'];
|
|
32
|
+
attribution: AssetImportPlan['attribution'];
|
|
33
|
+
settings?: Partial<AssetImportPlan['settings']>;
|
|
34
|
+
}): Promise<AssetImportPlan> {
|
|
35
|
+
const sourceFiles = await Promise.all(
|
|
36
|
+
input.sources.map(async (file) => {
|
|
37
|
+
const [bytes, info] = await Promise.all([readFile(file.stagedPath), stat(file.stagedPath)]);
|
|
38
|
+
return {
|
|
39
|
+
path: file.path,
|
|
40
|
+
url: file.url,
|
|
41
|
+
hash: createHash('sha256').update(bytes).digest('hex'),
|
|
42
|
+
size: info.size,
|
|
43
|
+
role: file.role,
|
|
44
|
+
};
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
const sourceHash = createHash('sha256')
|
|
48
|
+
.update(
|
|
49
|
+
JSON.stringify(
|
|
50
|
+
sourceFiles
|
|
51
|
+
.map(({ path, hash, size }) => ({ path, hash, size }))
|
|
52
|
+
.sort((left, right) => left.path.localeCompare(right.path)),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
.digest('hex');
|
|
56
|
+
const plan = assetImportPlanSchema.parse({
|
|
57
|
+
version: 1,
|
|
58
|
+
id: randomUUID(),
|
|
59
|
+
identity: {
|
|
60
|
+
familyId: input.familyId,
|
|
61
|
+
variantId: input.variantId,
|
|
62
|
+
deliveryId: input.deliveryId,
|
|
63
|
+
source: input.source,
|
|
64
|
+
displayName: input.displayName,
|
|
65
|
+
},
|
|
66
|
+
sourceFiles,
|
|
67
|
+
generatedFiles: [...(input.generatedFiles ?? [])],
|
|
68
|
+
destination: input.destination,
|
|
69
|
+
contentKind: input.contentKind ?? 'model',
|
|
70
|
+
...(input.targetSkeleton ? { targetSkeleton: input.targetSkeleton } : {}),
|
|
71
|
+
settings: {
|
|
72
|
+
scale: 1,
|
|
73
|
+
sourceUnits: 'auto',
|
|
74
|
+
upAxis: 'auto',
|
|
75
|
+
forwardAxis: 'auto',
|
|
76
|
+
normals: 'preserve',
|
|
77
|
+
tangents: 'preserve',
|
|
78
|
+
materials: 'import',
|
|
79
|
+
textures: 'copy',
|
|
80
|
+
animations: [],
|
|
81
|
+
meshOptimization: 'safe',
|
|
82
|
+
retainSource: true,
|
|
83
|
+
...input.settings,
|
|
84
|
+
},
|
|
85
|
+
conflicts: [],
|
|
86
|
+
attribution: input.attribution,
|
|
87
|
+
sourceHash,
|
|
88
|
+
runtimeReady: input.runtimeReady,
|
|
89
|
+
backend: input.backend,
|
|
90
|
+
createdAt: new Date().toISOString(),
|
|
91
|
+
});
|
|
92
|
+
const recipeFile = input.recipeRelativePath ?? basename(importRecipePath(input.destination));
|
|
93
|
+
const recipePath = join(input.stagingDirectory, recipeFile);
|
|
94
|
+
await mkdir(dirname(recipePath), { recursive: true });
|
|
95
|
+
await writeFile(recipePath, `${JSON.stringify(plan, null, 2)}\n`);
|
|
96
|
+
return plan;
|
|
97
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { type AssetImportPlan, assetImportPlanSchema } from '../src/asset-workflow/import-contract';
|
|
4
|
+
import { type CatalogRecipeSource, writeCatalogImportRecipe } from './catalog-import-recipe';
|
|
5
|
+
import {
|
|
6
|
+
findLocalAsset,
|
|
7
|
+
findLocalAssetFamily,
|
|
8
|
+
getLocalAssetLibraryRoot,
|
|
9
|
+
resolveCatalogFile,
|
|
10
|
+
resolveLocalAssetThumbnail,
|
|
11
|
+
} from './local-asset-catalog';
|
|
12
|
+
import {
|
|
13
|
+
convertStagedModelToGlb,
|
|
14
|
+
SERVER_CONVERTIBLE_MODEL_FORMATS,
|
|
15
|
+
} from './model-import-conversion';
|
|
16
|
+
import { replaceStagedProjectDirectory } from './project-output-writer';
|
|
17
|
+
import { isPathInside } from './server-utils';
|
|
18
|
+
|
|
19
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: reacquisition, conversion, recipe regeneration, source-retention, and atomic replacement are one transaction boundary.
|
|
20
|
+
export async function reimportLocalCatalogAsset(
|
|
21
|
+
publicRoot: string,
|
|
22
|
+
assetPath: string,
|
|
23
|
+
rawPlan: AssetImportPlan,
|
|
24
|
+
): Promise<{ path: string; provenanceOperationId?: string }> {
|
|
25
|
+
const plan = assetImportPlanSchema.parse(rawPlan);
|
|
26
|
+
const normalizedAssetPath = assetPath.replace(/^\/+/, '');
|
|
27
|
+
if (normalizedAssetPath !== plan.destination || plan.identity.source !== 'local') {
|
|
28
|
+
throw new Error('Reimport identity does not match the selected local project asset.');
|
|
29
|
+
}
|
|
30
|
+
const family = await findLocalAssetFamily(plan.identity.familyId);
|
|
31
|
+
const variant = await findLocalAsset(plan.identity.variantId);
|
|
32
|
+
if (!family || !variant || variant.familyId !== family.id) {
|
|
33
|
+
throw new Error('The recorded catalog family/variant is no longer available.');
|
|
34
|
+
}
|
|
35
|
+
const sourceFile = variant.files[0];
|
|
36
|
+
if (!sourceFile) throw new Error('The recorded catalog variant has no source file.');
|
|
37
|
+
const destination = resolve(publicRoot, dirname(plan.destination));
|
|
38
|
+
if (!isPathInside(publicRoot, destination))
|
|
39
|
+
throw new Error('Reimport destination escapes public/.');
|
|
40
|
+
const staging = await mkdtemp(join(dirname(destination), `.${basename(destination)}.reimport-`));
|
|
41
|
+
try {
|
|
42
|
+
const libraryRoot = getLocalAssetLibraryRoot();
|
|
43
|
+
const primarySource = resolveCatalogFile(libraryRoot, sourceFile.path);
|
|
44
|
+
const primaryRelative = basename(primarySource);
|
|
45
|
+
const stagedPrimary = join(staging, primaryRelative);
|
|
46
|
+
await copyFile(primarySource, stagedPrimary);
|
|
47
|
+
const recipeSources: CatalogRecipeSource[] = [
|
|
48
|
+
{
|
|
49
|
+
stagedPath: stagedPrimary,
|
|
50
|
+
path: primaryRelative,
|
|
51
|
+
url: plan.sourceFiles[0]?.url ?? `vgai-local:${variant.id}:0`,
|
|
52
|
+
role: 'primary',
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
for (const dependency of sourceFile.dependencies ?? []) {
|
|
56
|
+
const dependencySource = resolveCatalogFile(libraryRoot, dependency);
|
|
57
|
+
const sourceRelative = relative(dirname(primarySource), dependencySource);
|
|
58
|
+
const dependencyRelative = sourceRelative.startsWith('..')
|
|
59
|
+
? basename(dependencySource)
|
|
60
|
+
: sourceRelative;
|
|
61
|
+
const destinationPath = join(staging, dependencyRelative);
|
|
62
|
+
await mkdir(dirname(destinationPath), { recursive: true });
|
|
63
|
+
await copyFile(dependencySource, destinationPath);
|
|
64
|
+
recipeSources.push({
|
|
65
|
+
stagedPath: destinationPath,
|
|
66
|
+
path: dependencyRelative.replaceAll('\\', '/'),
|
|
67
|
+
url: `catalog:${dependency}`,
|
|
68
|
+
role: dependencyRole(dependency),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const format = sourceFile.format.toLowerCase();
|
|
72
|
+
const resultFile = basename(plan.destination);
|
|
73
|
+
let backend: AssetImportPlan['backend'] = 'native';
|
|
74
|
+
if (SERVER_CONVERTIBLE_MODEL_FORMATS.has(format)) {
|
|
75
|
+
const output = await convertStagedModelToGlb(stagedPrimary, format, plan.settings);
|
|
76
|
+
await writeFile(join(staging, resultFile), output);
|
|
77
|
+
backend = 'threejs';
|
|
78
|
+
} else if (resultFile !== primaryRelative) {
|
|
79
|
+
await copyFile(stagedPrimary, join(staging, resultFile));
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const thumbnail = resolveLocalAssetThumbnail(libraryRoot, variant);
|
|
83
|
+
await copyFile(thumbnail, join(staging, '.vgai-thumbnail.webp'));
|
|
84
|
+
} catch {
|
|
85
|
+
// Thumbnails are a presentation cache and never invalidate a source reimport.
|
|
86
|
+
}
|
|
87
|
+
await writeCatalogImportRecipe({
|
|
88
|
+
stagingDirectory: staging,
|
|
89
|
+
source: 'local',
|
|
90
|
+
familyId: family.id,
|
|
91
|
+
variantId: variant.id,
|
|
92
|
+
deliveryId: plan.identity.deliveryId,
|
|
93
|
+
displayName: plan.identity.displayName ?? family.name,
|
|
94
|
+
destination: plan.destination,
|
|
95
|
+
sources: recipeSources,
|
|
96
|
+
contentKind: plan.contentKind,
|
|
97
|
+
...(plan.targetSkeleton ? { targetSkeleton: plan.targetSkeleton } : {}),
|
|
98
|
+
generatedFiles: backend === 'threejs' ? [plan.destination] : [],
|
|
99
|
+
runtimeReady: ['glb', 'gltf'].includes(format) || backend === 'threejs',
|
|
100
|
+
backend,
|
|
101
|
+
attribution: plan.attribution,
|
|
102
|
+
settings: plan.settings,
|
|
103
|
+
});
|
|
104
|
+
if (backend === 'threejs' && !plan.settings.retainSource) {
|
|
105
|
+
await Promise.all(recipeSources.map((source) => rm(source.stagedPath, { force: true })));
|
|
106
|
+
}
|
|
107
|
+
const result = await replaceStagedProjectDirectory({
|
|
108
|
+
projectRoot: dirname(publicRoot),
|
|
109
|
+
stagedDirectory: staging,
|
|
110
|
+
destination,
|
|
111
|
+
provenance: {
|
|
112
|
+
operationName: 'catalog.asset.reimport',
|
|
113
|
+
operationSource: 'vgai-catalog',
|
|
114
|
+
input: {
|
|
115
|
+
familyId: family.id,
|
|
116
|
+
variantId: variant.id,
|
|
117
|
+
sourceHash: plan.sourceHash,
|
|
118
|
+
settings: plan.settings,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
path: plan.destination,
|
|
124
|
+
...(result.provenanceOperationId
|
|
125
|
+
? { provenanceOperationId: result.provenanceOperationId }
|
|
126
|
+
: {}),
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
await rm(staging, { recursive: true, force: true });
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function dependencyRole(path: string): 'dependency' | 'material' | 'texture' {
|
|
135
|
+
const extension = extname(path).toLowerCase();
|
|
136
|
+
if (extension === '.mtl') return 'material';
|
|
137
|
+
if (['.png', '.jpg', '.jpeg', '.webp', '.ktx', '.ktx2', '.dds', '.tga'].includes(extension)) {
|
|
138
|
+
return 'texture';
|
|
139
|
+
}
|
|
140
|
+
return 'dependency';
|
|
141
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const DEFAULT_CLOUD_ASSET_BASE_URL = 'https://vgai-asset-library.aaron-0ed.workers.dev';
|
|
2
2
|
|
|
3
3
|
export interface CloudAssetObject {
|
|
4
|
-
key
|
|
4
|
+
key?: string;
|
|
5
5
|
sha256: string;
|
|
6
6
|
sizeBytes: number;
|
|
7
7
|
contentType: string;
|
|
@@ -17,6 +17,8 @@ export interface CloudAssetFile {
|
|
|
17
17
|
|
|
18
18
|
export interface CloudAssetRecord {
|
|
19
19
|
id: string;
|
|
20
|
+
familyId?: string;
|
|
21
|
+
label?: string;
|
|
20
22
|
status: 'validated';
|
|
21
23
|
source: string;
|
|
22
24
|
name: string;
|
|
@@ -25,17 +27,24 @@ export interface CloudAssetRecord {
|
|
|
25
27
|
author: string;
|
|
26
28
|
sourceUrl: string;
|
|
27
29
|
attribution: string;
|
|
30
|
+
categories?: string[];
|
|
31
|
+
tags?: string[];
|
|
28
32
|
files: CloudAssetFile[];
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
interface CloudAssetManifest {
|
|
32
|
-
version: 1;
|
|
36
|
+
version: 1 | 2;
|
|
33
37
|
generatedAt: string;
|
|
34
38
|
assets: CloudAssetRecord[];
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
let cachedManifest: { value: CloudAssetManifest; url: string; loadedAt: number } | undefined;
|
|
38
42
|
const CACHE_MS = 5 * 60 * 1000;
|
|
43
|
+
const GRAPHQL_ID_BATCH = 500;
|
|
44
|
+
const graphqlAssetCache = new Map<
|
|
45
|
+
string,
|
|
46
|
+
{ baseUrl: string; value: CloudAssetRecord | null; loadedAt: number }
|
|
47
|
+
>();
|
|
39
48
|
|
|
40
49
|
export function getCloudAssetBaseUrl(): string {
|
|
41
50
|
return (process.env['VGAI_CLOUD_ASSET_URL'] ?? DEFAULT_CLOUD_ASSET_BASE_URL).replace(/\/+$/, '');
|
|
@@ -54,7 +63,7 @@ export async function loadCloudAssetManifest(): Promise<CloudAssetManifest | nul
|
|
|
54
63
|
const response = await fetch(`${baseUrl}/v1/manifest`, { signal: AbortSignal.timeout(5_000) });
|
|
55
64
|
if (!response.ok) return null;
|
|
56
65
|
const value = (await response.json()) as CloudAssetManifest;
|
|
57
|
-
if (value.version !== 1 || !Array.isArray(value.assets)) return null;
|
|
66
|
+
if ((value.version !== 1 && value.version !== 2) || !Array.isArray(value.assets)) return null;
|
|
58
67
|
cachedManifest = { value, url: baseUrl, loadedAt: Date.now() };
|
|
59
68
|
return value;
|
|
60
69
|
} catch {
|
|
@@ -63,8 +72,65 @@ export async function loadCloudAssetManifest(): Promise<CloudAssetManifest | nul
|
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
export async function findCloudAsset(id: string): Promise<CloudAssetRecord | null> {
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
return (await findCloudAssets([id]))[0] ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function queryCloudAssets(ids: readonly string[]): Promise<CloudAssetRecord[] | null> {
|
|
79
|
+
if (ids.length === 0) return [];
|
|
80
|
+
const response = await fetch(`${getCloudAssetBaseUrl()}/graphql`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
query: `query CloudAssets($ids: [ID!]!) {
|
|
85
|
+
assetsByIds(ids: $ids) {
|
|
86
|
+
id familyId label status source name type license author sourceUrl attribution categories tags
|
|
87
|
+
files: deliveries {
|
|
88
|
+
label format
|
|
89
|
+
main { sha256 sizeBytes contentType relativePath }
|
|
90
|
+
dependencies { sha256 sizeBytes contentType relativePath }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}`,
|
|
94
|
+
variables: { ids },
|
|
95
|
+
}),
|
|
96
|
+
signal: AbortSignal.timeout(5_000),
|
|
97
|
+
});
|
|
98
|
+
if (!response.ok) return null;
|
|
99
|
+
const body = (await response.json()) as {
|
|
100
|
+
data?: { assetsByIds?: CloudAssetRecord[] };
|
|
101
|
+
errors?: unknown[];
|
|
102
|
+
};
|
|
103
|
+
return body.errors || !Array.isArray(body.data?.assetsByIds) ? null : body.data.assetsByIds;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve exact hosted variants through GraphQL; REST manifest is rollout fallback only. */
|
|
107
|
+
export async function findCloudAssets(ids: readonly string[]): Promise<CloudAssetRecord[]> {
|
|
108
|
+
const uniqueIds = [...new Set(ids)];
|
|
109
|
+
const baseUrl = getCloudAssetBaseUrl();
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const unresolved = uniqueIds.filter((id) => {
|
|
112
|
+
const cached = graphqlAssetCache.get(id);
|
|
113
|
+
return !cached || cached.baseUrl !== baseUrl || now - cached.loadedAt >= CACHE_MS;
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
for (let offset = 0; offset < unresolved.length; offset += GRAPHQL_ID_BATCH) {
|
|
117
|
+
const batch = unresolved.slice(offset, offset + GRAPHQL_ID_BATCH);
|
|
118
|
+
const found = await queryCloudAssets(batch);
|
|
119
|
+
if (!found) throw new Error('GraphQL catalog unavailable.');
|
|
120
|
+
const byId = new Map(found.map((asset) => [asset.id, asset]));
|
|
121
|
+
for (const id of batch) {
|
|
122
|
+
graphqlAssetCache.set(id, { baseUrl, value: byId.get(id) ?? null, loadedAt: now });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return uniqueIds.flatMap((id) => {
|
|
126
|
+
const asset = graphqlAssetCache.get(id)?.value;
|
|
127
|
+
return asset ? [asset] : [];
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
const manifest = await loadCloudAssetManifest();
|
|
131
|
+
const requested = new Set(uniqueIds);
|
|
132
|
+
return manifest?.assets.filter((asset) => requested.has(asset.id)) ?? [];
|
|
133
|
+
}
|
|
68
134
|
}
|
|
69
135
|
|
|
70
136
|
export function cloudAssetObjectUrl(
|
package/server/editor-server.ts
CHANGED
|
@@ -19,6 +19,13 @@ import express from 'express';
|
|
|
19
19
|
import { GAME_MANIFEST_VERSION } from '../../engine/src/manifest/schema';
|
|
20
20
|
import { applyAssetRename } from '../../engine/src/scene/asset-paths';
|
|
21
21
|
import type { SceneFile } from '../../engine/src/scene/scene-types';
|
|
22
|
+
import {
|
|
23
|
+
deriveProjectAttributionReport,
|
|
24
|
+
emptyProjectProvenanceDocument,
|
|
25
|
+
formatProjectAttributionMarkdown,
|
|
26
|
+
PROJECT_PROVENANCE_PATH,
|
|
27
|
+
ProjectProvenanceDocumentSchema,
|
|
28
|
+
} from '../../vgai-sdk/src/project/provenance';
|
|
22
29
|
import {
|
|
23
30
|
assertEditorCompatibility,
|
|
24
31
|
assertProjectCompatibility,
|
|
@@ -70,6 +77,7 @@ interface AssetEntry {
|
|
|
70
77
|
name: string;
|
|
71
78
|
type: 'file' | 'directory';
|
|
72
79
|
size: number;
|
|
80
|
+
mtime: number;
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
interface RecentProject {
|
|
@@ -852,6 +860,55 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
852
860
|
createAssetLibraryRouter(() => publicRoot),
|
|
853
861
|
);
|
|
854
862
|
|
|
863
|
+
// Derived credits view. The sole durable source is central project provenance.
|
|
864
|
+
router.get('/__editor/project-attribution', async (req: Request, res: Response) => {
|
|
865
|
+
if (projectRoot === engineRoot) {
|
|
866
|
+
res.status(400).json({ error: 'No project is open.' });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
try {
|
|
870
|
+
const raw = await readFile(join(projectRoot, PROJECT_PROVENANCE_PATH), 'utf8').catch(
|
|
871
|
+
(error: NodeJS.ErrnoException) => {
|
|
872
|
+
if (error.code === 'ENOENT') return null;
|
|
873
|
+
throw error;
|
|
874
|
+
},
|
|
875
|
+
);
|
|
876
|
+
const provenance = raw
|
|
877
|
+
? ProjectProvenanceDocumentSchema.parse(JSON.parse(raw))
|
|
878
|
+
: emptyProjectProvenanceDocument();
|
|
879
|
+
const activeOperations = Object.fromEntries(
|
|
880
|
+
(
|
|
881
|
+
await Promise.all(
|
|
882
|
+
Object.entries(provenance.operations).map(async ([operationId, operation]) => {
|
|
883
|
+
const present = await Promise.all(
|
|
884
|
+
operation.outputs.map(async (output) => {
|
|
885
|
+
const absolute = resolve(projectRoot, output.path);
|
|
886
|
+
if (!isPathInside(projectRoot, absolute)) return false;
|
|
887
|
+
return stat(absolute).then(
|
|
888
|
+
(info) => info.isFile(),
|
|
889
|
+
() => false,
|
|
890
|
+
);
|
|
891
|
+
}),
|
|
892
|
+
);
|
|
893
|
+
return present.some(Boolean) ? ([operationId, operation] as const) : null;
|
|
894
|
+
}),
|
|
895
|
+
)
|
|
896
|
+
).filter((entry): entry is readonly [string, (typeof provenance.operations)[string]] =>
|
|
897
|
+
Boolean(entry),
|
|
898
|
+
),
|
|
899
|
+
);
|
|
900
|
+
const report = deriveProjectAttributionReport({ version: 1, operations: activeOperations });
|
|
901
|
+
const format = typeof req.query['format'] === 'string' ? req.query['format'] : 'json';
|
|
902
|
+
if (format === 'markdown' || req.accepts(['json', 'text/markdown']) === 'text/markdown') {
|
|
903
|
+
res.type('text/markdown').send(formatProjectAttributionMarkdown(report));
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
res.json(report);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
res.status(500).json({ error: `Project attribution failed: ${error}` });
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
|
|
855
912
|
// ---- Project file serving middleware ----
|
|
856
913
|
// Serves the v2 manifest from root and static files from project's public/
|
|
857
914
|
router.use((req: Request, res: Response, next) => {
|
|
@@ -1253,11 +1310,11 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
1253
1310
|
for (const d of dirents) {
|
|
1254
1311
|
if (d.name.startsWith('.')) continue;
|
|
1255
1312
|
const entryPath = join(absDir, d.name);
|
|
1313
|
+
const s = await stat(entryPath);
|
|
1256
1314
|
if (d.isDirectory()) {
|
|
1257
|
-
entries.push({ name: d.name, type: 'directory', size: 0 });
|
|
1315
|
+
entries.push({ name: d.name, type: 'directory', size: 0, mtime: s.mtimeMs });
|
|
1258
1316
|
} else if (d.isFile()) {
|
|
1259
|
-
|
|
1260
|
-
entries.push({ name: d.name, type: 'file', size: s.size });
|
|
1317
|
+
entries.push({ name: d.name, type: 'file', size: s.size, mtime: s.mtimeMs });
|
|
1261
1318
|
}
|
|
1262
1319
|
}
|
|
1263
1320
|
|
|
@@ -2251,6 +2308,7 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
2251
2308
|
// ---- Play-mode console log persistence ----
|
|
2252
2309
|
const MAX_LOG_FILES = 20;
|
|
2253
2310
|
let activeLogFile: string | null = null;
|
|
2311
|
+
let logSessionSequence = 0;
|
|
2254
2312
|
|
|
2255
2313
|
router.post('/__editor/log-session', async (req: Request, res: Response) => {
|
|
2256
2314
|
const body = req.body as { action: 'start' | 'end' };
|
|
@@ -2273,8 +2331,12 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
2273
2331
|
}
|
|
2274
2332
|
|
|
2275
2333
|
const now = new Date();
|
|
2276
|
-
|
|
2277
|
-
|
|
2334
|
+
// Milliseconds plus a process-local sequence make restart artifacts
|
|
2335
|
+
// distinct even when two sessions begin inside the same millisecond.
|
|
2336
|
+
// Lexicographic order remains chronological for pruning/tail tooling.
|
|
2337
|
+
const ts = now.toISOString().replace(/[:.]/g, '-');
|
|
2338
|
+
const sequence = String(++logSessionSequence).padStart(4, '0');
|
|
2339
|
+
const filename = `play-${ts}-${sequence}.jsonl`;
|
|
2278
2340
|
activeLogFile = join(logsDir, filename);
|
|
2279
2341
|
res.json({ ok: true, file: filename });
|
|
2280
2342
|
} else {
|