@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,193 @@
|
|
|
1
|
+
// Rodin via LiteLLM GatewayClient (`rodin-3d`). Do not import rodin.ts
|
|
2
|
+
// (Hyper3D multipart) and do not read RODIN_API_KEY.
|
|
3
|
+
//
|
|
4
|
+
// C1 2026-08-14: text requires `prompt`; image requires `image_url(s)`.
|
|
5
|
+
// Multi-view is the same image model with 2–5 URLs (official 1–5).
|
|
6
|
+
|
|
7
|
+
import type { ProviderResult, ProviderResultFile } from '../../shared/catalog';
|
|
8
|
+
import type { FileFormat, FileRole, GenerationMode } from '../../shared/manifest';
|
|
9
|
+
import type { RodinEnv } from '../env';
|
|
10
|
+
import { audit } from '../audit';
|
|
11
|
+
import { RateGuard } from '../rate-guard';
|
|
12
|
+
import {
|
|
13
|
+
explainRodinGatewayError,
|
|
14
|
+
fitsRodinAspect,
|
|
15
|
+
readImageSize,
|
|
16
|
+
rodinAspectRejectMessage,
|
|
17
|
+
} from '../../shared/rodin-image';
|
|
18
|
+
import { extractGatewayUrls } from './gateway-data';
|
|
19
|
+
import { GatewayClient, type FetchLike } from './gateway-client';
|
|
20
|
+
import { RODIN_GATEWAY_MODELS } from './gateway-models';
|
|
21
|
+
|
|
22
|
+
const GATEWAY_SUBMIT = '/v1/3d/generations';
|
|
23
|
+
const GATEWAY_POLL = '/v1/3d/tasks';
|
|
24
|
+
|
|
25
|
+
export type RodinGatewayMode = 'text' | 'image';
|
|
26
|
+
|
|
27
|
+
export interface RodinGatewayGenerateInput {
|
|
28
|
+
mode: RodinGatewayMode | 'views';
|
|
29
|
+
prompt?: string;
|
|
30
|
+
imageUrl?: string;
|
|
31
|
+
imageUrls?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DownloadLike = (url: string) => Promise<Uint8Array>;
|
|
35
|
+
|
|
36
|
+
export interface RodinGatewayDeps {
|
|
37
|
+
env: RodinEnv;
|
|
38
|
+
slug: string;
|
|
39
|
+
fetchImpl?: FetchLike;
|
|
40
|
+
downloadImpl?: DownloadLike;
|
|
41
|
+
rateGuard?: RateGuard;
|
|
42
|
+
sleep?: (ms: number) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DATA_TYPE_TO_FILE: Record<string, { role: FileRole; format: FileFormat }> = {
|
|
46
|
+
glb: { role: 'source_mesh', format: 'glb' },
|
|
47
|
+
fbx: { role: 'source_mesh', format: 'fbx' },
|
|
48
|
+
obj: { role: 'source_mesh', format: 'obj' },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function buildRodinGatewayPayload(input: RodinGatewayGenerateInput): Record<string, unknown> {
|
|
52
|
+
const payload: Record<string, unknown> = { model: RODIN_GATEWAY_MODELS.generate };
|
|
53
|
+
if (input.mode === 'text') {
|
|
54
|
+
payload.prompt = input.prompt ?? '';
|
|
55
|
+
} else {
|
|
56
|
+
const urls = (input.imageUrls ?? []).map((u) => u.trim()).filter(Boolean).slice(0, 5);
|
|
57
|
+
if (urls.length > 1) payload.image_urls = urls;
|
|
58
|
+
else payload.image_url = input.imageUrl?.trim() || urls[0] || '';
|
|
59
|
+
const flagged = input.prompt?.trim();
|
|
60
|
+
if (flagged) payload.prompt = flagged;
|
|
61
|
+
}
|
|
62
|
+
return payload;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function rodinImageUrls(input: RodinGatewayGenerateInput): string[] {
|
|
66
|
+
const listed = (input.imageUrls ?? []).map((u) => u.trim()).filter(Boolean).slice(0, 5);
|
|
67
|
+
if (listed.length > 0) return listed;
|
|
68
|
+
const one = input.imageUrl?.trim();
|
|
69
|
+
return one ? [one] : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function assertRodinImageUrls(urls: string[], fetchImpl: FetchLike): Promise<void> {
|
|
73
|
+
await Promise.all(
|
|
74
|
+
urls.map(async (url) => {
|
|
75
|
+
const resp = await fetchImpl(url, { method: 'GET' });
|
|
76
|
+
if (!resp.ok) return;
|
|
77
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
78
|
+
const size = readImageSize(bytes);
|
|
79
|
+
if (!size) return;
|
|
80
|
+
if (!fitsRodinAspect(size.width, size.height)) {
|
|
81
|
+
throw Object.assign(new Error(rodinAspectRejectMessage(size.width, size.height)), {
|
|
82
|
+
code: 'rodin_image_aspect',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rewriteRodinError(error: unknown): never {
|
|
90
|
+
if (error && typeof error === 'object' && (error as { code?: string }).code === 'rodin_image_aspect') {
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
94
|
+
const explained = explainRodinGatewayError(message);
|
|
95
|
+
if (explained) {
|
|
96
|
+
throw Object.assign(new Error(explained), {
|
|
97
|
+
code: 'rodin_image_aspect',
|
|
98
|
+
cause: error,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class RodinGatewayProvider {
|
|
105
|
+
private readonly env: RodinEnv;
|
|
106
|
+
private readonly slug: string;
|
|
107
|
+
private readonly fetchImpl: FetchLike;
|
|
108
|
+
private readonly downloadImpl: DownloadLike;
|
|
109
|
+
private readonly rateGuard: RateGuard;
|
|
110
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
111
|
+
|
|
112
|
+
constructor(deps: RodinGatewayDeps) {
|
|
113
|
+
this.env = deps.env;
|
|
114
|
+
this.slug = deps.slug;
|
|
115
|
+
this.fetchImpl = deps.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
116
|
+
this.downloadImpl =
|
|
117
|
+
deps.downloadImpl ??
|
|
118
|
+
(async (url) => this.gateway().download(url, { format: urlFormat(url), image: isImageUrl(url) }));
|
|
119
|
+
this.rateGuard = deps.rateGuard ?? new RateGuard(this.env.rateLimitPerMin);
|
|
120
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async generate(input: RodinGatewayGenerateInput): Promise<ProviderResult> {
|
|
124
|
+
const payload = buildRodinGatewayPayload(input);
|
|
125
|
+
this.rateGuard.check();
|
|
126
|
+
try {
|
|
127
|
+
if (input.mode !== 'text') {
|
|
128
|
+
await assertRodinImageUrls(rodinImageUrls(input), this.fetchImpl);
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
rewriteRodinError(error);
|
|
132
|
+
}
|
|
133
|
+
let submitResp: Record<string, unknown>;
|
|
134
|
+
try {
|
|
135
|
+
submitResp = await this.gateway().post(GATEWAY_SUBMIT, payload);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
rewriteRodinError(error);
|
|
138
|
+
}
|
|
139
|
+
const sourceJobId = typeof submitResp.id === 'string' ? submitResp.id : null;
|
|
140
|
+
if (!sourceJobId) {
|
|
141
|
+
throw Object.assign(new Error('rodin gateway submit returned no task id'), { code: 'provider_no_task' });
|
|
142
|
+
}
|
|
143
|
+
await audit(this.slug, {
|
|
144
|
+
ts: new Date().toISOString(),
|
|
145
|
+
provider: 'rodin',
|
|
146
|
+
mode: input.mode === 'views' ? 'image' : input.mode,
|
|
147
|
+
event: 'submit',
|
|
148
|
+
sourceJobId,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const resp = await this.gateway().pollTask(`${GATEWAY_POLL}/${sourceJobId}`, {
|
|
152
|
+
timeoutMs: this.env.pollTimeoutMs,
|
|
153
|
+
intervalMs: this.env.pollIntervalMs,
|
|
154
|
+
label: 'rodin gateway',
|
|
155
|
+
});
|
|
156
|
+
const urls = extractGatewayUrls(resp);
|
|
157
|
+
const files: ProviderResultFile[] = [];
|
|
158
|
+
for (const [key, mapping] of Object.entries(DATA_TYPE_TO_FILE)) {
|
|
159
|
+
const url = urls[key];
|
|
160
|
+
if (!url) continue;
|
|
161
|
+
files.push({ role: mapping.role, format: mapping.format, data: await this.downloadImpl(url) });
|
|
162
|
+
}
|
|
163
|
+
if (urls.__thumbnail) {
|
|
164
|
+
files.push({ role: 'preview_image', format: 'png', data: await this.downloadImpl(urls.__thumbnail) });
|
|
165
|
+
}
|
|
166
|
+
if (!files.some((f) => f.role === 'source_mesh' && f.format === 'glb')) {
|
|
167
|
+
throw Object.assign(new Error('rodin gateway succeeded without a GLB mesh'), {
|
|
168
|
+
code: 'provider_no_output',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
provider: 'rodin',
|
|
173
|
+
mode: input.mode as GenerationMode,
|
|
174
|
+
providerMode: 'real',
|
|
175
|
+
sourceJobId,
|
|
176
|
+
prompt: input.prompt ?? null,
|
|
177
|
+
files,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private gateway(): GatewayClient {
|
|
182
|
+
return new GatewayClient({ env: this.env, fetchImpl: this.fetchImpl, sleep: this.sleep });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function urlFormat(url: string): string | undefined {
|
|
187
|
+
const path = url.split('?', 1)[0] ?? url;
|
|
188
|
+
return path.split('.').pop()?.toLowerCase();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isImageUrl(url: string): boolean {
|
|
192
|
+
return /\.(png|jpe?g|webp|gif)(?:\?|$)/i.test(url);
|
|
193
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// RodinProvider — real submit/poll/download client for the Hyper3D Rodin API.
|
|
2
|
+
//
|
|
3
|
+
// Decoupling (ADR-0001): like the other providers, this talks to the remote API
|
|
4
|
+
// and returns a pure ProviderResult with downloaded bytes. It knows nothing
|
|
5
|
+
// about cache, asset-store, or manifests. Auth uses a standard bearer header.
|
|
6
|
+
//
|
|
7
|
+
// Contract (developer.hyper3d.ai, verified 2026-06-11):
|
|
8
|
+
// - submit: POST {base}/api/v2/rodin multipart/form-data
|
|
9
|
+
// → { uuid, jobs: { subscription_key } }
|
|
10
|
+
// - status: POST {base}/api/v2/status { subscription_key }
|
|
11
|
+
// → { jobs: [{ status: Waiting|Generating|Done|Failed }] }
|
|
12
|
+
// - download: POST {base}/api/v2/download { task_uuid }
|
|
13
|
+
// → { list: [{ url, name }] }
|
|
14
|
+
//
|
|
15
|
+
// Real-API end-to-end VERIFIED 2026-06-11 (text-to-3D, key + Business sub):
|
|
16
|
+
// submit/poll(6 sub-jobs all Done)/download returned `base_basic_pbr.glb` +
|
|
17
|
+
// `preview.webp`, persisted real per-game with providerMode='real'. Note the
|
|
18
|
+
// preview is a .webp (kept as preview_image; FileFormat carries 'webp').
|
|
19
|
+
//
|
|
20
|
+
// Mode selection is implicit: images present → image/views (Image-to-3D),
|
|
21
|
+
// none → text (Text-to-3D). We pin tier=Regular, material=PBR,
|
|
22
|
+
// geometry_file_format=glb, and pass quality_override for poly count. Multi-view
|
|
23
|
+
// uses condition_mode=concat with several image files.
|
|
24
|
+
//
|
|
25
|
+
// Image inputs arrive as URLs (the upload/pose flow hosts local files first).
|
|
26
|
+
// Rodin needs bytes, so the provider downloads each URL before attaching it as
|
|
27
|
+
// a multipart file — this also sidesteps any provider-side URL fetch.
|
|
28
|
+
|
|
29
|
+
import type { ProviderResult, ProviderResultFile } from '../../shared/catalog';
|
|
30
|
+
import type { FileFormat, FileRole, GenerationMode } from '../../shared/manifest';
|
|
31
|
+
import type { RodinEnv } from '../env';
|
|
32
|
+
import { audit } from '../audit';
|
|
33
|
+
import { RateGuard } from '../rate-guard';
|
|
34
|
+
|
|
35
|
+
const PATH_SUBMIT = '/api/v2/rodin';
|
|
36
|
+
const PATH_STATUS = '/api/v2/status';
|
|
37
|
+
const PATH_DOWNLOAD = '/api/v2/download';
|
|
38
|
+
|
|
39
|
+
const DONE = 'Done';
|
|
40
|
+
const FAILED = 'Failed';
|
|
41
|
+
|
|
42
|
+
export type RodinMode = 'text' | 'image' | 'views';
|
|
43
|
+
|
|
44
|
+
export interface RodinGenerateInput {
|
|
45
|
+
mode: RodinMode;
|
|
46
|
+
prompt?: string;
|
|
47
|
+
imageUrl?: string;
|
|
48
|
+
imageUrls?: string[];
|
|
49
|
+
// Customize poly count (Rodin quality_override; ranges ~2000–200000).
|
|
50
|
+
qualityOverride?: number;
|
|
51
|
+
params?: Record<string, string | number | boolean>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
55
|
+
export type DownloadLike = (url: string) => Promise<Uint8Array>;
|
|
56
|
+
|
|
57
|
+
export interface RodinProviderDeps {
|
|
58
|
+
env: RodinEnv;
|
|
59
|
+
// Active game slug for the per-game audit trail (ADR-0002). Smokes may pass any.
|
|
60
|
+
slug: string;
|
|
61
|
+
fetchImpl?: FetchLike;
|
|
62
|
+
downloadImpl?: DownloadLike;
|
|
63
|
+
rateGuard?: RateGuard;
|
|
64
|
+
sleep?: (ms: number) => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Map a downloaded result file's name to a durable role+format. The Rodin
|
|
68
|
+
// download list mixes the geometry file, textures, and a preview webp.
|
|
69
|
+
function classifyFile(name: string): { role: FileRole; format: FileFormat } | null {
|
|
70
|
+
const lower = name.toLowerCase();
|
|
71
|
+
if (lower.endsWith('.glb')) return { role: 'source_mesh', format: 'glb' };
|
|
72
|
+
if (lower.endsWith('.fbx')) return { role: 'source_mesh', format: 'fbx' };
|
|
73
|
+
if (lower.endsWith('.obj')) return { role: 'source_mesh', format: 'obj' };
|
|
74
|
+
if (lower.endsWith('.usdz')) return { role: 'source_mesh', format: 'usdz' };
|
|
75
|
+
if (lower.endsWith('.stl')) return { role: 'source_mesh', format: 'stl' };
|
|
76
|
+
if (lower.endsWith('.png')) return { role: 'preview_image', format: 'png' };
|
|
77
|
+
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return { role: 'preview_image', format: 'jpg' };
|
|
78
|
+
if (lower.endsWith('.webp')) return { role: 'preview_image', format: 'webp' };
|
|
79
|
+
// Texture maps and any other variants are not part of our durable contract.
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class RodinProvider {
|
|
84
|
+
private readonly env: RodinEnv;
|
|
85
|
+
private readonly slug: string;
|
|
86
|
+
private readonly fetchImpl: FetchLike;
|
|
87
|
+
private readonly downloadImpl: DownloadLike;
|
|
88
|
+
private readonly rateGuard: RateGuard;
|
|
89
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
90
|
+
|
|
91
|
+
constructor(deps: RodinProviderDeps) {
|
|
92
|
+
this.env = deps.env;
|
|
93
|
+
this.slug = deps.slug;
|
|
94
|
+
this.fetchImpl = deps.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
95
|
+
this.downloadImpl =
|
|
96
|
+
deps.downloadImpl ?? (async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer()));
|
|
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
|
+
async generate(input: RodinGenerateInput): Promise<ProviderResult> {
|
|
102
|
+
const form = await this.buildForm(input);
|
|
103
|
+
|
|
104
|
+
// Guard BEFORE the quotaed submit.
|
|
105
|
+
this.rateGuard.check();
|
|
106
|
+
|
|
107
|
+
const submit = await this.postForm(PATH_SUBMIT, form);
|
|
108
|
+
const uuid = submit.uuid as string | undefined;
|
|
109
|
+
const jobs = submit.jobs as { subscription_key?: string } | undefined;
|
|
110
|
+
const subscriptionKey = jobs?.subscription_key;
|
|
111
|
+
if (!uuid || !subscriptionKey) {
|
|
112
|
+
throw Object.assign(new Error('rodin submit returned no task uuid/subscription_key'), {
|
|
113
|
+
code: 'provider_no_task',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
await audit(this.slug, {
|
|
117
|
+
ts: new Date().toISOString(),
|
|
118
|
+
provider: 'rodin',
|
|
119
|
+
mode: input.mode,
|
|
120
|
+
event: 'submit',
|
|
121
|
+
sourceJobId: uuid,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await this.poll(input.mode, uuid, subscriptionKey);
|
|
125
|
+
|
|
126
|
+
const list = await this.download(uuid);
|
|
127
|
+
const files: ProviderResultFile[] = [];
|
|
128
|
+
let haveMesh = false;
|
|
129
|
+
let havePreview = false;
|
|
130
|
+
for (const item of list) {
|
|
131
|
+
const cls = classifyFile(item.name);
|
|
132
|
+
if (!cls) continue;
|
|
133
|
+
// Keep one mesh + one preview; ignore extra texture/format variants.
|
|
134
|
+
if (cls.role === 'source_mesh') {
|
|
135
|
+
if (haveMesh || cls.format !== 'glb') continue;
|
|
136
|
+
haveMesh = true;
|
|
137
|
+
} else if (cls.role === 'preview_image') {
|
|
138
|
+
if (havePreview) continue;
|
|
139
|
+
havePreview = true;
|
|
140
|
+
}
|
|
141
|
+
files.push({ role: cls.role, format: cls.format, data: await this.downloadImpl(item.url) });
|
|
142
|
+
}
|
|
143
|
+
if (!haveMesh) {
|
|
144
|
+
throw Object.assign(new Error('rodin download returned no GLB mesh'), {
|
|
145
|
+
code: 'provider_no_output',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
provider: 'rodin',
|
|
151
|
+
mode: input.mode as GenerationMode,
|
|
152
|
+
providerMode: 'real',
|
|
153
|
+
sourceJobId: uuid,
|
|
154
|
+
prompt: input.prompt ?? null,
|
|
155
|
+
files,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async buildForm(input: RodinGenerateInput): Promise<FormData> {
|
|
160
|
+
const form = new FormData();
|
|
161
|
+
form.append('tier', 'Regular');
|
|
162
|
+
form.append('material', 'PBR');
|
|
163
|
+
form.append('geometry_file_format', 'glb');
|
|
164
|
+
if (input.qualityOverride !== undefined) {
|
|
165
|
+
form.append('quality_override', String(Math.round(input.qualityOverride)));
|
|
166
|
+
}
|
|
167
|
+
if (input.prompt && input.prompt.trim()) form.append('prompt', input.prompt.trim());
|
|
168
|
+
|
|
169
|
+
const urls =
|
|
170
|
+
input.mode === 'image'
|
|
171
|
+
? input.imageUrl
|
|
172
|
+
? [input.imageUrl]
|
|
173
|
+
: []
|
|
174
|
+
: input.mode === 'views'
|
|
175
|
+
? input.imageUrls ?? []
|
|
176
|
+
: [];
|
|
177
|
+
if (urls.length > 1) form.append('condition_mode', 'concat');
|
|
178
|
+
let i = 0;
|
|
179
|
+
for (const url of urls) {
|
|
180
|
+
const bytes = await this.downloadImpl(url);
|
|
181
|
+
// Bun/Hono FormData accepts a Blob with a filename for multipart files.
|
|
182
|
+
const blob = new Blob([bytes as BlobPart]);
|
|
183
|
+
form.append('images', blob, `input-${i}.png`);
|
|
184
|
+
i += 1;
|
|
185
|
+
}
|
|
186
|
+
if (input.mode === 'text' && (!input.prompt || !input.prompt.trim())) {
|
|
187
|
+
throw Object.assign(new Error('rodin text mode requires a prompt'), { code: 'invalid_prompt' });
|
|
188
|
+
}
|
|
189
|
+
if (input.mode !== 'text' && urls.length === 0) {
|
|
190
|
+
throw Object.assign(new Error('rodin image/views mode requires at least one image'), {
|
|
191
|
+
code: 'invalid_image_url',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (input.params) {
|
|
195
|
+
for (const [key, value] of Object.entries(input.params)) {
|
|
196
|
+
form.set(key, String(value));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return form;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async poll(mode: RodinMode, uuid: string, subscriptionKey: string): Promise<void> {
|
|
203
|
+
const deadline = Date.now() + this.env.pollTimeoutMs;
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
const resp = await this.postJson(PATH_STATUS, { subscription_key: subscriptionKey });
|
|
206
|
+
const jobs = Array.isArray(resp.jobs) ? (resp.jobs as Array<{ status?: string }>) : [];
|
|
207
|
+
const statuses = jobs.map((j) => String(j.status ?? ''));
|
|
208
|
+
if (statuses.length > 0 && statuses.every((s) => s === DONE || s === FAILED)) {
|
|
209
|
+
if (statuses.some((s) => s === FAILED)) {
|
|
210
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'rodin', mode, event: 'poll_failed', sourceJobId: uuid, detail: 'Failed' });
|
|
211
|
+
throw Object.assign(new Error('rodin task failed'), { code: 'provider_failed' });
|
|
212
|
+
}
|
|
213
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'rodin', mode, event: 'poll_succeeded', sourceJobId: uuid, detail: DONE });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await this.sleep(this.env.pollIntervalMs);
|
|
217
|
+
}
|
|
218
|
+
await audit(this.slug, { ts: new Date().toISOString(), provider: 'rodin', mode, event: 'poll_timeout', sourceJobId: uuid });
|
|
219
|
+
throw Object.assign(new Error('rodin poll timed out'), { code: 'provider_timeout' });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async download(uuid: string): Promise<Array<{ url: string; name: string }>> {
|
|
223
|
+
const resp = await this.postJson(PATH_DOWNLOAD, { task_uuid: uuid });
|
|
224
|
+
const list = Array.isArray(resp.list) ? (resp.list as Array<Record<string, unknown>>) : [];
|
|
225
|
+
return list
|
|
226
|
+
.map((e) => ({ url: String(e.url ?? ''), name: String(e.name ?? '') }))
|
|
227
|
+
.filter((e) => e.url && e.name);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async postForm(path: string, form: FormData): Promise<Record<string, unknown>> {
|
|
231
|
+
const resp = await this.fetchImpl(`${this.env.baseUrl}${path}`, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: { Authorization: `Bearer ${this.env.apiKey}` },
|
|
234
|
+
body: form,
|
|
235
|
+
});
|
|
236
|
+
if (!resp.ok) {
|
|
237
|
+
throw Object.assign(new Error(`rodin http ${resp.status}`), { code: 'provider_http_error' });
|
|
238
|
+
}
|
|
239
|
+
return (await resp.json()) as Record<string, unknown>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async postJson(path: string, body: unknown): Promise<Record<string, unknown>> {
|
|
243
|
+
const resp = await this.fetchImpl(`${this.env.baseUrl}${path}`, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.env.apiKey}` },
|
|
246
|
+
body: JSON.stringify(body),
|
|
247
|
+
});
|
|
248
|
+
if (!resp.ok) {
|
|
249
|
+
throw Object.assign(new Error(`rodin http ${resp.status}`), { code: 'provider_http_error' });
|
|
250
|
+
}
|
|
251
|
+
return (await resp.json()) as Record<string, unknown>;
|
|
252
|
+
}
|
|
253
|
+
}
|