@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.
- package/SOURCE_PROVENANCE.md +8 -0
- package/dist/assets/index-CIx3aXo1.js +4336 -0
- package/dist/assets/index-CIx3aXo1.js.map +1 -0
- package/dist/assets/index-CmhOHLEB.css +1 -0
- package/dist/hdr/README.md +28 -0
- package/dist/hdr/presets.json +8 -0
- package/dist/index.html +13 -0
- package/forgeax-extension.json +428 -0
- package/marketplace-card.json +56 -0
- package/package.json +59 -0
- package/packages/external-asset-meta/package.json +24 -0
- package/packages/external-asset-meta/src/cook.test.ts +211 -0
- package/packages/external-asset-meta/src/cook.ts +188 -0
- package/packages/external-asset-meta/src/draco3dgltf.d.ts +11 -0
- package/packages/external-asset-meta/src/index.ts +18 -0
- package/packages/external-asset-meta/src/normalize.test.ts +36 -0
- package/packages/external-asset-meta/src/normalize.ts +101 -0
- package/packages/external-asset-meta/src/types.ts +82 -0
- package/schemas/adopt-playable-character.args.json +26 -0
- package/schemas/adopt-playable-character.returns.json +31 -0
- package/schemas/apply-motion.args.json +47 -0
- package/schemas/apply-motion.returns.json +23 -0
- package/schemas/auto-rig.args.json +35 -0
- package/schemas/auto-rig.returns.json +23 -0
- package/schemas/delete-asset.args.json +18 -0
- package/schemas/delete-asset.returns.json +18 -0
- package/schemas/engine-import-status.args.json +18 -0
- package/schemas/engine-import-status.returns.json +50 -0
- package/schemas/export-playable-character.args.json +21 -0
- package/schemas/export-playable-character.returns.json +47 -0
- package/schemas/gen3d-asset-manifest.json +281 -0
- package/schemas/generate-meshy-text-mock.args.json +42 -0
- package/schemas/generate-meshy-text-mock.returns.json +301 -0
- package/schemas/get-credentials.args.json +7 -0
- package/schemas/get-credentials.returns.json +45 -0
- package/schemas/get-playable-profile.args.json +11 -0
- package/schemas/get-playable-profile.returns.json +188 -0
- package/schemas/image-to-3d.args.json +47 -0
- package/schemas/image-to-3d.returns.json +306 -0
- package/schemas/import-to-engine.args.json +18 -0
- package/schemas/import-to-engine.returns.json +35 -0
- package/schemas/list-assets.args.json +22 -0
- package/schemas/list-assets.returns.json +296 -0
- package/schemas/list-motions.args.json +29 -0
- package/schemas/list-motions.returns.json +41 -0
- package/schemas/pose-standardization.args.json +28 -0
- package/schemas/pose-standardization.returns.json +29 -0
- package/schemas/provider-status.args.json +7 -0
- package/schemas/provider-status.returns.json +70 -0
- package/schemas/refine-mesh.args.json +33 -0
- package/schemas/refine-mesh.returns.json +306 -0
- package/schemas/rename-asset.args.json +22 -0
- package/schemas/rename-asset.returns.json +14 -0
- package/schemas/retopo-lowpoly.args.json +34 -0
- package/schemas/retopo-lowpoly.returns.json +24 -0
- package/schemas/score-quality.args.json +36 -0
- package/schemas/score-quality.returns.json +12 -0
- package/schemas/set-credentials.args.json +32 -0
- package/schemas/set-credentials.returns.json +45 -0
- package/schemas/set-playable-motion-mapping.args.json +25 -0
- package/schemas/set-playable-motion-mapping.returns.json +33 -0
- package/schemas/set-playable-profile.args.json +32 -0
- package/schemas/set-playable-profile.returns.json +67 -0
- package/schemas/text-to-3d.args.json +51 -0
- package/schemas/text-to-3d.returns.json +306 -0
- package/schemas/upload-image.args.json +19 -0
- package/schemas/upload-image.returns.json +21 -0
- package/schemas/upload-video.args.json +14 -0
- package/schemas/upload-video.returns.json +14 -0
- package/schemas/views-to-3d.args.json +58 -0
- package/schemas/views-to-3d.returns.json +306 -0
- package/server/adopt-playable-character.ts +291 -0
- package/server/asset-storage.ts +136 -0
- package/server/asset-upload.ts +123 -0
- package/server/audit.ts +58 -0
- package/server/cache.ts +75 -0
- package/server/cos-uploader.ts +141 -0
- package/server/credentials-store.ts +186 -0
- package/server/engine-import.ts +238 -0
- package/server/env.ts +287 -0
- package/server/export-playable-character.ts +364 -0
- package/server/generate.ts +89 -0
- package/server/merge-playable-character.ts +259 -0
- package/server/motion-catalog.ts +137 -0
- package/server/per-game-store.ts +1016 -0
- package/server/providers/gateway-client.ts +171 -0
- package/server/providers/gateway-data.ts +182 -0
- package/server/providers/gateway-models.ts +49 -0
- package/server/providers/hunyuan-rest.ts +317 -0
- package/server/providers/hunyuan-workflow.ts +225 -0
- package/server/providers/meshy-direct-client.ts +223 -0
- package/server/providers/meshy.ts +548 -0
- package/server/providers/rodin-gateway.ts +193 -0
- package/server/providers/rodin.ts +253 -0
- package/server/providers/visvise.ts +329 -0
- package/server/rate-guard.ts +29 -0
- package/server/tool-handlers.ts +2193 -0
- package/shared/catalog.ts +307 -0
- package/shared/manifest.ts +378 -0
- package/shared/meshy-actions.ts +690 -0
- package/shared/playable-preview-url.test.ts +14 -0
- package/shared/playable-preview-url.ts +13 -0
- package/shared/playable-profile.test.ts +127 -0
- package/shared/playable-profile.ts +171 -0
- package/shared/provider-params.test.ts +172 -0
- package/shared/provider-params.ts +389 -0
- package/shared/quality/heuristics.test.ts +84 -0
- package/shared/quality/heuristics.ts +100 -0
- package/shared/rodin-image.test.ts +51 -0
- package/shared/rodin-image.ts +125 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
// MeshyProvider — real submit/poll client for the LiteLLM 3D gateway.
|
|
2
|
+
//
|
|
3
|
+
// Decoupling (ADR-0001): like HunyuanWorkflowProvider, this talks to the remote
|
|
4
|
+
// gateway and returns a pure ProviderResult with downloaded bytes. It knows
|
|
5
|
+
// nothing about cache, asset-store, or manifests. Auth is plain `Authorization:
|
|
6
|
+
// Bearer <key>`.
|
|
7
|
+
//
|
|
8
|
+
// Differences from direct Meshy API (now routed through LiteLLM gateway):
|
|
9
|
+
// - All submits go to POST /v1/3d/generations with a `model` field.
|
|
10
|
+
// - All polls go to GET /v1/3d/tasks/{taskId}; task id is in `id`.
|
|
11
|
+
// - status is lower-case: succeeded/failed/canceled.
|
|
12
|
+
// - outputs come back as `data[]` (type+format-tagged), not model_urls dict.
|
|
13
|
+
// - text is two-stage: preview (white mesh) then refine (adds texture).
|
|
14
|
+
// - Rig/animate also go through the gateway with model=meshy-3d-auto-rigging
|
|
15
|
+
// /meshy-3d-animation (response format in data[] entries).
|
|
16
|
+
|
|
17
|
+
import { clampTargetPolycount, type ProviderResult, type ProviderResultFile } from '../../shared/catalog';
|
|
18
|
+
import type { FileFormat, FileRole, GenerationMode } from '../../shared/manifest';
|
|
19
|
+
import { MESHY_ACTIONS, MESHY_ACTION_BASE } from '../../shared/meshy-actions';
|
|
20
|
+
import type { MeshyEnv } from '../env';
|
|
21
|
+
import { audit } from '../audit';
|
|
22
|
+
import { RateGuard } from '../rate-guard';
|
|
23
|
+
import { extractGatewayUrls, extractRigUrls } from './gateway-data';
|
|
24
|
+
import { GatewayClient, type FetchLike } from './gateway-client';
|
|
25
|
+
import { DirectMeshyClient } from './meshy-direct-client';
|
|
26
|
+
import { MESHY_GATEWAY_MODELS } from './gateway-models';
|
|
27
|
+
|
|
28
|
+
const GATEWAY_SUBMIT = '/v1/3d/generations';
|
|
29
|
+
const GATEWAY_POLL = '/v1/3d/tasks';
|
|
30
|
+
|
|
31
|
+
const MODE_TO_MODEL: Record<string, string> = {
|
|
32
|
+
text: MESHY_GATEWAY_MODELS.text,
|
|
33
|
+
image: MESHY_GATEWAY_MODELS.image,
|
|
34
|
+
views: MESHY_GATEWAY_MODELS.views,
|
|
35
|
+
refine: MESHY_GATEWAY_MODELS.refine,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type MeshyMode = 'text' | 'image' | 'views' | 'refine';
|
|
39
|
+
|
|
40
|
+
export interface MeshyGenerateInput {
|
|
41
|
+
mode: MeshyMode;
|
|
42
|
+
prompt?: string;
|
|
43
|
+
imageUrl?: string;
|
|
44
|
+
imageUrls?: string[];
|
|
45
|
+
previewTaskId?: string;
|
|
46
|
+
texturePrompt?: string;
|
|
47
|
+
enablePbr?: boolean;
|
|
48
|
+
shouldTexture?: boolean;
|
|
49
|
+
targetPolycount?: number;
|
|
50
|
+
aiModel?: string;
|
|
51
|
+
params?: Record<string, string | number | boolean>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Rig / animation I/O (ADR-0006) ──────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export interface MeshyRigInput {
|
|
57
|
+
inputTaskId?: string;
|
|
58
|
+
modelUrl?: string;
|
|
59
|
+
heightMeters?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MeshyAnimateInput {
|
|
63
|
+
rigTaskId: string;
|
|
64
|
+
actionId: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MeshyBasicAnimation {
|
|
68
|
+
category: 'walking' | 'running';
|
|
69
|
+
glb: Uint8Array;
|
|
70
|
+
fbx: Uint8Array | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface MeshyRigResult {
|
|
74
|
+
sourceJobId: string;
|
|
75
|
+
rigType: string | null;
|
|
76
|
+
expiresAt: number | null;
|
|
77
|
+
glb: Uint8Array;
|
|
78
|
+
fbx: Uint8Array | null;
|
|
79
|
+
basicAnimations: MeshyBasicAnimation[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MeshyAnimateResult {
|
|
83
|
+
sourceJobId: string;
|
|
84
|
+
expiresAt: number | null;
|
|
85
|
+
glb: Uint8Array;
|
|
86
|
+
fbx: Uint8Array | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface MeshyAction {
|
|
90
|
+
id: number;
|
|
91
|
+
name: string;
|
|
92
|
+
category: string | null;
|
|
93
|
+
rigType: string | null;
|
|
94
|
+
isFree: boolean;
|
|
95
|
+
previewGifUrl: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Injectable transports so smokes can run without a real network call.
|
|
99
|
+
export type DownloadLike = (url: string) => Promise<Uint8Array>;
|
|
100
|
+
|
|
101
|
+
export interface MeshyProviderDeps {
|
|
102
|
+
env: MeshyEnv;
|
|
103
|
+
slug: string;
|
|
104
|
+
fetchImpl?: FetchLike;
|
|
105
|
+
downloadImpl?: DownloadLike;
|
|
106
|
+
rateGuard?: RateGuard;
|
|
107
|
+
sleep?: (ms: number) => Promise<void>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Map a gateway data[] entry format to a durable manifest file role+format.
|
|
111
|
+
const DATA_TYPE_TO_FILE: Record<string, { role: FileRole; format: FileFormat }> = {
|
|
112
|
+
glb: { role: 'source_mesh', format: 'glb' },
|
|
113
|
+
fbx: { role: 'source_mesh', format: 'fbx' },
|
|
114
|
+
obj: { role: 'source_mesh', format: 'obj' },
|
|
115
|
+
usdz: { role: 'source_mesh', format: 'usdz' },
|
|
116
|
+
stl: { role: 'source_mesh', format: 'stl' },
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export class MeshyProvider {
|
|
120
|
+
private readonly env: MeshyEnv;
|
|
121
|
+
private readonly slug: string;
|
|
122
|
+
private readonly fetchImpl: FetchLike;
|
|
123
|
+
private readonly downloadImpl: DownloadLike;
|
|
124
|
+
private readonly rateGuard: RateGuard;
|
|
125
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
126
|
+
// One client per provider: DirectMeshyClient remembers submit→poll paths
|
|
127
|
+
// on the instance. `new` per call used to drop that map, so rig/animate
|
|
128
|
+
// polls guessed `/image-to-3d/:id` and returned the static mesh with no
|
|
129
|
+
// Character_output.glb (`provider_no_output` in ~1s).
|
|
130
|
+
private cachedGateway: GatewayClient | null = null;
|
|
131
|
+
|
|
132
|
+
constructor(deps: MeshyProviderDeps) {
|
|
133
|
+
this.env = deps.env;
|
|
134
|
+
this.slug = deps.slug;
|
|
135
|
+
this.fetchImpl = deps.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
136
|
+
this.downloadImpl =
|
|
137
|
+
deps.downloadImpl ?? (async (url) => this.gateway().download(url, { format: urlFormat(url), image: isImageUrl(url) }));
|
|
138
|
+
this.rateGuard = deps.rateGuard ?? new RateGuard(this.env.rateLimitPerMin);
|
|
139
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async generate(input: MeshyGenerateInput): Promise<ProviderResult> {
|
|
143
|
+
const result = await this.submitAndDownload(input);
|
|
144
|
+
// Meshy text is two-stage: preview (white mesh) → refine (texture). Auto-chain
|
|
145
|
+
// when texturing is wanted (default on; enablePbr=false keeps preview-only).
|
|
146
|
+
// Persist BOTH task ids: manual refine needs preview; rig/audit use result.
|
|
147
|
+
if (input.mode === 'text' && input.enablePbr !== false) {
|
|
148
|
+
const previewTaskId = result.sourceJobId;
|
|
149
|
+
if (!previewTaskId) {
|
|
150
|
+
throw Object.assign(new Error('meshy text preview returned no task id'), { code: 'provider_no_task' });
|
|
151
|
+
}
|
|
152
|
+
const refined = await this.submitAndDownload({
|
|
153
|
+
mode: 'refine',
|
|
154
|
+
previewTaskId,
|
|
155
|
+
texturePrompt: resolveTexturePrompt(input),
|
|
156
|
+
enablePbr: input.enablePbr ?? true,
|
|
157
|
+
// Texture / lighting knobs belong on refine, not white-mesh preview.
|
|
158
|
+
params: pickRefineParams(input.params),
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
...refined,
|
|
162
|
+
mode: 'text',
|
|
163
|
+
prompt: input.prompt ?? null,
|
|
164
|
+
meshyTaskRefs: {
|
|
165
|
+
previewTaskId,
|
|
166
|
+
resultTaskId: refined.sourceJobId,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (input.mode === 'text' || input.mode === 'image' || input.mode === 'views' || input.mode === 'refine') {
|
|
171
|
+
return {
|
|
172
|
+
...result,
|
|
173
|
+
meshyTaskRefs: {
|
|
174
|
+
previewTaskId: input.mode === 'text' ? result.sourceJobId : input.mode === 'refine' ? (input.previewTaskId ?? null) : null,
|
|
175
|
+
resultTaskId: result.sourceJobId,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async submitAndDownload(input: MeshyGenerateInput): Promise<ProviderResult> {
|
|
183
|
+
const model = MODE_TO_MODEL[input.mode];
|
|
184
|
+
const payload = { model, ...this.buildPayload(input) };
|
|
185
|
+
|
|
186
|
+
this.rateGuard.check();
|
|
187
|
+
|
|
188
|
+
const submitResp = await this.post(payload);
|
|
189
|
+
const sourceJobId = submitTaskId(submitResp);
|
|
190
|
+
if (!sourceJobId) {
|
|
191
|
+
throw Object.assign(new Error('meshy submit returned no task id'), { code: 'provider_no_task' });
|
|
192
|
+
}
|
|
193
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'meshy', mode: input.mode, event: 'submit', sourceJobId });
|
|
194
|
+
|
|
195
|
+
const urls = await this.poll(input.mode, sourceJobId);
|
|
196
|
+
const files = await this.downloadFiles(urls);
|
|
197
|
+
if (!files.some((f) => f.role === 'source_mesh' && f.format === 'glb')) {
|
|
198
|
+
throw Object.assign(new Error('meshy succeeded without a GLB mesh'), { code: 'provider_no_output' });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
provider: 'meshy',
|
|
203
|
+
mode: input.mode as GenerationMode,
|
|
204
|
+
providerMode: 'real',
|
|
205
|
+
sourceJobId,
|
|
206
|
+
prompt: input.prompt ?? null,
|
|
207
|
+
files,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Auto-rig via gateway (model=meshy-3d-auto-rigging). The rig response data[]
|
|
212
|
+
// carries the rigged Character_output plus free walk/run clips (withSkin and
|
|
213
|
+
// _armature variants per format). extractRigUrls classifies by filename so
|
|
214
|
+
// Character_output.glb (full mesh+skeleton) is picked, not the last
|
|
215
|
+
// _armature.glb (skeleton-only proxy); the walk/run _withSkin clips are
|
|
216
|
+
// returned as basicAnimations. The gateway does NOT return rig_type or
|
|
217
|
+
// expires_at — those were Meshy direct API fields only.
|
|
218
|
+
async rig(input: MeshyRigInput): Promise<MeshyRigResult> {
|
|
219
|
+
if (!input.inputTaskId && !input.modelUrl) {
|
|
220
|
+
throw Object.assign(new Error('meshy rig needs inputTaskId or modelUrl'), {
|
|
221
|
+
code: 'invalid_rig_input',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const payload: Record<string, unknown> = { model: MESHY_GATEWAY_MODELS.autoRig };
|
|
225
|
+
if (input.inputTaskId) payload.input_task_id = input.inputTaskId;
|
|
226
|
+
if (input.modelUrl) payload.model_url = input.modelUrl;
|
|
227
|
+
if (input.heightMeters !== undefined) payload.height_meters = input.heightMeters;
|
|
228
|
+
|
|
229
|
+
this.rateGuard.check();
|
|
230
|
+
const submit = await this.post(payload);
|
|
231
|
+
const taskId = submitTaskId(submit);
|
|
232
|
+
if (!taskId) {
|
|
233
|
+
throw Object.assign(new Error('meshy rig submit returned no task id'), { code: 'provider_no_task' });
|
|
234
|
+
}
|
|
235
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'meshy', mode: 'image', event: 'submit', sourceJobId: taskId, detail: 'rig' });
|
|
236
|
+
|
|
237
|
+
const resp = await this.pollTask(taskId, 'rig');
|
|
238
|
+
const urls = extractRigUrls(resp);
|
|
239
|
+
if (!urls.glb) {
|
|
240
|
+
throw Object.assign(new Error('meshy rig returned no rigged glb (Character_output.glb)'), { code: 'provider_no_output' });
|
|
241
|
+
}
|
|
242
|
+
const basicAnimations: MeshyBasicAnimation[] = [];
|
|
243
|
+
if (urls.walkGlb) {
|
|
244
|
+
basicAnimations.push({
|
|
245
|
+
category: 'walking',
|
|
246
|
+
glb: await this.downloadImpl(urls.walkGlb),
|
|
247
|
+
fbx: urls.walkFbx ? await this.downloadImpl(urls.walkFbx) : null,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (urls.runGlb) {
|
|
251
|
+
basicAnimations.push({
|
|
252
|
+
category: 'running',
|
|
253
|
+
glb: await this.downloadImpl(urls.runGlb),
|
|
254
|
+
fbx: urls.runFbx ? await this.downloadImpl(urls.runFbx) : null,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
sourceJobId: taskId,
|
|
259
|
+
// Gateway does NOT return rig_type / expires_at — set null.
|
|
260
|
+
rigType: null,
|
|
261
|
+
expiresAt: null,
|
|
262
|
+
glb: await this.downloadImpl(urls.glb),
|
|
263
|
+
fbx: urls.fbx ? await this.downloadImpl(urls.fbx) : null,
|
|
264
|
+
basicAnimations,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Apply one motion via gateway (model=meshy-3d-animation).
|
|
269
|
+
// Gateway has no expires_at — treat remote "rig gone/expired/invalid" as
|
|
270
|
+
// rig_expired so callers can decide whether to re-rig (PLAN §8-Q3).
|
|
271
|
+
async animate(input: MeshyAnimateInput): Promise<MeshyAnimateResult> {
|
|
272
|
+
this.rateGuard.check();
|
|
273
|
+
try {
|
|
274
|
+
const submit = await this.post({
|
|
275
|
+
model: MESHY_GATEWAY_MODELS.animation,
|
|
276
|
+
rig_task_id: input.rigTaskId,
|
|
277
|
+
action_id: input.actionId,
|
|
278
|
+
});
|
|
279
|
+
const taskId = submitTaskId(submit);
|
|
280
|
+
if (!taskId) {
|
|
281
|
+
throw Object.assign(new Error('meshy animate submit returned no task id'), { code: 'provider_no_task' });
|
|
282
|
+
}
|
|
283
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'meshy', mode: 'image', event: 'submit', sourceJobId: taskId, detail: `animate:${input.actionId}` });
|
|
284
|
+
|
|
285
|
+
const resp = await this.pollTask(taskId, 'animate');
|
|
286
|
+
const urls = extractGatewayUrls(resp);
|
|
287
|
+
if (!urls.glb) {
|
|
288
|
+
throw Object.assign(new Error('meshy animate returned no animation glb'), { code: 'provider_no_output' });
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
sourceJobId: taskId,
|
|
292
|
+
expiresAt: null,
|
|
293
|
+
glb: await this.downloadImpl(urls.glb),
|
|
294
|
+
fbx: urls.fbx ? await this.downloadImpl(urls.fbx) : null,
|
|
295
|
+
};
|
|
296
|
+
} catch (error) {
|
|
297
|
+
throw mapAnimateRigExpiry(error);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Motion catalog — static, no network call.
|
|
302
|
+
async listActions(): Promise<MeshyAction[]> {
|
|
303
|
+
return MESHY_ACTIONS.map((row) => {
|
|
304
|
+
const [id, name, category, , previewGifRel] = row;
|
|
305
|
+
return {
|
|
306
|
+
id,
|
|
307
|
+
name,
|
|
308
|
+
category,
|
|
309
|
+
rigType: null,
|
|
310
|
+
isFree: false,
|
|
311
|
+
previewGifUrl: previewGifRel ? `${MESHY_ACTION_BASE}${previewGifRel}` : null,
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Gateway has no balance endpoint — null means "unknown", not zero credits.
|
|
317
|
+
async getBalance(): Promise<number | null> {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Flatten data[] urls into downloaded ProviderResultFiles.
|
|
322
|
+
private async downloadFiles(urls: Record<string, string>): Promise<ProviderResultFile[]> {
|
|
323
|
+
const files: ProviderResultFile[] = [];
|
|
324
|
+
const seenRoles = new Set<string>();
|
|
325
|
+
for (const [key, mapping] of Object.entries(DATA_TYPE_TO_FILE)) {
|
|
326
|
+
const url = urls[key];
|
|
327
|
+
if (!url) continue;
|
|
328
|
+
const roleKey = `${mapping.role}:${mapping.format}`;
|
|
329
|
+
if (seenRoles.has(roleKey)) continue;
|
|
330
|
+
seenRoles.add(roleKey);
|
|
331
|
+
files.push({ role: mapping.role, format: mapping.format, data: await this.downloadImpl(url) });
|
|
332
|
+
}
|
|
333
|
+
if (urls.__thumbnail) {
|
|
334
|
+
files.push({ role: 'preview_image', format: 'png', data: await this.downloadImpl(urls.__thumbnail) });
|
|
335
|
+
}
|
|
336
|
+
if (urls.__texture_base_color) {
|
|
337
|
+
files.push({ role: 'texture', format: 'png', data: await this.downloadImpl(urls.__texture_base_color) });
|
|
338
|
+
}
|
|
339
|
+
return files;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Poll for generation tasks (returns flat url map for downloadFiles).
|
|
343
|
+
private async poll(mode: MeshyMode, taskId: string): Promise<Record<string, string>> {
|
|
344
|
+
try {
|
|
345
|
+
const resp = await this.gateway().pollTask(`${GATEWAY_POLL}/${taskId}`, {
|
|
346
|
+
timeoutMs: this.env.pollTimeoutMs,
|
|
347
|
+
intervalMs: this.env.pollIntervalMs,
|
|
348
|
+
label: 'meshy task',
|
|
349
|
+
});
|
|
350
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'meshy', mode, event: 'poll_succeeded', sourceJobId: taskId, detail: 'succeeded' });
|
|
351
|
+
return extractGatewayUrls(resp);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'meshy', mode, event: auditPollEvent(error), sourceJobId: taskId, detail: error instanceof Error ? error.message : undefined });
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Poll for rig/animate tasks (returns the full response for custom extraction).
|
|
359
|
+
private async pollTask(taskId: string, label: string): Promise<Record<string, unknown>> {
|
|
360
|
+
try {
|
|
361
|
+
const resp = await this.gateway().pollTask(`${GATEWAY_POLL}/${taskId}`, {
|
|
362
|
+
timeoutMs: this.env.pollTimeoutMs,
|
|
363
|
+
intervalMs: this.env.pollIntervalMs,
|
|
364
|
+
label: `meshy ${label}`,
|
|
365
|
+
});
|
|
366
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'meshy', mode: 'image', event: 'poll_succeeded', sourceJobId: taskId, detail: label });
|
|
367
|
+
return resp;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'meshy', mode: 'image', event: auditPollEvent(error), sourceJobId: taskId, detail: `${label}:${error instanceof Error ? error.message : ''}` });
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private buildPayload(input: MeshyGenerateInput): Record<string, unknown> {
|
|
375
|
+
const polycount =
|
|
376
|
+
input.targetPolycount !== undefined ? clampTargetPolycount(input.targetPolycount) : undefined;
|
|
377
|
+
|
|
378
|
+
if (input.mode === 'text') {
|
|
379
|
+
const payload: Record<string, unknown> = { mode: 'preview', prompt: input.prompt ?? '' };
|
|
380
|
+
if (input.aiModel) payload.ai_model = input.aiModel;
|
|
381
|
+
if (polycount) {
|
|
382
|
+
payload.should_remesh = true;
|
|
383
|
+
payload.target_polycount = polycount;
|
|
384
|
+
}
|
|
385
|
+
// Preview is white-mesh only — strip refine/texture-only knobs.
|
|
386
|
+
this.applyProviderParams(payload, omitKeys(input.params, REFINE_TEXTURE_KEYS));
|
|
387
|
+
return payload;
|
|
388
|
+
}
|
|
389
|
+
if (input.mode === 'refine') {
|
|
390
|
+
const payload: Record<string, unknown> = { mode: 'refine', preview_task_id: input.previewTaskId };
|
|
391
|
+
if (input.texturePrompt) payload.texture_prompt = input.texturePrompt;
|
|
392
|
+
if (typeof input.enablePbr === 'boolean') payload.enable_pbr = input.enablePbr;
|
|
393
|
+
this.applyProviderParams(payload, pickRefineParams(input.params));
|
|
394
|
+
return payload;
|
|
395
|
+
}
|
|
396
|
+
if (input.mode === 'image') {
|
|
397
|
+
const payload: Record<string, unknown> = { image_url: input.imageUrl ?? '' };
|
|
398
|
+
this.applyMeshOptions(payload, input, polycount);
|
|
399
|
+
this.applyProviderParams(payload, input.params);
|
|
400
|
+
sanitizeSmartTopologyPayload(payload);
|
|
401
|
+
return payload;
|
|
402
|
+
}
|
|
403
|
+
// views
|
|
404
|
+
const payload: Record<string, unknown> = { image_urls: (input.imageUrls ?? []).slice(0, 4) };
|
|
405
|
+
this.applyMeshOptions(payload, input, polycount);
|
|
406
|
+
this.applyProviderParams(payload, input.params);
|
|
407
|
+
sanitizeSmartTopologyPayload(payload);
|
|
408
|
+
return payload;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private applyProviderParams(
|
|
412
|
+
payload: Record<string, unknown>,
|
|
413
|
+
params: Record<string, string | number | boolean> | undefined,
|
|
414
|
+
): void {
|
|
415
|
+
if (!params) return;
|
|
416
|
+
Object.assign(payload, params);
|
|
417
|
+
if (payload.topology !== undefined && payload.should_remesh === undefined) {
|
|
418
|
+
payload.should_remesh = true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private applyMeshOptions(
|
|
423
|
+
payload: Record<string, unknown>,
|
|
424
|
+
input: MeshyGenerateInput,
|
|
425
|
+
polycount: number | undefined,
|
|
426
|
+
): void {
|
|
427
|
+
if (input.aiModel) payload.ai_model = input.aiModel;
|
|
428
|
+
if (typeof input.enablePbr === 'boolean') payload.enable_pbr = input.enablePbr;
|
|
429
|
+
else payload.enable_pbr = true;
|
|
430
|
+
// LiteLLM gateway does not inherit Meshy API defaults — explicit should_texture
|
|
431
|
+
// avoids white-mesh-only output on image/views (character turnaround path).
|
|
432
|
+
if (typeof input.shouldTexture === 'boolean') payload.should_texture = input.shouldTexture;
|
|
433
|
+
else payload.should_texture = true;
|
|
434
|
+
if (polycount) payload.target_polycount = polycount;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private async post(body: unknown): Promise<Record<string, unknown>> {
|
|
438
|
+
return this.gateway().post(GATEWAY_SUBMIT, body);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private async get(path: string): Promise<Record<string, unknown>> {
|
|
442
|
+
return this.gateway().get(path);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private gateway(): GatewayClient {
|
|
446
|
+
if (!this.cachedGateway) {
|
|
447
|
+
this.cachedGateway =
|
|
448
|
+
this.env.transport === 'direct'
|
|
449
|
+
? new DirectMeshyClient({ env: this.env, fetchImpl: this.fetchImpl, sleep: this.sleep })
|
|
450
|
+
: new GatewayClient({ env: this.env, fetchImpl: this.fetchImpl, sleep: this.sleep });
|
|
451
|
+
}
|
|
452
|
+
return this.cachedGateway;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function submitTaskId(resp: Record<string, unknown>): string | null {
|
|
457
|
+
return (resp.id as string) ?? null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function auditPollEvent(error: unknown): 'poll_failed' | 'poll_timeout' {
|
|
461
|
+
return (error as { code?: unknown } | null)?.code === 'provider_timeout' ? 'poll_timeout' : 'poll_failed';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Map gateway animate failures that mean the rig task is gone → rig_expired. */
|
|
465
|
+
export function mapAnimateRigExpiry(error: unknown): Error {
|
|
466
|
+
if ((error as { code?: unknown } | null)?.code === 'rig_expired') return error as Error;
|
|
467
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
468
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
469
|
+
const looksExpired =
|
|
470
|
+
/expir|rig[_\s-]?task.*(invalid|not\s*found|gone|missing)|invalid.*rig|task.*(not\s*found|gone)/i.test(msg) ||
|
|
471
|
+
(code === 'provider_bad_request' && /rig/i.test(msg)) ||
|
|
472
|
+
(code === 'provider_not_enabled' && /rig/i.test(msg));
|
|
473
|
+
if (looksExpired) {
|
|
474
|
+
return Object.assign(new Error(`Meshy rig task expired or invalid; re-run gen3d:auto-rig or pass autoReRig:true (${msg})`), {
|
|
475
|
+
code: 'rig_expired',
|
|
476
|
+
cause: error,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
return error instanceof Error ? error : Object.assign(new Error(msg), { code: 'provider_failed' });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Texture / lighting fields that must not ride on text preview. */
|
|
483
|
+
const REFINE_TEXTURE_KEYS = [
|
|
484
|
+
'hd_texture',
|
|
485
|
+
'texture_prompt',
|
|
486
|
+
'texture_image_url',
|
|
487
|
+
'remove_lighting',
|
|
488
|
+
] as const;
|
|
489
|
+
|
|
490
|
+
function resolveTexturePrompt(input: MeshyGenerateInput): string | undefined {
|
|
491
|
+
const fromParams = input.params?.texture_prompt;
|
|
492
|
+
if (typeof fromParams === 'string' && fromParams.trim()) return fromParams.trim();
|
|
493
|
+
return input.prompt;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Params that belong on refine / textured stages. */
|
|
497
|
+
export function pickRefineParams(
|
|
498
|
+
params: Record<string, string | number | boolean> | undefined,
|
|
499
|
+
): Record<string, string | number | boolean> | undefined {
|
|
500
|
+
if (!params) return undefined;
|
|
501
|
+
const out: Record<string, string | number | boolean> = {};
|
|
502
|
+
if (params.hd_texture === true) out.hd_texture = true;
|
|
503
|
+
if (typeof params.texture_prompt === 'string' && params.texture_prompt.trim()) {
|
|
504
|
+
out.texture_prompt = params.texture_prompt.trim();
|
|
505
|
+
}
|
|
506
|
+
if (typeof params.texture_image_url === 'string' && params.texture_image_url.trim()) {
|
|
507
|
+
out.texture_image_url = params.texture_image_url.trim();
|
|
508
|
+
}
|
|
509
|
+
if (typeof params.remove_lighting === 'boolean') out.remove_lighting = params.remove_lighting;
|
|
510
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function omitKeys(
|
|
514
|
+
params: Record<string, string | number | boolean> | undefined,
|
|
515
|
+
keys: readonly string[],
|
|
516
|
+
): Record<string, string | number | boolean> | undefined {
|
|
517
|
+
if (!params) return undefined;
|
|
518
|
+
const drop = new Set(keys);
|
|
519
|
+
const out: Record<string, string | number | boolean> = {};
|
|
520
|
+
for (const [k, v] of Object.entries(params)) {
|
|
521
|
+
if (!drop.has(k)) out[k] = v;
|
|
522
|
+
}
|
|
523
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Smart Topology ignores remesh/topology; coerce standard Meshy-5/6 ids to t2
|
|
528
|
+
* so a mismatched advanced-param combo still hits the documented t2 path.
|
|
529
|
+
*/
|
|
530
|
+
export function sanitizeSmartTopologyPayload(payload: Record<string, unknown>): void {
|
|
531
|
+
if (payload.model_type !== 'smart-topology') return;
|
|
532
|
+
const ai = payload.ai_model;
|
|
533
|
+
if (ai === undefined || ai === 'meshy-5' || ai === 'meshy-6' || ai === 'latest') {
|
|
534
|
+
payload.ai_model = 'meshy-t2';
|
|
535
|
+
}
|
|
536
|
+
delete payload.should_remesh;
|
|
537
|
+
delete payload.topology;
|
|
538
|
+
delete payload.decimation_mode;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function urlFormat(url: string): string | undefined {
|
|
542
|
+
const path = url.split('?', 1)[0] ?? url;
|
|
543
|
+
return path.split('.').pop()?.toLowerCase();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function isImageUrl(url: string): boolean {
|
|
547
|
+
return /\.(png|jpe?g|webp|gif)(?:\?|$)/i.test(url);
|
|
548
|
+
}
|