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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # svmc-cli
2
2
 
3
- 将 `media-center-frontend` 的图生图能力封装为可供 Agent 调用的命令行工具。
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 image specs --provider nano-banana
37
+ svmc img2img specs --provider nano-banana
38
38
  ```
39
39
 
40
40
  需要显式传 Cookie 时:
41
41
 
42
42
  ```bash
43
- svmc image specs --provider nano-banana --cookie "session=...; sl-session=..."
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 image specs
49
+ svmc img2img specs
50
50
  ```
51
51
 
52
52
  ## 发起图生图任务
53
53
 
54
54
  ```bash
55
- svmc image generate \
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 image generate ... --steps 20 --guidance_scale 7 --cookie "session=...; sl-session=..."
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 image specs --provider <provider>`,确认返回规格。
80
- 3. `svmc image generate --provider <provider> --input <img> --prompt "..."`。
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
+ }
@@ -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 image_1 = require("./commands/image");
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 image = program.command("image").description("图像相关命令");
36
- image
37
- .command("specs")
38
- .description("获取 img2img 参数规格")
39
- .option("--provider <provider>", "指定供应商,仅输出该供应商规格")
40
- .option("--base-url <url>", "服务地址")
41
- .option("--cookie <cookie>", "会话 Cookie,可覆盖配置")
42
- .option("--config-path <path>", "指定配置文件路径")
43
- .action(async (options) => (0, image_1.runImageSpecsCommand)(options));
44
- image
45
- .command("generate")
46
- .description("提交 img2img 任务并轮询下载结果")
47
- .allowUnknownOption(true)
48
- .option("--config-path <path>", "指定配置文件路径")
49
- .action(async (options) => {
50
- const rawArgs = getRawArgsAfterToken(argv, "generate");
51
- await (0, image_1.runImageGenerateCommand)({
52
- rawArgs,
53
- configPath: options.configPath,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svmc-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "SVMC CLI for media-center image workflows",
5
5
  "main": "dist/src/index.js",
6
6
  "bin": {
@@ -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
+ }
@@ -15,7 +15,14 @@ export interface GenerateCliArgs {
15
15
  dynamicParams: Record<string, string | number | boolean | string[]>;
16
16
  }
17
17
 
18
- export function parseGenerateCliArgs(rawArgs: string[]): GenerateCliArgs {
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 { runImageGenerateCommand, runImageSpecsCommand } from "./commands/image";
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 image = program.command("image").description("图像相关命令");
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
- image
42
- .command("specs")
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
- image
54
- .command("generate")
55
- .description("提交 img2img 任务并轮询下载结果")
56
- .allowUnknownOption(true)
57
- .option("--config-path <path>", "指定配置文件路径")
58
- .action(async (options: { configPath?: string }) => {
59
- const rawArgs = getRawArgsAfterToken(argv, "generate");
60
- await runImageGenerateCommand({
61
- rawArgs,
62
- configPath: options.configPath,
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);
@@ -9,7 +9,7 @@ export interface ResultItem {
9
9
  size?: number;
10
10
  }
11
11
 
12
- export interface ImageGenerationTask {
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, Record<string, unknown>>;
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 ImageParamsResponse {
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;
@@ -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
- "--provider",
8
- "nano-banana",
9
- "--input",
10
- "a.jpg",
11
- "--input",
12
- "b.png",
13
- "--prompt",
14
- "cyberpunk",
15
- "--ratio",
16
- "16:9",
17
- "--steps",
18
- "20",
19
- "--negative_prompt",
20
- "low quality",
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"]);
@@ -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
- }