museav-cli 3.0.0 → 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/AGENTS.md CHANGED
@@ -6,6 +6,24 @@ This CLI is designed to be used directly by coding agents (Claude Code, Codex, e
6
6
 
7
7
  A command-line client for the "studio" image-generation platform (`https://manager.museav.top`). It generates images from a prompt, reverse-engineers a prompt from an existing image, and lists your own generation history. All output is designed for machine consumption: **stdout carries only the final result** (a URL, a prompt string, or JSON); progress and human-readable info goes to stderr.
8
8
 
9
+ ## Scope: this tool makes *assets*, not finished videos
10
+
11
+ Know the boundary before you pick a tool:
12
+
13
+ | You need | Use |
14
+ |---|---|
15
+ | An image, or a raw video clip from a model | `museav gen` (add `--video`) |
16
+ | Cut out a background / upscale / de-watermark / compress | `museav remove-bg` / `upscale` / `remove-watermark` / `compress` |
17
+ | **A finished vertical short video** (images + per-shot captions + BGM/voiceover, templated) | **[reel-kit](https://github.com/webkubor/reel-kit)** — `reel make` |
18
+
19
+ `museav gen --video` gives you a **silent, caption-less clip** — that's raw material, not
20
+ something you post. Assembling material into a publishable short is reel-kit's job: its
21
+ layouts are HTML/CSS templates, it does voiceover, and **shot length follows the narration**.
22
+
23
+ > `museav slideshow` existed in 2.9–2.10 and was **retired in 3.0.0** — it duplicated
24
+ > reel-kit. The command still exists as a stub that prints migration instructions.
25
+ > Don't try to bring it back; use reel-kit.
26
+
9
27
  ## Auth: pick one identity, not both
10
28
 
11
29
  - **You're acting as an individual user** (a person's own account): `museav login` — opens a device-authorization flow (prints a code + URL, polls until the user approves in a browser). Token cached in `~/.museav.json`, valid 7 days.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.1 (2026-08-28)
4
+
5
+ ### 把 reel-kit 的推荐位做实
6
+
7
+ 3.0.0 只是下线了 slideshow,但没告诉人「那该用什么」——README 里只有一句藏在引用块里的
8
+ 说明,agent 读的 AGENTS.md 里一个字都没提。补三处:
9
+
10
+ - **README** 加独立小节「🎬 要出成品视频?用 reel-kit」,带命令示例与分工对照表
11
+ - **AGENTS.md** 加 "Scope: this tool makes *assets*, not finished videos" ——
12
+ agent 被要求「做个视频」时,第一眼就能看到该去哪
13
+ - **`gen --video` 出片后提示一行**:模型出的是无声无字幕素材,不是成品。
14
+ 这不是广告,是防止两种误解——以为「出完了」,或以为 museav 该管配音烧字幕
15
+
16
+ 典型链路写清楚了:`museav gen` 出图 → `museav remove-bg` 抠图 → `reel make` 出片。
17
+
3
18
  ## 3.0.0 (2026-08-28)
4
19
 
5
20
  ### 破坏性变更:`slideshow` / `slideshow-layouts` 下线,出片改用 reel-kit
package/README.md CHANGED
@@ -108,12 +108,38 @@
108
108
 
109
109
  > 本地工具(`compress` / `remove-bg` / `upscale` / `remove-watermark`)**不用登录、不花一分钱**,装了就能用;其余命令需要一个凭证(个人 `login` 或租户 apiKey)。
110
110
  >
111
- > **出成品视频不在这里** —— 图集转竖版短视频、配音、烧字幕走 [reel-kit](https://github.com/webkubor/reel-kit)(`reel` 命令)。
112
- > museav 管的是**素材**(抠图/超分/去水印/AI 生成),reel-kit 管**成品合成**。曾经有过 `museav slideshow`,3.0.0 已下线,见 CHANGELOG。
113
- >
114
111
  > 语音(`speak` / `transcribe`)是第三种情况:**不走中台身份**,直连小米 MiMo,只认 `MIMO_API_KEY` 环境变量。
115
112
  > 中台的出音链路还没接完,这批能力目前内部用,没有那把 key 的人(包括租户)用不了。
116
113
 
114
+ ### 🎬 要出成品视频?用 [reel-kit](https://github.com/webkubor/reel-kit)
115
+
116
+ museav 出的是**素材**:`gen --video` 给你的是一段**无声、无字幕的片子**,
117
+ 那是原料,不是能直接发的成品。把素材装配成能发的竖版短片是
118
+ **[reel-kit](https://github.com/webkubor/reel-kit)** 的活:
119
+
120
+ ```bash
121
+ reel make --template sticker-promo \
122
+ --assets ./图片目录 --caps 文案.txt \
123
+ --title "标题" --bgm 儿童轻快 --out 成片.mp4
124
+ ```
125
+
126
+ - 版式是 `templates/*.html`,**改 CSS 就是改版式**,丢一个 HTML 进去就是一个新模板
127
+ - 支持配音,且**镜头时长跟着念白走**(念快的不干等,念慢的不被切)
128
+ - 配音默认走本地 TTS,批量出片不花钱
129
+
130
+ 两边的分工是明确的,reel-kit 也在它的 README 里写了同一条边界:
131
+
132
+ | 你要的 | 用谁 |
133
+ |---|---|
134
+ | 一张图 / 一段模型出的原始片子 | `museav gen`(加 `--video`) |
135
+ | 抠图 / 超分 / 去水印 / 压缩 | `museav remove-bg` / `upscale` / `remove-watermark` / `compress` |
136
+ | **一支能直接发的竖版短片** | **`reel make`** |
137
+
138
+ 典型链路:`museav gen` 出图 → `museav remove-bg` 抠图 → `reel make` 出片。
139
+
140
+ > `museav slideshow` 在 2.9~2.10 存在过,**3.0.0 已下线** —— 它和 reel-kit 重复了。
141
+ > 命令还在但只会打印迁移说明。详见 [CHANGELOG](./CHANGELOG.md)。
142
+
117
143
  ---
118
144
 
119
145
  ## 快速开始
@@ -443,27 +469,16 @@ museav remove-watermark photo.jpg --mask mask.png # 复杂画面手工掩
443
469
  - 依赖红线:本地**绝不自动拉起开源大模型**——这些工具是轻量 CNN/传统算法,用完即释放内存。reverse 的本地路已翻回显式 `--local`(需自备 Ollama),默认走中台 API。
444
470
  - 二进制与模型缓存:`~/.museav-bin/upscayl`(引擎)、`~/.museav-models/`(模型),Windows 对应 `%USERPROFILE%` 下同名目录;删除即彻底清理。
445
471
 
446
- ### 出成品视频请用 reel-kit(不在本 CLI)
472
+ ### 出成品视频:走 reel-kit
447
473
 
448
- 图集转竖版短视频、配音、烧字幕这些**成品合成**能力在
449
- [reel-kit](https://github.com/webkubor/reel-kit)(`reel` 命令),不在 museav:
474
+ 不在本 CLI,见上面 [🎬 要出成品视频](#-要出成品视频用-reel-kit)。常用三条:
450
475
 
451
476
  ```bash
452
- reel make --template sticker-promo \
453
- --assets ./图片目录 --caps 文案.txt \
454
- --title "标题" --bgm 儿童轻快 --out 成片.mp4
455
-
456
- reel templates # 看版式(模板是 templates/*.html,丢一个进去就是新版式)
457
- reel bgm # 看配乐库(带授权信息)
477
+ reel make --template sticker-promo --assets ./图 --caps 文案.txt --out 成片.mp4
478
+ reel templates # 看版式
479
+ reel bgm # 看配乐库(带授权)
458
480
  ```
459
481
 
460
- 2.9.0 曾在 museav 做过一个 `slideshow`,当天就发现 reel-kit 早已做了同一件事而且更好
461
- (HTML/CSS 排版能换行、支持配音且镜头时长由念白决定、本地 TTS 零成本),
462
- **3.0.0 已下线**,命令留了存根打印迁移说明。
463
-
464
- 职责边界:**museav 管素材**(`remove-bg` 抠图、`upscale` 超分、`gen` 出图出视频素材),
465
- **reel-kit 管成品合成**。同一件事没必要两套实现。
466
-
467
482
 
468
483
  ### 项目(工作区)与项目素材库 `projects`
469
484
 
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)
@@ -88,6 +212,12 @@ export async function gen(client, opts) {
88
212
  throw new Error(`视频生成失败: ${result.error || '未知原因'}`);
89
213
  }
90
214
  process.stderr.write(`✅ 视频完成\n`);
215
+ // 模型出的是**无声、无字幕的素材**,不是能直接发的成品。这一行是为了让人在拿到
216
+ // URL 的那一刻就知道下一步去哪 —— 而不是以为「出完了」,或者反过来以为
217
+ // museav 该管配音烧字幕(那是 reel-kit 的活,两边边界见 AGENTS.md)。
218
+ // 只在视频模式提示,且走 stderr 不污染 stdout。
219
+ process.stderr.write(` 这是无声无字幕的素材。要装配成能发的成品(配文案/配乐/配音):\n` +
220
+ ` reel make --assets <图目录> --caps 文案.txt → github.com/webkubor/reel-kit\n`);
91
221
  console.log(result.cdn_url);
92
222
  return;
93
223
  }
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.0",
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 仅图片出图支持')
@@ -109,6 +226,14 @@ export async function gen(client: StudioClient, opts: {
109
226
  throw new Error(`视频生成失败: ${result.error || '未知原因'}`)
110
227
  }
111
228
  process.stderr.write(`✅ 视频完成\n`)
229
+ // 模型出的是**无声、无字幕的素材**,不是能直接发的成品。这一行是为了让人在拿到
230
+ // URL 的那一刻就知道下一步去哪 —— 而不是以为「出完了」,或者反过来以为
231
+ // museav 该管配音烧字幕(那是 reel-kit 的活,两边边界见 AGENTS.md)。
232
+ // 只在视频模式提示,且走 stderr 不污染 stdout。
233
+ process.stderr.write(
234
+ ` 这是无声无字幕的素材。要装配成能发的成品(配文案/配乐/配音):\n` +
235
+ ` reel make --assets <图目录> --caps 文案.txt → github.com/webkubor/reel-kit\n`,
236
+ )
112
237
  console.log(result.cdn_url)
113
238
  return
114
239
  }
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