tripo-cli 0.1.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 (123) hide show
  1. package/README.md +105 -0
  2. package/dist/ai/experts.js +115 -0
  3. package/dist/ai/llm.js +88 -0
  4. package/dist/ai/plan-card.js +79 -0
  5. package/dist/ai/wizard.js +105 -0
  6. package/dist/cli.js +7 -0
  7. package/dist/commands/account.js +27 -0
  8. package/dist/commands/ai.js +114 -0
  9. package/dist/commands/batch.js +148 -0
  10. package/dist/commands/completion.js +84 -0
  11. package/dist/commands/config-cmd.js +84 -0
  12. package/dist/commands/docs.js +30 -0
  13. package/dist/commands/doctor.js +102 -0
  14. package/dist/commands/generate.js +81 -0
  15. package/dist/commands/home.js +68 -0
  16. package/dist/commands/login.js +309 -0
  17. package/dist/commands/make.js +142 -0
  18. package/dist/commands/mcp.js +194 -0
  19. package/dist/commands/process-groups.js +166 -0
  20. package/dist/commands/shared.js +75 -0
  21. package/dist/commands/task.js +128 -0
  22. package/dist/commands/topup.js +61 -0
  23. package/dist/commands/view.js +97 -0
  24. package/dist/config/config.js +99 -0
  25. package/dist/config/context.js +44 -0
  26. package/dist/config/history.js +104 -0
  27. package/dist/core/client.js +129 -0
  28. package/dist/core/device-auth.js +141 -0
  29. package/dist/core/download.js +107 -0
  30. package/dist/core/errors.js +64 -0
  31. package/dist/core/output.js +72 -0
  32. package/dist/core/region.js +45 -0
  33. package/dist/core/requests.js +197 -0
  34. package/dist/core/task-service.js +167 -0
  35. package/dist/core/upload.js +42 -0
  36. package/dist/core/viewer-assets.js +24 -0
  37. package/dist/core/viewer.js +59 -0
  38. package/dist/core/watch.js +42 -0
  39. package/dist/i18n.js +19 -0
  40. package/dist/knowledge/chains.js +157 -0
  41. package/dist/knowledge/error-catalog.js +91 -0
  42. package/dist/knowledge/models.js +158 -0
  43. package/dist/knowledge/params.js +203 -0
  44. package/dist/knowledge/scenarios.js +112 -0
  45. package/dist/pipeline/make.js +233 -0
  46. package/dist/pipeline/runner.js +73 -0
  47. package/dist/pipeline/stdin.js +40 -0
  48. package/dist/program.js +147 -0
  49. package/dist/src/ai/experts.js +115 -0
  50. package/dist/src/ai/llm.js +88 -0
  51. package/dist/src/ai/plan-card.js +79 -0
  52. package/dist/src/ai/wizard.js +104 -0
  53. package/dist/src/cli.js +7 -0
  54. package/dist/src/commands/account.js +27 -0
  55. package/dist/src/commands/ai.js +113 -0
  56. package/dist/src/commands/batch.js +148 -0
  57. package/dist/src/commands/completion.js +84 -0
  58. package/dist/src/commands/config-cmd.js +84 -0
  59. package/dist/src/commands/docs.js +30 -0
  60. package/dist/src/commands/doctor.js +94 -0
  61. package/dist/src/commands/generate.js +81 -0
  62. package/dist/src/commands/home.js +68 -0
  63. package/dist/src/commands/login.js +225 -0
  64. package/dist/src/commands/make.js +142 -0
  65. package/dist/src/commands/mcp.js +194 -0
  66. package/dist/src/commands/process-groups.js +164 -0
  67. package/dist/src/commands/shared.js +75 -0
  68. package/dist/src/commands/task.js +127 -0
  69. package/dist/src/commands/topup.js +61 -0
  70. package/dist/src/commands/view.js +97 -0
  71. package/dist/src/config/config.js +79 -0
  72. package/dist/src/config/context.js +44 -0
  73. package/dist/src/config/history.js +104 -0
  74. package/dist/src/core/client.js +120 -0
  75. package/dist/src/core/download.js +107 -0
  76. package/dist/src/core/errors.js +64 -0
  77. package/dist/src/core/output.js +72 -0
  78. package/dist/src/core/requests.js +169 -0
  79. package/dist/src/core/task-service.js +161 -0
  80. package/dist/src/core/upload.js +36 -0
  81. package/dist/src/core/viewer-assets.js +24 -0
  82. package/dist/src/core/viewer.js +59 -0
  83. package/dist/src/core/watch.js +41 -0
  84. package/dist/src/i18n.js +19 -0
  85. package/dist/src/knowledge/chains.js +152 -0
  86. package/dist/src/knowledge/error-catalog.js +91 -0
  87. package/dist/src/knowledge/models.js +110 -0
  88. package/dist/src/knowledge/params.js +174 -0
  89. package/dist/src/knowledge/scenarios.js +112 -0
  90. package/dist/src/pipeline/make.js +233 -0
  91. package/dist/src/pipeline/runner.js +73 -0
  92. package/dist/src/pipeline/stdin.js +40 -0
  93. package/dist/src/program.js +147 -0
  94. package/dist/src/types.js +2 -0
  95. package/dist/src/util/input-detect.js +92 -0
  96. package/dist/src/util/notify.js +17 -0
  97. package/dist/src/util/open.js +18 -0
  98. package/dist/src/util/spinner.js +62 -0
  99. package/dist/src/util/term-image.js +28 -0
  100. package/dist/src/version.js +10 -0
  101. package/dist/types.js +2 -0
  102. package/dist/util/input-detect.js +93 -0
  103. package/dist/util/notify.js +17 -0
  104. package/dist/util/open.js +18 -0
  105. package/dist/util/spinner.js +62 -0
  106. package/dist/util/term-image.js +28 -0
  107. package/dist/version.js +10 -0
  108. package/package.json +63 -0
  109. package/skill/SKILL.md +70 -0
  110. package/skill/commands/account.md +41 -0
  111. package/skill/commands/batch.md +33 -0
  112. package/skill/commands/generate.md +34 -0
  113. package/skill/commands/make.md +47 -0
  114. package/skill/commands/process.md +50 -0
  115. package/skill/commands/task.md +27 -0
  116. package/skill/commands/view.md +25 -0
  117. package/skill/common-errors.md +38 -0
  118. package/skill/examples/animation.md +33 -0
  119. package/skill/examples/ar-web.md +23 -0
  120. package/skill/examples/film.md +22 -0
  121. package/skill/examples/game-asset.md +35 -0
  122. package/skill/examples/pipes.md +39 -0
  123. package/skill/examples/print.md +31 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The shared task engine: resolve inputs, pre-check balance, submit, record
