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 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
  /** 列当前账户的工作区(含生成统计) */
@@ -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;
@@ -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 (picked === 0) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "museav-cli",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "MUSE AV 出图中台官方 CLI —— 命令行调中台 API 出图、出视频、读图逆向、图生模板",
5
5
  "type": "module",
6
6
  "bin": {
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
 
@@ -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 (picked === 0) {
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