@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,317 @@
|
|
|
1
|
+
// HunyuanRestProvider — real client for Hunyuan sub-capabilities via LiteLLM gateway.
|
|
2
|
+
//
|
|
3
|
+
// Unlike the workflow provider (async submit/poll), most REST sub-capabilities are
|
|
4
|
+
// SYNCHRONOUS on the gateway: a single POST /v1/3d/generations with the appropriate
|
|
5
|
+
// `model` returns immediately with `status=succeeded` and `data[]` filled.
|
|
6
|
+
// low_poly remains async (submit then poll). Auth uses a standard bearer header.
|
|
7
|
+
//
|
|
8
|
+
// Decoupling (ADR-0001): talks to the remote gateway and returns pure results with
|
|
9
|
+
// downloaded bytes. `fetchImpl`/`downloadImpl` are injectable for smokes.
|
|
10
|
+
|
|
11
|
+
import type { HunyuanEnv } from '../env';
|
|
12
|
+
import { audit } from '../audit';
|
|
13
|
+
import { RateGuard } from '../rate-guard';
|
|
14
|
+
import { extractGatewayUrls } from './gateway-data';
|
|
15
|
+
import {
|
|
16
|
+
GatewayClient,
|
|
17
|
+
gatewayErrorMessage,
|
|
18
|
+
normalizeGatewayStatus,
|
|
19
|
+
type FetchLike,
|
|
20
|
+
} from './gateway-client';
|
|
21
|
+
import { HUNYUAN_GATEWAY_MODELS } from './gateway-models';
|
|
22
|
+
|
|
23
|
+
const GATEWAY_SUBMIT = '/v1/3d/generations';
|
|
24
|
+
const GATEWAY_POLL = '/v1/3d/tasks';
|
|
25
|
+
|
|
26
|
+
export type DownloadLike = (url: string) => Promise<Uint8Array>;
|
|
27
|
+
|
|
28
|
+
export interface HunyuanRestDeps {
|
|
29
|
+
env: HunyuanEnv;
|
|
30
|
+
slug: string;
|
|
31
|
+
fetchImpl?: FetchLike;
|
|
32
|
+
downloadImpl?: DownloadLike;
|
|
33
|
+
rateGuard?: RateGuard;
|
|
34
|
+
sleep?: (ms: number) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PoseStandardizationInput {
|
|
38
|
+
imageUrl: string;
|
|
39
|
+
footnote?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One downloaded model file from a rig/motion/low_poly response.
|
|
43
|
+
export interface ModelFileOut {
|
|
44
|
+
format: 'glb' | 'fbx';
|
|
45
|
+
data: Uint8Array;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AutoRigInput {
|
|
49
|
+
glbUrl: string;
|
|
50
|
+
footnote?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ApplyMotionInput {
|
|
54
|
+
fbxUrl: string;
|
|
55
|
+
motionType: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LowPolyInput {
|
|
59
|
+
glbUrl: string;
|
|
60
|
+
polygonType?: 'triangle' | 'quadrilateral';
|
|
61
|
+
detailLevel?: 'high' | 'medium' | 'low';
|
|
62
|
+
footnote?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RigMotionResult {
|
|
66
|
+
sourceJobId: string | null;
|
|
67
|
+
files: ModelFileOut[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LowPolyResult {
|
|
71
|
+
sourceJobId: string | null;
|
|
72
|
+
glb: Uint8Array;
|
|
73
|
+
previewImage: Uint8Array | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PoseStandardizationResult {
|
|
77
|
+
sourceJobId: string | null;
|
|
78
|
+
imageData: Uint8Array;
|
|
79
|
+
sourceUrl: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class HunyuanRestProvider {
|
|
83
|
+
private readonly env: HunyuanEnv;
|
|
84
|
+
private readonly slug: string;
|
|
85
|
+
private readonly fetchImpl: FetchLike;
|
|
86
|
+
private readonly downloadImpl: DownloadLike;
|
|
87
|
+
private readonly rateGuard: RateGuard;
|
|
88
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
89
|
+
|
|
90
|
+
constructor(deps: HunyuanRestDeps) {
|
|
91
|
+
this.env = deps.env;
|
|
92
|
+
this.slug = deps.slug;
|
|
93
|
+
this.fetchImpl = deps.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
94
|
+
this.downloadImpl =
|
|
95
|
+
deps.downloadImpl ??
|
|
96
|
+
(async (url) => this.gateway().download(url, { format: urlFormat(url), image: isImageUrl(url) }));
|
|
97
|
+
this.rateGuard = deps.rateGuard ?? new RateGuard(this.env.rateLimitPerMin);
|
|
98
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// pose_standardization: gateway may return sync data[] or an async task.
|
|
102
|
+
async poseStandardization(input: PoseStandardizationInput): Promise<PoseStandardizationResult> {
|
|
103
|
+
const payload: Record<string, unknown> = {
|
|
104
|
+
model: HUNYUAN_GATEWAY_MODELS.pose,
|
|
105
|
+
image_url: input.imageUrl,
|
|
106
|
+
};
|
|
107
|
+
if (input.footnote) payload.footnote = input.footnote;
|
|
108
|
+
|
|
109
|
+
this.rateGuard.check();
|
|
110
|
+
const submitted = await this.post(payload);
|
|
111
|
+
const resp = await this.resolveSyncOrPoll(submitted, 'pose_standardization');
|
|
112
|
+
const sourceJobId = (resp.id as string) ?? (submitted.id as string) ?? null;
|
|
113
|
+
|
|
114
|
+
if (isGatewayFailed(resp)) {
|
|
115
|
+
const msg = gatewayErrorMessage(resp) ?? 'unknown';
|
|
116
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_rest', mode: 'image', event: 'rest_failed', sourceJobId, detail: msg });
|
|
117
|
+
throw Object.assign(new Error(`hunyuan pose_standardization failed: ${msg}`), { code: 'provider_failed' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const url = extractSingleImageUrl(resp);
|
|
121
|
+
if (!url) {
|
|
122
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_rest', mode: 'image', event: 'rest_no_output', sourceJobId });
|
|
123
|
+
throw Object.assign(new Error('hunyuan pose_standardization returned no image url'), { code: 'provider_no_output' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const imageData = await this.downloadImpl(url);
|
|
127
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_rest', mode: 'image', event: 'rest_succeeded', sourceJobId });
|
|
128
|
+
return { sourceJobId, imageData, sourceUrl: url };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// auto_rigging: textured high-poly GLB → rigged GLB+FBX (sync or async).
|
|
132
|
+
async autoRig(input: AutoRigInput): Promise<RigMotionResult> {
|
|
133
|
+
const payload: Record<string, unknown> = { model: HUNYUAN_GATEWAY_MODELS.autoRig, glb_url: input.glbUrl, n: 1 };
|
|
134
|
+
if (input.footnote) payload.footnote = input.footnote;
|
|
135
|
+
|
|
136
|
+
this.rateGuard.check();
|
|
137
|
+
const resp = await this.post(payload);
|
|
138
|
+
return this.collectModelResult(resp, 'auto_rigging');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// motion_retarget v1: rigged FBX + motion → animated GLB+FBX (sync or async).
|
|
142
|
+
async applyMotion(input: ApplyMotionInput): Promise<RigMotionResult> {
|
|
143
|
+
const payload: Record<string, unknown> = { model: HUNYUAN_GATEWAY_MODELS.motion, fbx_url: input.fbxUrl, motion_type: input.motionType, n: 1 };
|
|
144
|
+
|
|
145
|
+
this.rateGuard.check();
|
|
146
|
+
const resp = await this.post(payload);
|
|
147
|
+
return this.collectModelResult(resp, 'motion_retarget');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// low_poly (async via gateway): submit → poll until succeeded/failed.
|
|
151
|
+
async lowPoly(input: LowPolyInput): Promise<LowPolyResult> {
|
|
152
|
+
const payload: Record<string, unknown> = {
|
|
153
|
+
model: HUNYUAN_GATEWAY_MODELS.lowPoly,
|
|
154
|
+
glb_url: input.glbUrl,
|
|
155
|
+
polygon_type: input.polygonType ?? 'quadrilateral',
|
|
156
|
+
detail_level: input.detailLevel ?? 'high',
|
|
157
|
+
n: 1,
|
|
158
|
+
};
|
|
159
|
+
if (input.footnote) payload.footnote = input.footnote;
|
|
160
|
+
|
|
161
|
+
this.rateGuard.check();
|
|
162
|
+
const submit = await this.post(payload);
|
|
163
|
+
// Sync completion is rare but accepted; otherwise require a task id to poll.
|
|
164
|
+
if (normalizeGatewayStatus(submit.status) === 'succeeded' || extractGatewayUrls(submit).glb) {
|
|
165
|
+
const urls = extractGatewayUrls(submit);
|
|
166
|
+
const glbUrl = urls.glb;
|
|
167
|
+
if (!glbUrl) {
|
|
168
|
+
throw Object.assign(new Error('hunyuan low_poly returned no glb'), { code: 'provider_no_output' });
|
|
169
|
+
}
|
|
170
|
+
const taskId = (submit.id as string) ?? null;
|
|
171
|
+
const glb = await this.downloadImpl(glbUrl);
|
|
172
|
+
const previewImage = urls.__thumbnail ? await this.downloadImpl(urls.__thumbnail) : null;
|
|
173
|
+
return { sourceJobId: taskId, glb, previewImage };
|
|
174
|
+
}
|
|
175
|
+
const taskId = typeof submit.id === 'string' && submit.id ? submit.id : null;
|
|
176
|
+
if (!taskId) {
|
|
177
|
+
throw Object.assign(new Error('hunyuan low_poly submit returned no task id'), { code: 'provider_no_task' });
|
|
178
|
+
}
|
|
179
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_rest', mode: 'image', event: 'submit', sourceJobId: taskId, detail: 'low_poly' });
|
|
180
|
+
|
|
181
|
+
const urls = await this.pollLowPoly(taskId);
|
|
182
|
+
const glbUrl = urls.glb;
|
|
183
|
+
if (!glbUrl) {
|
|
184
|
+
throw Object.assign(new Error('hunyuan low_poly returned no glb'), { code: 'provider_no_output' });
|
|
185
|
+
}
|
|
186
|
+
const glb = await this.downloadImpl(glbUrl);
|
|
187
|
+
const previewImage = urls.__thumbnail ? await this.downloadImpl(urls.__thumbnail) : null;
|
|
188
|
+
return { sourceJobId: taskId, glb, previewImage };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Poll the low_poly task via the gateway task endpoint.
|
|
192
|
+
private async pollLowPoly(taskId: string): Promise<Record<string, string>> {
|
|
193
|
+
try {
|
|
194
|
+
const resp = await this.gateway().pollTask(`${GATEWAY_POLL}/${taskId}`, {
|
|
195
|
+
timeoutMs: this.env.pollTimeoutMs,
|
|
196
|
+
intervalMs: this.env.pollIntervalMs,
|
|
197
|
+
label: 'hunyuan low_poly',
|
|
198
|
+
});
|
|
199
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_rest', mode: 'image', event: 'poll_succeeded', sourceJobId: taskId, detail: 'low_poly' });
|
|
200
|
+
return extractGatewayUrls(resp);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_rest', mode: 'image', event: auditPollEvent(error), sourceJobId: taskId, detail: `low_poly:${error instanceof Error ? error.message : ''}` });
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// If the first response is still pending, poll; otherwise return as-is.
|
|
208
|
+
private async resolveSyncOrPoll(
|
|
209
|
+
resp: Record<string, unknown>,
|
|
210
|
+
label: string,
|
|
211
|
+
): Promise<Record<string, unknown>> {
|
|
212
|
+
if (isGatewayFailed(resp)) return resp;
|
|
213
|
+
if (normalizeGatewayStatus(resp.status) === 'succeeded') return resp;
|
|
214
|
+
// Some sync responses omit status but already include data[] / *_url.
|
|
215
|
+
if (hasImmediateOutput(resp)) return resp;
|
|
216
|
+
const taskId = typeof resp.id === 'string' && resp.id ? resp.id : null;
|
|
217
|
+
if (!taskId) {
|
|
218
|
+
throw Object.assign(new Error(`hunyuan ${label} returned no task id and no output`), {
|
|
219
|
+
code: 'provider_no_task',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return this.gateway().pollTask(`${GATEWAY_POLL}/${taskId}`, {
|
|
223
|
+
timeoutMs: this.env.pollTimeoutMs,
|
|
224
|
+
intervalMs: this.env.pollIntervalMs,
|
|
225
|
+
label: `hunyuan ${label}`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Shared success/failure + download for sync/async endpoints (auto_rig, motion).
|
|
230
|
+
private async collectModelResult(
|
|
231
|
+
resp: Record<string, unknown>,
|
|
232
|
+
label: 'auto_rigging' | 'motion_retarget',
|
|
233
|
+
): Promise<RigMotionResult> {
|
|
234
|
+
const resolved = await this.resolveSyncOrPoll(resp, label);
|
|
235
|
+
const sourceJobId = (resolved.id as string) ?? (resp.id as string) ?? null;
|
|
236
|
+
if (isGatewayFailed(resolved)) {
|
|
237
|
+
const msg = gatewayErrorMessage(resolved) ?? 'unknown';
|
|
238
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_rest', mode: 'image', event: 'rest_failed', sourceJobId, detail: `${label}:${msg}` });
|
|
239
|
+
throw Object.assign(new Error(`hunyuan ${label} failed: ${msg}`), { code: 'provider_failed' });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const urls = extractGatewayMeshUrls(resolved);
|
|
243
|
+
const files: ModelFileOut[] = [];
|
|
244
|
+
if (urls.glb_url) files.push({ format: 'glb', data: await this.downloadImpl(urls.glb_url) });
|
|
245
|
+
if (urls.fbx_url) files.push({ format: 'fbx', data: await this.downloadImpl(urls.fbx_url) });
|
|
246
|
+
if (files.length === 0) {
|
|
247
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_rest', mode: 'image', event: 'rest_no_output', sourceJobId, detail: label });
|
|
248
|
+
throw Object.assign(new Error(`hunyuan ${label} returned no model url`), { code: 'provider_no_output' });
|
|
249
|
+
}
|
|
250
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_rest', mode: 'image', event: 'rest_succeeded', sourceJobId, detail: label });
|
|
251
|
+
return { sourceJobId, files };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async post(body: unknown): Promise<Record<string, unknown>> {
|
|
255
|
+
return this.gateway().post(GATEWAY_SUBMIT, body);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async get(path: string): Promise<Record<string, unknown>> {
|
|
259
|
+
return this.gateway().get(path);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private gateway(): GatewayClient {
|
|
263
|
+
return new GatewayClient({ env: this.env, fetchImpl: this.fetchImpl, sleep: this.sleep });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isGatewayFailed(resp: Record<string, unknown>): boolean {
|
|
268
|
+
if (normalizeGatewayStatus(resp.status) === 'failed') return true;
|
|
269
|
+
const err = resp.error as Record<string, unknown> | undefined;
|
|
270
|
+
return Boolean(err && (err.code || err.message));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function hasImmediateOutput(resp: Record<string, unknown>): boolean {
|
|
274
|
+
const mesh = extractGatewayMeshUrls(resp);
|
|
275
|
+
if (mesh.glb_url || mesh.fbx_url) return true;
|
|
276
|
+
const urls = extractGatewayUrls(resp);
|
|
277
|
+
return Boolean(urls.glb || urls.__thumbnail || extractSingleImageUrl(resp));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function auditPollEvent(error: unknown): 'poll_failed' | 'poll_timeout' {
|
|
281
|
+
return (error as { code?: unknown } | null)?.code === 'provider_timeout' ? 'poll_timeout' : 'poll_failed';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Extract a single image URL from gateway data[] (for pose_std).
|
|
285
|
+
function extractSingleImageUrl(resp: Record<string, unknown>): string | null {
|
|
286
|
+
const urls = extractGatewayUrls(resp);
|
|
287
|
+
if (urls.__thumbnail) return urls.__thumbnail;
|
|
288
|
+
const data = resp.data;
|
|
289
|
+
if (Array.isArray(data)) {
|
|
290
|
+
for (const item of data) {
|
|
291
|
+
if (!item || typeof item !== 'object') continue;
|
|
292
|
+
const obj = item as Record<string, unknown>;
|
|
293
|
+
if (typeof obj.url === 'string' && obj.url) return obj.url;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Fallback: top-level *_url keys just in case the gateway embeds them.
|
|
297
|
+
for (const [key, value] of Object.entries(resp)) {
|
|
298
|
+
if (key.endsWith('_url') && typeof value === 'string' && value) return value;
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Extract glb_url + fbx_url from gateway data[] for rig/motion results.
|
|
304
|
+
// Uses shared extractGatewayUrls (accepts type mesh | rigged_model | animated_model).
|
|
305
|
+
function extractGatewayMeshUrls(resp: Record<string, unknown>): { glb_url?: string; fbx_url?: string } {
|
|
306
|
+
const urls = extractGatewayUrls(resp);
|
|
307
|
+
return { glb_url: urls.glb, fbx_url: urls.fbx };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function urlFormat(url: string): string | undefined {
|
|
311
|
+
const path = url.split('?', 1)[0] ?? url;
|
|
312
|
+
return path.split('.').pop()?.toLowerCase();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isImageUrl(url: string): boolean {
|
|
316
|
+
return /\.(png|jpe?g|webp|gif)(?:\?|$)/i.test(url);
|
|
317
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// HunyuanWorkflowProvider — real submit/poll client via the LiteLLM gateway.
|
|
2
|
+
//
|
|
3
|
+
// Decoupling (ADR-0001): talks to the remote gateway and returns a pure
|
|
4
|
+
// ProviderResult with downloaded bytes. Auth uses a standard bearer header.
|
|
5
|
+
// Submit goes to the unified `/v1/3d/generations` endpoint with a `model` field;
|
|
6
|
+
// poll goes to `/v1/3d/tasks/{taskId}`. Status is lower-case.
|
|
7
|
+
|
|
8
|
+
import type { ProviderResult, ProviderResultFile } from '../../shared/catalog';
|
|
9
|
+
import type { FileFormat, FileRole, GenerationMode } from '../../shared/manifest';
|
|
10
|
+
import type { HunyuanEnv } from '../env';
|
|
11
|
+
import { audit } from '../audit';
|
|
12
|
+
import { RateGuard } from '../rate-guard';
|
|
13
|
+
import { extractGatewayUrls } from './gateway-data';
|
|
14
|
+
import { GatewayClient, type FetchLike } from './gateway-client';
|
|
15
|
+
import { HUNYUAN_GATEWAY_MODELS } from './gateway-models';
|
|
16
|
+
|
|
17
|
+
const GATEWAY_SUBMIT = '/v1/3d/generations';
|
|
18
|
+
const GATEWAY_POLL = '/v1/3d/tasks';
|
|
19
|
+
|
|
20
|
+
const WF_MODEL: Record<HunyuanWorkflowMode, string> = {
|
|
21
|
+
text: HUNYUAN_GATEWAY_MODELS.text,
|
|
22
|
+
image: HUNYUAN_GATEWAY_MODELS.image,
|
|
23
|
+
views: HUNYUAN_GATEWAY_MODELS.views,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type HunyuanWorkflowMode = 'text' | 'image' | 'views';
|
|
27
|
+
|
|
28
|
+
export type ViewSlot =
|
|
29
|
+
| 'front_image_url'
|
|
30
|
+
| 'back_image_url'
|
|
31
|
+
| 'left_image_url'
|
|
32
|
+
| 'right_image_url'
|
|
33
|
+
| 'top_image_url'
|
|
34
|
+
| 'bottom_image_url'
|
|
35
|
+
| 'front_left_image_url'
|
|
36
|
+
| 'front_right_image_url';
|
|
37
|
+
|
|
38
|
+
export interface HunyuanGenerateInput {
|
|
39
|
+
mode: HunyuanWorkflowMode;
|
|
40
|
+
prompt?: string;
|
|
41
|
+
imageUrl?: string;
|
|
42
|
+
views?: Partial<Record<ViewSlot, string>>;
|
|
43
|
+
enablePbr?: boolean;
|
|
44
|
+
enableFbxUrl?: boolean;
|
|
45
|
+
faceCount?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type DownloadLike = (url: string) => Promise<Uint8Array>;
|
|
49
|
+
|
|
50
|
+
export interface HunyuanProviderDeps {
|
|
51
|
+
env: HunyuanEnv;
|
|
52
|
+
slug: string;
|
|
53
|
+
fetchImpl?: FetchLike;
|
|
54
|
+
downloadImpl?: DownloadLike;
|
|
55
|
+
rateGuard?: RateGuard;
|
|
56
|
+
sleep?: (ms: number) => Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Map a gateway data[] entry format to a durable manifest file role+format.
|
|
60
|
+
const DATA_TYPE_TO_FILE: Record<string, { role: FileRole; format: FileFormat }> = {
|
|
61
|
+
glb: { role: 'source_mesh', format: 'glb' },
|
|
62
|
+
fbx: { role: 'source_mesh', format: 'fbx' },
|
|
63
|
+
obj: { role: 'source_mesh', format: 'obj' },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export class HunyuanWorkflowProvider {
|
|
67
|
+
private readonly env: HunyuanEnv;
|
|
68
|
+
private readonly slug: string;
|
|
69
|
+
private readonly fetchImpl: FetchLike;
|
|
70
|
+
private readonly downloadImpl: DownloadLike;
|
|
71
|
+
private readonly rateGuard: RateGuard;
|
|
72
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
73
|
+
|
|
74
|
+
constructor(deps: HunyuanProviderDeps) {
|
|
75
|
+
this.env = deps.env;
|
|
76
|
+
this.slug = deps.slug;
|
|
77
|
+
this.fetchImpl = deps.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
78
|
+
this.downloadImpl =
|
|
79
|
+
deps.downloadImpl ??
|
|
80
|
+
(async (url) => this.gateway().download(url, { format: urlFormat(url), image: isImageUrl(url) }));
|
|
81
|
+
this.rateGuard = deps.rateGuard ?? new RateGuard(this.env.rateLimitPerMin);
|
|
82
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async generate(input: HunyuanGenerateInput): Promise<ProviderResult> {
|
|
86
|
+
const mode = input.mode;
|
|
87
|
+
if (mode === 'views') validateHunyuanViews(input.views);
|
|
88
|
+
const model = WF_MODEL[mode];
|
|
89
|
+
const payload = buildHunyuanWorkflowPayload(model, input);
|
|
90
|
+
|
|
91
|
+
this.rateGuard.check();
|
|
92
|
+
|
|
93
|
+
const submitResp = await this.post(payload);
|
|
94
|
+
const sourceJobId = submitTaskId(submitResp);
|
|
95
|
+
if (!sourceJobId) {
|
|
96
|
+
throw Object.assign(new Error('hunyuan workflow submit returned no task id'), { code: 'provider_no_task' });
|
|
97
|
+
}
|
|
98
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_workflow', mode, event: 'submit', sourceJobId });
|
|
99
|
+
|
|
100
|
+
const urls = await this.poll(sourceJobId, mode);
|
|
101
|
+
const files: ProviderResultFile[] = [];
|
|
102
|
+
const seenRoles = new Set<string>();
|
|
103
|
+
for (const [key, mapping] of Object.entries(DATA_TYPE_TO_FILE)) {
|
|
104
|
+
const url = urls[key];
|
|
105
|
+
if (!url) continue;
|
|
106
|
+
const roleKey = `${mapping.role}:${mapping.format}`;
|
|
107
|
+
if (seenRoles.has(roleKey)) continue;
|
|
108
|
+
seenRoles.add(roleKey);
|
|
109
|
+
const data = await this.downloadImpl(url);
|
|
110
|
+
files.push({ role: mapping.role, format: mapping.format, data });
|
|
111
|
+
}
|
|
112
|
+
if (urls.__thumbnail) {
|
|
113
|
+
files.push({ role: 'preview_image', format: 'png', data: await this.downloadImpl(urls.__thumbnail) });
|
|
114
|
+
}
|
|
115
|
+
if (!files.some((f) => f.role === 'source_mesh' && f.format === 'glb')) {
|
|
116
|
+
throw Object.assign(new Error('hunyuan workflow succeeded without a GLB mesh'), {
|
|
117
|
+
code: 'provider_no_output',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
provider: 'hunyuan_workflow',
|
|
123
|
+
mode: mode as GenerationMode,
|
|
124
|
+
providerMode: 'real',
|
|
125
|
+
sourceJobId,
|
|
126
|
+
prompt: input.prompt ?? null,
|
|
127
|
+
files,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async poll(
|
|
132
|
+
taskId: string,
|
|
133
|
+
mode: HunyuanWorkflowMode,
|
|
134
|
+
): Promise<Record<string, string>> {
|
|
135
|
+
try {
|
|
136
|
+
const resp = await this.gateway().pollTask(`${GATEWAY_POLL}/${taskId}`, {
|
|
137
|
+
timeoutMs: this.env.pollTimeoutMs,
|
|
138
|
+
intervalMs: this.env.pollIntervalMs,
|
|
139
|
+
label: 'hunyuan workflow',
|
|
140
|
+
});
|
|
141
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_workflow', mode, event: 'poll_succeeded', sourceJobId: taskId, detail: 'succeeded' });
|
|
142
|
+
return extractGatewayUrls(resp);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'hunyuan_workflow', mode, event: auditPollEvent(error), sourceJobId: taskId, detail: error instanceof Error ? error.message : undefined });
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async post(body: unknown): Promise<Record<string, unknown>> {
|
|
150
|
+
return this.gateway().post(GATEWAY_SUBMIT, body);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async get(path: string): Promise<Record<string, unknown>> {
|
|
154
|
+
return this.gateway().get(path);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private gateway(): GatewayClient {
|
|
158
|
+
return new GatewayClient({ env: this.env, fetchImpl: this.fetchImpl, sleep: this.sleep });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function submitTaskId(resp: Record<string, unknown>): string | null {
|
|
163
|
+
return (resp.id as string) ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function auditPollEvent(error: unknown): 'poll_failed' | 'poll_timeout' {
|
|
167
|
+
return (error as { code?: unknown } | null)?.code === 'provider_timeout' ? 'poll_timeout' : 'poll_failed';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function urlFormat(url: string): string | undefined {
|
|
171
|
+
const path = url.split('?', 1)[0] ?? url;
|
|
172
|
+
return path.split('.').pop()?.toLowerCase();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isImageUrl(url: string): boolean {
|
|
176
|
+
return /\.(png|jpe?g|webp|gif)(?:\?|$)/i.test(url);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const VIEW_SLOTS: ViewSlot[] = [
|
|
180
|
+
'front_image_url',
|
|
181
|
+
'back_image_url',
|
|
182
|
+
'left_image_url',
|
|
183
|
+
'right_image_url',
|
|
184
|
+
'top_image_url',
|
|
185
|
+
'bottom_image_url',
|
|
186
|
+
'front_left_image_url',
|
|
187
|
+
'front_right_image_url',
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
/** Require front + at least one extra non-empty view before submit. */
|
|
191
|
+
export function validateHunyuanViews(views: Partial<Record<ViewSlot, string>> | undefined): void {
|
|
192
|
+
const filled = VIEW_SLOTS.filter((slot) => Boolean(views?.[slot]?.trim()));
|
|
193
|
+
if (!views?.front_image_url?.trim()) {
|
|
194
|
+
throw Object.assign(new Error('hunyuan views require front_image_url'), { code: 'invalid_views' });
|
|
195
|
+
}
|
|
196
|
+
if (filled.length < 2) {
|
|
197
|
+
throw Object.assign(new Error('hunyuan views require front_image_url plus at least one additional view'), {
|
|
198
|
+
code: 'invalid_views',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Pure payload builder — only non-empty view slots are included. */
|
|
204
|
+
export function buildHunyuanWorkflowPayload(
|
|
205
|
+
model: string,
|
|
206
|
+
input: HunyuanGenerateInput,
|
|
207
|
+
): Record<string, unknown> {
|
|
208
|
+
const payload: Record<string, unknown> = { model };
|
|
209
|
+
if (input.faceCount && input.faceCount > 0) payload.face_count = input.faceCount;
|
|
210
|
+
if (typeof input.enablePbr === 'boolean') payload.enable_pbr = input.enablePbr;
|
|
211
|
+
if (typeof input.enableFbxUrl === 'boolean') payload.enable_fbx_url = input.enableFbxUrl;
|
|
212
|
+
|
|
213
|
+
if (input.mode === 'text') {
|
|
214
|
+
payload.prompt = input.prompt ?? '';
|
|
215
|
+
} else if (input.mode === 'image') {
|
|
216
|
+
payload.image_url = input.imageUrl ?? '';
|
|
217
|
+
} else {
|
|
218
|
+
const views = input.views ?? {};
|
|
219
|
+
for (const slot of VIEW_SLOTS) {
|
|
220
|
+
const url = views[slot]?.trim();
|
|
221
|
+
if (url) payload[slot] = url;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return payload;
|
|
225
|
+
}
|