3
+ * history, watch progress, download artifacts, notify. Every task-producing
4
+ * command (make/generate/model/anim/mesh/pipeline/batch) runs through here so
5
+ * behavior is identical everywhere.
6
+ */
7
+ import { basename } from 'node:path';
8
+ import { resolvePlatformBaseUrl } from '../config/config.js';
9
+ import { loadContext, updateContext } from '../config/context.js';
10
+ import { RefError, appendHistory, readHistory, resolveReference, slugify, updateHistoryStatus } from '../config/history.js';
11
+ import { t } from '../i18n.js';
12
+ import { detectInput } from '../util/input-detect.js';
13
+ import { notifyDone } from '../util/notify.js';
14
+ import { createSpinner } from '../util/spinner.js';
15
+ import { defaultOutputDir, downloadArtifacts } from './download.js';
16
+ import { ApiError, UsageError, balanceSuggestion } from './errors.js';
17
+ import { isQuiet, log, warn } from './output.js';
18
+ import { uploadLocalFile } from './upload.js';
19
+ /** Resolve a user input string into the API `input` value (upload as needed). */
20
+ export async function resolveInputValue(client, raw) {
21
+ const detected = detectInput(raw);
22
+ switch (detected.kind) {
23
+ case 'history-ref': {
24
+ let entry;
25
+ try {
26
+ entry = resolveReference(detected.value);
27
+ }
28
+ catch (err) {
29
+ if (err instanceof RefError)
30
+ throw new UsageError(err.message);
31
+ throw err;
32
+ }
33
+ if (!entry)
34
+ throw new UsageError(`cannot resolve reference ${raw}`);
35
+ return { value: entry.task_id, sourceTaskType: entry.type };
36
+ }
37
+ case 'image-file':
38
+ case 'model-file': {
39
+ const token = await uploadLocalFile(client, detected.value);
40
+ return { value: token };
41
+ }
42
+ case 'task-id':
43
+ return { value: detected.value };
44
+ case 'file-token':
45
+ case 'url':
46
+ return { value: detected.value };
47
+ case 'text':
48
+ throw new UsageError(`expected a file/url/task reference, got text: "${raw}"`);
49
+ }
50
+ }
51
+ /** Balance pre-check: warns when low, errors when empty. */
52
+ export async function precheckBalance(client) {
53
+ let balance;
54
+ try {
55
+ const data = await client.getBalance();
56
+ balance = Number(data.balance);
57
+ }
58
+ catch {
59
+ return; // pre-check must never block task creation on transient errors
60
+ }
61
+ if (Number.isNaN(balance))
62
+ return;
63
+ if (balance <= 0) {
64
+ throw new ApiError({ apiCode: 2010, suggestion: balanceSuggestion(undefined, balance) });
65
+ }
66
+ if (balance < 30) {
67
+ warn(t({
68
+ zh: `余额仅剩 ${balance} 积分,任务可能因余额不足失败(tripo topup 充值)`,
69
+ en: `balance is down to ${balance} credits; tasks may fail (run "tripo topup")`,
70
+ }));
71
+ }
72
+ }
73
+ /** Submit one task and (by default) watch it to completion + download. */
74
+ export async function runTask(options) {
75
+ const { client, request, taskType } = options;
76
+ if (options.precheckBalance !== false)
77
+ await precheckBalance(client);
78
+ const taskId = await client.createTask(request.endpoint, request.payload);
79
+ const name = uniqueName(slugify(options.name));
80
+ const entry = {
81
+ task_id: taskId,
82
+ type: taskType,
83
+ name,
84
+ status: 'queued',
85
+ created_at: new Date().toISOString(),
86
+ endpoint: request.endpoint,
87
+ payload: request.payload,
88
+ };
89
+ appendHistory(entry);
90
+ updateContext({ last_task_id: taskId });
91
+ log(t({ zh: `任务已创建 ${taskId} (@${name})`, en: `task created ${taskId} (@${name})` }));
92
+ if (options.noWait) {
93
+ return { task_id: taskId, type: taskType, status: 'queued' };
94
+ }
95
+ const spinner = createSpinner(`${taskType} · queued`, isQuiet());
96
+ let task;
97
+ try {
98
+ task = await (await import('./watch.js')).watchTask(client, taskId, {
99
+ timeoutMs: options.timeoutMs,
100
+ onProgress: (detail) => {
101
+ const pct = typeof detail.progress === 'number' ? ` ${detail.progress}%` : '';
102
+ spinner.update(`${taskType} · ${detail.status}${pct} · ${taskId.slice(0, 8)}`);
103
+ },
104
+ });
105
+ }
106
+ catch (err) {
107
+ spinner.fail(`${taskType} · ${taskId}`);
108
+ updateHistoryStatus(taskId, 'failed');
109
+ throw err;
110
+ }
111
+ updateHistoryStatus(taskId, 'success', { credits_consumed: task.credits_consumed });
112
+ spinner.succeed(`${taskType} · ${taskId}` +
113
+ (task.credits_consumed !== undefined
114
+ ? t({ zh: ` · 消耗 ${task.credits_consumed} 积分`, en: ` · ${task.credits_consumed} credits` })
115
+ : ''));
116
+ const result = {
117
+ task_id: taskId,
118
+ type: taskType,
119
+ status: task.status,
120
+ credits_consumed: task.credits_consumed,
121
+ };
122
+ if (!options.noDownload) {
123
+ const baseDir = options.outDir ?? loadContext().output_dir ?? process.cwd();
124
+ const dir = defaultOutputDir(baseDir, name, taskId);
125
+ const dlSpinner = createSpinner(t({ zh: '下载产物…', en: 'downloading artifacts…' }), isQuiet());
126
+ try {
127
+ const artifacts = await downloadArtifacts(task, dir, entry, {
128
+ onFile: (f) => dlSpinner.update(t({ zh: `下载 ${f}`, en: `downloading ${f}` })),
129
+ });
130
+ dlSpinner.succeed(t({ zh: `产物已保存 ${artifacts.outputDir}`, en: `artifacts saved to ${artifacts.outputDir}` }));
131
+ result.output_dir = artifacts.outputDir;
132
+ result.files = artifacts.files.map((f) => basename(f));
133
+ result.model_file = artifacts.modelFile;
134
+ result.preview = artifacts.preview;
135
+ updateHistoryStatus(taskId, 'success', { output_dir: artifacts.outputDir });
136
+ }
137
+ catch (err) {
138
+ dlSpinner.fail(t({ zh: '产物下载失败(任务本身已成功)', en: 'artifact download failed (the task itself succeeded)' }));
139
+ warn(String(err instanceof Error ? err.message : err));
140
+ warn(t({ zh: `可重试:tripo task get ${taskId} --download`, en: `retry with: tripo task get ${taskId} --download` }));
141
+ }
142
+ }
143
+ if (options.notify) {
144
+ notifyDone('Tripo', t({ zh: `${taskType} 完成`, en: `${taskType} finished` }));
145
+ }
146
+ return result;
147
+ }
148
+ /** Ensure history names stay unique by appending -2, -3, ... on collision. */
149
+ function uniqueName(base) {
150
+ const names = new Set(readHistory().map((e) => e.name));
151
+ if (!names.has(base))
152
+ return base;
153
+ for (let i = 2;; i++) {
154
+ const candidate = `${base}-${i}`;
155
+ if (!names.has(candidate))
156
+ return candidate;
157
+ }
158
+ }
159
+ export function topupUrl() {
160
+ return `${resolvePlatformBaseUrl()}/billing`;
161
+ }
@@ -0,0 +1,36 @@
1
+ /** Local file → file_token upload with size/type guards. */
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { basename, extname } from 'node:path';
4
+ import { UsageError } from './errors.js';
5
+ import { IMAGE_EXTS, MODEL_EXTS } from '../util/input-detect.js';
6
+ const IMAGE_MAX = 20 * 1024 * 1024;
7
+ const MODEL_MAX = 150 * 1024 * 1024;
8
+ const MIME = {
9
+ '.png': 'image/png',
10
+ '.jpg': 'image/jpeg',
11
+ '.jpeg': 'image/jpeg',
12
+ '.webp': 'image/webp',
13
+ '.bmp': 'image/bmp',
14
+ '.glb': 'model/gltf-binary',
15
+ '.gltf': 'model/gltf+json',
16
+ '.fbx': 'application/octet-stream',
17
+ '.obj': 'text/plain',
18
+ '.stl': 'application/octet-stream',
19
+ };
20
+ export async function uploadLocalFile(client, filePath) {
21
+ const ext = extname(filePath).toLowerCase();
22
+ const isImage = IMAGE_EXTS.has(ext);
23
+ const isModel = MODEL_EXTS.has(ext);
24
+ if (!isImage && !isModel) {
25
+ throw new UsageError(`unsupported file type "${ext}" for upload`);
26
+ }
27
+ const info = await stat(filePath);
28
+ const limit = isImage ? IMAGE_MAX : MODEL_MAX;
29
+ if (info.size > limit) {
30
+ throw new UsageError(`${basename(filePath)} is ${(info.size / 1024 / 1024).toFixed(1)}MB, exceeding the ${limit / 1024 / 1024}MB ${isImage ? 'image' : 'model'} limit`);
31
+ }
32
+ const bytes = await readFile(filePath);
33
+ const blob = new Blob([bytes], { type: MIME[ext] ?? 'application/octet-stream' });
34
+ const { file_token } = await client.uploadFile(basename(filePath), blob);
35
+ return file_token;
36
+ }
@@ -0,0 +1,24 @@
1
+ /** Static HTML template for the local model-viewer preview page. */
2
+ export function viewerHtml(modelFileName, title) {
3
+ return `<!doctype html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="utf-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <title>${escapeHtml(title)} · Tripo</title>
9
+ <script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"></script>
10
+ <style>
11
+ html, body { margin: 0; height: 100%; background: #101014; color: #e8e8ec; font: 14px/1.5 system-ui, sans-serif; }
12
+ model-viewer { width: 100%; height: 100%; --poster-color: transparent; }
13
+ .bar { position: fixed; top: 0; left: 0; right: 0; display: flex; justify-content: space-between; padding: 10px 16px; font-size: 13px; color: #9a9aa6; pointer-events: none; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <div class="bar"><span>${escapeHtml(title)}</span><span>drag to rotate · scroll to zoom</span></div>
18
+ <model-viewer src="${encodeURI(modelFileName)}" camera-controls auto-rotate shadow-intensity="1" exposure="0.9" ar></model-viewer>
19
+ </body>
20
+ </html>`;
21
+ }
22
+ function escapeHtml(text) {
23
+ return text.replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
24
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Local 3D preview: serve the model file + a model-viewer page on an
3
+ * ephemeral localhost port. The browser is an optional enhancement — the
4
+ * files are already on disk; headless callers just get the file path.
5
+ */
6
+ import { createServer } from 'node:http';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { basename, extname } from 'node:path';
9
+ import { viewerHtml } from './viewer-assets.js';
10
+ const CONTENT_TYPES = {
11
+ '.glb': 'model/gltf-binary',
12
+ '.gltf': 'model/gltf+json',
13
+ '.html': 'text/html; charset=utf-8',
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.webp': 'image/webp',
17
+ '.usdz': 'model/vnd.usdz+zip',
18
+ };
19
+ /** Start a one-model preview server; resolves once listening. */
20
+ export async function startViewer(modelFile, title) {
21
+ const fileName = basename(modelFile);
22
+ const server = createServer((req, res) => {
23
+ void (async () => {
24
+ const url = new URL(req.url ?? '/', 'http://localhost');
25
+ try {
26
+ if (url.pathname === '/') {
27
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
28
+ res.end(viewerHtml(fileName, title));
29
+ return;
30
+ }
31
+ if (decodeURIComponent(url.pathname.slice(1)) === fileName) {
32
+ const data = await readFile(modelFile);
33
+ res.writeHead(200, {
34
+ 'Content-Type': CONTENT_TYPES[extname(fileName).toLowerCase()] ?? 'application/octet-stream',
35
+ 'Content-Length': data.length,
36
+ });
37
+ res.end(data);
38
+ return;
39
+ }
40
+ res.writeHead(404);
41
+ res.end('not found');
42
+ }
43
+ catch {
44
+ res.writeHead(500);
45
+ res.end('error');
46
+ }
47
+ })();
48
+ });
49
+ await new Promise((resolve, reject) => {
50
+ server.once('error', reject);
51
+ server.listen(0, '127.0.0.1', resolve);
52
+ });
53
+ const address = server.address();
54
+ const port = typeof address === 'object' && address ? address.port : 0;
55
+ return {
56
+ url: `http://127.0.0.1:${port}/`,
57
+ close: () => server.close(),
58
+ };
59
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Adaptive task polling: start at 2s; when the API reports running_left_time
3
+ * take max(2, left*0.5); otherwise back off exponentially capped at 30s.
4
+ */
5
+ import { sleep } from './client.js';
6
+ import { TaskFailedError } from './errors.js';
7
+ export async function watchTask(client, taskId, options = {}) {
8
+ const timeoutMs = options.timeoutMs ?? 30 * 60 * 1000;
9
+ const deadline = Date.now() + timeoutMs;
10
+ let interval = 2000;
11
+ let backoff = 2000;
12
+ for (;;) {
13
+ const task = await client.getTask(taskId);
14
+ options.onProgress?.(task);
15
+ if (task.status === 'success')
16
+ return task;
17
+ if (task.status === 'failed' || task.status === 'cancelled') {
18
+ if (options.throwOnFailure === false)
19
+ return task;
20
+ throw new TaskFailedError(taskId, task.status, extractFailureReason(task));
21
+ }
22
+ if (Date.now() > deadline) {
23
+ throw new TaskFailedError(taskId, 'timeout', `still ${task.status} after ${Math.round(timeoutMs / 1000)}s (task keeps running server-side; check later with "tripo task get ${taskId}")`);
24
+ }
25
+ const left = task.running_left_time ?? task.output?.running_left_time;
26
+ if (typeof left === 'number' && left > 0) {
27
+ interval = Math.max(2000, left * 500);
28
+ backoff = 2000;
29
+ }
30
+ else {
31
+ interval = backoff;
32
+ backoff = Math.min(backoff * 1.5, 30_000);
33
+ }
34
+ await sleep(interval);
35
+ }
36
+ }
37
+ function extractFailureReason(task) {
38
+ const out = task.output ?? {};
39
+ const candidate = out.message ?? out.error ?? out.reason;
40
+ return typeof candidate === 'string' ? candidate : undefined;
41
+ }
@@ -0,0 +1,19 @@
1
+ /** Minimal bilingual UX strings. Locale: config override > env (LANG/LC_ALL). */
2
+ let currentLocale;
3
+ export function setLocale(locale) {
4
+ currentLocale = locale;
5
+ }
6
+ export function getLocale() {
7
+ if (currentLocale)
8
+ return currentLocale;
9
+ const env = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '';
10
+ return /^zh/i.test(env) ? 'zh' : 'en';
11
+ }
12
+ /** Pick the localized variant of a bilingual pair. */
13
+ export function t(pair) {
14
+ return getLocale() === 'zh' ? pair.zh : pair.en;
15
+ }
16
+ /** Pick the localized variant of a bilingual string list. */
17
+ export function tList(pair) {
18
+ return getLocale() === 'zh' ? pair.zh : pair.en;
19
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Task-chain legality — which processing step may consume which upstream task.
3
+ * Mirrors server-side handler constraints; violations are rejected locally
4
+ * before any credits are spent.
5
+ */
6
+ /** Task types that produce a downloadable 3D model. */
7
+ export const MODEL_PRODUCING_TYPES = new Set([
8
+ 'text_to_model',
9
+ 'image_to_model',
10
+ 'multiview_to_model',
11
+ 'refine_model',
12
+ 'texture_model',
13
+ 'stylize_model',
14
+ 'convert_model',
15
+ 'import_model',
16
+ 'highpoly_to_lowpoly',
17
+ 'mesh_segmentation',
18
+ 'mesh_completion',
19
+ 'animate_rig',
20
+ 'animate_retarget',
21
+ ]);
22
+ export const STEPS = [
23
+ { name: 'refine', endpoint: '/v3/models/refine', produces: 'refine_model', accepts: ['text_to_model'], acceptsFile: false },
24
+ { name: 'texture', endpoint: '/v3/models/texture', produces: 'texture_model', accepts: 'any-model', acceptsFile: true },
25
+ { name: 'stylize', endpoint: '/v3/models/stylize', produces: 'stylize_model', accepts: 'any-model', acceptsFile: true },
26
+ { name: 'convert', endpoint: '/v3/models/convert', produces: 'convert_model', accepts: 'any-model', acceptsFile: true },
27
+ { name: 'import', endpoint: '/v3/models/import', produces: 'import_model', accepts: [], acceptsFile: true },
28
+ { name: 'rig-check', endpoint: '/v3/animations/rig-check', produces: 'animate_prerigcheck', accepts: 'any-model', acceptsFile: true, passthrough: true, aliases: ['check', 'rigcheck'] },
29
+ { name: 'rig', endpoint: '/v3/animations/rig', produces: 'animate_rig', accepts: 'any-model', acceptsFile: true },
30
+ { name: 'retarget', endpoint: '/v3/animations/retarget', produces: 'animate_retarget', accepts: ['animate_rig'], acceptsFile: false, aliases: ['animate'] },
31
+ { name: 'segment', endpoint: '/v3/mesh/segment', produces: 'mesh_segmentation', accepts: 'any-model', acceptsFile: true },
32
+ { name: 'complete', endpoint: '/v3/mesh/complete', produces: 'mesh_completion', accepts: ['mesh_segmentation'], acceptsFile: false },
33
+ { name: 'decimate', endpoint: '/v3/mesh/decimate', produces: 'highpoly_to_lowpoly', accepts: 'any-model', acceptsFile: true, aliases: ['lowpoly'] },
34
+ { name: 'smartsegment', endpoint: '/v3/mesh/smartsegment', produces: 'smart_segmentation', accepts: 'any-model', acceptsFile: true },
35
+ ];
36
+ const stepIndex = new Map();
37
+ for (const s of STEPS) {
38
+ stepIndex.set(s.name, s);
39
+ for (const a of s.aliases ?? [])
40
+ stepIndex.set(a, s);
41
+ }
42
+ export function getStep(name) {
43
+ return stepIndex.get(name);
44
+ }
45
+ /**
46
+ * Parse a --then expression: "texture,rig,convert:fbx" or
47
+ * "convert:format=fbx,texture_size=2048". A bare value after ":" is sugar for
48
+ * the step's primary argument (convert→format, retarget→animation,
49
+ * stylize→style, decimate→face_limit).
50
+ */
51
+ export function parseThenExpression(expr) {
52
+ const out = [];
53
+ // Split on commas that are not inside a step's arg list (args use "=", so a
54
+ // comma always separates either steps or key=value pairs; a segment with "="
55
+ // and no known step prefix belongs to the previous step).
56
+ const rawParts = expr.split(',').map((p) => p.trim()).filter(Boolean);
57
+ for (const part of rawParts) {
58
+ const [head, ...rest] = part.split(':');
59
+ const stepName = (head ?? '').trim().toLowerCase();
60
+ const def = getStep(stepName);
61
+ if (!def) {
62
+ if (part.includes('=') && out.length > 0) {
63
+ // continuation of previous step's args: "k=v"
64
+ const prev = out[out.length - 1];
65
+ applyArg(prev.args, part);
66
+ continue;
67
+ }
68
+ throw new ChainError(`unknown step "${stepName}" (valid: ${STEPS.map((s) => s.name).join(', ')})`);
69
+ }
70
+ const args = {};
71
+ const argStr = rest.join(':').trim();
72
+ if (argStr) {
73
+ for (const token of argStr.split(/[,;]/).map((t) => t.trim()).filter(Boolean)) {
74
+ if (token.includes('='))
75
+ applyArg(args, token);
76
+ else
77
+ applyPrimaryArg(def.name, args, token);
78
+ }
79
+ }
80
+ out.push({ step: def.name, args });
81
+ }
82
+ return out;
83
+ }
84
+ function applyArg(args, token) {
85
+ const eq = token.indexOf('=');
86
+ const key = token.slice(0, eq).trim();
87
+ const raw = token.slice(eq + 1).trim();
88
+ args[key] = coerce(raw);
89
+ }
90
+ function applyPrimaryArg(step, args, value) {
91
+ const primary = {
92
+ convert: 'format',
93
+ retarget: 'animation',
94
+ stylize: 'style',
95
+ decimate: 'face_limit',
96
+ texture: 'texture_quality',
97
+ rig: 'rig_type',
98
+ segment: 'model',
99
+ smartsegment: 'granularity',
100
+ };
101
+ const key = primary[step];
102
+ if (!key)
103
+ throw new ChainError(`step "${step}" does not take a bare argument ("${value}")`);
104
+ args[key] = coerce(value);
105
+ }
106
+ function coerce(raw) {
107
+ if (raw === 'true')
108
+ return true;
109
+ if (raw === 'false')
110
+ return false;
111
+ if (/^-?\d+$/.test(raw))
112
+ return Number(raw);
113
+ if (/^-?\d*\.\d+$/.test(raw))
114
+ return Number(raw);
115
+ return raw;
116
+ }
117
+ export class ChainError extends Error {
118
+ }
119
+ /**
120
+ * Validate that a chain is legal given the type of the first (source) task.
121
+ * Returns the list of task types the chain will produce, in order.
122
+ */
123
+ export function validateChain(chain, sourceType) {
124
+ let currentType = sourceType;
125
+ const produced = [];
126
+ for (const { step } of chain) {
127
+ const def = getStep(step);
128
+ if (!def)
129
+ throw new ChainError(`unknown step "${step}"`);
130
+ if (def.accepts === 'any-model') {
131
+ if (!MODEL_PRODUCING_TYPES.has(currentType)) {
132
+ throw new ChainError(`step "${def.name}" needs a model-producing upstream task, but the previous step produces "${currentType}"`);
133
+ }
134
+ }
135
+ else if (!def.accepts.includes(currentType)) {
136
+ throw new ChainError(`step "${def.name}" only accepts ${def.accepts.join('/')} tasks, but the previous step produces "${currentType}"` +
137
+ hintFor(def.name));
138
+ }
139
+ if (!def.passthrough)
140
+ currentType = def.produces;
141
+ produced.push(def.produces);
142
+ }
143
+ return produced;
144
+ }
145
+ function hintFor(step) {
146
+ const hints = {
147
+ refine: ' (refine only accepts a successful text-to-model task)',
148
+ complete: ' (run "segment" first: complete only accepts mesh segmentation tasks)',
149
+ retarget: ' (run "rig" first: retarget only accepts rigged tasks)',
150
+ };
151
+ return hints[step] ?? '';
152
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * V3 error-code catalog: human translations (zh/en), retryability, and the
3
+ * CLI exit code each error maps to.
4
+ *
5
+ * Source of truth: backend ErrorCode enum (platform-common .../error/ErrorCode.java,
6
+ * itself aligned with openapi-server errors.gen.go). Note the public docs page
7
+ * is stale for 1000/1001 — the server actually sends 1002 for auth failures;
8
+ * 1000/1001 are 5xx server-side errors.
9
+ */
10
+ export const EXIT = {
11
+ OK: 0,
12
+ FATAL: 1,
13
+ USAGE: 2,
14
+ AUTH: 3,
15
+ BALANCE: 4,
16
+ CONTENT_POLICY: 5,
17
+ TASK_FAILED: 6,
18
+ NETWORK: 7,
19
+ NOT_FOUND: 8,
20
+ RATE_LIMIT: 9,
21
+ };
22
+ export const ERROR_CATALOG = [
23
+ { code: 1000, zh: '服务端未知错误,请携带 request_id 联系支持', en: 'Unknown server-side error; contact support with the request id', retryable: true, exitCode: EXIT.NETWORK },
24
+ { code: 1001, zh: '服务端严重错误,请携带 request_id 联系支持', en: 'Fatal server-side error; contact support with the request id', retryable: false, exitCode: EXIT.NETWORK },
25
+ { code: 1002, zh: '认证失败:API Key 无效或未正确设置', en: 'Authentication failed: the API key is invalid or not set correctly', retryable: false, exitCode: EXIT.AUTH },
26
+ { code: 1003, zh: '请求体格式不合法', en: 'The request body is malformed', retryable: false, exitCode: EXIT.USAGE },
27
+ { code: 1004, zh: '一个或多个参数不合法,请对照文档检查', en: 'One or more parameters are invalid (check the docs)', retryable: false, exitCode: EXIT.USAGE },
28
+ { code: 1005, zh: '无权访问该资源,请检查账号权限', en: 'Not allowed to access this resource (check permissions)', retryable: false, exitCode: EXIT.AUTH },
29
+ { code: 1007, zh: '请求过于频繁,触发全局速率限制', en: 'Rate limit exceeded — too many requests in a short time', retryable: true, exitCode: EXIT.RATE_LIMIT },
30
+ { code: 2000, zh: '触发生成并发上限,同系列任务共享并发池', en: 'Generation concurrency limit hit (task families share a pool)', retryable: true, exitCode: EXIT.RATE_LIMIT },
31
+ { code: 2001, zh: '任务不存在(确认任务属于当前 Key、区域正确)', en: 'Task not found (same key that created it? right region?)', retryable: false, exitCode: EXIT.NOT_FOUND },
32
+ { code: 2002, zh: '该任务类型不受支持', en: 'This task type is not supported', retryable: false, exitCode: EXIT.USAGE },
33
+ { code: 2003, zh: '输入图片文件为空,请检查文件是否损坏', en: 'The image file is empty (corrupted upload?)', retryable: false, exitCode: EXIT.USAGE },
34
+ { code: 2004, zh: '不支持的图片文件类型', en: 'Unsupported image file type', retryable: false, exitCode: EXIT.USAGE },
35
+ { code: 2005, zh: 'draft 任务不是成功状态,请用成功的任务重试', en: 'The draft task is not a success; retry with a successful one', retryable: false, exitCode: EXIT.USAGE },
36
+ { code: 2006, zh: '上游任务类型不受支持,请检查任务链', en: 'The original task type is not supported for this operation', retryable: false, exitCode: EXIT.USAGE },
37
+ { code: 2007, zh: '上游任务不是成功状态', en: 'The original task has not succeeded', retryable: false, exitCode: EXIT.USAGE },
38
+ { code: 2008, zh: '输入违反内容政策,请更换输入', en: 'Input violates the content policy; change the input', retryable: false, exitCode: EXIT.CONTENT_POLICY },
39
+ { code: 2009, zh: 'prompt 含有非法字符,请修改后重试', en: 'The prompt contains invalid characters', retryable: false, exitCode: EXIT.USAGE },
40
+ { code: 2010, zh: '积分不足', en: 'Insufficient credits', retryable: false, exitCode: EXIT.BALANCE },
41
+ { code: 2011, zh: 'rig-check 的输入任务没有模型产物', en: 'The rig-check input task has no model output', retryable: false, exitCode: EXIT.USAGE },
42
+ { code: 2012, zh: 'retarget 只接受 rig 任务作为输入', en: 'retarget only accepts a rig task as input', retryable: false, exitCode: EXIT.USAGE },
43
+ { code: 2013, zh: 'priority 取值无效(更高优先级需联系支持)', en: 'Invalid priority (contact support for higher priority)', retryable: false, exitCode: EXIT.USAGE },
44
+ { code: 2014, zh: '内容审核过程出错,请联系支持', en: 'Error occurred during content auditing; contact support', retryable: false, exitCode: EXIT.FATAL },
45
+ { code: 2015, zh: '模型版本已弃用,请升级 model 参数', en: 'Model version deprecated; upgrade the model parameter', retryable: false, exitCode: EXIT.USAGE },
46
+ { code: 2016, zh: '该请求类型已弃用,请改用其他类型', en: 'This request type is deprecated; use an alternative', retryable: false, exitCode: EXIT.USAGE },
47
+ { code: 2017, zh: 'model 版本取值无效', en: 'The model version value is invalid', retryable: false, exitCode: EXIT.USAGE },
48
+ { code: 2018, zh: '模型太复杂无法重网格化,降低 face_limit 或简化输入', en: 'Model too complex to remesh; lower face_limit or simplify input', retryable: false, exitCode: EXIT.USAGE },
49
+ { code: 2019, zh: '文件不存在,请确认上传是否成功、file_token 是否过期', en: 'File not found (upload succeeded? token expired?)', retryable: false, exitCode: EXIT.NOT_FOUND },
50
+ { code: 2020, zh: '图片 URL 无效,请确认可公开访问', en: 'The image URL is invalid (must be publicly reachable)', retryable: false, exitCode: EXIT.USAGE },
51
+ { code: 2021, zh: '文件大小超过上限,请压缩后重试', en: 'File size exceeds the limit; reduce and retry', retryable: false, exitCode: EXIT.USAGE },
52
+ { code: 2022, zh: '图片尺寸超过上限,请缩小后重试', en: 'Image size exceeds the limit; reduce and retry', retryable: false, exitCode: EXIT.USAGE },
53
+ { code: 4001, zh: '请求的接口不存在,请检查 API 路径', en: 'The requested endpoint does not exist (check the API path)', retryable: false, exitCode: EXIT.NOT_FOUND },
54
+ ];
55
+ const byCode = new Map(ERROR_CATALOG.map((e) => [e.code, e]));
56
+ export function lookupError(code) {
57
+ return byCode.get(code);
58
+ }
59
+ /** Map an API error code + HTTP status to a stable CLI exit code. */
60
+ export function exitCodeFor(apiCode, httpStatus) {
61
+ if (apiCode !== undefined) {
62
+ const info = byCode.get(apiCode);
63
+ if (info)
64
+ return info.exitCode;
65
+ }
66
+ if (httpStatus === 401)
67
+ return EXIT.AUTH;
68
+ if (httpStatus === 403)
69
+ return EXIT.AUTH;
70
+ if (httpStatus === 404)
71
+ return EXIT.NOT_FOUND;
72
+ if (httpStatus === 429)
73
+ return EXIT.RATE_LIMIT;
74
+ if (httpStatus !== undefined && httpStatus >= 500)
75
+ return EXIT.NETWORK;
76
+ if (httpStatus !== undefined && httpStatus >= 400)
77
+ return EXIT.USAGE;
78
+ return EXIT.FATAL;
79
+ }
80
+ export function isRetryable(apiCode, httpStatus) {
81
+ if (apiCode !== undefined) {
82
+ const info = byCode.get(apiCode);
83
+ if (info)
84
+ return info.retryable;
85
+ }
86
+ if (httpStatus === 429)
87
+ return true;
88
+ if (httpStatus !== undefined && httpStatus >= 500)
89
+ return true;
90
+ return false;
91
+ }