tripo-cli 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +16 -14
  3. package/dist/ai/experts.js +26 -20
  4. package/dist/ai/plan-card.js +73 -34
  5. package/dist/ai/wizard.js +19 -19
  6. package/dist/commands/account.js +2 -6
  7. package/dist/commands/ai.js +18 -16
  8. package/dist/commands/batch.js +58 -18
  9. package/dist/commands/completion.js +1 -1
  10. package/dist/commands/config-cmd.js +1 -1
  11. package/dist/commands/doctor.js +1 -3
  12. package/dist/commands/generate.js +50 -8
  13. package/dist/commands/home.js +12 -13
  14. package/dist/commands/login.js +125 -182
  15. package/dist/commands/make.js +50 -25
  16. package/dist/commands/mcp.js +8 -3
  17. package/dist/commands/process-groups.js +29 -18
  18. package/dist/commands/shared.js +22 -13
  19. package/dist/commands/task.js +12 -11
  20. package/dist/commands/topup.js +6 -10
  21. package/dist/commands/update.js +23 -11
  22. package/dist/commands/view.js +3 -4
  23. package/dist/config/config.js +1 -1
  24. package/dist/core/analytics.js +39 -16
  25. package/dist/core/client.js +53 -21
  26. package/dist/core/device-auth.js +49 -1
  27. package/dist/core/download.js +2 -1
  28. package/dist/core/errors.js +24 -14
  29. package/dist/core/output.js +0 -4
  30. package/dist/core/requests.js +94 -31
  31. package/dist/core/task-service.js +21 -20
  32. package/dist/core/upload.js +23 -5
  33. package/dist/core/watch.js +3 -0
  34. package/dist/knowledge/chains.js +16 -17
  35. package/dist/knowledge/error-catalog.js +39 -37
  36. package/dist/knowledge/models.js +147 -27
  37. package/dist/knowledge/params.js +143 -32
  38. package/dist/knowledge/scenarios.js +32 -55
  39. package/dist/pipeline/make.js +38 -30
  40. package/dist/pipeline/runner.js +27 -8
  41. package/dist/pipeline/stdin.js +40 -3
  42. package/dist/program.js +3 -10
  43. package/dist/util/input-detect.js +10 -1
  44. package/package.json +3 -2
  45. package/skill/SKILL.md +52 -15
  46. package/skill/commands/account.md +8 -3
  47. package/skill/commands/batch.md +13 -3
  48. package/skill/commands/generate.md +57 -23
  49. package/skill/commands/make.md +43 -16
  50. package/skill/commands/process.md +29 -24
  51. package/skill/commands/task.md +4 -4
  52. package/skill/commands/view.md +1 -1
  53. package/skill/common-errors.md +14 -10
  54. package/skill/examples/animation.md +4 -2
  55. package/skill/examples/ar-web.md +6 -4
  56. package/skill/examples/game-asset.md +18 -7
  57. package/skill/examples/pipes.md +2 -2
  58. package/skill/examples/print.md +7 -3
  59. package/dist/i18n.js +0 -19
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Dual-model strategy — the single source of truth for 3D model selection.
2
+ * Model routing — the single source of truth for 3D generation selection.
3
3
  *
4
- * Rule (one branch, no exceptions): explicit low-poly intent or a face budget
5
- * <= 20000 selects P1; everything else selects v3.1. Explicit --model wins but
6
- * still goes through constraint validation.
4
+ * H3.1 is the official default (flagship, highest fidelity) and also covers dense
5
+ * geometry and H-only parameters. Real-time, editable and topology-constrained
6
+ * assets route to P2.
7
7
  */
8
8
  /**
9
9
  * Wire values are what the server actually accepts (backend whitelist:
@@ -13,24 +13,28 @@
13
13
  */
14
14
  export const MODEL_V31 = 'v3.1-20260211';
15
15
  export const MODEL_P1 = 'P1-20260311';
16
- /** P-series upgrade (Preview): adds quad output; explicit --model only, never auto-selected. */
16
+ /** P-series model: editable low-poly topology and quad output. */
17
17
  export const MODEL_P2 = 'P2-20260801';
