@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
|
@@ -10,16 +10,31 @@
|
|
|
10
10
|
import { execFile } from 'node:child_process';
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
12
|
import { createWriteStream } from 'node:fs';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
copyFile,
|
|
15
|
+
mkdir,
|
|
16
|
+
mkdtemp,
|
|
17
|
+
readdir,
|
|
18
|
+
readFile,
|
|
19
|
+
rename,
|
|
20
|
+
rm,
|
|
21
|
+
stat,
|
|
22
|
+
unlink,
|
|
23
|
+
writeFile,
|
|
24
|
+
} from 'node:fs/promises';
|
|
14
25
|
import { basename, dirname, extname, join, relative } from 'node:path';
|
|
15
26
|
import { Readable, Transform } from 'node:stream';
|
|
16
27
|
import { pipeline } from 'node:stream/promises';
|
|
17
28
|
import type { Router } from 'express';
|
|
18
29
|
import express from 'express';
|
|
30
|
+
import { ProjectProvenanceDocumentSchema } from '../../vgai-sdk/src/project/provenance';
|
|
31
|
+
import { AssetHistorySnapshots } from './asset-history-snapshots';
|
|
32
|
+
import { type CatalogRecipeSource, writeCatalogImportRecipe } from './catalog-import-recipe';
|
|
33
|
+
import { reimportLocalCatalogAsset } from './catalog-reimport';
|
|
19
34
|
import {
|
|
20
35
|
cloudAssetObjectUrl,
|
|
21
36
|
findCloudAsset,
|
|
22
|
-
|
|
37
|
+
findCloudAssets,
|
|
23
38
|
parseCloudAssetObjectUrl,
|
|
24
39
|
} from './cloud-asset-catalog';
|
|
25
40
|
import { broadcast } from './editor-sse';
|
|
@@ -36,6 +51,11 @@ import {
|
|
|
36
51
|
resolveLocalAssetThumbnail,
|
|
37
52
|
searchLocalCatalog,
|
|
38
53
|
} from './local-asset-catalog';
|
|
54
|
+
import {
|
|
55
|
+
convertStagedModelToGlb,
|
|
56
|
+
SERVER_CONVERTIBLE_MODEL_FORMATS,
|
|
57
|
+
} from './model-import-conversion';
|
|
58
|
+
import { commitStagedProjectDirectory } from './project-output-writer';
|
|
39
59
|
import { isAllowedAssetHost, isAllowedAssetSource, isPathInside } from './server-utils';
|
|
40
60
|
|
|
41
61
|
// ---------------------------------------------------------------------------
|
|
@@ -103,6 +123,64 @@ interface CacheEntry<T> {
|
|
|
103
123
|
|
|
104
124
|
const cache = new Map<string, CacheEntry<unknown>>();
|
|
105
125
|
|
|
126
|
+
export async function beginAssetStaging(destination: string): Promise<string> {
|
|
127
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
128
|
+
const exists = await stat(destination).then(
|
|
129
|
+
() => true,
|
|
130
|
+
(error: NodeJS.ErrnoException) => {
|
|
131
|
+
if (error.code === 'ENOENT') return false;
|
|
132
|
+
throw error;
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
if (exists) throw new Error(`Destination already exists and was not overwritten: ${destination}`);
|
|
136
|
+
return mkdtemp(join(dirname(destination), `.${basename(destination)}.staging-`));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function commitAssetStaging(staging: string, destination: string): Promise<void> {
|
|
140
|
+
await rename(staging, destination);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function commitCatalogAsset(
|
|
144
|
+
publicRoot: string,
|
|
145
|
+
staging: string,
|
|
146
|
+
destination: string,
|
|
147
|
+
input: Record<string, unknown>,
|
|
148
|
+
) {
|
|
149
|
+
return commitStagedProjectDirectory({
|
|
150
|
+
projectRoot: dirname(publicRoot),
|
|
151
|
+
stagedDirectory: staging,
|
|
152
|
+
destination,
|
|
153
|
+
provenance: {
|
|
154
|
+
operationName: 'catalog.asset.acquire',
|
|
155
|
+
operationSource: 'vgai-catalog',
|
|
156
|
+
input,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function centralCatalogIds(projectRoot: string): Promise<Map<string, string>> {
|
|
162
|
+
try {
|
|
163
|
+
const document = ProjectProvenanceDocumentSchema.parse(
|
|
164
|
+
JSON.parse(await readFile(join(projectRoot, '.vgai', 'provenance.json'), 'utf8')),
|
|
165
|
+
);
|
|
166
|
+
const ids = new Map<string, string>();
|
|
167
|
+
for (const operation of Object.values(document.operations)) {
|
|
168
|
+
if (operation.operation.name !== 'catalog.asset.acquire') continue;
|
|
169
|
+
const input = operation.input;
|
|
170
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) continue;
|
|
171
|
+
const id = input['id'];
|
|
172
|
+
if (typeof id !== 'string') continue;
|
|
173
|
+
for (const output of operation.outputs) {
|
|
174
|
+
const match = /^public\/asset-library\/([^/]+)\/([^/]+)\//.exec(output.path);
|
|
175
|
+
if (match?.[1] && match[2]) ids.set(`${match[1]}/${match[2]}`, id);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return ids;
|
|
179
|
+
} catch {
|
|
180
|
+
return new Map();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
106
184
|
const LOCAL_ASSET_PLACEHOLDER_SVG =
|
|
107
185
|
'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" fill="#181818"/><path d="M64 82l64-36 64 36v76l-64 38-64-38z" fill="#252a31" stroke="#579eff" stroke-width="8"/><path d="M64 82l64 38 64-38M128 120v76" fill="none" stroke="#579eff" stroke-width="8"/><text x="128" y="226" text-anchor="middle" fill="#9aa4b2" font-family="sans-serif" font-size="18">PREVIEW PENDING</text></svg>';
|
|
108
186
|
|
|
@@ -110,6 +188,15 @@ function sendLocalPlaceholder(res: import('express').Response): void {
|
|
|
110
188
|
res.set('Cache-Control', 'no-store').type('image/svg+xml').send(LOCAL_ASSET_PLACEHOLDER_SVG);
|
|
111
189
|
}
|
|
112
190
|
|
|
191
|
+
function dependencyRole(path: string): 'dependency' | 'material' | 'texture' {
|
|
192
|
+
const extension = extname(path).toLowerCase();
|
|
193
|
+
if (extension === '.mtl') return 'material';
|
|
194
|
+
if (['.png', '.jpg', '.jpeg', '.webp', '.ktx', '.ktx2', '.dds', '.tga'].includes(extension)) {
|
|
195
|
+
return 'texture';
|
|
196
|
+
}
|
|
197
|
+
return 'dependency';
|
|
198
|
+
}
|
|
199
|
+
|
|
113
200
|
function getCached<T>(key: string): T | null {
|
|
114
201
|
const entry = cache.get(key);
|
|
115
202
|
if (!entry) return null;
|
|
@@ -353,6 +440,59 @@ function getAmbientCGFiles(id: string): AssetFileOption[] {
|
|
|
353
440
|
|
|
354
441
|
export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
355
442
|
const router = express.Router();
|
|
443
|
+
const historySnapshots = new Map<string, AssetHistorySnapshots>();
|
|
444
|
+
const assetHistory = () => {
|
|
445
|
+
const publicRoot = getPublicRoot();
|
|
446
|
+
const existing = historySnapshots.get(publicRoot);
|
|
447
|
+
if (existing) return existing;
|
|
448
|
+
const created = new AssetHistorySnapshots(publicRoot);
|
|
449
|
+
historySnapshots.set(publicRoot, created);
|
|
450
|
+
return created;
|
|
451
|
+
};
|
|
452
|
+
const captureCommittedHistory = async (assetPath: string, beforeToken: string) => {
|
|
453
|
+
try {
|
|
454
|
+
return {
|
|
455
|
+
assetPath,
|
|
456
|
+
beforeToken,
|
|
457
|
+
afterToken: await assetHistory().capture(assetPath),
|
|
458
|
+
};
|
|
459
|
+
} catch (error) {
|
|
460
|
+
await assetHistory().restore(assetPath, beforeToken);
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
router.post('/history/capture', async (req, res) => {
|
|
466
|
+
const assetPath = (req.body as { assetPath?: unknown }).assetPath;
|
|
467
|
+
if (typeof assetPath !== 'string') {
|
|
468
|
+
res.status(400).json({ error: 'Asset history capture requires an asset path.' });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
res.json({ ok: true, token: await assetHistory().capture(assetPath) });
|
|
473
|
+
} catch (error) {
|
|
474
|
+
res.status(400).json({
|
|
475
|
+
error: `Asset history capture failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
router.post('/history/restore', async (req, res) => {
|
|
481
|
+
const body = req.body as { assetPath?: unknown; token?: unknown };
|
|
482
|
+
if (typeof body.assetPath !== 'string' || typeof body.token !== 'string') {
|
|
483
|
+
res.status(400).json({ error: 'Asset history restore requires an asset path and token.' });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
await assetHistory().restore(body.assetPath, body.token);
|
|
488
|
+
broadcast('assets-changed', {});
|
|
489
|
+
res.json({ ok: true });
|
|
490
|
+
} catch (error) {
|
|
491
|
+
res.status(400).json({
|
|
492
|
+
error: `Asset history restore failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
});
|
|
356
496
|
router.use(express.json());
|
|
357
497
|
|
|
358
498
|
// ---- Search ----
|
|
@@ -374,8 +514,6 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
374
514
|
|
|
375
515
|
if (source === 'local') {
|
|
376
516
|
const catalog = await loadLocalAssetCatalog();
|
|
377
|
-
const cloudManifest = await loadCloudAssetManifest();
|
|
378
|
-
const cloudIds = new Set(cloudManifest?.assets.map((asset) => asset.id) ?? []);
|
|
379
517
|
const localResult = searchLocalCatalog(catalog, {
|
|
380
518
|
q,
|
|
381
519
|
type,
|
|
@@ -383,6 +521,10 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
383
521
|
limit,
|
|
384
522
|
...(category ? { category } : {}),
|
|
385
523
|
});
|
|
524
|
+
const cloudAssets = await findCloudAssets(
|
|
525
|
+
localResult.assets.flatMap((asset) => asset.variants.map((variant) => variant.id)),
|
|
526
|
+
);
|
|
527
|
+
const cloudIds = new Set(cloudAssets.map((asset) => asset.id));
|
|
386
528
|
result = {
|
|
387
529
|
total: localResult.total,
|
|
388
530
|
assets: localResult.assets.map((asset) => ({
|
|
@@ -530,10 +672,14 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
530
672
|
return;
|
|
531
673
|
}
|
|
532
674
|
const sourceDirectory = dirname(file.path);
|
|
533
|
-
const
|
|
675
|
+
const exactPath = [file.path, ...(file.dependencies ?? [])].find(
|
|
534
676
|
(candidate) =>
|
|
535
677
|
relative(sourceDirectory, candidate).replaceAll('\\', '/') === requestedRelativePath,
|
|
536
678
|
);
|
|
679
|
+
const basenameMatches = (file.dependencies ?? []).filter(
|
|
680
|
+
(candidate) => basename(candidate) === basename(requestedRelativePath),
|
|
681
|
+
);
|
|
682
|
+
const allowedPath = exactPath ?? (basenameMatches.length === 1 ? basenameMatches[0] : null);
|
|
537
683
|
if (!allowedPath) {
|
|
538
684
|
res.status(404).json({ error: 'Catalog preview dependency not found.' });
|
|
539
685
|
return;
|
|
@@ -639,9 +785,34 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
639
785
|
name: string;
|
|
640
786
|
url: string;
|
|
641
787
|
format: string;
|
|
788
|
+
jobId?: string;
|
|
642
789
|
includes?: { relativePath: string; url: string; size: number }[];
|
|
643
790
|
};
|
|
644
791
|
const { source, id, name, url, format } = body;
|
|
792
|
+
const cancellation = new AbortController();
|
|
793
|
+
req.once('aborted', () =>
|
|
794
|
+
cancellation.abort(new DOMException('Import cancelled', 'AbortError')),
|
|
795
|
+
);
|
|
796
|
+
res.once('close', () => {
|
|
797
|
+
if (!res.writableEnded)
|
|
798
|
+
cancellation.abort(new DOMException('Import cancelled', 'AbortError'));
|
|
799
|
+
});
|
|
800
|
+
const throwIfCancelled = () => cancellation.signal.throwIfAborted();
|
|
801
|
+
const progress = (
|
|
802
|
+
stage: 'fetch' | 'convert' | 'validate' | 'commit',
|
|
803
|
+
loaded: number,
|
|
804
|
+
total: number,
|
|
805
|
+
message?: string,
|
|
806
|
+
) => {
|
|
807
|
+
if (!body.jobId) return;
|
|
808
|
+
broadcast('asset-import-progress', {
|
|
809
|
+
jobId: body.jobId,
|
|
810
|
+
stage,
|
|
811
|
+
loaded,
|
|
812
|
+
total,
|
|
813
|
+
...(message ? { message } : {}),
|
|
814
|
+
});
|
|
815
|
+
};
|
|
645
816
|
const includes = body.includes ?? [];
|
|
646
817
|
|
|
647
818
|
if (!source || !id || !url) {
|
|
@@ -656,6 +827,7 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
656
827
|
}
|
|
657
828
|
|
|
658
829
|
if (source === 'local') {
|
|
830
|
+
let stagingDir: string | null = null;
|
|
659
831
|
try {
|
|
660
832
|
const family = await findLocalAssetFamily(id);
|
|
661
833
|
const asset = family ? await findLocalAsset(family.id) : null;
|
|
@@ -680,8 +852,11 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
680
852
|
res.status(404).json({ error: 'Cloud asset file not found.' });
|
|
681
853
|
return;
|
|
682
854
|
}
|
|
683
|
-
|
|
684
|
-
|
|
855
|
+
stagingDir = await beginAssetStaging(destDir);
|
|
856
|
+
const mainDestination = join(stagingDir, cloudFile.main.relativePath);
|
|
857
|
+
if (!isPathInside(stagingDir, mainDestination)) {
|
|
858
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
859
|
+
stagingDir = null;
|
|
685
860
|
res.status(400).json({ error: 'Invalid cloud asset path.' });
|
|
686
861
|
return;
|
|
687
862
|
}
|
|
@@ -690,10 +865,14 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
690
865
|
mainDestination,
|
|
691
866
|
cloudFile.main.sha256,
|
|
692
867
|
cloudFile.main.sizeBytes,
|
|
868
|
+
(loaded, total) => progress('fetch', loaded, total),
|
|
869
|
+
cancellation.signal,
|
|
693
870
|
);
|
|
694
871
|
for (const [dependencyIndex, dependency] of cloudFile.dependencies.entries()) {
|
|
695
|
-
const destination = join(
|
|
696
|
-
if (!isPathInside(
|
|
872
|
+
const destination = join(stagingDir, dependency.relativePath);
|
|
873
|
+
if (!isPathInside(stagingDir, destination)) {
|
|
874
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
875
|
+
stagingDir = null;
|
|
697
876
|
res
|
|
698
877
|
.status(400)
|
|
699
878
|
.json({ error: `Invalid dependency path: ${dependency.relativePath}` });
|
|
@@ -704,10 +883,51 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
704
883
|
destination,
|
|
705
884
|
dependency.sha256,
|
|
706
885
|
dependency.sizeBytes,
|
|
886
|
+
(loaded, total) =>
|
|
887
|
+
progress('fetch', loaded, total, `Dependency ${dependencyIndex + 1}`),
|
|
888
|
+
cancellation.signal,
|
|
707
889
|
);
|
|
708
890
|
}
|
|
709
|
-
|
|
710
|
-
|
|
891
|
+
const cloudSources: CatalogRecipeSource[] = [
|
|
892
|
+
{
|
|
893
|
+
stagedPath: mainDestination,
|
|
894
|
+
path: cloudFile.main.relativePath,
|
|
895
|
+
url,
|
|
896
|
+
role: 'primary' as const,
|
|
897
|
+
},
|
|
898
|
+
...cloudFile.dependencies.map((dependency, dependencyIndex) => ({
|
|
899
|
+
stagedPath: join(stagingDir!, dependency.relativePath),
|
|
900
|
+
path: dependency.relativePath,
|
|
901
|
+
url: cloudAssetObjectUrl(id, cloudRoute.fileIndex, dependencyIndex),
|
|
902
|
+
role: dependencyRole(dependency.relativePath),
|
|
903
|
+
})),
|
|
904
|
+
];
|
|
905
|
+
const cloudResultPath = `asset-library/local/${assetSlug}/${cloudFile.main.relativePath}`;
|
|
906
|
+
await writeCatalogImportRecipe({
|
|
907
|
+
stagingDirectory: stagingDir,
|
|
908
|
+
source: 'local',
|
|
909
|
+
familyId: family.id,
|
|
910
|
+
variantId: cloudAsset.id,
|
|
911
|
+
deliveryId: `cloud:${cloudAsset.id}`,
|
|
912
|
+
displayName: asset.name,
|
|
913
|
+
destination: cloudResultPath,
|
|
914
|
+
recipeRelativePath: `${cloudFile.main.relativePath}.vgai-import.json`,
|
|
915
|
+
sources: cloudSources,
|
|
916
|
+
contentKind: cloudFile.format.toLowerCase() === 'bvh' ? 'animation-only' : 'model',
|
|
917
|
+
runtimeReady: ['glb', 'gltf'].includes(cloudFile.format.toLowerCase()),
|
|
918
|
+
backend: 'native',
|
|
919
|
+
attribution: {
|
|
920
|
+
sourceUrl: asset.sourceUrl,
|
|
921
|
+
author: asset.author,
|
|
922
|
+
license: asset.license,
|
|
923
|
+
text: cloudAsset.attribution ?? `${asset.name} by ${asset.author} (${asset.license})`,
|
|
924
|
+
},
|
|
925
|
+
});
|
|
926
|
+
progress('validate', cloudFile.main.sizeBytes, cloudFile.main.sizeBytes);
|
|
927
|
+
throwIfCancelled();
|
|
928
|
+
await copyLocalAssetThumbnail(asset, stagingDir);
|
|
929
|
+
const beforeToken = await assetHistory().capture(cloudResultPath);
|
|
930
|
+
const committed = await commitCatalogAsset(publicRoot, stagingDir, destDir, {
|
|
711
931
|
id: family.id,
|
|
712
932
|
variantId: cloudVariantId,
|
|
713
933
|
name: asset.name,
|
|
@@ -719,9 +939,17 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
719
939
|
attribution: cloudAsset.attribution,
|
|
720
940
|
sha256: cloudFile.main.sha256,
|
|
721
941
|
});
|
|
722
|
-
|
|
942
|
+
progress('commit', 1, 1);
|
|
943
|
+
stagingDir = null;
|
|
944
|
+
const resultPath = cloudResultPath;
|
|
945
|
+
const history = await captureCommittedHistory(resultPath, beforeToken);
|
|
723
946
|
broadcast('assets-changed', {});
|
|
724
|
-
res.json({
|
|
947
|
+
res.json({
|
|
948
|
+
ok: true,
|
|
949
|
+
path: resultPath,
|
|
950
|
+
provenanceOperationId: committed.provenanceOperationId,
|
|
951
|
+
history,
|
|
952
|
+
});
|
|
725
953
|
return;
|
|
726
954
|
}
|
|
727
955
|
|
|
@@ -738,18 +966,75 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
738
966
|
const libraryRoot = getLocalAssetLibraryRoot();
|
|
739
967
|
const mainSource = resolveCatalogFile(libraryRoot, selectedFile.path);
|
|
740
968
|
const sourceDirectory = dirname(mainSource);
|
|
741
|
-
await
|
|
742
|
-
|
|
969
|
+
stagingDir = await beginAssetStaging(destDir);
|
|
970
|
+
const primaryRelative = basename(mainSource);
|
|
971
|
+
const stagedPrimary = join(stagingDir, primaryRelative);
|
|
972
|
+
progress('fetch', 0, selectedFile.sizeBytes);
|
|
973
|
+
await copyFile(mainSource, stagedPrimary);
|
|
974
|
+
progress('fetch', selectedFile.sizeBytes, selectedFile.sizeBytes);
|
|
975
|
+
throwIfCancelled();
|
|
976
|
+
const recipeSources: CatalogRecipeSource[] = [
|
|
977
|
+
{
|
|
978
|
+
stagedPath: stagedPrimary,
|
|
979
|
+
path: primaryRelative,
|
|
980
|
+
url,
|
|
981
|
+
role: 'primary' as const,
|
|
982
|
+
},
|
|
983
|
+
];
|
|
743
984
|
for (const dependency of selectedFile.dependencies ?? []) {
|
|
744
985
|
const dependencySource = resolveCatalogFile(libraryRoot, dependency);
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
986
|
+
const sourceRelative = relative(sourceDirectory, dependencySource);
|
|
987
|
+
const dependencyRelative = sourceRelative.startsWith('..')
|
|
988
|
+
? basename(dependencySource)
|
|
989
|
+
: sourceRelative;
|
|
990
|
+
const dependencyDestination = join(stagingDir, dependencyRelative);
|
|
748
991
|
await mkdir(dirname(dependencyDestination), { recursive: true });
|
|
749
992
|
await copyFile(dependencySource, dependencyDestination);
|
|
993
|
+
recipeSources.push({
|
|
994
|
+
stagedPath: dependencyDestination,
|
|
995
|
+
path: dependencyRelative.replaceAll('\\', '/'),
|
|
996
|
+
url: `catalog:${dependency}`,
|
|
997
|
+
role: dependencyRole(dependency),
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
let resultFile = primaryRelative;
|
|
1001
|
+
let backend: 'native' | 'threejs' = 'native';
|
|
1002
|
+
const format = selectedFile.format.toLowerCase();
|
|
1003
|
+
if (SERVER_CONVERTIBLE_MODEL_FORMATS.has(format)) {
|
|
1004
|
+
progress('convert', 0, 1);
|
|
1005
|
+
resultFile = `${basename(primaryRelative, extname(primaryRelative))}.glb`;
|
|
1006
|
+
const converted = await convertStagedModelToGlb(stagedPrimary, format);
|
|
1007
|
+
await writeFile(join(stagingDir, resultFile), converted);
|
|
1008
|
+
backend = 'threejs';
|
|
1009
|
+
progress('convert', 1, 1);
|
|
1010
|
+
throwIfCancelled();
|
|
750
1011
|
}
|
|
751
|
-
|
|
752
|
-
await
|
|
1012
|
+
const resultPath = `asset-library/local/${assetSlug}/${resultFile}`;
|
|
1013
|
+
await writeCatalogImportRecipe({
|
|
1014
|
+
stagingDirectory: stagingDir,
|
|
1015
|
+
source: 'local',
|
|
1016
|
+
familyId: family.id,
|
|
1017
|
+
variantId: selectedAsset.id,
|
|
1018
|
+
deliveryId: `local:${selectedAsset.id}`,
|
|
1019
|
+
displayName: asset.name,
|
|
1020
|
+
destination: resultPath,
|
|
1021
|
+
sources: recipeSources,
|
|
1022
|
+
contentKind: format === 'bvh' ? 'animation-only' : 'model',
|
|
1023
|
+
generatedFiles: backend === 'threejs' ? [resultPath] : [],
|
|
1024
|
+
runtimeReady: ['glb', 'gltf'].includes(format) || backend === 'threejs',
|
|
1025
|
+
backend,
|
|
1026
|
+
attribution: {
|
|
1027
|
+
sourceUrl: asset.sourceUrl,
|
|
1028
|
+
author: asset.author,
|
|
1029
|
+
license: asset.license,
|
|
1030
|
+
text: `${asset.name} by ${asset.author} (${asset.license})`,
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
progress('validate', 1, 1);
|
|
1034
|
+
throwIfCancelled();
|
|
1035
|
+
await copyLocalAssetThumbnail(asset, stagingDir);
|
|
1036
|
+
const beforeToken = await assetHistory().capture(resultPath);
|
|
1037
|
+
const committed = await commitCatalogAsset(publicRoot, stagingDir, destDir, {
|
|
753
1038
|
id: family.id,
|
|
754
1039
|
variantId: selectedAsset.id,
|
|
755
1040
|
name: asset.name,
|
|
@@ -760,11 +1045,18 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
760
1045
|
delivery: 'local-ssd',
|
|
761
1046
|
originalPath: selectedFile.path,
|
|
762
1047
|
});
|
|
763
|
-
|
|
764
|
-
|
|
1048
|
+
progress('commit', 1, 1);
|
|
1049
|
+
stagingDir = null;
|
|
1050
|
+
const history = await captureCommittedHistory(resultPath, beforeToken);
|
|
765
1051
|
broadcast('assets-changed', {});
|
|
766
|
-
res.json({
|
|
1052
|
+
res.json({
|
|
1053
|
+
ok: true,
|
|
1054
|
+
path: resultPath,
|
|
1055
|
+
provenanceOperationId: committed.provenanceOperationId,
|
|
1056
|
+
history,
|
|
1057
|
+
});
|
|
767
1058
|
} catch (error) {
|
|
1059
|
+
if (stagingDir) await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
|
|
768
1060
|
res.status(500).json({ error: `Local asset copy failed: ${error}` });
|
|
769
1061
|
}
|
|
770
1062
|
return;
|
|
@@ -808,73 +1100,124 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
808
1100
|
// Doesn't exist yet — continue
|
|
809
1101
|
}
|
|
810
1102
|
|
|
1103
|
+
let stagingDir: string | null = null;
|
|
811
1104
|
try {
|
|
812
|
-
await
|
|
1105
|
+
stagingDir = await beginAssetStaging(destDir);
|
|
813
1106
|
|
|
814
1107
|
/** Download a single file to a destination path. */
|
|
815
|
-
async function downloadFile(
|
|
1108
|
+
async function downloadFile(
|
|
1109
|
+
fileUrl: string,
|
|
1110
|
+
destPath: string,
|
|
1111
|
+
expectedBytes?: number,
|
|
1112
|
+
): Promise<void> {
|
|
816
1113
|
await mkdir(join(destPath, '..'), { recursive: true });
|
|
817
|
-
const response = await fetch(fileUrl);
|
|
1114
|
+
const response = await fetch(fileUrl, { signal: cancellation.signal });
|
|
818
1115
|
if (!response.ok || !response.body) {
|
|
819
1116
|
throw new Error(`Download failed: ${response.status} for ${fileUrl}`);
|
|
820
1117
|
}
|
|
821
1118
|
const nodeStream = Readable.fromWeb(response.body as import('stream/web').ReadableStream);
|
|
822
|
-
|
|
1119
|
+
const total =
|
|
1120
|
+
expectedBytes ?? Number.parseInt(response.headers.get('content-length') ?? '0', 10);
|
|
1121
|
+
let loaded = 0;
|
|
1122
|
+
const reporter = new Transform({
|
|
1123
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
1124
|
+
loaded += chunk.length;
|
|
1125
|
+
progress('fetch', loaded, total);
|
|
1126
|
+
callback(null, chunk);
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
await pipeline(nodeStream, reporter, createWriteStream(destPath));
|
|
823
1130
|
}
|
|
824
1131
|
|
|
825
1132
|
const isZip = format === 'zip' || url.endsWith('.zip');
|
|
826
1133
|
const ext = isZip ? 'zip' : url.split('.').pop() || format || 'bin';
|
|
827
1134
|
const fileName = `${slug}.${ext}`;
|
|
828
|
-
const filePath = join(
|
|
1135
|
+
const filePath = join(stagingDir, fileName);
|
|
829
1136
|
|
|
830
1137
|
// Download main file
|
|
831
1138
|
await downloadFile(url, filePath);
|
|
832
1139
|
|
|
833
1140
|
// Download included files (GLTF bin + textures)
|
|
834
1141
|
for (const inc of includes) {
|
|
835
|
-
const incPath = join(
|
|
1142
|
+
const incPath = join(stagingDir, inc.relativePath);
|
|
836
1143
|
// S1: ensure each relativePath stays inside destDir (reject `..` escapes).
|
|
837
|
-
if (!isPathInside(
|
|
1144
|
+
if (!isPathInside(stagingDir, incPath)) {
|
|
1145
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
1146
|
+
stagingDir = null;
|
|
838
1147
|
res.status(400).json({ error: `Invalid include path: ${inc.relativePath}` });
|
|
839
1148
|
return;
|
|
840
1149
|
}
|
|
841
|
-
await downloadFile(inc.url, incPath);
|
|
1150
|
+
await downloadFile(inc.url, incPath, inc.size);
|
|
842
1151
|
}
|
|
843
1152
|
|
|
844
1153
|
let resultPath = `asset-library/${source}/${slug}/${fileName}`;
|
|
845
1154
|
|
|
846
1155
|
// Extract ZIPs
|
|
847
1156
|
if (isZip) {
|
|
1157
|
+
progress('convert', 0, 1);
|
|
848
1158
|
await new Promise<void>((resolve, reject) => {
|
|
849
|
-
execFile('unzip', ['-o', filePath, '-d',
|
|
1159
|
+
execFile('unzip', ['-o', filePath, '-d', stagingDir!], (err) => {
|
|
850
1160
|
if (err) reject(err);
|
|
851
1161
|
else resolve();
|
|
852
1162
|
});
|
|
853
1163
|
});
|
|
854
1164
|
await unlink(filePath).catch(() => {});
|
|
855
|
-
const extracted = await readdir(
|
|
1165
|
+
const extracted = await readdir(stagingDir);
|
|
856
1166
|
const mainFile = extracted.find((f) => !f.endsWith('.zip')) ?? extracted[0];
|
|
857
1167
|
if (mainFile) {
|
|
858
1168
|
resultPath = `asset-library/${source}/${slug}/${mainFile}`;
|
|
859
1169
|
}
|
|
1170
|
+
progress('convert', 1, 1);
|
|
860
1171
|
}
|
|
861
1172
|
|
|
862
|
-
|
|
1173
|
+
progress('validate', 1, 1);
|
|
1174
|
+
throwIfCancelled();
|
|
1175
|
+
const beforeToken = await assetHistory().capture(resultPath);
|
|
1176
|
+
const committed = await commitCatalogAsset(publicRoot, stagingDir, destDir, {
|
|
863
1177
|
id,
|
|
864
1178
|
name,
|
|
865
1179
|
source,
|
|
866
1180
|
sourceUrl: url,
|
|
867
1181
|
delivery: source,
|
|
868
1182
|
});
|
|
869
|
-
|
|
1183
|
+
progress('commit', 1, 1);
|
|
1184
|
+
stagingDir = null;
|
|
1185
|
+
const history = await captureCommittedHistory(resultPath, beforeToken);
|
|
870
1186
|
broadcast('assets-changed', {});
|
|
871
1187
|
|
|
872
|
-
res.json({
|
|
1188
|
+
res.json({
|
|
1189
|
+
ok: true,
|
|
1190
|
+
path: resultPath,
|
|
1191
|
+
provenanceOperationId: committed.provenanceOperationId,
|
|
1192
|
+
history,
|
|
1193
|
+
});
|
|
873
1194
|
} catch (err) {
|
|
1195
|
+
if (stagingDir) await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
|
|
874
1196
|
res.status(500).json({ error: `Download failed: ${err}` });
|
|
875
1197
|
}
|
|
876
1198
|
});
|
|
877
1199
|
|
|
1200
|
+
router.post('/reimport', async (req, res) => {
|
|
1201
|
+
const body = req.body as { assetPath?: unknown; plan?: unknown };
|
|
1202
|
+
if (typeof body.assetPath !== 'string' || !body.plan) {
|
|
1203
|
+
res.status(400).json({ error: 'Reimport requires an asset path and saved import plan.' });
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
const result = await reimportLocalCatalogAsset(
|
|
1208
|
+
getPublicRoot(),
|
|
1209
|
+
body.assetPath,
|
|
1210
|
+
body.plan as Parameters<typeof reimportLocalCatalogAsset>[2],
|
|
1211
|
+
);
|
|
1212
|
+
broadcast('assets-changed', {});
|
|
1213
|
+
res.json({ ok: true, ...result });
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
res.status(500).json({
|
|
1216
|
+
error: `Reimport failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
|
|
878
1221
|
// ---- Categories for Poly Haven ----
|
|
879
1222
|
router.get('/categories', async (req, res) => {
|
|
880
1223
|
const source = req.query['source'] as string | undefined;
|
|
@@ -951,6 +1294,7 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
951
1294
|
const publicRoot = getPublicRoot();
|
|
952
1295
|
const libraryDir = join(publicRoot, 'asset-library');
|
|
953
1296
|
const downloaded: { source: string; id: string; path: string }[] = [];
|
|
1297
|
+
const centralIds = await centralCatalogIds(dirname(publicRoot));
|
|
954
1298
|
|
|
955
1299
|
try {
|
|
956
1300
|
const sources = await readdir(libraryDir).catch(() => [] as string[]);
|
|
@@ -969,9 +1313,15 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
|
|
|
969
1313
|
const provenance = await readFile(join(slugDir, '_VGAI_ASSET.json'), 'utf8')
|
|
970
1314
|
.then((text) => JSON.parse(text) as { id?: unknown })
|
|
971
1315
|
.catch(() => null);
|
|
972
|
-
const id =
|
|
1316
|
+
const id =
|
|
1317
|
+
centralIds.get(`${source}/${slug}`) ??
|
|
1318
|
+
(typeof provenance?.id === 'string' ? provenance.id : slug);
|
|
973
1319
|
// Find the main asset file
|
|
974
|
-
const
|
|
1320
|
+
const assetFiles = files.filter(
|
|
1321
|
+
(file) => file !== '_VGAI_ASSET.json' && file !== '.vgai-thumbnail.webp',
|
|
1322
|
+
);
|
|
1323
|
+
const mainFile =
|
|
1324
|
+
assetFiles.find((file) => /\.(gltf|glb|hdr|exr)$/i.test(file)) ?? assetFiles[0];
|
|
975
1325
|
if (mainFile) {
|
|
976
1326
|
downloaded.push({
|
|
977
1327
|
source,
|
|
@@ -996,9 +1346,15 @@ export async function downloadRemoteFile(
|
|
|
996
1346
|
destination: string,
|
|
997
1347
|
expectedSha256: string,
|
|
998
1348
|
expectedSizeBytes: number,
|
|
1349
|
+
onProgress?: (loaded: number, total: number) => void,
|
|
1350
|
+
signal?: AbortSignal,
|
|
999
1351
|
): Promise<void> {
|
|
1000
1352
|
await mkdir(dirname(destination), { recursive: true });
|
|
1001
|
-
const response = await fetch(url, {
|
|
1353
|
+
const response = await fetch(url, {
|
|
1354
|
+
signal: signal
|
|
1355
|
+
? AbortSignal.any([signal, AbortSignal.timeout(120_000)])
|
|
1356
|
+
: AbortSignal.timeout(120_000),
|
|
1357
|
+
});
|
|
1002
1358
|
if (!response.ok || !response.body) {
|
|
1003
1359
|
throw new Error(`Download failed: ${response.status} for ${url}`);
|
|
1004
1360
|
}
|
|
@@ -1006,9 +1362,15 @@ export async function downloadRemoteFile(
|
|
|
1006
1362
|
let sizeBytes = 0;
|
|
1007
1363
|
const verifier = new Transform({
|
|
1008
1364
|
transform(chunk: Buffer, _encoding, callback) {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1365
|
+
try {
|
|
1366
|
+
signal?.throwIfAborted();
|
|
1367
|
+
hash.update(chunk);
|
|
1368
|
+
sizeBytes += chunk.length;
|
|
1369
|
+
onProgress?.(sizeBytes, expectedSizeBytes);
|
|
1370
|
+
callback(null, chunk);
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
callback(error as Error);
|
|
1373
|
+
}
|
|
1012
1374
|
},
|
|
1013
1375
|
});
|
|
1014
1376
|
const nodeStream = Readable.fromWeb(response.body as import('stream/web').ReadableStream);
|
|
@@ -1026,17 +1388,6 @@ export async function downloadRemoteFile(
|
|
|
1026
1388
|
}
|
|
1027
1389
|
}
|
|
1028
1390
|
|
|
1029
|
-
async function writeAssetProvenance(
|
|
1030
|
-
destination: string,
|
|
1031
|
-
provenance: Record<string, unknown>,
|
|
1032
|
-
): Promise<void> {
|
|
1033
|
-
await mkdir(destination, { recursive: true });
|
|
1034
|
-
await writeFile(
|
|
1035
|
-
join(destination, '_VGAI_ASSET.json'),
|
|
1036
|
-
`${JSON.stringify(provenance, null, 2)}\n`,
|
|
1037
|
-
);
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
1391
|
async function copyLocalAssetThumbnail(
|
|
1041
1392
|
asset: NonNullable<Awaited<ReturnType<typeof findLocalAsset>>>,
|
|
1042
1393
|
destination: string,
|