@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,2193 @@
1
+ import {
2
+ CAPABILITIES,
3
+ QUALITY_RUBRIC,
4
+ clampTargetPolycount,
5
+ generateMeshyTextMockResult,
6
+ makeCacheKey,
7
+ type MeshyTextMockArgs,
8
+ type ProviderResult,
9
+ } from '../shared/catalog';
10
+ import {
11
+ MESHY_FREE_RUN_ID,
12
+ MESHY_FREE_WALK_ID,
13
+ emptyQualityReport,
14
+ motionRefFromLegacy,
15
+ motionRefKey,
16
+ selectFiles,
17
+ type AssetSlot,
18
+ type Gen3DAssetManifest,
19
+ type GenerationMode,
20
+ type MotionRef,
21
+ type MotionSystem,
22
+ type MotionType,
23
+ type PlayableDeliverySnapshot,
24
+ type ProviderId,
25
+ type QualityDim,
26
+ type QualityReport,
27
+ type RigChain,
28
+ } from '../shared/manifest';
29
+ import { DEFAULT_WEIGHTS, weightedTotal } from '../shared/quality/heuristics';
30
+ import { applyRodinPromptFlags, filterProviderParams } from '../shared/provider-params';
31
+ import {
32
+ BUILTIN_PROFILE_PRESETS,
33
+ effectiveSlots,
34
+ findPreset,
35
+ gameProfileFromPreset,
36
+ type CharacterMotionOverride,
37
+ type GameMotionProfile,
38
+ type MotionMappingDraft,
39
+ type MotionMappingEntry,
40
+ type MotionSlotDef,
41
+ type PlaybackMode,
42
+ type RootMotionStrategy,
43
+ } from '../shared/playable-profile';
44
+ import { playableDeliveryLocalUrl } from '../shared/playable-preview-url';
45
+ import { filterMotions, getMeshyCatalog, hunyuanV1Catalog, visviseCatalog, type MotionOption } from './motion-catalog';
46
+ import type { AssetStorage, DerivedFileInput } from './asset-storage';
47
+ import { PerGameAssetStore } from './per-game-store';
48
+ import { engineImportStatus, importToEngine, type EngineImportResult, type EngineImportStatus } from './engine-import';
49
+ import {
50
+ exportPlayableCharacter,
51
+ mappingFingerprint,
52
+ type ExportPlayableResult,
53
+ } from './export-playable-character';
54
+ import {
55
+ adoptPlayableCharacter,
56
+ inspectAdoptCandidate,
57
+ type AdoptCandidate,
58
+ type AdoptPlayableResult,
59
+ type AdoptSlotMapping,
60
+ } from './adopt-playable-character';
61
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
62
+ import { basename, extname, isAbsolute, relative, resolve } from 'node:path';
63
+ import { fileURLToPath } from 'node:url';
64
+ import { generateCacheFirst, persistGeneration, type PersistInput } from './generate';
65
+ import * as cache from './cache';
66
+ import { getCosEnv, getHunyuanEnv, getLitellmEnv, getMeshyEnv, getRodinEnv, getVisviseEnv, realProvidersEnabled } from './env';
67
+ import { HUNYUAN_GATEWAY_MODELS, MESHY_GATEWAY_MODELS, RODIN_GATEWAY_MODELS, VISVISE_GATEWAY_MODELS } from './providers/gateway-models';
68
+ import { readCredentials, writeCredentials } from './credentials-store';
69
+ import { CosUploader, mimeForModelFormat } from './cos-uploader';
70
+ import { uploadTransferArtifact } from './asset-upload';
71
+ import {
72
+ HunyuanWorkflowProvider,
73
+ type HunyuanGenerateInput,
74
+ type ViewSlot,
75
+ } from './providers/hunyuan-workflow';
76
+ import { MeshyProvider, type MeshyGenerateInput } from './providers/meshy';
77
+ import { RodinGatewayProvider, type RodinGatewayGenerateInput } from './providers/rodin-gateway';
78
+ import { VisviseProvider, visviseInputFromFiltered, visviseInputUrl, type VisviseGenerateInput } from './providers/visvise';
79
+ import { HunyuanRestProvider, type ModelFileOut } from './providers/hunyuan-rest';
80
+
81
+ // Per-game storage adapter (ADR-0002). Assets live under the active game's
82
+ // .forgeax/games/<slug>/assets/3d/{characters|meshes}/ tree; identity is the
83
+ // game-relative assetPath. Same-origin preview URLs mirror the Studio server's
84
+ // read-only /api/game-assets/:slug/* route (packages/server/src/main.ts).
85
+ const perGameStore = new PerGameAssetStore();
86
+ const storage: AssetStorage = perGameStore;
87
+
88
+ // Every store-touching tool needs an active game. The host iframe injects
89
+ // ?slug=<gameSlug>; the frontend threads it into each call. Reject early with a
90
+ // clear code so the UI can render an empty/disabled state instead of writing to
91
+ // a guessed path.
92
+ function requireSlug(slug: string | undefined): string {
93
+ const s = slug?.trim();
94
+ if (!s) {
95
+ throw Object.assign(new Error('no active game (slug is required)'), { code: 'missing_game' });
96
+ }
97
+ return s;
98
+ }
99
+
100
+ function resolveSlot(slot: AssetSlot | undefined): AssetSlot {
101
+ return slot === 'characters' ? 'characters' : 'meshes';
102
+ }
103
+
104
+ // Default base name when the caller does not name the asset: derive from the
105
+ // prompt (text) or fall back to provider+mode. The store sanitizes + de-dupes.
106
+ function defaultName(provided: string | undefined, fallback: string): string {
107
+ const n = provided?.trim();
108
+ return n && n.length > 0 ? n : fallback;
109
+ }
110
+
111
+ interface ProviderStatusResult {
112
+ ok: true;
113
+ quotaSafe: boolean;
114
+ realProvidersEnabled: boolean;
115
+ generatedAt: string;
116
+ rubric: readonly string[];
117
+ capabilities: typeof CAPABILITIES;
118
+ providers: ProviderRuntimeStatus[];
119
+ }
120
+
121
+ type ProviderAvailability = 'configured' | 'available' | 'unavailable' | 'unknown';
122
+
123
+ interface ProviderRuntimeStatus {
124
+ providerId: GenProvider;
125
+ providerName: string;
126
+ configured: boolean;
127
+ availability: ProviderAvailability;
128
+ selectable: boolean;
129
+ reason: string;
130
+ models: Record<string, string>;
131
+ }
132
+
133
+ const PROVIDER_MODELS: Record<GenProvider, Record<string, string>> = {
134
+ meshy: { ...MESHY_GATEWAY_MODELS, rig: MESHY_GATEWAY_MODELS.autoRig },
135
+ hunyuan_workflow: { ...HUNYUAN_GATEWAY_MODELS },
136
+ rodin: { generate: RODIN_GATEWAY_MODELS.generate },
137
+ visvise: { ...VISVISE_GATEWAY_MODELS },
138
+ };
139
+
140
+ function providerDisplayName(providerId: GenProvider): string {
141
+ if (providerId === 'meshy') return 'Meshy';
142
+ if (providerId === 'rodin') return 'Rodin';
143
+ if (providerId === 'visvise') return 'VISVISE';
144
+ return 'Hunyuan';
145
+ }
146
+
147
+ function providerEnvConfigured(providerId: GenProvider): boolean {
148
+ if (providerId === 'meshy') return getMeshyEnv() !== null;
149
+ if (providerId === 'rodin') return getRodinEnv() !== null;
150
+ if (providerId === 'visvise') return getVisviseEnv() !== null;
151
+ return getHunyuanEnv() !== null;
152
+ }
153
+
154
+ function providerRuntimeStatus(providerId: GenProvider): ProviderRuntimeStatus {
155
+ const realEnabled = realProvidersEnabled();
156
+ const litellm = getLitellmEnv();
157
+ const configured = providerEnvConfigured(providerId);
158
+ const providerName = providerDisplayName(providerId);
159
+ const selectable = providerId !== 'meshy';
160
+ const hiddenReason = 'UI hidden; code retained';
161
+ if (configured) {
162
+ const meshy = providerId === 'meshy' ? getMeshyEnv() : null;
163
+ return {
164
+ providerId,
165
+ providerName,
166
+ configured: true,
167
+ availability: 'configured',
168
+ selectable,
169
+ reason: selectable
170
+ ? meshy?.transport === 'direct'
171
+ ? 'Meshy is on the local recording path (operator key → api.meshy.ai).'
172
+ : 'Real provider calls are enabled and the LiteLLM gateway key is configured.'
173
+ : hiddenReason,
174
+ models: PROVIDER_MODELS[providerId],
175
+ };
176
+ }
177
+ if (!realEnabled && litellm) {
178
+ return {
179
+ providerId,
180
+ providerName,
181
+ configured: false,
182
+ availability: 'available',
183
+ selectable,
184
+ reason: selectable
185
+ ? 'LiteLLM gateway key is present; enable real providers to use it.'
186
+ : hiddenReason,
187
+ models: PROVIDER_MODELS[providerId],
188
+ };
189
+ }
190
+ return {
191
+ providerId,
192
+ providerName,
193
+ configured: false,
194
+ availability: realEnabled ? 'unknown' : 'unknown',
195
+ selectable,
196
+ reason: selectable
197
+ ? realEnabled
198
+ ? 'Real providers are enabled but no LiteLLM gateway key was found.'
199
+ : 'Mock mode is active; configure a LiteLLM gateway key before enabling real providers.'
200
+ : hiddenReason,
201
+ models: PROVIDER_MODELS[providerId],
202
+ };
203
+ }
204
+
205
+ function getProviderStatus(): ProviderStatusResult {
206
+ const realEnabled = realProvidersEnabled();
207
+ return {
208
+ ok: true,
209
+ quotaSafe: !realEnabled,
210
+ realProvidersEnabled: realEnabled,
211
+ generatedAt: new Date().toISOString(),
212
+ rubric: QUALITY_RUBRIC,
213
+ capabilities: CAPABILITIES,
214
+ providers: (['hunyuan_workflow', 'visvise', 'rodin', 'meshy'] as const).map(providerRuntimeStatus),
215
+ };
216
+ }
217
+
218
+ interface ListAssetsArgs {
219
+ slug?: string;
220
+ assetSlot?: AssetSlot;
221
+ provider?: ProviderId | 'all';
222
+ }
223
+
224
+ interface ListAssetsResult {
225
+ ok: true;
226
+ assets: Gen3DAssetManifest[];
227
+ }
228
+
229
+ async function listAssets(args: ListAssetsArgs = {}): Promise<ListAssetsResult> {
230
+ const slug = requireSlug(args.slug);
231
+ const provider = args.provider ?? 'all';
232
+ const all = await storage.listAssets(slug, args.assetSlot);
233
+ const assets = provider === 'all' ? all : all.filter((m) => m.provider === provider);
234
+ return { ok: true, assets };
235
+ }
236
+
237
+ interface DeleteAssetArgs {
238
+ slug?: string;
239
+ assetPath: string;
240
+ }
241
+
242
+ interface DeleteAssetResult {
243
+ ok: true;
244
+ assetPath: string;
245
+ tombstoned: boolean;
246
+ }
247
+
248
+ // Destructive: removes the main GLB + sidecar + same-basename sidefiles, then
249
+ // tombstones its cacheKey so a deliberately deleted asset never resurrects from
250
+ // a later cache hit (and never silently re-burns quota).
251
+ async function deleteAsset(args: DeleteAssetArgs): Promise<DeleteAssetResult> {
252
+ const slug = requireSlug(args.slug);
253
+ const assetPath = args.assetPath?.trim();
254
+ if (!assetPath) {
255
+ throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
256
+ }
257
+ const { cacheKey } = await storage.deleteAsset(slug, assetPath);
258
+ if (cacheKey) await cache.tombstone(slug, cacheKey);
259
+ return { ok: true, assetPath, tombstoned: Boolean(cacheKey) };
260
+ }
261
+
262
+ interface GenerateMockArgs extends MeshyTextMockArgs {
263
+ slug?: string;
264
+ assetSlot?: AssetSlot;
265
+ assetName?: string;
266
+ }
267
+
268
+ interface GenerateMockResult {
269
+ ok: true;
270
+ quotaSafe: true;
271
+ cacheKey: string;
272
+ manifest: Gen3DAssetManifest;
273
+ }
274
+
275
+ async function generateMeshyTextMock(args: GenerateMockArgs): Promise<GenerateMockResult> {
276
+ const slug = requireSlug(args.slug);
277
+ const assetSlot = resolveSlot(args.assetSlot);
278
+ const { cacheKey, result } = generateMeshyTextMockResult(args);
279
+ const ctx: PersistInput = {
280
+ slug,
281
+ assetSlot,
282
+ assetName: defaultName(args.assetName, args.prompt),
283
+ cacheKey,
284
+ };
285
+ const manifest = await persistGeneration(result, storage, ctx);
286
+ return { ok: true, quotaSafe: true, cacheKey, manifest };
287
+ }
288
+
289
+ // Mode-based generation tools. Provider is a parameter; today the real
290
+ // providers are Hunyuan workflow and Meshy. When real providers are not
291
+ // configured, these fall back to the deterministic mock so the path stays
292
+ // quota-safe by default.
293
+
294
+ interface BaseGenArgs {
295
+ slug?: string;
296
+ assetSlot?: AssetSlot;
297
+ assetName?: string;
298
+ provider?: ProviderId;
299
+ enablePbr?: boolean;
300
+ enableFbxUrl?: boolean;
301
+ targetPolycount?: number;
302
+ providerParams?: Record<string, unknown>;
303
+ }
304
+
305
+ interface TextTo3DArgs extends BaseGenArgs {
306
+ prompt: string;
307
+ }
308
+
309
+ interface ImageTo3DArgs extends BaseGenArgs {
310
+ imageUrl: string;
311
+ }
312
+
313
+ interface ViewsTo3DArgs extends BaseGenArgs {
314
+ views: Partial<Record<ViewSlot, string>>;
315
+ }
316
+
317
+ interface GenerateResult {
318
+ ok: true;
319
+ cacheKey: string;
320
+ cacheHit: boolean;
321
+ usedMock: boolean;
322
+ manifest: Gen3DAssetManifest;
323
+ }
324
+
325
+ function mockFallback(provider: ProviderId, mode: GenerationMode, prompt: string | null): ProviderResult {
326
+ // Reuse the deterministic mock byte payloads regardless of provider/mode so
327
+ // the path works without quota. prompt may be null for image/views. The mock
328
+ // is tagged with the requested provider so the manifest reflects user intent.
329
+ const { result } = generateMeshyTextMockResult({ prompt: prompt ?? mode });
330
+ return { ...result, provider, mode, sourceJobId: null };
331
+ }
332
+
333
+ // Providers backing the mode tools today: Hunyuan workflow, Meshy, Rodin.
334
+ // Default (and any unknown value) resolves to Hunyuan workflow for backward
335
+ // compatibility. refine is Meshy-only and handled separately.
336
+ type GenProvider = 'hunyuan_workflow' | 'meshy' | 'rodin' | 'visvise';
337
+
338
+ function resolveProvider(provider: ProviderId | undefined): GenProvider {
339
+ if (provider === 'meshy') return 'meshy';
340
+ if (provider === 'rodin') return 'rodin';
341
+ if (provider === 'visvise') return 'visvise';
342
+ return 'hunyuan_workflow';
343
+ }
344
+
345
+ function providerNotConfigured(provider: GenProvider | 'hunyuan_rest', action: string): Error {
346
+ const providerLabel =
347
+ provider === 'meshy' ? 'Meshy'
348
+ : provider === 'rodin' ? 'Rodin'
349
+ : provider === 'visvise' ? 'VISVISE'
350
+ : 'Hunyuan';
351
+ const reason = 'GEN3D_ENABLE_REAL_PROVIDERS=1 but the LiteLLM 3D gateway key is not configured.';
352
+ return Object.assign(
353
+ new Error(`${providerLabel} real ${action} is not configured. ${reason}`),
354
+ { code: 'provider_not_configured', provider, action },
355
+ );
356
+ }
357
+
358
+ function resolvePolycount(target: number | undefined, provider: GenProvider): number {
359
+ if (target !== undefined) return clampTargetPolycount(target);
360
+ if (provider === 'meshy') return clampTargetPolycount(getMeshyEnv()?.defaultPolycount ?? 30000);
361
+ return clampTargetPolycount(getHunyuanEnv()?.defaultFaceCount ?? 30000);
362
+ }
363
+
364
+ function buildProviderParams(
365
+ provider: GenProvider,
366
+ mode: GenerationMode,
367
+ raw: Record<string, unknown> | undefined,
368
+ ): {
369
+ filtered: Record<string, string | number | boolean>;
370
+ cacheBits: Record<string, string | number | boolean>;
371
+ } {
372
+ const filtered = filterProviderParams(provider, mode, raw);
373
+ const cacheBits: Record<string, string | number | boolean> = {};
374
+ for (const [k, v] of Object.entries(filtered)) cacheBits[`pp:${k}`] = v;
375
+ return { filtered, cacheBits };
376
+ }
377
+
378
+ // Provider-aware cache-first generation. Picks the real provider when its env is
379
+ // configured, else falls back to the deterministic mock (quota-safe). The
380
+ // cacheKey is computed by the caller (includes provider + assetSlot, excludes
381
+ // assetName), so caches stay isolated per slot and a rename never re-burns
382
+ // quota.
383
+ // Provider inputs may be passed eagerly, or as a lazy async factory invoked
384
+ // ONLY on a cache MISS and ONLY for the real-provider branch — so views-to-3d
385
+ // can defer its studio-local→COS transfer until it's actually needed (never on a
386
+ // cache hit, never on the mock path). See viewsTo3D.
387
+ type GenInputs = {
388
+ hunyuan: HunyuanGenerateInput;
389
+ meshy: MeshyGenerateInput;
390
+ rodin: RodinGatewayGenerateInput;
391
+ visvise: VisviseGenerateInput;
392
+ };
393
+
394
+ async function runGeneration(
395
+ provider: GenProvider,
396
+ mode: GenerationMode,
397
+ ctx: PersistInput,
398
+ inputs: GenInputs | (() => Promise<GenInputs>),
399
+ mockPrompt: string | null,
400
+ ): Promise<GenerateResult> {
401
+ let usedMock = false;
402
+ const resolveInputs = (): Promise<GenInputs> =>
403
+ typeof inputs === 'function' ? inputs() : Promise.resolve(inputs);
404
+ const produce = async (): Promise<ProviderResult> => {
405
+ if (provider === 'meshy') {
406
+ const env = getMeshyEnv();
407
+ if (env) return new MeshyProvider({ env, slug: ctx.slug }).generate((await resolveInputs()).meshy);
408
+ if (realProvidersEnabled()) throw providerNotConfigured('meshy', mode);
409
+ } else if (provider === 'rodin') {
410
+ const env = getRodinEnv();
411
+ if (env) return new RodinGatewayProvider({ env, slug: ctx.slug }).generate((await resolveInputs()).rodin);
412
+ if (realProvidersEnabled()) throw providerNotConfigured('rodin', mode);
413
+ } else if (provider === 'visvise') {
414
+ const env = getVisviseEnv();
415
+ if (env) return new VisviseProvider({ env, slug: ctx.slug }).generate((await resolveInputs()).visvise);
416
+ if (realProvidersEnabled()) throw providerNotConfigured('visvise', mode);
417
+ } else {
418
+ const env = getHunyuanEnv();
419
+ if (env) return new HunyuanWorkflowProvider({ env, slug: ctx.slug }).generate((await resolveInputs()).hunyuan);
420
+ if (realProvidersEnabled()) throw providerNotConfigured('hunyuan_workflow', mode);
421
+ }
422
+ usedMock = true;
423
+ return mockFallback(provider, mode, mockPrompt);
424
+ };
425
+ const { manifest, cacheHit } = await generateCacheFirst(storage, ctx, produce);
426
+ return { ok: true, cacheKey: ctx.cacheKey, cacheHit, usedMock: cacheHit ? manifest.providerMode === 'mock' : usedMock, manifest };
427
+ }
428
+
429
+ /** Live Meshy / LiteLLM reject with HTTP 400 before any mesh is billed. */
430
+ export const MESHY_TEXT_PROMPT_MAX = 800;
431
+
432
+ export function assertMeshyTextPrompt(prompt: string): void {
433
+ if (prompt.length <= MESHY_TEXT_PROMPT_MAX) return;
434
+ throw Object.assign(
435
+ new Error(
436
+ `Meshy text prompt is ${prompt.length} characters; max is ${MESHY_TEXT_PROMPT_MAX}. Compress the same design spec and retry — do not switch vendor.`,
437
+ ),
438
+ { code: 'prompt_too_long', limit: MESHY_TEXT_PROMPT_MAX, length: prompt.length },
439
+ );
440
+ }
441
+
442
+ async function textTo3D(args: TextTo3DArgs): Promise<GenerateResult> {
443
+ const prompt = args.prompt.trim();
444
+ if (!prompt) throw Object.assign(new Error('prompt is required'), { code: 'invalid_prompt' });
445
+ const provider = resolveProvider(args.provider);
446
+ if (provider === 'meshy') assertMeshyTextPrompt(prompt);
447
+ const slug = requireSlug(args.slug);
448
+ const assetSlot = resolveSlot(args.assetSlot);
449
+ const faceCount = resolvePolycount(args.targetPolycount, provider);
450
+ const enablePbr = args.enablePbr ?? true;
451
+ const enableFbxUrl = args.enableFbxUrl ?? false;
452
+ const { filtered, cacheBits } = buildProviderParams(provider, 'text', args.providerParams);
453
+ const cacheKey = makeCacheKey(provider, 'text', {
454
+ assetSlot,
455
+ prompt,
456
+ faceCount,
457
+ enablePbr,
458
+ enableFbxUrl,
459
+ ...(provider === 'meshy' ? { shouldTexture: true, autoRefine: enablePbr } : {}),
460
+ ...cacheBits,
461
+ });
462
+ return runGeneration(
463
+ provider,
464
+ 'text',
465
+ { slug, assetSlot, assetName: defaultName(args.assetName, prompt), faceCount, cacheKey },
466
+ {
467
+ hunyuan: { mode: 'text', prompt, faceCount, enablePbr, enableFbxUrl },
468
+ meshy: { mode: 'text', prompt, targetPolycount: faceCount, enablePbr, params: filtered },
469
+ rodin: { mode: 'text', prompt: applyRodinPromptFlags(prompt, filtered, 'text') },
470
+ visvise: visviseInputFromFiltered({ mode: 'text', prompt }, filtered),
471
+ },
472
+ prompt,
473
+ );
474
+ }
475
+
476
+ async function imageTo3D(args: ImageTo3DArgs): Promise<GenerateResult> {
477
+ const slug = requireSlug(args.slug);
478
+ const imageUrl = args.imageUrl.trim();
479
+ if (!imageUrl) throw Object.assign(new Error('imageUrl is required'), { code: 'invalid_image_url' });
480
+ const assetSlot = resolveSlot(args.assetSlot);
481
+ const provider = resolveProvider(args.provider);
482
+ const faceCount = resolvePolycount(args.targetPolycount, provider);
483
+ const enablePbr = args.enablePbr ?? true;
484
+ const enableFbxUrl = args.enableFbxUrl ?? false;
485
+ const { filtered, cacheBits } = buildProviderParams(provider, 'image', args.providerParams);
486
+ // cacheKey keys off the stable studio-local URL, never an ephemeral COS URL.
487
+ const cacheKey = makeCacheKey(provider, 'image', {
488
+ assetSlot,
489
+ imageUrl,
490
+ faceCount,
491
+ enablePbr,
492
+ enableFbxUrl,
493
+ ...(provider === 'meshy' ? { shouldTexture: true } : {}),
494
+ ...cacheBits,
495
+ });
496
+ const buildInputs = async (): Promise<GenInputs> => {
497
+ const reachable = await ensureProviderReachableUrl(imageUrl, slug);
498
+ const rodinPrompt = applyRodinPromptFlags('', filtered, 'image');
499
+ return {
500
+ hunyuan: { mode: 'image', imageUrl: reachable, faceCount, enablePbr, enableFbxUrl },
501
+ meshy: { mode: 'image', imageUrl: reachable, targetPolycount: faceCount, enablePbr, params: filtered },
502
+ rodin: {
503
+ mode: 'image',
504
+ imageUrl: reachable,
505
+ ...(rodinPrompt ? { prompt: rodinPrompt } : {}),
506
+ },
507
+ visvise: visviseInputFromFiltered({ mode: 'image', imageUrl: reachable }, filtered),
508
+ };
509
+ };
510
+ return runGeneration(
511
+ provider,
512
+ 'image',
513
+ { slug, assetSlot, assetName: defaultName(args.assetName, `image-${provider}`), faceCount, cacheKey },
514
+ buildInputs,
515
+ null,
516
+ );
517
+ }
518
+
519
+ // ── T1 (ADR-0008 D-B): studio-local view URLs → COS transfer ────────────────
520
+ // character:generate-turnaround returns studio-local image URLs — a relative
521
+ // /api/wb/character/asset?path=… or a loopback host — that a URL-fetching
522
+ // provider (Meshy/Hunyuan/Rodin) cannot reach. When a REAL provider will run we
523
+ // server-side fetch those bytes and re-host them on COS, then feed the public
524
+ // URL. Forge passes the turnaround url straight into views-to-3d (no schema
525
+ // change, no multi-MB base64 over the LLM). Public URLs and the mock path are
526
+ // untouched.
527
+ // URL#hostname yields the bracketed form '[::1]' for IPv6 loopback, so the bare
528
+ // '::1' would be dead — only the bracketed form can ever match.
529
+ const STUDIO_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0']);
530
+
531
+ const CHAT_IMAGE_EXT_MIME: Record<string, string> = {
532
+ '.png': 'image/png',
533
+ '.jpg': 'image/jpeg',
534
+ '.jpeg': 'image/jpeg',
535
+ '.webp': 'image/webp',
536
+ '.gif': 'image/gif',
537
+ };
538
+
539
+ /** Chat paste lands as an absolute file path (or file://). Not a Studio HTTP URL. */
540
+ export function looksLikeAbsoluteFilesystemPath(ref: string): boolean {
541
+ const u = ref.trim();
542
+ if (!u) return false;
543
+ if (/^file:/i.test(u)) return true;
544
+ if (/^[a-zA-Z]:[\\/]/.test(u)) return true;
545
+ return u.startsWith('/') && !u.startsWith('/api/');
546
+ }
547
+
548
+ function projectRootFromEnv(): string {
549
+ return process.env.FORGEAX_PROJECT_ROOT ?? resolve(process.cwd(), '.forgeax-runtime');
550
+ }
551
+
552
+ function isSafePathSegment(s: string): boolean {
553
+ return Boolean(s) && !s.includes('/') && !s.includes('\\') && s !== '.' && s !== '..' && !s.includes('\0');
554
+ }
555
+
556
+ function safeUploadBasename(raw: string): string | null {
557
+ let name = raw.trim();
558
+ if (!name) return null;
559
+ try {
560
+ name = decodeURIComponent(name);
561
+ } catch {
562
+ // keep raw
563
+ }
564
+ name = basename(name);
565
+ if (!isSafePathSegment(name)) return null;
566
+ if (!CHAT_IMAGE_EXT_MIME[extname(name).toLowerCase()]) return null;
567
+ return name;
568
+ }
569
+
570
+ const SESSION_UPLOAD_API_RE = /^\/api\/sessions\/([^/?#]+)\/uploads\/([^/?#]+)/i;
571
+
572
+ /** Bare chat-upload filename, or /api/sessions/<sid>/uploads/<file>. */
573
+ export function parseChatUploadRef(ref: string): { sid?: string; fileName: string } | null {
574
+ const raw = ref.trim();
575
+ if (!raw) return null;
576
+ let pathname = raw;
577
+ if (/^https?:\/\//i.test(raw)) {
578
+ try {
579
+ pathname = new URL(raw).pathname;
580
+ } catch {
581
+ return null;
582
+ }
583
+ }
584
+ const api = pathname.match(SESSION_UPLOAD_API_RE);
585
+ if (api) {
586
+ const fileName = safeUploadBasename(api[2]);
587
+ if (!fileName || !isSafePathSegment(api[1])) return null;
588
+ return { sid: api[1], fileName };
589
+ }
590
+ if (raw.includes('/') || raw.includes('\\') || /^(?:https?|file|data|blob):/i.test(raw)) {
591
+ return null;
592
+ }
593
+ const fileName = safeUploadBasename(raw);
594
+ return fileName ? { fileName } : null;
595
+ }
596
+
597
+ /** Newest matching file under a game's session uploads directory. */
598
+ export function resolveSessionUploadPath(
599
+ fileName: string,
600
+ slug: string,
601
+ sid?: string,
602
+ ): string | null {
603
+ const safeName = safeUploadBasename(fileName);
604
+ if (!safeName || !isSafePathSegment(slug) || (sid && !isSafePathSegment(sid))) return null;
605
+ const sessionsRoot = resolve(projectRootFromEnv(), '.forgeax', 'games', slug, 'sessions');
606
+ const candidates: string[] = [];
607
+ if (sid) {
608
+ candidates.push(resolve(sessionsRoot, sid, 'uploads', safeName));
609
+ } else {
610
+ let entries: string[] = [];
611
+ try {
612
+ entries = readdirSync(sessionsRoot);
613
+ } catch {
614
+ return null;
615
+ }
616
+ for (const entry of entries) {
617
+ if (!isSafePathSegment(entry)) continue;
618
+ candidates.push(resolve(sessionsRoot, entry, 'uploads', safeName));
619
+ }
620
+ }
621
+ let best: { path: string; mtime: number } | null = null;
622
+ for (const p of candidates) {
623
+ const rel = relative(sessionsRoot, p);
624
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
625
+ try {
626
+ const st = statSync(p);
627
+ if (!st.isFile() || st.size === 0) continue;
628
+ if (!best || st.mtimeMs > best.mtime) best = { path: p, mtime: st.mtimeMs };
629
+ } catch {
630
+ // skip missing session dirs
631
+ }
632
+ }
633
+ return best?.path ?? null;
634
+ }
635
+
636
+ export function chatAttachmentImagePath(ref: string, slug?: string): string | null {
637
+ const raw = ref.trim();
638
+ if (!raw) return null;
639
+ if (looksLikeAbsoluteFilesystemPath(raw)) {
640
+ try {
641
+ return raw.startsWith('file:') ? fileURLToPath(raw) : raw;
642
+ } catch {
643
+ return null;
644
+ }
645
+ }
646
+ const parsed = parseChatUploadRef(raw);
647
+ if (!parsed || !slug) return null;
648
+ return resolveSessionUploadPath(parsed.fileName, slug, parsed.sid);
649
+ }
650
+
651
+ export function isStudioLocalImageUrl(url: string): boolean {
652
+ const u = url.trim();
653
+ if (!u) return false;
654
+ // data:/blob: are self-contained; file: and absolute FS paths are chat attachments.
655
+ if (/^(?:data|blob|file):/i.test(u) || u.startsWith('//')) return false;
656
+ if (!/^https?:\/\//i.test(u)) {
657
+ if (looksLikeAbsoluteFilesystemPath(u)) return false;
658
+ // Bare "shot.png" is a chat-upload name, not /api/... or a workbench relative path.
659
+ if (!u.includes('/')) return false;
660
+ return true;
661
+ }
662
+ try {
663
+ return STUDIO_LOCAL_HOSTS.has(new URL(u).hostname);
664
+ } catch {
665
+ return false;
666
+ }
667
+ }
668
+
669
+ // Studio hosts plugin handlers in-process; resolve relative URLs against its own
670
+ // loopback origin (FORGEAX_SERVER_PORT, default 18900 — packages/server/main.ts).
671
+ export function studioBaseUrl(): string {
672
+ return `http://127.0.0.1:${process.env.FORGEAX_SERVER_PORT ?? '18900'}`;
673
+ }
674
+
675
+ export interface ImageTransferDeps {
676
+ baseUrl: string;
677
+ fetchImpl: typeof fetch;
678
+ upload: (data: Uint8Array, mimetype: string) => Promise<string>;
679
+ }
680
+
681
+ // Fetch a studio-local image and re-host it on COS, returning the public URL.
682
+ // Deps are injected so the transfer is unit-testable with zero network.
683
+ export async function transferStudioLocalImage(url: string, deps: ImageTransferDeps): Promise<string> {
684
+ const abs = /^https?:\/\//i.test(url) ? url : `${deps.baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
685
+ const res = await deps.fetchImpl(abs);
686
+ if (!res.ok) {
687
+ throw Object.assign(new Error(`failed to fetch studio-local image (${res.status}): ${url}`), {
688
+ code: 'studio_local_fetch_failed',
689
+ });
690
+ }
691
+ const data = new Uint8Array(await res.arrayBuffer());
692
+ if (data.byteLength === 0) {
693
+ throw Object.assign(new Error(`studio-local image is empty: ${url}`), {
694
+ code: 'studio_local_fetch_failed',
695
+ });
696
+ }
697
+ const mimetype = res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png';
698
+ return deps.upload(data, mimetype);
699
+ }
700
+
701
+ /** Read a chat-attachment image from disk and re-host it. No LLM base64. */
702
+ export async function transferChatAttachmentImage(
703
+ ref: string,
704
+ upload: ImageTransferDeps['upload'],
705
+ ): Promise<string> {
706
+ const path = chatAttachmentImagePath(ref);
707
+ if (!path || !isAbsolute(path)) {
708
+ throw Object.assign(new Error(`not a local image path: ${ref}`), { code: 'invalid_image_url' });
709
+ }
710
+ let st;
711
+ try {
712
+ st = statSync(path);
713
+ } catch {
714
+ throw Object.assign(new Error(`chat attachment image not found: ${path}`), { code: 'invalid_image_url' });
715
+ }
716
+ if (!st.isFile()) {
717
+ throw Object.assign(new Error(`chat attachment is not a file: ${path}`), { code: 'invalid_image_url' });
718
+ }
719
+ if (st.size === 0) {
720
+ throw Object.assign(new Error(`chat attachment image is empty: ${path}`), { code: 'invalid_image_url' });
721
+ }
722
+ if (st.size > MAX_UPLOAD_BYTES) {
723
+ throw Object.assign(
724
+ new Error(`chat attachment image too large: ${st.size} bytes (max ${MAX_UPLOAD_BYTES})`),
725
+ { code: 'image_too_large' },
726
+ );
727
+ }
728
+ const mime = CHAT_IMAGE_EXT_MIME[extname(path).toLowerCase()];
729
+ if (!mime) {
730
+ throw Object.assign(new Error(`unsupported chat attachment image type: ${path}`), { code: 'invalid_mimetype' });
731
+ }
732
+ const data = new Uint8Array(readFileSync(path));
733
+ return upload(data, mime);
734
+ }
735
+
736
+ // Re-host a studio-local URL or chat-attachment path on COS so a real provider
737
+ // can fetch it; public URLs pass through. Fail loud when COS is missing.
738
+ export async function ensureProviderReachableUrl(url: string, slug?: string): Promise<string> {
739
+ const filePath = chatAttachmentImagePath(url, slug);
740
+ if (filePath) {
741
+ if (!existsSync(filePath)) {
742
+ throw Object.assign(new Error(`chat attachment image not found: ${filePath}`), { code: 'invalid_image_url' });
743
+ }
744
+ return transferChatAttachmentImage(filePath, (data, mimetype) =>
745
+ uploadTransferArtifact(data, mimetype).then((r) => r.url),
746
+ );
747
+ }
748
+ const parsed = parseChatUploadRef(url);
749
+ if (parsed && !url.includes('/')) {
750
+ throw Object.assign(
751
+ new Error(`chat attachment image not found in game sessions: ${url}`),
752
+ { code: 'invalid_image_url' },
753
+ );
754
+ }
755
+ if (!isStudioLocalImageUrl(url)) return url;
756
+ return transferStudioLocalImage(url, {
757
+ baseUrl: studioBaseUrl(),
758
+ fetchImpl: globalThis.fetch,
759
+ upload: (data, mimetype) => uploadTransferArtifact(data, mimetype).then((r) => r.url),
760
+ });
761
+ }
762
+
763
+ export async function ensureProviderReachableUrls(
764
+ urls: Record<string, string>,
765
+ slug?: string,
766
+ ): Promise<Record<string, string>> {
767
+ const out: Record<string, string> = {};
768
+ for (const [key, url] of Object.entries(urls)) {
769
+ out[key] = await ensureProviderReachableUrl(url, slug);
770
+ }
771
+ return out;
772
+ }
773
+
774
+ /** @deprecated Prefer ensureProviderReachableUrls — kept as a thin alias. */
775
+ async function transferStudioLocalViews(
776
+ views: Record<string, string>,
777
+ slug?: string,
778
+ ): Promise<Record<string, string>> {
779
+ return ensureProviderReachableUrls(views, slug);
780
+ }
781
+
782
+ async function viewsTo3D(args: ViewsTo3DArgs): Promise<GenerateResult> {
783
+ const slug = requireSlug(args.slug);
784
+ const front = args.views?.front_image_url?.trim();
785
+ if (!front) {
786
+ throw Object.assign(new Error('views.front_image_url is required'), { code: 'invalid_views' });
787
+ }
788
+ const assetSlot = resolveSlot(args.assetSlot);
789
+ const provider = resolveProvider(args.provider);
790
+ if (provider === 'visvise') {
791
+ throw Object.assign(
792
+ new Error('visvise views is not on the LiteLLM catalog; use text or image'),
793
+ { code: 'views_not_supported' },
794
+ );
795
+ }
796
+ const faceCount = resolvePolycount(args.targetPolycount, provider);
797
+ const enablePbr = args.enablePbr ?? true;
798
+ const enableFbxUrl = args.enableFbxUrl ?? false;
799
+ const normalizedViews: Record<string, string> = {};
800
+ for (const [slot, url] of Object.entries(args.views)) {
801
+ if (url && url.trim()) normalizedViews[slot] = url.trim();
802
+ }
803
+ // cacheKey keys off the STABLE studio-local URLs, never the ephemeral COS
804
+ // presigned URL (which carries an expiring signature) — so an identical
805
+ // request still hits the cache and never re-burns quota.
806
+ const cacheKey = makeCacheKey(provider, 'views', {
807
+ assetSlot,
808
+ ...normalizedViews,
809
+ faceCount,
810
+ enablePbr,
811
+ enableFbxUrl,
812
+ ...(provider === 'meshy' ? { shouldTexture: true } : {}),
813
+ ...buildProviderParams(provider, 'views', args.providerParams).cacheBits,
814
+ });
815
+ // D-B (lazy): inputs are built ONLY on a cache MISS for the real-provider
816
+ // branch (runGeneration invokes this just before a real generate()), so the
817
+ // studio-local→COS transfer never runs on a cache hit or the mock path — no
818
+ // redundant fetch/upload, and a cached success can't be re-broken by a moved
819
+ // source image or changed COS config.
820
+ const buildInputs = async (): Promise<GenInputs> => {
821
+ const providerViews = await transferStudioLocalViews(normalizedViews, slug);
822
+ // Meshy multi-image takes an ordered URL array (front/back/left/right first),
823
+ // not Hunyuan's named view slots.
824
+ const meshyUrls = [
825
+ providerViews.front_image_url,
826
+ providerViews.back_image_url,
827
+ providerViews.left_image_url,
828
+ providerViews.right_image_url,
829
+ ].filter((u): u is string => Boolean(u));
830
+ const { filtered } = buildProviderParams(provider, 'views', args.providerParams);
831
+ const rodinPrompt = applyRodinPromptFlags('', filtered, 'views');
832
+ return {
833
+ hunyuan: {
834
+ mode: 'views',
835
+ views: providerViews as Partial<Record<ViewSlot, string>>,
836
+ faceCount,
837
+ enablePbr,
838
+ enableFbxUrl,
839
+ },
840
+ meshy: { mode: 'views', imageUrls: meshyUrls, targetPolycount: faceCount, enablePbr, params: filtered },
841
+ rodin: {
842
+ mode: 'views',
843
+ imageUrls: meshyUrls,
844
+ ...(rodinPrompt ? { prompt: rodinPrompt } : {}),
845
+ },
846
+ visvise: { mode: 'views' },
847
+ };
848
+ };
849
+ return runGeneration(
850
+ provider,
851
+ 'views',
852
+ { slug, assetSlot, assetName: defaultName(args.assetName, `views-${provider}`), faceCount, cacheKey },
853
+ buildInputs,
854
+ null,
855
+ );
856
+ }
857
+
858
+ // Meshy-only second stage: add texture to a prior Meshy text `preview` task.
859
+ // previewTaskId is the sourceJobId of a prior gen3d:text-to-3d (provider=meshy)
860
+ // result. Produces a new durable manifest (mode='refine'). Quota-safe: falls
861
+ // back to mock when Meshy is not configured.
862
+ interface RefineMeshArgs {
863
+ slug?: string;
864
+ assetSlot?: AssetSlot;
865
+ assetName?: string;
866
+ previewTaskId: string;
867
+ texturePrompt?: string;
868
+ enablePbr?: boolean;
869
+ }
870
+
871
+ async function refineMesh(args: RefineMeshArgs): Promise<GenerateResult> {
872
+ const slug = requireSlug(args.slug);
873
+ const previewTaskId = args.previewTaskId?.trim();
874
+ if (!previewTaskId) {
875
+ throw Object.assign(new Error('previewTaskId is required'), { code: 'invalid_preview_task' });
876
+ }
877
+ const assetSlot = resolveSlot(args.assetSlot);
878
+ const enablePbr = args.enablePbr ?? true;
879
+ const texturePrompt = args.texturePrompt?.trim() || undefined;
880
+ const cacheKey = makeCacheKey('meshy', 'refine', {
881
+ assetSlot,
882
+ previewTaskId,
883
+ enablePbr,
884
+ texturePrompt: texturePrompt ?? '',
885
+ });
886
+ const ctx: PersistInput = {
887
+ slug,
888
+ assetSlot,
889
+ assetName: defaultName(args.assetName, `refine-${previewTaskId}`),
890
+ cacheKey,
891
+ };
892
+ let usedMock = false;
893
+ const produce = async (): Promise<ProviderResult> => {
894
+ const env = getMeshyEnv();
895
+ if (env) {
896
+ return new MeshyProvider({ env, slug }).generate({ mode: 'refine', previewTaskId, texturePrompt, enablePbr });
897
+ }
898
+ if (realProvidersEnabled()) throw providerNotConfigured('meshy', 'refine');
899
+ usedMock = true;
900
+ return mockFallback('meshy', 'refine', `refine:${previewTaskId}`);
901
+ };
902
+ const { manifest, cacheHit } = await generateCacheFirst(storage, ctx, produce);
903
+ return { ok: true, cacheKey, cacheHit, usedMock: cacheHit ? manifest.providerMode === 'mock' : usedMock, manifest };
904
+ }
905
+
906
+ // Hunyuan REST sub-capability: pose_standardization. This is an UPSTREAM
907
+ // preprocessing tool (image → standardized portrait image), not 3D generation.
908
+ // It does NOT produce a Gen3DAssetManifest. The output image is persisted as a
909
+ // scratch (transfer) artifact under the game's .gen3d/tmp/ — never the asset
910
+ // library (CONTEXT.md "临时/中转产物"). Quota-safe by default: with no real
911
+ // provider configured it falls back to a deterministic mock image.
912
+
913
+ interface PoseStandardizationArgs {
914
+ slug?: string;
915
+ imageUrl: string;
916
+ footnote?: string;
917
+ provider?: 'hunyuan_rest' | 'visvise';
918
+ }
919
+
920
+ interface PoseStandardizationResult {
921
+ ok: true;
922
+ usedMock: boolean;
923
+ sourceJobId: string | null;
924
+ // The standardized image as a scratch artifact (NOT an asset; no manifest).
925
+ // Use storageKey as the upstream input for a subsequent gen3d:image-to-3d.
926
+ storageKey: string;
927
+ bytes: number;
928
+ sha256: string;
929
+ localUrl: string | null;
930
+ sourceUrl: string | null;
931
+ }
932
+
933
+ async function poseStandardization(
934
+ args: PoseStandardizationArgs,
935
+ ): Promise<PoseStandardizationResult> {
936
+ const slug = requireSlug(args.slug);
937
+ const imageUrl = args.imageUrl?.trim();
938
+ if (!imageUrl) {
939
+ throw Object.assign(new Error('imageUrl is required'), { code: 'invalid_image_url' });
940
+ }
941
+ const footnote = args.footnote?.trim() || undefined;
942
+ const poseProvider = args.provider === 'visvise' ? 'visvise' : 'hunyuan_rest';
943
+
944
+ let imageData: Uint8Array;
945
+ let sourceJobId: string | null;
946
+ let sourceUrl: string | null;
947
+ let usedMock: boolean;
948
+
949
+ if (poseProvider === 'visvise') {
950
+ const visviseEnv = getVisviseEnv();
951
+ if (visviseEnv) {
952
+ const reachable = await ensureProviderReachableUrl(imageUrl, slug);
953
+ const result = await new VisviseProvider({ env: visviseEnv, slug }).pose({ inputModelUrl: reachable });
954
+ imageData = result.imageData;
955
+ sourceJobId = result.sourceJobId;
956
+ sourceUrl = result.sourceUrl;
957
+ usedMock = false;
958
+ } else {
959
+ if (realProvidersEnabled()) throw providerNotConfigured('visvise', 'pose-standardization');
960
+ const { result } = generateMeshyTextMockResult({ prompt: `pose-visvise:${imageUrl}` });
961
+ const preview = result.files.find((f) => f.role === 'preview_image');
962
+ imageData = preview?.data ?? new Uint8Array();
963
+ sourceJobId = null;
964
+ sourceUrl = null;
965
+ usedMock = true;
966
+ }
967
+ } else {
968
+ const env = getHunyuanEnv();
969
+
970
+ if (env) {
971
+ const reachable = await ensureProviderReachableUrl(imageUrl, slug);
972
+ const provider = new HunyuanRestProvider({ env, slug });
973
+ const result = await provider.poseStandardization({ imageUrl: reachable, footnote });
974
+ imageData = result.imageData;
975
+ sourceJobId = result.sourceJobId;
976
+ sourceUrl = result.sourceUrl;
977
+ usedMock = false;
978
+ } else {
979
+ if (realProvidersEnabled()) throw providerNotConfigured('hunyuan_rest', 'pose-standardization');
980
+ // Deterministic no-quota fallback: reuse the mock preview image bytes so the
981
+ // storage path runs without a network call.
982
+ const { result } = generateMeshyTextMockResult({ prompt: `pose:${imageUrl}` });
983
+ const preview = result.files.find((f) => f.role === 'preview_image');
984
+ imageData = preview?.data ?? new Uint8Array();
985
+ sourceJobId = null;
986
+ sourceUrl = null;
987
+ usedMock = true;
988
+ }
989
+ }
990
+
991
+ const stored = await storage.putScratch({ slug, data: imageData, format: 'png' });
992
+ return {
993
+ ok: true,
994
+ usedMock,
995
+ sourceJobId,
996
+ storageKey: stored.storageKey,
997
+ bytes: stored.bytes,
998
+ sha256: stored.sha256,
999
+ localUrl: stored.localUrl,
1000
+ sourceUrl,
1001
+ };
1002
+ }
1003
+
1004
+ // Local image upload (transfer artifact, NOT an asset). Decodes a base64 image
1005
+ // and hosts it on COS so URL-fetching providers (Hunyuan/Meshy) can reach a
1006
+ // user's local file; the result URL is fed into image/views/pose tools. Rodin
1007
+ // takes bytes directly and does not need this. base64 rides the existing JSON
1008
+ // tools route (no extra server route); the decoded image is hard-capped at 8MB
1009
+ // so an oversized upload can't exhaust server memory.
1010
+
1011
+ const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
1012
+ const ALLOWED_UPLOAD_MIMES = new Set([
1013
+ 'image/png',
1014
+ 'image/jpeg',
1015
+ 'image/jpg',
1016
+ 'image/webp',
1017
+ 'image/gif',
1018
+ ]);
1019
+
1020
+ interface UploadImageArgs {
1021
+ // Raw base64 (no data: prefix) of the image bytes.
1022
+ base64: string;
1023
+ mimetype: string;
1024
+ }
1025
+
1026
+ interface UploadImageResult {
1027
+ ok: true;
1028
+ url: string;
1029
+ bytes: number;
1030
+ sha256: string;
1031
+ expiresInSec: number;
1032
+ }
1033
+
1034
+ function decodeBase64(raw: string): Uint8Array {
1035
+ // Tolerate an accidental data: URL prefix the UI might pass.
1036
+ const comma = raw.indexOf(',');
1037
+ const b64 = raw.startsWith('data:') && comma !== -1 ? raw.slice(comma + 1) : raw;
1038
+ return new Uint8Array(Buffer.from(b64, 'base64'));
1039
+ }
1040
+
1041
+ async function uploadImage(args: UploadImageArgs): Promise<UploadImageResult> {
1042
+ const mimetype = args.mimetype?.trim().toLowerCase();
1043
+ if (!mimetype || !ALLOWED_UPLOAD_MIMES.has(mimetype)) {
1044
+ throw Object.assign(new Error(`unsupported image mimetype ${JSON.stringify(args.mimetype)}`), {
1045
+ code: 'invalid_mimetype',
1046
+ });
1047
+ }
1048
+ if (!args.base64 || typeof args.base64 !== 'string') {
1049
+ throw Object.assign(new Error('base64 image data is required'), { code: 'invalid_base64' });
1050
+ }
1051
+ const data = decodeBase64(args.base64);
1052
+ if (data.byteLength === 0) {
1053
+ throw Object.assign(new Error('decoded image is empty'), { code: 'invalid_base64' });
1054
+ }
1055
+ if (data.byteLength > MAX_UPLOAD_BYTES) {
1056
+ throw Object.assign(
1057
+ new Error(`image too large: ${data.byteLength} bytes (max ${MAX_UPLOAD_BYTES})`),
1058
+ { code: 'image_too_large' },
1059
+ );
1060
+ }
1061
+ const result = await uploadTransferArtifact(data, mimetype);
1062
+ return { ok: true, ...result };
1063
+ }
1064
+
1065
+ const MAX_VIDEO_BYTES = 100 * 1024 * 1024;
1066
+ const ALLOWED_VIDEO_MIMES = new Set(['video/mp4', 'video/webm']);
1067
+
1068
+ interface UploadVideoArgs {
1069
+ mimetype: string;
1070
+ }
1071
+
1072
+ interface UploadVideoResult {
1073
+ ok: true;
1074
+ uploadUrl: string;
1075
+ publicUrl: string;
1076
+ expiresInSec: number;
1077
+ maxBytes: number;
1078
+ }
1079
+
1080
+ async function uploadVideo(args: UploadVideoArgs): Promise<UploadVideoResult> {
1081
+ const mimetype = args.mimetype?.trim().toLowerCase();
1082
+ if (!mimetype || !ALLOWED_VIDEO_MIMES.has(mimetype)) {
1083
+ throw Object.assign(new Error(`unsupported video mimetype ${JSON.stringify(args.mimetype)}`), {
1084
+ code: 'invalid_mimetype',
1085
+ });
1086
+ }
1087
+ const env = getCosEnv();
1088
+ if (!env) {
1089
+ throw Object.assign(new Error('COS upload is not configured; paste a video URL instead'), {
1090
+ code: 'cos_not_configured',
1091
+ });
1092
+ }
1093
+ const signed = await new CosUploader(env).presignPut(mimetype);
1094
+ return {
1095
+ ok: true,
1096
+ uploadUrl: signed.uploadUrl,
1097
+ publicUrl: signed.publicUrl,
1098
+ expiresInSec: signed.expiresInSec,
1099
+ maxBytes: MAX_VIDEO_BYTES,
1100
+ };
1101
+ }
1102
+
1103
+ // ─── M13: rig / motion / low_poly (mock-first; ADR-0009) ────────────────────
1104
+ //
1105
+ // Core pipeline: textured high-poly GLB → auto-rig → apply-motion.
1106
+ // rig/motion append GLB (canonical) + FBX (motion transport) to the SAME mesh
1107
+ // asset and flip readiness; low_poly is an optional geometry/LOD side-branch.
1108
+ //
1109
+ // Provider dispatch (ADR-0009):
1110
+ // • auto-rig auto = Hunyuan REST → VISVISE → mock. Meshy is explicit-only.
1111
+ // • apply-motion follows the asset's recorded rig.rigProvider.
1112
+ // • With neither configured, deterministic placeholder bytes exercise the
1113
+ // storage path with zero quota (mock).
1114
+ // Meshy animation MUST be driven by Meshy's own rig_task_id (not an external
1115
+ // FBX), so apply-motion dispatches strictly by the asset's recorded rig system
1116
+ // (manifest.rig.rigProvider) and reads rig.rigTaskId. rig_task_id + signed URLs
1117
+ // expire ~3 days → rig_expired is reported unless autoReRig is set (PLAN §8-Q3).
1118
+ // exposedToAI stays false until operator real-machine verification (PLAN §5 P3).
1119
+
1120
+ // Deterministic placeholder model bytes (GLB magic header). Stand-in so the
1121
+ // rig/motion/low_poly append + persist paths run end-to-end without quota.
1122
+ function mockModelBytes(seed: string): Uint8Array {
1123
+ const header = new Uint8Array([0x67, 0x6c, 0x54, 0x46, 0x02, 0x00, 0x00, 0x00]);
1124
+ const tail = new TextEncoder().encode(`mock-model:${seed}`);
1125
+ const out = new Uint8Array(header.length + tail.length);
1126
+ out.set(header, 0);
1127
+ out.set(tail, header.length);
1128
+ return out;
1129
+ }
1130
+
1131
+ // Share an asset file (already on disk) as a public COS transfer URL so the
1132
+ // URL-fetching Hunyuan REST endpoint can read it. Returns null when COS is not
1133
+ // configured (caller then falls back to mock). The file is read from the store,
1134
+ // never assumed to be a reachable provider URL (ADR-0001).
1135
+ async function shareAssetFileUrl(
1136
+ slug: string,
1137
+ assetPath: string,
1138
+ role: 'source_mesh' | 'rigged_model',
1139
+ format: 'glb' | 'fbx',
1140
+ ): Promise<string | null> {
1141
+ const file = await storage.readAssetFile(slug, assetPath, role, format);
1142
+ if (!file) {
1143
+ throw Object.assign(
1144
+ new Error(`asset has no ${role} ${format} file: ${assetPath}`),
1145
+ { code: 'missing_input_file' },
1146
+ );
1147
+ }
1148
+ try {
1149
+ const { url } = await uploadTransferArtifact(file.data, mimeForModelFormat(format));
1150
+ return url;
1151
+ } catch (error) {
1152
+ if ((error as { code?: unknown } | null)?.code === 'cos_not_configured') return null;
1153
+ throw error;
1154
+ }
1155
+ }
1156
+
1157
+ interface AutoRigArgs {
1158
+ slug?: string;
1159
+ assetPath: string;
1160
+ /** auto | meshy | hunyuan_rest | visvise — explicit force never falls back to the other route. */
1161
+ rigProvider?: 'auto' | 'meshy' | 'hunyuan_rest' | 'visvise';
1162
+ /**
1163
+ * When true and the asset is already rigged: strip rigged_model + animated_model
1164
+ * (+ custom.rig), then re-run the chosen route (burns fresh credits). Default
1165
+ * false keeps the historical idempotent no-op.
1166
+ */
1167
+ force?: boolean;
1168
+ /** Meshy-only: approximate character height (m). Omit → upstream default ~1.7. */
1169
+ heightMeters?: number;
1170
+ }
1171
+
1172
+ function parseHeightMeters(raw: number | undefined): number | undefined {
1173
+ if (raw === undefined || !Number.isFinite(raw)) return undefined;
1174
+ if (raw < 0.1 || raw > 5) {
1175
+ throw Object.assign(new Error('heightMeters must be between 0.1 and 5'), {
1176
+ code: 'invalid_height_meters',
1177
+ });
1178
+ }
1179
+ return raw;
1180
+ }
1181
+
1182
+ interface RigMotionResult {
1183
+ ok: true;
1184
+ usedMock: boolean;
1185
+ assetPath: string;
1186
+ manifest: Gen3DAssetManifest;
1187
+ }
1188
+
1189
+ // A free walk/run clip bundled in a Meshy rig result (reserved ids, isFree).
1190
+ function freeMotionRef(category: 'walking' | 'running'): MotionRef {
1191
+ return category === 'walking'
1192
+ ? { system: 'meshy', id: MESHY_FREE_WALK_ID, label: '走路(免费)' }
1193
+ : { system: 'meshy', id: MESHY_FREE_RUN_ID, label: '跑步(免费)' };
1194
+ }
1195
+
1196
+ const HUMANOID_SKELETON = {
1197
+ hasSkeleton: true,
1198
+ skeletonProfile: 'humanoid' as const,
1199
+ animationInputReady: true,
1200
+ };
1201
+
1202
+ // Meshy credit costs per paid call (ADR-0006): rig ~5, animation ~3.
1203
+ export const MESHY_RIG_COST = 5;
1204
+ export const MESHY_ANIM_COST = 3;
1205
+
1206
+ // Proactive balance pre-check before a paid Meshy rig/animation call (ADR-0006 /
1207
+ // ADR-0008 D-E). Meshy also rejects 402 → provider_insufficient_credits
1208
+ // reactively, but pre-checking lets the agent get a clear quote (needed vs
1209
+ // available) BEFORE any spend instead of discovering it mid-dispatch.
1210
+ export async function assertMeshyBalance(
1211
+ provider: { getBalance(): Promise<number | null> },
1212
+ needed: number,
1213
+ op: string,
1214
+ ): Promise<void> {
1215
+ const balance = await provider.getBalance();
1216
+ // Gateway has no balance endpoint — skip pre-check; 402 is handled reactively.
1217
+ if (balance === null) return;
1218
+ if (balance < needed) {
1219
+ throw Object.assign(
1220
+ new Error(`${op} needs ~${needed} Meshy credits but the balance is ${balance}; top up or skip the motion step`),
1221
+ { code: 'provider_insufficient_credits', needed, balance },
1222
+ );
1223
+ }
1224
+ }
1225
+
1226
+ // Meshy auto-rig: LiteLLM gateway requires `model_url` (input_task_id alone → HTTP 500
1227
+ // "Missing required parameter model_url"). Always COS-share the source GLB; optionally
1228
+ // also pass Meshy result task id. Then append rigged GLB+FBX + free walk/run clips.
1229
+ async function meshyRigAppend(
1230
+ slug: string,
1231
+ asset: Gen3DAssetManifest,
1232
+ provider: MeshyProvider,
1233
+ heightMeters?: number,
1234
+ ): Promise<Gen3DAssetManifest> {
1235
+ const modelUrl = await shareAssetFileUrl(slug, asset.assetPath, 'source_mesh', 'glb');
1236
+ // Prefer the final textured mesh task (result), never the white-mesh preview.
1237
+ const meshTaskId =
1238
+ asset.meshyTaskRefs?.resultTaskId ??
1239
+ (asset.provider === 'meshy' ? asset.sourceJobId : null);
1240
+ const inputTaskId =
1241
+ asset.provider === 'meshy' && meshTaskId && !meshTaskId.startsWith('mock') ? meshTaskId : undefined;
1242
+ if (!modelUrl) {
1243
+ throw Object.assign(
1244
+ new Error('Meshy auto-rig needs COS configured to share model_url (gateway rejects input_task_id-only)'),
1245
+ { code: 'cos_not_configured' },
1246
+ );
1247
+ }
1248
+ const rig = await provider.rig({
1249
+ modelUrl,
1250
+ ...(inputTaskId ? { inputTaskId } : {}),
1251
+ ...(heightMeters !== undefined ? { heightMeters } : {}),
1252
+ });
1253
+ const files: DerivedFileInput[] = [{ data: rig.glb, format: 'glb', role: 'rigged_model' }];
1254
+ if (rig.fbx) files.push({ data: rig.fbx, format: 'fbx', role: 'rigged_model' });
1255
+ for (const ba of rig.basicAnimations) {
1256
+ const ref = freeMotionRef(ba.category);
1257
+ files.push({ data: ba.glb, format: 'glb', role: 'animated_model', motionRef: ref });
1258
+ if (ba.fbx) files.push({ data: ba.fbx, format: 'fbx', role: 'animated_model', motionRef: ref });
1259
+ }
1260
+ return storage.appendDerivedFiles({
1261
+ slug,
1262
+ assetPath: asset.assetPath,
1263
+ files,
1264
+ skeleton: HUMANOID_SKELETON,
1265
+ rigChain: {
1266
+ rigProvider: 'meshy',
1267
+ rigTaskId: rig.sourceJobId,
1268
+ rigType: rig.rigType,
1269
+ rigExpiresAt: rig.expiresAt,
1270
+ },
1271
+ });
1272
+ }
1273
+
1274
+ async function hunyuanRigAppend(
1275
+ slug: string,
1276
+ assetPath: string,
1277
+ hunyuanEnv: NonNullable<ReturnType<typeof getHunyuanEnv>>,
1278
+ ): Promise<Gen3DAssetManifest> {
1279
+ const glbUrl = await shareAssetFileUrl(slug, assetPath, 'source_mesh', 'glb');
1280
+ if (!glbUrl) {
1281
+ throw Object.assign(new Error('auto-rig needs COS configured to share the input model URL'), {
1282
+ code: 'cos_not_configured',
1283
+ });
1284
+ }
1285
+ const files: ModelFileOut[] = (await new HunyuanRestProvider({ env: hunyuanEnv, slug }).autoRig({ glbUrl })).files;
1286
+ const derived: DerivedFileInput[] = files.map((f) => ({ data: f.data, format: f.format, role: 'rigged_model' }));
1287
+ return storage.appendDerivedFiles({
1288
+ slug,
1289
+ assetPath,
1290
+ files: derived,
1291
+ skeleton: HUMANOID_SKELETON,
1292
+ rigChain: { rigProvider: 'hunyuan_rest', rigTaskId: null, rigType: null, rigExpiresAt: null },
1293
+ });
1294
+ }
1295
+
1296
+ async function visviseRigAppend(
1297
+ slug: string,
1298
+ asset: Gen3DAssetManifest,
1299
+ visviseEnv: NonNullable<ReturnType<typeof getVisviseEnv>>,
1300
+ ): Promise<Gen3DAssetManifest> {
1301
+ const assetPath = asset.assetPath;
1302
+ const inputModelUrl =
1303
+ visviseInputUrl(asset.visviseRefs, 'rig') ??
1304
+ (await shareAssetFileUrl(slug, assetPath, 'source_mesh', 'glb'));
1305
+ if (!inputModelUrl) {
1306
+ throw Object.assign(new Error('auto-rig needs a VISVISE mesh URL (or COS to share the local GLB)'), {
1307
+ code: 'cos_not_configured',
1308
+ });
1309
+ }
1310
+ const out = await new VisviseProvider({ env: visviseEnv, slug }).autoRig({ inputModelUrl });
1311
+ const derived: DerivedFileInput[] = out.files.map((f) => ({ data: f.data, format: f.format, role: 'rigged_model' }));
1312
+ return storage.appendDerivedFiles({
1313
+ slug,
1314
+ assetPath,
1315
+ files: derived,
1316
+ skeleton: HUMANOID_SKELETON,
1317
+ rigChain: { rigProvider: 'visvise', rigTaskId: null, rigType: null, rigExpiresAt: null },
1318
+ visviseRefs: {
1319
+ meshUrl: asset.visviseRefs?.meshUrl ?? null,
1320
+ rigUrl: out.sourceUrl,
1321
+ },
1322
+ });
1323
+ }
1324
+
1325
+ function defaultAutoRigRoute(asset: Gen3DAssetManifest): 'hunyuan_rest' | 'visvise' {
1326
+ if (asset.provider === 'visvise' || asset.provider === 'rodin') return 'visvise';
1327
+ return 'hunyuan_rest';
1328
+ }
1329
+
1330
+ // gen3d:auto-rig — append a rigged_model GLB (canonical) + FBX (motion transport)
1331
+ // to a textured mesh asset and set skeleton flags. Humanoid only (characters
1332
+ // slot, soft-gated in the UI/schema). Idempotent by default: if already rigged,
1333
+ // returns the existing manifest without burning quota. Pass force=true to strip
1334
+ // prior rig+motions and re-run (new credits; optional provider switch).
1335
+ // Dispatch (ADR-0009):
1336
+ // rigProvider=auto → Hunyuan if configured, else VISVISE, else mock
1337
+ // explicit meshy|hunyuan_rest|visvise → that route; missing env → provider_not_configured
1338
+ async function autoRig(args: AutoRigArgs): Promise<RigMotionResult> {
1339
+ const slug = requireSlug(args.slug);
1340
+ const assetPath = args.assetPath?.trim();
1341
+ if (!assetPath) {
1342
+ throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
1343
+ }
1344
+ let existing = await storage.getAsset(slug, assetPath);
1345
+ if (!existing) {
1346
+ throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
1347
+ }
1348
+ // Idempotent: a verified rigged_model already present → return as-is unless force.
1349
+ if (existing.readiness.rigged) {
1350
+ if (!args.force) {
1351
+ return { ok: true, usedMock: existing.providerMode === 'mock', assetPath, manifest: existing };
1352
+ }
1353
+ existing = await storage.clearRigAndMotions(slug, assetPath);
1354
+ }
1355
+
1356
+ const route = args.rigProvider ?? 'auto';
1357
+ const heightMeters = parseHeightMeters(args.heightMeters);
1358
+ const meshyEnv = getMeshyEnv();
1359
+ const hunyuanEnv = getHunyuanEnv();
1360
+ const visviseEnv = getVisviseEnv();
1361
+
1362
+ if (route === 'meshy') {
1363
+ if (!meshyEnv) throw providerNotConfigured('meshy', 'auto-rig');
1364
+ const provider = new MeshyProvider({ env: meshyEnv, slug });
1365
+ await assertMeshyBalance(provider, MESHY_RIG_COST, 'auto-rig');
1366
+ const manifest = await meshyRigAppend(slug, existing, provider, heightMeters);
1367
+ return { ok: true, usedMock: false, assetPath, manifest };
1368
+ }
1369
+
1370
+ if (route === 'hunyuan_rest') {
1371
+ if (!hunyuanEnv) throw providerNotConfigured('hunyuan_rest', 'auto-rig');
1372
+ const manifest = await hunyuanRigAppend(slug, assetPath, hunyuanEnv);
1373
+ return { ok: true, usedMock: false, assetPath, manifest };
1374
+ }
1375
+
1376
+ if (route === 'visvise') {
1377
+ if (!visviseEnv) throw providerNotConfigured('visvise', 'auto-rig');
1378
+ const manifest = await visviseRigAppend(slug, existing, visviseEnv);
1379
+ return { ok: true, usedMock: false, assetPath, manifest };
1380
+ }
1381
+
1382
+ // auto: Hunyuan → VISVISE → mock. Meshy is no longer first (ADR-0009).
1383
+ if (hunyuanEnv) {
1384
+ const manifest = await hunyuanRigAppend(slug, assetPath, hunyuanEnv);
1385
+ return { ok: true, usedMock: false, assetPath, manifest };
1386
+ }
1387
+ if (visviseEnv) {
1388
+ const manifest = await visviseRigAppend(slug, existing, visviseEnv);
1389
+ return { ok: true, usedMock: false, assetPath, manifest };
1390
+ }
1391
+
1392
+ if (realProvidersEnabled()) throw providerNotConfigured('hunyuan_rest', 'auto-rig');
1393
+
1394
+ const mockRoute = defaultAutoRigRoute(existing);
1395
+ const files: DerivedFileInput[] = [
1396
+ { data: mockModelBytes(`rig-glb:${assetPath}`), format: 'glb', role: 'rigged_model' },
1397
+ { data: mockModelBytes(`rig-fbx:${assetPath}`), format: 'fbx', role: 'rigged_model' },
1398
+ ];
1399
+ const manifest = await storage.appendDerivedFiles({
1400
+ slug,
1401
+ assetPath,
1402
+ files,
1403
+ skeleton: HUMANOID_SKELETON,
1404
+ rigChain: { rigProvider: mockRoute, rigTaskId: `mock-rig:${assetPath}`, rigType: 'mock', rigExpiresAt: null },
1405
+ });
1406
+ return { ok: true, usedMock: true, assetPath, manifest };
1407
+ }
1408
+
1409
+ interface ApplyMotionArgs {
1410
+ slug?: string;
1411
+ assetPath: string;
1412
+ // Meshy path: the action_id from gen3d:list-motions (positive integer).
1413
+ actionId?: number;
1414
+ // Hunyuan dev path: the v1 fixed motion (int 9–16).
1415
+ motionType?: number;
1416
+ // VISVISE: text-motion prompt XOR video-motion URL.
1417
+ prompt?: string;
1418
+ videoUrl?: string;
1419
+ // Optional display label (the UI passes the action name it showed); the
1420
+ // catalog is not re-queried here. Falls back to "动作 <id>".
1421
+ label?: string;
1422
+ // When the Meshy rig task is stale (expired ~3 days, or mock), re-rig
1423
+ // (+credits) instead of erroring with rig_expired. Default false (PLAN §8-Q3).
1424
+ autoReRig?: boolean;
1425
+ }
1426
+
1427
+ const VALID_MOTION_TYPES: readonly MotionType[] = [9, 10, 11, 12, 13, 14, 15, 16];
1428
+
1429
+ function asMotionType(value: number | undefined): MotionType {
1430
+ if (value === undefined || !VALID_MOTION_TYPES.includes(value as MotionType)) {
1431
+ throw Object.assign(new Error(`motionType must be an int 9–16, got ${value}`), {
1432
+ code: 'invalid_motion_type',
1433
+ });
1434
+ }
1435
+ return value as MotionType;
1436
+ }
1437
+
1438
+ // Real Meshy action ids are positive; reserved negatives are internal (bundled
1439
+ // free clips) and must not be requested directly through apply-motion.
1440
+ function asActionId(value: number | undefined): number {
1441
+ if (value === undefined || !Number.isInteger(value) || value <= 0) {
1442
+ throw Object.assign(new Error(`actionId must be a positive integer, got ${value}`), {
1443
+ code: 'invalid_action_id',
1444
+ });
1445
+ }
1446
+ return value;
1447
+ }
1448
+
1449
+ // Has this exact motion already been applied? (idempotency, by structural key.)
1450
+ function hasMotion(asset: Gen3DAssetManifest, ref: MotionRef): boolean {
1451
+ const key = motionRefKey(ref);
1452
+ return selectFiles(asset.files, 'animated_model').some(
1453
+ (f) => f.motionRef !== undefined && motionRefKey(f.motionRef) === key,
1454
+ );
1455
+ }
1456
+
1457
+ // A Meshy rig task is stale when it expired (~3 days) or was only mock-rigged —
1458
+ // either way it cannot drive a real /animations call.
1459
+ function meshyRigStale(rig: RigChain | undefined): boolean {
1460
+ if (!rig || !rig.rigTaskId) return true;
1461
+ if (rig.rigTaskId.startsWith('mock')) return true;
1462
+ return rig.rigExpiresAt !== null && Date.now() > rig.rigExpiresAt;
1463
+ }
1464
+
1465
+ // gen3d:apply-motion — append an animated_model GLB (canonical) + FBX for one
1466
+ // motion to a RIGGED asset, flipping readiness.animated. Dispatches strictly by
1467
+ // the asset's recorded rig system (ADR-0006 §8-Q4): Meshy uses rig.rigTaskId +
1468
+ // actionId; Hunyuan uses the rigged FBX + motionType. Multiple motions coexist;
1469
+ // idempotent per motion. Requires a prior auto-rig (else not_rigged).
1470
+ async function applyMotion(args: ApplyMotionArgs): Promise<RigMotionResult> {
1471
+ const slug = requireSlug(args.slug);
1472
+ const assetPath = args.assetPath?.trim();
1473
+ if (!assetPath) {
1474
+ throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
1475
+ }
1476
+ const existing = await storage.getAsset(slug, assetPath);
1477
+ if (!existing) {
1478
+ throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
1479
+ }
1480
+ if (!existing.readiness.rigged) {
1481
+ throw Object.assign(new Error('asset is not rigged; run gen3d:auto-rig first'), {
1482
+ code: 'not_rigged',
1483
+ });
1484
+ }
1485
+
1486
+ const rigProvider = existing.rig?.rigProvider ?? 'meshy';
1487
+
1488
+ if (rigProvider === 'visvise') {
1489
+ const prompt = args.prompt?.trim();
1490
+ const videoUrl = args.videoUrl?.trim();
1491
+ if (!prompt && !videoUrl) {
1492
+ throw Object.assign(new Error('VISVISE apply-motion requires prompt or videoUrl'), {
1493
+ code: 'invalid_motion_input',
1494
+ });
1495
+ }
1496
+ if (prompt && videoUrl) {
1497
+ throw Object.assign(new Error('VISVISE apply-motion accepts prompt or videoUrl, not both'), {
1498
+ code: 'invalid_motion_input',
1499
+ });
1500
+ }
1501
+ const ref: MotionRef = prompt
1502
+ ? { system: 'visvise', id: `text:${prompt}`, label: args.label?.trim() || prompt }
1503
+ : { system: 'visvise', id: `video:${videoUrl}`, label: args.label?.trim() || 'video-motion' };
1504
+ if (hasMotion(existing, ref)) {
1505
+ return { ok: true, usedMock: existing.providerMode === 'mock', assetPath, manifest: existing };
1506
+ }
1507
+ const visviseEnv = getVisviseEnv();
1508
+ let files: ModelFileOut[];
1509
+ let usedMock: boolean;
1510
+ if (visviseEnv) {
1511
+ const inputModelUrl =
1512
+ visviseInputUrl(existing.visviseRefs, 'motion') ??
1513
+ (await shareAssetFileUrl(slug, assetPath, 'rigged_model', 'glb'));
1514
+ if (!inputModelUrl) {
1515
+ throw Object.assign(new Error('apply-motion needs a VISVISE rig URL (or COS to share the local GLB)'), {
1516
+ code: 'cos_not_configured',
1517
+ });
1518
+ }
1519
+ const provider = new VisviseProvider({ env: visviseEnv, slug });
1520
+ const out = prompt
1521
+ ? await provider.textMotion({ inputModelUrl, prompt })
1522
+ : await provider.videoMotion({ inputModelUrl, videoUrl });
1523
+ files = out.files;
1524
+ usedMock = false;
1525
+ } else {
1526
+ if (realProvidersEnabled()) throw providerNotConfigured('visvise', 'apply-motion');
1527
+ files = [
1528
+ { format: 'glb', data: mockModelBytes(`motion-visvise-glb:${assetPath}:${ref.id}`) },
1529
+ { format: 'fbx', data: mockModelBytes(`motion-visvise-fbx:${assetPath}:${ref.id}`) },
1530
+ ];
1531
+ usedMock = true;
1532
+ }
1533
+ const derived: DerivedFileInput[] = files.map((f) => ({
1534
+ data: f.data,
1535
+ format: f.format,
1536
+ role: 'animated_model',
1537
+ motionRef: ref,
1538
+ }));
1539
+ const manifest = await storage.appendDerivedFiles({ slug, assetPath, files: derived });
1540
+ return { ok: true, usedMock, assetPath, manifest };
1541
+ }
1542
+
1543
+ // ── Hunyuan dev path (internal): fixed motion int 9–16 via the rigged FBX. ──
1544
+ if (rigProvider === 'hunyuan_rest') {
1545
+ const motionType = asMotionType(args.motionType);
1546
+ const ref = motionRefFromLegacy(motionType);
1547
+ if (hasMotion(existing, ref)) {
1548
+ return { ok: true, usedMock: existing.providerMode === 'mock', assetPath, manifest: existing };
1549
+ }
1550
+ const env = getHunyuanEnv();
1551
+ let files: ModelFileOut[];
1552
+ let usedMock: boolean;
1553
+ if (env) {
1554
+ const fbxUrl = await shareAssetFileUrl(slug, assetPath, 'rigged_model', 'fbx');
1555
+ if (!fbxUrl) {
1556
+ throw Object.assign(new Error('apply-motion needs COS configured to share the rigged FBX URL'), {
1557
+ code: 'cos_not_configured',
1558
+ });
1559
+ }
1560
+ files = (await new HunyuanRestProvider({ env, slug }).applyMotion({ fbxUrl, motionType })).files;
1561
+ usedMock = false;
1562
+ } else {
1563
+ if (realProvidersEnabled()) throw providerNotConfigured('hunyuan_rest', 'apply-motion');
1564
+ files = [
1565
+ { format: 'glb', data: mockModelBytes(`motion-${motionType}-glb:${assetPath}`) },
1566
+ { format: 'fbx', data: mockModelBytes(`motion-${motionType}-fbx:${assetPath}`) },
1567
+ ];
1568
+ usedMock = true;
1569
+ }
1570
+ const derived: DerivedFileInput[] = files.map((f) => ({
1571
+ data: f.data,
1572
+ format: f.format,
1573
+ role: 'animated_model',
1574
+ motionRef: ref,
1575
+ }));
1576
+ const manifest = await storage.appendDerivedFiles({ slug, assetPath, files: derived });
1577
+ return { ok: true, usedMock, assetPath, manifest };
1578
+ }
1579
+
1580
+ // ── Meshy public path (default): actionId via Meshy's own rig_task_id. ──
1581
+ const actionId = asActionId(args.actionId);
1582
+ const ref: MotionRef = { system: 'meshy', id: actionId, label: args.label?.trim() || `动作 ${actionId}` };
1583
+ if (hasMotion(existing, ref)) {
1584
+ return { ok: true, usedMock: existing.providerMode === 'mock', assetPath, manifest: existing };
1585
+ }
1586
+
1587
+ const env = getMeshyEnv();
1588
+ if (!env) {
1589
+ if (realProvidersEnabled()) throw providerNotConfigured('meshy', 'apply-motion');
1590
+ // Mock: no key → deterministic placeholder clip, zero quota.
1591
+ const files: DerivedFileInput[] = [
1592
+ { data: mockModelBytes(`motion-meshy-${actionId}-glb:${assetPath}`), format: 'glb', role: 'animated_model', motionRef: ref },
1593
+ { data: mockModelBytes(`motion-meshy-${actionId}-fbx:${assetPath}`), format: 'fbx', role: 'animated_model', motionRef: ref },
1594
+ ];
1595
+ const manifest = await storage.appendDerivedFiles({ slug, assetPath, files });
1596
+ return { ok: true, usedMock: true, assetPath, manifest };
1597
+ }
1598
+
1599
+ // Real Meshy: the animation input is the rig task id, NOT a local FBX. If that
1600
+ // task is stale, re-rig only when autoReRig is set; else report rig_expired so
1601
+ // the caller decides (PLAN §8-Q3).
1602
+ const provider = new MeshyProvider({ env, slug });
1603
+ let asset = existing;
1604
+ const willReRig = meshyRigStale(asset.rig);
1605
+ if (willReRig && !args.autoReRig) {
1606
+ throw Object.assign(
1607
+ new Error('Meshy rig task expired (~3 days); re-run gen3d:auto-rig or pass autoReRig:true'),
1608
+ { code: 'rig_expired' },
1609
+ );
1610
+ }
1611
+ // Pre-check the total spend before any paid call: a re-rig adds the rig cost on
1612
+ // top of the animation (ADR-0006 / ADR-0008 D-E).
1613
+ await assertMeshyBalance(provider, (willReRig ? MESHY_RIG_COST : 0) + MESHY_ANIM_COST, 'apply-motion');
1614
+ if (willReRig) {
1615
+ asset = await meshyRigAppend(slug, asset, provider);
1616
+ }
1617
+ const rigTaskId = asset.rig?.rigTaskId;
1618
+ if (!rigTaskId) {
1619
+ throw Object.assign(new Error('asset has no Meshy rig task id; re-run gen3d:auto-rig'), {
1620
+ code: 'rig_expired',
1621
+ });
1622
+ }
1623
+ const out = await provider.animate({ rigTaskId, actionId });
1624
+ const files: DerivedFileInput[] = [{ data: out.glb, format: 'glb', role: 'animated_model', motionRef: ref }];
1625
+ if (out.fbx) files.push({ data: out.fbx, format: 'fbx', role: 'animated_model', motionRef: ref });
1626
+ const manifest = await storage.appendDerivedFiles({ slug, assetPath, files });
1627
+ return { ok: true, usedMock: false, assetPath, manifest };
1628
+ }
1629
+
1630
+ // gen3d:list-motions — two-step motion discovery (PLAN §8-Q1b). Returns a
1631
+ // filtered slice of the motion catalog for the asset's rig system (Hunyuan →
1632
+ // the v1 fixed set; Meshy → the public catalog). Zero credits (a GET);
1633
+ // quota-safe mock sample when Meshy is not configured. The AI schema never
1634
+ // enumerates the ~680 actions — callers narrow via query/category/rigType.
1635
+ interface ListMotionsArgs {
1636
+ slug?: string;
1637
+ assetPath?: string;
1638
+ query?: string;
1639
+ category?: string;
1640
+ rigType?: string;
1641
+ }
1642
+
1643
+ interface ListMotionsResult {
1644
+ ok: true;
1645
+ usedMock: boolean;
1646
+ system: MotionSystem;
1647
+ total: number;
1648
+ motions: MotionOption[];
1649
+ }
1650
+
1651
+ async function listMotions(args: ListMotionsArgs = {}): Promise<ListMotionsResult> {
1652
+ const slug = requireSlug(args.slug);
1653
+ let system: MotionSystem = 'meshy';
1654
+ const path = args.assetPath?.trim();
1655
+ if (path) {
1656
+ const asset = await storage.getAsset(slug, path);
1657
+ if (asset?.rig?.rigProvider === 'hunyuan_rest') system = 'hunyuan_v1';
1658
+ if (asset?.rig?.rigProvider === 'visvise') system = 'visvise';
1659
+ }
1660
+ const filter = { query: args.query, category: args.category, rigType: args.rigType };
1661
+ if (system === 'hunyuan_v1') {
1662
+ const motions = filterMotions(hunyuanV1Catalog(), filter);
1663
+ return { ok: true, usedMock: false, system, total: motions.length, motions };
1664
+ }
1665
+ if (system === 'visvise') {
1666
+ const motions = filterMotions(visviseCatalog(), filter);
1667
+ return { ok: true, usedMock: false, system, total: motions.length, motions };
1668
+ }
1669
+ if (realProvidersEnabled() && !getMeshyEnv()) {
1670
+ throw providerNotConfigured('meshy', 'list-motions');
1671
+ }
1672
+ const { usedMock, options } = await getMeshyCatalog(slug);
1673
+ const motions = filterMotions(options, filter);
1674
+ return { ok: true, usedMock, system: 'meshy', total: motions.length, motions };
1675
+ }
1676
+
1677
+ interface RetopoLowpolyArgs {
1678
+ slug?: string;
1679
+ assetPath: string;
1680
+ assetName?: string;
1681
+ assetSlot?: AssetSlot;
1682
+ polygonType?: 'triangle' | 'quadrilateral';
1683
+ detailLevel?: 'high' | 'medium' | 'low';
1684
+ }
1685
+
1686
+ // gen3d:retopo-lowpoly — OPTIONAL geometry/LOD side-branch (NOT a pre-rig step;
1687
+ // textures are NOT preserved). Produces a NEW derived low-poly GLB asset from a
1688
+ // high-poly source; the high-poly source is retained. cache-first + mock fallback.
1689
+ async function retopoLowpoly(args: RetopoLowpolyArgs): Promise<GenerateResult> {
1690
+ const slug = requireSlug(args.slug);
1691
+ const assetPath = args.assetPath?.trim();
1692
+ if (!assetPath) {
1693
+ throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
1694
+ }
1695
+ const source = await storage.getAsset(slug, assetPath);
1696
+ if (!source) {
1697
+ throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
1698
+ }
1699
+ const assetSlot = resolveSlot(args.assetSlot ?? source.assetSlot);
1700
+ const polygonType = args.polygonType ?? 'quadrilateral';
1701
+ const detailLevel = args.detailLevel ?? 'high';
1702
+ const sourceHash = source.files.find((f) => f.role === 'source_mesh')?.sha256 ?? assetPath;
1703
+ const cacheKey = makeCacheKey('hunyuan_rest', 'image', {
1704
+ op: 'lowpoly',
1705
+ assetSlot,
1706
+ inputHash: sourceHash,
1707
+ polygonType,
1708
+ detailLevel,
1709
+ });
1710
+ const baseName = source.assetPath
1711
+ .replace(/^assets\/3d\/[^/]+\//, '')
1712
+ .replace(/\.glb$/, '');
1713
+ const ctx: PersistInput = {
1714
+ slug,
1715
+ assetSlot,
1716
+ assetName: defaultName(args.assetName, `${baseName}-lowpoly`),
1717
+ cacheKey,
1718
+ sourceInputAssetPaths: [assetPath],
1719
+ };
1720
+
1721
+ let usedMock = false;
1722
+ const produce = async (): Promise<ProviderResult> => {
1723
+ const env = getHunyuanEnv();
1724
+ if (env) {
1725
+ const glbUrl = await shareAssetFileUrl(slug, assetPath, 'source_mesh', 'glb');
1726
+ if (!glbUrl) {
1727
+ throw Object.assign(
1728
+ new Error('retopo-lowpoly needs COS configured to share the input model URL'),
1729
+ { code: 'cos_not_configured' },
1730
+ );
1731
+ }
1732
+ const out = await new HunyuanRestProvider({ env, slug }).lowPoly({
1733
+ glbUrl,
1734
+ polygonType,
1735
+ detailLevel,
1736
+ });
1737
+ const files: ProviderResult['files'] = [
1738
+ { role: 'source_mesh', format: 'glb', data: out.glb },
1739
+ ];
1740
+ if (out.previewImage) {
1741
+ files.push({ role: 'preview_image', format: 'png', data: out.previewImage });
1742
+ }
1743
+ return {
1744
+ provider: 'hunyuan_rest',
1745
+ mode: 'image',
1746
+ providerMode: 'real',
1747
+ sourceJobId: out.sourceJobId,
1748
+ prompt: source.prompt,
1749
+ files,
1750
+ };
1751
+ }
1752
+ if (realProvidersEnabled()) throw providerNotConfigured('hunyuan_rest', 'retopo-lowpoly');
1753
+ usedMock = true;
1754
+ return {
1755
+ provider: 'hunyuan_rest',
1756
+ mode: 'image',
1757
+ providerMode: 'mock',
1758
+ sourceJobId: null,
1759
+ prompt: source.prompt,
1760
+ files: [{ role: 'source_mesh', format: 'glb', data: mockModelBytes(`lowpoly:${assetPath}`) }],
1761
+ };
1762
+ };
1763
+ const { manifest, cacheHit } = await generateCacheFirst(storage, ctx, produce);
1764
+ return { ok: true, cacheKey, cacheHit, usedMock: cacheHit ? manifest.providerMode === 'mock' : usedMock, manifest };
1765
+ }
1766
+
1767
+ // ─── Quality scoring (ADR-0004, P3) ─────────────────────────────────────────
1768
+
1769
+ type DimKey = 'geometry' | 'topology' | 'texture' | 'pbr' | 'prompt_fidelity';
1770
+
1771
+ interface ScoreQualityArgs {
1772
+ slug?: string;
1773
+ assetPath: string;
1774
+ objective?: Partial<Record<'geometry' | 'topology' | 'texture' | 'pbr', number | null>>;
1775
+ aiPass?: boolean;
1776
+ manual?: Partial<Record<DimKey, number | null>> & { notes?: string };
1777
+ }
1778
+
1779
+ interface ScoreQualityResult {
1780
+ ok: true;
1781
+ usedMock: boolean;
1782
+ manifest: Gen3DAssetManifest;
1783
+ }
1784
+
1785
+ function clampScore(v: number | null | undefined): number | null {
1786
+ if (v === null || v === undefined || !Number.isFinite(v)) return null;
1787
+ return Math.min(100, Math.max(0, Math.round(v)));
1788
+ }
1789
+
1790
+ async function scoreQuality(args: ScoreQualityArgs): Promise<ScoreQualityResult> {
1791
+ const slug = requireSlug(args.slug);
1792
+ const assetPath = args.assetPath?.trim();
1793
+ if (!assetPath) {
1794
+ throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
1795
+ }
1796
+ const existing = await storage.getAsset(slug, assetPath);
1797
+ if (!existing) {
1798
+ throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
1799
+ }
1800
+
1801
+ const report: QualityReport = emptyQualityReport();
1802
+ const setDim = (key: DimKey, value: number | null, source: QualityDim['source']) => {
1803
+ report[key] = { value: clampScore(value), source };
1804
+ };
1805
+
1806
+ let hasObjective = false;
1807
+ if (args.objective) {
1808
+ for (const key of ['geometry', 'topology', 'texture', 'pbr'] as const) {
1809
+ if (key in args.objective) {
1810
+ setDim(key, args.objective[key] ?? null, 'auto');
1811
+ hasObjective = true;
1812
+ }
1813
+ }
1814
+ }
1815
+
1816
+ let usedMock = false;
1817
+ if (args.aiPass) usedMock = true;
1818
+
1819
+ let hasManual = false;
1820
+ if (args.manual) {
1821
+ for (const key of ['geometry', 'topology', 'texture', 'pbr', 'prompt_fidelity'] as const) {
1822
+ if (key in args.manual) {
1823
+ setDim(key, args.manual[key] ?? null, 'manual');
1824
+ hasManual = true;
1825
+ }
1826
+ }
1827
+ if (typeof args.manual.notes === 'string') report.notes = args.manual.notes;
1828
+ if (hasManual) report.rater = 'local';
1829
+ }
1830
+
1831
+ report.method = hasManual && hasObjective ? 'mixed' : hasManual ? 'manual' : 'auto';
1832
+ report.total = weightedTotal([
1833
+ { value: report.geometry.value, weight: DEFAULT_WEIGHTS.geometry },
1834
+ { value: report.topology.value, weight: DEFAULT_WEIGHTS.topology },
1835
+ { value: report.texture.value, weight: DEFAULT_WEIGHTS.texture },
1836
+ { value: report.pbr.value, weight: DEFAULT_WEIGHTS.pbr },
1837
+ { value: report.prompt_fidelity.value, weight: DEFAULT_WEIGHTS.prompt_fidelity },
1838
+ ]);
1839
+ report.scoredAt = new Date().toISOString();
1840
+
1841
+ const manifest = await storage.updateAssetQuality(slug, assetPath, report);
1842
+ return { ok: true, usedMock, manifest };
1843
+ }
1844
+
1845
+ interface RenameAssetArgs {
1846
+ slug?: string;
1847
+ assetPath: string;
1848
+ label: string | null;
1849
+ }
1850
+
1851
+ async function renameAsset(args: RenameAssetArgs): Promise<{ ok: true; manifest: Gen3DAssetManifest }> {
1852
+ const slug = requireSlug(args.slug);
1853
+ const assetPath = args.assetPath?.trim();
1854
+ if (!assetPath) {
1855
+ throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
1856
+ }
1857
+ const manifest = await storage.updateAssetLabel(slug, assetPath, args.label ?? null);
1858
+ return { ok: true, manifest };
1859
+ }
1860
+
1861
+ // ─── Playable-character motion profile (R7, PLAN §4.1/§5.3) ─────────────────
1862
+ // Four-layer model: built-in preset → game default profile → character
1863
+ // override → motion mapping. Game profile is one file per game; override +
1864
+ // mapping live in the character's own gen3d sidecar (never the engine meta).
1865
+
1866
+ async function requireCharacterAsset(slug: string, assetPath: string): Promise<Gen3DAssetManifest> {
1867
+ const asset = await storage.getAsset(slug, assetPath);
1868
+ if (!asset) {
1869
+ throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
1870
+ }
1871
+ if (asset.assetSlot !== 'characters') {
1872
+ throw Object.assign(new Error('this tool only applies to character assets'), {
1873
+ code: 'not_a_character',
1874
+ });
1875
+ }
1876
+ return asset;
1877
+ }
1878
+
1879
+ interface GetPlayableProfileArgs {
1880
+ slug?: string;
1881
+ assetPath?: string;
1882
+ }
1883
+
1884
+ interface GetPlayableProfileResult {
1885
+ ok: true;
1886
+ presets: typeof BUILTIN_PROFILE_PRESETS;
1887
+ gameProfile: GameMotionProfile | null;
1888
+ effectiveSlots: MotionSlotDef[];
1889
+ override: CharacterMotionOverride | null;
1890
+ mapping: MotionMappingDraft | null;
1891
+ delivery: (PlayableDeliverySnapshot & { localUrl: string }) | null;
1892
+ oneClickReady: boolean;
1893
+ /** PROF3: game default drifted past what this character/delivery was based on. */
1894
+ migrationNeeded: boolean;
1895
+ /** ADOPT1: orphan merged.glb+meta exist and this source has no delivery yet. */
1896
+ adoptCandidate: AdoptCandidate | null;
1897
+ }
1898
+
1899
+ async function getPlayableProfile(args: GetPlayableProfileArgs): Promise<GetPlayableProfileResult> {
1900
+ const slug = requireSlug(args.slug);
1901
+ const gameProfile = await perGameStore.getGameMotionProfile(slug);
1902
+ const assetPath = args.assetPath?.trim();
1903
+
1904
+ let override: CharacterMotionOverride | null = null;
1905
+ let mapping: MotionMappingDraft | null = null;
1906
+ let delivery: PlayableDeliverySnapshot | null = null;
1907
+ let adoptCandidate: AdoptCandidate | null = null;
1908
+ if (assetPath) {
1909
+ await requireCharacterAsset(slug, assetPath);
1910
+ const state = await perGameStore.getCharacterPlayableState(slug, assetPath);
1911
+ override = state?.override ?? null;
1912
+ mapping = state?.mapping ?? null;
1913
+ delivery = state?.delivery ?? null;
1914
+ adoptCandidate = await inspectAdoptCandidate(perGameStore, slug, assetPath);
1915
+ }
1916
+
1917
+ const baseGameProfile = gameProfile ?? gameProfileFromPreset('basic-character-v1', new Date(0).toISOString());
1918
+ const slots = effectiveSlots(baseGameProfile, override);
1919
+ const profileId = override?.basedOnProfileId ?? baseGameProfile.profileId;
1920
+ const profileVersion = override?.basedOnProfileVersion ?? baseGameProfile.profileVersion;
1921
+ // PROF3: only a *persisted* game profile can drift. Virtual preset anchors
1922
+ // always report v1 and must not alone trigger migration review.
1923
+ const migrationNeeded = Boolean(
1924
+ delivery &&
1925
+ gameProfile &&
1926
+ (override
1927
+ ? override.basedOnProfileId !== gameProfile.profileId ||
1928
+ override.basedOnProfileVersion !== gameProfile.profileVersion
1929
+ : delivery.profileId !== gameProfile.profileId ||
1930
+ delivery.profileVersion !== gameProfile.profileVersion),
1931
+ );
1932
+ const currentFingerprint =
1933
+ mapping?.confirmed
1934
+ ? mappingFingerprint(slots, mapping.mappings, profileId, profileVersion)
1935
+ : null;
1936
+ const deliveryResult = delivery
1937
+ ? {
1938
+ ...delivery,
1939
+ localUrl: playableDeliveryLocalUrl(slug, delivery.modelPath),
1940
+ }
1941
+ : null;
1942
+ return {
1943
+ ok: true,
1944
+ // Cloned, not the live module singleton — callers must not be able to
1945
+ // mutate BUILTIN_PROFILE_PRESETS through the returned object. Slot arrays
1946
+ // (matchKeywords) are cloned too, not just the slot objects themselves.
1947
+ presets: BUILTIN_PROFILE_PRESETS.map((p) => ({
1948
+ ...p,
1949
+ slots: p.slots.map((s) => ({ ...s, matchKeywords: [...s.matchKeywords] })),
1950
+ })),
1951
+ gameProfile,
1952
+ effectiveSlots: slots,
1953
+ override,
1954
+ mapping,
1955
+ delivery: deliveryResult,
1956
+ oneClickReady: Boolean(
1957
+ delivery &&
1958
+ !migrationNeeded &&
1959
+ currentFingerprint &&
1960
+ delivery.mappingFingerprint === currentFingerprint,
1961
+ ),
1962
+ migrationNeeded,
1963
+ adoptCandidate,
1964
+ };
1965
+ }
1966
+
1967
+ interface SetPlayableProfileArgs {
1968
+ slug?: string;
1969
+ assetPath: string;
1970
+ slots: MotionSlotDef[];
1971
+ profileId?: string;
1972
+ displayName?: string;
1973
+ saveAsGameDefault?: boolean;
1974
+ }
1975
+
1976
+ interface SetPlayableProfileResult {
1977
+ ok: true;
1978
+ gameProfile: GameMotionProfile;
1979
+ override: CharacterMotionOverride;
1980
+ }
1981
+
1982
+ const VALID_PLAYBACK_MODES: readonly PlaybackMode[] = ['loop', 'once', 'freeze_frame'];
1983
+ const VALID_ROOT_MOTIONS: readonly RootMotionStrategy[] = ['preserve', 'remove_xz', 'remove_xyz'];
1984
+
1985
+ // The args schema declares speed/playbackMode/rootMotion constraints, but
1986
+ // nothing upstream enforces JSON-Schema constraints at runtime (no validator
1987
+ // lib in this pipeline). Mirrors the asMotionType/asActionId hand-validator
1988
+ // convention above.
1989
+ function validateMotionSlots(slots: unknown): MotionSlotDef[] {
1990
+ if (!Array.isArray(slots) || slots.length === 0) {
1991
+ throw Object.assign(new Error('slots must be a non-empty array'), { code: 'invalid_slots' });
1992
+ }
1993
+ for (const s of slots as MotionSlotDef[]) {
1994
+ if (typeof s.speed !== 'number' || !(s.speed > 0)) {
1995
+ throw Object.assign(new Error(`slot ${s.slotId}: speed must be > 0, got ${s.speed}`), {
1996
+ code: 'invalid_slots',
1997
+ });
1998
+ }
1999
+ if (!VALID_PLAYBACK_MODES.includes(s.playbackMode)) {
2000
+ throw Object.assign(new Error(`slot ${s.slotId}: invalid playbackMode ${s.playbackMode}`), {
2001
+ code: 'invalid_slots',
2002
+ });
2003
+ }
2004
+ if (!VALID_ROOT_MOTIONS.includes(s.rootMotion)) {
2005
+ throw Object.assign(new Error(`slot ${s.slotId}: invalid rootMotion ${s.rootMotion}`), {
2006
+ code: 'invalid_slots',
2007
+ });
2008
+ }
2009
+ }
2010
+ return slots as MotionSlotDef[];
2011
+ }
2012
+
2013
+ // PROF6: writing a character's slots ALWAYS records a character override (the
2014
+ // per-character source of truth). saveAsGameDefault is the opt-in extra step
2015
+ // that also bumps the shared game default profile — plain override saves never
2016
+ // touch it (PROF1: "单角色默认只改自己的覆盖"). PROF3 (migration review on
2017
+ // game-default drift) is enforced later, at export time (R9/R10) by comparing
2018
+ // basedOnProfileVersion, not here.
2019
+ async function setPlayableProfile(args: SetPlayableProfileArgs): Promise<SetPlayableProfileResult> {
2020
+ const slug = requireSlug(args.slug);
2021
+ const assetPath = args.assetPath?.trim();
2022
+ if (!assetPath) throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
2023
+ validateMotionSlots(args.slots);
2024
+ await requireCharacterAsset(slug, assetPath);
2025
+
2026
+ const now = new Date().toISOString();
2027
+
2028
+ let gameProfile: GameMotionProfile;
2029
+ if (args.saveAsGameDefault) {
2030
+ // Read-compute-write inside one lock acquisition: setGameMotionProfile's
2031
+ // updateFn sees the CURRENT stored profile atomically, so two concurrent
2032
+ // saveAsGameDefault calls can't both read the same profileVersion and
2033
+ // silently clobber each other (lost-update).
2034
+ gameProfile = await perGameStore.setGameMotionProfile(slug, (existing) => {
2035
+ const presetFallback = findPreset(args.profileId ?? existing?.profileId ?? 'basic-character-v1');
2036
+ return {
2037
+ schemaVersion: 1,
2038
+ profileId: args.profileId ?? existing?.profileId ?? 'basic-character-v1',
2039
+ // Bump only when a stored profile already existed; the very first real
2040
+ // save stays at version 1.
2041
+ profileVersion: existing ? existing.profileVersion + 1 : 1,
2042
+ displayName: args.displayName ?? existing?.displayName ?? presetFallback?.displayName ?? 'Custom',
2043
+ slots: args.slots,
2044
+ updatedAt: now,
2045
+ };
2046
+ });
2047
+ } else {
2048
+ // No saveAsGameDefault: the shared game default file must stay untouched
2049
+ // (PROF1/PROF6). If nothing has ever been saved for real, fall back to an
2050
+ // in-memory-only anchor (never persisted) purely so the override still has
2051
+ // a basedOnProfileId/Version to record. NOTE for R10/PROF3: this virtual
2052
+ // anchor always reports version 1 (same as a real first save would), so
2053
+ // migration-review must not treat basedOnProfileVersion:1 alone as proof
2054
+ // the override was based on an actually-persisted profile — compare
2055
+ // basedOnProfileId too.
2056
+ gameProfile =
2057
+ (await perGameStore.getGameMotionProfile(slug)) ??
2058
+ gameProfileFromPreset(args.profileId ?? 'basic-character-v1', now);
2059
+ }
2060
+
2061
+ const override: CharacterMotionOverride = {
2062
+ schemaVersion: 1,
2063
+ slots: args.slots,
2064
+ basedOnProfileId: gameProfile.profileId,
2065
+ basedOnProfileVersion: gameProfile.profileVersion,
2066
+ updatedAt: now,
2067
+ };
2068
+ await perGameStore.setCharacterPlayableOverride(slug, assetPath, override);
2069
+
2070
+ return { ok: true, gameProfile, override };
2071
+ }
2072
+
2073
+ interface SetPlayableMotionMappingArgs {
2074
+ slug?: string;
2075
+ assetPath: string;
2076
+ mappings: MotionMappingEntry[];
2077
+ confirmed?: boolean;
2078
+ }
2079
+
2080
+ interface SetPlayableMotionMappingResult {
2081
+ ok: true;
2082
+ mapping: MotionMappingDraft;
2083
+ }
2084
+
2085
+ async function setPlayableMotionMapping(
2086
+ args: SetPlayableMotionMappingArgs,
2087
+ ): Promise<SetPlayableMotionMappingResult> {
2088
+ const slug = requireSlug(args.slug);
2089
+ const assetPath = args.assetPath?.trim();
2090
+ if (!assetPath) throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
2091
+ if (!Array.isArray(args.mappings)) {
2092
+ throw Object.assign(new Error('mappings must be an array'), { code: 'invalid_mappings' });
2093
+ }
2094
+ await requireCharacterAsset(slug, assetPath);
2095
+
2096
+ const mapping: MotionMappingDraft = {
2097
+ schemaVersion: 1,
2098
+ mappings: args.mappings,
2099
+ confirmed: args.confirmed ?? false,
2100
+ updatedAt: new Date().toISOString(),
2101
+ };
2102
+ await perGameStore.setCharacterMotionMapping(slug, assetPath, mapping);
2103
+ return { ok: true, mapping };
2104
+ }
2105
+
2106
+ interface EngineImportArgs {
2107
+ slug?: string;
2108
+ assetPath: string;
2109
+ }
2110
+
2111
+ async function getEngineImportStatus(args: EngineImportArgs): Promise<EngineImportStatus> {
2112
+ const slug = requireSlug(args.slug);
2113
+ const assetPath = args.assetPath?.trim();
2114
+ if (!assetPath) throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
2115
+ return engineImportStatus(perGameStore, slug, assetPath);
2116
+ }
2117
+
2118
+ async function doImportToEngine(args: EngineImportArgs): Promise<EngineImportResult> {
2119
+ const slug = requireSlug(args.slug);
2120
+ const assetPath = args.assetPath?.trim();
2121
+ if (!assetPath) throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
2122
+ return importToEngine(perGameStore, slug, assetPath);
2123
+ }
2124
+
2125
+
2126
+ async function doExportPlayableCharacter(args: {
2127
+ slug?: string;
2128
+ assetPath: string;
2129
+ forceWizardConfirm?: boolean;
2130
+ }): Promise<ExportPlayableResult> {
2131
+ const slug = requireSlug(args.slug);
2132
+ const assetPath = args.assetPath?.trim();
2133
+ if (!assetPath) throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
2134
+ await requireCharacterAsset(slug, assetPath);
2135
+ return exportPlayableCharacter(perGameStore, {
2136
+ slug,
2137
+ assetPath,
2138
+ forceWizardConfirm: args.forceWizardConfirm,
2139
+ });
2140
+ }
2141
+
2142
+ export const tools = {
2143
+ 'gen3d:provider-status': async () => getProviderStatus(),
2144
+ 'gen3d:list-assets': async (args: ListAssetsArgs = {}) => listAssets(args),
2145
+ 'gen3d:delete-asset': async (args: DeleteAssetArgs) => deleteAsset(args),
2146
+ 'gen3d:generate-meshy-text-mock': async (args: GenerateMockArgs) => generateMeshyTextMock(args),
2147
+ 'gen3d:text-to-3d': async (args: TextTo3DArgs) => textTo3D(args),
2148
+ 'gen3d:image-to-3d': async (args: ImageTo3DArgs) => imageTo3D(args),
2149
+ 'gen3d:views-to-3d': async (args: ViewsTo3DArgs) => viewsTo3D(args),
2150
+ 'gen3d:refine-mesh': async (args: RefineMeshArgs) => refineMesh(args),
2151
+ 'gen3d:pose-standardization': async (args: PoseStandardizationArgs) =>
2152
+ poseStandardization(args),
2153
+ 'gen3d:upload-image': async (args: UploadImageArgs) => uploadImage(args),
2154
+ 'gen3d:upload-video': async (args: UploadVideoArgs) => uploadVideo(args),
2155
+ 'gen3d:auto-rig': async (args: AutoRigArgs) => autoRig(args),
2156
+ 'gen3d:apply-motion': async (args: ApplyMotionArgs) => applyMotion(args),
2157
+ 'gen3d:list-motions': async (args: ListMotionsArgs = {}) => listMotions(args),
2158
+ 'gen3d:retopo-lowpoly': async (args: RetopoLowpolyArgs) => retopoLowpoly(args),
2159
+ 'gen3d:score-quality': async (args: ScoreQualityArgs) => scoreQuality(args),
2160
+ 'gen3d:rename-asset': async (args: RenameAssetArgs) => renameAsset(args),
2161
+ 'gen3d:engine-import-status': async (args: EngineImportArgs) => getEngineImportStatus(args),
2162
+ 'gen3d:import-to-engine': async (args: EngineImportArgs) => doImportToEngine(args),
2163
+ 'gen3d:get-playable-profile': async (args: GetPlayableProfileArgs = {}) => getPlayableProfile(args),
2164
+ 'gen3d:set-playable-profile': async (args: SetPlayableProfileArgs) => setPlayableProfile(args),
2165
+ 'gen3d:set-playable-motion-mapping': async (args: SetPlayableMotionMappingArgs) =>
2166
+ setPlayableMotionMapping(args),
2167
+ 'gen3d:export-playable-character': async (args: {
2168
+ slug?: string;
2169
+ assetPath: string;
2170
+ forceWizardConfirm?: boolean;
2171
+ }) => doExportPlayableCharacter(args),
2172
+ 'gen3d:adopt-playable-character': async (args: {
2173
+ slug?: string;
2174
+ assetPath: string;
2175
+ slotMappings: AdoptSlotMapping[];
2176
+ confirmed?: boolean;
2177
+ }): Promise<AdoptPlayableResult> => {
2178
+ const slug = requireSlug(args.slug);
2179
+ const assetPath = args.assetPath?.trim();
2180
+ if (!assetPath) throw Object.assign(new Error('assetPath is required'), { code: 'invalid_asset_path' });
2181
+ await requireCharacterAsset(slug, assetPath);
2182
+ return adoptPlayableCharacter(perGameStore, {
2183
+ slug,
2184
+ assetPath,
2185
+ slotMappings: args.slotMappings ?? [],
2186
+ confirmed: args.confirmed,
2187
+ });
2188
+ },
2189
+ 'gen3d:get-credentials': async () => readCredentials(),
2190
+ 'gen3d:set-credentials': async (args: Record<string, unknown> = {}) => writeCredentials(args),
2191
+ };
2192
+
2193
+ export default tools;