@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,329 @@
|
|
|
1
|
+
// VISVISE via LiteLLM GatewayClient. Same 3D key as Meshy/Hunyuan.
|
|
2
|
+
//
|
|
3
|
+
// C1 2026-08-14 body clues (docs/evidence/gateway-rodin-visvise-probe-2026-08-14.json):
|
|
4
|
+
// visvise-3d / visvise-3d-low → require `algorithm_model` (no public enum; do not invent)
|
|
5
|
+
// visvise-3d-mid → `image_url` (image-only)
|
|
6
|
+
// pose/rig/skin/text-motion/video-motion → `input_model_url` must be a VISVISE COS URL
|
|
7
|
+
// no views model on catalog
|
|
8
|
+
// C7: call visvise-rigging only; do not chain visvise-skinning unless live proves needed.
|
|
9
|
+
|
|
10
|
+
import type { ProviderResult, ProviderResultFile } from '../../shared/catalog';
|
|
11
|
+
import type { FileFormat, FileRole, GenerationMode } from '../../shared/manifest';
|
|
12
|
+
import type { VisviseEnv } from '../env';
|
|
13
|
+
import { audit } from '../audit';
|
|
14
|
+
import { RateGuard } from '../rate-guard';
|
|
15
|
+
import { extractGatewayUrls } from './gateway-data';
|
|
16
|
+
import { GatewayClient, type FetchLike } from './gateway-client';
|
|
17
|
+
import { defaultVisviseAlgorithmModel } from '../../shared/provider-params';
|
|
18
|
+
import {
|
|
19
|
+
VISVISE_GATEWAY_MODELS,
|
|
20
|
+
visviseGenerateModel,
|
|
21
|
+
type VisviseQuality,
|
|
22
|
+
} from './gateway-models';
|
|
23
|
+
|
|
24
|
+
const GATEWAY_SUBMIT = '/v1/3d/generations';
|
|
25
|
+
const GATEWAY_POLL = '/v1/3d/tasks';
|
|
26
|
+
|
|
27
|
+
export type VisviseGenerateMode = 'text' | 'image';
|
|
28
|
+
|
|
29
|
+
export interface VisviseGenerateInput {
|
|
30
|
+
mode: VisviseGenerateMode | 'views';
|
|
31
|
+
prompt?: string;
|
|
32
|
+
imageUrl?: string;
|
|
33
|
+
quality?: VisviseQuality;
|
|
34
|
+
/** Only send when the caller pasted a list_algorithm_model value. Do not invent. */
|
|
35
|
+
algorithmModel?: string;
|
|
36
|
+
enableAPose?: boolean;
|
|
37
|
+
faceType?: 1 | 2;
|
|
38
|
+
faceNum?: number;
|
|
39
|
+
enablePbr?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface VisviseUrlInput {
|
|
43
|
+
inputModelUrl: string;
|
|
44
|
+
prompt?: string;
|
|
45
|
+
videoUrl?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type DownloadLike = (url: string) => Promise<Uint8Array>;
|
|
49
|
+
|
|
50
|
+
/** Map LiteLLM/Weaver 400s we have live evidence for. Unknown strings stay raw. */
|
|
51
|
+
export function explainVisviseGatewayError(message: string): string | null {
|
|
52
|
+
if (message.includes('模型url路径前缀错误')) {
|
|
53
|
+
return 'VISVISE 拒绝了这张图的地址:网关要求图片已经在该账号的 VISVISE COS(weaver/user-…)下。工作台上传的是 ForgeaX COS,过不了前缀校验。只改算法名解决不了。等网关适配器代传,或从官网贴已上传的 VISVISE COS 地址。';
|
|
54
|
+
}
|
|
55
|
+
if (message.includes('algorithm_model is required')) {
|
|
56
|
+
return '网关要求填写 algorithm_model(算法模型名)。';
|
|
57
|
+
}
|
|
58
|
+
if (message.includes('image-to-3d-mid requires')) {
|
|
59
|
+
return 'VISVISE 中档不是单图降质量:网关要正/背/左/右四张图,或先有 360 / 拆分资产。本轮工作台没有四视图入口。';
|
|
60
|
+
}
|
|
61
|
+
if (message.includes('输入视图不能为空')) {
|
|
62
|
+
return '网关没收到它能用的输入视图(官方 input_view 键会被适配器丢掉,只认 image_url)。';
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function rewriteVisviseError(error: unknown): unknown {
|
|
68
|
+
if (!(error instanceof Error)) return error;
|
|
69
|
+
const explained = explainVisviseGatewayError(error.message);
|
|
70
|
+
if (!explained) return error;
|
|
71
|
+
const extra = error as Error & { code?: string; status?: number; httpStatus?: number; body?: string };
|
|
72
|
+
return Object.assign(new Error(explained), {
|
|
73
|
+
code: extra.code,
|
|
74
|
+
status: extra.status,
|
|
75
|
+
httpStatus: extra.httpStatus,
|
|
76
|
+
body: extra.body,
|
|
77
|
+
cause: error,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VisviseProviderDeps {
|
|
82
|
+
env: VisviseEnv;
|
|
83
|
+
slug: string;
|
|
84
|
+
fetchImpl?: FetchLike;
|
|
85
|
+
downloadImpl?: DownloadLike;
|
|
86
|
+
rateGuard?: RateGuard;
|
|
87
|
+
sleep?: (ms: number) => Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const DATA_TYPE_TO_FILE: Record<string, { role: FileRole; format: FileFormat }> = {
|
|
91
|
+
glb: { role: 'source_mesh', format: 'glb' },
|
|
92
|
+
fbx: { role: 'source_mesh', format: 'fbx' },
|
|
93
|
+
obj: { role: 'source_mesh', format: 'obj' },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function buildVisviseGeneratePayload(input: VisviseGenerateInput): Record<string, unknown> {
|
|
97
|
+
if (input.mode === 'views') {
|
|
98
|
+
throw Object.assign(new Error('VISVISE views is not on the LiteLLM catalog; use text or image'), {
|
|
99
|
+
code: 'views_not_supported',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const mode: VisviseGenerateMode = input.mode;
|
|
103
|
+
const model = visviseGenerateModel(mode, input.quality);
|
|
104
|
+
const payload: Record<string, unknown> = { model };
|
|
105
|
+
payload.algorithm_model = input.algorithmModel?.trim() || defaultVisviseAlgorithmModel(input.quality);
|
|
106
|
+
if (typeof input.enableAPose === 'boolean') payload.enable_a_pose = input.enableAPose;
|
|
107
|
+
if (input.faceType === 1 || input.faceType === 2) payload.face_type = input.faceType;
|
|
108
|
+
const highOnly = input.quality !== 'mid' && input.quality !== 'low';
|
|
109
|
+
if (highOnly && typeof input.faceNum === 'number' && input.faceNum > 0) payload.face_num = input.faceNum;
|
|
110
|
+
if (highOnly && typeof input.enablePbr === 'boolean') payload.enable_pbr = input.enablePbr;
|
|
111
|
+
if (mode === 'text') payload.prompt = input.prompt ?? '';
|
|
112
|
+
else payload.image_url = input.imageUrl ?? '';
|
|
113
|
+
return payload;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Map filtered providerParams onto a generate input. Unset keys stay omitted. */
|
|
117
|
+
export function visviseInputFromFiltered(
|
|
118
|
+
base: Pick<VisviseGenerateInput, 'mode' | 'prompt' | 'imageUrl'>,
|
|
119
|
+
filtered: Record<string, string | number | boolean>,
|
|
120
|
+
): VisviseGenerateInput {
|
|
121
|
+
const input: VisviseGenerateInput = { ...base };
|
|
122
|
+
const q = filtered.quality;
|
|
123
|
+
if (q === 'standard' || q === 'mid' || q === 'low') input.quality = q;
|
|
124
|
+
if (typeof filtered.algorithm_model === 'string' && filtered.algorithm_model.trim()) {
|
|
125
|
+
input.algorithmModel = filtered.algorithm_model.trim();
|
|
126
|
+
}
|
|
127
|
+
if (typeof filtered.enable_a_pose === 'boolean') input.enableAPose = filtered.enable_a_pose;
|
|
128
|
+
if (filtered.face_type === '1' || filtered.face_type === 1) input.faceType = 1;
|
|
129
|
+
if (filtered.face_type === '2' || filtered.face_type === 2) input.faceType = 2;
|
|
130
|
+
if (typeof filtered.face_num === 'number') input.faceNum = filtered.face_num;
|
|
131
|
+
if (typeof filtered.enable_pbr === 'boolean') input.enablePbr = filtered.enable_pbr;
|
|
132
|
+
return input;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function buildVisviseUrlPayload(
|
|
136
|
+
model: string,
|
|
137
|
+
input: VisviseUrlInput,
|
|
138
|
+
extra?: Record<string, unknown>,
|
|
139
|
+
): Record<string, unknown> {
|
|
140
|
+
return {
|
|
141
|
+
model,
|
|
142
|
+
input_model_url: input.inputModelUrl,
|
|
143
|
+
...extra,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Prefer the VISVISE COS URL from generate/rig. Do not invent a ForgeaX COS stand-in. */
|
|
148
|
+
export function visviseInputUrl(
|
|
149
|
+
refs: { meshUrl?: string | null; rigUrl?: string | null } | undefined,
|
|
150
|
+
stage: 'rig' | 'motion',
|
|
151
|
+
): string | null {
|
|
152
|
+
const mesh = refs?.meshUrl?.trim() || null;
|
|
153
|
+
const rig = refs?.rigUrl?.trim() || null;
|
|
154
|
+
if (stage === 'motion') return rig ?? mesh;
|
|
155
|
+
return mesh;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class VisviseProvider {
|
|
159
|
+
private readonly env: VisviseEnv;
|
|
160
|
+
private readonly slug: string;
|
|
161
|
+
private readonly fetchImpl: FetchLike;
|
|
162
|
+
private readonly downloadImpl: DownloadLike;
|
|
163
|
+
private readonly rateGuard: RateGuard;
|
|
164
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
165
|
+
|
|
166
|
+
constructor(deps: VisviseProviderDeps) {
|
|
167
|
+
this.env = deps.env;
|
|
168
|
+
this.slug = deps.slug;
|
|
169
|
+
this.fetchImpl = deps.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
170
|
+
this.downloadImpl =
|
|
171
|
+
deps.downloadImpl ??
|
|
172
|
+
(async (url) => this.gateway().download(url, { format: urlFormat(url), image: isImageUrl(url) }));
|
|
173
|
+
this.rateGuard = deps.rateGuard ?? new RateGuard(this.env.rateLimitPerMin);
|
|
174
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async generate(input: VisviseGenerateInput): Promise<ProviderResult> {
|
|
178
|
+
const payload = buildVisviseGeneratePayload(input);
|
|
179
|
+
const out = await this.submitDownload(payload, input.mode === 'views' ? 'image' : input.mode, 'generate');
|
|
180
|
+
if (!out.files.some((f) => f.role === 'source_mesh' && f.format === 'glb')) {
|
|
181
|
+
throw Object.assign(new Error('visvise generate succeeded without a GLB mesh'), {
|
|
182
|
+
code: 'provider_no_output',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
provider: 'visvise',
|
|
187
|
+
mode: input.mode as GenerationMode,
|
|
188
|
+
providerMode: 'real',
|
|
189
|
+
sourceJobId: out.sourceJobId,
|
|
190
|
+
prompt: input.prompt ?? null,
|
|
191
|
+
files: out.files,
|
|
192
|
+
visviseRefs: { meshUrl: out.remoteUrls.glb ?? null, rigUrl: null },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async pose(input: VisviseUrlInput): Promise<{ sourceJobId: string | null; imageData: Uint8Array; sourceUrl: string }> {
|
|
197
|
+
this.rateGuard.check();
|
|
198
|
+
const submitted = await this.gateway().post(
|
|
199
|
+
GATEWAY_SUBMIT,
|
|
200
|
+
buildVisviseUrlPayload(VISVISE_GATEWAY_MODELS.pose, input),
|
|
201
|
+
);
|
|
202
|
+
const resp = await this.resolve(submitted, 'pose');
|
|
203
|
+
const urls = extractGatewayUrls(resp);
|
|
204
|
+
const url = urls.__thumbnail ?? Object.values(urls).find((u) => /\.(png|jpe?g|webp)(?:\?|$)/i.test(u));
|
|
205
|
+
if (!url) {
|
|
206
|
+
throw Object.assign(new Error('visvise pose returned no image url'), { code: 'provider_no_output' });
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
sourceJobId: typeof resp.id === 'string' ? resp.id : null,
|
|
210
|
+
imageData: await this.downloadImpl(url),
|
|
211
|
+
sourceUrl: url,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async autoRig(input: VisviseUrlInput): Promise<{
|
|
216
|
+
sourceJobId: string | null;
|
|
217
|
+
sourceUrl: string | null;
|
|
218
|
+
files: Array<{ format: 'glb' | 'fbx'; data: Uint8Array }>;
|
|
219
|
+
}> {
|
|
220
|
+
const out = await this.submitDownload(
|
|
221
|
+
buildVisviseUrlPayload(VISVISE_GATEWAY_MODELS.autoRig, input),
|
|
222
|
+
'image',
|
|
223
|
+
'auto-rig',
|
|
224
|
+
);
|
|
225
|
+
const files = out.files
|
|
226
|
+
.filter((f) => f.role === 'source_mesh' && (f.format === 'glb' || f.format === 'fbx'))
|
|
227
|
+
.map((f) => ({ format: f.format as 'glb' | 'fbx', data: f.data }));
|
|
228
|
+
if (!files.some((f) => f.format === 'glb')) {
|
|
229
|
+
throw Object.assign(new Error('visvise rigging returned no GLB'), { code: 'provider_no_output' });
|
|
230
|
+
}
|
|
231
|
+
return { sourceJobId: out.sourceJobId, sourceUrl: out.remoteUrls.glb ?? null, files };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async textMotion(input: VisviseUrlInput): Promise<{ sourceJobId: string | null; files: Array<{ format: 'glb' | 'fbx'; data: Uint8Array }> }> {
|
|
235
|
+
if (!input.prompt?.trim()) {
|
|
236
|
+
throw Object.assign(new Error('visvise text-motion requires prompt'), { code: 'invalid_motion_input' });
|
|
237
|
+
}
|
|
238
|
+
const out = await this.submitDownload(
|
|
239
|
+
buildVisviseUrlPayload(VISVISE_GATEWAY_MODELS.textMotion, input, { prompt: input.prompt.trim() }),
|
|
240
|
+
'image',
|
|
241
|
+
'text-motion',
|
|
242
|
+
);
|
|
243
|
+
return {
|
|
244
|
+
sourceJobId: out.sourceJobId,
|
|
245
|
+
files: out.files
|
|
246
|
+
.filter((f) => f.role === 'source_mesh' && (f.format === 'glb' || f.format === 'fbx'))
|
|
247
|
+
.map((f) => ({ format: f.format as 'glb' | 'fbx', data: f.data })),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async videoMotion(input: VisviseUrlInput): Promise<{ sourceJobId: string | null; files: Array<{ format: 'glb' | 'fbx'; data: Uint8Array }> }> {
|
|
252
|
+
if (!input.videoUrl?.trim()) {
|
|
253
|
+
throw Object.assign(new Error('visvise video-motion requires videoUrl'), { code: 'invalid_motion_input' });
|
|
254
|
+
}
|
|
255
|
+
const out = await this.submitDownload(
|
|
256
|
+
buildVisviseUrlPayload(VISVISE_GATEWAY_MODELS.videoMotion, input, { video_url: input.videoUrl.trim() }),
|
|
257
|
+
'image',
|
|
258
|
+
'video-motion',
|
|
259
|
+
);
|
|
260
|
+
return {
|
|
261
|
+
sourceJobId: out.sourceJobId,
|
|
262
|
+
files: out.files
|
|
263
|
+
.filter((f) => f.role === 'source_mesh' && (f.format === 'glb' || f.format === 'fbx'))
|
|
264
|
+
.map((f) => ({ format: f.format as 'glb' | 'fbx', data: f.data })),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async submitDownload(
|
|
269
|
+
payload: Record<string, unknown>,
|
|
270
|
+
mode: GenerationMode,
|
|
271
|
+
label: string,
|
|
272
|
+
): Promise<{ sourceJobId: string | null; files: ProviderResultFile[]; remoteUrls: Record<string, string> }> {
|
|
273
|
+
this.rateGuard.check();
|
|
274
|
+
let submitted: Record<string, unknown>;
|
|
275
|
+
try {
|
|
276
|
+
submitted = await this.gateway().post(GATEWAY_SUBMIT, payload);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw rewriteVisviseError(error);
|
|
279
|
+
}
|
|
280
|
+
const resp = await this.resolve(submitted, label);
|
|
281
|
+
const sourceJobId = typeof resp.id === 'string' ? resp.id : typeof submitted.id === 'string' ? submitted.id : null;
|
|
282
|
+
await audit(this.slug, {
|
|
283
|
+
ts: new Date().toISOString(),
|
|
284
|
+
provider: 'visvise',
|
|
285
|
+
mode,
|
|
286
|
+
event: 'submit',
|
|
287
|
+
sourceJobId,
|
|
288
|
+
detail: label,
|
|
289
|
+
});
|
|
290
|
+
const urls = extractGatewayUrls(resp);
|
|
291
|
+
const files: ProviderResultFile[] = [];
|
|
292
|
+
for (const [key, mapping] of Object.entries(DATA_TYPE_TO_FILE)) {
|
|
293
|
+
const url = urls[key];
|
|
294
|
+
if (!url) continue;
|
|
295
|
+
files.push({ role: mapping.role, format: mapping.format, data: await this.downloadImpl(url) });
|
|
296
|
+
}
|
|
297
|
+
if (urls.__thumbnail) {
|
|
298
|
+
files.push({ role: 'preview_image', format: 'png', data: await this.downloadImpl(urls.__thumbnail) });
|
|
299
|
+
}
|
|
300
|
+
return { sourceJobId, files, remoteUrls: urls };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async resolve(submitted: Record<string, unknown>, label: string): Promise<Record<string, unknown>> {
|
|
304
|
+
const status = String(submitted.status ?? '').toLowerCase();
|
|
305
|
+
if (status === 'succeeded' || extractGatewayUrls(submitted).glb) return submitted;
|
|
306
|
+
const taskId = typeof submitted.id === 'string' ? submitted.id : null;
|
|
307
|
+
if (!taskId) {
|
|
308
|
+
throw Object.assign(new Error(`visvise ${label} returned no task id`), { code: 'provider_no_task' });
|
|
309
|
+
}
|
|
310
|
+
return this.gateway().pollTask(`${GATEWAY_POLL}/${taskId}`, {
|
|
311
|
+
timeoutMs: this.env.pollTimeoutMs,
|
|
312
|
+
intervalMs: this.env.pollIntervalMs,
|
|
313
|
+
label: `visvise ${label}`,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private gateway(): GatewayClient {
|
|
318
|
+
return new GatewayClient({ env: this.env, fetchImpl: this.fetchImpl, sleep: this.sleep });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function urlFormat(url: string): string | undefined {
|
|
323
|
+
const path = url.split('?', 1)[0] ?? url;
|
|
324
|
+
return path.split('.').pop()?.toLowerCase();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isImageUrl(url: string): boolean {
|
|
328
|
+
return /\.(png|jpe?g|webp|gif)(?:\?|$)/i.test(url);
|
|
329
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// RateGuard — client-side submit guard. Sliding 60s window; over-limit throws
|
|
2
|
+
// QuotaProtectionError before any quotaed call is made. Mirrors the lab's
|
|
3
|
+
// submit-only protection. In-process state; not a distributed limiter.
|
|
4
|
+
|
|
5
|
+
export class QuotaProtectionError extends Error {
|
|
6
|
+
readonly code = 'quota_protection';
|
|
7
|
+
constructor(perMin: number) {
|
|
8
|
+
super(`rate limit: at most ${perMin} submits per minute`);
|
|
9
|
+
this.name = 'QuotaProtectionError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class RateGuard {
|
|
14
|
+
private readonly stamps: number[] = [];
|
|
15
|
+
|
|
16
|
+
constructor(private readonly perMin: number) {}
|
|
17
|
+
|
|
18
|
+
// Call immediately before a quotaed submit. Throws if the window is full.
|
|
19
|
+
check(now = Date.now()): void {
|
|
20
|
+
const windowStart = now - 60_000;
|
|
21
|
+
while (this.stamps.length && this.stamps[0] < windowStart) {
|
|
22
|
+
this.stamps.shift();
|
|
23
|
+
}
|
|
24
|
+
if (this.stamps.length >= this.perMin) {
|
|
25
|
+
throw new QuotaProtectionError(this.perMin);
|
|
26
|
+
}
|
|
27
|
+
this.stamps.push(now);
|
|
28
|
+
}
|
|
29
|
+
}
|