18
- /** User-facing aliases normalized to the wire values above. */
18
+ /** User-facing aliases normalized to the wire values above (matched case-insensitively). */
19
19
  export const MODEL_ALIASES = {
20
+ 'H3.1': MODEL_V31,
21
+ 'P1.0': MODEL_P1,
22
+ 'P2.0': MODEL_P2,
23
+ 'P2.0 Preview': MODEL_P2,
20
24
  'v3.1': MODEL_V31,
21
25
  'v3.1-20260211': MODEL_V31,
22
26
  'tripo-v3.1': MODEL_V31,
23
- p1: MODEL_P1,
24
27
  P1: MODEL_P1,
25
28
  'P1-20260311': MODEL_P1,
26
29
  'tripo-p1': MODEL_P1,
27
- p2: MODEL_P2,
28
30
  P2: MODEL_P2,
29
31
  'P2-20260801': MODEL_P2,
30
32
  'tripo-p2': MODEL_P2,
31
33
  // Older whitelisted versions, alias → wire.
34
+ 'H3.0': 'v3.0-20250812',
32
35
  'v3.0': 'v3.0-20250812',
33
36
  'tripo-v3.0': 'v3.0-20250812',
37
+ 'H2.5': 'v2.5-20250123',
34
38
  'v2.5': 'v2.5-20250123',
35
39
  'tripo-v2.5': 'v2.5-20250123',
36
40
  };
