svmc-cli 1.0.0 → 1.0.1
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/README.md +89 -8
- package/dist/src/commands/generation.js +170 -0
- package/dist/src/core/parser.js +2 -2
- package/dist/src/index.js +75 -21
- package/dist/tests/parser.test.js +1 -1
- package/package.json +1 -1
- package/src/commands/generation.ts +245 -0
- package/src/core/parser.ts +9 -2
- package/src/index.ts +83 -24
- package/src/types/index.ts +27 -3
- package/tests/parser.test.ts +19 -16
- package/src/commands/image.ts +0 -104
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# svmc-cli
|
|
2
2
|
|
|
3
|
-
将 `media-center-frontend`
|
|
3
|
+
将 `media-center-frontend` 的图像/视频生成能力封装为可供 Agent 调用的命令行工具。
|
|
4
4
|
|
|
5
5
|
## 安装与构建
|
|
6
6
|
|
|
@@ -34,25 +34,25 @@ svmc auth status
|
|
|
34
34
|
## 获取参数规格
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
svmc
|
|
37
|
+
svmc img2img specs --provider nano-banana
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
需要显式传 Cookie 时:
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
|
-
svmc
|
|
43
|
+
svmc img2img specs --provider nano-banana --cookie "session=...; sl-session=..."
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
仅列出 provider 列表:
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
svmc
|
|
49
|
+
svmc img2img specs
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
## 发起图生图任务
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
svmc
|
|
55
|
+
svmc img2img generate \
|
|
56
56
|
--provider nano-banana \
|
|
57
57
|
--input "img1.jpg" \
|
|
58
58
|
--input "img2.png" \
|
|
@@ -63,7 +63,7 @@ svmc image generate \
|
|
|
63
63
|
支持传递动态参数(会原样透传给后端):
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
svmc
|
|
66
|
+
svmc img2img generate ... --steps 20 --guidance_scale 7 --cookie "session=...; sl-session=..."
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
任务轮询间隔固定 5 秒,完成后输出:
|
|
@@ -73,11 +73,38 @@ IMAGE_SAVED: /abs/path/to/file.jpg
|
|
|
73
73
|
MEDIA:/abs/path/to/file.jpg
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
## 其他任务命令
|
|
77
|
+
|
|
78
|
+
以下模块都遵循相同流程:动态获取 `params`、按 provider 规格校验参数、提交任务并轮询 `get_task`、下载结果。
|
|
79
|
+
|
|
80
|
+
- 修图:`svmc editImage specs` / `svmc editImage generate`
|
|
81
|
+
- 文生图:`svmc text2img specs` / `svmc text2img generate`
|
|
82
|
+
- 图生视频:`svmc img2video specs` / `svmc img2video generate`
|
|
83
|
+
- 文生视频:`svmc text2video specs` / `svmc text2video generate`
|
|
84
|
+
|
|
85
|
+
示例(修图):
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
svmc editImage generate \
|
|
89
|
+
--provider nano-banana \
|
|
90
|
+
--input "origin.jpg" \
|
|
91
|
+
--prompt "remove watermark"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
示例(文生视频):
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
svmc text2video generate \
|
|
98
|
+
--provider kling \
|
|
99
|
+
--prompt "a panda dancing in snowfall" \
|
|
100
|
+
--ratio "16:9"
|
|
101
|
+
```
|
|
102
|
+
|
|
76
103
|
## 端到端验收脚本
|
|
77
104
|
|
|
78
105
|
1. `svmc auth`,写入 token。
|
|
79
|
-
2. `svmc
|
|
80
|
-
3. `svmc
|
|
106
|
+
2. `svmc img2img specs --provider <provider>`,确认返回规格。
|
|
107
|
+
3. `svmc img2img generate --provider <provider> --input <img> --prompt "..."`。
|
|
81
108
|
4. 观察输出包含 `IMAGE_SAVED` 和 `MEDIA`,并确认本地文件存在。
|
|
82
109
|
|
|
83
110
|
## 质量命令
|
|
@@ -87,3 +114,57 @@ npm run lint:eslint
|
|
|
87
114
|
npm run type-check
|
|
88
115
|
npm test
|
|
89
116
|
```
|
|
117
|
+
|
|
118
|
+
## npm 发布流程
|
|
119
|
+
|
|
120
|
+
发布前建议先确认当前 npm 登录账号:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npm whoami
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 1) 发布前检查
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npm install
|
|
130
|
+
npm run lint:eslint
|
|
131
|
+
npm run type-check
|
|
132
|
+
npm test
|
|
133
|
+
npm run build
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 2) 更新版本号
|
|
137
|
+
|
|
138
|
+
按语义化版本选择其一:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npm version patch
|
|
142
|
+
# 或 npm version minor
|
|
143
|
+
# 或 npm version major
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 3) 执行发布
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npm publish
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
如果是首次发布非 scoped 包,使用:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npm publish --access public
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### 4) 发布后验证
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npm view svmc-cli version
|
|
162
|
+
npm view svmc-cli dist-tags
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
如需快速验证安装:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npm i -g svmc-cli@latest
|
|
169
|
+
svmc --version
|
|
170
|
+
```
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runModuleSpecsCommand = runModuleSpecsCommand;
|
|
4
|
+
exports.runModuleGenerateCommand = runModuleGenerateCommand;
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const apiClient_1 = require("../core/apiClient");
|
|
7
|
+
const config_1 = require("../core/config");
|
|
8
|
+
const downloader_1 = require("../core/downloader");
|
|
9
|
+
const errors_1 = require("../core/errors");
|
|
10
|
+
const parser_1 = require("../core/parser");
|
|
11
|
+
const poller_1 = require("../core/poller");
|
|
12
|
+
async function runModuleSpecsCommand(meta, options) {
|
|
13
|
+
const token = await (0, config_1.getAccessToken)(options.configPath);
|
|
14
|
+
const baseUrl = await (0, config_1.getBaseUrl)(options.baseUrl, options.configPath);
|
|
15
|
+
const cookie = await (0, config_1.getSessionCookie)(options.cookie, options.configPath);
|
|
16
|
+
const client = new apiClient_1.ApiClient(baseUrl, token, cookie);
|
|
17
|
+
const params = await client.get(`/${meta.moduleRoot}/params`);
|
|
18
|
+
const moduleConfig = readModuleConfig(params, meta);
|
|
19
|
+
const suppliers = moduleConfig.for_supplier ?? {};
|
|
20
|
+
if (!options.provider) {
|
|
21
|
+
process.stdout.write(`${JSON.stringify({ providers: Object.keys(suppliers) }, null, 2)}\n`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const providerSpec = suppliers[options.provider];
|
|
25
|
+
if (!providerSpec) {
|
|
26
|
+
throw new errors_1.CliError(`${meta.displayName} 未找到 provider: ${options.provider}`, errors_1.EXIT_CODES.USAGE);
|
|
27
|
+
}
|
|
28
|
+
process.stdout.write(`${JSON.stringify({ module: meta.commandName, provider: options.provider, spec: providerSpec }, null, 2)}\n`);
|
|
29
|
+
}
|
|
30
|
+
async function runModuleGenerateCommand(meta, options) {
|
|
31
|
+
const parsed = (0, parser_1.parseGenerateCliArgs)(options.rawArgs, {
|
|
32
|
+
requireInput: meta.requireInput,
|
|
33
|
+
});
|
|
34
|
+
await (0, parser_1.ensureInputsReadable)(parsed.input);
|
|
35
|
+
const token = await (0, config_1.getAccessToken)(options.configPath);
|
|
36
|
+
const baseUrl = await (0, config_1.getBaseUrl)(parsed.baseUrl, options.configPath);
|
|
37
|
+
const cookie = await (0, config_1.getSessionCookie)(parsed.cookie, options.configPath);
|
|
38
|
+
const client = new apiClient_1.ApiClient(baseUrl, token, cookie);
|
|
39
|
+
const params = await client.get(`/${meta.moduleRoot}/params`);
|
|
40
|
+
const moduleConfig = readModuleConfig(params, meta);
|
|
41
|
+
const providerSpec = readProviderSpec(moduleConfig, parsed.provider, meta.displayName);
|
|
42
|
+
const submitPayload = {
|
|
43
|
+
supplier: parsed.provider,
|
|
44
|
+
...parsed.dynamicParams,
|
|
45
|
+
};
|
|
46
|
+
if (parsed.input.length > 0) {
|
|
47
|
+
const imageData = await (0, parser_1.readImagesAsDataUrls)(parsed.input);
|
|
48
|
+
submitPayload.image = imageData;
|
|
49
|
+
}
|
|
50
|
+
if (parsed.prompt) {
|
|
51
|
+
submitPayload.prompt = parsed.prompt;
|
|
52
|
+
}
|
|
53
|
+
if (parsed.ratio) {
|
|
54
|
+
submitPayload.ratio = parsed.ratio;
|
|
55
|
+
}
|
|
56
|
+
validatePayloadBySpec(providerSpec, submitPayload);
|
|
57
|
+
if (parsed.verbose) {
|
|
58
|
+
process.stdout.write(`[generate] module=${meta.commandName} provider=${parsed.provider} inputs=${parsed.input.length}\n`);
|
|
59
|
+
}
|
|
60
|
+
const submittedTask = await client.post(meta.submitPath, submitPayload);
|
|
61
|
+
const taskUuid = submittedTask.task_uuid;
|
|
62
|
+
if (!taskUuid) {
|
|
63
|
+
throw new errors_1.CliError("提交成功但未返回 task_uuid。", errors_1.EXIT_CODES.API);
|
|
64
|
+
}
|
|
65
|
+
process.stdout.write(`TASK_UUID: ${taskUuid}\n`);
|
|
66
|
+
const completedTask = await (0, poller_1.pollTaskUntilDone)(() => client.get(`/${meta.moduleRoot}/get_task?task_uuid=${taskUuid}`), {
|
|
67
|
+
intervalMs: 5000,
|
|
68
|
+
timeoutMs: parsed.timeoutSec * 1000,
|
|
69
|
+
verbose: parsed.verbose,
|
|
70
|
+
});
|
|
71
|
+
const results = completedTask.results ?? [];
|
|
72
|
+
if (results.length === 0) {
|
|
73
|
+
throw new errors_1.CliError(`任务已完成,但未返回任何${meta.outputKind === "IMAGE" ? "图片" : "视频"}结果。`, errors_1.EXIT_CODES.TASK_FAILED);
|
|
74
|
+
}
|
|
75
|
+
const outputDir = parsed.outputDir ? (0, node_path_1.join)(process.cwd(), parsed.outputDir) : process.cwd();
|
|
76
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
77
|
+
const item = results[index];
|
|
78
|
+
if (!item.result_url) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const name = `${meta.outputPrefix}_${taskUuid}_${Date.now()}_${index + 1}.${item.format || (meta.outputKind === "VIDEO" ? "mp4" : "jpg")}`;
|
|
82
|
+
const localPath = await (0, downloader_1.downloadToLocal)(item.result_url, outputDir, name);
|
|
83
|
+
process.stdout.write(`${meta.outputKind}_SAVED: ${localPath}\n`);
|
|
84
|
+
process.stdout.write(`MEDIA:${localPath}\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function readModuleConfig(params, meta) {
|
|
88
|
+
const moduleConfig = params[meta.paramsKey];
|
|
89
|
+
if (!moduleConfig || typeof moduleConfig !== "object") {
|
|
90
|
+
throw new errors_1.CliError(`${meta.displayName} 参数配置不存在,请检查接口 /${meta.moduleRoot}/params`, errors_1.EXIT_CODES.API);
|
|
91
|
+
}
|
|
92
|
+
return moduleConfig;
|
|
93
|
+
}
|
|
94
|
+
function readProviderSpec(moduleConfig, provider, displayName) {
|
|
95
|
+
const providerSpec = moduleConfig.for_supplier?.[provider];
|
|
96
|
+
if (!providerSpec) {
|
|
97
|
+
throw new errors_1.CliError(`${displayName} 未找到 provider: ${provider}`, errors_1.EXIT_CODES.USAGE);
|
|
98
|
+
}
|
|
99
|
+
if (!providerSpec.properties || !Array.isArray(providerSpec.required)) {
|
|
100
|
+
throw new errors_1.CliError(`${displayName} provider=${provider} 规格格式不合法,缺少 properties/required`, errors_1.EXIT_CODES.API);
|
|
101
|
+
}
|
|
102
|
+
return providerSpec;
|
|
103
|
+
}
|
|
104
|
+
function validatePayloadBySpec(spec, payload) {
|
|
105
|
+
const properties = spec.properties ?? {};
|
|
106
|
+
for (const requiredKey of spec.required) {
|
|
107
|
+
const value = payload[requiredKey];
|
|
108
|
+
if (isMissingValue(value)) {
|
|
109
|
+
throw new errors_1.CliError(`缺少必填参数: --${requiredKey}`, errors_1.EXIT_CODES.USAGE);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
113
|
+
if (key === "supplier" || value === undefined) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const schema = properties[key];
|
|
117
|
+
if (!schema) {
|
|
118
|
+
throw new errors_1.CliError(`参数 --${key} 不在 provider 规格中,请先执行 specs 查看可用参数。`, errors_1.EXIT_CODES.USAGE);
|
|
119
|
+
}
|
|
120
|
+
if (!isValueConformingSchema(value, schema)) {
|
|
121
|
+
const expectedType = readExpectedTypeText(schema);
|
|
122
|
+
throw new errors_1.CliError(`参数 --${key} 类型不匹配,期望 ${expectedType}`, errors_1.EXIT_CODES.USAGE);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function isMissingValue(value) {
|
|
127
|
+
if (value === undefined || value === null) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (typeof value === "string") {
|
|
131
|
+
return value.trim().length === 0;
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
return value.length === 0;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
function isValueConformingSchema(value, schema) {
|
|
139
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
140
|
+
return schema.anyOf.some((item) => isValueMatchingType(value, item.type));
|
|
141
|
+
}
|
|
142
|
+
if (typeof schema.type === "string") {
|
|
143
|
+
return isValueMatchingType(value, schema.type);
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
function isValueMatchingType(value, schemaType) {
|
|
148
|
+
switch (schemaType) {
|
|
149
|
+
case "string":
|
|
150
|
+
return typeof value === "string";
|
|
151
|
+
case "integer":
|
|
152
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
153
|
+
case "number":
|
|
154
|
+
return typeof value === "number";
|
|
155
|
+
case "boolean":
|
|
156
|
+
return typeof value === "boolean";
|
|
157
|
+
case "array":
|
|
158
|
+
return Array.isArray(value);
|
|
159
|
+
case "object":
|
|
160
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
161
|
+
default:
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function readExpectedTypeText(schema) {
|
|
166
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
167
|
+
return schema.anyOf.map((item) => item.type).join(" | ");
|
|
168
|
+
}
|
|
169
|
+
return typeof schema.type === "string" ? schema.type : "任意类型";
|
|
170
|
+
}
|
package/dist/src/core/parser.js
CHANGED
|
@@ -9,7 +9,7 @@ exports.readImagesAsDataUrls = readImagesAsDataUrls;
|
|
|
9
9
|
const promises_1 = require("node:fs/promises");
|
|
10
10
|
const yargs_parser_1 = __importDefault(require("yargs-parser"));
|
|
11
11
|
const errors_1 = require("./errors");
|
|
12
|
-
function parseGenerateCliArgs(rawArgs) {
|
|
12
|
+
function parseGenerateCliArgs(rawArgs, options) {
|
|
13
13
|
const parsed = (0, yargs_parser_1.default)(rawArgs, {
|
|
14
14
|
array: ["input"],
|
|
15
15
|
string: ["provider", "prompt", "ratio", "output-dir", "base-url", "cookie", "config-path"],
|
|
@@ -28,7 +28,7 @@ function parseGenerateCliArgs(rawArgs) {
|
|
|
28
28
|
throw new errors_1.CliError("缺少 `--provider` 参数。", errors_1.EXIT_CODES.USAGE);
|
|
29
29
|
}
|
|
30
30
|
const input = toStringArray(parsed.input);
|
|
31
|
-
if (input.length === 0) {
|
|
31
|
+
if (options.requireInput && input.length === 0) {
|
|
32
32
|
throw new errors_1.CliError("至少传入一个 `--input` 图片路径。", errors_1.EXIT_CODES.USAGE);
|
|
33
33
|
}
|
|
34
34
|
const timeoutSec = typeof parsed["timeout-sec"] === "number" ? parsed["timeout-sec"] : 600;
|
package/dist/src/index.js
CHANGED
|
@@ -5,7 +5,7 @@ const commander_1 = require("commander");
|
|
|
5
5
|
const node_fs_1 = require("node:fs");
|
|
6
6
|
const node_path_1 = require("node:path");
|
|
7
7
|
const auth_1 = require("./commands/auth");
|
|
8
|
-
const
|
|
8
|
+
const generation_1 = require("./commands/generation");
|
|
9
9
|
const errors_1 = require("./core/errors");
|
|
10
10
|
async function run(argv = process.argv) {
|
|
11
11
|
const program = new commander_1.Command();
|
|
@@ -32,27 +32,81 @@ async function run(argv = process.argv) {
|
|
|
32
32
|
.action(async (options) => {
|
|
33
33
|
await (0, auth_1.runAuthStatusCommand)(options);
|
|
34
34
|
});
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
35
|
+
const taskModules = [
|
|
36
|
+
{
|
|
37
|
+
commandName: "img2img",
|
|
38
|
+
displayName: "图生图",
|
|
39
|
+
moduleRoot: "image_generation",
|
|
40
|
+
paramsKey: "img2img",
|
|
41
|
+
submitPath: "/image_generation/img2img",
|
|
42
|
+
requireInput: true,
|
|
43
|
+
outputPrefix: "img2img",
|
|
44
|
+
outputKind: "IMAGE",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
commandName: "editImage",
|
|
48
|
+
displayName: "修图",
|
|
49
|
+
moduleRoot: "image_generation",
|
|
50
|
+
paramsKey: "edit_image",
|
|
51
|
+
submitPath: "/image_generation/edit_image",
|
|
52
|
+
requireInput: true,
|
|
53
|
+
outputPrefix: "edit_image",
|
|
54
|
+
outputKind: "IMAGE",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
commandName: "text2img",
|
|
58
|
+
displayName: "文生图",
|
|
59
|
+
moduleRoot: "image_generation",
|
|
60
|
+
paramsKey: "text2img",
|
|
61
|
+
submitPath: "/image_generation/text2img",
|
|
62
|
+
requireInput: false,
|
|
63
|
+
outputPrefix: "text2img",
|
|
64
|
+
outputKind: "IMAGE",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
commandName: "img2video",
|
|
68
|
+
displayName: "图生视频",
|
|
69
|
+
moduleRoot: "video_generation",
|
|
70
|
+
paramsKey: "img2video",
|
|
71
|
+
submitPath: "/video_generation/img2video",
|
|
72
|
+
requireInput: true,
|
|
73
|
+
outputPrefix: "img2video",
|
|
74
|
+
outputKind: "VIDEO",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
commandName: "text2video",
|
|
78
|
+
displayName: "文生视频",
|
|
79
|
+
moduleRoot: "video_generation",
|
|
80
|
+
paramsKey: "text2video",
|
|
81
|
+
submitPath: "/video_generation/text2video",
|
|
82
|
+
requireInput: false,
|
|
83
|
+
outputPrefix: "text2video",
|
|
84
|
+
outputKind: "VIDEO",
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
for (const meta of taskModules) {
|
|
88
|
+
const task = program.command(meta.commandName).description(`${meta.displayName}命令`);
|
|
89
|
+
task
|
|
90
|
+
.command("specs")
|
|
91
|
+
.description(`获取 ${meta.commandName} 参数规格`)
|
|
92
|
+
.option("--provider <provider>", "指定供应商,仅输出该供应商规格")
|
|
93
|
+
.option("--base-url <url>", "服务地址")
|
|
94
|
+
.option("--cookie <cookie>", "会话 Cookie,可覆盖配置")
|
|
95
|
+
.option("--config-path <path>", "指定配置文件路径")
|
|
96
|
+
.action(async (options) => (0, generation_1.runModuleSpecsCommand)(meta, options));
|
|
97
|
+
task
|
|
98
|
+
.command("generate")
|
|
99
|
+
.description(`提交 ${meta.commandName} 任务并轮询下载结果`)
|
|
100
|
+
.allowUnknownOption(true)
|
|
101
|
+
.option("--config-path <path>", "指定配置文件路径")
|
|
102
|
+
.action(async (options) => {
|
|
103
|
+
const rawArgs = getRawArgsAfterToken(argv, "generate");
|
|
104
|
+
await (0, generation_1.runModuleGenerateCommand)(meta, {
|
|
105
|
+
rawArgs,
|
|
106
|
+
configPath: options.configPath,
|
|
107
|
+
});
|
|
54
108
|
});
|
|
55
|
-
}
|
|
109
|
+
}
|
|
56
110
|
try {
|
|
57
111
|
await program.parseAsync(argv);
|
|
58
112
|
}
|
|
@@ -19,7 +19,7 @@ const parser_1 = require("../src/core/parser");
|
|
|
19
19
|
"20",
|
|
20
20
|
"--negative_prompt",
|
|
21
21
|
"low quality",
|
|
22
|
-
]);
|
|
22
|
+
], { requireInput: true });
|
|
23
23
|
(0, vitest_1.expect)(args.provider).toBe("nano-banana");
|
|
24
24
|
(0, vitest_1.expect)(args.input).toEqual(["a.jpg", "b.png"]);
|
|
25
25
|
(0, vitest_1.expect)(args.prompt).toBe("cyberpunk");
|
package/package.json
CHANGED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ApiClient } from "../core/apiClient";
|
|
3
|
+
import { getAccessToken, getBaseUrl, getSessionCookie } from "../core/config";
|
|
4
|
+
import { downloadToLocal } from "../core/downloader";
|
|
5
|
+
import { CliError, EXIT_CODES } from "../core/errors";
|
|
6
|
+
import { ensureInputsReadable, parseGenerateCliArgs, readImagesAsDataUrls } from "../core/parser";
|
|
7
|
+
import { pollTaskUntilDone } from "../core/poller";
|
|
8
|
+
import type {
|
|
9
|
+
CommonGenerationTask,
|
|
10
|
+
ModuleParamsConfig,
|
|
11
|
+
MultiModuleParamsResponse,
|
|
12
|
+
PropertySchema,
|
|
13
|
+
SupplierSchema,
|
|
14
|
+
TaskModuleId,
|
|
15
|
+
} from "../types";
|
|
16
|
+
|
|
17
|
+
export interface SpecsCommandOptions {
|
|
18
|
+
provider?: string;
|
|
19
|
+
configPath?: string;
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
cookie?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GenerateCommandOptions {
|
|
25
|
+
rawArgs: string[];
|
|
26
|
+
configPath?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TaskModuleMeta {
|
|
30
|
+
commandName: TaskModuleId;
|
|
31
|
+
displayName: string;
|
|
32
|
+
moduleRoot: "image_generation" | "video_generation";
|
|
33
|
+
paramsKey: string;
|
|
34
|
+
submitPath: string;
|
|
35
|
+
requireInput: boolean;
|
|
36
|
+
outputPrefix: string;
|
|
37
|
+
outputKind: "IMAGE" | "VIDEO";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runModuleSpecsCommand(
|
|
41
|
+
meta: TaskModuleMeta,
|
|
42
|
+
options: SpecsCommandOptions,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const token = await getAccessToken(options.configPath);
|
|
45
|
+
const baseUrl = await getBaseUrl(options.baseUrl, options.configPath);
|
|
46
|
+
const cookie = await getSessionCookie(options.cookie, options.configPath);
|
|
47
|
+
const client = new ApiClient(baseUrl, token, cookie);
|
|
48
|
+
|
|
49
|
+
const params = await client.get<MultiModuleParamsResponse>(`/${meta.moduleRoot}/params`);
|
|
50
|
+
const moduleConfig = readModuleConfig(params, meta);
|
|
51
|
+
const suppliers = moduleConfig.for_supplier ?? {};
|
|
52
|
+
if (!options.provider) {
|
|
53
|
+
process.stdout.write(`${JSON.stringify({ providers: Object.keys(suppliers) }, null, 2)}\n`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const providerSpec = suppliers[options.provider];
|
|
58
|
+
if (!providerSpec) {
|
|
59
|
+
throw new CliError(
|
|
60
|
+
`${meta.displayName} 未找到 provider: ${options.provider}`,
|
|
61
|
+
EXIT_CODES.USAGE,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
process.stdout.write(
|
|
65
|
+
`${JSON.stringify({ module: meta.commandName, provider: options.provider, spec: providerSpec }, null, 2)}\n`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runModuleGenerateCommand(
|
|
70
|
+
meta: TaskModuleMeta,
|
|
71
|
+
options: GenerateCommandOptions,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
const parsed = parseGenerateCliArgs(options.rawArgs, {
|
|
74
|
+
requireInput: meta.requireInput,
|
|
75
|
+
});
|
|
76
|
+
await ensureInputsReadable(parsed.input);
|
|
77
|
+
const token = await getAccessToken(options.configPath);
|
|
78
|
+
const baseUrl = await getBaseUrl(parsed.baseUrl, options.configPath);
|
|
79
|
+
const cookie = await getSessionCookie(parsed.cookie, options.configPath);
|
|
80
|
+
const client = new ApiClient(baseUrl, token, cookie);
|
|
81
|
+
const params = await client.get<MultiModuleParamsResponse>(`/${meta.moduleRoot}/params`);
|
|
82
|
+
const moduleConfig = readModuleConfig(params, meta);
|
|
83
|
+
const providerSpec = readProviderSpec(moduleConfig, parsed.provider, meta.displayName);
|
|
84
|
+
|
|
85
|
+
const submitPayload: Record<string, unknown> = {
|
|
86
|
+
supplier: parsed.provider,
|
|
87
|
+
...parsed.dynamicParams,
|
|
88
|
+
};
|
|
89
|
+
if (parsed.input.length > 0) {
|
|
90
|
+
const imageData = await readImagesAsDataUrls(parsed.input);
|
|
91
|
+
submitPayload.image = imageData;
|
|
92
|
+
}
|
|
93
|
+
if (parsed.prompt) {
|
|
94
|
+
submitPayload.prompt = parsed.prompt;
|
|
95
|
+
}
|
|
96
|
+
if (parsed.ratio) {
|
|
97
|
+
submitPayload.ratio = parsed.ratio;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
validatePayloadBySpec(providerSpec, submitPayload);
|
|
101
|
+
|
|
102
|
+
if (parsed.verbose) {
|
|
103
|
+
process.stdout.write(
|
|
104
|
+
`[generate] module=${meta.commandName} provider=${parsed.provider} inputs=${parsed.input.length}\n`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const submittedTask = await client.post<CommonGenerationTask>(meta.submitPath, submitPayload);
|
|
109
|
+
const taskUuid = submittedTask.task_uuid;
|
|
110
|
+
if (!taskUuid) {
|
|
111
|
+
throw new CliError("提交成功但未返回 task_uuid。", EXIT_CODES.API);
|
|
112
|
+
}
|
|
113
|
+
process.stdout.write(`TASK_UUID: ${taskUuid}\n`);
|
|
114
|
+
|
|
115
|
+
const completedTask = await pollTaskUntilDone(
|
|
116
|
+
() => client.get<CommonGenerationTask>(`/${meta.moduleRoot}/get_task?task_uuid=${taskUuid}`),
|
|
117
|
+
{
|
|
118
|
+
intervalMs: 5000,
|
|
119
|
+
timeoutMs: parsed.timeoutSec * 1000,
|
|
120
|
+
verbose: parsed.verbose,
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const results = completedTask.results ?? [];
|
|
125
|
+
if (results.length === 0) {
|
|
126
|
+
throw new CliError(`任务已完成,但未返回任何${meta.outputKind === "IMAGE" ? "图片" : "视频"}结果。`, EXIT_CODES.TASK_FAILED);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const outputDir = parsed.outputDir ? join(process.cwd(), parsed.outputDir) : process.cwd();
|
|
130
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
131
|
+
const item = results[index];
|
|
132
|
+
if (!item.result_url) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const name = `${meta.outputPrefix}_${taskUuid}_${Date.now()}_${index + 1}.${item.format || (meta.outputKind === "VIDEO" ? "mp4" : "jpg")}`;
|
|
136
|
+
const localPath = await downloadToLocal(item.result_url, outputDir, name);
|
|
137
|
+
process.stdout.write(`${meta.outputKind}_SAVED: ${localPath}\n`);
|
|
138
|
+
process.stdout.write(`MEDIA:${localPath}\n`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readModuleConfig(
|
|
143
|
+
params: MultiModuleParamsResponse,
|
|
144
|
+
meta: TaskModuleMeta,
|
|
145
|
+
): ModuleParamsConfig {
|
|
146
|
+
const moduleConfig = params[meta.paramsKey];
|
|
147
|
+
if (!moduleConfig || typeof moduleConfig !== "object") {
|
|
148
|
+
throw new CliError(
|
|
149
|
+
`${meta.displayName} 参数配置不存在,请检查接口 /${meta.moduleRoot}/params`,
|
|
150
|
+
EXIT_CODES.API,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return moduleConfig;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readProviderSpec(
|
|
157
|
+
moduleConfig: ModuleParamsConfig,
|
|
158
|
+
provider: string,
|
|
159
|
+
displayName: string,
|
|
160
|
+
): SupplierSchema {
|
|
161
|
+
const providerSpec = moduleConfig.for_supplier?.[provider];
|
|
162
|
+
if (!providerSpec) {
|
|
163
|
+
throw new CliError(`${displayName} 未找到 provider: ${provider}`, EXIT_CODES.USAGE);
|
|
164
|
+
}
|
|
165
|
+
if (!providerSpec.properties || !Array.isArray(providerSpec.required)) {
|
|
166
|
+
throw new CliError(
|
|
167
|
+
`${displayName} provider=${provider} 规格格式不合法,缺少 properties/required`,
|
|
168
|
+
EXIT_CODES.API,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return providerSpec;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validatePayloadBySpec(spec: SupplierSchema, payload: Record<string, unknown>): void {
|
|
175
|
+
const properties = spec.properties ?? {};
|
|
176
|
+
for (const requiredKey of spec.required) {
|
|
177
|
+
const value = payload[requiredKey];
|
|
178
|
+
if (isMissingValue(value)) {
|
|
179
|
+
throw new CliError(`缺少必填参数: --${requiredKey}`, EXIT_CODES.USAGE);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
184
|
+
if (key === "supplier" || value === undefined) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const schema = properties[key];
|
|
188
|
+
if (!schema) {
|
|
189
|
+
throw new CliError(`参数 --${key} 不在 provider 规格中,请先执行 specs 查看可用参数。`, EXIT_CODES.USAGE);
|
|
190
|
+
}
|
|
191
|
+
if (!isValueConformingSchema(value, schema)) {
|
|
192
|
+
const expectedType = readExpectedTypeText(schema);
|
|
193
|
+
throw new CliError(`参数 --${key} 类型不匹配,期望 ${expectedType}`, EXIT_CODES.USAGE);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isMissingValue(value: unknown): boolean {
|
|
199
|
+
if (value === undefined || value === null) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (typeof value === "string") {
|
|
203
|
+
return value.trim().length === 0;
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
return value.length === 0;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isValueConformingSchema(value: unknown, schema: PropertySchema): boolean {
|
|
212
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
213
|
+
return schema.anyOf.some((item) => isValueMatchingType(value, item.type));
|
|
214
|
+
}
|
|
215
|
+
if (typeof schema.type === "string") {
|
|
216
|
+
return isValueMatchingType(value, schema.type);
|
|
217
|
+
}
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isValueMatchingType(value: unknown, schemaType: string): boolean {
|
|
222
|
+
switch (schemaType) {
|
|
223
|
+
case "string":
|
|
224
|
+
return typeof value === "string";
|
|
225
|
+
case "integer":
|
|
226
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
227
|
+
case "number":
|
|
228
|
+
return typeof value === "number";
|
|
229
|
+
case "boolean":
|
|
230
|
+
return typeof value === "boolean";
|
|
231
|
+
case "array":
|
|
232
|
+
return Array.isArray(value);
|
|
233
|
+
case "object":
|
|
234
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
235
|
+
default:
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function readExpectedTypeText(schema: PropertySchema): string {
|
|
241
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
242
|
+
return schema.anyOf.map((item) => item.type).join(" | ");
|
|
243
|
+
}
|
|
244
|
+
return typeof schema.type === "string" ? schema.type : "任意类型";
|
|
245
|
+
}
|
package/src/core/parser.ts
CHANGED
|
@@ -15,7 +15,14 @@ export interface GenerateCliArgs {
|
|
|
15
15
|
dynamicParams: Record<string, string | number | boolean | string[]>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export
|
|
18
|
+
export interface ParseGenerateCliArgsOptions {
|
|
19
|
+
requireInput: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseGenerateCliArgs(
|
|
23
|
+
rawArgs: string[],
|
|
24
|
+
options: ParseGenerateCliArgsOptions,
|
|
25
|
+
): GenerateCliArgs {
|
|
19
26
|
const parsed = yargsParser(rawArgs, {
|
|
20
27
|
array: ["input"],
|
|
21
28
|
string: ["provider", "prompt", "ratio", "output-dir", "base-url", "cookie", "config-path"],
|
|
@@ -36,7 +43,7 @@ export function parseGenerateCliArgs(rawArgs: string[]): GenerateCliArgs {
|
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
const input = toStringArray(parsed.input);
|
|
39
|
-
if (input.length === 0) {
|
|
46
|
+
if (options.requireInput && input.length === 0) {
|
|
40
47
|
throw new CliError("至少传入一个 `--input` 图片路径。", EXIT_CODES.USAGE);
|
|
41
48
|
}
|
|
42
49
|
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { Command } from "commander";
|
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { runAuthCommand, runAuthStatusCommand } from "./commands/auth";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
runModuleGenerateCommand,
|
|
7
|
+
runModuleSpecsCommand,
|
|
8
|
+
type TaskModuleMeta,
|
|
9
|
+
} from "./commands/generation";
|
|
6
10
|
import { CliError } from "./core/errors";
|
|
7
11
|
|
|
8
12
|
export async function run(argv: string[] = process.argv): Promise<void> {
|
|
@@ -36,32 +40,87 @@ export async function run(argv: string[] = process.argv): Promise<void> {
|
|
|
36
40
|
await runAuthStatusCommand(options);
|
|
37
41
|
});
|
|
38
42
|
|
|
39
|
-
const
|
|
43
|
+
const taskModules: TaskModuleMeta[] = [
|
|
44
|
+
{
|
|
45
|
+
commandName: "img2img",
|
|
46
|
+
displayName: "图生图",
|
|
47
|
+
moduleRoot: "image_generation",
|
|
48
|
+
paramsKey: "img2img",
|
|
49
|
+
submitPath: "/image_generation/img2img",
|
|
50
|
+
requireInput: true,
|
|
51
|
+
outputPrefix: "img2img",
|
|
52
|
+
outputKind: "IMAGE",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
commandName: "editImage",
|
|
56
|
+
displayName: "修图",
|
|
57
|
+
moduleRoot: "image_generation",
|
|
58
|
+
paramsKey: "edit_image",
|
|
59
|
+
submitPath: "/image_generation/edit_image",
|
|
60
|
+
requireInput: true,
|
|
61
|
+
outputPrefix: "edit_image",
|
|
62
|
+
outputKind: "IMAGE",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
commandName: "text2img",
|
|
66
|
+
displayName: "文生图",
|
|
67
|
+
moduleRoot: "image_generation",
|
|
68
|
+
paramsKey: "text2img",
|
|
69
|
+
submitPath: "/image_generation/text2img",
|
|
70
|
+
requireInput: false,
|
|
71
|
+
outputPrefix: "text2img",
|
|
72
|
+
outputKind: "IMAGE",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
commandName: "img2video",
|
|
76
|
+
displayName: "图生视频",
|
|
77
|
+
moduleRoot: "video_generation",
|
|
78
|
+
paramsKey: "img2video",
|
|
79
|
+
submitPath: "/video_generation/img2video",
|
|
80
|
+
requireInput: true,
|
|
81
|
+
outputPrefix: "img2video",
|
|
82
|
+
outputKind: "VIDEO",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
commandName: "text2video",
|
|
86
|
+
displayName: "文生视频",
|
|
87
|
+
moduleRoot: "video_generation",
|
|
88
|
+
paramsKey: "text2video",
|
|
89
|
+
submitPath: "/video_generation/text2video",
|
|
90
|
+
requireInput: false,
|
|
91
|
+
outputPrefix: "text2video",
|
|
92
|
+
outputKind: "VIDEO",
|
|
93
|
+
},
|
|
94
|
+
];
|
|
40
95
|
|
|
41
|
-
|
|
42
|
-
.command(
|
|
43
|
-
.description("获取 img2img 参数规格")
|
|
44
|
-
.option("--provider <provider>", "指定供应商,仅输出该供应商规格")
|
|
45
|
-
.option("--base-url <url>", "服务地址")
|
|
46
|
-
.option("--cookie <cookie>", "会话 Cookie,可覆盖配置")
|
|
47
|
-
.option("--config-path <path>", "指定配置文件路径")
|
|
48
|
-
.action(
|
|
49
|
-
async (options: { provider?: string; baseUrl?: string; cookie?: string; configPath?: string }) =>
|
|
50
|
-
runImageSpecsCommand(options),
|
|
51
|
-
);
|
|
96
|
+
for (const meta of taskModules) {
|
|
97
|
+
const task = program.command(meta.commandName).description(`${meta.displayName}命令`);
|
|
52
98
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
99
|
+
task
|
|
100
|
+
.command("specs")
|
|
101
|
+
.description(`获取 ${meta.commandName} 参数规格`)
|
|
102
|
+
.option("--provider <provider>", "指定供应商,仅输出该供应商规格")
|
|
103
|
+
.option("--base-url <url>", "服务地址")
|
|
104
|
+
.option("--cookie <cookie>", "会话 Cookie,可覆盖配置")
|
|
105
|
+
.option("--config-path <path>", "指定配置文件路径")
|
|
106
|
+
.action(
|
|
107
|
+
async (options: { provider?: string; baseUrl?: string; cookie?: string; configPath?: string }) =>
|
|
108
|
+
runModuleSpecsCommand(meta, options),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
task
|
|
112
|
+
.command("generate")
|
|
113
|
+
.description(`提交 ${meta.commandName} 任务并轮询下载结果`)
|
|
114
|
+
.allowUnknownOption(true)
|
|
115
|
+
.option("--config-path <path>", "指定配置文件路径")
|
|
116
|
+
.action(async (options: { configPath?: string }) => {
|
|
117
|
+
const rawArgs = getRawArgsAfterToken(argv, "generate");
|
|
118
|
+
await runModuleGenerateCommand(meta, {
|
|
119
|
+
rawArgs,
|
|
120
|
+
configPath: options.configPath,
|
|
121
|
+
});
|
|
63
122
|
});
|
|
64
|
-
|
|
123
|
+
}
|
|
65
124
|
|
|
66
125
|
try {
|
|
67
126
|
await program.parseAsync(argv);
|
package/src/types/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface ResultItem {
|
|
|
9
9
|
size?: number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export interface
|
|
12
|
+
export interface CommonGenerationTask {
|
|
13
13
|
id?: number;
|
|
14
14
|
task_uuid: string;
|
|
15
15
|
status: TaskStatus;
|
|
@@ -19,16 +19,40 @@ export interface ImageGenerationTask {
|
|
|
19
19
|
request_payload?: Record<string, unknown>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export type ImageGenerationTask = CommonGenerationTask;
|
|
23
|
+
|
|
22
24
|
export interface ModuleParamsConfig {
|
|
23
25
|
supplier?: Record<string, unknown>;
|
|
24
|
-
for_supplier?: Record<string,
|
|
26
|
+
for_supplier?: Record<string, SupplierSchema>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PropertySchema {
|
|
30
|
+
type?: string;
|
|
31
|
+
anyOf?: Array<{
|
|
32
|
+
type: string;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}>;
|
|
35
|
+
[key: string]: unknown;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
export interface
|
|
38
|
+
export interface SupplierSchema {
|
|
39
|
+
type?: string;
|
|
40
|
+
properties: Record<string, PropertySchema>;
|
|
41
|
+
required: string[];
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MultiModuleParamsResponse {
|
|
46
|
+
text2img?: ModuleParamsConfig;
|
|
28
47
|
img2img?: ModuleParamsConfig;
|
|
48
|
+
edit_image?: ModuleParamsConfig;
|
|
49
|
+
text2video?: ModuleParamsConfig;
|
|
50
|
+
img2video?: ModuleParamsConfig;
|
|
29
51
|
[key: string]: unknown;
|
|
30
52
|
}
|
|
31
53
|
|
|
54
|
+
export type TaskModuleId = "img2img" | "editImage" | "text2img" | "img2video" | "text2video";
|
|
55
|
+
|
|
32
56
|
export interface RuntimeConfig {
|
|
33
57
|
auth: {
|
|
34
58
|
access_token: string;
|
package/tests/parser.test.ts
CHANGED
|
@@ -3,22 +3,25 @@ import { parseGenerateCliArgs } from "../src/core/parser";
|
|
|
3
3
|
|
|
4
4
|
describe("parseGenerateCliArgs", () => {
|
|
5
5
|
it("parses standard and dynamic options", () => {
|
|
6
|
-
const args = parseGenerateCliArgs(
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
6
|
+
const args = parseGenerateCliArgs(
|
|
7
|
+
[
|
|
8
|
+
"--provider",
|
|
9
|
+
"nano-banana",
|
|
10
|
+
"--input",
|
|
11
|
+
"a.jpg",
|
|
12
|
+
"--input",
|
|
13
|
+
"b.png",
|
|
14
|
+
"--prompt",
|
|
15
|
+
"cyberpunk",
|
|
16
|
+
"--ratio",
|
|
17
|
+
"16:9",
|
|
18
|
+
"--steps",
|
|
19
|
+
"20",
|
|
20
|
+
"--negative_prompt",
|
|
21
|
+
"low quality",
|
|
22
|
+
],
|
|
23
|
+
{ requireInput: true },
|
|
24
|
+
);
|
|
22
25
|
|
|
23
26
|
expect(args.provider).toBe("nano-banana");
|
|
24
27
|
expect(args.input).toEqual(["a.jpg", "b.png"]);
|
package/src/commands/image.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { ApiClient } from "../core/apiClient";
|
|
3
|
-
import { getAccessToken, getBaseUrl, getSessionCookie } from "../core/config";
|
|
4
|
-
import { downloadToLocal } from "../core/downloader";
|
|
5
|
-
import { CliError, EXIT_CODES } from "../core/errors";
|
|
6
|
-
import { ensureInputsReadable, parseGenerateCliArgs, readImagesAsDataUrls } from "../core/parser";
|
|
7
|
-
import { pollTaskUntilDone } from "../core/poller";
|
|
8
|
-
import type { ImageGenerationTask, ImageParamsResponse } from "../types";
|
|
9
|
-
|
|
10
|
-
export interface SpecsCommandOptions {
|
|
11
|
-
provider?: string;
|
|
12
|
-
configPath?: string;
|
|
13
|
-
baseUrl?: string;
|
|
14
|
-
cookie?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface GenerateCommandOptions {
|
|
18
|
-
rawArgs: string[];
|
|
19
|
-
configPath?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function runImageSpecsCommand(options: SpecsCommandOptions): Promise<void> {
|
|
23
|
-
const token = await getAccessToken(options.configPath);
|
|
24
|
-
const baseUrl = await getBaseUrl(options.baseUrl, options.configPath);
|
|
25
|
-
const cookie = await getSessionCookie(options.cookie, options.configPath);
|
|
26
|
-
const client = new ApiClient(baseUrl, token, cookie);
|
|
27
|
-
|
|
28
|
-
const params = await client.get<ImageParamsResponse>("/image_generation/params");
|
|
29
|
-
const suppliers = params.img2img?.for_supplier ?? {};
|
|
30
|
-
if (!options.provider) {
|
|
31
|
-
process.stdout.write(`${JSON.stringify({ providers: Object.keys(suppliers) }, null, 2)}\n`);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const providerSpec = suppliers[options.provider];
|
|
36
|
-
if (!providerSpec) {
|
|
37
|
-
throw new CliError(`未找到 provider: ${options.provider}`, EXIT_CODES.USAGE);
|
|
38
|
-
}
|
|
39
|
-
process.stdout.write(
|
|
40
|
-
`${JSON.stringify({ provider: options.provider, spec: providerSpec }, null, 2)}\n`,
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export async function runImageGenerateCommand(options: GenerateCommandOptions): Promise<void> {
|
|
45
|
-
const parsed = parseGenerateCliArgs(options.rawArgs);
|
|
46
|
-
await ensureInputsReadable(parsed.input);
|
|
47
|
-
const token = await getAccessToken(options.configPath);
|
|
48
|
-
const baseUrl = await getBaseUrl(parsed.baseUrl, options.configPath);
|
|
49
|
-
const cookie = await getSessionCookie(parsed.cookie, options.configPath);
|
|
50
|
-
const client = new ApiClient(baseUrl, token, cookie);
|
|
51
|
-
const imageData = await readImagesAsDataUrls(parsed.input);
|
|
52
|
-
|
|
53
|
-
const submitPayload: Record<string, unknown> = {
|
|
54
|
-
supplier: parsed.provider,
|
|
55
|
-
image: imageData,
|
|
56
|
-
...parsed.dynamicParams,
|
|
57
|
-
};
|
|
58
|
-
if (parsed.prompt) {
|
|
59
|
-
submitPayload.prompt = parsed.prompt;
|
|
60
|
-
}
|
|
61
|
-
if (parsed.ratio) {
|
|
62
|
-
submitPayload.ratio = parsed.ratio;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (parsed.verbose) {
|
|
66
|
-
process.stdout.write(`[generate] provider=${parsed.provider} images=${imageData.length}\n`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const submittedTask = await client.post<ImageGenerationTask>(
|
|
70
|
-
"/image_generation/img2img",
|
|
71
|
-
submitPayload,
|
|
72
|
-
);
|
|
73
|
-
const taskUuid = submittedTask.task_uuid;
|
|
74
|
-
if (!taskUuid) {
|
|
75
|
-
throw new CliError("提交成功但未返回 task_uuid。", EXIT_CODES.API);
|
|
76
|
-
}
|
|
77
|
-
process.stdout.write(`TASK_UUID: ${taskUuid}\n`);
|
|
78
|
-
|
|
79
|
-
const completedTask = await pollTaskUntilDone(
|
|
80
|
-
() => client.get<ImageGenerationTask>(`/image_generation/get_task?task_uuid=${taskUuid}`),
|
|
81
|
-
{
|
|
82
|
-
intervalMs: 5000,
|
|
83
|
-
timeoutMs: parsed.timeoutSec * 1000,
|
|
84
|
-
verbose: parsed.verbose,
|
|
85
|
-
},
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
const results = completedTask.results ?? [];
|
|
89
|
-
if (results.length === 0) {
|
|
90
|
-
throw new CliError("任务已完成,但未返回任何图片结果。", EXIT_CODES.TASK_FAILED);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const outputDir = parsed.outputDir ? join(process.cwd(), parsed.outputDir) : process.cwd();
|
|
94
|
-
for (let index = 0; index < results.length; index += 1) {
|
|
95
|
-
const item = results[index];
|
|
96
|
-
if (!item.result_url) {
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
const name = `img2img_${taskUuid}_${Date.now()}_${index + 1}.${item.format || "jpg"}`;
|
|
100
|
-
const localPath = await downloadToLocal(item.result_url, outputDir, name);
|
|
101
|
-
process.stdout.write(`IMAGE_SAVED: ${localPath}\n`);
|
|
102
|
-
process.stdout.write(`MEDIA:${localPath}\n`);
|
|
103
|
-
}
|
|
104
|
-
}
|