@vgai/editor 0.4.0 → 0.4.1
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-N4cNyzfs.js} +1 -1
- package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
- package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
- package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
- package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
- package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
- package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
- package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
- package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
- package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
- package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
- package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
- package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
- package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
- package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
- package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
- package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
- package/dist/assets/index-DiUacMXR.css +1 -0
- package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
- package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
- package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
- package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
- package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/packaged.mjs +27125 -1075
- 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/dev.ts +1 -1
- package/server/editor-server.ts +106 -51
- package/server/editor-sse.ts +5 -0
- package/server/generative-execution-context.ts +45 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/packaged.ts +1 -1
- package/server/project-hmr-files.ts +3 -5
- package/server/project-output-writer.ts +440 -36
- package/server/project-tools.ts +529 -0
- package/server/server-utils.ts +14 -11
- package/src/EditorContext.tsx +4 -4
- package/src/action-registry.ts +12 -0
- package/src/asset-editor-context.tsx +9 -0
- package/src/asset-events.ts +3 -15
- package/src/asset-selection.ts +3 -0
- package/src/asset-workflow/asset-capabilities.ts +40 -0
- package/src/asset-workflow/asset-import-jobs.ts +108 -0
- package/src/asset-workflow/asset-library-query.ts +8 -1
- package/src/asset-workflow/asset-workflow-quality.ts +2 -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-health.ts +3 -6
- package/src/asset-workflow/project-asset-operations.ts +119 -4
- package/src/command-listener.ts +37 -9
- package/src/components/ApplicationMenus.tsx +13 -15
- package/src/components/AssetBrowser.tsx +301 -26
- package/src/components/AssetImportDetails.tsx +207 -92
- package/src/components/AssetImportJobStatus.tsx +55 -0
- package/src/components/CenterDocuments.tsx +28 -1
- package/src/components/CommandPalette.tsx +2 -2
- package/src/components/DefaultEditorLayout.tsx +4 -0
- package/src/components/InspectorToolSection.tsx +12 -10
- package/src/components/OnlineAssetBrowser.tsx +197 -11
- package/src/components/ToolHost.tsx +8 -8
- package/src/components/ViewportOverlay.tsx +13 -26
- package/src/components/ViewportPanel.tsx +19 -14
- package/src/components/ViewportShadingMenu.tsx +103 -0
- package/src/components/WorkspaceDock.tsx +103 -17
- package/src/components/asset-documents.tsx +38 -11
- package/src/components/asset-editor-persistence.ts +300 -5
- package/src/components/asset-selection-section.tsx +83 -9
- 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 +52 -12
- package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
- package/src/components/asset-workflow.css +30 -3
- package/src/components/primitives/editor-icons.ts +1 -0
- package/src/components/project-tool-documents.tsx +298 -0
- package/src/components/status-contributions.tsx +38 -0
- package/src/components/tool-documents.tsx +20 -17
- package/src/components/tool-schema-form.tsx +258 -0
- package/src/components/workspace-dock.css +16 -0
- package/src/editor-api.ts +159 -22
- package/src/editor-store.ts +6 -5
- package/src/editor-viewport.ts +12 -7
- package/src/game-viewport-state.ts +28 -0
- package/src/model-thumbnail.ts +97 -9
- package/src/play-mode.ts +30 -3
- package/src/project-tool-discovery.ts +5 -5
- package/src/{project-operations.ts → project-tools.ts} +39 -26
- package/src/scene-sync.ts +0 -67
- package/src/storage/handle-storage.ts +5 -1
- package/src/storage/http-storage.ts +7 -2
- package/src/storage/mem-storage.ts +7 -1
- package/src/storage/types.ts +2 -0
- package/src/theme-css.ts +17 -7
- package/src/tool-loader.ts +152 -241
- package/src/viewport-shading-boundary.ts +15 -0
- package/src/widgets/index.ts +2 -1
- package/src/workspace-dock-controller.ts +8 -0
- package/src/workspace-document-registry.ts +4 -4
- package/src/workspace-state-persistence.ts +36 -10
- package/template/.claude/skills/editor/SKILL.md +13 -3
- package/template/AGENTS.md +100 -83
- package/template/package.json +16 -0
- package/template/public/scenes/default.vscn.json +1 -2
- package/template/scripts/playtest.ts +7 -37
- package/template/server/rooms/game-room.ts +2 -2
- package/template/src/data/README.md +3 -2
- package/template/src/data/tuning.data.json +1 -3
- package/template/src/data/tuning.schema.json +0 -12
- package/template/src/data/tuning.schema.ts +2 -9
- package/template/src/data/tuning.ts +1 -1
- package/template/src/scripts/registry.ts +0 -3
- package/template/src/tools/README.md +151 -16
- package/template/src/tools/autoplay.tool.ts +31 -0
- package/template/src/tools/autoplay.ts +15 -0
- package/template/src/tools/tuning.document.tsx +107 -0
- package/template/src/tools/tuning.tool.ts +24 -0
- package/template/src/tools/tuning.ts +9 -0
- package/template/src/ui/Button.stories.tsx +1 -1
- package/template/tests/acceptance/example.spec.ts +4 -59
- 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
- package/server/project-operations.ts +0 -343
- package/src/components/project-operation-documents.tsx +0 -226
- package/template/src/operations/README.md +0 -88
- package/template/src/scripts/components/data-spinner.ts +0 -86
- package/template/src/scripts/components/spin-lap-math.ts +0 -27
- package/template/src/tools/example.tool.tsx +0 -97
- package/template/src/tools/spin.tool.tsx +0 -101
- package/template/tests/logic/example.test.ts +0 -31
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Atomic persistence boundary for files produced by project
|
|
2
|
+
* Atomic persistence boundary for files produced by project tools.
|
|
3
3
|
*
|
|
4
4
|
* Generators remain ordinary project/package code. They hand their completed
|
|
5
5
|
* bytes to this writer only when they are ready to commit; the editor host
|
|
@@ -8,20 +8,58 @@
|
|
|
8
8
|
* visible to the asset watcher.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { randomUUID } from 'node:crypto';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
11
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
12
|
+
import { createReadStream } from 'node:fs';
|
|
13
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { dirname, extname, posix, relative, resolve, sep } from 'node:path';
|
|
15
|
+
import {
|
|
16
|
+
emptyProjectProvenanceDocument,
|
|
17
|
+
PROJECT_PROVENANCE_PATH,
|
|
18
|
+
ProjectProvenanceDocumentSchema,
|
|
19
|
+
ProjectProvenanceOperationSchema,
|
|
20
|
+
} from '../../vgai-sdk/src/project/provenance';
|
|
14
21
|
import type {
|
|
15
22
|
ProjectGeneratedOutput,
|
|
16
23
|
ProjectGeneratedOutputFile,
|
|
17
24
|
ProjectOutputFile,
|
|
25
|
+
ProjectOutputProvenanceContext,
|
|
18
26
|
ProjectOutputWriter,
|
|
19
27
|
} from '../../vgai-sdk/src/types';
|
|
20
|
-
|
|
21
|
-
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
import { consumeGenerativeExecutions } from './generative-execution-context';
|
|
22
29
|
|
|
23
30
|
type NormalizedOutputFile = ProjectOutputFile & { path: string };
|
|
24
31
|
|
|
32
|
+
interface HashedProjectFile extends ProjectGeneratedOutputFile {
|
|
33
|
+
sha256: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const projectCommitQueues = new Map<string, Promise<unknown>>();
|
|
37
|
+
|
|
38
|
+
function withCapturedExecutions(
|
|
39
|
+
provenanceContext: ProjectOutputProvenanceContext,
|
|
40
|
+
): ProjectOutputProvenanceContext {
|
|
41
|
+
const captured = consumeGenerativeExecutions();
|
|
42
|
+
const explicit = provenanceContext.executions
|
|
43
|
+
? [...provenanceContext.executions]
|
|
44
|
+
: provenanceContext.execution
|
|
45
|
+
? [provenanceContext.execution]
|
|
46
|
+
: [];
|
|
47
|
+
if (
|
|
48
|
+
captured.length > 0 &&
|
|
49
|
+
explicit.length > 0 &&
|
|
50
|
+
JSON.stringify(captured) !== JSON.stringify(explicit)
|
|
51
|
+
) {
|
|
52
|
+
throw new Error('Explicit and automatically captured provider execution facts disagree.');
|
|
53
|
+
}
|
|
54
|
+
const executions = captured.length > 0 ? captured : explicit;
|
|
55
|
+
const effective = { ...provenanceContext };
|
|
56
|
+
delete effective.execution;
|
|
57
|
+
delete effective.executions;
|
|
58
|
+
if (executions.length === 1) effective.execution = executions[0]!;
|
|
59
|
+
if (executions.length > 1) effective.executions = executions;
|
|
60
|
+
return effective;
|
|
61
|
+
}
|
|
62
|
+
|
|
25
63
|
function normalizeOutputPath(path: string): string {
|
|
26
64
|
const slashPath = path.split(sep).join('/');
|
|
27
65
|
const normalized = posix.normalize(slashPath).replace(/^\.\//, '');
|
|
@@ -44,12 +82,24 @@ function byteLength(content: string | Uint8Array): number {
|
|
|
44
82
|
return typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
|
|
45
83
|
}
|
|
46
84
|
|
|
47
|
-
|
|
85
|
+
function sha256(content: string | Uint8Array): string {
|
|
86
|
+
return createHash('sha256').update(content).digest('hex');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function sha256File(path: string): Promise<string> {
|
|
90
|
+
const hash = createHash('sha256');
|
|
91
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk as Buffer);
|
|
92
|
+
return hash.digest('hex');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function existingFile(path: string): Promise<boolean> {
|
|
48
96
|
try {
|
|
49
|
-
await stat(path);
|
|
97
|
+
const info = await stat(path);
|
|
98
|
+
if (!info.isFile()) throw new Error(`Generated output target is not a file: ${path}`);
|
|
50
99
|
return true;
|
|
51
|
-
} catch {
|
|
52
|
-
return false;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false;
|
|
102
|
+
throw error;
|
|
53
103
|
}
|
|
54
104
|
}
|
|
55
105
|
|
|
@@ -71,11 +121,6 @@ function validateBatch(files: readonly ProjectOutputFile[]): {
|
|
|
71
121
|
paths.add(file.path);
|
|
72
122
|
totalBytes += byteLength(file.content);
|
|
73
123
|
}
|
|
74
|
-
if (totalBytes > MAX_OUTPUT_BYTES) {
|
|
75
|
-
throw new Error(
|
|
76
|
-
`Generated output batch is ${totalBytes} bytes; the atomic project-output limit is ${MAX_OUTPUT_BYTES} bytes.`,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
124
|
return {
|
|
80
125
|
normalized,
|
|
81
126
|
totalBytes,
|
|
@@ -110,24 +155,152 @@ async function commitBatch(
|
|
|
110
155
|
const backup = resolve(backupRoot, file.path);
|
|
111
156
|
await mkdir(dirname(target), { recursive: true });
|
|
112
157
|
let prior: string | null = null;
|
|
113
|
-
if (await
|
|
158
|
+
if (await existingFile(target)) {
|
|
114
159
|
await mkdir(dirname(backup), { recursive: true });
|
|
115
|
-
await
|
|
160
|
+
await rename(target, backup);
|
|
116
161
|
prior = backup;
|
|
117
162
|
}
|
|
118
|
-
|
|
163
|
+
try {
|
|
164
|
+
await rename(staged, target);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (prior) await rename(prior, target);
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
119
169
|
committed.push({ target, backup: prior });
|
|
120
170
|
}
|
|
121
171
|
} catch (error) {
|
|
122
172
|
for (const entry of committed.reverse()) {
|
|
123
|
-
|
|
124
|
-
|
|
173
|
+
await rm(entry.target, { force: true });
|
|
174
|
+
if (entry.backup) await rename(entry.backup, entry.target);
|
|
125
175
|
}
|
|
126
176
|
throw error;
|
|
127
177
|
}
|
|
128
178
|
}
|
|
129
179
|
|
|
130
|
-
|
|
180
|
+
async function readProvenance(projectRoot: string) {
|
|
181
|
+
try {
|
|
182
|
+
const parsed: unknown = JSON.parse(
|
|
183
|
+
await readFile(resolve(projectRoot, PROJECT_PROVENANCE_PATH), 'utf8'),
|
|
184
|
+
);
|
|
185
|
+
return ProjectProvenanceDocumentSchema.parse(parsed);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error;
|
|
188
|
+
return emptyProjectProvenanceDocument();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const SECRET_FIELD = /(authorization|credential|password|secret|token|api.?key|fal.?key)/i;
|
|
193
|
+
const SIGNED_URL_PARAMETER = /(signature|credential|token|expires|x-amz-)/i;
|
|
194
|
+
|
|
195
|
+
function sanitizeString(value: string): string {
|
|
196
|
+
try {
|
|
197
|
+
const url = new URL(value);
|
|
198
|
+
if (
|
|
199
|
+
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
200
|
+
[...url.searchParams.keys()].some((key) => SIGNED_URL_PARAMETER.test(key))
|
|
201
|
+
) {
|
|
202
|
+
return `${url.origin}${url.pathname}?[redacted signed query]`;
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// Most provenance strings are not URLs.
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isBinaryValue(value: object): boolean {
|
|
211
|
+
return (
|
|
212
|
+
value instanceof ArrayBuffer ||
|
|
213
|
+
ArrayBuffer.isView(value) ||
|
|
214
|
+
(typeof Blob !== 'undefined' && value instanceof Blob)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function sanitizeProvenanceValue(value: unknown, seen: WeakSet<object>): unknown {
|
|
219
|
+
if (typeof value === 'string') return sanitizeString(value);
|
|
220
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number') return value;
|
|
221
|
+
if (value === undefined) return undefined;
|
|
222
|
+
if (typeof value !== 'object') return `[omitted ${typeof value}]`;
|
|
223
|
+
if (isBinaryValue(value)) return '[binary omitted]';
|
|
224
|
+
if (seen.has(value)) return '[circular omitted]';
|
|
225
|
+
seen.add(value);
|
|
226
|
+
if (Array.isArray(value)) {
|
|
227
|
+
return value.map((entry) => sanitizeProvenanceValue(entry, seen) ?? null);
|
|
228
|
+
}
|
|
229
|
+
const sanitized: Record<string, unknown> = {};
|
|
230
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
231
|
+
if (SECRET_FIELD.test(key)) {
|
|
232
|
+
sanitized[key] = '[redacted]';
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const next = sanitizeProvenanceValue(entry, seen);
|
|
236
|
+
if (next !== undefined) sanitized[key] = next;
|
|
237
|
+
}
|
|
238
|
+
return sanitized;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function jsonSafeInput(input: unknown): unknown {
|
|
242
|
+
if (input === undefined) return undefined;
|
|
243
|
+
return sanitizeProvenanceValue(input, new WeakSet());
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function provenanceOperation(
|
|
247
|
+
provenanceContext: ProjectOutputProvenanceContext,
|
|
248
|
+
outputs: readonly HashedProjectFile[],
|
|
249
|
+
) {
|
|
250
|
+
return ProjectProvenanceOperationSchema.parse({
|
|
251
|
+
createdAt: new Date().toISOString(),
|
|
252
|
+
operation: {
|
|
253
|
+
name: provenanceContext.operationName,
|
|
254
|
+
...(provenanceContext.operationSource ? { source: provenanceContext.operationSource } : {}),
|
|
255
|
+
},
|
|
256
|
+
...(provenanceContext.execution ? { execution: provenanceContext.execution } : {}),
|
|
257
|
+
...(provenanceContext.executions ? { executions: provenanceContext.executions } : {}),
|
|
258
|
+
...(provenanceContext.input === undefined
|
|
259
|
+
? {}
|
|
260
|
+
: { input: jsonSafeInput(provenanceContext.input) }),
|
|
261
|
+
outputs,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function nextProvenanceFile(
|
|
266
|
+
projectRoot: string,
|
|
267
|
+
provenanceOperationId: string,
|
|
268
|
+
provenanceContext: ProjectOutputProvenanceContext,
|
|
269
|
+
outputs: readonly HashedProjectFile[],
|
|
270
|
+
): Promise<NormalizedOutputFile> {
|
|
271
|
+
const provenance = await readProvenance(projectRoot);
|
|
272
|
+
const nextProvenance = ProjectProvenanceDocumentSchema.parse({
|
|
273
|
+
...provenance,
|
|
274
|
+
operations: {
|
|
275
|
+
...provenance.operations,
|
|
276
|
+
[provenanceOperationId]: provenanceOperation(provenanceContext, outputs),
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
path: PROJECT_PROVENANCE_PATH,
|
|
281
|
+
content: `${JSON.stringify(nextProvenance, null, 2)}\n`,
|
|
282
|
+
mediaType: 'application/json',
|
|
283
|
+
role: 'provenance',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function serializeProjectCommit<T>(projectRoot: string, work: () => Promise<T>): Promise<T> {
|
|
288
|
+
const previous = projectCommitQueues.get(projectRoot) ?? Promise.resolve();
|
|
289
|
+
const current = previous.catch(() => undefined).then(work);
|
|
290
|
+
projectCommitQueues.set(projectRoot, current);
|
|
291
|
+
try {
|
|
292
|
+
return await current;
|
|
293
|
+
} finally {
|
|
294
|
+
if (projectCommitQueues.get(projectRoot) === current) projectCommitQueues.delete(projectRoot);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function createProjectOutputWriter(
|
|
299
|
+
projectRoot: string,
|
|
300
|
+
provenanceContext: ProjectOutputProvenanceContext = {
|
|
301
|
+
operationName: 'project.output.write',
|
|
302
|
+
},
|
|
303
|
+
): ProjectOutputWriter {
|
|
131
304
|
return {
|
|
132
305
|
async write(files, options = {}): Promise<ProjectGeneratedOutput> {
|
|
133
306
|
const { normalized, summaries, totalBytes } = validateBatch(files);
|
|
@@ -135,22 +308,253 @@ export function createProjectOutputWriter(projectRoot: string): ProjectOutputWri
|
|
|
135
308
|
return { files: summaries, totalBytes, dryRun: true };
|
|
136
309
|
}
|
|
137
310
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
311
|
+
const effectiveProvenanceContext = withCapturedExecutions(provenanceContext);
|
|
312
|
+
|
|
313
|
+
return serializeProjectCommit(projectRoot, async () => {
|
|
314
|
+
const provenanceOperationId = randomUUID();
|
|
315
|
+
const hashedFiles: HashedProjectFile[] = normalized.map((file) => ({
|
|
316
|
+
path: file.path,
|
|
317
|
+
bytes: byteLength(file.content),
|
|
318
|
+
sha256: sha256(file.content),
|
|
319
|
+
...(file.mediaType ? { mediaType: file.mediaType } : {}),
|
|
320
|
+
...(file.role ? { role: file.role } : {}),
|
|
321
|
+
}));
|
|
322
|
+
const provenanceFile = await nextProvenanceFile(
|
|
323
|
+
projectRoot,
|
|
324
|
+
provenanceOperationId,
|
|
325
|
+
effectiveProvenanceContext,
|
|
326
|
+
hashedFiles,
|
|
327
|
+
);
|
|
328
|
+
const committedFiles = [...normalized, provenanceFile];
|
|
329
|
+
const transactionRoot = resolve(
|
|
330
|
+
projectRoot,
|
|
331
|
+
'.vgai',
|
|
332
|
+
'tmp',
|
|
333
|
+
`project-output-${provenanceOperationId}`,
|
|
334
|
+
);
|
|
335
|
+
const stagedRoot = resolve(transactionRoot, 'staged');
|
|
336
|
+
const backupRoot = resolve(transactionRoot, 'backup');
|
|
337
|
+
try {
|
|
338
|
+
await stageBatch(stagedRoot, committedFiles);
|
|
339
|
+
await commitBatch(projectRoot, stagedRoot, backupRoot, committedFiles);
|
|
340
|
+
} finally {
|
|
341
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
342
|
+
}
|
|
152
343
|
|
|
153
|
-
|
|
344
|
+
return {
|
|
345
|
+
files: summaries,
|
|
346
|
+
totalBytes,
|
|
347
|
+
dryRun: false,
|
|
348
|
+
provenanceOperationId,
|
|
349
|
+
};
|
|
350
|
+
});
|
|
154
351
|
},
|
|
155
352
|
};
|
|
156
353
|
}
|
|
354
|
+
|
|
355
|
+
function stagedMediaType(path: string): string | undefined {
|
|
356
|
+
switch (extname(path).toLowerCase()) {
|
|
357
|
+
case '.glb':
|
|
358
|
+
return 'model/gltf-binary';
|
|
359
|
+
case '.gltf':
|
|
360
|
+
return 'model/gltf+json';
|
|
361
|
+
case '.png':
|
|
362
|
+
return 'image/png';
|
|
363
|
+
case '.webp':
|
|
364
|
+
return 'image/webp';
|
|
365
|
+
case '.jpg':
|
|
366
|
+
case '.jpeg':
|
|
367
|
+
return 'image/jpeg';
|
|
368
|
+
case '.json':
|
|
369
|
+
return 'application/json';
|
|
370
|
+
case '.hdr':
|
|
371
|
+
return 'image/vnd.radiance';
|
|
372
|
+
default:
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function stagedRole(path: string): ProjectOutputFile['role'] {
|
|
378
|
+
if (path.endsWith('.prefab.json')) return 'prefab';
|
|
379
|
+
if (path.endsWith('/.vgai-thumbnail.webp')) return 'other';
|
|
380
|
+
return 'asset';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function describeStagedDirectory(
|
|
384
|
+
stagedDirectory: string,
|
|
385
|
+
destinationProjectPath: string,
|
|
386
|
+
): Promise<HashedProjectFile[]> {
|
|
387
|
+
const outputs: HashedProjectFile[] = [];
|
|
388
|
+
async function walk(directory: string): Promise<void> {
|
|
389
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
390
|
+
for (const entry of entries) {
|
|
391
|
+
const absolute = resolve(directory, entry.name);
|
|
392
|
+
if (entry.isDirectory()) {
|
|
393
|
+
await walk(absolute);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (!entry.isFile()) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`Staged project output contains an unsupported filesystem entry: ${absolute}`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
const relativeFile = relative(stagedDirectory, absolute).split(sep).join('/');
|
|
402
|
+
const path = posix.join(destinationProjectPath, relativeFile);
|
|
403
|
+
const info = await stat(absolute);
|
|
404
|
+
const mediaType = stagedMediaType(path);
|
|
405
|
+
outputs.push({
|
|
406
|
+
path,
|
|
407
|
+
bytes: info.size,
|
|
408
|
+
sha256: await sha256File(absolute),
|
|
409
|
+
...(mediaType ? { mediaType } : {}),
|
|
410
|
+
role: stagedRole(path),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
await walk(stagedDirectory);
|
|
415
|
+
outputs.sort((left, right) => left.path.localeCompare(right.path));
|
|
416
|
+
if (outputs.length === 0) throw new Error('Staged project output directory is empty.');
|
|
417
|
+
return outputs;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
421
|
+
try {
|
|
422
|
+
await stat(path);
|
|
423
|
+
return true;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false;
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Commits an already-streamed directory and the central provenance rewrite as
|
|
432
|
+
* one rollback-safe project transaction. Catalog acquisition uses this path so
|
|
433
|
+
* large assets never need to be buffered into ProjectOutputFile.content.
|
|
434
|
+
*/
|
|
435
|
+
export async function commitStagedProjectDirectory(options: {
|
|
436
|
+
projectRoot: string;
|
|
437
|
+
stagedDirectory: string;
|
|
438
|
+
destination: string;
|
|
439
|
+
provenance: ProjectOutputProvenanceContext;
|
|
440
|
+
}): Promise<ProjectGeneratedOutput> {
|
|
441
|
+
const destinationProjectPath = normalizeOutputPath(
|
|
442
|
+
relative(options.projectRoot, options.destination).split(sep).join('/'),
|
|
443
|
+
);
|
|
444
|
+
const outputs = await describeStagedDirectory(options.stagedDirectory, destinationProjectPath);
|
|
445
|
+
const summaries: ProjectGeneratedOutputFile[] = outputs.map(
|
|
446
|
+
({ sha256: _sha256, ...file }) => file,
|
|
447
|
+
);
|
|
448
|
+
const totalBytes = summaries.reduce((total, file) => total + file.bytes, 0);
|
|
449
|
+
|
|
450
|
+
return serializeProjectCommit(options.projectRoot, async () => {
|
|
451
|
+
if (await pathExists(options.destination)) {
|
|
452
|
+
throw new Error(`Destination already exists and was not overwritten: ${options.destination}`);
|
|
453
|
+
}
|
|
454
|
+
const provenanceOperationId = randomUUID();
|
|
455
|
+
const provenanceFile = await nextProvenanceFile(
|
|
456
|
+
options.projectRoot,
|
|
457
|
+
provenanceOperationId,
|
|
458
|
+
options.provenance,
|
|
459
|
+
outputs,
|
|
460
|
+
);
|
|
461
|
+
const transactionRoot = resolve(
|
|
462
|
+
options.projectRoot,
|
|
463
|
+
'.vgai',
|
|
464
|
+
'tmp',
|
|
465
|
+
`project-directory-${provenanceOperationId}`,
|
|
466
|
+
);
|
|
467
|
+
const stagedRoot = resolve(transactionRoot, 'staged');
|
|
468
|
+
const backupRoot = resolve(transactionRoot, 'backup');
|
|
469
|
+
let directoryCommitted = false;
|
|
470
|
+
try {
|
|
471
|
+
await stageBatch(stagedRoot, [provenanceFile]);
|
|
472
|
+
await mkdir(dirname(options.destination), { recursive: true });
|
|
473
|
+
await rename(options.stagedDirectory, options.destination);
|
|
474
|
+
directoryCommitted = true;
|
|
475
|
+
await commitBatch(options.projectRoot, stagedRoot, backupRoot, [provenanceFile]);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (directoryCommitted) {
|
|
478
|
+
try {
|
|
479
|
+
await rename(options.destination, options.stagedDirectory);
|
|
480
|
+
} catch (rollbackError) {
|
|
481
|
+
throw new AggregateError(
|
|
482
|
+
[error, rollbackError],
|
|
483
|
+
'Project directory transaction and its rollback both failed.',
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
throw error;
|
|
488
|
+
} finally {
|
|
489
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
files: summaries,
|
|
493
|
+
totalBytes,
|
|
494
|
+
dryRun: false,
|
|
495
|
+
provenanceOperationId,
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Atomically replaces an imported asset directory and appends its reimport provenance. */
|
|
501
|
+
export async function replaceStagedProjectDirectory(options: {
|
|
502
|
+
projectRoot: string;
|
|
503
|
+
stagedDirectory: string;
|
|
504
|
+
destination: string;
|
|
505
|
+
provenance: ProjectOutputProvenanceContext;
|
|
506
|
+
}): Promise<ProjectGeneratedOutput> {
|
|
507
|
+
const destinationProjectPath = normalizeOutputPath(
|
|
508
|
+
relative(options.projectRoot, options.destination).split(sep).join('/'),
|
|
509
|
+
);
|
|
510
|
+
const outputs = await describeStagedDirectory(options.stagedDirectory, destinationProjectPath);
|
|
511
|
+
const summaries: ProjectGeneratedOutputFile[] = outputs.map(
|
|
512
|
+
({ sha256: _sha256, ...file }) => file,
|
|
513
|
+
);
|
|
514
|
+
const totalBytes = summaries.reduce((total, file) => total + file.bytes, 0);
|
|
515
|
+
|
|
516
|
+
return serializeProjectCommit(options.projectRoot, async () => {
|
|
517
|
+
if (!(await pathExists(options.destination))) {
|
|
518
|
+
throw new Error(`Reimport target does not exist: ${options.destination}`);
|
|
519
|
+
}
|
|
520
|
+
const provenanceOperationId = randomUUID();
|
|
521
|
+
const provenanceFile = await nextProvenanceFile(
|
|
522
|
+
options.projectRoot,
|
|
523
|
+
provenanceOperationId,
|
|
524
|
+
options.provenance,
|
|
525
|
+
outputs,
|
|
526
|
+
);
|
|
527
|
+
const transactionRoot = resolve(
|
|
528
|
+
options.projectRoot,
|
|
529
|
+
'.vgai',
|
|
530
|
+
'tmp',
|
|
531
|
+
`project-reimport-${provenanceOperationId}`,
|
|
532
|
+
);
|
|
533
|
+
const stagedRoot = resolve(transactionRoot, 'staged');
|
|
534
|
+
const backupRoot = resolve(transactionRoot, 'backup');
|
|
535
|
+
const directoryBackup = resolve(transactionRoot, 'previous-asset');
|
|
536
|
+
let previousMoved = false;
|
|
537
|
+
let replacementCommitted = false;
|
|
538
|
+
try {
|
|
539
|
+
await stageBatch(stagedRoot, [provenanceFile]);
|
|
540
|
+
await mkdir(dirname(directoryBackup), { recursive: true });
|
|
541
|
+
await rename(options.destination, directoryBackup);
|
|
542
|
+
previousMoved = true;
|
|
543
|
+
await rename(options.stagedDirectory, options.destination);
|
|
544
|
+
replacementCommitted = true;
|
|
545
|
+
await commitBatch(options.projectRoot, stagedRoot, backupRoot, [provenanceFile]);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (replacementCommitted) await rm(options.destination, { recursive: true, force: true });
|
|
548
|
+
if (previousMoved) await rename(directoryBackup, options.destination);
|
|
549
|
+
throw error;
|
|
550
|
+
} finally {
|
|
551
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
files: summaries,
|
|
555
|
+
totalBytes,
|
|
556
|
+
dryRun: false,
|
|
557
|
+
provenanceOperationId,
|
|
558
|
+
};
|
|
559
|
+
});
|
|
560
|
+
}
|