@forgeax-extension/character-3d 0.1.2

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.
Files changed (110) hide show
  1. package/SOURCE_PROVENANCE.md +8 -0
  2. package/dist/assets/index-CIx3aXo1.js +4336 -0
  3. package/dist/assets/index-CIx3aXo1.js.map +1 -0
  4. package/dist/assets/index-CmhOHLEB.css +1 -0
  5. package/dist/hdr/README.md +28 -0
  6. package/dist/hdr/presets.json +8 -0
  7. package/dist/index.html +13 -0
  8. package/forgeax-extension.json +428 -0
  9. package/marketplace-card.json +56 -0
  10. package/package.json +59 -0
  11. package/packages/external-asset-meta/package.json +24 -0
  12. package/packages/external-asset-meta/src/cook.test.ts +211 -0
  13. package/packages/external-asset-meta/src/cook.ts +188 -0
  14. package/packages/external-asset-meta/src/draco3dgltf.d.ts +11 -0
  15. package/packages/external-asset-meta/src/index.ts +18 -0
  16. package/packages/external-asset-meta/src/normalize.test.ts +36 -0
  17. package/packages/external-asset-meta/src/normalize.ts +101 -0
  18. package/packages/external-asset-meta/src/types.ts +82 -0
  19. package/schemas/adopt-playable-character.args.json +26 -0
  20. package/schemas/adopt-playable-character.returns.json +31 -0
  21. package/schemas/apply-motion.args.json +47 -0
  22. package/schemas/apply-motion.returns.json +23 -0
  23. package/schemas/auto-rig.args.json +35 -0
  24. package/schemas/auto-rig.returns.json +23 -0
  25. package/schemas/delete-asset.args.json +18 -0
  26. package/schemas/delete-asset.returns.json +18 -0
  27. package/schemas/engine-import-status.args.json +18 -0
  28. package/schemas/engine-import-status.returns.json +50 -0
  29. package/schemas/export-playable-character.args.json +21 -0
  30. package/schemas/export-playable-character.returns.json +47 -0
  31. package/schemas/gen3d-asset-manifest.json +281 -0
  32. package/schemas/generate-meshy-text-mock.args.json +42 -0
  33. package/schemas/generate-meshy-text-mock.returns.json +301 -0
  34. package/schemas/get-credentials.args.json +7 -0
  35. package/schemas/get-credentials.returns.json +45 -0
  36. package/schemas/get-playable-profile.args.json +11 -0
  37. package/schemas/get-playable-profile.returns.json +188 -0
  38. package/schemas/image-to-3d.args.json +47 -0
  39. package/schemas/image-to-3d.returns.json +306 -0
  40. package/schemas/import-to-engine.args.json +18 -0
  41. package/schemas/import-to-engine.returns.json +35 -0
  42. package/schemas/list-assets.args.json +22 -0
  43. package/schemas/list-assets.returns.json +296 -0
  44. package/schemas/list-motions.args.json +29 -0
  45. package/schemas/list-motions.returns.json +41 -0
  46. package/schemas/pose-standardization.args.json +28 -0
  47. package/schemas/pose-standardization.returns.json +29 -0
  48. package/schemas/provider-status.args.json +7 -0
  49. package/schemas/provider-status.returns.json +70 -0
  50. package/schemas/refine-mesh.args.json +33 -0
  51. package/schemas/refine-mesh.returns.json +306 -0
  52. package/schemas/rename-asset.args.json +22 -0
  53. package/schemas/rename-asset.returns.json +14 -0
  54. package/schemas/retopo-lowpoly.args.json +34 -0
  55. package/schemas/retopo-lowpoly.returns.json +24 -0
  56. package/schemas/score-quality.args.json +36 -0
  57. package/schemas/score-quality.returns.json +12 -0
  58. package/schemas/set-credentials.args.json +32 -0
  59. package/schemas/set-credentials.returns.json +45 -0
  60. package/schemas/set-playable-motion-mapping.args.json +25 -0
  61. package/schemas/set-playable-motion-mapping.returns.json +33 -0
  62. package/schemas/set-playable-profile.args.json +32 -0
  63. package/schemas/set-playable-profile.returns.json +67 -0
  64. package/schemas/text-to-3d.args.json +51 -0
  65. package/schemas/text-to-3d.returns.json +306 -0
  66. package/schemas/upload-image.args.json +19 -0
  67. package/schemas/upload-image.returns.json +21 -0
  68. package/schemas/upload-video.args.json +14 -0
  69. package/schemas/upload-video.returns.json +14 -0
  70. package/schemas/views-to-3d.args.json +58 -0
  71. package/schemas/views-to-3d.returns.json +306 -0
  72. package/server/adopt-playable-character.ts +291 -0
  73. package/server/asset-storage.ts +136 -0
  74. package/server/asset-upload.ts +123 -0
  75. package/server/audit.ts +58 -0
  76. package/server/cache.ts +75 -0
  77. package/server/cos-uploader.ts +141 -0
  78. package/server/credentials-store.ts +186 -0
  79. package/server/engine-import.ts +238 -0
  80. package/server/env.ts +287 -0
  81. package/server/export-playable-character.ts +364 -0
  82. package/server/generate.ts +89 -0
  83. package/server/merge-playable-character.ts +259 -0
  84. package/server/motion-catalog.ts +137 -0
  85. package/server/per-game-store.ts +1016 -0
  86. package/server/providers/gateway-client.ts +171 -0
  87. package/server/providers/gateway-data.ts +182 -0
  88. package/server/providers/gateway-models.ts +49 -0
  89. package/server/providers/hunyuan-rest.ts +317 -0
  90. package/server/providers/hunyuan-workflow.ts +225 -0
  91. package/server/providers/meshy-direct-client.ts +223 -0
  92. package/server/providers/meshy.ts +548 -0
  93. package/server/providers/rodin-gateway.ts +193 -0
  94. package/server/providers/rodin.ts +253 -0
  95. package/server/providers/visvise.ts +329 -0
  96. package/server/rate-guard.ts +29 -0
  97. package/server/tool-handlers.ts +2193 -0
  98. package/shared/catalog.ts +307 -0
  99. package/shared/manifest.ts +378 -0
  100. package/shared/meshy-actions.ts +690 -0
  101. package/shared/playable-preview-url.test.ts +14 -0
  102. package/shared/playable-preview-url.ts +13 -0
  103. package/shared/playable-profile.test.ts +127 -0
  104. package/shared/playable-profile.ts +171 -0
  105. package/shared/provider-params.test.ts +172 -0
  106. package/shared/provider-params.ts +389 -0
  107. package/shared/quality/heuristics.test.ts +84 -0
  108. package/shared/quality/heuristics.ts +100 -0
  109. package/shared/rodin-image.test.ts +51 -0
  110. package/shared/rodin-image.ts +125 -0
