museav-cli 3.0.1 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +22 -0
- package/dist/client.js +16 -0
- package/dist/commands/gen.d.ts +1 -1
- package/dist/commands/gen.js +125 -1
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/client.ts +34 -0
- package/src/commands/gen.ts +118 -1
- package/src/index.ts +1 -0
package/dist/client.d.ts
CHANGED
|
@@ -356,6 +356,28 @@ export declare class StudioClient {
|
|
|
356
356
|
}>;
|
|
357
357
|
/** 查单个任务状态 */
|
|
358
358
|
getJob(id: string): Promise<Job>;
|
|
359
|
+
/**
|
|
360
|
+
* 批量提交出图(POST /api/generate-batch)。
|
|
361
|
+
* items 每项与 generate 的请求体同构;defaults 是公共字段(skill/ratio 等),项内覆盖。
|
|
362
|
+
* 服务端逐项走与单张同一套流程;撞频控/积分不足时后续项 skipped 并带 retry_after_sec,
|
|
363
|
+
* 客户端睡够后只补提交 skipped 的部分即可(jobId 已拿到的不要重发)。
|
|
364
|
+
*/
|
|
365
|
+
generateBatch(items: Array<Record<string, unknown>>, defaults?: Record<string, unknown>): Promise<{
|
|
366
|
+
ok: boolean;
|
|
367
|
+
accepted: number;
|
|
368
|
+
submitted: number;
|
|
369
|
+
results: Array<{
|
|
370
|
+
ok: boolean;
|
|
371
|
+
jobId?: string;
|
|
372
|
+
trace_id?: string;
|
|
373
|
+
status?: number;
|
|
374
|
+
error?: string;
|
|
375
|
+
skipped?: boolean;
|
|
376
|
+
reason?: string;
|
|
377
|
+
retry_after_sec?: number;
|
|
378
|
+
}>;
|
|
379
|
+
retry_after_sec?: number;
|
|
380
|
+
}>;
|
|
359
381
|
/** 列当前账户的工作区(含生成统计) */
|
|
360
382
|
workspaces(): Promise<Workspace[]>;
|
|
361
383
|
/** 新建工作区(最多 5 个,超了服务端会 400) */
|
package/dist/client.js
CHANGED
|
@@ -210,6 +210,22 @@ export class StudioClient {
|
|
|
210
210
|
const r = await this.request(`jobs?id=${encodeURIComponent(id)}`);
|
|
211
211
|
return r;
|
|
212
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* 批量提交出图(POST /api/generate-batch)。
|
|
215
|
+
* items 每项与 generate 的请求体同构;defaults 是公共字段(skill/ratio 等),项内覆盖。
|
|
216
|
+
* 服务端逐项走与单张同一套流程;撞频控/积分不足时后续项 skipped 并带 retry_after_sec,
|
|
217
|
+
* 客户端睡够后只补提交 skipped 的部分即可(jobId 已拿到的不要重发)。
|
|
218
|
+
*/
|
|
219
|
+
async generateBatch(items, defaults) {
|
|
220
|
+
const body = { items };
|
|
221
|
+
if (defaults)
|
|
222
|
+
body.defaults = defaults;
|
|
223
|
+
return this.request('generate-batch', {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
body: JSON.stringify(body),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
213
229
|
// ── 工作区(项目)与项目素材库 ──
|
|
214
230
|
// 平台 → 账户 → 工作区三层归属;素材挂工作区,换业务换工作区,互不污染。
|
|
215
231
|
/** 列当前账户的工作区(含生成统计) */
|
package/dist/commands/gen.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** museav gen —— 出图 / 出视频(核心命令) */
|
|
2
1
|
import type { StudioClient } from '../client.js';
|
|
3
2
|
export declare function gen(client: StudioClient, opts: {
|
|
4
3
|
prompt?: string;
|
|
@@ -12,6 +11,7 @@ export declare function gen(client: StudioClient, opts: {
|
|
|
12
11
|
ref?: string[];
|
|
13
12
|
transparent?: boolean;
|
|
14
13
|
project?: string;
|
|
14
|
+
batch?: string;
|
|
15
15
|
video?: boolean;
|
|
16
16
|
duration?: number;
|
|
17
17
|
image?: string;
|
package/dist/commands/gen.js
CHANGED
|
@@ -1,10 +1,73 @@
|
|
|
1
|
+
/** museav gen —— 出图 / 出视频(核心命令) */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
1
3
|
import { resolveWorkspace } from './projects.js';
|
|
2
4
|
/** 与中台/各租户后台口径一致:一次最多 5 张参考图 */
|
|
3
5
|
const MAX_REFS = 5;
|
|
6
|
+
/** 中台 /api/generate-batch 的单批上限;更大批量 CLI 自动分批 */
|
|
7
|
+
const BATCH_CHUNK = 32;
|
|
8
|
+
/** 读批量文件:每行一条,空行和 # 注释行跳过。'-' 读 stdin。 */
|
|
9
|
+
function readBatchLines(file) {
|
|
10
|
+
const raw = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
|
|
11
|
+
return raw.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 批量提交 + 撞频控自动补交:items 与返回的 jobId/错误数组等长(保持顺序)。
|
|
15
|
+
* 服务端对频控的处理是「后续项 skipped + retry_after_sec」,这里睡够后只把
|
|
16
|
+
* skipped 的行回池补交——已拿到 jobId 的绝不重发(重发就是重复扣费)。
|
|
17
|
+
*/
|
|
18
|
+
async function submitBatch(client, items, defaults) {
|
|
19
|
+
const out = items.map(() => ({}));
|
|
20
|
+
let pending = items.map((_, i) => i);
|
|
21
|
+
let rounds = 0;
|
|
22
|
+
while (pending.length && rounds++ < 20) {
|
|
23
|
+
const retryable = new Set();
|
|
24
|
+
let cooldown = 0;
|
|
25
|
+
for (let c = 0; c < pending.length; c += BATCH_CHUNK) {
|
|
26
|
+
const chunkIdx = pending.slice(c, c + BATCH_CHUNK);
|
|
27
|
+
const res = await client.generateBatch(chunkIdx.map((i) => items[i]), defaults);
|
|
28
|
+
chunkIdx.forEach((itemIdx, k) => {
|
|
29
|
+
const r = res.results[k];
|
|
30
|
+
if (r?.ok && r.jobId) {
|
|
31
|
+
out[itemIdx] = { jobId: r.jobId };
|
|
32
|
+
process.stderr.write(` [${itemIdx + 1}/${items.length}] 已提交 ${r.jobId.slice(0, 8)}\n`);
|
|
33
|
+
}
|
|
34
|
+
else if (r?.skipped && res.retry_after_sec) {
|
|
35
|
+
retryable.add(itemIdx);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
out[itemIdx] = { error: r?.error || r?.reason || `HTTP ${r?.status}` };
|
|
39
|
+
process.stderr.write(` [${itemIdx + 1}/${items.length}] 失败: ${out[itemIdx].error}\n`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
if (res.retry_after_sec)
|
|
43
|
+
cooldown = Math.max(cooldown, res.retry_after_sec);
|
|
44
|
+
}
|
|
45
|
+
pending = [...retryable];
|
|
46
|
+
if (pending.length && cooldown) {
|
|
47
|
+
process.stderr.write(` 频控冷却 ${cooldown}s 后补交剩余 ${pending.length} 项...\n`);
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, cooldown * 1000));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
out.forEach((o) => { if (!o.jobId && !o.error)
|
|
52
|
+
o.error = '重试次数用尽仍未提交'; });
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
4
55
|
export async function gen(client, opts) {
|
|
5
56
|
// prompt / skill / template 三选一。commander 不好表达互斥,在这里校验,报错要说清怎么改
|
|
6
57
|
const picked = [opts.prompt, opts.skill, opts.template].filter(Boolean).length;
|
|
7
|
-
if (
|
|
58
|
+
if (opts.batch) {
|
|
59
|
+
// 批量有自己的入口语义:每行一条。--skill/--template 时行内容当 input/prompt 用,
|
|
60
|
+
// 所以 --prompt / --input / --fields 这些「单条内容」参数与它互斥。
|
|
61
|
+
if (opts.video)
|
|
62
|
+
throw new Error('--batch 仅图片出图支持(视频走单条 gen --video)');
|
|
63
|
+
if (opts.prompt)
|
|
64
|
+
throw new Error('--batch 与 --prompt 互斥:批量时每行就是一条提示词');
|
|
65
|
+
if (opts.input)
|
|
66
|
+
throw new Error('--batch 与 --input 互斥:批量时每行就是一条描述');
|
|
67
|
+
if (opts.fields)
|
|
68
|
+
throw new Error('--batch 与 --fields 互斥:批量场景模板占位符无法逐行区分');
|
|
69
|
+
}
|
|
70
|
+
if (!opts.batch && picked === 0) {
|
|
8
71
|
throw new Error('需要 --prompt "完整提示词" 或 --skill <技能名>(museav skills 查)或 --template <模板id>(museav templates 查)');
|
|
9
72
|
}
|
|
10
73
|
if (picked > 1) {
|
|
@@ -62,6 +125,67 @@ export async function gen(client, opts) {
|
|
|
62
125
|
}
|
|
63
126
|
// 项目归档:--project 解析成 workspace_id(名字/ id 都行),租户身份时中台会忽略
|
|
64
127
|
const workspaceId = opts.project ? (await resolveWorkspace(client, opts.project)).id : undefined;
|
|
128
|
+
// ── 批量模式:走 /api/generate-batch,中台逐项消化,本端不 pacing ──
|
|
129
|
+
if (opts.batch) {
|
|
130
|
+
const lines = readBatchLines(opts.batch);
|
|
131
|
+
if (!lines.length)
|
|
132
|
+
throw new Error(`批量文件里没有可用行(每行一条,# 开头的注释和空行会跳过): ${opts.batch}`);
|
|
133
|
+
process.stderr.write(`批量出图: ${lines.length} 条${opts.skill ? ` · 技能 ${opts.skill}` : ''}${opts.template ? ` · 模板 ${opts.template}` : ''}\n`);
|
|
134
|
+
// 每行内容按 skill/template 有无决定语义:有 → 行是 input(业务描述),
|
|
135
|
+
// 没有 → 行是完整 prompt。其余选项全部作为公共 defaults 下发。
|
|
136
|
+
const defaults = {};
|
|
137
|
+
if (opts.skill)
|
|
138
|
+
defaults.skill_slug = opts.skill;
|
|
139
|
+
if (opts.template)
|
|
140
|
+
defaults.template_id = opts.template;
|
|
141
|
+
if (opts.ratio)
|
|
142
|
+
defaults.ratio = opts.ratio;
|
|
143
|
+
if (opts.model)
|
|
144
|
+
defaults.model = opts.model;
|
|
145
|
+
if (opts.quality)
|
|
146
|
+
defaults.quality = opts.quality;
|
|
147
|
+
if (referenceImage)
|
|
148
|
+
defaults.reference_image = referenceImage;
|
|
149
|
+
if (referenceImages)
|
|
150
|
+
defaults.reference_images = referenceImages;
|
|
151
|
+
if (opts.transparent)
|
|
152
|
+
defaults.background = 'transparent';
|
|
153
|
+
if (workspaceId)
|
|
154
|
+
defaults.workspace_id = workspaceId;
|
|
155
|
+
const lineKey = (opts.skill || opts.template) ? 'input' : 'prompt';
|
|
156
|
+
const submitted = await submitBatch(client, lines.map((line) => ({ [lineKey]: line })), defaults);
|
|
157
|
+
// 等待全部完成:轮询所有 jobId,按行序输出 URL(stdout 每行一个,方便管道续接)
|
|
158
|
+
const waiters = submitted.map((s) => s.jobId).filter(Boolean);
|
|
159
|
+
process.stderr.write(`已提交 ${waiters.length}/${lines.length},等待生成...\n`);
|
|
160
|
+
const urls = await Promise.all(submitted.map(async (s) => {
|
|
161
|
+
if (!s.jobId)
|
|
162
|
+
return null;
|
|
163
|
+
for (let i = 0; i < 200; i++) {
|
|
164
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
165
|
+
const job = await client.getJob(s.jobId);
|
|
166
|
+
if (job.status === 'done')
|
|
167
|
+
return job.cdn_url || null;
|
|
168
|
+
if (job.status === 'failed') {
|
|
169
|
+
process.stderr.write(` ${s.jobId.slice(0, 8)} 失败: ${job.error || '未知原因'}\n`);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}));
|
|
175
|
+
let okCount = 0;
|
|
176
|
+
for (const [i, url] of urls.entries()) {
|
|
177
|
+
if (url) {
|
|
178
|
+
okCount++;
|
|
179
|
+
console.log(url);
|
|
180
|
+
}
|
|
181
|
+
else if (submitted[i].error)
|
|
182
|
+
process.stderr.write(`第 ${i + 1} 行未提交: ${submitted[i].error}\n`);
|
|
183
|
+
}
|
|
184
|
+
process.stderr.write(`✅ 批量完成: ${okCount}/${lines.length} 张\n`);
|
|
185
|
+
if (okCount === 0)
|
|
186
|
+
throw new Error('批量出图全部失败');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
65
189
|
// ── 视频模式:走 /api/videos 独立链路 ──
|
|
66
190
|
if (opts.video) {
|
|
67
191
|
if (opts.quality)
|
package/dist/index.js
CHANGED
|
@@ -171,6 +171,7 @@ program
|
|
|
171
171
|
.option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
|
|
172
172
|
.option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
|
|
173
173
|
.option('--project <id|名>', '归档进该工作区(museav projects 查;账户身份才生效)')
|
|
174
|
+
.option('--batch <file>', '批量出图:文件每行一条(\'#\' 注释与空行跳过,\'-\' 读 stdin),走 /api/generate-batch 中台排队消化;配合 --skill/--template 时每行是业务描述,否则是完整提示词;其余选项作为公共参数')
|
|
174
175
|
.action(withClient((client, opts) => gen(client, opts)));
|
|
175
176
|
program
|
|
176
177
|
.command('compress <file>')
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -514,6 +514,40 @@ export class StudioClient {
|
|
|
514
514
|
return r
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
/**
|
|
518
|
+
* 批量提交出图(POST /api/generate-batch)。
|
|
519
|
+
* items 每项与 generate 的请求体同构;defaults 是公共字段(skill/ratio 等),项内覆盖。
|
|
520
|
+
* 服务端逐项走与单张同一套流程;撞频控/积分不足时后续项 skipped 并带 retry_after_sec,
|
|
521
|
+
* 客户端睡够后只补提交 skipped 的部分即可(jobId 已拿到的不要重发)。
|
|
522
|
+
*/
|
|
523
|
+
async generateBatch(
|
|
524
|
+
items: Array<Record<string, unknown>>,
|
|
525
|
+
defaults?: Record<string, unknown>,
|
|
526
|
+
): Promise<{
|
|
527
|
+
ok: boolean
|
|
528
|
+
accepted: number
|
|
529
|
+
submitted: number
|
|
530
|
+
results: Array<{
|
|
531
|
+
ok: boolean
|
|
532
|
+
jobId?: string
|
|
533
|
+
trace_id?: string
|
|
534
|
+
status?: number
|
|
535
|
+
error?: string
|
|
536
|
+
skipped?: boolean
|
|
537
|
+
reason?: string
|
|
538
|
+
retry_after_sec?: number
|
|
539
|
+
}>
|
|
540
|
+
retry_after_sec?: number
|
|
541
|
+
}> {
|
|
542
|
+
const body: Record<string, unknown> = { items }
|
|
543
|
+
if (defaults) body.defaults = defaults
|
|
544
|
+
return this.request('generate-batch', {
|
|
545
|
+
method: 'POST',
|
|
546
|
+
headers: { 'Content-Type': 'application/json' },
|
|
547
|
+
body: JSON.stringify(body),
|
|
548
|
+
})
|
|
549
|
+
}
|
|
550
|
+
|
|
517
551
|
// ── 工作区(项目)与项目素材库 ──
|
|
518
552
|
// 平台 → 账户 → 工作区三层归属;素材挂工作区,换业务换工作区,互不污染。
|
|
519
553
|
|
package/src/commands/gen.ts
CHANGED
|
@@ -1,10 +1,66 @@
|
|
|
1
1
|
/** museav gen —— 出图 / 出视频(核心命令) */
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
2
3
|
import type { StudioClient } from '../client.js'
|
|
3
4
|
import { resolveWorkspace } from './projects.js'
|
|
4
5
|
|
|
5
6
|
/** 与中台/各租户后台口径一致:一次最多 5 张参考图 */
|
|
6
7
|
const MAX_REFS = 5
|
|
7
8
|
|
|
9
|
+
/** 中台 /api/generate-batch 的单批上限;更大批量 CLI 自动分批 */
|
|
10
|
+
const BATCH_CHUNK = 32
|
|
11
|
+
|
|
12
|
+
/** 读批量文件:每行一条,空行和 # 注释行跳过。'-' 读 stdin。 */
|
|
13
|
+
function readBatchLines(file: string): string[] {
|
|
14
|
+
const raw = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8')
|
|
15
|
+
return raw.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 批量提交 + 撞频控自动补交:items 与返回的 jobId/错误数组等长(保持顺序)。
|
|
20
|
+
* 服务端对频控的处理是「后续项 skipped + retry_after_sec」,这里睡够后只把
|
|
21
|
+
* skipped 的行回池补交——已拿到 jobId 的绝不重发(重发就是重复扣费)。
|
|
22
|
+
*/
|
|
23
|
+
async function submitBatch(
|
|
24
|
+
client: StudioClient,
|
|
25
|
+
items: Array<Record<string, unknown>>,
|
|
26
|
+
defaults: Record<string, unknown>,
|
|
27
|
+
): Promise<Array<{ jobId?: string; error?: string }>> {
|
|
28
|
+
const out: Array<{ jobId?: string; error?: string }> = items.map(() => ({}))
|
|
29
|
+
let pending = items.map((_, i) => i)
|
|
30
|
+
let rounds = 0
|
|
31
|
+
while (pending.length && rounds++ < 20) {
|
|
32
|
+
const retryable = new Set<number>()
|
|
33
|
+
let cooldown = 0
|
|
34
|
+
for (let c = 0; c < pending.length; c += BATCH_CHUNK) {
|
|
35
|
+
const chunkIdx = pending.slice(c, c + BATCH_CHUNK)
|
|
36
|
+
const res = await client.generateBatch(
|
|
37
|
+
chunkIdx.map((i) => items[i]),
|
|
38
|
+
defaults,
|
|
39
|
+
)
|
|
40
|
+
chunkIdx.forEach((itemIdx, k) => {
|
|
41
|
+
const r = res.results[k]
|
|
42
|
+
if (r?.ok && r.jobId) {
|
|
43
|
+
out[itemIdx] = { jobId: r.jobId }
|
|
44
|
+
process.stderr.write(` [${itemIdx + 1}/${items.length}] 已提交 ${r.jobId.slice(0, 8)}\n`)
|
|
45
|
+
} else if (r?.skipped && res.retry_after_sec) {
|
|
46
|
+
retryable.add(itemIdx)
|
|
47
|
+
} else {
|
|
48
|
+
out[itemIdx] = { error: r?.error || r?.reason || `HTTP ${r?.status}` }
|
|
49
|
+
process.stderr.write(` [${itemIdx + 1}/${items.length}] 失败: ${out[itemIdx].error}\n`)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
if (res.retry_after_sec) cooldown = Math.max(cooldown, res.retry_after_sec)
|
|
53
|
+
}
|
|
54
|
+
pending = [...retryable]
|
|
55
|
+
if (pending.length && cooldown) {
|
|
56
|
+
process.stderr.write(` 频控冷却 ${cooldown}s 后补交剩余 ${pending.length} 项...\n`)
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, cooldown * 1000))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
out.forEach((o) => { if (!o.jobId && !o.error) o.error = '重试次数用尽仍未提交' })
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
8
64
|
export async function gen(client: StudioClient, opts: {
|
|
9
65
|
prompt?: string
|
|
10
66
|
skill?: string
|
|
@@ -17,6 +73,8 @@ export async function gen(client: StudioClient, opts: {
|
|
|
17
73
|
ref?: string[] // 可重复:--ref a.jpg --ref b.jpg,顺序即「图片1、图片2…」
|
|
18
74
|
transparent?: boolean // 透明背景 PNG;能不能做由中台按上游能力判定,做不了会明确报错
|
|
19
75
|
project?: string // 工作区 id|名:生成结果归档进该项目(账户身份才生效)
|
|
76
|
+
// 批量:文件每行一条('- 读 stdin'),走 /api/generate-batch,只支持图片
|
|
77
|
+
batch?: string
|
|
20
78
|
// 视频
|
|
21
79
|
video?: boolean
|
|
22
80
|
duration?: number
|
|
@@ -24,7 +82,15 @@ export async function gen(client: StudioClient, opts: {
|
|
|
24
82
|
}): Promise<void> {
|
|
25
83
|
// prompt / skill / template 三选一。commander 不好表达互斥,在这里校验,报错要说清怎么改
|
|
26
84
|
const picked = [opts.prompt, opts.skill, opts.template].filter(Boolean).length
|
|
27
|
-
if (
|
|
85
|
+
if (opts.batch) {
|
|
86
|
+
// 批量有自己的入口语义:每行一条。--skill/--template 时行内容当 input/prompt 用,
|
|
87
|
+
// 所以 --prompt / --input / --fields 这些「单条内容」参数与它互斥。
|
|
88
|
+
if (opts.video) throw new Error('--batch 仅图片出图支持(视频走单条 gen --video)')
|
|
89
|
+
if (opts.prompt) throw new Error('--batch 与 --prompt 互斥:批量时每行就是一条提示词')
|
|
90
|
+
if (opts.input) throw new Error('--batch 与 --input 互斥:批量时每行就是一条描述')
|
|
91
|
+
if (opts.fields) throw new Error('--batch 与 --fields 互斥:批量场景模板占位符无法逐行区分')
|
|
92
|
+
}
|
|
93
|
+
if (!opts.batch && picked === 0) {
|
|
28
94
|
throw new Error('需要 --prompt "完整提示词" 或 --skill <技能名>(museav skills 查)或 --template <模板id>(museav templates 查)')
|
|
29
95
|
}
|
|
30
96
|
if (picked > 1) {
|
|
@@ -84,6 +150,57 @@ export async function gen(client: StudioClient, opts: {
|
|
|
84
150
|
// 项目归档:--project 解析成 workspace_id(名字/ id 都行),租户身份时中台会忽略
|
|
85
151
|
const workspaceId = opts.project ? (await resolveWorkspace(client, opts.project)).id : undefined
|
|
86
152
|
|
|
153
|
+
// ── 批量模式:走 /api/generate-batch,中台逐项消化,本端不 pacing ──
|
|
154
|
+
if (opts.batch) {
|
|
155
|
+
const lines = readBatchLines(opts.batch)
|
|
156
|
+
if (!lines.length) throw new Error(`批量文件里没有可用行(每行一条,# 开头的注释和空行会跳过): ${opts.batch}`)
|
|
157
|
+
process.stderr.write(`批量出图: ${lines.length} 条${opts.skill ? ` · 技能 ${opts.skill}` : ''}${opts.template ? ` · 模板 ${opts.template}` : ''}\n`)
|
|
158
|
+
// 每行内容按 skill/template 有无决定语义:有 → 行是 input(业务描述),
|
|
159
|
+
// 没有 → 行是完整 prompt。其余选项全部作为公共 defaults 下发。
|
|
160
|
+
const defaults: Record<string, unknown> = {}
|
|
161
|
+
if (opts.skill) defaults.skill_slug = opts.skill
|
|
162
|
+
if (opts.template) defaults.template_id = opts.template
|
|
163
|
+
if (opts.ratio) defaults.ratio = opts.ratio
|
|
164
|
+
if (opts.model) defaults.model = opts.model
|
|
165
|
+
if (opts.quality) defaults.quality = opts.quality
|
|
166
|
+
if (referenceImage) defaults.reference_image = referenceImage
|
|
167
|
+
if (referenceImages) defaults.reference_images = referenceImages
|
|
168
|
+
if (opts.transparent) defaults.background = 'transparent'
|
|
169
|
+
if (workspaceId) defaults.workspace_id = workspaceId
|
|
170
|
+
const lineKey = (opts.skill || opts.template) ? 'input' : 'prompt'
|
|
171
|
+
const submitted = await submitBatch(
|
|
172
|
+
client,
|
|
173
|
+
lines.map((line) => ({ [lineKey]: line })),
|
|
174
|
+
defaults,
|
|
175
|
+
)
|
|
176
|
+
// 等待全部完成:轮询所有 jobId,按行序输出 URL(stdout 每行一个,方便管道续接)
|
|
177
|
+
const waiters = submitted.map((s) => s.jobId).filter(Boolean) as string[]
|
|
178
|
+
process.stderr.write(`已提交 ${waiters.length}/${lines.length},等待生成...\n`)
|
|
179
|
+
const urls = await Promise.all(
|
|
180
|
+
submitted.map(async (s) => {
|
|
181
|
+
if (!s.jobId) return null
|
|
182
|
+
for (let i = 0; i < 200; i++) {
|
|
183
|
+
await new Promise((r) => setTimeout(r, 3000))
|
|
184
|
+
const job = await client.getJob(s.jobId!)
|
|
185
|
+
if (job.status === 'done') return job.cdn_url || null
|
|
186
|
+
if (job.status === 'failed') {
|
|
187
|
+
process.stderr.write(` ${s.jobId!.slice(0, 8)} 失败: ${job.error || '未知原因'}\n`)
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
let okCount = 0
|
|
195
|
+
for (const [i, url] of urls.entries()) {
|
|
196
|
+
if (url) { okCount++; console.log(url) }
|
|
197
|
+
else if (submitted[i].error) process.stderr.write(`第 ${i + 1} 行未提交: ${submitted[i].error}\n`)
|
|
198
|
+
}
|
|
199
|
+
process.stderr.write(`✅ 批量完成: ${okCount}/${lines.length} 张\n`)
|
|
200
|
+
if (okCount === 0) throw new Error('批量出图全部失败')
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
87
204
|
// ── 视频模式:走 /api/videos 独立链路 ──
|
|
88
205
|
if (opts.video) {
|
|
89
206
|
if (opts.quality) throw new Error('--quality 仅图片出图支持')
|
package/src/index.ts
CHANGED
|
@@ -174,6 +174,7 @@ program
|
|
|
174
174
|
.option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
|
|
175
175
|
.option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
|
|
176
176
|
.option('--project <id|名>', '归档进该工作区(museav projects 查;账户身份才生效)')
|
|
177
|
+
.option('--batch <file>', '批量出图:文件每行一条(\'#\' 注释与空行跳过,\'-\' 读 stdin),走 /api/generate-batch 中台排队消化;配合 --skill/--template 时每行是业务描述,否则是完整提示词;其余选项作为公共参数')
|
|
177
178
|
.action(withClient((client: StudioClient, opts: any) => gen(client, opts)))
|
|
178
179
|
|
|
179
180
|
program
|