@@ -52,6 +56,7 @@ export const P2_FORBIDDEN_PARAMS = ['smart_low_poly', 'generate_parts', 'geometr
52
56
  /** Rig wire versions (backend whitelist; marketing names rig-v1.0/rig-v2.0 are rejected). */
53
57
  export const RIG_MODEL_V1 = 'v1.0-20240301';
54
58
  export const RIG_MODEL_V25 = 'v2.5-20260210';
59
+ export const RIG_MODEL_V30 = 'v3.0-20260909';
55
60
  /** Marketing rig names → wire versions the server actually accepts. */
56
61
  export const RIG_MODEL_ALIASES = {
57
62
  'rig-v1.0': RIG_MODEL_V1,
@@ -59,6 +64,8 @@ export const RIG_MODEL_ALIASES = {
59
64
  'rig-v2.0': RIG_MODEL_V25,
60
65
  'rig-v2.5': RIG_MODEL_V25,
61
66
  'v2.5-20260210': RIG_MODEL_V25,
67
+ 'rig-v3.0': RIG_MODEL_V30,
68
+ 'v3.0-20260909': RIG_MODEL_V30,
62
69
  };
63
70
  export function normalizeRigModel(input) {
64
71
  return RIG_MODEL_ALIASES[input] ?? input;
@@ -67,8 +74,9 @@ export function normalizeRigModel(input) {
67
74
  export const CAPABILITY_DEFAULTS = {
68
75
  texture: 'v3.0-20250812',
69
76
  /** Server default is v1.0 (biped-only presets); the CLI defaults to the
70
- * v2.5 rig which has presets for all seven body types. */
71
- rig: RIG_MODEL_V25,
77
+ * v3.0 rig: recommended for humanoids, v2.5 behavior and presets for the
78
+ * other body types. */
79
+ rig: RIG_MODEL_V30,
72
80
  segment: 'v1.0-20250506',
73
81
  textToImage: 'seedream_v4',
74
82
  imageToImage: 'seedream_v5',
@@ -81,8 +89,18 @@ export const RIG_TYPES = [
81
89
  'avian',
82
90
  'serpentine',
83
91
  'aquatic',
92
+ 'others',
84
93
  ];
85
- export const RIG_SPECS = ['tripo', 'mixamo'];
94
+ /** Bone naming specs (biped only); actorcore/unity/unreal/vrm need rig v3.0. */
95
+ export const RIG_SPECS = ['tripo', 'mixamo', 'actorcore', 'unity', 'unreal', 'vrm'];
96
+ /** Retarget presets of non-biped rig types (rig v2.5/v3.0); avian and others have none. */
97
+ export const NON_BIPED_PRESETS = {
98
+ quadruped: ['preset:quadruped:walk'],
99
+ hexapod: ['preset:hexapod:walk'],
100
+ octopod: ['preset:octopod:walk'],
101
+ serpentine: ['preset:serpentine:march'],
102
+ aquatic: ['preset:aquatic:march'],
103
+ };
86
104
  /** Per-endpoint image model whitelists (ImageGenerationValidation):
87
105
  * image-to-image does NOT accept seedream_v4. */
88
106
  export const TEXT_TO_IMAGE_MODELS = [
@@ -91,21 +109,25 @@ export const TEXT_TO_IMAGE_MODELS = [
91
109
  'banana',
92
110
  'banana_pro',
93
111
  'banana2',
94
- 'chat_image_1',
95
112
  'chat_image_1.5',
96
113
  'chat_image_2',
114
+ 'chat_image_2.5_flare',
115
+ 'chat_image_2.5_sunburst',
97
116
  ];
98
117
  export const IMAGE_TO_IMAGE_MODELS = [
99
118
  'seedream_v5',
100
119
  'banana',
101
120
  'banana_pro',
102
121
  'banana2',
103
- 'chat_image_1',
104
122
  'chat_image_1.5',
105
123
  'chat_image_2',
124
+ 'chat_image_2.5_flare',
125
+ 'chat_image_2.5_sunburst',
106
126
  ];
127
+ /** Texture model required by texture_quality=fast and delight. */
128
+ export const TEXTURE_MODEL_V35 = 'v3.5-20260815';
107
129
  /** texture_model whitelist (ModelPostProcessValidation.ALLOWED_TEXTURE_MODELS). */
108
- export const TEXTURE_MODELS = ['v3.0-20250812', 'v2.5-20250123'];
130
+ export const TEXTURE_MODELS = [TEXTURE_MODEL_V35, 'v3.0-20250812', 'v2.5-20250123'];
109
131
  /** mesh_segmentation whitelist (ALLOWED_SEGMENT_MODELS). */
110
132
  export const SEGMENT_MODELS = ['v1.0-20250506', 'v2.0-20260430'];
111
133
  /** mesh/decimate whitelist (ALLOWED_DECIMATE_MODELS; blank/"default" → server default v2.0). */
@@ -137,12 +159,14 @@ export const TEXTURE_FORMATS = [
137
159
  'TIFF',
138
160
  'WEBP',
139
161
  ];
140
- export const FBX_PRESETS = ['blender', '3dsmax', 'mixamo'];
162
+ export const FBX_PRESETS = ['blender', '3dsmax', 'mixamo', 'bake_scale'];
141
163
  export const EXPORT_ORIENTATIONS = ['+x', '-x', '+y', '-y'];
142
- export const TEXTURE_QUALITIES = ['standard', 'detailed', 'extreme'];
143
- /** Normalize any accepted alias to the canonical wire value; unknown values pass through. */
164
+ /** `fast` only works with texture model v3.5 (other versions → 1004). */
165
+ export const TEXTURE_QUALITIES = ['fast', 'standard', 'detailed', 'extreme'];
166
+ const MODEL_ALIAS_LOOKUP = new Map(Object.entries(MODEL_ALIASES).map(([alias, wire]) => [alias.toLowerCase(), wire]));
167
+ /** Normalize any accepted alias to the canonical wire value; unknown values pass through for validation to reject. */
144
168
  export function normalizeModel(input) {
145
- return MODEL_ALIASES[input] ?? input;
169
+ return MODEL_ALIAS_LOOKUP.get(input.trim().toLowerCase()) ?? input;
146
170
  }
147
171
  export function isP1(model) {
148
172
  return normalizeModel(model) === MODEL_P1;
@@ -150,21 +174,117 @@ export function isP1(model) {
150
174
  export function isP2(model) {
151
175
  return normalizeModel(model) === MODEL_P2;
152
176
  }
153
- /** The one rule. Returns the model plus a human-readable reason. */
177
+ function hRequirement(intent) {
178
+ const params = intent.params ?? {};
179
+ const quad = intent.quad === true || params.quad === true;
180
+ if (intent.denseGeometry)
181
+ return 'dense geometry';
182
+ if (params.geometry_quality !== undefined)
183
+ return 'geometry_quality';
184
+ if (params.smart_low_poly === true)
185
+ return 'smart_low_poly';
186
+ if (params.generate_parts === true)
187
+ return 'generate_parts';
188
+ if (typeof intent.faceLimit === 'number' && intent.faceLimit > (quad ? P2_FACE_LIMIT.quadMax : P2_FACE_LIMIT.triMax)) {
189
+ return `face budget exceeds P2 ${quad ? 'quad' : 'triangle'} limit`;
190
+ }
191
+ if (typeof intent.faceLimit === 'number' && intent.faceLimit > 0 && intent.faceLimit < P2_FACE_LIMIT.min) {
192
+ return 'face budget below P2 minimum';
193
+ }
194
+ return undefined;
195
+ }
196
+ /** Select a generation model from explicit choice, geometry requirements and intended use. */
154
197
  export function chooseModel(intent) {
155
- if (intent.explicit) {
156
- return { model: normalizeModel(intent.explicit), reason: 'explicit --model' };
198
+ const paramModel = typeof intent.params?.model === 'string' ? intent.params.model : undefined;
199
+ const explicit = intent.explicit ?? paramModel;
200
+ if (explicit) {
201
+ return {
202
+ model: normalizeModel(explicit),
203
+ reason: intent.explicit ? 'explicit --model' : 'explicit model parameter',
204
+ };
157
205
  }
158
- if (intent.lowPoly) {
159
- return { model: MODEL_P1, reason: 'low-poly intent → P1' };
206
+ const hOnly = hRequirement(intent);
207
+ if (hOnly) {
208
+ return { model: MODEL_V31, reason: `${hOnly} → v3.1` };
160
209
  }
161
- if (intent.faceLimit !== undefined && intent.faceLimit <= P1_FACE_LIMIT.max) {
162
- return { model: MODEL_P1, reason: `face budget ${intent.faceLimit} ≤ ${P1_FACE_LIMIT.max} → P1` };
210
+ if (intent.preferred) {
211
+ const preferred = normalizeModel(intent.preferred);
212
+ if (preferred !== MODEL_P1)
213
+ return { model: preferred, reason: 'scenario preference' };
214
+ }
215
+ const params = intent.params ?? {};
216
+ if (intent.lowPoly || intent.realtime || intent.editable || intent.quad === true || params.quad === true || intent.faceLimit !== undefined) {
217
+ return { model: MODEL_P2, reason: 'real-time or editable topology → P2' };
218
+ }
219
+ return { model: MODEL_V31, reason: 'official default → v3.1' };
220
+ }
221
+ /**
222
+ * Official list price per generation task in credits, identical on the ov and cn
223
+ * model pages. Tiers: [no texture, standard/fast, detailed, extreme]. P1 and the
224
+ * legacy models publish no price, so they are never estimated.
225
+ */
226
+ const GENERATION_CREDITS = {
227
+ [MODEL_V31]: {
228
+ text_to_model: [10, 20, 30, 40],
229
+ image_to_model: [20, 30, 40, 50],
230
+ multiview_to_model: [20, 30, 40, 50],
231
+ },
232
+ [MODEL_P2]: {
233
+ text_to_model: [100, 110, 120, 130],
234
+ image_to_model: [100, 110, 120, 130],
235
+ multiview_to_model: [100, 110, 120, 130],
236
+ },
237
+ };
238
+ /** List-price credits for one generation task from its validated payload; undefined when no price is published. */
239
+ export function estimateGenerationCredits(model, taskType, params) {
240
+ const wire = normalizeModel(model);
241
+ const tiers = GENERATION_CREDITS[wire]?.[taskType];
242
+ if (!tiers)
243
+ return undefined;
244
+ let credits = tiers[params.texture === false ? 0 : params.texture_quality === 'extreme' ? 3 : params.texture_quality === 'detailed' ? 2 : 1];
245
+ if (wire === MODEL_V31) {
246
+ if (params.geometry_quality === 'detailed')
247
+ credits += 20;
248
+ if (params.quad === true)
249
+ credits += 5;
250
+ if (params.smart_low_poly === true)
251
+ credits += 10;
252
+ if (params.generate_parts === true)
253
+ credits += 20;
163
254
  }
164
- return { model: MODEL_V31, reason: 'default high-fidelity → v3.1' };
255
+ return credits;
165
256
  }
166
257
  /** Keywords that signal low-poly intent in a prompt (used by make/ai). */
167
- const LOW_POLY_HINTS = [/low[\s_-]?poly/i, /低模/, /低面/, /手游/, /voxel/i, /像素风/];
258
+ const LOW_POLY_HINTS = [/low[\s_-]?poly/i, /低模/, /低面/, /低多边形/, /手游/, /voxel/i, /像素风/];
259
+ /** Physical games, product nouns (gaming PC, console) and animation *styles* are not real-time intent. */
260
+ const REALTIME_HINTS = [
261
+ /\b(?:(?<!\b(?:board|card|tabletop|dice|party)[\s_-]?)games?|gameplay|real[\s_-]?time|unity|unreal(?:\s+engine)?|godot|three\.?js|webgl)\b/i,
262
+ /\b(?:editable|edit[\s_-]?friendly|topology|retopo(?:logy)?|quad(?:s)?|rig(?:[\s_-]?ready|ged|ging)?|animat(?:e|ions?)(?![\s_-]?styl))\b/i,
263
+ /(?<!棋盘|桌面)游戏|手游|端游|实时|引擎|可编辑|可修改|拓扑|重拓扑|四边面|四边形|绑定|骨骼|动画(?![风片])|虚幻/,
264
+ ];
265
+ const DENSE_GEOMETRY_HINTS = [
266
+ /\b(?:dense|high[\s_-]?density|high[\s_-]?detail(?:ed)?)\s+(?:mesh|geometry|topology)\b/i,
267
+ /\bhigh[\s_-]?poly(?:gon(?:al)?)?\b/i,
268
+ /\bnanite\b/i,
269
+ /\b(?:million|millions)\s+of\s+(?:faces|triangles|polygons)\b/i,
270
+ /高模|高面数|高多边形|高密度(?:网格|几何)|密集(?:网格|几何)|稠密(?:网格|几何)|高精度(?:网格|几何)|百万(?:面|三角面)/,
271
+ ];
168
272
  export function detectLowPolyIntent(text) {
169
273
  return LOW_POLY_HINTS.some((re) => re.test(text));
170
274
  }
275
+ /** Detect uses that benefit from P-series topology without treating visual quality as mesh density. */
276
+ export function detectRealtimeIntent(text) {
277
+ return REALTIME_HINTS.some((re) => re.test(text));
278
+ }
279
+ /** Detect only explicit dense-geometry requirements; phrases such as "high quality" do not match. */
280
+ export function detectDenseGeometryIntent(text) {
281
+ // Match each mention independently: negating one must not hide a later positive requirement.
282
+ return DENSE_GEOMETRY_HINTS.some((re) => Array.from(text.matchAll(new RegExp(re.source, `${re.flags}g`))).some((match) => {
283
+ const before = text.slice(0, match.index);
284
+ const after = text.slice(match.index + match[0].length);
285
+ const negatedBefore = /(?:\b(?:no|not|without|avoid|exclude)\s+(?:(?:a|an|any|the)\s+)?|\b(?:do\s+not|don't|does\s+not|doesn't)\s+(?:need|want|require|use)\s+(?:(?:a|an|any|the)\s+)?|\bno\s+need\s+for\s+(?:(?:a|an|any|the)\s+)?|(?:不要|不需要|无需|不用|不使用|避免|非)\s*)$/i.test(before);
286
+ const negatedAfter = /^\s*(?:(?:mesh|geometry|topology)\s+)?(?:is\s+not|isn't|not)\s+(?:needed|required|wanted)\b/i.test(after)
287
+ || /^\s*(?:不需要|不要|无需)/.test(after);
288
+ return !negatedBefore && !negatedAfter;
289
+ }));
290
+ }
@@ -5,14 +5,96 @@
5
5
  * Every rule here prevents a guaranteed 400 or a silently wasted credit.
6
6
  * Rules marked [hard] mirror server enforcement; [soft] are CLI smart defaults.
7
7
  */
8
- import { CONVERT_FORMATS, DECIMATE_MODELS, EXPORT_ORIENTATIONS, FBX_PRESETS, MODEL_P2, P1_FACE_LIMIT, P1_FORBIDDEN_PARAMS, P2_FACE_LIMIT, P2_FORBIDDEN_PARAMS, TEXTURE_FORMATS, TEXTURE_QUALITIES, isP1, isP2, normalizeModel, } from './models.js';
8
+ import { CAPABILITY_DEFAULTS, IMAGE_TO_IMAGE_MODELS, TEXT_TO_IMAGE_MODELS, CONVERT_FORMATS, DECIMATE_MODELS, EXPORT_ORIENTATIONS, FBX_PRESETS, LEGACY_3D_MODELS, MODEL_P1, MODEL_P2, MODEL_V31, P1_FACE_LIMIT, P1_FORBIDDEN_PARAMS, P2_FACE_LIMIT, P2_FORBIDDEN_PARAMS, TEXTURE_FORMATS, TEXTURE_MODEL_V35, TEXTURE_QUALITIES, isP1, isP2, normalizeModel, } from './models.js';
9
9
  const PROMPT_MAX = 1024;
10
10
  const NEGATIVE_PROMPT_MAX = 255;
11
+ /** Image endpoint contracts; never silently substitute a paid model. */
12
+ export function validateImageParams(input, editing) {
13
+ const params = { ...input };
14
+ const warnings = [];
15
+ const errors = [];
16
+ const model = params.model === undefined
17
+ ? (editing ? CAPABILITY_DEFAULTS.imageToImage : CAPABILITY_DEFAULTS.textToImage)
18
+ : params.model;
19
+ params.model = model;
20
+ const models = editing ? IMAGE_TO_IMAGE_MODELS : TEXT_TO_IMAGE_MODELS;
21
+ if (model === 'chat_image_1') {
22
+ errors.push('model chat_image_1 retired on 2026-10-23; explicitly choose chat_image_2 or chat_image_2.5_flare/sunburst (no automatic resubmission)');
23
+ }
24
+ else if (!models.includes(model)) {
25
+ errors.push(`unsupported image model "${String(model)}" for ${editing ? 'image-to-image' : 'text-to-image'}`);
26
+ }
27
+ const chat25 = model === 'chat_image_2.5_flare' || model === 'chat_image_2.5_sunburst';
28
+ if (params.size !== undefined && typeof model === 'string' && !validImageSize(model, params.size)) {
29
+ errors.push(`size is unsupported for model ${model}`);
30
+ }
31
+ if ((model === 'seedream_v4' || model === 'seedream_v5') && typeof params.size === 'string' && params.size.includes('x')) {
32
+ warnings.push('Seedream exact pixel-count limits unverified: official MP ranges are rounded; server validation still applies');
33
+ }
34
+ if (params.quality !== undefined) {
35
+ const qualities = chat25 ? ['low', 'medium', 'high', 'xhigh', 'max']
36
+ : model === 'chat_image_2' ? ['low', 'medium', 'high'] : [];
37
+ if (!qualities.includes(params.quality))
38
+ errors.push(`quality is unsupported for model ${String(model)} (auto is not supported)`);
39
+ }
40
+ if (params.output_format !== undefined && !['png', 'jpeg'].includes(params.output_format)) {
41
+ errors.push('output_format must be png or jpeg');
42
+ }
43
+ if (params.background !== undefined) {
44
+ if (!chat25) {
45
+ delete params.background;
46
+ warnings.push('background is ignored outside chat_image_2.5 models (removed)');
47
+ }
48
+ else if (!['auto', 'opaque', 'transparent'].includes(params.background)) {
49
+ errors.push('background must be auto, opaque or transparent');
50
+ }
51
+ else if (params.background === 'transparent' && params.output_format !== undefined && params.output_format !== 'png') {
52
+ errors.push('background=transparent requires output_format=png');
53
+ }
54
+ }
55
+ return { params, warnings, errors };
56
+ }
57
+ /** Only documented size rules; rounded Seedream MP limits are not exact pixel thresholds. */
58
+ function validImageSize(model, size) {
59
+ if (typeof size !== 'string')
60
+ return false;
61
+ const match = /^([1-9]\d*)x([1-9]\d*)$/.exec(size);
62
+ const w = Number(match?.[1]);
63
+ const h = Number(match?.[2]);
64
+ const pixels = !!match && Number.isSafeInteger(w) && Number.isSafeInteger(h);
65
+ if (model === 'banana') {
66
+ return ['1024x1024', '832x1248', '1248x832', '864x1184', '1184x864', '896x1152', '1152x896', '768x1344', '1344x768', '1536x672'].includes(size);
67
+ }
68
+ if (model === 'banana2')
69
+ return ['0.5K', '1K', '2K', '4K'].includes(size);
70
+ if (model === 'banana_pro')
71
+ return ['1K', '2K', '4K'].includes(size) || pixels;
72
+ if (model === 'seedream_v4' || model === 'seedream_v5') {
73
+ return ['2K', model === 'seedream_v5' ? '3K' : '4K'].includes(size) || (pixels && w / h >= 1 / 16 && w / h <= 16);
74
+ }
75
+ if (model === 'chat_image_1.5')
76
+ return ['1024x1024', '1024x1536', '1536x1024', 'auto'].includes(size);
77
+ if (model === 'chat_image_2' || model.startsWith('chat_image_2.5_')) {
78
+ return size === 'auto' || (pixels && w <= 3840 && h <= 3840 && w % 16 === 0 && h % 16 === 0 &&
79
+ Math.max(w, h) <= 3 * Math.min(w, h) && w * h >= 655360 && w * h <= 8294400);
80
+ }
81
+ return true; // Unknown models are rejected separately.
82
+ }
83
+ function validateFaceLimit(params, errors) {
84
+ if (params.face_limit !== undefined &&
85
+ (typeof params.face_limit !== 'number' || !Number.isFinite(params.face_limit) || !Number.isInteger(params.face_limit))) {
86
+ errors.push('face_limit must be a finite integer');
87
+ }
88
+ }
11
89
  /** Validate + auto-fix parameters for 3D generation (text/image/multiview-to-model). */
12
90
  export function validateGenerationParams(model, input) {
13
91
  const params = { ...input };
14
92
  const warnings = [];
15
93
  const errors = [];
94
+ if (![MODEL_V31, MODEL_P1, MODEL_P2, ...LEGACY_3D_MODELS].includes(normalizeModel(model))) {
95
+ errors.push(`unsupported 3D model "${model}"`);
96
+ }
97
+ validateFaceLimit(params, errors);
16
98
  if (typeof params.prompt === 'string' && params.prompt.length > PROMPT_MAX) {
17
99
  errors.push(`prompt exceeds ${PROMPT_MAX} characters (${params.prompt.length})`);
18
100
  }
@@ -44,10 +126,16 @@ export function validateGenerationParams(model, input) {
44
126
  params.texture = true;
45
127
  warnings.push('pbr=true forces texture=true (adjusted)');
46
128
  }
47
- // [hard] generate_parts outputs bare geometry: texture/pbr/quad must be off.
129
+ // [hard] pbr defaults to true server-side, so texture=false alone would still be textured.
130
+ if (params.texture === false && params.pbr === undefined) {
131
+ params.pbr = false;
132
+ warnings.push('texture=false implies pbr=false (adjusted)');
133
+ }
134
+ // [hard] generate_parts outputs bare geometry: texture/pbr/quad must be off
135
+ // (texture and pbr default to true server-side, so omitting them counts as on).
48
136
  if (params.generate_parts === true) {
49
137
  for (const key of ['texture', 'pbr', 'quad']) {
50
- if (params[key] === true) {
138
+ if (key === 'quad' ? params[key] === true : params[key] !== false) {
51
139
  params[key] = false;
52
140
  warnings.push(`generate_parts=true disables ${key} (adjusted; run "tripo model texture" afterwards)`);
53
141
  }
@@ -59,7 +147,7 @@ export function validateGenerationParams(model, input) {
59
147
  if (typeof face === 'number') {
60
148
  if (face < P1_FACE_LIMIT.min || face > P1_FACE_LIMIT.max) {
61
149
  errors.push(`P1 face_limit must be within ${P1_FACE_LIMIT.min}–${P1_FACE_LIMIT.max} (got ${face}); ` +
62
- `use tripo-v3.1 for higher budgets`);
150
+ `use tripo-v3.1 for budgets outside this range`);
63
151
  }
64
152
  }
65
153
  }
@@ -71,7 +159,7 @@ export function validateGenerationParams(model, input) {
71
159
  const max = quad ? P2_FACE_LIMIT.quadMax : P2_FACE_LIMIT.triMax;
72
160
  if (face < P2_FACE_LIMIT.min || face > max) {
73
161
  errors.push(`P2 face_limit must be within ${P2_FACE_LIMIT.min}–${max}${quad ? ' with quad=true' : ''} (got ${face}); ` +
74
- `use tripo-v3.1 for higher budgets`);
162
+ `use tripo-v3.1 for budgets outside this range`);
75
163
  }
76
164
  }
77
165
  }
@@ -84,21 +172,44 @@ export function validateGenerationParams(model, input) {
84
172
  }
85
173
  }
86
174
  }
87
- if (params.texture_quality !== undefined &&
88
- !TEXTURE_QUALITIES.includes(params.texture_quality)) {
89
- errors.push(`texture_quality must be one of ${TEXTURE_QUALITIES.join('/')}`);
90
- }
175
+ applyTextureV35(params, 'texture_version', warnings, errors);
91
176
  // [hard] quad topology cannot be stored in GLB → output will be FBX.
92
177
  if (params.quad === true) {
93
178
  warnings.push('quad=true forces FBX output (quad topology cannot be stored in GLB)');
94
179
  }
95
180
  return { params, warnings, errors };
96
181
  }
182
+ /**
183
+ * [hard] texture_quality=fast and delight only exist on texture model v3.5:
184
+ * fast on any other version is a 1004, delight is silently ignored. v3.5 is
185
+ * selected when no version was given. `key` is `texture_version` on the
186
+ * generation endpoints and `model` on /v3/models/texture.
187
+ */
188
+ export function applyTextureV35(params, key, warnings, errors) {
189
+ if (params.texture_quality !== undefined && !TEXTURE_QUALITIES.includes(params.texture_quality)) {
190
+ errors.push(`texture_quality must be one of ${TEXTURE_QUALITIES.join('/')}`);
191
+ }
192
+ const feature = params.texture_quality === 'fast' ? 'texture_quality=fast' : params.delight !== undefined ? 'delight' : undefined;
193
+ if (!feature)
194
+ return;
195
+ const version = params[key];
196
+ if (version === undefined) {
197
+ params[key] = TEXTURE_MODEL_V35;
198
+ warnings.push(`${feature} needs ${key}=${TEXTURE_MODEL_V35} (selected)`);
199
+ }
200
+ else if (version !== TEXTURE_MODEL_V35) {
201
+ if (feature === 'delight')
202
+ warnings.push(`delight is ignored unless ${key}=${TEXTURE_MODEL_V35}`);
203
+ else
204
+ errors.push(`texture_quality=fast requires ${key}=${TEXTURE_MODEL_V35} (got "${String(version)}")`);
205
+ }
206
+ }
97
207
  /** Validate parameters for models/convert. */
98
208
  export function validateConvertParams(input) {
99
209
  const params = { ...input };
100
210
  const warnings = [];
101
211
  const errors = [];
212
+ validateFaceLimit(params, errors);
102
213
  const format = typeof params.format === 'string' ? params.format.toUpperCase() : undefined;
103
214
  if (!format) {
104
215
  errors.push(`convert requires format (${CONVERT_FORMATS.join('/')})`);
@@ -109,9 +220,12 @@ export function validateConvertParams(input) {
109
220
  else {
110
221
  params.format = format;
111
222
  }
112
- // [hard] quad topology cannot live in GLTF/GLB.
223
+ // [hard] quad topology cannot live in GLTF/GLB; quad output is always FBX.
113
224
  if (params.quad === true && format === 'GLTF') {
114
- errors.push('quad=true cannot export GLTF (quad topology is not storable in glTF); use FBX/OBJ/USDZ');
225
+ errors.push('quad=true cannot export GLTF (quad topology is not storable in glTF); quad forces FBX output — use format=FBX');
226
+ }
227
+ else if (params.quad === true && format && format !== 'FBX') {
228
+ warnings.push(`quad=true forces FBX output (format ${format} is overridden)`);
115
229
  }
116
230
  if (params.force_symmetry === true && params.quad !== true) {
117
231
  delete params.force_symmetry;
@@ -142,30 +256,26 @@ export function validateConvertParams(input) {
142
256
  !EXPORT_ORIENTATIONS.includes(params.export_orientation)) {
143
257
  errors.push(`export_orientation must be one of ${EXPORT_ORIENTATIONS.join('/')}`);
144
258
  }
145
- // [soft] cost transparency: any non-default option upgrades billing to
146
- // convert_model_complex.
147
- const defaults = {
148
- texture_size: 4096,
149
- texture_format: 'JPEG',
150
- bake: true,
151
- pack_uv: false,
152
- export_vertex_colors: false,
153
- pivot_to_center_bottom: false,
154
- scale_factor: 1,
155
- with_animation: true,
156
- animate_in_place: false,
157
- export_orientation: '+x',
158
- fbx_preset: 'blender',
159
- quad: false,
160
- flatten_bottom: false,
161
- force_symmetry: false,
162
- };
163
- const complex = Object.entries(defaults).some(([k, dv]) => params[k] !== undefined && params[k] !== dv) || params.face_limit !== undefined || params.part_names !== undefined;
164
- if (complex) {
259
+ // [soft] cost transparency.
260
+ if (isComplexConvert(params)) {
165
261
  warnings.push('non-default convert options use the "complex convert" price tier');
166
262
  }
167
263
  return { params, warnings, errors };
168
264
  }
265
+ /** Pricing page: a non-default value of any of these (or any face_limit) bills the complex convert tier. */
266
+ const COMPLEX_CONVERT_DEFAULTS = {
267
+ quad: false,
268
+ flatten_bottom: false,
269
+ flatten_bottom_threshold: 0.01,
270
+ texture_size: 4096,
271
+ texture_format: 'JPEG',
272
+ pivot_to_center_bottom: false,
273
+ scale_factor: 1,
274
+ };
275
+ export function isComplexConvert(params) {
276
+ return (params.face_limit !== undefined ||
277
+ Object.entries(COMPLEX_CONVERT_DEFAULTS).some(([k, dv]) => params[k] !== undefined && String(params[k]).toUpperCase() !== String(dv).toUpperCase()));
278
+ }
169
279
  /**
170
280
  * Validate parameters for mesh/decimate (ModelPostProcessValidation):
171
281
  * v2.0 (default) / v3.0: face_limit 500–20000 tri / 500–10000 quad.
@@ -176,6 +286,7 @@ export function validateDecimateParams(input) {
176
286
  const params = { ...input };
177
287
  const warnings = [];
178
288
  const errors = [];
289
+ validateFaceLimit(params, errors);
179
290
  const model = typeof params.model === 'string' ? params.model.toLowerCase() : undefined;
180
291
  if (model !== undefined && model !== 'default' && !DECIMATE_MODELS.includes(model)) {
181
292
  errors.push(`decimate model must be ${DECIMATE_MODELS.join('/')} (got "${String(params.model)}")`);
@@ -184,7 +295,7 @@ export function validateDecimateParams(input) {
184
295
  if (params.smart_uv === true) {
185
296
  if (model === undefined) {
186
297
  params.model = 'v3.0';
187
- warnings.push('smart_uv requires decimate v3.0: using model=v3.0 (35 credits tri / 40 quad; v2.0 is 30)');
298
+ warnings.push('smart_uv requires decimate v3.0: using model=v3.0 (cost not estimated; check current regional pricing)');
188
299
  }
189
300
  else if (model !== 'v3.0') {
190
301
  errors.push(`smart_uv only works with decimate model v3.0 (got "${String(params.model)}")`);