@@ -0,0 +1,186 @@
1
+ // Plugin-self-managed provider credentials store.
2
+ //
3
+ // Plugin-local .env holds only the master real/mock switch and optional COS
4
+ // fallback keys. Transfer uploads prefer LiteLLM /v1/assets (Studio key).
5
+ // Meshy personal keys (GEN3D_MESHY_DIRECT / MESHY_API_KEY) are env-only for
6
+ // local recording — never written here.
7
+ //
8
+ // Writes go to the plugin-local .env (gitignored) AND are live-applied to
9
+ // process.env so server/env.ts read() — which reads process.env — picks them up
10
+ // immediately, no server restart (mirrors the host's PUT /api/settings/env
11
+ // live-apply). parseEnv/serializeEnv/maskKey are ported verbatim from
12
+ // packages/server/src/api/settings.ts so the masked-render and comment-preserving
13
+ // contracts match the rest of Studio.
14
+ //
15
+ // SECURITY: nothing here logs, and no thrown error ever carries a plaintext key
16
+ // value — only field names / file paths.
17
+
18
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
+ import { dirname, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { pickLitellmFromEnv } from './env';
22
+
23
+ // Whitelist of keys this store is allowed to read/write. LiteLLM keys are
24
+ // excluded — they live in Studio global .env only.
25
+ export const CRED_KEYS = [
26
+ 'GEN3D_ENABLE_REAL_PROVIDERS',
27
+ 'COS_SECRET_ID',
28
+ 'COS_SECRET_KEY',
29
+ 'COS_BUCKET',
30
+ 'COS_REGION',
31
+ ] as const;
32
+
33
+ export const SECRET_KEYS = ['COS_SECRET_ID', 'COS_SECRET_KEY'] as const;
34
+
35
+ export interface CredentialsState {
36
+ ok: true;
37
+ realProvidersEnabled: boolean;
38
+ /** Read-only: derived from Studio global .env, never from plugin .env. */
39
+ litellmConfigured: boolean;
40
+ /** Masked gateway key from Studio settings, or null when unset. Read-only. */
41
+ litellmProxyKey: string | null;
42
+ credentials: {
43
+ COS_SECRET_ID: string | null; // masked or null
44
+ COS_SECRET_KEY: string | null; // masked or null
45
+ COS_BUCKET: string | null; // PLAINTEXT or null
46
+ COS_REGION: string | null; // PLAINTEXT or null
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Mask a secret for safe rendering. Ported verbatim from settings.ts: undefined
52
+ * / empty → null; len ≤ 8 → '***'; otherwise first-4 + ellipsis + last-4 so the
53
+ * user can recognise which key is set without exposing enough to steal it. The
54
+ * UI parses these strings literally, so the shape is a contract (unit-tested).
55
+ */
56
+ export function maskKey(v?: string): string | null {
57
+ if (!v) return null;
58
+ if (v.length <= 8) return '***';
59
+ return `${v.slice(0, 4)}...${v.slice(-4)}`;
60
+ }
61
+
62
+ // Minimal .env parser — ported from settings.ts. Recognizes UPPER_SNAKE keys,
63
+ // strips matched surrounding quotes; ignores comments / blank / unknown lines.
64
+ function parseEnv(raw: string): Record<string, string> {
65
+ const out: Record<string, string> = {};
66
+ for (const line of raw.split('\n')) {
67
+ const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
68
+ if (!m) continue;
69
+ let value = m[2];
70
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
71
+ value = value.slice(1, -1);
72
+ }
73
+ out[m[1]] = value;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ // Serialize — ported from settings.ts. Preserves comments / unknown lines from
79
+ // the original, updates recognized keys in place, appends new keys at the end.
80
+ function serializeEnv(env: Record<string, string>, original?: string): string {
81
+ const seen = new Set<string>();
82
+ const lines: string[] = [];
83
+ if (original) {
84
+ for (const line of original.split('\n')) {
85
+ const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=/.exec(line);
86
+ if (m && env[m[1]] !== undefined) {
87
+ lines.push(`${m[1]}=${env[m[1]]}`);
88
+ seen.add(m[1]);
89
+ } else {
90
+ lines.push(line);
91
+ }
92
+ }
93
+ }
94
+ for (const [k, v] of Object.entries(env)) {
95
+ if (!seen.has(k)) lines.push(`${k}=${v}`);
96
+ }
97
+ return lines.join('\n');
98
+ }
99
+
100
+ // Plugin-local .env path — IDENTICAL resolution to server/env.ts
101
+ // loadPluginEnvOnce so both touch the same file (server/ → '..' → plugin root).
102
+ export function defaultEnvPath(): string {
103
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..', '.env');
104
+ }
105
+
106
+ // Resolve one key's effective value the same way env.ts does: process.env wins
107
+ // over the file (the server's env always wins), and an empty / whitespace value
108
+ // reads as "not set". Returns the trimmed value or undefined.
109
+ function effective(name: string, fileEnv: Record<string, string>): string | undefined {
110
+ const raw = name in process.env ? process.env[name] : fileEnv[name];
111
+ return raw && raw.trim() ? raw.trim() : undefined;
112
+ }
113
+
114
+ function readLitellmStatus(): { configured: boolean; maskedKey: string | null } {
115
+ const litellm = pickLitellmFromEnv(process.env);
116
+ return {
117
+ configured: litellm !== null,
118
+ maskedKey: litellm ? maskKey(litellm.apiKey) : null,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Read the masked credential state. Plugin .env holds COS + master switch only;
124
+ * LiteLLM status is read-only from Studio global .env. Never returns a plaintext key.
125
+ */
126
+ export function readCredentials(envPath: string = defaultEnvPath()): CredentialsState {
127
+ let fileEnv: Record<string, string> = {};
128
+ if (existsSync(envPath)) {
129
+ try {
130
+ fileEnv = parseEnv(readFileSync(envPath, 'utf8'));
131
+ } catch {
132
+ fileEnv = {};
133
+ }
134
+ }
135
+ const litellm = readLitellmStatus();
136
+ return {
137
+ ok: true,
138
+ realProvidersEnabled: effective('GEN3D_ENABLE_REAL_PROVIDERS', fileEnv) === '1',
139
+ litellmConfigured: litellm.configured,
140
+ litellmProxyKey: litellm.maskedKey,
141
+ credentials: {
142
+ COS_SECRET_ID: maskKey(effective('COS_SECRET_ID', fileEnv)),
143
+ COS_SECRET_KEY: maskKey(effective('COS_SECRET_KEY', fileEnv)),
144
+ COS_BUCKET: effective('COS_BUCKET', fileEnv) ?? null,
145
+ COS_REGION: effective('COS_REGION', fileEnv) ?? null,
146
+ },
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Write a credential patch to the plugin-local .env and live-apply to
152
+ * process.env. LiteLLM keys in the patch are silently dropped (managed in
153
+ * Studio Settings only).
154
+ */
155
+ export function writeCredentials(
156
+ patch: Record<string, unknown> = {},
157
+ envPath: string = defaultEnvPath(),
158
+ ): CredentialsState {
159
+ const clean: Record<string, string> = {};
160
+ for (const [k, v] of Object.entries(patch ?? {})) {
161
+ if (!(CRED_KEYS as readonly string[]).includes(k)) continue;
162
+ if (typeof v !== 'string' && typeof v !== 'number') continue;
163
+ clean[k] = String(v);
164
+ }
165
+
166
+ if (Object.keys(clean).length > 0) {
167
+ let originalText = '';
168
+ let env: Record<string, string> = {};
169
+ if (existsSync(envPath)) {
170
+ originalText = readFileSync(envPath, 'utf8');
171
+ env = parseEnv(originalText);
172
+ }
173
+ for (const [k, v] of Object.entries(clean)) env[k] = v;
174
+
175
+ const tmp = `${envPath}.tmp`;
176
+ writeFileSync(tmp, serializeEnv(env, originalText), 'utf8');
177
+ renameSync(tmp, envPath);
178
+
179
+ for (const [k, v] of Object.entries(clean)) {
180
+ if (v === '') delete process.env[k];
181
+ else process.env[k] = v;
182
+ }
183
+ }
184
+
185
+ return readCredentials(envPath);
186
+ }
@@ -0,0 +1,238 @@
1
+ // Manual "Import to Game" for gen3d props/mesh assets (PLAN §5.3 / ROLE1).
2
+ // Character assets are explicitly rejected here — they use the (separate,
3
+ // not-yet-built) playable-character export pipeline, never this simple path.
4
+ import { createHash } from 'node:crypto';
5
+ import { access, readFile, rename, rm, writeFile } from 'node:fs/promises';
6
+ import {
7
+ cookExternalAssetMeta,
8
+ normalizeGlbForEngine,
9
+ type ExternalAssetMeta,
10
+ } from '@forgeax-extension/external-asset-meta';
11
+ import type { AssetSidecar } from '../shared/manifest';
12
+ import type { PerGameAssetStore } from './per-game-store';
13
+
14
+ export interface EngineImportStatus {
15
+ ok: true;
16
+ imported: boolean;
17
+ needsManualImport: boolean;
18
+ needsDracoNormalize: boolean;
19
+ engineMetaPath: string | null;
20
+ sourceHash: string | null;
21
+ importedAt: string | null;
22
+ message: string;
23
+ retryable: boolean;
24
+ }
25
+
26
+ export type EngineImportResult =
27
+ | {
28
+ ok: true;
29
+ firstImport: boolean;
30
+ normalizedDraco: boolean;
31
+ reusedGuidCount: number;
32
+ engineMetaPath: string;
33
+ assetPath: string;
34
+ message: string;
35
+ }
36
+ | {
37
+ ok: false;
38
+ code: string;
39
+ message: string;
40
+ retryable: boolean;
41
+ };
42
+
43
+ function sha256Hex(data: Uint8Array): string {
44
+ return createHash('sha256').update(data).digest('hex');
45
+ }
46
+
47
+ async function exists(path: string): Promise<boolean> {
48
+ try {
49
+ await access(path);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function countReused(existing: ExternalAssetMeta | null, next: ExternalAssetMeta): number {
57
+ if (!existing) return 0;
58
+ const prev = new Set(existing.subAssets.map((s) => `${s.kind}\0${s.sourceIndex}\0${s.guid}`));
59
+ let n = 0;
60
+ for (const s of next.subAssets) {
61
+ if (prev.has(`${s.kind}\0${s.sourceIndex}\0${s.guid}`)) n += 1;
62
+ }
63
+ return n;
64
+ }
65
+
66
+ export async function engineImportStatus(
67
+ store: PerGameAssetStore,
68
+ slug: string,
69
+ assetPath: string,
70
+ ): Promise<EngineImportStatus> {
71
+ const resolved = store.resolveAssetFiles(slug, assetPath);
72
+ if (!resolved) {
73
+ return {
74
+ ok: true, imported: false, needsManualImport: true, needsDracoNormalize: false,
75
+ engineMetaPath: null, sourceHash: null, importedAt: null,
76
+ message: 'Asset not found.', retryable: false,
77
+ };
78
+ }
79
+ if (resolved.slot === 'characters') {
80
+ return {
81
+ ok: true, imported: false, needsManualImport: false, needsDracoNormalize: false,
82
+ engineMetaPath: null, sourceHash: null, importedAt: null,
83
+ message: 'Character assets use Export Playable Character, not Import to Game.',
84
+ retryable: false,
85
+ };
86
+ }
87
+ const { glbAbs, metaAbs, sidecarAbs } = resolved;
88
+ if (!(await exists(glbAbs))) {
89
+ return {
90
+ ok: true, imported: false, needsManualImport: true, needsDracoNormalize: false,
91
+ engineMetaPath: null, sourceHash: null, importedAt: null,
92
+ message: 'GLB missing on disk.', retryable: false,
93
+ };
94
+ }
95
+ const glbBytes = new Uint8Array(await readFile(glbAbs));
96
+ const currentHash = `sha256:${sha256Hex(glbBytes)}`;
97
+ const hasMeta = await exists(metaAbs);
98
+ let sidecar: AssetSidecar | null = null;
99
+ if (await exists(sidecarAbs)) {
100
+ try {
101
+ sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
102
+ } catch {
103
+ sidecar = null;
104
+ }
105
+ }
106
+ const engineImport = (sidecar?.custom as { engineImport?: { importedAt?: string; sourceHash?: string } } | undefined)
107
+ ?.engineImport;
108
+ const importedAt = engineImport?.importedAt ?? null;
109
+ const recordedHash = engineImport?.sourceHash ?? null;
110
+
111
+ if (hasMeta && recordedHash === currentHash) {
112
+ return {
113
+ ok: true, imported: true, needsManualImport: false, needsDracoNormalize: false,
114
+ engineMetaPath: metaAbs, sourceHash: currentHash, importedAt,
115
+ message: 'Already imported to game (engine meta matches current GLB).', retryable: false,
116
+ };
117
+ }
118
+
119
+ const probe = await cookExternalAssetMeta(glbBytes, currentHash, assetPath.split('/').pop()!);
120
+ const needsDraco =
121
+ !probe.ok &&
122
+ probe.code === 'engine_unsupported_extension' &&
123
+ (probe.unsupportedExtensions ?? []).includes('KHR_draco_mesh_compression');
124
+
125
+ return {
126
+ ok: true, imported: false, needsManualImport: true, needsDracoNormalize: needsDraco,
127
+ engineMetaPath: hasMeta ? metaAbs : null, sourceHash: currentHash, importedAt,
128
+ message: needsDraco
129
+ ? 'Model generated; Import to Game needs manual confirm (includes format conversion).'
130
+ : hasMeta
131
+ ? 'Engine meta is stale or mismatched; re-import required.'
132
+ : 'Not imported yet. Click Import to Game.',
133
+ retryable: true,
134
+ };
135
+ }
136
+
137
+ export async function importToEngine(
138
+ store: PerGameAssetStore,
139
+ slug: string,
140
+ assetPath: string,
141
+ ): Promise<EngineImportResult> {
142
+ const resolved = store.resolveAssetFiles(slug, assetPath);
143
+ if (!resolved) {
144
+ return { ok: false, code: 'asset_not_found', message: 'Asset not found.', retryable: false };
145
+ }
146
+ if (resolved.slot === 'characters') {
147
+ return {
148
+ ok: false,
149
+ code: 'character_not_supported',
150
+ message: 'Character assets use Export Playable Character, not Import to Game.',
151
+ retryable: false,
152
+ };
153
+ }
154
+ const { glbAbs, metaAbs, sidecarAbs, glbFileName } = resolved;
155
+ if (!(await exists(glbAbs))) {
156
+ return { ok: false, code: 'asset_not_found', message: 'GLB missing on disk.', retryable: false };
157
+ }
158
+
159
+ const originalGlb = new Uint8Array(await readFile(glbAbs));
160
+ let glbBytes: Uint8Array = originalGlb;
161
+ let contentHash = `sha256:${sha256Hex(glbBytes)}`;
162
+ let normalizedDraco = false;
163
+
164
+ let existingMeta: ExternalAssetMeta | null = null;
165
+ if (await exists(metaAbs)) {
166
+ try {
167
+ existingMeta = JSON.parse(await readFile(metaAbs, 'utf8')) as ExternalAssetMeta;
168
+ if (existingMeta.kind !== 'external-asset-package') existingMeta = null;
169
+ } catch {
170
+ existingMeta = null;
171
+ }
172
+ }
173
+ const firstImport = existingMeta === null;
174
+
175
+ let cooked = await cookExternalAssetMeta(glbBytes, contentHash, glbFileName, { existingMeta });
176
+
177
+ if (
178
+ !cooked.ok &&
179
+ cooked.code === 'engine_unsupported_extension' &&
180
+ (cooked.unsupportedExtensions ?? []).includes('KHR_draco_mesh_compression')
181
+ ) {
182
+ const normalized = await normalizeGlbForEngine(glbBytes);
183
+ if (!normalized.ok) {
184
+ return { ok: false, code: normalized.code, message: `Draco normalize failed: ${normalized.message}`, retryable: true };
185
+ }
186
+ glbBytes = normalized.bytes;
187
+ contentHash = `sha256:${sha256Hex(glbBytes)}`;
188
+ cooked = await cookExternalAssetMeta(glbBytes, contentHash, glbFileName, { existingMeta });
189
+ if (!cooked.ok) {
190
+ return { ok: false, code: cooked.code, message: cooked.message, retryable: true };
191
+ }
192
+ normalizedDraco = true;
193
+ } else if (!cooked.ok) {
194
+ return { ok: false, code: cooked.code, message: cooked.message, retryable: true };
195
+ }
196
+
197
+ const tmpGlb = `${glbAbs}.__import_tmp`;
198
+ const tmpMeta = `${metaAbs}.__import_tmp`;
199
+ try {
200
+ if (normalizedDraco) await writeFile(tmpGlb, glbBytes);
201
+ await writeFile(tmpMeta, `${JSON.stringify(cooked.meta, null, 2)}\n`, 'utf8');
202
+ if (normalizedDraco) await rename(tmpGlb, glbAbs);
203
+ await rename(tmpMeta, metaAbs);
204
+ } catch (err) {
205
+ await rm(tmpGlb, { force: true }).catch(() => undefined);
206
+ await rm(tmpMeta, { force: true }).catch(() => undefined);
207
+ return {
208
+ ok: false, code: 'write_failed',
209
+ message: err instanceof Error ? err.message : 'Failed to write import artifacts',
210
+ retryable: true,
211
+ };
212
+ }
213
+
214
+ if (await exists(sidecarAbs)) {
215
+ try {
216
+ const sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
217
+ sidecar.contentHash = contentHash;
218
+ sidecar.size = glbBytes.byteLength;
219
+ (sidecar.custom as Record<string, unknown>).engineImport = {
220
+ sourceHash: contentHash,
221
+ importedAt: new Date().toISOString(),
222
+ };
223
+ await writeFile(sidecarAbs, `${JSON.stringify(sidecar, null, 2)}\n`, 'utf8');
224
+ } catch {
225
+ // Non-fatal: engine meta already written.
226
+ }
227
+ }
228
+
229
+ return {
230
+ ok: true,
231
+ firstImport,
232
+ normalizedDraco,
233
+ reusedGuidCount: countReused(existingMeta, cooked.meta),
234
+ engineMetaPath: metaAbs,
235
+ assetPath,
236
+ message: 'Imported to game Edit asset catalog. This does not auto-replace the game protagonist or modify gameplay code.',
237
+ };
238
+ }